@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.
Files changed (66) 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/parts.d.ts +2 -2
  18. package/dist/command-palette/parts.js +125 -164
  19. package/dist/file-tree/empty-lines.d.ts +11 -0
  20. package/dist/file-tree/empty-lines.js +89 -0
  21. package/dist/file-tree/highlight.d.ts +4 -0
  22. package/dist/file-tree/highlight.js +75 -0
  23. package/dist/file-tree/parts.d.ts +9 -1
  24. package/dist/file-tree/parts.js +297 -205
  25. package/dist/file-tree/style.js +13 -0
  26. package/dist/file-tree/use-empty-lines.d.ts +3 -0
  27. package/dist/file-tree/use-empty-lines.js +176 -0
  28. package/dist/file-tree/use-file-tree.js +5 -1
  29. package/dist/file-tree/use-slots.d.ts +16 -2
  30. package/dist/file-tree/use-slots.js +186 -43
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/locales/en.d.ts +1 -0
  34. package/dist/locales/en.js +1 -0
  35. package/dist/locales/nl.d.ts +1 -0
  36. package/dist/locales/nl.js +1 -0
  37. package/dist/select-shared.d.ts +1 -1
  38. package/dist/select-shared.js +1 -1
  39. package/dist/settings/SettingsDialog.js +1 -1
  40. package/dist/theme.css +33 -3
  41. package/package.json +1 -1
  42. package/src/Button.tsx +3 -36
  43. package/src/Checkbox.tsx +1 -1
  44. package/src/ChoiceCards.tsx +2 -2
  45. package/src/IconPicker.tsx +220 -136
  46. package/src/Input.tsx +19 -10
  47. package/src/Segmented.tsx +1 -1
  48. package/src/SplitButton.tsx +72 -0
  49. package/src/Stepper.tsx +6 -1
  50. package/src/Switch.tsx +1 -1
  51. package/src/Tile.tsx +2 -2
  52. package/src/button-class.ts +111 -0
  53. package/src/command-palette/parts.tsx +34 -2
  54. package/src/file-tree/empty-lines.ts +105 -0
  55. package/src/file-tree/highlight.ts +80 -0
  56. package/src/file-tree/parts.tsx +42 -11
  57. package/src/file-tree/style.ts +13 -0
  58. package/src/file-tree/use-empty-lines.ts +115 -0
  59. package/src/file-tree/use-file-tree.ts +2 -1
  60. package/src/file-tree/use-slots.ts +176 -17
  61. package/src/index.ts +1 -0
  62. package/src/locales/en.ts +1 -0
  63. package/src/locales/nl.ts +1 -0
  64. package/src/select-shared.ts +2 -1
  65. package/src/settings/SettingsDialog.tsx +1 -1
  66. package/src/theme.css +33 -3
@@ -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 slots = useFileTreeSlots(model, frame, label ?? t('tree.label'));
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
- } else if (event.key === 'Enter' && !event.altKey && !event.metaKey && !event.ctrlKey) {
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
- <span key={control.slot} style={{ display: 'contents' }}>
188
- {createPortal(renderControl?.(row), control)}
189
- {createPortal(renderDecoration?.(row), decoration)}
190
- </span>
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
  </>
@@ -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>): 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
  }
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
@@ -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}