@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.
@@ -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 { Snippet } from 'svelte';
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'`). `false` disables.
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
- /** Custom toggle-button renderer; receives the title, visibility, and toggle callback. */
48
- toggle_button?: Snippet<[{ title: string; show_sidebar: boolean; toggle: () => void }]>;
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
- const sidebar_width_px = $derived(sidebar_state.show_sidebar ? sidebar_width : 0);
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 (keyboard_shortcut && e.key === keyboard_shortcut && !is_editable(e.target)) {
67
- sidebar_state.toggle_sidebar();
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 {...rest} style:--sidebar_width="{sidebar_width_px}px">
74
- <div class="content" style:padding-left="var(--sidebar_width)">
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
- <div class="sidebar" style:width="var(--sidebar_width)">
78
- {@render sidebar()}
79
- </div>
80
- {#if show_toggle}
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={button_title}
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
- {sidebar_state.show_sidebar ? '\u2190' : '\u2192'}
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
- .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));
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
- .content {
113
- display: flex;
114
- flex-direction: column;
115
- min-height: 100vh;
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 { Snippet } from 'svelte';
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'`). `false` disables.
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
- /** 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
- }]>;
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;;;;;;;;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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
- Provisions `sidebar_state_context` internally (creates a fresh
82
- `SidebarState` if `sidebar_state` prop is not supplied).
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` (with `activate()` cleanup
91
- pattern, optional reactive `enabled` getter), `sidebar_state_context`.
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
- constructor(options?: SidebarStateOptions);
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 `show_sidebar`; disposer clears both
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;;;;;;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;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
- #show_sidebar = $state.raw(true);
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.#show_sidebar;
80
+ return this.narrow ? this.show_sidebar_narrow : this.show_sidebar_wide;
29
81
  }
30
82
  set show_sidebar(value) {
31
- this.#show_sidebar = value;
32
- }
33
- constructor(options) {
34
- this.#get_enabled = options?.enabled;
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 `show_sidebar`; disposer clears both
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.show_sidebar = true;
115
+ this.show_sidebar_wide = true;
48
116
  return () => {
49
117
  this.enabled = false;
50
- this.show_sidebar = false;
118
+ this.show_sidebar_wide = false;
119
+ this.show_sidebar_narrow = false;
51
120
  };
52
121
  }
53
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.115.0",
3
+ "version": "0.116.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",