@dorsk/tsumikit 0.81.0 → 0.82.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/README.md +180 -2
- package/dist/breakpoints.d.ts +15 -0
- package/dist/breakpoints.js +14 -0
- package/dist/components/atoms/Badge.svelte +1 -0
- package/dist/components/atoms/Button.svelte +16 -5
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +1 -0
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Text.svelte +4 -4
- package/dist/components/atoms/Text.svelte.d.ts +1 -1
- package/dist/components/layouts/AppShell.svelte +14 -0
- package/dist/components/layouts/Backdrop.svelte +152 -0
- package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
- package/dist/components/layouts/NavSection.svelte +1 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -1
- package/dist/components/molecules/CapBar.svelte +1 -1
- package/dist/components/molecules/Composer.svelte +1 -1
- package/dist/components/molecules/Dropzone.svelte +1 -1
- package/dist/components/molecules/EmptyState.svelte +13 -6
- package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FilterInput.svelte +5 -3
- package/dist/components/molecules/Meter.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +3 -1
- package/dist/components/molecules/Modal.svelte +4 -4
- package/dist/components/molecules/Pagination.svelte +26 -2
- package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
- package/dist/components/molecules/Prose.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +2 -2
- package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
- package/dist/components/molecules/SectionHeader.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +55 -22
- package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
- package/dist/components/molecules/SortableList.svelte +1 -1
- package/dist/components/molecules/StatusBar.svelte +2 -2
- package/dist/components/molecules/Tabs.svelte +32 -0
- package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
- package/dist/components/molecules/ThemePicker.svelte +3 -3
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
- package/dist/components/organisms/DataTable.svelte +2 -2
- package/dist/dev-audit.d.ts +19 -0
- package/dist/dev-audit.js +259 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/media.svelte.d.ts +8 -0
- package/dist/media.svelte.js +26 -0
- package/dist/pending.svelte.d.ts +16 -0
- package/dist/pending.svelte.js +75 -0
- package/dist/stores/toast.svelte.d.ts +22 -0
- package/dist/stores/toast.svelte.js +54 -2
- package/dist/styles/app.css +2 -1
- package/dist/styles/font.css +19 -0
- package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
- package/dist/styles/fonts/inter.woff2 +0 -0
- package/dist/styles/themes.css +58 -58
- package/dist/styles/tokens.css +15 -9
- package/dist/url-state.svelte.d.ts +22 -0
- package/dist/url-state.svelte.js +64 -0
- package/lint/breakpoints.mjs +33 -0
- package/lint/cli.mjs +99 -0
- package/lint/context.mjs +242 -0
- package/lint/index.mjs +97 -0
- package/lint/rules/breakpoints.mjs +25 -0
- package/lint/rules/img-alt.mjs +13 -0
- package/lint/rules/motion.mjs +85 -0
- package/lint/rules/no-raw-color.mjs +59 -0
- package/lint/rules/no-raw-control.mjs +23 -0
- package/lint/rules/no-raw-font.mjs +64 -0
- package/lint/rules/one-primary.mjs +74 -0
- package/lint/rules/spacing-scale.mjs +66 -0
- package/package.json +12 -2
- package/principles/01-visibility-of-status.md +32 -0
- package/principles/02-one-primary-action.md +31 -0
- package/principles/03-emphasise-by-de-emphasising.md +31 -0
- package/principles/04-labels-last-resort.md +30 -0
- package/principles/05-tint-the-greys.md +30 -0
- package/principles/06-depth-from-shade.md +30 -0
- package/principles/07-one-accent.md +31 -0
- package/principles/08-spacing-scale.md +30 -0
- package/principles/09-group-with-space.md +31 -0
- package/principles/10-real-typeface-fixed-scale.md +33 -0
- package/principles/11-mono-only-for-code.md +30 -0
- package/principles/12-measure.md +29 -0
- package/principles/13-imagery-sets-the-mood.md +32 -0
- package/principles/14-instant-calm-feedback.md +32 -0
- package/principles/15-undo-over-confirm.md +30 -0
- package/principles/16-empty-states.md +31 -0
- package/principles/17-errors-in-plain-words.md +31 -0
- package/principles/18-match-the-real-world.md +31 -0
- package/principles/19-consistency-and-standards.md +32 -0
- package/principles/20-error-prevention.md +31 -0
- package/principles/21-recognition-over-recall.md +31 -0
- package/principles/22-flexibility-and-shortcuts.md +31 -0
- package/principles/23-url-is-state.md +31 -0
- package/principles/24-links-are-links.md +31 -0
- package/principles/25-visible-focus.md +32 -0
- package/principles/26-generous-targets.md +31 -0
- package/principles/27-cheap-motion-respect-preferences.md +32 -0
- package/principles/28-less-but-better.md +31 -0
- package/principles/29-colour-is-not-the-only-cue.md +31 -0
- package/principles/30-help-where-needed.md +31 -0
- package/principles/README.md +62 -0
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// • an optional action area (slot via `action`, or a built-in button when
|
|
10
10
|
// `actionLabel`+`onAction` are passed)
|
|
11
11
|
// Everything is token-driven. `size` picks the layout: `default`, `compact`
|
|
12
|
-
// (tighter spacing), or `inline` (a single muted line). `loading`
|
|
13
|
-
// chip for a spinner
|
|
12
|
+
// (tighter spacing), or `inline` (a single muted line). `loading` marks the
|
|
13
|
+
// root busy at once and swaps the chip for a spinner once `pending()` lets it.
|
|
14
14
|
import type { Snippet } from 'svelte';
|
|
15
|
+
import { pending } from '../../pending.svelte';
|
|
15
16
|
import Button from '../atoms/Button.svelte';
|
|
16
17
|
import Icon, { type IconName } from '../atoms/Icon.svelte';
|
|
17
18
|
import Spinner from '../atoms/Spinner.svelte';
|
|
@@ -29,6 +30,8 @@
|
|
|
29
30
|
compact = false,
|
|
30
31
|
size,
|
|
31
32
|
loading = false,
|
|
33
|
+
loadingDelay,
|
|
34
|
+
loadingMin,
|
|
32
35
|
class: klass = '',
|
|
33
36
|
// Raw SVG markup for a custom glyph — passed straight to `Icon`.
|
|
34
37
|
iconChildren,
|
|
@@ -46,6 +49,9 @@
|
|
|
46
49
|
compact?: boolean;
|
|
47
50
|
size?: 'inline' | 'compact' | 'default';
|
|
48
51
|
loading?: boolean;
|
|
52
|
+
/** Show delay / minimum visible time for the spinner (ms); 0 follows `loading` at once. */
|
|
53
|
+
loadingDelay?: number;
|
|
54
|
+
loadingMin?: number;
|
|
49
55
|
class?: string;
|
|
50
56
|
iconChildren?: Snippet;
|
|
51
57
|
action?: Snippet;
|
|
@@ -53,18 +59,19 @@
|
|
|
53
59
|
} = $props();
|
|
54
60
|
|
|
55
61
|
const sz = $derived(size ?? (compact ? 'compact' : 'default'));
|
|
62
|
+
const spin = pending(() => loading, () => ({ delay: loadingDelay, min: loadingMin }));
|
|
56
63
|
</script>
|
|
57
64
|
|
|
58
65
|
<div
|
|
59
66
|
class="empty empty-{canonicalTone(tone)} {klass}"
|
|
60
67
|
class:empty-compact={sz === 'compact'}
|
|
61
68
|
class:empty-inline={sz === 'inline'}
|
|
62
|
-
class:empty-loading={
|
|
69
|
+
class:empty-loading={spin.visible}
|
|
63
70
|
aria-busy={loading ? 'true' : undefined}
|
|
64
71
|
data-tsu="EmptyState"
|
|
65
72
|
{...rest}
|
|
66
73
|
>
|
|
67
|
-
{#if
|
|
74
|
+
{#if spin.visible}
|
|
68
75
|
<span class="empty-chip">
|
|
69
76
|
<Spinner />
|
|
70
77
|
</span>
|
|
@@ -161,7 +168,7 @@
|
|
|
161
168
|
font-size: var(--fs-sm);
|
|
162
169
|
}
|
|
163
170
|
.empty-inline .empty-desc {
|
|
164
|
-
max-
|
|
171
|
+
max-inline-size: none;
|
|
165
172
|
}
|
|
166
173
|
.empty-inline .empty-action {
|
|
167
174
|
margin-top: 0;
|
|
@@ -175,7 +182,7 @@
|
|
|
175
182
|
}
|
|
176
183
|
.empty-desc {
|
|
177
184
|
margin: 0;
|
|
178
|
-
max-
|
|
185
|
+
max-inline-size: var(--measure, 68ch);
|
|
179
186
|
font-size: var(--fs-sm);
|
|
180
187
|
color: var(--text-muted);
|
|
181
188
|
line-height: var(--lh-normal);
|
|
@@ -12,6 +12,9 @@ type $$ComponentProps = {
|
|
|
12
12
|
compact?: boolean;
|
|
13
13
|
size?: 'inline' | 'compact' | 'default';
|
|
14
14
|
loading?: boolean;
|
|
15
|
+
/** Show delay / minimum visible time for the spinner (ms); 0 follows `loading` at once. */
|
|
16
|
+
loadingDelay?: number;
|
|
17
|
+
loadingMin?: number;
|
|
15
18
|
class?: string;
|
|
16
19
|
iconChildren?: Snippet;
|
|
17
20
|
action?: Snippet;
|
|
@@ -549,6 +549,7 @@
|
|
|
549
549
|
background: transparent;
|
|
550
550
|
color: var(--text);
|
|
551
551
|
font-family: var(--font-numeric);
|
|
552
|
+
font-variant-numeric: tabular-nums;
|
|
552
553
|
font-size: var(--control-fs-md);
|
|
553
554
|
outline: none;
|
|
554
555
|
min-width: 0;
|
|
@@ -576,7 +577,7 @@
|
|
|
576
577
|
border: 0;
|
|
577
578
|
background: transparent;
|
|
578
579
|
font-size: var(--fi-icon);
|
|
579
|
-
color: var(--text-
|
|
580
|
+
color: var(--text-muted);
|
|
580
581
|
cursor: pointer;
|
|
581
582
|
padding: var(--sp-1);
|
|
582
583
|
border-radius: var(--r-sm);
|
|
@@ -632,7 +633,7 @@
|
|
|
632
633
|
font-size: var(--fs-xs);
|
|
633
634
|
text-transform: uppercase;
|
|
634
635
|
letter-spacing: 0.06em;
|
|
635
|
-
color: var(--text-
|
|
636
|
+
color: var(--text-muted);
|
|
636
637
|
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
|
637
638
|
}
|
|
638
639
|
.fi__opt {
|
|
@@ -659,12 +660,13 @@
|
|
|
659
660
|
font-weight: var(--fw-medium);
|
|
660
661
|
}
|
|
661
662
|
.fi__opt-hint {
|
|
662
|
-
color: var(--text-
|
|
663
|
+
color: var(--text-muted);
|
|
663
664
|
font-size: var(--fs-xs);
|
|
664
665
|
}
|
|
665
666
|
.fi__opt-code {
|
|
666
667
|
margin-left: auto;
|
|
667
668
|
font-family: var(--font-numeric);
|
|
669
|
+
font-variant-numeric: tabular-nums;
|
|
668
670
|
font-size: var(--fs-xs);
|
|
669
671
|
color: var(--text-muted);
|
|
670
672
|
background: var(--bg-elevated-2);
|
|
@@ -208,6 +208,7 @@
|
|
|
208
208
|
align-items: baseline;
|
|
209
209
|
gap: var(--sp-1);
|
|
210
210
|
font-family: var(--font-numeric);
|
|
211
|
+
font-variant-numeric: tabular-nums;
|
|
211
212
|
line-height: var(--lh-none);
|
|
212
213
|
}
|
|
213
214
|
.metric-num {
|
|
@@ -228,7 +229,7 @@
|
|
|
228
229
|
}
|
|
229
230
|
.metric-sub {
|
|
230
231
|
font-size: var(--fs-xs);
|
|
231
|
-
color: var(--text-
|
|
232
|
+
color: var(--text-muted);
|
|
232
233
|
line-height: var(--lh-snug);
|
|
233
234
|
}
|
|
234
235
|
:global(.metric-sm) .metric-num {
|
|
@@ -275,6 +276,7 @@
|
|
|
275
276
|
align-items: baseline;
|
|
276
277
|
gap: var(--sp-1) var(--sp-3);
|
|
277
278
|
font-family: var(--font-numeric);
|
|
279
|
+
font-variant-numeric: tabular-nums;
|
|
278
280
|
font-size: var(--fs-xs);
|
|
279
281
|
color: var(--text-muted);
|
|
280
282
|
}
|
|
@@ -271,7 +271,7 @@
|
|
|
271
271
|
opacity: 0;
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
|
-
@media (min-width:
|
|
274
|
+
@media (min-width: 40rem) {
|
|
275
275
|
.modal {
|
|
276
276
|
align-items: center;
|
|
277
277
|
padding: var(--sp-6);
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
translate: 0 100%;
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
|
-
@media (min-width:
|
|
312
|
+
@media (min-width: 40rem) {
|
|
313
313
|
.sheet {
|
|
314
314
|
border-radius: var(--r-lg);
|
|
315
315
|
padding-bottom: 0;
|
|
@@ -394,7 +394,7 @@
|
|
|
394
394
|
.sheet-resize {
|
|
395
395
|
display: none;
|
|
396
396
|
}
|
|
397
|
-
@media (min-width:
|
|
397
|
+
@media (min-width: 40rem) {
|
|
398
398
|
.sheet-resize {
|
|
399
399
|
display: block;
|
|
400
400
|
position: absolute;
|
|
@@ -439,7 +439,7 @@
|
|
|
439
439
|
background: var(--accent);
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
|
-
@media (min-width:
|
|
442
|
+
@media (min-width: 40rem) and (hover: hover) and (pointer: fine) {
|
|
443
443
|
.sheet-resize:hover::before {
|
|
444
444
|
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
445
445
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// next when the container is narrower than 24rem.
|
|
7
7
|
import Text from '../atoms/Text.svelte';
|
|
8
8
|
import { playSound } from '../../sound';
|
|
9
|
+
import { isModifiedClick } from '../../url-state.svelte';
|
|
9
10
|
import IconButton from './IconButton.svelte';
|
|
10
11
|
|
|
11
12
|
let {
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
limit,
|
|
16
17
|
total,
|
|
17
18
|
onchange,
|
|
19
|
+
href,
|
|
18
20
|
siblings = 1,
|
|
19
21
|
showEdges = true,
|
|
20
22
|
showRange = false,
|
|
@@ -34,6 +36,8 @@
|
|
|
34
36
|
limit?: number;
|
|
35
37
|
total?: number;
|
|
36
38
|
onchange?: (page: number) => void;
|
|
39
|
+
/** Pages become real links (`<a aria-current="page">`): middle-click opens a page in a new tab. */
|
|
40
|
+
href?: (page: number) => string;
|
|
37
41
|
/** Numbered pages shown on each side of the current one. */
|
|
38
42
|
siblings?: number;
|
|
39
43
|
/** Always show the first and last page. */
|
|
@@ -105,6 +109,9 @@
|
|
|
105
109
|
if (offsetMode) offset = (next - 1) * (limit as number);
|
|
106
110
|
onchange?.(next);
|
|
107
111
|
}
|
|
112
|
+
function onlinkclick(e: MouseEvent, p: number) {
|
|
113
|
+
if (!isModifiedClick(e)) go(p);
|
|
114
|
+
}
|
|
108
115
|
</script>
|
|
109
116
|
|
|
110
117
|
<nav
|
|
@@ -121,14 +128,26 @@
|
|
|
121
128
|
label="Previous page"
|
|
122
129
|
box={size === 'sm' ? 'sm' : 'md'}
|
|
123
130
|
disabled={current <= 1}
|
|
131
|
+
href={href?.(current - 1)}
|
|
124
132
|
sound={false}
|
|
125
|
-
onclick={() =>
|
|
133
|
+
onclick={(e) => onlinkclick(e, current - 1)}
|
|
126
134
|
/>
|
|
127
135
|
<ol class="pages">
|
|
128
136
|
{#each items as item, i (item === ELLIPSIS ? `e${i}` : item)}
|
|
129
137
|
<li>
|
|
130
138
|
{#if item === ELLIPSIS}
|
|
131
139
|
<span class="ellipsis" aria-hidden="true">…</span>
|
|
140
|
+
{:else if href}
|
|
141
|
+
<a
|
|
142
|
+
href={href(item)}
|
|
143
|
+
class="page"
|
|
144
|
+
class:on={item === current}
|
|
145
|
+
aria-current={item === current ? 'page' : undefined}
|
|
146
|
+
aria-label="Page {item}"
|
|
147
|
+
onclick={(e) => onlinkclick(e, item)}
|
|
148
|
+
>
|
|
149
|
+
{item}
|
|
150
|
+
</a>
|
|
132
151
|
{:else}
|
|
133
152
|
<button
|
|
134
153
|
type="button"
|
|
@@ -150,8 +169,9 @@
|
|
|
150
169
|
label="Next page"
|
|
151
170
|
box={size === 'sm' ? 'sm' : 'md'}
|
|
152
171
|
disabled={current >= count}
|
|
172
|
+
href={href?.(current + 1)}
|
|
153
173
|
sound={false}
|
|
154
|
-
onclick={() =>
|
|
174
|
+
onclick={(e) => onlinkclick(e, current + 1)}
|
|
155
175
|
/>
|
|
156
176
|
{#if showRange}
|
|
157
177
|
<Text class="range" size="sm" numeric tone="muted">{rangeText}</Text>
|
|
@@ -186,6 +206,9 @@
|
|
|
186
206
|
padding: 0;
|
|
187
207
|
}
|
|
188
208
|
.page {
|
|
209
|
+
display: inline-flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
justify-content: center;
|
|
189
212
|
min-width: var(--box-md);
|
|
190
213
|
height: var(--box-md);
|
|
191
214
|
padding: 0 var(--control-px-md);
|
|
@@ -195,6 +218,7 @@
|
|
|
195
218
|
color: var(--text-muted);
|
|
196
219
|
font-size: var(--control-fs-md);
|
|
197
220
|
font-variant-numeric: tabular-nums;
|
|
221
|
+
text-decoration: none;
|
|
198
222
|
cursor: pointer;
|
|
199
223
|
--page-scale-dur: var(--dur-fast);
|
|
200
224
|
transition:
|
|
@@ -8,6 +8,8 @@ type $$ComponentProps = {
|
|
|
8
8
|
limit?: number;
|
|
9
9
|
total?: number;
|
|
10
10
|
onchange?: (page: number) => void;
|
|
11
|
+
/** Pages become real links (`<a aria-current="page">`): middle-click opens a page in a new tab. */
|
|
12
|
+
href?: (page: number) => string;
|
|
11
13
|
/** Numbered pages shown on each side of the current one. */
|
|
12
14
|
siblings?: number;
|
|
13
15
|
/** Always show the first and last page. */
|
|
@@ -26,6 +28,6 @@ type $$ComponentProps = {
|
|
|
26
28
|
sound?: boolean;
|
|
27
29
|
class?: string;
|
|
28
30
|
};
|
|
29
|
-
declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "
|
|
31
|
+
declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "page" | "offset">;
|
|
30
32
|
type Pagination = ReturnType<typeof Pagination>;
|
|
31
33
|
export default Pagination;
|
|
@@ -277,7 +277,7 @@
|
|
|
277
277
|
}
|
|
278
278
|
.hint {
|
|
279
279
|
font-size: var(--fs-xs);
|
|
280
|
-
color: var(--text-
|
|
280
|
+
color: var(--text-muted);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
.rows {
|
|
@@ -324,7 +324,7 @@
|
|
|
324
324
|
}
|
|
325
325
|
.note {
|
|
326
326
|
font-size: var(--fs-xs);
|
|
327
|
-
color: var(--text-
|
|
327
|
+
color: var(--text-muted);
|
|
328
328
|
}
|
|
329
329
|
.description {
|
|
330
330
|
font-size: var(--fs-xs);
|
|
@@ -45,7 +45,7 @@ declare function $$render<T>(): {
|
|
|
45
45
|
style?: string;
|
|
46
46
|
};
|
|
47
47
|
exports: {};
|
|
48
|
-
bindings: "
|
|
48
|
+
bindings: "value" | "open";
|
|
49
49
|
slots: {};
|
|
50
50
|
events: {};
|
|
51
51
|
};
|
|
@@ -53,7 +53,7 @@ declare class __sveltets_Render<T> {
|
|
|
53
53
|
props(): ReturnType<typeof $$render<T>>['props'];
|
|
54
54
|
events(): ReturnType<typeof $$render<T>>['events'];
|
|
55
55
|
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
56
|
-
bindings(): "
|
|
56
|
+
bindings(): "value" | "open";
|
|
57
57
|
exports(): {};
|
|
58
58
|
}
|
|
59
59
|
interface $$IsomorphicComponent {
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
<Heading {level} {size} class="sh-title">{text}</Heading>
|
|
141
141
|
{/if}
|
|
142
142
|
{#if count !== undefined}
|
|
143
|
-
<Text tone="
|
|
143
|
+
<Text tone="muted" weight="normal" numeric class="sh-count">{count}</Text>
|
|
144
144
|
{/if}
|
|
145
145
|
{/snippet}
|
|
146
146
|
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
icon?: import('../atoms/Icon.svelte').IconName;
|
|
12
12
|
/** Greyed out, not selectable, skipped by keyboard navigation. */
|
|
13
13
|
disabled?: boolean;
|
|
14
|
+
/** Render this segment as a real link; overrides the control's `href`. */
|
|
15
|
+
href?: string;
|
|
14
16
|
}
|
|
15
17
|
</script>
|
|
16
18
|
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
import type { Attachment } from 'svelte/attachments';
|
|
29
31
|
import Icon from '../atoms/Icon.svelte';
|
|
30
32
|
import { playSound } from '../../sound';
|
|
33
|
+
import { isModifiedClick } from '../../url-state.svelte';
|
|
31
34
|
import { formatSegmentCount } from './segmented-control-count.js';
|
|
32
35
|
import { nextEnabledSegment } from './segmented-control-keyboard.js';
|
|
33
36
|
|
|
@@ -35,6 +38,7 @@
|
|
|
35
38
|
options,
|
|
36
39
|
value = $bindable(),
|
|
37
40
|
onchange,
|
|
41
|
+
href,
|
|
38
42
|
justify,
|
|
39
43
|
wrap = false,
|
|
40
44
|
// `icon` packs the segments into compact square buttons (label hidden as an
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
value?: string;
|
|
61
65
|
/** Fires after a selection (click or keyboard). */
|
|
62
66
|
onchange?: (value: string) => void;
|
|
67
|
+
/** Segments become real links (`<a aria-current>`): middle-click opens the segment's URL in a new tab. */
|
|
68
|
+
href?: (value: string) => string;
|
|
63
69
|
/** Pin the control to the start/end of a flex row. */
|
|
64
70
|
justify?: 'start' | 'end';
|
|
65
71
|
/** Allow segments to wrap onto multiple lines. */
|
|
@@ -100,6 +106,7 @@
|
|
|
100
106
|
options.find((o) => o.value === value && !o.disabled) ?? options.find((o) => !o.disabled)
|
|
101
107
|
);
|
|
102
108
|
const segId = (val: string) => `${baseId}-${val}`;
|
|
109
|
+
const linkTo = (o: SegmentOption) => (o.disabled ? undefined : (o.href ?? href?.(o.value)));
|
|
103
110
|
|
|
104
111
|
// A pill wider than its container falls back to the `scroll` layout; the
|
|
105
112
|
// class is dropped again once the row fits so the touch slab stays unclipped.
|
|
@@ -185,6 +192,16 @@
|
|
|
185
192
|
}
|
|
186
193
|
</script>
|
|
187
194
|
|
|
195
|
+
{#snippet content(o: SegmentOption, selected: boolean)}
|
|
196
|
+
{#if optionSnippet}
|
|
197
|
+
{@render optionSnippet(o, selected)}
|
|
198
|
+
{:else}
|
|
199
|
+
{#if o.icon}<Icon name={o.icon} />{/if}
|
|
200
|
+
{#if o.label}<span class="seg-label">{o.label}</span>{/if}
|
|
201
|
+
{#if o.count !== undefined}<span class="seg-count">{formatSegmentCount(o.count)}</span>{/if}
|
|
202
|
+
{/if}
|
|
203
|
+
{/snippet}
|
|
204
|
+
|
|
188
205
|
<div
|
|
189
206
|
bind:this={listEl}
|
|
190
207
|
role="radiogroup"
|
|
@@ -209,27 +226,42 @@
|
|
|
209
226
|
<span bind:this={thumb} class="seg-thumb" aria-hidden="true"></span>
|
|
210
227
|
{#each options as o (o.value)}
|
|
211
228
|
{@const selected = value === o.value}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
{@const link = linkTo(o)}
|
|
230
|
+
{#if link !== undefined}
|
|
231
|
+
<a
|
|
232
|
+
href={link}
|
|
233
|
+
id={segId(o.value)}
|
|
234
|
+
role="radio"
|
|
235
|
+
aria-checked={selected}
|
|
236
|
+
aria-current={selected ? 'page' : undefined}
|
|
237
|
+
aria-label={variant === 'icon' && !o.label ? o.value : o.label}
|
|
238
|
+
tabindex={stop?.value === o.value ? 0 : -1}
|
|
239
|
+
class="seg-item"
|
|
240
|
+
class:has-label={!!o.label}
|
|
241
|
+
class:selected
|
|
242
|
+
onclick={(e) => {
|
|
243
|
+
if (!isModifiedClick(e)) select(o.value);
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{@render content(o, selected)}
|
|
247
|
+
</a>
|
|
248
|
+
{:else}
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
id={segId(o.value)}
|
|
252
|
+
role="radio"
|
|
253
|
+
aria-checked={selected}
|
|
254
|
+
aria-label={variant === 'icon' && !o.label ? o.value : o.label}
|
|
255
|
+
tabindex={stop?.value === o.value ? 0 : -1}
|
|
256
|
+
class="seg-item"
|
|
257
|
+
class:has-label={!!o.label}
|
|
258
|
+
class:selected
|
|
259
|
+
disabled={o.disabled}
|
|
260
|
+
onclick={() => select(o.value)}
|
|
261
|
+
>
|
|
262
|
+
{@render content(o, selected)}
|
|
263
|
+
</button>
|
|
264
|
+
{/if}
|
|
233
265
|
{/each}
|
|
234
266
|
</div>
|
|
235
267
|
|
|
@@ -354,6 +386,7 @@
|
|
|
354
386
|
line-height: var(--lh-none);
|
|
355
387
|
--icon-size: var(--control-icon-md);
|
|
356
388
|
white-space: nowrap;
|
|
389
|
+
text-decoration: none;
|
|
357
390
|
cursor: pointer;
|
|
358
391
|
transition:
|
|
359
392
|
background var(--dur-fast) var(--ease),
|
|
@@ -455,7 +488,7 @@
|
|
|
455
488
|
padding: 0 var(--sp-1-5);
|
|
456
489
|
border-radius: var(--r-pill);
|
|
457
490
|
background: var(--bg-elevated-2);
|
|
458
|
-
color: var(--text-
|
|
491
|
+
color: var(--text-muted);
|
|
459
492
|
font-size: var(--fs-xs);
|
|
460
493
|
font-weight: var(--fw-semibold);
|
|
461
494
|
font-variant-numeric: tabular-nums;
|
|
@@ -10,6 +10,8 @@ export interface SegmentOption {
|
|
|
10
10
|
icon?: import('../atoms/Icon.svelte').IconName;
|
|
11
11
|
/** Greyed out, not selectable, skipped by keyboard navigation. */
|
|
12
12
|
disabled?: boolean;
|
|
13
|
+
/** Render this segment as a real link; overrides the control's `href`. */
|
|
14
|
+
href?: string;
|
|
13
15
|
}
|
|
14
16
|
import type { ControlSize } from '../../size';
|
|
15
17
|
import type { Snippet } from 'svelte';
|
|
@@ -18,6 +20,8 @@ type $$ComponentProps = {
|
|
|
18
20
|
value?: string;
|
|
19
21
|
/** Fires after a selection (click or keyboard). */
|
|
20
22
|
onchange?: (value: string) => void;
|
|
23
|
+
/** Segments become real links (`<a aria-current>`): middle-click opens the segment's URL in a new tab. */
|
|
24
|
+
href?: (value: string) => string;
|
|
21
25
|
/** Pin the control to the start/end of a flex row. */
|
|
22
26
|
justify?: 'start' | 'end';
|
|
23
27
|
/** Allow segments to wrap onto multiple lines. */
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
color: var(--text-muted);
|
|
66
66
|
}
|
|
67
67
|
.status-bar.idle {
|
|
68
|
-
color: var(--text-
|
|
68
|
+
color: var(--text-muted);
|
|
69
69
|
}
|
|
70
70
|
.status-bar :global(.status-glyph) {
|
|
71
71
|
flex: none;
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
display: inline-flex;
|
|
86
86
|
align-items: center;
|
|
87
87
|
gap: var(--sp-2);
|
|
88
|
-
color: var(--text-
|
|
88
|
+
color: var(--text-muted);
|
|
89
89
|
font-variant-numeric: tabular-nums;
|
|
90
90
|
white-space: nowrap;
|
|
91
91
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
title?: string;
|
|
12
12
|
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
13
13
|
closable?: boolean;
|
|
14
|
+
/** Render this tab as a real link; overrides the strip's `href`. */
|
|
15
|
+
href?: string;
|
|
14
16
|
/**
|
|
15
17
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
16
18
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -37,11 +39,13 @@
|
|
|
37
39
|
import Icon from '../atoms/Icon.svelte';
|
|
38
40
|
import IconButton from './IconButton.svelte';
|
|
39
41
|
import { playSound } from '../../sound';
|
|
42
|
+
import { isModifiedClick } from '../../url-state.svelte';
|
|
40
43
|
|
|
41
44
|
let {
|
|
42
45
|
tabs,
|
|
43
46
|
value = $bindable(),
|
|
44
47
|
label = 'Tabs',
|
|
48
|
+
href,
|
|
45
49
|
panel,
|
|
46
50
|
closable = false,
|
|
47
51
|
onclose,
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
tabs: TabItem[];
|
|
61
65
|
value?: string;
|
|
62
66
|
label?: string;
|
|
67
|
+
/** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
|
|
68
|
+
href?: (id: string) => string;
|
|
63
69
|
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
64
70
|
panel?: Snippet<[string]>;
|
|
65
71
|
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
@@ -101,6 +107,10 @@
|
|
|
101
107
|
function canClose(t: TabItem): boolean {
|
|
102
108
|
return t.closable ?? closable;
|
|
103
109
|
}
|
|
110
|
+
function linkTo(t: TabItem): string | undefined {
|
|
111
|
+
if (t.disabled || canClose(t)) return undefined;
|
|
112
|
+
return t.href ?? href?.(t.id);
|
|
113
|
+
}
|
|
104
114
|
|
|
105
115
|
// One indicator slides under the selected tab on translate/scale (never
|
|
106
116
|
// width/left). It snaps on mount and on reflow, and only animates on a
|
|
@@ -288,6 +298,27 @@
|
|
|
288
298
|
}}
|
|
289
299
|
/>
|
|
290
300
|
</div>
|
|
301
|
+
{:else if linkTo(t) !== undefined}
|
|
302
|
+
<a
|
|
303
|
+
{...t.attrs as HTMLAttributes<HTMLAnchorElement>}
|
|
304
|
+
href={linkTo(t)}
|
|
305
|
+
role="tab"
|
|
306
|
+
id="{baseId}-tab-{t.id}"
|
|
307
|
+
title={t.title ?? t.attrs?.title}
|
|
308
|
+
aria-selected={value === t.id}
|
|
309
|
+
aria-current={value === t.id ? 'page' : undefined}
|
|
310
|
+
aria-controls={panelId}
|
|
311
|
+
tabindex={stop?.id === t.id ? 0 : -1}
|
|
312
|
+
class="tab"
|
|
313
|
+
class:selected={value === t.id}
|
|
314
|
+
onclick={(e) => {
|
|
315
|
+
if (isModifiedClick(e)) return;
|
|
316
|
+
inkFrom(e);
|
|
317
|
+
select(t.id);
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
{@render body(t)}
|
|
321
|
+
</a>
|
|
291
322
|
{:else}
|
|
292
323
|
<button
|
|
293
324
|
type="button"
|
|
@@ -362,6 +393,7 @@
|
|
|
362
393
|
border: none;
|
|
363
394
|
background: none;
|
|
364
395
|
color: var(--text-muted);
|
|
396
|
+
text-decoration: none;
|
|
365
397
|
font-size: var(--control-fs-md);
|
|
366
398
|
font-weight: var(--fw-medium);
|
|
367
399
|
--icon-size: var(--control-icon-md);
|
|
@@ -10,6 +10,8 @@ export interface TabItem {
|
|
|
10
10
|
title?: string;
|
|
11
11
|
/** Per-tab override of the strip's `closable` (pin one tab open). */
|
|
12
12
|
closable?: boolean;
|
|
13
|
+
/** Render this tab as a real link; overrides the strip's `href`. */
|
|
14
|
+
href?: string;
|
|
13
15
|
/**
|
|
14
16
|
* Extra attributes for this tab's button — `data-*`, `title`…
|
|
15
17
|
* The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
|
|
@@ -23,6 +25,8 @@ type Own = {
|
|
|
23
25
|
tabs: TabItem[];
|
|
24
26
|
value?: string;
|
|
25
27
|
label?: string;
|
|
28
|
+
/** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
|
|
29
|
+
href?: (id: string) => string;
|
|
26
30
|
/** Content for the active tab. Omit it for a strip-only tablist. */
|
|
27
31
|
panel?: Snippet<[string]>;
|
|
28
32
|
/** Every tab gets a close control; Delete and middle-click close too. */
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
--c-bg: #0f1115;
|
|
182
182
|
--c-surface: #21262f;
|
|
183
183
|
--c-text: #e6e9ef;
|
|
184
|
-
--c-accent: #
|
|
184
|
+
--c-accent: #4cc3f0;
|
|
185
185
|
}
|
|
186
186
|
.q {
|
|
187
187
|
display: block;
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
font-size: var(--fs-xs);
|
|
213
213
|
text-transform: uppercase;
|
|
214
214
|
letter-spacing: 0.06em;
|
|
215
|
-
color: var(--text-
|
|
215
|
+
color: var(--text-muted);
|
|
216
216
|
}
|
|
217
217
|
.grid {
|
|
218
218
|
display: grid;
|
|
@@ -297,7 +297,7 @@
|
|
|
297
297
|
}
|
|
298
298
|
.auto-help {
|
|
299
299
|
font-size: var(--fs-xs);
|
|
300
|
-
color: var(--text-
|
|
300
|
+
color: var(--text-muted);
|
|
301
301
|
white-space: normal;
|
|
302
302
|
}
|
|
303
303
|
.auto-pair {
|