@mystravil/hardpoint 0.1.0 → 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 +20 -0
- package/README.md +5 -5
- package/dist/base.css +279 -0
- package/dist/components/Checkbox.svelte +46 -9
- package/dist/components/Checkbox.svelte.d.ts +9 -2
- package/dist/components/CheckboxGroup.svelte +131 -23
- package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
- package/dist/components/Combobox.svelte +104 -27
- package/dist/components/Combobox.svelte.d.ts +10 -1
- package/dist/components/Dialog.svelte +4 -1
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +135 -119
- 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 +6 -6
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +89 -23
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/Spinner.svelte +3 -2
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/Toaster.svelte +6 -6
- package/dist/components/Tooltip.svelte +5 -5
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/tokens.css +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { Dialog as BitsDialog } from 'bits-ui';
|
|
3
|
+
export type ImageViewerTriggerSnippetProps = {
|
|
4
|
+
/**
|
|
5
|
+
* Spread onto the button that opens the viewer, usually around a
|
|
6
|
+
* thumbnail, so the image opens from the keyboard too.
|
|
7
|
+
*/
|
|
8
|
+
props: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Attributes and handlers not listed here go to the viewer element (Bits
|
|
12
|
+
* UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
|
|
13
|
+
* `onEscapeKeydown` and `onInteractOutside`.
|
|
14
|
+
*/
|
|
15
|
+
export type ImageViewerProps = Omit<BitsDialog.ContentProps, 'child' | 'children' | 'forceMount' | 'ref' | 'title'> & {
|
|
16
|
+
open?: boolean;
|
|
17
|
+
/** The image's URL. A different URL while open starts a new load at scale 1. */
|
|
18
|
+
src: string;
|
|
19
|
+
/**
|
|
20
|
+
* The text alternative, required. It also names the viewer when there
|
|
21
|
+
* is no `title`, so it should describe the image, never be empty.
|
|
22
|
+
*/
|
|
23
|
+
alt: string;
|
|
24
|
+
/**
|
|
25
|
+
* A line in the top bar, shown as written (a file name, a caption) on
|
|
26
|
+
* one line, cut with an ellipsis. It names the viewer when set.
|
|
27
|
+
*/
|
|
28
|
+
title?: string;
|
|
29
|
+
/** The button that opens the viewer: spread `props` onto it. */
|
|
30
|
+
trigger?: Snippet<[ImageViewerTriggerSnippetProps]>;
|
|
31
|
+
/**
|
|
32
|
+
* Shows the Download button, which saves `src` under this file name
|
|
33
|
+
* through a link with `download`. A `data:` URL becomes a `blob:` URL
|
|
34
|
+
* first. Browsers ignore `download` on a link to another origin, so an
|
|
35
|
+
* image from another origin needs `ondownload`.
|
|
36
|
+
*/
|
|
37
|
+
downloadName?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Shows the Download button and replaces the built-in save, for an app
|
|
40
|
+
* with its own save picker or confirmation. While its promise is
|
|
41
|
+
* pending, another press does nothing. Errors are the app's to catch.
|
|
42
|
+
*/
|
|
43
|
+
ondownload?: () => void | Promise<void>;
|
|
44
|
+
/** The close button's accessible name. */
|
|
45
|
+
closeLabel?: string;
|
|
46
|
+
/** The Download button's label. */
|
|
47
|
+
downloadLabel?: string;
|
|
48
|
+
/** The spinner's label while the image loads. */
|
|
49
|
+
loadingLabel?: string;
|
|
50
|
+
/** The viewer element. */
|
|
51
|
+
ref?: HTMLElement | null;
|
|
52
|
+
};
|
|
53
|
+
declare const ImageViewer: import("svelte").Component<ImageViewerProps, {}, "ref" | "open">;
|
|
54
|
+
type ImageViewer = ReturnType<typeof ImageViewer>;
|
|
55
|
+
export default ImageViewer;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
|
|
5
|
+
/** From which width the panes sit side by side: 600px, 900px or 1200px. */
|
|
6
|
+
export type ListDetailBreakpoint = 'medium' | 'expanded' | 'large';
|
|
7
|
+
|
|
8
|
+
/** What the `list` and `detail` snippets receive. */
|
|
9
|
+
export type ListDetailSnippetProps = {
|
|
10
|
+
/**
|
|
11
|
+
* True below `splitFrom`, where one pane shows at a time. Show the
|
|
12
|
+
* detail's back control only then.
|
|
13
|
+
*/
|
|
14
|
+
stacked: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ListDetailProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
18
|
+
/** The list pane's content: the items, and any filter over them. */
|
|
19
|
+
list: Snippet<[ListDetailSnippetProps]>;
|
|
20
|
+
/**
|
|
21
|
+
* The detail pane's content: the open item. Side by side with nothing
|
|
22
|
+
* open, render an empty state here.
|
|
23
|
+
*/
|
|
24
|
+
detail: Snippet<[ListDetailSnippetProps]>;
|
|
25
|
+
/**
|
|
26
|
+
* Which pane shows while stacked: the detail when true, the list when
|
|
27
|
+
* false (default). Side by side both show and this changes nothing.
|
|
28
|
+
* Bindable.
|
|
29
|
+
*/
|
|
30
|
+
showDetail?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* From which width the panes sit side by side; narrower, one shows at
|
|
33
|
+
* a time. Default `expanded` (900px).
|
|
34
|
+
*/
|
|
35
|
+
splitFrom?: ListDetailBreakpoint;
|
|
36
|
+
/** The root element. */
|
|
37
|
+
ref?: HTMLDivElement | null;
|
|
38
|
+
};
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script lang="ts">
|
|
42
|
+
import { untrack } from 'svelte';
|
|
43
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
list,
|
|
47
|
+
detail,
|
|
48
|
+
showDetail = $bindable(false),
|
|
49
|
+
splitFrom = 'expanded',
|
|
50
|
+
ref = $bindable(null),
|
|
51
|
+
class: className,
|
|
52
|
+
...rest
|
|
53
|
+
}: ListDetailProps = $props();
|
|
54
|
+
|
|
55
|
+
// The CSS below lays the panes out from the same widths before any script
|
|
56
|
+
// runs; this query only tells the snippets and the focus rules.
|
|
57
|
+
const widths: Record<ListDetailBreakpoint, number> = { medium: 600, expanded: 900, large: 1200 };
|
|
58
|
+
const split = $derived(new MediaQuery(`min-width: ${widths[splitFrom]}px`, true));
|
|
59
|
+
const stacked = $derived(!split.current);
|
|
60
|
+
/** The one pane on screen while stacked; null side by side. */
|
|
61
|
+
const alone = $derived(stacked ? (showDetail ? 'detail' : 'list') : null);
|
|
62
|
+
|
|
63
|
+
let listPane = $state<HTMLDivElement | null>(null);
|
|
64
|
+
let detailPane = $state<HTMLDivElement | null>(null);
|
|
65
|
+
|
|
66
|
+
// ---- The list keeps its place ------------------------------------------
|
|
67
|
+
|
|
68
|
+
// A pane that is not shown is display: none, which drops its scroll box.
|
|
69
|
+
// Some browsers then reset its offset, so the list remembers the offset it
|
|
70
|
+
// had while it showed and puts it back when it shows again.
|
|
71
|
+
let listTop = 0;
|
|
72
|
+
function onListScroll() {
|
|
73
|
+
if (listPane && listPane.getClientRects().length > 0) listTop = listPane.scrollTop;
|
|
74
|
+
}
|
|
75
|
+
$effect(() => {
|
|
76
|
+
const showsList = alone !== 'detail';
|
|
77
|
+
untrack(() => {
|
|
78
|
+
if (showsList && listPane && listPane.scrollTop !== listTop) listPane.scrollTop = listTop;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- Focus follows the pane that shows ---------------------------------
|
|
83
|
+
|
|
84
|
+
// When the stacked view switches panes, the app's own update runs first:
|
|
85
|
+
// the check waits a task, past the app's effects and an `await tick()`,
|
|
86
|
+
// so an app that focuses a title, an input or the item that was open
|
|
87
|
+
// keeps that focus. Only if focus is then outside the pane on screen
|
|
88
|
+
// does the pane itself take it. When the window narrows to one pane,
|
|
89
|
+
// focus moves only if the pane that hid held it. Side by side the app
|
|
90
|
+
// owns focus. A new switch before the check cancels it.
|
|
91
|
+
let before: 'list' | 'detail' | null | undefined;
|
|
92
|
+
$effect(() => {
|
|
93
|
+
const now = alone;
|
|
94
|
+
return untrack(() => {
|
|
95
|
+
const was = before;
|
|
96
|
+
before = now;
|
|
97
|
+
if (was === undefined || now === null || was === now) return;
|
|
98
|
+
const shown = now === 'list' ? listPane : detailPane;
|
|
99
|
+
const hidden = now === 'list' ? detailPane : listPane;
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
if (!shown) return;
|
|
102
|
+
const active = document.activeElement;
|
|
103
|
+
if (active && shown.contains(active)) return;
|
|
104
|
+
const lost = !active || active === document.body || !!hidden?.contains(active);
|
|
105
|
+
if (was === null && !lost) return;
|
|
106
|
+
shown.focus({ preventScroll: true });
|
|
107
|
+
});
|
|
108
|
+
return () => clearTimeout(timer);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<!--
|
|
114
|
+
The list-detail layout: a list of items beside the open item. From
|
|
115
|
+
`splitFrom` (900px by default) the list is a column of
|
|
116
|
+
`--list-detail-width` at the left and the detail fills the rest; below
|
|
117
|
+
it the two are separate screens and `showDetail` picks the one that
|
|
118
|
+
shows. The other pane stays mounted, out of the layout, the
|
|
119
|
+
accessibility tree and the tab order, so its state and the list's scroll
|
|
120
|
+
position survive. Switching cuts, as page changes do (motion rule 1).
|
|
121
|
+
|
|
122
|
+
The root fills the height its container gives it, or
|
|
123
|
+
`--list-detail-height`; each pane scrolls inside itself. The panes pad
|
|
124
|
+
their content by `--gutter`, and the root keeps the side safe areas, so
|
|
125
|
+
the layout is built to reach the window's edges under the app's header.
|
|
126
|
+
No frame and no divider: the gutters between the panes group them.
|
|
127
|
+
|
|
128
|
+
Extra attributes and `class` go to the root, `div.hp-list-detail`, which
|
|
129
|
+
carries `data-split-from`, `data-stacked` (below `splitFrom`) and
|
|
130
|
+
`data-show-detail`. The panes are `div.hp-list-detail-list` and
|
|
131
|
+
`div.hp-list-detail-detail`, each with `tabindex="-1"`.
|
|
132
|
+
-->
|
|
133
|
+
<div
|
|
134
|
+
{...rest}
|
|
135
|
+
bind:this={ref}
|
|
136
|
+
class={['hp-list-detail', className]}
|
|
137
|
+
data-split-from={splitFrom}
|
|
138
|
+
data-stacked={stacked || undefined}
|
|
139
|
+
data-show-detail={showDetail || undefined}
|
|
140
|
+
>
|
|
141
|
+
<div bind:this={listPane} class="pane hp-list-detail-list" tabindex="-1" onscroll={onListScroll}>
|
|
142
|
+
{@render list({ stacked })}
|
|
143
|
+
</div>
|
|
144
|
+
<div bind:this={detailPane} class="pane hp-list-detail-detail" tabindex="-1">
|
|
145
|
+
{@render detail({ stacked })}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<style>
|
|
150
|
+
/* A row of two panes. The side safe areas sit outside them, so a tint
|
|
151
|
+
that reaches into a gutter stops short of a notch. min-height: 0 lets
|
|
152
|
+
the root shrink as a flex or grid item. */
|
|
153
|
+
.hp-list-detail {
|
|
154
|
+
display: flex;
|
|
155
|
+
box-sizing: border-box;
|
|
156
|
+
height: var(--list-detail-height, 100%);
|
|
157
|
+
min-height: 0;
|
|
158
|
+
padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Each pane is a scroller of its own. The page edges come from
|
|
162
|
+
--gutter, as everywhere; the bottom safe area keeps the last item
|
|
163
|
+
clear of the home indicator. A stable scrollbar gutter keeps the
|
|
164
|
+
content still when filtering or a new item changes whether a pane
|
|
165
|
+
overflows. */
|
|
166
|
+
.pane {
|
|
167
|
+
flex: 1 1 0;
|
|
168
|
+
min-width: 0;
|
|
169
|
+
box-sizing: border-box;
|
|
170
|
+
padding: var(--space-4) var(--gutter) calc(var(--space-4) + env(safe-area-inset-bottom));
|
|
171
|
+
overflow-y: auto;
|
|
172
|
+
overscroll-behavior: contain;
|
|
173
|
+
scrollbar-gutter: stable;
|
|
174
|
+
}
|
|
175
|
+
/* Focus lands on a pane only from the switch above, as <main> takes it
|
|
176
|
+
from a skip link: no contour, since the pane is not a control. */
|
|
177
|
+
.pane:focus {
|
|
178
|
+
outline: none;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ---- Stacked: one pane at a time --------------------------------- */
|
|
182
|
+
|
|
183
|
+
.hp-list-detail[data-show-detail] > .hp-list-detail-list,
|
|
184
|
+
.hp-list-detail:not([data-show-detail]) > .hp-list-detail-detail {
|
|
185
|
+
display: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---- Side by side: from splitFrom -------------------------------- */
|
|
189
|
+
|
|
190
|
+
/* Later and as specific as the rules above, so both panes show. */
|
|
191
|
+
@media (min-width: 600px) {
|
|
192
|
+
.hp-list-detail[data-split-from='medium'] > .pane {
|
|
193
|
+
display: block;
|
|
194
|
+
}
|
|
195
|
+
.hp-list-detail[data-split-from='medium'] > .hp-list-detail-list {
|
|
196
|
+
flex: none;
|
|
197
|
+
width: var(--list-detail-width);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
@media (min-width: 900px) {
|
|
201
|
+
.hp-list-detail[data-split-from='expanded'] > .pane {
|
|
202
|
+
display: block;
|
|
203
|
+
}
|
|
204
|
+
.hp-list-detail[data-split-from='expanded'] > .hp-list-detail-list {
|
|
205
|
+
flex: none;
|
|
206
|
+
width: var(--list-detail-width);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
@media (min-width: 1200px) {
|
|
210
|
+
.hp-list-detail[data-split-from='large'] > .pane {
|
|
211
|
+
display: block;
|
|
212
|
+
}
|
|
213
|
+
.hp-list-detail[data-split-from='large'] > .hp-list-detail-list {
|
|
214
|
+
flex: none;
|
|
215
|
+
width: var(--list-detail-width);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
/** From which width the panes sit side by side: 600px, 900px or 1200px. */
|
|
4
|
+
export type ListDetailBreakpoint = 'medium' | 'expanded' | 'large';
|
|
5
|
+
/** What the `list` and `detail` snippets receive. */
|
|
6
|
+
export type ListDetailSnippetProps = {
|
|
7
|
+
/**
|
|
8
|
+
* True below `splitFrom`, where one pane shows at a time. Show the
|
|
9
|
+
* detail's back control only then.
|
|
10
|
+
*/
|
|
11
|
+
stacked: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type ListDetailProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
14
|
+
/** The list pane's content: the items, and any filter over them. */
|
|
15
|
+
list: Snippet<[ListDetailSnippetProps]>;
|
|
16
|
+
/**
|
|
17
|
+
* The detail pane's content: the open item. Side by side with nothing
|
|
18
|
+
* open, render an empty state here.
|
|
19
|
+
*/
|
|
20
|
+
detail: Snippet<[ListDetailSnippetProps]>;
|
|
21
|
+
/**
|
|
22
|
+
* Which pane shows while stacked: the detail when true, the list when
|
|
23
|
+
* false (default). Side by side both show and this changes nothing.
|
|
24
|
+
* Bindable.
|
|
25
|
+
*/
|
|
26
|
+
showDetail?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* From which width the panes sit side by side; narrower, one shows at
|
|
29
|
+
* a time. Default `expanded` (900px).
|
|
30
|
+
*/
|
|
31
|
+
splitFrom?: ListDetailBreakpoint;
|
|
32
|
+
/** The root element. */
|
|
33
|
+
ref?: HTMLDivElement | null;
|
|
34
|
+
};
|
|
35
|
+
declare const ListDetail: import("svelte").Component<ListDetailProps, {}, "ref" | "showDetail">;
|
|
36
|
+
type ListDetail = ReturnType<typeof ListDetail>;
|
|
37
|
+
export default ListDetail;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { Meter as BitsMeter } from 'bits-ui';
|
|
3
|
+
|
|
4
|
+
/** A status hue for the bar: `ok` fine, `warn` needs attention, `danger` bad. */
|
|
5
|
+
export type MeterTone = 'ok' | 'warn' | 'danger';
|
|
6
|
+
|
|
7
|
+
export type MeterProps = Omit<BitsMeter.RootProps, 'value' | 'child' | 'children'> & {
|
|
8
|
+
/**
|
|
9
|
+
* The measurement, from `min` to `max`. Pass the real figure each time
|
|
10
|
+
* it changes: the bar never moves on its own. A value outside the
|
|
11
|
+
* range is held at its ends for the bar and `aria-valuenow`. Work that
|
|
12
|
+
* is getting done shows a `Progress` instead.
|
|
13
|
+
*/
|
|
14
|
+
value: number;
|
|
15
|
+
/** The top of the range. Default 100. */
|
|
16
|
+
max?: number;
|
|
17
|
+
/** The bottom of the range. Default 0. */
|
|
18
|
+
min?: number;
|
|
19
|
+
/**
|
|
20
|
+
* How many segments the bar has (default 20, at least 2). A segment
|
|
21
|
+
* lights once the value has passed its whole share, so the bar never
|
|
22
|
+
* shows more than the value.
|
|
23
|
+
*/
|
|
24
|
+
segments?: number;
|
|
25
|
+
/**
|
|
26
|
+
* A line above the bar saying what is measured, as written (never
|
|
27
|
+
* caps: it may hold a name). It names the meter; without it, pass
|
|
28
|
+
* `aria-label` or `aria-labelledby`.
|
|
29
|
+
*/
|
|
30
|
+
label?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The value in words, such as `4.2 of 5 GB`. Shown at the end of the
|
|
33
|
+
* label line and read as `aria-valuetext`. Without it both give the
|
|
34
|
+
* share of the range, rounded down.
|
|
35
|
+
*/
|
|
36
|
+
valueText?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The value at and above which the meter shows `warn`. Default 80% of
|
|
39
|
+
* the range. `null` turns the warn step off.
|
|
40
|
+
*/
|
|
41
|
+
warnAt?: number | null;
|
|
42
|
+
/**
|
|
43
|
+
* The value at and above which the meter shows `danger`. Default
|
|
44
|
+
* `max`. `null` turns the danger step off.
|
|
45
|
+
*/
|
|
46
|
+
dangerAt?: number | null;
|
|
47
|
+
/**
|
|
48
|
+
* Replaces the thresholds with a colour the app chooses. Use it where
|
|
49
|
+
* lower is worse, or for a state the value cannot show. Colour never
|
|
50
|
+
* carries meaning alone: say the reason in `valueText` or `detail`.
|
|
51
|
+
*/
|
|
52
|
+
tone?: MeterTone;
|
|
53
|
+
/**
|
|
54
|
+
* A reference value drawn as a thin mark across the bar: a target, an
|
|
55
|
+
* expected level, an earlier reading. Held inside the range. The mark
|
|
56
|
+
* is visual only and hidden from assistive technology, so say what it
|
|
57
|
+
* stands for in `detail`.
|
|
58
|
+
*/
|
|
59
|
+
marker?: number;
|
|
60
|
+
/**
|
|
61
|
+
* A muted line under the bar, as written. Assistive technology reads
|
|
62
|
+
* it as the meter's description; the app's own `aria-describedby`
|
|
63
|
+
* ids follow it.
|
|
64
|
+
*/
|
|
65
|
+
detail?: string;
|
|
66
|
+
};
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<script lang="ts">
|
|
70
|
+
let {
|
|
71
|
+
value,
|
|
72
|
+
max = 100,
|
|
73
|
+
min = 0,
|
|
74
|
+
segments = 20,
|
|
75
|
+
label,
|
|
76
|
+
valueText,
|
|
77
|
+
warnAt,
|
|
78
|
+
dangerAt,
|
|
79
|
+
tone,
|
|
80
|
+
marker,
|
|
81
|
+
detail,
|
|
82
|
+
id,
|
|
83
|
+
class: className,
|
|
84
|
+
ref = $bindable(null),
|
|
85
|
+
'aria-describedby': describedby,
|
|
86
|
+
...rest
|
|
87
|
+
}: MeterProps = $props();
|
|
88
|
+
|
|
89
|
+
const uid = $props.id();
|
|
90
|
+
const labelId = `${uid}-label`;
|
|
91
|
+
const detailId = `${uid}-detail`;
|
|
92
|
+
|
|
93
|
+
// The value inside its range, so the bar and aria-valuenow agree. An
|
|
94
|
+
// empty range counts as full.
|
|
95
|
+
const current = $derived(Math.min(max, Math.max(min, value)));
|
|
96
|
+
const span = $derived(max - min);
|
|
97
|
+
const count = $derived(Math.max(2, Math.floor(segments)));
|
|
98
|
+
// Round down: a segment lights only when its whole share is reached.
|
|
99
|
+
// Multiply before dividing and clear float noise before rounding down,
|
|
100
|
+
// so 58 of 100 and 0.58 of 1 are both 58% and not 57.99…
|
|
101
|
+
const share = (n: number) => Math.floor(Number((((current - min) * n) / span).toPrecision(12)));
|
|
102
|
+
const lit = $derived(current >= max || span <= 0 ? count : share(count));
|
|
103
|
+
const percent = $derived(span > 0 ? share(100) : 100);
|
|
104
|
+
const shown = $derived(valueText ?? `${percent}%`);
|
|
105
|
+
|
|
106
|
+
// Thresholds apply to the value inside the range. `undefined` takes the
|
|
107
|
+
// default; `null` turns the step off. 0.8 of the span is cleared of float
|
|
108
|
+
// noise so a value on the line counts.
|
|
109
|
+
const warnLine = $derived(
|
|
110
|
+
warnAt === undefined ? min + Number((0.8 * span).toPrecision(12)) : warnAt
|
|
111
|
+
);
|
|
112
|
+
const dangerLine = $derived(dangerAt === undefined ? max : dangerAt);
|
|
113
|
+
const shownTone = $derived<MeterTone | undefined>(
|
|
114
|
+
tone ??
|
|
115
|
+
(dangerLine !== null && current >= dangerLine
|
|
116
|
+
? 'danger'
|
|
117
|
+
: warnLine !== null && current >= warnLine
|
|
118
|
+
? 'warn'
|
|
119
|
+
: undefined)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// The marker's place across the bar, held inside the range.
|
|
123
|
+
const markerAt = $derived(
|
|
124
|
+
marker === undefined
|
|
125
|
+
? undefined
|
|
126
|
+
: span > 0
|
|
127
|
+
? Math.min(1, Math.max(0, (marker - min) / span)) * 100
|
|
128
|
+
: 0
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const describedBy = $derived([detail ? detailId : undefined, describedby].filter(Boolean).join(' ') || undefined);
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<!--
|
|
135
|
+
Bits UI Meter, rendered through its `child` snippet so the element is this
|
|
136
|
+
component's markup and its styles stay scoped. The root is the meter
|
|
137
|
+
(`role`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`); `id`, extra
|
|
138
|
+
attributes and `class` go to it. Its children are presentational to
|
|
139
|
+
assistive technology, so the label line names it through
|
|
140
|
+
`aria-labelledby`, the value as shown reaches it as `aria-valuetext`, and
|
|
141
|
+
the detail line describes it. The marker is a decoration.
|
|
142
|
+
A segment that lights, goes out or changes tone fades its colour over
|
|
143
|
+
--dur-segment; the marker moves at once.
|
|
144
|
+
-->
|
|
145
|
+
<BitsMeter.Root
|
|
146
|
+
bind:ref
|
|
147
|
+
value={current}
|
|
148
|
+
{max}
|
|
149
|
+
{min}
|
|
150
|
+
aria-labelledby={label ? labelId : undefined}
|
|
151
|
+
aria-valuetext={shown}
|
|
152
|
+
aria-describedby={describedBy}
|
|
153
|
+
{...rest}
|
|
154
|
+
>
|
|
155
|
+
{#snippet child({ props })}
|
|
156
|
+
<div
|
|
157
|
+
{...props}
|
|
158
|
+
{id}
|
|
159
|
+
class={['hp-meter', className]}
|
|
160
|
+
data-tone={shownTone}
|
|
161
|
+
data-lit={lit}
|
|
162
|
+
data-segments={count}
|
|
163
|
+
>
|
|
164
|
+
{#if label}
|
|
165
|
+
<div class="hp-meter-line">
|
|
166
|
+
<span id={labelId} class="hp-meter-label">{label}</span>
|
|
167
|
+
<span class="hp-meter-value">{shown}</span>
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
<div class="hp-meter-bar" style:--segments={count}>
|
|
171
|
+
{#each { length: count }, i}
|
|
172
|
+
<span class="hp-meter-segment" data-lit={i < lit || undefined}></span>
|
|
173
|
+
{/each}
|
|
174
|
+
{#if markerAt !== undefined}
|
|
175
|
+
<span class="hp-meter-marker" aria-hidden="true" style:left="{markerAt}%"></span>
|
|
176
|
+
{/if}
|
|
177
|
+
</div>
|
|
178
|
+
{#if detail}
|
|
179
|
+
<p id={detailId} class="hp-meter-detail">{detail}</p>
|
|
180
|
+
{/if}
|
|
181
|
+
</div>
|
|
182
|
+
{/snippet}
|
|
183
|
+
</BitsMeter.Root>
|
|
184
|
+
|
|
185
|
+
<style>
|
|
186
|
+
/* The bar fills the width it is given; the app sets a narrower one. */
|
|
187
|
+
.hp-meter {
|
|
188
|
+
display: grid;
|
|
189
|
+
gap: var(--space-2);
|
|
190
|
+
min-width: 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* 16px on touch, the dense 14px on fine pointers. The value keeps the
|
|
194
|
+
end of the line; a long label wraps rather than widen the page. */
|
|
195
|
+
.hp-meter-line {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: baseline;
|
|
198
|
+
justify-content: space-between;
|
|
199
|
+
gap: var(--space-4);
|
|
200
|
+
font-size: var(--type-body);
|
|
201
|
+
line-height: 24px;
|
|
202
|
+
}
|
|
203
|
+
@media (pointer: fine) {
|
|
204
|
+
.hp-meter-line {
|
|
205
|
+
font-size: var(--type-dense);
|
|
206
|
+
line-height: 20px;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
.hp-meter-label {
|
|
210
|
+
min-width: 0;
|
|
211
|
+
color: var(--text);
|
|
212
|
+
overflow-wrap: anywhere;
|
|
213
|
+
}
|
|
214
|
+
/* Muted with no tone; the tone's hue otherwise, so the words and the
|
|
215
|
+
bar agree. The value text is always there, so colour never carries the
|
|
216
|
+
meaning alone. */
|
|
217
|
+
.hp-meter-value {
|
|
218
|
+
flex: none;
|
|
219
|
+
color: var(--muted);
|
|
220
|
+
font-variant-numeric: tabular-nums;
|
|
221
|
+
}
|
|
222
|
+
.hp-meter[data-tone='ok'] .hp-meter-value {
|
|
223
|
+
color: var(--ok);
|
|
224
|
+
}
|
|
225
|
+
.hp-meter[data-tone='warn'] .hp-meter-value {
|
|
226
|
+
color: var(--warn);
|
|
227
|
+
}
|
|
228
|
+
.hp-meter[data-tone='danger'] .hp-meter-value {
|
|
229
|
+
color: var(--danger);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* Separate segments as in Progress (BRIEF, visual rule 7): lit in the
|
|
233
|
+
tone's colour, the 3:1 control boundary while unlit. Square, 4px
|
|
234
|
+
apart, one row. */
|
|
235
|
+
.hp-meter-bar {
|
|
236
|
+
position: relative;
|
|
237
|
+
display: grid;
|
|
238
|
+
grid-template-columns: repeat(var(--segments), minmax(0, 1fr));
|
|
239
|
+
gap: var(--space-1);
|
|
240
|
+
height: 12px;
|
|
241
|
+
}
|
|
242
|
+
/* A segment that lights, goes out or changes tone fades its colour over
|
|
243
|
+
--dur-segment (BRIEF, short motion). A CSS transition never runs on an
|
|
244
|
+
element's first style, so a meter shows its value at once when it
|
|
245
|
+
renders. 0ms is a snap. Colour only, so reduced motion keeps it. */
|
|
246
|
+
.hp-meter-segment {
|
|
247
|
+
background: var(--border-control);
|
|
248
|
+
transition: background-color var(--dur-segment) var(--settle);
|
|
249
|
+
}
|
|
250
|
+
.hp-meter-segment[data-lit] {
|
|
251
|
+
background: var(--accent);
|
|
252
|
+
}
|
|
253
|
+
.hp-meter[data-tone='ok'] .hp-meter-segment[data-lit] {
|
|
254
|
+
background: var(--ok);
|
|
255
|
+
}
|
|
256
|
+
.hp-meter[data-tone='warn'] .hp-meter-segment[data-lit] {
|
|
257
|
+
background: var(--warn);
|
|
258
|
+
}
|
|
259
|
+
.hp-meter[data-tone='danger'] .hp-meter-segment[data-lit] {
|
|
260
|
+
background: var(--danger);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* A 2px line in the text colour, 4px past the bar at each end. A 1px
|
|
264
|
+
canvas edge on each side lets it read over lit segments of every tone,
|
|
265
|
+
unlit ones and the gaps, in both themes. It moves at once. */
|
|
266
|
+
.hp-meter-marker {
|
|
267
|
+
position: absolute;
|
|
268
|
+
top: calc(var(--space-1) * -1);
|
|
269
|
+
bottom: calc(var(--space-1) * -1);
|
|
270
|
+
width: 2px;
|
|
271
|
+
margin-left: -1px;
|
|
272
|
+
background: var(--text);
|
|
273
|
+
box-shadow: 0 0 0 1px var(--canvas);
|
|
274
|
+
pointer-events: none;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.hp-meter-detail {
|
|
278
|
+
margin: 0;
|
|
279
|
+
color: var(--muted);
|
|
280
|
+
font-size: var(--type-caption);
|
|
281
|
+
line-height: 18px;
|
|
282
|
+
}
|
|
283
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Meter as BitsMeter } from 'bits-ui';
|
|
2
|
+
/** A status hue for the bar: `ok` fine, `warn` needs attention, `danger` bad. */
|
|
3
|
+
export type MeterTone = 'ok' | 'warn' | 'danger';
|
|
4
|
+
export type MeterProps = Omit<BitsMeter.RootProps, 'value' | 'child' | 'children'> & {
|
|
5
|
+
/**
|
|
6
|
+
* The measurement, from `min` to `max`. Pass the real figure each time
|
|
7
|
+
* it changes: the bar never moves on its own. A value outside the
|
|
8
|
+
* range is held at its ends for the bar and `aria-valuenow`. Work that
|
|
9
|
+
* is getting done shows a `Progress` instead.
|
|
10
|
+
*/
|
|
11
|
+
value: number;
|
|
12
|
+
/** The top of the range. Default 100. */
|
|
13
|
+
max?: number;
|
|
14
|
+
/** The bottom of the range. Default 0. */
|
|
15
|
+
min?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How many segments the bar has (default 20, at least 2). A segment
|
|
18
|
+
* lights once the value has passed its whole share, so the bar never
|
|
19
|
+
* shows more than the value.
|
|
20
|
+
*/
|
|
21
|
+
segments?: number;
|
|
22
|
+
/**
|
|
23
|
+
* A line above the bar saying what is measured, as written (never
|
|
24
|
+
* caps: it may hold a name). It names the meter; without it, pass
|
|
25
|
+
* `aria-label` or `aria-labelledby`.
|
|
26
|
+
*/
|
|
27
|
+
label?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The value in words, such as `4.2 of 5 GB`. Shown at the end of the
|
|
30
|
+
* label line and read as `aria-valuetext`. Without it both give the
|
|
31
|
+
* share of the range, rounded down.
|
|
32
|
+
*/
|
|
33
|
+
valueText?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The value at and above which the meter shows `warn`. Default 80% of
|
|
36
|
+
* the range. `null` turns the warn step off.
|
|
37
|
+
*/
|
|
38
|
+
warnAt?: number | null;
|
|
39
|
+
/**
|
|
40
|
+
* The value at and above which the meter shows `danger`. Default
|
|
41
|
+
* `max`. `null` turns the danger step off.
|
|
42
|
+
*/
|
|
43
|
+
dangerAt?: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* Replaces the thresholds with a colour the app chooses. Use it where
|
|
46
|
+
* lower is worse, or for a state the value cannot show. Colour never
|
|
47
|
+
* carries meaning alone: say the reason in `valueText` or `detail`.
|
|
48
|
+
*/
|
|
49
|
+
tone?: MeterTone;
|
|
50
|
+
/**
|
|
51
|
+
* A reference value drawn as a thin mark across the bar: a target, an
|
|
52
|
+
* expected level, an earlier reading. Held inside the range. The mark
|
|
53
|
+
* is visual only and hidden from assistive technology, so say what it
|
|
54
|
+
* stands for in `detail`.
|
|
55
|
+
*/
|
|
56
|
+
marker?: number;
|
|
57
|
+
/**
|
|
58
|
+
* A muted line under the bar, as written. Assistive technology reads
|
|
59
|
+
* it as the meter's description; the app's own `aria-describedby`
|
|
60
|
+
* ids follow it.
|
|
61
|
+
*/
|
|
62
|
+
detail?: string;
|
|
63
|
+
};
|
|
64
|
+
declare const Meter: import("svelte").Component<MeterProps, {}, "ref">;
|
|
65
|
+
type Meter = ReturnType<typeof Meter>;
|
|
66
|
+
export default Meter;
|