@fuzdev/fuz_app 0.115.0 → 0.116.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/dist/ui/AppShell.svelte +303 -33
- package/dist/ui/AppShell.svelte.d.ts +80 -11
- package/dist/ui/AppShell.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +133 -5
- package/dist/ui/sidebar_state.svelte.d.ts +62 -2
- package/dist/ui/sidebar_state.svelte.d.ts.map +1 -1
- package/dist/ui/sidebar_state.svelte.js +78 -9
- package/package.json +1 -1
package/dist/ui/AppShell.svelte
CHANGED
|
@@ -4,13 +4,67 @@
|
|
|
4
4
|
* a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
|
|
5
5
|
* can read sidebar visibility and toggle it. Optionally binds a global
|
|
6
6
|
* keyboard shortcut and renders a built-in toggle button (or a custom one
|
|
7
|
-
* via the `toggle_button` snippet).
|
|
7
|
+
* via the `toggle_button` snippet) while the sidebar is enabled. Requires
|
|
8
|
+
* the SvelteKit runtime — it closes the overlay from `afterNavigate`.
|
|
9
|
+
*
|
|
10
|
+
* Two modes, split by the state's `narrow_query`. Outside it the sidebar
|
|
11
|
+
* sits beside the content, padded by `--sidebar_offset`, following
|
|
12
|
+
* `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
|
|
13
|
+
* overlay over a scrim, following `show_sidebar_narrow`; the content is
|
|
14
|
+
* `inert` while it's open, and navigation, Escape, a scrim click, or
|
|
15
|
+
* widening out of narrow mode closes it. Escape never prevents the
|
|
16
|
+
* default, so the browser's close requests stay intact; it does nothing
|
|
17
|
+
* while a modal (`:modal`) is open, or when a window `keydown` listener
|
|
18
|
+
* registered before the overlay opened swallows it (a contextmenu, say).
|
|
19
|
+
* Closing with Escape or the keyboard shortcut returns focus to what
|
|
20
|
+
* opened the overlay, or else to the toggle, when focus was inside the
|
|
21
|
+
* sidebar or had fallen to the body (as it does when the content goes
|
|
22
|
+
* inert).
|
|
23
|
+
*
|
|
24
|
+
* The shell's rules sit in two cascade layers nested under `fuz_app`.
|
|
25
|
+
* `fuz_app.app_shell_base`, in the scoped style, holds the base and state
|
|
26
|
+
* rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
|
|
27
|
+
* some of them from a style element in `svelte:head` carrying
|
|
28
|
+
* `media={narrow_query}`, so the server render paints the right mode
|
|
29
|
+
* before hydration and the markup's classes never depend on the viewport.
|
|
30
|
+
* The narrow layer is ordered after the base layer, so it wins whatever
|
|
31
|
+
* the selectors' specificity or the sheets' order. Its rules are global —
|
|
32
|
+
* they match every `.app-shell` on the page — so shells mounted at the
|
|
33
|
+
* same time must share a `narrow_query`. Pages served under a strict CSP
|
|
34
|
+
* need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
|
|
35
|
+
*
|
|
36
|
+
* Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
|
|
37
|
+
* `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
|
|
38
|
+
* `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
|
|
39
|
+
* unlayered rule beats both layers at any specificity, so overrides need
|
|
40
|
+
* no specificity games; to layer them instead, order `fuz_app` first
|
|
41
|
+
* (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
|
|
42
|
+
* The same rule means an unlayered reset overrides the shell too — the
|
|
43
|
+
* fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
|
|
44
|
+
* so the layers set none of those. The one they need, the content's
|
|
45
|
+
* wide offset, goes through `--sidebar_offset`: the layers set it on
|
|
46
|
+
* `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
|
|
47
|
+
* else `0px`), and one unlayered rule at (0,0,1) reads it into the
|
|
48
|
+
* content's `padding-left`. Descendants of the content can read it too.
|
|
49
|
+
* Override the offset by setting `--sidebar_offset` on
|
|
50
|
+
* `.app-shell-content` or with a `padding-left` rule there; a `padding`
|
|
51
|
+
* shorthand there overrides it as well. Like any unlayered override,
|
|
52
|
+
* these apply in both modes, open or closed; to change only the wide open
|
|
53
|
+
* offset, target `.app-shell.wide-open > .app-shell-content` inside the
|
|
54
|
+
* narrow query's negation — the state classes don't track the viewport,
|
|
55
|
+
* so `.wide-open` stays set in narrow mode too. CSS variables:
|
|
56
|
+
* `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
|
|
57
|
+
* above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
|
|
58
|
+
* `sidebar_width` prop; always the configured width, not the offset), and
|
|
59
|
+
* `--sidebar_offset`.
|
|
8
60
|
*
|
|
9
61
|
* @module
|
|
10
62
|
*/
|
|
11
63
|
|
|
12
|
-
import type
|
|
64
|
+
import { flushSync, type Snippet } from 'svelte';
|
|
13
65
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
66
|
+
import { on } from 'svelte/events';
|
|
67
|
+
import { afterNavigate } from '$app/navigation';
|
|
14
68
|
import { is_editable, swallow } from '@fuzdev/fuz_util/dom.ts';
|
|
15
69
|
|
|
16
70
|
import { SidebarState, sidebar_state_context } from './sidebar_state.svelte.js';
|
|
@@ -23,95 +77,311 @@
|
|
|
23
77
|
keyboard_shortcut = false,
|
|
24
78
|
show_toggle = true,
|
|
25
79
|
toggle_button,
|
|
80
|
+
class: class_prop,
|
|
26
81
|
...rest
|
|
27
82
|
}: SvelteHTMLElements['div'] & {
|
|
28
83
|
children: Snippet;
|
|
29
84
|
sidebar: Snippet;
|
|
30
85
|
/**
|
|
31
|
-
* Sidebar width in pixels when shown
|
|
86
|
+
* Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
|
|
32
87
|
* @default 180
|
|
33
88
|
*/
|
|
34
89
|
sidebar_width?: number;
|
|
35
90
|
/** Optional pre-built `SidebarState` for sharing visibility across shells. */
|
|
36
91
|
sidebar_state?: SidebarState;
|
|
37
92
|
/**
|
|
38
|
-
* Single-key shortcut that toggles the sidebar (e.g. `'b'`)
|
|
93
|
+
* Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
|
|
94
|
+
* enabled and no modal is open — the persistent sidebar in wide mode,
|
|
95
|
+
* the overlay in narrow mode. `false` disables.
|
|
39
96
|
* @default false
|
|
40
97
|
*/
|
|
41
98
|
keyboard_shortcut?: string | false;
|
|
42
99
|
/**
|
|
43
|
-
* Whether to render the built-in (or custom) toggle button.
|
|
100
|
+
* Whether to render the built-in (or custom) toggle button. It renders
|
|
101
|
+
* only while the sidebar is enabled either way.
|
|
44
102
|
* @default true
|
|
45
103
|
*/
|
|
46
104
|
show_toggle?: boolean;
|
|
47
|
-
/**
|
|
48
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Custom toggle-button renderer; receives the title, visibility, toggle
|
|
107
|
+
* callback, and the sidebar element's id. Give the button
|
|
108
|
+
* `class="app-shell-toggle"` so it stacks above the sidebar and scrim
|
|
109
|
+
* (it renders as a direct child of the root) — the z-index applies only
|
|
110
|
+
* to a positioned button, so position it (e.g. `position: fixed`) — and
|
|
111
|
+
* `aria-controls={sidebar_id}` so Escape can return focus to it.
|
|
112
|
+
* `show_sidebar` is viewport-dependent: the server renders it as in wide
|
|
113
|
+
* mode, so rendering it as visible content changes on hydration at
|
|
114
|
+
* narrow widths — attributes are repaired, but key visible content on
|
|
115
|
+
* CSS or the viewport-free fields instead.
|
|
116
|
+
*/
|
|
117
|
+
toggle_button?: Snippet<
|
|
118
|
+
[{ title: string; show_sidebar: boolean; toggle: () => void; sidebar_id: string }]
|
|
119
|
+
>;
|
|
49
120
|
} = $props();
|
|
50
121
|
|
|
122
|
+
const uid = $props.id();
|
|
123
|
+
const sidebar_id = `${uid}-sidebar`;
|
|
124
|
+
|
|
125
|
+
let fallback_sidebar_state: SidebarState | undefined;
|
|
51
126
|
const get_sidebar_state = sidebar_state_context.set(
|
|
52
|
-
() => sidebar_state_prop ?? new SidebarState()
|
|
127
|
+
() => sidebar_state_prop ?? (fallback_sidebar_state ??= new SidebarState())
|
|
53
128
|
);
|
|
54
129
|
const sidebar_state = $derived(get_sidebar_state());
|
|
55
130
|
|
|
56
|
-
|
|
131
|
+
// viewport-free, so the server and hydration renders agree
|
|
132
|
+
const wide_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_wide);
|
|
133
|
+
const narrow_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_narrow);
|
|
134
|
+
// viewport-dependent, only for `inert` and input handling (always `false` in SSR)
|
|
135
|
+
const overlay_open = $derived(sidebar_state.narrow && narrow_open);
|
|
136
|
+
|
|
137
|
+
const toggle_selector = `[aria-controls="${sidebar_id}"]`;
|
|
57
138
|
|
|
58
139
|
const button_title = $derived(
|
|
59
140
|
(sidebar_state.show_sidebar ? 'hide sidebar' : 'show sidebar') +
|
|
60
141
|
(keyboard_shortcut ? ` [${keyboard_shortcut}]` : '')
|
|
61
142
|
);
|
|
143
|
+
|
|
144
|
+
let sidebar_el: HTMLElement | undefined = $state();
|
|
145
|
+
let opener: HTMLElement | null = null;
|
|
146
|
+
|
|
147
|
+
afterNavigate(() => sidebar_state.close_narrow());
|
|
148
|
+
|
|
149
|
+
// widening out of narrow mode closes the overlay; the wide preference is untouched
|
|
150
|
+
$effect(() => {
|
|
151
|
+
if (!sidebar_state.narrow) sidebar_state.close_narrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const on_escape = (e: KeyboardEvent): void => {
|
|
155
|
+
if (e.key !== 'Escape' || e.defaultPrevented || !overlay_open) return;
|
|
156
|
+
// an open modal dialog owns Escape; not preventing the default leaves the
|
|
157
|
+
// browser's close requests (dialog `cancel`, `popover="auto"`) intact
|
|
158
|
+
if (document.querySelector(':modal')) return;
|
|
159
|
+
close_overlay();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// closes the overlay, returning focus when it was inside the sidebar or had
|
|
163
|
+
// fallen to the body — the inert content drops focus opened from within it
|
|
164
|
+
const close_overlay = (): void => {
|
|
165
|
+
const active = document.activeElement;
|
|
166
|
+
const should_return_focus =
|
|
167
|
+
!active || active === document.body || !!sidebar_el?.contains(active);
|
|
168
|
+
// flush so the content is no longer `inert` when focus returns into it
|
|
169
|
+
flushSync(() => sidebar_state.close_narrow());
|
|
170
|
+
if (should_return_focus) return_focus();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// the opener if it's still there and takes focus, else the first toggle that
|
|
174
|
+
// does — Safari doesn't focus a clicked button, so the opener can be `null`
|
|
175
|
+
const return_focus = (): void => {
|
|
176
|
+
if (opener?.isConnected) {
|
|
177
|
+
opener.focus();
|
|
178
|
+
if (document.activeElement === opener) return;
|
|
179
|
+
}
|
|
180
|
+
for (const toggle of document.querySelectorAll<HTMLElement>(toggle_selector)) {
|
|
181
|
+
toggle.focus();
|
|
182
|
+
if (document.activeElement === toggle) return;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// while the overlay is open, remember what had focus (to return it on Escape)
|
|
187
|
+
// and listen for Escape — registered now, not at mount, so window listeners
|
|
188
|
+
// mounted earlier (a contextmenu's, say) see Escape first and can swallow it
|
|
189
|
+
$effect(() => {
|
|
190
|
+
if (!overlay_open) return;
|
|
191
|
+
const active = document.activeElement;
|
|
192
|
+
opener = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
193
|
+
return on(window, 'keydown', on_escape);
|
|
194
|
+
});
|
|
62
195
|
</script>
|
|
63
196
|
|
|
197
|
+
<svelte:head>
|
|
198
|
+
{#if sidebar_state.narrow_query !== null}
|
|
199
|
+
<style media={sidebar_state.narrow_query}>
|
|
200
|
+
@layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
|
|
201
|
+
@layer fuz_app.app_shell_narrow {
|
|
202
|
+
.app-shell > .app-shell-content {
|
|
203
|
+
--sidebar_offset: 0px;
|
|
204
|
+
}
|
|
205
|
+
.app-shell > .app-shell-sidebar {
|
|
206
|
+
visibility: hidden;
|
|
207
|
+
translate: -100% 0;
|
|
208
|
+
width: min(var(--sidebar_width), 85vw);
|
|
209
|
+
}
|
|
210
|
+
.app-shell.narrow-open > .app-shell-sidebar {
|
|
211
|
+
visibility: visible;
|
|
212
|
+
translate: none;
|
|
213
|
+
}
|
|
214
|
+
.app-shell.narrow-open > .app-shell-scrim {
|
|
215
|
+
display: block;
|
|
216
|
+
}
|
|
217
|
+
.app-shell > .app-shell-toggle .app-shell-toggle-glyph {
|
|
218
|
+
scale: -1 1;
|
|
219
|
+
}
|
|
220
|
+
.app-shell.narrow-open > .app-shell-toggle .app-shell-toggle-glyph {
|
|
221
|
+
scale: none;
|
|
222
|
+
}
|
|
223
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
224
|
+
.app-shell > .app-shell-sidebar {
|
|
225
|
+
transition:
|
|
226
|
+
translate 150ms ease-out,
|
|
227
|
+
visibility 0s 150ms;
|
|
228
|
+
}
|
|
229
|
+
.app-shell.narrow-open > .app-shell-sidebar {
|
|
230
|
+
transition:
|
|
231
|
+
translate 150ms ease-out,
|
|
232
|
+
visibility 0s;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
</style>
|
|
237
|
+
{/if}
|
|
238
|
+
</svelte:head>
|
|
239
|
+
|
|
64
240
|
<svelte:window
|
|
65
241
|
onkeydowncapture={(e) => {
|
|
66
|
-
if (
|
|
67
|
-
|
|
242
|
+
if (
|
|
243
|
+
keyboard_shortcut &&
|
|
244
|
+
sidebar_state.enabled &&
|
|
245
|
+
e.key === keyboard_shortcut &&
|
|
246
|
+
!is_editable(e.target) &&
|
|
247
|
+
// a modal owns the keyboard, even one opened from the overlay
|
|
248
|
+
!document.querySelector(':modal')
|
|
249
|
+
) {
|
|
250
|
+
// closing the overlay returns focus like Escape; the rest just toggles
|
|
251
|
+
if (overlay_open) {
|
|
252
|
+
close_overlay();
|
|
253
|
+
} else {
|
|
254
|
+
sidebar_state.toggle_sidebar();
|
|
255
|
+
}
|
|
68
256
|
swallow(e);
|
|
69
257
|
}
|
|
70
258
|
}}
|
|
71
259
|
/>
|
|
72
260
|
|
|
73
|
-
<div
|
|
74
|
-
|
|
261
|
+
<div
|
|
262
|
+
{...rest}
|
|
263
|
+
class={['app-shell', class_prop, { 'wide-open': wide_open, 'narrow-open': narrow_open }]}
|
|
264
|
+
style:--sidebar_width="{sidebar_width}px"
|
|
265
|
+
>
|
|
266
|
+
<div class="app-shell-content" inert={overlay_open}>
|
|
75
267
|
{@render children()}
|
|
76
268
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
269
|
+
<!-- pointer-only dismissal; Escape is the keyboard path -->
|
|
270
|
+
<div
|
|
271
|
+
class="app-shell-scrim"
|
|
272
|
+
aria-hidden="true"
|
|
273
|
+
onclick={() => sidebar_state.close_narrow()}
|
|
274
|
+
></div>
|
|
275
|
+
{#if show_toggle && sidebar_state.enabled}
|
|
81
276
|
{#if toggle_button}
|
|
82
277
|
{@render toggle_button({
|
|
83
278
|
title: button_title,
|
|
84
279
|
show_sidebar: sidebar_state.show_sidebar,
|
|
85
|
-
toggle: () => sidebar_state.toggle_sidebar()
|
|
280
|
+
toggle: () => sidebar_state.toggle_sidebar(),
|
|
281
|
+
sidebar_id
|
|
86
282
|
})}
|
|
87
283
|
{:else}
|
|
88
284
|
<button
|
|
89
285
|
type="button"
|
|
90
|
-
class="position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
|
|
91
|
-
aria-label=
|
|
286
|
+
class="app-shell-toggle position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
|
|
287
|
+
aria-label="sidebar"
|
|
288
|
+
aria-keyshortcuts={keyboard_shortcut || undefined}
|
|
289
|
+
aria-expanded={sidebar_state.show_sidebar}
|
|
290
|
+
aria-controls={sidebar_id}
|
|
92
291
|
title={button_title}
|
|
93
292
|
onclick={() => sidebar_state.toggle_sidebar()}
|
|
94
293
|
>
|
|
95
|
-
|
|
294
|
+
<span class="app-shell-toggle-glyph" aria-hidden="true">←</span>
|
|
96
295
|
</button>
|
|
97
296
|
{/if}
|
|
98
297
|
{/if}
|
|
298
|
+
<div class="app-shell-sidebar" id={sidebar_id} bind:this={sidebar_el}>
|
|
299
|
+
{@render sidebar()}
|
|
300
|
+
</div>
|
|
99
301
|
</div>
|
|
100
302
|
|
|
101
303
|
<style>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
304
|
+
/*
|
|
305
|
+
* The shell's rules sit in `fuz_app.app_shell_base`. The narrow overrides live
|
|
306
|
+
* in the `media` style in `svelte:head`, in `fuz_app.app_shell_narrow`, which
|
|
307
|
+
* this order statement puts after the base layer — so they win regardless of
|
|
308
|
+
* specificity (these selectors carry the scope hash) or of which sheet loads
|
|
309
|
+
* first. The head style opens with the same statement; repeating it here pins
|
|
310
|
+
* the order whichever sheet the browser meets first.
|
|
311
|
+
*/
|
|
312
|
+
@layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
|
|
313
|
+
|
|
314
|
+
/*
|
|
315
|
+
* Never put a property the fuz_css reset sets (`padding`, `margin`, `border`,
|
|
316
|
+
* `box-sizing`, and their longhands) in a layer: the reset is unlayered, so it
|
|
317
|
+
* beats every layered declaration and the layered value computes to the
|
|
318
|
+
* reset's. Route such a value through a custom property the layers set, read
|
|
319
|
+
* by an unlayered rule. The content offset is the one case: the layers set
|
|
320
|
+
* `--sidebar_offset`, and this rule reads it. It's unscoped at (0,0,1) — above
|
|
321
|
+
* the reset's (0,0,0), below any class selector — so a consumer rule on
|
|
322
|
+
* `.app-shell-content` overrides it without matching specificity.
|
|
323
|
+
*/
|
|
324
|
+
:global(div:where(.app-shell > .app-shell-content)) {
|
|
325
|
+
padding-left: var(--sidebar_offset, 0px);
|
|
110
326
|
}
|
|
111
327
|
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
328
|
+
@layer fuz_app.app_shell_base {
|
|
329
|
+
.app-shell-content {
|
|
330
|
+
--sidebar_offset: 0px;
|
|
331
|
+
display: flex;
|
|
332
|
+
flex-direction: column;
|
|
333
|
+
min-height: 100vh;
|
|
334
|
+
}
|
|
335
|
+
.app-shell.wide-open > .app-shell-content {
|
|
336
|
+
--sidebar_offset: var(--sidebar_width);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.app-shell-sidebar {
|
|
340
|
+
position: fixed;
|
|
341
|
+
top: 0;
|
|
342
|
+
left: 0;
|
|
343
|
+
width: var(--sidebar_width);
|
|
344
|
+
height: 100%;
|
|
345
|
+
overflow: auto;
|
|
346
|
+
overscroll-behavior: contain;
|
|
347
|
+
scrollbar-width: thin;
|
|
348
|
+
background: var(--sidebar_bg, var(--shade_05));
|
|
349
|
+
z-index: var(--sidebar_z_index, 200);
|
|
350
|
+
visibility: hidden;
|
|
351
|
+
}
|
|
352
|
+
.app-shell.wide-open > .app-shell-sidebar {
|
|
353
|
+
visibility: visible;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.app-shell-scrim {
|
|
357
|
+
display: none;
|
|
358
|
+
position: fixed;
|
|
359
|
+
inset: 0;
|
|
360
|
+
z-index: var(--sidebar_z_index, 200);
|
|
361
|
+
background: var(--sidebar_scrim_bg, var(--darken_40));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/*
|
|
365
|
+
* The toggle and its glyph may be consumer markup from the `toggle_button`
|
|
366
|
+
* snippet, which never carries this component's scope hash, so they're
|
|
367
|
+
* matched by their public class names under the scoped root. The glyph
|
|
368
|
+
* rules go through the root's own toggle so an outer shell's state never
|
|
369
|
+
* reaches a nested shell's glyph.
|
|
370
|
+
*/
|
|
371
|
+
.app-shell > :global(.app-shell-toggle) {
|
|
372
|
+
z-index: calc(var(--sidebar_z_index, 200) + 1);
|
|
373
|
+
}
|
|
374
|
+
.app-shell > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
|
|
375
|
+
display: inline-block;
|
|
376
|
+
scale: -1 1;
|
|
377
|
+
}
|
|
378
|
+
.app-shell.wide-open > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
|
|
379
|
+
scale: none;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* a modal in the sidebar is in the top layer but inherits its visibility */
|
|
383
|
+
.app-shell-sidebar :global(:modal) {
|
|
384
|
+
visibility: visible;
|
|
385
|
+
}
|
|
116
386
|
}
|
|
117
387
|
</style>
|
|
@@ -3,39 +3,108 @@
|
|
|
3
3
|
* a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
|
|
4
4
|
* can read sidebar visibility and toggle it. Optionally binds a global
|
|
5
5
|
* keyboard shortcut and renders a built-in toggle button (or a custom one
|
|
6
|
-
* via the `toggle_button` snippet).
|
|
6
|
+
* via the `toggle_button` snippet) while the sidebar is enabled. Requires
|
|
7
|
+
* the SvelteKit runtime — it closes the overlay from `afterNavigate`.
|
|
8
|
+
*
|
|
9
|
+
* Two modes, split by the state's `narrow_query`. Outside it the sidebar
|
|
10
|
+
* sits beside the content, padded by `--sidebar_offset`, following
|
|
11
|
+
* `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
|
|
12
|
+
* overlay over a scrim, following `show_sidebar_narrow`; the content is
|
|
13
|
+
* `inert` while it's open, and navigation, Escape, a scrim click, or
|
|
14
|
+
* widening out of narrow mode closes it. Escape never prevents the
|
|
15
|
+
* default, so the browser's close requests stay intact; it does nothing
|
|
16
|
+
* while a modal (`:modal`) is open, or when a window `keydown` listener
|
|
17
|
+
* registered before the overlay opened swallows it (a contextmenu, say).
|
|
18
|
+
* Closing with Escape or the keyboard shortcut returns focus to what
|
|
19
|
+
* opened the overlay, or else to the toggle, when focus was inside the
|
|
20
|
+
* sidebar or had fallen to the body (as it does when the content goes
|
|
21
|
+
* inert).
|
|
22
|
+
*
|
|
23
|
+
* The shell's rules sit in two cascade layers nested under `fuz_app`.
|
|
24
|
+
* `fuz_app.app_shell_base`, in the scoped style, holds the base and state
|
|
25
|
+
* rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
|
|
26
|
+
* some of them from a style element in `svelte:head` carrying
|
|
27
|
+
* `media={narrow_query}`, so the server render paints the right mode
|
|
28
|
+
* before hydration and the markup's classes never depend on the viewport.
|
|
29
|
+
* The narrow layer is ordered after the base layer, so it wins whatever
|
|
30
|
+
* the selectors' specificity or the sheets' order. Its rules are global —
|
|
31
|
+
* they match every `.app-shell` on the page — so shells mounted at the
|
|
32
|
+
* same time must share a `narrow_query`. Pages served under a strict CSP
|
|
33
|
+
* need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
|
|
34
|
+
*
|
|
35
|
+
* Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
|
|
36
|
+
* `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
|
|
37
|
+
* `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
|
|
38
|
+
* unlayered rule beats both layers at any specificity, so overrides need
|
|
39
|
+
* no specificity games; to layer them instead, order `fuz_app` first
|
|
40
|
+
* (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
|
|
41
|
+
* The same rule means an unlayered reset overrides the shell too — the
|
|
42
|
+
* fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
|
|
43
|
+
* so the layers set none of those. The one they need, the content's
|
|
44
|
+
* wide offset, goes through `--sidebar_offset`: the layers set it on
|
|
45
|
+
* `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
|
|
46
|
+
* else `0px`), and one unlayered rule at (0,0,1) reads it into the
|
|
47
|
+
* content's `padding-left`. Descendants of the content can read it too.
|
|
48
|
+
* Override the offset by setting `--sidebar_offset` on
|
|
49
|
+
* `.app-shell-content` or with a `padding-left` rule there; a `padding`
|
|
50
|
+
* shorthand there overrides it as well. Like any unlayered override,
|
|
51
|
+
* these apply in both modes, open or closed; to change only the wide open
|
|
52
|
+
* offset, target `.app-shell.wide-open > .app-shell-content` inside the
|
|
53
|
+
* narrow query's negation — the state classes don't track the viewport,
|
|
54
|
+
* so `.wide-open` stays set in narrow mode too. CSS variables:
|
|
55
|
+
* `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
|
|
56
|
+
* above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
|
|
57
|
+
* `sidebar_width` prop; always the configured width, not the offset), and
|
|
58
|
+
* `--sidebar_offset`.
|
|
7
59
|
*
|
|
8
60
|
* @module
|
|
9
61
|
*/
|
|
10
|
-
import type
|
|
62
|
+
import { type Snippet } from 'svelte';
|
|
11
63
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
12
64
|
import { SidebarState } from './sidebar_state.svelte.js';
|
|
13
65
|
type $$ComponentProps = SvelteHTMLElements['div'] & {
|
|
14
66
|
children: Snippet;
|
|
15
67
|
sidebar: Snippet;
|
|
16
68
|
/**
|
|
17
|
-
* Sidebar width in pixels when shown
|
|
69
|
+
* Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
|
|
18
70
|
* @default 180
|
|
19
71
|
*/
|
|
20
72
|
sidebar_width?: number;
|
|
21
73
|
/** Optional pre-built `SidebarState` for sharing visibility across shells. */
|
|
22
74
|
sidebar_state?: SidebarState;
|
|
23
75
|
/**
|
|
24
|
-
* Single-key shortcut that toggles the sidebar (e.g. `'b'`)
|
|
76
|
+
* Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
|
|
77
|
+
* enabled and no modal is open — the persistent sidebar in wide mode,
|
|
78
|
+
* the overlay in narrow mode. `false` disables.
|
|
25
79
|
* @default false
|
|
26
80
|
*/
|
|
27
81
|
keyboard_shortcut?: string | false;
|
|
28
82
|
/**
|
|
29
|
-
* Whether to render the built-in (or custom) toggle button.
|
|
83
|
+
* Whether to render the built-in (or custom) toggle button. It renders
|
|
84
|
+
* only while the sidebar is enabled either way.
|
|
30
85
|
* @default true
|
|
31
86
|
*/
|
|
32
87
|
show_toggle?: boolean;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Custom toggle-button renderer; receives the title, visibility, toggle
|
|
90
|
+
* callback, and the sidebar element's id. Give the button
|
|
91
|
+
* `class="app-shell-toggle"` so it stacks above the sidebar and scrim
|
|
92
|
+
* (it renders as a direct child of the root) — the z-index applies only
|
|
93
|
+
* to a positioned button, so position it (e.g. `position: fixed`) — and
|
|
94
|
+
* `aria-controls={sidebar_id}` so Escape can return focus to it.
|
|
95
|
+
* `show_sidebar` is viewport-dependent: the server renders it as in wide
|
|
96
|
+
* mode, so rendering it as visible content changes on hydration at
|
|
97
|
+
* narrow widths — attributes are repaired, but key visible content on
|
|
98
|
+
* CSS or the viewport-free fields instead.
|
|
99
|
+
*/
|
|
100
|
+
toggle_button?: Snippet<[
|
|
101
|
+
{
|
|
102
|
+
title: string;
|
|
103
|
+
show_sidebar: boolean;
|
|
104
|
+
toggle: () => void;
|
|
105
|
+
sidebar_id: string;
|
|
106
|
+
}
|
|
107
|
+
]>;
|
|
39
108
|
};
|
|
40
109
|
declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
41
110
|
type AppShell = ReturnType<typeof AppShell>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA
|
|
1
|
+
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4DI;AACJ,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAK1D,OAAO,EAAE,YAAY,EAAyB,MAAM,2BAA2B,CAAC;AAE/E,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG;IACpD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACnC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,OAAO,CACtB;QAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,YAAY,EAAE,OAAO,CAAC;YAAC,MAAM,EAAE,MAAM,IAAI,CAAC;YAAC,UAAU,EAAE,MAAM,CAAA;SAAE;KAAC,CAClF,CAAC;CACF,CAAC;AAyJH,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
package/dist/ui/CLAUDE.md
CHANGED
|
@@ -77,9 +77,125 @@ pattern rather than reintroducing prop-drilling.
|
|
|
77
77
|
|
|
78
78
|
- `AppShell.svelte` — sidebar-and-main shell. Props: `children`,
|
|
79
79
|
`sidebar` (Snippet), `sidebar_width = 180`, `sidebar_state?`,
|
|
80
|
-
`keyboard_shortcut?`, `show_toggle?`, `toggle_button
|
|
81
|
-
|
|
82
|
-
`
|
|
80
|
+
`keyboard_shortcut?`, `show_toggle?`, `toggle_button?` (receives
|
|
81
|
+
`{title, show_sidebar, toggle, sidebar_id}`). Provisions
|
|
82
|
+
`sidebar_state_context` internally (creates one `SidebarState` if the
|
|
83
|
+
`sidebar_state` prop is not supplied). Requires the SvelteKit runtime
|
|
84
|
+
(`afterNavigate` from `$app/navigation`). The toggle renders only while
|
|
85
|
+
`show_toggle && sidebar_state.enabled`, before the sidebar in the DOM;
|
|
86
|
+
the built-in one is a disclosure (`aria-expanded` + `aria-controls`
|
|
87
|
+
pointing at the sidebar's `$props.id()` id) with the static name
|
|
88
|
+
`aria-label="sidebar"`, a dynamic `title`, `aria-keyshortcuts` when
|
|
89
|
+
`keyboard_shortcut` is set, and a `←` glyph CSS mirrors when closed.
|
|
90
|
+
The shortcut acts only while the sidebar is enabled, on the current
|
|
91
|
+
mode's field (so it drives the overlay at narrow widths).
|
|
92
|
+
- **Custom toggles** (`toggle_button`): give the button
|
|
93
|
+
`class="app-shell-toggle"` — it renders as a direct child of the root,
|
|
94
|
+
where a `:global` rule (the snippet's markup carries no scope hash)
|
|
95
|
+
stacks it one above the sidebar; the z-index applies only to a
|
|
96
|
+
positioned button, so position it (e.g. `position: fixed`, like the
|
|
97
|
+
built-in one) — and `aria-controls={sidebar_id}`, which the
|
|
98
|
+
focus fallback looks up (the first match that takes focus wins).
|
|
99
|
+
`show_sidebar` is viewport-dependent (the server renders it as wide):
|
|
100
|
+
attributes are repaired on hydration, but visible content keyed on it
|
|
101
|
+
changes at narrow widths, so key visible content on CSS or the
|
|
102
|
+
viewport-free fields.
|
|
103
|
+
- **Two modes, split by `SidebarState.narrow_query`.** Wide: the sidebar
|
|
104
|
+
sits beside the content (padded by `--sidebar_offset`) and follows
|
|
105
|
+
`show_sidebar_wide`. Narrow: it starts closed and opens as an overlay
|
|
106
|
+
(capped at `85vw`) over a scrim, following `show_sidebar_narrow`; the
|
|
107
|
+
content is `inert` while open. The overlay closes on navigation
|
|
108
|
+
(`afterNavigate` inside `AppShell` — consumers write nothing), Escape,
|
|
109
|
+
a scrim click, and widening out of narrow mode (a crossing `$effect`).
|
|
110
|
+
Not `role="dialog"` — the same element is the persistent sidebar at
|
|
111
|
+
wide, so no focus trap.
|
|
112
|
+
- **Escape** is a window `keydown` listener registered by an `$effect`
|
|
113
|
+
only while the overlay is open, so listeners registered earlier (a
|
|
114
|
+
contextmenu's, mounted with the page) see Escape first and can swallow
|
|
115
|
+
it. It never calls `preventDefault`, so the browser's close requests
|
|
116
|
+
(dialog `cancel`, `popover="auto"`) stay intact, and it bails while a
|
|
117
|
+
modal is open (`document.querySelector(':modal')` matches). An open
|
|
118
|
+
`popover="auto"` isn't `:modal`, so one Escape closes it and the overlay
|
|
119
|
+
together. Closing is `flushSync`ed so the content leaves `inert` before
|
|
120
|
+
focus returns. When focus was inside the sidebar or had fallen to `body`
|
|
121
|
+
(opening from the content makes it `inert`, which drops focus there), it
|
|
122
|
+
goes back to the opener if still connected and focusable, else to the
|
|
123
|
+
first `[aria-controls="<sidebar_id>"]` that takes focus (Safari doesn't
|
|
124
|
+
focus a clicked button, so the opener can be `null`). A narrow-mode
|
|
125
|
+
close from `keyboard_shortcut` goes through the same close-and-return
|
|
126
|
+
path; wide mode just toggles. The shortcut bails on an open modal too,
|
|
127
|
+
so pressing it inside a dialog opened from the overlay doesn't close the
|
|
128
|
+
overlay behind it.
|
|
129
|
+
- **No hydration flash.** The mode rules sit in two cascade layers
|
|
130
|
+
nested under `fuz_app`. `@layer fuz_app.app_shell_base` in the scoped
|
|
131
|
+
`<style>` holds the base and state rules, which apply in both modes;
|
|
132
|
+
the narrow overrides are one `<style media={narrow_query}>` in
|
|
133
|
+
`<svelte:head>` (Svelte attribute-escapes the query; the CSS text is
|
|
134
|
+
constant), inside `@layer fuz_app.app_shell_narrow`, rendered only
|
|
135
|
+
while `narrow_query` isn't `null`. Both sheets open with
|
|
136
|
+
`@layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;`, so the
|
|
137
|
+
narrow layer wins whatever the selectors' specificity (the scoped ones
|
|
138
|
+
carry the hash) and whichever sheet the browser meets first — a
|
|
139
|
+
client-mounted shell appending its head style late changes nothing.
|
|
140
|
+
The root's state classes read only the viewport-free fields, so the
|
|
141
|
+
server render and hydration agree and CSS picks the mode; `narrow` only
|
|
142
|
+
drives `inert`, ARIA, and input routing. The narrow rules are unscoped
|
|
143
|
+
(they match every `.app-shell` by plain class names), so shells mounted
|
|
144
|
+
at the same time must share a `narrow_query`. CSP: needs
|
|
145
|
+
`style-src-elem 'unsafe-inline'` (fuz_ui's CSP default allows it) or
|
|
146
|
+
the style's hash.
|
|
147
|
+
- **Layer names are namespaced** under one top-level `fuz_app` layer, so
|
|
148
|
+
they can't collide with a consumer's and a consumer that layers its own
|
|
149
|
+
CSS has one name to order against: `@layer fuz_app, overrides;` in a
|
|
150
|
+
sheet that loads before the shell puts `overrides` above it.
|
|
151
|
+
- **Never layer a property the reset sets.** fuz_css's reset
|
|
152
|
+
(`*, ::before, ::after, ::backdrop`) sets `box-sizing`, `border`,
|
|
153
|
+
`margin`, and `padding`, and it's unlayered, so it beats every layered
|
|
154
|
+
declaration of those at any specificity. The shell routes such a value
|
|
155
|
+
through a custom property the layers set and one unlayered rule reads.
|
|
156
|
+
The content offset is the one case: the layers set `--sidebar_offset`
|
|
157
|
+
on `.app-shell-content` (`--sidebar_width` under `.wide-open`, `0px`
|
|
158
|
+
otherwise and in the narrow layer), and
|
|
159
|
+
`:global(div:where(.app-shell > .app-shell-content)) { padding-left: var(--sidebar_offset, 0px) }`
|
|
160
|
+
reads it, unscoped at (0,0,1) — above the reset's (0,0,0), below any
|
|
161
|
+
class selector. The SSR test fails any layered `padding*`, `margin*`,
|
|
162
|
+
`border*`, or `box-sizing` declaration. No other layered declaration
|
|
163
|
+
overlaps the reset or fuz_css's element base rules (the sidebar, scrim,
|
|
164
|
+
and content are `div`s and the glyph a `span`, which fuz_css leaves
|
|
165
|
+
unstyled; the toggle's only layered property is `z-index`, which
|
|
166
|
+
`button`'s base rule doesn't set).
|
|
167
|
+
- **Public class hooks**: `.app-shell` (state classes `.wide-open` /
|
|
168
|
+
`.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
|
|
169
|
+
`.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`.
|
|
170
|
+
**Any unlayered rule overrides the shell**, at any specificity and in
|
|
171
|
+
any sheet order, so no specificity matching is needed. The flip side
|
|
172
|
+
is the reset case above: an unlayered rule that sets a property the
|
|
173
|
+
shell layers wins over it, which is why the shell keeps reset-owned
|
|
174
|
+
properties out of its layers. A consumer's own layered rules order
|
|
175
|
+
against `fuz_app` by first appearance in the document, so keep
|
|
176
|
+
overrides unlayered or order `fuz_app` first. The rules that must
|
|
177
|
+
reach consumer markup are `:global` under the scoped root: the toggle's
|
|
178
|
+
z-index (`.app-shell > .app-shell-toggle`), the glyph mirroring
|
|
179
|
+
(`.app-shell > .app-shell-toggle .app-shell-toggle-glyph`, so a custom
|
|
180
|
+
toggle can reuse `.app-shell-toggle-glyph` inside its
|
|
181
|
+
`.app-shell-toggle`; going through the root's own toggle keeps an outer
|
|
182
|
+
shell's state off a nested shell's glyph), and
|
|
183
|
+
`.app-shell-sidebar :modal { visibility: visible }`, which keeps a
|
|
184
|
+
top-layer dialog opened from the sidebar shown while the sidebar hides.
|
|
185
|
+
- **CSS variables**: `--sidebar_width` (always the configured width, set
|
|
186
|
+
from the prop — not the effective offset), `--sidebar_offset` (the
|
|
187
|
+
effective offset, set on `.app-shell-content`: `--sidebar_width` while
|
|
188
|
+
the wide sidebar is open, else `0px`; the content's descendants can
|
|
189
|
+
read it, e.g. to inset a fixed header), `--sidebar_bg` (default
|
|
190
|
+
`--shade_05`), `--sidebar_z_index` (default `200` in both modes; the
|
|
191
|
+
scrim shares it, the toggle sits one above), `--sidebar_scrim_bg`
|
|
192
|
+
(default `--darken_40`). Override the offset by setting
|
|
193
|
+
`--sidebar_offset` or writing a `padding-left` rule on
|
|
194
|
+
`.app-shell-content`; a consumer `padding` shorthand there overrides it
|
|
195
|
+
too. Either applies in both modes and open or closed, since the state
|
|
196
|
+
classes don't track the viewport — wrap it in the narrow query's negation
|
|
197
|
+
to keep narrow mode at `0px`. To change only the wide open offset, target
|
|
198
|
+
`.app-shell.wide-open > .app-shell-content`, also inside that negation.
|
|
83
199
|
- `ColumnLayout.svelte` — fixed `aside` column + fluid `children`
|
|
84
200
|
column; `column_width = '280px'`.
|
|
85
201
|
- `MenuLink.svelte` — SvelteKit `<a>` with `selected` derived from
|
|
@@ -87,8 +203,20 @@ pattern rather than reintroducing prop-drilling.
|
|
|
87
203
|
Exact matches additionally get `aria-current="page"`. Takes `path`
|
|
88
204
|
(resolved via `resolve` from `$app/paths`). The `.highlighted` name is
|
|
89
205
|
intentionally free for orthogonal emphasis (badges, recent activity).
|
|
90
|
-
- `sidebar_state.svelte.ts` — `SidebarState
|
|
91
|
-
|
|
206
|
+
- `sidebar_state.svelte.ts` — `SidebarState`, `sidebar_state_context`,
|
|
207
|
+
`SIDEBAR_NARROW_QUERY_DEFAULT` (`'(max-width: 800px)'`). Options:
|
|
208
|
+
`enabled?` (reactive getter overriding the internal field) and
|
|
209
|
+
`narrow_query?` (`null` disables narrow mode; must contain parentheses
|
|
210
|
+
since it feeds both CSS `media` and `MediaQuery`, which would auto-wrap a
|
|
211
|
+
paren-less query while CSS wouldn't — throws in DEV). Visibility is two
|
|
212
|
+
viewport-free fields, `show_sidebar_wide` (default `true`, the desktop
|
|
213
|
+
preference narrow interactions never touch) and `show_sidebar_narrow`
|
|
214
|
+
(default `false`). `narrow` is the viewport match (`false` in SSR);
|
|
215
|
+
`show_sidebar` is the effective visibility for the current mode and its
|
|
216
|
+
setter writes that mode's field, as does `toggle_sidebar` (routed at call
|
|
217
|
+
time). `close_narrow()` closes the overlay. `activate()` enables and
|
|
218
|
+
shows wide; its disposer clears `enabled` and both fields — pair with
|
|
219
|
+
`$effect` for scoped activation.
|
|
92
220
|
|
|
93
221
|
## Auth forms
|
|
94
222
|
|
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
* `sidebar_state_context`; consumers read `show_sidebar` and call
|
|
4
4
|
* `toggle_sidebar` / `activate`.
|
|
5
5
|
*
|
|
6
|
+
* Visibility is two viewport-free fields — `show_sidebar_wide` (the persistent
|
|
7
|
+
* sidebar beside the content) and `show_sidebar_narrow` (the overlay below
|
|
8
|
+
* `narrow_query`) — so server and client render the same classes and CSS picks
|
|
9
|
+
* which field is in effect. `narrow` reads the viewport and is only for input
|
|
10
|
+
* handling and ARIA, never for markup that must match SSR.
|
|
11
|
+
*
|
|
6
12
|
* @module
|
|
7
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Default `narrow_query` — below 800px the sidebar overlays the content and
|
|
16
|
+
* starts closed.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SIDEBAR_NARROW_QUERY_DEFAULT = "(max-width: 800px)";
|
|
8
19
|
/**
|
|
9
20
|
* Svelte context carrying a reactive `SidebarState` accessor. Set by
|
|
10
21
|
* `ui/AppShell.svelte` (creates a fresh `SidebarState` if not supplied);
|
|
@@ -22,20 +33,69 @@ export interface SidebarStateOptions {
|
|
|
22
33
|
* auto-returns `false` while the getter returns `false`.
|
|
23
34
|
*/
|
|
24
35
|
enabled?: () => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Media query under which the sidebar overlays the content and starts
|
|
38
|
+
* closed. It feeds both `AppShell`'s CSS `media` attribute and a
|
|
39
|
+
* `MediaQuery`, so it must be a full query with parentheses
|
|
40
|
+
* (`'(max-width: 900px)'`, not `'max-width: 900px'`). `null` disables
|
|
41
|
+
* narrow mode.
|
|
42
|
+
* @default SIDEBAR_NARROW_QUERY_DEFAULT
|
|
43
|
+
*/
|
|
44
|
+
narrow_query?: string | null;
|
|
25
45
|
}
|
|
26
46
|
export declare class SidebarState {
|
|
27
47
|
#private;
|
|
48
|
+
/** The media query for narrow mode, or `null` when narrow mode is disabled. */
|
|
49
|
+
readonly narrow_query: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the sidebar shows beside the content outside narrow mode — the
|
|
52
|
+
* desktop preference. Narrow-mode interactions never touch it.
|
|
53
|
+
*/
|
|
54
|
+
show_sidebar_wide: boolean;
|
|
55
|
+
/** Whether the narrow-mode overlay is open. */
|
|
56
|
+
show_sidebar_narrow: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Creates the state, subscribing to `narrow_query` unless it is `null`.
|
|
59
|
+
*
|
|
60
|
+
* @param options - enabled getter and narrow-mode query
|
|
61
|
+
* @throws Error in DEV when `narrow_query` has no parentheses — `MediaQuery`
|
|
62
|
+
* would auto-wrap it but CSS `media` would not, so they'd disagree
|
|
63
|
+
*/
|
|
64
|
+
constructor(options?: SidebarStateOptions);
|
|
28
65
|
get enabled(): boolean;
|
|
29
66
|
set enabled(value: boolean);
|
|
67
|
+
/**
|
|
68
|
+
* Whether the viewport matches `narrow_query`. Always `false` during SSR
|
|
69
|
+
* and when `narrow_query` is `null`. Read it for input handling and ARIA
|
|
70
|
+
* only — markup that must match the server render should key on
|
|
71
|
+
* `show_sidebar_wide` / `show_sidebar_narrow` instead.
|
|
72
|
+
*/
|
|
73
|
+
get narrow(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Effective visibility for the current mode: `false` while disabled,
|
|
76
|
+
* otherwise `show_sidebar_narrow` in narrow mode and `show_sidebar_wide`
|
|
77
|
+
* outside it. The setter writes the current mode's field.
|
|
78
|
+
*/
|
|
30
79
|
get show_sidebar(): boolean;
|
|
31
80
|
set show_sidebar(value: boolean);
|
|
32
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Sets the current mode's visibility, routed by `narrow` at call time.
|
|
83
|
+
*
|
|
84
|
+
* @param value - the new visibility, defaults to the inverse of `show_sidebar`
|
|
85
|
+
*/
|
|
33
86
|
toggle_sidebar(value?: boolean): void;
|
|
87
|
+
/**
|
|
88
|
+
* Closes the narrow-mode overlay, leaving the wide preference untouched.
|
|
89
|
+
* `AppShell` calls it on navigation, Escape, scrim click, and when the
|
|
90
|
+
* viewport widens out of narrow mode.
|
|
91
|
+
*/
|
|
92
|
+
close_narrow(): void;
|
|
34
93
|
/**
|
|
35
94
|
* Show the sidebar and enable the toggle. The returned disposer hides
|
|
36
95
|
* and disables on cleanup — pair with `$effect` for scoped activation.
|
|
37
96
|
*
|
|
38
|
-
* @mutates `this` - sets `enabled` and `
|
|
97
|
+
* @mutates `this` - sets `enabled` and `show_sidebar_wide`; the disposer
|
|
98
|
+
* clears `enabled`, `show_sidebar_wide`, and `show_sidebar_narrow`
|
|
39
99
|
*/
|
|
40
100
|
activate(): () => void;
|
|
41
101
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidebar_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/sidebar_state.svelte.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sidebar_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/sidebar_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH;;;GAGG;AACH,eAAO,MAAM,4BAA4B,uBAAuB,CAAC;AAEjE;;;;GAIG;AACH,eAAO,MAAM,qBAAqB;2CAAwB,YAAY;4BAAZ,YAAY;uBAAZ,YAAY,WAAZ,YAAY;CAAG,CAAC;AAE1E,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC;IACxB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,qBAAa,YAAY;;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAErC;;;OAGG;IACH,iBAAiB,EAAE,OAAO,CAAoB;IAE9C,+CAA+C;IAC/C,mBAAmB,EAAE,OAAO,CAAqB;IAMjD;;;;;;OAMG;gBACS,OAAO,CAAC,EAAE,mBAAmB;IAazC,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAEzB;IAED;;;;;OAKG;IACH,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;;;;OAIG;IACH,IAAI,YAAY,IAAI,OAAO,CAG1B;IAED,IAAI,YAAY,CAAC,KAAK,EAAE,OAAO,EAM9B;IAED;;;;OAIG;IACH,cAAc,CAAC,KAAK,GAAE,OAA4B,GAAG,IAAI;IAIzD;;;;OAIG;IACH,YAAY,IAAI,IAAI;IAIpB;;;;;;OAMG;IACH,QAAQ,IAAI,MAAM,IAAI;CAStB"}
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
* `sidebar_state_context`; consumers read `show_sidebar` and call
|
|
4
4
|
* `toggle_sidebar` / `activate`.
|
|
5
5
|
*
|
|
6
|
+
* Visibility is two viewport-free fields — `show_sidebar_wide` (the persistent
|
|
7
|
+
* sidebar beside the content) and `show_sidebar_narrow` (the overlay below
|
|
8
|
+
* `narrow_query`) — so server and client render the same classes and CSS picks
|
|
9
|
+
* which field is in effect. `narrow` reads the viewport and is only for input
|
|
10
|
+
* handling and ARIA, never for markup that must match SSR.
|
|
11
|
+
*
|
|
6
12
|
* @module
|
|
7
13
|
*/
|
|
14
|
+
import { DEV } from 'esm-env';
|
|
15
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
8
16
|
import { create_context } from '@fuzdev/fuz_ui/context_helpers.ts';
|
|
17
|
+
/**
|
|
18
|
+
* Default `narrow_query` — below 800px the sidebar overlays the content and
|
|
19
|
+
* starts closed.
|
|
20
|
+
*/
|
|
21
|
+
export const SIDEBAR_NARROW_QUERY_DEFAULT = '(max-width: 800px)';
|
|
9
22
|
/**
|
|
10
23
|
* Svelte context carrying a reactive `SidebarState` accessor. Set by
|
|
11
24
|
* `ui/AppShell.svelte` (creates a fresh `SidebarState` if not supplied);
|
|
@@ -13,41 +26,97 @@ import { create_context } from '@fuzdev/fuz_ui/context_helpers.ts';
|
|
|
13
26
|
*/
|
|
14
27
|
export const sidebar_state_context = create_context();
|
|
15
28
|
export class SidebarState {
|
|
29
|
+
/** The media query for narrow mode, or `null` when narrow mode is disabled. */
|
|
30
|
+
narrow_query;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the sidebar shows beside the content outside narrow mode — the
|
|
33
|
+
* desktop preference. Narrow-mode interactions never touch it.
|
|
34
|
+
*/
|
|
35
|
+
show_sidebar_wide = $state.raw(true);
|
|
36
|
+
/** Whether the narrow-mode overlay is open. */
|
|
37
|
+
show_sidebar_narrow = $state.raw(false);
|
|
16
38
|
#get_enabled;
|
|
17
39
|
#enabled = $state.raw(true);
|
|
18
|
-
#
|
|
40
|
+
#narrow_media;
|
|
41
|
+
/**
|
|
42
|
+
* Creates the state, subscribing to `narrow_query` unless it is `null`.
|
|
43
|
+
*
|
|
44
|
+
* @param options - enabled getter and narrow-mode query
|
|
45
|
+
* @throws Error in DEV when `narrow_query` has no parentheses — `MediaQuery`
|
|
46
|
+
* would auto-wrap it but CSS `media` would not, so they'd disagree
|
|
47
|
+
*/
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.#get_enabled = options?.enabled;
|
|
50
|
+
const narrow_query = options?.narrow_query === undefined ? SIDEBAR_NARROW_QUERY_DEFAULT : options.narrow_query;
|
|
51
|
+
if (DEV && narrow_query !== null && !narrow_query.includes('(')) {
|
|
52
|
+
throw new Error(`SidebarState narrow_query must include parentheses, got '${narrow_query}' — try '(${narrow_query})'`);
|
|
53
|
+
}
|
|
54
|
+
this.narrow_query = narrow_query;
|
|
55
|
+
this.#narrow_media = narrow_query === null ? null : new MediaQuery(narrow_query, false);
|
|
56
|
+
}
|
|
19
57
|
get enabled() {
|
|
20
58
|
return this.#get_enabled?.() ?? this.#enabled;
|
|
21
59
|
}
|
|
22
60
|
set enabled(value) {
|
|
23
61
|
this.#enabled = value;
|
|
24
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether the viewport matches `narrow_query`. Always `false` during SSR
|
|
65
|
+
* and when `narrow_query` is `null`. Read it for input handling and ARIA
|
|
66
|
+
* only — markup that must match the server render should key on
|
|
67
|
+
* `show_sidebar_wide` / `show_sidebar_narrow` instead.
|
|
68
|
+
*/
|
|
69
|
+
get narrow() {
|
|
70
|
+
return this.#narrow_media?.current ?? false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Effective visibility for the current mode: `false` while disabled,
|
|
74
|
+
* otherwise `show_sidebar_narrow` in narrow mode and `show_sidebar_wide`
|
|
75
|
+
* outside it. The setter writes the current mode's field.
|
|
76
|
+
*/
|
|
25
77
|
get show_sidebar() {
|
|
26
78
|
if (!this.enabled)
|
|
27
79
|
return false;
|
|
28
|
-
return this
|
|
80
|
+
return this.narrow ? this.show_sidebar_narrow : this.show_sidebar_wide;
|
|
29
81
|
}
|
|
30
82
|
set show_sidebar(value) {
|
|
31
|
-
this
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
83
|
+
if (this.narrow) {
|
|
84
|
+
this.show_sidebar_narrow = value;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.show_sidebar_wide = value;
|
|
88
|
+
}
|
|
35
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Sets the current mode's visibility, routed by `narrow` at call time.
|
|
92
|
+
*
|
|
93
|
+
* @param value - the new visibility, defaults to the inverse of `show_sidebar`
|
|
94
|
+
*/
|
|
36
95
|
toggle_sidebar(value = !this.show_sidebar) {
|
|
37
96
|
this.show_sidebar = value;
|
|
38
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Closes the narrow-mode overlay, leaving the wide preference untouched.
|
|
100
|
+
* `AppShell` calls it on navigation, Escape, scrim click, and when the
|
|
101
|
+
* viewport widens out of narrow mode.
|
|
102
|
+
*/
|
|
103
|
+
close_narrow() {
|
|
104
|
+
this.show_sidebar_narrow = false;
|
|
105
|
+
}
|
|
39
106
|
/**
|
|
40
107
|
* Show the sidebar and enable the toggle. The returned disposer hides
|
|
41
108
|
* and disables on cleanup — pair with `$effect` for scoped activation.
|
|
42
109
|
*
|
|
43
|
-
* @mutates `this` - sets `enabled` and `
|
|
110
|
+
* @mutates `this` - sets `enabled` and `show_sidebar_wide`; the disposer
|
|
111
|
+
* clears `enabled`, `show_sidebar_wide`, and `show_sidebar_narrow`
|
|
44
112
|
*/
|
|
45
113
|
activate() {
|
|
46
114
|
this.enabled = true;
|
|
47
|
-
this.
|
|
115
|
+
this.show_sidebar_wide = true;
|
|
48
116
|
return () => {
|
|
49
117
|
this.enabled = false;
|
|
50
|
-
this.
|
|
118
|
+
this.show_sidebar_wide = false;
|
|
119
|
+
this.show_sidebar_narrow = false;
|
|
51
120
|
};
|
|
52
121
|
}
|
|
53
122
|
}
|