@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.
Files changed (81) hide show
  1. package/dist/Button.d.ts +2 -3
  2. package/dist/Button.js +21 -46
  3. package/dist/Checkbox.js +1 -1
  4. package/dist/ChoiceCards.js +2 -2
  5. package/dist/IconPicker.d.ts +2 -1
  6. package/dist/IconPicker.js +504 -217
  7. package/dist/Input.d.ts +5 -3
  8. package/dist/Input.js +127 -112
  9. package/dist/Segmented.js +1 -1
  10. package/dist/SplitButton.d.ts +17 -0
  11. package/dist/SplitButton.js +237 -0
  12. package/dist/Stepper.js +1 -1
  13. package/dist/Switch.js +1 -1
  14. package/dist/Tile.js +2 -2
  15. package/dist/button-class.d.ts +6 -0
  16. package/dist/button-class.js +92 -0
  17. package/dist/command-palette/context.d.ts +10 -0
  18. package/dist/command-palette/context.js +5 -0
  19. package/dist/command-palette/index.parts.d.ts +1 -0
  20. package/dist/command-palette/index.parts.js +2 -1
  21. package/dist/command-palette/parts.d.ts +1 -1
  22. package/dist/command-palette/parts.js +12 -3
  23. package/dist/command-palette/virtual.d.ts +20 -0
  24. package/dist/command-palette/virtual.js +155 -0
  25. package/dist/file-tree/empty-lines.d.ts +11 -0
  26. package/dist/file-tree/empty-lines.js +89 -0
  27. package/dist/file-tree/highlight.d.ts +4 -0
  28. package/dist/file-tree/highlight.js +75 -0
  29. package/dist/file-tree/parts.d.ts +9 -1
  30. package/dist/file-tree/parts.js +297 -205
  31. package/dist/file-tree/style.js +13 -0
  32. package/dist/file-tree/use-empty-lines.d.ts +3 -0
  33. package/dist/file-tree/use-empty-lines.js +176 -0
  34. package/dist/file-tree/use-file-tree.js +5 -1
  35. package/dist/file-tree/use-slots.d.ts +16 -2
  36. package/dist/file-tree/use-slots.js +186 -43
  37. package/dist/format/index.d.ts +1 -1
  38. package/dist/format/index.js +1 -1
  39. package/dist/format/locale.d.ts +1 -0
  40. package/dist/format/locale.js +4 -5
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +1 -0
  43. package/dist/locales/en.d.ts +1 -0
  44. package/dist/locales/en.js +1 -0
  45. package/dist/locales/nl.d.ts +1 -0
  46. package/dist/locales/nl.js +1 -0
  47. package/dist/select-shared.d.ts +1 -1
  48. package/dist/select-shared.js +1 -1
  49. package/dist/settings/SettingsDialog.js +1 -1
  50. package/dist/theme.css +33 -3
  51. package/package.json +2 -1
  52. package/src/Button.tsx +3 -36
  53. package/src/Checkbox.tsx +1 -1
  54. package/src/ChoiceCards.tsx +2 -2
  55. package/src/IconPicker.tsx +220 -136
  56. package/src/Input.tsx +19 -10
  57. package/src/Segmented.tsx +1 -1
  58. package/src/SplitButton.tsx +72 -0
  59. package/src/Stepper.tsx +6 -1
  60. package/src/Switch.tsx +1 -1
  61. package/src/Tile.tsx +2 -2
  62. package/src/button-class.ts +111 -0
  63. package/src/command-palette/context.ts +16 -0
  64. package/src/command-palette/index.parts.ts +2 -0
  65. package/src/command-palette/parts.tsx +11 -12
  66. package/src/command-palette/virtual.tsx +147 -0
  67. package/src/file-tree/empty-lines.ts +105 -0
  68. package/src/file-tree/highlight.ts +80 -0
  69. package/src/file-tree/parts.tsx +42 -11
  70. package/src/file-tree/style.ts +13 -0
  71. package/src/file-tree/use-empty-lines.ts +115 -0
  72. package/src/file-tree/use-file-tree.ts +2 -1
  73. package/src/file-tree/use-slots.ts +176 -17
  74. package/src/format/index.ts +1 -1
  75. package/src/format/locale.ts +13 -7
  76. package/src/index.ts +3 -0
  77. package/src/locales/en.ts +1 -0
  78. package/src/locales/nl.ts +1 -0
  79. package/src/select-shared.ts +2 -1
  80. package/src/settings/SettingsDialog.tsx +1 -1
  81. 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>): void {
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 content = row.querySelector<HTMLElement>('[data-item-section="content"]');
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
- controlLeft: `${row.offsetLeft + (icon?.offsetLeft ?? 4) - control}px`,
62
- decorationLeft: `${row.offsetLeft + (content === null ? row.offsetWidth - decoration - 2 : content.offsetLeft + content.offsetWidth + 6)}px`
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
- next.push({ row, control: port.lightControl, decoration: port.lightDecoration });
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(({ row, control }) => `${control.slot}:${row.path}:${row.isSelected}:${row.isExpanded}:${row.isFocused}:${row.name}`)
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.rowElement.style.removeProperty('--adecore-tree-control-space');
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
  }
@@ -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,
@@ -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
- * A formatter per options object, keyed on the object itself, so a module-level spec builds its
104
- * formatter once and a list of a thousand rows constructs nothing. A region change throws the entry
105
- * away, since the locale is part of what was cached.
106
- */
107
- const cacheFor = <O extends object, T>(store: WeakMap<O, Cached<T>>, options: O, locale: string, build: (locale: string, options: O) => T): T => {
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
@@ -49,6 +49,7 @@ export default {
49
49
  iconPicker: {
50
50
  label: 'Icon',
51
51
  none: 'No icon',
52
+ placeholder: 'None',
52
53
  search: 'Search icons',
53
54
  clear: 'Clear search',
54
55
  noMatch: 'No icon matches “{{query}}”'
package/src/locales/nl.ts CHANGED
@@ -49,6 +49,7 @@ export default {
49
49
  iconPicker: {
50
50
  label: 'Icoon',
51
51
  none: 'Geen icoon',
52
+ placeholder: 'Geen',
52
53
  search: 'Iconen zoeken',
53
54
  clear: 'Zoekopdracht wissen',
54
55
  noMatch: 'Geen icoon past bij “{{query}}”'
@@ -23,7 +23,8 @@ export const TRIGGER_SIZE = {
23
23
  } as const;
24
24
 
25
25
  export const TRIGGER_VARIANT = {
26
- outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
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="bg-surface-sunken pr-12 pl-8 text-xs"
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: #08080a;
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(--surface);
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;