@mystravil/hardpoint 0.0.0-stage → 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 (126) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +625 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +235 -0
  10. package/dist/components/Checkbox.svelte.d.ts +23 -0
  11. package/dist/components/CheckboxGroup.svelte +207 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
  13. package/dist/components/Combobox.svelte +901 -0
  14. package/dist/components/Combobox.svelte.d.ts +72 -0
  15. package/dist/components/Dialog.svelte +359 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/ImageViewer.svelte +764 -0
  24. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  25. package/dist/components/KeyValueList.svelte +114 -0
  26. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  27. package/dist/components/ListDetail.svelte +218 -0
  28. package/dist/components/ListDetail.svelte.d.ts +37 -0
  29. package/dist/components/LoadRegion.svelte +219 -0
  30. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  31. package/dist/components/MediaFrame.svelte +110 -0
  32. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  33. package/dist/components/MediaImage.svelte +256 -0
  34. package/dist/components/MediaImage.svelte.d.ts +16 -0
  35. package/dist/components/Menu.svelte +258 -0
  36. package/dist/components/Menu.svelte.d.ts +36 -0
  37. package/dist/components/MenuCheckboxItem.svelte +119 -0
  38. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  39. package/dist/components/MenuGroup.svelte +50 -0
  40. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  41. package/dist/components/MenuItem.svelte +94 -0
  42. package/dist/components/MenuItem.svelte.d.ts +19 -0
  43. package/dist/components/MenuRadioGroup.svelte +62 -0
  44. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  45. package/dist/components/MenuRadioItem.svelte +110 -0
  46. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  47. package/dist/components/MenuSeparator.svelte +28 -0
  48. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  49. package/dist/components/Meter.svelte +283 -0
  50. package/dist/components/Meter.svelte.d.ts +66 -0
  51. package/dist/components/NativeSelect.svelte +135 -0
  52. package/dist/components/NativeSelect.svelte.d.ts +52 -0
  53. package/dist/components/Notice.svelte +262 -0
  54. package/dist/components/Notice.svelte.d.ts +30 -0
  55. package/dist/components/Pagination.svelte +132 -0
  56. package/dist/components/Pagination.svelte.d.ts +18 -0
  57. package/dist/components/Popover.svelte +222 -0
  58. package/dist/components/Popover.svelte.d.ts +35 -0
  59. package/dist/components/Progress.svelte +157 -0
  60. package/dist/components/Progress.svelte.d.ts +34 -0
  61. package/dist/components/RadioGroup.svelte +440 -0
  62. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  63. package/dist/components/Select.svelte +471 -0
  64. package/dist/components/Select.svelte.d.ts +48 -0
  65. package/dist/components/Skeleton.svelte +149 -0
  66. package/dist/components/Skeleton.svelte.d.ts +24 -0
  67. package/dist/components/Spinner.svelte +118 -0
  68. package/dist/components/Spinner.svelte.d.ts +13 -0
  69. package/dist/components/Stat.svelte +253 -0
  70. package/dist/components/Stat.svelte.d.ts +46 -0
  71. package/dist/components/Steps.svelte +189 -0
  72. package/dist/components/Steps.svelte.d.ts +31 -0
  73. package/dist/components/Switch.svelte +222 -0
  74. package/dist/components/Switch.svelte.d.ts +18 -0
  75. package/dist/components/Table.svelte +504 -0
  76. package/dist/components/Table.svelte.d.ts +92 -0
  77. package/dist/components/Tabs.svelte +246 -0
  78. package/dist/components/Tabs.svelte.d.ts +44 -0
  79. package/dist/components/Tag.svelte +40 -0
  80. package/dist/components/Tag.svelte.d.ts +15 -0
  81. package/dist/components/TextArea.svelte +272 -0
  82. package/dist/components/TextArea.svelte.d.ts +28 -0
  83. package/dist/components/TextField.svelte +256 -0
  84. package/dist/components/TextField.svelte.d.ts +32 -0
  85. package/dist/components/Timeline.svelte +156 -0
  86. package/dist/components/Timeline.svelte.d.ts +30 -0
  87. package/dist/components/Toaster.svelte +411 -0
  88. package/dist/components/Toaster.svelte.d.ts +11 -0
  89. package/dist/components/Tooltip.svelte +200 -0
  90. package/dist/components/Tooltip.svelte.d.ts +38 -0
  91. package/dist/components/TooltipGroup.svelte +46 -0
  92. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  93. package/dist/components/internal/Scrim.svelte +52 -0
  94. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  95. package/dist/components/internal/Sheet.svelte +263 -0
  96. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  97. package/dist/components/internal/fade-in.d.ts +10 -0
  98. package/dist/components/internal/fade-in.js +20 -0
  99. package/dist/components/internal/groups.d.ts +14 -0
  100. package/dist/components/internal/groups.js +23 -0
  101. package/dist/components/internal/resolve.d.ts +8 -0
  102. package/dist/components/internal/resolve.js +247 -0
  103. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  104. package/dist/components/internal/swipe-dismiss.js +220 -0
  105. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  106. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  107. package/dist/components/toast.svelte.d.ts +57 -0
  108. package/dist/components/toast.svelte.js +84 -0
  109. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  110. package/dist/fonts/OFL.txt +93 -0
  111. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  112. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  113. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  114. package/dist/fonts.css +71 -0
  115. package/dist/index.d.ts +45 -0
  116. package/dist/index.js +45 -0
  117. package/dist/motion/Motion.svelte +15 -0
  118. package/dist/motion/Motion.svelte.d.ts +9 -0
  119. package/dist/motion/context.d.ts +18 -0
  120. package/dist/motion/context.js +26 -0
  121. package/dist/motion/index.d.ts +3 -0
  122. package/dist/motion/index.js +3 -0
  123. package/dist/motion/transitions.d.ts +31 -0
  124. package/dist/motion/transitions.js +112 -0
  125. package/dist/tokens.css +283 -0
  126. package/package.json +76 -6
@@ -0,0 +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
+ /**
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>
@@ -0,0 +1,48 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { Select as BitsSelect } from 'bits-ui';
3
+ export type SelectItem = {
4
+ value: string;
5
+ /** The option's text, in the trigger once chosen, and for typeahead. */
6
+ label: string;
7
+ /** A second line in `--muted`. */
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;
15
+ disabled?: boolean;
16
+ };
17
+ export type SelectItemSnippetProps = {
18
+ item: SelectItem;
19
+ selected: boolean;
20
+ highlighted: boolean;
21
+ };
22
+ export type SelectLabelBeside = boolean | 'medium' | 'expanded' | 'large';
23
+ /** Attributes and handlers not listed here go to the trigger button. */
24
+ export type SelectProps = Omit<BitsSelect.TriggerProps, 'child' | 'children' | 'value' | 'name' | 'disabled' | 'type' | 'ref'> & {
25
+ items: SelectItem[];
26
+ /** The chosen item's `value`; `''` for none. */
27
+ value?: string;
28
+ open?: boolean;
29
+ /** The caps field label above the trigger, and the sheet's title on compact widths. */
30
+ label: string;
31
+ /** Shown in `--muted` while nothing is chosen. Never the only label. */
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ /** Submits the value with a form, through a hidden input. */
35
+ name?: string;
36
+ /**
37
+ * The label beside the trigger instead of above it: always (`true`), or
38
+ * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
39
+ */
40
+ labelBeside?: SelectLabelBeside;
41
+ /** The trigger button. */
42
+ ref?: HTMLElement | null;
43
+ /** Renders an option's content in place of its label and description. */
44
+ item?: Snippet<[SelectItemSnippetProps]>;
45
+ };
46
+ declare const Select: import("svelte").Component<SelectProps, {}, "value" | "ref" | "open">;
47
+ type Select = ReturnType<typeof Select>;
48
+ export default Select;
@@ -0,0 +1,149 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+
4
+ /**
5
+ * `text` (default): bars that stand for lines of text, each one line box
6
+ * high in the surrounding type, so content replaces them without moving
7
+ * anything. `block`: one solid box, for a control, a figure or a mark.
8
+ */
9
+ export type SkeletonVariant = 'text' | 'block';
10
+
11
+ export type SkeletonProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
12
+ variant?: SkeletonVariant;
13
+ /**
14
+ * Any CSS width, such as `8ch` for an identifier in a mono cell or
15
+ * `60%`. Defaults to `100%`, and never exceeds its container's width.
16
+ * With several `lines`, the width of the full lines; the last line is
17
+ * shorter.
18
+ */
19
+ width?: string;
20
+ /** `block` only: any CSS height. Defaults to `--control-height`. */
21
+ height?: string;
22
+ /** `text` only: how many lines of text the bars stand for. Defaults to 1. */
23
+ lines?: number;
24
+ };
25
+
26
+ // Blocks that mount together appear together: every skeleton that mounts
27
+ // in the same task with the same delay shares one timer, so a page of
28
+ // blocks shows in one frame instead of rippling in.
29
+ let batch: Map<number, Set<() => void>> | null = null;
30
+
31
+ function afterDelay(delay: number, show: () => void): () => void {
32
+ if (!batch) {
33
+ batch = new Map();
34
+ queueMicrotask(() => (batch = null));
35
+ }
36
+ let members = batch.get(delay);
37
+ if (!members) {
38
+ const waiting = new Set<() => void>();
39
+ batch.set(delay, waiting);
40
+ setTimeout(() => waiting.forEach((fn) => fn()), delay);
41
+ members = waiting;
42
+ }
43
+ members.add(show);
44
+ const own = members;
45
+ return () => own.delete(show);
46
+ }
47
+ </script>
48
+
49
+ <script lang="ts">
50
+ import { duration } from '../motion/transitions.js';
51
+
52
+ let {
53
+ variant = 'text',
54
+ width,
55
+ height,
56
+ lines = 1,
57
+ class: className,
58
+ ...rest
59
+ }: SkeletonProps = $props();
60
+
61
+ const count = $derived(variant === 'text' ? Math.max(1, Math.floor(lines)) : 0);
62
+
63
+ let shown = $state(false);
64
+
65
+ // Motion rule 2: blocks only while waiting. Nothing shows until
66
+ // --skeleton-delay (300ms) has passed, so fast content never flashes
67
+ // blocks; the box keeps its size meanwhile. The delay is read from the
68
+ // element, so an app that already waited can set it to 0ms. It is not
69
+ // motion, so reduced motion keeps it.
70
+ function appear(node: HTMLElement) {
71
+ const delay = duration(node, '--skeleton-delay');
72
+ if (delay <= 0) {
73
+ shown = true;
74
+ return;
75
+ }
76
+ return afterDelay(delay, () => (shown = true));
77
+ }
78
+ </script>
79
+
80
+ <!--
81
+ A static placeholder that mirrors the content it stands for (BRIEF, motion
82
+ rule 2): no shimmer and no transition of its own. Render it in a
83
+ LoadRegion's `placeholder`, which swaps it for the content as
84
+ `--dur-reveal` sets (0ms: a cut). It is aria-hidden: the region says what
85
+ loads itself (aria-busy and a status line). A `text` skeleton takes the
86
+ type of its context, so give it the content's type class
87
+ (`hp-page-title`, `hp-label`) or put it where the content goes, such as a
88
+ table cell. Extra attributes and `class` go to the root
89
+ `span.hp-skeleton`, which carries `data-variant` and `data-state`
90
+ waiting | visible.
91
+ -->
92
+ <span
93
+ {...rest}
94
+ class={['hp-skeleton', className]}
95
+ aria-hidden="true"
96
+ data-variant={variant}
97
+ data-state={shown ? 'visible' : 'waiting'}
98
+ style:width
99
+ style:height={variant === 'block' ? height : undefined}
100
+ {@attach appear}
101
+ >
102
+ {#each { length: count }}
103
+ <span class="hp-skeleton-line"></span>
104
+ {/each}
105
+ </span>
106
+
107
+ <style>
108
+ /* Never wider than its container. In a grid or flex item a fixed width
109
+ still counts toward the item's minimum width, as an image's does: let
110
+ the item shrink (minmax(0, 1fr), min-width: 0). */
111
+ .hp-skeleton {
112
+ width: 100%;
113
+ max-width: 100%;
114
+ }
115
+ /* Hidden, not removed, while it waits: the box keeps its size. */
116
+ .hp-skeleton[data-state='waiting'] {
117
+ visibility: hidden;
118
+ }
119
+
120
+ /* ---- Text: one bar per line box ------------------------------------ */
121
+
122
+ /* Top-aligned and as tall as its lines, so it never changes the height
123
+ of the line it sits in. */
124
+ .hp-skeleton[data-variant='text'] {
125
+ display: inline-block;
126
+ vertical-align: top;
127
+ }
128
+ /* The bar is three quarters of the font size, rounded down to an even
129
+ number of pixels, centred in a line box of the context's line height.
130
+ Even bars in even line heights leave whole pixels above and below. */
131
+ .hp-skeleton-line {
132
+ display: block;
133
+ height: 1lh;
134
+ background: linear-gradient(var(--surface-band) 0 0) 0 50% / 100% round(down, 0.75em, 2px)
135
+ no-repeat;
136
+ }
137
+ /* The last of several lines is shorter, as a paragraph's last line is. */
138
+ .hp-skeleton-line:last-child:not(:first-child) {
139
+ width: 60%;
140
+ }
141
+
142
+ /* ---- Block: one solid box ------------------------------------------- */
143
+
144
+ .hp-skeleton[data-variant='block'] {
145
+ display: block;
146
+ height: var(--control-height);
147
+ background: var(--surface-band);
148
+ }
149
+ </style>
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ /**
3
+ * `text` (default): bars that stand for lines of text, each one line box
4
+ * high in the surrounding type, so content replaces them without moving
5
+ * anything. `block`: one solid box, for a control, a figure or a mark.
6
+ */
7
+ export type SkeletonVariant = 'text' | 'block';
8
+ export type SkeletonProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
9
+ variant?: SkeletonVariant;
10
+ /**
11
+ * Any CSS width, such as `8ch` for an identifier in a mono cell or
12
+ * `60%`. Defaults to `100%`, and never exceeds its container's width.
13
+ * With several `lines`, the width of the full lines; the last line is
14
+ * shorter.
15
+ */
16
+ width?: string;
17
+ /** `block` only: any CSS height. Defaults to `--control-height`. */
18
+ height?: string;
19
+ /** `text` only: how many lines of text the bars stand for. Defaults to 1. */
20
+ lines?: number;
21
+ };
22
+ declare const Skeleton: import("svelte").Component<SkeletonProps, {}, "">;
23
+ type Skeleton = ReturnType<typeof Skeleton>;
24
+ export default Skeleton;