@adecore/ui 0.35.5 → 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 (63) 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/file-tree/empty-lines.d.ts +11 -0
  18. package/dist/file-tree/empty-lines.js +89 -0
  19. package/dist/file-tree/highlight.d.ts +4 -0
  20. package/dist/file-tree/highlight.js +75 -0
  21. package/dist/file-tree/parts.d.ts +9 -1
  22. package/dist/file-tree/parts.js +297 -205
  23. package/dist/file-tree/style.js +13 -0
  24. package/dist/file-tree/use-empty-lines.d.ts +3 -0
  25. package/dist/file-tree/use-empty-lines.js +176 -0
  26. package/dist/file-tree/use-file-tree.js +5 -1
  27. package/dist/file-tree/use-slots.d.ts +16 -2
  28. package/dist/file-tree/use-slots.js +186 -43
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/locales/en.d.ts +1 -0
  32. package/dist/locales/en.js +1 -0
  33. package/dist/locales/nl.d.ts +1 -0
  34. package/dist/locales/nl.js +1 -0
  35. package/dist/select-shared.d.ts +1 -1
  36. package/dist/select-shared.js +1 -1
  37. package/dist/settings/SettingsDialog.js +1 -1
  38. package/dist/theme.css +33 -3
  39. package/package.json +1 -1
  40. package/src/Button.tsx +3 -36
  41. package/src/Checkbox.tsx +1 -1
  42. package/src/ChoiceCards.tsx +2 -2
  43. package/src/IconPicker.tsx +220 -136
  44. package/src/Input.tsx +19 -10
  45. package/src/Segmented.tsx +1 -1
  46. package/src/SplitButton.tsx +72 -0
  47. package/src/Stepper.tsx +6 -1
  48. package/src/Switch.tsx +1 -1
  49. package/src/Tile.tsx +2 -2
  50. package/src/button-class.ts +111 -0
  51. package/src/file-tree/empty-lines.ts +105 -0
  52. package/src/file-tree/highlight.ts +80 -0
  53. package/src/file-tree/parts.tsx +42 -11
  54. package/src/file-tree/style.ts +13 -0
  55. package/src/file-tree/use-empty-lines.ts +115 -0
  56. package/src/file-tree/use-file-tree.ts +2 -1
  57. package/src/file-tree/use-slots.ts +176 -17
  58. package/src/index.ts +1 -0
  59. package/src/locales/en.ts +1 -0
  60. package/src/locales/nl.ts +1 -0
  61. package/src/select-shared.ts +2 -1
  62. package/src/settings/SettingsDialog.tsx +1 -1
  63. package/src/theme.css +33 -3
package/src/Input.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComponentProps } from 'react';
1
+ import type { ComponentProps, ReactNode } from 'react';
2
2
  import type { LucideIcon } from 'lucide-react';
3
3
  import clsx from 'clsx';
4
4
  import { fieldControlProps, useFieldControl } from './field-context.ts';
@@ -11,34 +11,40 @@ export interface InputProps extends Omit<ComponentProps<'input'>, 'size'> {
11
11
  mono?: boolean;
12
12
  /* Drawn in front of the text, such as a magnifier on a search field. `className` then goes to the box around the icon and the input; `ref` stays on the input. */
13
13
  icon?: LucideIcon;
14
+ /* Drawn after the text inside the field, such as a count of matches and an `IconButton` that clears it. `className` then goes to the box, as with `icon`. */
15
+ end?: ReactNode;
14
16
  }
15
17
 
16
18
  /* The one text input: one border and one focus ring at the one height or the compact one. */
17
- export function Input({ size = 'md', mono = false, icon, className, id, ...props }: InputProps) {
19
+ export function Input({ size = 'md', mono = false, icon, end, className, id, ...props }: InputProps) {
18
20
  const field = useFieldControl();
19
21
  const small = size === 'sm';
20
22
  const control = { ...fieldControlProps(field, id), ...props };
21
23
 
22
- if (icon === undefined) {
24
+ if (icon === undefined && end === undefined) {
23
25
  return <input {...control} className={clsx('field', small && 'field-sm', mono && 'font-mono text-code', className)} />;
24
26
  }
25
27
 
26
28
  // The input fills the whole box and the icon lets clicks through, so a click on the icon still lands in the input.
27
29
  return (
28
30
  <span className={clsx('field relative flex p-0', small && 'field-sm', className)}>
29
- <Icon
30
- icon={icon}
31
- size={small ? 12 : 14}
32
- className={clsx('pointer-events-none absolute top-1/2 -translate-y-1/2 text-text-faint', small ? 'left-2' : 'left-2.5')}
33
- />
31
+ {icon !== undefined && (
32
+ <Icon
33
+ icon={icon}
34
+ size={small ? 12 : 14}
35
+ className={clsx('pointer-events-none absolute top-1/2 -translate-y-1/2 text-text-faint', small ? 'left-2' : 'left-2.5')}
36
+ />
37
+ )}
34
38
  <input
35
39
  {...control}
36
40
  className={clsx(
37
41
  'min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint',
38
- small ? 'pr-2 pl-7' : 'pr-2.5 pl-8',
42
+ icon === undefined ? (small ? 'pl-2' : 'pl-2.5') : small ? 'pl-7' : 'pl-8',
43
+ end === undefined ? (small ? 'pr-2' : 'pr-2.5') : 'pr-1',
39
44
  mono && 'font-mono text-code'
40
45
  )}
41
46
  />
47
+ {end !== undefined && <span className={clsx('flex shrink-0 items-center gap-1', small ? 'pr-0.5' : 'pr-1')}>{end}</span>}
42
48
  </span>
43
49
  );
44
50
  }
@@ -50,10 +56,12 @@ export interface TextAreaProps extends ComponentProps<'textarea'> {
50
56
  resize?: 'none' | 'vertical';
51
57
  /* A command or anything else read back letter by letter. */
52
58
  mono?: boolean;
59
+ /* `bare` drops the border, the ground and the focus outline, for a field inside a card that draws all three around it. */
60
+ variant?: 'field' | 'bare';
53
61
  }
54
62
 
55
63
  /* A field of a few lines: a commit message, the body of a pull request, a note. At least 64px tall; `rows` makes it taller. */
56
- export function TextArea({ size = 'md', resize = 'none', mono = false, className, id, ...props }: TextAreaProps) {
64
+ export function TextArea({ size = 'md', resize = 'none', mono = false, variant = 'field', className, id, ...props }: TextAreaProps) {
57
65
  const field = useFieldControl();
58
66
  return (
59
67
  <textarea
@@ -62,6 +70,7 @@ export function TextArea({ size = 'md', resize = 'none', mono = false, className
62
70
  className={clsx(
63
71
  'field h-auto min-h-16 py-1.5',
64
72
  size === 'sm' && 'field-sm',
73
+ variant === 'bare' && 'field-bare',
65
74
  resize === 'vertical' ? 'resize-y' : 'resize-none',
66
75
  mono && 'font-mono text-code',
67
76
  className
package/src/Segmented.tsx CHANGED
@@ -69,7 +69,7 @@ export function Segmented<T extends string>({
69
69
  <div
70
70
  ref={ref}
71
71
  className={clsx(
72
- 'flex bg-surface-sunken p-0.5 text-xs font-medium',
72
+ 'flex bg-surface-sunken p-0.5 text-xs font-medium dark:bg-surface-idle',
73
73
  sizing.track,
74
74
  vertical ? 'w-full flex-col items-stretch gap-0.5' : clsx(sizing.row, 'w-fit items-center'),
75
75
  className
@@ -0,0 +1,72 @@
1
+ import { useState, type ComponentProps, type ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { ChevronDown } from 'lucide-react';
4
+ import { splitFrameClass, splitHalfClass, type ButtonSize, type ButtonVariant } from './button-class.ts';
5
+ import { Icon } from './Icon.tsx';
6
+ import { MenuPopup, MenuRoot, MenuTrigger, type MenuPopupProps } from './menu/parts.tsx';
7
+ import { mergeRefs } from './merge-refs.ts';
8
+ import { Tooltip } from './Tooltip.tsx';
9
+
10
+ export interface SplitButtonProps extends Omit<ComponentProps<'button'>, 'ref'> {
11
+ variant?: ButtonVariant;
12
+ size?: ButtonSize;
13
+ /* The hint on the action half. */
14
+ tooltip?: ReactNode;
15
+ /* The rows of the menu the chevron opens: `Menu.Item`, `Menu.Separator` and the rest of a menu's parts. */
16
+ menu: ReactNode;
17
+ /* The accessible name of the chevron, and its hint unless `menuTooltip` says something else. */
18
+ menuLabel: string;
19
+ menuTooltip?: ReactNode;
20
+ menuDisabled?: boolean;
21
+ /* Where the menu lines up under the whole control. */
22
+ menuAlign?: MenuPopupProps['align'];
23
+ /* A width or a layout of the menu's own, such as `w-72`. */
24
+ menuClassName?: string;
25
+ /* On the box around both halves; every other prop goes to the action half. */
26
+ className?: string;
27
+ ref?: ComponentProps<'span'>['ref'];
28
+ }
29
+
30
+ /*
31
+ * A primary action and a chevron that opens a menu of related ones, drawn as one button with a short
32
+ * line between the halves. Each half is a button of its own, so each takes the focus, can be disabled
33
+ * and carries its own hint; the menu hangs under the whole control.
34
+ */
35
+ export function SplitButton({
36
+ variant = 'secondary',
37
+ size = 'md',
38
+ tooltip,
39
+ menu,
40
+ menuLabel,
41
+ menuTooltip,
42
+ menuDisabled = false,
43
+ menuAlign = 'end',
44
+ menuClassName,
45
+ className,
46
+ ref,
47
+ type = 'button',
48
+ ...props
49
+ }: SplitButtonProps) {
50
+ const [box, setBox] = useState<HTMLSpanElement | null>(null);
51
+ const action = <button type={type} className={splitHalfClass(variant, size, 'action')} {...props} />;
52
+
53
+ return (
54
+ <span ref={mergeRefs<HTMLSpanElement>(ref, setBox)} className={clsx(splitFrameClass(variant, size), className)}>
55
+ {tooltip === undefined ? action : <Tooltip label={tooltip}>{action}</Tooltip>}
56
+ <MenuRoot>
57
+ <Tooltip label={menuTooltip ?? menuLabel}>
58
+ <MenuTrigger
59
+ render={<button type="button" className={splitHalfClass(variant, size, 'menu')} />}
60
+ aria-label={menuLabel}
61
+ disabled={menuDisabled}
62
+ >
63
+ <Icon icon={ChevronDown} size={14} />
64
+ </MenuTrigger>
65
+ </Tooltip>
66
+ <MenuPopup anchor={box} align={menuAlign} className={menuClassName}>
67
+ {menu}
68
+ </MenuPopup>
69
+ </MenuRoot>
70
+ </span>
71
+ );
72
+ }
package/src/Stepper.tsx CHANGED
@@ -36,7 +36,12 @@ export function Stepper({ value, onValueChange, min, max, step, decimals = place
36
36
  onValueChange(Math.min(max, Math.max(min, next)));
37
37
  };
38
38
  return (
39
- <ButtonGroup ref={ref} className={clsx('shrink-0 rounded-lg bg-surface-sunken p-0.5', className)} role="group" aria-label={label}>
39
+ <ButtonGroup
40
+ ref={ref}
41
+ className={clsx('shrink-0 rounded-lg border border-field-border bg-field bg-clip-padding p-px', className)}
42
+ role="group"
43
+ aria-label={label}
44
+ >
40
45
  <IconButton
41
46
  icon={Minus}
42
47
  size="sm"
package/src/Switch.tsx CHANGED
@@ -23,7 +23,7 @@ export function Switch({ checked, onCheckedChange, label, disabled, className, r
23
23
  disabled={disabled}
24
24
  // Base UI renders the root as a `span`, which ignores its size outside a flex row unless it lays out as a box.
25
25
  className={clsx(
26
- 'relative inline-flex h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent data-disabled:opacity-50',
26
+ 'relative inline-flex h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent dark:not-data-checked:bg-field dark:not-data-checked:inset-ring dark:not-data-checked:inset-ring-field-border data-disabled:opacity-50',
27
27
  className
28
28
  )}
29
29
  >
package/src/Tile.tsx CHANGED
@@ -24,7 +24,7 @@ export function Tile({ icon, title, description, shortcut, primary = false, size
24
24
  <button
25
25
  type={type}
26
26
  className={clsx(
27
- 'flex min-w-0 items-center border bg-surface text-left hover:bg-surface-hover disabled:opacity-50 disabled:hover:bg-surface',
27
+ 'flex min-w-0 items-center border bg-surface text-left hover:bg-surface-hover-edged disabled:opacity-50 disabled:hover:bg-surface',
28
28
  size === 'md' ? 'gap-3 rounded-xl p-3' : 'gap-2 rounded-lg px-2 py-1.5',
29
29
  primary ? 'border-accent' : 'border-border',
30
30
  className
@@ -34,7 +34,7 @@ export function Tile({ icon, title, description, shortcut, primary = false, size
34
34
  <span
35
35
  className={clsx(
36
36
  'grid size-8 shrink-0 place-items-center rounded-lg',
37
- primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted'
37
+ primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted dark:bg-field dark:inset-ring dark:inset-ring-field-border'
38
38
  )}
39
39
  >
40
40
  {icon}
@@ -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,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
  `;