@ultimat3/ui 29.4.0 → 30.0.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 (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -0,0 +1,331 @@
1
+ // One — or many — of a long, closed list, chosen by typing, where the posted value is not the
2
+ // label. Without script it IS a native `<select>` (`multiple`, `<optgroup>`), which posts the same
3
+ // `name` and values; with script an effect swaps in a WAI-ARIA combobox whose text is never posted:
4
+ // hidden inputs carry the chosen values, so a half-typed name cannot reach the server.
5
+
6
+ import { finiteCount, finiteOption } from '@ultimat3/core';
7
+ import type { JSX } from 'solid-js';
8
+ import { ariaBool, useId } from '../a11y';
9
+ import { cx } from '../cx';
10
+ import { DEBOUNCE_DEFAULT_MS, debounce } from '../debounce';
11
+ import { UI_KEYS } from '../i18n-keys';
12
+ import { useUi } from '../theme/context';
13
+ import { solid } from '../theme/solid-adapter';
14
+ import { COMBOBOX_LIMIT } from './combobox-filter';
15
+ import styles from './SearchableSelect.module.scss';
16
+ import { SearchableSelectChips } from './SearchableSelectChips';
17
+ import { SearchableSelectNative } from './SearchableSelectNative';
18
+ import { searchableKeys } from './searchable-select-keys';
19
+ import type { SearchableOption, SearchableView } from './searchable-select-view';
20
+ import { checkOptions, chosenOptions, searchableView } from './searchable-select-view';
21
+ import { thunk } from './thunk';
22
+ import type { Size } from './variants';
23
+
24
+ interface SearchableSelectBase {
25
+ /** The form field name every chosen value posts under. */
26
+ name: string;
27
+ /** Unique by `value`; a group's options adjacent. Chosen values must be among them. */
28
+ options: readonly SearchableOption[];
29
+ /**
30
+ * The caller searches: called `debounceMs` after typing stops, and hands back `options`. The
31
+ * list is then shown as given, never filtered again here; keep the chosen options in it.
32
+ */
33
+ onSearch?: ((query: string) => void) | undefined;
34
+ /** Debounce window for `onSearch`, in ms. Read once. */
35
+ debounceMs?: number | undefined;
36
+ /** Rows shown at most; past it the control says "first N of M". Default `COMBOBOX_LIMIT`. */
37
+ limit?: number | undefined;
38
+ /** Already translated: the no-script placeholder option and the empty field's hint. */
39
+ placeholder?: string | undefined;
40
+ /** Lands on whichever control is live, so `Field`'s `<label for>` names it in both modes. */
41
+ id?: string | undefined;
42
+ required?: boolean | undefined;
43
+ disabled?: boolean | undefined;
44
+ size?: Size | undefined;
45
+ class?: string | undefined;
46
+ 'aria-label'?: string | undefined;
47
+ 'aria-describedby'?: string | undefined;
48
+ 'aria-invalid'?: boolean | undefined;
49
+ }
50
+
51
+ export interface SearchableSelectOneProps extends SearchableSelectBase {
52
+ multiple?: false | undefined;
53
+ /** The chosen value; absent is nothing chosen. */
54
+ value?: string | undefined;
55
+ onChange?: ((value: string) => void) | undefined;
56
+ }
57
+
58
+ export interface SearchableSelectManyProps extends SearchableSelectBase {
59
+ multiple: true;
60
+ value?: readonly string[] | undefined;
61
+ onChange?: ((values: readonly string[]) => void) | undefined;
62
+ }
63
+
64
+ export type SearchableSelectProps = SearchableSelectOneProps | SearchableSelectManyProps;
65
+
66
+ const valuesOf = (props: SearchableSelectProps): readonly string[] => {
67
+ if (props.multiple === true) return props.value ?? [];
68
+ return props.value === undefined ? [] : [props.value];
69
+ };
70
+
71
+ export function SearchableSelect(props: SearchableSelectProps): JSX.Element {
72
+ const ui = useUi();
73
+ const rt = solid();
74
+ const base = props.id ?? useId('searchable-select');
75
+ const listId = `${base}-list`;
76
+ const statusId = `${base}-status`;
77
+ const initial = valuesOf(props);
78
+ const [mounted, setMounted] = rt.createSignal(false);
79
+ const [chosen, setChosen] = rt.createSignal<readonly string[]>(initial);
80
+ // `null` is "not typing": the field shows the chosen label. Free text lives only here.
81
+ const [query, setQuery] = rt.createSignal<string | null>(null);
82
+ const [open, setOpen] = rt.createSignal(false);
83
+ const [active, setActive] = rt.createSignal(-1);
84
+ let select: HTMLSelectElement | undefined;
85
+ let input: HTMLInputElement | undefined;
86
+ let list: HTMLDivElement | undefined;
87
+ let root: HTMLSpanElement | undefined;
88
+ const many = (): boolean => props.multiple === true;
89
+
90
+ const notify = debounce(
91
+ (text: string) => props.onSearch?.(text),
92
+ finiteOption('SearchableSelect', 'debounceMs', props.debounceMs ?? DEBOUNCE_DEFAULT_MS),
93
+ );
94
+
95
+ const view = (): SearchableView => {
96
+ checkOptions(props.options, chosen());
97
+ const limit = finiteCount('SearchableSelect', 'limit', props.limit ?? COMBOBOX_LIMIT, 0);
98
+ // Under `onSearch` the caller already matched: ranking again could drop what it found.
99
+ return props.onSearch === undefined
100
+ ? searchableView(props.options, query() ?? '', limit)
101
+ : searchableView(props.options, '', limit);
102
+ };
103
+ const picked = (): readonly SearchableOption[] => chosenOptions(props.options, chosen());
104
+ const text = (): string => query() ?? (many() ? '' : (picked()[0]?.label ?? ''));
105
+
106
+ const commit = (next: readonly string[]): void => {
107
+ setChosen(next);
108
+ if (props.multiple === true) props.onChange?.(next);
109
+ else props.onChange?.(next[0] ?? '');
110
+ };
111
+
112
+ const keys = searchableKeys({
113
+ view,
114
+ chosen,
115
+ multiple: many,
116
+ open,
117
+ active,
118
+ query,
119
+ setOpen,
120
+ setActive,
121
+ setQuery,
122
+ commit,
123
+ });
124
+
125
+ rt.createEffect(() => {
126
+ const value = valuesOf(props);
127
+ setChosen(value);
128
+ });
129
+ let held = false;
130
+ let form: HTMLFormElement | null = null;
131
+ const reset = (): void => {
132
+ setQuery(null);
133
+ setOpen(false);
134
+ commit(initial);
135
+ };
136
+ // A required control with nothing chosen refuses the submit; typed text never satisfies it.
137
+ // Written only once `required` was ever asked for: an optional control has no refusal to clear.
138
+ let refuses = false;
139
+ const validity = (): void => {
140
+ if (props.required !== true && !refuses) return;
141
+ refuses = props.required === true && chosen().length === 0;
142
+ input?.setCustomValidity(refuses ? ui.t(UI_KEYS.selectNone) : '');
143
+ };
144
+ /**
145
+ * The field's ref. A ref runs before the element is in the document — no form, no focus — so
146
+ * the work waits one microtask, by when the swap has inserted it.
147
+ */
148
+ const adopt = (el: HTMLInputElement): void => {
149
+ input = el;
150
+ queueMicrotask(() => {
151
+ // The swap: the select held focus, so the field takes it — the reader is never dropped.
152
+ if (held) el.focus();
153
+ validity();
154
+ form = el.form;
155
+ form?.addEventListener('reset', reset);
156
+ });
157
+ };
158
+ rt.createEffect(() => {
159
+ held = select !== undefined && document.activeElement === select;
160
+ setMounted(true);
161
+ rt.onCleanup(() => {
162
+ form?.removeEventListener('reset', reset);
163
+ notify.cancel();
164
+ });
165
+ });
166
+ rt.createEffect(() => {
167
+ chosen();
168
+ validity();
169
+ });
170
+ rt.createEffect(() => {
171
+ // On a touch screen the keyboard takes the lower half: the field goes to the top on open, so
172
+ // the list opens into the half that is still visible.
173
+ if (!open() || typeof matchMedia !== 'function') return;
174
+ if (matchMedia('(pointer: coarse)').matches) root?.scrollIntoView?.({ block: 'start' });
175
+ });
176
+ rt.createEffect(() => {
177
+ if (!open() || active() < 0) return;
178
+ // `?.()`: a DOM with no layout (jsdom, a test harness) has no `scrollIntoView`, and a missing
179
+ // scroll is no reason to lose the keystroke that asked for it.
180
+ list?.querySelector(`[id="${base}-row-${active()}"]`)?.scrollIntoView?.({ block: 'nearest' });
181
+ });
182
+
183
+ const status = (): string => {
184
+ if (!open()) return '';
185
+ const { rows, total } = view();
186
+ if (total === 0) return ui.t(UI_KEYS.noResults);
187
+ return rows.length < total ? ui.t(UI_KEYS.selectMore, { shown: rows.length, total }) : '';
188
+ };
189
+
190
+ const native = (): JSX.Element => {
191
+ // Refused here too: the server render never reaches `view()`, and it is the one that ships.
192
+ checkOptions(props.options, chosen());
193
+ return (
194
+ <SearchableSelectNative
195
+ ref={(el) => {
196
+ select = el;
197
+ }}
198
+ name={props.name}
199
+ options={props.options}
200
+ chosen={chosen()}
201
+ multiple={many()}
202
+ placeholder={props.placeholder}
203
+ id={props.id}
204
+ required={props.required}
205
+ disabled={props.disabled}
206
+ size={props.size}
207
+ aria-label={props['aria-label']}
208
+ aria-describedby={props['aria-describedby']}
209
+ aria-invalid={props['aria-invalid']}
210
+ />
211
+ );
212
+ };
213
+
214
+ const listbox = (): JSX.Element => (
215
+ <div
216
+ ref={(el: HTMLDivElement) => {
217
+ list = el;
218
+ }}
219
+ id={listId}
220
+ role="listbox"
221
+ class={styles['listbox']}
222
+ aria-label={props['aria-label']}
223
+ aria-multiselectable={many() ? 'true' : undefined}
224
+ hidden={!open()}
225
+ >
226
+ {view().rows.map((row, index) => (
227
+ <>
228
+ {row.opensGroup === undefined ? null : (
229
+ <div class={styles['group']} role="presentation" data-group={row.opensGroup}>
230
+ {row.opensGroup}
231
+ </div>
232
+ )}
233
+ {/* biome-ignore lint/a11y/useFocusableInteractive: focus never leaves the combobox; the active row is aria-activedescendant */}
234
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: the combobox's own onKeyDown is the keyboard path to every row */}
235
+ <div
236
+ id={`${base}-row-${index}`}
237
+ role="option"
238
+ tabindex={-1}
239
+ class={styles['option']}
240
+ aria-selected={chosen().includes(row.option.value) ? 'true' : 'false'}
241
+ aria-disabled={row.option.disabled === true ? 'true' : undefined}
242
+ data-active={index === active() ? 'true' : undefined}
243
+ data-value={row.option.value}
244
+ onMouseDown={(event) => event.preventDefault()}
245
+ onClick={() => keys.pickRow(index)}
246
+ >
247
+ <span class={styles['optionLabel']}>{row.option.label}</span>
248
+ {row.option.description === undefined ? null : (
249
+ <span class={styles['description']}>{row.option.description}</span>
250
+ )}
251
+ </div>
252
+ </>
253
+ ))}
254
+ </div>
255
+ );
256
+
257
+ const enhanced = (): JSX.Element => (
258
+ <span class={styles['enhanced']}>
259
+ {many() && picked().length > 0 ? (
260
+ <SearchableSelectChips
261
+ chosen={picked()}
262
+ disabled={props.disabled}
263
+ onRemove={(value) => commit(chosen().filter((v) => v !== value))}
264
+ />
265
+ ) : null}
266
+ <span class={cx(styles['control'], styles[`size-${props.size ?? 'md'}`])}>
267
+ <input
268
+ ref={adopt}
269
+ class={styles['input']}
270
+ type="text"
271
+ role="combobox"
272
+ id={props.id}
273
+ value={text()}
274
+ placeholder={props.placeholder ?? ui.t(UI_KEYS.selectNone)}
275
+ disabled={props.disabled === true}
276
+ autocomplete="off"
277
+ aria-autocomplete="list"
278
+ aria-expanded={open() ? 'true' : 'false'}
279
+ aria-controls={listId}
280
+ aria-activedescendant={open() && active() >= 0 ? `${base}-row-${active()}` : undefined}
281
+ aria-required={props.required === true ? 'true' : undefined}
282
+ aria-label={props['aria-label']}
283
+ aria-describedby={[props['aria-describedby'], statusId].filter(Boolean).join(' ')}
284
+ aria-invalid={ariaBool(props['aria-invalid'])}
285
+ onInput={(event) => {
286
+ keys.typed(event.currentTarget.value);
287
+ notify(event.currentTarget.value);
288
+ }}
289
+ onKeyDown={keys.onKeyDown}
290
+ onClick={() => keys.toggle()}
291
+ onBlur={() => keys.close()}
292
+ />
293
+ {chosen().length > 0 && (many() || props.required !== true) ? (
294
+ <button
295
+ type="button"
296
+ class={styles['clear']}
297
+ disabled={props.disabled === true}
298
+ data-clear="true"
299
+ onMouseDown={(event) => event.preventDefault()}
300
+ onClick={() => {
301
+ keys.close();
302
+ commit([]);
303
+ }}
304
+ >
305
+ {ui.t(UI_KEYS.selectClear)}
306
+ </button>
307
+ ) : null}
308
+ </span>
309
+ {chosen().map((value) => (
310
+ <input type="hidden" name={props.name} value={value} data-posted="true" />
311
+ ))}
312
+ {listbox()}
313
+ <p id={statusId} role="status" class={status() === '' ? styles['quiet'] : styles['status']}>
314
+ {status()}
315
+ </p>
316
+ </span>
317
+ );
318
+
319
+ return (
320
+ <span
321
+ ref={(el: HTMLSpanElement) => {
322
+ root = el;
323
+ }}
324
+ class={cx(styles['root'], props.class)}
325
+ data-mounted={mounted() ? 'true' : 'false'}
326
+ >
327
+ {/* A thunk: the one branch the swap re-decides, and what a test re-walks after it. */}
328
+ {thunk(() => (mounted() ? enhanced() : native()))}
329
+ </span>
330
+ );
331
+ }
@@ -0,0 +1,41 @@
1
+ // The chosen values of a many-valued SearchableSelect, as removable chips: each a label and one
2
+ // button naming what it removes. Split from `SearchableSelect.tsx` by the 200-line target; it owns
3
+ // no state — every removal is the parent's `onRemove`, so a chip and the posted inputs agree.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { UI_KEYS } from '../i18n-keys';
7
+ import { useUi } from '../theme/context';
8
+ import styles from './SearchableSelect.module.scss';
9
+ import type { SearchableOption } from './searchable-select-view';
10
+
11
+ export interface SearchableSelectChipsProps {
12
+ /** The chosen options, in the order they were chosen. */
13
+ chosen: readonly SearchableOption[];
14
+ onRemove: (value: string) => void;
15
+ disabled?: boolean | undefined;
16
+ }
17
+
18
+ export function SearchableSelectChips(props: SearchableSelectChipsProps): JSX.Element {
19
+ const ui = useUi();
20
+ return (
21
+ <ul class={styles['chips']} data-chips="true">
22
+ {props.chosen.map((option) => (
23
+ <li class={styles['chip']}>
24
+ <span class={styles['chipLabel']}>{option.label}</span>
25
+ <button
26
+ type="button"
27
+ class={styles['chipRemove']}
28
+ disabled={props.disabled === true}
29
+ aria-label={ui.t(UI_KEYS.selectRemove, { label: option.label })}
30
+ data-remove={option.value}
31
+ // Pressed with the mouse, the field keeps focus: the listbox stays where it was.
32
+ onMouseDown={(event) => event.preventDefault()}
33
+ onClick={() => props.onRemove(option.value)}
34
+ >
35
+ <span aria-hidden="true" class={styles['cross']} />
36
+ </button>
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ );
41
+ }
@@ -0,0 +1,89 @@
1
+ // A SearchableSelect with scripting off — and its first client render: the real `<select>`, with
2
+ // `multiple` for many and `<optgroup>` for groups, posting the same `name` and values the enhanced
3
+ // control does. Split from `SearchableSelect.tsx` by the 200-line target; it holds no state.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { ariaBool } from '../a11y';
7
+ import { cx } from '../cx';
8
+ import styles from './SearchableSelect.module.scss';
9
+ import type { SearchableOption } from './searchable-select-view';
10
+ import type { Size } from './variants';
11
+
12
+ export interface SearchableSelectNativeProps {
13
+ ref: (el: HTMLSelectElement) => void;
14
+ name: string;
15
+ options: readonly SearchableOption[];
16
+ chosen: readonly string[];
17
+ multiple: boolean;
18
+ placeholder: string | undefined;
19
+ id: string | undefined;
20
+ required: boolean | undefined;
21
+ disabled: boolean | undefined;
22
+ size: Size | undefined;
23
+ 'aria-label': string | undefined;
24
+ 'aria-describedby': string | undefined;
25
+ 'aria-invalid': boolean | undefined;
26
+ }
27
+
28
+ export function SearchableSelectNative(props: SearchableSelectNativeProps): JSX.Element {
29
+ return (
30
+ <span class={cx(styles['native'], styles[`size-${props.size ?? 'md'}`])}>
31
+ <select
32
+ ref={props.ref}
33
+ class={styles['select']}
34
+ id={props.id}
35
+ name={props.name}
36
+ multiple={props.multiple}
37
+ required={props.required === true}
38
+ disabled={props.disabled === true}
39
+ aria-label={props['aria-label']}
40
+ aria-describedby={props['aria-describedby']}
41
+ aria-invalid={ariaBool(props['aria-invalid'])}
42
+ >
43
+ {/* A disabled first option: an unset single field is never a submittable value. */}
44
+ {props.multiple || props.placeholder === undefined ? null : (
45
+ <option value="" disabled selected={props.chosen.length === 0}>
46
+ {props.placeholder}
47
+ </option>
48
+ )}
49
+ {groupsOf(props.options).map((part) =>
50
+ part.group === undefined ? (
51
+ part.options.map((option) => nativeOption(option, props.chosen))
52
+ ) : (
53
+ <optgroup label={part.group}>
54
+ {part.options.map((option) => nativeOption(option, props.chosen))}
55
+ </optgroup>
56
+ ),
57
+ )}
58
+ </select>
59
+ </span>
60
+ );
61
+ }
62
+
63
+ function nativeOption(option: SearchableOption, chosen: readonly string[]): JSX.Element {
64
+ return (
65
+ <option
66
+ value={option.value}
67
+ disabled={option.disabled === true}
68
+ selected={chosen.includes(option.value)}
69
+ >
70
+ {option.label}
71
+ </option>
72
+ );
73
+ }
74
+
75
+ interface OptionRun {
76
+ readonly group: string | undefined;
77
+ readonly options: readonly SearchableOption[];
78
+ }
79
+
80
+ /** Adjacent options of one group as one run — `checkOptions` already refused a split group. */
81
+ function groupsOf(options: readonly SearchableOption[]): readonly OptionRun[] {
82
+ const runs: { group: string | undefined; options: SearchableOption[] }[] = [];
83
+ for (const option of options) {
84
+ const last = runs.at(-1);
85
+ if (last !== undefined && last.group === option.group) last.options.push(option);
86
+ else runs.push({ group: option.group, options: [option] });
87
+ }
88
+ return runs;
89
+ }
@@ -0,0 +1,33 @@
1
+ @use '../tokens' as t;
2
+
3
+ .sheet {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: t.space(2);
7
+ margin: 0;
8
+ }
9
+
10
+ .row {
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: space-between;
14
+ gap: t.space(4);
15
+ }
16
+
17
+ .label {
18
+ color: t.role('fg');
19
+ }
20
+
21
+ .keys {
22
+ display: flex;
23
+ flex-wrap: wrap;
24
+ gap: t.space(2);
25
+ margin: 0;
26
+ }
27
+
28
+ .binding,
29
+ .step {
30
+ display: inline-flex;
31
+ align-items: center;
32
+ gap: t.space(1);
33
+ }
@@ -0,0 +1,67 @@
1
+ // The page's keyboard legend: a `Dialog` listing every row every island registered, opened by `?`
2
+ // — itself a row of the same table, so the sheet lists exactly the bindings that work. Hidden with
3
+ // scripting off (a shortcut needs a listener) and never offered where nothing hovers: a phone.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { hotkeyKeycaps } from '../hotkeys-view';
8
+ import { UI_KEYS } from '../i18n-keys';
9
+ import { useUi } from '../theme/context';
10
+ import { solid } from '../theme/solid-adapter';
11
+ import { useHotkeyRows, useHotkeys } from '../use-hotkeys';
12
+ import { Dialog } from './Dialog';
13
+ import { Kbd } from './Kbd';
14
+ import styles from './ShortcutSheet.module.scss';
15
+
16
+ export interface ShortcutSheetProps {
17
+ /** The dialog's title and the `?` row's legend. Defaults to the translated `ui.shortcuts`. */
18
+ title?: string | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ /** A touch-only device has no keyboard to read the legend for. */
23
+ const offered = (): boolean =>
24
+ typeof matchMedia !== 'function' || !matchMedia('(hover: none)').matches;
25
+
26
+ export function ShortcutSheet(props: ShortcutSheetProps): JSX.Element {
27
+ const ui = useUi();
28
+ const rt = solid();
29
+ const [open, setOpen] = rt.createSignal(false);
30
+ const title = (): string => props.title ?? ui.t(UI_KEYS.shortcuts);
31
+ const table = useHotkeyRows();
32
+
33
+ useHotkeys([
34
+ {
35
+ keys: ['?'],
36
+ label: title(),
37
+ run: () => {
38
+ if (offered()) setOpen(!open());
39
+ },
40
+ },
41
+ ]);
42
+
43
+ return (
44
+ <Dialog open={open()} title={title()} size="sm" onClose={() => setOpen(false)}>
45
+ <dl class={cx(styles['sheet'], props.class)}>
46
+ {table().rows.map((row) => (
47
+ <div class={styles['row']}>
48
+ <dt class={styles['label']}>{row.label}</dt>
49
+ <dd class={styles['keys']}>
50
+ {row.keys.map((binding) => (
51
+ <span class={styles['binding']}>
52
+ {hotkeyKeycaps(binding, table().mac).map((step) => (
53
+ <span class={styles['step']}>
54
+ {step.map((cap) => (
55
+ <Kbd>{cap}</Kbd>
56
+ ))}
57
+ </span>
58
+ ))}
59
+ </span>
60
+ ))}
61
+ </dd>
62
+ </div>
63
+ ))}
64
+ </dl>
65
+ </Dialog>
66
+ );
67
+ }
@@ -0,0 +1,50 @@
1
+ @use '../tokens' as t;
2
+
3
+ .slider {
4
+ display: grid;
5
+ gap: t.space(1);
6
+ min-inline-size: 0;
7
+ }
8
+
9
+ .head {
10
+ display: flex;
11
+ align-items: baseline;
12
+ justify-content: space-between;
13
+ gap: t.space(2);
14
+ }
15
+
16
+ .label {
17
+ color: t.role('fg-strong');
18
+ font-size: t.text(sm);
19
+ font-weight: t.weight(medium);
20
+ }
21
+
22
+ .output {
23
+ color: t.role('fg-muted');
24
+ font-size: t.text(sm);
25
+ font-variant-numeric: tabular-nums;
26
+ }
27
+
28
+ // The thumb's hit area is the touch target: the input is that tall, the track stays thin.
29
+ .input {
30
+ inline-size: 100%;
31
+ min-block-size: t.touch(target);
32
+ margin: 0;
33
+ cursor: pointer;
34
+
35
+ &:focus-visible {
36
+ outline: t.stroke(thick) solid t.role('accent');
37
+ outline-offset: t.stroke(thick);
38
+ }
39
+
40
+ &:disabled {
41
+ @include t.disabled;
42
+ }
43
+ }
44
+
45
+ // The fill and thumb painted from the accent token where the engine paints a range natively.
46
+ @supports (accent-color: auto) {
47
+ .input {
48
+ accent-color: t.role('accent');
49
+ }
50
+ }
@@ -0,0 +1,68 @@
1
+ // A bounded number: a native `<input type="range">` with its label and an `<output>` bound to it.
2
+ // The platform owns the thumb, the keyboard and the touch; the paint is tokens (`accent-color`).
3
+ // No thumb script — a kit without one sends an agent to a div. Rules: `slider-view.ts`.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { useId } from '../a11y';
7
+ import { cx } from '../cx';
8
+ import { solid } from '../theme/solid-adapter';
9
+ import styles from './Slider.module.scss';
10
+ import { sliderNumbers } from './slider-view';
11
+
12
+ export interface SliderProps {
13
+ name: string;
14
+ /** Already-translated visible label. */
15
+ label: string;
16
+ id?: string | undefined;
17
+ min?: number | undefined;
18
+ max?: number | undefined;
19
+ step?: number | undefined;
20
+ value?: number | undefined;
21
+ /** The output's text and the control's `aria-valuetext`, in the caller's locale and unit. */
22
+ format?: ((value: number) => string) | undefined;
23
+ disabled?: boolean | undefined;
24
+ 'aria-describedby'?: string | undefined;
25
+ onInput?: ((value: number) => void) | undefined;
26
+ class?: string | undefined;
27
+ }
28
+
29
+ export function Slider(props: SliderProps): JSX.Element {
30
+ const rt = solid();
31
+ const fallbackId = useId('slider');
32
+ const id = (): string => props.id ?? fallbackId;
33
+ const numbers = () =>
34
+ sliderNumbers({ min: props.min, max: props.max, step: props.step, value: props.value });
35
+ const [value, setValue] = rt.createSignal(numbers().value);
36
+ const text = (): string => (props.format === undefined ? String(value()) : props.format(value()));
37
+
38
+ return (
39
+ <div class={cx(styles['slider'], props.class)}>
40
+ <div class={styles['head']}>
41
+ <label class={styles['label']} for={id()}>
42
+ {props.label}
43
+ </label>
44
+ <output class={styles['output']} for={id()}>
45
+ {text()}
46
+ </output>
47
+ </div>
48
+ <input
49
+ class={styles['input']}
50
+ type="range"
51
+ id={id()}
52
+ name={props.name}
53
+ min={numbers().min}
54
+ max={numbers().max}
55
+ step={numbers().step}
56
+ value={numbers().value}
57
+ disabled={props.disabled === true}
58
+ aria-valuetext={props.format === undefined ? undefined : text()}
59
+ aria-describedby={props['aria-describedby']}
60
+ onInput={(event: InputEvent) => {
61
+ const next = Number((event.currentTarget as HTMLInputElement).value);
62
+ setValue(next);
63
+ props.onInput?.(next);
64
+ }}
65
+ />
66
+ </div>
67
+ );
68
+ }