@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.
- package/dist/ui/AppShell.svelte +361 -33
- package/dist/ui/AppShell.svelte.d.ts +102 -11
- package/dist/ui/AppShell.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +162 -7
- package/dist/ui/sidebar_state.svelte.d.ts +73 -2
- package/dist/ui/sidebar_state.svelte.d.ts.map +1 -1
- package/dist/ui/sidebar_state.svelte.js +81 -9
- package/package.json +1 -1
package/dist/ui/AppShell.svelte
CHANGED
|
@@ -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
|
|
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'`)
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
|
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 (
|
|
67
|
-
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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'`)
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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`, `
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
#
|
|
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
|
|
83
|
+
return this.narrow ? this.show_sidebar_narrow : this.show_sidebar_wide;
|
|
29
84
|
}
|
|
30
85
|
set show_sidebar(value) {
|
|
31
|
-
this
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 `
|
|
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.
|
|
118
|
+
this.show_sidebar_wide = true;
|
|
48
119
|
return () => {
|
|
49
120
|
this.enabled = false;
|
|
50
|
-
this.
|
|
121
|
+
this.show_sidebar_wide = false;
|
|
122
|
+
this.show_sidebar_narrow = false;
|
|
51
123
|
};
|
|
52
124
|
}
|
|
53
125
|
}
|