@dorsk/tsumikit 0.35.0 → 0.36.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 +14 -3
- package/dist/cap-bar.d.ts +8 -0
- package/dist/cap-bar.js +43 -0
- package/dist/components/atoms/Gauge.svelte +165 -0
- package/dist/components/atoms/Gauge.svelte.d.ts +24 -0
- package/dist/components/layouts/MasterDetail.svelte +220 -0
- package/dist/components/layouts/MasterDetail.svelte.d.ts +22 -0
- package/dist/components/layouts/ResizablePanel.svelte +3 -7
- package/dist/components/molecules/CapBar.svelte +254 -0
- package/dist/components/molecules/CapBar.svelte.d.ts +33 -0
- package/dist/components/molecules/Fieldset.svelte +183 -0
- package/dist/components/molecules/Fieldset.svelte.d.ts +22 -0
- package/dist/components/molecules/GitRef.svelte +174 -0
- package/dist/components/molecules/GitRef.svelte.d.ts +30 -0
- package/dist/components/molecules/WorkingDir.svelte +134 -0
- package/dist/components/molecules/WorkingDir.svelte.d.ts +21 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/working-dir.d.ts +12 -0
- package/dist/working-dir.js +38 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -148,7 +148,8 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
|
|
|
148
148
|
`clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
|
|
149
149
|
`onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
|
|
150
150
|
is used), Textarea, Select, Switch, Checkbox,
|
|
151
|
-
Slider, Progress,
|
|
151
|
+
Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
|
|
152
|
+
threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
|
|
152
153
|
fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
|
|
153
154
|
(`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
|
|
154
155
|
`icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
|
|
@@ -159,7 +160,15 @@ any custom SVG).
|
|
|
159
160
|
**Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
160
161
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
161
162
|
Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
|
|
162
|
-
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore
|
|
163
|
+
ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
|
|
164
|
+
GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
|
|
165
|
+
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), WorkingDir
|
|
167
|
+
(fish-style path chip: ancestors abbreviate left to right as the slot narrows,
|
|
168
|
+
then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
|
|
169
|
+
a copy-on-click button; candidates via the `workingDirCandidates` helper).
|
|
170
|
+
Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
|
|
171
|
+
drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job).
|
|
163
172
|
|
|
164
173
|
**Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
|
|
165
174
|
`layout="fixed"` makes column widths authoritative, `Column.truncate` /
|
|
@@ -186,7 +195,9 @@ children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
|
186
195
|
mobile), NavItem
|
|
187
196
|
(collapses to an icon rail when the sidebar is narrow; `icon` from the
|
|
188
197
|
registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
|
|
189
|
-
for the inset-bar active look),
|
|
198
|
+
for the inset-bar active look), MasterDetail (list + detail columns that
|
|
199
|
+
become two pages — one pane at a time with a sticky 44px back header — below
|
|
200
|
+
their own `breakpoint`, so `selected` can be driven from the URL), Container, Stack
|
|
190
201
|
(vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
|
|
191
202
|
query container and stacks children full-width below 18/30/40/48rem — phone
|
|
192
203
|
action rows without a viewport query), AutoGrid (intrinsically responsive
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function clampCap(value: number, min?: number, max?: number): number;
|
|
2
|
+
export declare function snapCap(value: number, step?: number, min?: number, max?: number): number;
|
|
3
|
+
export declare function capFromPointer(clientX: number, rect: {
|
|
4
|
+
left: number;
|
|
5
|
+
width: number;
|
|
6
|
+
}, step?: number, min?: number, max?: number): number;
|
|
7
|
+
export declare function capKeyStep(key: string, shift: boolean, current: number, step?: number, min?: number, max?: number): number | null;
|
|
8
|
+
export declare function capTone(value: number, cap: number, warnAt?: number): 'ok' | 'warn' | 'danger';
|
package/dist/cap-bar.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function clampCap(value, min = 0, max = 100) {
|
|
2
|
+
if (Number.isNaN(value))
|
|
3
|
+
return min;
|
|
4
|
+
return Math.min(max, Math.max(min, value));
|
|
5
|
+
}
|
|
6
|
+
export function snapCap(value, step = 5, min = 0, max = 100) {
|
|
7
|
+
const s = step > 0 ? step : 1;
|
|
8
|
+
return clampCap(min + Math.round((value - min) / s) * s, min, max);
|
|
9
|
+
}
|
|
10
|
+
export function capFromPointer(clientX, rect, step = 5, min = 0, max = 100) {
|
|
11
|
+
if (rect.width <= 0)
|
|
12
|
+
return min;
|
|
13
|
+
const ratio = (clientX - rect.left) / rect.width;
|
|
14
|
+
return snapCap(min + ratio * (max - min), step, min, max);
|
|
15
|
+
}
|
|
16
|
+
export function capKeyStep(key, shift, current, step = 5, min = 0, max = 100) {
|
|
17
|
+
const delta = shift ? step * 5 : step;
|
|
18
|
+
switch (key) {
|
|
19
|
+
case 'ArrowRight':
|
|
20
|
+
case 'ArrowUp':
|
|
21
|
+
return snapCap(current + delta, step, min, max);
|
|
22
|
+
case 'ArrowLeft':
|
|
23
|
+
case 'ArrowDown':
|
|
24
|
+
return snapCap(current - delta, step, min, max);
|
|
25
|
+
case 'Home':
|
|
26
|
+
return min;
|
|
27
|
+
case 'End':
|
|
28
|
+
return max;
|
|
29
|
+
case 'PageUp':
|
|
30
|
+
return snapCap(current + step * 5, step, min, max);
|
|
31
|
+
case 'PageDown':
|
|
32
|
+
return snapCap(current - step * 5, step, min, max);
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function capTone(value, cap, warnAt = 75) {
|
|
38
|
+
if (value >= cap)
|
|
39
|
+
return 'danger';
|
|
40
|
+
if (value >= warnAt)
|
|
41
|
+
return 'warn';
|
|
42
|
+
return 'ok';
|
|
43
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type GaugeTone = 'ok' | 'warn' | 'danger';
|
|
3
|
+
|
|
4
|
+
export function clampPct(value: number): number {
|
|
5
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function gaugeTone(value: number, warnAt = 70, dangerAt = 90): GaugeTone {
|
|
9
|
+
const v = clampPct(value);
|
|
10
|
+
if (v >= dangerAt) return 'danger';
|
|
11
|
+
if (v >= warnAt) return 'warn';
|
|
12
|
+
return 'ok';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function litSegments(value: number, segments = 3): number {
|
|
16
|
+
const v = clampPct(value);
|
|
17
|
+
if (segments <= 0) return 0;
|
|
18
|
+
return Math.min(segments, Math.floor((v / 100) * segments));
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
value,
|
|
27
|
+
variant = 'continuous',
|
|
28
|
+
segments = 3,
|
|
29
|
+
tone,
|
|
30
|
+
warnAt = 70,
|
|
31
|
+
dangerAt = 90,
|
|
32
|
+
label,
|
|
33
|
+
as = 'div',
|
|
34
|
+
width,
|
|
35
|
+
height,
|
|
36
|
+
corner,
|
|
37
|
+
class: klass = '',
|
|
38
|
+
style = '',
|
|
39
|
+
...rest
|
|
40
|
+
}: {
|
|
41
|
+
value: number;
|
|
42
|
+
variant?: 'continuous' | 'segments';
|
|
43
|
+
// `segments` variant only: number of stacked bars, lit bottom-up.
|
|
44
|
+
segments?: number;
|
|
45
|
+
// Forces the fill colour; otherwise derived from `warnAt` / `dangerAt`.
|
|
46
|
+
tone?: GaugeTone;
|
|
47
|
+
warnAt?: number;
|
|
48
|
+
dangerAt?: number;
|
|
49
|
+
label?: string;
|
|
50
|
+
as?: 'div' | 'a' | 'button';
|
|
51
|
+
width?: string;
|
|
52
|
+
height?: string;
|
|
53
|
+
corner?: Snippet;
|
|
54
|
+
class?: string;
|
|
55
|
+
style?: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
} = $props();
|
|
58
|
+
|
|
59
|
+
const pct = $derived(clampPct(value));
|
|
60
|
+
const resolvedTone = $derived(tone ?? gaugeTone(pct, warnAt, dangerAt));
|
|
61
|
+
const lit = $derived(litSegments(pct, segments));
|
|
62
|
+
const segmentCount = $derived(Math.max(0, Math.floor(segments)));
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<svelte:element
|
|
66
|
+
this={as}
|
|
67
|
+
data-tsu="Gauge"
|
|
68
|
+
class="gauge tone-{resolvedTone} {klass}"
|
|
69
|
+
class:interactive={as !== 'div'}
|
|
70
|
+
type={as === 'button' ? 'button' : undefined}
|
|
71
|
+
role="meter"
|
|
72
|
+
aria-label={label}
|
|
73
|
+
aria-valuemin={0}
|
|
74
|
+
aria-valuemax={100}
|
|
75
|
+
aria-valuenow={pct}
|
|
76
|
+
style:--gauge-w={width}
|
|
77
|
+
style:--gauge-h={height}
|
|
78
|
+
{style}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
{#if variant === 'segments'}
|
|
82
|
+
<span class="segments" style:--gauge-segments={segmentCount}>
|
|
83
|
+
{#each { length: segmentCount } as _, i (i)}
|
|
84
|
+
<span class="segment" class:lit={i < lit}></span>
|
|
85
|
+
{/each}
|
|
86
|
+
</span>
|
|
87
|
+
{:else}
|
|
88
|
+
<span class="fill" style:height="{pct}%"></span>
|
|
89
|
+
{/if}
|
|
90
|
+
{#if corner}
|
|
91
|
+
<span class="corner">{@render corner()}</span>
|
|
92
|
+
{/if}
|
|
93
|
+
</svelte:element>
|
|
94
|
+
|
|
95
|
+
<style>
|
|
96
|
+
.gauge {
|
|
97
|
+
position: relative;
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
flex: none;
|
|
100
|
+
box-sizing: border-box;
|
|
101
|
+
width: var(--gauge-w, 0.875rem);
|
|
102
|
+
height: var(--gauge-h, 1.25rem);
|
|
103
|
+
margin: 0;
|
|
104
|
+
padding: 0;
|
|
105
|
+
border: 1px solid var(--border);
|
|
106
|
+
border-radius: var(--r-sm);
|
|
107
|
+
background: var(--bg-elevated-2);
|
|
108
|
+
overflow: visible;
|
|
109
|
+
color: inherit;
|
|
110
|
+
font: inherit;
|
|
111
|
+
text-decoration: none;
|
|
112
|
+
vertical-align: middle;
|
|
113
|
+
}
|
|
114
|
+
.interactive {
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
.interactive:hover {
|
|
118
|
+
border-color: var(--gauge-fill);
|
|
119
|
+
}
|
|
120
|
+
.interactive:focus-visible {
|
|
121
|
+
outline: 2px solid var(--accent);
|
|
122
|
+
outline-offset: 2px;
|
|
123
|
+
}
|
|
124
|
+
.tone-ok {
|
|
125
|
+
--gauge-fill: var(--ok);
|
|
126
|
+
}
|
|
127
|
+
.tone-warn {
|
|
128
|
+
--gauge-fill: var(--warn);
|
|
129
|
+
}
|
|
130
|
+
.tone-danger {
|
|
131
|
+
--gauge-fill: var(--danger);
|
|
132
|
+
}
|
|
133
|
+
.fill {
|
|
134
|
+
position: absolute;
|
|
135
|
+
inset: auto 0 0 0;
|
|
136
|
+
border-radius: calc(var(--r-sm) - 1px);
|
|
137
|
+
background: var(--gauge-fill);
|
|
138
|
+
transition: height 0.2s var(--ease), background 0.2s var(--ease);
|
|
139
|
+
}
|
|
140
|
+
.segments {
|
|
141
|
+
position: absolute;
|
|
142
|
+
inset: 2px;
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column-reverse;
|
|
145
|
+
gap: 2px;
|
|
146
|
+
}
|
|
147
|
+
.segment {
|
|
148
|
+
flex: 1 1 0%;
|
|
149
|
+
border-radius: 2px;
|
|
150
|
+
background: var(--bg-elevated);
|
|
151
|
+
transition: background 0.2s var(--ease);
|
|
152
|
+
}
|
|
153
|
+
.segment.lit {
|
|
154
|
+
background: var(--gauge-fill);
|
|
155
|
+
}
|
|
156
|
+
.corner {
|
|
157
|
+
position: absolute;
|
|
158
|
+
right: -0.35em;
|
|
159
|
+
bottom: -0.35em;
|
|
160
|
+
display: inline-flex;
|
|
161
|
+
font-size: 0.625rem;
|
|
162
|
+
line-height: 1;
|
|
163
|
+
pointer-events: none;
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type GaugeTone = 'ok' | 'warn' | 'danger';
|
|
2
|
+
export declare function clampPct(value: number): number;
|
|
3
|
+
export declare function gaugeTone(value: number, warnAt?: number, dangerAt?: number): GaugeTone;
|
|
4
|
+
export declare function litSegments(value: number, segments?: number): number;
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
value: number;
|
|
8
|
+
variant?: 'continuous' | 'segments';
|
|
9
|
+
segments?: number;
|
|
10
|
+
tone?: GaugeTone;
|
|
11
|
+
warnAt?: number;
|
|
12
|
+
dangerAt?: number;
|
|
13
|
+
label?: string;
|
|
14
|
+
as?: 'div' | 'a' | 'button';
|
|
15
|
+
width?: string;
|
|
16
|
+
height?: string;
|
|
17
|
+
corner?: Snippet;
|
|
18
|
+
class?: string;
|
|
19
|
+
style?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
declare const Gauge: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
23
|
+
type Gauge = ReturnType<typeof Gauge>;
|
|
24
|
+
export default Gauge;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// List + detail columns. Above `breakpoint` (measured on this component's own
|
|
3
|
+
// box, not the viewport) both columns render and scroll independently. Below
|
|
4
|
+
// it exactly one pane renders: the list while nothing is `selected`, the
|
|
5
|
+
// detail (with a sticky back header) once it is. The consumer drives
|
|
6
|
+
// `selected` — typically from the URL — so each pane can be its own route.
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
|
+
import { browser } from '../../env';
|
|
9
|
+
import Icon from '../atoms/Icon.svelte';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
list,
|
|
13
|
+
detail,
|
|
14
|
+
detailHeader,
|
|
15
|
+
empty,
|
|
16
|
+
listWidth = '17rem',
|
|
17
|
+
breakpoint = '48rem',
|
|
18
|
+
selected = false,
|
|
19
|
+
onback,
|
|
20
|
+
backLabel = 'Back',
|
|
21
|
+
listLabel = 'List',
|
|
22
|
+
class: klass = '',
|
|
23
|
+
style,
|
|
24
|
+
...rest
|
|
25
|
+
}: {
|
|
26
|
+
list: Snippet;
|
|
27
|
+
detail: Snippet;
|
|
28
|
+
/** Rendered above the detail; on mobile it shares the sticky header with the back button. */
|
|
29
|
+
detailHeader?: Snippet;
|
|
30
|
+
/** Desktop placeholder in the detail column while nothing is selected. */
|
|
31
|
+
empty?: Snippet;
|
|
32
|
+
listWidth?: string;
|
|
33
|
+
/** Own-width threshold (px/em/rem) below which the panes become two pages. */
|
|
34
|
+
breakpoint?: string;
|
|
35
|
+
selected?: boolean;
|
|
36
|
+
onback?: () => void;
|
|
37
|
+
backLabel?: string;
|
|
38
|
+
listLabel?: string;
|
|
39
|
+
class?: string;
|
|
40
|
+
style?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
let root = $state<HTMLElement | null>(null);
|
|
45
|
+
let mobile = $state(false);
|
|
46
|
+
|
|
47
|
+
function toPx(length: string, el: HTMLElement): number {
|
|
48
|
+
const n = Number.parseFloat(length);
|
|
49
|
+
const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
|
|
50
|
+
if (length.endsWith('rem')) return n * fontSize(document.documentElement);
|
|
51
|
+
if (length.endsWith('em')) return n * fontSize(el);
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (!browser || !root) return;
|
|
57
|
+
const el = root;
|
|
58
|
+
const limit = toPx(breakpoint, el);
|
|
59
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
60
|
+
mobile = entry.contentRect.width < limit;
|
|
61
|
+
});
|
|
62
|
+
observer.observe(el);
|
|
63
|
+
return () => observer.disconnect();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const showList = $derived(!mobile || !selected);
|
|
67
|
+
const showDetail = $derived(!mobile || selected);
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<div
|
|
71
|
+
bind:this={root}
|
|
72
|
+
class="md {klass}"
|
|
73
|
+
class:mobile
|
|
74
|
+
class:selected
|
|
75
|
+
style="--md-list-w: {listWidth};{style ? ` ${style}` : ''}"
|
|
76
|
+
data-tsu="MasterDetail"
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
{#if showList}
|
|
80
|
+
<aside class="md-list" aria-label={listLabel}>
|
|
81
|
+
{@render list()}
|
|
82
|
+
</aside>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if showDetail}
|
|
85
|
+
<section class="md-detail">
|
|
86
|
+
{#if mobile}
|
|
87
|
+
<header class="md-detail-header">
|
|
88
|
+
<button type="button" class="md-back" onclick={() => onback?.()}>
|
|
89
|
+
<Icon name="chevron-left" />
|
|
90
|
+
<span>{backLabel}</span>
|
|
91
|
+
</button>
|
|
92
|
+
{#if detailHeader}
|
|
93
|
+
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
94
|
+
{/if}
|
|
95
|
+
</header>
|
|
96
|
+
{:else if detailHeader}
|
|
97
|
+
<header class="md-detail-header">
|
|
98
|
+
<div class="md-detail-header-content">{@render detailHeader()}</div>
|
|
99
|
+
</header>
|
|
100
|
+
{/if}
|
|
101
|
+
<div class="md-detail-body">
|
|
102
|
+
{#if selected}
|
|
103
|
+
{@render detail()}
|
|
104
|
+
{:else}
|
|
105
|
+
{@render empty?.()}
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
</section>
|
|
109
|
+
{/if}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<style>
|
|
113
|
+
.md {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: var(--md-list-w) minmax(0, 1fr);
|
|
116
|
+
min-width: 0;
|
|
117
|
+
min-height: 0;
|
|
118
|
+
height: 100%;
|
|
119
|
+
overflow: hidden;
|
|
120
|
+
}
|
|
121
|
+
.md-list,
|
|
122
|
+
.md-detail {
|
|
123
|
+
min-width: 0;
|
|
124
|
+
min-height: 0;
|
|
125
|
+
overflow-y: auto;
|
|
126
|
+
overflow-x: hidden;
|
|
127
|
+
-webkit-overflow-scrolling: touch;
|
|
128
|
+
}
|
|
129
|
+
.md-list {
|
|
130
|
+
border-right: 1px solid var(--border);
|
|
131
|
+
}
|
|
132
|
+
.md-detail {
|
|
133
|
+
display: flex;
|
|
134
|
+
flex-direction: column;
|
|
135
|
+
}
|
|
136
|
+
.md-detail-header {
|
|
137
|
+
position: sticky;
|
|
138
|
+
top: 0;
|
|
139
|
+
z-index: var(--z-header);
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: var(--sp-2);
|
|
143
|
+
min-width: 0;
|
|
144
|
+
min-height: var(--touch-target);
|
|
145
|
+
padding-inline: var(--sp-3);
|
|
146
|
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
147
|
+
backdrop-filter: blur(8px);
|
|
148
|
+
border-bottom: 1px solid var(--border);
|
|
149
|
+
}
|
|
150
|
+
.md-detail-header-content {
|
|
151
|
+
flex: 1;
|
|
152
|
+
min-width: 0;
|
|
153
|
+
}
|
|
154
|
+
.md-detail-body {
|
|
155
|
+
flex: 1;
|
|
156
|
+
min-height: 0;
|
|
157
|
+
}
|
|
158
|
+
.md-back {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: var(--sp-1);
|
|
162
|
+
min-height: var(--touch-target);
|
|
163
|
+
min-width: var(--touch-target);
|
|
164
|
+
padding-inline: var(--sp-2);
|
|
165
|
+
margin-inline-start: calc(-1 * var(--sp-2));
|
|
166
|
+
border: 0;
|
|
167
|
+
border-radius: var(--r-sm);
|
|
168
|
+
background: transparent;
|
|
169
|
+
color: var(--accent);
|
|
170
|
+
font: inherit;
|
|
171
|
+
font-size: var(--fs-sm);
|
|
172
|
+
font-weight: var(--fw-medium);
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
}
|
|
176
|
+
.md-back:hover {
|
|
177
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
178
|
+
}
|
|
179
|
+
.md-back:focus-visible {
|
|
180
|
+
outline: 2px solid var(--accent);
|
|
181
|
+
outline-offset: 2px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.md.mobile {
|
|
185
|
+
grid-template-columns: minmax(0, 1fr);
|
|
186
|
+
}
|
|
187
|
+
.md.mobile .md-list {
|
|
188
|
+
border-right: 0;
|
|
189
|
+
width: 100%;
|
|
190
|
+
padding-inline: var(--safe-left) var(--safe-right);
|
|
191
|
+
padding-bottom: var(--safe-bottom);
|
|
192
|
+
}
|
|
193
|
+
.md.mobile .md-detail {
|
|
194
|
+
width: 100%;
|
|
195
|
+
animation: md-slide-in 0.2s var(--ease);
|
|
196
|
+
}
|
|
197
|
+
.md.mobile .md-detail-header {
|
|
198
|
+
padding-inline: max(var(--sp-3), var(--safe-left)) max(var(--sp-3), var(--safe-right));
|
|
199
|
+
}
|
|
200
|
+
.md.mobile .md-detail-body {
|
|
201
|
+
padding-inline: var(--safe-left) var(--safe-right);
|
|
202
|
+
padding-bottom: var(--safe-bottom);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@keyframes md-slide-in {
|
|
206
|
+
from {
|
|
207
|
+
transform: translateX(8%);
|
|
208
|
+
opacity: 0;
|
|
209
|
+
}
|
|
210
|
+
to {
|
|
211
|
+
transform: none;
|
|
212
|
+
opacity: 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
@media (prefers-reduced-motion: reduce) {
|
|
216
|
+
.md.mobile .md-detail {
|
|
217
|
+
animation: none;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
list: Snippet;
|
|
4
|
+
detail: Snippet;
|
|
5
|
+
/** Rendered above the detail; on mobile it shares the sticky header with the back button. */
|
|
6
|
+
detailHeader?: Snippet;
|
|
7
|
+
/** Desktop placeholder in the detail column while nothing is selected. */
|
|
8
|
+
empty?: Snippet;
|
|
9
|
+
listWidth?: string;
|
|
10
|
+
/** Own-width threshold (px/em/rem) below which the panes become two pages. */
|
|
11
|
+
breakpoint?: string;
|
|
12
|
+
selected?: boolean;
|
|
13
|
+
onback?: () => void;
|
|
14
|
+
backLabel?: string;
|
|
15
|
+
listLabel?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
style?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
declare const MasterDetail: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
21
|
+
type MasterDetail = ReturnType<typeof MasterDetail>;
|
|
22
|
+
export default MasterDetail;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { untrack } from 'svelte';
|
|
3
4
|
import { browser } from '../../env';
|
|
4
5
|
import Icon from '../atoms/Icon.svelte';
|
|
5
6
|
import Scrim from '../atoms/Scrim.svelte';
|
|
@@ -87,7 +88,6 @@
|
|
|
87
88
|
|
|
88
89
|
function measureLength(css: string) {
|
|
89
90
|
if (!browser || !root) return undefined;
|
|
90
|
-
console.count('measure ' + css);
|
|
91
91
|
const probe = document.createElement('div');
|
|
92
92
|
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${css}`;
|
|
93
93
|
root.appendChild(probe);
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
$effect(() => {
|
|
129
129
|
if (!browser || restored) return;
|
|
130
130
|
restored = true;
|
|
131
|
-
lengthTick
|
|
131
|
+
lengthTick = untrack(() => lengthTick) + 1;
|
|
132
132
|
if (!widthKey) return;
|
|
133
133
|
|
|
134
134
|
const savedWidth = parseStoredWidth(
|
|
@@ -149,9 +149,8 @@
|
|
|
149
149
|
$effect(() => {
|
|
150
150
|
if (!browser) return;
|
|
151
151
|
const sync = () => {
|
|
152
|
-
console.count('sync');
|
|
153
152
|
viewportWidth = window.innerWidth;
|
|
154
|
-
lengthTick
|
|
153
|
+
lengthTick = untrack(() => lengthTick) + 1;
|
|
155
154
|
};
|
|
156
155
|
sync();
|
|
157
156
|
addEventListener('resize', sync);
|
|
@@ -163,7 +162,6 @@
|
|
|
163
162
|
fullBleed = false;
|
|
164
163
|
return;
|
|
165
164
|
}
|
|
166
|
-
console.count('bleed');
|
|
167
165
|
const query = matchMedia(`(max-width: ${fullWidthBelow})`);
|
|
168
166
|
const sync = () => {
|
|
169
167
|
fullBleed = query.matches;
|
|
@@ -175,7 +173,6 @@
|
|
|
175
173
|
|
|
176
174
|
$effect(() => {
|
|
177
175
|
if (!browser || !overlay || !open) return;
|
|
178
|
-
console.count('escape');
|
|
179
176
|
const onKeydown = (event: KeyboardEvent) => {
|
|
180
177
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
181
178
|
event.preventDefault();
|
|
@@ -187,7 +184,6 @@
|
|
|
187
184
|
|
|
188
185
|
$effect(() => {
|
|
189
186
|
if (!browser || !overlay || !open || !panelEl) return;
|
|
190
|
-
console.count('focus');
|
|
191
187
|
const previous = document.activeElement as HTMLElement | null;
|
|
192
188
|
if (!panelEl.contains(previous)) panelEl.focus({ preventScroll: true });
|
|
193
189
|
return () => {
|