@mystravil/hardpoint 0.0.0-stage → 0.1.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 (112) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +346 -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 +198 -0
  10. package/dist/components/Checkbox.svelte.d.ts +16 -0
  11. package/dist/components/CheckboxGroup.svelte +99 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
  13. package/dist/components/Combobox.svelte +824 -0
  14. package/dist/components/Combobox.svelte.d.ts +63 -0
  15. package/dist/components/Dialog.svelte +356 -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/KeyValueList.svelte +114 -0
  24. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  25. package/dist/components/LoadRegion.svelte +219 -0
  26. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  27. package/dist/components/MediaFrame.svelte +110 -0
  28. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  29. package/dist/components/MediaImage.svelte +256 -0
  30. package/dist/components/MediaImage.svelte.d.ts +16 -0
  31. package/dist/components/Menu.svelte +258 -0
  32. package/dist/components/Menu.svelte.d.ts +36 -0
  33. package/dist/components/MenuCheckboxItem.svelte +119 -0
  34. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  35. package/dist/components/MenuGroup.svelte +50 -0
  36. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  37. package/dist/components/MenuItem.svelte +94 -0
  38. package/dist/components/MenuItem.svelte.d.ts +19 -0
  39. package/dist/components/MenuRadioGroup.svelte +62 -0
  40. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  41. package/dist/components/MenuRadioItem.svelte +110 -0
  42. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  43. package/dist/components/MenuSeparator.svelte +28 -0
  44. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  45. package/dist/components/NativeSelect.svelte +119 -0
  46. package/dist/components/NativeSelect.svelte.d.ts +46 -0
  47. package/dist/components/Pagination.svelte +132 -0
  48. package/dist/components/Pagination.svelte.d.ts +18 -0
  49. package/dist/components/Popover.svelte +222 -0
  50. package/dist/components/Popover.svelte.d.ts +35 -0
  51. package/dist/components/Progress.svelte +157 -0
  52. package/dist/components/Progress.svelte.d.ts +34 -0
  53. package/dist/components/Select.svelte +405 -0
  54. package/dist/components/Select.svelte.d.ts +42 -0
  55. package/dist/components/Skeleton.svelte +149 -0
  56. package/dist/components/Skeleton.svelte.d.ts +24 -0
  57. package/dist/components/Spinner.svelte +117 -0
  58. package/dist/components/Spinner.svelte.d.ts +13 -0
  59. package/dist/components/Stat.svelte +253 -0
  60. package/dist/components/Stat.svelte.d.ts +46 -0
  61. package/dist/components/Steps.svelte +189 -0
  62. package/dist/components/Steps.svelte.d.ts +31 -0
  63. package/dist/components/Switch.svelte +222 -0
  64. package/dist/components/Switch.svelte.d.ts +18 -0
  65. package/dist/components/Table.svelte +504 -0
  66. package/dist/components/Table.svelte.d.ts +92 -0
  67. package/dist/components/Tabs.svelte +246 -0
  68. package/dist/components/Tabs.svelte.d.ts +44 -0
  69. package/dist/components/Tag.svelte +40 -0
  70. package/dist/components/Tag.svelte.d.ts +15 -0
  71. package/dist/components/TextField.svelte +256 -0
  72. package/dist/components/TextField.svelte.d.ts +32 -0
  73. package/dist/components/Timeline.svelte +156 -0
  74. package/dist/components/Timeline.svelte.d.ts +30 -0
  75. package/dist/components/Toaster.svelte +411 -0
  76. package/dist/components/Toaster.svelte.d.ts +11 -0
  77. package/dist/components/Tooltip.svelte +200 -0
  78. package/dist/components/Tooltip.svelte.d.ts +38 -0
  79. package/dist/components/TooltipGroup.svelte +46 -0
  80. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  81. package/dist/components/internal/Scrim.svelte +52 -0
  82. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  83. package/dist/components/internal/Sheet.svelte +263 -0
  84. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  85. package/dist/components/internal/fade-in.d.ts +10 -0
  86. package/dist/components/internal/fade-in.js +20 -0
  87. package/dist/components/internal/resolve.d.ts +8 -0
  88. package/dist/components/internal/resolve.js +247 -0
  89. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  90. package/dist/components/internal/swipe-dismiss.js +220 -0
  91. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  92. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  93. package/dist/components/toast.svelte.d.ts +57 -0
  94. package/dist/components/toast.svelte.js +84 -0
  95. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  96. package/dist/fonts/OFL.txt +93 -0
  97. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  98. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  99. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  100. package/dist/fonts.css +71 -0
  101. package/dist/index.d.ts +39 -0
  102. package/dist/index.js +39 -0
  103. package/dist/motion/Motion.svelte +15 -0
  104. package/dist/motion/Motion.svelte.d.ts +9 -0
  105. package/dist/motion/context.d.ts +18 -0
  106. package/dist/motion/context.js +26 -0
  107. package/dist/motion/index.d.ts +3 -0
  108. package/dist/motion/index.js +3 -0
  109. package/dist/motion/transitions.d.ts +31 -0
  110. package/dist/motion/transitions.js +112 -0
  111. package/dist/tokens.css +279 -0
  112. package/package.json +76 -6
@@ -0,0 +1,405 @@
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>
@@ -0,0 +1,42 @@
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
+ disabled?: boolean;
10
+ };
11
+ export type SelectItemSnippetProps = {
12
+ item: SelectItem;
13
+ selected: boolean;
14
+ highlighted: boolean;
15
+ };
16
+ export type SelectLabelBeside = boolean | 'medium' | 'expanded' | 'large';
17
+ /** Attributes and handlers not listed here go to the trigger button. */
18
+ export type SelectProps = Omit<BitsSelect.TriggerProps, 'child' | 'children' | 'value' | 'name' | 'disabled' | 'type' | 'ref'> & {
19
+ items: SelectItem[];
20
+ /** The chosen item's `value`; `''` for none. */
21
+ value?: string;
22
+ open?: boolean;
23
+ /** The caps field label above the trigger, and the sheet's title on compact widths. */
24
+ label: string;
25
+ /** Shown in `--muted` while nothing is chosen. Never the only label. */
26
+ placeholder?: string;
27
+ disabled?: boolean;
28
+ /** Submits the value with a form, through a hidden input. */
29
+ name?: string;
30
+ /**
31
+ * The label beside the trigger instead of above it: always (`true`), or
32
+ * from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
33
+ */
34
+ labelBeside?: SelectLabelBeside;
35
+ /** The trigger button. */
36
+ ref?: HTMLElement | null;
37
+ /** Renders an option's content in place of its label and description. */
38
+ item?: Snippet<[SelectItemSnippetProps]>;
39
+ };
40
+ declare const Select: import("svelte").Component<SelectProps, {}, "value" | "ref" | "open">;
41
+ type Select = ReturnType<typeof Select>;
42
+ 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;