@mystravil/hardpoint 0.1.0 → 0.2.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 (36) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -5
  3. package/dist/base.css +279 -0
  4. package/dist/components/Checkbox.svelte +46 -9
  5. package/dist/components/Checkbox.svelte.d.ts +9 -2
  6. package/dist/components/CheckboxGroup.svelte +131 -23
  7. package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
  8. package/dist/components/Combobox.svelte +104 -27
  9. package/dist/components/Combobox.svelte.d.ts +10 -1
  10. package/dist/components/Dialog.svelte +4 -1
  11. package/dist/components/ImageViewer.svelte +764 -0
  12. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  13. package/dist/components/ListDetail.svelte +218 -0
  14. package/dist/components/ListDetail.svelte.d.ts +37 -0
  15. package/dist/components/Meter.svelte +283 -0
  16. package/dist/components/Meter.svelte.d.ts +66 -0
  17. package/dist/components/NativeSelect.svelte +135 -119
  18. package/dist/components/NativeSelect.svelte.d.ts +6 -0
  19. package/dist/components/Notice.svelte +262 -0
  20. package/dist/components/Notice.svelte.d.ts +30 -0
  21. package/dist/components/Popover.svelte +6 -6
  22. package/dist/components/RadioGroup.svelte +440 -0
  23. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  24. package/dist/components/Select.svelte +89 -23
  25. package/dist/components/Select.svelte.d.ts +6 -0
  26. package/dist/components/Spinner.svelte +3 -2
  27. package/dist/components/TextArea.svelte +272 -0
  28. package/dist/components/TextArea.svelte.d.ts +28 -0
  29. package/dist/components/Toaster.svelte +6 -6
  30. package/dist/components/Tooltip.svelte +5 -5
  31. package/dist/components/internal/groups.d.ts +14 -0
  32. package/dist/components/internal/groups.js +23 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +6 -0
  35. package/dist/tokens.css +4 -0
  36. package/package.json +1 -1
@@ -0,0 +1,440 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { RadioGroup as BitsRadioGroup } from 'bits-ui';
4
+
5
+ export type RadioGroupItem = {
6
+ /** Identifies the choice; the bound `value` holds the chosen one. */
7
+ value: string;
8
+ /** The text beside the box, as written (never caps). */
9
+ label: string;
10
+ /** A second line in `--muted` under the label, read out as the choice's description. */
11
+ hint?: string;
12
+ /**
13
+ * A Tag after the label, such as "Recommended": a short fixed string the
14
+ * app defines, set in caps. It is part of the choice's accessible name.
15
+ */
16
+ tag?: string;
17
+ /** Shown in muted ink; it cannot be chosen and arrow keys skip it. */
18
+ disabled?: boolean;
19
+ };
20
+
21
+ /** The group's name: a fixed caps `label`, or a `legend` set as written. Exactly one. */
22
+ type RadioGroupName =
23
+ | {
24
+ /** The caps label that names the group: a fixed string the app defines. */
25
+ label: string;
26
+ legend?: never;
27
+ }
28
+ | {
29
+ label?: never;
30
+ /**
31
+ * The group's name as content, set as written in 16px at weight 500:
32
+ * a question, or a name with a counter before it.
33
+ */
34
+ legend: Snippet;
35
+ };
36
+
37
+ /** Attributes and handlers not listed here go to the group element. */
38
+ export type RadioGroupProps = Omit<BitsRadioGroup.RootProps, 'child' | 'children' | 'ref' | 'orientation'> &
39
+ RadioGroupName & {
40
+ /** The choices, stacked in order. */
41
+ items: RadioGroupItem[];
42
+ /** The chosen item's `value`. Bindable; `''` means none is chosen, and the app may set `''` to clear it. */
43
+ value?: string;
44
+ /** A line in `--muted` under the name, read out as the group's description. */
45
+ hint?: string;
46
+ /**
47
+ * The error to show under the choices, with an icon, linked by
48
+ * `aria-describedby`; it sets `aria-invalid` on the group. Say how to
49
+ * fix the problem. The app validates; the group shows what it is given.
50
+ */
51
+ error?: string;
52
+ /** The group element (`role="radiogroup"`). */
53
+ ref?: HTMLElement | null;
54
+ };
55
+ </script>
56
+
57
+ <script lang="ts">
58
+ import { untrack } from 'svelte';
59
+ import Tag from './Tag.svelte';
60
+ import { fadeIn } from './internal/fade-in.js';
61
+
62
+ const uid = $props.id();
63
+
64
+ let {
65
+ value = $bindable(''),
66
+ label,
67
+ legend,
68
+ items,
69
+ hint,
70
+ error,
71
+ disabled = false,
72
+ id = uid,
73
+ ref = $bindable(null),
74
+ class: className,
75
+ 'aria-describedby': describedBy,
76
+ 'aria-invalid': ariaInvalid,
77
+ onValueChange,
78
+ ...rest
79
+ }: RadioGroupProps = $props();
80
+
81
+ const labelId = $derived(`${id}-label`);
82
+ const hintId = $derived(`${id}-hint`);
83
+ const errorId = $derived(`${id}-error`);
84
+ // The error first, so a screen reader reads it before the hint.
85
+ const describedByIds = $derived(
86
+ [error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
87
+ undefined
88
+ );
89
+
90
+ // ---- The tab stop -------------------------------------------------------
91
+
92
+ // One radio is in the tab order. While focus is in the group it is the
93
+ // focused radio, so Tab and Shift+Tab leave the group from there. From
94
+ // outside, Tab enters on the chosen radio, or on the first one that can
95
+ // be chosen when none is (an app that sets `value` to '' resets it).
96
+ // Bits UI keeps the last chosen radio as its tab stop after a clear, so
97
+ // the group sets tabindex itself; Bits UI still moves focus on arrow keys.
98
+ let focused = $state<string | null>(null);
99
+ const tabStop = $derived.by(() => {
100
+ if (focused !== null) return focused;
101
+ const open = items.filter((item) => !disabled && !item.disabled);
102
+ return (open.find((item) => item.value === value) ?? open[0])?.value ?? null;
103
+ });
104
+
105
+ function focusIn(event: FocusEvent) {
106
+ const radio = event.target instanceof Element ? event.target.closest('[role="radio"]') : null;
107
+ focused = radio instanceof HTMLElement ? (radio.dataset.value ?? null) : null;
108
+ }
109
+ function focusOut(event: FocusEvent) {
110
+ const next = event.relatedTarget;
111
+ if (!(next instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(next))) {
112
+ focused = null;
113
+ }
114
+ }
115
+
116
+ // ---- Short motion: the mark (BRIEF, --dur-mark) -------------------------
117
+
118
+ // The mark a pointer or a touch chooses fades in from 0 over --dur-mark;
119
+ // the mark it takes away goes at once. A choice made with a key (arrows,
120
+ // Home, End, Space, Enter) or a `value` the app sets cuts, so holding an
121
+ // arrow key through the choices never starts a fade (the frequency
122
+ // check). As in Tabs: the choices keep the last input they saw, from
123
+ // pointerdown and keydown in the capture phase, before Bits UI acts on
124
+ // them; Bits UI calls onValueChange only for a choice the user makes, so
125
+ // the change notes there whether a pointer made it, and the effect below
126
+ // fades the new mark once it shows. A new choice cancels a fade that
127
+ // still runs. 0ms is a cut.
128
+ let input: 'pointer' | 'key' | null = null;
129
+ let fadeTo: string | null = null;
130
+ let fade: Animation | null = null;
131
+
132
+ function changed(next: string) {
133
+ fadeTo = input === 'pointer' ? next : null;
134
+ onValueChange?.(next);
135
+ }
136
+
137
+ $effect(() => {
138
+ const chosen = value;
139
+ untrack(() => {
140
+ fade?.cancel();
141
+ fade = null;
142
+ const pointed = fadeTo !== null && fadeTo === chosen;
143
+ fadeTo = null;
144
+ if (!pointed || !ref) return;
145
+ const radio = Array.from(ref.querySelectorAll<HTMLElement>('[role="radio"]')).find(
146
+ (el) => el.dataset.value === chosen
147
+ );
148
+ const mark = radio?.querySelector('.mark');
149
+ if (mark) fade = fadeIn(mark, '--dur-mark');
150
+ });
151
+ });
152
+
153
+ // ---- Short motion: the error (BRIEF, --dur-message) ---------------------
154
+
155
+ // As in TextField: an error that appears after the first render fades in
156
+ // from 0 over --dur-message; one that clears goes at once; a group that
157
+ // renders with an error shows it at once.
158
+ let errorShown = untrack(() => !!error);
159
+ $effect(() => {
160
+ const shown = !!error;
161
+ untrack(() => {
162
+ const appeared = shown && !errorShown;
163
+ errorShown = shown;
164
+ const message = appeared ? ref?.querySelector(':scope > .error') : null;
165
+ if (message) fadeIn(message, '--dur-message');
166
+ });
167
+ });
168
+ </script>
169
+
170
+ <!--
171
+ One choice from a set read side by side: a Bits UI RadioGroup, a
172
+ role="radiogroup" element named by its caps `label` or its `legend`, with
173
+ one role="radio" button per item. Pressing anywhere on an item's row (the
174
+ box, the label, the tag, the hint) chooses it; arrow keys move the choice.
175
+ `disabled`, `required`, `name` and `loop` go to Bits UI. Attributes,
176
+ handlers and `class` go to the group element.
177
+ -->
178
+ <BitsRadioGroup.Root
179
+ bind:value
180
+ bind:ref
181
+ {id}
182
+ {disabled}
183
+ aria-labelledby={labelId}
184
+ aria-describedby={describedByIds}
185
+ aria-invalid={error ? true : ariaInvalid}
186
+ onValueChange={changed}
187
+ {...rest}
188
+ >
189
+ {#snippet child({ props })}
190
+ <div {...props} class={['hp-radio-group', className]} data-invalid={error ? '' : undefined}>
191
+ {#if legend}
192
+ <div class="legend" id={labelId}>{@render legend()}</div>
193
+ {:else}
194
+ <span class="hp-label group-label" id={labelId}>{label}</span>
195
+ {/if}
196
+ {#if hint}
197
+ <p class="hint hp-caption" id={hintId}>{hint}</p>
198
+ {/if}
199
+ <div
200
+ class="items"
201
+ onpointerdowncapture={() => (input = 'pointer')}
202
+ onkeydowncapture={() => (input = 'key')}
203
+ onfocusin={focusIn}
204
+ onfocusout={focusOut}
205
+ >
206
+ {#each items as item, index (item.value)}
207
+ {@const radioId = `${id}-item-${index}`}
208
+ <div class="item">
209
+ <span class="control">
210
+ <BitsRadioGroup.Item
211
+ id={radioId}
212
+ value={item.value}
213
+ disabled={item.disabled}
214
+ aria-describedby={item.hint ? `${radioId}-hint` : undefined}
215
+ >
216
+ {#snippet child({ props: radioProps })}
217
+ <button {...radioProps} class="box" tabindex={item.value === tabStop ? 0 : -1}>
218
+ <span class="mark" aria-hidden="true"></span>
219
+ </button>
220
+ {/snippet}
221
+ </BitsRadioGroup.Item>
222
+ </span>
223
+ <label class="hp-dense" class:tagged={!!item.tag} for={radioId}
224
+ >{item.label}{#if item.tag}{' '}<Tag as="span">{item.tag}</Tag>{/if}</label
225
+ >
226
+ {#if item.hint}
227
+ <p class="hint hp-caption" id="{radioId}-hint">{item.hint}</p>
228
+ {/if}
229
+ </div>
230
+ {/each}
231
+ </div>
232
+ {#if error}
233
+ <p class="error hp-caption" id={errorId}>
234
+ <span class="icon" aria-hidden="true">
235
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
236
+ <path d="M1.5 1.5h13v13h-13z" />
237
+ <path d="M8 4v4M8 10v2" stroke-width="2" />
238
+ </svg>
239
+ </span>
240
+ <span>{error}</span>
241
+ </p>
242
+ {/if}
243
+ </div>
244
+ {/snippet}
245
+ </BitsRadioGroup.Root>
246
+
247
+ <style>
248
+ /* The name, its hint, the choices 8px apart (as CheckboxGroup's boxes),
249
+ then the error. Rows keep their own height when a layout makes the
250
+ group taller. */
251
+ .hp-radio-group {
252
+ display: grid;
253
+ align-content: start;
254
+ gap: var(--space-1);
255
+ min-width: 0;
256
+ }
257
+ .group-label,
258
+ .hint {
259
+ color: var(--muted);
260
+ }
261
+ /* A name set as written: a question, or a name with a counter. */
262
+ .legend {
263
+ font-size: var(--type-body);
264
+ font-weight: 500;
265
+ line-height: 24px;
266
+ }
267
+ [data-disabled] > .legend {
268
+ color: var(--muted);
269
+ }
270
+ .items {
271
+ display: grid;
272
+ gap: var(--space-2);
273
+ }
274
+
275
+ /* ---- A choice: Checkbox's row ---------------------------------------- */
276
+
277
+ /* The box, then the label as written. The first line is one hit-target
278
+ band (24px fine, 44px coarse): the box sits centred in it and the
279
+ label's first line too. The hint runs under the label, aligned with
280
+ its text. The row is as wide as its content, and all of it is the
281
+ target: the label's ::after covers the row, so a press on the hint, the
282
+ tag or the space between them is a press on the label, which chooses
283
+ the radio. The box sits above that cover with its own hit area. */
284
+ .item {
285
+ /* The label's line height, as .hp-dense sets it. */
286
+ --_line: 24px;
287
+ position: relative;
288
+ display: grid;
289
+ grid-template-columns: auto minmax(0, 1fr);
290
+ column-gap: var(--space-3);
291
+ align-items: start;
292
+ width: fit-content;
293
+ max-width: 100%;
294
+ }
295
+ @media (pointer: fine) {
296
+ .item {
297
+ --_line: 20px;
298
+ }
299
+ }
300
+
301
+ .control {
302
+ position: relative;
303
+ z-index: 1;
304
+ display: flex;
305
+ align-items: center;
306
+ height: var(--hit-target);
307
+ }
308
+
309
+ /* Checkbox's box. Chosen, the edge turns titanium and an 8px titanium
310
+ square sits inside, as on a menu's radio item; the fill stays black,
311
+ because selection is never a fill. Focus is base.css's contour 4px
312
+ outside the box, as on Checkbox: the row has no frame to draw it on,
313
+ and the label and hint beside the box show which choice it is. */
314
+ .box {
315
+ position: relative;
316
+ display: grid;
317
+ place-items: center;
318
+ box-sizing: border-box;
319
+ width: 20px;
320
+ height: 20px;
321
+ margin: 0;
322
+ padding: 0;
323
+ border: 1px solid var(--border-control);
324
+ background: var(--canvas);
325
+ color: var(--accent);
326
+ cursor: pointer;
327
+ -webkit-tap-highlight-color: transparent;
328
+ }
329
+ /* The hit area: at least --hit-target square, centred on the box. */
330
+ .box::before {
331
+ content: '';
332
+ position: absolute;
333
+ top: 50%;
334
+ left: 50%;
335
+ width: max(100%, var(--hit-target));
336
+ height: max(100%, var(--hit-target));
337
+ translate: -50% -50%;
338
+ }
339
+ .box[data-state='checked'] {
340
+ border-color: var(--accent);
341
+ }
342
+
343
+ /* The mark is always there, at opacity 0 until its radio is chosen, and
344
+ switches at once here; a pointer's choice fades it in (the script). */
345
+ .mark {
346
+ width: 8px;
347
+ height: 8px;
348
+ background: currentColor;
349
+ opacity: 0;
350
+ }
351
+ .box[data-state='checked'] .mark {
352
+ opacity: 1;
353
+ }
354
+
355
+ label {
356
+ justify-self: start;
357
+ padding: calc((var(--hit-target) - var(--_line)) / 2) 0;
358
+ cursor: pointer;
359
+ -webkit-tap-highlight-color: transparent;
360
+ }
361
+ label::after {
362
+ content: '';
363
+ position: absolute;
364
+ inset: 0;
365
+ }
366
+ /* The tag sits after the label when both fit on one line; otherwise it
367
+ wraps under the label whole. */
368
+ .tagged {
369
+ display: flex;
370
+ flex-wrap: wrap;
371
+ align-items: center;
372
+ gap: var(--space-1) var(--space-2);
373
+ }
374
+
375
+ .item > .hint {
376
+ grid-column: 2;
377
+ }
378
+
379
+ /* ---- Hover: fine pointers only, over any part of the row -------------- */
380
+
381
+ /* Only the fill fades. The edge switches at once, on hover as on a
382
+ choice, so an arrow key that moves the choice away from a row under a
383
+ resting pointer never starts a fade (as on Tabs). */
384
+ @media (hover: hover) and (pointer: fine) {
385
+ .box {
386
+ transition: background-color var(--dur-hover) var(--settle);
387
+ }
388
+ .item:has(> label:hover, .box:hover) .box:not(:disabled) {
389
+ background-color: var(--surface-hover);
390
+ }
391
+ .item:has(> label:hover, .box:hover) .box[data-state='unchecked']:not(:disabled) {
392
+ border-color: var(--muted);
393
+ }
394
+ .item:has(> label:active, .box:active) .box:not(:disabled) {
395
+ transition-duration: var(--dur-press);
396
+ }
397
+ }
398
+
399
+ /* ---- Press: one step past hover, so it reads on a phone too ---------- */
400
+
401
+ .item:has(> label:active, .box:active) .box:not(:disabled) {
402
+ background-color: var(--surface-raised);
403
+ }
404
+
405
+ /* ---- Disabled: the quieter --border and muted ink, the tag too -------- */
406
+
407
+ .box:disabled {
408
+ border-color: var(--border);
409
+ color: var(--muted);
410
+ cursor: not-allowed;
411
+ }
412
+ .item:has(.box:disabled) label {
413
+ color: var(--muted);
414
+ cursor: not-allowed;
415
+ }
416
+ .item:has(.box:disabled) label > :global(.hp-tag) {
417
+ background: var(--muted);
418
+ }
419
+
420
+ /* ---- The error: TextField's ------------------------------------------- */
421
+
422
+ /* It starts 8px under the last row, clear of its focus contour. */
423
+ .error {
424
+ display: flex;
425
+ gap: var(--space-2);
426
+ margin-top: var(--space-1);
427
+ color: var(--danger);
428
+ }
429
+ /* One line box high, so the icon centres on the first line of the
430
+ message however it wraps. */
431
+ .icon {
432
+ display: flex;
433
+ flex: none;
434
+ align-items: center;
435
+ height: 1lh;
436
+ }
437
+ .icon svg {
438
+ display: block;
439
+ }
440
+ </style>
@@ -0,0 +1,50 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { RadioGroup as BitsRadioGroup } from 'bits-ui';
3
+ export type RadioGroupItem = {
4
+ /** Identifies the choice; the bound `value` holds the chosen one. */
5
+ value: string;
6
+ /** The text beside the box, as written (never caps). */
7
+ label: string;
8
+ /** A second line in `--muted` under the label, read out as the choice's description. */
9
+ hint?: string;
10
+ /**
11
+ * A Tag after the label, such as "Recommended": a short fixed string the
12
+ * app defines, set in caps. It is part of the choice's accessible name.
13
+ */
14
+ tag?: string;
15
+ /** Shown in muted ink; it cannot be chosen and arrow keys skip it. */
16
+ disabled?: boolean;
17
+ };
18
+ /** The group's name: a fixed caps `label`, or a `legend` set as written. Exactly one. */
19
+ type RadioGroupName = {
20
+ /** The caps label that names the group: a fixed string the app defines. */
21
+ label: string;
22
+ legend?: never;
23
+ } | {
24
+ label?: never;
25
+ /**
26
+ * The group's name as content, set as written in 16px at weight 500:
27
+ * a question, or a name with a counter before it.
28
+ */
29
+ legend: Snippet;
30
+ };
31
+ /** Attributes and handlers not listed here go to the group element. */
32
+ export type RadioGroupProps = Omit<BitsRadioGroup.RootProps, 'child' | 'children' | 'ref' | 'orientation'> & RadioGroupName & {
33
+ /** The choices, stacked in order. */
34
+ items: RadioGroupItem[];
35
+ /** The chosen item's `value`. Bindable; `''` means none is chosen, and the app may set `''` to clear it. */
36
+ value?: string;
37
+ /** A line in `--muted` under the name, read out as the group's description. */
38
+ hint?: string;
39
+ /**
40
+ * The error to show under the choices, with an icon, linked by
41
+ * `aria-describedby`; it sets `aria-invalid` on the group. Say how to
42
+ * fix the problem. The app validates; the group shows what it is given.
43
+ */
44
+ error?: string;
45
+ /** The group element (`role="radiogroup"`). */
46
+ ref?: HTMLElement | null;
47
+ };
48
+ declare const RadioGroup: import("svelte").Component<RadioGroupProps, {}, "value" | "ref">;
49
+ type RadioGroup = ReturnType<typeof RadioGroup>;
50
+ export default RadioGroup;
@@ -8,6 +8,12 @@
8
8
  label: string;
9
9
  /** A second line in `--muted`. */
10
10
  description?: string;
11
+ /**
12
+ * The heading the option sits under, as written. Options with the same
13
+ * group share one heading, in the order each group first appears;
14
+ * options without one come first.
15
+ */
16
+ group?: string;
11
17
  disabled?: boolean;
12
18
  };
13
19
 
@@ -50,6 +56,7 @@
50
56
  <script lang="ts">
51
57
  import { MediaQuery } from 'svelte/reactivity';
52
58
  import { pop } from '../motion/index.js';
59
+ import { groupOptions } from './internal/groups.js';
53
60
  import Sheet from './internal/Sheet.svelte';
54
61
 
55
62
  const uid = $props.id();
@@ -78,6 +85,10 @@
78
85
 
79
86
  const chosen = $derived(items.find((i) => i.value === value));
80
87
 
88
+ // The list's order: options without a group, then each group. Bits UI
89
+ // gets it too, so typeahead on the closed trigger follows the list.
90
+ const grouped = $derived(groupOptions(items));
91
+
81
92
  // The highlight moved by keys gets the focus contour; a pointer's gets the
82
93
  // hover tint alone. Focus itself stays on the trigger.
83
94
  let keyboard = $state(false);
@@ -104,12 +115,13 @@
104
115
  // ContentStatic with forceMount reports placement once, at mount, while
105
116
  // still closed. So Bits neither opens it at the chosen option nor scrolls
106
117
  // to the option the keys move to; this does both, nearest edge first, as
107
- // Bits does in the popover.
118
+ // Bits does in the popover. The viewport's scroll-padding keeps an option
119
+ // clear of a group heading stuck at the top.
108
120
  function followHighlight(viewport: HTMLElement) {
109
121
  const reveal = (option: Element | null) => {
110
122
  if (!(option instanceof HTMLElement)) return;
111
- const top = option.offsetTop;
112
- const bottom = top + option.offsetHeight;
123
+ const top = option.offsetTop - (parseFloat(getComputedStyle(viewport).scrollPaddingTop) || 0);
124
+ const bottom = option.offsetTop + option.offsetHeight;
113
125
  if (top < viewport.scrollTop) viewport.scrollTop = top;
114
126
  else if (bottom > viewport.scrollTop + viewport.clientHeight) {
115
127
  viewport.scrollTop = bottom - viewport.clientHeight;
@@ -138,7 +150,7 @@
138
150
  >
139
151
  <label class="hp-select-label hp-label" id={labelId} for={id}>{label}</label>
140
152
 
141
- <BitsSelect.Root type="single" bind:value bind:open {items} {disabled} {name}>
153
+ <BitsSelect.Root type="single" bind:value bind:open items={grouped.ordered} {disabled} {name}>
142
154
  <BitsSelect.Trigger bind:ref {id} aria-labelledby={labelId} {...rest}>
143
155
  {#snippet child({ props })}
144
156
  <span class="hp-select-field hp-well-chevron">
@@ -207,33 +219,60 @@
207
219
  {#snippet options(inSheet: boolean)}
208
220
  <BitsSelect.Viewport>
209
221
  {#snippet child({ props: viewportProps })}
210
- <div {...viewportProps} class="hp-select-viewport" {@attach inSheet && followHighlight}>
211
- {#each items as entry (entry.value)}
212
- <BitsSelect.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
213
- {#snippet child({ props: optionProps, selected, highlighted })}
214
- <div
215
- {...optionProps}
216
- class="hp-select-option"
217
- data-described={entry.description ? '' : undefined}
218
- aria-disabled={entry.disabled ? 'true' : undefined}
219
- >
220
- {#if itemContent}
221
- {@render itemContent({ item: entry, selected, highlighted })}
222
- {:else}
223
- <span class="hp-select-option-label">{entry.label}</span>
224
- {#if entry.description}
225
- <span class="hp-select-option-description">{entry.description}</span>
226
- {/if}
227
- {/if}
222
+ <div
223
+ {...viewportProps}
224
+ class="hp-select-viewport"
225
+ data-grouped={grouped.groups.length ? '' : undefined}
226
+ {@attach inSheet && followHighlight}
227
+ >
228
+ {#each grouped.loose as entry (entry.value)}
229
+ {@render option(entry)}
230
+ {/each}
231
+ {#each grouped.groups as group (group.name)}
232
+ <!-- role="group", named by its heading, which is no option: keys and
233
+ typeahead skip it. -->
234
+ <BitsSelect.Group>
235
+ {#snippet child({ props: groupProps })}
236
+ <div {...groupProps} class="hp-select-group">
237
+ <BitsSelect.GroupHeading>
238
+ {#snippet child({ props: headingProps })}
239
+ <div {...headingProps} class="hp-select-group-heading">{group.name}</div>
240
+ {/snippet}
241
+ </BitsSelect.GroupHeading>
242
+ {#each group.items as entry (entry.value)}
243
+ {@render option(entry)}
244
+ {/each}
228
245
  </div>
229
246
  {/snippet}
230
- </BitsSelect.Item>
247
+ </BitsSelect.Group>
231
248
  {/each}
232
249
  </div>
233
250
  {/snippet}
234
251
  </BitsSelect.Viewport>
235
252
  {/snippet}
236
253
 
254
+ {#snippet option(entry: SelectItem)}
255
+ <BitsSelect.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
256
+ {#snippet child({ props: optionProps, selected, highlighted })}
257
+ <div
258
+ {...optionProps}
259
+ class="hp-select-option"
260
+ data-described={entry.description ? '' : undefined}
261
+ aria-disabled={entry.disabled ? 'true' : undefined}
262
+ >
263
+ {#if itemContent}
264
+ {@render itemContent({ item: entry, selected, highlighted })}
265
+ {:else}
266
+ <span class="hp-select-option-label">{entry.label}</span>
267
+ {#if entry.description}
268
+ <span class="hp-select-option-description">{entry.description}</span>
269
+ {/if}
270
+ {/if}
271
+ </div>
272
+ {/snippet}
273
+ </BitsSelect.Item>
274
+ {/snippet}
275
+
237
276
  <style>
238
277
  /* ---- Local tokens --------------------------------------------------- */
239
278
 
@@ -353,6 +392,33 @@
353
392
  overscroll-behavior: contain;
354
393
  padding-block: var(--space-1);
355
394
  }
395
+ /* An option the keys or the chosen value scroll to stays clear of the
396
+ group heading stuck at the top: one heading line high. */
397
+ .hp-select-viewport[data-grouped] {
398
+ scroll-padding-top: calc(var(--space-2) + 16px + var(--space-1));
399
+ }
400
+
401
+ /* ---- Group headings ------------------------------------------------- */
402
+
403
+ /* MenuGroup's heading: muted, on the options' text edge, 8px above and
404
+ 4px below. The text stays as written, because a group name may be the
405
+ app's data (BRIEF, style rule 4). It stays at the top of the list while
406
+ its options scroll under it, until the next group pushes it up; that is
407
+ scrolling, not motion. It sticks to the viewport's edge, over its 4px
408
+ padding, so nothing shows above it. */
409
+ .hp-select-group-heading {
410
+ position: sticky;
411
+ top: calc(-1 * var(--space-1));
412
+ z-index: 1;
413
+ padding: var(--space-2) var(--option-inset) var(--space-1);
414
+ background: var(--canvas);
415
+ color: var(--muted);
416
+ font-size: var(--type-label);
417
+ font-weight: 500;
418
+ line-height: 16px;
419
+ overflow-wrap: anywhere;
420
+ user-select: none;
421
+ }
356
422
 
357
423
  /* ---- Options: Geist list items, sentence case ----------------------- */
358
424
 
@@ -6,6 +6,12 @@ export type SelectItem = {
6
6
  label: string;
7
7
  /** A second line in `--muted`. */
8
8
  description?: string;
9
+ /**
10
+ * The heading the option sits under, as written. Options with the same
11
+ * group share one heading, in the order each group first appears;
12
+ * options without one come first.
13
+ */
14
+ group?: string;
9
15
  disabled?: boolean;
10
16
  };
11
17
  export type SelectItemSnippetProps = {
@@ -25,8 +25,9 @@
25
25
 
26
26
  // A timer, not CSS keyframes (keyframes are for expressive entrances
27
27
  // only). It reads both durations from the element, so an app can retune
28
- // them, also per spinner (`style="--spinner-delay: 0ms"`). It keeps
29
- // stepping under reduced motion: spinners and progress stay.
28
+ // them, also per spinner (a class on the Spinner whose rule sets
29
+ // `--spinner-delay: 0ms`). It keeps stepping under reduced motion:
30
+ // spinners and progress stay.
30
31
  function run(node: HTMLElement) {
31
32
  const delay = duration(node, '--spinner-delay');
32
33
  const every = duration(node, '--spinner-step');