@djangocfg/widget-data 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +122 -0
- package/src/DataGrid/README.md +48 -0
- package/src/DataGrid/context/DataGridContext.tsx +442 -0
- package/src/DataGrid/context/index.ts +4 -0
- package/src/DataGrid/index.ts +34 -0
- package/src/DataGrid/lazy.tsx +23 -0
- package/src/DataGrid/parts/DataGrid.tsx +125 -0
- package/src/DataGrid/parts/DataGridCell.tsx +30 -0
- package/src/DataGrid/parts/DataGridHeader.tsx +111 -0
- package/src/DataGrid/parts/DataGridPagination.tsx +105 -0
- package/src/DataGrid/parts/DataGridRow.tsx +84 -0
- package/src/DataGrid/parts/index.ts +7 -0
- package/src/DataGrid/types.ts +95 -0
- package/src/DataTable/README.md +42 -0
- package/src/DataTable/context/DataTableContext.tsx +434 -0
- package/src/DataTable/context/index.ts +4 -0
- package/src/DataTable/index.ts +33 -0
- package/src/DataTable/lazy.tsx +23 -0
- package/src/DataTable/parts/DataTable.tsx +142 -0
- package/src/DataTable/parts/DataTableHeader.tsx +103 -0
- package/src/DataTable/parts/DataTablePagination.tsx +115 -0
- package/src/DataTable/parts/DataTableRow.tsx +96 -0
- package/src/DataTable/parts/index.ts +6 -0
- package/src/DataTable/types.ts +88 -0
- package/src/FileIcon/FileIcon.tsx +87 -0
- package/src/FileIcon/get-file-icon.ts +73 -0
- package/src/FileIcon/icons/icon-data.ts +399 -0
- package/src/FileIcon/index.ts +19 -0
- package/src/FileIcon/loader.ts +29 -0
- package/src/FileIcon/specialFolders.ts +110 -0
- package/src/FileIcon/treeAdapter.tsx +49 -0
- package/src/JsonTree/JsonViewer.tsx +720 -0
- package/src/JsonTree/README.md +142 -0
- package/src/JsonTree/index.tsx +11 -0
- package/src/JsonTree/lazy.tsx +22 -0
- package/src/JsonTree/types.ts +91 -0
- package/src/Kanban/context/KanbanContext.tsx +165 -0
- package/src/Kanban/context/index.ts +4 -0
- package/src/Kanban/index.ts +31 -0
- package/src/Kanban/lazy.tsx +23 -0
- package/src/Kanban/parts/Kanban.tsx +77 -0
- package/src/Kanban/parts/KanbanCard.tsx +76 -0
- package/src/Kanban/parts/KanbanColumn.tsx +79 -0
- package/src/Kanban/parts/KanbanDragOverlay.tsx +46 -0
- package/src/Kanban/parts/index.ts +6 -0
- package/src/Kanban/types.ts +45 -0
- package/src/Listbox/context/ListboxProvider.tsx +616 -0
- package/src/Listbox/context/index.ts +8 -0
- package/src/Listbox/index.ts +28 -0
- package/src/Listbox/lazy.tsx +31 -0
- package/src/Listbox/parts/ListboxGroup.tsx +71 -0
- package/src/Listbox/parts/ListboxItem.tsx +151 -0
- package/src/Listbox/parts/index.ts +12 -0
- package/src/Listbox/types.ts +98 -0
- package/src/Masonry/README.md +79 -0
- package/src/Masonry/context/MasonryProvider.tsx +1205 -0
- package/src/Masonry/context/index.ts +8 -0
- package/src/Masonry/index.ts +18 -0
- package/src/Masonry/lazy.tsx +20 -0
- package/src/Masonry/parts/index.ts +2 -0
- package/src/Masonry/types.ts +44 -0
- package/src/Timeline/context/TimelineProvider.tsx +474 -0
- package/src/Timeline/context/index.ts +16 -0
- package/src/Timeline/index.ts +29 -0
- package/src/Timeline/lazy.tsx +27 -0
- package/src/Timeline/parts/index.ts +2 -0
- package/src/Timeline/types.ts +25 -0
- package/src/Tree/FinderTree.tsx +42 -0
- package/src/Tree/README.md +542 -0
- package/src/Tree/TreeDndProvider.tsx +137 -0
- package/src/Tree/TreeRoot.tsx +290 -0
- package/src/Tree/components/TreeChevron.tsx +48 -0
- package/src/Tree/components/TreeContent.tsx +70 -0
- package/src/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/Tree/components/TreeEmpty.tsx +22 -0
- package/src/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/Tree/components/TreeError.tsx +24 -0
- package/src/Tree/components/TreeIcon.tsx +39 -0
- package/src/Tree/components/TreeIndentGuides.tsx +33 -0
- package/src/Tree/components/TreeLabel.tsx +39 -0
- package/src/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/Tree/components/TreeRow.tsx +300 -0
- package/src/Tree/components/TreeSearchInput.tsx +50 -0
- package/src/Tree/components/TreeSkeleton.tsx +22 -0
- package/src/Tree/components/index.ts +28 -0
- package/src/Tree/context/TreeContext.tsx +409 -0
- package/src/Tree/context/TreeContextValue.ts +139 -0
- package/src/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/Tree/context/async-children/index.ts +8 -0
- package/src/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/Tree/context/clipboard/index.ts +4 -0
- package/src/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/Tree/context/dnd/index.ts +8 -0
- package/src/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/Tree/context/expansion/index.ts +4 -0
- package/src/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/Tree/context/hooks.ts +177 -0
- package/src/Tree/context/index.ts +16 -0
- package/src/Tree/context/menu/builtin-actions.ts +358 -0
- package/src/Tree/context/menu/index.ts +11 -0
- package/src/Tree/context/menu/render.tsx +104 -0
- package/src/Tree/context/menu/use-resolved-menu.ts +141 -0
- package/src/Tree/context/persist/index.ts +4 -0
- package/src/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/Tree/context/rename/index.ts +4 -0
- package/src/Tree/context/rename/use-rename.ts +113 -0
- package/src/Tree/context/selection/index.ts +4 -0
- package/src/Tree/context/selection/use-selection.ts +146 -0
- package/src/Tree/context/state/index.ts +6 -0
- package/src/Tree/context/state/initial.ts +41 -0
- package/src/Tree/context/state/reducer.ts +76 -0
- package/src/Tree/context/state/types.ts +46 -0
- package/src/Tree/data/appearance.ts +237 -0
- package/src/Tree/data/childCache.ts +43 -0
- package/src/Tree/data/clipboard.ts +33 -0
- package/src/Tree/data/createDemoTree.ts +42 -0
- package/src/Tree/data/dnd.ts +123 -0
- package/src/Tree/data/finderShortcuts.ts +67 -0
- package/src/Tree/data/flatten.ts +66 -0
- package/src/Tree/data/index.ts +45 -0
- package/src/Tree/data/persist.ts +62 -0
- package/src/Tree/data/renameUtils.ts +51 -0
- package/src/Tree/data/selection.ts +161 -0
- package/src/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/Tree/hooks/index.ts +25 -0
- package/src/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/Tree/hooks/keyboard/index.ts +10 -0
- package/src/Tree/hooks/keyboard/types.ts +39 -0
- package/src/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/Tree/hooks/type-ahead/use-tree-type-ahead.ts +89 -0
- package/src/Tree/index.tsx +135 -0
- package/src/Tree/lazy.tsx +14 -0
- package/src/Tree/types/activation.ts +30 -0
- package/src/Tree/types/adapter.ts +70 -0
- package/src/Tree/types/index.ts +27 -0
- package/src/Tree/types/labels.ts +97 -0
- package/src/Tree/types/loader.ts +9 -0
- package/src/Tree/types/node.ts +38 -0
- package/src/Tree/types/root-props.ts +158 -0
- package/src/Tree/types/selection.ts +3 -0
- package/src/Tree/types/slots.ts +64 -0
- package/src/index.ts +21 -0
- package/src/stories/_fixtures/categories.ts +51 -0
- package/src/stories/_fixtures/finder-store.ts +196 -0
- package/src/stories/_fixtures/fs.ts +83 -0
- package/src/stories/_fixtures/index.ts +5 -0
- package/src/stories/_fixtures/org.ts +46 -0
- package/src/stories/_fixtures/status.ts +21 -0
- package/src/styles/index.css +10 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
5
|
+
import {
|
|
6
|
+
ContextMenu,
|
|
7
|
+
ContextMenuContent,
|
|
8
|
+
ContextMenuItem,
|
|
9
|
+
ContextMenuSeparator,
|
|
10
|
+
ContextMenuShortcut,
|
|
11
|
+
ContextMenuTrigger,
|
|
12
|
+
} from '@djangocfg/ui-core/components';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { useTreeContext } from '../context/TreeContext';
|
|
16
|
+
import {
|
|
17
|
+
buildDefaultMenuItems,
|
|
18
|
+
type BuiltinActionContext,
|
|
19
|
+
} from '../context/menu';
|
|
20
|
+
import { TREE_ROOT_DROP_ID } from '../data/dnd';
|
|
21
|
+
import type { TreeContextMenuItem } from '../types';
|
|
22
|
+
|
|
23
|
+
export interface TreeEmptyAreaProps {
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fills the remaining vertical space below `<TreeContent>` so the user
|
|
29
|
+
* can right-click "into nothing" to get a Finder/Explorer-style empty
|
|
30
|
+
* area menu (paste / new file / new folder at root), and so DnD has a
|
|
31
|
+
* root drop target.
|
|
32
|
+
*
|
|
33
|
+
* Built-in actions are derived the same way as for rows — through
|
|
34
|
+
* `buildDefaultMenuItems`, with `targetNode = null` (root) and
|
|
35
|
+
* `selectedNodes = []` (nothing under the right-click). Items whose
|
|
36
|
+
* `available()` predicate fails are simply skipped, so a tree without
|
|
37
|
+
* `adapter.createFile/Folder` and without a clipboard payload shows
|
|
38
|
+
* no menu at all.
|
|
39
|
+
*/
|
|
40
|
+
export function TreeEmptyArea({ className }: TreeEmptyAreaProps) {
|
|
41
|
+
const ctx = useTreeContext();
|
|
42
|
+
const {
|
|
43
|
+
adapter,
|
|
44
|
+
labels,
|
|
45
|
+
getItemName,
|
|
46
|
+
clipboard,
|
|
47
|
+
cutToClipboard,
|
|
48
|
+
copyToClipboard,
|
|
49
|
+
pasteFromClipboard,
|
|
50
|
+
startRename,
|
|
51
|
+
inlineRenameEnabled,
|
|
52
|
+
dnd,
|
|
53
|
+
} = ctx;
|
|
54
|
+
|
|
55
|
+
// Root drop target — receives drops that miss every row. The shared
|
|
56
|
+
// `TreeDndProvider.onDragMove` detects when `e.over.id === ROOT_DROP_ID`
|
|
57
|
+
// and pushes `{ id: null, position: 'inside' }` into `dnd.dropTarget`,
|
|
58
|
+
// so this component doesn't need an `onPointerMove` of its own.
|
|
59
|
+
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
|
60
|
+
id: TREE_ROOT_DROP_ID,
|
|
61
|
+
disabled: !dnd.active,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Resolve menu items every render — they depend on clipboard state
|
|
65
|
+
// (paste shows/hides) and on whether the adapter exposes
|
|
66
|
+
// createFile / createFolder.
|
|
67
|
+
const items = useMemo<TreeContextMenuItem<unknown>[] | null>(() => {
|
|
68
|
+
if (!adapter) return null;
|
|
69
|
+
const builtinCtx: BuiltinActionContext<unknown> = {
|
|
70
|
+
adapter,
|
|
71
|
+
labels,
|
|
72
|
+
selectedNodes: [],
|
|
73
|
+
targetNode: null,
|
|
74
|
+
getName: getItemName,
|
|
75
|
+
startInlineRename: inlineRenameEnabled ? startRename : undefined,
|
|
76
|
+
clipboard: {
|
|
77
|
+
hasItems: !!clipboard && clipboard.ids.length > 0,
|
|
78
|
+
cut: cutToClipboard,
|
|
79
|
+
copy: copyToClipboard,
|
|
80
|
+
paste: () => pasteFromClipboard(null, 'inside'),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return buildDefaultMenuItems(builtinCtx);
|
|
84
|
+
}, [
|
|
85
|
+
adapter,
|
|
86
|
+
labels,
|
|
87
|
+
getItemName,
|
|
88
|
+
inlineRenameEnabled,
|
|
89
|
+
startRename,
|
|
90
|
+
clipboard,
|
|
91
|
+
cutToClipboard,
|
|
92
|
+
copyToClipboard,
|
|
93
|
+
pasteFromClipboard,
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const surface = (
|
|
97
|
+
<div
|
|
98
|
+
ref={dnd.active ? setDroppableRef : undefined}
|
|
99
|
+
data-tree-empty-area=""
|
|
100
|
+
data-drop-active={dnd.active && isOver ? 'true' : undefined}
|
|
101
|
+
className={cn(
|
|
102
|
+
// Soaks up the remaining vertical space inside the scroll
|
|
103
|
+
// container so right-click on whitespace lands here, not on
|
|
104
|
+
// the scroll viewport.
|
|
105
|
+
'min-h-[2.5rem] flex-1',
|
|
106
|
+
dnd.active && isOver && 'bg-primary/5 rounded-sm ring-1 ring-primary/30',
|
|
107
|
+
className,
|
|
108
|
+
)}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// No items and no DnD overlap → render plain spacer. Wrapping in a
|
|
113
|
+
// `<ContextMenu>` with zero items would still capture right-clicks
|
|
114
|
+
// and show an empty popover — that's worse than browser default.
|
|
115
|
+
if (!items || items.length === 0) {
|
|
116
|
+
return surface;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<ContextMenu>
|
|
121
|
+
<ContextMenuTrigger asChild>{surface}</ContextMenuTrigger>
|
|
122
|
+
<ContextMenuContent>
|
|
123
|
+
{items.map((item, idx) => {
|
|
124
|
+
if (item === 'separator') {
|
|
125
|
+
return <ContextMenuSeparator key={`sep-${idx}`} />;
|
|
126
|
+
}
|
|
127
|
+
const Icon = item.icon;
|
|
128
|
+
return (
|
|
129
|
+
<ContextMenuItem
|
|
130
|
+
key={item.id}
|
|
131
|
+
disabled={item.disabled}
|
|
132
|
+
variant={item.destructive ? 'destructive' : undefined}
|
|
133
|
+
// Pass a synthetic "no-target" row props — the built-in
|
|
134
|
+
// action handlers we surface here (new-file / new-folder
|
|
135
|
+
// / paste) don't read row-specific fields anyway.
|
|
136
|
+
onSelect={() =>
|
|
137
|
+
item.onSelect({
|
|
138
|
+
node: undefined as never,
|
|
139
|
+
level: 0,
|
|
140
|
+
isSelected: false,
|
|
141
|
+
isExpanded: false,
|
|
142
|
+
isFocused: false,
|
|
143
|
+
isFolder: false,
|
|
144
|
+
isLoading: false,
|
|
145
|
+
isMatchingSearch: false,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
{Icon ? <Icon /> : null}
|
|
150
|
+
{item.label}
|
|
151
|
+
{item.shortcut ? (
|
|
152
|
+
<ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
|
|
153
|
+
) : null}
|
|
154
|
+
</ContextMenuItem>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</ContextMenuContent>
|
|
158
|
+
</ContextMenu>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertCircle } from 'lucide-react';
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
|
+
|
|
6
|
+
export interface TreeErrorProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TreeError({ children, className }: TreeErrorProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn(
|
|
15
|
+
'flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
role="alert"
|
|
19
|
+
>
|
|
20
|
+
<AlertCircle aria-hidden className="mt-0.5 size-4 shrink-0" />
|
|
21
|
+
<span>{children}</span>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { File, Folder, FolderOpen } from 'lucide-react';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { useTreeContext } from '../context/TreeContext';
|
|
8
|
+
|
|
9
|
+
export interface TreeIconProps {
|
|
10
|
+
isFolder: boolean;
|
|
11
|
+
isExpanded: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TreeIcon — file/folder icon for a tree row.
|
|
17
|
+
*
|
|
18
|
+
* Memoised: re-renders only when `isFolder`, `isExpanded` or `className`
|
|
19
|
+
* change. Reads appearance from context — context updates will still
|
|
20
|
+
* trigger re-render as expected.
|
|
21
|
+
*/
|
|
22
|
+
function TreeIconRaw({ isFolder, isExpanded, className }: TreeIconProps) {
|
|
23
|
+
const { appearance } = useTreeContext();
|
|
24
|
+
const Icon = isFolder ? (isExpanded ? FolderOpen : Folder) : File;
|
|
25
|
+
return (
|
|
26
|
+
<Icon
|
|
27
|
+
aria-hidden
|
|
28
|
+
strokeWidth={appearance.iconStrokeWidth}
|
|
29
|
+
style={{ width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' }}
|
|
30
|
+
className={cn(
|
|
31
|
+
'shrink-0',
|
|
32
|
+
isFolder ? 'text-foreground/70' : 'text-muted-foreground/80',
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TreeIcon = memo(TreeIconRaw);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTreeContext } from '../context/TreeContext';
|
|
4
|
+
|
|
5
|
+
export interface TreeIndentGuidesProps {
|
|
6
|
+
level: number;
|
|
7
|
+
indent: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Vertical guide lines under nested rows. Renders one absolute-positioned
|
|
12
|
+
* 1px column per ancestor level. Decorative — `aria-hidden` and
|
|
13
|
+
* pointer-events disabled. Opacity comes from the tree appearance.
|
|
14
|
+
*/
|
|
15
|
+
export function TreeIndentGuides({ level, indent }: TreeIndentGuidesProps) {
|
|
16
|
+
const { appearance } = useTreeContext();
|
|
17
|
+
if (level <= 0) return null;
|
|
18
|
+
return (
|
|
19
|
+
<span
|
|
20
|
+
aria-hidden
|
|
21
|
+
className="pointer-events-none absolute inset-y-0 left-0"
|
|
22
|
+
style={{ width: 8 + level * indent, opacity: appearance.indentGuideOpacity }}
|
|
23
|
+
>
|
|
24
|
+
{Array.from({ length: level }).map((_, i) => (
|
|
25
|
+
<span
|
|
26
|
+
key={i}
|
|
27
|
+
className="absolute inset-y-0 w-px bg-border"
|
|
28
|
+
style={{ left: 8 + i * indent + indent / 2 - 0.5 }}
|
|
29
|
+
/>
|
|
30
|
+
))}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
|
+
|
|
6
|
+
export interface TreeLabelProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
isMatchingSearch?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TreeLabel — truncated label for a tree row.
|
|
14
|
+
*
|
|
15
|
+
* Memoised: re-renders only when `children`, `isMatchingSearch` or
|
|
16
|
+
* `className` change. `children` is compared by reference.
|
|
17
|
+
*/
|
|
18
|
+
function TreeLabelRaw({ children, isMatchingSearch, className }: TreeLabelProps) {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
// Colour is set inline via the semantic token rather than the
|
|
22
|
+
// `text-foreground` Tailwind utility: that class isn't always
|
|
23
|
+
// emitted into a consuming app's stylesheet (the bundler only
|
|
24
|
+
// generates utilities it sees referenced in scanned source), so
|
|
25
|
+
// relying on it left the label rendering as UA-default black on
|
|
26
|
+
// a dark surface. `var(--foreground)` always resolves.
|
|
27
|
+
style={{ fontSize: 'var(--tree-font-size)', color: 'var(--foreground)' }}
|
|
28
|
+
className={cn(
|
|
29
|
+
'truncate leading-tight tracking-[-0.005em]',
|
|
30
|
+
isMatchingSearch && 'font-medium',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TreeLabel = memo(TreeLabelRaw);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
5
|
+
|
|
6
|
+
import { autoSelectRange } from '../data/renameUtils';
|
|
7
|
+
|
|
8
|
+
export interface TreeRenameInputProps {
|
|
9
|
+
initialValue: string;
|
|
10
|
+
isFolder: boolean;
|
|
11
|
+
/** Called with the new (trimmed) name when the user presses Enter / blurs. */
|
|
12
|
+
onCommit: (nextName: string) => void | Promise<unknown>;
|
|
13
|
+
/** Called when the user presses Escape. */
|
|
14
|
+
onCancel: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inline rename input rendered in place of `<TreeLabel>` while a row is
|
|
20
|
+
* being renamed. Mounts focused with the *base* portion of the name
|
|
21
|
+
* pre-selected (Finder behaviour — `foo.txt` selects `foo`).
|
|
22
|
+
*
|
|
23
|
+
* Behaviour:
|
|
24
|
+
* - Enter → commit
|
|
25
|
+
* - Escape → cancel (no adapter call)
|
|
26
|
+
* - blur → commit (matches Finder; intentional even for empty
|
|
27
|
+
* names — the host validates and re-opens on error)
|
|
28
|
+
* - all other keys are stopped from bubbling so Tree's container
|
|
29
|
+
* hotkeys (↑↓ delete F2 etc.) don't fire while typing.
|
|
30
|
+
*/
|
|
31
|
+
export function TreeRenameInput({
|
|
32
|
+
initialValue,
|
|
33
|
+
isFolder,
|
|
34
|
+
onCommit,
|
|
35
|
+
onCancel,
|
|
36
|
+
className,
|
|
37
|
+
}: TreeRenameInputProps) {
|
|
38
|
+
const [value, setValue] = useState(initialValue);
|
|
39
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
40
|
+
// Track whether we already committed/cancelled so the blur handler
|
|
41
|
+
// doesn't fire a second time after Enter/Escape.
|
|
42
|
+
const settledRef = useRef(false);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const el = inputRef.current;
|
|
46
|
+
if (!el) return;
|
|
47
|
+
el.focus();
|
|
48
|
+
const [start, end] = autoSelectRange(initialValue, isFolder);
|
|
49
|
+
// setSelectionRange on a freshly focused input — schedule on next
|
|
50
|
+
// tick to dodge browser quirks where focus() resets the selection.
|
|
51
|
+
requestAnimationFrame(() => {
|
|
52
|
+
try {
|
|
53
|
+
el.setSelectionRange(start, end);
|
|
54
|
+
} catch {
|
|
55
|
+
/* Some input types reject setSelectionRange — safe to ignore. */
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
// Run only once on mount.
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const commit = () => {
|
|
63
|
+
if (settledRef.current) return;
|
|
64
|
+
settledRef.current = true;
|
|
65
|
+
void onCommit(value);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const cancel = () => {
|
|
69
|
+
if (settledRef.current) return;
|
|
70
|
+
settledRef.current = true;
|
|
71
|
+
onCancel();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<input
|
|
76
|
+
ref={inputRef}
|
|
77
|
+
type="text"
|
|
78
|
+
value={value}
|
|
79
|
+
// Tree's container hotkeys (delete / arrows / F2 …) must not fire
|
|
80
|
+
// while the user is typing the new name.
|
|
81
|
+
onKeyDown={(e) => {
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
if (e.key === 'Enter') {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
commit();
|
|
86
|
+
} else if (e.key === 'Escape') {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
cancel();
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
onChange={(e) => setValue(e.target.value)}
|
|
92
|
+
onBlur={commit}
|
|
93
|
+
// Prevent click/dblclick on the row from re-firing while the input
|
|
94
|
+
// is mounted (otherwise a focused click commits + re-selects).
|
|
95
|
+
onClick={(e) => e.stopPropagation()}
|
|
96
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
97
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
98
|
+
// Right-click inside the input should be the native input menu,
|
|
99
|
+
// not the row's context menu.
|
|
100
|
+
onContextMenu={(e) => e.stopPropagation()}
|
|
101
|
+
className={cn(
|
|
102
|
+
'min-w-0 flex-1 rounded-sm border border-primary/50 bg-background',
|
|
103
|
+
'px-1 py-0 text-foreground outline-none',
|
|
104
|
+
'focus:ring-1 focus:ring-primary/40',
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
style={{
|
|
108
|
+
// Match the row's font metrics so the input doesn't visibly jolt.
|
|
109
|
+
fontSize: 'var(--tree-font-size)',
|
|
110
|
+
height: 'calc(var(--tree-row-height) - 4px)',
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|