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