@adecore/ui 0.35.5 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/Button.d.ts +2 -3
  2. package/dist/Button.js +21 -46
  3. package/dist/Checkbox.js +1 -1
  4. package/dist/ChoiceCards.js +2 -2
  5. package/dist/IconPicker.d.ts +2 -1
  6. package/dist/IconPicker.js +504 -217
  7. package/dist/Input.d.ts +5 -3
  8. package/dist/Input.js +127 -112
  9. package/dist/Segmented.js +1 -1
  10. package/dist/SplitButton.d.ts +17 -0
  11. package/dist/SplitButton.js +237 -0
  12. package/dist/Stepper.js +1 -1
  13. package/dist/Switch.js +1 -1
  14. package/dist/Tile.js +2 -2
  15. package/dist/button-class.d.ts +6 -0
  16. package/dist/button-class.js +92 -0
  17. package/dist/file-tree/empty-lines.d.ts +11 -0
  18. package/dist/file-tree/empty-lines.js +89 -0
  19. package/dist/file-tree/highlight.d.ts +4 -0
  20. package/dist/file-tree/highlight.js +75 -0
  21. package/dist/file-tree/parts.d.ts +9 -1
  22. package/dist/file-tree/parts.js +297 -205
  23. package/dist/file-tree/style.js +13 -0
  24. package/dist/file-tree/use-empty-lines.d.ts +3 -0
  25. package/dist/file-tree/use-empty-lines.js +176 -0
  26. package/dist/file-tree/use-file-tree.js +5 -1
  27. package/dist/file-tree/use-slots.d.ts +16 -2
  28. package/dist/file-tree/use-slots.js +186 -43
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/locales/en.d.ts +1 -0
  32. package/dist/locales/en.js +1 -0
  33. package/dist/locales/nl.d.ts +1 -0
  34. package/dist/locales/nl.js +1 -0
  35. package/dist/select-shared.d.ts +1 -1
  36. package/dist/select-shared.js +1 -1
  37. package/dist/settings/SettingsDialog.js +1 -1
  38. package/dist/theme.css +33 -3
  39. package/package.json +1 -1
  40. package/src/Button.tsx +3 -36
  41. package/src/Checkbox.tsx +1 -1
  42. package/src/ChoiceCards.tsx +2 -2
  43. package/src/IconPicker.tsx +220 -136
  44. package/src/Input.tsx +19 -10
  45. package/src/Segmented.tsx +1 -1
  46. package/src/SplitButton.tsx +72 -0
  47. package/src/Stepper.tsx +6 -1
  48. package/src/Switch.tsx +1 -1
  49. package/src/Tile.tsx +2 -2
  50. package/src/button-class.ts +111 -0
  51. package/src/file-tree/empty-lines.ts +105 -0
  52. package/src/file-tree/highlight.ts +80 -0
  53. package/src/file-tree/parts.tsx +42 -11
  54. package/src/file-tree/style.ts +13 -0
  55. package/src/file-tree/use-empty-lines.ts +115 -0
  56. package/src/file-tree/use-file-tree.ts +2 -1
  57. package/src/file-tree/use-slots.ts +176 -17
  58. package/src/index.ts +1 -0
  59. package/src/locales/en.ts +1 -0
  60. package/src/locales/nl.ts +1 -0
  61. package/src/select-shared.ts +2 -1
  62. package/src/settings/SettingsDialog.tsx +1 -1
  63. package/src/theme.css +33 -3
@@ -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-raised text-text',
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: "bg-surface-sunken pr-12 pl-8 text-xs",
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: #08080a;
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(--surface);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.35.5",
3
+ "version": "0.36.0",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
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 ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-outline' | 'positive' | 'inverse';
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-strong bg-surface text-accent-text transition-colors',
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
  )}
@@ -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
  )}
@@ -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, in one frame with it. On by default for groups, off for a flat set. */
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 grid. A surface that also takes an image calls this grid something else, or the two read as one list. */
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, as a grid under a label. With a search or a
53
- * height in rows, the search and the grid share one frame. The grid is one tab stop: arrows move the
54
- * focus and only Enter, Space or a click chooses, since a caller may save every choice at once.
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 area = useRef<HTMLDivElement>(null);
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(([name]) => name)),
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 ref={ref} className={className}>
195
- <SectionLabel render={<div />} className="mb-1.5 flex items-center gap-2">
196
- {name}
197
- {onClear && (
198
- <>
199
- <span className="grow" />
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={Ban}
256
+ icon={X}
202
257
  size="sm"
203
- label={t('iconPicker.none')}
204
- className="-my-1"
205
- disabled={disabled || value === null}
206
- onClick={onClear}
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
- grid
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
  );