@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,405 +1,471 @@
1
- <script lang="ts" module>
2
- import type { Snippet } from 'svelte';
3
- import { Select as BitsSelect } from 'bits-ui';
4
-
5
- export type SelectItem = {
6
- value: string;
7
- /** The option's text, in the trigger once chosen, and for typeahead. */
8
- label: string;
9
- /** A second line in `--muted`. */
10
- description?: string;
11
- disabled?: boolean;
12
- };
13
-
14
- export type SelectItemSnippetProps = {
15
- item: SelectItem;
16
- selected: boolean;
17
- highlighted: boolean;
18
- };
19
-
20
- export type SelectLabelBeside = boolean | 'medium' | 'expanded' | 'large';
21
-
22
- /** Attributes and handlers not listed here go to the trigger button. */
23
- export type SelectProps = Omit<
24
- BitsSelect.TriggerProps,
25
- 'child' | 'children' | 'value' | 'name' | 'disabled' | 'type' | 'ref'
26
- > & {
27
- items: SelectItem[];
28
- /** The chosen item's `value`; `''` for none. */
29
- value?: string;
30
- open?: boolean;
31
- /** The caps field label above the trigger, and the sheet's title on compact widths. */
32
- label: string;
33
- /** Shown in `--muted` while nothing is chosen. Never the only label. */
34
- placeholder?: string;
35
- disabled?: boolean;
36
- /** Submits the value with a form, through a hidden input. */
37
- name?: string;
38
- /**
39
- * The label beside the trigger instead of above it: always (`true`), or
40
- * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
41
- */
42
- labelBeside?: SelectLabelBeside;
43
- /** The trigger button. */
44
- ref?: HTMLElement | null;
45
- /** Renders an option's content in place of its label and description. */
46
- item?: Snippet<[SelectItemSnippetProps]>;
47
- };
48
- </script>
49
-
50
- <script lang="ts">
51
- import { MediaQuery } from 'svelte/reactivity';
52
- import { pop } from '../motion/index.js';
53
- import Sheet from './internal/Sheet.svelte';
54
-
55
- const uid = $props.id();
56
-
57
- let {
58
- items,
59
- value = $bindable(''),
60
- open = $bindable(false),
61
- label,
62
- placeholder = '',
63
- disabled = false,
64
- name,
65
- labelBeside = false,
66
- id = `${uid}-trigger`,
67
- ref = $bindable(null),
68
- item: itemContent,
69
- class: className,
70
- ...rest
71
- }: SelectProps = $props();
72
-
73
- const labelId = `${uid}-label`;
74
-
75
- // Compact (< 600px) opens a bottom sheet; medium and up a list under the
76
- // trigger (BRIEF, Apps and phones: Overlays and Choices).
77
- const wide = new MediaQuery('min-width: 600px', true);
78
-
79
- const chosen = $derived(items.find((i) => i.value === value));
80
-
81
- // The highlight moved by keys gets the focus contour; a pointer's gets the
82
- // hover tint alone. Focus itself stays on the trigger.
83
- let keyboard = $state(false);
84
-
85
- let sheetNode = $state<HTMLElement | null>(null);
86
-
87
- function close() {
88
- open = false;
89
- ref?.focus();
90
- }
91
-
92
- // The sheet's title row and handle sit outside the listbox; pressing
93
- // them is not an outside press.
94
- function insideSheet(e: PointerEvent) {
95
- if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
96
- }
97
-
98
- // Presses on the sheet keep focus on the trigger.
99
- function keepFocus(e: PointerEvent) {
100
- e.preventDefault();
101
- }
102
-
103
- // In the sheet, Bits UI never learns that its content is placed:
104
- // ContentStatic with forceMount reports placement once, at mount, while
105
- // still closed. So Bits neither opens it at the chosen option nor scrolls
106
- // to the option the keys move to; this does both, nearest edge first, as
107
- // Bits does in the popover.
108
- function followHighlight(viewport: HTMLElement) {
109
- const reveal = (option: Element | null) => {
110
- if (!(option instanceof HTMLElement)) return;
111
- const top = option.offsetTop;
112
- const bottom = top + option.offsetHeight;
113
- if (top < viewport.scrollTop) viewport.scrollTop = top;
114
- else if (bottom > viewport.scrollTop + viewport.clientHeight) {
115
- viewport.scrollTop = bottom - viewport.clientHeight;
116
- }
117
- };
118
- reveal(viewport.querySelector('[data-selected]'));
119
- const observer = new MutationObserver((records) => {
120
- if (!keyboard) return;
121
- for (const { target } of records) {
122
- if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
123
- }
124
- });
125
- observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
126
- return () => observer.disconnect();
127
- }
128
- </script>
129
-
130
- <div
131
- class={['hp-select', className]}
132
- data-state={open ? 'open' : 'closed'}
133
- data-disabled={disabled || undefined}
134
- data-placeholder={chosen ? undefined : ''}
135
- data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
136
- onkeydowncapture={() => (keyboard = true)}
137
- onpointerdowncapture={() => (keyboard = false)}
138
- >
139
- <label class="hp-select-label hp-label" id={labelId} for={id}>{label}</label>
140
-
141
- <BitsSelect.Root type="single" bind:value bind:open {items} {disabled} {name}>
142
- <BitsSelect.Trigger bind:ref {id} aria-labelledby={labelId} {...rest}>
143
- {#snippet child({ props })}
144
- <span class="hp-select-field hp-well-chevron">
145
- <button {...props} class="hp-select-trigger hp-well" data-placeholder={chosen ? undefined : ''}>
146
- <span class="hp-select-value">{chosen?.label ?? placeholder}</span>
147
- </button>
148
- </span>
149
- {/snippet}
150
- </BitsSelect.Trigger>
151
-
152
- <BitsSelect.Portal>
153
- {#if wide.current}
154
- <BitsSelect.Content
155
- forceMount
156
- side="bottom"
157
- align="start"
158
- sideOffset={8}
159
- collisionPadding={8}
160
- aria-labelledby={labelId}
161
- >
162
- {#snippet child({ wrapperProps, props, open: shown })}
163
- {#if shown}
164
- <div {...wrapperProps}>
165
- <div
166
- {...props}
167
- class="hp-select-list"
168
- data-form="popover"
169
- data-keyboard={keyboard || undefined}
170
- onpointermovecapture={() => (keyboard = false)}
171
- transition:pop
172
- >
173
- {@render options(false)}
174
- </div>
175
- </div>
176
- {/if}
177
- {/snippet}
178
- </BitsSelect.Content>
179
- {:else}
180
- <BitsSelect.ContentStatic forceMount preventScroll aria-labelledby={labelId} onInteractOutside={insideSheet}>
181
- {#snippet child({ props, open: shown })}
182
- <Sheet
183
- open={shown}
184
- layer="popover"
185
- title={label}
186
- onclose={close}
187
- bind:ref={sheetNode}
188
- onpointerdown={keepFocus}
189
- >
190
- <div
191
- {...props}
192
- class="hp-select-list"
193
- data-form="sheet"
194
- data-keyboard={keyboard || undefined}
195
- onpointermovecapture={() => (keyboard = false)}
196
- >
197
- {@render options(true)}
198
- </div>
199
- </Sheet>
200
- {/snippet}
201
- </BitsSelect.ContentStatic>
202
- {/if}
203
- </BitsSelect.Portal>
204
- </BitsSelect.Root>
205
- </div>
206
-
207
- {#snippet options(inSheet: boolean)}
208
- <BitsSelect.Viewport>
209
- {#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}
228
- </div>
229
- {/snippet}
230
- </BitsSelect.Item>
231
- {/each}
232
- </div>
233
- {/snippet}
234
- </BitsSelect.Viewport>
235
- {/snippet}
236
-
237
- <style>
238
- /* ---- Local tokens --------------------------------------------------- */
239
-
240
- /* The popover list renders in a portal at the end of <body>, outside
241
- .hp-select, so it carries this itself. */
242
- .hp-select-list {
243
- /* The popover list's cap: eight and a half 36px rows, so a cut row
244
- shows there is more. */
245
- --select-list-height: 320px;
246
- }
247
-
248
- /* ---- Field: caps label over a black well --------------------------- */
249
-
250
- .hp-select {
251
- /* Bits UI's hidden form input is absolutely placed in here. */
252
- position: relative;
253
- display: grid;
254
- gap: var(--space-1);
255
- min-width: 0;
256
- }
257
-
258
- .hp-select-label {
259
- color: var(--muted);
260
- }
261
-
262
- /* Label beside the trigger, as in a filter bar: always, or from a
263
- breakpoint up. */
264
- .hp-select[data-label-beside='always'] {
265
- display: flex;
266
- align-items: center;
267
- gap: var(--space-2);
268
- }
269
- @media (min-width: 600px) {
270
- .hp-select[data-label-beside='medium'] {
271
- display: flex;
272
- align-items: center;
273
- gap: var(--space-2);
274
- }
275
- }
276
- @media (min-width: 900px) {
277
- .hp-select[data-label-beside='expanded'] {
278
- display: flex;
279
- align-items: center;
280
- gap: var(--space-2);
281
- }
282
- }
283
- @media (min-width: 1200px) {
284
- .hp-select[data-label-beside='large'] {
285
- display: flex;
286
- align-items: center;
287
- gap: var(--space-2);
288
- }
289
- }
290
- .hp-select[data-label-beside] .hp-select-label {
291
- flex: none;
292
- white-space: nowrap;
293
- }
294
-
295
- /* The trigger is base.css's well; its wrapper draws the chevron, like a
296
- native select's. The wrapper is the grid or flex item, so it holds the
297
- width and the minimum. */
298
- .hp-select-field {
299
- width: 100%;
300
- min-width: 136px;
301
- }
302
- .hp-select-trigger {
303
- display: flex;
304
- align-items: center;
305
- width: 100%;
306
- text-align: start;
307
- cursor: pointer;
308
- -webkit-tap-highlight-color: transparent;
309
- }
310
- .hp-select-trigger:disabled {
311
- cursor: not-allowed;
312
- }
313
- .hp-select-trigger[data-placeholder] {
314
- color: var(--muted);
315
- }
316
- .hp-select-value {
317
- min-width: 0;
318
- overflow: hidden;
319
- text-overflow: ellipsis;
320
- white-space: nowrap;
321
- }
322
-
323
- /* ---- The list: a popover on medium and up, the shared sheet's body on
324
- compact (internal/Sheet.svelte, on the popover layer) ---------------- */
325
-
326
- .hp-select-list {
327
- /* Option text lines up with the trigger's text (popover) or the
328
- sheet's title (sheet). */
329
- --option-inset: var(--space-3);
330
-
331
- position: relative;
332
- z-index: var(--layer-popover);
333
- box-sizing: border-box;
334
- background: var(--canvas);
335
- color: var(--text);
336
- font-family: var(--sans);
337
- }
338
- .hp-select-list[data-form='popover'] {
339
- min-width: var(--bits-select-anchor-width);
340
- max-width: max(var(--bits-select-anchor-width), min(var(--bits-select-content-available-width), 480px));
341
- max-height: min(var(--select-list-height), var(--bits-select-content-available-height));
342
- border: 1px solid var(--border-control);
343
- }
344
- .hp-select-list[data-form='sheet'] {
345
- --option-inset: var(--gutter);
346
- flex: 1 1 auto;
347
- min-height: 0;
348
- }
349
-
350
- /* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
351
- .hp-select-viewport[data-select-viewport] {
352
- scrollbar-width: thin !important;
353
- overscroll-behavior: contain;
354
- padding-block: var(--space-1);
355
- }
356
-
357
- /* ---- Options: Geist list items, sentence case ----------------------- */
358
-
359
- .hp-select-option {
360
- display: flex;
361
- flex-direction: column;
362
- justify-content: center;
363
- box-sizing: border-box;
364
- /* 36px rows on fine pointers, 44px on coarse. */
365
- min-height: var(--control-height);
366
- padding: var(--space-1) var(--option-inset);
367
- font-size: var(--type-body);
368
- line-height: 24px;
369
- overflow-wrap: anywhere;
370
- cursor: pointer;
371
- user-select: none;
372
- -webkit-tap-highlight-color: transparent;
373
- }
374
- .hp-select-option[data-described] {
375
- padding-block: var(--space-2);
376
- }
377
- .hp-select-option-description {
378
- color: var(--muted);
379
- font-size: var(--type-caption);
380
- line-height: 18px;
381
- }
382
-
383
- /* Highlight, by pointer or keys: the hover tint. Selection snaps, so
384
- nothing here transitions (BRIEF, motion rule 3). */
385
- .hp-select-option[data-highlighted],
386
- .hp-select-option:active {
387
- background: var(--surface-hover);
388
- }
389
- /* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
390
- press never darken it. */
391
- .hp-select-option[data-selected] {
392
- background: var(--surface-selected);
393
- box-shadow: inset 3px 0 0 var(--accent);
394
- }
395
- /* Keys move a virtual focus (focus stays on the trigger), so the
396
- highlight they move also draws the focus contour, inside the row. */
397
- .hp-select-list[data-keyboard] .hp-select-option[data-highlighted] {
398
- outline: 2px solid var(--focus);
399
- outline-offset: -2px;
400
- }
401
- .hp-select-option[data-disabled] {
402
- color: var(--muted);
403
- cursor: not-allowed;
404
- }
405
- </style>
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Select as BitsSelect } from 'bits-ui';
4
+
5
+ export type SelectItem = {
6
+ value: string;
7
+ /** The option's text, in the trigger once chosen, and for typeahead. */
8
+ label: string;
9
+ /** A second line in `--muted`. */
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;
17
+ disabled?: boolean;
18
+ };
19
+
20
+ export type SelectItemSnippetProps = {
21
+ item: SelectItem;
22
+ selected: boolean;
23
+ highlighted: boolean;
24
+ };
25
+
26
+ export type SelectLabelBeside = boolean | 'medium' | 'expanded' | 'large';
27
+
28
+ /** Attributes and handlers not listed here go to the trigger button. */
29
+ export type SelectProps = Omit<
30
+ BitsSelect.TriggerProps,
31
+ 'child' | 'children' | 'value' | 'name' | 'disabled' | 'type' | 'ref'
32
+ > & {
33
+ items: SelectItem[];
34
+ /** The chosen item's `value`; `''` for none. */
35
+ value?: string;
36
+ open?: boolean;
37
+ /** The caps field label above the trigger, and the sheet's title on compact widths. */
38
+ label: string;
39
+ /** Shown in `--muted` while nothing is chosen. Never the only label. */
40
+ placeholder?: string;
41
+ disabled?: boolean;
42
+ /** Submits the value with a form, through a hidden input. */
43
+ name?: string;
44
+ /**
45
+ * The label beside the trigger instead of above it: always (`true`), or
46
+ * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
47
+ */
48
+ labelBeside?: SelectLabelBeside;
49
+ /** The trigger button. */
50
+ ref?: HTMLElement | null;
51
+ /** Renders an option's content in place of its label and description. */
52
+ item?: Snippet<[SelectItemSnippetProps]>;
53
+ };
54
+ </script>
55
+
56
+ <script lang="ts">
57
+ import { MediaQuery } from 'svelte/reactivity';
58
+ import { pop } from '../motion/index.js';
59
+ import { groupOptions } from './internal/groups.js';
60
+ import Sheet from './internal/Sheet.svelte';
61
+
62
+ const uid = $props.id();
63
+
64
+ let {
65
+ items,
66
+ value = $bindable(''),
67
+ open = $bindable(false),
68
+ label,
69
+ placeholder = '',
70
+ disabled = false,
71
+ name,
72
+ labelBeside = false,
73
+ id = `${uid}-trigger`,
74
+ ref = $bindable(null),
75
+ item: itemContent,
76
+ class: className,
77
+ ...rest
78
+ }: SelectProps = $props();
79
+
80
+ const labelId = `${uid}-label`;
81
+
82
+ // Compact (< 600px) opens a bottom sheet; medium and up a list under the
83
+ // trigger (BRIEF, Apps and phones: Overlays and Choices).
84
+ const wide = new MediaQuery('min-width: 600px', true);
85
+
86
+ const chosen = $derived(items.find((i) => i.value === value));
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
+
92
+ // The highlight moved by keys gets the focus contour; a pointer's gets the
93
+ // hover tint alone. Focus itself stays on the trigger.
94
+ let keyboard = $state(false);
95
+
96
+ let sheetNode = $state<HTMLElement | null>(null);
97
+
98
+ function close() {
99
+ open = false;
100
+ ref?.focus();
101
+ }
102
+
103
+ // The sheet's title row and handle sit outside the listbox; pressing
104
+ // them is not an outside press.
105
+ function insideSheet(e: PointerEvent) {
106
+ if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
107
+ }
108
+
109
+ // Presses on the sheet keep focus on the trigger.
110
+ function keepFocus(e: PointerEvent) {
111
+ e.preventDefault();
112
+ }
113
+
114
+ // In the sheet, Bits UI never learns that its content is placed:
115
+ // ContentStatic with forceMount reports placement once, at mount, while
116
+ // still closed. So Bits neither opens it at the chosen option nor scrolls
117
+ // to the option the keys move to; this does both, nearest edge first, as
118
+ // Bits does in the popover. The viewport's scroll-padding keeps an option
119
+ // clear of a group heading stuck at the top.
120
+ function followHighlight(viewport: HTMLElement) {
121
+ const reveal = (option: Element | null) => {
122
+ if (!(option instanceof HTMLElement)) return;
123
+ const top = option.offsetTop - (parseFloat(getComputedStyle(viewport).scrollPaddingTop) || 0);
124
+ const bottom = option.offsetTop + option.offsetHeight;
125
+ if (top < viewport.scrollTop) viewport.scrollTop = top;
126
+ else if (bottom > viewport.scrollTop + viewport.clientHeight) {
127
+ viewport.scrollTop = bottom - viewport.clientHeight;
128
+ }
129
+ };
130
+ reveal(viewport.querySelector('[data-selected]'));
131
+ const observer = new MutationObserver((records) => {
132
+ if (!keyboard) return;
133
+ for (const { target } of records) {
134
+ if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
135
+ }
136
+ });
137
+ observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
138
+ return () => observer.disconnect();
139
+ }
140
+ </script>
141
+
142
+ <div
143
+ class={['hp-select', className]}
144
+ data-state={open ? 'open' : 'closed'}
145
+ data-disabled={disabled || undefined}
146
+ data-placeholder={chosen ? undefined : ''}
147
+ data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
148
+ onkeydowncapture={() => (keyboard = true)}
149
+ onpointerdowncapture={() => (keyboard = false)}
150
+ >
151
+ <label class="hp-select-label hp-label" id={labelId} for={id}>{label}</label>
152
+
153
+ <BitsSelect.Root type="single" bind:value bind:open items={grouped.ordered} {disabled} {name}>
154
+ <BitsSelect.Trigger bind:ref {id} aria-labelledby={labelId} {...rest}>
155
+ {#snippet child({ props })}
156
+ <span class="hp-select-field hp-well-chevron">
157
+ <button {...props} class="hp-select-trigger hp-well" data-placeholder={chosen ? undefined : ''}>
158
+ <span class="hp-select-value">{chosen?.label ?? placeholder}</span>
159
+ </button>
160
+ </span>
161
+ {/snippet}
162
+ </BitsSelect.Trigger>
163
+
164
+ <BitsSelect.Portal>
165
+ {#if wide.current}
166
+ <BitsSelect.Content
167
+ forceMount
168
+ side="bottom"
169
+ align="start"
170
+ sideOffset={8}
171
+ collisionPadding={8}
172
+ aria-labelledby={labelId}
173
+ >
174
+ {#snippet child({ wrapperProps, props, open: shown })}
175
+ {#if shown}
176
+ <div {...wrapperProps}>
177
+ <div
178
+ {...props}
179
+ class="hp-select-list"
180
+ data-form="popover"
181
+ data-keyboard={keyboard || undefined}
182
+ onpointermovecapture={() => (keyboard = false)}
183
+ transition:pop
184
+ >
185
+ {@render options(false)}
186
+ </div>
187
+ </div>
188
+ {/if}
189
+ {/snippet}
190
+ </BitsSelect.Content>
191
+ {:else}
192
+ <BitsSelect.ContentStatic forceMount preventScroll aria-labelledby={labelId} onInteractOutside={insideSheet}>
193
+ {#snippet child({ props, open: shown })}
194
+ <Sheet
195
+ open={shown}
196
+ layer="popover"
197
+ title={label}
198
+ onclose={close}
199
+ bind:ref={sheetNode}
200
+ onpointerdown={keepFocus}
201
+ >
202
+ <div
203
+ {...props}
204
+ class="hp-select-list"
205
+ data-form="sheet"
206
+ data-keyboard={keyboard || undefined}
207
+ onpointermovecapture={() => (keyboard = false)}
208
+ >
209
+ {@render options(true)}
210
+ </div>
211
+ </Sheet>
212
+ {/snippet}
213
+ </BitsSelect.ContentStatic>
214
+ {/if}
215
+ </BitsSelect.Portal>
216
+ </BitsSelect.Root>
217
+ </div>
218
+
219
+ {#snippet options(inSheet: boolean)}
220
+ <BitsSelect.Viewport>
221
+ {#snippet child({ props: viewportProps })}
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}
245
+ </div>
246
+ {/snippet}
247
+ </BitsSelect.Group>
248
+ {/each}
249
+ </div>
250
+ {/snippet}
251
+ </BitsSelect.Viewport>
252
+ {/snippet}
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
+
276
+ <style>
277
+ /* ---- Local tokens --------------------------------------------------- */
278
+
279
+ /* The popover list renders in a portal at the end of <body>, outside
280
+ .hp-select, so it carries this itself. */
281
+ .hp-select-list {
282
+ /* The popover list's cap: eight and a half 36px rows, so a cut row
283
+ shows there is more. */
284
+ --select-list-height: 320px;
285
+ }
286
+
287
+ /* ---- Field: caps label over a black well --------------------------- */
288
+
289
+ .hp-select {
290
+ /* Bits UI's hidden form input is absolutely placed in here. */
291
+ position: relative;
292
+ display: grid;
293
+ gap: var(--space-1);
294
+ min-width: 0;
295
+ }
296
+
297
+ .hp-select-label {
298
+ color: var(--muted);
299
+ }
300
+
301
+ /* Label beside the trigger, as in a filter bar: always, or from a
302
+ breakpoint up. */
303
+ .hp-select[data-label-beside='always'] {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: var(--space-2);
307
+ }
308
+ @media (min-width: 600px) {
309
+ .hp-select[data-label-beside='medium'] {
310
+ display: flex;
311
+ align-items: center;
312
+ gap: var(--space-2);
313
+ }
314
+ }
315
+ @media (min-width: 900px) {
316
+ .hp-select[data-label-beside='expanded'] {
317
+ display: flex;
318
+ align-items: center;
319
+ gap: var(--space-2);
320
+ }
321
+ }
322
+ @media (min-width: 1200px) {
323
+ .hp-select[data-label-beside='large'] {
324
+ display: flex;
325
+ align-items: center;
326
+ gap: var(--space-2);
327
+ }
328
+ }
329
+ .hp-select[data-label-beside] .hp-select-label {
330
+ flex: none;
331
+ white-space: nowrap;
332
+ }
333
+
334
+ /* The trigger is base.css's well; its wrapper draws the chevron, like a
335
+ native select's. The wrapper is the grid or flex item, so it holds the
336
+ width and the minimum. */
337
+ .hp-select-field {
338
+ width: 100%;
339
+ min-width: 136px;
340
+ }
341
+ .hp-select-trigger {
342
+ display: flex;
343
+ align-items: center;
344
+ width: 100%;
345
+ text-align: start;
346
+ cursor: pointer;
347
+ -webkit-tap-highlight-color: transparent;
348
+ }
349
+ .hp-select-trigger:disabled {
350
+ cursor: not-allowed;
351
+ }
352
+ .hp-select-trigger[data-placeholder] {
353
+ color: var(--muted);
354
+ }
355
+ .hp-select-value {
356
+ min-width: 0;
357
+ overflow: hidden;
358
+ text-overflow: ellipsis;
359
+ white-space: nowrap;
360
+ }
361
+
362
+ /* ---- The list: a popover on medium and up, the shared sheet's body on
363
+ compact (internal/Sheet.svelte, on the popover layer) ---------------- */
364
+
365
+ .hp-select-list {
366
+ /* Option text lines up with the trigger's text (popover) or the
367
+ sheet's title (sheet). */
368
+ --option-inset: var(--space-3);
369
+
370
+ position: relative;
371
+ z-index: var(--layer-popover);
372
+ box-sizing: border-box;
373
+ background: var(--canvas);
374
+ color: var(--text);
375
+ font-family: var(--sans);
376
+ }
377
+ .hp-select-list[data-form='popover'] {
378
+ min-width: var(--bits-select-anchor-width);
379
+ max-width: max(var(--bits-select-anchor-width), min(var(--bits-select-content-available-width), 480px));
380
+ max-height: min(var(--select-list-height), var(--bits-select-content-available-height));
381
+ border: 1px solid var(--border-control);
382
+ }
383
+ .hp-select-list[data-form='sheet'] {
384
+ --option-inset: var(--gutter);
385
+ flex: 1 1 auto;
386
+ min-height: 0;
387
+ }
388
+
389
+ /* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
390
+ .hp-select-viewport[data-select-viewport] {
391
+ scrollbar-width: thin !important;
392
+ overscroll-behavior: contain;
393
+ padding-block: var(--space-1);
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
+ }
422
+
423
+ /* ---- Options: Geist list items, sentence case ----------------------- */
424
+
425
+ .hp-select-option {
426
+ display: flex;
427
+ flex-direction: column;
428
+ justify-content: center;
429
+ box-sizing: border-box;
430
+ /* 36px rows on fine pointers, 44px on coarse. */
431
+ min-height: var(--control-height);
432
+ padding: var(--space-1) var(--option-inset);
433
+ font-size: var(--type-body);
434
+ line-height: 24px;
435
+ overflow-wrap: anywhere;
436
+ cursor: pointer;
437
+ user-select: none;
438
+ -webkit-tap-highlight-color: transparent;
439
+ }
440
+ .hp-select-option[data-described] {
441
+ padding-block: var(--space-2);
442
+ }
443
+ .hp-select-option-description {
444
+ color: var(--muted);
445
+ font-size: var(--type-caption);
446
+ line-height: 18px;
447
+ }
448
+
449
+ /* Highlight, by pointer or keys: the hover tint. Selection snaps, so
450
+ nothing here transitions (BRIEF, motion rule 3). */
451
+ .hp-select-option[data-highlighted],
452
+ .hp-select-option:active {
453
+ background: var(--surface-hover);
454
+ }
455
+ /* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
456
+ press never darken it. */
457
+ .hp-select-option[data-selected] {
458
+ background: var(--surface-selected);
459
+ box-shadow: inset 3px 0 0 var(--accent);
460
+ }
461
+ /* Keys move a virtual focus (focus stays on the trigger), so the
462
+ highlight they move also draws the focus contour, inside the row. */
463
+ .hp-select-list[data-keyboard] .hp-select-option[data-highlighted] {
464
+ outline: 2px solid var(--focus);
465
+ outline-offset: -2px;
466
+ }
467
+ .hp-select-option[data-disabled] {
468
+ color: var(--muted);
469
+ cursor: not-allowed;
470
+ }
471
+ </style>