@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +625 -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 +235 -0
- package/dist/components/Checkbox.svelte.d.ts +23 -0
- package/dist/components/CheckboxGroup.svelte +207 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
- package/dist/components/Combobox.svelte +901 -0
- package/dist/components/Combobox.svelte.d.ts +72 -0
- package/dist/components/Dialog.svelte +359 -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/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -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/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +135 -0
- package/dist/components/NativeSelect.svelte.d.ts +52 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -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/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -0
- package/dist/components/Select.svelte.d.ts +48 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +118 -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/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -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/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -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 +45 -0
- package/dist/index.js +45 -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 +283 -0
- package/package.json +76 -6
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { Progress as BitsProgress } from 'bits-ui';
|
|
3
|
+
|
|
4
|
+
export type ProgressProps = Omit<BitsProgress.RootProps, 'value' | 'child' | 'children'> & {
|
|
5
|
+
/**
|
|
6
|
+
* How far the work has got, from `min` to `max`. Pass the real figure
|
|
7
|
+
* each time it changes: the bar never moves on its own. Work whose
|
|
8
|
+
* progress is unknown shows a `Spinner` instead.
|
|
9
|
+
*/
|
|
10
|
+
value: number;
|
|
11
|
+
/** The value at which the work is done. Default 100. */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** The value at which the work starts. Default 0. */
|
|
14
|
+
min?: number;
|
|
15
|
+
/**
|
|
16
|
+
* How many segments the bar has (default 20, at least 2). A segment
|
|
17
|
+
* lights once the value has passed its whole share, so the bar never
|
|
18
|
+
* shows more than has been done.
|
|
19
|
+
*/
|
|
20
|
+
segments?: number;
|
|
21
|
+
/**
|
|
22
|
+
* A line above the bar saying what is in progress, as written (never
|
|
23
|
+
* caps: it may hold a file or project name). It names the progress
|
|
24
|
+
* bar; without it, pass `aria-label` or `aria-labelledby`.
|
|
25
|
+
*/
|
|
26
|
+
label?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The value in words, such as `3 of 12 files`. Shown at the end of the
|
|
29
|
+
* label line and read as `aria-valuetext`. Without it both give the
|
|
30
|
+
* percentage done, rounded down.
|
|
31
|
+
*/
|
|
32
|
+
valueText?: string;
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
let {
|
|
38
|
+
value,
|
|
39
|
+
max = 100,
|
|
40
|
+
min = 0,
|
|
41
|
+
segments = 20,
|
|
42
|
+
label,
|
|
43
|
+
valueText,
|
|
44
|
+
id,
|
|
45
|
+
class: className,
|
|
46
|
+
ref = $bindable(null),
|
|
47
|
+
...rest
|
|
48
|
+
}: ProgressProps = $props();
|
|
49
|
+
|
|
50
|
+
const uid = $props.id();
|
|
51
|
+
const labelId = `${uid}-label`;
|
|
52
|
+
|
|
53
|
+
// The value inside its range, so the bar and aria-valuenow agree. An
|
|
54
|
+
// empty range counts as done.
|
|
55
|
+
const current = $derived(Math.min(max, Math.max(min, value)));
|
|
56
|
+
const span = $derived(max - min);
|
|
57
|
+
const count = $derived(Math.max(2, Math.floor(segments)));
|
|
58
|
+
// Round down: a segment lights only when its whole share is done.
|
|
59
|
+
// Multiply before dividing and clear float noise before rounding down,
|
|
60
|
+
// so 58 of 100 and 0.58 of 1 are both 58% and not 57.99…
|
|
61
|
+
const share = (n: number) => Math.floor(Number((((current - min) * n) / span).toPrecision(12)));
|
|
62
|
+
const lit = $derived(current >= max || span <= 0 ? count : share(count));
|
|
63
|
+
const percent = $derived(span > 0 ? share(100) : 100);
|
|
64
|
+
const shown = $derived(valueText ?? `${percent}%`);
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<!--
|
|
68
|
+
Bits UI Progress, rendered through its `child` snippet so the element is
|
|
69
|
+
this component's markup and its styles stay scoped. The root is the
|
|
70
|
+
progressbar (`role`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`,
|
|
71
|
+
`data-state` loading | loaded); `id`, extra attributes and `class` go to
|
|
72
|
+
it. Its children are presentational to assistive technology, so the label
|
|
73
|
+
line names it through `aria-labelledby`, and the value as shown (the
|
|
74
|
+
`valueText` or the floored percentage) reaches it as `aria-valuetext`.
|
|
75
|
+
A segment that lights or goes out fades its colour over --dur-segment.
|
|
76
|
+
-->
|
|
77
|
+
<BitsProgress.Root
|
|
78
|
+
bind:ref
|
|
79
|
+
value={current}
|
|
80
|
+
{max}
|
|
81
|
+
{min}
|
|
82
|
+
aria-labelledby={label ? labelId : undefined}
|
|
83
|
+
aria-valuetext={shown}
|
|
84
|
+
{...rest}
|
|
85
|
+
>
|
|
86
|
+
{#snippet child({ props })}
|
|
87
|
+
<div {...props} {id} class={['hp-progress', className]} data-lit={lit} data-segments={count}>
|
|
88
|
+
{#if label}
|
|
89
|
+
<div class="hp-progress-line">
|
|
90
|
+
<span id={labelId} class="hp-progress-label">{label}</span>
|
|
91
|
+
<span class="hp-progress-value">{shown}</span>
|
|
92
|
+
</div>
|
|
93
|
+
{/if}
|
|
94
|
+
<div class="hp-progress-bar" style:--segments={count}>
|
|
95
|
+
{#each { length: count }, i}
|
|
96
|
+
<span class="hp-progress-segment" data-lit={i < lit || undefined}></span>
|
|
97
|
+
{/each}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{/snippet}
|
|
101
|
+
</BitsProgress.Root>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
/* The bar fills the width it is given; the app sets a narrower one. */
|
|
105
|
+
.hp-progress {
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: var(--space-2);
|
|
108
|
+
min-width: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* 16px on touch, the dense 14px on fine pointers. The value keeps the
|
|
112
|
+
end of the line; a long label wraps rather than widen the page. */
|
|
113
|
+
.hp-progress-line {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: baseline;
|
|
116
|
+
justify-content: space-between;
|
|
117
|
+
gap: var(--space-4);
|
|
118
|
+
font-size: var(--type-body);
|
|
119
|
+
line-height: 24px;
|
|
120
|
+
}
|
|
121
|
+
@media (pointer: fine) {
|
|
122
|
+
.hp-progress-line {
|
|
123
|
+
font-size: var(--type-dense);
|
|
124
|
+
line-height: 20px;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
.hp-progress-label {
|
|
128
|
+
min-width: 0;
|
|
129
|
+
overflow-wrap: anywhere;
|
|
130
|
+
}
|
|
131
|
+
.hp-progress-value {
|
|
132
|
+
flex: none;
|
|
133
|
+
color: var(--muted);
|
|
134
|
+
font-variant-numeric: tabular-nums;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Separate segments (BRIEF, visual rule 7; da3-38): titanium once done,
|
|
138
|
+
the 3:1 control boundary while still to do, so the unlit share reads as
|
|
139
|
+
part of the bar. Square, 4px apart, one row. */
|
|
140
|
+
.hp-progress-bar {
|
|
141
|
+
display: grid;
|
|
142
|
+
grid-template-columns: repeat(var(--segments), minmax(0, 1fr));
|
|
143
|
+
gap: var(--space-1);
|
|
144
|
+
height: 12px;
|
|
145
|
+
}
|
|
146
|
+
/* A segment that lights or goes out fades its colour over --dur-segment
|
|
147
|
+
(BRIEF, short motion). A CSS transition never runs on an element's
|
|
148
|
+
first style, so a bar shows its value at once when it renders. 0ms is
|
|
149
|
+
a snap. Colour only, so reduced motion keeps it. */
|
|
150
|
+
.hp-progress-segment {
|
|
151
|
+
background: var(--border-control);
|
|
152
|
+
transition: background-color var(--dur-segment) var(--settle);
|
|
153
|
+
}
|
|
154
|
+
.hp-progress-segment[data-lit] {
|
|
155
|
+
background: var(--accent);
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Progress as BitsProgress } from 'bits-ui';
|
|
2
|
+
export type ProgressProps = Omit<BitsProgress.RootProps, 'value' | 'child' | 'children'> & {
|
|
3
|
+
/**
|
|
4
|
+
* How far the work has got, from `min` to `max`. Pass the real figure
|
|
5
|
+
* each time it changes: the bar never moves on its own. Work whose
|
|
6
|
+
* progress is unknown shows a `Spinner` instead.
|
|
7
|
+
*/
|
|
8
|
+
value: number;
|
|
9
|
+
/** The value at which the work is done. Default 100. */
|
|
10
|
+
max?: number;
|
|
11
|
+
/** The value at which the work starts. Default 0. */
|
|
12
|
+
min?: number;
|
|
13
|
+
/**
|
|
14
|
+
* How many segments the bar has (default 20, at least 2). A segment
|
|
15
|
+
* lights once the value has passed its whole share, so the bar never
|
|
16
|
+
* shows more than has been done.
|
|
17
|
+
*/
|
|
18
|
+
segments?: number;
|
|
19
|
+
/**
|
|
20
|
+
* A line above the bar saying what is in progress, as written (never
|
|
21
|
+
* caps: it may hold a file or project name). It names the progress
|
|
22
|
+
* bar; without it, pass `aria-label` or `aria-labelledby`.
|
|
23
|
+
*/
|
|
24
|
+
label?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The value in words, such as `3 of 12 files`. Shown at the end of the
|
|
27
|
+
* label line and read as `aria-valuetext`. Without it both give the
|
|
28
|
+
* percentage done, rounded down.
|
|
29
|
+
*/
|
|
30
|
+
valueText?: string;
|
|
31
|
+
};
|
|
32
|
+
declare const Progress: import("svelte").Component<ProgressProps, {}, "ref">;
|
|
33
|
+
type Progress = ReturnType<typeof Progress>;
|
|
34
|
+
export default Progress;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { RadioGroup as BitsRadioGroup } from 'bits-ui';
|
|
4
|
+
|
|
5
|
+
export type RadioGroupItem = {
|
|
6
|
+
/** Identifies the choice; the bound `value` holds the chosen one. */
|
|
7
|
+
value: string;
|
|
8
|
+
/** The text beside the box, as written (never caps). */
|
|
9
|
+
label: string;
|
|
10
|
+
/** A second line in `--muted` under the label, read out as the choice's description. */
|
|
11
|
+
hint?: string;
|
|
12
|
+
/**
|
|
13
|
+
* A Tag after the label, such as "Recommended": a short fixed string the
|
|
14
|
+
* app defines, set in caps. It is part of the choice's accessible name.
|
|
15
|
+
*/
|
|
16
|
+
tag?: string;
|
|
17
|
+
/** Shown in muted ink; it cannot be chosen and arrow keys skip it. */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The group's name: a fixed caps `label`, or a `legend` set as written. Exactly one. */
|
|
22
|
+
type RadioGroupName =
|
|
23
|
+
| {
|
|
24
|
+
/** The caps label that names the group: a fixed string the app defines. */
|
|
25
|
+
label: string;
|
|
26
|
+
legend?: never;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
label?: never;
|
|
30
|
+
/**
|
|
31
|
+
* The group's name as content, set as written in 16px at weight 500:
|
|
32
|
+
* a question, or a name with a counter before it.
|
|
33
|
+
*/
|
|
34
|
+
legend: Snippet;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Attributes and handlers not listed here go to the group element. */
|
|
38
|
+
export type RadioGroupProps = Omit<BitsRadioGroup.RootProps, 'child' | 'children' | 'ref' | 'orientation'> &
|
|
39
|
+
RadioGroupName & {
|
|
40
|
+
/** The choices, stacked in order. */
|
|
41
|
+
items: RadioGroupItem[];
|
|
42
|
+
/** The chosen item's `value`. Bindable; `''` means none is chosen, and the app may set `''` to clear it. */
|
|
43
|
+
value?: string;
|
|
44
|
+
/** A line in `--muted` under the name, read out as the group's description. */
|
|
45
|
+
hint?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The error to show under the choices, with an icon, linked by
|
|
48
|
+
* `aria-describedby`; it sets `aria-invalid` on the group. Say how to
|
|
49
|
+
* fix the problem. The app validates; the group shows what it is given.
|
|
50
|
+
*/
|
|
51
|
+
error?: string;
|
|
52
|
+
/** The group element (`role="radiogroup"`). */
|
|
53
|
+
ref?: HTMLElement | null;
|
|
54
|
+
};
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<script lang="ts">
|
|
58
|
+
import { untrack } from 'svelte';
|
|
59
|
+
import Tag from './Tag.svelte';
|
|
60
|
+
import { fadeIn } from './internal/fade-in.js';
|
|
61
|
+
|
|
62
|
+
const uid = $props.id();
|
|
63
|
+
|
|
64
|
+
let {
|
|
65
|
+
value = $bindable(''),
|
|
66
|
+
label,
|
|
67
|
+
legend,
|
|
68
|
+
items,
|
|
69
|
+
hint,
|
|
70
|
+
error,
|
|
71
|
+
disabled = false,
|
|
72
|
+
id = uid,
|
|
73
|
+
ref = $bindable(null),
|
|
74
|
+
class: className,
|
|
75
|
+
'aria-describedby': describedBy,
|
|
76
|
+
'aria-invalid': ariaInvalid,
|
|
77
|
+
onValueChange,
|
|
78
|
+
...rest
|
|
79
|
+
}: RadioGroupProps = $props();
|
|
80
|
+
|
|
81
|
+
const labelId = $derived(`${id}-label`);
|
|
82
|
+
const hintId = $derived(`${id}-hint`);
|
|
83
|
+
const errorId = $derived(`${id}-error`);
|
|
84
|
+
// The error first, so a screen reader reads it before the hint.
|
|
85
|
+
const describedByIds = $derived(
|
|
86
|
+
[error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
|
|
87
|
+
undefined
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// ---- The tab stop -------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
// One radio is in the tab order. While focus is in the group it is the
|
|
93
|
+
// focused radio, so Tab and Shift+Tab leave the group from there. From
|
|
94
|
+
// outside, Tab enters on the chosen radio, or on the first one that can
|
|
95
|
+
// be chosen when none is (an app that sets `value` to '' resets it).
|
|
96
|
+
// Bits UI keeps the last chosen radio as its tab stop after a clear, so
|
|
97
|
+
// the group sets tabindex itself; Bits UI still moves focus on arrow keys.
|
|
98
|
+
let focused = $state<string | null>(null);
|
|
99
|
+
const tabStop = $derived.by(() => {
|
|
100
|
+
if (focused !== null) return focused;
|
|
101
|
+
const open = items.filter((item) => !disabled && !item.disabled);
|
|
102
|
+
return (open.find((item) => item.value === value) ?? open[0])?.value ?? null;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function focusIn(event: FocusEvent) {
|
|
106
|
+
const radio = event.target instanceof Element ? event.target.closest('[role="radio"]') : null;
|
|
107
|
+
focused = radio instanceof HTMLElement ? (radio.dataset.value ?? null) : null;
|
|
108
|
+
}
|
|
109
|
+
function focusOut(event: FocusEvent) {
|
|
110
|
+
const next = event.relatedTarget;
|
|
111
|
+
if (!(next instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(next))) {
|
|
112
|
+
focused = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Short motion: the mark (BRIEF, --dur-mark) -------------------------
|
|
117
|
+
|
|
118
|
+
// The mark a pointer or a touch chooses fades in from 0 over --dur-mark;
|
|
119
|
+
// the mark it takes away goes at once. A choice made with a key (arrows,
|
|
120
|
+
// Home, End, Space, Enter) or a `value` the app sets cuts, so holding an
|
|
121
|
+
// arrow key through the choices never starts a fade (the frequency
|
|
122
|
+
// check). As in Tabs: the choices keep the last input they saw, from
|
|
123
|
+
// pointerdown and keydown in the capture phase, before Bits UI acts on
|
|
124
|
+
// them; Bits UI calls onValueChange only for a choice the user makes, so
|
|
125
|
+
// the change notes there whether a pointer made it, and the effect below
|
|
126
|
+
// fades the new mark once it shows. A new choice cancels a fade that
|
|
127
|
+
// still runs. 0ms is a cut.
|
|
128
|
+
let input: 'pointer' | 'key' | null = null;
|
|
129
|
+
let fadeTo: string | null = null;
|
|
130
|
+
let fade: Animation | null = null;
|
|
131
|
+
|
|
132
|
+
function changed(next: string) {
|
|
133
|
+
fadeTo = input === 'pointer' ? next : null;
|
|
134
|
+
onValueChange?.(next);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
$effect(() => {
|
|
138
|
+
const chosen = value;
|
|
139
|
+
untrack(() => {
|
|
140
|
+
fade?.cancel();
|
|
141
|
+
fade = null;
|
|
142
|
+
const pointed = fadeTo !== null && fadeTo === chosen;
|
|
143
|
+
fadeTo = null;
|
|
144
|
+
if (!pointed || !ref) return;
|
|
145
|
+
const radio = Array.from(ref.querySelectorAll<HTMLElement>('[role="radio"]')).find(
|
|
146
|
+
(el) => el.dataset.value === chosen
|
|
147
|
+
);
|
|
148
|
+
const mark = radio?.querySelector('.mark');
|
|
149
|
+
if (mark) fade = fadeIn(mark, '--dur-mark');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---- Short motion: the error (BRIEF, --dur-message) ---------------------
|
|
154
|
+
|
|
155
|
+
// As in TextField: an error that appears after the first render fades in
|
|
156
|
+
// from 0 over --dur-message; one that clears goes at once; a group that
|
|
157
|
+
// renders with an error shows it at once.
|
|
158
|
+
let errorShown = untrack(() => !!error);
|
|
159
|
+
$effect(() => {
|
|
160
|
+
const shown = !!error;
|
|
161
|
+
untrack(() => {
|
|
162
|
+
const appeared = shown && !errorShown;
|
|
163
|
+
errorShown = shown;
|
|
164
|
+
const message = appeared ? ref?.querySelector(':scope > .error') : null;
|
|
165
|
+
if (message) fadeIn(message, '--dur-message');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<!--
|
|
171
|
+
One choice from a set read side by side: a Bits UI RadioGroup, a
|
|
172
|
+
role="radiogroup" element named by its caps `label` or its `legend`, with
|
|
173
|
+
one role="radio" button per item. Pressing anywhere on an item's row (the
|
|
174
|
+
box, the label, the tag, the hint) chooses it; arrow keys move the choice.
|
|
175
|
+
`disabled`, `required`, `name` and `loop` go to Bits UI. Attributes,
|
|
176
|
+
handlers and `class` go to the group element.
|
|
177
|
+
-->
|
|
178
|
+
<BitsRadioGroup.Root
|
|
179
|
+
bind:value
|
|
180
|
+
bind:ref
|
|
181
|
+
{id}
|
|
182
|
+
{disabled}
|
|
183
|
+
aria-labelledby={labelId}
|
|
184
|
+
aria-describedby={describedByIds}
|
|
185
|
+
aria-invalid={error ? true : ariaInvalid}
|
|
186
|
+
onValueChange={changed}
|
|
187
|
+
{...rest}
|
|
188
|
+
>
|
|
189
|
+
{#snippet child({ props })}
|
|
190
|
+
<div {...props} class={['hp-radio-group', className]} data-invalid={error ? '' : undefined}>
|
|
191
|
+
{#if legend}
|
|
192
|
+
<div class="legend" id={labelId}>{@render legend()}</div>
|
|
193
|
+
{:else}
|
|
194
|
+
<span class="hp-label group-label" id={labelId}>{label}</span>
|
|
195
|
+
{/if}
|
|
196
|
+
{#if hint}
|
|
197
|
+
<p class="hint hp-caption" id={hintId}>{hint}</p>
|
|
198
|
+
{/if}
|
|
199
|
+
<div
|
|
200
|
+
class="items"
|
|
201
|
+
onpointerdowncapture={() => (input = 'pointer')}
|
|
202
|
+
onkeydowncapture={() => (input = 'key')}
|
|
203
|
+
onfocusin={focusIn}
|
|
204
|
+
onfocusout={focusOut}
|
|
205
|
+
>
|
|
206
|
+
{#each items as item, index (item.value)}
|
|
207
|
+
{@const radioId = `${id}-item-${index}`}
|
|
208
|
+
<div class="item">
|
|
209
|
+
<span class="control">
|
|
210
|
+
<BitsRadioGroup.Item
|
|
211
|
+
id={radioId}
|
|
212
|
+
value={item.value}
|
|
213
|
+
disabled={item.disabled}
|
|
214
|
+
aria-describedby={item.hint ? `${radioId}-hint` : undefined}
|
|
215
|
+
>
|
|
216
|
+
{#snippet child({ props: radioProps })}
|
|
217
|
+
<button {...radioProps} class="box" tabindex={item.value === tabStop ? 0 : -1}>
|
|
218
|
+
<span class="mark" aria-hidden="true"></span>
|
|
219
|
+
</button>
|
|
220
|
+
{/snippet}
|
|
221
|
+
</BitsRadioGroup.Item>
|
|
222
|
+
</span>
|
|
223
|
+
<label class="hp-dense" class:tagged={!!item.tag} for={radioId}
|
|
224
|
+
>{item.label}{#if item.tag}{' '}<Tag as="span">{item.tag}</Tag>{/if}</label
|
|
225
|
+
>
|
|
226
|
+
{#if item.hint}
|
|
227
|
+
<p class="hint hp-caption" id="{radioId}-hint">{item.hint}</p>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
{/each}
|
|
231
|
+
</div>
|
|
232
|
+
{#if error}
|
|
233
|
+
<p class="error hp-caption" id={errorId}>
|
|
234
|
+
<span class="icon" aria-hidden="true">
|
|
235
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
|
|
236
|
+
<path d="M1.5 1.5h13v13h-13z" />
|
|
237
|
+
<path d="M8 4v4M8 10v2" stroke-width="2" />
|
|
238
|
+
</svg>
|
|
239
|
+
</span>
|
|
240
|
+
<span>{error}</span>
|
|
241
|
+
</p>
|
|
242
|
+
{/if}
|
|
243
|
+
</div>
|
|
244
|
+
{/snippet}
|
|
245
|
+
</BitsRadioGroup.Root>
|
|
246
|
+
|
|
247
|
+
<style>
|
|
248
|
+
/* The name, its hint, the choices 8px apart (as CheckboxGroup's boxes),
|
|
249
|
+
then the error. Rows keep their own height when a layout makes the
|
|
250
|
+
group taller. */
|
|
251
|
+
.hp-radio-group {
|
|
252
|
+
display: grid;
|
|
253
|
+
align-content: start;
|
|
254
|
+
gap: var(--space-1);
|
|
255
|
+
min-width: 0;
|
|
256
|
+
}
|
|
257
|
+
.group-label,
|
|
258
|
+
.hint {
|
|
259
|
+
color: var(--muted);
|
|
260
|
+
}
|
|
261
|
+
/* A name set as written: a question, or a name with a counter. */
|
|
262
|
+
.legend {
|
|
263
|
+
font-size: var(--type-body);
|
|
264
|
+
font-weight: 500;
|
|
265
|
+
line-height: 24px;
|
|
266
|
+
}
|
|
267
|
+
[data-disabled] > .legend {
|
|
268
|
+
color: var(--muted);
|
|
269
|
+
}
|
|
270
|
+
.items {
|
|
271
|
+
display: grid;
|
|
272
|
+
gap: var(--space-2);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ---- A choice: Checkbox's row ---------------------------------------- */
|
|
276
|
+
|
|
277
|
+
/* The box, then the label as written. The first line is one hit-target
|
|
278
|
+
band (24px fine, 44px coarse): the box sits centred in it and the
|
|
279
|
+
label's first line too. The hint runs under the label, aligned with
|
|
280
|
+
its text. The row is as wide as its content, and all of it is the
|
|
281
|
+
target: the label's ::after covers the row, so a press on the hint, the
|
|
282
|
+
tag or the space between them is a press on the label, which chooses
|
|
283
|
+
the radio. The box sits above that cover with its own hit area. */
|
|
284
|
+
.item {
|
|
285
|
+
/* The label's line height, as .hp-dense sets it. */
|
|
286
|
+
--_line: 24px;
|
|
287
|
+
position: relative;
|
|
288
|
+
display: grid;
|
|
289
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
290
|
+
column-gap: var(--space-3);
|
|
291
|
+
align-items: start;
|
|
292
|
+
width: fit-content;
|
|
293
|
+
max-width: 100%;
|
|
294
|
+
}
|
|
295
|
+
@media (pointer: fine) {
|
|
296
|
+
.item {
|
|
297
|
+
--_line: 20px;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.control {
|
|
302
|
+
position: relative;
|
|
303
|
+
z-index: 1;
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
height: var(--hit-target);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* Checkbox's box. Chosen, the edge turns titanium and an 8px titanium
|
|
310
|
+
square sits inside, as on a menu's radio item; the fill stays black,
|
|
311
|
+
because selection is never a fill. Focus is base.css's contour 4px
|
|
312
|
+
outside the box, as on Checkbox: the row has no frame to draw it on,
|
|
313
|
+
and the label and hint beside the box show which choice it is. */
|
|
314
|
+
.box {
|
|
315
|
+
position: relative;
|
|
316
|
+
display: grid;
|
|
317
|
+
place-items: center;
|
|
318
|
+
box-sizing: border-box;
|
|
319
|
+
width: 20px;
|
|
320
|
+
height: 20px;
|
|
321
|
+
margin: 0;
|
|
322
|
+
padding: 0;
|
|
323
|
+
border: 1px solid var(--border-control);
|
|
324
|
+
background: var(--canvas);
|
|
325
|
+
color: var(--accent);
|
|
326
|
+
cursor: pointer;
|
|
327
|
+
-webkit-tap-highlight-color: transparent;
|
|
328
|
+
}
|
|
329
|
+
/* The hit area: at least --hit-target square, centred on the box. */
|
|
330
|
+
.box::before {
|
|
331
|
+
content: '';
|
|
332
|
+
position: absolute;
|
|
333
|
+
top: 50%;
|
|
334
|
+
left: 50%;
|
|
335
|
+
width: max(100%, var(--hit-target));
|
|
336
|
+
height: max(100%, var(--hit-target));
|
|
337
|
+
translate: -50% -50%;
|
|
338
|
+
}
|
|
339
|
+
.box[data-state='checked'] {
|
|
340
|
+
border-color: var(--accent);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* The mark is always there, at opacity 0 until its radio is chosen, and
|
|
344
|
+
switches at once here; a pointer's choice fades it in (the script). */
|
|
345
|
+
.mark {
|
|
346
|
+
width: 8px;
|
|
347
|
+
height: 8px;
|
|
348
|
+
background: currentColor;
|
|
349
|
+
opacity: 0;
|
|
350
|
+
}
|
|
351
|
+
.box[data-state='checked'] .mark {
|
|
352
|
+
opacity: 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
label {
|
|
356
|
+
justify-self: start;
|
|
357
|
+
padding: calc((var(--hit-target) - var(--_line)) / 2) 0;
|
|
358
|
+
cursor: pointer;
|
|
359
|
+
-webkit-tap-highlight-color: transparent;
|
|
360
|
+
}
|
|
361
|
+
label::after {
|
|
362
|
+
content: '';
|
|
363
|
+
position: absolute;
|
|
364
|
+
inset: 0;
|
|
365
|
+
}
|
|
366
|
+
/* The tag sits after the label when both fit on one line; otherwise it
|
|
367
|
+
wraps under the label whole. */
|
|
368
|
+
.tagged {
|
|
369
|
+
display: flex;
|
|
370
|
+
flex-wrap: wrap;
|
|
371
|
+
align-items: center;
|
|
372
|
+
gap: var(--space-1) var(--space-2);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.item > .hint {
|
|
376
|
+
grid-column: 2;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ---- Hover: fine pointers only, over any part of the row -------------- */
|
|
380
|
+
|
|
381
|
+
/* Only the fill fades. The edge switches at once, on hover as on a
|
|
382
|
+
choice, so an arrow key that moves the choice away from a row under a
|
|
383
|
+
resting pointer never starts a fade (as on Tabs). */
|
|
384
|
+
@media (hover: hover) and (pointer: fine) {
|
|
385
|
+
.box {
|
|
386
|
+
transition: background-color var(--dur-hover) var(--settle);
|
|
387
|
+
}
|
|
388
|
+
.item:has(> label:hover, .box:hover) .box:not(:disabled) {
|
|
389
|
+
background-color: var(--surface-hover);
|
|
390
|
+
}
|
|
391
|
+
.item:has(> label:hover, .box:hover) .box[data-state='unchecked']:not(:disabled) {
|
|
392
|
+
border-color: var(--muted);
|
|
393
|
+
}
|
|
394
|
+
.item:has(> label:active, .box:active) .box:not(:disabled) {
|
|
395
|
+
transition-duration: var(--dur-press);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ---- Press: one step past hover, so it reads on a phone too ---------- */
|
|
400
|
+
|
|
401
|
+
.item:has(> label:active, .box:active) .box:not(:disabled) {
|
|
402
|
+
background-color: var(--surface-raised);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ---- Disabled: the quieter --border and muted ink, the tag too -------- */
|
|
406
|
+
|
|
407
|
+
.box:disabled {
|
|
408
|
+
border-color: var(--border);
|
|
409
|
+
color: var(--muted);
|
|
410
|
+
cursor: not-allowed;
|
|
411
|
+
}
|
|
412
|
+
.item:has(.box:disabled) label {
|
|
413
|
+
color: var(--muted);
|
|
414
|
+
cursor: not-allowed;
|
|
415
|
+
}
|
|
416
|
+
.item:has(.box:disabled) label > :global(.hp-tag) {
|
|
417
|
+
background: var(--muted);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ---- The error: TextField's ------------------------------------------- */
|
|
421
|
+
|
|
422
|
+
/* It starts 8px under the last row, clear of its focus contour. */
|
|
423
|
+
.error {
|
|
424
|
+
display: flex;
|
|
425
|
+
gap: var(--space-2);
|
|
426
|
+
margin-top: var(--space-1);
|
|
427
|
+
color: var(--danger);
|
|
428
|
+
}
|
|
429
|
+
/* One line box high, so the icon centres on the first line of the
|
|
430
|
+
message however it wraps. */
|
|
431
|
+
.icon {
|
|
432
|
+
display: flex;
|
|
433
|
+
flex: none;
|
|
434
|
+
align-items: center;
|
|
435
|
+
height: 1lh;
|
|
436
|
+
}
|
|
437
|
+
.icon svg {
|
|
438
|
+
display: block;
|
|
439
|
+
}
|
|
440
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { RadioGroup as BitsRadioGroup } from 'bits-ui';
|
|
3
|
+
export type RadioGroupItem = {
|
|
4
|
+
/** Identifies the choice; the bound `value` holds the chosen one. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** The text beside the box, as written (never caps). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** A second line in `--muted` under the label, read out as the choice's description. */
|
|
9
|
+
hint?: string;
|
|
10
|
+
/**
|
|
11
|
+
* A Tag after the label, such as "Recommended": a short fixed string the
|
|
12
|
+
* app defines, set in caps. It is part of the choice's accessible name.
|
|
13
|
+
*/
|
|
14
|
+
tag?: string;
|
|
15
|
+
/** Shown in muted ink; it cannot be chosen and arrow keys skip it. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** The group's name: a fixed caps `label`, or a `legend` set as written. Exactly one. */
|
|
19
|
+
type RadioGroupName = {
|
|
20
|
+
/** The caps label that names the group: a fixed string the app defines. */
|
|
21
|
+
label: string;
|
|
22
|
+
legend?: never;
|
|
23
|
+
} | {
|
|
24
|
+
label?: never;
|
|
25
|
+
/**
|
|
26
|
+
* The group's name as content, set as written in 16px at weight 500:
|
|
27
|
+
* a question, or a name with a counter before it.
|
|
28
|
+
*/
|
|
29
|
+
legend: Snippet;
|
|
30
|
+
};
|
|
31
|
+
/** Attributes and handlers not listed here go to the group element. */
|
|
32
|
+
export type RadioGroupProps = Omit<BitsRadioGroup.RootProps, 'child' | 'children' | 'ref' | 'orientation'> & RadioGroupName & {
|
|
33
|
+
/** The choices, stacked in order. */
|
|
34
|
+
items: RadioGroupItem[];
|
|
35
|
+
/** The chosen item's `value`. Bindable; `''` means none is chosen, and the app may set `''` to clear it. */
|
|
36
|
+
value?: string;
|
|
37
|
+
/** A line in `--muted` under the name, read out as the group's description. */
|
|
38
|
+
hint?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The error to show under the choices, with an icon, linked by
|
|
41
|
+
* `aria-describedby`; it sets `aria-invalid` on the group. Say how to
|
|
42
|
+
* fix the problem. The app validates; the group shows what it is given.
|
|
43
|
+
*/
|
|
44
|
+
error?: string;
|
|
45
|
+
/** The group element (`role="radiogroup"`). */
|
|
46
|
+
ref?: HTMLElement | null;
|
|
47
|
+
};
|
|
48
|
+
declare const RadioGroup: import("svelte").Component<RadioGroupProps, {}, "value" | "ref">;
|
|
49
|
+
type RadioGroup = ReturnType<typeof RadioGroup>;
|
|
50
|
+
export default RadioGroup;
|