@adecore/ui 0.35.5 → 0.37.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/context.d.ts +10 -0
- package/dist/command-palette/context.js +5 -0
- package/dist/command-palette/index.parts.d.ts +1 -0
- package/dist/command-palette/index.parts.js +2 -1
- package/dist/command-palette/parts.d.ts +1 -1
- package/dist/command-palette/parts.js +12 -3
- package/dist/command-palette/virtual.d.ts +20 -0
- package/dist/command-palette/virtual.js +155 -0
- 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/format/index.d.ts +1 -1
- package/dist/format/index.js +1 -1
- package/dist/format/locale.d.ts +1 -0
- package/dist/format/locale.js +4 -5
- package/dist/index.d.ts +2 -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 +2 -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/context.ts +16 -0
- package/src/command-palette/index.parts.ts +2 -0
- package/src/command-palette/parts.tsx +11 -12
- package/src/command-palette/virtual.tsx +147 -0
- 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/format/index.ts +1 -1
- package/src/format/locale.ts +13 -7
- package/src/index.ts +3 -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
|
@@ -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/format/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
isSameDay
|
|
18
18
|
} from './datetime.ts';
|
|
19
19
|
export { formatAgo, formatClockDuration, formatCountdown, formatDuration, formatElapsedShort, formatPreciseDuration } from './duration.ts';
|
|
20
|
-
export { formatLocale, labelCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
20
|
+
export { formatLocale, labelCollator, nameCollator, setFormatSource, systemLocale, useFormatLocale, type FormatSource } from './locale.ts';
|
|
21
21
|
export {
|
|
22
22
|
formatBytes,
|
|
23
23
|
formatDecimal,
|
package/src/format/locale.ts
CHANGED
|
@@ -95,16 +95,17 @@ const subscribe = (onChange: () => void): (() => void) => source.subscribe(onCha
|
|
|
95
95
|
export const useFormatLocale = (): string => useSyncExternalStore(subscribe, formatLocale, formatLocale);
|
|
96
96
|
|
|
97
97
|
interface Cached<T> {
|
|
98
|
-
locale: string;
|
|
98
|
+
locale: string | undefined;
|
|
99
99
|
formatter: T;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
/* A module-level options object shares its formatter across rows; a changed locale replaces it. */
|
|
103
|
+
const cacheFor = <O extends object, T>(
|
|
104
|
+
store: WeakMap<O, Cached<T>>,
|
|
105
|
+
options: O,
|
|
106
|
+
locale: string | undefined,
|
|
107
|
+
build: (locale: string | undefined, options: O) => T
|
|
108
|
+
): T => {
|
|
108
109
|
const hit = store.get(options);
|
|
109
110
|
if (hit !== undefined && hit.locale === locale) {
|
|
110
111
|
return hit.formatter;
|
|
@@ -118,6 +119,7 @@ const numbers = new WeakMap<Intl.NumberFormatOptions, Cached<Intl.NumberFormat>>
|
|
|
118
119
|
const dates = new WeakMap<Intl.DateTimeFormatOptions, Cached<Intl.DateTimeFormat>>();
|
|
119
120
|
const words = new WeakMap<Intl.DateTimeFormatOptions, Cached<Intl.DateTimeFormat>>();
|
|
120
121
|
const relatives = new WeakMap<Intl.RelativeTimeFormatOptions, Cached<Intl.RelativeTimeFormat>>();
|
|
122
|
+
const names = new WeakMap<Intl.CollatorOptions, Cached<Intl.Collator>>();
|
|
121
123
|
|
|
122
124
|
export const numberFormatter = (options: Intl.NumberFormatOptions): Intl.NumberFormat =>
|
|
123
125
|
cacheFor(numbers, options, formatLocale(), (locale, spec) => new Intl.NumberFormat(locale, spec));
|
|
@@ -134,6 +136,10 @@ export const wordFormatter = (options: Intl.DateTimeFormatOptions): Intl.DateTim
|
|
|
134
136
|
export const relativeFormatter = (options: Intl.RelativeTimeFormatOptions): Intl.RelativeTimeFormat =>
|
|
135
137
|
cacheFor(relatives, options, wordLocale(), (locale, spec) => new Intl.RelativeTimeFormat(locale, spec));
|
|
136
138
|
|
|
139
|
+
/* Proper names keep the runtime locale; a shared options object reuses its collator. */
|
|
140
|
+
export const nameCollator = (options: Intl.CollatorOptions): Intl.Collator =>
|
|
141
|
+
cacheFor(names, options, undefined, (locale, spec) => new Intl.Collator(locale, spec));
|
|
142
|
+
|
|
137
143
|
const collators = new Map<string, Intl.Collator>();
|
|
138
144
|
|
|
139
145
|
/*
|
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';
|
|
@@ -151,3 +152,5 @@ export {
|
|
|
151
152
|
type SplitCommand
|
|
152
153
|
} from './workspace/model.ts';
|
|
153
154
|
export { tabGapAt, type SplitRect, type SplitEdges, type SplitMinimum } from './workspace/geometry.ts';
|
|
155
|
+
|
|
156
|
+
export type { CommandPaletteVirtualListProps, CommandPaletteVirtualItemProps } from './command-palette/virtual.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}
|
package/src/theme.css
CHANGED
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
than the pressed step, so hover reads as a lift and pressed as the heavier state. A white
|
|
18
18
|
popup cannot go lighter, so in this theme the step is the softest gray there is. */
|
|
19
19
|
--surface-hover: #f3f3f5;
|
|
20
|
+
/* The hover of something that draws its own border: a smaller step, so the ground never passes
|
|
21
|
+
its edge and the outline stays visible under the pointer. */
|
|
22
|
+
--surface-hover-edged: color-mix(in srgb, var(--surface-raised) 60%, var(--surface-hover));
|
|
20
23
|
/* The pressed step of a toggle. It cannot be `--surface-raised`, which equals `--surface` in the
|
|
21
24
|
light theme, and it has to sit a shade past `--surface-hover` so pressed outranks hover. */
|
|
22
25
|
--surface-active: #dcdce2;
|
|
26
|
+
/* What a person types or picks in: a step lighter than the ground it stands on in the dark theme, the raised white here. */
|
|
27
|
+
--field: var(--surface-raised);
|
|
28
|
+
/* An edge of its own, since an app may set `--border` to a solid separator that vanishes against a field. */
|
|
29
|
+
--field-border: rgb(0 0 0 / 0.11);
|
|
23
30
|
/* Borders carry an alpha rather than a gray of their own, so a line takes the color of whatever
|
|
24
31
|
it is drawn over instead of fighting it. A gray hairline over a tinted surface reads as a
|
|
25
32
|
smudge; the same line in black at a low alpha stays a line. Everything with a surface of its
|
|
@@ -41,6 +48,9 @@
|
|
|
41
48
|
--accent-soft: color-mix(in srgb, var(--accent) 16%, var(--surface));
|
|
42
49
|
--accent-text: #ffffff;
|
|
43
50
|
--selection: rgb(var(--accent-rgb) / 0.55);
|
|
51
|
+
/* The part of a name that answers a filter or a search. A tint of its own rather than the
|
|
52
|
+
selection's, so a selected row that matches still shows both. */
|
|
53
|
+
--match: color-mix(in srgb, var(--status-needs-you) 30%, transparent);
|
|
44
54
|
--float-shadow:
|
|
45
55
|
0 1px 2px -1px rgb(0 0 0 / 0.08),
|
|
46
56
|
0 3px 6px -2px rgb(0 0 0 / 0.07),
|
|
@@ -106,9 +116,12 @@
|
|
|
106
116
|
--bg: #0d0d10;
|
|
107
117
|
--surface: #131316;
|
|
108
118
|
--surface-raised: #18181c;
|
|
109
|
-
--surface-sunken: #
|
|
119
|
+
--surface-sunken: #0b0b0e;
|
|
110
120
|
--surface-hover: #202024;
|
|
111
121
|
--surface-active: #28282e;
|
|
122
|
+
/* Derived, so an app that tints its surfaces tints its fields along. */
|
|
123
|
+
--field: color-mix(in srgb, var(--surface) 40%, var(--surface-hover));
|
|
124
|
+
--field-border: rgb(255 255 255 / 0.1);
|
|
112
125
|
/* White at a low alpha, which is what gives a dark surface its lifted edge. */
|
|
113
126
|
--border: rgb(255 255 255 / 0.07);
|
|
114
127
|
--separator: oklch(0.236 0.006 285.956);
|
|
@@ -118,6 +131,7 @@
|
|
|
118
131
|
--text-muted: #9a9aa6;
|
|
119
132
|
--text-faint: #5f5f6b;
|
|
120
133
|
--selection: rgb(var(--accent-rgb) / 0.6);
|
|
134
|
+
--match: color-mix(in srgb, var(--status-needs-you) 28%, transparent);
|
|
121
135
|
--float-shadow:
|
|
122
136
|
0 1px 2px -1px rgb(0 0 0 / 0.3),
|
|
123
137
|
0 2px 4px rgb(0 0 0 / 0.24),
|
|
@@ -233,7 +247,10 @@
|
|
|
233
247
|
--color-surface-sunken: var(--surface-sunken);
|
|
234
248
|
--color-surface-idle: var(--surface-idle);
|
|
235
249
|
--color-surface-hover: var(--surface-hover);
|
|
250
|
+
--color-surface-hover-edged: var(--surface-hover-edged);
|
|
236
251
|
--color-surface-active: var(--surface-active);
|
|
252
|
+
--color-field: var(--field);
|
|
253
|
+
--color-field-border: var(--field-border);
|
|
237
254
|
--color-border: var(--border);
|
|
238
255
|
--color-separator: var(--separator);
|
|
239
256
|
--color-border-strong: var(--border-strong);
|
|
@@ -261,6 +278,10 @@
|
|
|
261
278
|
}
|
|
262
279
|
|
|
263
280
|
@layer base {
|
|
281
|
+
/* Whatever a click acts on shows the hand; a disabled one shows that nothing will. Menu rows keep the arrow (`.menu-item`). */
|
|
282
|
+
:where(button, a[href], summary, label[for], [role="button"], [role="checkbox"], [role="switch"], [role="radio"], [role="tab"], [role="option"]):not(:disabled, [aria-disabled="true"], [data-disabled]) {
|
|
283
|
+
cursor: pointer;
|
|
284
|
+
}
|
|
264
285
|
/*
|
|
265
286
|
* A border with an alpha has to mix with what is behind the element, not with the element's own
|
|
266
287
|
* background: by default a background runs on under the border and a node's white surface would
|
|
@@ -326,8 +347,8 @@
|
|
|
326
347
|
width: 100%;
|
|
327
348
|
padding: 0 10px;
|
|
328
349
|
border-radius: 8px;
|
|
329
|
-
border: 1px solid var(--border);
|
|
330
|
-
background: var(--
|
|
350
|
+
border: 1px solid var(--field-border);
|
|
351
|
+
background-color: var(--field);
|
|
331
352
|
color: var(--text);
|
|
332
353
|
font-size: var(--text-sm);
|
|
333
354
|
outline: none;
|
|
@@ -345,6 +366,10 @@
|
|
|
345
366
|
border-radius: 6px;
|
|
346
367
|
font-size: var(--text-xs);
|
|
347
368
|
}
|
|
369
|
+
/* A field inside a card that draws the edge and the focus for it. The border stays as a transparent
|
|
370
|
+
line, so the text sits where it would in a field of its own. */
|
|
371
|
+
.field-bare { border-color: transparent; background: transparent; }
|
|
372
|
+
.field-bare:focus-visible { outline: none; }
|
|
348
373
|
/* Keyboard focus is visible on every control; pointer focus stays quiet. `.focus-ring` is the
|
|
349
374
|
same outline for something that takes focus without being a button, such as a row in a list. */
|
|
350
375
|
.icon-btn:focus-visible, .menu-item:focus-visible, button:focus-visible, .focus-ring:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
@@ -683,6 +708,11 @@
|
|
|
683
708
|
.adecore-tree-decoration {
|
|
684
709
|
font-variant-numeric: tabular-nums;
|
|
685
710
|
}
|
|
711
|
+
/* A row's own icon in place of the tree's glyph, in the glyph's color. */
|
|
712
|
+
.adecore-tree-icon {
|
|
713
|
+
display: inline-flex;
|
|
714
|
+
color: var(--text-muted);
|
|
715
|
+
}
|
|
686
716
|
/* A tree whose rows slide sideways: every part of a row keeps its width and moves with the shift. */
|
|
687
717
|
.adecore-tree[data-overflow="scroll"] .adecore-tree-row {
|
|
688
718
|
overflow: clip;
|