@dorsk/tsumikit 0.36.0 → 0.37.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/README.md +13 -3
- package/dist/components/atoms/Select.svelte +112 -4
- package/dist/components/atoms/Select.svelte.d.ts +12 -0
- package/dist/components/molecules/CapBar.svelte +1 -1
- package/dist/components/molecules/Drawer.svelte +302 -0
- package/dist/components/molecules/Drawer.svelte.d.ts +35 -0
- package/dist/components/molecules/Menu.svelte +24 -1
- package/dist/components/molecules/Menu.svelte.d.ts +5 -0
- package/dist/components/molecules/RadioGroup.svelte +120 -14
- package/dist/components/molecules/RadioGroup.svelte.d.ts +6 -0
- package/dist/components/molecules/SectionHeader.svelte +35 -1
- package/dist/components/molecules/SectionHeader.svelte.d.ts +3 -0
- package/dist/components/molecules/Timestamp.svelte +7 -5
- package/dist/components/molecules/Timestamp.svelte.d.ts +2 -2
- package/dist/components/molecules/WorkingDir.svelte +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/query/parser.js +1 -1
- package/dist/timestamp.d.ts +11 -5
- package/dist/timestamp.js +16 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -147,7 +147,8 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
|
|
|
147
147
|
**Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
|
|
148
148
|
`clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
|
|
149
149
|
`onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
|
|
150
|
-
is used), Textarea, Select
|
|
150
|
+
is used), Textarea, Select (`options` array with per-option `icon`/`emoji`/`hint`/`disabled`;
|
|
151
|
+
the trigger overlays the selected option's glyph + muted hint on the native control), Switch, Checkbox,
|
|
151
152
|
Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
|
|
152
153
|
threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
|
|
153
154
|
fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
|
|
@@ -158,12 +159,19 @@ always/hover/none, `align`), Icon (open registry — pass a `children` snippet f
|
|
|
158
159
|
any custom SVG).
|
|
159
160
|
|
|
160
161
|
**Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
161
|
-
Popover, Menu
|
|
162
|
+
Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet),
|
|
163
|
+
Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`description`,
|
|
164
|
+
`action(option)` trailing control that never toggles, `below(option)` inline panel),
|
|
165
|
+
Tooltip, Accordion, CopyButton, FileButton,
|
|
162
166
|
Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
|
|
163
167
|
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
|
|
164
168
|
GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
|
|
165
169
|
auto/never/glyph degrades to icons inside a narrow `.cq` container),
|
|
166
|
-
CapBar (consumption track with a draggable, keyboard-steppable cap handle; `oninput` live, `onchange` on commit),
|
|
170
|
+
CapBar (consumption track with a draggable, keyboard-steppable cap handle; `oninput` live, `onchange` on commit),
|
|
171
|
+
Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
|
|
172
|
+
under 48rem; `nav` page column that turns into a horizontal strip on narrow
|
|
173
|
+
screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
|
|
174
|
+
Timestamp (`mode` date/time/datetime/relative/iso/`short-iso` for a locale-independent `YYYY-MM-DD`; `utc`; click for a details popover), WorkingDir
|
|
167
175
|
(fish-style path chip: ancestors abbreviate left to right as the slot narrows,
|
|
168
176
|
then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
|
|
169
177
|
a copy-on-click button; candidates via the `workingDirCandidates` helper).
|
|
@@ -320,6 +328,8 @@ chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
|
|
|
320
328
|
`--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
|
|
321
329
|
parent), `collapsible` + `bind:open` (title becomes a disclosure button with
|
|
322
330
|
`aria-expanded`; `children` render beneath while open), `actions` snippet.
|
|
331
|
+
`variant="group"` is the non-wrapping list-group row: `lead` snippet (dot /
|
|
332
|
+
badge) · title · `count` · flexible 1px rule · `actions` (sort menu, eye toggle).
|
|
323
333
|
|
|
324
334
|
`Card` gains `header` / `footer` snippets rendered outside the padded body
|
|
325
335
|
behind a divider, `title` / `subtitle` / `actions` sugar that renders a
|
|
@@ -1,7 +1,30 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { IconName } from './Icon.svelte';
|
|
3
|
+
|
|
4
|
+
export type SelectOption = {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
emoji?: string;
|
|
9
|
+
/** Muted, right-aligned secondary text (e.g. "62%"). */
|
|
10
|
+
hint?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
};
|
|
13
|
+
</script>
|
|
14
|
+
|
|
1
15
|
<script lang="ts">
|
|
2
16
|
// Native <select> primitive. Owns its styling from theme tokens; supports
|
|
3
|
-
// `bind:value` and passes through every native attribute. Options are
|
|
4
|
-
// children
|
|
17
|
+
// `bind:value` and passes through every native attribute. Options are either
|
|
18
|
+
// slotted children (full control over <option> rendering) or an `options`
|
|
19
|
+
// array, which adds per-option icon/emoji/hint.
|
|
20
|
+
//
|
|
21
|
+
// A native <select> cannot render icons in its popup or trigger. With
|
|
22
|
+
// `options`, the trigger text is drawn by a pointer-transparent face layer
|
|
23
|
+
// (leading icon/emoji, label, muted right-aligned hint) laid over the native
|
|
24
|
+
// control whose own text is made transparent, so keyboard, focus, form
|
|
25
|
+
// submission and the platform popup stay native. The popup degrades
|
|
26
|
+
// gracefully: emoji and hint are folded into the option text
|
|
27
|
+
// (`🐼 personal · 62%`); an `icon` only shows in the trigger.
|
|
5
28
|
//
|
|
6
29
|
// The default variant hides the OS chevron (appearance: none) and draws its
|
|
7
30
|
// own, so the control looks identical across platforms and the chevron sits
|
|
@@ -42,6 +65,8 @@
|
|
|
42
65
|
'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
|
|
43
66
|
class?: string;
|
|
44
67
|
value?: HTMLSelectAttributes['value'];
|
|
68
|
+
/** Rich option list; rendered instead of `children` when given. */
|
|
69
|
+
options?: SelectOption[];
|
|
45
70
|
children?: Snippet;
|
|
46
71
|
};
|
|
47
72
|
|
|
@@ -58,6 +83,7 @@
|
|
|
58
83
|
'aria-invalid': ariaInvalid,
|
|
59
84
|
class: klass = '',
|
|
60
85
|
value = $bindable(),
|
|
86
|
+
options,
|
|
61
87
|
children,
|
|
62
88
|
...rest
|
|
63
89
|
}: Props = $props();
|
|
@@ -70,8 +96,23 @@
|
|
|
70
96
|
|
|
71
97
|
const small = $derived(compact || size === 'sm');
|
|
72
98
|
const showChevron = $derived(chevron ?? variant !== 'embedded');
|
|
99
|
+
const selected = $derived(options?.find((o) => o.value === value));
|
|
100
|
+
const hasFace = $derived(!!options && variant !== 'ghost');
|
|
101
|
+
|
|
102
|
+
const optionText = (o: SelectOption) =>
|
|
103
|
+
[o.emoji, o.label, o.hint && `· ${o.hint}`].filter(Boolean).join(' ');
|
|
73
104
|
</script>
|
|
74
105
|
|
|
106
|
+
{#snippet optionList()}
|
|
107
|
+
{#if options}
|
|
108
|
+
{#each options as o (o.value)}
|
|
109
|
+
<option value={o.value} disabled={o.disabled}>{optionText(o)}</option>
|
|
110
|
+
{/each}
|
|
111
|
+
{:else}
|
|
112
|
+
{@render children?.()}
|
|
113
|
+
{/if}
|
|
114
|
+
{/snippet}
|
|
115
|
+
|
|
75
116
|
{#if variant === 'ghost'}
|
|
76
117
|
<select
|
|
77
118
|
bind:this={el}
|
|
@@ -83,7 +124,7 @@
|
|
|
83
124
|
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
84
125
|
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
85
126
|
>
|
|
86
|
-
{@render
|
|
127
|
+
{@render optionList()}
|
|
87
128
|
</select>
|
|
88
129
|
{:else}
|
|
89
130
|
<div
|
|
@@ -100,14 +141,28 @@
|
|
|
100
141
|
class:select-sm={size === 'sm'}
|
|
101
142
|
class:w-auto={width === 'auto'}
|
|
102
143
|
class:embedded={variant === 'embedded'}
|
|
144
|
+
class:has-face={hasFace}
|
|
103
145
|
bind:value
|
|
104
146
|
{...rest}
|
|
105
147
|
id={id ?? field?.id}
|
|
106
148
|
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
107
149
|
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
108
150
|
>
|
|
109
|
-
{@render
|
|
151
|
+
{@render optionList()}
|
|
110
152
|
</select>
|
|
153
|
+
{#if hasFace}
|
|
154
|
+
<span class="select-face" class:compact={small} aria-hidden="true">
|
|
155
|
+
{#if selected?.icon}
|
|
156
|
+
<Icon name={selected.icon} size={small ? 14 : 16} />
|
|
157
|
+
{:else if selected?.emoji}
|
|
158
|
+
<span class="select-emoji">{selected.emoji}</span>
|
|
159
|
+
{/if}
|
|
160
|
+
<span class="select-label">{selected?.label ?? ''}</span>
|
|
161
|
+
{#if selected?.hint}
|
|
162
|
+
<span class="select-hint">{selected.hint}</span>
|
|
163
|
+
{/if}
|
|
164
|
+
</span>
|
|
165
|
+
{/if}
|
|
111
166
|
{#if showChevron}
|
|
112
167
|
<span class="select-chevron" aria-hidden="true">
|
|
113
168
|
<Icon name="chevron-down" size={16} />
|
|
@@ -178,6 +233,59 @@
|
|
|
178
233
|
.select[aria-invalid='true']:focus {
|
|
179
234
|
border-color: var(--danger);
|
|
180
235
|
}
|
|
236
|
+
/* The face draws the trigger text; the native text underneath stays laid out
|
|
237
|
+
(so `width="auto"` still sizes to it) but invisible. Popup options keep
|
|
238
|
+
their own colour so the transparency does not inherit into the list. */
|
|
239
|
+
.select.has-face {
|
|
240
|
+
color: transparent;
|
|
241
|
+
}
|
|
242
|
+
.select.has-face option {
|
|
243
|
+
color: var(--text);
|
|
244
|
+
background: var(--bg);
|
|
245
|
+
}
|
|
246
|
+
.select-face {
|
|
247
|
+
position: absolute;
|
|
248
|
+
inset: 0;
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: var(--sp-2);
|
|
252
|
+
padding: 0 var(--sp-3);
|
|
253
|
+
padding-right: calc(var(--sp-3) + 1.25rem);
|
|
254
|
+
color: var(--text);
|
|
255
|
+
pointer-events: none;
|
|
256
|
+
overflow: hidden;
|
|
257
|
+
}
|
|
258
|
+
.select-wrap.no-chevron .select-face {
|
|
259
|
+
padding-right: var(--sp-3);
|
|
260
|
+
}
|
|
261
|
+
.select-face.compact {
|
|
262
|
+
padding: 0 var(--sp-2);
|
|
263
|
+
padding-right: calc(var(--sp-2) + 1rem);
|
|
264
|
+
font-size: var(--fs-xs);
|
|
265
|
+
}
|
|
266
|
+
.select-wrap.no-chevron .select-face.compact {
|
|
267
|
+
padding-right: var(--sp-2);
|
|
268
|
+
}
|
|
269
|
+
.select:disabled ~ .select-face {
|
|
270
|
+
color: var(--text-muted);
|
|
271
|
+
}
|
|
272
|
+
.select-emoji {
|
|
273
|
+
line-height: 1;
|
|
274
|
+
}
|
|
275
|
+
.select-label {
|
|
276
|
+
flex: 1 1 auto;
|
|
277
|
+
min-width: 0;
|
|
278
|
+
overflow: hidden;
|
|
279
|
+
text-overflow: ellipsis;
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
}
|
|
282
|
+
.select-hint {
|
|
283
|
+
flex: none;
|
|
284
|
+
margin-left: auto;
|
|
285
|
+
color: var(--text-muted);
|
|
286
|
+
font-size: var(--fs-xs);
|
|
287
|
+
font-variant-numeric: tabular-nums;
|
|
288
|
+
}
|
|
181
289
|
.select-chevron {
|
|
182
290
|
position: absolute;
|
|
183
291
|
top: 50%;
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import type { IconName } from './Icon.svelte';
|
|
2
|
+
export type SelectOption = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
icon?: IconName;
|
|
6
|
+
emoji?: string;
|
|
7
|
+
/** Muted, right-aligned secondary text (e.g. "62%"). */
|
|
8
|
+
hint?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
};
|
|
1
11
|
import type { Snippet } from 'svelte';
|
|
2
12
|
import type { HTMLSelectAttributes } from 'svelte/elements';
|
|
3
13
|
type Props = Omit<HTMLSelectAttributes, 'size'> & {
|
|
@@ -21,6 +31,8 @@ type Props = Omit<HTMLSelectAttributes, 'size'> & {
|
|
|
21
31
|
'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
|
|
22
32
|
class?: string;
|
|
23
33
|
value?: HTMLSelectAttributes['value'];
|
|
34
|
+
/** Rich option list; rendered instead of `children` when given. */
|
|
35
|
+
options?: SelectOption[];
|
|
24
36
|
children?: Snippet;
|
|
25
37
|
};
|
|
26
38
|
declare const Select: import("svelte").Component<Props, {}, "value">;
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
position: relative;
|
|
185
185
|
height: var(--track-h);
|
|
186
186
|
border-radius: var(--r-pill);
|
|
187
|
-
background: color-mix(in oklab, var(--
|
|
187
|
+
background: color-mix(in oklab, var(--fill) 26%, var(--bg));
|
|
188
188
|
cursor: ew-resize;
|
|
189
189
|
touch-action: none;
|
|
190
190
|
user-select: none;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Side panel on the native <dialog>: top layer, focus trap, inert background,
|
|
3
|
+
// focus restoration and Escape come from the platform. Adds click-outside,
|
|
4
|
+
// body scroll lock, an optional page-nav column and a sticky footer.
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import { browser } from '../../env';
|
|
7
|
+
import Icon, { type IconName } from '../atoms/Icon.svelte';
|
|
8
|
+
import IconButton from './IconButton.svelte';
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
open = $bindable(),
|
|
12
|
+
side = 'right',
|
|
13
|
+
width = '620px',
|
|
14
|
+
navWidth = '150px',
|
|
15
|
+
title,
|
|
16
|
+
page,
|
|
17
|
+
icon,
|
|
18
|
+
onclose,
|
|
19
|
+
closeLabel = 'Close panel',
|
|
20
|
+
header,
|
|
21
|
+
nav,
|
|
22
|
+
navMobile,
|
|
23
|
+
children,
|
|
24
|
+
footer,
|
|
25
|
+
class: className,
|
|
26
|
+
style
|
|
27
|
+
}: {
|
|
28
|
+
/** Controlled visibility. When provided the `<dialog>` stays mounted and
|
|
29
|
+
* `showModal()`/`close()` follow the value; closing sets it back to false.
|
|
30
|
+
* Omit to open on mount (mount/unmount the component to show/hide). */
|
|
31
|
+
open?: boolean;
|
|
32
|
+
side?: 'left' | 'right';
|
|
33
|
+
/** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
|
|
34
|
+
* 48rem the panel is always full-screen. */
|
|
35
|
+
width?: string;
|
|
36
|
+
/** Width of the `nav` column on wide viewports. */
|
|
37
|
+
navWidth?: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** Name of the current page, shown after the title in the header. */
|
|
40
|
+
page?: string;
|
|
41
|
+
/** Leading header glyph. */
|
|
42
|
+
icon?: IconName;
|
|
43
|
+
onclose?: () => void;
|
|
44
|
+
closeLabel?: string;
|
|
45
|
+
/** Replaces the whole header row (title, page, close button). */
|
|
46
|
+
header?: Snippet;
|
|
47
|
+
/** Vertical page list beside the content on wide viewports; on narrow
|
|
48
|
+
* viewports it becomes a horizontal strip above the content unless
|
|
49
|
+
* `navMobile` is provided. */
|
|
50
|
+
nav?: Snippet;
|
|
51
|
+
navMobile?: Snippet;
|
|
52
|
+
children: Snippet;
|
|
53
|
+
footer?: Snippet;
|
|
54
|
+
class?: string;
|
|
55
|
+
style?: string;
|
|
56
|
+
} = $props();
|
|
57
|
+
|
|
58
|
+
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 8)}`;
|
|
59
|
+
|
|
60
|
+
let dialogEl = $state<HTMLDialogElement | null>(null);
|
|
61
|
+
const controlled = $derived(open !== undefined);
|
|
62
|
+
const shown = $derived(!controlled || open === true);
|
|
63
|
+
|
|
64
|
+
$effect(() => {
|
|
65
|
+
if (!dialogEl) return;
|
|
66
|
+
if (!controlled) {
|
|
67
|
+
dialogEl.showModal();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (open && !dialogEl.open) dialogEl.showModal();
|
|
71
|
+
else if (!open && dialogEl.open) dialogEl.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$effect(() => {
|
|
75
|
+
if (!browser || !shown) return;
|
|
76
|
+
const previous = document.body.style.overflow;
|
|
77
|
+
document.body.style.overflow = 'hidden';
|
|
78
|
+
return () => {
|
|
79
|
+
document.body.style.overflow = previous;
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function requestClose() {
|
|
84
|
+
if (controlled) open = false;
|
|
85
|
+
onclose?.();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onDialogClick(e: MouseEvent) {
|
|
89
|
+
if (e.target === dialogEl) requestClose();
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<dialog
|
|
94
|
+
bind:this={dialogEl}
|
|
95
|
+
data-tsu="Drawer"
|
|
96
|
+
class="drawer"
|
|
97
|
+
data-side={side}
|
|
98
|
+
aria-labelledby={header ? undefined : titleId}
|
|
99
|
+
aria-label={header ? title : undefined}
|
|
100
|
+
oncancel={(e) => {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
requestClose();
|
|
103
|
+
}}
|
|
104
|
+
onclose={() => {
|
|
105
|
+
if (controlled) open = false;
|
|
106
|
+
}}
|
|
107
|
+
onclick={onDialogClick}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
class={['panel', className]}
|
|
111
|
+
class:has-nav={Boolean(nav)}
|
|
112
|
+
style:--drawer-w={width}
|
|
113
|
+
style:--drawer-nav-w={navWidth}
|
|
114
|
+
{style}
|
|
115
|
+
>
|
|
116
|
+
{#if header}
|
|
117
|
+
{@render header()}
|
|
118
|
+
{:else}
|
|
119
|
+
<div class="panel-head">
|
|
120
|
+
{#if icon}<span class="panel-icon"><Icon name={icon} size={18} /></span>{/if}
|
|
121
|
+
<span id={titleId} class="panel-title truncate">{title}</span>
|
|
122
|
+
{#if page}<span class="panel-page truncate">{page}</span>{/if}
|
|
123
|
+
<div class="spacer"></div>
|
|
124
|
+
<IconButton icon="x" label={closeLabel} onclick={requestClose} />
|
|
125
|
+
</div>
|
|
126
|
+
{/if}
|
|
127
|
+
{#if nav}
|
|
128
|
+
<nav class="panel-nav" class:has-mobile={Boolean(navMobile)}>{@render nav()}</nav>
|
|
129
|
+
{/if}
|
|
130
|
+
{#if navMobile}
|
|
131
|
+
<nav class="panel-nav-mobile">{@render navMobile()}</nav>
|
|
132
|
+
{/if}
|
|
133
|
+
<div class="panel-body">
|
|
134
|
+
{@render children()}
|
|
135
|
+
</div>
|
|
136
|
+
{#if footer}
|
|
137
|
+
<div class="panel-foot">{@render footer()}</div>
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
</dialog>
|
|
141
|
+
|
|
142
|
+
<style>
|
|
143
|
+
.drawer {
|
|
144
|
+
margin: 0;
|
|
145
|
+
padding: 0;
|
|
146
|
+
border: 0;
|
|
147
|
+
background: none;
|
|
148
|
+
max-width: 100vw;
|
|
149
|
+
max-height: 100dvh;
|
|
150
|
+
width: 100%;
|
|
151
|
+
height: 100%;
|
|
152
|
+
color: var(--text);
|
|
153
|
+
display: flex;
|
|
154
|
+
justify-content: flex-end;
|
|
155
|
+
}
|
|
156
|
+
.drawer:not([open]) {
|
|
157
|
+
display: none;
|
|
158
|
+
}
|
|
159
|
+
.drawer[data-side='left'] {
|
|
160
|
+
justify-content: flex-start;
|
|
161
|
+
}
|
|
162
|
+
.drawer::backdrop {
|
|
163
|
+
background: rgba(0, 0, 0, 0.55);
|
|
164
|
+
animation: drawer-backdrop-in 0.18s var(--ease);
|
|
165
|
+
}
|
|
166
|
+
@keyframes drawer-backdrop-in {
|
|
167
|
+
from {
|
|
168
|
+
opacity: 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.panel {
|
|
173
|
+
display: grid;
|
|
174
|
+
grid-template-columns: minmax(0, 1fr);
|
|
175
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
176
|
+
grid-template-areas:
|
|
177
|
+
'head'
|
|
178
|
+
'nav'
|
|
179
|
+
'body'
|
|
180
|
+
'foot';
|
|
181
|
+
width: 100vw;
|
|
182
|
+
height: 100dvh;
|
|
183
|
+
background: var(--bg-elevated);
|
|
184
|
+
padding-top: var(--safe-top);
|
|
185
|
+
padding-bottom: var(--safe-bottom);
|
|
186
|
+
animation: drawer-slide-right 0.2s var(--ease);
|
|
187
|
+
}
|
|
188
|
+
.drawer[data-side='left'] .panel {
|
|
189
|
+
animation-name: drawer-slide-left;
|
|
190
|
+
}
|
|
191
|
+
@keyframes drawer-slide-right {
|
|
192
|
+
from {
|
|
193
|
+
transform: translateX(100%);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
@keyframes drawer-slide-left {
|
|
197
|
+
from {
|
|
198
|
+
transform: translateX(-100%);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
@media (prefers-reduced-motion: reduce) {
|
|
202
|
+
.drawer::backdrop,
|
|
203
|
+
.panel {
|
|
204
|
+
animation: none;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.panel-head {
|
|
209
|
+
grid-area: head;
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: var(--sp-2);
|
|
213
|
+
padding: var(--sp-4);
|
|
214
|
+
border-bottom: 1px solid var(--border);
|
|
215
|
+
}
|
|
216
|
+
.panel-icon {
|
|
217
|
+
display: inline-flex;
|
|
218
|
+
flex: none;
|
|
219
|
+
color: var(--text-muted);
|
|
220
|
+
}
|
|
221
|
+
.panel-title {
|
|
222
|
+
font-size: var(--fs-lg);
|
|
223
|
+
font-weight: var(--fw-semibold);
|
|
224
|
+
}
|
|
225
|
+
.panel-page {
|
|
226
|
+
color: var(--text-muted);
|
|
227
|
+
font-size: var(--fs-sm);
|
|
228
|
+
}
|
|
229
|
+
.panel-page::before {
|
|
230
|
+
content: '·';
|
|
231
|
+
margin-right: var(--sp-2);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.panel-nav,
|
|
235
|
+
.panel-nav-mobile {
|
|
236
|
+
grid-area: nav;
|
|
237
|
+
display: flex;
|
|
238
|
+
gap: var(--sp-1);
|
|
239
|
+
padding: var(--sp-2);
|
|
240
|
+
border-bottom: 1px solid var(--border);
|
|
241
|
+
overflow-x: auto;
|
|
242
|
+
scrollbar-width: thin;
|
|
243
|
+
}
|
|
244
|
+
.panel-nav.has-mobile {
|
|
245
|
+
display: none;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.panel-body {
|
|
249
|
+
grid-area: body;
|
|
250
|
+
padding: var(--sp-4);
|
|
251
|
+
overflow-y: auto;
|
|
252
|
+
-webkit-overflow-scrolling: touch;
|
|
253
|
+
}
|
|
254
|
+
.panel-foot {
|
|
255
|
+
grid-area: foot;
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
justify-content: flex-end;
|
|
259
|
+
gap: var(--sp-2);
|
|
260
|
+
padding: var(--sp-4);
|
|
261
|
+
border-top: 1px solid var(--border);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@media (min-width: 48rem) {
|
|
265
|
+
.panel {
|
|
266
|
+
width: min(var(--drawer-w), 100vw);
|
|
267
|
+
padding-top: 0;
|
|
268
|
+
padding-bottom: 0;
|
|
269
|
+
border-left: 1px solid var(--border-strong);
|
|
270
|
+
box-shadow: var(--shadow-lg);
|
|
271
|
+
grid-template-columns: minmax(0, 1fr);
|
|
272
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
273
|
+
grid-template-areas:
|
|
274
|
+
'head'
|
|
275
|
+
'body'
|
|
276
|
+
'foot';
|
|
277
|
+
}
|
|
278
|
+
.drawer[data-side='left'] .panel {
|
|
279
|
+
border-left: 0;
|
|
280
|
+
border-right: 1px solid var(--border-strong);
|
|
281
|
+
}
|
|
282
|
+
.panel.has-nav {
|
|
283
|
+
grid-template-columns: var(--drawer-nav-w) minmax(0, 1fr);
|
|
284
|
+
grid-template-areas:
|
|
285
|
+
'head head'
|
|
286
|
+
'nav body'
|
|
287
|
+
'foot foot';
|
|
288
|
+
}
|
|
289
|
+
.panel-nav,
|
|
290
|
+
.panel-nav.has-mobile {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
overflow-x: hidden;
|
|
294
|
+
overflow-y: auto;
|
|
295
|
+
border-bottom: 0;
|
|
296
|
+
border-right: 1px solid var(--border);
|
|
297
|
+
}
|
|
298
|
+
.panel-nav-mobile {
|
|
299
|
+
display: none;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type IconName } from '../atoms/Icon.svelte';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
/** Controlled visibility. When provided the `<dialog>` stays mounted and
|
|
5
|
+
* `showModal()`/`close()` follow the value; closing sets it back to false.
|
|
6
|
+
* Omit to open on mount (mount/unmount the component to show/hide). */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
side?: 'left' | 'right';
|
|
9
|
+
/** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
|
|
10
|
+
* 48rem the panel is always full-screen. */
|
|
11
|
+
width?: string;
|
|
12
|
+
/** Width of the `nav` column on wide viewports. */
|
|
13
|
+
navWidth?: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Name of the current page, shown after the title in the header. */
|
|
16
|
+
page?: string;
|
|
17
|
+
/** Leading header glyph. */
|
|
18
|
+
icon?: IconName;
|
|
19
|
+
onclose?: () => void;
|
|
20
|
+
closeLabel?: string;
|
|
21
|
+
/** Replaces the whole header row (title, page, close button). */
|
|
22
|
+
header?: Snippet;
|
|
23
|
+
/** Vertical page list beside the content on wide viewports; on narrow
|
|
24
|
+
* viewports it becomes a horizontal strip above the content unless
|
|
25
|
+
* `navMobile` is provided. */
|
|
26
|
+
nav?: Snippet;
|
|
27
|
+
navMobile?: Snippet;
|
|
28
|
+
children: Snippet;
|
|
29
|
+
footer?: Snippet;
|
|
30
|
+
class?: string;
|
|
31
|
+
style?: string;
|
|
32
|
+
};
|
|
33
|
+
declare const Drawer: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
34
|
+
type Drawer = ReturnType<typeof Drawer>;
|
|
35
|
+
export default Drawer;
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
icon?: import('../atoms/Icon.svelte').IconName;
|
|
6
6
|
danger?: boolean;
|
|
7
7
|
disabled?: boolean;
|
|
8
|
+
/** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
|
|
9
|
+
tag?: string;
|
|
10
|
+
tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
|
|
8
11
|
}
|
|
9
12
|
</script>
|
|
10
13
|
|
|
@@ -13,10 +16,12 @@
|
|
|
13
16
|
// `menuitem`s navigable with ↑/↓/Home/End, activated with Enter/Space (and
|
|
14
17
|
// click). Selecting an item runs its action and closes the menu. Focus moves
|
|
15
18
|
// to the first item when the menu opens. Escape / click-outside close it
|
|
16
|
-
// (inherited from the native popover).
|
|
19
|
+
// (inherited from the native popover). An item's `tag` renders as a soft
|
|
20
|
+
// Badge pill after the label; the `tag` snippet replaces that pill.
|
|
17
21
|
import type { ComponentProps, Snippet } from 'svelte';
|
|
18
22
|
import Popover from './Popover.svelte';
|
|
19
23
|
import Icon from '../atoms/Icon.svelte';
|
|
24
|
+
import Badge from '../atoms/Badge.svelte';
|
|
20
25
|
|
|
21
26
|
type PopoverProps = ComponentProps<typeof Popover>;
|
|
22
27
|
type TriggerChrome = Pick<
|
|
@@ -40,6 +45,7 @@
|
|
|
40
45
|
label,
|
|
41
46
|
items,
|
|
42
47
|
trigger,
|
|
48
|
+
tag,
|
|
43
49
|
placement = 'bottom-start',
|
|
44
50
|
variant,
|
|
45
51
|
tone,
|
|
@@ -58,6 +64,8 @@
|
|
|
58
64
|
label: string;
|
|
59
65
|
items: MenuItem[];
|
|
60
66
|
trigger: Snippet;
|
|
67
|
+
/** Custom trailing content for items that carry a `tag`. */
|
|
68
|
+
tag?: Snippet<[MenuItem]>;
|
|
61
69
|
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
62
70
|
} & TriggerChrome = $props();
|
|
63
71
|
|
|
@@ -133,6 +141,15 @@
|
|
|
133
141
|
>
|
|
134
142
|
{#if item.icon}<Icon name={item.icon} />{/if}
|
|
135
143
|
<span>{item.label}</span>
|
|
144
|
+
{#if item.tag !== undefined}
|
|
145
|
+
{#if tag}
|
|
146
|
+
{@render tag(item)}
|
|
147
|
+
{:else}
|
|
148
|
+
<span class="menu-tag">
|
|
149
|
+
<Badge size="xs" tone={item.tagTone ?? 'neutral'} border={false}>{item.tag}</Badge>
|
|
150
|
+
</span>
|
|
151
|
+
{/if}
|
|
152
|
+
{/if}
|
|
136
153
|
</button>
|
|
137
154
|
{/each}
|
|
138
155
|
</div>
|
|
@@ -166,6 +183,12 @@
|
|
|
166
183
|
.menu-item.danger {
|
|
167
184
|
color: var(--danger);
|
|
168
185
|
}
|
|
186
|
+
.menu-tag {
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
flex: none;
|
|
189
|
+
margin-inline-start: auto;
|
|
190
|
+
padding-inline-start: var(--sp-3);
|
|
191
|
+
}
|
|
169
192
|
.menu-item:disabled {
|
|
170
193
|
opacity: 0.45;
|
|
171
194
|
cursor: not-allowed;
|
|
@@ -4,6 +4,9 @@ export interface MenuItem {
|
|
|
4
4
|
icon?: import('../atoms/Icon.svelte').IconName;
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
disabled?: boolean;
|
|
7
|
+
/** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
|
|
8
|
+
tag?: string;
|
|
9
|
+
tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
|
|
7
10
|
}
|
|
8
11
|
import type { ComponentProps, Snippet } from 'svelte';
|
|
9
12
|
import Popover from './Popover.svelte';
|
|
@@ -13,6 +16,8 @@ type $$ComponentProps = {
|
|
|
13
16
|
label: string;
|
|
14
17
|
items: MenuItem[];
|
|
15
18
|
trigger: Snippet;
|
|
19
|
+
/** Custom trailing content for items that carry a `tag`. */
|
|
20
|
+
tag?: Snippet<[MenuItem]>;
|
|
16
21
|
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
17
22
|
} & TriggerChrome;
|
|
18
23
|
declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -3,40 +3,78 @@
|
|
|
3
3
|
value: string;
|
|
4
4
|
label: string;
|
|
5
5
|
hint?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
note?: string;
|
|
6
8
|
disabled?: boolean;
|
|
7
9
|
}
|
|
8
10
|
</script>
|
|
9
11
|
|
|
10
12
|
<script lang="ts">
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
|
|
15
|
+
// Native <input type="radio"> sharing one `name`: the browser handles
|
|
16
|
+
// single-selection, arrow keys and form participation. `variant="rows"`
|
|
17
|
+
// turns each option into a bordered row with optional trailing `action`
|
|
18
|
+
// (outside the label, so clicking it never toggles) and a `below` panel.
|
|
15
19
|
let {
|
|
16
20
|
options,
|
|
17
21
|
value = $bindable(),
|
|
18
22
|
name = `radio-${Math.random().toString(36).slice(2, 8)}`,
|
|
19
23
|
label,
|
|
24
|
+
variant = 'list',
|
|
25
|
+
action,
|
|
26
|
+
below,
|
|
20
27
|
class: klass = ''
|
|
21
28
|
}: {
|
|
22
29
|
options: RadioOption[];
|
|
23
30
|
value?: string;
|
|
24
31
|
name?: string;
|
|
25
32
|
label: string;
|
|
33
|
+
variant?: 'list' | 'rows';
|
|
34
|
+
action?: Snippet<[RadioOption]>;
|
|
35
|
+
below?: Snippet<[RadioOption]>;
|
|
26
36
|
class?: string;
|
|
27
37
|
} = $props();
|
|
28
38
|
</script>
|
|
29
39
|
|
|
30
|
-
<div
|
|
40
|
+
<div
|
|
41
|
+
role="radiogroup"
|
|
42
|
+
aria-label={label}
|
|
43
|
+
class="radio-group {klass}"
|
|
44
|
+
class:rows={variant === 'rows'}
|
|
45
|
+
data-tsu="RadioGroup"
|
|
46
|
+
data-variant={variant}
|
|
47
|
+
>
|
|
31
48
|
{#each options as o (o.value)}
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
{#if variant === 'rows'}
|
|
50
|
+
<div class="row-wrap">
|
|
51
|
+
<div class="row" class:selected={o.value === value} class:disabled={o.disabled}>
|
|
52
|
+
<label class="radio">
|
|
53
|
+
<input type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
|
|
54
|
+
<span class="dot-ctl" aria-hidden="true"></span>
|
|
55
|
+
<span class="texts">
|
|
56
|
+
<span class="label-line">
|
|
57
|
+
<span class="label-text">{o.label}</span>
|
|
58
|
+
{#if o.note}<span class="note">{o.note}</span>{/if}
|
|
59
|
+
</span>
|
|
60
|
+
{#if o.description}<span class="description">{o.description}</span>{/if}
|
|
61
|
+
{#if o.hint}<span class="hint">{o.hint}</span>{/if}
|
|
62
|
+
</span>
|
|
63
|
+
</label>
|
|
64
|
+
{#if action}<span class="action">{@render action(o)}</span>{/if}
|
|
65
|
+
</div>
|
|
66
|
+
{#if below}<div class="below">{@render below(o)}</div>{/if}
|
|
67
|
+
</div>
|
|
68
|
+
{:else}
|
|
69
|
+
<label class="radio" class:disabled={o.disabled}>
|
|
70
|
+
<input type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
|
|
71
|
+
<span class="dot-ctl" aria-hidden="true"></span>
|
|
72
|
+
<span class="texts">
|
|
73
|
+
<span class="label-text">{o.label}</span>
|
|
74
|
+
{#if o.hint}<span class="hint">{o.hint}</span>{/if}
|
|
75
|
+
</span>
|
|
76
|
+
</label>
|
|
77
|
+
{/if}
|
|
40
78
|
{/each}
|
|
41
79
|
</div>
|
|
42
80
|
|
|
@@ -54,8 +92,12 @@
|
|
|
54
92
|
font-size: var(--fs-sm);
|
|
55
93
|
color: var(--text);
|
|
56
94
|
}
|
|
57
|
-
.radio.disabled
|
|
95
|
+
.radio.disabled,
|
|
96
|
+
.row.disabled .radio {
|
|
58
97
|
cursor: not-allowed;
|
|
98
|
+
}
|
|
99
|
+
.radio.disabled,
|
|
100
|
+
.row.disabled {
|
|
59
101
|
opacity: 0.45;
|
|
60
102
|
}
|
|
61
103
|
input {
|
|
@@ -102,9 +144,73 @@
|
|
|
102
144
|
display: flex;
|
|
103
145
|
flex-direction: column;
|
|
104
146
|
gap: 1px;
|
|
147
|
+
min-width: 0;
|
|
105
148
|
}
|
|
106
149
|
.hint {
|
|
107
150
|
font-size: var(--fs-xs);
|
|
108
151
|
color: var(--text-faint);
|
|
109
152
|
}
|
|
153
|
+
|
|
154
|
+
.rows {
|
|
155
|
+
gap: var(--sp-1);
|
|
156
|
+
}
|
|
157
|
+
.row-wrap {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
}
|
|
161
|
+
.row {
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: flex-start;
|
|
164
|
+
gap: var(--sp-2);
|
|
165
|
+
padding: var(--pad, var(--sp-2) var(--sp-3));
|
|
166
|
+
border: 1px solid var(--border);
|
|
167
|
+
border-radius: var(--r-md);
|
|
168
|
+
transition:
|
|
169
|
+
border-color 0.12s var(--ease),
|
|
170
|
+
background-color 0.12s var(--ease);
|
|
171
|
+
}
|
|
172
|
+
.row.selected {
|
|
173
|
+
border-color: var(--accent-dim);
|
|
174
|
+
background: var(--bg-elevated);
|
|
175
|
+
}
|
|
176
|
+
.row .radio {
|
|
177
|
+
flex: 1 1 auto;
|
|
178
|
+
min-width: 0;
|
|
179
|
+
}
|
|
180
|
+
.row .texts {
|
|
181
|
+
flex: 1 1 auto;
|
|
182
|
+
}
|
|
183
|
+
.label-line {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: baseline;
|
|
186
|
+
gap: var(--sp-2);
|
|
187
|
+
min-width: 0;
|
|
188
|
+
}
|
|
189
|
+
.row .label-text {
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
}
|
|
192
|
+
.note {
|
|
193
|
+
font-size: var(--fs-xs);
|
|
194
|
+
color: var(--text-faint);
|
|
195
|
+
white-space: nowrap;
|
|
196
|
+
}
|
|
197
|
+
.description {
|
|
198
|
+
font-size: var(--fs-xs);
|
|
199
|
+
color: var(--text-muted);
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
text-overflow: ellipsis;
|
|
202
|
+
white-space: nowrap;
|
|
203
|
+
}
|
|
204
|
+
.action {
|
|
205
|
+
flex: none;
|
|
206
|
+
margin-left: auto;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
}
|
|
210
|
+
.below {
|
|
211
|
+
padding: var(--sp-2) 0 0 var(--sp-3);
|
|
212
|
+
}
|
|
213
|
+
.below:empty {
|
|
214
|
+
display: none;
|
|
215
|
+
}
|
|
110
216
|
</style>
|
|
@@ -2,13 +2,19 @@ export interface RadioOption {
|
|
|
2
2
|
value: string;
|
|
3
3
|
label: string;
|
|
4
4
|
hint?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
note?: string;
|
|
5
7
|
disabled?: boolean;
|
|
6
8
|
}
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
7
10
|
type $$ComponentProps = {
|
|
8
11
|
options: RadioOption[];
|
|
9
12
|
value?: string;
|
|
10
13
|
name?: string;
|
|
11
14
|
label: string;
|
|
15
|
+
variant?: 'list' | 'rows';
|
|
16
|
+
action?: Snippet<[RadioOption]>;
|
|
17
|
+
below?: Snippet<[RadioOption]>;
|
|
12
18
|
class?: string;
|
|
13
19
|
};
|
|
14
20
|
declare const RadioGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// it, `sticky` pins it under the app header and publishes its height as
|
|
5
5
|
// `--section-header-h` on the parent so sibling sticky content can offset
|
|
6
6
|
// itself. `collapsible` turns the title into a disclosure button that
|
|
7
|
-
// toggles the `children` block rendered beneath.
|
|
7
|
+
// toggles the `children` block rendered beneath. `variant="group"` is the
|
|
8
|
+
// non-wrapping list-group row: `lead` · title · count · flexible rule · actions.
|
|
8
9
|
import type { Snippet } from 'svelte';
|
|
9
10
|
import Heading from '../atoms/Heading.svelte';
|
|
10
11
|
import Icon, { type IconName } from '../atoms/Icon.svelte';
|
|
@@ -16,6 +17,8 @@
|
|
|
16
17
|
let {
|
|
17
18
|
title,
|
|
18
19
|
label,
|
|
20
|
+
variant = 'default',
|
|
21
|
+
lead,
|
|
19
22
|
level = 2,
|
|
20
23
|
size,
|
|
21
24
|
subtitle,
|
|
@@ -36,6 +39,9 @@
|
|
|
36
39
|
title?: string;
|
|
37
40
|
/** Alias for `title`. */
|
|
38
41
|
label?: string;
|
|
42
|
+
variant?: 'default' | 'group';
|
|
43
|
+
/** Leading content before the title (status dot, badge); group rows. */
|
|
44
|
+
lead?: Snippet;
|
|
39
45
|
level?: 1 | 2 | 3 | 4;
|
|
40
46
|
size?: Size;
|
|
41
47
|
subtitle?: string;
|
|
@@ -76,6 +82,7 @@
|
|
|
76
82
|
data-tsu="SectionHeader"
|
|
77
83
|
class="section-header sh-{tone} {klass}"
|
|
78
84
|
class:sh-divider={divider}
|
|
85
|
+
class:sh-group={variant === 'group'}
|
|
79
86
|
class:sh-sticky={sticky}
|
|
80
87
|
class:sh-uppercase={uppercase}
|
|
81
88
|
class:sh-open={open}
|
|
@@ -99,6 +106,9 @@
|
|
|
99
106
|
{#if subtitle}
|
|
100
107
|
<Text variant="caption" class="sh-subtitle">{subtitle}</Text>
|
|
101
108
|
{/if}
|
|
109
|
+
{#if variant === 'group'}
|
|
110
|
+
<span class="sh-rule" aria-hidden="true"></span>
|
|
111
|
+
{/if}
|
|
102
112
|
{#if actions}
|
|
103
113
|
<div class="sh-actions">{@render actions()}</div>
|
|
104
114
|
{/if}
|
|
@@ -109,6 +119,9 @@
|
|
|
109
119
|
</div>
|
|
110
120
|
|
|
111
121
|
{#snippet head()}
|
|
122
|
+
{#if lead}
|
|
123
|
+
<span class="sh-lead">{@render lead()}</span>
|
|
124
|
+
{/if}
|
|
112
125
|
{#if hue !== undefined}
|
|
113
126
|
<span class="sh-swatch" style:--sh-hue={hue} aria-hidden="true"></span>
|
|
114
127
|
{/if}
|
|
@@ -148,6 +161,27 @@
|
|
|
148
161
|
padding-bottom: var(--sp-2);
|
|
149
162
|
border-bottom: 1px solid var(--border);
|
|
150
163
|
}
|
|
164
|
+
.sh-group > .sh-row {
|
|
165
|
+
flex-wrap: nowrap;
|
|
166
|
+
gap: var(--sp-2);
|
|
167
|
+
}
|
|
168
|
+
.sh-lead {
|
|
169
|
+
display: inline-flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: var(--sp-2);
|
|
172
|
+
flex: none;
|
|
173
|
+
}
|
|
174
|
+
.sh-rule {
|
|
175
|
+
flex: 1 1 auto;
|
|
176
|
+
min-width: var(--sp-4);
|
|
177
|
+
height: 1px;
|
|
178
|
+
background: var(--border);
|
|
179
|
+
}
|
|
180
|
+
.sh-group .sh-actions {
|
|
181
|
+
flex: none;
|
|
182
|
+
flex-wrap: nowrap;
|
|
183
|
+
margin-inline-start: 0;
|
|
184
|
+
}
|
|
151
185
|
.sh-sticky {
|
|
152
186
|
position: sticky;
|
|
153
187
|
top: var(--sticky-offset, var(--header-h, 0));
|
|
@@ -6,6 +6,9 @@ type $$ComponentProps = {
|
|
|
6
6
|
title?: string;
|
|
7
7
|
/** Alias for `title`. */
|
|
8
8
|
label?: string;
|
|
9
|
+
variant?: 'default' | 'group';
|
|
10
|
+
/** Leading content before the title (status dot, badge); group rows. */
|
|
11
|
+
lead?: Snippet;
|
|
9
12
|
level?: 1 | 2 | 3 | 4;
|
|
10
13
|
size?: Size;
|
|
11
14
|
subtitle?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// A timestamp you can read
|
|
3
|
-
// renders in the chosen `mode` (date / time / datetime / relative / iso
|
|
2
|
+
// A timestamp you can read six ways and inspect in one place. Inline it
|
|
3
|
+
// renders in the chosen `mode` (date / time / datetime / relative / iso /
|
|
4
|
+
// short-iso),
|
|
4
5
|
// in the viewer's zone or UTC via the `utc` flag; by default clicking it
|
|
5
6
|
// opens a popover that lays out the same instant as ISO-UTC, local, relative,
|
|
6
7
|
// the IANA zone and the unix epoch — so the one value answers "when,
|
|
@@ -45,8 +46,8 @@
|
|
|
45
46
|
value: TimeInput | null | undefined;
|
|
46
47
|
/** Inline display mode. Default 'datetime'. */
|
|
47
48
|
mode?: TimestampMode;
|
|
48
|
-
/** Render the date/time/datetime modes in UTC rather than the
|
|
49
|
-
* zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
49
|
+
/** Render the date/time/datetime/short-iso modes in UTC rather than the
|
|
50
|
+
* viewer's zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
50
51
|
* (zoneless). Handy for calendar-date fields, where a local midnight-UTC
|
|
51
52
|
* value would otherwise show the wrong day. */
|
|
52
53
|
utc?: boolean;
|
|
@@ -101,7 +102,8 @@
|
|
|
101
102
|
{ id: 'time', name: 'Time' },
|
|
102
103
|
{ id: 'datetime', name: 'Date+time' },
|
|
103
104
|
{ id: 'relative', name: 'Relative' },
|
|
104
|
-
{ id: 'iso', name: 'ISO' }
|
|
105
|
+
{ id: 'iso', name: 'ISO' },
|
|
106
|
+
{ id: 'short-iso', name: 'YYYY-MM-DD' }
|
|
105
107
|
];
|
|
106
108
|
|
|
107
109
|
const rows = $derived(
|
|
@@ -7,8 +7,8 @@ type $$ComponentProps = {
|
|
|
7
7
|
value: TimeInput | null | undefined;
|
|
8
8
|
/** Inline display mode. Default 'datetime'. */
|
|
9
9
|
mode?: TimestampMode;
|
|
10
|
-
/** Render the date/time/datetime modes in UTC rather than the
|
|
11
|
-
* zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
10
|
+
/** Render the date/time/datetime/short-iso modes in UTC rather than the
|
|
11
|
+
* viewer's zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
12
12
|
* (zoneless). Handy for calendar-date fields, where a local midnight-UTC
|
|
13
13
|
* value would otherwise show the wrong day. */
|
|
14
14
|
utc?: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { default as Link } from './components/atoms/Link.svelte';
|
|
|
17
17
|
export { default as Progress } from './components/atoms/Progress.svelte';
|
|
18
18
|
export { default as Scrim } from './components/atoms/Scrim.svelte';
|
|
19
19
|
export { default as SegmentedProgress, type ProgressSegment, } from './components/atoms/SegmentedProgress.svelte';
|
|
20
|
+
export type { SelectOption } from './components/atoms/Select.svelte';
|
|
20
21
|
export { default as Select } from './components/atoms/Select.svelte';
|
|
21
22
|
export { default as Skeleton } from './components/atoms/Skeleton.svelte';
|
|
22
23
|
export { default as Slider } from './components/atoms/Slider.svelte';
|
|
@@ -41,6 +42,7 @@ export { default as CapBar } from './components/molecules/CapBar.svelte';
|
|
|
41
42
|
export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
|
|
42
43
|
export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
|
|
43
44
|
export { default as CopyButton } from './components/molecules/CopyButton.svelte';
|
|
45
|
+
export { default as Drawer } from './components/molecules/Drawer.svelte';
|
|
44
46
|
export { default as Dropzone } from './components/molecules/Dropzone.svelte';
|
|
45
47
|
export { default as EmptyState } from './components/molecules/EmptyState.svelte';
|
|
46
48
|
export { default as Field } from './components/molecules/Field.svelte';
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,7 @@ export { default as CapBar } from './components/molecules/CapBar.svelte';
|
|
|
48
48
|
export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
|
|
49
49
|
export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
|
|
50
50
|
export { default as CopyButton } from './components/molecules/CopyButton.svelte';
|
|
51
|
+
export { default as Drawer } from './components/molecules/Drawer.svelte';
|
|
51
52
|
export { default as Dropzone } from './components/molecules/Dropzone.svelte';
|
|
52
53
|
export { default as EmptyState } from './components/molecules/EmptyState.svelte';
|
|
53
54
|
// ---- molecules ----
|
package/dist/query/parser.js
CHANGED
package/dist/timestamp.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export type TimeInput = Date | number | string;
|
|
2
2
|
/**
|
|
3
3
|
* Which slice of the instant the inline text shows. Orthogonal to the zone:
|
|
4
|
-
* `date`/`time`/`datetime` honour the `utc` flag, while `iso` is
|
|
5
|
-
* (by definition) and `relative` is zoneless.
|
|
4
|
+
* `date`/`time`/`datetime`/`short-iso` honour the `utc` flag, while `iso` is
|
|
5
|
+
* always UTC (by definition) and `relative` is zoneless.
|
|
6
6
|
*/
|
|
7
|
-
export type TimestampMode = 'date' | 'time' | 'datetime' | 'relative' | 'iso';
|
|
7
|
+
export type TimestampMode = 'date' | 'time' | 'datetime' | 'relative' | 'iso' | 'short-iso';
|
|
8
8
|
/**
|
|
9
9
|
* Coerce a loose input to a Date, or null when it's missing or can't be parsed.
|
|
10
10
|
* Accepting null/undefined lets callers pass an optional field straight through
|
|
@@ -20,6 +20,12 @@ export declare function toISO(d: Date): string;
|
|
|
20
20
|
* without the off-by-one a local midnight-UTC value otherwise shows.
|
|
21
21
|
*/
|
|
22
22
|
export declare function toLocale(d: Date, parts?: 'date' | 'time' | 'datetime', utc?: boolean): string;
|
|
23
|
+
/**
|
|
24
|
+
* Calendar date as `YYYY-MM-DD`, locale-independent — in UTC or, with
|
|
25
|
+
* `utc=false`, the viewer's zone (so a local evening past midnight UTC still
|
|
26
|
+
* reads as the viewer's day).
|
|
27
|
+
*/
|
|
28
|
+
export declare function toShortISO(d: Date, utc?: boolean): string;
|
|
23
29
|
/** Unix epoch in whole seconds. */
|
|
24
30
|
export declare function toEpochSeconds(d: Date): number;
|
|
25
31
|
/** The viewer's IANA time zone — `Asia/Tokyo`, `UTC`, … */
|
|
@@ -38,7 +44,7 @@ export declare function localTimeZone(): string;
|
|
|
38
44
|
export declare function relativeTime(value: TimeInput | null | undefined, now?: number, precision?: boolean): string;
|
|
39
45
|
/**
|
|
40
46
|
* Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
|
|
41
|
-
* zone for the date/time/datetime modes (ignored by `iso`, always
|
|
42
|
-
* `relative`, zoneless). Returns '' for unparseable input.
|
|
47
|
+
* zone for the date/time/datetime/short-iso modes (ignored by `iso`, always
|
|
48
|
+
* UTC, and `relative`, zoneless). Returns '' for unparseable input.
|
|
43
49
|
*/
|
|
44
50
|
export declare function formatTimestamp(value: TimeInput | null | undefined, mode: TimestampMode, now?: number, utc?: boolean, precision?: boolean): string;
|
package/dist/timestamp.js
CHANGED
|
@@ -41,6 +41,18 @@ export function toLocale(d, parts = 'datetime', utc = false) {
|
|
|
41
41
|
}
|
|
42
42
|
return new Intl.DateTimeFormat(undefined, opts).format(d);
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Calendar date as `YYYY-MM-DD`, locale-independent — in UTC or, with
|
|
46
|
+
* `utc=false`, the viewer's zone (so a local evening past midnight UTC still
|
|
47
|
+
* reads as the viewer's day).
|
|
48
|
+
*/
|
|
49
|
+
export function toShortISO(d, utc = true) {
|
|
50
|
+
const y = utc ? d.getUTCFullYear() : d.getFullYear();
|
|
51
|
+
const m = (utc ? d.getUTCMonth() : d.getMonth()) + 1;
|
|
52
|
+
const day = utc ? d.getUTCDate() : d.getDate();
|
|
53
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
54
|
+
return `${pad(y, 4)}-${pad(m)}-${pad(day)}`;
|
|
55
|
+
}
|
|
44
56
|
/** Unix epoch in whole seconds. */
|
|
45
57
|
export function toEpochSeconds(d) {
|
|
46
58
|
return Math.floor(d.getTime() / 1000);
|
|
@@ -103,8 +115,8 @@ export function relativeTime(value, now = Date.now(), precision = false) {
|
|
|
103
115
|
}
|
|
104
116
|
/**
|
|
105
117
|
* Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
|
|
106
|
-
* zone for the date/time/datetime modes (ignored by `iso`, always
|
|
107
|
-
* `relative`, zoneless). Returns '' for unparseable input.
|
|
118
|
+
* zone for the date/time/datetime/short-iso modes (ignored by `iso`, always
|
|
119
|
+
* UTC, and `relative`, zoneless). Returns '' for unparseable input.
|
|
108
120
|
*/
|
|
109
121
|
export function formatTimestamp(value, mode, now, utc = false, precision = false) {
|
|
110
122
|
const d = toDate(value);
|
|
@@ -113,6 +125,8 @@ export function formatTimestamp(value, mode, now, utc = false, precision = false
|
|
|
113
125
|
switch (mode) {
|
|
114
126
|
case 'iso':
|
|
115
127
|
return toISO(d);
|
|
128
|
+
case 'short-iso':
|
|
129
|
+
return toShortISO(d, utc);
|
|
116
130
|
case 'date':
|
|
117
131
|
case 'time':
|
|
118
132
|
case 'datetime':
|
package/package.json
CHANGED