@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,111 @@
1
+ import clsx from 'clsx';
2
+
3
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-outline' | 'positive' | 'inverse';
4
+ export type ButtonSize = 'xs' | 'sm' | 'md';
5
+ export type ButtonShape = 'default' | 'pill';
6
+
7
+ const BASE = 'inline-flex shrink-0 items-center justify-center gap-1.5 font-medium disabled:opacity-50';
8
+
9
+ const VARIANT: Record<ButtonVariant, string> = {
10
+ // A filled button darkens under the pointer. Its color is the meaning, so no token of its own for hover.
11
+ primary: 'bg-accent text-accent-text hover:brightness-90 disabled:hover:brightness-100',
12
+ // Drawn like a field, so it stands a step off any ground, a dialog's raised one included.
13
+ secondary: 'border border-field-border bg-field text-text hover:bg-surface-hover disabled:hover:bg-field',
14
+ ghost: 'text-text-muted hover:bg-surface-hover hover:text-text',
15
+ danger: 'bg-status-error text-accent-text hover:brightness-90 disabled:hover:brightness-100',
16
+ // A step that forgets something rather than destroys it: softer than the filled button of a deletion that cannot come back.
17
+ 'danger-outline': 'border border-status-error/35 text-status-error hover:bg-status-error/10 disabled:hover:bg-transparent',
18
+ positive: 'bg-positive text-positive-text hover:brightness-90 disabled:hover:brightness-100',
19
+ // Dark on a light theme, light on a dark one, matching what Apple's and GitHub's sign-in buttons ask for.
20
+ inverse: 'bg-text text-bg hover:opacity-90 disabled:hover:opacity-100'
21
+ };
22
+
23
+ /* 28 and 32 pixels are the compact and the normal control; 24 fits a header of 32. */
24
+ const SIZE: Record<ButtonSize, string> = {
25
+ xs: 'h-6 px-2',
26
+ sm: 'h-7 px-2.5',
27
+ md: 'h-8 px-3'
28
+ };
29
+
30
+ /* A pill is one size of its own, for a control that sits in a round row, so it takes the place of `size`. */
31
+ const SHAPE: Record<ButtonShape, string> = {
32
+ default: 'rounded-md text-xs',
33
+ pill: 'h-9 rounded-full px-3.5 text-sm'
34
+ };
35
+
36
+ export function buttonClass(variant: ButtonVariant, size: ButtonSize, shape: ButtonShape): string {
37
+ return clsx(BASE, SHAPE[shape], VARIANT[variant], shape === 'default' && SIZE[size]);
38
+ }
39
+
40
+ /*
41
+ * A split button is one shape: the frame draws the radius, the outline of an outlined variant and the
42
+ * height, and clips both halves, which carry the ground and its hover each on their own.
43
+ */
44
+ const SPLIT_FRAME: Record<ButtonVariant, string> = {
45
+ primary: '',
46
+ secondary: 'border border-field-border',
47
+ ghost: '',
48
+ danger: '',
49
+ 'danger-outline': 'border border-status-error/35',
50
+ positive: '',
51
+ inverse: ''
52
+ };
53
+
54
+ const SPLIT_HALF: Record<ButtonVariant, string> = {
55
+ primary: VARIANT.primary,
56
+ secondary: 'bg-field text-text hover:bg-surface-hover disabled:hover:bg-field',
57
+ ghost: VARIANT.ghost,
58
+ danger: VARIANT.danger,
59
+ 'danger-outline': 'text-status-error hover:bg-status-error/10 disabled:hover:bg-transparent',
60
+ positive: VARIANT.positive,
61
+ inverse: VARIANT.inverse
62
+ };
63
+
64
+ /* The short line between the halves: a light alpha of the text over a filled ground, the border's alpha elsewhere. */
65
+ const SPLIT_SEPARATOR: Record<ButtonVariant, string> = {
66
+ primary: 'before:bg-current/30',
67
+ secondary: 'before:bg-border-strong',
68
+ ghost: 'before:bg-border-strong',
69
+ danger: 'before:bg-current/30',
70
+ 'danger-outline': 'before:bg-status-error/35',
71
+ positive: 'before:bg-current/30',
72
+ inverse: 'before:bg-current/30'
73
+ };
74
+
75
+ const SPLIT_HEIGHT: Record<ButtonSize, string> = {
76
+ xs: 'h-6',
77
+ sm: 'h-7',
78
+ md: 'h-8'
79
+ };
80
+
81
+ /* Half the height of the button, centered by auto margins, which land on whole pixels in the bordered frames too. */
82
+ const SPLIT_SEPARATOR_HEIGHT: Record<ButtonSize, string> = {
83
+ xs: 'before:h-3',
84
+ sm: 'before:h-3.5',
85
+ md: 'before:h-4'
86
+ };
87
+
88
+ const SPLIT_PADDING: Record<'action' | 'menu', Record<ButtonSize, string>> = {
89
+ action: { xs: 'px-2', sm: 'px-2.5', md: 'px-3' },
90
+ // The chevron half is narrower than a half with a word in it.
91
+ menu: { xs: 'px-1', sm: 'px-1.5', md: 'px-2' }
92
+ };
93
+
94
+ export function splitFrameClass(variant: ButtonVariant, size: ButtonSize): string {
95
+ return clsx('inline-flex shrink-0 items-stretch overflow-clip rounded-md', SPLIT_HEIGHT[size], SPLIT_FRAME[variant]);
96
+ }
97
+
98
+ /* The frame clips the focus outline, so a half draws it inside its own edge. */
99
+ export function splitHalfClass(variant: ButtonVariant, size: ButtonSize, half: 'action' | 'menu'): string {
100
+ return clsx(
101
+ 'inline-flex items-center justify-center gap-1.5 text-xs font-medium focus-visible:-outline-offset-2 disabled:opacity-50',
102
+ SPLIT_HALF[variant],
103
+ SPLIT_PADDING[half][size],
104
+ half === 'menu' &&
105
+ clsx(
106
+ 'relative before:absolute before:inset-y-0 before:left-0 before:my-auto before:w-px before:content-[""]',
107
+ SPLIT_SEPARATOR_HEIGHT[size],
108
+ SPLIT_SEPARATOR[variant]
109
+ )
110
+ );
111
+ }
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext, type RefObject } from 'react';
2
+
3
+ interface PaletteRefs {
4
+ /* For the popup to put the focus in when it opens: a control before it would take it otherwise. */
5
+ input: RefObject<HTMLInputElement | null>;
6
+ /* For the root to scroll back to the top when the app sets the query. */
7
+ list: RefObject<HTMLDivElement | null>;
8
+ highlight(row: Element): void;
9
+ virtualScroll: RefObject<((index: number, reason: string) => void) | null>;
10
+ }
11
+
12
+ export const PaletteRefsContext = createContext<PaletteRefs | null>(null);
13
+
14
+ export function useCommandPaletteVirtualScroll(): PaletteRefs['virtualScroll'] | null {
15
+ return useContext(PaletteRefsContext)?.virtualScroll ?? null;
16
+ }
@@ -14,3 +14,5 @@ export {
14
14
  CommandPalettePopup as Popup,
15
15
  CommandPaletteRoot as Root
16
16
  } from './parts.tsx';
17
+
18
+ export { CommandPaletteVirtualList as VirtualList } from './virtual.tsx';
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react';
1
+ import { useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
2
2
  import { flushSync } from 'react-dom';
3
3
  import clsx from 'clsx';
4
4
  import { Search } from 'lucide-react';
@@ -11,16 +11,7 @@ import { Icon } from '../Icon.tsx';
11
11
  import { Kbd } from '../Kbd.tsx';
12
12
  import { mergeRefs } from '../merge-refs.ts';
13
13
  import { KEY_SHORTCUTS } from '../shortcut.ts';
14
-
15
- interface PaletteRefs {
16
- /* For the popup to put the focus in when it opens: a control before it would take it otherwise. */
17
- input: RefObject<HTMLInputElement | null>;
18
- /* For the root to scroll back to the top when the app sets the query. */
19
- list: RefObject<HTMLDivElement | null>;
20
- highlight(row: Element): void;
21
- }
22
-
23
- const PaletteRefsContext = createContext<PaletteRefs | null>(null);
14
+ import { PaletteRefsContext } from './context.ts';
24
15
 
25
16
  const NO_ITEMS: readonly never[] = [];
26
17
 
@@ -58,11 +49,13 @@ export function CommandPaletteRoot<ItemValue>({
58
49
  value,
59
50
  onValueChange,
60
51
  items,
52
+ onItemHighlighted,
61
53
  children,
62
54
  ...props
63
55
  }: CommandPaletteRootProps<ItemValue>) {
64
56
  const inputRef = useRef<HTMLInputElement>(null);
65
57
  const listRef = useRef<HTMLDivElement>(null);
58
+ const virtualScroll = useRef<((index: number, reason: string) => void) | null>(null);
66
59
  const [focusHighlight, setFocusHighlight] = useState(false);
67
60
  /*
68
61
  * Base UI moves the highlight to the first row for a typed query only. For a query the app sets,
@@ -104,7 +97,7 @@ export function CommandPaletteRoot<ItemValue>({
104
97
 
105
98
  return (
106
99
  <DialogRoot open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
107
- <PaletteRefsContext value={{ input: inputRef, list: listRef, highlight }}>
100
+ <PaletteRefsContext value={{ input: inputRef, list: listRef, highlight, virtualScroll }}>
108
101
  {/* Its props take a flat list or groups; only its overloads split the two, which a generic caller cannot pick between. */}
109
102
  <Autocomplete.Root<ItemValue>
110
103
  {...props}
@@ -116,6 +109,12 @@ export function CommandPaletteRoot<ItemValue>({
116
109
  onValueChange?.(next, details);
117
110
  }}
118
111
  items={(resetting ? NO_ITEMS : items) as readonly ItemValue[] | undefined}
112
+ onItemHighlighted={(item, details) => {
113
+ if (item !== undefined && virtualScroll.current !== null) {
114
+ queueMicrotask(() => virtualScroll.current?.(details.index, details.reason));
115
+ }
116
+ onItemHighlighted?.(item, details);
117
+ }}
119
118
  inline
120
119
  open
121
120
  autoHighlight={autoHighlight}
@@ -0,0 +1,147 @@
1
+ import { Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from 'react';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { Autocomplete } from '@base-ui/react/autocomplete';
4
+ import { CommandPaletteList } from './parts.tsx';
5
+ import { useCommandPaletteVirtualScroll } from './context.ts';
6
+ import { MENU_LABEL } from '../menu/parts.tsx';
7
+ import { mergeRefs } from '../merge-refs.ts';
8
+
9
+ export interface CommandPaletteVirtualItemProps {
10
+ index: number;
11
+ 'aria-setsize': number;
12
+ 'aria-posinset': number;
13
+ }
14
+
15
+ export type CommandPaletteVirtualListProps<ItemValue, Group extends { items: readonly ItemValue[] } = { items: readonly ItemValue[] }> = Omit<
16
+ ComponentProps<typeof CommandPaletteList>,
17
+ 'children'
18
+ > & {
19
+ children(item: ItemValue, props: CommandPaletteVirtualItemProps): ReactNode;
20
+ groupLabel?(group: Group): ReactNode;
21
+ };
22
+
23
+ interface VirtualPaletteRow<ItemValue> {
24
+ group: number;
25
+ index: number;
26
+ item?: ItemValue;
27
+ }
28
+
29
+ function virtualRowsOf<ItemValue, Group extends { items: readonly ItemValue[] }>(values: readonly (ItemValue | Group)[], labelled: boolean) {
30
+ const groups: (Group | null)[] = [];
31
+ const rows: VirtualPaletteRow<ItemValue>[] = [];
32
+ const itemRows: number[] = [];
33
+ let count = 0;
34
+ for (const value of values) {
35
+ const grouped = value !== null && typeof value === 'object' && 'items' in value && Array.isArray(value.items);
36
+ const group = grouped ? (value as Group) : null;
37
+ if (group !== null && group.items.length === 0) {
38
+ continue;
39
+ }
40
+ const groupIndex = groups.length;
41
+ groups.push(group);
42
+ if (group !== null && labelled) {
43
+ rows.push({ group: groupIndex, index: -1 });
44
+ }
45
+ for (const item of group === null ? [value as ItemValue] : group.items) {
46
+ itemRows.push(rows.length);
47
+ rows.push({ group: groupIndex, index: count++, item });
48
+ }
49
+ }
50
+ return { groups, rows, itemRows, count };
51
+ }
52
+
53
+ /* The root retains every value for keyboard navigation; only the visible rows need DOM elements. */
54
+ export function CommandPaletteVirtualList<ItemValue, Group extends { items: readonly ItemValue[] } = { items: readonly ItemValue[] }>({
55
+ children,
56
+ groupLabel,
57
+ ref,
58
+ ...props
59
+ }: CommandPaletteVirtualListProps<ItemValue, Group>) {
60
+ // The virtualizer keeps a mutable handle; every render reads its current window.
61
+ 'use no memo';
62
+ const scroll = useCommandPaletteVirtualScroll();
63
+ const filtered = Autocomplete.useFilteredItems<ItemValue | Group>();
64
+ const scrollRef = useRef<HTMLDivElement | null>(null);
65
+ const labelled = groupLabel !== undefined;
66
+ const { groups, rows, itemRows, count } = useMemo(() => virtualRowsOf<ItemValue, Group>(filtered, labelled), [filtered, labelled]);
67
+ // oxlint-disable-next-line react/incompatible-library
68
+ const virtualizer = useVirtualizer({
69
+ count: rows.length,
70
+ getScrollElement: () => scrollRef.current,
71
+ estimateSize: (index) => (rows[index]!.index === -1 ? 28 : 32),
72
+ overscan: 8,
73
+ initialRect: { width: 576, height: 320 },
74
+ scrollPaddingStart: 6,
75
+ scrollPaddingEnd: 6
76
+ });
77
+ useLayoutEffect(() => {
78
+ if (scroll === null) {
79
+ return;
80
+ }
81
+ const highlight = (index: number, reason: string): void => {
82
+ const row = itemRows[index];
83
+ if (row !== undefined && reason !== 'pointer') {
84
+ virtualizer.scrollToIndex(row, { align: reason === 'none' ? 'start' : 'auto' });
85
+ }
86
+ };
87
+ scroll.current = highlight;
88
+ return () => {
89
+ if (scroll.current === highlight) {
90
+ scroll.current = null;
91
+ }
92
+ };
93
+ }, [scroll, virtualizer, itemRows]);
94
+ const visible = virtualizer.getVirtualItems();
95
+ const visibleGroups = new Map<number, typeof visible>();
96
+ for (const item of visible) {
97
+ const group = rows[item.index]!.group;
98
+ const current = visibleGroups.get(group);
99
+ if (current === undefined) {
100
+ visibleGroups.set(group, [item]);
101
+ } else {
102
+ current.push(item);
103
+ }
104
+ }
105
+ const renderRows = (items: typeof visible): ReactNode =>
106
+ items.map((item) => {
107
+ const row = rows[item.index]!;
108
+ const group = groups[row.group];
109
+ return (
110
+ <div
111
+ key={item.key}
112
+ role="presentation"
113
+ data-index={item.index}
114
+ ref={virtualizer.measureElement}
115
+ className="absolute top-0 left-0 w-full"
116
+ style={{ transform: `translateY(${item.start}px)` }}
117
+ >
118
+ {row.index === -1 && group !== null && group !== undefined ? (
119
+ <Autocomplete.GroupLabel className={MENU_LABEL}>{groupLabel?.(group)}</Autocomplete.GroupLabel>
120
+ ) : (
121
+ children(row.item as ItemValue, { index: row.index, 'aria-posinset': row.index + 1, 'aria-setsize': count })
122
+ )}
123
+ </div>
124
+ );
125
+ });
126
+ return (
127
+ <CommandPaletteList {...props} ref={mergeRefs(ref, scrollRef)}>
128
+ <div role="presentation" className="relative" style={{ height: virtualizer.getTotalSize() }}>
129
+ {[...visibleGroups].map(([index, items]) => {
130
+ const group = groups[index];
131
+ if (group === null || group === undefined) {
132
+ return <Fragment key={index}>{renderRows(items)}</Fragment>;
133
+ }
134
+ const labelVisible = items.some((item) => rows[item.index]!.index === -1);
135
+ return (
136
+ <Autocomplete.Group key={index} items={group.items}>
137
+ {!labelVisible && groupLabel !== undefined && (
138
+ <Autocomplete.GroupLabel className="sr-only">{groupLabel(group)}</Autocomplete.GroupLabel>
139
+ )}
140
+ {renderRows(items)}
141
+ </Autocomplete.Group>
142
+ );
143
+ })}
144
+ </div>
145
+ </CommandPaletteList>
146
+ );
147
+ }
@@ -0,0 +1,105 @@
1
+ import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
2
+ import { pathOfRow } from './helpers.ts';
3
+
4
+ /*
5
+ * The engine draws only rows it holds, so the line under an open, empty directory is a child the
6
+ * wrapper adds and removes itself. U+FDD0 is a noncharacter, which no real file name carries.
7
+ */
8
+ const EMPTY_LINE_NAME = '﷐empty';
9
+
10
+ export type FileTreeEmptyLabel = (row: FileTreeVisibleRow) => string | undefined;
11
+
12
+ export function emptyLineOf(directory: string): string {
13
+ return `${directory}${EMPTY_LINE_NAME}`;
14
+ }
15
+
16
+ export function isEmptyLine(path: string): boolean {
17
+ return path.endsWith(`/${EMPTY_LINE_NAME}`);
18
+ }
19
+
20
+ export function directoryOfEmptyLine(path: string): string {
21
+ return path.slice(0, -EMPTY_LINE_NAME.length);
22
+ }
23
+
24
+ /*
25
+ * The lines to add and the ones to take away: an open directory whose label is not undefined and
26
+ * that holds nothing besides its own line gets one, every other line the wrapper added goes.
27
+ */
28
+ export function emptyLineChanges(
29
+ rows: readonly FileTreeVisibleRow[],
30
+ labelOf: FileTreeEmptyLabel,
31
+ has: (path: string) => boolean,
32
+ added: Iterable<string>
33
+ ): { add: string[]; remove: string[] } {
34
+ const wanted = new Set<string>();
35
+ rows.forEach((row, index) => {
36
+ if (row.kind !== 'directory' || !row.isExpanded) {
37
+ return;
38
+ }
39
+ const line = emptyLineOf(row.path);
40
+ for (let next = index + 1; next < rows.length && rows[next]!.depth > row.depth; next++) {
41
+ if (rows[next]!.path !== line) {
42
+ return;
43
+ }
44
+ }
45
+ if (labelOf(row) !== undefined) {
46
+ wanted.add(line);
47
+ }
48
+ });
49
+ return {
50
+ add: [...wanted].filter((line) => !has(line)),
51
+ remove: [...added].filter((line) => !wanted.has(line) && has(line))
52
+ };
53
+ }
54
+
55
+ /*
56
+ * Where an arrow, Home or End goes instead when the row it would land on is a line: past it, or
57
+ * nowhere (the focused path itself) where nothing lies past it. Null when no line is in the way.
58
+ */
59
+ export function pastEmptyLine(model: FileTree, key: string): string | null {
60
+ const index = model.getFocusedIndex();
61
+ const focused = model.getFocusedPath();
62
+ if (index < 0 || focused === null) {
63
+ return null;
64
+ }
65
+ const count = model.getVisibleCount();
66
+ const at = (position: number): FileTreeVisibleRow | null =>
67
+ position >= 0 && position < count ? (model.getVisibleRows(position, position)[0] ?? null) : null;
68
+ const lineAt = (position: number): boolean => {
69
+ const row = at(position);
70
+ return row !== null && isEmptyLine(row.path);
71
+ };
72
+ const pathAt = (position: number): string => {
73
+ const row = at(position);
74
+ return row === null ? focused : pathOfRow(row);
75
+ };
76
+ if ((key === 'ArrowDown' || key === 'ArrowRight') && lineAt(index + 1)) {
77
+ return key === 'ArrowDown' ? pathAt(index + 2) : focused;
78
+ }
79
+ if (key === 'ArrowUp' && lineAt(index - 1)) {
80
+ return pathAt(index - 2);
81
+ }
82
+ if (key === 'End' && lineAt(count - 1)) {
83
+ return pathAt(count - 2);
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /* Focus that still lands on a line, through a key with a modifier or the engine giving focus back to the line's element, goes on the way the last key went. */
89
+ export function skipEmptyLine(model: FileTree, key: string): boolean {
90
+ const path = model.getFocusedPath();
91
+ if (path === null || !isEmptyLine(path)) {
92
+ return false;
93
+ }
94
+ if (key === 'ArrowUp' || key === 'PageUp') {
95
+ model.focusPreviousItem();
96
+ return true;
97
+ }
98
+ if (key === 'ArrowDown' || key === 'PageDown' || key === 'End') {
99
+ model.focusNextItem();
100
+ }
101
+ if (model.getFocusedPath() === path) {
102
+ model.focusPath(directoryOfEmptyLine(path));
103
+ }
104
+ return true;
105
+ }
@@ -0,0 +1,80 @@
1
+ /* The name a tree's shadow stylesheet colors with `::highlight()`. One registry entry is shared by every tree on the page. */
2
+ export const MATCH_HIGHLIGHT = 'adecore-tree-match';
3
+
4
+ // The copies of a name the engine keeps for truncation, which the tree's stylesheet hides.
5
+ const HIDDEN_PARTS = '[data-truncate-content="overflow"], [data-truncate-marker-cell], [data-truncate-fill]';
6
+
7
+ let shared: Highlight | null = null;
8
+
9
+ /* Null where the browser has no CSS Custom Highlight API; the names are then drawn without a mark. */
10
+ export function matchHighlight(): Highlight | null {
11
+ if (typeof CSS === 'undefined' || !('highlights' in CSS) || typeof Highlight === 'undefined') {
12
+ return null;
13
+ }
14
+ if (shared === null) {
15
+ shared = new Highlight();
16
+ }
17
+ if (CSS.highlights.get(MATCH_HIGHLIGHT) !== shared) {
18
+ CSS.highlights.set(MATCH_HIGHLIGHT, shared);
19
+ }
20
+ return shared;
21
+ }
22
+
23
+ /* Every place `query` occurs in `text`, ignoring case, as `[start, end)` pairs that do not overlap. */
24
+ export function matchRanges(text: string, query: string): [number, number][] {
25
+ const wanted = query.toLowerCase();
26
+ const haystack = text.toLowerCase();
27
+ // A letter that lowercases to more than one would shift every offset after it.
28
+ if (wanted === '' || haystack.length !== text.length) {
29
+ return [];
30
+ }
31
+ const found: [number, number][] = [];
32
+ let at = haystack.indexOf(wanted);
33
+ while (at !== -1) {
34
+ found.push([at, at + wanted.length]);
35
+ at = haystack.indexOf(wanted, at + wanted.length);
36
+ }
37
+ return found;
38
+ }
39
+
40
+ /* The ranges over the visible text of each row's name where `query` occurs. */
41
+ export function rangesOfMatches(rows: readonly HTMLElement[], query: string): Range[] {
42
+ const ranges: Range[] = [];
43
+ if (query === '') {
44
+ return ranges;
45
+ }
46
+ for (const row of rows) {
47
+ const content = row.querySelector('[data-item-section="content"]');
48
+ if (content === null) {
49
+ continue;
50
+ }
51
+ const nodes: Text[] = [];
52
+ const walker = row.ownerDocument.createTreeWalker(content, NodeFilter.SHOW_TEXT);
53
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
54
+ if (node.parentElement?.closest(HIDDEN_PARTS) === null) {
55
+ nodes.push(node as Text);
56
+ }
57
+ }
58
+ const starts: number[] = [];
59
+ let text = '';
60
+ for (const node of nodes) {
61
+ starts.push(text.length);
62
+ text += node.data;
63
+ }
64
+ // The node an offset falls in, and the offset inside it; an end may sit at the end of a node.
65
+ const locate = (offset: number, end: boolean): [Text, number] => {
66
+ let index = nodes.length - 1;
67
+ while (index > 0 && (end ? starts[index]! >= offset : starts[index]! > offset)) {
68
+ index--;
69
+ }
70
+ return [nodes[index]!, offset - starts[index]!];
71
+ };
72
+ for (const [start, end] of matchRanges(text, query)) {
73
+ const range = row.ownerDocument.createRange();
74
+ range.setStart(...locate(start, false));
75
+ range.setEnd(...locate(end, true));
76
+ ranges.push(range);
77
+ }
78
+ }
79
+ return ranges;
80
+ }
@@ -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
  `;