@dorsk/tsumikit 0.71.0 → 0.72.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 +33 -5
- package/dist/autoresize.js +13 -0
- package/dist/clipboard.js +12 -0
- package/dist/components/atoms/Artwork.svelte +4 -2
- package/dist/components/atoms/Avatar.svelte +4 -3
- package/dist/components/atoms/Badge.svelte +13 -0
- package/dist/components/atoms/Button.svelte +25 -9
- package/dist/components/atoms/Card.svelte +2 -0
- package/dist/components/atoms/Dot.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +2 -0
- package/dist/components/atoms/Icon.svelte.d.ts +2 -0
- package/dist/components/atoms/Kbd.svelte +28 -16
- package/dist/components/atoms/Link.svelte +6 -4
- package/dist/components/atoms/Scrim.svelte +18 -1
- package/dist/components/atoms/Slider.svelte +11 -1
- package/dist/components/atoms/Swatch.svelte +12 -0
- package/dist/components/layouts/AppShell.svelte +70 -39
- package/dist/components/layouts/Container.svelte +25 -3
- package/dist/components/layouts/MasterDetail.svelte +59 -33
- package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
- package/dist/components/layouts/NavItem.svelte +8 -4
- package/dist/components/layouts/NavSection.svelte +8 -2
- package/dist/components/layouts/ResizablePanel.svelte +14 -0
- package/dist/components/layouts/TabBar.svelte +217 -0
- package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
- package/dist/components/layouts/Toolbar.svelte +5 -0
- package/dist/components/layouts/toolbar-roving.d.ts +16 -0
- package/dist/components/layouts/toolbar-roving.js +47 -1
- package/dist/components/molecules/FilterInput.svelte +7 -0
- package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
- package/dist/components/molecules/PullToRefresh.svelte +205 -0
- package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
- package/dist/components/molecules/SwipeRow.svelte +349 -0
- package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
- package/dist/components/organisms/ConversationFeed.svelte +22 -8
- package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
- package/dist/components/organisms/DataTable.svelte +22 -5
- package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
- package/dist/components/organisms/FilterSearchBar.svelte +4 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown.js +15 -7
- package/dist/styles/reset.css +30 -0
- package/dist/styles/syntax.css +0 -29
- package/package.json +1 -1
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// scrim; AppShell renders the hamburger toggle for you (shown only on
|
|
10
10
|
// mobile), locks body scroll while open, closes on Escape / scrim click /
|
|
11
11
|
// a same-origin link click inside it, and marks the rest of the page
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// (all but the hamburger) `inert`. Opening focuses the first control in
|
|
13
|
+
// the drawer; closing returns focus to the toggle. `bind:open` to drive it.
|
|
14
|
+
// Switching to desktop auto-closes the drawer. Safe-area insets are honored;
|
|
15
|
+
// `--shell-header-h` is the rendered header height (incl. the top inset).
|
|
14
16
|
import { untrack, type Snippet } from 'svelte';
|
|
15
17
|
import { browser } from '../../env';
|
|
16
18
|
import IconButton from '../molecules/IconButton.svelte';
|
|
19
|
+
import { resizeHandle } from './resizable-panel-frame.js';
|
|
17
20
|
|
|
18
21
|
let {
|
|
19
22
|
header,
|
|
@@ -71,6 +74,8 @@
|
|
|
71
74
|
|
|
72
75
|
let isMobile = $state(false);
|
|
73
76
|
let aside = $state<HTMLElement>();
|
|
77
|
+
let menuBtn = $state<HTMLElement>();
|
|
78
|
+
let asideW = $state(0);
|
|
74
79
|
|
|
75
80
|
function setOpen(next: boolean) {
|
|
76
81
|
if (open === next) return;
|
|
@@ -103,39 +108,17 @@
|
|
|
103
108
|
}
|
|
104
109
|
let sideW = $state<number | null>(loadW());
|
|
105
110
|
let dragging = $state(false);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
function startDrag(e: PointerEvent) {
|
|
109
|
-
dragging = true;
|
|
110
|
-
lastX = e.clientX;
|
|
111
|
-
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
}
|
|
114
|
-
function onDrag(e: PointerEvent) {
|
|
115
|
-
if (!dragging) return;
|
|
116
|
-
lastX = e.clientX;
|
|
117
|
-
if (rafId) return;
|
|
118
|
-
rafId = requestAnimationFrame(() => {
|
|
119
|
-
rafId = 0;
|
|
120
|
-
sideW = Math.round(Math.max(minSidebar, Math.min(lastX, maxSidebar)));
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
function endDrag(e: PointerEvent) {
|
|
124
|
-
if (!dragging) return;
|
|
125
|
-
dragging = false;
|
|
126
|
-
if (rafId) {
|
|
127
|
-
cancelAnimationFrame(rafId);
|
|
128
|
-
rafId = 0;
|
|
129
|
-
}
|
|
130
|
-
try {
|
|
131
|
-
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
132
|
-
} catch {
|
|
133
|
-
/* already released */
|
|
134
|
-
}
|
|
135
|
-
if (browser && sidebarWidthKey && sideW != null) localStorage.setItem(sidebarWidthKey, String(sideW));
|
|
111
|
+
function persistW(w: number) {
|
|
112
|
+
if (browser && sidebarWidthKey) localStorage.setItem(sidebarWidthKey, String(w));
|
|
136
113
|
}
|
|
114
|
+
const measureW = () => sideW ?? aside?.getBoundingClientRect().width ?? 0;
|
|
137
115
|
// The effective desktop width: a resized px value wins over the prop default.
|
|
138
116
|
const widthCss = $derived(resizableSidebar && sideW != null ? `${sideW}px` : sidebarWidth);
|
|
117
|
+
$effect(() => {
|
|
118
|
+
if (!resizableSidebar || !aside) return;
|
|
119
|
+
void widthCss;
|
|
120
|
+
asideW = Math.round(measureW());
|
|
121
|
+
});
|
|
139
122
|
|
|
140
123
|
$effect(() => {
|
|
141
124
|
if (!browser) return;
|
|
@@ -167,6 +150,24 @@
|
|
|
167
150
|
|
|
168
151
|
// Background is inert (untabbable, hidden from AT) when the drawer is open.
|
|
169
152
|
const bgInert = $derived(open && isMobile);
|
|
153
|
+
|
|
154
|
+
const FOCUSABLE =
|
|
155
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
156
|
+
let wasInert = false;
|
|
157
|
+
$effect(() => {
|
|
158
|
+
const active = bgInert;
|
|
159
|
+
if (active === wasInert) return;
|
|
160
|
+
wasInert = active;
|
|
161
|
+
untrack(() => {
|
|
162
|
+
if (active) {
|
|
163
|
+
aside?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const current = document.activeElement;
|
|
167
|
+
if (current && current !== document.body && !aside?.contains(current)) return;
|
|
168
|
+
menuBtn?.querySelector<HTMLElement>('button')?.focus();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
170
171
|
</script>
|
|
171
172
|
|
|
172
173
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && setOpen(false)} />
|
|
@@ -183,7 +184,7 @@
|
|
|
183
184
|
{#if sidebar}
|
|
184
185
|
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
185
186
|
element, not a :global reach into the IconButton's button. -->
|
|
186
|
-
<div class="shell-menu-btn">
|
|
187
|
+
<div class="shell-menu-btn" bind:this={menuBtn}>
|
|
187
188
|
<IconButton
|
|
188
189
|
icon="menu"
|
|
189
190
|
label="Toggle navigation"
|
|
@@ -192,7 +193,9 @@
|
|
|
192
193
|
/>
|
|
193
194
|
</div>
|
|
194
195
|
{/if}
|
|
195
|
-
{
|
|
196
|
+
<div class="shell-header-content" inert={bgInert || undefined}>
|
|
197
|
+
{@render header?.()}
|
|
198
|
+
</div>
|
|
196
199
|
</header>
|
|
197
200
|
|
|
198
201
|
{#if sidebar}
|
|
@@ -213,15 +216,25 @@
|
|
|
213
216
|
>
|
|
214
217
|
{@render sidebar()}
|
|
215
218
|
{#if resizableSidebar}
|
|
219
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
216
220
|
<div
|
|
217
221
|
class="shell-sidebar-resize"
|
|
218
222
|
role="separator"
|
|
223
|
+
tabindex="0"
|
|
219
224
|
aria-label="Resize sidebar"
|
|
220
225
|
aria-orientation="vertical"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
aria-valuemin={minSidebar}
|
|
227
|
+
aria-valuemax={maxSidebar}
|
|
228
|
+
aria-valuenow={sideW ?? asideW}
|
|
229
|
+
use:resizeHandle={{
|
|
230
|
+
side: 'left',
|
|
231
|
+
min: minSidebar,
|
|
232
|
+
max: maxSidebar,
|
|
233
|
+
measure: measureW,
|
|
234
|
+
onwidth: (w) => (sideW = w),
|
|
235
|
+
oncommit: persistW,
|
|
236
|
+
onactive: (active) => (dragging = active)
|
|
237
|
+
}}
|
|
225
238
|
></div>
|
|
226
239
|
{/if}
|
|
227
240
|
</aside>
|
|
@@ -238,6 +251,7 @@
|
|
|
238
251
|
|
|
239
252
|
<style>
|
|
240
253
|
.shell {
|
|
254
|
+
--shell-header-h: calc(var(--header-h) + var(--safe-top));
|
|
241
255
|
min-height: 100dvh;
|
|
242
256
|
display: grid;
|
|
243
257
|
grid-template-columns: minmax(0, 1fr);
|
|
@@ -256,7 +270,7 @@
|
|
|
256
270
|
align-items: center;
|
|
257
271
|
gap: var(--sp-3);
|
|
258
272
|
min-width: 0;
|
|
259
|
-
height: var(--header-h);
|
|
273
|
+
height: var(--shell-header-h);
|
|
260
274
|
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
261
275
|
padding-top: var(--safe-top);
|
|
262
276
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
@@ -266,6 +280,12 @@
|
|
|
266
280
|
.shell-header > :global(*) {
|
|
267
281
|
min-width: 0;
|
|
268
282
|
}
|
|
283
|
+
.shell-header-content {
|
|
284
|
+
display: contents;
|
|
285
|
+
}
|
|
286
|
+
.shell-header-content > :global(*) {
|
|
287
|
+
min-width: 0;
|
|
288
|
+
}
|
|
269
289
|
.shell-menu-btn {
|
|
270
290
|
display: inline-flex;
|
|
271
291
|
align-items: center;
|
|
@@ -399,9 +419,14 @@
|
|
|
399
419
|
transition: background 0.12s var(--ease);
|
|
400
420
|
}
|
|
401
421
|
.shell-sidebar-resize:hover::after,
|
|
422
|
+
.shell-sidebar-resize:focus-visible::after,
|
|
402
423
|
.shell.dragging .shell-sidebar-resize::after {
|
|
403
424
|
background: var(--accent);
|
|
404
425
|
}
|
|
426
|
+
.shell-sidebar-resize:focus-visible {
|
|
427
|
+
outline: 2px solid var(--accent);
|
|
428
|
+
outline-offset: -2px;
|
|
429
|
+
}
|
|
405
430
|
}
|
|
406
431
|
|
|
407
432
|
/* Tablet: the persistent column narrows to an icon rail so the content and
|
|
@@ -417,4 +442,10 @@
|
|
|
417
442
|
display: none;
|
|
418
443
|
}
|
|
419
444
|
}
|
|
445
|
+
|
|
446
|
+
@media (prefers-reduced-motion: reduce) {
|
|
447
|
+
.shell-sidebar {
|
|
448
|
+
transition: none;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
420
451
|
</style>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// `--container-gutter` so children can bleed to the column edge with
|
|
5
5
|
// `margin-inline: calc(-1 * var(--container-gutter))`.
|
|
6
6
|
import type { Snippet } from 'svelte';
|
|
7
|
+
import { browser } from '../../env';
|
|
7
8
|
|
|
8
9
|
let {
|
|
9
10
|
as = 'div',
|
|
@@ -36,8 +37,26 @@
|
|
|
36
37
|
[key: string]: unknown;
|
|
37
38
|
} = $props();
|
|
38
39
|
|
|
40
|
+
// `100vw` includes the reserved scrollbar gutter (`scrollbar-gutter: stable`).
|
|
41
|
+
let scrollbarW = $state(0);
|
|
42
|
+
$effect(() => {
|
|
43
|
+
if (!browser || !fullWidth) return;
|
|
44
|
+
const sync = () => {
|
|
45
|
+
scrollbarW = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
|
|
46
|
+
};
|
|
47
|
+
sync();
|
|
48
|
+
window.addEventListener('resize', sync);
|
|
49
|
+
const ro = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(sync);
|
|
50
|
+
ro?.observe(document.documentElement);
|
|
51
|
+
return () => {
|
|
52
|
+
window.removeEventListener('resize', sync);
|
|
53
|
+
ro?.disconnect();
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
const style = $derived.by(() => {
|
|
40
58
|
const vars: string[] = [];
|
|
59
|
+
if (fullWidth && scrollbarW) vars.push(`--ct-scrollbar: ${scrollbarW}px`);
|
|
41
60
|
if (!fullWidth && size && size !== 'none') vars.push(`--ct-max: ${size}`);
|
|
42
61
|
if (gutter) vars.push(`--container-gutter: ${gutter}`);
|
|
43
62
|
if (fullWidth && inset) {
|
|
@@ -87,12 +106,15 @@
|
|
|
87
106
|
|
|
88
107
|
/* Break out of any centered ancestor to span the viewport (minus `inset`).
|
|
89
108
|
`margin-inline: calc(50% - 50vw)` pulls each edge out to the viewport,
|
|
90
|
-
keeping the element in normal flow (no transform/overflow side-effects).
|
|
109
|
+
keeping the element in normal flow (no transform/overflow side-effects).
|
|
110
|
+
--ct-scrollbar (measured) takes the scrollbar gutter out of `100vw`. */
|
|
91
111
|
.ct.full {
|
|
92
112
|
--ct-inset-l: 0px;
|
|
93
113
|
--ct-inset-r: 0px;
|
|
114
|
+
--ct-vw: calc(100vw - var(--ct-scrollbar, 0px));
|
|
94
115
|
max-width: none;
|
|
95
|
-
width: calc(
|
|
96
|
-
margin-inline: calc(50% -
|
|
116
|
+
width: calc(var(--ct-vw) - var(--ct-inset-l) - var(--ct-inset-r));
|
|
117
|
+
margin-inline: calc(50% - var(--ct-vw) / 2 + var(--ct-inset-l))
|
|
118
|
+
calc(50% - var(--ct-vw) / 2 + var(--ct-inset-r));
|
|
97
119
|
}
|
|
98
120
|
</style>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// List + detail columns. Above `breakpoint` (measured on this component's own
|
|
3
3
|
// box, not the viewport) both columns render and scroll independently. Below
|
|
4
|
-
// it exactly one pane
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
4
|
+
// it exactly one pane shows: the list while nothing is `selected`, the detail
|
|
5
|
+
// (with a sticky back header) once it is. Both stay mounted, so the list keeps
|
|
6
|
+
// its scroll and the focus moves to Back and returns to the opened item.
|
|
7
|
+
// The consumer drives `selected` — typically from the URL — so each pane can
|
|
8
|
+
// be its own route.
|
|
9
|
+
import { type Snippet, tick } from 'svelte';
|
|
8
10
|
import { browser } from '../../env';
|
|
9
11
|
import Icon from '../atoms/Icon.svelte';
|
|
10
12
|
|
|
@@ -49,6 +51,11 @@
|
|
|
49
51
|
|
|
50
52
|
let root = $state<HTMLElement | null>(null);
|
|
51
53
|
let mobile = $state(false);
|
|
54
|
+
let listEl = $state<HTMLElement | null>(null);
|
|
55
|
+
let backEl = $state<HTMLButtonElement | null>(null);
|
|
56
|
+
let listScroll = 0;
|
|
57
|
+
let listFocus: HTMLElement | null = null;
|
|
58
|
+
let wasSelected: boolean | undefined;
|
|
52
59
|
|
|
53
60
|
function toPx(length: string, el: HTMLElement): number {
|
|
54
61
|
const n = Number.parseFloat(length);
|
|
@@ -71,6 +78,26 @@
|
|
|
71
78
|
|
|
72
79
|
const showList = $derived(!mobile || !selected);
|
|
73
80
|
const showDetail = $derived(!mobile || selected);
|
|
81
|
+
|
|
82
|
+
$effect.pre(() => {
|
|
83
|
+
const now = selected;
|
|
84
|
+
const prev = wasSelected;
|
|
85
|
+
wasSelected = now;
|
|
86
|
+
if (!mobile || prev === undefined || prev === now) return;
|
|
87
|
+
const list = listEl;
|
|
88
|
+
if (now) {
|
|
89
|
+
listScroll = list?.scrollTop ?? 0;
|
|
90
|
+
const active = document.activeElement;
|
|
91
|
+
listFocus = active instanceof HTMLElement && list?.contains(active) ? active : null;
|
|
92
|
+
tick().then(() => backEl?.focus());
|
|
93
|
+
} else {
|
|
94
|
+
tick().then(() => {
|
|
95
|
+
if (list) list.scrollTop = listScroll;
|
|
96
|
+
if (listFocus?.isConnected) listFocus.focus({ preventScroll: true });
|
|
97
|
+
listFocus = null;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
74
101
|
</script>
|
|
75
102
|
|
|
76
103
|
<div
|
|
@@ -84,37 +111,33 @@
|
|
|
84
111
|
data-tsu="MasterDetail"
|
|
85
112
|
{...rest}
|
|
86
113
|
>
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<span>{backLabel}</span>
|
|
99
|
-
</button>
|
|
100
|
-
{#if detailHeader}
|
|
101
|
-
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
102
|
-
{/if}
|
|
103
|
-
</header>
|
|
104
|
-
{:else if detailHeader}
|
|
105
|
-
<header class="md-detail-header">
|
|
114
|
+
<aside bind:this={listEl} class="md-list" aria-label={listLabel} hidden={!showList}>
|
|
115
|
+
{@render list()}
|
|
116
|
+
</aside>
|
|
117
|
+
<section class="md-detail" hidden={!showDetail}>
|
|
118
|
+
{#if mobile}
|
|
119
|
+
<header class="md-detail-header">
|
|
120
|
+
<button bind:this={backEl} type="button" class="md-back" onclick={() => onback?.()}>
|
|
121
|
+
<Icon name="chevron-left" />
|
|
122
|
+
<span>{backLabel}</span>
|
|
123
|
+
</button>
|
|
124
|
+
{#if detailHeader}
|
|
106
125
|
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
107
|
-
</header>
|
|
108
|
-
{/if}
|
|
109
|
-
<div class="md-detail-body">
|
|
110
|
-
{#if selected}
|
|
111
|
-
{@render detail()}
|
|
112
|
-
{:else}
|
|
113
|
-
{@render empty?.()}
|
|
114
126
|
{/if}
|
|
115
|
-
</
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
</header>
|
|
128
|
+
{:else if detailHeader}
|
|
129
|
+
<header class="md-detail-header">
|
|
130
|
+
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
131
|
+
</header>
|
|
132
|
+
{/if}
|
|
133
|
+
<div class="md-detail-body">
|
|
134
|
+
{#if selected}
|
|
135
|
+
{@render detail()}
|
|
136
|
+
{:else}
|
|
137
|
+
{@render empty?.()}
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
</section>
|
|
118
141
|
</div>
|
|
119
142
|
|
|
120
143
|
<style>
|
|
@@ -142,6 +165,9 @@
|
|
|
142
165
|
display: flex;
|
|
143
166
|
flex-direction: column;
|
|
144
167
|
}
|
|
168
|
+
.md-detail[hidden] {
|
|
169
|
+
display: none;
|
|
170
|
+
}
|
|
145
171
|
.md-detail-header {
|
|
146
172
|
position: sticky;
|
|
147
173
|
top: 0;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// (≤ 8rem — e.g. the user drags AppShell's resizable sidebar down to a rail)
|
|
5
5
|
// the label hides and the icon centers, purely in CSS; a non-empty badge then
|
|
6
6
|
// collapses to a small dot indicator so the count cue survives the rail.
|
|
7
|
-
//
|
|
8
|
-
// and shows a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
7
|
+
// In the rail the label and pill stay in the accessible name (visually
|
|
8
|
+
// hidden), and `title` shows the label as a tooltip. Renders an <a> when `href` is set, else a <button>.
|
|
9
9
|
// The glyph is `iconChildren` (raw 24×24 SVG content), else `iconPath`
|
|
10
10
|
// (single path d), else the registry `icon`.
|
|
11
11
|
import type { Snippet } from 'svelte';
|
|
@@ -73,7 +73,6 @@
|
|
|
73
73
|
class:active
|
|
74
74
|
class:bar={activeStyle === 'bar'}
|
|
75
75
|
title={label}
|
|
76
|
-
aria-label={label}
|
|
77
76
|
aria-current={active ? 'page' : undefined}
|
|
78
77
|
{onclick}
|
|
79
78
|
{...rest}
|
|
@@ -184,7 +183,12 @@
|
|
|
184
183
|
}
|
|
185
184
|
.nav-label,
|
|
186
185
|
.nav-trail {
|
|
187
|
-
|
|
186
|
+
position: absolute;
|
|
187
|
+
width: 1px;
|
|
188
|
+
height: 1px;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
clip-path: inset(50%);
|
|
191
|
+
white-space: nowrap;
|
|
188
192
|
}
|
|
189
193
|
.nav-dot {
|
|
190
194
|
display: block;
|
|
@@ -21,13 +21,19 @@
|
|
|
21
21
|
class?: string;
|
|
22
22
|
style?: string;
|
|
23
23
|
} = $props();
|
|
24
|
+
|
|
25
|
+
const labelId = $props.id();
|
|
24
26
|
</script>
|
|
25
27
|
|
|
26
28
|
<div class="nav-section {klass}" data-tsu="NavSection" {...rest}>
|
|
27
29
|
{#if label}
|
|
28
|
-
<div class="nav-section-label">{label}</div>
|
|
30
|
+
<div class="nav-section-label" id={labelId}>{label}</div>
|
|
29
31
|
{/if}
|
|
30
|
-
<div
|
|
32
|
+
<div
|
|
33
|
+
class="nav-section-items"
|
|
34
|
+
role={label ? 'group' : undefined}
|
|
35
|
+
aria-labelledby={label ? labelId : undefined}
|
|
36
|
+
>
|
|
31
37
|
{@render children?.()}
|
|
32
38
|
</div>
|
|
33
39
|
</div>
|
|
@@ -594,10 +594,24 @@
|
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
+
/* Coarse pointers: an invisible slab grows the chevron towards --touch-target. */
|
|
598
|
+
@media (pointer: coarse) {
|
|
599
|
+
.collapse-control::before {
|
|
600
|
+
content: '';
|
|
601
|
+
position: absolute;
|
|
602
|
+
inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
603
|
+
inset-inline: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
597
607
|
@media (prefers-reduced-motion: reduce) {
|
|
598
608
|
.panel {
|
|
599
609
|
transition: none;
|
|
600
610
|
animation: none;
|
|
601
611
|
}
|
|
612
|
+
.overlay .panel,
|
|
613
|
+
.overlay.right .panel {
|
|
614
|
+
animation: none;
|
|
615
|
+
}
|
|
602
616
|
}
|
|
603
617
|
</style>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Phone bottom tab bar: fixed to the bottom edge with safe-area padding.
|
|
3
|
+
// Items without `href` form a `tablist` of `tab`s with roving focus; with
|
|
4
|
+
// `href` the bar is a `<nav>` of `<a aria-current="page">`. While mounted as
|
|
5
|
+
// a bar it publishes its measured height as `--tabbar-h` on `<html>` so
|
|
6
|
+
// content can clear it (`padding-bottom: var(--tabbar-h)`).
|
|
7
|
+
import Badge from '../atoms/Badge.svelte';
|
|
8
|
+
import Icon from '../atoms/Icon.svelte';
|
|
9
|
+
import type { IconName } from '../atoms/Icon.svelte';
|
|
10
|
+
import { formatCount, hasCount } from '../../count';
|
|
11
|
+
|
|
12
|
+
export interface TabBarItem {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: IconName;
|
|
16
|
+
/** Renders an `<a>`; without it the item is a `<button role="tab">`. */
|
|
17
|
+
href?: string;
|
|
18
|
+
/** Count (capped at `99+`) or short text pinned to the glyph. 0 / '' render nothing. */
|
|
19
|
+
badge?: number | string;
|
|
20
|
+
/** Id of the panel a tab shows (`aria-controls`). */
|
|
21
|
+
controls?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
items,
|
|
26
|
+
value = $bindable(),
|
|
27
|
+
onselect,
|
|
28
|
+
label = 'Primary',
|
|
29
|
+
placement = 'bar',
|
|
30
|
+
class: klass = '',
|
|
31
|
+
...rest
|
|
32
|
+
}: {
|
|
33
|
+
items: TabBarItem[];
|
|
34
|
+
/** `id` of the selected item. */
|
|
35
|
+
value?: string;
|
|
36
|
+
onselect?: (id: string, item: TabBarItem) => void;
|
|
37
|
+
/** Accessible name of the bar. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** 'bar' = fixed to the bottom edge; 'inline' = flows with its parent. */
|
|
40
|
+
placement?: 'bar' | 'inline';
|
|
41
|
+
class?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
} = $props();
|
|
44
|
+
|
|
45
|
+
const links = $derived(items.some((item) => item.href));
|
|
46
|
+
const isCurrent = (item: TabBarItem) => item.id === value;
|
|
47
|
+
const focusIndex = $derived(Math.max(0, items.findIndex(isCurrent)));
|
|
48
|
+
const badgeText = (badge: TabBarItem['badge']) =>
|
|
49
|
+
typeof badge === 'number' ? (hasCount(badge) ? formatCount(badge) : '') : (badge ?? '');
|
|
50
|
+
|
|
51
|
+
let height = $state(0);
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (placement !== 'bar' || typeof document === 'undefined') return;
|
|
55
|
+
const root = document.documentElement;
|
|
56
|
+
root.style.setProperty('--tabbar-h', `${height}px`);
|
|
57
|
+
return () => root.style.removeProperty('--tabbar-h');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function select(item: TabBarItem) {
|
|
61
|
+
if (item.id === value) return;
|
|
62
|
+
value = item.id;
|
|
63
|
+
onselect?.(item.id, item);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onkeydown(e: KeyboardEvent) {
|
|
67
|
+
const all = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('.tabbar-item')];
|
|
68
|
+
const i = all.indexOf(e.target as HTMLElement);
|
|
69
|
+
if (i < 0) return;
|
|
70
|
+
let next: number;
|
|
71
|
+
if (e.key === 'ArrowRight') next = (i + 1) % all.length;
|
|
72
|
+
else if (e.key === 'ArrowLeft') next = (i - 1 + all.length) % all.length;
|
|
73
|
+
else if (e.key === 'Home') next = 0;
|
|
74
|
+
else if (e.key === 'End') next = all.length - 1;
|
|
75
|
+
else return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
all[next].focus();
|
|
78
|
+
if (!links) select(items[next]);
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
{#snippet glyph(item: TabBarItem)}
|
|
83
|
+
<span class="tabbar-glyph">
|
|
84
|
+
{#if item.icon}
|
|
85
|
+
<Icon name={item.icon} size={22} />
|
|
86
|
+
{/if}
|
|
87
|
+
{#if badgeText(item.badge)}
|
|
88
|
+
<Badge class="tabbar-badge" tone="accent" size="xs" border={false} active numeric={typeof item.badge === 'number'}>
|
|
89
|
+
{badgeText(item.badge)}
|
|
90
|
+
</Badge>
|
|
91
|
+
{/if}
|
|
92
|
+
</span>
|
|
93
|
+
<span class="tabbar-label">{item.label}</span>
|
|
94
|
+
{/snippet}
|
|
95
|
+
|
|
96
|
+
<nav
|
|
97
|
+
bind:offsetHeight={height}
|
|
98
|
+
data-tsu="TabBar"
|
|
99
|
+
aria-label={label}
|
|
100
|
+
class="tabbar {klass}"
|
|
101
|
+
class:bar={placement === 'bar'}
|
|
102
|
+
{...rest}
|
|
103
|
+
>
|
|
104
|
+
<div
|
|
105
|
+
class="tabbar-row"
|
|
106
|
+
role={links ? undefined : 'tablist'}
|
|
107
|
+
aria-label={links ? undefined : label}
|
|
108
|
+
{onkeydown}
|
|
109
|
+
>
|
|
110
|
+
{#each items as item, i (item.id)}
|
|
111
|
+
{#if item.href}
|
|
112
|
+
<a
|
|
113
|
+
href={item.href}
|
|
114
|
+
class="tabbar-item"
|
|
115
|
+
class:current={isCurrent(item)}
|
|
116
|
+
aria-current={isCurrent(item) ? 'page' : undefined}
|
|
117
|
+
onclick={() => select(item)}
|
|
118
|
+
>
|
|
119
|
+
{@render glyph(item)}
|
|
120
|
+
</a>
|
|
121
|
+
{:else}
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
role="tab"
|
|
125
|
+
class="tabbar-item"
|
|
126
|
+
class:current={isCurrent(item)}
|
|
127
|
+
aria-selected={isCurrent(item)}
|
|
128
|
+
aria-controls={item.controls}
|
|
129
|
+
tabindex={i === focusIndex ? 0 : -1}
|
|
130
|
+
onclick={() => select(item)}
|
|
131
|
+
>
|
|
132
|
+
{@render glyph(item)}
|
|
133
|
+
</button>
|
|
134
|
+
{/if}
|
|
135
|
+
{/each}
|
|
136
|
+
</div>
|
|
137
|
+
</nav>
|
|
138
|
+
|
|
139
|
+
<style>
|
|
140
|
+
.tabbar {
|
|
141
|
+
min-width: 0;
|
|
142
|
+
background: var(--bg-elevated);
|
|
143
|
+
border-top: 1px solid var(--border);
|
|
144
|
+
}
|
|
145
|
+
.bar {
|
|
146
|
+
position: fixed;
|
|
147
|
+
inset-inline: 0;
|
|
148
|
+
bottom: 0;
|
|
149
|
+
z-index: var(--z-nav);
|
|
150
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
151
|
+
padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
|
|
152
|
+
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
|
153
|
+
backdrop-filter: blur(12px);
|
|
154
|
+
}
|
|
155
|
+
.tabbar-row {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: stretch;
|
|
158
|
+
min-width: 0;
|
|
159
|
+
min-height: var(--nav-h);
|
|
160
|
+
}
|
|
161
|
+
.tabbar-item {
|
|
162
|
+
position: relative;
|
|
163
|
+
display: flex;
|
|
164
|
+
flex: 1 1 0;
|
|
165
|
+
min-width: 0;
|
|
166
|
+
min-height: max(48px, var(--touch-target));
|
|
167
|
+
flex-direction: column;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: center;
|
|
170
|
+
gap: 2px;
|
|
171
|
+
padding: var(--sp-1) var(--sp-2);
|
|
172
|
+
border: none;
|
|
173
|
+
border-radius: 0;
|
|
174
|
+
background: none;
|
|
175
|
+
color: var(--text-muted);
|
|
176
|
+
font: inherit;
|
|
177
|
+
font-size: var(--fs-xs);
|
|
178
|
+
font-weight: var(--fw-medium);
|
|
179
|
+
text-decoration: none;
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
-webkit-tap-highlight-color: transparent;
|
|
182
|
+
transition: color 0.12s var(--ease);
|
|
183
|
+
}
|
|
184
|
+
.tabbar-item:hover {
|
|
185
|
+
color: var(--text);
|
|
186
|
+
text-decoration: none;
|
|
187
|
+
}
|
|
188
|
+
.tabbar-item:focus-visible {
|
|
189
|
+
outline: 2px solid var(--accent);
|
|
190
|
+
outline-offset: -2px;
|
|
191
|
+
}
|
|
192
|
+
.tabbar-item.current {
|
|
193
|
+
color: var(--accent);
|
|
194
|
+
}
|
|
195
|
+
.tabbar-glyph {
|
|
196
|
+
position: relative;
|
|
197
|
+
display: inline-flex;
|
|
198
|
+
flex: none;
|
|
199
|
+
align-items: center;
|
|
200
|
+
}
|
|
201
|
+
.tabbar-label {
|
|
202
|
+
max-width: 100%;
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
text-overflow: ellipsis;
|
|
205
|
+
white-space: nowrap;
|
|
206
|
+
}
|
|
207
|
+
.tabbar :global(.tabbar-badge) {
|
|
208
|
+
position: absolute;
|
|
209
|
+
top: -0.4rem;
|
|
210
|
+
left: calc(100% - 0.5rem);
|
|
211
|
+
}
|
|
212
|
+
@media (prefers-reduced-motion: reduce) {
|
|
213
|
+
.tabbar-item {
|
|
214
|
+
transition: none;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
</style>
|