@dorsk/tsumikit 0.23.0 → 0.24.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 +107 -5
- package/dist/components/atoms/Scrim.svelte +81 -0
- package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
- package/dist/components/layouts/ResizablePanel.svelte +238 -120
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
- package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
- package/dist/components/layouts/resizable-panel-frame.js +161 -0
- package/dist/components/molecules/ThemePicker.svelte +11 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/stores/theme.svelte.d.ts +21 -5
- package/dist/stores/theme.svelte.js +48 -22
- package/dist/styles/app.css +7 -297
- package/dist/styles/reset.css +97 -0
- package/dist/styles/syntax.css +94 -0
- package/dist/styles/themes.css +729 -0
- package/dist/styles/tokens.css +207 -0
- package/dist/styles/utilities.css +111 -0
- package/dist/styles/variables.css +5 -926
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -57,15 +57,91 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
|
|
|
57
57
|
|
|
58
58
|
## Theming
|
|
59
59
|
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`[data-theme="id"]` block in `variables.css`. Nothing else changes.
|
|
60
|
+
- 23 themes ship (light, highcontrast, gruvboxlight, solarizedlight,
|
|
61
|
+
everforestlight, rosepinedawn, latte, nordlight, tokyoday, kanagawalotus,
|
|
62
|
+
sepia, dark, **colorblind** — Okabe-Ito —, mocha, dracula, nord, tokyonight,
|
|
63
|
+
gruvbox, solarized, rosepine, onedark, everforest, monokai, amoled).
|
|
65
64
|
- `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
|
|
66
65
|
is persisted to `localStorage` and applied with no flash (head snippet in
|
|
67
66
|
`app.html`) and updates the mobile `<meta name="theme-color">`.
|
|
68
67
|
|
|
68
|
+
### Stylesheets
|
|
69
|
+
|
|
70
|
+
`@dorsk/tsumikit/styles/app.css` is a shell over five files you can import
|
|
71
|
+
individually, in this order:
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
@import '@dorsk/tsumikit/styles/tokens.css';
|
|
75
|
+
@import '@dorsk/tsumikit/styles/themes.css'; /* optional */
|
|
76
|
+
@import '@dorsk/tsumikit/styles/reset.css';
|
|
77
|
+
@import '@dorsk/tsumikit/styles/utilities.css';
|
|
78
|
+
@import '@dorsk/tsumikit/styles/syntax.css';
|
|
79
|
+
@import './brand.css';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| export | contents |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `styles/tokens.css` | `:root` only — every token the kit reads (`--c-*` palette, `--bg`/`--text`/`--accent` aliases, type scale, spacing, radii, shadows, fonts, `--control-height`, …). Theme-less. |
|
|
85
|
+
| `styles/themes.css` | the built-in `[data-theme="id"]` blocks, one per `THEMES` entry. |
|
|
86
|
+
| `styles/reset.css` | reset + element defaults. |
|
|
87
|
+
| `styles/utilities.css` | `.container`, `.stack`, `.row`, `.sr-only`, icon sizing, … |
|
|
88
|
+
| `styles/syntax.css` | highlight.js / Prism class → `--syn-*` mapping. |
|
|
89
|
+
|
|
90
|
+
`styles/variables.css` = `tokens.css` + `themes.css` (kept for compatibility).
|
|
91
|
+
Nothing is wrapped in `@layer`: kit rules are unlayered so component-scoped
|
|
92
|
+
styles cascade exactly as before, and a consumer stylesheet imported **after**
|
|
93
|
+
the kit wins on source order at equal specificity. Do not put brand overrides
|
|
94
|
+
inside a `@layer` of your own — layered rules lose to the kit's unlayered ones.
|
|
95
|
+
|
|
96
|
+
### Your own theme, without vendoring
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// +layout.ts (or any module that runs before first render)
|
|
100
|
+
import { theme } from '@dorsk/tsumikit';
|
|
101
|
+
theme.register({ id: 'kusaritoi', label: 'Kusaritoi', icon: '鎖', themeColor: '#2a2a2a', mode: 'dark' });
|
|
102
|
+
theme.setDefault('kusaritoi');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
/* brand.css — imported after the kit stylesheet(s) */
|
|
107
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap');
|
|
108
|
+
:root { --font-mono: 'JetBrains Mono', ui-monospace, monospace; }
|
|
109
|
+
|
|
110
|
+
[data-theme='kusaritoi'] {
|
|
111
|
+
color-scheme: dark;
|
|
112
|
+
--c-bg: #2a2a2a; --c-bg-elev: #323232; --c-bg-elev-2: #3a3a3a;
|
|
113
|
+
--c-surface: #323232; --c-border: #484848; --c-border-strong: #585858;
|
|
114
|
+
--c-text: #e8e8e8; --c-text-muted: #b0b0b0; --c-text-faint: #808080;
|
|
115
|
+
--c-accent: #5ac8c8; --c-accent-ink: #0d1f1f; --c-accent-dim: #3a8a8a;
|
|
116
|
+
--c-blue: #5a9fd4; --c-amber: #d9a543; --c-red: #e06060;
|
|
117
|
+
--c-green: #6abf69; --c-violet: #b48ef0; --c-gold: #d9a543;
|
|
118
|
+
--c-teal: #5ac8c8;
|
|
119
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
120
|
+
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.5);
|
|
121
|
+
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.6);
|
|
122
|
+
--mach-bg-sl: 45% 24%; --mach-fg-sl: 70% 82%; --mach-border-sl: 45% 40%;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Contract for a `[data-theme='x']` block: all nineteen `--c-*` palette tokens,
|
|
127
|
+
`--shadow-sm|md|lg`, `--mach-bg-sl|fg-sl|border-sl`, and `color-scheme: light`
|
|
128
|
+
for a light theme (`:root` defaults to dark, so dark themes may omit it).
|
|
129
|
+
`--md-code-bg` is optional (derives from `--c-blue`). Everything else (`--bg`,
|
|
130
|
+
`--text`, `--syn-*`, …) is an alias in `tokens.css` and follows automatically.
|
|
131
|
+
|
|
132
|
+
- `theme.register(def | def[])` appends to `theme.all` (built-ins first); an
|
|
133
|
+
entry with a built-in `id` replaces that built-in. `icon` and `themeColor` are
|
|
134
|
+
optional. `ThemePicker` reads `theme.all`, so registered themes appear at once.
|
|
135
|
+
- `theme.setDefault(id)` is the theme used when `localStorage` holds nothing
|
|
136
|
+
valid — set the same id as `data-theme` in your `app.html` so the no-flash
|
|
137
|
+
snippet and the store agree. A persisted id that only becomes valid after
|
|
138
|
+
`register()` is picked up as soon as it is registered.
|
|
139
|
+
- Only the palette or only the fonts? Import `tokens.css` + your `brand.css`
|
|
140
|
+
and skip `themes.css`; or import the full `app.css` and override `:root`
|
|
141
|
+
tokens after it. Never copy the kit files.
|
|
142
|
+
- Adding a built-in theme to the kit = one entry in `THEMES`
|
|
143
|
+
(`stores/theme.svelte.ts`) + one block in `styles/themes.css`.
|
|
144
|
+
|
|
69
145
|
## Components
|
|
70
146
|
|
|
71
147
|
**Atoms:** Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox,
|
|
@@ -113,6 +189,32 @@ edge-to-edge sections — inside AppShell's main column use `size="none"` instea
|
|
|
113
189
|
Children can bleed to the column edge with
|
|
114
190
|
`margin-inline: calc(-1 * var(--container-gutter))`.
|
|
115
191
|
|
|
192
|
+
### ResizablePanel
|
|
193
|
+
|
|
194
|
+
Inline by default: `panel` shares the row with `children` on its `side`, drag the
|
|
195
|
+
edge or use the arrow keys/Home/End on the separator, collapse it with the edge
|
|
196
|
+
chevron; `width`/`minWidth`/`maxWidth` (px numbers or CSS lengths such as
|
|
197
|
+
`'12rem'`) and `widthKey` persist the width. `mode="overlay"` turns it into a
|
|
198
|
+
fixed non-modal drawer (`role="dialog"`) on the viewport edge: `bind:open`,
|
|
199
|
+
`onclose` fires on Escape, scrim click or the edge control, `scrim={false}` drops
|
|
200
|
+
the dim backdrop, `fullWidthBelow="960px"` makes the drawer span the viewport (no
|
|
201
|
+
handle, no scrim) on small screens, and `clampToViewport` (default true) keeps a
|
|
202
|
+
stored width inside a shrunken window. `children` is optional in overlay mode.
|
|
203
|
+
|
|
204
|
+
```svelte
|
|
205
|
+
<ResizablePanel mode="overlay" side="right" label="Conversation" bind:open
|
|
206
|
+
width={720} minWidth={360} maxWidth="90vw" widthKey="conv-w" fullWidthBelow="960px">
|
|
207
|
+
{#snippet panel()}…{/snippet}
|
|
208
|
+
</ResizablePanel>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`Scrim` (`onclose`, `hideBelow`, `z`, `label`) is the drawer's backdrop atom,
|
|
212
|
+
exported for custom overlays: click or Escape (document-level) calls `onclose`.
|
|
213
|
+
`resizeHandle` is the shared drag action behind every grip: `use:resizeHandle={{
|
|
214
|
+
side, min, max, step, onwidth, oncommit, onreset, onactive, measure }}` gives
|
|
215
|
+
any element pointer-capture + rAF-coalesced dragging, arrow/Home/End keys and
|
|
216
|
+
double-click reset; the width defaults to the element's parent box.
|
|
217
|
+
|
|
116
218
|
### Stacked distribution + legend
|
|
117
219
|
|
|
118
220
|
`SegmentedProgress mode="stacked"` turns the bar into one shared track whose slice
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { browser } from '../../env';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
onclose,
|
|
6
|
+
hideBelow,
|
|
7
|
+
z = 'var(--z-drawer)',
|
|
8
|
+
label = 'Close'
|
|
9
|
+
}: {
|
|
10
|
+
/** Called on click anywhere on the scrim and on Escape (document-level). */
|
|
11
|
+
onclose?: () => void;
|
|
12
|
+
/** Viewport width (CSS length) under which the scrim is not rendered,
|
|
13
|
+
* for overlays that go full-bleed on small screens. */
|
|
14
|
+
hideBelow?: string;
|
|
15
|
+
/** z-index; defaults to the drawer layer. */
|
|
16
|
+
z?: string | number;
|
|
17
|
+
/** Accessible name of the click target. */
|
|
18
|
+
label?: string;
|
|
19
|
+
} = $props();
|
|
20
|
+
|
|
21
|
+
let hidden = $state(false);
|
|
22
|
+
|
|
23
|
+
$effect(() => {
|
|
24
|
+
if (!browser || !hideBelow) {
|
|
25
|
+
hidden = false;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const query = matchMedia(`(max-width: ${hideBelow})`);
|
|
29
|
+
const sync = () => {
|
|
30
|
+
hidden = query.matches;
|
|
31
|
+
};
|
|
32
|
+
sync();
|
|
33
|
+
query.addEventListener('change', sync);
|
|
34
|
+
return () => query.removeEventListener('change', sync);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
$effect(() => {
|
|
38
|
+
if (!browser) return;
|
|
39
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
40
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
onclose?.();
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener('keydown', onKeydown);
|
|
45
|
+
return () => document.removeEventListener('keydown', onKeydown);
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
{#if !hidden}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="scrim"
|
|
53
|
+
style:z-index={z}
|
|
54
|
+
aria-label={label}
|
|
55
|
+
tabindex="-1"
|
|
56
|
+
onclick={() => onclose?.()}
|
|
57
|
+
data-tsu="Scrim"
|
|
58
|
+
></button>
|
|
59
|
+
{/if}
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.scrim {
|
|
63
|
+
position: fixed;
|
|
64
|
+
inset: 0;
|
|
65
|
+
padding: 0;
|
|
66
|
+
border: 0;
|
|
67
|
+
background: color-mix(in srgb, var(--bg) 45%, transparent);
|
|
68
|
+
cursor: default;
|
|
69
|
+
animation: scrim-fade 0.18s var(--ease);
|
|
70
|
+
}
|
|
71
|
+
@keyframes scrim-fade {
|
|
72
|
+
from {
|
|
73
|
+
opacity: 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
@media (prefers-reduced-motion: reduce) {
|
|
77
|
+
.scrim {
|
|
78
|
+
animation: none;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** Called on click anywhere on the scrim and on Escape (document-level). */
|
|
3
|
+
onclose?: () => void;
|
|
4
|
+
/** Viewport width (CSS length) under which the scrim is not rendered,
|
|
5
|
+
* for overlays that go full-bleed on small screens. */
|
|
6
|
+
hideBelow?: string;
|
|
7
|
+
/** z-index; defaults to the drawer layer. */
|
|
8
|
+
z?: string | number;
|
|
9
|
+
/** Accessible name of the click target. */
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const Scrim: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type Scrim = ReturnType<typeof Scrim>;
|
|
14
|
+
export default Scrim;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
3
|
import { browser } from '../../env';
|
|
4
4
|
import Icon from '../atoms/Icon.svelte';
|
|
5
|
-
import
|
|
5
|
+
import Scrim from '../atoms/Scrim.svelte';
|
|
6
|
+
import { resizeHandle, resolveLength } from './resizable-panel-frame.js';
|
|
6
7
|
import { parseStoredCollapsed, parseStoredWidth } from './resizable-panel-persistence';
|
|
7
8
|
|
|
8
9
|
let {
|
|
@@ -18,23 +19,31 @@
|
|
|
18
19
|
persistCollapsed = true,
|
|
19
20
|
resizeStep = 16,
|
|
20
21
|
handlePlacement = 'bottom',
|
|
21
|
-
stickyHandle = true
|
|
22
|
+
stickyHandle = true,
|
|
23
|
+
mode = 'inline',
|
|
24
|
+
open = $bindable(false),
|
|
25
|
+
onclose,
|
|
26
|
+
scrim,
|
|
27
|
+
fullWidthBelow,
|
|
28
|
+
clampToViewport = true
|
|
22
29
|
}: {
|
|
23
30
|
/** Content shown while the panel is expanded. */
|
|
24
31
|
panel: Snippet;
|
|
25
|
-
/** Main content beside the panel. */
|
|
26
|
-
children
|
|
32
|
+
/** Main content beside the panel (optional in overlay mode). */
|
|
33
|
+
children?: Snippet;
|
|
27
34
|
/** Physical edge occupied by the panel. */
|
|
28
35
|
side?: 'left' | 'right';
|
|
29
|
-
/** Accessible name for the panel landmark. */
|
|
36
|
+
/** Accessible name for the panel landmark / dialog. */
|
|
30
37
|
label?: string;
|
|
31
38
|
/** Initial expanded width in pixels. */
|
|
32
39
|
width?: number;
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
/** Pixel number or CSS length (`'12rem'`, `'30vw'`). */
|
|
41
|
+
minWidth?: number | string;
|
|
42
|
+
/** Pixel number or CSS length (`'40rem'`, `'90vw'`). */
|
|
43
|
+
maxWidth?: number | string;
|
|
35
44
|
/** localStorage key used to restore the expanded width. */
|
|
36
45
|
widthKey?: string;
|
|
37
|
-
/** Bindable collapsed state. */
|
|
46
|
+
/** Bindable collapsed state (inline mode). */
|
|
38
47
|
collapsed?: boolean;
|
|
39
48
|
/** Persist collapsed state as `${widthKey}:collapsed`. */
|
|
40
49
|
persistCollapsed?: boolean;
|
|
@@ -45,23 +54,69 @@
|
|
|
45
54
|
/** Keep the collapse handle in view when the panel scrolls past the
|
|
46
55
|
* viewport, repositioning on scroll/resize via requestAnimationFrame. */
|
|
47
56
|
stickyHandle?: boolean;
|
|
57
|
+
/** `inline` shares the row with `children`; `overlay` fixes the panel to
|
|
58
|
+
* its viewport edge as a non-modal drawer above the page. */
|
|
59
|
+
mode?: 'inline' | 'overlay';
|
|
60
|
+
/** Bindable drawer visibility (overlay mode). */
|
|
61
|
+
open?: boolean;
|
|
62
|
+
/** Overlay mode: Escape, scrim click or the edge control closed the drawer. */
|
|
63
|
+
onclose?: () => void;
|
|
64
|
+
/** Overlay mode: dim the page behind the drawer; clicking it closes (default true). */
|
|
65
|
+
scrim?: boolean;
|
|
66
|
+
/** Overlay mode: viewport width (CSS length) under which the drawer spans
|
|
67
|
+
* the full viewport and hides its resize handle and scrim. */
|
|
68
|
+
fullWidthBelow?: string;
|
|
69
|
+
/** Overlay mode: cap the width at the viewport and re-clamp on window resize. */
|
|
70
|
+
clampToViewport?: boolean;
|
|
48
71
|
} = $props();
|
|
49
72
|
|
|
50
73
|
let root: HTMLDivElement;
|
|
74
|
+
let panelEl = $state<HTMLElement | null>(null);
|
|
51
75
|
let handleEl = $state<HTMLButtonElement | null>(null);
|
|
52
76
|
let panelWidth = $state<number>();
|
|
53
77
|
let resizing = $state(false);
|
|
54
78
|
let restored = false;
|
|
55
79
|
let stickyShift = $state(0);
|
|
80
|
+
let viewportWidth = $state<number>();
|
|
81
|
+
let lengthTick = $state(0);
|
|
82
|
+
let fullBleed = $state(false);
|
|
56
83
|
|
|
57
|
-
const
|
|
58
|
-
const
|
|
84
|
+
const overlay = $derived(mode === 'overlay');
|
|
85
|
+
const shown = $derived(overlay ? open : !collapsed);
|
|
86
|
+
const showScrim = $derived(overlay && open && (scrim ?? true));
|
|
87
|
+
|
|
88
|
+
function measureLength(css: string) {
|
|
89
|
+
if (!browser || !root) return undefined;
|
|
90
|
+
console.count('measure ' + css);
|
|
91
|
+
const probe = document.createElement('div');
|
|
92
|
+
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${css}`;
|
|
93
|
+
root.appendChild(probe);
|
|
94
|
+
const measured = probe.getBoundingClientRect().width;
|
|
95
|
+
probe.remove();
|
|
96
|
+
return measured;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const minPx = $derived.by(() => {
|
|
100
|
+
void lengthTick;
|
|
101
|
+
return resolveLength(minWidth, measureLength) ?? 180;
|
|
102
|
+
});
|
|
103
|
+
const maxPx = $derived.by(() => {
|
|
104
|
+
void lengthTick;
|
|
105
|
+
return resolveLength(maxWidth, measureLength) ?? 480;
|
|
106
|
+
});
|
|
107
|
+
const viewportCap = $derived(
|
|
108
|
+
overlay && clampToViewport && viewportWidth ? viewportWidth : Number.POSITIVE_INFINITY
|
|
109
|
+
);
|
|
110
|
+
const boundedMin = $derived(Math.max(1, Math.min(minPx, maxPx, viewportCap)));
|
|
111
|
+
const boundedMax = $derived(Math.max(boundedMin, Math.min(maxPx, viewportCap)));
|
|
59
112
|
const currentWidth = $derived(
|
|
60
113
|
Math.round(Math.max(boundedMin, Math.min(panelWidth ?? width, boundedMax)))
|
|
61
114
|
);
|
|
62
|
-
const toggleLabel = $derived(
|
|
115
|
+
const toggleLabel = $derived(
|
|
116
|
+
overlay ? `Close ${label}` : collapsed ? `Expand ${label}` : `Collapse ${label}`
|
|
117
|
+
);
|
|
63
118
|
const toggleIcon = $derived(
|
|
64
|
-
collapsed
|
|
119
|
+
!overlay && collapsed
|
|
65
120
|
? side === 'left'
|
|
66
121
|
? 'chevron-right'
|
|
67
122
|
: 'chevron-left'
|
|
@@ -73,6 +128,7 @@
|
|
|
73
128
|
$effect(() => {
|
|
74
129
|
if (!browser || restored) return;
|
|
75
130
|
restored = true;
|
|
131
|
+
lengthTick += 1;
|
|
76
132
|
if (!widthKey) return;
|
|
77
133
|
|
|
78
134
|
const savedWidth = parseStoredWidth(
|
|
@@ -90,28 +146,70 @@
|
|
|
90
146
|
}
|
|
91
147
|
});
|
|
92
148
|
|
|
93
|
-
|
|
94
|
-
if (browser
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (!browser) return;
|
|
151
|
+
const sync = () => {
|
|
152
|
+
console.count('sync');
|
|
153
|
+
viewportWidth = window.innerWidth;
|
|
154
|
+
lengthTick += 1;
|
|
155
|
+
};
|
|
156
|
+
sync();
|
|
157
|
+
addEventListener('resize', sync);
|
|
158
|
+
return () => removeEventListener('resize', sync);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
$effect(() => {
|
|
162
|
+
if (!browser || !overlay || !fullWidthBelow) {
|
|
163
|
+
fullBleed = false;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.count('bleed');
|
|
167
|
+
const query = matchMedia(`(max-width: ${fullWidthBelow})`);
|
|
168
|
+
const sync = () => {
|
|
169
|
+
fullBleed = query.matches;
|
|
170
|
+
};
|
|
171
|
+
sync();
|
|
172
|
+
query.addEventListener('change', sync);
|
|
173
|
+
return () => query.removeEventListener('change', sync);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$effect(() => {
|
|
177
|
+
if (!browser || !overlay || !open) return;
|
|
178
|
+
console.count('escape');
|
|
179
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
180
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
close();
|
|
183
|
+
};
|
|
184
|
+
document.addEventListener('keydown', onKeydown);
|
|
185
|
+
return () => document.removeEventListener('keydown', onKeydown);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
$effect(() => {
|
|
189
|
+
if (!browser || !overlay || !open || !panelEl) return;
|
|
190
|
+
console.count('focus');
|
|
191
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
192
|
+
if (!panelEl.contains(previous)) panelEl.focus({ preventScroll: true });
|
|
193
|
+
return () => {
|
|
194
|
+
if (previous?.isConnected && document.activeElement === document.body) previous.focus();
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
function persistWidth(nextWidth: number) {
|
|
199
|
+
if (browser && widthKey) localStorage.setItem(widthKey, String(nextWidth));
|
|
95
200
|
}
|
|
96
201
|
|
|
97
|
-
function setWidth(nextWidth: number
|
|
202
|
+
function setWidth(nextWidth: number) {
|
|
98
203
|
panelWidth = Math.round(Math.max(boundedMin, Math.min(nextWidth, boundedMax)));
|
|
99
|
-
if (persist) persistWidth();
|
|
100
204
|
}
|
|
101
205
|
|
|
102
|
-
const pointerWidths = createFrameBatcher<number>(
|
|
103
|
-
(callback) => requestAnimationFrame(callback),
|
|
104
|
-
(handle) => cancelAnimationFrame(handle),
|
|
105
|
-
(nextWidth) => setWidth(nextWidth, false)
|
|
106
|
-
);
|
|
107
|
-
|
|
108
206
|
// Keep the collapse handle within the viewport (and its panel) while a long
|
|
109
207
|
// panel scrolls past the fold. Scroll fires often, so coalesce recomputes
|
|
110
208
|
// into one per animation frame.
|
|
111
209
|
let stickyFrame: number | undefined;
|
|
112
210
|
|
|
113
211
|
function computeSticky() {
|
|
114
|
-
if (!stickyHandle || !root || !handleEl) {
|
|
212
|
+
if (!stickyHandle || overlay || !root || !handleEl) {
|
|
115
213
|
stickyShift = 0;
|
|
116
214
|
return;
|
|
117
215
|
}
|
|
@@ -139,7 +237,7 @@
|
|
|
139
237
|
}
|
|
140
238
|
|
|
141
239
|
$effect(() => {
|
|
142
|
-
if (!browser || !stickyHandle) {
|
|
240
|
+
if (!browser || !stickyHandle || overlay) {
|
|
143
241
|
stickyShift = 0;
|
|
144
242
|
return;
|
|
145
243
|
}
|
|
@@ -154,69 +252,22 @@
|
|
|
154
252
|
};
|
|
155
253
|
});
|
|
156
254
|
|
|
157
|
-
|
|
255
|
+
function close() {
|
|
256
|
+
if (!open) return;
|
|
257
|
+
open = false;
|
|
258
|
+
onclose?.();
|
|
259
|
+
}
|
|
158
260
|
|
|
159
261
|
function toggle() {
|
|
262
|
+
if (overlay) {
|
|
263
|
+
close();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
160
266
|
collapsed = !collapsed;
|
|
161
267
|
if (browser && widthKey && persistCollapsed) {
|
|
162
268
|
localStorage.setItem(`${widthKey}:collapsed`, String(collapsed));
|
|
163
269
|
}
|
|
164
270
|
}
|
|
165
|
-
|
|
166
|
-
function widthFromPointer(clientX: number) {
|
|
167
|
-
const bounds = root.getBoundingClientRect();
|
|
168
|
-
return side === 'left' ? clientX - bounds.left : bounds.right - clientX;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function startResize(event: PointerEvent) {
|
|
172
|
-
resizing = true;
|
|
173
|
-
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
174
|
-
event.preventDefault();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function resize(event: PointerEvent) {
|
|
178
|
-
if (!resizing) return;
|
|
179
|
-
pointerWidths.schedule(widthFromPointer(event.clientX));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function finishResize(event: PointerEvent) {
|
|
183
|
-
if (!resizing) return;
|
|
184
|
-
const finalWidth = Math.round(
|
|
185
|
-
Math.max(boundedMin, Math.min(widthFromPointer(event.clientX), boundedMax))
|
|
186
|
-
);
|
|
187
|
-
pointerWidths.flush(finalWidth);
|
|
188
|
-
resizing = false;
|
|
189
|
-
try {
|
|
190
|
-
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
191
|
-
} catch {
|
|
192
|
-
// Pointer capture may already have been released by the browser.
|
|
193
|
-
}
|
|
194
|
-
if (browser && widthKey) localStorage.setItem(widthKey, String(finalWidth));
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function resizeWithKeyboard(event: KeyboardEvent) {
|
|
198
|
-
let nextWidth: number | undefined;
|
|
199
|
-
const direction = side === 'left' ? 1 : -1;
|
|
200
|
-
|
|
201
|
-
switch (event.key) {
|
|
202
|
-
case 'ArrowLeft':
|
|
203
|
-
nextWidth = currentWidth - resizeStep * direction;
|
|
204
|
-
break;
|
|
205
|
-
case 'ArrowRight':
|
|
206
|
-
nextWidth = currentWidth + resizeStep * direction;
|
|
207
|
-
break;
|
|
208
|
-
case 'Home':
|
|
209
|
-
nextWidth = boundedMin;
|
|
210
|
-
break;
|
|
211
|
-
case 'End':
|
|
212
|
-
nextWidth = boundedMax;
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (nextWidth === undefined) return;
|
|
217
|
-
event.preventDefault();
|
|
218
|
-
setWidth(nextWidth);
|
|
219
|
-
}
|
|
220
271
|
</script>
|
|
221
272
|
|
|
222
273
|
<div
|
|
@@ -224,52 +275,74 @@
|
|
|
224
275
|
class="panel-layout"
|
|
225
276
|
class:left={side === 'left'}
|
|
226
277
|
class:right={side === 'right'}
|
|
227
|
-
class:collapsed
|
|
278
|
+
class:collapsed={!overlay && collapsed}
|
|
279
|
+
class:overlay
|
|
280
|
+
class:full-bleed={fullBleed}
|
|
228
281
|
class:resizing
|
|
229
282
|
style="--panel-width: {currentWidth}px"
|
|
230
283
|
data-tsu="ResizablePanel"
|
|
231
284
|
>
|
|
232
|
-
|
|
233
|
-
{
|
|
234
|
-
|
|
235
|
-
<!-- The separator role is interactive when focusable and wired to the
|
|
236
|
-
required arrow/Home/End keyboard behavior. -->
|
|
237
|
-
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
238
|
-
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
239
|
-
<div
|
|
240
|
-
class="resize-handle"
|
|
241
|
-
role="separator"
|
|
242
|
-
tabindex="0"
|
|
243
|
-
aria-label={`Resize ${label}`}
|
|
244
|
-
aria-orientation="vertical"
|
|
245
|
-
aria-valuemin={boundedMin}
|
|
246
|
-
aria-valuemax={boundedMax}
|
|
247
|
-
aria-valuenow={currentWidth}
|
|
248
|
-
onkeydown={resizeWithKeyboard}
|
|
249
|
-
onpointerdown={startResize}
|
|
250
|
-
onpointermove={resize}
|
|
251
|
-
onpointerup={finishResize}
|
|
252
|
-
onpointercancel={finishResize}
|
|
253
|
-
></div>
|
|
254
|
-
{/if}
|
|
285
|
+
{#if showScrim}
|
|
286
|
+
<Scrim onclose={close} hideBelow={fullWidthBelow} label={`Close ${label}`} />
|
|
287
|
+
{/if}
|
|
255
288
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
class
|
|
262
|
-
|
|
263
|
-
aria-
|
|
264
|
-
|
|
265
|
-
style="transform: translateY({stickyShift}px)"
|
|
266
|
-
onclick={toggle}
|
|
289
|
+
{#if !overlay || open}
|
|
290
|
+
<svelte:element
|
|
291
|
+
this={overlay ? 'div' : 'aside'}
|
|
292
|
+
bind:this={panelEl}
|
|
293
|
+
aria-label={label}
|
|
294
|
+
class="panel"
|
|
295
|
+
role={overlay ? 'dialog' : undefined}
|
|
296
|
+
aria-modal={overlay ? 'false' : undefined}
|
|
297
|
+
tabindex={overlay ? -1 : undefined}
|
|
267
298
|
>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
299
|
+
{#if shown}
|
|
300
|
+
<div class="panel-content">{@render panel()}</div>
|
|
301
|
+
<!-- The separator role is interactive when focusable and wired to the
|
|
302
|
+
required arrow/Home/End keyboard behavior by the resizeHandle action. -->
|
|
303
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
304
|
+
<div
|
|
305
|
+
class="resize-handle"
|
|
306
|
+
role="separator"
|
|
307
|
+
tabindex="0"
|
|
308
|
+
aria-label={`Resize ${label}`}
|
|
309
|
+
aria-orientation="vertical"
|
|
310
|
+
aria-valuemin={boundedMin}
|
|
311
|
+
aria-valuemax={boundedMax}
|
|
312
|
+
aria-valuenow={currentWidth}
|
|
313
|
+
use:resizeHandle={{
|
|
314
|
+
side,
|
|
315
|
+
min: boundedMin,
|
|
316
|
+
max: boundedMax,
|
|
317
|
+
step: resizeStep,
|
|
318
|
+
measure: () => currentWidth,
|
|
319
|
+
onwidth: setWidth,
|
|
320
|
+
oncommit: persistWidth,
|
|
321
|
+
onactive: (active) => (resizing = active)
|
|
322
|
+
}}
|
|
323
|
+
></div>
|
|
324
|
+
{/if}
|
|
271
325
|
|
|
272
|
-
|
|
326
|
+
<button
|
|
327
|
+
bind:this={handleEl}
|
|
328
|
+
type="button"
|
|
329
|
+
class="collapse-control"
|
|
330
|
+
class:top={handlePlacement === 'top'}
|
|
331
|
+
class:bottom={handlePlacement === 'bottom'}
|
|
332
|
+
aria-label={toggleLabel}
|
|
333
|
+
aria-expanded={overlay ? undefined : !collapsed}
|
|
334
|
+
title={toggleLabel}
|
|
335
|
+
style="transform: translateY({stickyShift}px)"
|
|
336
|
+
onclick={toggle}
|
|
337
|
+
>
|
|
338
|
+
<Icon name={toggleIcon} size={14} />
|
|
339
|
+
</button>
|
|
340
|
+
</svelte:element>
|
|
341
|
+
{/if}
|
|
342
|
+
|
|
343
|
+
{#if children}
|
|
344
|
+
<div class="main">{@render children()}</div>
|
|
345
|
+
{/if}
|
|
273
346
|
</div>
|
|
274
347
|
|
|
275
348
|
<style>
|
|
@@ -443,9 +516,54 @@
|
|
|
443
516
|
}
|
|
444
517
|
}
|
|
445
518
|
|
|
519
|
+
/* Overlay mode: a fixed non-modal drawer on the viewport edge. The page
|
|
520
|
+
(`.main`) flows as normal underneath; the handle overhang is a viewport
|
|
521
|
+
edge, so the container clamp does not apply. */
|
|
522
|
+
.panel-layout.overlay {
|
|
523
|
+
display: block;
|
|
524
|
+
container: none;
|
|
525
|
+
}
|
|
526
|
+
.overlay .panel {
|
|
527
|
+
position: fixed;
|
|
528
|
+
inset-block: 0;
|
|
529
|
+
left: 0;
|
|
530
|
+
z-index: var(--z-drawer);
|
|
531
|
+
width: min(var(--panel-current-width), 100vw);
|
|
532
|
+
box-shadow: var(--shadow-md);
|
|
533
|
+
outline: none;
|
|
534
|
+
animation: panel-slide-left 0.18s var(--ease);
|
|
535
|
+
}
|
|
536
|
+
.overlay.right .panel {
|
|
537
|
+
right: 0;
|
|
538
|
+
left: auto;
|
|
539
|
+
animation-name: panel-slide-right;
|
|
540
|
+
}
|
|
541
|
+
.overlay .resize-handle {
|
|
542
|
+
--handle-shift: -6px;
|
|
543
|
+
}
|
|
544
|
+
.overlay.full-bleed .panel {
|
|
545
|
+
width: 100vw;
|
|
546
|
+
border: 0;
|
|
547
|
+
box-shadow: none;
|
|
548
|
+
}
|
|
549
|
+
.overlay.full-bleed .resize-handle {
|
|
550
|
+
display: none;
|
|
551
|
+
}
|
|
552
|
+
@keyframes panel-slide-left {
|
|
553
|
+
from {
|
|
554
|
+
transform: translateX(-100%);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
@keyframes panel-slide-right {
|
|
558
|
+
from {
|
|
559
|
+
transform: translateX(100%);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
446
563
|
@media (prefers-reduced-motion: reduce) {
|
|
447
564
|
.panel {
|
|
448
565
|
transition: none;
|
|
566
|
+
animation: none;
|
|
449
567
|
}
|
|
450
568
|
}
|
|
451
569
|
</style>
|