@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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +346 -0
- package/dist/components/AppShell.svelte +698 -0
- package/dist/components/AppShell.svelte.d.ts +77 -0
- package/dist/components/Button.svelte +174 -0
- package/dist/components/Button.svelte.d.ts +20 -0
- package/dist/components/Checkbox.svelte +198 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/CheckboxGroup.svelte +99 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
- package/dist/components/Combobox.svelte +824 -0
- package/dist/components/Combobox.svelte.d.ts +63 -0
- package/dist/components/Dialog.svelte +356 -0
- package/dist/components/Dialog.svelte.d.ts +43 -0
- package/dist/components/Disclosure.svelte +201 -0
- package/dist/components/Disclosure.svelte.d.ts +27 -0
- package/dist/components/EmptyState.svelte +88 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/ErrorState.svelte +135 -0
- package/dist/components/ErrorState.svelte.d.ts +26 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/LoadRegion.svelte +219 -0
- package/dist/components/LoadRegion.svelte.d.ts +27 -0
- package/dist/components/MediaFrame.svelte +110 -0
- package/dist/components/MediaFrame.svelte.d.ts +20 -0
- package/dist/components/MediaImage.svelte +256 -0
- package/dist/components/MediaImage.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +258 -0
- package/dist/components/Menu.svelte.d.ts +36 -0
- package/dist/components/MenuCheckboxItem.svelte +119 -0
- package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
- package/dist/components/MenuGroup.svelte +50 -0
- package/dist/components/MenuGroup.svelte.d.ts +15 -0
- package/dist/components/MenuItem.svelte +94 -0
- package/dist/components/MenuItem.svelte.d.ts +19 -0
- package/dist/components/MenuRadioGroup.svelte +62 -0
- package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/components/MenuRadioItem.svelte +110 -0
- package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
- package/dist/components/MenuSeparator.svelte +28 -0
- package/dist/components/MenuSeparator.svelte.d.ts +6 -0
- package/dist/components/NativeSelect.svelte +119 -0
- package/dist/components/NativeSelect.svelte.d.ts +46 -0
- package/dist/components/Pagination.svelte +132 -0
- package/dist/components/Pagination.svelte.d.ts +18 -0
- package/dist/components/Popover.svelte +222 -0
- package/dist/components/Popover.svelte.d.ts +35 -0
- package/dist/components/Progress.svelte +157 -0
- package/dist/components/Progress.svelte.d.ts +34 -0
- package/dist/components/Select.svelte +405 -0
- package/dist/components/Select.svelte.d.ts +42 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +117 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +253 -0
- package/dist/components/Stat.svelte.d.ts +46 -0
- package/dist/components/Steps.svelte +189 -0
- package/dist/components/Steps.svelte.d.ts +31 -0
- package/dist/components/Switch.svelte +222 -0
- package/dist/components/Switch.svelte.d.ts +18 -0
- package/dist/components/Table.svelte +504 -0
- package/dist/components/Table.svelte.d.ts +92 -0
- package/dist/components/Tabs.svelte +246 -0
- package/dist/components/Tabs.svelte.d.ts +44 -0
- package/dist/components/Tag.svelte +40 -0
- package/dist/components/Tag.svelte.d.ts +15 -0
- package/dist/components/TextField.svelte +256 -0
- package/dist/components/TextField.svelte.d.ts +32 -0
- package/dist/components/Timeline.svelte +156 -0
- package/dist/components/Timeline.svelte.d.ts +30 -0
- package/dist/components/Toaster.svelte +411 -0
- package/dist/components/Toaster.svelte.d.ts +11 -0
- package/dist/components/Tooltip.svelte +200 -0
- package/dist/components/Tooltip.svelte.d.ts +38 -0
- package/dist/components/TooltipGroup.svelte +46 -0
- package/dist/components/TooltipGroup.svelte.d.ts +29 -0
- package/dist/components/internal/Scrim.svelte +52 -0
- package/dist/components/internal/Scrim.svelte.d.ts +15 -0
- package/dist/components/internal/Sheet.svelte +263 -0
- package/dist/components/internal/Sheet.svelte.d.ts +36 -0
- package/dist/components/internal/fade-in.d.ts +10 -0
- package/dist/components/internal/fade-in.js +20 -0
- package/dist/components/internal/resolve.d.ts +8 -0
- package/dist/components/internal/resolve.js +247 -0
- package/dist/components/internal/swipe-dismiss.d.ts +47 -0
- package/dist/components/internal/swipe-dismiss.js +220 -0
- package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
- package/dist/components/internal/visual-viewport.svelte.js +79 -0
- package/dist/components/toast.svelte.d.ts +57 -0
- package/dist/components/toast.svelte.js +84 -0
- package/dist/fonts/OFL-geist-pixel.txt +92 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/fonts/geist-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-pixel-square.woff2 +0 -0
- package/dist/fonts.css +71 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +39 -0
- package/dist/motion/Motion.svelte +15 -0
- package/dist/motion/Motion.svelte.d.ts +9 -0
- package/dist/motion/context.d.ts +18 -0
- package/dist/motion/context.js +26 -0
- package/dist/motion/index.d.ts +3 -0
- package/dist/motion/index.js +3 -0
- package/dist/motion/transitions.d.ts +31 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/tokens.css +279 -0
- 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;
|