@adecore/ui 0.35.4 → 0.36.0
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/dist/Button.d.ts +2 -3
- package/dist/Button.js +21 -46
- package/dist/Checkbox.js +1 -1
- package/dist/ChoiceCards.js +2 -2
- package/dist/IconPicker.d.ts +2 -1
- package/dist/IconPicker.js +504 -217
- package/dist/Input.d.ts +5 -3
- package/dist/Input.js +127 -112
- package/dist/Segmented.js +1 -1
- package/dist/SplitButton.d.ts +17 -0
- package/dist/SplitButton.js +237 -0
- package/dist/Stepper.js +1 -1
- package/dist/Switch.js +1 -1
- package/dist/Tile.js +2 -2
- package/dist/button-class.d.ts +6 -0
- package/dist/button-class.js +92 -0
- package/dist/command-palette/parts.d.ts +2 -2
- package/dist/command-palette/parts.js +125 -164
- package/dist/file-tree/empty-lines.d.ts +11 -0
- package/dist/file-tree/empty-lines.js +89 -0
- package/dist/file-tree/highlight.d.ts +4 -0
- package/dist/file-tree/highlight.js +75 -0
- package/dist/file-tree/parts.d.ts +9 -1
- package/dist/file-tree/parts.js +297 -205
- package/dist/file-tree/style.js +13 -0
- package/dist/file-tree/use-empty-lines.d.ts +3 -0
- package/dist/file-tree/use-empty-lines.js +176 -0
- package/dist/file-tree/use-file-tree.js +5 -1
- package/dist/file-tree/use-slots.d.ts +16 -2
- package/dist/file-tree/use-slots.js +186 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/locales/en.d.ts +1 -0
- package/dist/locales/en.js +1 -0
- package/dist/locales/nl.d.ts +1 -0
- package/dist/locales/nl.js +1 -0
- package/dist/select-shared.d.ts +1 -1
- package/dist/select-shared.js +1 -1
- package/dist/settings/SettingsDialog.js +1 -1
- package/dist/theme.css +33 -3
- package/package.json +1 -1
- package/src/Button.tsx +3 -36
- package/src/Checkbox.tsx +1 -1
- package/src/ChoiceCards.tsx +2 -2
- package/src/IconPicker.tsx +220 -136
- package/src/Input.tsx +19 -10
- package/src/Segmented.tsx +1 -1
- package/src/SplitButton.tsx +72 -0
- package/src/Stepper.tsx +6 -1
- package/src/Switch.tsx +1 -1
- package/src/Tile.tsx +2 -2
- package/src/button-class.ts +111 -0
- package/src/command-palette/parts.tsx +34 -2
- package/src/file-tree/empty-lines.ts +105 -0
- package/src/file-tree/highlight.ts +80 -0
- package/src/file-tree/parts.tsx +42 -11
- package/src/file-tree/style.ts +13 -0
- package/src/file-tree/use-empty-lines.ts +115 -0
- package/src/file-tree/use-file-tree.ts +2 -1
- package/src/file-tree/use-slots.ts +176 -17
- package/src/index.ts +1 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/nl.ts +1 -0
- package/src/select-shared.ts +2 -1
- package/src/settings/SettingsDialog.tsx +1 -1
- package/src/theme.css +33 -3
package/src/file-tree/parts.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { useRender } from '@base-ui/react/use-render';
|
|
4
4
|
import { FileTree as PierreFileTree, type FileTreeProps as PierreProps } from '@pierre/trees/react';
|
|
5
5
|
import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import type { LucideIcon } from 'lucide-react';
|
|
7
8
|
import clsx from 'clsx';
|
|
9
|
+
import { Icon } from '../Icon.tsx';
|
|
8
10
|
import { mergeRefs } from '../merge-refs.ts';
|
|
9
11
|
import { directoryHandle, extendsSelection, followFocus, movesFocus, menuTargetsOf, rowPathOf, visibleRows } from './helpers.ts';
|
|
12
|
+
import type { FileTreeEmptyLabel } from './empty-lines.ts';
|
|
13
|
+
import { useEmptyLines } from './use-empty-lines.ts';
|
|
10
14
|
import { useFileTreeShift } from './use-file-tree-shift.tsx';
|
|
11
|
-
import { useFileTreeSlots } from './use-slots.ts';
|
|
15
|
+
import { useFileTreeSlots, type FileTreeDecorationOrder } from './use-slots.ts';
|
|
12
16
|
|
|
13
17
|
export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
14
18
|
model: FileTree;
|
|
@@ -23,6 +27,16 @@ export type FileTreeRootProps = useRender.ComponentProps<'div'> & {
|
|
|
23
27
|
onExpandedPathsChange?(paths: readonly string[]): void;
|
|
24
28
|
renderControl?(row: FileTreeVisibleRow): ReactNode;
|
|
25
29
|
renderDecoration?(row: FileTreeVisibleRow): ReactNode;
|
|
30
|
+
/* `after-status` draws the decoration after the engine's git status column instead of between the name and that column. */
|
|
31
|
+
decorationOrder?: FileTreeDecorationOrder;
|
|
32
|
+
/* A Lucide icon in place of the row's own glyph (the file icon, or a directory's chevron); undefined keeps that glyph. */
|
|
33
|
+
rowIcon?(row: FileTreeVisibleRow): LucideIcon | undefined;
|
|
34
|
+
/* As tall as its rows, up to the height its container leaves it, instead of filling that container. */
|
|
35
|
+
fitContent?: boolean;
|
|
36
|
+
/* Marks where this occurs in every name, ignoring case, such as the query a list was filtered by. */
|
|
37
|
+
highlight?: string;
|
|
38
|
+
/* The line under an open directory with nothing in it; undefined draws none, such as while its children still load. */
|
|
39
|
+
emptyLabel?: FileTreeEmptyLabel;
|
|
26
40
|
treeProps?: Omit<PierreProps, 'model'>;
|
|
27
41
|
};
|
|
28
42
|
|
|
@@ -43,6 +57,11 @@ export function FileTreeRoot({
|
|
|
43
57
|
onRowDragStart,
|
|
44
58
|
renderControl,
|
|
45
59
|
renderDecoration,
|
|
60
|
+
decorationOrder = 'before-status',
|
|
61
|
+
rowIcon,
|
|
62
|
+
fitContent = false,
|
|
63
|
+
highlight = '',
|
|
64
|
+
emptyLabel,
|
|
46
65
|
treeProps,
|
|
47
66
|
render,
|
|
48
67
|
className,
|
|
@@ -50,6 +69,7 @@ export function FileTreeRoot({
|
|
|
50
69
|
children,
|
|
51
70
|
onClick,
|
|
52
71
|
onKeyDownCapture,
|
|
72
|
+
style,
|
|
53
73
|
...props
|
|
54
74
|
}: FileTreeRootProps) {
|
|
55
75
|
const { t } = useTranslation('ui');
|
|
@@ -60,7 +80,11 @@ export function FileTreeRoot({
|
|
|
60
80
|
attach(node as HTMLElement | null);
|
|
61
81
|
mergeRefs<Element>(ref)(node);
|
|
62
82
|
};
|
|
63
|
-
const
|
|
83
|
+
const subscribeToRows = (listener: () => void): (() => void) => (fitContent ? model.subscribe(listener) : () => {});
|
|
84
|
+
const countRows = (): number => model.getVisibleCount();
|
|
85
|
+
const rowCount = useSyncExternalStore(subscribeToRows, countRows, countRows);
|
|
86
|
+
const slots = useFileTreeSlots(model, frame, label ?? t('tree.label'), { order: decorationOrder, rowIcon, highlight, emptyLabel });
|
|
87
|
+
const passEmptyLine = useEmptyLines(model, frame, emptyLabel);
|
|
64
88
|
const latestLoads = useRef({ onLoadChildren, onExpandedPathsChange });
|
|
65
89
|
useLayoutEffect(() => {
|
|
66
90
|
latestLoads.current = { onLoadChildren, onExpandedPathsChange };
|
|
@@ -131,7 +155,9 @@ export function FileTreeRoot({
|
|
|
131
155
|
cancelFocus.current?.();
|
|
132
156
|
// beta.6 stops the key in its own handler; selection must follow after that handler moves focus.
|
|
133
157
|
cancelFocus.current = followFocus(model, onFocusMove);
|
|
134
|
-
}
|
|
158
|
+
}
|
|
159
|
+
passEmptyLine(event);
|
|
160
|
+
if (event.key === 'Enter' && !event.altKey && !event.metaKey && !event.ctrlKey) {
|
|
135
161
|
const path = model.getFocusedPath();
|
|
136
162
|
if (path === null) {
|
|
137
163
|
return;
|
|
@@ -169,7 +195,7 @@ export function FileTreeRoot({
|
|
|
169
195
|
frame.removeEventListener('contextmenu', onContext);
|
|
170
196
|
frame.removeEventListener('dragstart', onDrag);
|
|
171
197
|
};
|
|
172
|
-
}, [frame, model, selectionFollowsFocus, onActivate, onFocusMove, onRowContextMenu, onRowDragStart]);
|
|
198
|
+
}, [frame, model, selectionFollowsFocus, passEmptyLine, onActivate, onFocusMove, onRowContextMenu, onRowDragStart]);
|
|
173
199
|
|
|
174
200
|
return useRender({
|
|
175
201
|
render,
|
|
@@ -178,17 +204,22 @@ export function FileTreeRoot({
|
|
|
178
204
|
props: {
|
|
179
205
|
...props,
|
|
180
206
|
className: clsx('adecore-tree adecore-file-tree', className),
|
|
207
|
+
style: fitContent ? { ...style, height: rowCount * model.getItemHeight(), maxHeight: '100%' } : style,
|
|
181
208
|
onClick,
|
|
182
209
|
onKeyDownCapture,
|
|
183
210
|
children: (
|
|
184
211
|
<>
|
|
185
212
|
<PierreFileTree {...treeProps} model={model} className={clsx('adecore-file-tree-host', treeProps?.className)} />
|
|
186
|
-
{slots.map(({ row, control, decoration }) =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
{slots.map(({ row, control, decoration, icon }) => {
|
|
214
|
+
const ownIcon = icon === null ? undefined : rowIcon?.(row);
|
|
215
|
+
return (
|
|
216
|
+
<span key={control.slot} style={{ display: 'contents' }}>
|
|
217
|
+
{createPortal(renderControl?.(row), control)}
|
|
218
|
+
{createPortal(renderDecoration?.(row), decoration)}
|
|
219
|
+
{icon !== null && ownIcon !== undefined && createPortal(<Icon icon={ownIcon} size={14} />, icon)}
|
|
220
|
+
</span>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
192
223
|
{children}
|
|
193
224
|
{bar}
|
|
194
225
|
</>
|
package/src/file-tree/style.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { treeRowStyle } from '../tree/style.ts';
|
|
2
|
+
import { MATCH_HIGHLIGHT } from './highlight.ts';
|
|
2
3
|
|
|
3
4
|
// beta.6 splits the extension into nested grids; flatten them to truncate the end of the entire name.
|
|
4
5
|
export const FILE_TREE_CSS = `
|
|
@@ -20,6 +21,18 @@ export const FILE_TREE_CSS = `
|
|
|
20
21
|
[data-item-section="content"] :where([data-truncate-group-container], [data-truncate-group-container] div, [data-truncate-container], [data-truncate-container] div) { display: inline; }
|
|
21
22
|
[data-item-section="content"] [data-truncate-content] { direction: ltr; }
|
|
22
23
|
[data-item-section="content"] :where([data-truncate-content="overflow"], [data-truncate-marker-cell], [data-truncate-fill]) { display: none; }
|
|
24
|
+
[data-item-section="git"] { margin-inline-end: var(--adecore-tree-status-space, 0px); }
|
|
25
|
+
::highlight(${MATCH_HIGHLIGHT}) { background-color: var(--match); color: inherit; }
|
|
23
26
|
[data-tree-port] { position: absolute; z-index: 4; display: flex; align-items: center; height: 25px; pointer-events: none; }
|
|
24
27
|
[data-tree-port] > slot { pointer-events: auto; }
|
|
28
|
+
[data-tree-port="icon"] { width: 16px; justify-content: center; }
|
|
29
|
+
[data-tree-port="icon"] > slot { pointer-events: none; }
|
|
30
|
+
[data-own-icon="true"] [data-item-section="icon"] > * { visibility: hidden; }
|
|
31
|
+
[data-own-icon="true"][data-item-type="folder"]:hover [data-item-section="icon"] > * { visibility: visible; }
|
|
32
|
+
[data-tree-port="icon"][data-hover="true"] { visibility: hidden; }
|
|
33
|
+
[data-empty-line="true"] { cursor: default; }
|
|
34
|
+
[data-empty-line="true"] :is([data-item-section="icon"], [data-item-section="git"]) { display: none; }
|
|
35
|
+
[data-empty-line="true"] [data-item-section="content"] { overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
|
36
|
+
[data-empty-line="true"] [data-item-section="content"] > * { display: none; }
|
|
37
|
+
[data-empty-line="true"] [data-item-section="content"]::before { content: attr(data-empty-label); }
|
|
25
38
|
`;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import type { FileTree } from '@pierre/trees';
|
|
3
|
+
import { emptyLineChanges, isEmptyLine, pastEmptyLine, skipEmptyLine, type FileTreeEmptyLabel } from './empty-lines.ts';
|
|
4
|
+
import { movesFocus, visibleRows } from './helpers.ts';
|
|
5
|
+
|
|
6
|
+
const POINTER_EVENTS = ['pointerdown', 'mousedown', 'click', 'dblclick', 'auxclick', 'contextmenu', 'dragstart'] as const;
|
|
7
|
+
|
|
8
|
+
function onEmptyLine(event: Event): boolean {
|
|
9
|
+
return event.composedPath().some((node) => node instanceof HTMLElement && node.dataset.emptyLine === 'true');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Keeps a line under every open directory that is known to be empty, and keeps the line out of
|
|
14
|
+
* reach: a pointer on it does nothing and a key passes it. Returns what to call with every key the
|
|
15
|
+
* tree hears, after anything that notes where focus was.
|
|
16
|
+
*/
|
|
17
|
+
export function useEmptyLines(model: FileTree, frame: HTMLElement | null, emptyLabel: FileTreeEmptyLabel | undefined): (event: KeyboardEvent) => void {
|
|
18
|
+
const latest = useRef(emptyLabel);
|
|
19
|
+
const reconcile = useRef<(() => void) | undefined>(undefined);
|
|
20
|
+
// Focus that lands on a line goes on in the direction of the last key, which the engine handles after the tree hears it.
|
|
21
|
+
const lastKey = useRef('');
|
|
22
|
+
const enabled = emptyLabel !== undefined;
|
|
23
|
+
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
latest.current = emptyLabel;
|
|
26
|
+
reconcile.current?.();
|
|
27
|
+
}, [emptyLabel]);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const added = new Set<string>();
|
|
34
|
+
let pending = false;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
const run = (): void => {
|
|
37
|
+
pending = false;
|
|
38
|
+
const labelOf = latest.current;
|
|
39
|
+
if (disposed || labelOf === undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const selectedLines = model.getSelectedPaths().filter(isEmptyLine);
|
|
43
|
+
for (const path of selectedLines) {
|
|
44
|
+
model.getItem(path)?.deselect();
|
|
45
|
+
}
|
|
46
|
+
// A selection that followed focus onto the line follows it on to where it went.
|
|
47
|
+
const focused = skipEmptyLine(model, lastKey.current) ? model.getFocusedPath() : null;
|
|
48
|
+
if (focused !== null && selectedLines.length > 0) {
|
|
49
|
+
model.getItem(focused)?.select();
|
|
50
|
+
}
|
|
51
|
+
const { add, remove } = emptyLineChanges(visibleRows(model), labelOf, (path) => model.getItem(path) !== null, added);
|
|
52
|
+
for (const path of remove) {
|
|
53
|
+
added.delete(path);
|
|
54
|
+
}
|
|
55
|
+
for (const path of add) {
|
|
56
|
+
added.add(path);
|
|
57
|
+
}
|
|
58
|
+
if (add.length > 0 || remove.length > 0) {
|
|
59
|
+
model.batch([...remove.map((path) => ({ type: 'remove' as const, path })), ...add.map((path) => ({ type: 'add' as const, path }))]);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const schedule = (): void => {
|
|
63
|
+
if (!pending && !disposed) {
|
|
64
|
+
pending = true;
|
|
65
|
+
queueMicrotask(run);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
reconcile.current = schedule;
|
|
69
|
+
const unsubscribe = model.subscribe(schedule);
|
|
70
|
+
run();
|
|
71
|
+
return () => {
|
|
72
|
+
disposed = true;
|
|
73
|
+
reconcile.current = undefined;
|
|
74
|
+
unsubscribe();
|
|
75
|
+
const left = [...added].filter((path) => model.getItem(path) !== null);
|
|
76
|
+
if (left.length > 0) {
|
|
77
|
+
model.batch(left.map((path) => ({ type: 'remove' as const, path })));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}, [model, enabled]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (frame === null) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const block = (event: Event): void => {
|
|
87
|
+
if (onEmptyLine(event)) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
event.stopImmediatePropagation();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
for (const type of POINTER_EVENTS) {
|
|
93
|
+
frame.addEventListener(type, block, true);
|
|
94
|
+
}
|
|
95
|
+
return () => {
|
|
96
|
+
for (const type of POINTER_EVENTS) {
|
|
97
|
+
frame.removeEventListener(type, block, true);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}, [frame]);
|
|
101
|
+
|
|
102
|
+
return (event: KeyboardEvent): void => {
|
|
103
|
+
lastKey.current = event.key;
|
|
104
|
+
const past = latest.current !== undefined && movesFocus(event) ? pastEmptyLine(model, event.key) : null;
|
|
105
|
+
if (past === null) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// The engine never sees this key, so focus never lands on the line for a moment.
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
if (past !== model.getFocusedPath()) {
|
|
112
|
+
model.scrollToPath(past, { focus: true, offset: 'nearest' });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -3,6 +3,7 @@ import { useFileTree as usePierreFileTree } from '@pierre/trees/react';
|
|
|
3
3
|
import { useLayoutEffect, useRef } from 'react';
|
|
4
4
|
import { FILE_TREE_ICONS } from '../file-icon.ts';
|
|
5
5
|
import { TREE_ROW_HEIGHT } from '../tree/style.ts';
|
|
6
|
+
import { isEmptyLine } from './empty-lines.ts';
|
|
6
7
|
import { compareRows } from './helpers.ts';
|
|
7
8
|
import { FILE_TREE_CSS } from './style.ts';
|
|
8
9
|
|
|
@@ -25,7 +26,7 @@ export function useFileTree(options: FileTreeOptions) {
|
|
|
25
26
|
...options,
|
|
26
27
|
density: 'compact',
|
|
27
28
|
itemHeight: TREE_ROW_HEIGHT,
|
|
28
|
-
onSelectionChange: (paths) => latest.current?.(paths),
|
|
29
|
+
onSelectionChange: (paths) => latest.current?.(paths.some(isEmptyLine) ? paths.filter((path) => !isEmptyLine(path)) : paths),
|
|
29
30
|
unsafeCSS: `${FILE_TREE_CSS}\n${options.unsafeCSS ?? ''}`
|
|
30
31
|
});
|
|
31
32
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { useEffect, useId, useState } from 'react';
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { flushSync } from 'react-dom';
|
|
3
3
|
import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
|
|
4
|
+
import type { LucideIcon } from 'lucide-react';
|
|
5
|
+
import { directoryOfEmptyLine, isEmptyLine, type FileTreeEmptyLabel } from './empty-lines.ts';
|
|
4
6
|
import { pathOfRow, visibleRows, visibleRowsAround } from './helpers.ts';
|
|
7
|
+
import { matchHighlight, rangesOfMatches } from './highlight.ts';
|
|
5
8
|
import { FILE_TREE_CSS } from './style.ts';
|
|
6
9
|
|
|
7
10
|
export interface RowSlot {
|
|
8
11
|
row: FileTreeVisibleRow;
|
|
9
12
|
control: HTMLElement;
|
|
10
13
|
decoration: HTMLElement;
|
|
14
|
+
/* Where a row's own icon goes in place of the engine's glyph; null when the row keeps that glyph. */
|
|
15
|
+
icon: HTMLElement | null;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface Port {
|
|
@@ -16,8 +21,13 @@ export interface Port {
|
|
|
16
21
|
decoration: HTMLElement;
|
|
17
22
|
lightControl: HTMLElement;
|
|
18
23
|
lightDecoration: HTMLElement;
|
|
24
|
+
icon?: HTMLElement;
|
|
25
|
+
lightIcon?: HTMLElement;
|
|
26
|
+
unhover?(): void;
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
export type FileTreeRowIcon = (row: FileTreeVisibleRow) => LucideIcon | undefined;
|
|
30
|
+
|
|
21
31
|
/*
|
|
22
32
|
* The rows of the mounted window, read from the spacer beta.6 puts above it (its first index times the
|
|
23
33
|
* row height), so a scroll frame copies those and not every visible row. The whole list stands in when
|
|
@@ -37,36 +47,48 @@ function mountedRows(model: FileTree, shadow: ShadowRoot, elements: readonly HTM
|
|
|
37
47
|
return elements.every((element) => element.dataset.itemPath === undefined || paths.has(element.dataset.itemPath)) ? rows : visibleRows(model);
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
/* Where a row's own decoration goes: between the name and the engine's git status column, or after that column. */
|
|
51
|
+
export type FileTreeDecorationOrder = 'before-status' | 'after-status';
|
|
52
|
+
|
|
40
53
|
/*
|
|
41
54
|
* Lines the ports up with their rows: every size first, then the spaces they reserve, then every offset,
|
|
42
55
|
* then the ports, so a pass forces two layouts at most however many rows are mounted.
|
|
43
56
|
*/
|
|
44
|
-
export function positionPorts(ports: Iterable<Port
|
|
57
|
+
export function positionPorts(ports: Iterable<Port>, order: FileTreeDecorationOrder = 'before-status'): void {
|
|
45
58
|
const spaced = [...ports].map((port) => ({
|
|
46
59
|
port,
|
|
60
|
+
// Without a git column, after the column is right after the name.
|
|
61
|
+
status: order === 'after-status' ? port.rowElement.querySelector<HTMLElement>('[data-item-section="git"]') : null,
|
|
47
62
|
control: port.lightControl.childNodes.length > 0 ? Math.ceil(port.lightControl.getBoundingClientRect().width) + 6 : 0,
|
|
48
63
|
decoration: port.lightDecoration.childNodes.length > 0 ? Math.ceil(port.lightDecoration.getBoundingClientRect().width) + 6 : 0
|
|
49
64
|
}));
|
|
50
|
-
for (const { port, control, decoration } of spaced) {
|
|
65
|
+
for (const { port, status, control, decoration } of spaced) {
|
|
51
66
|
setSpace(port.rowElement, '--adecore-tree-control-space', control);
|
|
52
|
-
setSpace(port.rowElement, '--adecore-tree-decoration-space', decoration);
|
|
67
|
+
setSpace(port.rowElement, '--adecore-tree-decoration-space', status === null ? decoration : 0);
|
|
68
|
+
setSpace(port.rowElement, '--adecore-tree-status-space', status === null ? 0 : decoration);
|
|
53
69
|
}
|
|
54
|
-
const placed = spaced.map(({ port, control, decoration }) => {
|
|
70
|
+
const placed = spaced.map(({ port, status, control, decoration }) => {
|
|
55
71
|
const row = port.rowElement;
|
|
56
|
-
const
|
|
72
|
+
const before = status ?? row.querySelector<HTMLElement>('[data-item-section="content"]');
|
|
57
73
|
const icon = row.querySelector<HTMLElement>('[data-item-section="icon"]');
|
|
74
|
+
const iconLeft = row.offsetLeft + (icon?.offsetLeft ?? 4);
|
|
58
75
|
return {
|
|
59
76
|
port,
|
|
60
77
|
top: `${row.offsetTop}px`,
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
iconLeft: `${iconLeft}px`,
|
|
79
|
+
controlLeft: `${iconLeft - control}px`,
|
|
80
|
+
decorationLeft: `${row.offsetLeft + (before === null ? row.offsetWidth - decoration - 2 : before.offsetLeft + before.offsetWidth + 6)}px`
|
|
63
81
|
};
|
|
64
82
|
});
|
|
65
|
-
for (const { port, top, controlLeft, decorationLeft } of placed) {
|
|
83
|
+
for (const { port, top, iconLeft, controlLeft, decorationLeft } of placed) {
|
|
66
84
|
port.control.style.top = top;
|
|
67
85
|
port.control.style.left = controlLeft;
|
|
68
86
|
port.decoration.style.top = top;
|
|
69
87
|
port.decoration.style.left = decorationLeft;
|
|
88
|
+
if (port.icon !== undefined) {
|
|
89
|
+
port.icon.style.top = top;
|
|
90
|
+
port.icon.style.left = iconLeft;
|
|
91
|
+
}
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
94
|
|
|
@@ -78,10 +100,53 @@ function setSpace(row: HTMLElement, name: string, space: number): void {
|
|
|
78
100
|
}
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
export interface FileTreeSlotOptions {
|
|
104
|
+
order: FileTreeDecorationOrder;
|
|
105
|
+
rowIcon?: FileTreeRowIcon;
|
|
106
|
+
/* What to mark in every name; empty marks nothing. */
|
|
107
|
+
highlight: string;
|
|
108
|
+
emptyLabel?: FileTreeEmptyLabel;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* An empty line draws its directory's label in place of its name and is no item to point at or read out. */
|
|
112
|
+
function markEmptyLine(element: HTMLElement, label: string): void {
|
|
113
|
+
element.dataset.emptyLine = 'true';
|
|
114
|
+
element.dataset.interactive = 'false';
|
|
115
|
+
element.setAttribute('aria-hidden', 'true');
|
|
116
|
+
const content = element.querySelector<HTMLElement>('[data-item-section="content"]');
|
|
117
|
+
if (content !== null && content.dataset.emptyLabel !== label) {
|
|
118
|
+
content.dataset.emptyLabel = label;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The engine recycles row elements, so one that drew a line can come back as an ordinary row.
|
|
123
|
+
function unmarkEmptyLine(element: HTMLElement): void {
|
|
124
|
+
if (element.dataset.emptyLine === undefined) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
delete element.dataset.emptyLine;
|
|
128
|
+
element.removeAttribute('aria-hidden');
|
|
129
|
+
const content = element.querySelector<HTMLElement>('[data-item-section="content"]');
|
|
130
|
+
if (content !== null) {
|
|
131
|
+
delete content.dataset.emptyLabel;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
81
135
|
// beta.6 owns button rows. Sibling slots keep real controls outside those buttons and in the app's stylesheet.
|
|
82
|
-
export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, label: string) {
|
|
136
|
+
export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, label: string, { order, rowIcon, highlight, emptyLabel }: FileTreeSlotOptions) {
|
|
83
137
|
const prefix = useId().replaceAll(':', '');
|
|
84
138
|
const [slots, setSlots] = useState<RowSlot[]>([]);
|
|
139
|
+
const latestIcon = useRef(rowIcon);
|
|
140
|
+
const latestHighlight = useRef(highlight);
|
|
141
|
+
const latestEmpty = useRef(emptyLabel);
|
|
142
|
+
const rescan = useRef<(() => void) | undefined>(undefined);
|
|
143
|
+
|
|
144
|
+
useLayoutEffect(() => {
|
|
145
|
+
latestIcon.current = rowIcon;
|
|
146
|
+
latestHighlight.current = highlight;
|
|
147
|
+
latestEmpty.current = emptyLabel;
|
|
148
|
+
rescan.current?.();
|
|
149
|
+
}, [rowIcon, highlight, emptyLabel]);
|
|
85
150
|
|
|
86
151
|
useEffect(() => {
|
|
87
152
|
if (frame === null) {
|
|
@@ -94,8 +159,23 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
94
159
|
let disposed = false;
|
|
95
160
|
let pending = false;
|
|
96
161
|
let snapshot: string | null = null;
|
|
162
|
+
let marked: Range[] = [];
|
|
163
|
+
|
|
164
|
+
const mark = (rows: readonly HTMLElement[], query: string): void => {
|
|
165
|
+
const matches = matchHighlight();
|
|
166
|
+
if (matches === null) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const range of marked) {
|
|
170
|
+
matches.delete(range);
|
|
171
|
+
}
|
|
172
|
+
marked = rangesOfMatches(rows, query);
|
|
173
|
+
for (const range of marked) {
|
|
174
|
+
matches.add(range);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
97
177
|
|
|
98
|
-
const position = (): void => positionPorts(ports.values());
|
|
178
|
+
const position = (): void => positionPorts(ports.values(), order);
|
|
99
179
|
|
|
100
180
|
const resize = new ResizeObserver(position);
|
|
101
181
|
const dropPort = (port: Port): void => {
|
|
@@ -105,6 +185,53 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
105
185
|
resize.unobserve(port.lightDecoration);
|
|
106
186
|
port.lightControl.remove();
|
|
107
187
|
port.lightDecoration.remove();
|
|
188
|
+
dropIcon(port);
|
|
189
|
+
};
|
|
190
|
+
const clearPort = (port: Port): void => {
|
|
191
|
+
port.rowElement.style.removeProperty('--adecore-tree-control-space');
|
|
192
|
+
port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
|
|
193
|
+
port.rowElement.style.removeProperty('--adecore-tree-status-space');
|
|
194
|
+
delete port.rowElement.dataset.ownIcon;
|
|
195
|
+
dropPort(port);
|
|
196
|
+
};
|
|
197
|
+
const dropIcon = (port: Port): void => {
|
|
198
|
+
port.unhover?.();
|
|
199
|
+
port.icon?.remove();
|
|
200
|
+
port.lightIcon?.remove();
|
|
201
|
+
port.icon = undefined;
|
|
202
|
+
port.lightIcon = undefined;
|
|
203
|
+
port.unhover = undefined;
|
|
204
|
+
};
|
|
205
|
+
// The icon lets every pointer event through to the row under it, which then opens, folds or selects as it would.
|
|
206
|
+
const makeIcon = (port: Port, host: HTMLElement): void => {
|
|
207
|
+
const container = document.createElement('span');
|
|
208
|
+
container.dataset.treePort = 'icon';
|
|
209
|
+
container.dataset.itemPath = port.rowElement.dataset.itemPath;
|
|
210
|
+
const slot = document.createElement('slot');
|
|
211
|
+
slot.name = `${prefix}-icon-${serial++}`;
|
|
212
|
+
container.append(slot);
|
|
213
|
+
port.rowElement.parentElement?.append(container);
|
|
214
|
+
const target = document.createElement('span');
|
|
215
|
+
target.slot = slot.name;
|
|
216
|
+
target.className = 'adecore-tree-icon';
|
|
217
|
+
host.append(target);
|
|
218
|
+
// A folder under the pointer gives its chevron back, so the row still says it opens.
|
|
219
|
+
const show = (): void => {
|
|
220
|
+
if (port.rowElement.dataset.itemType === 'folder') {
|
|
221
|
+
container.dataset.hover = 'true';
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const hide = (): void => {
|
|
225
|
+
delete container.dataset.hover;
|
|
226
|
+
};
|
|
227
|
+
port.rowElement.addEventListener('pointerenter', show);
|
|
228
|
+
port.rowElement.addEventListener('pointerleave', hide);
|
|
229
|
+
port.icon = container;
|
|
230
|
+
port.lightIcon = target;
|
|
231
|
+
port.unhover = () => {
|
|
232
|
+
port.rowElement.removeEventListener('pointerenter', show);
|
|
233
|
+
port.rowElement.removeEventListener('pointerleave', hide);
|
|
234
|
+
};
|
|
108
235
|
};
|
|
109
236
|
const makePort = (element: HTMLElement, host: HTMLElement, kind: 'control' | 'decoration'): { container: HTMLElement; target: HTMLElement } => {
|
|
110
237
|
const container = document.createElement('span');
|
|
@@ -164,12 +291,25 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
164
291
|
}
|
|
165
292
|
}
|
|
166
293
|
const next: RowSlot[] = [];
|
|
294
|
+
const named: HTMLElement[] = [];
|
|
167
295
|
for (const element of elements) {
|
|
168
296
|
const path = element.dataset.itemPath;
|
|
169
297
|
const row = path === undefined ? undefined : byPath.get(path);
|
|
170
298
|
if (row === undefined) {
|
|
171
299
|
continue;
|
|
172
300
|
}
|
|
301
|
+
if (isEmptyLine(row.path)) {
|
|
302
|
+
const recycled = ports.get(element);
|
|
303
|
+
if (recycled !== undefined) {
|
|
304
|
+
clearPort(recycled);
|
|
305
|
+
ports.delete(element);
|
|
306
|
+
}
|
|
307
|
+
const directory = byPath.get(directoryOfEmptyLine(row.path));
|
|
308
|
+
markEmptyLine(element, (directory === undefined ? undefined : latestEmpty.current?.(directory)) ?? '');
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
unmarkEmptyLine(element);
|
|
312
|
+
named.push(element);
|
|
173
313
|
element.dataset.interactive = 'true';
|
|
174
314
|
element.dataset.joinedStart = String(row.isSelected && byIndex.get(row.index - 1)?.isSelected === true);
|
|
175
315
|
element.dataset.joinedEnd = String(row.isSelected && byIndex.get(row.index + 1)?.isSelected === true);
|
|
@@ -195,16 +335,34 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
195
335
|
}
|
|
196
336
|
port.lightControl.dataset.itemPath = row.path;
|
|
197
337
|
port.lightDecoration.dataset.itemPath = row.path;
|
|
198
|
-
|
|
338
|
+
const ownIcon = latestIcon.current?.(row) !== undefined;
|
|
339
|
+
if (ownIcon && port.icon === undefined) {
|
|
340
|
+
makeIcon(port, current);
|
|
341
|
+
} else if (!ownIcon && port.icon !== undefined) {
|
|
342
|
+
dropIcon(port);
|
|
343
|
+
}
|
|
344
|
+
if (port.icon !== undefined && port.icon.dataset.itemPath !== row.path) {
|
|
345
|
+
port.icon.dataset.itemPath = row.path;
|
|
346
|
+
}
|
|
347
|
+
if (ownIcon) {
|
|
348
|
+
element.dataset.ownIcon = 'true';
|
|
349
|
+
} else if (element.dataset.ownIcon !== undefined) {
|
|
350
|
+
delete element.dataset.ownIcon;
|
|
351
|
+
}
|
|
352
|
+
next.push({ row, control: port.lightControl, decoration: port.lightDecoration, icon: port.lightIcon ?? null });
|
|
199
353
|
}
|
|
200
354
|
const key = next
|
|
201
|
-
.map(
|
|
355
|
+
.map(
|
|
356
|
+
({ row, control, icon }) =>
|
|
357
|
+
`${control.slot}:${row.path}:${row.isSelected}:${row.isExpanded}:${row.isFocused}:${row.name}:${icon?.slot ?? ''}`
|
|
358
|
+
)
|
|
202
359
|
.join('|');
|
|
203
360
|
if (snapshot !== key) {
|
|
204
361
|
snapshot = key;
|
|
205
362
|
// Recycled rows must paint with their new portal contents in the same frame.
|
|
206
363
|
flushSync(() => setSlots(next));
|
|
207
364
|
}
|
|
365
|
+
mark(named, latestHighlight.current);
|
|
208
366
|
position();
|
|
209
367
|
};
|
|
210
368
|
const schedule = (): void => {
|
|
@@ -213,6 +371,7 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
213
371
|
queueMicrotask(scan);
|
|
214
372
|
}
|
|
215
373
|
};
|
|
374
|
+
rescan.current = schedule;
|
|
216
375
|
const shifts = new MutationObserver(position);
|
|
217
376
|
shifts.observe(frame, { attributes: true, attributeFilter: ['style'] });
|
|
218
377
|
const changes = new MutationObserver(schedule);
|
|
@@ -221,18 +380,18 @@ export function useFileTreeSlots(model: FileTree, frame: HTMLElement | null, lab
|
|
|
221
380
|
schedule();
|
|
222
381
|
return () => {
|
|
223
382
|
disposed = true;
|
|
383
|
+
rescan.current = undefined;
|
|
224
384
|
unsubscribe();
|
|
225
385
|
changes.disconnect();
|
|
226
386
|
shifts.disconnect();
|
|
227
387
|
resize.disconnect();
|
|
228
388
|
style?.remove();
|
|
389
|
+
mark([], '');
|
|
229
390
|
for (const port of ports.values()) {
|
|
230
|
-
port
|
|
231
|
-
port.rowElement.style.removeProperty('--adecore-tree-decoration-space');
|
|
232
|
-
dropPort(port);
|
|
391
|
+
clearPort(port);
|
|
233
392
|
}
|
|
234
393
|
};
|
|
235
|
-
}, [frame, model, label, prefix]);
|
|
394
|
+
}, [frame, model, label, order, prefix]);
|
|
236
395
|
|
|
237
396
|
return slots;
|
|
238
397
|
}
|
package/src/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export { Skeleton, type SkeletonProps } from './Skeleton.tsx';
|
|
|
72
72
|
export { Slider, type SliderProps } from './Slider.tsx';
|
|
73
73
|
export { SlidingColumn, type SlidingColumnProps } from './SlidingColumn.tsx';
|
|
74
74
|
export { Spinner, type SpinnerProps } from './Spinner.tsx';
|
|
75
|
+
export { SplitButton, type SplitButtonProps } from './SplitButton.tsx';
|
|
75
76
|
export { Stepper, type StepperProps } from './Stepper.tsx';
|
|
76
77
|
export { Surface, type SurfaceProps } from './Surface.tsx';
|
|
77
78
|
export { Switch, type SwitchProps } from './Switch.tsx';
|
package/src/locales/en.ts
CHANGED
package/src/locales/nl.ts
CHANGED
package/src/select-shared.ts
CHANGED
|
@@ -23,7 +23,8 @@ export const TRIGGER_SIZE = {
|
|
|
23
23
|
} as const;
|
|
24
24
|
|
|
25
25
|
export const TRIGGER_VARIANT = {
|
|
26
|
-
outlined:
|
|
26
|
+
outlined:
|
|
27
|
+
'max-w-56 rounded-lg border border-field-border bg-field text-text hover:bg-surface-hover data-[popup-open]:bg-surface-hover data-disabled:hover:bg-field',
|
|
27
28
|
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
28
29
|
} as const;
|
|
29
30
|
|
|
@@ -135,7 +135,7 @@ function SearchField({ search, query, onQuery, onPick }: SearchFieldProps) {
|
|
|
135
135
|
<Icon icon={Search} size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-text-faint" />
|
|
136
136
|
<Input
|
|
137
137
|
ref={input}
|
|
138
|
-
className="
|
|
138
|
+
className="pr-12 pl-8 text-xs"
|
|
139
139
|
placeholder={t('settings.search.placeholder')}
|
|
140
140
|
aria-label={t('settings.search.placeholder')}
|
|
141
141
|
value={query}
|