@mystravil/hardpoint 0.1.0 → 0.2.1

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 (67) hide show
  1. package/CHANGELOG.md +42 -18
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -346
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +174 -174
  7. package/dist/components/Checkbox.svelte +235 -198
  8. package/dist/components/Checkbox.svelte.d.ts +9 -2
  9. package/dist/components/CheckboxGroup.svelte +207 -99
  10. package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
  11. package/dist/components/Combobox.svelte +901 -824
  12. package/dist/components/Combobox.svelte.d.ts +10 -1
  13. package/dist/components/Dialog.svelte +359 -356
  14. package/dist/components/Disclosure.svelte +201 -201
  15. package/dist/components/EmptyState.svelte +88 -88
  16. package/dist/components/ErrorState.svelte +135 -135
  17. package/dist/components/ImageViewer.svelte +764 -0
  18. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  19. package/dist/components/KeyValueList.svelte +114 -114
  20. package/dist/components/ListDetail.svelte +218 -0
  21. package/dist/components/ListDetail.svelte.d.ts +37 -0
  22. package/dist/components/LoadRegion.svelte +219 -219
  23. package/dist/components/MediaFrame.svelte +110 -110
  24. package/dist/components/MediaImage.svelte +256 -256
  25. package/dist/components/Menu.svelte +258 -258
  26. package/dist/components/MenuCheckboxItem.svelte +119 -119
  27. package/dist/components/MenuGroup.svelte +50 -50
  28. package/dist/components/MenuItem.svelte +94 -94
  29. package/dist/components/MenuRadioGroup.svelte +62 -62
  30. package/dist/components/MenuRadioItem.svelte +110 -110
  31. package/dist/components/MenuSeparator.svelte +28 -28
  32. package/dist/components/Meter.svelte +283 -0
  33. package/dist/components/Meter.svelte.d.ts +66 -0
  34. package/dist/components/NativeSelect.svelte +18 -2
  35. package/dist/components/NativeSelect.svelte.d.ts +6 -0
  36. package/dist/components/Notice.svelte +262 -0
  37. package/dist/components/Notice.svelte.d.ts +30 -0
  38. package/dist/components/Popover.svelte +222 -222
  39. package/dist/components/Progress.svelte +157 -157
  40. package/dist/components/RadioGroup.svelte +440 -0
  41. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  42. package/dist/components/Select.svelte +471 -405
  43. package/dist/components/Select.svelte.d.ts +6 -0
  44. package/dist/components/Skeleton.svelte +149 -149
  45. package/dist/components/Spinner.svelte +118 -117
  46. package/dist/components/Stat.svelte +253 -253
  47. package/dist/components/Steps.svelte +189 -189
  48. package/dist/components/Switch.svelte +222 -222
  49. package/dist/components/Table.svelte +504 -504
  50. package/dist/components/Tabs.svelte +246 -246
  51. package/dist/components/Tag.svelte +40 -40
  52. package/dist/components/TextArea.svelte +272 -0
  53. package/dist/components/TextArea.svelte.d.ts +28 -0
  54. package/dist/components/TextField.svelte +256 -256
  55. package/dist/components/Timeline.svelte +156 -156
  56. package/dist/components/Toaster.svelte +411 -411
  57. package/dist/components/Tooltip.svelte +200 -200
  58. package/dist/components/TooltipGroup.svelte +46 -46
  59. package/dist/components/internal/Scrim.svelte +52 -52
  60. package/dist/components/internal/Sheet.svelte +263 -263
  61. package/dist/components/internal/groups.d.ts +14 -0
  62. package/dist/components/internal/groups.js +23 -0
  63. package/dist/fonts.css +71 -71
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.js +6 -0
  66. package/dist/tokens.css +283 -279
  67. package/package.json +81 -76
@@ -1,824 +1,901 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
-
5
- export type ComboboxItem = {
6
- value: string;
7
- /** The option's text: what the default filter matches, and the field's text once chosen. */
8
- label: string;
9
- /** A second line in `--muted`. */
10
- description?: string;
11
- disabled?: boolean;
12
- };
13
-
14
- export type ComboboxItemSnippetProps = {
15
- item: ComboboxItem;
16
- selected: boolean;
17
- highlighted: boolean;
18
- };
19
-
20
- export type ComboboxLabelBeside = boolean | 'medium' | 'expanded' | 'large';
21
-
22
- /** Whether an item stays in the list for the typed text (trimmed, never empty). */
23
- export type ComboboxFilter = (item: ComboboxItem, query: string) => boolean;
24
-
25
- /**
26
- * Attributes and handlers not listed here go to the field control: the
27
- * text input from 600px, the button that opens the sheet below.
28
- */
29
- export type ComboboxProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'id'> & {
30
- items: ComboboxItem[];
31
- /** The chosen item's `value`; `''` for none. It changes only by choosing from the list. */
32
- value?: string;
33
- open?: boolean;
34
- /** The caps field label above the field, and the sheet's title on compact widths. */
35
- label: string;
36
- /** Shown in `--muted` in the empty field and in the sheet's search input. Never the only label. */
37
- placeholder?: string;
38
- /** Guidance under the field in `--muted`, linked by `aria-describedby`. */
39
- hint?: string;
40
- /**
41
- * The error to show under the field, with an icon, linked by
42
- * `aria-describedby`; it sets `aria-invalid`. Say how to fix it.
43
- */
44
- error?: string;
45
- disabled?: boolean;
46
- /** Submits the value with a form, through a hidden input. */
47
- name?: string;
48
- /**
49
- * The label beside the field instead of above it: always (`true`), or
50
- * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
51
- * The hint and the error stay under the field.
52
- */
53
- labelBeside?: ComboboxLabelBeside;
54
- /** Replaces the default filter: the label contains the text, ignoring case and accents. */
55
- filter?: ComboboxFilter;
56
- /** Shown in place of the options when nothing matches. */
57
- emptyText?: string;
58
- /** The sheet's close button name. */
59
- closeLabel?: string;
60
- /** The field control's id; the label points at it. */
61
- id?: string;
62
- /** The field control: the text input from 600px, the button below. */
63
- ref?: HTMLElement | null;
64
- /** Renders an option's content in place of its label and description. */
65
- item?: Snippet<[ComboboxItemSnippetProps]>;
66
- };
67
- </script>
68
-
69
- <script lang="ts">
70
- import { Combobox as BitsCombobox } from 'bits-ui';
71
- import { untrack } from 'svelte';
72
- import { MediaQuery } from 'svelte/reactivity';
73
- import { pop } from '../motion/index.js';
74
- import { fadeIn } from './internal/fade-in.js';
75
- import Sheet from './internal/Sheet.svelte';
76
-
77
- const uid = $props.id();
78
-
79
- let {
80
- items,
81
- value = $bindable(''),
82
- open = $bindable(false),
83
- label,
84
- placeholder = '',
85
- hint,
86
- error,
87
- disabled = false,
88
- name,
89
- labelBeside = false,
90
- filter,
91
- emptyText = 'No matches',
92
- closeLabel = 'Close',
93
- id = `${uid}-field`,
94
- ref = $bindable(null),
95
- item: itemContent,
96
- class: className,
97
- onclick: onclickProp,
98
- oninput: oninputProp,
99
- 'aria-describedby': describedBy,
100
- 'aria-invalid': ariaInvalid,
101
- ...rest
102
- }: ComboboxProps = $props();
103
-
104
- const labelId = `${uid}-label`;
105
- const sheetId = `${uid}-sheet`;
106
- // The listbox, in the popover or the sheet; the input that drives it
107
- // says so with aria-controls, which Bits UI leaves out.
108
- const listId = `${uid}-list`;
109
- const sheetTitleId = `${uid}-sheet-title`;
110
- const hintId = $derived(`${id}-hint`);
111
- const errorId = $derived(`${id}-error`);
112
- // The error first, so a screen reader reads it before the hint.
113
- const describedByIds = $derived(
114
- [error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
115
- undefined
116
- );
117
- const invalid = $derived(error ? true : ariaInvalid);
118
-
119
- // Short motion (BRIEF, --dur-message), as TextField: an error that
120
- // appears after the first render fades in from 0 and the field's edge
121
- // fades to --danger with it; one that clears goes at once. A field that
122
- // renders or mounts with an error shows it at once. 0ms is a snap.
123
- let root: HTMLDivElement | null = $state(null);
124
- let errorShown = untrack(() => !!error);
125
- $effect(() => {
126
- const shown = !!error;
127
- untrack(() => {
128
- const appeared = shown && !errorShown;
129
- errorShown = shown;
130
- const message = appeared ? root?.querySelector(':scope > .hp-combobox-messages > .hp-combobox-error') : null;
131
- if (message) fadeIn(message, '--dur-message');
132
- });
133
- });
134
-
135
- // Compact (< 600px) opens a bottom sheet with the search input inside;
136
- // medium and up type in the field and list under it (BRIEF, Apps and
137
- // phones: Overlays and Choices).
138
- const wide = new MediaQuery('min-width: 600px', true);
139
-
140
- const chosen = $derived(items.find((i) => i.value === value));
141
- // A string, so a fresh `items` array with the same choice changes nothing.
142
- const chosenLabel = $derived(chosen?.label ?? '');
143
-
144
- // The input's text. It shows the chosen label while the list is closed;
145
- // typing overrides it, and closing puts the label back, so the value
146
- // only ever comes from the list.
147
- let text = $derived(chosenLabel);
148
- // What the list is filtered by: only what was typed since it opened, so
149
- // it opens with every option even when the field shows a label.
150
- let query = $state('');
151
-
152
- const fold = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
153
- const byLabel: ComboboxFilter = (entry, q) => fold(entry.label).includes(fold(q));
154
- const matches = $derived.by(() => {
155
- const q = query.trim();
156
- if (!q) return items;
157
- const keep = filter ?? byLabel;
158
- return items.filter((entry) => keep(entry, q));
159
- });
160
-
161
- // The highlight moved by keys gets the focus contour; a pointer's gets the
162
- // hover tint alone. Focus itself stays in the input.
163
- let keyboard = $state(false);
164
-
165
- // Whether Ctrl or Meta was held at the last key press in the field. Bits
166
- // UI opens the list on any key, Ctrl and Meta included, so a shortcut
167
- // such as Ctrl+C opens it too; that open keeps the field's selection, so
168
- // the shortcut copies what the person selected, not the whole label.
169
- let shortcut = false;
170
-
171
- let panelNode = $state<HTMLElement | null>(null);
172
- let sheetNode = $state<HTMLElement | null>(null);
173
-
174
- // Every open and close passes here once, whoever caused it: Bits UI
175
- // (typing, Escape, an outside press, a choice, Tab), the field, the
176
- // sheet's close button and swipe, or the app through `bind:open`.
177
- let settled = false;
178
- function openChanged(next: boolean) {
179
- if (next === settled) return;
180
- settled = next;
181
- query = '';
182
- if (next) {
183
- if (!wide.current) {
184
- // The sheet's search input starts empty.
185
- text = '';
186
- } else if (!shortcut && ref instanceof HTMLInputElement && document.activeElement === ref) {
187
- // Opened in the field, by a press or a key: the text is selected
188
- // (before a typed character lands), so typing replaces the label
189
- // and filters on what was typed alone.
190
- ref.select();
191
- }
192
- return;
193
- }
194
- text = chosenLabel;
195
- // The sheet held focus; give it back to the field.
196
- if (!wide.current) {
197
- const active = document.activeElement;
198
- if (!active || active === document.body || sheetNode?.contains(active)) ref?.focus();
199
- }
200
- }
201
- $effect.pre(() => {
202
- const next = open;
203
- untrack(() => openChanged(next));
204
- });
205
-
206
- function setOpen(next: boolean) {
207
- open = next;
208
- openChanged(next);
209
- }
210
-
211
- function onInput(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
212
- oninputProp?.(e);
213
- query = e.currentTarget.value;
214
- text = query;
215
- }
216
-
217
- // A press on the field opens the list (openChanged selects its text).
218
- // While open, a press on the chevron end closes it; one on the text
219
- // places the caret. A label's click (detail 0) only focuses.
220
- function onFieldClick(e: MouseEvent & { currentTarget: EventTarget & HTMLInputElement }) {
221
- onclickProp?.(e);
222
- if (disabled || e.detail === 0) return;
223
- const input = e.currentTarget;
224
- if (!open) {
225
- setOpen(true);
226
- return;
227
- }
228
- const box = input.getBoundingClientRect();
229
- if (e.clientX >= box.right - parseFloat(getComputedStyle(input).paddingRight)) setOpen(false);
230
- }
231
-
232
- function onTriggerClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
233
- onclickProp?.(e);
234
- if (!disabled) setOpen(true);
235
- }
236
-
237
- // As on Select's trigger: the arrow keys open the sheet too.
238
- function onTriggerKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
239
- rest.onkeydown?.(e);
240
- if (disabled || e.defaultPrevented) return;
241
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
242
- e.preventDefault();
243
- setOpen(true);
244
- }
245
- }
246
-
247
- function close() {
248
- setOpen(false);
249
- }
250
-
251
- // The panel's empty line and the sheet's title row sit outside the
252
- // listbox; pressing them is not an outside press.
253
- function insidePanel(e: PointerEvent) {
254
- if (panelNode && e.target instanceof Node && panelNode.contains(e.target)) e.preventDefault();
255
- }
256
- function insideSheet(e: PointerEvent) {
257
- if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
258
- }
259
-
260
- // Presses on the overlay's chrome keep focus in the input; a press on the
261
- // sheet's search input itself still focuses it.
262
- function keepFocus(e: PointerEvent) {
263
- if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
264
- }
265
-
266
- function focusOnMount(node: HTMLInputElement) {
267
- node.focus({ preventScroll: true });
268
- }
269
-
270
- // In the sheet, Bits UI never learns that its content is placed:
271
- // ContentStatic with forceMount reports placement once, at mount, while
272
- // still closed. So Bits neither opens it at the chosen option nor scrolls
273
- // to the option the keys move to; this does both, nearest edge first, as
274
- // Bits does in the popover (Select does the same).
275
- function followHighlight(viewport: HTMLElement) {
276
- const reveal = (option: Element | null) => {
277
- if (!(option instanceof HTMLElement)) return;
278
- const top = option.offsetTop;
279
- const bottom = top + option.offsetHeight;
280
- if (top < viewport.scrollTop) viewport.scrollTop = top;
281
- else if (bottom > viewport.scrollTop + viewport.clientHeight) {
282
- viewport.scrollTop = bottom - viewport.clientHeight;
283
- }
284
- };
285
- reveal(viewport.querySelector('[data-selected]'));
286
- const observer = new MutationObserver((records) => {
287
- if (!keyboard) return;
288
- for (const { target } of records) {
289
- if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
290
- }
291
- });
292
- observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
293
- return () => observer.disconnect();
294
- }
295
- </script>
296
-
297
- <div
298
- bind:this={root}
299
- class={['hp-combobox', className]}
300
- data-state={open ? 'open' : 'closed'}
301
- data-disabled={disabled || undefined}
302
- data-placeholder={chosen ? undefined : ''}
303
- data-invalid={error ? '' : undefined}
304
- data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
305
- onkeydowncapture={(e) => {
306
- keyboard = true;
307
- // Chrome on Windows reports AltGr as Ctrl+Alt; AltGr types a character.
308
- shortcut = (e.ctrlKey || e.metaKey) && !e.altKey;
309
- }}
310
- onpointerdowncapture={() => {
311
- keyboard = false;
312
- shortcut = false;
313
- }}
314
- >
315
- <label class="hp-combobox-label hp-label" id={labelId} for={id}>{label}</label>
316
-
317
- <BitsCombobox.Root
318
- type="single"
319
- bind:value
320
- bind:open
321
- {items}
322
- {disabled}
323
- {name}
324
- allowDeselect={false}
325
- inputValue={text}
326
- onOpenChange={openChanged}
327
- >
328
- {#if wide.current}
329
- <BitsCombobox.Input
330
- {...rest}
331
- bind:ref
332
- {id}
333
- {placeholder}
334
- autocomplete="off"
335
- spellcheck={false}
336
- aria-describedby={describedByIds}
337
- aria-invalid={invalid}
338
- oninput={onInput}
339
- aria-controls={open ? listId : undefined}
340
- onclick={onFieldClick}
341
- >
342
- {#snippet child({ props })}
343
- <span class="hp-combobox-field hp-well-chevron">
344
- <input {...props} class="hp-combobox-input hp-well" />
345
- </span>
346
- {/snippet}
347
- </BitsCombobox.Input>
348
-
349
- <BitsCombobox.Portal>
350
- <BitsCombobox.Content
351
- id={listId}
352
- forceMount
353
- side="bottom"
354
- align="start"
355
- sideOffset={8}
356
- collisionPadding={8}
357
- aria-labelledby={labelId}
358
- onInteractOutside={insidePanel}
359
- >
360
- {#snippet child({ wrapperProps, props, open: shown })}
361
- {#if shown}
362
- <div {...wrapperProps}>
363
- <div
364
- class="hp-combobox-panel"
365
- data-form="popover"
366
- data-side={props['data-side'] as string | undefined}
367
- bind:this={panelNode}
368
- role="presentation"
369
- onpointerdown={keepFocus}
370
- transition:pop
371
- >
372
- {@render empty(false)}
373
- <div
374
- {...props}
375
- class="hp-combobox-list"
376
- data-form="popover"
377
- data-empty={matches.length ? undefined : ''}
378
- data-keyboard={keyboard || undefined}
379
- onpointermovecapture={() => (keyboard = false)}
380
- >
381
- {@render options(false)}
382
- </div>
383
- </div>
384
- </div>
385
- {/if}
386
- {/snippet}
387
- </BitsCombobox.Content>
388
- </BitsCombobox.Portal>
389
- {:else}
390
- <span class="hp-combobox-field hp-well-chevron">
391
- <button
392
- {...rest}
393
- bind:this={ref}
394
- type="button"
395
- {id}
396
- class="hp-combobox-trigger hp-well"
397
- role="combobox"
398
- aria-haspopup="dialog"
399
- aria-expanded={open}
400
- aria-controls={open ? sheetId : undefined}
401
- aria-labelledby={labelId}
402
- aria-describedby={describedByIds}
403
- aria-invalid={invalid}
404
- data-placeholder={chosen ? undefined : ''}
405
- {disabled}
406
- onclick={onTriggerClick}
407
- onkeydown={onTriggerKeydown}
408
- >
409
- <span class="hp-combobox-value">{chosen?.label ?? placeholder}</span>
410
- </button>
411
- </span>
412
-
413
- <BitsCombobox.Portal>
414
- <BitsCombobox.ContentStatic id={listId} forceMount preventScroll onInteractOutside={insideSheet}>
415
- {#snippet child({ props, open: shown })}
416
- <Sheet
417
- open={shown}
418
- title={label}
419
- titleId={sheetTitleId}
420
- layer="popover"
421
- onclose={close}
422
- {closeLabel}
423
- bind:ref={sheetNode}
424
- id={sheetId}
425
- role="dialog"
426
- aria-modal="true"
427
- aria-labelledby={sheetTitleId}
428
- onpointerdown={keepFocus}
429
- >
430
- <div class="hp-combobox-search" onkeydowncapture={() => (keyboard = true)}>
431
- <BitsCombobox.Input
432
- {placeholder}
433
- autocomplete="off"
434
- spellcheck={false}
435
- aria-labelledby={sheetTitleId}
436
- aria-describedby={describedByIds}
437
- aria-controls={open ? listId : undefined}
438
- oninput={onInput}
439
- >
440
- {#snippet child({ props: inputProps })}
441
- <input {...inputProps} class="hp-combobox-input hp-well" {@attach focusOnMount} />
442
- {/snippet}
443
- </BitsCombobox.Input>
444
- </div>
445
- {@render empty(true)}
446
- <div
447
- {...props}
448
- class="hp-combobox-list"
449
- data-form="sheet"
450
- data-empty={matches.length ? undefined : ''}
451
- aria-labelledby={sheetTitleId}
452
- data-keyboard={keyboard || undefined}
453
- onpointermovecapture={() => (keyboard = false)}
454
- >
455
- {@render options(true)}
456
- </div>
457
- </Sheet>
458
- {/snippet}
459
- </BitsCombobox.ContentStatic>
460
- </BitsCombobox.Portal>
461
- {/if}
462
- </BitsCombobox.Root>
463
-
464
- {#if hint || error}
465
- <div class="hp-combobox-messages">
466
- {#if hint}
467
- <p class="hp-combobox-hint hp-caption" id={hintId}>{hint}</p>
468
- {/if}
469
- {#if error}
470
- <p class="hp-combobox-error hp-caption" id={errorId}>
471
- <span class="hp-combobox-error-icon" aria-hidden="true">
472
- <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
473
- <path d="M1.5 1.5h13v13h-13z" />
474
- <path d="M8 4v4M8 10v2" stroke-width="2" />
475
- </svg>
476
- </span>
477
- <span>{error}</span>
478
- </p>
479
- {/if}
480
- </div>
481
- {/if}
482
- </div>
483
-
484
- {#snippet options(inSheet: boolean)}
485
- <BitsCombobox.Viewport>
486
- {#snippet child({ props: viewportProps })}
487
- <div {...viewportProps} class="hp-combobox-viewport" {@attach inSheet && followHighlight}>
488
- {#each matches as entry (entry.value)}
489
- <BitsCombobox.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
490
- {#snippet child({ props: optionProps, selected, highlighted })}
491
- <div
492
- {...optionProps}
493
- class="hp-combobox-option"
494
- data-described={entry.description ? '' : undefined}
495
- aria-disabled={entry.disabled ? 'true' : undefined}
496
- >
497
- {#if itemContent}
498
- {@render itemContent({ item: entry, selected, highlighted })}
499
- {:else}
500
- <span class="hp-combobox-option-label">{entry.label}</span>
501
- {#if entry.description}
502
- <span class="hp-combobox-option-description">{entry.description}</span>
503
- {/if}
504
- {/if}
505
- </div>
506
- {/snippet}
507
- </BitsCombobox.Item>
508
- {/each}
509
- </div>
510
- {/snippet}
511
- </BitsCombobox.Viewport>
512
- {/snippet}
513
-
514
- <!-- Outside the listbox, which may hold options only. Mounted while the
515
- list is open, so the text it gains when nothing matches is announced. -->
516
- {#snippet empty(inSheet: boolean)}
517
- <p class="hp-combobox-empty" data-form={inSheet ? 'sheet' : 'popover'} role="status">
518
- {#if !matches.length}{emptyText}{/if}
519
- </p>
520
- {/snippet}
521
-
522
- <style>
523
- /* ---- Field: caps label over a black well --------------------------- */
524
-
525
- .hp-combobox {
526
- /* Bits UI's hidden form input is absolutely placed in here. */
527
- position: relative;
528
- display: grid;
529
- gap: var(--space-1);
530
- min-width: 0;
531
- }
532
-
533
- .hp-combobox-label {
534
- color: var(--muted);
535
- }
536
-
537
- /* The input or the trigger is base.css's well; its wrapper draws the
538
- chevron, like a select's. The wrapper is the grid item, so it holds
539
- the width and the minimum. */
540
- .hp-combobox-field {
541
- width: 100%;
542
- min-width: 136px;
543
- }
544
- .hp-combobox-input {
545
- display: block;
546
- width: 100%;
547
- min-width: 0;
548
- }
549
- .hp-combobox-input::placeholder {
550
- color: var(--muted);
551
- opacity: 1;
552
- }
553
- .hp-combobox-trigger {
554
- display: flex;
555
- align-items: center;
556
- width: 100%;
557
- text-align: start;
558
- cursor: pointer;
559
- -webkit-tap-highlight-color: transparent;
560
- }
561
- .hp-combobox-trigger:disabled {
562
- cursor: not-allowed;
563
- }
564
- .hp-combobox-trigger[data-placeholder] {
565
- color: var(--muted);
566
- }
567
- .hp-combobox-value {
568
- min-width: 0;
569
- overflow: hidden;
570
- text-overflow: ellipsis;
571
- white-space: nowrap;
572
- }
573
-
574
- /* Invalid: the boundary turns --danger, never alone: the icon and the
575
- message say what is wrong (as TextField). It fades to --danger over
576
- --dur-message with the message and goes back at once when the error
577
- clears, because the transition belongs to this rule. The hover fill
578
- keeps base.css's timing. */
579
- .hp-combobox[data-invalid] .hp-well {
580
- border-color: var(--danger);
581
- transition:
582
- background-color var(--dur-hover) var(--settle),
583
- border-color var(--dur-message) var(--settle);
584
- }
585
- .hp-combobox[data-invalid] .hp-well:disabled {
586
- border-color: var(--border);
587
- }
588
-
589
- /* ---- Hint and error, as TextField's -------------------------------- */
590
-
591
- /* The first message starts 8px under the well, clear of the focus
592
- contour (2px, drawn 4px outside the well); 4px between messages. */
593
- .hp-combobox-messages {
594
- display: grid;
595
- gap: var(--space-1);
596
- margin-top: var(--space-1);
597
- }
598
- .hp-combobox-hint {
599
- margin: 0;
600
- color: var(--muted);
601
- }
602
- .hp-combobox-error {
603
- display: flex;
604
- gap: var(--space-2);
605
- margin: 0;
606
- color: var(--danger);
607
- }
608
- /* One line box high, so the icon centres on the first line of the
609
- message however it wraps. */
610
- .hp-combobox-error-icon {
611
- display: flex;
612
- flex: none;
613
- align-items: center;
614
- height: 1lh;
615
- }
616
- .hp-combobox-error-icon svg {
617
- display: block;
618
- }
619
-
620
- /* ---- Label beside the field: always, or from a breakpoint up ------- */
621
-
622
- /* The label keeps its one line in the first column; the field and its
623
- messages share the second. */
624
- .hp-combobox[data-label-beside='always'] {
625
- grid-template-columns: auto minmax(0, 1fr);
626
- column-gap: var(--space-2);
627
- }
628
- .hp-combobox[data-label-beside='always'] .hp-combobox-label {
629
- grid-row: 1;
630
- align-self: center;
631
- white-space: nowrap;
632
- }
633
- .hp-combobox[data-label-beside='always'] .hp-combobox-field,
634
- .hp-combobox[data-label-beside='always'] .hp-combobox-messages {
635
- grid-column: 2;
636
- }
637
- @media (min-width: 600px) {
638
- .hp-combobox[data-label-beside='medium'] {
639
- grid-template-columns: auto minmax(0, 1fr);
640
- column-gap: var(--space-2);
641
- }
642
- .hp-combobox[data-label-beside='medium'] .hp-combobox-label {
643
- grid-row: 1;
644
- align-self: center;
645
- white-space: nowrap;
646
- }
647
- .hp-combobox[data-label-beside='medium'] .hp-combobox-field,
648
- .hp-combobox[data-label-beside='medium'] .hp-combobox-messages {
649
- grid-column: 2;
650
- }
651
- }
652
- @media (min-width: 900px) {
653
- .hp-combobox[data-label-beside='expanded'] {
654
- grid-template-columns: auto minmax(0, 1fr);
655
- column-gap: var(--space-2);
656
- }
657
- .hp-combobox[data-label-beside='expanded'] .hp-combobox-label {
658
- grid-row: 1;
659
- align-self: center;
660
- white-space: nowrap;
661
- }
662
- .hp-combobox[data-label-beside='expanded'] .hp-combobox-field,
663
- .hp-combobox[data-label-beside='expanded'] .hp-combobox-messages {
664
- grid-column: 2;
665
- }
666
- }
667
- @media (min-width: 1200px) {
668
- .hp-combobox[data-label-beside='large'] {
669
- grid-template-columns: auto minmax(0, 1fr);
670
- column-gap: var(--space-2);
671
- }
672
- .hp-combobox[data-label-beside='large'] .hp-combobox-label {
673
- grid-row: 1;
674
- align-self: center;
675
- white-space: nowrap;
676
- }
677
- .hp-combobox[data-label-beside='large'] .hp-combobox-field,
678
- .hp-combobox[data-label-beside='large'] .hp-combobox-messages {
679
- grid-column: 2;
680
- }
681
- }
682
-
683
- /* ---- The list: a popover panel on medium and up, the sheet's body on
684
- compact ---------------------------------------------------------- */
685
-
686
- /* The overlay parts render in a portal at the end of <body>, outside
687
- .hp-combobox, so they carry this themselves. */
688
- .hp-combobox-panel,
689
- .hp-combobox-list,
690
- .hp-combobox-empty {
691
- /* Option text lines up with the field's text (popover) or the
692
- sheet's title (sheet). */
693
- --option-inset: var(--space-3);
694
- /* The popover list's cap: eight and a half 36px rows, so a cut row
695
- shows there is more. */
696
- --combobox-list-height: 320px;
697
- }
698
-
699
- /* The panel draws the frame round the listbox and the empty line, on the
700
- popover layer (tokens.css), over the shell and an open dialog. */
701
- .hp-combobox-panel {
702
- position: relative;
703
- z-index: var(--layer-popover);
704
- display: flex;
705
- flex-direction: column;
706
- box-sizing: border-box;
707
- border: 1px solid var(--border-control);
708
- background: var(--canvas);
709
- color: var(--text);
710
- font-family: var(--sans);
711
- }
712
-
713
- .hp-combobox-list {
714
- box-sizing: border-box;
715
- background: var(--canvas);
716
- color: var(--text);
717
- font-family: var(--sans);
718
- }
719
- /* As wide as the field at least, inside the panel's two 1px borders. */
720
- .hp-combobox-list[data-form='popover'] {
721
- min-width: calc(var(--bits-combobox-anchor-width) - 2px);
722
- max-width: max(
723
- calc(var(--bits-combobox-anchor-width) - 2px),
724
- min(calc(var(--bits-combobox-content-available-width) - 2px), 480px)
725
- );
726
- max-height: min(var(--combobox-list-height), calc(var(--bits-combobox-content-available-height) - 2px));
727
- }
728
- /* The sheet keeps the height it opened with while the list filters, so
729
- the search input never moves under the typing thumb: the list asks for
730
- the sheet's whole cap (--sheet-max-height from internal/Sheet.svelte:
731
- half the screen, or the room above the on-screen keyboard) and shrinks
732
- into what the title and search rows leave. */
733
- .hp-combobox-list[data-form='sheet'] {
734
- --option-inset: var(--gutter);
735
- flex: 1 1 auto;
736
- height: var(--sheet-max-height);
737
- min-height: 0;
738
- }
739
-
740
- /* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
741
- .hp-combobox-viewport[data-combobox-viewport] {
742
- scrollbar-width: thin !important;
743
- overscroll-behavior: contain;
744
- padding-block: var(--space-1);
745
- }
746
- .hp-combobox-list[data-empty] .hp-combobox-viewport[data-combobox-viewport] {
747
- padding-block: 0;
748
- }
749
-
750
- /* ---- Options: Geist list items, sentence case ----------------------- */
751
-
752
- .hp-combobox-option {
753
- display: flex;
754
- flex-direction: column;
755
- justify-content: center;
756
- box-sizing: border-box;
757
- /* 36px rows on fine pointers, 44px on coarse. */
758
- min-height: var(--control-height);
759
- padding: var(--space-1) var(--option-inset);
760
- font-size: var(--type-body);
761
- line-height: 24px;
762
- overflow-wrap: anywhere;
763
- cursor: pointer;
764
- user-select: none;
765
- -webkit-tap-highlight-color: transparent;
766
- }
767
- .hp-combobox-option[data-described] {
768
- padding-block: var(--space-2);
769
- }
770
- .hp-combobox-option-description {
771
- color: var(--muted);
772
- font-size: var(--type-caption);
773
- line-height: 18px;
774
- }
775
-
776
- /* Highlight, by pointer or keys: the hover tint. Selection snaps and
777
- filtering never animates, so nothing here transitions (BRIEF, motion
778
- rule 3 and the frequency check). */
779
- .hp-combobox-option[data-highlighted],
780
- .hp-combobox-option:active {
781
- background: var(--surface-hover);
782
- }
783
- /* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
784
- press never darken it. */
785
- .hp-combobox-option[data-selected] {
786
- background: var(--surface-selected);
787
- box-shadow: inset 3px 0 0 var(--accent);
788
- }
789
- /* Keys move a virtual focus (focus stays in the input), so the
790
- highlight they move also draws the focus contour, inside the row. */
791
- .hp-combobox-list[data-keyboard] .hp-combobox-option[data-highlighted] {
792
- outline: 2px solid var(--focus);
793
- outline-offset: -2px;
794
- }
795
- .hp-combobox-option[data-disabled] {
796
- color: var(--muted);
797
- cursor: not-allowed;
798
- }
799
-
800
- /* Nothing matches: one muted line where the options were. */
801
- .hp-combobox-empty {
802
- margin: 0;
803
- padding: var(--space-2) var(--option-inset);
804
- color: var(--muted);
805
- font-size: var(--type-body);
806
- line-height: 24px;
807
- }
808
- .hp-combobox-empty:empty {
809
- padding: 0;
810
- }
811
-
812
- /* ---- Sheet (compact): the search input over the list ---------------- */
813
-
814
- .hp-combobox-search {
815
- flex: none;
816
- /* 8px above the well: its focus contour (2px, 4px out) stays inside
817
- the sheet body, which clips. */
818
- padding: var(--space-2) var(--gutter);
819
- }
820
- /* In the sheet the empty line keeps the gutter inset, as the options do. */
821
- .hp-combobox-empty[data-form='sheet'] {
822
- --option-inset: var(--gutter);
823
- }
824
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ export type ComboboxItem = {
6
+ value: string;
7
+ /** The option's text: what the default filter matches, and the field's text once chosen. */
8
+ label: string;
9
+ /** A second line in `--muted`. */
10
+ description?: string;
11
+ /**
12
+ * The heading the option sits under, as written; the default filter
13
+ * matches it too. Options with the same group share one heading, in the
14
+ * order each group first appears; options without one come first.
15
+ */
16
+ group?: string;
17
+ disabled?: boolean;
18
+ };
19
+
20
+ export type ComboboxItemSnippetProps = {
21
+ item: ComboboxItem;
22
+ selected: boolean;
23
+ highlighted: boolean;
24
+ };
25
+
26
+ export type ComboboxLabelBeside = boolean | 'medium' | 'expanded' | 'large';
27
+
28
+ /** Whether an item stays in the list for the typed text (trimmed, never empty). */
29
+ export type ComboboxFilter = (item: ComboboxItem, query: string) => boolean;
30
+
31
+ /**
32
+ * Attributes and handlers not listed here go to the field control: the
33
+ * text input from 600px, the button that opens the sheet below.
34
+ */
35
+ export type ComboboxProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'id'> & {
36
+ items: ComboboxItem[];
37
+ /** The chosen item's `value`; `''` for none. It changes only by choosing from the list. */
38
+ value?: string;
39
+ open?: boolean;
40
+ /** The caps field label above the field, and the sheet's title on compact widths. */
41
+ label: string;
42
+ /** Shown in `--muted` in the empty field and in the sheet's search input. Never the only label. */
43
+ placeholder?: string;
44
+ /** Guidance under the field in `--muted`, linked by `aria-describedby`. */
45
+ hint?: string;
46
+ /**
47
+ * The error to show under the field, with an icon, linked by
48
+ * `aria-describedby`; it sets `aria-invalid`. Say how to fix it.
49
+ */
50
+ error?: string;
51
+ disabled?: boolean;
52
+ /** Submits the value with a form, through a hidden input. */
53
+ name?: string;
54
+ /**
55
+ * The label beside the field instead of above it: always (`true`), or
56
+ * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
57
+ * The hint and the error stay under the field.
58
+ */
59
+ labelBeside?: ComboboxLabelBeside;
60
+ /**
61
+ * Replaces the default filter: the label or the group contains the text,
62
+ * ignoring case and accents.
63
+ */
64
+ filter?: ComboboxFilter;
65
+ /** Shown in place of the options when nothing matches. */
66
+ emptyText?: string;
67
+ /** The sheet's close button name. */
68
+ closeLabel?: string;
69
+ /** The field control's id; the label points at it. */
70
+ id?: string;
71
+ /** The field control: the text input from 600px, the button below. */
72
+ ref?: HTMLElement | null;
73
+ /** Renders an option's content in place of its label and description. */
74
+ item?: Snippet<[ComboboxItemSnippetProps]>;
75
+ };
76
+ </script>
77
+
78
+ <script lang="ts">
79
+ import { Combobox as BitsCombobox } from 'bits-ui';
80
+ import { untrack } from 'svelte';
81
+ import { MediaQuery } from 'svelte/reactivity';
82
+ import { pop } from '../motion/index.js';
83
+ import { fadeIn } from './internal/fade-in.js';
84
+ import { groupOptions } from './internal/groups.js';
85
+ import Sheet from './internal/Sheet.svelte';
86
+
87
+ const uid = $props.id();
88
+
89
+ let {
90
+ items,
91
+ value = $bindable(''),
92
+ open = $bindable(false),
93
+ label,
94
+ placeholder = '',
95
+ hint,
96
+ error,
97
+ disabled = false,
98
+ name,
99
+ labelBeside = false,
100
+ filter,
101
+ emptyText = 'No matches',
102
+ closeLabel = 'Close',
103
+ id = `${uid}-field`,
104
+ ref = $bindable(null),
105
+ item: itemContent,
106
+ class: className,
107
+ onclick: onclickProp,
108
+ oninput: oninputProp,
109
+ 'aria-describedby': describedBy,
110
+ 'aria-invalid': ariaInvalid,
111
+ ...rest
112
+ }: ComboboxProps = $props();
113
+
114
+ const labelId = `${uid}-label`;
115
+ const sheetId = `${uid}-sheet`;
116
+ // The listbox, in the popover or the sheet; the input that drives it
117
+ // says so with aria-controls, which Bits UI leaves out.
118
+ const listId = `${uid}-list`;
119
+ const sheetTitleId = `${uid}-sheet-title`;
120
+ const hintId = $derived(`${id}-hint`);
121
+ const errorId = $derived(`${id}-error`);
122
+ // The error first, so a screen reader reads it before the hint.
123
+ const describedByIds = $derived(
124
+ [error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
125
+ undefined
126
+ );
127
+ const invalid = $derived(error ? true : ariaInvalid);
128
+
129
+ // Short motion (BRIEF, --dur-message), as TextField: an error that
130
+ // appears after the first render fades in from 0 and the field's edge
131
+ // fades to --danger with it; one that clears goes at once. A field that
132
+ // renders or mounts with an error shows it at once. 0ms is a snap.
133
+ let root: HTMLDivElement | null = $state(null);
134
+ let errorShown = untrack(() => !!error);
135
+ $effect(() => {
136
+ const shown = !!error;
137
+ untrack(() => {
138
+ const appeared = shown && !errorShown;
139
+ errorShown = shown;
140
+ const message = appeared ? root?.querySelector(':scope > .hp-combobox-messages > .hp-combobox-error') : null;
141
+ if (message) fadeIn(message, '--dur-message');
142
+ });
143
+ });
144
+
145
+ // Compact (< 600px) opens a bottom sheet with the search input inside;
146
+ // medium and up type in the field and list under it (BRIEF, Apps and
147
+ // phones: Overlays and Choices).
148
+ const wide = new MediaQuery('min-width: 600px', true);
149
+
150
+ const chosen = $derived(items.find((i) => i.value === value));
151
+ // A string, so a fresh `items` array with the same choice changes nothing.
152
+ const chosenLabel = $derived(chosen?.label ?? '');
153
+
154
+ // The input's text. It shows the chosen label while the list is closed;
155
+ // typing overrides it, and closing puts the label back, so the value
156
+ // only ever comes from the list.
157
+ let text = $derived(chosenLabel);
158
+ // What the list is filtered by: only what was typed since it opened, so
159
+ // it opens with every option even when the field shows a label.
160
+ let query = $state('');
161
+
162
+ const fold = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
163
+ const byLabelOrGroup: ComboboxFilter = (entry, q) => {
164
+ const needle = fold(q);
165
+ return fold(entry.label).includes(needle) || (!!entry.group && fold(entry.group).includes(needle));
166
+ };
167
+ const matches = $derived.by(() => {
168
+ const q = query.trim();
169
+ if (!q) return items;
170
+ const keep = filter ?? byLabelOrGroup;
171
+ return items.filter((entry) => keep(entry, q));
172
+ });
173
+ // Options without a group, then each group that still has a match; a
174
+ // group whose options all filter out drops its heading too.
175
+ const grouped = $derived(groupOptions(matches));
176
+
177
+ // The highlight moved by keys gets the focus contour; a pointer's gets the
178
+ // hover tint alone. Focus itself stays in the input.
179
+ let keyboard = $state(false);
180
+
181
+ // Whether Ctrl or Meta was held at the last key press in the field. Bits
182
+ // UI opens the list on any key, Ctrl and Meta included, so a shortcut
183
+ // such as Ctrl+C opens it too; that open keeps the field's selection, so
184
+ // the shortcut copies what the person selected, not the whole label.
185
+ let shortcut = false;
186
+
187
+ let panelNode = $state<HTMLElement | null>(null);
188
+ let sheetNode = $state<HTMLElement | null>(null);
189
+
190
+ // Every open and close passes here once, whoever caused it: Bits UI
191
+ // (typing, Escape, an outside press, a choice, Tab), the field, the
192
+ // sheet's close button and swipe, or the app through `bind:open`.
193
+ let settled = false;
194
+ function openChanged(next: boolean) {
195
+ if (next === settled) return;
196
+ settled = next;
197
+ query = '';
198
+ if (next) {
199
+ if (!wide.current) {
200
+ // The sheet's search input starts empty.
201
+ text = '';
202
+ } else if (!shortcut && ref instanceof HTMLInputElement && document.activeElement === ref) {
203
+ // Opened in the field, by a press or a key: the text is selected
204
+ // (before a typed character lands), so typing replaces the label
205
+ // and filters on what was typed alone.
206
+ ref.select();
207
+ }
208
+ return;
209
+ }
210
+ text = chosenLabel;
211
+ // The sheet held focus; give it back to the field.
212
+ if (!wide.current) {
213
+ const active = document.activeElement;
214
+ if (!active || active === document.body || sheetNode?.contains(active)) ref?.focus();
215
+ }
216
+ }
217
+ $effect.pre(() => {
218
+ const next = open;
219
+ untrack(() => openChanged(next));
220
+ });
221
+
222
+ function setOpen(next: boolean) {
223
+ open = next;
224
+ openChanged(next);
225
+ }
226
+
227
+ function onInput(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
228
+ oninputProp?.(e);
229
+ query = e.currentTarget.value;
230
+ text = query;
231
+ // The matches show from the top, so Bits UI's highlight, which goes to
232
+ // the first option in view, lands on the first match, and no match
233
+ // waits above the fold or under a group heading.
234
+ const viewport = document.getElementById(listId)?.querySelector('[data-combobox-viewport]');
235
+ if (viewport) viewport.scrollTop = 0;
236
+ }
237
+
238
+ // A press on the field opens the list (openChanged selects its text).
239
+ // While open, a press on the chevron end closes it; one on the text
240
+ // places the caret. A label's click (detail 0) only focuses.
241
+ function onFieldClick(e: MouseEvent & { currentTarget: EventTarget & HTMLInputElement }) {
242
+ onclickProp?.(e);
243
+ if (disabled || e.detail === 0) return;
244
+ const input = e.currentTarget;
245
+ if (!open) {
246
+ setOpen(true);
247
+ return;
248
+ }
249
+ const box = input.getBoundingClientRect();
250
+ if (e.clientX >= box.right - parseFloat(getComputedStyle(input).paddingRight)) setOpen(false);
251
+ }
252
+
253
+ function onTriggerClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
254
+ onclickProp?.(e);
255
+ if (!disabled) setOpen(true);
256
+ }
257
+
258
+ // As on Select's trigger: the arrow keys open the sheet too.
259
+ function onTriggerKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
260
+ rest.onkeydown?.(e);
261
+ if (disabled || e.defaultPrevented) return;
262
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
263
+ e.preventDefault();
264
+ setOpen(true);
265
+ }
266
+ }
267
+
268
+ function close() {
269
+ setOpen(false);
270
+ }
271
+
272
+ // The panel's empty line and the sheet's title row sit outside the
273
+ // listbox; pressing them is not an outside press.
274
+ function insidePanel(e: PointerEvent) {
275
+ if (panelNode && e.target instanceof Node && panelNode.contains(e.target)) e.preventDefault();
276
+ }
277
+ function insideSheet(e: PointerEvent) {
278
+ if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
279
+ }
280
+
281
+ // Presses on the overlay's chrome keep focus in the input; a press on the
282
+ // sheet's search input itself still focuses it.
283
+ function keepFocus(e: PointerEvent) {
284
+ if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
285
+ }
286
+
287
+ function focusOnMount(node: HTMLInputElement) {
288
+ node.focus({ preventScroll: true });
289
+ }
290
+
291
+ // In the sheet, Bits UI never learns that its content is placed:
292
+ // ContentStatic with forceMount reports placement once, at mount, while
293
+ // still closed. So Bits neither opens it at the chosen option nor scrolls
294
+ // to the option the keys move to; this does both, nearest edge first, as
295
+ // Bits does in the popover (Select does the same). The viewport's
296
+ // scroll-padding keeps an option clear of a group heading stuck at the top.
297
+ function followHighlight(viewport: HTMLElement) {
298
+ const reveal = (option: Element | null) => {
299
+ if (!(option instanceof HTMLElement)) return;
300
+ const top = option.offsetTop - (parseFloat(getComputedStyle(viewport).scrollPaddingTop) || 0);
301
+ const bottom = option.offsetTop + option.offsetHeight;
302
+ if (top < viewport.scrollTop) viewport.scrollTop = top;
303
+ else if (bottom > viewport.scrollTop + viewport.clientHeight) {
304
+ viewport.scrollTop = bottom - viewport.clientHeight;
305
+ }
306
+ };
307
+ reveal(viewport.querySelector('[data-selected]'));
308
+ const observer = new MutationObserver((records) => {
309
+ if (!keyboard) return;
310
+ for (const { target } of records) {
311
+ if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
312
+ }
313
+ });
314
+ observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
315
+ return () => observer.disconnect();
316
+ }
317
+ </script>
318
+
319
+ <div
320
+ bind:this={root}
321
+ class={['hp-combobox', className]}
322
+ data-state={open ? 'open' : 'closed'}
323
+ data-disabled={disabled || undefined}
324
+ data-placeholder={chosen ? undefined : ''}
325
+ data-invalid={error ? '' : undefined}
326
+ data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
327
+ onkeydowncapture={(e) => {
328
+ keyboard = true;
329
+ // Chrome on Windows reports AltGr as Ctrl+Alt; AltGr types a character.
330
+ shortcut = (e.ctrlKey || e.metaKey) && !e.altKey;
331
+ }}
332
+ onpointerdowncapture={() => {
333
+ keyboard = false;
334
+ shortcut = false;
335
+ }}
336
+ >
337
+ <label class="hp-combobox-label hp-label" id={labelId} for={id}>{label}</label>
338
+
339
+ <BitsCombobox.Root
340
+ type="single"
341
+ bind:value
342
+ bind:open
343
+ {items}
344
+ {disabled}
345
+ {name}
346
+ allowDeselect={false}
347
+ inputValue={text}
348
+ onOpenChange={openChanged}
349
+ >
350
+ {#if wide.current}
351
+ <BitsCombobox.Input
352
+ {...rest}
353
+ bind:ref
354
+ {id}
355
+ {placeholder}
356
+ autocomplete="off"
357
+ spellcheck={false}
358
+ aria-describedby={describedByIds}
359
+ aria-invalid={invalid}
360
+ oninput={onInput}
361
+ aria-controls={open ? listId : undefined}
362
+ onclick={onFieldClick}
363
+ >
364
+ {#snippet child({ props })}
365
+ <span class="hp-combobox-field hp-well-chevron">
366
+ <input {...props} class="hp-combobox-input hp-well" />
367
+ </span>
368
+ {/snippet}
369
+ </BitsCombobox.Input>
370
+
371
+ <BitsCombobox.Portal>
372
+ <BitsCombobox.Content
373
+ id={listId}
374
+ forceMount
375
+ side="bottom"
376
+ align="start"
377
+ sideOffset={8}
378
+ collisionPadding={8}
379
+ aria-labelledby={labelId}
380
+ onInteractOutside={insidePanel}
381
+ >
382
+ {#snippet child({ wrapperProps, props, open: shown })}
383
+ {#if shown}
384
+ <div {...wrapperProps}>
385
+ <div
386
+ class="hp-combobox-panel"
387
+ data-form="popover"
388
+ data-side={props['data-side'] as string | undefined}
389
+ bind:this={panelNode}
390
+ role="presentation"
391
+ onpointerdown={keepFocus}
392
+ transition:pop
393
+ >
394
+ {@render empty(false)}
395
+ <div
396
+ {...props}
397
+ class="hp-combobox-list"
398
+ data-form="popover"
399
+ data-empty={matches.length ? undefined : ''}
400
+ data-keyboard={keyboard || undefined}
401
+ onpointermovecapture={() => (keyboard = false)}
402
+ >
403
+ {@render options(false)}
404
+ </div>
405
+ </div>
406
+ </div>
407
+ {/if}
408
+ {/snippet}
409
+ </BitsCombobox.Content>
410
+ </BitsCombobox.Portal>
411
+ {:else}
412
+ <span class="hp-combobox-field hp-well-chevron">
413
+ <button
414
+ {...rest}
415
+ bind:this={ref}
416
+ type="button"
417
+ {id}
418
+ class="hp-combobox-trigger hp-well"
419
+ role="combobox"
420
+ aria-haspopup="dialog"
421
+ aria-expanded={open}
422
+ aria-controls={open ? sheetId : undefined}
423
+ aria-labelledby={labelId}
424
+ aria-describedby={describedByIds}
425
+ aria-invalid={invalid}
426
+ data-placeholder={chosen ? undefined : ''}
427
+ {disabled}
428
+ onclick={onTriggerClick}
429
+ onkeydown={onTriggerKeydown}
430
+ >
431
+ <span class="hp-combobox-value">{chosen?.label ?? placeholder}</span>
432
+ </button>
433
+ </span>
434
+
435
+ <BitsCombobox.Portal>
436
+ <BitsCombobox.ContentStatic id={listId} forceMount preventScroll onInteractOutside={insideSheet}>
437
+ {#snippet child({ props, open: shown })}
438
+ <Sheet
439
+ open={shown}
440
+ title={label}
441
+ titleId={sheetTitleId}
442
+ layer="popover"
443
+ onclose={close}
444
+ {closeLabel}
445
+ bind:ref={sheetNode}
446
+ id={sheetId}
447
+ role="dialog"
448
+ aria-modal="true"
449
+ aria-labelledby={sheetTitleId}
450
+ onpointerdown={keepFocus}
451
+ >
452
+ <div class="hp-combobox-search" onkeydowncapture={() => (keyboard = true)}>
453
+ <BitsCombobox.Input
454
+ {placeholder}
455
+ autocomplete="off"
456
+ spellcheck={false}
457
+ aria-labelledby={sheetTitleId}
458
+ aria-describedby={describedByIds}
459
+ aria-controls={open ? listId : undefined}
460
+ oninput={onInput}
461
+ >
462
+ {#snippet child({ props: inputProps })}
463
+ <input {...inputProps} class="hp-combobox-input hp-well" {@attach focusOnMount} />
464
+ {/snippet}
465
+ </BitsCombobox.Input>
466
+ </div>
467
+ {@render empty(true)}
468
+ <div
469
+ {...props}
470
+ class="hp-combobox-list"
471
+ data-form="sheet"
472
+ data-empty={matches.length ? undefined : ''}
473
+ aria-labelledby={sheetTitleId}
474
+ data-keyboard={keyboard || undefined}
475
+ onpointermovecapture={() => (keyboard = false)}
476
+ >
477
+ {@render options(true)}
478
+ </div>
479
+ </Sheet>
480
+ {/snippet}
481
+ </BitsCombobox.ContentStatic>
482
+ </BitsCombobox.Portal>
483
+ {/if}
484
+ </BitsCombobox.Root>
485
+
486
+ {#if hint || error}
487
+ <div class="hp-combobox-messages">
488
+ {#if hint}
489
+ <p class="hp-combobox-hint hp-caption" id={hintId}>{hint}</p>
490
+ {/if}
491
+ {#if error}
492
+ <p class="hp-combobox-error hp-caption" id={errorId}>
493
+ <span class="hp-combobox-error-icon" aria-hidden="true">
494
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
495
+ <path d="M1.5 1.5h13v13h-13z" />
496
+ <path d="M8 4v4M8 10v2" stroke-width="2" />
497
+ </svg>
498
+ </span>
499
+ <span>{error}</span>
500
+ </p>
501
+ {/if}
502
+ </div>
503
+ {/if}
504
+ </div>
505
+
506
+ {#snippet options(inSheet: boolean)}
507
+ <BitsCombobox.Viewport>
508
+ {#snippet child({ props: viewportProps })}
509
+ <div
510
+ {...viewportProps}
511
+ class="hp-combobox-viewport"
512
+ data-grouped={grouped.groups.length ? '' : undefined}
513
+ {@attach inSheet && followHighlight}
514
+ >
515
+ {#each grouped.loose as entry (entry.value)}
516
+ {@render option(entry)}
517
+ {/each}
518
+ {#each grouped.groups as group (group.name)}
519
+ <!-- role="group", named by its heading, which is no option: keys
520
+ skip it. -->
521
+ <BitsCombobox.Group>
522
+ {#snippet child({ props: groupProps })}
523
+ <div {...groupProps} class="hp-combobox-group">
524
+ <BitsCombobox.GroupHeading>
525
+ {#snippet child({ props: headingProps })}
526
+ <div {...headingProps} class="hp-combobox-group-heading">{group.name}</div>
527
+ {/snippet}
528
+ </BitsCombobox.GroupHeading>
529
+ {#each group.items as entry (entry.value)}
530
+ {@render option(entry)}
531
+ {/each}
532
+ </div>
533
+ {/snippet}
534
+ </BitsCombobox.Group>
535
+ {/each}
536
+ </div>
537
+ {/snippet}
538
+ </BitsCombobox.Viewport>
539
+ {/snippet}
540
+
541
+ {#snippet option(entry: ComboboxItem)}
542
+ <BitsCombobox.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
543
+ {#snippet child({ props: optionProps, selected, highlighted })}
544
+ <div
545
+ {...optionProps}
546
+ class="hp-combobox-option"
547
+ data-described={entry.description ? '' : undefined}
548
+ aria-disabled={entry.disabled ? 'true' : undefined}
549
+ >
550
+ {#if itemContent}
551
+ {@render itemContent({ item: entry, selected, highlighted })}
552
+ {:else}
553
+ <span class="hp-combobox-option-label">{entry.label}</span>
554
+ {#if entry.description}
555
+ <span class="hp-combobox-option-description">{entry.description}</span>
556
+ {/if}
557
+ {/if}
558
+ </div>
559
+ {/snippet}
560
+ </BitsCombobox.Item>
561
+ {/snippet}
562
+
563
+ <!-- Outside the listbox, which may hold options and groups only. Mounted
564
+ while the list is open, so the text it gains when nothing matches is
565
+ announced. -->
566
+ {#snippet empty(inSheet: boolean)}
567
+ <p class="hp-combobox-empty" data-form={inSheet ? 'sheet' : 'popover'} role="status">
568
+ {#if !matches.length}{emptyText}{/if}
569
+ </p>
570
+ {/snippet}
571
+
572
+ <style>
573
+ /* ---- Field: caps label over a black well --------------------------- */
574
+
575
+ .hp-combobox {
576
+ /* Bits UI's hidden form input is absolutely placed in here. */
577
+ position: relative;
578
+ display: grid;
579
+ gap: var(--space-1);
580
+ min-width: 0;
581
+ }
582
+
583
+ .hp-combobox-label {
584
+ color: var(--muted);
585
+ }
586
+
587
+ /* The input or the trigger is base.css's well; its wrapper draws the
588
+ chevron, like a select's. The wrapper is the grid item, so it holds
589
+ the width and the minimum. */
590
+ .hp-combobox-field {
591
+ width: 100%;
592
+ min-width: 136px;
593
+ }
594
+ .hp-combobox-input {
595
+ display: block;
596
+ width: 100%;
597
+ min-width: 0;
598
+ }
599
+ .hp-combobox-input::placeholder {
600
+ color: var(--muted);
601
+ opacity: 1;
602
+ }
603
+ .hp-combobox-trigger {
604
+ display: flex;
605
+ align-items: center;
606
+ width: 100%;
607
+ text-align: start;
608
+ cursor: pointer;
609
+ -webkit-tap-highlight-color: transparent;
610
+ }
611
+ .hp-combobox-trigger:disabled {
612
+ cursor: not-allowed;
613
+ }
614
+ .hp-combobox-trigger[data-placeholder] {
615
+ color: var(--muted);
616
+ }
617
+ .hp-combobox-value {
618
+ min-width: 0;
619
+ overflow: hidden;
620
+ text-overflow: ellipsis;
621
+ white-space: nowrap;
622
+ }
623
+
624
+ /* Invalid: the boundary turns --danger, never alone: the icon and the
625
+ message say what is wrong (as TextField). It fades to --danger over
626
+ --dur-message with the message and goes back at once when the error
627
+ clears, because the transition belongs to this rule. The hover fill
628
+ keeps base.css's timing. */
629
+ .hp-combobox[data-invalid] .hp-well {
630
+ border-color: var(--danger);
631
+ transition:
632
+ background-color var(--dur-hover) var(--settle),
633
+ border-color var(--dur-message) var(--settle);
634
+ }
635
+ .hp-combobox[data-invalid] .hp-well:disabled {
636
+ border-color: var(--border);
637
+ }
638
+
639
+ /* ---- Hint and error, as TextField's -------------------------------- */
640
+
641
+ /* The first message starts 8px under the well, clear of the focus
642
+ contour (2px, drawn 4px outside the well); 4px between messages. */
643
+ .hp-combobox-messages {
644
+ display: grid;
645
+ gap: var(--space-1);
646
+ margin-top: var(--space-1);
647
+ }
648
+ .hp-combobox-hint {
649
+ margin: 0;
650
+ color: var(--muted);
651
+ }
652
+ .hp-combobox-error {
653
+ display: flex;
654
+ gap: var(--space-2);
655
+ margin: 0;
656
+ color: var(--danger);
657
+ }
658
+ /* One line box high, so the icon centres on the first line of the
659
+ message however it wraps. */
660
+ .hp-combobox-error-icon {
661
+ display: flex;
662
+ flex: none;
663
+ align-items: center;
664
+ height: 1lh;
665
+ }
666
+ .hp-combobox-error-icon svg {
667
+ display: block;
668
+ }
669
+
670
+ /* ---- Label beside the field: always, or from a breakpoint up ------- */
671
+
672
+ /* The label keeps its one line in the first column; the field and its
673
+ messages share the second. */
674
+ .hp-combobox[data-label-beside='always'] {
675
+ grid-template-columns: auto minmax(0, 1fr);
676
+ column-gap: var(--space-2);
677
+ }
678
+ .hp-combobox[data-label-beside='always'] .hp-combobox-label {
679
+ grid-row: 1;
680
+ align-self: center;
681
+ white-space: nowrap;
682
+ }
683
+ .hp-combobox[data-label-beside='always'] .hp-combobox-field,
684
+ .hp-combobox[data-label-beside='always'] .hp-combobox-messages {
685
+ grid-column: 2;
686
+ }
687
+ @media (min-width: 600px) {
688
+ .hp-combobox[data-label-beside='medium'] {
689
+ grid-template-columns: auto minmax(0, 1fr);
690
+ column-gap: var(--space-2);
691
+ }
692
+ .hp-combobox[data-label-beside='medium'] .hp-combobox-label {
693
+ grid-row: 1;
694
+ align-self: center;
695
+ white-space: nowrap;
696
+ }
697
+ .hp-combobox[data-label-beside='medium'] .hp-combobox-field,
698
+ .hp-combobox[data-label-beside='medium'] .hp-combobox-messages {
699
+ grid-column: 2;
700
+ }
701
+ }
702
+ @media (min-width: 900px) {
703
+ .hp-combobox[data-label-beside='expanded'] {
704
+ grid-template-columns: auto minmax(0, 1fr);
705
+ column-gap: var(--space-2);
706
+ }
707
+ .hp-combobox[data-label-beside='expanded'] .hp-combobox-label {
708
+ grid-row: 1;
709
+ align-self: center;
710
+ white-space: nowrap;
711
+ }
712
+ .hp-combobox[data-label-beside='expanded'] .hp-combobox-field,
713
+ .hp-combobox[data-label-beside='expanded'] .hp-combobox-messages {
714
+ grid-column: 2;
715
+ }
716
+ }
717
+ @media (min-width: 1200px) {
718
+ .hp-combobox[data-label-beside='large'] {
719
+ grid-template-columns: auto minmax(0, 1fr);
720
+ column-gap: var(--space-2);
721
+ }
722
+ .hp-combobox[data-label-beside='large'] .hp-combobox-label {
723
+ grid-row: 1;
724
+ align-self: center;
725
+ white-space: nowrap;
726
+ }
727
+ .hp-combobox[data-label-beside='large'] .hp-combobox-field,
728
+ .hp-combobox[data-label-beside='large'] .hp-combobox-messages {
729
+ grid-column: 2;
730
+ }
731
+ }
732
+
733
+ /* ---- The list: a popover panel on medium and up, the sheet's body on
734
+ compact ---------------------------------------------------------- */
735
+
736
+ /* The overlay parts render in a portal at the end of <body>, outside
737
+ .hp-combobox, so they carry this themselves. */
738
+ .hp-combobox-panel,
739
+ .hp-combobox-list,
740
+ .hp-combobox-empty {
741
+ /* Option text lines up with the field's text (popover) or the
742
+ sheet's title (sheet). */
743
+ --option-inset: var(--space-3);
744
+ /* The popover list's cap: eight and a half 36px rows, so a cut row
745
+ shows there is more. */
746
+ --combobox-list-height: 320px;
747
+ }
748
+
749
+ /* The panel draws the frame round the listbox and the empty line, on the
750
+ popover layer (tokens.css), over the shell and an open dialog. */
751
+ .hp-combobox-panel {
752
+ position: relative;
753
+ z-index: var(--layer-popover);
754
+ display: flex;
755
+ flex-direction: column;
756
+ box-sizing: border-box;
757
+ border: 1px solid var(--border-control);
758
+ background: var(--canvas);
759
+ color: var(--text);
760
+ font-family: var(--sans);
761
+ }
762
+
763
+ .hp-combobox-list {
764
+ box-sizing: border-box;
765
+ background: var(--canvas);
766
+ color: var(--text);
767
+ font-family: var(--sans);
768
+ }
769
+ /* As wide as the field at least, inside the panel's two 1px borders. */
770
+ .hp-combobox-list[data-form='popover'] {
771
+ min-width: calc(var(--bits-combobox-anchor-width) - 2px);
772
+ max-width: max(
773
+ calc(var(--bits-combobox-anchor-width) - 2px),
774
+ min(calc(var(--bits-combobox-content-available-width) - 2px), 480px)
775
+ );
776
+ max-height: min(var(--combobox-list-height), calc(var(--bits-combobox-content-available-height) - 2px));
777
+ }
778
+ /* The sheet keeps the height it opened with while the list filters, so
779
+ the search input never moves under the typing thumb: the list asks for
780
+ the sheet's whole cap (--sheet-max-height from internal/Sheet.svelte:
781
+ half the screen, or the room above the on-screen keyboard) and shrinks
782
+ into what the title and search rows leave. */
783
+ .hp-combobox-list[data-form='sheet'] {
784
+ --option-inset: var(--gutter);
785
+ flex: 1 1 auto;
786
+ height: var(--sheet-max-height);
787
+ min-height: 0;
788
+ }
789
+
790
+ /* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
791
+ .hp-combobox-viewport[data-combobox-viewport] {
792
+ scrollbar-width: thin !important;
793
+ overscroll-behavior: contain;
794
+ padding-block: var(--space-1);
795
+ }
796
+ .hp-combobox-list[data-empty] .hp-combobox-viewport[data-combobox-viewport] {
797
+ padding-block: 0;
798
+ }
799
+ /* An option the keys or the chosen value scroll to stays clear of the
800
+ group heading stuck at the top: one heading line high. */
801
+ .hp-combobox-viewport[data-grouped] {
802
+ scroll-padding-top: calc(var(--space-2) + 16px + var(--space-1));
803
+ }
804
+
805
+ /* ---- Group headings: as Select's ------------------------------------ */
806
+
807
+ /* MenuGroup's heading: muted, on the options' text edge, 8px above and
808
+ 4px below. The text stays as written, because a group name may be the
809
+ app's data (BRIEF, style rule 4). It stays at the top of the list while
810
+ its options scroll under it, until the next group pushes it up; that is
811
+ scrolling, not motion. It sticks to the viewport's edge, over its 4px
812
+ padding, so nothing shows above it. */
813
+ .hp-combobox-group-heading {
814
+ position: sticky;
815
+ top: calc(-1 * var(--space-1));
816
+ z-index: 1;
817
+ padding: var(--space-2) var(--option-inset) var(--space-1);
818
+ background: var(--canvas);
819
+ color: var(--muted);
820
+ font-size: var(--type-label);
821
+ font-weight: 500;
822
+ line-height: 16px;
823
+ overflow-wrap: anywhere;
824
+ user-select: none;
825
+ }
826
+
827
+ /* ---- Options: Geist list items, sentence case ----------------------- */
828
+
829
+ .hp-combobox-option {
830
+ display: flex;
831
+ flex-direction: column;
832
+ justify-content: center;
833
+ box-sizing: border-box;
834
+ /* 36px rows on fine pointers, 44px on coarse. */
835
+ min-height: var(--control-height);
836
+ padding: var(--space-1) var(--option-inset);
837
+ font-size: var(--type-body);
838
+ line-height: 24px;
839
+ overflow-wrap: anywhere;
840
+ cursor: pointer;
841
+ user-select: none;
842
+ -webkit-tap-highlight-color: transparent;
843
+ }
844
+ .hp-combobox-option[data-described] {
845
+ padding-block: var(--space-2);
846
+ }
847
+ .hp-combobox-option-description {
848
+ color: var(--muted);
849
+ font-size: var(--type-caption);
850
+ line-height: 18px;
851
+ }
852
+
853
+ /* Highlight, by pointer or keys: the hover tint. Selection snaps and
854
+ filtering never animates, so nothing here transitions (BRIEF, motion
855
+ rule 3 and the frequency check). */
856
+ .hp-combobox-option[data-highlighted],
857
+ .hp-combobox-option:active {
858
+ background: var(--surface-hover);
859
+ }
860
+ /* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
861
+ press never darken it. */
862
+ .hp-combobox-option[data-selected] {
863
+ background: var(--surface-selected);
864
+ box-shadow: inset 3px 0 0 var(--accent);
865
+ }
866
+ /* Keys move a virtual focus (focus stays in the input), so the
867
+ highlight they move also draws the focus contour, inside the row. */
868
+ .hp-combobox-list[data-keyboard] .hp-combobox-option[data-highlighted] {
869
+ outline: 2px solid var(--focus);
870
+ outline-offset: -2px;
871
+ }
872
+ .hp-combobox-option[data-disabled] {
873
+ color: var(--muted);
874
+ cursor: not-allowed;
875
+ }
876
+
877
+ /* Nothing matches: one muted line where the options were. */
878
+ .hp-combobox-empty {
879
+ margin: 0;
880
+ padding: var(--space-2) var(--option-inset);
881
+ color: var(--muted);
882
+ font-size: var(--type-body);
883
+ line-height: 24px;
884
+ }
885
+ .hp-combobox-empty:empty {
886
+ padding: 0;
887
+ }
888
+
889
+ /* ---- Sheet (compact): the search input over the list ---------------- */
890
+
891
+ .hp-combobox-search {
892
+ flex: none;
893
+ /* 8px above the well: its focus contour (2px, 4px out) stays inside
894
+ the sheet body, which clips. */
895
+ padding: var(--space-2) var(--gutter);
896
+ }
897
+ /* In the sheet the empty line keeps the gutter inset, as the options do. */
898
+ .hp-combobox-empty[data-form='sheet'] {
899
+ --option-inset: var(--gutter);
900
+ }
901
+ </style>