@fuzdev/fuz_app 0.114.0 → 0.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/CLAUDE.md +52 -8
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +4 -3
- package/dist/auth/audit_log_schema.d.ts +3 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +13 -3
- package/dist/auth/cell_action_specs.js +1 -1
- package/dist/auth/cell_actions.d.ts.map +1 -1
- package/dist/auth/cell_actions.js +33 -4
- package/dist/auth/cell_authorize.d.ts +3 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -1
- package/dist/auth/cell_authorize.js +7 -4
- package/dist/auth/request_context.d.ts +12 -3
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +12 -3
- package/dist/auth/role_grant_offer_action_specs.d.ts +30 -5
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +53 -17
- package/dist/auth/role_grant_offer_actions.d.ts +23 -12
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +132 -38
- package/dist/auth/role_grant_offer_queries.d.ts +36 -0
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +76 -0
- package/dist/auth/role_schema.d.ts +12 -0
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +12 -0
- package/dist/auth/self_service_role_actions.d.ts +14 -0
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +25 -1
- package/dist/server/serve_fact_route.d.ts +3 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +7 -4
- package/dist/testing/CLAUDE.md +12 -4
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +23 -1
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +34 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +12 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +13 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +9 -7
- package/dist/testing/entities.d.ts +6 -3
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -1
- package/dist/ui/AppShell.svelte +303 -33
- package/dist/ui/AppShell.svelte.d.ts +80 -11
- package/dist/ui/AppShell.svelte.d.ts.map +1 -1
- package/dist/ui/BootstrapForm.svelte +14 -10
- package/dist/ui/BootstrapForm.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +152 -12
- package/dist/ui/LoginForm.svelte +8 -4
- package/dist/ui/LoginForm.svelte.d.ts.map +1 -1
- package/dist/ui/RoleGrantOfferForm.svelte +29 -14
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts +2 -3
- package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -1
- package/dist/ui/SignupForm.svelte +14 -13
- package/dist/ui/form_state.svelte.d.ts +15 -7
- package/dist/ui/form_state.svelte.d.ts.map +1 -1
- package/dist/ui/form_state.svelte.js +10 -2
- package/dist/ui/role_grant_offers_state.svelte.d.ts +25 -15
- package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
- package/dist/ui/role_grant_offers_state.svelte.js +7 -1
- package/dist/ui/sidebar_state.svelte.d.ts +62 -2
- package/dist/ui/sidebar_state.svelte.d.ts.map +1 -1
- package/dist/ui/sidebar_state.svelte.js +78 -9
- package/package.json +1 -21
package/dist/ui/AppShell.svelte
CHANGED
|
@@ -4,13 +4,67 @@
|
|
|
4
4
|
* a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
|
|
5
5
|
* can read sidebar visibility and toggle it. Optionally binds a global
|
|
6
6
|
* keyboard shortcut and renders a built-in toggle button (or a custom one
|
|
7
|
-
* via the `toggle_button` snippet).
|
|
7
|
+
* via the `toggle_button` snippet) while the sidebar is enabled. Requires
|
|
8
|
+
* the SvelteKit runtime — it closes the overlay from `afterNavigate`.
|
|
9
|
+
*
|
|
10
|
+
* Two modes, split by the state's `narrow_query`. Outside it the sidebar
|
|
11
|
+
* sits beside the content, padded by `--sidebar_offset`, following
|
|
12
|
+
* `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
|
|
13
|
+
* overlay over a scrim, following `show_sidebar_narrow`; the content is
|
|
14
|
+
* `inert` while it's open, and navigation, Escape, a scrim click, or
|
|
15
|
+
* widening out of narrow mode closes it. Escape never prevents the
|
|
16
|
+
* default, so the browser's close requests stay intact; it does nothing
|
|
17
|
+
* while a modal (`:modal`) is open, or when a window `keydown` listener
|
|
18
|
+
* registered before the overlay opened swallows it (a contextmenu, say).
|
|
19
|
+
* Closing with Escape or the keyboard shortcut returns focus to what
|
|
20
|
+
* opened the overlay, or else to the toggle, when focus was inside the
|
|
21
|
+
* sidebar or had fallen to the body (as it does when the content goes
|
|
22
|
+
* inert).
|
|
23
|
+
*
|
|
24
|
+
* The shell's rules sit in two cascade layers nested under `fuz_app`.
|
|
25
|
+
* `fuz_app.app_shell_base`, in the scoped style, holds the base and state
|
|
26
|
+
* rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
|
|
27
|
+
* some of them from a style element in `svelte:head` carrying
|
|
28
|
+
* `media={narrow_query}`, so the server render paints the right mode
|
|
29
|
+
* before hydration and the markup's classes never depend on the viewport.
|
|
30
|
+
* The narrow layer is ordered after the base layer, so it wins whatever
|
|
31
|
+
* the selectors' specificity or the sheets' order. Its rules are global —
|
|
32
|
+
* they match every `.app-shell` on the page — so shells mounted at the
|
|
33
|
+
* same time must share a `narrow_query`. Pages served under a strict CSP
|
|
34
|
+
* need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
|
|
35
|
+
*
|
|
36
|
+
* Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
|
|
37
|
+
* `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
|
|
38
|
+
* `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
|
|
39
|
+
* unlayered rule beats both layers at any specificity, so overrides need
|
|
40
|
+
* no specificity games; to layer them instead, order `fuz_app` first
|
|
41
|
+
* (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
|
|
42
|
+
* The same rule means an unlayered reset overrides the shell too — the
|
|
43
|
+
* fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
|
|
44
|
+
* so the layers set none of those. The one they need, the content's
|
|
45
|
+
* wide offset, goes through `--sidebar_offset`: the layers set it on
|
|
46
|
+
* `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
|
|
47
|
+
* else `0px`), and one unlayered rule at (0,0,1) reads it into the
|
|
48
|
+
* content's `padding-left`. Descendants of the content can read it too.
|
|
49
|
+
* Override the offset by setting `--sidebar_offset` on
|
|
50
|
+
* `.app-shell-content` or with a `padding-left` rule there; a `padding`
|
|
51
|
+
* shorthand there overrides it as well. Like any unlayered override,
|
|
52
|
+
* these apply in both modes, open or closed; to change only the wide open
|
|
53
|
+
* offset, target `.app-shell.wide-open > .app-shell-content` inside the
|
|
54
|
+
* narrow query's negation — the state classes don't track the viewport,
|
|
55
|
+
* so `.wide-open` stays set in narrow mode too. CSS variables:
|
|
56
|
+
* `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
|
|
57
|
+
* above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
|
|
58
|
+
* `sidebar_width` prop; always the configured width, not the offset), and
|
|
59
|
+
* `--sidebar_offset`.
|
|
8
60
|
*
|
|
9
61
|
* @module
|
|
10
62
|
*/
|
|
11
63
|
|
|
12
|
-
import type
|
|
64
|
+
import { flushSync, type Snippet } from 'svelte';
|
|
13
65
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
66
|
+
import { on } from 'svelte/events';
|
|
67
|
+
import { afterNavigate } from '$app/navigation';
|
|
14
68
|
import { is_editable, swallow } from '@fuzdev/fuz_util/dom.ts';
|
|
15
69
|
|
|
16
70
|
import { SidebarState, sidebar_state_context } from './sidebar_state.svelte.js';
|
|
@@ -23,95 +77,311 @@
|
|
|
23
77
|
keyboard_shortcut = false,
|
|
24
78
|
show_toggle = true,
|
|
25
79
|
toggle_button,
|
|
80
|
+
class: class_prop,
|
|
26
81
|
...rest
|
|
27
82
|
}: SvelteHTMLElements['div'] & {
|
|
28
83
|
children: Snippet;
|
|
29
84
|
sidebar: Snippet;
|
|
30
85
|
/**
|
|
31
|
-
* Sidebar width in pixels when shown
|
|
86
|
+
* Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
|
|
32
87
|
* @default 180
|
|
33
88
|
*/
|
|
34
89
|
sidebar_width?: number;
|
|
35
90
|
/** Optional pre-built `SidebarState` for sharing visibility across shells. */
|
|
36
91
|
sidebar_state?: SidebarState;
|
|
37
92
|
/**
|
|
38
|
-
* Single-key shortcut that toggles the sidebar (e.g. `'b'`)
|
|
93
|
+
* Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
|
|
94
|
+
* enabled and no modal is open — the persistent sidebar in wide mode,
|
|
95
|
+
* the overlay in narrow mode. `false` disables.
|
|
39
96
|
* @default false
|
|
40
97
|
*/
|
|
41
98
|
keyboard_shortcut?: string | false;
|
|
42
99
|
/**
|
|
43
|
-
* Whether to render the built-in (or custom) toggle button.
|
|
100
|
+
* Whether to render the built-in (or custom) toggle button. It renders
|
|
101
|
+
* only while the sidebar is enabled either way.
|
|
44
102
|
* @default true
|
|
45
103
|
*/
|
|
46
104
|
show_toggle?: boolean;
|
|
47
|
-
/**
|
|
48
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Custom toggle-button renderer; receives the title, visibility, toggle
|
|
107
|
+
* callback, and the sidebar element's id. Give the button
|
|
108
|
+
* `class="app-shell-toggle"` so it stacks above the sidebar and scrim
|
|
109
|
+
* (it renders as a direct child of the root) — the z-index applies only
|
|
110
|
+
* to a positioned button, so position it (e.g. `position: fixed`) — and
|
|
111
|
+
* `aria-controls={sidebar_id}` so Escape can return focus to it.
|
|
112
|
+
* `show_sidebar` is viewport-dependent: the server renders it as in wide
|
|
113
|
+
* mode, so rendering it as visible content changes on hydration at
|
|
114
|
+
* narrow widths — attributes are repaired, but key visible content on
|
|
115
|
+
* CSS or the viewport-free fields instead.
|
|
116
|
+
*/
|
|
117
|
+
toggle_button?: Snippet<
|
|
118
|
+
[{ title: string; show_sidebar: boolean; toggle: () => void; sidebar_id: string }]
|
|
119
|
+
>;
|
|
49
120
|
} = $props();
|
|
50
121
|
|
|
122
|
+
const uid = $props.id();
|
|
123
|
+
const sidebar_id = `${uid}-sidebar`;
|
|
124
|
+
|
|
125
|
+
let fallback_sidebar_state: SidebarState | undefined;
|
|
51
126
|
const get_sidebar_state = sidebar_state_context.set(
|
|
52
|
-
() => sidebar_state_prop ?? new SidebarState()
|
|
127
|
+
() => sidebar_state_prop ?? (fallback_sidebar_state ??= new SidebarState())
|
|
53
128
|
);
|
|
54
129
|
const sidebar_state = $derived(get_sidebar_state());
|
|
55
130
|
|
|
56
|
-
|
|
131
|
+
// viewport-free, so the server and hydration renders agree
|
|
132
|
+
const wide_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_wide);
|
|
133
|
+
const narrow_open = $derived(sidebar_state.enabled && sidebar_state.show_sidebar_narrow);
|
|
134
|
+
// viewport-dependent, only for `inert` and input handling (always `false` in SSR)
|
|
135
|
+
const overlay_open = $derived(sidebar_state.narrow && narrow_open);
|
|
136
|
+
|
|
137
|
+
const toggle_selector = `[aria-controls="${sidebar_id}"]`;
|
|
57
138
|
|
|
58
139
|
const button_title = $derived(
|
|
59
140
|
(sidebar_state.show_sidebar ? 'hide sidebar' : 'show sidebar') +
|
|
60
141
|
(keyboard_shortcut ? ` [${keyboard_shortcut}]` : '')
|
|
61
142
|
);
|
|
143
|
+
|
|
144
|
+
let sidebar_el: HTMLElement | undefined = $state();
|
|
145
|
+
let opener: HTMLElement | null = null;
|
|
146
|
+
|
|
147
|
+
afterNavigate(() => sidebar_state.close_narrow());
|
|
148
|
+
|
|
149
|
+
// widening out of narrow mode closes the overlay; the wide preference is untouched
|
|
150
|
+
$effect(() => {
|
|
151
|
+
if (!sidebar_state.narrow) sidebar_state.close_narrow();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const on_escape = (e: KeyboardEvent): void => {
|
|
155
|
+
if (e.key !== 'Escape' || e.defaultPrevented || !overlay_open) return;
|
|
156
|
+
// an open modal dialog owns Escape; not preventing the default leaves the
|
|
157
|
+
// browser's close requests (dialog `cancel`, `popover="auto"`) intact
|
|
158
|
+
if (document.querySelector(':modal')) return;
|
|
159
|
+
close_overlay();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// closes the overlay, returning focus when it was inside the sidebar or had
|
|
163
|
+
// fallen to the body — the inert content drops focus opened from within it
|
|
164
|
+
const close_overlay = (): void => {
|
|
165
|
+
const active = document.activeElement;
|
|
166
|
+
const should_return_focus =
|
|
167
|
+
!active || active === document.body || !!sidebar_el?.contains(active);
|
|
168
|
+
// flush so the content is no longer `inert` when focus returns into it
|
|
169
|
+
flushSync(() => sidebar_state.close_narrow());
|
|
170
|
+
if (should_return_focus) return_focus();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// the opener if it's still there and takes focus, else the first toggle that
|
|
174
|
+
// does — Safari doesn't focus a clicked button, so the opener can be `null`
|
|
175
|
+
const return_focus = (): void => {
|
|
176
|
+
if (opener?.isConnected) {
|
|
177
|
+
opener.focus();
|
|
178
|
+
if (document.activeElement === opener) return;
|
|
179
|
+
}
|
|
180
|
+
for (const toggle of document.querySelectorAll<HTMLElement>(toggle_selector)) {
|
|
181
|
+
toggle.focus();
|
|
182
|
+
if (document.activeElement === toggle) return;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// while the overlay is open, remember what had focus (to return it on Escape)
|
|
187
|
+
// and listen for Escape — registered now, not at mount, so window listeners
|
|
188
|
+
// mounted earlier (a contextmenu's, say) see Escape first and can swallow it
|
|
189
|
+
$effect(() => {
|
|
190
|
+
if (!overlay_open) return;
|
|
191
|
+
const active = document.activeElement;
|
|
192
|
+
opener = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
193
|
+
return on(window, 'keydown', on_escape);
|
|
194
|
+
});
|
|
62
195
|
</script>
|
|
63
196
|
|
|
197
|
+
<svelte:head>
|
|
198
|
+
{#if sidebar_state.narrow_query !== null}
|
|
199
|
+
<style media={sidebar_state.narrow_query}>
|
|
200
|
+
@layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
|
|
201
|
+
@layer fuz_app.app_shell_narrow {
|
|
202
|
+
.app-shell > .app-shell-content {
|
|
203
|
+
--sidebar_offset: 0px;
|
|
204
|
+
}
|
|
205
|
+
.app-shell > .app-shell-sidebar {
|
|
206
|
+
visibility: hidden;
|
|
207
|
+
translate: -100% 0;
|
|
208
|
+
width: min(var(--sidebar_width), 85vw);
|
|
209
|
+
}
|
|
210
|
+
.app-shell.narrow-open > .app-shell-sidebar {
|
|
211
|
+
visibility: visible;
|
|
212
|
+
translate: none;
|
|
213
|
+
}
|
|
214
|
+
.app-shell.narrow-open > .app-shell-scrim {
|
|
215
|
+
display: block;
|
|
216
|
+
}
|
|
217
|
+
.app-shell > .app-shell-toggle .app-shell-toggle-glyph {
|
|
218
|
+
scale: -1 1;
|
|
219
|
+
}
|
|
220
|
+
.app-shell.narrow-open > .app-shell-toggle .app-shell-toggle-glyph {
|
|
221
|
+
scale: none;
|
|
222
|
+
}
|
|
223
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
224
|
+
.app-shell > .app-shell-sidebar {
|
|
225
|
+
transition:
|
|
226
|
+
translate 150ms ease-out,
|
|
227
|
+
visibility 0s 150ms;
|
|
228
|
+
}
|
|
229
|
+
.app-shell.narrow-open > .app-shell-sidebar {
|
|
230
|
+
transition:
|
|
231
|
+
translate 150ms ease-out,
|
|
232
|
+
visibility 0s;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
</style>
|
|
237
|
+
{/if}
|
|
238
|
+
</svelte:head>
|
|
239
|
+
|
|
64
240
|
<svelte:window
|
|
65
241
|
onkeydowncapture={(e) => {
|
|
66
|
-
if (
|
|
67
|
-
|
|
242
|
+
if (
|
|
243
|
+
keyboard_shortcut &&
|
|
244
|
+
sidebar_state.enabled &&
|
|
245
|
+
e.key === keyboard_shortcut &&
|
|
246
|
+
!is_editable(e.target) &&
|
|
247
|
+
// a modal owns the keyboard, even one opened from the overlay
|
|
248
|
+
!document.querySelector(':modal')
|
|
249
|
+
) {
|
|
250
|
+
// closing the overlay returns focus like Escape; the rest just toggles
|
|
251
|
+
if (overlay_open) {
|
|
252
|
+
close_overlay();
|
|
253
|
+
} else {
|
|
254
|
+
sidebar_state.toggle_sidebar();
|
|
255
|
+
}
|
|
68
256
|
swallow(e);
|
|
69
257
|
}
|
|
70
258
|
}}
|
|
71
259
|
/>
|
|
72
260
|
|
|
73
|
-
<div
|
|
74
|
-
|
|
261
|
+
<div
|
|
262
|
+
{...rest}
|
|
263
|
+
class={['app-shell', class_prop, { 'wide-open': wide_open, 'narrow-open': narrow_open }]}
|
|
264
|
+
style:--sidebar_width="{sidebar_width}px"
|
|
265
|
+
>
|
|
266
|
+
<div class="app-shell-content" inert={overlay_open}>
|
|
75
267
|
{@render children()}
|
|
76
268
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
269
|
+
<!-- pointer-only dismissal; Escape is the keyboard path -->
|
|
270
|
+
<div
|
|
271
|
+
class="app-shell-scrim"
|
|
272
|
+
aria-hidden="true"
|
|
273
|
+
onclick={() => sidebar_state.close_narrow()}
|
|
274
|
+
></div>
|
|
275
|
+
{#if show_toggle && sidebar_state.enabled}
|
|
81
276
|
{#if toggle_button}
|
|
82
277
|
{@render toggle_button({
|
|
83
278
|
title: button_title,
|
|
84
279
|
show_sidebar: sidebar_state.show_sidebar,
|
|
85
|
-
toggle: () => sidebar_state.toggle_sidebar()
|
|
280
|
+
toggle: () => sidebar_state.toggle_sidebar(),
|
|
281
|
+
sidebar_id
|
|
86
282
|
})}
|
|
87
283
|
{:else}
|
|
88
284
|
<button
|
|
89
285
|
type="button"
|
|
90
|
-
class="position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
|
|
91
|
-
aria-label=
|
|
286
|
+
class="app-shell-toggle position:fixed bottom:0 left:0 icon_button plain border-radius:0 border_top_right_radius_sm"
|
|
287
|
+
aria-label="sidebar"
|
|
288
|
+
aria-keyshortcuts={keyboard_shortcut || undefined}
|
|
289
|
+
aria-expanded={sidebar_state.show_sidebar}
|
|
290
|
+
aria-controls={sidebar_id}
|
|
92
291
|
title={button_title}
|
|
93
292
|
onclick={() => sidebar_state.toggle_sidebar()}
|
|
94
293
|
>
|
|
95
|
-
|
|
294
|
+
<span class="app-shell-toggle-glyph" aria-hidden="true">←</span>
|
|
96
295
|
</button>
|
|
97
296
|
{/if}
|
|
98
297
|
{/if}
|
|
298
|
+
<div class="app-shell-sidebar" id={sidebar_id} bind:this={sidebar_el}>
|
|
299
|
+
{@render sidebar()}
|
|
300
|
+
</div>
|
|
99
301
|
</div>
|
|
100
302
|
|
|
101
303
|
<style>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
304
|
+
/*
|
|
305
|
+
* The shell's rules sit in `fuz_app.app_shell_base`. The narrow overrides live
|
|
306
|
+
* in the `media` style in `svelte:head`, in `fuz_app.app_shell_narrow`, which
|
|
307
|
+
* this order statement puts after the base layer — so they win regardless of
|
|
308
|
+
* specificity (these selectors carry the scope hash) or of which sheet loads
|
|
309
|
+
* first. The head style opens with the same statement; repeating it here pins
|
|
310
|
+
* the order whichever sheet the browser meets first.
|
|
311
|
+
*/
|
|
312
|
+
@layer fuz_app.app_shell_base, fuz_app.app_shell_narrow;
|
|
313
|
+
|
|
314
|
+
/*
|
|
315
|
+
* Never put a property the fuz_css reset sets (`padding`, `margin`, `border`,
|
|
316
|
+
* `box-sizing`, and their longhands) in a layer: the reset is unlayered, so it
|
|
317
|
+
* beats every layered declaration and the layered value computes to the
|
|
318
|
+
* reset's. Route such a value through a custom property the layers set, read
|
|
319
|
+
* by an unlayered rule. The content offset is the one case: the layers set
|
|
320
|
+
* `--sidebar_offset`, and this rule reads it. It's unscoped at (0,0,1) — above
|
|
321
|
+
* the reset's (0,0,0), below any class selector — so a consumer rule on
|
|
322
|
+
* `.app-shell-content` overrides it without matching specificity.
|
|
323
|
+
*/
|
|
324
|
+
:global(div:where(.app-shell > .app-shell-content)) {
|
|
325
|
+
padding-left: var(--sidebar_offset, 0px);
|
|
110
326
|
}
|
|
111
327
|
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
328
|
+
@layer fuz_app.app_shell_base {
|
|
329
|
+
.app-shell-content {
|
|
330
|
+
--sidebar_offset: 0px;
|
|
331
|
+
display: flex;
|
|
332
|
+
flex-direction: column;
|
|
333
|
+
min-height: 100vh;
|
|
334
|
+
}
|
|
335
|
+
.app-shell.wide-open > .app-shell-content {
|
|
336
|
+
--sidebar_offset: var(--sidebar_width);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.app-shell-sidebar {
|
|
340
|
+
position: fixed;
|
|
341
|
+
top: 0;
|
|
342
|
+
left: 0;
|
|
343
|
+
width: var(--sidebar_width);
|
|
344
|
+
height: 100%;
|
|
345
|
+
overflow: auto;
|
|
346
|
+
overscroll-behavior: contain;
|
|
347
|
+
scrollbar-width: thin;
|
|
348
|
+
background: var(--sidebar_bg, var(--shade_05));
|
|
349
|
+
z-index: var(--sidebar_z_index, 200);
|
|
350
|
+
visibility: hidden;
|
|
351
|
+
}
|
|
352
|
+
.app-shell.wide-open > .app-shell-sidebar {
|
|
353
|
+
visibility: visible;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.app-shell-scrim {
|
|
357
|
+
display: none;
|
|
358
|
+
position: fixed;
|
|
359
|
+
inset: 0;
|
|
360
|
+
z-index: var(--sidebar_z_index, 200);
|
|
361
|
+
background: var(--sidebar_scrim_bg, var(--darken_40));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/*
|
|
365
|
+
* The toggle and its glyph may be consumer markup from the `toggle_button`
|
|
366
|
+
* snippet, which never carries this component's scope hash, so they're
|
|
367
|
+
* matched by their public class names under the scoped root. The glyph
|
|
368
|
+
* rules go through the root's own toggle so an outer shell's state never
|
|
369
|
+
* reaches a nested shell's glyph.
|
|
370
|
+
*/
|
|
371
|
+
.app-shell > :global(.app-shell-toggle) {
|
|
372
|
+
z-index: calc(var(--sidebar_z_index, 200) + 1);
|
|
373
|
+
}
|
|
374
|
+
.app-shell > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
|
|
375
|
+
display: inline-block;
|
|
376
|
+
scale: -1 1;
|
|
377
|
+
}
|
|
378
|
+
.app-shell.wide-open > :global(.app-shell-toggle) :global(.app-shell-toggle-glyph) {
|
|
379
|
+
scale: none;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* a modal in the sidebar is in the top layer but inherits its visibility */
|
|
383
|
+
.app-shell-sidebar :global(:modal) {
|
|
384
|
+
visibility: visible;
|
|
385
|
+
}
|
|
116
386
|
}
|
|
117
387
|
</style>
|
|
@@ -3,39 +3,108 @@
|
|
|
3
3
|
* a fresh `SidebarState` if `sidebar_state` is not supplied) so descendants
|
|
4
4
|
* can read sidebar visibility and toggle it. Optionally binds a global
|
|
5
5
|
* keyboard shortcut and renders a built-in toggle button (or a custom one
|
|
6
|
-
* via the `toggle_button` snippet).
|
|
6
|
+
* via the `toggle_button` snippet) while the sidebar is enabled. Requires
|
|
7
|
+
* the SvelteKit runtime — it closes the overlay from `afterNavigate`.
|
|
8
|
+
*
|
|
9
|
+
* Two modes, split by the state's `narrow_query`. Outside it the sidebar
|
|
10
|
+
* sits beside the content, padded by `--sidebar_offset`, following
|
|
11
|
+
* `show_sidebar_wide`. Inside it the sidebar starts closed and opens as an
|
|
12
|
+
* overlay over a scrim, following `show_sidebar_narrow`; the content is
|
|
13
|
+
* `inert` while it's open, and navigation, Escape, a scrim click, or
|
|
14
|
+
* widening out of narrow mode closes it. Escape never prevents the
|
|
15
|
+
* default, so the browser's close requests stay intact; it does nothing
|
|
16
|
+
* while a modal (`:modal`) is open, or when a window `keydown` listener
|
|
17
|
+
* registered before the overlay opened swallows it (a contextmenu, say).
|
|
18
|
+
* Closing with Escape or the keyboard shortcut returns focus to what
|
|
19
|
+
* opened the overlay, or else to the toggle, when focus was inside the
|
|
20
|
+
* sidebar or had fallen to the body (as it does when the content goes
|
|
21
|
+
* inert).
|
|
22
|
+
*
|
|
23
|
+
* The shell's rules sit in two cascade layers nested under `fuz_app`.
|
|
24
|
+
* `fuz_app.app_shell_base`, in the scoped style, holds the base and state
|
|
25
|
+
* rules, which apply in both modes; `fuz_app.app_shell_narrow` overrides
|
|
26
|
+
* some of them from a style element in `svelte:head` carrying
|
|
27
|
+
* `media={narrow_query}`, so the server render paints the right mode
|
|
28
|
+
* before hydration and the markup's classes never depend on the viewport.
|
|
29
|
+
* The narrow layer is ordered after the base layer, so it wins whatever
|
|
30
|
+
* the selectors' specificity or the sheets' order. Its rules are global —
|
|
31
|
+
* they match every `.app-shell` on the page — so shells mounted at the
|
|
32
|
+
* same time must share a `narrow_query`. Pages served under a strict CSP
|
|
33
|
+
* need `style-src-elem 'unsafe-inline'` or the hash of that constant style.
|
|
34
|
+
*
|
|
35
|
+
* Public class hooks: `.app-shell` (root, with state classes `.wide-open` /
|
|
36
|
+
* `.narrow-open`), `.app-shell-content`, `.app-shell-sidebar`,
|
|
37
|
+
* `.app-shell-scrim`, `.app-shell-toggle`, `.app-shell-toggle-glyph`. Any
|
|
38
|
+
* unlayered rule beats both layers at any specificity, so overrides need
|
|
39
|
+
* no specificity games; to layer them instead, order `fuz_app` first
|
|
40
|
+
* (`@layer fuz_app, overrides;`) in a sheet that loads before the shell.
|
|
41
|
+
* The same rule means an unlayered reset overrides the shell too — the
|
|
42
|
+
* fuz_css reset zeroes `padding`, `margin`, and `border` on every element —
|
|
43
|
+
* so the layers set none of those. The one they need, the content's
|
|
44
|
+
* wide offset, goes through `--sidebar_offset`: the layers set it on
|
|
45
|
+
* `.app-shell-content` (`--sidebar_width` while the wide sidebar is open,
|
|
46
|
+
* else `0px`), and one unlayered rule at (0,0,1) reads it into the
|
|
47
|
+
* content's `padding-left`. Descendants of the content can read it too.
|
|
48
|
+
* Override the offset by setting `--sidebar_offset` on
|
|
49
|
+
* `.app-shell-content` or with a `padding-left` rule there; a `padding`
|
|
50
|
+
* shorthand there overrides it as well. Like any unlayered override,
|
|
51
|
+
* these apply in both modes, open or closed; to change only the wide open
|
|
52
|
+
* offset, target `.app-shell.wide-open > .app-shell-content` inside the
|
|
53
|
+
* narrow query's negation — the state classes don't track the viewport,
|
|
54
|
+
* so `.wide-open` stays set in narrow mode too. CSS variables:
|
|
55
|
+
* `--sidebar_bg`, `--sidebar_z_index` (default `200` in both modes; the toggle sits one
|
|
56
|
+
* above), `--sidebar_scrim_bg`, `--sidebar_width` (set from the
|
|
57
|
+
* `sidebar_width` prop; always the configured width, not the offset), and
|
|
58
|
+
* `--sidebar_offset`.
|
|
7
59
|
*
|
|
8
60
|
* @module
|
|
9
61
|
*/
|
|
10
|
-
import type
|
|
62
|
+
import { type Snippet } from 'svelte';
|
|
11
63
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
12
64
|
import { SidebarState } from './sidebar_state.svelte.js';
|
|
13
65
|
type $$ComponentProps = SvelteHTMLElements['div'] & {
|
|
14
66
|
children: Snippet;
|
|
15
67
|
sidebar: Snippet;
|
|
16
68
|
/**
|
|
17
|
-
* Sidebar width in pixels when shown
|
|
69
|
+
* Sidebar width in pixels when shown; in narrow mode capped at `85vw`.
|
|
18
70
|
* @default 180
|
|
19
71
|
*/
|
|
20
72
|
sidebar_width?: number;
|
|
21
73
|
/** Optional pre-built `SidebarState` for sharing visibility across shells. */
|
|
22
74
|
sidebar_state?: SidebarState;
|
|
23
75
|
/**
|
|
24
|
-
* Single-key shortcut that toggles the sidebar (e.g. `'b'`)
|
|
76
|
+
* Single-key shortcut that toggles the sidebar (e.g. `'b'`) while it's
|
|
77
|
+
* enabled and no modal is open — the persistent sidebar in wide mode,
|
|
78
|
+
* the overlay in narrow mode. `false` disables.
|
|
25
79
|
* @default false
|
|
26
80
|
*/
|
|
27
81
|
keyboard_shortcut?: string | false;
|
|
28
82
|
/**
|
|
29
|
-
* Whether to render the built-in (or custom) toggle button.
|
|
83
|
+
* Whether to render the built-in (or custom) toggle button. It renders
|
|
84
|
+
* only while the sidebar is enabled either way.
|
|
30
85
|
* @default true
|
|
31
86
|
*/
|
|
32
87
|
show_toggle?: boolean;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Custom toggle-button renderer; receives the title, visibility, toggle
|
|
90
|
+
* callback, and the sidebar element's id. Give the button
|
|
91
|
+
* `class="app-shell-toggle"` so it stacks above the sidebar and scrim
|
|
92
|
+
* (it renders as a direct child of the root) — the z-index applies only
|
|
93
|
+
* to a positioned button, so position it (e.g. `position: fixed`) — and
|
|
94
|
+
* `aria-controls={sidebar_id}` so Escape can return focus to it.
|
|
95
|
+
* `show_sidebar` is viewport-dependent: the server renders it as in wide
|
|
96
|
+
* mode, so rendering it as visible content changes on hydration at
|
|
97
|
+
* narrow widths — attributes are repaired, but key visible content on
|
|
98
|
+
* CSS or the viewport-free fields instead.
|
|
99
|
+
*/
|
|
100
|
+
toggle_button?: Snippet<[
|
|
101
|
+
{
|
|
102
|
+
title: string;
|
|
103
|
+
show_sidebar: boolean;
|
|
104
|
+
toggle: () => void;
|
|
105
|
+
sidebar_id: string;
|
|
106
|
+
}
|
|
107
|
+
]>;
|
|
39
108
|
};
|
|
40
109
|
declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
41
110
|
type AppShell = ReturnType<typeof AppShell>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA
|
|
1
|
+
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/AppShell.svelte"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4DI;AACJ,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAK1D,OAAO,EAAE,YAAY,EAAyB,MAAM,2BAA2B,CAAC;AAE/E,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG;IACpD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACnC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,OAAO,CACtB;QAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,YAAY,EAAE,OAAO,CAAC;YAAC,MAAM,EAAE,MAAM,IAAI,CAAC;YAAC,UAAU,EAAE,MAAM,CAAA;SAAE;KAAC,CAClF,CAAC;CACF,CAAC;AAyJH,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import PendingButton from '@fuzdev/fuz_ui/PendingButton.svelte';
|
|
18
18
|
import { autofocus } from '@fuzdev/fuz_ui/autofocus.svelte.ts';
|
|
19
19
|
|
|
20
|
-
import { Username } from '../primitive_schemas.js';
|
|
20
|
+
import { Username, USERNAME_LENGTH_MAX, USERNAME_LENGTH_MIN } from '../primitive_schemas.js';
|
|
21
21
|
import { PASSWORD_LENGTH_MIN } from '../auth/password.js';
|
|
22
22
|
import { auth_state_context } from './auth_state.svelte.js';
|
|
23
23
|
import { FormState } from './form_state.svelte.js';
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
} = $props();
|
|
34
34
|
|
|
35
35
|
const auth_state = auth_state_context.get();
|
|
36
|
-
const form_state = new FormState();
|
|
36
|
+
const form_state = new FormState<'token' | 'username' | 'password' | 'password_confirm'>();
|
|
37
37
|
|
|
38
38
|
let token = $state.raw('');
|
|
39
39
|
let username = $state.raw('');
|
|
@@ -47,11 +47,13 @@
|
|
|
47
47
|
username.trim() &&
|
|
48
48
|
username_valid &&
|
|
49
49
|
password.length >= PASSWORD_LENGTH_MIN &&
|
|
50
|
-
passwords_match
|
|
51
|
-
!auth_state.verifying
|
|
50
|
+
passwords_match
|
|
52
51
|
);
|
|
53
52
|
|
|
54
53
|
const handle_bootstrap = async (): Promise<void> => {
|
|
54
|
+
// while verifying the fields are `readonly` and the button `aria-disabled` —
|
|
55
|
+
// `disabled` would drop focus to `<body>` — so Enter and clicks still land here
|
|
56
|
+
if (auth_state.verifying) return;
|
|
55
57
|
form_state.attempt();
|
|
56
58
|
if (!can_submit) {
|
|
57
59
|
if (!token.trim()) form_state.focus('token');
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
type="password"
|
|
87
89
|
bind:value={token}
|
|
88
90
|
placeholder="paste token"
|
|
89
|
-
|
|
91
|
+
readonly={auth_state.verifying}
|
|
90
92
|
{@attach autofocus()}
|
|
91
93
|
/>
|
|
92
94
|
</label>
|
|
@@ -98,12 +100,13 @@
|
|
|
98
100
|
bind:value={username}
|
|
99
101
|
placeholder="admin"
|
|
100
102
|
autocomplete="username"
|
|
101
|
-
|
|
103
|
+
readonly={auth_state.verifying}
|
|
102
104
|
/>
|
|
103
105
|
</label>
|
|
104
106
|
{#if form_state.show('username') && username && !username_valid}
|
|
105
107
|
<p class="color_c_50 font_size_sm mt_0 mb_xs">
|
|
106
|
-
|
|
108
|
+
{USERNAME_LENGTH_MIN}-{USERNAME_LENGTH_MAX} chars, starts with a letter, ends with
|
|
109
|
+
letter/number, middle allows dash/underscore
|
|
107
110
|
</p>
|
|
108
111
|
{/if}
|
|
109
112
|
<fieldset>
|
|
@@ -115,7 +118,7 @@
|
|
|
115
118
|
bind:value={password}
|
|
116
119
|
placeholder="password"
|
|
117
120
|
autocomplete="new-password"
|
|
118
|
-
|
|
121
|
+
readonly={auth_state.verifying}
|
|
119
122
|
/>
|
|
120
123
|
</label>
|
|
121
124
|
{#if form_state.show('password') && password && password.length < PASSWORD_LENGTH_MIN}
|
|
@@ -131,7 +134,7 @@
|
|
|
131
134
|
bind:value={password_confirm}
|
|
132
135
|
placeholder="confirm password"
|
|
133
136
|
autocomplete="new-password"
|
|
134
|
-
|
|
137
|
+
readonly={auth_state.verifying}
|
|
135
138
|
/>
|
|
136
139
|
</label>
|
|
137
140
|
{#if form_state.show('password_confirm') && password && password_confirm && !passwords_match}
|
|
@@ -141,7 +144,8 @@
|
|
|
141
144
|
<div class="row gap_sm">
|
|
142
145
|
<PendingButton
|
|
143
146
|
pending={auth_state.verifying}
|
|
144
|
-
disabled={
|
|
147
|
+
disabled={false}
|
|
148
|
+
aria-disabled={auth_state.verifying}
|
|
145
149
|
onclick={handle_bootstrap}
|
|
146
150
|
class={auth_state.verify_error ? 'color_c' : ''}
|
|
147
151
|
>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"AAyBC,KAAK,gBAAgB,GAAI;IACxB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;
|
|
1
|
+
{"version":3,"file":"BootstrapForm.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/BootstrapForm.svelte"],"names":[],"mappings":"AAyBC,KAAK,gBAAgB,GAAI;IACxB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AA0GH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|