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