@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.
- package/CHANGELOG.md +42 -18
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -346
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +174 -174
- package/dist/components/Checkbox.svelte +235 -198
- package/dist/components/Checkbox.svelte.d.ts +9 -2
- package/dist/components/CheckboxGroup.svelte +207 -99
- package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
- package/dist/components/Combobox.svelte +901 -824
- package/dist/components/Combobox.svelte.d.ts +10 -1
- package/dist/components/Dialog.svelte +359 -356
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +258 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +18 -2
- package/dist/components/NativeSelect.svelte.d.ts +6 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -405
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -117
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -256
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/fonts.css +71 -71
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/tokens.css +283 -279
- package/package.json +81 -76
|
@@ -6,6 +6,12 @@ export type SelectItem = {
|
|
|
6
6
|
label: string;
|
|
7
7
|
/** A second line in `--muted`. */
|
|
8
8
|
description?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The heading the option sits under, as written. Options with the same
|
|
11
|
+
* group share one heading, in the order each group first appears;
|
|
12
|
+
* options without one come first.
|
|
13
|
+
*/
|
|
14
|
+
group?: string;
|
|
9
15
|
disabled?: boolean;
|
|
10
16
|
};
|
|
11
17
|
export type SelectItemSnippetProps = {
|
|
@@ -1,149 +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>
|
|
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>
|
|
@@ -1,117 +1,118 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
-
|
|
4
|
-
export type SpinnerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
|
|
5
|
-
/**
|
|
6
|
-
* What the app is waiting for, such as `Loading projects`. Shown beside
|
|
7
|
-
* the glyph and announced politely once the spinner appears.
|
|
8
|
-
*/
|
|
9
|
-
label: string;
|
|
10
|
-
/** The root element. */
|
|
11
|
-
ref?: HTMLSpanElement | null;
|
|
12
|
-
};
|
|
13
|
-
</script>
|
|
14
|
-
|
|
15
|
-
<script lang="ts">
|
|
16
|
-
import { duration } from '../motion/transitions.js';
|
|
17
|
-
|
|
18
|
-
// The reel's line spinner (da3-10): bar, slash, dash, backslash.
|
|
19
|
-
const glyphs = ['|', '/', '-', '\\'];
|
|
20
|
-
|
|
21
|
-
let { label, class: className, ref = $bindable(null), ...rest }: SpinnerProps = $props();
|
|
22
|
-
|
|
23
|
-
let shown = $state(false);
|
|
24
|
-
let step = $state(0);
|
|
25
|
-
|
|
26
|
-
// A timer, not CSS keyframes (keyframes are for expressive entrances
|
|
27
|
-
// only). It reads both durations from the element, so an app can retune
|
|
28
|
-
// them, also per spinner (
|
|
29
|
-
// stepping under reduced motion:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
</span>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
font-
|
|
92
|
-
font-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type SpinnerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
|
|
5
|
+
/**
|
|
6
|
+
* What the app is waiting for, such as `Loading projects`. Shown beside
|
|
7
|
+
* the glyph and announced politely once the spinner appears.
|
|
8
|
+
*/
|
|
9
|
+
label: string;
|
|
10
|
+
/** The root element. */
|
|
11
|
+
ref?: HTMLSpanElement | null;
|
|
12
|
+
};
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { duration } from '../motion/transitions.js';
|
|
17
|
+
|
|
18
|
+
// The reel's line spinner (da3-10): bar, slash, dash, backslash.
|
|
19
|
+
const glyphs = ['|', '/', '-', '\\'];
|
|
20
|
+
|
|
21
|
+
let { label, class: className, ref = $bindable(null), ...rest }: SpinnerProps = $props();
|
|
22
|
+
|
|
23
|
+
let shown = $state(false);
|
|
24
|
+
let step = $state(0);
|
|
25
|
+
|
|
26
|
+
// A timer, not CSS keyframes (keyframes are for expressive entrances
|
|
27
|
+
// only). It reads both durations from the element, so an app can retune
|
|
28
|
+
// them, also per spinner (a class on the Spinner whose rule sets
|
|
29
|
+
// `--spinner-delay: 0ms`). It keeps stepping under reduced motion:
|
|
30
|
+
// spinners and progress stay.
|
|
31
|
+
function run(node: HTMLElement) {
|
|
32
|
+
const delay = duration(node, '--spinner-delay');
|
|
33
|
+
const every = duration(node, '--spinner-step');
|
|
34
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
const timeout = setTimeout(() => {
|
|
36
|
+
shown = true;
|
|
37
|
+
if (every > 0) interval = setInterval(() => (step = (step + 1) % glyphs.length), every);
|
|
38
|
+
}, delay);
|
|
39
|
+
return () => {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
clearInterval(interval);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<!--
|
|
47
|
+
A stepped spinner (BRIEF, visual rule 7): `|`, `/`, `-`, `\` in Geist
|
|
48
|
+
Pixel Square at 26.32px, one state per `--spinner-step` (167ms). Nothing
|
|
49
|
+
shows until `--spinner-delay` (300ms) has passed, so fast work never
|
|
50
|
+
flashes it; the row keeps its height meanwhile. The glyph is aria-hidden
|
|
51
|
+
and sits outside the live region; the label is a `role="status"` region
|
|
52
|
+
that is empty until the spinner appears, so screen readers announce it
|
|
53
|
+
once. Extra attributes and `class` go to the root `span.hp-spinner`, which
|
|
54
|
+
carries `data-state` waiting | visible. Place it start-aligned: centring
|
|
55
|
+
it in a wider box can put the glyph between pixels.
|
|
56
|
+
-->
|
|
57
|
+
<span
|
|
58
|
+
{...rest}
|
|
59
|
+
bind:this={ref}
|
|
60
|
+
class={['hp-spinner', className]}
|
|
61
|
+
data-state={shown ? 'visible' : 'waiting'}
|
|
62
|
+
{@attach run}
|
|
63
|
+
>
|
|
64
|
+
<span class="hp-spinner-glyph" aria-hidden="true" data-step={shown ? step : undefined}
|
|
65
|
+
>{shown ? glyphs[step] : ''}</span
|
|
66
|
+
>
|
|
67
|
+
<span class="hp-spinner-label" role="status">{shown ? label : ''}</span>
|
|
68
|
+
</span>
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
/* One row the height of the pixel line, so the spinner does not move
|
|
72
|
+
anything when it appears. The label line sits an even number of pixels
|
|
73
|
+
from the top, so the glyph's top edge stays on a whole pixel. */
|
|
74
|
+
.hp-spinner {
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: var(--space-3);
|
|
78
|
+
min-height: var(--type-pixel-leading);
|
|
79
|
+
vertical-align: top;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Geist Pixel is proportional: at 26.32px (a 1px module) the four glyphs
|
|
83
|
+
advance 6, 11, 10 and 11px. A fixed 12px box keeps the label still
|
|
84
|
+
while the glyph changes. Each glyph starts at the box's edge plus a
|
|
85
|
+
whole-pixel inset that centres it as nearly as whole pixels allow,
|
|
86
|
+
because centring in odd free space would put its edges on half pixels
|
|
87
|
+
(BRIEF, Crisp pixel text). The insets are font metrics. */
|
|
88
|
+
.hp-spinner-glyph {
|
|
89
|
+
flex: none;
|
|
90
|
+
width: 12px;
|
|
91
|
+
font-family: var(--pixel);
|
|
92
|
+
font-size: var(--type-pixel);
|
|
93
|
+
font-weight: 400;
|
|
94
|
+
line-height: var(--type-pixel-leading);
|
|
95
|
+
letter-spacing: 0;
|
|
96
|
+
text-align: start;
|
|
97
|
+
color: var(--text);
|
|
98
|
+
}
|
|
99
|
+
.hp-spinner-glyph[data-step='0'] {
|
|
100
|
+
padding-inline-start: 3px;
|
|
101
|
+
}
|
|
102
|
+
.hp-spinner-glyph[data-step='2'] {
|
|
103
|
+
padding-inline-start: 1px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* 16px on touch, the dense 14px on fine pointers. */
|
|
107
|
+
.hp-spinner-label {
|
|
108
|
+
color: var(--muted);
|
|
109
|
+
font-size: var(--type-body);
|
|
110
|
+
line-height: 24px;
|
|
111
|
+
}
|
|
112
|
+
@media (pointer: fine) {
|
|
113
|
+
.hp-spinner-label {
|
|
114
|
+
font-size: var(--type-dense);
|
|
115
|
+
line-height: 20px;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</style>
|