@adecore/ui 0.16.0 → 0.17.0-beta.2
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 +97 -8
- package/README.md +1 -1
- package/dist/Select.js +2 -1
- package/dist/file-tree/helpers.d.ts +42 -0
- package/dist/file-tree/helpers.js +167 -0
- package/dist/file-tree/index.parts.d.ts +3 -0
- package/dist/file-tree/index.parts.js +3 -0
- package/dist/file-tree/parts.d.ts +20 -0
- package/dist/file-tree/parts.js +150 -0
- package/dist/file-tree/shift.d.ts +15 -0
- package/dist/file-tree/shift.js +34 -0
- package/dist/file-tree/style.d.ts +1 -0
- package/dist/file-tree/style.js +24 -0
- package/dist/file-tree/use-file-tree-shift.d.ts +6 -0
- package/dist/file-tree/use-file-tree-shift.js +142 -0
- package/dist/file-tree/use-file-tree.d.ts +5 -0
- package/dist/file-tree/use-file-tree.js +26 -0
- package/dist/file-tree/use-slots.d.ts +7 -0
- package/dist/file-tree/use-slots.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/locales/en.json +5 -0
- package/dist/locales/nl.json +5 -0
- package/dist/menu/parts.js +2 -1
- package/dist/popover/parts.js +2 -1
- package/dist/popup-layer.d.ts +6 -0
- package/dist/popup-layer.js +30 -0
- package/dist/theme.css +104 -1
- package/dist/tree/index.parts.d.ts +1 -0
- package/dist/tree/index.parts.js +1 -0
- package/dist/tree/parts.d.ts +27 -0
- package/dist/tree/parts.js +113 -0
- package/dist/tree/style.d.ts +2 -0
- package/dist/tree/style.js +29 -0
- package/package.json +6 -4
- package/src/Select.tsx +9 -1
- package/src/file-tree/helpers.ts +203 -0
- package/src/file-tree/index.parts.ts +25 -0
- package/src/file-tree/parts.tsx +206 -0
- package/src/file-tree/shift.ts +48 -0
- package/src/file-tree/style.ts +25 -0
- package/src/file-tree/use-file-tree-shift.tsx +169 -0
- package/src/file-tree/use-file-tree.ts +31 -0
- package/src/file-tree/use-slots.ts +191 -0
- package/src/index.ts +17 -0
- package/src/locales/en.json +5 -0
- package/src/locales/nl.json +5 -0
- package/src/menu/parts.tsx +2 -0
- package/src/popover/parts.tsx +2 -0
- package/src/popup-layer.ts +34 -0
- package/src/theme.css +104 -1
- package/src/tree/index.parts.ts +10 -0
- package/src/tree/parts.tsx +170 -0
- package/src/tree/style.ts +30 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
4
|
+
import { ChevronRight } from 'lucide-react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Checkbox as UICheckbox } from '../Checkbox.js';
|
|
7
|
+
import { Icon } from '../Icon.js';
|
|
8
|
+
import { treeRowStyle } from './style.js';
|
|
9
|
+
export function TreeRoot({ render, className, ref, children, ...props }) {
|
|
10
|
+
return useRender({
|
|
11
|
+
render,
|
|
12
|
+
ref,
|
|
13
|
+
defaultTagName: 'div',
|
|
14
|
+
props: {
|
|
15
|
+
role: 'tree',
|
|
16
|
+
...props,
|
|
17
|
+
className: clsx('adecore-tree', className),
|
|
18
|
+
children: (_jsxs(_Fragment, { children: [_jsx("style", { children: treeRowStyle('.adecore-tree-row') }), children] }))
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function TreeRow({ level = 1, selected = false, joinedStart = false, joinedEnd = false, interactive = true, render, className, ref, style, children, ...props }) {
|
|
23
|
+
return useRender({
|
|
24
|
+
render,
|
|
25
|
+
ref,
|
|
26
|
+
defaultTagName: 'div',
|
|
27
|
+
props: {
|
|
28
|
+
role: 'treeitem',
|
|
29
|
+
'aria-level': level,
|
|
30
|
+
'aria-selected': selected,
|
|
31
|
+
...props,
|
|
32
|
+
'data-selected': selected,
|
|
33
|
+
'data-joined-start': joinedStart,
|
|
34
|
+
'data-joined-end': joinedEnd,
|
|
35
|
+
'data-interactive': interactive,
|
|
36
|
+
className: clsx('adecore-tree-row', className),
|
|
37
|
+
style: { paddingInlineStart: 4 + (Math.max(1, level) - 1) * 16, ...style },
|
|
38
|
+
children: (_jsxs(_Fragment, { children: [Array.from({ length: Math.max(0, level - 1) }, (_, index) => (_jsx("span", { "aria-hidden": true, className: "adecore-tree-guide", style: { insetInlineStart: 11 + index * 16 } }, index))), children] }))
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function TreeChevronSlot({ render, className, ref, ...props }) {
|
|
43
|
+
return useRender({
|
|
44
|
+
render,
|
|
45
|
+
ref,
|
|
46
|
+
defaultTagName: 'span',
|
|
47
|
+
props: { 'aria-hidden': true, ...props, className: clsx('adecore-tree-chevron-slot', className) }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function TreeChevron({ expanded, onExpandedChange, render, className, ref, onClick, onDoubleClick, ...props }) {
|
|
51
|
+
const { t } = useTranslation('ui');
|
|
52
|
+
return useRender({
|
|
53
|
+
render,
|
|
54
|
+
ref,
|
|
55
|
+
defaultTagName: 'button',
|
|
56
|
+
props: {
|
|
57
|
+
type: 'button',
|
|
58
|
+
tabIndex: -1,
|
|
59
|
+
'aria-label': t(expanded ? 'tree.collapse' : 'tree.expand'),
|
|
60
|
+
...props,
|
|
61
|
+
className: clsx('adecore-tree-chevron-slot', className),
|
|
62
|
+
onClick: (event) => {
|
|
63
|
+
event.stopPropagation();
|
|
64
|
+
onClick?.(event);
|
|
65
|
+
if (!event.defaultPrevented) {
|
|
66
|
+
onExpandedChange?.(!expanded);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
onDoubleClick: (event) => {
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
onDoubleClick?.(event);
|
|
72
|
+
},
|
|
73
|
+
children: _jsx(Icon, { icon: ChevronRight, size: 12, className: expanded ? 'rotate-90' : undefined })
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export function TreeLabel({ render, className, ref, ...props }) {
|
|
78
|
+
return useRender({ render, ref, defaultTagName: 'span', props: { ...props, className: clsx('adecore-tree-label', className) } });
|
|
79
|
+
}
|
|
80
|
+
export function TreeDecoration({ render, className, ref, ...props }) {
|
|
81
|
+
return useRender({ render, ref, defaultTagName: 'span', props: { ...props, className: clsx('adecore-tree-decoration', className) } });
|
|
82
|
+
}
|
|
83
|
+
export function TreeControl({ render, className, ref, onClick, onDoubleClick, onKeyDown, onPointerDown, ...props }) {
|
|
84
|
+
return useRender({
|
|
85
|
+
render,
|
|
86
|
+
ref,
|
|
87
|
+
defaultTagName: 'span',
|
|
88
|
+
props: {
|
|
89
|
+
...props,
|
|
90
|
+
'data-tree-control': true,
|
|
91
|
+
className: clsx('adecore-tree-control', className),
|
|
92
|
+
onClick: (event) => {
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
onClick?.(event);
|
|
95
|
+
},
|
|
96
|
+
onDoubleClick: (event) => {
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
onDoubleClick?.(event);
|
|
99
|
+
},
|
|
100
|
+
onKeyDown: (event) => {
|
|
101
|
+
event.stopPropagation();
|
|
102
|
+
onKeyDown?.(event);
|
|
103
|
+
},
|
|
104
|
+
onPointerDown: (event) => {
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
onPointerDown?.(event);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function TreeCheckbox(props) {
|
|
112
|
+
return (_jsx(TreeControl, { children: _jsx(UICheckbox, { ...props }) }));
|
|
113
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const TREE_ROW_HEIGHT = 25;
|
|
2
|
+
export function treeRowStyle(selector, selected = 'data-selected') {
|
|
3
|
+
return `
|
|
4
|
+
${selector} {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
display: flex;
|
|
7
|
+
position: relative;
|
|
8
|
+
align-items: center;
|
|
9
|
+
height: var(--tree-row-height, 25px);
|
|
10
|
+
min-width: 0;
|
|
11
|
+
gap: var(--tree-row-gap, 6px);
|
|
12
|
+
border: 0;
|
|
13
|
+
border-radius: var(--tree-row-radius, 6px);
|
|
14
|
+
background: transparent;
|
|
15
|
+
background-clip: padding-box;
|
|
16
|
+
color: var(--text);
|
|
17
|
+
font-family: var(--font-sans);
|
|
18
|
+
font-size: var(--text-xs);
|
|
19
|
+
line-height: var(--tree-row-height, 25px);
|
|
20
|
+
text-align: start;
|
|
21
|
+
outline: none;
|
|
22
|
+
}
|
|
23
|
+
${selector}[data-interactive="true"]:hover { background: var(--surface-hover); }
|
|
24
|
+
${selector}[${selected}="true"] { background: var(--surface-active); color: var(--text); }
|
|
25
|
+
${selector}[data-joined-start="true"] { border-start-start-radius: 0; border-start-end-radius: 0; }
|
|
26
|
+
${selector}[data-joined-end="true"] { border-end-start-radius: 0; border-end-end-radius: 0; }
|
|
27
|
+
${selector}:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
28
|
+
`;
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0-beta.2",
|
|
4
4
|
"description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
|
|
5
|
-
"license": "MIT",
|
|
5
|
+
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.test.ts",
|
|
26
26
|
"!src/**/*.test.tsx",
|
|
27
|
-
"!src/**/__snapshots__"
|
|
27
|
+
"!src/**/__snapshots__",
|
|
28
|
+
"!src/file-tree/testing"
|
|
28
29
|
],
|
|
29
30
|
"exports": {
|
|
30
31
|
".": {
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"react": "^19.3.0",
|
|
88
89
|
"react-dom": "^19.3.0",
|
|
89
90
|
"react-i18next": "^17.0.15",
|
|
90
|
-
"typescript": "^7.0.2"
|
|
91
|
+
"typescript": "^7.0.2",
|
|
92
|
+
"@happy-dom/global-registrator": "^20.14.5"
|
|
91
93
|
}
|
|
92
94
|
}
|
package/src/Select.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { ChevronDown } from 'lucide-react';
|
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { Icon } from './Icon.tsx';
|
|
7
7
|
import { MENU_LABEL, MenuCheck } from './menu/parts.tsx';
|
|
8
|
+
import { renderPositioner } from './popup-layer.ts';
|
|
8
9
|
|
|
9
10
|
export interface SelectItem<T extends string> {
|
|
10
11
|
value: T;
|
|
@@ -113,7 +114,14 @@ export function Select<T extends string>({
|
|
|
113
114
|
</BaseSelect.Icon>
|
|
114
115
|
</BaseSelect.Trigger>
|
|
115
116
|
<BaseSelect.Portal>
|
|
116
|
-
<BaseSelect.Positioner
|
|
117
|
+
<BaseSelect.Positioner
|
|
118
|
+
className="popup-positioner"
|
|
119
|
+
render={renderPositioner}
|
|
120
|
+
side="bottom"
|
|
121
|
+
align={align}
|
|
122
|
+
sideOffset={6}
|
|
123
|
+
alignItemWithTrigger={false}
|
|
124
|
+
>
|
|
117
125
|
<BaseSelect.Popup className="menu-popup">
|
|
118
126
|
<BaseSelect.List className="max-h-80 overflow-auto">
|
|
119
127
|
{groups
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { FileTree, FileTreeDirectoryHandle, FileTreeVisibleRow } from '@pierre/trees';
|
|
2
|
+
|
|
3
|
+
// Expanded folders can reveal more flattened rows; cap custom-key cycles.
|
|
4
|
+
const EXPANSION_PASSES = 32;
|
|
5
|
+
|
|
6
|
+
export interface SortRow {
|
|
7
|
+
isDirectory: boolean;
|
|
8
|
+
segments: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function directoryHandle(model: FileTree, path: string): FileTreeDirectoryHandle | null {
|
|
12
|
+
const item = model.getItem(path);
|
|
13
|
+
return item?.isDirectory() ? (item as FileTreeDirectoryHandle) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function pathOfRow(row: FileTreeVisibleRow): string {
|
|
17
|
+
return row.isFlattened ? (row.flattenedSegments?.findLast((segment) => segment.isTerminal)?.path ?? row.path) : row.path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function visibleRows(model: FileTree): FileTreeVisibleRow[] {
|
|
21
|
+
return model.getVisibleRows(0, model.getVisibleCount()).map((row) => ({ ...row, path: pathOfRow(row) }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function applyExpansion(model: FileTree, collapsed: ReadonlySet<string>, keyOf?: FoldKeyOf): void {
|
|
25
|
+
for (let pass = 0; pass < EXPANSION_PASSES; pass++) {
|
|
26
|
+
const { collapse, expand } = expansionChanges(visibleRows(model), collapsed, keyOf);
|
|
27
|
+
if (collapse.length === 0 && expand.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const path of collapse) {
|
|
31
|
+
directoryHandle(model, path)?.collapse();
|
|
32
|
+
}
|
|
33
|
+
for (const path of expand) {
|
|
34
|
+
directoryHandle(model, path)?.expand();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resetExpandedPaths(model: FileTree, paths: readonly string[], expanded: ReadonlySet<string>): void {
|
|
40
|
+
model.resetPaths(paths, { initialExpandedPaths: [...expanded] });
|
|
41
|
+
// @pierre/trees beta.6 restores expansion with its default sort, so custom sorting needs a second pass.
|
|
42
|
+
for (const path of expanded) {
|
|
43
|
+
directoryHandle(model, path)?.expand();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rowPathOf(event: { nativeEvent: Event }): string | null {
|
|
48
|
+
for (const node of event.nativeEvent.composedPath()) {
|
|
49
|
+
const path = node instanceof HTMLElement ? node.dataset.itemPath : undefined;
|
|
50
|
+
if (path) {
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function menuTargetsOf(row: string, selected: readonly string[]): string[] {
|
|
58
|
+
return selected.includes(row) && selected.length > 1 ? [...selected] : [row];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function extendsSelection(event: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }): boolean {
|
|
62
|
+
return event.shiftKey || event.metaKey || event.ctrlKey;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FOCUS_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
|
66
|
+
|
|
67
|
+
export function movesFocus(event: { key: string; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean; altKey: boolean }): boolean {
|
|
68
|
+
return FOCUS_KEYS.has(event.key) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function selectOnly(model: FileTree, path: string | null): void {
|
|
72
|
+
for (const selected of model.getSelectedPaths()) {
|
|
73
|
+
if (selected !== path) {
|
|
74
|
+
model.getItem(selected)?.deselect();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const item = path === null ? null : model.getItem(path);
|
|
78
|
+
if (item && !item.isSelected()) {
|
|
79
|
+
item.select();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function followFocus(model: FileTree, onMoved?: (path: string) => void): () => void {
|
|
84
|
+
const from = model.getFocusedPath();
|
|
85
|
+
const timer = window.setTimeout(() => {
|
|
86
|
+
const path = model.getFocusedPath();
|
|
87
|
+
selectOnly(model, path);
|
|
88
|
+
if (path !== null && path !== from) {
|
|
89
|
+
onMoved?.(path);
|
|
90
|
+
}
|
|
91
|
+
}, 0);
|
|
92
|
+
return () => window.clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function focusRow(model: FileTree, path: string): boolean {
|
|
96
|
+
const selector = `[data-type="item"][data-item-path="${CSS.escape(path)}"]:not([data-item-parked="true"])`;
|
|
97
|
+
const row = model.getFileTreeContainer()?.shadowRoot?.querySelector<HTMLElement>(selector) ?? null;
|
|
98
|
+
row?.focus();
|
|
99
|
+
return row !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ancestorDirsOf(treePath: string): string[] {
|
|
103
|
+
const segments = treePath.split('/').filter((segment) => segment !== '');
|
|
104
|
+
segments.pop();
|
|
105
|
+
const dirs: string[] = [];
|
|
106
|
+
let prefix = '';
|
|
107
|
+
for (const segment of segments) {
|
|
108
|
+
prefix = `${prefix}${segment}/`;
|
|
109
|
+
dirs.push(prefix);
|
|
110
|
+
}
|
|
111
|
+
return dirs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function withoutClosedBranches(open: ReadonlySet<string>, known: ReadonlySet<string>): Set<string> {
|
|
115
|
+
return new Set([...open].filter((path) => ancestorDirsOf(path).every((dir) => open.has(dir) || !known.has(dir))));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function newlyExpanded(before: ReadonlySet<string>, after: ReadonlySet<string>): string[] {
|
|
119
|
+
return [...after].filter((path) => !before.has(path));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function mergeExpanded(remembered: ReadonlySet<string>, reported: ReadonlySet<string>, known: ReadonlySet<string>): Set<string> {
|
|
123
|
+
const merged = new Set(reported);
|
|
124
|
+
for (const path of remembered) {
|
|
125
|
+
if (!known.has(path)) {
|
|
126
|
+
merged.add(path);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function compareRows(left: SortRow, right: SortRow): number {
|
|
133
|
+
const shared = Math.min(left.segments.length, right.segments.length);
|
|
134
|
+
for (let i = 0; i < shared; i++) {
|
|
135
|
+
const leftSegment = left.segments[i]!;
|
|
136
|
+
const rightSegment = right.segments[i]!;
|
|
137
|
+
if (leftSegment === rightSegment) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const leftIsDirectory = left.isDirectory || i < left.segments.length - 1;
|
|
141
|
+
const rightIsDirectory = right.isDirectory || i < right.segments.length - 1;
|
|
142
|
+
if (leftIsDirectory !== rightIsDirectory) {
|
|
143
|
+
return leftIsDirectory ? -1 : 1;
|
|
144
|
+
}
|
|
145
|
+
// Case is not a reason to split two names apart; the raw compare only breaks a tie.
|
|
146
|
+
return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
|
|
147
|
+
}
|
|
148
|
+
// One path is the head of the other, so the shorter one is the directory the longer one sits in.
|
|
149
|
+
return left.segments.length - right.segments.length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function dirPathOf(rowPath: string): string {
|
|
153
|
+
return rowPath.endsWith('/') ? rowPath.slice(0, -1) : rowPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type FoldKeyOf = (rowPath: string) => string | null;
|
|
157
|
+
|
|
158
|
+
function plainKey(rowPath: string): string | null {
|
|
159
|
+
return dirPathOf(rowPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function collapsedPathsOf(rows: readonly FileTreeVisibleRow[], keyOf: FoldKeyOf = plainKey): string[] {
|
|
163
|
+
return rows.flatMap((row) => {
|
|
164
|
+
const key = row.kind === 'directory' && !row.isExpanded ? keyOf(row.path) : null;
|
|
165
|
+
return key === null ? [] : [key];
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function mergeCollapsedPaths(current: string[], rows: readonly FileTreeVisibleRow[], keyOf: FoldKeyOf = plainKey): string[] {
|
|
170
|
+
const known = new Set(rows.flatMap((row) => (row.kind === 'directory' ? [keyOf(row.path)] : [])));
|
|
171
|
+
const folded = new Set(collapsedPathsOf(rows, keyOf));
|
|
172
|
+
const next = current.filter((dir) => !known.has(dir) || folded.has(dir));
|
|
173
|
+
const existing = new Set(current);
|
|
174
|
+
for (const dir of folded) {
|
|
175
|
+
if (!existing.has(dir)) {
|
|
176
|
+
next.push(dir);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Selection also notifies subscribers; unchanged folds must not restart the shared-state cycle.
|
|
180
|
+
return next.length === current.length && next.every((dir, index) => dir === current[index]) ? current : next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function expansionChanges(
|
|
184
|
+
rows: readonly FileTreeVisibleRow[],
|
|
185
|
+
collapsed: ReadonlySet<string>,
|
|
186
|
+
keyOf: FoldKeyOf = plainKey
|
|
187
|
+
): { collapse: string[]; expand: string[] } {
|
|
188
|
+
const changes: { collapse: string[]; expand: string[] } = { collapse: [], expand: [] };
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const key = row.kind === 'directory' ? keyOf(row.path) : null;
|
|
191
|
+
if (key === null) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const folded = collapsed.has(key);
|
|
195
|
+
if (folded && row.isExpanded) {
|
|
196
|
+
changes.collapse.push(row.path);
|
|
197
|
+
}
|
|
198
|
+
if (!folded && !row.isExpanded) {
|
|
199
|
+
changes.expand.push(row.path);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return changes;
|
|
203
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { FileTreeRoot as Root } from './parts.tsx';
|
|
2
|
+
export { TreeCheckbox as Checkbox, TreeControl as Control, TreeDecoration as Decoration } from '../tree/parts.tsx';
|
|
3
|
+
export {
|
|
4
|
+
ancestorDirsOf,
|
|
5
|
+
applyExpansion,
|
|
6
|
+
collapsedPathsOf,
|
|
7
|
+
compareRows,
|
|
8
|
+
directoryHandle,
|
|
9
|
+
dirPathOf,
|
|
10
|
+
expansionChanges,
|
|
11
|
+
extendsSelection,
|
|
12
|
+
focusRow,
|
|
13
|
+
followFocus,
|
|
14
|
+
menuTargetsOf,
|
|
15
|
+
mergeCollapsedPaths,
|
|
16
|
+
mergeExpanded,
|
|
17
|
+
movesFocus,
|
|
18
|
+
newlyExpanded,
|
|
19
|
+
pathOfRow,
|
|
20
|
+
resetExpandedPaths,
|
|
21
|
+
rowPathOf,
|
|
22
|
+
selectOnly,
|
|
23
|
+
visibleRows,
|
|
24
|
+
withoutClosedBranches
|
|
25
|
+
} from './helpers.ts';
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { useRender } from '@base-ui-components/react/use-render';
|
|
4
|
+
import { FileTree as PierreFileTree, type FileTreeProps as PierreProps } from '@pierre/trees/react';
|
|
5
|
+
import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import clsx from 'clsx';
|
|
8
|
+
import { mergeRefs } from '../merge-refs.ts';
|
|
9
|
+
import { directoryHandle, extendsSelection, followFocus, movesFocus, menuTargetsOf, rowPathOf, visibleRows } from './helpers.ts';
|
|
10
|
+
import { useFileTreeShift } from './use-file-tree-shift.tsx';
|
|
11
|
+
import { useFileTreeSlots } from './use-slots.ts';
|
|
12
|
+
|
|
13
|
+
export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
14
|
+
model: FileTree;
|
|
15
|
+
label?: string;
|
|
16
|
+
resetKey?: string;
|
|
17
|
+
selectionFollowsFocus?: boolean;
|
|
18
|
+
onActivate?(path: string): void;
|
|
19
|
+
onFocusMove?(path: string): void;
|
|
20
|
+
onLoadChildren?(path: string): void;
|
|
21
|
+
onRowContextMenu?(path: string, targets: readonly string[], event: MouseEvent): void;
|
|
22
|
+
onRowDragStart?(path: string, targets: readonly string[], event: DragEvent): void;
|
|
23
|
+
onExpandedPathsChange?(paths: readonly string[]): void;
|
|
24
|
+
renderControl?(row: FileTreeVisibleRow): ReactNode;
|
|
25
|
+
renderDecoration?(row: FileTreeVisibleRow): ReactNode;
|
|
26
|
+
treeProps?: Omit<PierreProps, 'model'>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function inSlot(event: { nativeEvent: Event }): boolean {
|
|
30
|
+
return event.nativeEvent.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute('data-tree-slot'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function FileTreeRoot({
|
|
34
|
+
model,
|
|
35
|
+
label,
|
|
36
|
+
resetKey,
|
|
37
|
+
selectionFollowsFocus = true,
|
|
38
|
+
onActivate,
|
|
39
|
+
onFocusMove,
|
|
40
|
+
onLoadChildren,
|
|
41
|
+
onExpandedPathsChange,
|
|
42
|
+
onRowContextMenu,
|
|
43
|
+
onRowDragStart,
|
|
44
|
+
renderControl,
|
|
45
|
+
renderDecoration,
|
|
46
|
+
treeProps,
|
|
47
|
+
render,
|
|
48
|
+
className,
|
|
49
|
+
ref,
|
|
50
|
+
children,
|
|
51
|
+
onClick,
|
|
52
|
+
onKeyDownCapture,
|
|
53
|
+
...props
|
|
54
|
+
}: FileTreeRootProps) {
|
|
55
|
+
const { t } = useTranslation('ui');
|
|
56
|
+
const [frame, setFrame] = useState<HTMLDivElement | null>(null);
|
|
57
|
+
const { attach, bar } = useFileTreeShift(model, resetKey);
|
|
58
|
+
const frameRef = useCallback(
|
|
59
|
+
(node: Element | null) => {
|
|
60
|
+
setFrame(node as HTMLDivElement | null);
|
|
61
|
+
attach(node as HTMLElement | null);
|
|
62
|
+
mergeRefs<Element>(ref)(node);
|
|
63
|
+
},
|
|
64
|
+
[ref, attach]
|
|
65
|
+
);
|
|
66
|
+
const slots = useFileTreeSlots(model, frame, label ?? t('tree.label'));
|
|
67
|
+
const latestLoads = useRef({ onLoadChildren, onExpandedPathsChange });
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
latestLoads.current = { onLoadChildren, onExpandedPathsChange };
|
|
70
|
+
}, [onLoadChildren, onExpandedPathsChange]);
|
|
71
|
+
const cancelFocus = useRef<(() => void) | undefined>(undefined);
|
|
72
|
+
|
|
73
|
+
useEffect(() => () => cancelFocus.current?.(), [model]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
let previous = new Set<string>();
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let pending = false;
|
|
79
|
+
const report = (): void => {
|
|
80
|
+
pending = false;
|
|
81
|
+
if (disposed) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const rows = visibleRows(model).filter((row) => row.kind === 'directory');
|
|
85
|
+
const known = new Set(rows.map((row) => row.path));
|
|
86
|
+
const expanded = new Set([...previous].filter((path) => !known.has(path)));
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
if (row.isExpanded) {
|
|
89
|
+
expanded.add(row.path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const opened = [...expanded].filter((path) => !previous.has(path));
|
|
93
|
+
const changed = opened.length > 0 || [...previous].some((path) => !expanded.has(path));
|
|
94
|
+
previous = expanded;
|
|
95
|
+
if (changed) {
|
|
96
|
+
latestLoads.current.onExpandedPathsChange?.([...expanded]);
|
|
97
|
+
}
|
|
98
|
+
for (const path of opened) {
|
|
99
|
+
latestLoads.current.onLoadChildren?.(path);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const schedule = (): void => {
|
|
103
|
+
if (!pending) {
|
|
104
|
+
pending = true;
|
|
105
|
+
queueMicrotask(report);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const unsubscribe = model.subscribe(schedule);
|
|
109
|
+
report();
|
|
110
|
+
return () => {
|
|
111
|
+
disposed = true;
|
|
112
|
+
unsubscribe();
|
|
113
|
+
};
|
|
114
|
+
}, [model]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (frame === null) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const onClickRow = (event: MouseEvent): void => {
|
|
121
|
+
if (event.defaultPrevented || inSlot({ nativeEvent: event }) || extendsSelection(event)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const path = rowPathOf({ nativeEvent: event });
|
|
125
|
+
if (path !== null && directoryHandle(model, path) === null) {
|
|
126
|
+
onActivate?.(path);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
130
|
+
if (event.defaultPrevented || inSlot({ nativeEvent: event })) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (movesFocus(event) && selectionFollowsFocus) {
|
|
134
|
+
cancelFocus.current?.();
|
|
135
|
+
// beta.6 stops the key in its own handler; selection must follow after that handler moves focus.
|
|
136
|
+
cancelFocus.current = followFocus(model, onFocusMove);
|
|
137
|
+
} else if (event.key === 'Enter' && !event.altKey && !event.metaKey && !event.ctrlKey) {
|
|
138
|
+
const path = model.getFocusedPath();
|
|
139
|
+
if (path === null) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
const directory = directoryHandle(model, path);
|
|
145
|
+
if (directory) {
|
|
146
|
+
directory.toggle();
|
|
147
|
+
} else {
|
|
148
|
+
onActivate?.(path);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const onContext = (event: MouseEvent): void => {
|
|
153
|
+
if (inSlot({ nativeEvent: event })) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const path = rowPathOf({ nativeEvent: event });
|
|
157
|
+
if (path !== null) {
|
|
158
|
+
onRowContextMenu?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const onDrag = (event: DragEvent): void => {
|
|
162
|
+
if (inSlot({ nativeEvent: event })) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const path = rowPathOf({ nativeEvent: event });
|
|
166
|
+
if (path !== null) {
|
|
167
|
+
onRowDragStart?.(path, menuTargetsOf(path, model.getSelectedPaths()), event);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
frame.addEventListener('click', onClickRow);
|
|
171
|
+
frame.addEventListener('keydown', onKey, true);
|
|
172
|
+
frame.addEventListener('contextmenu', onContext);
|
|
173
|
+
frame.addEventListener('dragstart', onDrag);
|
|
174
|
+
return () => {
|
|
175
|
+
frame.removeEventListener('click', onClickRow);
|
|
176
|
+
frame.removeEventListener('keydown', onKey, true);
|
|
177
|
+
frame.removeEventListener('contextmenu', onContext);
|
|
178
|
+
frame.removeEventListener('dragstart', onDrag);
|
|
179
|
+
};
|
|
180
|
+
}, [frame, model, selectionFollowsFocus, onActivate, onFocusMove, onRowContextMenu, onRowDragStart]);
|
|
181
|
+
|
|
182
|
+
return useRender({
|
|
183
|
+
render,
|
|
184
|
+
ref: frameRef,
|
|
185
|
+
defaultTagName: 'div',
|
|
186
|
+
props: {
|
|
187
|
+
...props,
|
|
188
|
+
className: clsx('adecore-tree adecore-file-tree', className),
|
|
189
|
+
onClick,
|
|
190
|
+
onKeyDownCapture,
|
|
191
|
+
children: (
|
|
192
|
+
<>
|
|
193
|
+
<PierreFileTree {...treeProps} model={model} className={clsx('adecore-file-tree-host', treeProps?.className)} />
|
|
194
|
+
{slots.map(({ row, control, decoration }) => (
|
|
195
|
+
<span key={control.slot} style={{ display: 'contents' }}>
|
|
196
|
+
{createPortal(renderControl?.(row), control)}
|
|
197
|
+
{createPortal(renderDecoration?.(row), decoration)}
|
|
198
|
+
</span>
|
|
199
|
+
))}
|
|
200
|
+
{children}
|
|
201
|
+
{bar}
|
|
202
|
+
</>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const SHIFT_PROPERTY = '--adecore-tree-shift';
|
|
2
|
+
|
|
3
|
+
const LINE_WIDTH = 16;
|
|
4
|
+
|
|
5
|
+
const MIN_THUMB = 24;
|
|
6
|
+
|
|
7
|
+
export function clampShift(value: number, max: number): number {
|
|
8
|
+
return Math.min(Math.max(value, 0), Math.max(max, 0));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function shiftNeed(textRight: number, limit: number, shift: number): number {
|
|
12
|
+
return Math.max(0, Math.ceil(textRight + shift - limit));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function maxShift(needs: readonly number[]): number {
|
|
16
|
+
return needs.reduce((max, need) => Math.max(max, need), 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WheelStep {
|
|
20
|
+
readonly deltaX: number;
|
|
21
|
+
readonly deltaY: number;
|
|
22
|
+
readonly deltaMode: number;
|
|
23
|
+
readonly shiftKey: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sidewaysDelta(step: WheelStep, pageWidth: number): number {
|
|
27
|
+
const swapped = step.shiftKey && step.deltaX === 0;
|
|
28
|
+
const sideways = swapped ? step.deltaY : step.deltaX;
|
|
29
|
+
const across = swapped ? 0 : step.deltaY;
|
|
30
|
+
if (sideways === 0 || Math.abs(sideways) <= Math.abs(across)) {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
if (step.deltaMode === 1) {
|
|
34
|
+
return sideways * LINE_WIDTH;
|
|
35
|
+
}
|
|
36
|
+
if (step.deltaMode === 2) {
|
|
37
|
+
return sideways * pageWidth;
|
|
38
|
+
}
|
|
39
|
+
return sideways;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shiftThumb(shift: number, max: number, track: number, view: number): { left: number; width: number } {
|
|
43
|
+
if (max <= 0 || track <= 0) {
|
|
44
|
+
return { left: 0, width: Math.max(track, 0) };
|
|
45
|
+
}
|
|
46
|
+
const width = Math.min(track, Math.max(MIN_THUMB, Math.round((track * view) / (view + max))));
|
|
47
|
+
return { left: Math.round(((track - width) * clampShift(shift, max)) / max), width };
|
|
48
|
+
}
|