@djangocfg/ui-tools 2.1.415 → 2.1.417

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/audio-player/index.cjs +2099 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +174 -0
  6. package/dist/audio-player/index.d.ts +174 -0
  7. package/dist/audio-player/index.mjs +2076 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/file-icon/index.d.cts +1 -1
  16. package/dist/file-icon/index.d.ts +1 -1
  17. package/dist/slots-ClRpIzoh.d.cts +88 -0
  18. package/dist/slots-ClRpIzoh.d.ts +88 -0
  19. package/dist/tree/index.cjs +2019 -279
  20. package/dist/tree/index.cjs.map +1 -1
  21. package/dist/tree/index.d.cts +731 -72
  22. package/dist/tree/index.d.ts +731 -72
  23. package/dist/tree/index.mjs +2009 -282
  24. package/dist/tree/index.mjs.map +1 -1
  25. package/package.json +18 -9
  26. package/src/tools/chat/README.md +111 -1
  27. package/src/tools/chat/composer/Composer.tsx +146 -25
  28. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  29. package/src/tools/chat/composer/index.ts +22 -0
  30. package/src/tools/chat/composer/slash/README.md +187 -0
  31. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  32. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  33. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  34. package/src/tools/chat/composer/slash/index.ts +44 -0
  35. package/src/tools/chat/composer/slash/labels.ts +19 -0
  36. package/src/tools/chat/composer/slash/state.ts +168 -0
  37. package/src/tools/chat/composer/slash/types.ts +64 -0
  38. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  39. package/src/tools/chat/composer/types.ts +8 -0
  40. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  41. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  42. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  43. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  44. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  45. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  46. package/src/tools/chat/shell/index.ts +6 -0
  47. package/src/tools/data/Listbox/lazy.tsx +1 -1
  48. package/src/tools/data/Masonry/lazy.tsx +1 -1
  49. package/src/tools/data/Timeline/lazy.tsx +1 -1
  50. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  51. package/src/tools/data/Tree/README.md +337 -208
  52. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  53. package/src/tools/data/Tree/TreeRoot.tsx +111 -72
  54. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  55. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  56. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  57. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  58. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  59. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  60. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  61. package/src/tools/data/Tree/components/TreeRow.tsx +103 -8
  62. package/src/tools/data/Tree/components/index.ts +6 -0
  63. package/src/tools/data/Tree/context/TreeContext.tsx +223 -363
  64. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  65. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  66. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  67. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  68. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  69. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  70. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  71. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  72. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  73. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  74. package/src/tools/data/Tree/context/hooks.ts +68 -1
  75. package/src/tools/data/Tree/context/index.ts +3 -0
  76. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  77. package/src/tools/data/Tree/context/menu/index.ts +11 -0
  78. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  79. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -0
  80. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  81. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  82. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  83. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  84. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  85. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  86. package/src/tools/data/Tree/context/state/index.ts +6 -0
  87. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  88. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  89. package/src/tools/data/Tree/context/state/types.ts +46 -0
  90. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  91. package/src/tools/data/Tree/data/dnd.ts +123 -0
  92. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  93. package/src/tools/data/Tree/data/index.ts +19 -0
  94. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  95. package/src/tools/data/Tree/data/selection.ts +157 -0
  96. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  97. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  98. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  99. package/src/tools/data/Tree/hooks/index.ts +23 -4
  100. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  101. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  102. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  103. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  104. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  105. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  106. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  107. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  108. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  109. package/src/tools/data/Tree/index.tsx +26 -2
  110. package/src/tools/data/Tree/types/activation.ts +30 -0
  111. package/src/tools/data/Tree/types/adapter.ts +70 -0
  112. package/src/tools/data/Tree/types/index.ts +27 -0
  113. package/src/tools/data/Tree/types/labels.ts +97 -0
  114. package/src/tools/data/Tree/types/loader.ts +9 -0
  115. package/src/tools/data/Tree/types/node.ts +38 -0
  116. package/src/tools/data/Tree/types/root-props.ts +158 -0
  117. package/src/tools/data/Tree/types/selection.ts +3 -0
  118. package/src/tools/data/Tree/types/slots.ts +64 -0
  119. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  120. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  121. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  122. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  123. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  124. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  125. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  126. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  127. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  128. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  129. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  130. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  131. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  132. package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
  133. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  134. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  135. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  136. package/src/tools/media/AudioPlayer/types.ts +15 -0
  137. package/dist/types-j2vhn4Kv.d.cts +0 -241
  138. package/dist/types-j2vhn4Kv.d.ts +0 -241
  139. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  140. package/src/tools/data/Tree/types.ts +0 -217
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId, TreeMovePosition, TreeNode } from '../types';
4
+
5
+ // =====================================================================
6
+ // Drag-and-drop primitives — pure, framework-agnostic.
7
+ //
8
+ // @dnd-kit handles the pointer/keyboard sensors and overlay rendering;
9
+ // these helpers only translate pointer geometry into a domain-level
10
+ // drop position (`before` / `inside` / `after`) and validate whether
11
+ // a given drag/drop combo is allowed.
12
+ //
13
+ // Folder vs leaf semantics:
14
+ // - leaves → no `inside` zone; row splits into top/bottom halves
15
+ // for `before` / `after` reorder
16
+ // - folders → top third = `before`, middle third = `inside`,
17
+ // bottom third = `after`
18
+ // =====================================================================
19
+
20
+ export interface DropZoneInput {
21
+ /** Pointer Y in viewport coordinates. */
22
+ pointerY: number;
23
+ /** Row bounding box (`getBoundingClientRect()`). */
24
+ rowRect: { top: number; bottom: number; height: number };
25
+ /** Folders accept `inside` drops; leaves only reorder via before/after. */
26
+ isFolder: boolean;
27
+ }
28
+
29
+ /**
30
+ * Translate pointer geometry into a drop position relative to the row.
31
+ *
32
+ * For folders the row is split into three zones (top third / middle /
33
+ * bottom third). For leaves it's split in half (before / after).
34
+ */
35
+ export function resolveDropZone(input: DropZoneInput): TreeMovePosition {
36
+ const { pointerY, rowRect, isFolder } = input;
37
+ const offset = pointerY - rowRect.top;
38
+ const ratio = rowRect.height > 0 ? offset / rowRect.height : 0.5;
39
+
40
+ if (isFolder) {
41
+ if (ratio < 0.33) return 'before';
42
+ if (ratio > 0.66) return 'after';
43
+ return 'inside';
44
+ }
45
+ return ratio < 0.5 ? 'before' : 'after';
46
+ }
47
+
48
+ // =====================================================================
49
+ // canDrop — default validator.
50
+ //
51
+ // Rejects:
52
+ // 1. Dropping a node onto itself.
53
+ // 2. Dropping a folder into one of its own descendants (would create
54
+ // a cycle).
55
+ // 3. Dropping `inside` a non-folder (defensive — UI already routes
56
+ // these as `before`/`after`, but the API can be called directly).
57
+ //
58
+ // Consumers can pass their own `canDrop` via `<TreeRoot canDrop={…}>`
59
+ // to layer extra rules on top (e.g. read-only branches, type matching).
60
+ // =====================================================================
61
+
62
+ export interface CanDropInput<T> {
63
+ /** Nodes being dragged. */
64
+ source: TreeNode<T>[];
65
+ /** Row under the pointer (`null` = root drop zone). */
66
+ target: TreeNode<T> | null;
67
+ /** Resolved drop position. */
68
+ position: TreeMovePosition;
69
+ /** Tree's id→node lookup, used to walk descendants. */
70
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
71
+ }
72
+
73
+ export function defaultCanDrop<T>(input: CanDropInput<T>): boolean {
74
+ const { source, target, position } = input;
75
+ if (source.length === 0) return false;
76
+ if (!target) return true; // root drop is always allowed when no other rule rejects.
77
+
78
+ // Inside a leaf doesn't make sense.
79
+ if (position === 'inside') {
80
+ const isFolder = Array.isArray(target.children) || !!target.isFolder;
81
+ if (!isFolder) return false;
82
+ }
83
+
84
+ for (const node of source) {
85
+ if (node.id === target.id) return false;
86
+ if (isDescendant(node, target.id)) return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Walk `root`'s inline children searching for `id`. Cached async children
93
+ * are *not* walked here because the cycle check needs to be cheap and
94
+ * deterministic — a missing cycle in unloaded branches is acceptable
95
+ * (the consumer's `adapter.move` is the final gatekeeper).
96
+ */
97
+ function isDescendant<T>(root: TreeNode<T>, id: TreeItemId): boolean {
98
+ if (!Array.isArray(root.children)) return false;
99
+ for (const child of root.children) {
100
+ if (child.id === id) return true;
101
+ if (isDescendant(child, id)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ // =====================================================================
107
+ // DataTransfer mime — used both for in-tree drags and (later) for
108
+ // receiving dragged files from the OS. The id payload is the JSON-
109
+ // encoded list of `TreeItemId`s.
110
+ // =====================================================================
111
+
112
+ export const TREE_DND_MIME = 'application/x-djangocfg-tree';
113
+
114
+ /**
115
+ * Sentinel droppable id used by `<TreeEmptyArea>` to claim the
116
+ * "root drop target" slot. `<TreeDndProvider>` recognises this id and
117
+ * maps it to `{ id: null, position: 'inside' }` in `dnd.dropTarget`.
118
+ *
119
+ * Lives in `data/` (not in the component) so both producer and
120
+ * consumer reference one constant instead of duplicating the magic
121
+ * string.
122
+ */
123
+ export const TREE_ROOT_DROP_ID = '__tree_root_drop__';
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import type { TreeBuiltinAction } from '../types';
4
+
5
+ // =====================================================================
6
+ // Finder / Explorer keyboard shortcuts.
7
+ //
8
+ // Each entry maps a built-in action to a list of `react-hotkeys-hook`
9
+ // combos. `mod` resolves to ⌘ on macOS and Ctrl on Win/Linux automatically,
10
+ // so a single declaration works cross-platform.
11
+ //
12
+ // Tree only binds shortcuts whose corresponding adapter method is
13
+ // defined. So `delete` only fires if `adapter.remove` exists — same
14
+ // gating used for context-menu items, no duplicated source of truth.
15
+ // =====================================================================
16
+
17
+ export interface FinderShortcut {
18
+ /** Built-in action id this shortcut runs. */
19
+ action: TreeBuiltinAction;
20
+ /** `react-hotkeys-hook` key combos. */
21
+ keys: string[];
22
+ /** Cheat-sheet description (registered via `useHotkey`). */
23
+ description: string;
24
+ }
25
+
26
+ export const FINDER_SHORTCUTS: FinderShortcut[] = [
27
+ {
28
+ action: 'delete',
29
+ keys: ['mod+backspace', 'delete'],
30
+ description: 'Delete selected items',
31
+ },
32
+ {
33
+ action: 'rename',
34
+ keys: ['f2'],
35
+ description: 'Rename selected item',
36
+ },
37
+ {
38
+ action: 'duplicate',
39
+ keys: ['mod+d'],
40
+ description: 'Duplicate selected items',
41
+ },
42
+ {
43
+ action: 'new-folder',
44
+ keys: ['mod+shift+n'],
45
+ description: 'New folder',
46
+ },
47
+ {
48
+ action: 'new-file',
49
+ keys: ['mod+n'],
50
+ description: 'New file',
51
+ },
52
+ {
53
+ action: 'cut',
54
+ keys: ['mod+x'],
55
+ description: 'Cut',
56
+ },
57
+ {
58
+ action: 'copy',
59
+ keys: ['mod+c'],
60
+ description: 'Copy',
61
+ },
62
+ {
63
+ action: 'paste',
64
+ keys: ['mod+v'],
65
+ description: 'Paste',
66
+ },
67
+ ];
@@ -8,6 +8,25 @@ export { loadTreeState, saveTreeState, clearTreeState } from './persist';
8
8
  export type { PersistedTreeState } from './persist';
9
9
  export { createDemoTree } from './createDemoTree';
10
10
  export type { DemoNode } from './createDemoTree';
11
+ export {
12
+ computeRange,
13
+ selectionFromClick,
14
+ selectionFromMove,
15
+ selectionSelectAll,
16
+ selectionClear,
17
+ } from './selection';
18
+ export type { SelectionState, ClickModifiers } from './selection';
19
+ export { splitFileName, autoSelectRange } from './renameUtils';
20
+ export type { SplitName } from './renameUtils';
21
+ export { isCutId } from './clipboard';
22
+ export type { ClipboardKind, ClipboardEntry, ClipboardState } from './clipboard';
23
+ export {
24
+ resolveDropZone,
25
+ defaultCanDrop,
26
+ TREE_DND_MIME,
27
+ TREE_ROOT_DROP_ID,
28
+ } from './dnd';
29
+ export type { DropZoneInput, CanDropInput } from './dnd';
11
30
  export {
12
31
  DEFAULT_TREE_APPEARANCE,
13
32
  resolveAppearance,
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ // =====================================================================
4
+ // Filename split — used by the inline-rename input to auto-select the
5
+ // "base" portion without the extension (Finder / macOS behaviour).
6
+ //
7
+ // Rules:
8
+ // - dotfiles like ".env" or ".gitignore" → base = whole name, ext = ''
9
+ // - "foo" → base = 'foo', ext = ''
10
+ // - "foo.txt" → base = 'foo', ext = '.txt'
11
+ // - "foo.bar.baz" → base = 'foo.bar', ext = '.baz'
12
+ // - "folder/" (trailing slash) → trailing slash kept in ext
13
+ //
14
+ // We deliberately don't handle nested dots or multi-part extensions like
15
+ // ".tar.gz". Finder picks the last `.` and so do we — it's good enough
16
+ // for selection UX and consistent with the OS.
17
+ // =====================================================================
18
+
19
+ export interface SplitName {
20
+ base: string;
21
+ ext: string;
22
+ }
23
+
24
+ export function splitFileName(name: string): SplitName {
25
+ if (name.length === 0) return { base: '', ext: '' };
26
+
27
+ // Dotfiles — leading dot is part of the base, not an extension.
28
+ if (name.startsWith('.')) {
29
+ const rest = name.slice(1);
30
+ const dot = rest.lastIndexOf('.');
31
+ if (dot < 0) return { base: name, ext: '' };
32
+ return { base: '.' + rest.slice(0, dot), ext: rest.slice(dot) };
33
+ }
34
+
35
+ const dot = name.lastIndexOf('.');
36
+ if (dot <= 0) return { base: name, ext: '' };
37
+ return { base: name.slice(0, dot), ext: name.slice(dot) };
38
+ }
39
+
40
+ /**
41
+ * Returns the `[selectionStart, selectionEnd]` pair to use on focus of an
42
+ * `<input>` so only the base name is highlighted (Finder behaviour).
43
+ *
44
+ * Folders pass `isFolder=true` to skip extension detection and select
45
+ * the entire name — folders don't have file extensions semantically.
46
+ */
47
+ export function autoSelectRange(name: string, isFolder: boolean): [number, number] {
48
+ if (isFolder) return [0, name.length];
49
+ const { base } = splitFileName(name);
50
+ return [0, base.length];
51
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId } from '../types';
4
+
5
+ // =====================================================================
6
+ // Selection helpers — pure functions over flatRows + state.
7
+ //
8
+ // Finder/Explorer rules implemented here:
9
+ // - plain click → replace selection, set anchor = id
10
+ // - meta-click → toggle id, set anchor = id (clicked row becomes new anchor)
11
+ // - shift-click → range [anchor..id] (replaces selection)
12
+ // - shift+meta → union(range [anchor..id], previous selection)
13
+ // - shift+arrows → extend range from anchor through new focused row
14
+ // - mod+a → select all visible rows
15
+ // - escape → clear selection (anchor stays — focused stays)
16
+ // =====================================================================
17
+
18
+ export interface SelectionState {
19
+ selected: ReadonlySet<TreeItemId>;
20
+ /** Anchor for shift-range. `null` when nothing has been selected yet. */
21
+ anchor: TreeItemId | null;
22
+ /** Visual cursor — usually equals the last clicked / arrow-moved row. */
23
+ focused: TreeItemId | null;
24
+ }
25
+
26
+ function indexOf(rows: readonly FlatRow<unknown>[], id: TreeItemId | null): number {
27
+ if (id == null) return -1;
28
+ for (let i = 0; i < rows.length; i++) {
29
+ if (rows[i].node.id === id) return i;
30
+ }
31
+ return -1;
32
+ }
33
+
34
+ /**
35
+ * Inclusive range of ids between `fromId` and `toId` according to their
36
+ * positions in `rows`. Returns an empty array if either endpoint is not in
37
+ * `rows` (e.g. anchor pointed at a row that got collapsed away).
38
+ */
39
+ export function computeRange<T>(
40
+ rows: readonly FlatRow<T>[],
41
+ fromId: TreeItemId | null,
42
+ toId: TreeItemId | null,
43
+ ): TreeItemId[] {
44
+ if (fromId == null || toId == null) return [];
45
+ const a = indexOf(rows, fromId);
46
+ const b = indexOf(rows, toId);
47
+ if (a < 0 || b < 0) return [];
48
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
49
+ const out: TreeItemId[] = [];
50
+ for (let i = lo; i <= hi; i++) out.push(rows[i].node.id);
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Click selection — derives next `{selected, anchor, focused}` from a
56
+ * pointer event's modifier keys. `rows` is the current flat view (used
57
+ * for shift-range computation).
58
+ */
59
+ export interface ClickModifiers {
60
+ shift: boolean;
61
+ meta: boolean;
62
+ }
63
+
64
+ export function selectionFromClick<T>(
65
+ state: SelectionState,
66
+ rows: readonly FlatRow<T>[],
67
+ id: TreeItemId,
68
+ mods: ClickModifiers,
69
+ multi: boolean,
70
+ ): SelectionState {
71
+ // Single-selection mode → always collapse to one id.
72
+ if (!multi) {
73
+ return { selected: new Set([id]), anchor: id, focused: id };
74
+ }
75
+
76
+ // shift+meta → union of current selection with range from anchor to id.
77
+ if (mods.shift && mods.meta) {
78
+ const anchor = state.anchor ?? state.focused ?? id;
79
+ const range = computeRange(rows, anchor, id);
80
+ const next = new Set(state.selected);
81
+ for (const r of range) next.add(r);
82
+ return { selected: next, anchor: state.anchor ?? id, focused: id };
83
+ }
84
+
85
+ // shift-only → replace with range from anchor to id.
86
+ if (mods.shift) {
87
+ const anchor = state.anchor ?? state.focused ?? id;
88
+ const range = computeRange(rows, anchor, id);
89
+ return {
90
+ selected: new Set(range.length > 0 ? range : [id]),
91
+ anchor: state.anchor ?? anchor,
92
+ focused: id,
93
+ };
94
+ }
95
+
96
+ // meta-only → toggle id, reset anchor.
97
+ if (mods.meta) {
98
+ const next = new Set(state.selected);
99
+ if (next.has(id)) next.delete(id);
100
+ else next.add(id);
101
+ return { selected: next, anchor: id, focused: id };
102
+ }
103
+
104
+ // Plain click → replace selection.
105
+ return { selected: new Set([id]), anchor: id, focused: id };
106
+ }
107
+
108
+ /**
109
+ * Move-focus selection — used by arrow keys. When `extend` is true (shift),
110
+ * recompute the range from anchor through the new focused id. Otherwise
111
+ * collapse selection to the new id and reset anchor.
112
+ */
113
+ export function selectionFromMove<T>(
114
+ state: SelectionState,
115
+ rows: readonly FlatRow<T>[],
116
+ nextFocusedId: TreeItemId,
117
+ extend: boolean,
118
+ multi: boolean,
119
+ ): SelectionState {
120
+ if (!multi || !extend) {
121
+ return { selected: new Set([nextFocusedId]), anchor: nextFocusedId, focused: nextFocusedId };
122
+ }
123
+ const anchor = state.anchor ?? state.focused ?? nextFocusedId;
124
+ const range = computeRange(rows, anchor, nextFocusedId);
125
+ return {
126
+ selected: new Set(range.length > 0 ? range : [nextFocusedId]),
127
+ anchor,
128
+ focused: nextFocusedId,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Select every visible row (Cmd/Ctrl+A). Anchor is set to the first row
134
+ * so a subsequent shift-click extends from a predictable starting point.
135
+ */
136
+ export function selectionSelectAll<T>(
137
+ rows: readonly FlatRow<T>[],
138
+ focused: TreeItemId | null,
139
+ ): SelectionState {
140
+ if (rows.length === 0) {
141
+ return { selected: new Set(), anchor: null, focused };
142
+ }
143
+ const ids = rows.map((r) => r.node.id);
144
+ return {
145
+ selected: new Set(ids),
146
+ anchor: ids[0],
147
+ focused: focused ?? ids[0],
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Esc behaviour — clear selected items and anchor, but keep focused so the
153
+ * visual cursor stays where the user left it (Finder mimics this).
154
+ */
155
+ export function selectionClear(state: SelectionState): SelectionState {
156
+ return { selected: new Set(), anchor: null, focused: state.focused };
157
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import type { BuiltinActionContext } from '../../context/menu';
4
+ import type {
5
+ TreeAdapter,
6
+ TreeItemId,
7
+ TreeLabels,
8
+ TreeNode,
9
+ } from '../../types';
10
+
11
+ export interface BuildCtxInput<T> {
12
+ adapter?: TreeAdapter<T>;
13
+ labels: TreeLabels;
14
+ selected: ReadonlySet<TreeItemId>;
15
+ focused: TreeItemId | null;
16
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
17
+ getItemName: (node: TreeNode<T>) => string;
18
+ startInlineRename?: (id: TreeItemId) => void;
19
+ clipboard?: BuiltinActionContext<T>['clipboard'];
20
+ }
21
+
22
+ /**
23
+ * Build a `BuiltinActionContext<T>` snapshot from the current Tree state.
24
+ * Returns `null` when the adapter is missing — the hotkey handlers
25
+ * short-circuit on null so no built-in action fires without an adapter.
26
+ */
27
+ export function buildBuiltinCtx<T>(
28
+ input: BuildCtxInput<T>,
29
+ ): BuiltinActionContext<T> | null {
30
+ if (!input.adapter) return null;
31
+ const selectedNodes: TreeNode<T>[] = [];
32
+ for (const id of input.selected) {
33
+ const n = input.getNodeById(id);
34
+ if (n) selectedNodes.push(n);
35
+ }
36
+ const targetNode = input.focused
37
+ ? input.getNodeById(input.focused) ?? null
38
+ : null;
39
+ return {
40
+ adapter: input.adapter,
41
+ labels: input.labels,
42
+ selectedNodes,
43
+ targetNode,
44
+ getName: input.getItemName,
45
+ startInlineRename: input.startInlineRename,
46
+ clipboard: input.clipboard,
47
+ };
48
+ }
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ export { useTreeFinderHotkeys } from './use-tree-finder-hotkeys';
4
+ export type {
5
+ UseTreeFinderHotkeysOptions,
6
+ UseTreeFinderHotkeysReturn,
7
+ } from './use-tree-finder-hotkeys';
8
+ export { buildBuiltinCtx } from './build-ctx';
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
5
+
6
+ import { runBuiltinAction, type BuiltinActionContext } from '../../context/menu';
7
+ import type {
8
+ TreeAdapter,
9
+ TreeBuiltinAction,
10
+ TreeItemId,
11
+ TreeLabels,
12
+ TreeNode,
13
+ } from '../../types';
14
+ import { buildBuiltinCtx } from './build-ctx';
15
+
16
+ export interface UseTreeFinderHotkeysOptions<T> {
17
+ /** Off by default — Tree opt-ins via `enableFinderHotkeys`. */
18
+ enabled: boolean;
19
+ /** Adapter — used both for action availability and dispatch. */
20
+ adapter?: TreeAdapter<T>;
21
+ /** Labels (passed through into adapter action context for dialogs). */
22
+ labels: TreeLabels;
23
+ /** Live selection (set of ids). */
24
+ selected: ReadonlySet<TreeItemId>;
25
+ /** Live focused id (used as "target" for new-file/new-folder actions). */
26
+ focused: TreeItemId | null;
27
+ /** Id → node lookup. */
28
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
29
+ /** Display name resolver. */
30
+ getItemName: (node: TreeNode<T>) => string;
31
+ /** Open inline rename on the row (P3). Falls back to a prompt otherwise. */
32
+ startInlineRename?: (id: TreeItemId) => void;
33
+ /** Clipboard bindings (P5). When undefined, ⌘C/X/V are no-ops. */
34
+ clipboard?: BuiltinActionContext<T>['clipboard'];
35
+ /** Whether typing is currently in inline-rename input — pauses bindings. */
36
+ paused?: boolean;
37
+ }
38
+
39
+ export interface UseTreeFinderHotkeysReturn {
40
+ /** Attach to the tree container ref so hotkeys only fire when it has focus. */
41
+ ref: (instance: HTMLElement | null) => void;
42
+ }
43
+
44
+ /**
45
+ * Wire the platform-aware Finder/Explorer shortcuts to the built-in
46
+ * adapter actions. Bindings are scoped to the container ref via
47
+ * `useHotkey`, so they don't leak to the rest of the page.
48
+ *
49
+ * Each shortcut is bound by an explicit `useHotkey` call (no `.map(useHotkey)`
50
+ * loop, so the rules-of-hooks lint passes cleanly). The handler routes
51
+ * through `runBuiltinAction`, which silently no-ops when the adapter
52
+ * doesn't expose the matching method — so a Tree with `adapter = { remove }`
53
+ * only effectively reacts to ⌘⌫ / Delete.
54
+ */
55
+ export function useTreeFinderHotkeys<T>(
56
+ opts: UseTreeFinderHotkeysOptions<T>,
57
+ ): UseTreeFinderHotkeysReturn {
58
+ const optsRef = useRef(opts);
59
+ optsRef.current = opts;
60
+
61
+ const run = useCallback(async (action: TreeBuiltinAction) => {
62
+ const o = optsRef.current;
63
+ if (o.paused) return;
64
+ const ctx = buildBuiltinCtx({
65
+ adapter: o.adapter,
66
+ labels: o.labels,
67
+ selected: o.selected,
68
+ focused: o.focused,
69
+ getNodeById: o.getNodeById,
70
+ getItemName: o.getItemName,
71
+ startInlineRename: o.startInlineRename,
72
+ clipboard: o.clipboard,
73
+ });
74
+ if (!ctx) return;
75
+ await runBuiltinAction(action, ctx);
76
+ }, []);
77
+
78
+ // One explicit binding per shortcut. Adding a new built-in action means
79
+ // adding one more line here — the trade-off is verbose-but-static vs.
80
+ // a fragile `.map(useHotkey)` loop.
81
+
82
+ const refDelete = useHotkey(
83
+ ['mod+backspace', 'delete'],
84
+ () => void run('delete'),
85
+ {
86
+ enabled: opts.enabled,
87
+ preventDefault: true,
88
+ description: 'Delete selected items',
89
+ scope: 'tree',
90
+ },
91
+ );
92
+
93
+ const refRename = useHotkey('f2', () => void run('rename'), {
94
+ enabled: opts.enabled,
95
+ preventDefault: true,
96
+ description: 'Rename selected item',
97
+ scope: 'tree',
98
+ });
99
+
100
+ const refDuplicate = useHotkey('mod+d', () => void run('duplicate'), {
101
+ enabled: opts.enabled,
102
+ preventDefault: true,
103
+ description: 'Duplicate selected items',
104
+ scope: 'tree',
105
+ });
106
+
107
+ const refNewFolder = useHotkey('mod+shift+n', () => void run('new-folder'), {
108
+ enabled: opts.enabled,
109
+ preventDefault: true,
110
+ description: 'New folder',
111
+ scope: 'tree',
112
+ });
113
+
114
+ const refNewFile = useHotkey('mod+n', () => void run('new-file'), {
115
+ enabled: opts.enabled,
116
+ preventDefault: true,
117
+ description: 'New file',
118
+ scope: 'tree',
119
+ });
120
+
121
+ const refCut = useHotkey('mod+x', () => void run('cut'), {
122
+ enabled: opts.enabled,
123
+ preventDefault: true,
124
+ description: 'Cut',
125
+ scope: 'tree',
126
+ });
127
+
128
+ const refCopy = useHotkey('mod+c', () => void run('copy'), {
129
+ enabled: opts.enabled,
130
+ preventDefault: true,
131
+ description: 'Copy',
132
+ scope: 'tree',
133
+ });
134
+
135
+ const refPaste = useHotkey('mod+v', () => void run('paste'), {
136
+ enabled: opts.enabled,
137
+ preventDefault: true,
138
+ description: 'Paste',
139
+ scope: 'tree',
140
+ });
141
+
142
+ const ref = useCallback(
143
+ (instance: HTMLElement | null) => {
144
+ refDelete(instance);
145
+ refRename(instance);
146
+ refDuplicate(instance);
147
+ refNewFolder(instance);
148
+ refNewFile(instance);
149
+ refCut(instance);
150
+ refCopy(instance);
151
+ refPaste(instance);
152
+ },
153
+ [
154
+ refDelete,
155
+ refRename,
156
+ refDuplicate,
157
+ refNewFolder,
158
+ refNewFile,
159
+ refCut,
160
+ refCopy,
161
+ refPaste,
162
+ ],
163
+ );
164
+
165
+ return { ref };
166
+ }
@@ -1,6 +1,25 @@
1
1
  'use client';
2
2
 
3
- export { useTreeTypeAhead } from './useTreeTypeAhead';
4
- export type { UseTreeTypeAheadOptions } from './useTreeTypeAhead';
5
- export { useTreeKeyboard } from './useTreeKeyboard';
6
- export type { UseTreeKeyboardOptions } from './useTreeKeyboard';
3
+ // Public surface re-exports from each feature folder.
4
+ //
5
+ // Each folder owns one "scope" of keyboard interaction:
6
+ // - keyboard/ arrow / home / end / enter / esc / mod+a
7
+ // - type-ahead/ printable-keys rolling buffer
8
+ // - finder-hotkeys/ Finder/Explorer shortcuts wired to adapter actions
9
+ //
10
+ // Adding a new scope = adding a new folder, not editing this barrel.
11
+
12
+ export { useTreeKeyboard } from './keyboard';
13
+ export type {
14
+ UseTreeKeyboardOptions,
15
+ UseTreeKeyboardReturn,
16
+ } from './keyboard';
17
+
18
+ export { useTreeTypeAhead } from './type-ahead';
19
+ export type { UseTreeTypeAheadOptions } from './type-ahead';
20
+
21
+ export { useTreeFinderHotkeys } from './finder-hotkeys';
22
+ export type {
23
+ UseTreeFinderHotkeysOptions,
24
+ UseTreeFinderHotkeysReturn,
25
+ } from './finder-hotkeys';