diff --git a/package-lock.json b/package-lock.json index 345ee2b1..23dadbfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { - "name": "@numeracode/whimsy-file-browser-workspace", + "name": "@numeracode/whimsy-file-browser", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@numeracode/whimsy-file-browser-workspace", + "name": "@numeracode/whimsy-file-browser", "workspaces": [ "packages/chonky", "packages/chonky-icon-fontawesome" ], + "dependencies": { + "@dnd-kit/core": "^6.3.1" + }, "devDependencies": { "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", @@ -269,6 +272,45 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -4167,6 +4209,7 @@ "version": "2.3.2", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@reduxjs/toolkit": "^1.5.0", "@types/fuzzy-search": "^2.1.0", "@types/memoizee": "^0.4.5", diff --git a/package.json b/package.json index 7f26d866..a9d971d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,26 @@ { - "name": "@numeracode/whimsy-file-browser-workspace", + "name": "@numeracode/whimsy-file-browser", "private": true, + "main": "packages/chonky/dist/index.js", + "module": "packages/chonky/dist/index.mjs", + "types": "packages/chonky/dist/index.d.ts", + "exports": { + ".": { + "types": "./packages/chonky/dist/index.d.ts", + "import": "./packages/chonky/dist/index.mjs", + "require": "./packages/chonky/dist/index.js" + }, + "./browser": { + "types": "./packages/chonky/dist/browser.d.ts", + "import": "./packages/chonky/dist/browser.mjs", + "require": "./packages/chonky/dist/browser.js" + }, + "./preview": { + "types": "./packages/chonky/dist/preview.d.ts", + "import": "./packages/chonky/dist/preview.mjs", + "require": "./packages/chonky/dist/preview.js" + } + }, "workspaces": [ "packages/chonky", "packages/chonky-icon-fontawesome" @@ -9,9 +29,13 @@ "build": "npm run build --workspace packages/chonky && npm run build --workspace packages/chonky-icon-fontawesome", "clean": "npm run clean --workspaces --if-present", "lint": "npm run typecheck", + "prepare": "npm run build --workspace packages/chonky", "test": "npm run build --workspace packages/chonky && npm run test --workspace packages/chonky && npm run test --workspace packages/chonky-icon-fontawesome", "typecheck": "npm run typecheck --workspace packages/chonky && npm run build --workspace packages/chonky && npm run typecheck --workspace packages/chonky-icon-fontawesome" }, + "dependencies": { + "@dnd-kit/core": "^6.3.1" + }, "devDependencies": { "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", diff --git a/packages/chonky/package.json b/packages/chonky/package.json index f4028e17..4c88d156 100644 --- a/packages/chonky/package.json +++ b/packages/chonky/package.json @@ -13,6 +13,11 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.mjs", + "require": "./dist/browser.js" + }, "./preview": { "types": "./dist/preview.d.ts", "import": "./dist/preview.mjs", @@ -30,9 +35,9 @@ "node": ">=20" }, "scripts": { - "build": "tsup src/index.ts src/preview.ts --format cjs,esm --dts --sourcemap --clean --external react --external react-dom", + "build": "tsup src/index.ts src/preview.ts src/browser.ts --format cjs,esm --dts --sourcemap --clean --external react --external react-dom", "clean": "rm -rf dist", - "dev": "tsup src/index.ts src/preview.ts --format cjs,esm --dts --sourcemap --watch --external react --external react-dom", + "dev": "tsup src/index.ts src/preview.ts src/browser.ts --format cjs,esm --dts --sourcemap --watch --external react --external react-dom", "lint": "npm run typecheck", "test": "vitest run --passWithNoTests --globals --environment jsdom", "typecheck": "tsc -p tsconfig.json --noEmit" @@ -57,6 +62,7 @@ "react-is": "^17.0.1" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@reduxjs/toolkit": "^1.5.0", "@types/fuzzy-search": "^2.1.0", "@types/memoizee": "^0.4.5", @@ -80,8 +86,8 @@ "react-window": "^1.8.6", "redux-watch": "^1.2.0", "shortid": "^2.2.16", - "tslib": "^2.8.1", - "tsdef": "^0.0.14" + "tsdef": "^0.0.14", + "tslib": "^2.8.1" }, "homepage": "https://github.com/Numeracode/whimsy-file-browser", "author": { diff --git a/packages/chonky/src/browser.ts b/packages/chonky/src/browser.ts new file mode 100644 index 00000000..7a639253 --- /dev/null +++ b/packages/chonky/src/browser.ts @@ -0,0 +1,83 @@ +export { BrowserShell } from './components/browser-shell/BrowserShell'; +export { BrowserDestinationPicker, BrowserFolderTree } from './components/tree/BrowserFolderTree'; +export { createBrowserOpaqueId } from './types/browser-item.types'; +export type { + BrowserAction, + BrowserActionPlacement, + BrowserActionSelectionScope, + BrowserActionTone, + BrowserFileOperationKind, + BrowserFolderChainItem, + BrowserItem, + BrowserItemCapabilities, + BrowserItemFlags, + BrowserItemKind, + BrowserItemRef, + BrowserMetadataValue, + BrowserOpaqueId, + BrowserPreviewAsset, + BrowserPreviewAssetKind, + BrowserPreviewRenderer, + BrowserPreviewTrack, + BrowserPreviewTrackKind, + BrowserPreviewUnavailableReason, + BrowserSelection, + BrowserSourceDescriptor, + BrowserSourceKind, + PreviewDescriptor, +} from './types/browser-item.types'; +export type { + BrowserShellActionEvent, + BrowserShellContextMenuRenderProps, + BrowserShellItemEvent, + BrowserShellNavigateEvent, + BrowserShellProps, + BrowserShellToolbarRenderProps, + BrowserSortDirection, + BrowserSortKey, + BrowserSortState, + BrowserViewMode, +} from './types/browser-shell.types'; +export type { + BrowserFileOperationEvent, +} from './types/browser-operation.types'; +export type { + BrowserDestinationPickerProps, + BrowserFolderChildrenLoader, + BrowserFolderExpansionEvent, + BrowserFolderSelectionEvent, + BrowserFolderTreeNode, + BrowserFolderTreeProps, +} from './types/browser-tree.types'; +export type { + BrowserDropTargetKind, + BrowserTransferDropSnapshot, + BrowserTransferIntent, + BrowserTransferModifierState, + BrowserTransferOperation, + BrowserTransferSourceSnapshot, + BrowserTransferTarget, +} from './types/browser-transfer.types'; +export { + BROWSER_FILE_OPERATION_ACTIONS, + createBrowserFileOperationEvent, + fileOperationForAction, + isBrowserActionEnabled, + isSelectionScopeSatisfied, + selectedItemsForOperation, +} from './util/browser-file-operations'; +export { + createBrowserTransferIntent, + resolveBrowserTransferOperation, +} from './util/browser-transfer'; +export { + browserActionFixtures, + browserFolderChainFixtures, + browserItemFixtures, + browserSelectionFixture, + browserSourceFixtures, +} from './fixtures/browser-items'; +export { + browserFolderTreeFixtures, + lazyBrowserFolderChildrenFixtures, +} from './fixtures/browser-tree'; diff --git a/packages/chonky/src/components/browser-shell/BrowserShell.tsx b/packages/chonky/src/components/browser-shell/BrowserShell.tsx index e166584c..0b4ef018 100644 --- a/packages/chonky/src/components/browser-shell/BrowserShell.tsx +++ b/packages/chonky/src/components/browser-shell/BrowserShell.tsx @@ -1,4 +1,16 @@ -import React, { CSSProperties, KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; +import React, { CSSProperties, KeyboardEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BrowserAction, @@ -16,6 +28,17 @@ import type { BrowserSortState, BrowserViewMode, } from '../../types/browser-shell.types'; +import type { + BrowserTransferDropSnapshot, + BrowserTransferModifierState, + BrowserTransferSourceSnapshot, +} from '../../types/browser-transfer.types'; +import { + BROWSER_FILE_OPERATION_ACTIONS, + createBrowserFileOperationEvent, + isBrowserActionEnabled, +} from '../../util/browser-file-operations'; +import { createBrowserTransferIntent } from '../../util/browser-transfer'; const DEFAULT_SORT: BrowserSortState = { key: 'name', direction: 'asc' }; let browserShellInstanceCounter = 0; @@ -27,10 +50,22 @@ interface ContextMenuState { y: number; } +interface BrowserDndSourceData { + type: 'browser-transfer-source'; + source: BrowserTransferSourceSnapshot; +} + +interface BrowserDndDropData { + type: 'browser-transfer-drop'; + drop: BrowserTransferDropSnapshot; +} + const isDisabled = (item: BrowserItem): boolean => item.flags?.disabled === true; const canOpen = (item: BrowserItem): boolean => !isDisabled(item) && item.capabilities?.open !== false; const canSelect = (item: BrowserItem): boolean => !isDisabled(item) && item.capabilities?.select !== false; const canPreview = (item: BrowserItem): boolean => !isDisabled(item) && item.kind === 'file' && item.capabilities?.preview === true; +const canDrag = (item: BrowserItem): boolean => !isDisabled(item) && item.capabilities?.drag !== false; +const canDropInto = (item: BrowserItem): boolean => !isDisabled(item) && item.kind === 'folder' && item.capabilities?.drop !== false; const browserItemToFolder = (item: BrowserItem): BrowserFolderChainItem => ({ id: item.id, @@ -106,14 +141,6 @@ const makeSelection = ( const isActionForPlacement = (action: BrowserAction, placement: 'toolbar' | 'context-menu'): boolean => !action.placement || action.placement.includes(placement); -const isActionEnabled = (action: BrowserAction, selectedCount: number): boolean => { - if (action.disabled) return false; - if (action.selectionScope === 'none') return selectedCount === 0; - if (action.selectionScope === 'single') return selectedCount === 1; - if (action.selectionScope === 'multiple') return selectedCount > 1; - return true; -}; - const isInteractiveKeyboardTarget = (target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) return false; return Boolean(target.closest('button,input,select,textarea,a,[contenteditable="true"],[role="textbox"],[role="combobox"]')); @@ -125,6 +152,25 @@ const createBrowserShellDomId = (): string => { return `browser-shell-${browserShellInstanceCounter}`; }; +const toDndId = (prefix: string, value: BrowserOpaqueId): string => `${prefix}:${value}`; + +const modifierStateFromKeyboardEvent = (event: globalThis.KeyboardEvent): BrowserTransferModifierState => ({ + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, +}); + +const isBrowserDndSourceData = (value: unknown): value is BrowserDndSourceData => + typeof value === 'object' && + value !== null && + (value as BrowserDndSourceData).type === 'browser-transfer-source'; + +const isBrowserDndDropData = (value: unknown): value is BrowserDndDropData => + typeof value === 'object' && + value !== null && + (value as BrowserDndDropData).type === 'browser-transfer-drop'; + const createActionEvent = ( action: BrowserAction, selection: BrowserSelection, @@ -148,7 +194,7 @@ const selectedItemsForSelection = (selection: BrowserSelection, items: readonly export const BrowserShell: React.FC = React.memo((props) => { const { - actions = [], + actions = BROWSER_FILE_OPERATION_ACTIONS, className, defaultSelection, defaultSort = DEFAULT_SORT, @@ -158,10 +204,12 @@ export const BrowserShell: React.FC = React.memo((props) => { items, onAction, onNavigateFolder, + onFileOperation, onOpen, onPreview, onSelectionChange, onSortChange, + onTransferIntent, onViewModeChange, renderContextMenu, renderThumbnail, @@ -177,14 +225,22 @@ export const BrowserShell: React.FC = React.memo((props) => { defaultSelection ?? makeSelection([]) ); const [contextMenu, setContextMenu] = useState(null); + const [activeTransferSource, setActiveTransferSource] = useState(null); + const modifierStateRef = useRef({}); const shellId = useMemo(createBrowserShellDomId, []); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor) + ); const currentViewMode = viewMode ?? internalViewMode; const currentSort = sort ?? internalSort; const currentSelection = selection ?? internalSelection; + const dndEnabled = Boolean(onTransferIntent); const visibleItems = useMemo(() => sortItems(items.filter((item) => !item.flags?.hidden), currentSort), [items, currentSort]); const selectedIdSet = useMemo(() => new Set(currentSelection.selectedIds), [currentSelection.selectedIds]); const selectedItems = useMemo(() => selectedItemsForSelection(currentSelection, visibleItems), [currentSelection, visibleItems]); + const currentFolder = folderChain[folderChain.length - 1]; const focusedIndex = useMemo( () => visibleItems.findIndex((item) => item.id === currentSelection.focusedId), [currentSelection.focusedId, visibleItems] @@ -291,11 +347,47 @@ export const BrowserShell: React.FC = React.memo((props) => { const triggerAction = useCallback( (action: BrowserAction, item?: BrowserItem, actionSelection = currentSelection) => { const event = createActionEvent(action, actionSelection, visibleItems, item); - if (!isActionEnabled(action, event.selectedItems.length)) return; + if (!isBrowserActionEnabled(action, event.selectedItems, item)) return; onAction?.(event); + const operationEvent = createBrowserFileOperationEvent({ + action, + item, + selectedItems: event.selectedItems, + selection: event.selection, + }); + if (operationEvent) onFileOperation?.(operationEvent); setContextMenu(null); }, - [currentSelection, onAction, visibleItems] + [currentSelection, onAction, onFileOperation, visibleItems] + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const data = event.active.data.current; + if (!isBrowserDndSourceData(data)) return; + modifierStateRef.current = {}; + setActiveTransferSource(data.source); + }, []); + + const clearActiveTransfer = useCallback(() => { + modifierStateRef.current = {}; + setActiveTransferSource(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const sourceData = event.active.data.current; + const dropData = event.over?.data.current; + const intent = isBrowserDndSourceData(sourceData) && isBrowserDndDropData(dropData) + ? createBrowserTransferIntent({ + source: sourceData.source, + drop: dropData.drop, + modifiers: modifierStateRef.current, + }) + : null; + clearActiveTransfer(); + if (intent) onTransferIntent?.(intent); + }, + [clearActiveTransfer, onTransferIntent] ); const handleItemClick = useCallback( @@ -374,6 +466,23 @@ export const BrowserShell: React.FC = React.memo((props) => { return () => document.removeEventListener('click', close); }, [contextMenu]); + useEffect(() => { + if (!activeTransferSource || typeof globalThis.window === 'undefined') return undefined; + const browserWindow = globalThis.window; + const updateModifierState = (event: globalThis.KeyboardEvent) => { + modifierStateRef.current = modifierStateFromKeyboardEvent(event); + }; + // Browsers do not expose already-held modifier keys globally; seed a + // neutral browser event before live key events take over. + modifierStateRef.current = modifierStateFromKeyboardEvent(new browserWindow.KeyboardEvent('keydown')); + browserWindow.addEventListener('keydown', updateModifierState); + browserWindow.addEventListener('keyup', updateModifierState); + return () => { + browserWindow.removeEventListener('keydown', updateModifierState); + browserWindow.removeEventListener('keyup', updateModifierState); + }; + }, [activeTransferSource]); + const toolbarProps: BrowserShellToolbarRenderProps = { actions, selectedItems, @@ -406,93 +515,120 @@ export const BrowserShell: React.FC = React.memo((props) => { style={styles.shell} tabIndex={0} > - - - {renderToolbar ? renderToolbar(toolbarProps) : ( - triggerAction(action)} - viewMode={currentViewMode} - onSortChange={applySort} - onViewModeChange={applyViewMode} - /> - )} - - {visibleItems.length === 0 ? ( -
{emptyState ?? 'No files'}
- ) : ( -
- {visibleItems.map((item) => ( - { - event.preventDefault(); - let nextSelection = currentSelection; - if (!selectedIdSet.has(item.id)) { - nextSelection = canSelect(item) - ? makeSelection([item.id], item.id, item.id) - : makeSelection(currentSelection.selectedIds, item.id, currentSelection.anchorId); - applySelection(nextSelection); - } - setContextMenu({ item, selection: nextSelection, x: event.clientX, y: event.clientY }); - }} - onClick={(event) => handleItemClick(event, item)} - onDoubleClick={() => openItem(item)} - onOpen={() => openItem(item)} - onPreview={() => previewItem(item)} - onToggleSelection={() => toggleSelection(item)} - /> + +
- )} - - {contextMenu && contextMenuProps ? ( -
+ + {renderToolbar ? renderToolbar(toolbarProps) : ( + triggerAction(action)} + viewMode={currentViewMode} + onSortChange={applySort} + onViewModeChange={applyViewMode} + /> + )} + + - {renderContextMenu ? renderContextMenu(contextMenuProps) : ( - triggerAction(action, contextMenu.item, contextMenu.selection)} - /> - )} -
- ) : null} + {visibleItems.length === 0 ? ( +
{emptyState ?? 'No files'}
+ ) : visibleItems.map((item) => { + const selected = selectedIdSet.has(item.id); + const transferSelection = selected + ? currentSelection + : makeSelection([item.id], item.id, item.id); + const transferItems = selected && selectedItems.length > 0 ? selectedItems : [item]; + + return ( + { + event.preventDefault(); + let nextSelection = currentSelection; + if (!selectedIdSet.has(item.id)) { + nextSelection = canSelect(item) + ? makeSelection([item.id], item.id, item.id) + : makeSelection(currentSelection.selectedIds, item.id, currentSelection.anchorId); + applySelection(nextSelection); + } + setContextMenu({ item, selection: nextSelection, x: event.clientX, y: event.clientY }); + }} + onClick={(event) => handleItemClick(event, item)} + onDoubleClick={() => openItem(item)} + onOpen={() => openItem(item)} + onPreview={() => previewItem(item)} + onToggleSelection={() => toggleSelection(item)} + /> + ); + })} + + + {contextMenu && contextMenuProps ? ( +
+ {renderContextMenu ? renderContextMenu(contextMenuProps) : ( + triggerAction(action, contextMenu.item, contextMenu.selection)} + /> + )} +
+ ) : null} + + + {activeTransferSource ? ( +
+ {activeTransferSource.selectedItems.length > 1 + ? `${activeTransferSource.selectedItems.length} items` + : activeTransferSource.item.name} +
+ ) : null} +
+ ); }); @@ -500,7 +636,7 @@ BrowserShell.displayName = 'BrowserShell'; interface DefaultToolbarProps { actions: readonly BrowserAction[]; - selectedCount: number; + selectedItems: readonly BrowserItem[]; sort: BrowserSortState; triggerAction: (action: BrowserAction) => void; viewMode: BrowserViewMode; @@ -509,7 +645,7 @@ interface DefaultToolbarProps { } const DefaultToolbar: React.FC = (props) => { - const { actions, onSortChange, onViewModeChange, selectedCount, sort, triggerAction, viewMode } = props; + const { actions, onSortChange, onViewModeChange, selectedItems, sort, triggerAction, viewMode } = props; const toolbarActions = actions.filter((action) => isActionForPlacement(action, 'toolbar')); return ( @@ -557,7 +693,7 @@ const DefaultToolbar: React.FC = (props) => { {toolbarActions.map((action) => ( + + + {loaded?.status === 'error' ?
{loaded.error}
: null} + {loaded?.status === 'loading' ?
Loading folders...
: null} + {expanded && children.length > 0 ? ( +
    + {children.map((child) => renderNode(child, depth + 1))} +
+ ) : null} + + ); + }; + + return ( +
    + {roots.map((node) => renderNode(node, 0))} +
+ ); +}); +BrowserFolderTree.displayName = 'BrowserFolderTree'; + +export const BrowserDestinationPicker: React.FC = (props) => { + const { + confirmLabel = 'Choose folder', + onConfirmDestination, + title = 'Choose destination', + ...treeProps + } = props; + const [lastSelection, setLastSelection] = useState(null); + + return ( +
+
{title}
+ { + setLastSelection(event); + treeProps.onSelectedFolderChange?.(event); + }} + /> + +
+ ); +}; + +const styles: Record = { + confirmButton: { + alignSelf: 'flex-end', + background: '#111827', + border: 0, + borderRadius: 8, + color: '#fff', + cursor: 'pointer', + padding: '7px 12px', + }, + disabledRow: { + cursor: 'not-allowed', + opacity: 0.48, + }, + expander: { + background: 'transparent', + border: 0, + color: '#475569', + cursor: 'pointer', + height: 28, + width: 28, + }, + group: { + listStyle: 'none', + margin: 0, + padding: 0, + }, + message: { + color: '#64748b', + fontSize: 12, + paddingBottom: 4, + paddingTop: 2, + }, + node: { + listStyle: 'none', + margin: 0, + padding: 0, + }, + nodeButton: { + alignItems: 'baseline', + background: 'transparent', + border: 0, + color: '#0f172a', + cursor: 'pointer', + display: 'flex', + flex: 1, + gap: 8, + minWidth: 0, + padding: '6px 8px', + textAlign: 'left', + }, + nodeName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + picker: { + border: '1px solid #e2e8f0', + borderRadius: 12, + display: 'flex', + flexDirection: 'column', + gap: 10, + minWidth: 280, + padding: 12, + }, + pickerHeader: { + color: '#0f172a', + fontSize: 14, + fontWeight: 700, + }, + row: { + alignItems: 'center', + borderRadius: 8, + display: 'flex', + minHeight: 34, + }, + selectedRow: { + background: '#e0f2fe', + }, + sourceLabel: { + color: '#64748b', + fontSize: 11, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + tree: { + listStyle: 'none', + margin: 0, + maxHeight: 360, + overflow: 'auto', + padding: 0, + }, +}; diff --git a/packages/chonky/src/fixtures/browser-items.ts b/packages/chonky/src/fixtures/browser-items.ts index 954dc23d..35aa1c66 100644 --- a/packages/chonky/src/fixtures/browser-items.ts +++ b/packages/chonky/src/fixtures/browser-items.ts @@ -14,6 +14,7 @@ import type { BrowserSourceDescriptor, PreviewDescriptor, } from '../types/browser-item.types'; +import { BROWSER_FILE_OPERATION_ACTIONS } from '../util/browser-file-operations'; const PREVIEW_ORIGIN = 'https://preview.example.test'; @@ -64,6 +65,7 @@ const folderCapabilities: BrowserItemCapabilities = { drag: true, drop: true, rename: true, + delete: true, }; const previewableFileCapabilities: BrowserItemCapabilities = { @@ -72,6 +74,9 @@ const previewableFileCapabilities: BrowserItemCapabilities = { select: true, drag: true, download: true, + rename: true, + delete: true, + favorite: true, }; const unavailablePreviewFileCapabilities: BrowserItemCapabilities = { @@ -152,37 +157,7 @@ const remoteFile = ( id: opaqueId(item.id), }); -export const browserActionFixtures: readonly BrowserAction[] = [ - { - id: 'open', - label: 'Open', - icon: 'open', - placement: ['context-menu', 'row'], - selectionScope: 'single', - }, - { - id: 'download', - label: 'Download', - icon: 'download', - placement: ['toolbar', 'context-menu'], - selectionScope: 'any', - }, - { - id: 'delete', - label: 'Delete', - icon: 'trash', - placement: ['context-menu'], - selectionScope: 'any', - tone: 'destructive', - }, - { - id: 'restore-preview', - label: 'Refresh preview', - icon: 'refresh', - placement: ['row', 'context-menu'], - selectionScope: 'single', - }, -]; +export const browserActionFixtures: readonly BrowserAction[] = BROWSER_FILE_OPERATION_ACTIONS; export const browserFolderChainFixtures: readonly BrowserFolderChainItem[] = [ { @@ -364,7 +339,13 @@ export const browserItemFixtures: readonly BrowserItem[] = [ thumbnail: unavailableAsset('thumbnail', 'expired', 'Thumbnail URL expired', true), preview: unavailableAsset('preview', 'expired', 'Preview URL expired', true), }), - actions: [browserActionFixtures[3]], + actions: [{ + id: 'restore-preview', + label: 'Refresh preview', + icon: 'refresh', + placement: ['row', 'context-menu'], + selectionScope: 'single', + }], capabilities: previewableFileCapabilities, }), catalogFile({ diff --git a/packages/chonky/src/fixtures/browser-tree.ts b/packages/chonky/src/fixtures/browser-tree.ts new file mode 100644 index 00000000..353c2748 --- /dev/null +++ b/packages/chonky/src/fixtures/browser-tree.ts @@ -0,0 +1,68 @@ +import { createBrowserOpaqueId } from '../types/browser-item.types'; +import type { BrowserFolderTreeNode } from '../types/browser-tree.types'; +import { browserSourceFixtures } from './browser-items'; + +const id = createBrowserOpaqueId; + +export const browserFolderTreeFixtures: readonly BrowserFolderTreeNode[] = [ + { + id: id('tree:private:root'), + name: 'Private storage', + source: browserSourceFixtures.privateStorage, + hasChildren: true, + children: [ + { + id: id('tree:private:projects'), + name: 'Projects', + source: browserSourceFixtures.privateStorage, + hasChildren: true, + }, + { + id: id('tree:private:archive'), + name: 'Archive', + source: browserSourceFixtures.privateStorage, + }, + ], + }, + { + id: id('tree:remote:gdrive:root'), + name: 'Design Google Drive', + source: browserSourceFixtures.googleDrive, + hasChildren: true, + }, + { + id: id('tree:remote:dropbox:root'), + name: 'Marketing Dropbox', + source: browserSourceFixtures.dropbox, + hasChildren: true, + }, +]; + +export const lazyBrowserFolderChildrenFixtures: Readonly> = { + 'tree:private:projects': [ + { + id: id('tree:private:projects:brand'), + name: 'Brand', + source: browserSourceFixtures.privateStorage, + }, + { + id: id('tree:private:projects:launch'), + name: 'Launch', + source: browserSourceFixtures.privateStorage, + }, + ], + 'tree:remote:gdrive:root': [ + { + id: id('tree:remote:gdrive:campaign'), + name: 'Campaign', + source: browserSourceFixtures.googleDrive, + }, + ], + 'tree:remote:dropbox:root': [ + { + id: id('tree:remote:dropbox:deliveries'), + name: 'Deliveries', + source: browserSourceFixtures.dropbox, + }, + ], +}; diff --git a/packages/chonky/src/index.ts b/packages/chonky/src/index.ts index 44bcedd4..5ca61ffe 100644 --- a/packages/chonky/src/index.ts +++ b/packages/chonky/src/index.ts @@ -7,7 +7,7 @@ export { FileToolbar } from './components/external/FileToolbar'; export { FileList } from './components/file-list/FileList'; export { FileContextMenu } from './components/external/FileContextMenu'; export { FullFileBrowser } from './components/external/FullFileBrowser'; -export { BrowserShell } from './components/browser-shell/BrowserShell'; +export * from './browser'; export { FilePreviewer, defaultPreviewRenderers } from './components/preview/FilePreviewer'; export { MediaLightbox } from './components/preview/MediaLightbox'; export { PreviewFallback } from './components/preview/PreviewFallback'; @@ -35,43 +35,6 @@ export type FileViewConfig = import('./types/file-view.types').FileViewConfig; export type FileViewConfigGrid = import('./types/file-view.types').FileViewConfigGrid; export type FileViewConfigList = import('./types/file-view.types').FileViewConfigList; export { ThumbnailGenerator } from './types/thumbnails.types'; -export { createBrowserOpaqueId } from './types/browser-item.types'; -export type { - BrowserAction, - BrowserActionPlacement, - BrowserActionSelectionScope, - BrowserActionTone, - BrowserFolderChainItem, - BrowserItem, - BrowserItemCapabilities, - BrowserItemFlags, - BrowserItemKind, - BrowserItemRef, - BrowserMetadataValue, - BrowserOpaqueId, - BrowserPreviewAsset, - BrowserPreviewAssetKind, - BrowserPreviewRenderer, - BrowserPreviewTrack, - BrowserPreviewTrackKind, - BrowserPreviewUnavailableReason, - BrowserSelection, - BrowserSourceDescriptor, - BrowserSourceKind, - PreviewDescriptor, -} from './types/browser-item.types'; -export type { - BrowserShellActionEvent, - BrowserShellContextMenuRenderProps, - BrowserShellItemEvent, - BrowserShellNavigateEvent, - BrowserShellProps, - BrowserShellToolbarRenderProps, - BrowserSortDirection, - BrowserSortKey, - BrowserSortState, - BrowserViewMode, -} from './types/browser-shell.types'; export type { AvailableBrowserPreviewAsset, FilePreviewerProps, @@ -88,13 +51,6 @@ export type { PreviewShellProps, PreviewTileProps, } from './types/preview-shell.types'; -export { - browserActionFixtures, - browserFolderChainFixtures, - browserItemFixtures, - browserSelectionFixture, - browserSourceFixtures, -} from './fixtures/browser-items'; export { adaptBrowserFolderChainToFileArray, adaptBrowserItemToFileData, diff --git a/packages/chonky/src/types/browser-item.types.ts b/packages/chonky/src/types/browser-item.types.ts index 354147de..91e88c84 100644 --- a/packages/chonky/src/types/browser-item.types.ts +++ b/packages/chonky/src/types/browser-item.types.ts @@ -161,10 +161,23 @@ export interface BrowserFolderChainItem { export type BrowserActionPlacement = 'toolbar' | 'context-menu' | 'row' | 'selection-bar'; export type BrowserActionTone = 'default' | 'primary' | 'destructive'; export type BrowserActionSelectionScope = 'none' | 'single' | 'multiple' | 'any'; +export type BrowserFileOperationKind = + | 'open' + | 'preview' + | 'download' + | 'rename' + | 'delete' + | 'favorite' + | 'copy' + | 'move' + | 'paste' + | 'new-folder' + | 'upload'; export interface BrowserAction { id: string; label: string; + operation?: BrowserFileOperationKind; icon?: string; placement?: readonly BrowserActionPlacement[]; tone?: BrowserActionTone; diff --git a/packages/chonky/src/types/browser-operation.types.ts b/packages/chonky/src/types/browser-operation.types.ts new file mode 100644 index 00000000..33a61e71 --- /dev/null +++ b/packages/chonky/src/types/browser-operation.types.ts @@ -0,0 +1,21 @@ +import type { + BrowserAction, + BrowserFileOperationKind, + BrowserFolderChainItem, + BrowserItem, + BrowserOpaqueId, + BrowserSelection, +} from './browser-item.types'; + +export interface BrowserFileOperationEvent { + operation: BrowserFileOperationKind; + action: BrowserAction; + actionId: string; + item?: BrowserItem; + itemId?: BrowserOpaqueId; + selectedItems: readonly BrowserItem[]; + selectedIds: readonly BrowserOpaqueId[]; + selection: BrowserSelection; + targetFolder?: BrowserFolderChainItem; + payload?: Readonly>; +} diff --git a/packages/chonky/src/types/browser-shell.types.ts b/packages/chonky/src/types/browser-shell.types.ts index 96a797b7..dec601a7 100644 --- a/packages/chonky/src/types/browser-shell.types.ts +++ b/packages/chonky/src/types/browser-shell.types.ts @@ -7,6 +7,8 @@ import type { BrowserOpaqueId, BrowserSelection, } from './browser-item.types'; +import type { BrowserFileOperationEvent } from './browser-operation.types'; +import type { BrowserTransferIntent } from './browser-transfer.types'; export type BrowserViewMode = 'list' | 'grid'; export type BrowserSortKey = 'name' | 'kind' | 'sizeBytes' | 'modifiedAt'; @@ -73,6 +75,8 @@ export interface BrowserShellProps { onPreview?: (event: BrowserShellItemEvent) => void; onNavigateFolder?: (event: BrowserShellNavigateEvent) => void; onAction?: (event: BrowserShellActionEvent) => void; + onFileOperation?: (event: BrowserFileOperationEvent) => void; + onTransferIntent?: (event: BrowserTransferIntent) => void; renderToolbar?: (props: BrowserShellToolbarRenderProps) => ReactNode; renderContextMenu?: (props: BrowserShellContextMenuRenderProps) => ReactNode; renderThumbnail?: (item: BrowserItem) => ReactNode; diff --git a/packages/chonky/src/types/browser-transfer.types.ts b/packages/chonky/src/types/browser-transfer.types.ts new file mode 100644 index 00000000..f161cde4 --- /dev/null +++ b/packages/chonky/src/types/browser-transfer.types.ts @@ -0,0 +1,45 @@ +import type { + BrowserFolderChainItem, + BrowserItem, + BrowserOpaqueId, + BrowserSelection, +} from './browser-item.types'; + +export type BrowserTransferOperation = 'copy' | 'move'; +export type BrowserDropTargetKind = 'folder' | 'listing'; + +export interface BrowserTransferModifierState { + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; +} + +export interface BrowserTransferTarget { + kind: BrowserDropTargetKind; + folder?: BrowserFolderChainItem; + folderId?: BrowserOpaqueId; + item?: BrowserItem; +} + +export interface BrowserTransferIntent { + operation: BrowserTransferOperation; + sourceItems: readonly BrowserItem[]; + sourceIds: readonly BrowserOpaqueId[]; + selection: BrowserSelection; + activeItem: BrowserItem; + activeItemId: BrowserOpaqueId; + destination: BrowserTransferTarget; + destinationFolderId?: BrowserOpaqueId; + modifiers: BrowserTransferModifierState; +} + +export interface BrowserTransferSourceSnapshot { + item: BrowserItem; + selectedItems: readonly BrowserItem[]; + selection: BrowserSelection; +} + +export interface BrowserTransferDropSnapshot { + target: BrowserTransferTarget; +} diff --git a/packages/chonky/src/types/browser-tree.types.ts b/packages/chonky/src/types/browser-tree.types.ts new file mode 100644 index 00000000..be136ef2 --- /dev/null +++ b/packages/chonky/src/types/browser-tree.types.ts @@ -0,0 +1,52 @@ +import type { + BrowserFolderChainItem, + BrowserItemRef, + BrowserOpaqueId, + BrowserSourceDescriptor, +} from './browser-item.types'; + +export interface BrowserFolderTreeNode { + id: BrowserOpaqueId; + name: string; + ref?: BrowserItemRef; + source?: BrowserSourceDescriptor; + children?: readonly BrowserFolderTreeNode[]; + hasChildren?: boolean; + disabled?: boolean; + disabledReason?: string; + metadata?: Readonly>; +} + +export interface BrowserFolderSelectionEvent { + folder: BrowserFolderTreeNode; + folderId: BrowserOpaqueId; + chain: readonly BrowserFolderChainItem[]; +} + +export interface BrowserFolderExpansionEvent { + folder: BrowserFolderTreeNode; + folderId: BrowserOpaqueId; + expanded: boolean; +} + +export type BrowserFolderChildrenLoader = ( + folder: BrowserFolderTreeNode +) => readonly BrowserFolderTreeNode[] | Promise; + +export interface BrowserFolderTreeProps { + roots: readonly BrowserFolderTreeNode[]; + loadChildren?: BrowserFolderChildrenLoader; + selectedFolderId?: BrowserOpaqueId; + defaultSelectedFolderId?: BrowserOpaqueId; + expandedFolderIds?: readonly BrowserOpaqueId[]; + defaultExpandedFolderIds?: readonly BrowserOpaqueId[]; + onSelectedFolderChange?: (event: BrowserFolderSelectionEvent) => void; + onFolderExpansionChange?: (event: BrowserFolderExpansionEvent) => void; + className?: string; +} + +export interface BrowserDestinationPickerProps extends BrowserFolderTreeProps { + title?: string; + confirmLabel?: string; + onConfirmDestination?: (event: BrowserFolderSelectionEvent) => void; +} diff --git a/packages/chonky/src/util/browser-file-operations.ts b/packages/chonky/src/util/browser-file-operations.ts new file mode 100644 index 00000000..7752c5ae --- /dev/null +++ b/packages/chonky/src/util/browser-file-operations.ts @@ -0,0 +1,152 @@ +import type { + BrowserAction, + BrowserActionPlacement, + BrowserActionSelectionScope, + BrowserActionTone, + BrowserFileOperationKind, + BrowserItem, + BrowserSelection, +} from '../types/browser-item.types'; +import type { BrowserFileOperationEvent } from '../types/browser-operation.types'; + +type BrowserActionPreset = readonly [ + BrowserFileOperationKind, + string, + string, + readonly BrowserActionPlacement[], + BrowserActionSelectionScope, + BrowserActionTone?, +]; + +const ROW_ACTION = ['context-menu', 'row'] as const; +const CONTEXT_ACTION = ['context-menu'] as const; +const TOOLBAR_ACTION = ['toolbar'] as const; +const TOOLBAR_CONTEXT_ACTION = ['toolbar', 'context-menu'] as const; + +const DEFAULT_ACTION_PRESETS = [ + ['open', 'Open', 'open', ROW_ACTION, 'single'], + ['preview', 'Preview', 'preview', ROW_ACTION, 'single'], + ['download', 'Download', 'download', TOOLBAR_CONTEXT_ACTION, 'any'], + ['rename', 'Rename', 'rename', CONTEXT_ACTION, 'single'], + ['delete', 'Delete', 'trash', TOOLBAR_CONTEXT_ACTION, 'any', 'destructive'], + ['favorite', 'Favorite', 'star', CONTEXT_ACTION, 'single'], + ['copy', 'Copy', 'copy', TOOLBAR_CONTEXT_ACTION, 'any'], + ['move', 'Move', 'move', TOOLBAR_CONTEXT_ACTION, 'any'], + ['paste', 'Paste', 'paste', TOOLBAR_CONTEXT_ACTION, 'none'], + ['new-folder', 'New folder', 'folder-plus', TOOLBAR_CONTEXT_ACTION, 'none'], + ['upload', 'Upload', 'upload', TOOLBAR_ACTION, 'none'], +] as const satisfies readonly BrowserActionPreset[]; + +function createActionFromPreset([ + operation, + label, + icon, + placement, + selectionScope, + tone, +]: BrowserActionPreset): BrowserAction { + return { + id: operation, + label, + operation, + icon, + placement, + selectionScope, + ...(tone ? { tone } : {}), + }; +} + +export const BROWSER_FILE_OPERATION_ACTIONS: readonly BrowserAction[] = DEFAULT_ACTION_PRESETS.map(createActionFromPreset); + +const OPERATIONS = new Set( + BROWSER_FILE_OPERATION_ACTIONS.map((action) => action.operation!) +); + +export function fileOperationForAction(action: BrowserAction): BrowserFileOperationKind | null { + if (action.operation) return action.operation; + return OPERATIONS.has(action.id as BrowserFileOperationKind) + ? action.id as BrowserFileOperationKind + : null; +} + +export function selectedItemsForOperation( + action: BrowserAction, + selectedItems: readonly BrowserItem[], + item?: BrowserItem, +): readonly BrowserItem[] { + const operation = fileOperationForAction(action); + if (action.selectionScope === 'none' || operation === 'paste' || operation === 'new-folder' || operation === 'upload') { + return []; + } + if (selectedItems.length > 0) return selectedItems; + return item ? [item] : []; +} + +function hasCapability(item: BrowserItem, operation: BrowserFileOperationKind): boolean { + if (item.flags?.disabled) return false; + switch (operation) { + case 'open': return item.capabilities?.open !== false; + case 'preview': return item.kind === 'file' && item.capabilities?.preview === true; + case 'download': return item.kind === 'file' && item.capabilities?.download !== false; + case 'rename': return item.capabilities?.rename === true; + case 'delete': return item.capabilities?.delete === true; + case 'favorite': return item.kind === 'file' && item.capabilities?.favorite === true; + case 'copy': + case 'move': + return item.capabilities?.drag !== false; + case 'paste': + case 'new-folder': + case 'upload': + return true; + default: + return true; + } +} + +export function isSelectionScopeSatisfied(action: BrowserAction, selectedCount: number): boolean { + if (action.disabled) return false; + if (action.selectionScope === 'none') return selectedCount === 0; + if (action.selectionScope === 'single') return selectedCount === 1; + if (action.selectionScope === 'multiple') return selectedCount > 1; + if (action.selectionScope === 'any') return selectedCount > 0; + return true; +} + +export function isBrowserActionEnabled( + action: BrowserAction, + selectedItems: readonly BrowserItem[], + item?: BrowserItem, +): boolean { + const operationTargets = selectedItemsForOperation(action, selectedItems, item); + const selectedCount = operationTargets.length; + if (!isSelectionScopeSatisfied(action, selectedCount)) return false; + + const operation = fileOperationForAction(action); + if (!operation) return true; + return operationTargets.every((target) => hasCapability(target, operation)); +} + +export function createBrowserFileOperationEvent(input: { + action: BrowserAction; + selectedItems: readonly BrowserItem[]; + selection: BrowserSelection; + item?: BrowserItem; + payload?: Readonly>; +}): BrowserFileOperationEvent | null { + const operation = fileOperationForAction(input.action); + if (!operation) return null; + const selectedItems = selectedItemsForOperation(input.action, input.selectedItems, input.item); + if (!isBrowserActionEnabled(input.action, selectedItems, input.item)) return null; + + return { + operation, + action: input.action, + actionId: input.action.id, + item: input.item, + itemId: input.item?.id, + selectedItems, + selectedIds: selectedItems.map((item) => item.id), + selection: input.selection, + payload: input.payload, + }; +} diff --git a/packages/chonky/src/util/browser-transfer.ts b/packages/chonky/src/util/browser-transfer.ts new file mode 100644 index 00000000..989de0ff --- /dev/null +++ b/packages/chonky/src/util/browser-transfer.ts @@ -0,0 +1,45 @@ +import type { + BrowserTransferDropSnapshot, + BrowserTransferIntent, + BrowserTransferModifierState, + BrowserTransferOperation, + BrowserTransferSourceSnapshot, +} from '../types/browser-transfer.types'; + +export const resolveBrowserTransferOperation = ( + modifiers: BrowserTransferModifierState, + defaultOperation: BrowserTransferOperation = 'move' +): BrowserTransferOperation => { + // Copy modifiers take precedence when combined with Shift; otherwise Shift forces move and unset modifiers use the host default. + if (modifiers.ctrlKey || modifiers.metaKey) return 'copy'; + if (modifiers.shiftKey) return 'move'; + return defaultOperation; +}; + +export const createBrowserTransferIntent = (options: { + source: BrowserTransferSourceSnapshot; + drop: BrowserTransferDropSnapshot; + modifiers?: BrowserTransferModifierState; + defaultOperation?: BrowserTransferOperation; +}): BrowserTransferIntent | null => { + const modifiers = options.modifiers ?? {}; + const sourceItems = options.source.selectedItems.length > 0 + ? options.source.selectedItems + : [options.source.item]; + const sourceIds = sourceItems.map((item) => item.id); + const destinationFolderId = options.drop.target.folderId; + + if (destinationFolderId && sourceIds.includes(destinationFolderId)) return null; + + return { + operation: resolveBrowserTransferOperation(modifiers, options.defaultOperation), + sourceItems, + sourceIds, + selection: options.source.selection, + activeItem: options.source.item, + activeItemId: options.source.item.id, + destination: options.drop.target, + destinationFolderId, + modifiers, + }; +}; diff --git a/packages/chonky/test/browser-folder-tree.test.tsx b/packages/chonky/test/browser-folder-tree.test.tsx new file mode 100644 index 00000000..da9efbbc --- /dev/null +++ b/packages/chonky/test/browser-folder-tree.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import React from 'react'; + +import { + BrowserDestinationPicker, + BrowserFolderTree, + browserFolderTreeFixtures, + createBrowserOpaqueId, + lazyBrowserFolderChildrenFixtures, +} from '../src'; + +describe('BrowserFolderTree', () => { + it('renders fixture roots and emits opaque selected folder chains', () => { + const onSelectedFolderChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Projects')); + + expect(onSelectedFolderChange).toHaveBeenCalledWith( + expect.objectContaining({ + folderId: 'tree:private:projects', + folder: expect.objectContaining({ id: 'tree:private:projects' }), + chain: [ + expect.objectContaining({ id: 'tree:private:root' }), + expect.objectContaining({ id: 'tree:private:projects' }), + ], + }) + ); + }); + + it('lazy-loads children once for an expanded folder and renders the result', async () => { + const loadChildren = vi.fn(async (folder) => lazyBrowserFolderChildrenFixtures[folder.id] ?? []); + + render( + + ); + + fireEvent.click(screen.getByLabelText('Expand Design Google Drive')); + + await waitFor(() => expect(loadChildren).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Campaign')).toBeTruthy(); + }); + + it('does not select disabled folders', () => { + const onSelectedFolderChange = vi.fn(); + const roots = [{ + ...browserFolderTreeFixtures[0], + disabled: true, + disabledReason: 'Read only', + }]; + + render(); + + const treeNode = screen.getByTestId('browser-folder-tree-node'); + const disabledFolderButton = within(treeNode) + .getAllByRole('button') + .find((button) => button.hasAttribute('disabled')); + + expect(disabledFolderButton).toBeTruthy(); + if (!disabledFolderButton) throw new Error('Expected disabled folder button'); + fireEvent.click(disabledFolderButton); + + expect(onSelectedFolderChange).not.toHaveBeenCalled(); + }); +}); + +describe('BrowserDestinationPicker', () => { + it('confirms the selected destination folder without host API imports', () => { + const onConfirmDestination = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Archive')); + fireEvent.click(screen.getByText('Choose folder')); + + expect(onConfirmDestination).toHaveBeenCalledWith( + expect.objectContaining({ + folderId: 'tree:private:archive', + chain: [ + expect.objectContaining({ id: 'tree:private:root' }), + expect.objectContaining({ id: 'tree:private:archive' }), + ], + }) + ); + }); +}); diff --git a/packages/chonky/test/browser-shell.test.tsx b/packages/chonky/test/browser-shell.test.tsx index 1ad9ceda..1117952a 100644 --- a/packages/chonky/test/browser-shell.test.tsx +++ b/packages/chonky/test/browser-shell.test.tsx @@ -276,6 +276,7 @@ describe('BrowserShell', () => { it('preserves multi-selection when opening a context menu on an already selected item', () => { const onAction = vi.fn(); + const onFileOperation = vi.fn(); const selection: BrowserSelection = { selectedIds: ['image:hero', 'pdf:contract'].map(createBrowserOpaqueId), focusedId: createBrowserOpaqueId('pdf:contract'), @@ -287,6 +288,7 @@ describe('BrowserShell', () => { actions={browserActionFixtures} items={browserItemFixtures} onAction={onAction} + onFileOperation={onFileOperation} selection={selection} /> ); @@ -304,10 +306,22 @@ describe('BrowserShell', () => { selection: expect.objectContaining({ selectedIds: ['image:hero', 'pdf:contract'] }), }) ); + expect(onFileOperation).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'download', + actionId: 'download', + selectedIds: expect.arrayContaining(['image:hero', 'pdf:contract']), + selectedItems: expect.arrayContaining([ + expect.objectContaining({ id: 'image:hero' }), + expect.objectContaining({ id: 'pdf:contract' }), + ]), + }) + ); }); it('uses the pending context-menu selection before controlled selection state updates', () => { const onAction = vi.fn(); + const onFileOperation = vi.fn(); const onSelectionChange = vi.fn(); const selection: BrowserSelection = { selectedIds: [], @@ -318,6 +332,7 @@ describe('BrowserShell', () => { actions={browserActionFixtures} items={browserItemFixtures} onAction={onAction} + onFileOperation={onFileOperation} onSelectionChange={onSelectionChange} selection={selection} /> @@ -336,5 +351,68 @@ describe('BrowserShell', () => { selection: expect.objectContaining({ selectedIds: ['image:hero'] }), }) ); + expect(onFileOperation).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'open', + itemId: 'image:hero', + selectedIds: ['image:hero'], + }) + ); + }); + + it('does not emit typed operations when item capabilities disallow the operation', () => { + const onAction = vi.fn(); + const onFileOperation = vi.fn(); + const readOnlyItem: BrowserItem = { + ...browserItemFixtures.find((item) => item.id === 'image:hero')!, + id: createBrowserOpaqueId('readonly:image'), + name: 'readonly.jpg', + capabilities: { + open: true, + preview: true, + select: true, + download: true, + delete: false, + }, + }; + + render( + + ); + + fireEvent.click(screen.getByText('Delete')); + + expect(onAction).not.toHaveBeenCalled(); + expect(onFileOperation).not.toHaveBeenCalled(); + }); + + it('exposes DnD transfer affordances only when the host handles transfer intents', () => { + const { rerender } = render( + + ); + + expect((screen.getByText('hero-photo.jpg').closest('[data-testid="browser-item"]') as HTMLElement | null)?.dataset.dndDraggable).toBe('false'); + expect(screen.getByLabelText('Files').dataset.dndDroppable).toBe('false'); + + rerender( + + ); + + expect((screen.getByText('hero-photo.jpg').closest('[data-testid="browser-item"]') as HTMLElement | null)?.dataset.dndDraggable).toBe('true'); + expect((screen.getByText('Brand assets').closest('[data-testid="browser-item"]') as HTMLElement | null)?.dataset.dndDroppable).toBe('true'); + expect(screen.getByLabelText('Files').dataset.dndDroppable).toBe('true'); }); }); diff --git a/packages/chonky/test/browser-transfer.test.ts b/packages/chonky/test/browser-transfer.test.ts new file mode 100644 index 00000000..9200a7e8 --- /dev/null +++ b/packages/chonky/test/browser-transfer.test.ts @@ -0,0 +1,74 @@ +import { + browserFolderChainFixtures, + browserItemFixtures, + createBrowserOpaqueId, + createBrowserTransferIntent, + resolveBrowserTransferOperation, +} from '../src'; + +describe('browser transfer intents', () => { + it('resolves copy and move hints from modifier state', () => { + expect(resolveBrowserTransferOperation({})).toBe('move'); + expect(resolveBrowserTransferOperation({ ctrlKey: true })).toBe('copy'); + expect(resolveBrowserTransferOperation({ metaKey: true })).toBe('copy'); + expect(resolveBrowserTransferOperation({ shiftKey: true }, 'copy')).toBe('move'); + }); + + it('creates an opaque host transfer intent from source and destination snapshots', () => { + const hero = browserItemFixtures.find((item) => item.id === 'image:hero')!; + const contract = browserItemFixtures.find((item) => item.id === 'pdf:contract')!; + const destination = browserFolderChainFixtures.at(-1)!; + + const intent = createBrowserTransferIntent({ + source: { + item: hero, + selectedItems: [hero, contract], + selection: { + selectedIds: [hero.id, contract.id], + focusedId: contract.id, + }, + }, + drop: { + target: { + kind: 'listing', + folder: destination, + folderId: destination.id, + }, + }, + modifiers: { ctrlKey: true }, + }); + + expect(intent).toEqual( + expect.objectContaining({ + operation: 'copy', + activeItemId: 'image:hero', + destinationFolderId: destination.id, + sourceIds: ['image:hero', 'pdf:contract'], + sourceItems: [hero, contract], + }) + ); + }); + + it('rejects dropping a folder selection into itself', () => { + const folder = browserItemFixtures.find((item) => item.id === 'folder:brand-assets')!; + + const intent = createBrowserTransferIntent({ + source: { + item: folder, + selectedItems: [folder], + selection: { + selectedIds: [folder.id], + focusedId: folder.id, + }, + }, + drop: { + target: { + kind: 'folder', + folderId: createBrowserOpaqueId('folder:brand-assets'), + }, + }, + }); + + expect(intent).toBeNull(); + }); +});