@adecore/ui 0.16.0 → 0.17.0-beta.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.
Files changed (44) hide show
  1. package/dist/file-tree/helpers.d.ts +42 -0
  2. package/dist/file-tree/helpers.js +167 -0
  3. package/dist/file-tree/index.parts.d.ts +3 -0
  4. package/dist/file-tree/index.parts.js +3 -0
  5. package/dist/file-tree/parts.d.ts +20 -0
  6. package/dist/file-tree/parts.js +150 -0
  7. package/dist/file-tree/shift.d.ts +15 -0
  8. package/dist/file-tree/shift.js +35 -0
  9. package/dist/file-tree/style.d.ts +1 -0
  10. package/dist/file-tree/style.js +25 -0
  11. package/dist/file-tree/use-file-tree-shift.d.ts +6 -0
  12. package/dist/file-tree/use-file-tree-shift.js +143 -0
  13. package/dist/file-tree/use-file-tree.d.ts +5 -0
  14. package/dist/file-tree/use-file-tree.js +26 -0
  15. package/dist/file-tree/use-slots.d.ts +7 -0
  16. package/dist/file-tree/use-slots.js +171 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +3 -0
  19. package/dist/locales/en.json +5 -0
  20. package/dist/locales/nl.json +5 -0
  21. package/dist/theme.css +103 -0
  22. package/dist/tree/index.parts.d.ts +1 -0
  23. package/dist/tree/index.parts.js +1 -0
  24. package/dist/tree/parts.d.ts +27 -0
  25. package/dist/tree/parts.js +113 -0
  26. package/dist/tree/style.d.ts +2 -0
  27. package/dist/tree/style.js +29 -0
  28. package/package.json +6 -4
  29. package/src/file-tree/LICENSE +110 -0
  30. package/src/file-tree/helpers.ts +204 -0
  31. package/src/file-tree/index.parts.ts +25 -0
  32. package/src/file-tree/parts.tsx +206 -0
  33. package/src/file-tree/shift.ts +49 -0
  34. package/src/file-tree/style.ts +26 -0
  35. package/src/file-tree/use-file-tree-shift.tsx +170 -0
  36. package/src/file-tree/use-file-tree.ts +31 -0
  37. package/src/file-tree/use-slots.ts +191 -0
  38. package/src/index.ts +17 -0
  39. package/src/locales/en.json +5 -0
  40. package/src/locales/nl.json +5 -0
  41. package/src/theme.css +103 -0
  42. package/src/tree/index.parts.ts +10 -0
  43. package/src/tree/parts.tsx +170 -0
  44. package/src/tree/style.ts +30 -0
@@ -0,0 +1,204 @@
1
+ // SPDX-License-Identifier: FSL-1.1-MIT
2
+ import type { FileTree, FileTreeDirectoryHandle, FileTreeVisibleRow } from '@pierre/trees';
3
+
4
+ // Expanded folders can reveal more flattened rows; cap custom-key cycles.
5
+ const EXPANSION_PASSES = 32;
6
+
7
+ export interface SortRow {
8
+ isDirectory: boolean;
9
+ segments: readonly string[];
10
+ }
11
+
12
+ export function directoryHandle(model: FileTree, path: string): FileTreeDirectoryHandle | null {
13
+ const item = model.getItem(path);
14
+ return item?.isDirectory() ? (item as FileTreeDirectoryHandle) : null;
15
+ }
16
+
17
+ export function pathOfRow(row: FileTreeVisibleRow): string {
18
+ return row.isFlattened ? (row.flattenedSegments?.findLast((segment) => segment.isTerminal)?.path ?? row.path) : row.path;
19
+ }
20
+
21
+ export function visibleRows(model: FileTree): FileTreeVisibleRow[] {
22
+ return model.getVisibleRows(0, model.getVisibleCount()).map((row) => ({ ...row, path: pathOfRow(row) }));
23
+ }
24
+
25
+ export function applyExpansion(model: FileTree, collapsed: ReadonlySet<string>, keyOf?: FoldKeyOf): void {
26
+ for (let pass = 0; pass < EXPANSION_PASSES; pass++) {
27
+ const { collapse, expand } = expansionChanges(visibleRows(model), collapsed, keyOf);
28
+ if (collapse.length === 0 && expand.length === 0) {
29
+ return;
30
+ }
31
+ for (const path of collapse) {
32
+ directoryHandle(model, path)?.collapse();
33
+ }
34
+ for (const path of expand) {
35
+ directoryHandle(model, path)?.expand();
36
+ }
37
+ }
38
+ }
39
+
40
+ export function resetExpandedPaths(model: FileTree, paths: readonly string[], expanded: ReadonlySet<string>): void {
41
+ model.resetPaths(paths, { initialExpandedPaths: [...expanded] });
42
+ // @pierre/trees beta.6 restores expansion with its default sort, so custom sorting needs a second pass.
43
+ for (const path of expanded) {
44
+ directoryHandle(model, path)?.expand();
45
+ }
46
+ }
47
+
48
+ export function rowPathOf(event: { nativeEvent: Event }): string | null {
49
+ for (const node of event.nativeEvent.composedPath()) {
50
+ const path = node instanceof HTMLElement ? node.dataset.itemPath : undefined;
51
+ if (path) {
52
+ return path;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export function menuTargetsOf(row: string, selected: readonly string[]): string[] {
59
+ return selected.includes(row) && selected.length > 1 ? [...selected] : [row];
60
+ }
61
+
62
+ export function extendsSelection(event: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }): boolean {
63
+ return event.shiftKey || event.metaKey || event.ctrlKey;
64
+ }
65
+
66
+ const FOCUS_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']);
67
+
68
+ export function movesFocus(event: { key: string; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean; altKey: boolean }): boolean {
69
+ return FOCUS_KEYS.has(event.key) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey;
70
+ }
71
+
72
+ export function selectOnly(model: FileTree, path: string | null): void {
73
+ for (const selected of model.getSelectedPaths()) {
74
+ if (selected !== path) {
75
+ model.getItem(selected)?.deselect();
76
+ }
77
+ }
78
+ const item = path === null ? null : model.getItem(path);
79
+ if (item && !item.isSelected()) {
80
+ item.select();
81
+ }
82
+ }
83
+
84
+ export function followFocus(model: FileTree, onMoved?: (path: string) => void): () => void {
85
+ const from = model.getFocusedPath();
86
+ const timer = window.setTimeout(() => {
87
+ const path = model.getFocusedPath();
88
+ selectOnly(model, path);
89
+ if (path !== null && path !== from) {
90
+ onMoved?.(path);
91
+ }
92
+ }, 0);
93
+ return () => window.clearTimeout(timer);
94
+ }
95
+
96
+ export function focusRow(model: FileTree, path: string): boolean {
97
+ const selector = `[data-type="item"][data-item-path="${CSS.escape(path)}"]:not([data-item-parked="true"])`;
98
+ const row = model.getFileTreeContainer()?.shadowRoot?.querySelector<HTMLElement>(selector) ?? null;
99
+ row?.focus();
100
+ return row !== null;
101
+ }
102
+
103
+ export function ancestorDirsOf(treePath: string): string[] {
104
+ const segments = treePath.split('/').filter((segment) => segment !== '');
105
+ segments.pop();
106
+ const dirs: string[] = [];
107
+ let prefix = '';
108
+ for (const segment of segments) {
109
+ prefix = `${prefix}${segment}/`;
110
+ dirs.push(prefix);
111
+ }
112
+ return dirs;
113
+ }
114
+
115
+ export function withoutClosedBranches(open: ReadonlySet<string>, known: ReadonlySet<string>): Set<string> {
116
+ return new Set([...open].filter((path) => ancestorDirsOf(path).every((dir) => open.has(dir) || !known.has(dir))));
117
+ }
118
+
119
+ export function newlyExpanded(before: ReadonlySet<string>, after: ReadonlySet<string>): string[] {
120
+ return [...after].filter((path) => !before.has(path));
121
+ }
122
+
123
+ export function mergeExpanded(remembered: ReadonlySet<string>, reported: ReadonlySet<string>, known: ReadonlySet<string>): Set<string> {
124
+ const merged = new Set(reported);
125
+ for (const path of remembered) {
126
+ if (!known.has(path)) {
127
+ merged.add(path);
128
+ }
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ export function compareRows(left: SortRow, right: SortRow): number {
134
+ const shared = Math.min(left.segments.length, right.segments.length);
135
+ for (let i = 0; i < shared; i++) {
136
+ const leftSegment = left.segments[i]!;
137
+ const rightSegment = right.segments[i]!;
138
+ if (leftSegment === rightSegment) {
139
+ continue;
140
+ }
141
+ const leftIsDirectory = left.isDirectory || i < left.segments.length - 1;
142
+ const rightIsDirectory = right.isDirectory || i < right.segments.length - 1;
143
+ if (leftIsDirectory !== rightIsDirectory) {
144
+ return leftIsDirectory ? -1 : 1;
145
+ }
146
+ // Case is not a reason to split two names apart; the raw compare only breaks a tie.
147
+ return leftSegment.localeCompare(rightSegment, undefined, { numeric: true, sensitivity: 'base' }) || leftSegment.localeCompare(rightSegment);
148
+ }
149
+ // One path is the head of the other, so the shorter one is the directory the longer one sits in.
150
+ return left.segments.length - right.segments.length;
151
+ }
152
+
153
+ export function dirPathOf(rowPath: string): string {
154
+ return rowPath.endsWith('/') ? rowPath.slice(0, -1) : rowPath;
155
+ }
156
+
157
+ export type FoldKeyOf = (rowPath: string) => string | null;
158
+
159
+ function plainKey(rowPath: string): string | null {
160
+ return dirPathOf(rowPath);
161
+ }
162
+
163
+ export function collapsedPathsOf(rows: readonly FileTreeVisibleRow[], keyOf: FoldKeyOf = plainKey): string[] {
164
+ return rows.flatMap((row) => {
165
+ const key = row.kind === 'directory' && !row.isExpanded ? keyOf(row.path) : null;
166
+ return key === null ? [] : [key];
167
+ });
168
+ }
169
+
170
+ export function mergeCollapsedPaths(current: string[], rows: readonly FileTreeVisibleRow[], keyOf: FoldKeyOf = plainKey): string[] {
171
+ const known = new Set(rows.flatMap((row) => (row.kind === 'directory' ? [keyOf(row.path)] : [])));
172
+ const folded = new Set(collapsedPathsOf(rows, keyOf));
173
+ const next = current.filter((dir) => !known.has(dir) || folded.has(dir));
174
+ const existing = new Set(current);
175
+ for (const dir of folded) {
176
+ if (!existing.has(dir)) {
177
+ next.push(dir);
178
+ }
179
+ }
180
+ // Selection also notifies subscribers; unchanged folds must not restart the shared-state cycle.
181
+ return next.length === current.length && next.every((dir, index) => dir === current[index]) ? current : next;
182
+ }
183
+
184
+ export function expansionChanges(
185
+ rows: readonly FileTreeVisibleRow[],
186
+ collapsed: ReadonlySet<string>,
187
+ keyOf: FoldKeyOf = plainKey
188
+ ): { collapse: string[]; expand: string[] } {
189
+ const changes: { collapse: string[]; expand: string[] } = { collapse: [], expand: [] };
190
+ for (const row of rows) {
191
+ const key = row.kind === 'directory' ? keyOf(row.path) : null;
192
+ if (key === null) {
193
+ continue;
194
+ }
195
+ const folded = collapsed.has(key);
196
+ if (folded && row.isExpanded) {
197
+ changes.collapse.push(row.path);
198
+ }
199
+ if (!folded && !row.isExpanded) {
200
+ changes.expand.push(row.path);
201
+ }
202
+ }
203
+ return changes;
204
+ }
@@ -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,49 @@
1
+ // SPDX-License-Identifier: FSL-1.1-MIT
2
+ export const SHIFT_PROPERTY = '--adecore-tree-shift';
3
+
4
+ const LINE_WIDTH = 16;
5
+
6
+ const MIN_THUMB = 24;
7
+
8
+ export function clampShift(value: number, max: number): number {
9
+ return Math.min(Math.max(value, 0), Math.max(max, 0));
10
+ }
11
+
12
+ export function shiftNeed(textRight: number, limit: number, shift: number): number {
13
+ return Math.max(0, Math.ceil(textRight + shift - limit));
14
+ }
15
+
16
+ export function maxShift(needs: readonly number[]): number {
17
+ return needs.reduce((max, need) => Math.max(max, need), 0);
18
+ }
19
+
20
+ export interface WheelStep {
21
+ readonly deltaX: number;
22
+ readonly deltaY: number;
23
+ readonly deltaMode: number;
24
+ readonly shiftKey: boolean;
25
+ }
26
+
27
+ export function sidewaysDelta(step: WheelStep, pageWidth: number): number {
28
+ const swapped = step.shiftKey && step.deltaX === 0;
29
+ const sideways = swapped ? step.deltaY : step.deltaX;
30
+ const across = swapped ? 0 : step.deltaY;
31
+ if (sideways === 0 || Math.abs(sideways) <= Math.abs(across)) {
32
+ return 0;
33
+ }
34
+ if (step.deltaMode === 1) {
35
+ return sideways * LINE_WIDTH;
36
+ }
37
+ if (step.deltaMode === 2) {
38
+ return sideways * pageWidth;
39
+ }
40
+ return sideways;
41
+ }
42
+
43
+ export function shiftThumb(shift: number, max: number, track: number, view: number): { left: number; width: number } {
44
+ if (max <= 0 || track <= 0) {
45
+ return { left: 0, width: Math.max(track, 0) };
46
+ }
47
+ const width = Math.min(track, Math.max(MIN_THUMB, Math.round((track * view) / (view + max))));
48
+ return { left: Math.round(((track - width) * clampShift(shift, max)) / max), width };
49
+ }
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: FSL-1.1-MIT
2
+ import { treeRowStyle } from '../tree/style.ts';
3
+
4
+ // beta.6 splits the extension into nested grids; flatten them to truncate the end of the entire name.
5
+ export const FILE_TREE_CSS = `
6
+ ${treeRowStyle('[data-type="item"]', 'data-item-selected')}
7
+ [data-type="item"] { overflow: clip; padding-inline: 4px 8px; --trees-item-row-gap: var(--tree-row-gap, 6px); }
8
+ [data-type="item"]::after {
9
+ content: "";
10
+ flex: none;
11
+ order: -100;
12
+ margin-inline: calc(var(--adecore-tree-shift, 0px) * -1) calc(var(--trees-item-row-gap) * -1);
13
+ }
14
+ [data-icon-name="file-tree-icon-chevron"] { width: 12px; height: 12px; }
15
+ [data-item-section="spacing"] { padding: 0; margin-inline-end: -6px; }
16
+ [data-item-section="spacing-item"] { position: relative; flex: none; width: 16px; margin: 0; border: 0; background: transparent; opacity: 1; transform: none; transition: none; }
17
+ [data-item-section="spacing-item"] + [data-item-section="spacing-item"] { margin: 0; }
18
+ [data-item-section="spacing-item"]::before { content: ""; position: absolute; inset-block: 0; inset-inline-start: 7px; width: 1px; background: var(--border); }
19
+ [data-item-section="icon"] { width: 16px; margin-inline-start: var(--adecore-tree-control-space, 0px); }
20
+ [data-item-section="content"] { white-space: nowrap; max-width: none; flex: 1 1 auto; margin-inline-end: var(--adecore-tree-decoration-space, 0px); }
21
+ [data-item-section="content"] :where([data-truncate-group-container], [data-truncate-group-container] div, [data-truncate-container], [data-truncate-container] div) { display: inline; }
22
+ [data-item-section="content"] [data-truncate-content] { direction: ltr; }
23
+ [data-item-section="content"] :where([data-truncate-content="overflow"], [data-truncate-marker-cell], [data-truncate-fill]) { display: none; }
24
+ [data-tree-port] { position: absolute; z-index: 4; display: flex; align-items: center; height: 25px; pointer-events: none; }
25
+ [data-tree-port] > slot { pointer-events: auto; }
26
+ `;
@@ -0,0 +1,170 @@
1
+ // SPDX-License-Identifier: FSL-1.1-MIT
2
+ import { useEffect, useState, type ReactElement } from 'react';
3
+ import type { FileTree } from '@pierre/trees';
4
+ import { clampShift, maxShift, SHIFT_PROPERTY, shiftNeed, shiftThumb, sidewaysDelta } from './shift.ts';
5
+
6
+ const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
7
+
8
+ const HIDE_AFTER = 900;
9
+
10
+ const NEAR_BOTTOM = 16;
11
+
12
+ function boxesOf(element: Element): Element[] {
13
+ return element.getClientRects().length > 0 ? [element] : [...element.children].flatMap((child) => boxesOf(child));
14
+ }
15
+
16
+ function limitOf(row: HTMLElement, content: Element, gap: number): number {
17
+ const start = content.getBoundingClientRect().left;
18
+ let limit = Number.POSITIVE_INFINITY;
19
+ for (const part of [...row.children].filter((child) => child !== content).flatMap((child) => boxesOf(child))) {
20
+ const rect = part.getBoundingClientRect();
21
+ if (rect.width === 0 || rect.left < start) {
22
+ continue;
23
+ }
24
+ if (part.childNodes.length === 0 && Number.parseFloat(getComputedStyle(part).flexGrow) > 0) {
25
+ continue;
26
+ }
27
+ limit = Math.min(limit, rect.left - gap);
28
+ }
29
+ if (limit !== Number.POSITIVE_INFINITY) {
30
+ return limit - (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0);
31
+ }
32
+ return (
33
+ row.getBoundingClientRect().right -
34
+ Number.parseFloat(getComputedStyle(row).paddingInlineEnd) -
35
+ (Number.parseFloat(getComputedStyle(content).marginInlineEnd) || 0)
36
+ );
37
+ }
38
+
39
+ function needOf(row: HTMLElement, shift: number, gap: number, range: Range): number | null {
40
+ const content = row.querySelector(':scope > [data-item-section="content"]');
41
+ if (content === null || row.getBoundingClientRect().width === 0) {
42
+ return null;
43
+ }
44
+ range.selectNodeContents(content);
45
+ return shiftNeed(range.getBoundingClientRect().right, limitOf(row, content, gap), shift);
46
+ }
47
+
48
+ export function useFileTreeShift(model: FileTree, resetKey?: string): { attach(node: HTMLElement | null): void; bar: ReactElement } {
49
+ const [frame, setFrame] = useState<HTMLElement | null>(null);
50
+ const [track, setTrack] = useState<HTMLDivElement | null>(null);
51
+ const [thumb, setThumb] = useState<HTMLDivElement | null>(null);
52
+
53
+ useEffect(() => {
54
+ if (frame === null || track === null || thumb === null) {
55
+ return;
56
+ }
57
+ const range = document.createRange();
58
+ let root: ShadowRoot | null = null;
59
+ let exact = 0;
60
+ let drawn = 0;
61
+ let max = 0;
62
+ let pending = 0;
63
+ let hideTimer = 0;
64
+ let scrolling = false;
65
+ let near = false;
66
+
67
+ const draw = (): void => {
68
+ const shift = Math.round(exact);
69
+ if (shift !== drawn) {
70
+ drawn = shift;
71
+ frame.style.setProperty(SHIFT_PROPERTY, `${shift}px`);
72
+ }
73
+ const { left, width } = shiftThumb(shift, max, track.clientWidth, frame.clientWidth);
74
+ thumb.style.left = `${left}px`;
75
+ thumb.style.width = `${width}px`;
76
+ track.toggleAttribute('data-visible', max > 0 && (scrolling || near));
77
+ };
78
+
79
+ const measure = (): void => {
80
+ pending = 0;
81
+ if (root === null) {
82
+ root = model.getFileTreeContainer()?.shadowRoot ?? null;
83
+ if (root === null) {
84
+ return;
85
+ }
86
+ rows.observe(root, { childList: true, subtree: true, characterData: true });
87
+ }
88
+ const drawnRows = [...root.querySelectorAll<HTMLElement>(ROWS)];
89
+ const gap = drawnRows.length === 0 ? 0 : Number.parseFloat(getComputedStyle(drawnRows[0]!).columnGap) || 0;
90
+ max = maxShift(drawnRows.map((row) => needOf(row, drawn, gap, range)).filter((need) => need !== null));
91
+ exact = clampShift(exact, max);
92
+ draw();
93
+ };
94
+
95
+ const schedule = (): void => {
96
+ if (pending === 0) {
97
+ pending = requestAnimationFrame(measure);
98
+ }
99
+ };
100
+
101
+ const rows = new MutationObserver(schedule);
102
+ const mounts = new MutationObserver(schedule);
103
+ mounts.observe(frame, { childList: true, subtree: true });
104
+ const sizes = new ResizeObserver(schedule);
105
+ sizes.observe(frame);
106
+
107
+ const onWheel = (event: WheelEvent): void => {
108
+ const delta = sidewaysDelta(event, frame.clientWidth);
109
+ if (delta === 0 || max === 0) {
110
+ return;
111
+ }
112
+ scrolling = true;
113
+ window.clearTimeout(hideTimer);
114
+ hideTimer = window.setTimeout(() => {
115
+ scrolling = false;
116
+ draw();
117
+ }, HIDE_AFTER);
118
+ const next = clampShift(exact + delta, max);
119
+ if (next !== exact) {
120
+ event.preventDefault();
121
+ exact = next;
122
+ }
123
+ draw();
124
+ };
125
+
126
+ const onPointerMove = (event: PointerEvent): void => {
127
+ const bottom = track.getBoundingClientRect().bottom;
128
+ const isNear = event.clientY >= bottom - NEAR_BOTTOM;
129
+ if (isNear !== near) {
130
+ near = isNear;
131
+ draw();
132
+ }
133
+ };
134
+
135
+ const onPointerLeave = (): void => {
136
+ if (near) {
137
+ near = false;
138
+ draw();
139
+ }
140
+ };
141
+
142
+ frame.addEventListener('wheel', onWheel, { passive: false });
143
+ frame.addEventListener('pointermove', onPointerMove);
144
+ frame.addEventListener('pointerleave', onPointerLeave);
145
+ schedule();
146
+
147
+ return () => {
148
+ frame.removeEventListener('wheel', onWheel);
149
+ frame.removeEventListener('pointermove', onPointerMove);
150
+ frame.removeEventListener('pointerleave', onPointerLeave);
151
+ rows.disconnect();
152
+ mounts.disconnect();
153
+ sizes.disconnect();
154
+ cancelAnimationFrame(pending);
155
+ window.clearTimeout(hideTimer);
156
+ frame.style.removeProperty(SHIFT_PROPERTY);
157
+ track.removeAttribute('data-visible');
158
+ };
159
+ }, [frame, track, thumb, model, resetKey]);
160
+
161
+ const bar = (
162
+ <div aria-hidden className="adecore-tree-shift">
163
+ <div ref={setTrack} className="adecore-tree-shift-track">
164
+ <div ref={setThumb} className="adecore-tree-shift-thumb" />
165
+ </div>
166
+ </div>
167
+ );
168
+
169
+ return { attach: setFrame, bar };
170
+ }