@lyeve-labs/ui-kit 0.13.1 → 0.15.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/components/AccountMenu.svelte +131 -0
- package/dist/components/AccountMenu.svelte.d.ts +38 -0
- package/dist/components/AppShell.svelte +190 -0
- package/dist/components/AppShell.svelte.d.ts +45 -0
- package/dist/components/PageShell.svelte +32 -8
- package/dist/components/PageShell.svelte.d.ts +13 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/internal/layout.d.ts +27 -0
- package/dist/internal/layout.js +27 -0
- package/dist/styles/theme.css +5 -0
- package/package.json +1 -1
- package/src/lib/styles/theme.css +5 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The signed-in identity and the actions that belong to it, at the end of the
|
|
4
|
+
* app header.
|
|
5
|
+
*
|
|
6
|
+
* Where it lives is the point. The admin and the customer portal both put it
|
|
7
|
+
* in the bottom left corner of the sidebar and the ops console put it in the
|
|
8
|
+
* header, so the same account block was in two places depending on which of
|
|
9
|
+
* our own products you were looking at. The sidebar is also the worst of the
|
|
10
|
+
* two: it is already full height, so opening a menu in its last row pushes
|
|
11
|
+
* the last entry - Sign out, every time - past the bottom edge of the window.
|
|
12
|
+
* The header has room below it by construction.
|
|
13
|
+
*
|
|
14
|
+
* A native `details`, not a menu assembled from buttons. The panel holds
|
|
15
|
+
* links and a form post, and both have to keep working when hydration fails
|
|
16
|
+
* or never runs, which is exactly the moment somebody most needs to be able
|
|
17
|
+
* to sign out.
|
|
18
|
+
*/
|
|
19
|
+
import type { Snippet } from 'svelte';
|
|
20
|
+
import { ChevronDown } from '@lucide/svelte';
|
|
21
|
+
import type { AccentTone } from '../internal/tone.js';
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
/** The line people recognise themselves by: a display name, or the email. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Under it. The email when the name is a name, otherwise a role or a plan. */
|
|
27
|
+
secondary?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The avatar's letters. Derived from `name` when absent, which is right far
|
|
30
|
+
* more often than it is wrong.
|
|
31
|
+
*/
|
|
32
|
+
initials?: string;
|
|
33
|
+
/** The avatar's accent. Follows the app, not the person. */
|
|
34
|
+
tone?: AccentTone;
|
|
35
|
+
class?: string;
|
|
36
|
+
/** The menu itself: links, and the form that ends the session. */
|
|
37
|
+
children: Snippet;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
name,
|
|
42
|
+
secondary = undefined,
|
|
43
|
+
initials = undefined,
|
|
44
|
+
tone = 'brand',
|
|
45
|
+
class: klass = '',
|
|
46
|
+
children,
|
|
47
|
+
}: Props = $props();
|
|
48
|
+
|
|
49
|
+
// Splitting on the @ as well as on spaces: an account with no display name
|
|
50
|
+
// shows its email here, and "ka" reads as a person where "k@" reads as a
|
|
51
|
+
// rendering bug.
|
|
52
|
+
const letters = $derived(
|
|
53
|
+
initials ??
|
|
54
|
+
(name || '?')
|
|
55
|
+
.split(/[\s@._-]+/)
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.slice(0, 2)
|
|
58
|
+
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
59
|
+
.join(''),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const AVATAR_TONE: Record<AccentTone, string> = {
|
|
63
|
+
neutral: 'bg-surface-2 text-fg',
|
|
64
|
+
brand: 'bg-brand/15 text-brand',
|
|
65
|
+
success: 'bg-success/15 text-success',
|
|
66
|
+
warn: 'bg-warn/15 text-warn',
|
|
67
|
+
danger: 'bg-danger/15 text-danger',
|
|
68
|
+
violet: 'bg-violet/15 text-violet',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let root = $state<HTMLDetailsElement | null>(null);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A `details` closes on its own summary and on nothing else, so without this
|
|
75
|
+
* the panel stays open behind whatever the reader does next. Escape and an
|
|
76
|
+
* outside click are both what a menu owes; they are enhancement, and the
|
|
77
|
+
* disclosure still works without either.
|
|
78
|
+
*/
|
|
79
|
+
$effect(() => {
|
|
80
|
+
function onPointerDown(event: MouseEvent) {
|
|
81
|
+
if (!root?.open) return;
|
|
82
|
+
if (root.contains(event.target as Node)) return;
|
|
83
|
+
root.open = false;
|
|
84
|
+
}
|
|
85
|
+
function onKeydown(event: KeyboardEvent) {
|
|
86
|
+
if (event.key !== 'Escape' || !root?.open) return;
|
|
87
|
+
root.open = false;
|
|
88
|
+
root.querySelector('summary')?.focus();
|
|
89
|
+
}
|
|
90
|
+
document.addEventListener('click', onPointerDown, { capture: true });
|
|
91
|
+
document.addEventListener('keydown', onKeydown);
|
|
92
|
+
return () => {
|
|
93
|
+
document.removeEventListener('click', onPointerDown, { capture: true });
|
|
94
|
+
document.removeEventListener('keydown', onKeydown);
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<details bind:this={root} class="group relative {klass}">
|
|
100
|
+
<summary
|
|
101
|
+
class="flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-150 hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
102
|
+
>
|
|
103
|
+
<span
|
|
104
|
+
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold {AVATAR_TONE[
|
|
105
|
+
tone
|
|
106
|
+
]}"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
>
|
|
109
|
+
{letters}
|
|
110
|
+
</span>
|
|
111
|
+
<span class="hidden min-w-0 max-w-[10rem] truncate text-sm text-fg sm:block">{name}</span>
|
|
112
|
+
<ChevronDown
|
|
113
|
+
size={14}
|
|
114
|
+
class="shrink-0 text-faint transition-transform duration-150 group-open:rotate-180"
|
|
115
|
+
/>
|
|
116
|
+
</summary>
|
|
117
|
+
|
|
118
|
+
<!-- end-0: the panel hangs from the header's trailing edge, which is the one
|
|
119
|
+
edge it can grow from without leaving the window. The surface and the
|
|
120
|
+
rule are the card treatment, because lifted off the header it is a
|
|
121
|
+
surface of its own. -->
|
|
122
|
+
<div
|
|
123
|
+
class="absolute end-0 top-full z-50 mt-2 flex w-64 flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl"
|
|
124
|
+
>
|
|
125
|
+
<div class="border-b border-line px-2 pb-2">
|
|
126
|
+
<p class="truncate text-sm font-medium text-fg">{name}</p>
|
|
127
|
+
{#if secondary}<p class="truncate text-xs text-muted">{secondary}</p>{/if}
|
|
128
|
+
</div>
|
|
129
|
+
{@render children()}
|
|
130
|
+
</div>
|
|
131
|
+
</details>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-in identity and the actions that belong to it, at the end of the
|
|
3
|
+
* app header.
|
|
4
|
+
*
|
|
5
|
+
* Where it lives is the point. The admin and the customer portal both put it
|
|
6
|
+
* in the bottom left corner of the sidebar and the ops console put it in the
|
|
7
|
+
* header, so the same account block was in two places depending on which of
|
|
8
|
+
* our own products you were looking at. The sidebar is also the worst of the
|
|
9
|
+
* two: it is already full height, so opening a menu in its last row pushes
|
|
10
|
+
* the last entry - Sign out, every time - past the bottom edge of the window.
|
|
11
|
+
* The header has room below it by construction.
|
|
12
|
+
*
|
|
13
|
+
* A native `details`, not a menu assembled from buttons. The panel holds
|
|
14
|
+
* links and a form post, and both have to keep working when hydration fails
|
|
15
|
+
* or never runs, which is exactly the moment somebody most needs to be able
|
|
16
|
+
* to sign out.
|
|
17
|
+
*/
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
import type { AccentTone } from '../internal/tone.js';
|
|
20
|
+
interface Props {
|
|
21
|
+
/** The line people recognise themselves by: a display name, or the email. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Under it. The email when the name is a name, otherwise a role or a plan. */
|
|
24
|
+
secondary?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The avatar's letters. Derived from `name` when absent, which is right far
|
|
27
|
+
* more often than it is wrong.
|
|
28
|
+
*/
|
|
29
|
+
initials?: string;
|
|
30
|
+
/** The avatar's accent. Follows the app, not the person. */
|
|
31
|
+
tone?: AccentTone;
|
|
32
|
+
class?: string;
|
|
33
|
+
/** The menu itself: links, and the form that ends the session. */
|
|
34
|
+
children: Snippet;
|
|
35
|
+
}
|
|
36
|
+
declare const AccountMenu: import("svelte").Component<Props, {}, "">;
|
|
37
|
+
type AccountMenu = ReturnType<typeof AccountMenu>;
|
|
38
|
+
export default AccountMenu;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The authed application frame: the sidebar, the header bar and the content
|
|
4
|
+
* column, owned once so three apps cannot each invent their own.
|
|
5
|
+
*
|
|
6
|
+
* They did. The admin, the customer portal and the ops console each hand
|
|
7
|
+
* rolled this shell, and no two agreed: the sidebar was 224px in one and
|
|
8
|
+
* 240px in the other two, opaque in two and 30% translucent in the third,
|
|
9
|
+
* built from the kit's SidebarNav in one and from inline anchors in the
|
|
10
|
+
* others. Two of the three had no header at all above md:, so the current
|
|
11
|
+
* page had no name on screen and the account block sat in the bottom left
|
|
12
|
+
* corner of the sidebar in one shape and in a header dropdown in another.
|
|
13
|
+
* Every one of those was a defensible local choice and together they read as
|
|
14
|
+
* three products.
|
|
15
|
+
*
|
|
16
|
+
* The frame renders `main` and the drawer's dialog role. It renders no `nav`
|
|
17
|
+
* and no heading: the nav comes from the caller's SidebarNav, which is the
|
|
18
|
+
* only navigation landmark in the page, and the page's own PageShell owns the
|
|
19
|
+
* one `h1`. The section name here is a span for that reason - a second `h1`
|
|
20
|
+
* in the header makes a heading query answer with two elements and a screen
|
|
21
|
+
* reader announce the page name twice.
|
|
22
|
+
*/
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
import { Menu } from '@lucide/svelte';
|
|
25
|
+
import { overlay } from '../internal/overlay.js';
|
|
26
|
+
import { APP_BRAND, APP_HEADER, APP_SIDEBAR, APP_SIDEBAR_BAND } from '../internal/layout.js';
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
/** The current section, shown in the header. Not a heading: the page owns its h1. */
|
|
30
|
+
section?: string;
|
|
31
|
+
/** Bindable so the app router can close the drawer after a navigation. */
|
|
32
|
+
navOpen?: boolean;
|
|
33
|
+
/** The sidebar landmark's accessible name. */
|
|
34
|
+
sidebarLabel?: string;
|
|
35
|
+
/** The drawer's accessible name, below md: where the sidebar is a dialog. */
|
|
36
|
+
drawerLabel?: string;
|
|
37
|
+
/** The product mark, in the sidebar's brand row. */
|
|
38
|
+
brand?: Snippet;
|
|
39
|
+
/** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
|
|
40
|
+
nav?: Snippet;
|
|
41
|
+
/** A status band at the foot of the sidebar. Not the account: that is header work. */
|
|
42
|
+
sidebarFooter?: Snippet;
|
|
43
|
+
/** The right end of the header bar. The theme toggle and the account menu live here. */
|
|
44
|
+
headerActions?: Snippet;
|
|
45
|
+
class?: string;
|
|
46
|
+
children: Snippet;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let {
|
|
50
|
+
section = undefined,
|
|
51
|
+
navOpen = $bindable(false),
|
|
52
|
+
sidebarLabel = 'Sidebar',
|
|
53
|
+
drawerLabel = 'Navigation',
|
|
54
|
+
brand,
|
|
55
|
+
nav,
|
|
56
|
+
sidebarFooter,
|
|
57
|
+
headerActions,
|
|
58
|
+
class: klass = '',
|
|
59
|
+
children,
|
|
60
|
+
}: Props = $props();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Desktop until the browser says otherwise. The server cannot know the
|
|
64
|
+
* viewport, and a sidebar rendered inert on the server is unreachable to
|
|
65
|
+
* anyone whose JavaScript never arrives.
|
|
66
|
+
*/
|
|
67
|
+
let isMobile = $state(false);
|
|
68
|
+
const drawerOpen = $derived(isMobile && navOpen);
|
|
69
|
+
|
|
70
|
+
$effect(() => {
|
|
71
|
+
const mq = window.matchMedia('(max-width: 767px)');
|
|
72
|
+
const sync = () => {
|
|
73
|
+
isMobile = mq.matches;
|
|
74
|
+
if (!mq.matches) navOpen = false;
|
|
75
|
+
};
|
|
76
|
+
sync();
|
|
77
|
+
mq.addEventListener('change', sync);
|
|
78
|
+
return () => mq.removeEventListener('change', sync);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** The backdrop is the pointer's way out of the drawer. A keyboard has none. */
|
|
82
|
+
function onWindowKeydown(event: KeyboardEvent) {
|
|
83
|
+
if (event.key === 'Escape' && navOpen) navOpen = false;
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
88
|
+
|
|
89
|
+
<div class="flex h-screen {klass}">
|
|
90
|
+
<!-- First focusable thing in the document. Without it a keyboard reader tabs
|
|
91
|
+
the whole sidebar again on every page. -->
|
|
92
|
+
<a
|
|
93
|
+
href="#content"
|
|
94
|
+
class="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:text-fg focus:outline-none focus:ring-2 focus:ring-brand"
|
|
95
|
+
>
|
|
96
|
+
Skip to content
|
|
97
|
+
</a>
|
|
98
|
+
|
|
99
|
+
{#if drawerOpen}
|
|
100
|
+
<!-- Follows Drawer and Modal: no palette token reads as a dimmer in both
|
|
101
|
+
themes. Closing on it is the gesture people expect, and it stops a tap
|
|
102
|
+
reaching the page under the open drawer. -->
|
|
103
|
+
<div
|
|
104
|
+
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
|
105
|
+
role="presentation"
|
|
106
|
+
onclick={() => (navOpen = false)}
|
|
107
|
+
></div>
|
|
108
|
+
{/if}
|
|
109
|
+
|
|
110
|
+
<!--
|
|
111
|
+
One aside at every width. Below md: it sits inside a positioned dialog; above
|
|
112
|
+
it, that wrapper is the column it has always been. Rendering a second copy
|
|
113
|
+
for the drawer would put every nav link in the page twice, which is what a
|
|
114
|
+
strict-mode locator trips on and what a screen reader reads out.
|
|
115
|
+
|
|
116
|
+
The dialog role is on the wrapper and not on the aside: an aside is a
|
|
117
|
+
complementary landmark, and the a11y gate rejects a non-interactive element
|
|
118
|
+
taking an interactive role. use:overlay is what makes aria-modal true rather
|
|
119
|
+
than merely claimed - it moves focus in, keeps Tab inside, and hands focus
|
|
120
|
+
back to the hamburger on close.
|
|
121
|
+
-->
|
|
122
|
+
{#if drawerOpen}
|
|
123
|
+
<div
|
|
124
|
+
class="fixed inset-y-0 start-0 z-50 flex shadow-2xl"
|
|
125
|
+
role="dialog"
|
|
126
|
+
aria-modal="true"
|
|
127
|
+
aria-label={drawerLabel}
|
|
128
|
+
use:overlay
|
|
129
|
+
>
|
|
130
|
+
{@render sidebar(false)}
|
|
131
|
+
</div>
|
|
132
|
+
{:else}
|
|
133
|
+
<div class="hidden md:flex">
|
|
134
|
+
{@render sidebar(isMobile)}
|
|
135
|
+
</div>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
139
|
+
<header class={APP_HEADER}>
|
|
140
|
+
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
141
|
+
{#if isMobile}
|
|
142
|
+
<!-- 44px square, the smallest tap target SC 2.5.5 accepts. The -ms-2
|
|
143
|
+
pulls it back to the header's own gutter so the row it starts
|
|
144
|
+
still lines up with the page content below. -->
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
class="-ms-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted outline-none transition-colors duration-150 hover:bg-surface-2 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
148
|
+
aria-label={navOpen ? 'Close navigation' : 'Open navigation'}
|
|
149
|
+
aria-expanded={navOpen}
|
|
150
|
+
onclick={() => (navOpen = !navOpen)}
|
|
151
|
+
>
|
|
152
|
+
<Menu size={20} />
|
|
153
|
+
</button>
|
|
154
|
+
{/if}
|
|
155
|
+
{#if section}
|
|
156
|
+
<span data-testid="app-section" class="truncate text-sm font-semibold text-fg">
|
|
157
|
+
{section}
|
|
158
|
+
</span>
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{#if headerActions}
|
|
163
|
+
<div class="flex shrink-0 items-center gap-1">{@render headerActions()}</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</header>
|
|
166
|
+
|
|
167
|
+
<main id="content" class="min-w-0 flex-1 overflow-auto bg-ink">
|
|
168
|
+
{@render children()}
|
|
169
|
+
</main>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{#snippet sidebar(hidden: boolean)}
|
|
174
|
+
<aside
|
|
175
|
+
aria-label={sidebarLabel}
|
|
176
|
+
class="{APP_SIDEBAR} flex"
|
|
177
|
+
inert={hidden}
|
|
178
|
+
aria-hidden={hidden ? 'true' : undefined}
|
|
179
|
+
>
|
|
180
|
+
{#if brand}
|
|
181
|
+
<div class={APP_BRAND}>{@render brand()}</div>
|
|
182
|
+
{/if}
|
|
183
|
+
{#if nav}
|
|
184
|
+
{@render nav()}
|
|
185
|
+
{/if}
|
|
186
|
+
{#if sidebarFooter}
|
|
187
|
+
<div class={APP_SIDEBAR_BAND}>{@render sidebarFooter()}</div>
|
|
188
|
+
{/if}
|
|
189
|
+
</aside>
|
|
190
|
+
{/snippet}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authed application frame: the sidebar, the header bar and the content
|
|
3
|
+
* column, owned once so three apps cannot each invent their own.
|
|
4
|
+
*
|
|
5
|
+
* They did. The admin, the customer portal and the ops console each hand
|
|
6
|
+
* rolled this shell, and no two agreed: the sidebar was 224px in one and
|
|
7
|
+
* 240px in the other two, opaque in two and 30% translucent in the third,
|
|
8
|
+
* built from the kit's SidebarNav in one and from inline anchors in the
|
|
9
|
+
* others. Two of the three had no header at all above md:, so the current
|
|
10
|
+
* page had no name on screen and the account block sat in the bottom left
|
|
11
|
+
* corner of the sidebar in one shape and in a header dropdown in another.
|
|
12
|
+
* Every one of those was a defensible local choice and together they read as
|
|
13
|
+
* three products.
|
|
14
|
+
*
|
|
15
|
+
* The frame renders `main` and the drawer's dialog role. It renders no `nav`
|
|
16
|
+
* and no heading: the nav comes from the caller's SidebarNav, which is the
|
|
17
|
+
* only navigation landmark in the page, and the page's own PageShell owns the
|
|
18
|
+
* one `h1`. The section name here is a span for that reason - a second `h1`
|
|
19
|
+
* in the header makes a heading query answer with two elements and a screen
|
|
20
|
+
* reader announce the page name twice.
|
|
21
|
+
*/
|
|
22
|
+
import type { Snippet } from 'svelte';
|
|
23
|
+
interface Props {
|
|
24
|
+
/** The current section, shown in the header. Not a heading: the page owns its h1. */
|
|
25
|
+
section?: string;
|
|
26
|
+
/** Bindable so the app router can close the drawer after a navigation. */
|
|
27
|
+
navOpen?: boolean;
|
|
28
|
+
/** The sidebar landmark's accessible name. */
|
|
29
|
+
sidebarLabel?: string;
|
|
30
|
+
/** The drawer's accessible name, below md: where the sidebar is a dialog. */
|
|
31
|
+
drawerLabel?: string;
|
|
32
|
+
/** The product mark, in the sidebar's brand row. */
|
|
33
|
+
brand?: Snippet;
|
|
34
|
+
/** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
|
|
35
|
+
nav?: Snippet;
|
|
36
|
+
/** A status band at the foot of the sidebar. Not the account: that is header work. */
|
|
37
|
+
sidebarFooter?: Snippet;
|
|
38
|
+
/** The right end of the header bar. The theme toggle and the account menu live here. */
|
|
39
|
+
headerActions?: Snippet;
|
|
40
|
+
class?: string;
|
|
41
|
+
children: Snippet;
|
|
42
|
+
}
|
|
43
|
+
declare const AppShell: import("svelte").Component<Props, {}, "navOpen">;
|
|
44
|
+
type AppShell = ReturnType<typeof AppShell>;
|
|
45
|
+
export default AppShell;
|
|
@@ -26,9 +26,21 @@
|
|
|
26
26
|
width?: PageWidth;
|
|
27
27
|
/**
|
|
28
28
|
* A full-height page that manages its own scrolling, for instance a split
|
|
29
|
-
* pane or a canvas.
|
|
29
|
+
* pane or a canvas. The content loses the gutter and the cap so the pane
|
|
30
|
+
* can reach the edges; the title row keeps both.
|
|
30
31
|
*/
|
|
31
32
|
fill?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Drop the title to body size and hide the description, for a page with
|
|
35
|
+
* genuinely no room for a heading.
|
|
36
|
+
*
|
|
37
|
+
* It used to be implied by `fill`, which made the two pages that own the
|
|
38
|
+
* viewport the only two in the app whose name rendered at body size against
|
|
39
|
+
* the window edge. Owning the viewport is a statement about the content
|
|
40
|
+
* pane, not about the heading, so a caller that wants the smaller title now
|
|
41
|
+
* asks for it.
|
|
42
|
+
*/
|
|
43
|
+
compact?: boolean;
|
|
32
44
|
/** Rendered above the title at one fixed distance. */
|
|
33
45
|
breadcrumb?: Snippet;
|
|
34
46
|
/** Right-aligned controls in the title row. */
|
|
@@ -42,6 +54,7 @@
|
|
|
42
54
|
description = undefined,
|
|
43
55
|
width = 'default',
|
|
44
56
|
fill = false,
|
|
57
|
+
compact = false,
|
|
45
58
|
breadcrumb,
|
|
46
59
|
actions,
|
|
47
60
|
class: klass = '',
|
|
@@ -49,11 +62,11 @@
|
|
|
49
62
|
}: Props = $props();
|
|
50
63
|
|
|
51
64
|
/**
|
|
52
|
-
* A fill page owns the viewport instead of sitting in it:
|
|
53
|
-
* and the height the title row leaves goes to
|
|
54
|
-
* scrolls inside the page rather than scrolling the page.
|
|
55
|
-
* documented waiver against their app's own layout lint for
|
|
56
|
-
* shape, which is the argument for the shell supporting it.
|
|
65
|
+
* A fill page owns the viewport instead of sitting in it: the content takes
|
|
66
|
+
* no cap and reaches the edges, and the height the title row leaves goes to
|
|
67
|
+
* it, so a split pane scrolls inside the page rather than scrolling the page.
|
|
68
|
+
* Two pages carry a documented waiver against their app's own layout lint for
|
|
69
|
+
* exactly this shape, which is the argument for the shell supporting it.
|
|
57
70
|
*
|
|
58
71
|
* `min-h-0` is load bearing on the column: a flex item refuses to shrink
|
|
59
72
|
* below its content by default, so without it the pane runs past the bottom
|
|
@@ -65,6 +78,17 @@
|
|
|
65
78
|
: `${PAGE_PAD} ${PAGE_WIDTH[width]} ${PAGE_STACK}`,
|
|
66
79
|
);
|
|
67
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The gutter the title row keeps when the content gives it up.
|
|
83
|
+
*
|
|
84
|
+
* The frame supplies it on an ordinary page, so only a fill page states it
|
|
85
|
+
* here, and it composes from the same tokens rather than a second spelling:
|
|
86
|
+
* a fill page's title lines up with every other page's to the pixel. Only
|
|
87
|
+
* the top edge is padded, because the frame's own gap owns the distance down
|
|
88
|
+
* to the content.
|
|
89
|
+
*/
|
|
90
|
+
const headerPad = $derived(fill ? 'px-page-x pt-page-y' : '');
|
|
91
|
+
|
|
68
92
|
/** The content stack. On a fill page it also takes the leftover height. */
|
|
69
93
|
const content = $derived(fill ? `${PAGE_STACK} min-h-0 flex-1` : PAGE_STACK);
|
|
70
94
|
</script>
|
|
@@ -72,11 +96,11 @@
|
|
|
72
96
|
<div class="{frame} {klass}">
|
|
73
97
|
<!-- The breadcrumb and the title are one group, so the distance between them
|
|
74
98
|
is fixed here and does not change with whether a description is set. -->
|
|
75
|
-
<div class="flex flex-col gap-2">
|
|
99
|
+
<div class="flex flex-col gap-2 {headerPad}">
|
|
76
100
|
{#if breadcrumb}{@render breadcrumb()}{/if}
|
|
77
101
|
<!-- flush: the shell's own section stack supplies the gap below the title,
|
|
78
102
|
so the header must not add a second one. -->
|
|
79
|
-
<PageHeader {title} {description} {actions} compact
|
|
103
|
+
<PageHeader {title} {description} {actions} {compact} flush />
|
|
80
104
|
</div>
|
|
81
105
|
|
|
82
106
|
<div class={content}>
|
|
@@ -23,9 +23,21 @@ interface Props {
|
|
|
23
23
|
width?: PageWidth;
|
|
24
24
|
/**
|
|
25
25
|
* A full-height page that manages its own scrolling, for instance a split
|
|
26
|
-
* pane or a canvas.
|
|
26
|
+
* pane or a canvas. The content loses the gutter and the cap so the pane
|
|
27
|
+
* can reach the edges; the title row keeps both.
|
|
27
28
|
*/
|
|
28
29
|
fill?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Drop the title to body size and hide the description, for a page with
|
|
32
|
+
* genuinely no room for a heading.
|
|
33
|
+
*
|
|
34
|
+
* It used to be implied by `fill`, which made the two pages that own the
|
|
35
|
+
* viewport the only two in the app whose name rendered at body size against
|
|
36
|
+
* the window edge. Owning the viewport is a statement about the content
|
|
37
|
+
* pane, not about the heading, so a caller that wants the smaller title now
|
|
38
|
+
* asks for it.
|
|
39
|
+
*/
|
|
40
|
+
compact?: boolean;
|
|
29
41
|
/** Rendered above the title at one fixed distance. */
|
|
30
42
|
breadcrumb?: Snippet;
|
|
31
43
|
/** Right-aligned controls in the title row. */
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { default as Card } from './components/Card.svelte';
|
|
10
10
|
export { default as Panel } from './components/Panel.svelte';
|
|
11
|
+
export { default as AppShell } from './components/AppShell.svelte';
|
|
11
12
|
export { default as PageShell } from './components/PageShell.svelte';
|
|
12
13
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
13
14
|
export { default as SectionHeading } from './components/SectionHeading.svelte';
|
|
@@ -55,6 +56,7 @@ export { default as Pagination } from './components/Pagination.svelte';
|
|
|
55
56
|
export { default as StepIndicator } from './components/StepIndicator.svelte';
|
|
56
57
|
export { default as Dropdown } from './components/Dropdown.svelte';
|
|
57
58
|
export { default as SidebarNav } from './components/SidebarNav.svelte';
|
|
59
|
+
export { default as AccountMenu } from './components/AccountMenu.svelte';
|
|
58
60
|
export type { NavNode, NavTree } from './internal/nav-tree.js';
|
|
59
61
|
export { default as Modal } from './components/Modal.svelte';
|
|
60
62
|
export { default as Drawer } from './components/Drawer.svelte';
|
|
@@ -84,4 +86,4 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
|
|
|
84
86
|
export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
|
|
85
87
|
export { cn, type ClassValue } from './utils/cn.js';
|
|
86
88
|
export { getTheme, setTheme, toggleTheme, themeBootScript, type Theme } from './utils/theme.js';
|
|
87
|
-
export declare const VERSION = "0.
|
|
89
|
+
export declare const VERSION = "0.15.0";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// ── Layout & structure ─────────────────────────────────────────────────────
|
|
10
10
|
export { default as Card } from './components/Card.svelte';
|
|
11
11
|
export { default as Panel } from './components/Panel.svelte';
|
|
12
|
+
export { default as AppShell } from './components/AppShell.svelte';
|
|
12
13
|
export { default as PageShell } from './components/PageShell.svelte';
|
|
13
14
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
14
15
|
export { default as SectionHeading } from './components/SectionHeading.svelte';
|
|
@@ -54,6 +55,7 @@ export { default as Pagination } from './components/Pagination.svelte';
|
|
|
54
55
|
export { default as StepIndicator } from './components/StepIndicator.svelte';
|
|
55
56
|
export { default as Dropdown } from './components/Dropdown.svelte';
|
|
56
57
|
export { default as SidebarNav } from './components/SidebarNav.svelte';
|
|
58
|
+
export { default as AccountMenu } from './components/AccountMenu.svelte';
|
|
57
59
|
// ── Overlays ───────────────────────────────────────────────────────────────
|
|
58
60
|
export { default as Modal } from './components/Modal.svelte';
|
|
59
61
|
export { default as Drawer } from './components/Drawer.svelte';
|
|
@@ -90,4 +92,4 @@ export { getTheme, setTheme, toggleTheme, themeBootScript } from './utils/theme.
|
|
|
90
92
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
91
93
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
92
94
|
// this line; the build and the test suite fail when the two disagree.
|
|
93
|
-
export const VERSION = '0.
|
|
95
|
+
export const VERSION = '0.15.0';
|
|
@@ -117,3 +117,30 @@ export declare const MODAL_PAD = "px-card py-card-sm";
|
|
|
117
117
|
* the caller is already writing.
|
|
118
118
|
*/
|
|
119
119
|
export declare function sectionHeading(level: 2 | 3): string;
|
|
120
|
+
/**
|
|
121
|
+
* The authed app frame, stated once for every app that has one.
|
|
122
|
+
*
|
|
123
|
+
* Three apps hand rolled this and none of the three agreed. The sidebar was
|
|
124
|
+
* `w-56` in the admin and `w-60` in the other two, `bg-surface` in two and
|
|
125
|
+
* `bg-surface/30` in the third; the header existed in one and not in the other
|
|
126
|
+
* two. None of that was visible from inside any one app, which is why it went
|
|
127
|
+
* on being three shells for as long as it did.
|
|
128
|
+
*
|
|
129
|
+
* `border-e` and not `border-r`: the shell is the one place a right-to-left
|
|
130
|
+
* locale flips, and a physical border leaves the rule on the wrong edge.
|
|
131
|
+
*/
|
|
132
|
+
export declare const APP_SIDEBAR = "h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface";
|
|
133
|
+
/**
|
|
134
|
+
* The header bar. Its gutter is the page gutter, so the section name in the
|
|
135
|
+
* header sits directly above the page title under it rather than 8px off.
|
|
136
|
+
*/
|
|
137
|
+
export declare const APP_HEADER = "flex h-header shrink-0 items-center gap-2 border-b border-line bg-surface px-page-x";
|
|
138
|
+
/**
|
|
139
|
+
* The sidebar's brand row. Exactly as tall as the header beside it: the two
|
|
140
|
+
* meet at the top left corner and a 4px disagreement there reads as a seam.
|
|
141
|
+
* Its inset is the nav row inset, so the mark lines up with the nav icons
|
|
142
|
+
* under it instead of with their labels.
|
|
143
|
+
*/
|
|
144
|
+
export declare const APP_BRAND = "flex h-header shrink-0 items-center gap-2 border-b border-line px-inline";
|
|
145
|
+
/** A band at the foot of the sidebar, on the nav's own inset. */
|
|
146
|
+
export declare const APP_SIDEBAR_BAND = "shrink-0 border-t border-line px-inline py-3";
|
package/dist/internal/layout.js
CHANGED
|
@@ -130,3 +130,30 @@ export function sectionHeading(level) {
|
|
|
130
130
|
// one heading broken in half.
|
|
131
131
|
return level === 2 ? 'text-lg font-semibold text-fg' : 'text-sm font-semibold text-fg';
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The authed app frame, stated once for every app that has one.
|
|
135
|
+
*
|
|
136
|
+
* Three apps hand rolled this and none of the three agreed. The sidebar was
|
|
137
|
+
* `w-56` in the admin and `w-60` in the other two, `bg-surface` in two and
|
|
138
|
+
* `bg-surface/30` in the third; the header existed in one and not in the other
|
|
139
|
+
* two. None of that was visible from inside any one app, which is why it went
|
|
140
|
+
* on being three shells for as long as it did.
|
|
141
|
+
*
|
|
142
|
+
* `border-e` and not `border-r`: the shell is the one place a right-to-left
|
|
143
|
+
* locale flips, and a physical border leaves the rule on the wrong edge.
|
|
144
|
+
*/
|
|
145
|
+
export const APP_SIDEBAR = 'h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface';
|
|
146
|
+
/**
|
|
147
|
+
* The header bar. Its gutter is the page gutter, so the section name in the
|
|
148
|
+
* header sits directly above the page title under it rather than 8px off.
|
|
149
|
+
*/
|
|
150
|
+
export const APP_HEADER = 'flex h-header shrink-0 items-center gap-2 border-b border-line bg-surface px-page-x';
|
|
151
|
+
/**
|
|
152
|
+
* The sidebar's brand row. Exactly as tall as the header beside it: the two
|
|
153
|
+
* meet at the top left corner and a 4px disagreement there reads as a seam.
|
|
154
|
+
* Its inset is the nav row inset, so the mark lines up with the nav icons
|
|
155
|
+
* under it instead of with their labels.
|
|
156
|
+
*/
|
|
157
|
+
export const APP_BRAND = 'flex h-header shrink-0 items-center gap-2 border-b border-line px-inline';
|
|
158
|
+
/** A band at the foot of the sidebar, on the nav's own inset. */
|
|
159
|
+
export const APP_SIDEBAR_BAND = 'shrink-0 border-t border-line px-inline py-3';
|
package/dist/styles/theme.css
CHANGED
|
@@ -137,6 +137,11 @@
|
|
|
137
137
|
* the 13px/1.25 control text plus input-y padding and a 1px border. */
|
|
138
138
|
--spacing-control: 2.375rem;
|
|
139
139
|
--spacing-panel-max: 15rem; /* 240px - the scroll cap on a floating option list */
|
|
140
|
+
/* 56px - the app header bar, and the sidebar's brand row above the nav.
|
|
141
|
+
* One token, because the two sit side by side across the top of every
|
|
142
|
+
* authed screen and a 4px disagreement between them reads as a broken
|
|
143
|
+
* seam. Three apps each picked their own height before this existed. */
|
|
144
|
+
--spacing-header: 3.5rem;
|
|
140
145
|
--spacing-nav-indent: 0.875rem; /* 14px - one level of sidebar nesting */
|
|
141
146
|
--spacing-nav-rail: 3.5rem; /* 56px - the collapsed icon rail */
|
|
142
147
|
--spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
|
package/package.json
CHANGED
package/src/lib/styles/theme.css
CHANGED
|
@@ -137,6 +137,11 @@
|
|
|
137
137
|
* the 13px/1.25 control text plus input-y padding and a 1px border. */
|
|
138
138
|
--spacing-control: 2.375rem;
|
|
139
139
|
--spacing-panel-max: 15rem; /* 240px - the scroll cap on a floating option list */
|
|
140
|
+
/* 56px - the app header bar, and the sidebar's brand row above the nav.
|
|
141
|
+
* One token, because the two sit side by side across the top of every
|
|
142
|
+
* authed screen and a 4px disagreement between them reads as a broken
|
|
143
|
+
* seam. Three apps each picked their own height before this existed. */
|
|
144
|
+
--spacing-header: 3.5rem;
|
|
140
145
|
--spacing-nav-indent: 0.875rem; /* 14px - one level of sidebar nesting */
|
|
141
146
|
--spacing-nav-rail: 3.5rem; /* 56px - the collapsed icon rail */
|
|
142
147
|
--spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
|