@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
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react';
2
+ import { flushSync } from 'react-dom';
2
3
  import clsx from 'clsx';
3
4
  import { Search } from 'lucide-react';
4
5
  import { useTranslation } from 'react-i18next';
@@ -16,6 +17,7 @@ interface PaletteRefs {
16
17
  input: RefObject<HTMLInputElement | null>;
17
18
  /* For the root to scroll back to the top when the app sets the query. */
18
19
  list: RefObject<HTMLDivElement | null>;
20
+ highlight(row: Element): void;
19
21
  }
20
22
 
21
23
  const PaletteRefsContext = createContext<PaletteRefs | null>(null);
@@ -52,6 +54,7 @@ export function CommandPaletteRoot<ItemValue>({
52
54
  onOpenChangeComplete,
53
55
  autoHighlight = 'always',
54
56
  keepHighlight = true,
57
+ highlightItemOnHover = true,
55
58
  value,
56
59
  onValueChange,
57
60
  items,
@@ -60,6 +63,7 @@ export function CommandPaletteRoot<ItemValue>({
60
63
  }: CommandPaletteRootProps<ItemValue>) {
61
64
  const inputRef = useRef<HTMLInputElement>(null);
62
65
  const listRef = useRef<HTMLDivElement>(null);
66
+ const [focusHighlight, setFocusHighlight] = useState(false);
63
67
  /*
64
68
  * Base UI moves the highlight to the first row for a typed query only. For a query the app sets,
65
69
  * the list is empty for one commit that is never painted: Base UI drops the highlight then and
@@ -80,9 +84,27 @@ export function CommandPaletteRoot<ItemValue>({
80
84
  setSettledValue(value);
81
85
  }, [resetting, value]);
82
86
 
87
+ const highlight = (row: Element): void => {
88
+ if (row.hasAttribute('data-highlighted')) {
89
+ return;
90
+ }
91
+ // Base UI has no imperative highlight action and disables highlighting on item focus.
92
+ // Enable pointer navigation just for this event when the app has disabled hover highlighting.
93
+ if (!highlightItemOnHover) {
94
+ flushSync(() => setFocusHighlight(true));
95
+ }
96
+ try {
97
+ row.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, movementX: 1 }));
98
+ } finally {
99
+ if (!highlightItemOnHover) {
100
+ flushSync(() => setFocusHighlight(false));
101
+ }
102
+ }
103
+ };
104
+
83
105
  return (
84
106
  <DialogRoot open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
85
- <PaletteRefsContext value={{ input: inputRef, list: listRef }}>
107
+ <PaletteRefsContext value={{ input: inputRef, list: listRef, highlight }}>
86
108
  {/* Its props take a flat list or groups; only its overloads split the two, which a generic caller cannot pick between. */}
87
109
  <Autocomplete.Root<ItemValue>
88
110
  {...props}
@@ -98,6 +120,7 @@ export function CommandPaletteRoot<ItemValue>({
98
120
  open
99
121
  autoHighlight={autoHighlight}
100
122
  keepHighlight={keepHighlight}
123
+ highlightItemOnHover={highlightItemOnHover || focusHighlight}
101
124
  >
102
125
  {children}
103
126
  </Autocomplete.Root>
@@ -159,7 +182,7 @@ export function CommandPaletteInput({ leading, trailing, escapeHint = true, mono
159
182
  }
160
183
 
161
184
  /* The rows. It scrolls on its own, so the field and the footer stay put. */
162
- export function CommandPaletteList({ className, ref, ...props }: ComponentProps<typeof Autocomplete.List>) {
185
+ export function CommandPaletteList({ className, ref, onFocus, ...props }: ComponentProps<typeof Autocomplete.List>) {
163
186
  const { t } = useTranslation('ui');
164
187
  const refs = useContext(PaletteRefsContext);
165
188
  return (
@@ -168,6 +191,15 @@ export function CommandPaletteList({ className, ref, ...props }: ComponentProps<
168
191
  aria-label={t('palette.results')}
169
192
  className={withClass('max-h-[50vh] min-h-0 overflow-y-auto overscroll-contain scroll-py-1.5 not-empty:p-1.5', className)}
170
193
  {...props}
194
+ onFocus={(event) => {
195
+ onFocus?.(event);
196
+ const row = event.target.closest('[role="option"]');
197
+ if (row === null || !event.currentTarget.contains(row) || refs?.input.current == null) {
198
+ return;
199
+ }
200
+ refs.highlight(row);
201
+ refs.input.current.focus({ preventScroll: true });
202
+ }}
171
203
  />
172
204
  );
173
205
  }
@@ -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
+ }