@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 CHANGED
@@ -57,15 +57,91 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
57
57
 
58
58
  ## Theming
59
59
 
60
- - 17 themes ship (dark, light, sepia, **colorblind** — Okabe-Ito —, plus mocha,
61
- dracula, nord, tokyonight, gruvbox, solarized, rosepine, onedark, everforest,
62
- monokai, amoled, highcontrast).
63
- - A new theme = one entry in `THEMES` (`stores/theme.svelte.ts`) + one
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 { onDestroy, type Snippet } from 'svelte';
2
+ import type { Snippet } from 'svelte';
3
3
  import { browser } from '../../env';
4
4
  import Icon from '../atoms/Icon.svelte';
5
- import { createFrameBatcher } from './resizable-panel-frame.js';
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: Snippet;
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
- minWidth?: number;
34
- maxWidth?: number;
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 boundedMin = $derived(Math.max(1, Math.min(minWidth, maxWidth)));
58
- const boundedMax = $derived(Math.max(boundedMin, maxWidth));
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(collapsed ? `Expand ${label}` : `Collapse ${label}`);
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
- function persistWidth() {
94
- if (browser && widthKey) localStorage.setItem(widthKey, String(currentWidth));
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, persist = true) {
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
- onDestroy(() => pointerWidths.discard());
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
- <aside aria-label={label} class="panel">
233
- {#if !collapsed}
234
- <div class="panel-content">{@render panel()}</div>
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
- <button
257
- bind:this={handleEl}
258
- type="button"
259
- class="collapse-control"
260
- class:top={handlePlacement === 'top'}
261
- class:bottom={handlePlacement === 'bottom'}
262
- aria-label={toggleLabel}
263
- aria-expanded={!collapsed}
264
- title={toggleLabel}
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
- <Icon name={toggleIcon} size={14} />
269
- </button>
270
- </aside>
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
- <div class="main">{@render children()}</div>
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>