@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.
- package/dist/Button.d.ts +2 -3
- package/dist/Button.js +21 -46
- package/dist/Checkbox.js +1 -1
- package/dist/ChoiceCards.js +2 -2
- package/dist/IconPicker.d.ts +2 -1
- package/dist/IconPicker.js +504 -217
- package/dist/Input.d.ts +5 -3
- package/dist/Input.js +127 -112
- package/dist/Segmented.js +1 -1
- package/dist/SplitButton.d.ts +17 -0
- package/dist/SplitButton.js +237 -0
- package/dist/Stepper.js +1 -1
- package/dist/Switch.js +1 -1
- package/dist/Tile.js +2 -2
- package/dist/button-class.d.ts +6 -0
- package/dist/button-class.js +92 -0
- package/dist/command-palette/parts.d.ts +2 -2
- package/dist/command-palette/parts.js +125 -164
- package/dist/file-tree/empty-lines.d.ts +11 -0
- package/dist/file-tree/empty-lines.js +89 -0
- package/dist/file-tree/highlight.d.ts +4 -0
- package/dist/file-tree/highlight.js +75 -0
- package/dist/file-tree/parts.d.ts +9 -1
- package/dist/file-tree/parts.js +297 -205
- package/dist/file-tree/style.js +13 -0
- package/dist/file-tree/use-empty-lines.d.ts +3 -0
- package/dist/file-tree/use-empty-lines.js +176 -0
- package/dist/file-tree/use-file-tree.js +5 -1
- package/dist/file-tree/use-slots.d.ts +16 -2
- package/dist/file-tree/use-slots.js +186 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/locales/en.d.ts +1 -0
- package/dist/locales/en.js +1 -0
- package/dist/locales/nl.d.ts +1 -0
- package/dist/locales/nl.js +1 -0
- package/dist/select-shared.d.ts +1 -1
- package/dist/select-shared.js +1 -1
- package/dist/settings/SettingsDialog.js +1 -1
- package/dist/theme.css +33 -3
- package/package.json +1 -1
- package/src/Button.tsx +3 -36
- package/src/Checkbox.tsx +1 -1
- package/src/ChoiceCards.tsx +2 -2
- package/src/IconPicker.tsx +220 -136
- package/src/Input.tsx +19 -10
- package/src/Segmented.tsx +1 -1
- package/src/SplitButton.tsx +72 -0
- package/src/Stepper.tsx +6 -1
- package/src/Switch.tsx +1 -1
- package/src/Tile.tsx +2 -2
- package/src/button-class.ts +111 -0
- package/src/command-palette/parts.tsx +34 -2
- package/src/file-tree/empty-lines.ts +105 -0
- package/src/file-tree/highlight.ts +80 -0
- package/src/file-tree/parts.tsx +42 -11
- package/src/file-tree/style.ts +13 -0
- package/src/file-tree/use-empty-lines.ts +115 -0
- package/src/file-tree/use-file-tree.ts +2 -1
- package/src/file-tree/use-slots.ts +176 -17
- package/src/index.ts +1 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/nl.ts +1 -0
- package/src/select-shared.ts +2 -1
- package/src/settings/SettingsDialog.tsx +1 -1
- 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 ? '
|
|
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
|
|
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
|
+
}
|