@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/dist/select-shared.js
CHANGED
|
@@ -5,7 +5,7 @@ export const TRIGGER_SIZE = {
|
|
|
5
5
|
md: 'h-8 gap-1.5 px-2'
|
|
6
6
|
};
|
|
7
7
|
export const TRIGGER_VARIANT = {
|
|
8
|
-
outlined: 'max-w-56 rounded-lg border border-border bg-surface-
|
|
8
|
+
outlined: 'max-w-56 rounded-lg border border-field-border bg-field text-text hover:bg-surface-hover data-[popup-open]:bg-surface-hover data-disabled:hover:bg-field',
|
|
9
9
|
ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
|
|
10
10
|
};
|
|
11
11
|
/* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
|
|
@@ -241,7 +241,7 @@ function SearchField(t0) {
|
|
|
241
241
|
if ($[15] !== query || $[16] !== t4 || $[17] !== t5 || $[18] !== t6 || $[19] !== t7) {
|
|
242
242
|
t8 = /*#__PURE__*/_jsx(Input, {
|
|
243
243
|
ref: input,
|
|
244
|
-
className: "
|
|
244
|
+
className: "pr-12 pl-8 text-xs",
|
|
245
245
|
placeholder: t4,
|
|
246
246
|
"aria-label": t5,
|
|
247
247
|
value: query,
|
package/dist/theme.css
CHANGED
|
@@ -17,9 +17,16 @@
|
|
|
17
17
|
than the pressed step, so hover reads as a lift and pressed as the heavier state. A white
|
|
18
18
|
popup cannot go lighter, so in this theme the step is the softest gray there is. */
|
|
19
19
|
--surface-hover: #f3f3f5;
|
|
20
|
+
/* The hover of something that draws its own border: a smaller step, so the ground never passes
|
|
21
|
+
its edge and the outline stays visible under the pointer. */
|
|
22
|
+
--surface-hover-edged: color-mix(in srgb, var(--surface-raised) 60%, var(--surface-hover));
|
|
20
23
|
/* The pressed step of a toggle. It cannot be `--surface-raised`, which equals `--surface` in the
|
|
21
24
|
light theme, and it has to sit a shade past `--surface-hover` so pressed outranks hover. */
|
|
22
25
|
--surface-active: #dcdce2;
|
|
26
|
+
/* What a person types or picks in: a step lighter than the ground it stands on in the dark theme, the raised white here. */
|
|
27
|
+
--field: var(--surface-raised);
|
|
28
|
+
/* An edge of its own, since an app may set `--border` to a solid separator that vanishes against a field. */
|
|
29
|
+
--field-border: rgb(0 0 0 / 0.11);
|
|
23
30
|
/* Borders carry an alpha rather than a gray of their own, so a line takes the color of whatever
|
|
24
31
|
it is drawn over instead of fighting it. A gray hairline over a tinted surface reads as a
|
|
25
32
|
smudge; the same line in black at a low alpha stays a line. Everything with a surface of its
|
|
@@ -41,6 +48,9 @@
|
|
|
41
48
|
--accent-soft: color-mix(in srgb, var(--accent) 16%, var(--surface));
|
|
42
49
|
--accent-text: #ffffff;
|
|
43
50
|
--selection: rgb(var(--accent-rgb) / 0.55);
|
|
51
|
+
/* The part of a name that answers a filter or a search. A tint of its own rather than the
|
|
52
|
+
selection's, so a selected row that matches still shows both. */
|
|
53
|
+
--match: color-mix(in srgb, var(--status-needs-you) 30%, transparent);
|
|
44
54
|
--float-shadow:
|
|
45
55
|
0 1px 2px -1px rgb(0 0 0 / 0.08),
|
|
46
56
|
0 3px 6px -2px rgb(0 0 0 / 0.07),
|
|
@@ -106,9 +116,12 @@
|
|
|
106
116
|
--bg: #0d0d10;
|
|
107
117
|
--surface: #131316;
|
|
108
118
|
--surface-raised: #18181c;
|
|
109
|
-
--surface-sunken: #
|
|
119
|
+
--surface-sunken: #0b0b0e;
|
|
110
120
|
--surface-hover: #202024;
|
|
111
121
|
--surface-active: #28282e;
|
|
122
|
+
/* Derived, so an app that tints its surfaces tints its fields along. */
|
|
123
|
+
--field: color-mix(in srgb, var(--surface) 40%, var(--surface-hover));
|
|
124
|
+
--field-border: rgb(255 255 255 / 0.1);
|
|
112
125
|
/* White at a low alpha, which is what gives a dark surface its lifted edge. */
|
|
113
126
|
--border: rgb(255 255 255 / 0.07);
|
|
114
127
|
--separator: oklch(0.236 0.006 285.956);
|
|
@@ -118,6 +131,7 @@
|
|
|
118
131
|
--text-muted: #9a9aa6;
|
|
119
132
|
--text-faint: #5f5f6b;
|
|
120
133
|
--selection: rgb(var(--accent-rgb) / 0.6);
|
|
134
|
+
--match: color-mix(in srgb, var(--status-needs-you) 28%, transparent);
|
|
121
135
|
--float-shadow:
|
|
122
136
|
0 1px 2px -1px rgb(0 0 0 / 0.3),
|
|
123
137
|
0 2px 4px rgb(0 0 0 / 0.24),
|
|
@@ -233,7 +247,10 @@
|
|
|
233
247
|
--color-surface-sunken: var(--surface-sunken);
|
|
234
248
|
--color-surface-idle: var(--surface-idle);
|
|
235
249
|
--color-surface-hover: var(--surface-hover);
|
|
250
|
+
--color-surface-hover-edged: var(--surface-hover-edged);
|
|
236
251
|
--color-surface-active: var(--surface-active);
|
|
252
|
+
--color-field: var(--field);
|
|
253
|
+
--color-field-border: var(--field-border);
|
|
237
254
|
--color-border: var(--border);
|
|
238
255
|
--color-separator: var(--separator);
|
|
239
256
|
--color-border-strong: var(--border-strong);
|
|
@@ -261,6 +278,10 @@
|
|
|
261
278
|
}
|
|
262
279
|
|
|
263
280
|
@layer base {
|
|
281
|
+
/* Whatever a click acts on shows the hand; a disabled one shows that nothing will. Menu rows keep the arrow (`.menu-item`). */
|
|
282
|
+
:where(button, a[href], summary, label[for], [role="button"], [role="checkbox"], [role="switch"], [role="radio"], [role="tab"], [role="option"]):not(:disabled, [aria-disabled="true"], [data-disabled]) {
|
|
283
|
+
cursor: pointer;
|
|
284
|
+
}
|
|
264
285
|
/*
|
|
265
286
|
* A border with an alpha has to mix with what is behind the element, not with the element's own
|
|
266
287
|
* background: by default a background runs on under the border and a node's white surface would
|
|
@@ -326,8 +347,8 @@
|
|
|
326
347
|
width: 100%;
|
|
327
348
|
padding: 0 10px;
|
|
328
349
|
border-radius: 8px;
|
|
329
|
-
border: 1px solid var(--border);
|
|
330
|
-
background: var(--
|
|
350
|
+
border: 1px solid var(--field-border);
|
|
351
|
+
background-color: var(--field);
|
|
331
352
|
color: var(--text);
|
|
332
353
|
font-size: var(--text-sm);
|
|
333
354
|
outline: none;
|
|
@@ -345,6 +366,10 @@
|
|
|
345
366
|
border-radius: 6px;
|
|
346
367
|
font-size: var(--text-xs);
|
|
347
368
|
}
|
|
369
|
+
/* A field inside a card that draws the edge and the focus for it. The border stays as a transparent
|
|
370
|
+
line, so the text sits where it would in a field of its own. */
|
|
371
|
+
.field-bare { border-color: transparent; background: transparent; }
|
|
372
|
+
.field-bare:focus-visible { outline: none; }
|
|
348
373
|
/* Keyboard focus is visible on every control; pointer focus stays quiet. `.focus-ring` is the
|
|
349
374
|
same outline for something that takes focus without being a button, such as a row in a list. */
|
|
350
375
|
.icon-btn:focus-visible, .menu-item:focus-visible, button:focus-visible, .focus-ring:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
@@ -683,6 +708,11 @@
|
|
|
683
708
|
.adecore-tree-decoration {
|
|
684
709
|
font-variant-numeric: tabular-nums;
|
|
685
710
|
}
|
|
711
|
+
/* A row's own icon in place of the tree's glyph, in the glyph's color. */
|
|
712
|
+
.adecore-tree-icon {
|
|
713
|
+
display: inline-flex;
|
|
714
|
+
color: var(--text-muted);
|
|
715
|
+
}
|
|
686
716
|
/* A tree whose rows slide sideways: every part of a row keeps its width and moves with the shift. */
|
|
687
717
|
.adecore-tree[data-overflow="scroll"] .adecore-tree-row {
|
|
688
718
|
overflow: clip;
|
package/package.json
CHANGED
package/src/Button.tsx
CHANGED
|
@@ -1,35 +1,8 @@
|
|
|
1
1
|
import type { AnchorHTMLAttributes, ComponentProps, Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
+
import { buttonClass, type ButtonShape, type ButtonSize, type ButtonVariant } from './button-class.ts';
|
|
3
4
|
|
|
4
|
-
export type
|
|
5
|
-
export type ButtonSize = 'xs' | 'sm' | 'md';
|
|
6
|
-
export type ButtonShape = 'default' | 'pill';
|
|
7
|
-
|
|
8
|
-
const VARIANT: Record<ButtonVariant, string> = {
|
|
9
|
-
// A filled button darkens under the pointer. Its color is the meaning, so no token of its own for hover.
|
|
10
|
-
primary: 'bg-accent text-accent-text hover:brightness-90 disabled:hover:brightness-100',
|
|
11
|
-
secondary: 'border border-border bg-surface-raised text-text hover:bg-surface-hover disabled:hover:bg-surface-raised',
|
|
12
|
-
ghost: 'text-text-muted hover:bg-surface-hover hover:text-text',
|
|
13
|
-
danger: 'bg-status-error text-accent-text hover:brightness-90 disabled:hover:brightness-100',
|
|
14
|
-
// A step that forgets something rather than destroys it: softer than the filled button of a deletion that cannot come back.
|
|
15
|
-
'danger-outline': 'border border-status-error/35 text-status-error hover:bg-status-error/10 disabled:hover:bg-transparent',
|
|
16
|
-
positive: 'bg-positive text-positive-text hover:brightness-90 disabled:hover:brightness-100',
|
|
17
|
-
// Dark on a light theme, light on a dark one, matching what Apple's and GitHub's sign-in buttons ask for.
|
|
18
|
-
inverse: 'bg-text text-bg hover:opacity-90 disabled:hover:opacity-100'
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/* 28 and 32 pixels are the compact and the normal control; 24 fits a header of 32. */
|
|
22
|
-
const SIZE: Record<ButtonSize, string> = {
|
|
23
|
-
xs: 'h-6 px-2',
|
|
24
|
-
sm: 'h-7 px-2.5',
|
|
25
|
-
md: 'h-8 px-3'
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/* A pill is one size of its own, for a control that sits in a round row, so it takes the place of `size`. */
|
|
29
|
-
const SHAPE: Record<ButtonShape, string> = {
|
|
30
|
-
default: 'rounded-md text-xs',
|
|
31
|
-
pill: 'h-9 rounded-full px-3.5 text-sm'
|
|
32
|
-
};
|
|
5
|
+
export type { ButtonShape, ButtonSize, ButtonVariant } from './button-class.ts';
|
|
33
6
|
|
|
34
7
|
export interface ButtonProps extends ComponentProps<'button'> {
|
|
35
8
|
variant?: ButtonVariant;
|
|
@@ -42,13 +15,7 @@ export interface ButtonProps extends ComponentProps<'button'> {
|
|
|
42
15
|
|
|
43
16
|
/* Every button with a word in it. A button that is only an icon is an `IconButton`, which is a square, not a label. */
|
|
44
17
|
export function Button({ variant = 'ghost', size = 'md', shape = 'default', className, href, type = 'button', ref, ...rest }: ButtonProps) {
|
|
45
|
-
const classes = clsx(
|
|
46
|
-
'inline-flex shrink-0 items-center justify-center gap-1.5 font-medium disabled:opacity-50',
|
|
47
|
-
SHAPE[shape],
|
|
48
|
-
VARIANT[variant],
|
|
49
|
-
shape === 'default' && SIZE[size],
|
|
50
|
-
className
|
|
51
|
-
);
|
|
18
|
+
const classes = clsx(buttonClass(variant, size, shape), className);
|
|
52
19
|
if (href) {
|
|
53
20
|
return (
|
|
54
21
|
<a
|
package/src/Checkbox.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export function Checkbox({ checked, onCheckedChange, label, indeterminate = fals
|
|
|
27
27
|
aria-label={label}
|
|
28
28
|
disabled={disabled}
|
|
29
29
|
className={clsx(
|
|
30
|
-
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border
|
|
30
|
+
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-field-border bg-field text-accent-text transition-colors',
|
|
31
31
|
'data-checked:border-accent data-checked:bg-accent data-indeterminate:border-accent data-indeterminate:bg-accent data-disabled:opacity-50',
|
|
32
32
|
className
|
|
33
33
|
)}
|
package/src/ChoiceCards.tsx
CHANGED
|
@@ -47,11 +47,11 @@ function ChoiceCard<Value extends string | number>({ choice, radio }: { choice:
|
|
|
47
47
|
disabled={choice.disabled}
|
|
48
48
|
aria-labelledby={`${id}-title`}
|
|
49
49
|
aria-describedby={`${id}-description`}
|
|
50
|
-
className="group focus-ring flex min-w-0 items-start gap-3 rounded-xl border border-border bg-surface p-3 text-left hover:bg-surface-hover data-checked:border-accent data-disabled:opacity-50 data-disabled:hover:bg-surface"
|
|
50
|
+
className="group focus-ring flex min-w-0 items-start gap-3 rounded-xl border border-border bg-surface p-3 text-left hover:bg-surface-hover-edged data-checked:border-accent data-disabled:opacity-50 data-disabled:hover:bg-surface"
|
|
51
51
|
>
|
|
52
52
|
{radio === 'start' && dot}
|
|
53
53
|
{choice.icon && (
|
|
54
|
-
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-sunken text-text-muted group-data-checked:text-accent">
|
|
54
|
+
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-sunken text-text-muted group-data-checked:text-accent dark:bg-field dark:inset-ring dark:inset-ring-field-border">
|
|
55
55
|
<Icon icon={choice.icon} size={16} />
|
|
56
56
|
</span>
|
|
57
57
|
)}
|
package/src/IconPicker.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import { Ban, Search, X } from 'lucide-react';
|
|
3
|
+
import { Ban, ChevronDown, Search, X } from 'lucide-react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Button } from './Button.tsx';
|
|
5
6
|
import { EmptyState } from './EmptyState.tsx';
|
|
6
7
|
import { formatNumber } from './format/number.ts';
|
|
7
8
|
import { Icon } from './Icon.tsx';
|
|
8
9
|
import { IconButton } from './IconButton.tsx';
|
|
9
|
-
import { cellForKey, escapeClearsSearch, filterSections, isGrouped, sectionsOf, tabStopOf, type IconPickerIcons } from './icon-picker.ts';
|
|
10
|
+
import { cellForKey, escapeClearsSearch, filterSections, isGrouped, sectionsOf, tabStopOf, type IconPickerIcons, type IconSection } from './icon-picker.ts';
|
|
11
|
+
import { PopoverPopup, PopoverRoot, PopoverTrigger } from './popover/parts.tsx';
|
|
10
12
|
import { SectionLabel } from './SectionLabel.tsx';
|
|
11
13
|
import { Tooltip } from './Tooltip.tsx';
|
|
12
14
|
|
|
@@ -25,15 +27,17 @@ export interface IconPickerProps {
|
|
|
25
27
|
onValueChange(name: string): void;
|
|
26
28
|
/* More words a search finds an icon by, beside its name and its group's label, by icon name. */
|
|
27
29
|
keywords?: Readonly<Record<string, readonly string[]>>;
|
|
28
|
-
/* A search field on top of the grid
|
|
30
|
+
/* A search field on top of the grid. On by default for groups, off for a flat set. */
|
|
29
31
|
searchable?: boolean;
|
|
30
32
|
/* The grid as a scroll area this many rows high, with room for a heading. Without it the grid is as tall as its icons. */
|
|
31
33
|
rows?: number;
|
|
32
34
|
disabled?: boolean;
|
|
33
35
|
/* Clearing the icon. A surface that falls back to a default of its own offers that instead. */
|
|
34
36
|
onClear?: () => void;
|
|
35
|
-
/* The label above the
|
|
37
|
+
/* The label above the trigger. A surface that also takes an image calls this something else, or the two read as one list. */
|
|
36
38
|
label?: string;
|
|
39
|
+
/* Leaves the label out of sight, for a row that names the control beside it; it still names the trigger. */
|
|
40
|
+
hideLabel?: boolean;
|
|
37
41
|
className?: string;
|
|
38
42
|
ref?: Ref<HTMLDivElement>;
|
|
39
43
|
}
|
|
@@ -49,9 +53,9 @@ const markStuckHeadings = (area: HTMLElement): void => {
|
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
/*
|
|
52
|
-
* The mark a thing wears, one of a set of Lucide icons,
|
|
53
|
-
*
|
|
54
|
-
*
|
|
56
|
+
* The mark a thing wears, one of a set of Lucide icons, behind a trigger drawn like a field. The
|
|
57
|
+
* popover holds a search over the grid. The grid is one tab stop: arrows move the focus and only
|
|
58
|
+
* Enter, Space or a click chooses, since a caller may save every choice at once.
|
|
55
59
|
*/
|
|
56
60
|
export function IconPicker({
|
|
57
61
|
icons,
|
|
@@ -63,25 +67,120 @@ export function IconPicker({
|
|
|
63
67
|
disabled = false,
|
|
64
68
|
onClear,
|
|
65
69
|
label,
|
|
70
|
+
hideLabel = false,
|
|
66
71
|
className,
|
|
67
72
|
ref
|
|
68
73
|
}: IconPickerProps) {
|
|
69
74
|
const { t } = useTranslation('ui');
|
|
70
75
|
const id = useId();
|
|
71
|
-
const
|
|
76
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
const sections = sectionsOf(icons);
|
|
79
|
+
const glyph = value === null ? undefined : sections.flatMap((section) => section.icons).find(([icon]) => icon === value)?.[1];
|
|
80
|
+
const name = label ?? t('iconPicker.label');
|
|
81
|
+
const clearable = onClear !== undefined && value !== null && !disabled;
|
|
82
|
+
|
|
83
|
+
const choose = (icon: string): void => {
|
|
84
|
+
onValueChange(icon);
|
|
85
|
+
setOpen(false);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const clear = (): void => {
|
|
89
|
+
onClear?.();
|
|
90
|
+
setOpen(false);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div ref={ref} className={clsx('flex flex-col gap-1.5', className)}>
|
|
95
|
+
<SectionLabel id={`${id}-label`} render={<div />} className={clsx(hideLabel && 'sr-only')}>
|
|
96
|
+
{name}
|
|
97
|
+
</SectionLabel>
|
|
98
|
+
<PopoverRoot open={open} onOpenChange={setOpen}>
|
|
99
|
+
<div className="flex gap-1.5">
|
|
100
|
+
<PopoverTrigger
|
|
101
|
+
ref={trigger}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
aria-labelledby={`${id}-label ${id}-value`}
|
|
104
|
+
className="field flex w-auto min-w-0 items-center gap-2 text-left hover:bg-surface-hover data-disabled:cursor-default data-disabled:opacity-50 data-disabled:hover:bg-field data-[popup-open]:bg-surface-hover"
|
|
105
|
+
>
|
|
106
|
+
{glyph && <Icon icon={glyph} size={16} className="shrink-0 text-text" />}
|
|
107
|
+
{/* The icon says which it is; its name is there for a screen reader. */}
|
|
108
|
+
<span id={`${id}-value`} className={clsx('min-w-0 truncate', glyph ? 'sr-only' : 'text-text-faint')}>
|
|
109
|
+
{value ?? t('iconPicker.placeholder')}
|
|
110
|
+
</span>
|
|
111
|
+
<Icon icon={ChevronDown} size={14} className="shrink-0 text-text-faint" />
|
|
112
|
+
</PopoverTrigger>
|
|
113
|
+
{clearable && (
|
|
114
|
+
<Tooltip label={t('iconPicker.none')}>
|
|
115
|
+
<Button
|
|
116
|
+
variant="secondary"
|
|
117
|
+
className="w-8 shrink-0 px-0!"
|
|
118
|
+
aria-label={t('iconPicker.none')}
|
|
119
|
+
onClick={() => {
|
|
120
|
+
onClear();
|
|
121
|
+
trigger.current?.focus();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<Icon icon={X} size={16} />
|
|
125
|
+
</Button>
|
|
126
|
+
</Tooltip>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
<PopoverPopup
|
|
130
|
+
variant="picker"
|
|
131
|
+
aria-label={name}
|
|
132
|
+
className="w-90"
|
|
133
|
+
initialFocus={() =>
|
|
134
|
+
document.getElementById(`${id}-search`) ?? document.getElementById(`${id}-grid`)?.querySelector<HTMLElement>('[tabindex="0"]') ?? false
|
|
135
|
+
}
|
|
136
|
+
finalFocus={trigger}
|
|
137
|
+
>
|
|
138
|
+
<IconPickerPanel
|
|
139
|
+
id={id}
|
|
140
|
+
name={name}
|
|
141
|
+
sections={sections}
|
|
142
|
+
headed={isGrouped(icons)}
|
|
143
|
+
value={value}
|
|
144
|
+
keywords={keywords}
|
|
145
|
+
searchable={searchable}
|
|
146
|
+
rows={rows}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
onChoose={choose}
|
|
149
|
+
onClear={onClear && clear}
|
|
150
|
+
/>
|
|
151
|
+
</PopoverPopup>
|
|
152
|
+
</PopoverRoot>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface IconPickerPanelProps {
|
|
158
|
+
id: string;
|
|
159
|
+
name: string;
|
|
160
|
+
sections: readonly IconSection[];
|
|
161
|
+
headed: boolean;
|
|
162
|
+
value: string | null;
|
|
163
|
+
keywords: Readonly<Record<string, readonly string[]>> | undefined;
|
|
164
|
+
searchable: boolean;
|
|
165
|
+
rows: number | undefined;
|
|
166
|
+
disabled: boolean;
|
|
167
|
+
onChoose(name: string): void;
|
|
168
|
+
onClear: (() => void) | undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* What the popover holds. It mounts on every open, so a search and the focus start over each time. */
|
|
172
|
+
function IconPickerPanel({ id, name, sections, headed, value, keywords, searchable, rows, disabled, onChoose, onClear }: IconPickerPanelProps) {
|
|
173
|
+
const { t } = useTranslation('ui');
|
|
72
174
|
const input = useRef<HTMLInputElement>(null);
|
|
175
|
+
const area = useRef<HTMLDivElement>(null);
|
|
73
176
|
const [query, setQuery] = useState('');
|
|
74
177
|
const [focused, setFocused] = useState<string | null>(null);
|
|
75
|
-
const headed = isGrouped(icons);
|
|
76
|
-
const framed = searchable || rows !== undefined;
|
|
77
|
-
const sections = sectionsOf(icons);
|
|
78
178
|
const shown = filterSections(sections, query, keywords);
|
|
79
179
|
const tabStop = tabStopOf(
|
|
80
|
-
shown.flatMap((section) => section.icons.map(([
|
|
180
|
+
shown.flatMap((section) => section.icons.map(([icon]) => icon)),
|
|
81
181
|
focused,
|
|
82
182
|
value
|
|
83
183
|
);
|
|
84
|
-
const name = label ?? t('iconPicker.label');
|
|
85
184
|
|
|
86
185
|
// Opens with the chosen icon in the middle of the area: a person changing an icon often starts from the current one.
|
|
87
186
|
useLayoutEffect(() => {
|
|
@@ -101,6 +200,17 @@ export function IconPicker({
|
|
|
101
200
|
}
|
|
102
201
|
};
|
|
103
202
|
|
|
203
|
+
// Escape empties a search first, wherever the focus is, and only an empty one reaches the popover and closes it.
|
|
204
|
+
const clearSearchFirst = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
205
|
+
if (!escapeClearsSearch(event.key, query)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
search('');
|
|
211
|
+
input.current?.focus();
|
|
212
|
+
};
|
|
213
|
+
|
|
104
214
|
const moveFocus = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
105
215
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
106
216
|
return;
|
|
@@ -122,135 +232,109 @@ export function IconPicker({
|
|
|
122
232
|
cells[to]?.focus();
|
|
123
233
|
};
|
|
124
234
|
|
|
125
|
-
const grid = (
|
|
126
|
-
<div
|
|
127
|
-
ref={area}
|
|
128
|
-
id={`${id}-grid`}
|
|
129
|
-
role="radiogroup"
|
|
130
|
-
aria-label={name}
|
|
131
|
-
className={clsx(framed && 'relative overflow-y-auto overscroll-contain', framed && (headed ? 'scroll-pt-7' : 'px-1.5 pt-1.5 pb-2'))}
|
|
132
|
-
style={rows === undefined ? undefined : { height: rows * ROW_HEIGHT + (headed ? HEADED_EXTRA : BARE_EXTRA) }}
|
|
133
|
-
onScroll={(event) => markStuckHeadings(event.currentTarget)}
|
|
134
|
-
onKeyDown={moveFocus}
|
|
135
|
-
>
|
|
136
|
-
{shown.length === 0 && query.trim() !== '' ? (
|
|
137
|
-
<EmptyState className="h-full">{t('iconPicker.noMatch', { query: query.trim() })}</EmptyState>
|
|
138
|
-
) : (
|
|
139
|
-
shown.map((section, index) => {
|
|
140
|
-
const cells = (
|
|
141
|
-
<div
|
|
142
|
-
key={section.id}
|
|
143
|
-
className={clsx('grid grid-cols-[repeat(auto-fill,minmax(28px,1fr))] gap-1', framed && section.label !== null && 'px-1.5 pb-2')}
|
|
144
|
-
>
|
|
145
|
-
{section.icons.map(([icon, glyph]) => (
|
|
146
|
-
<Tooltip key={icon} label={icon} name>
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
role="radio"
|
|
150
|
-
aria-checked={value === icon}
|
|
151
|
-
tabIndex={icon === tabStop ? 0 : -1}
|
|
152
|
-
disabled={disabled}
|
|
153
|
-
className={clsx(
|
|
154
|
-
'flex h-7 items-center justify-center rounded-md hover:bg-surface-hover disabled:opacity-50',
|
|
155
|
-
value === icon ? 'bg-surface-active text-text' : 'text-text-muted'
|
|
156
|
-
)}
|
|
157
|
-
onFocus={() => setFocused(icon)}
|
|
158
|
-
onClick={() => onValueChange(icon)}
|
|
159
|
-
>
|
|
160
|
-
<Icon icon={glyph} size={16} />
|
|
161
|
-
</button>
|
|
162
|
-
</Tooltip>
|
|
163
|
-
))}
|
|
164
|
-
</div>
|
|
165
|
-
);
|
|
166
|
-
if (section.label === null) {
|
|
167
|
-
return cells;
|
|
168
|
-
}
|
|
169
|
-
const headingId = `${id}-group-${index}`;
|
|
170
|
-
return (
|
|
171
|
-
<div key={section.id} role="group" aria-labelledby={headingId}>
|
|
172
|
-
<SectionLabel
|
|
173
|
-
render={<div />}
|
|
174
|
-
data-icon-heading=""
|
|
175
|
-
className={clsx(
|
|
176
|
-
'flex h-7 items-center gap-2',
|
|
177
|
-
framed ? 'sticky top-0 z-10 bg-surface px-2.5 data-stuck:shadow-[0_1px_0_var(--border)]' : 'mt-1'
|
|
178
|
-
)}
|
|
179
|
-
>
|
|
180
|
-
<span id={headingId} className="truncate">
|
|
181
|
-
{section.label}
|
|
182
|
-
</span>
|
|
183
|
-
<span className="ms-auto shrink-0">{formatNumber(section.icons.length)}</span>
|
|
184
|
-
</SectionLabel>
|
|
185
|
-
{cells}
|
|
186
|
-
</div>
|
|
187
|
-
);
|
|
188
|
-
})
|
|
189
|
-
)}
|
|
190
|
-
</div>
|
|
191
|
-
);
|
|
192
|
-
|
|
193
235
|
return (
|
|
194
|
-
<div
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
236
|
+
<div onKeyDown={clearSearchFirst}>
|
|
237
|
+
{searchable && (
|
|
238
|
+
<div className="flex h-9 items-center gap-2 border-b border-border pr-1 pl-2.5">
|
|
239
|
+
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
240
|
+
<input
|
|
241
|
+
ref={input}
|
|
242
|
+
id={`${id}-search`}
|
|
243
|
+
type="text"
|
|
244
|
+
value={query}
|
|
245
|
+
placeholder={t('iconPicker.search')}
|
|
246
|
+
aria-label={t('iconPicker.search')}
|
|
247
|
+
aria-controls={`${id}-grid`}
|
|
248
|
+
spellCheck={false}
|
|
249
|
+
autoComplete="off"
|
|
250
|
+
disabled={disabled}
|
|
251
|
+
className="h-full min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint"
|
|
252
|
+
onChange={(event) => search(event.target.value)}
|
|
253
|
+
/>
|
|
254
|
+
{query && (
|
|
200
255
|
<IconButton
|
|
201
|
-
icon={
|
|
256
|
+
icon={X}
|
|
202
257
|
size="sm"
|
|
203
|
-
label={t('iconPicker.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
258
|
+
label={t('iconPicker.clear')}
|
|
259
|
+
tooltip={false}
|
|
260
|
+
onClick={() => {
|
|
261
|
+
search('');
|
|
262
|
+
input.current?.focus();
|
|
263
|
+
}}
|
|
207
264
|
/>
|
|
208
|
-
</>
|
|
209
|
-
)}
|
|
210
|
-
</SectionLabel>
|
|
211
|
-
{framed ? (
|
|
212
|
-
// The frame wears the field's outline while its search has the focus; the search draws none of its own.
|
|
213
|
-
<div className="field h-auto overflow-hidden p-0 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-accent">
|
|
214
|
-
{searchable && (
|
|
215
|
-
<div className="flex h-8 items-center gap-2 border-b border-border pr-1 pl-2.5">
|
|
216
|
-
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
217
|
-
<input
|
|
218
|
-
ref={input}
|
|
219
|
-
type="text"
|
|
220
|
-
value={query}
|
|
221
|
-
placeholder={t('iconPicker.search')}
|
|
222
|
-
aria-label={t('iconPicker.search')}
|
|
223
|
-
aria-controls={`${id}-grid`}
|
|
224
|
-
spellCheck={false}
|
|
225
|
-
autoComplete="off"
|
|
226
|
-
disabled={disabled}
|
|
227
|
-
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint"
|
|
228
|
-
onChange={(event) => search(event.target.value)}
|
|
229
|
-
onKeyDown={(event) => {
|
|
230
|
-
if (escapeClearsSearch(event.key, query)) {
|
|
231
|
-
event.stopPropagation();
|
|
232
|
-
search('');
|
|
233
|
-
}
|
|
234
|
-
}}
|
|
235
|
-
/>
|
|
236
|
-
{query && (
|
|
237
|
-
<IconButton
|
|
238
|
-
icon={X}
|
|
239
|
-
size="sm"
|
|
240
|
-
label={t('iconPicker.clear')}
|
|
241
|
-
tooltip={false}
|
|
242
|
-
onClick={() => {
|
|
243
|
-
search('');
|
|
244
|
-
input.current?.focus();
|
|
245
|
-
}}
|
|
246
|
-
/>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
265
|
)}
|
|
250
|
-
{grid}
|
|
251
266
|
</div>
|
|
252
|
-
)
|
|
253
|
-
|
|
267
|
+
)}
|
|
268
|
+
<div
|
|
269
|
+
ref={area}
|
|
270
|
+
id={`${id}-grid`}
|
|
271
|
+
role="radiogroup"
|
|
272
|
+
aria-label={name}
|
|
273
|
+
className={clsx('relative overflow-y-auto overscroll-contain', headed ? 'scroll-pt-7' : 'px-1.5 pt-1.5 pb-2')}
|
|
274
|
+
style={rows === undefined ? undefined : { height: rows * ROW_HEIGHT + (headed ? HEADED_EXTRA : BARE_EXTRA) }}
|
|
275
|
+
onScroll={(event) => markStuckHeadings(event.currentTarget)}
|
|
276
|
+
onKeyDown={moveFocus}
|
|
277
|
+
>
|
|
278
|
+
{shown.length === 0 && query.trim() !== '' ? (
|
|
279
|
+
<EmptyState className="h-full">{t('iconPicker.noMatch', { query: query.trim() })}</EmptyState>
|
|
280
|
+
) : (
|
|
281
|
+
shown.map((section, index) => {
|
|
282
|
+
const cells = (
|
|
283
|
+
<div
|
|
284
|
+
key={section.id}
|
|
285
|
+
className={clsx('grid grid-cols-[repeat(auto-fill,minmax(28px,1fr))] gap-1', section.label !== null && 'px-1.5 pb-2')}
|
|
286
|
+
>
|
|
287
|
+
{section.icons.map(([icon, cellGlyph]) => (
|
|
288
|
+
<Tooltip key={icon} label={icon} name>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
role="radio"
|
|
292
|
+
aria-checked={value === icon}
|
|
293
|
+
tabIndex={icon === tabStop ? 0 : -1}
|
|
294
|
+
disabled={disabled}
|
|
295
|
+
className={clsx(
|
|
296
|
+
'flex h-7 items-center justify-center rounded-md hover:bg-surface-hover disabled:opacity-50',
|
|
297
|
+
value === icon ? 'bg-surface-active text-text' : 'text-text-muted'
|
|
298
|
+
)}
|
|
299
|
+
onFocus={() => setFocused(icon)}
|
|
300
|
+
onClick={() => onChoose(icon)}
|
|
301
|
+
>
|
|
302
|
+
<Icon icon={cellGlyph} size={16} />
|
|
303
|
+
</button>
|
|
304
|
+
</Tooltip>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
if (section.label === null) {
|
|
309
|
+
return cells;
|
|
310
|
+
}
|
|
311
|
+
const headingId = `${id}-group-${index}`;
|
|
312
|
+
return (
|
|
313
|
+
<div key={section.id} role="group" aria-labelledby={headingId}>
|
|
314
|
+
{/* A step lighter than the popover in the dark theme, where a darker band would read as a hole in it. */}
|
|
315
|
+
<SectionLabel
|
|
316
|
+
render={<div />}
|
|
317
|
+
data-icon-heading=""
|
|
318
|
+
className="sticky top-0 z-10 flex h-7 items-center gap-2 bg-surface px-2.5 data-stuck:shadow-[0_1px_0_var(--border)] dark:bg-surface-hover"
|
|
319
|
+
>
|
|
320
|
+
<span id={headingId} className="truncate">
|
|
321
|
+
{section.label}
|
|
322
|
+
</span>
|
|
323
|
+
<span className="ms-auto shrink-0">{formatNumber(section.icons.length)}</span>
|
|
324
|
+
</SectionLabel>
|
|
325
|
+
{cells}
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
})
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
{onClear && (
|
|
332
|
+
<div className="flex border-t border-border p-1">
|
|
333
|
+
<Button size="sm" disabled={disabled || value === null} onClick={onClear}>
|
|
334
|
+
<Icon icon={Ban} size={12} />
|
|
335
|
+
{t('iconPicker.none')}
|
|
336
|
+
</Button>
|
|
337
|
+
</div>
|
|
254
338
|
)}
|
|
255
339
|
</div>
|
|
256
340
|
);
|