@lyeve-labs/ui-kit 0.25.0 → 0.26.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/README.md +35 -9
- package/dist/components/AccountMenu.svelte +1 -1
- package/dist/components/AccountMenu.svelte.d.ts +1 -1
- package/dist/components/AppShell.svelte +1 -1
- package/dist/components/AppShell.svelte.d.ts +1 -1
- package/dist/components/AuthShell.svelte +76 -0
- package/dist/components/AuthShell.svelte.d.ts +19 -0
- package/dist/components/Autocomplete.svelte +2 -2
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CheckboxGroup.svelte +6 -1
- package/dist/components/DatePicker.svelte +2 -2
- package/dist/components/DateTimePicker.svelte +1 -1
- package/dist/components/Drawer.svelte +43 -13
- package/dist/components/Drawer.svelte.d.ts +13 -2
- package/dist/components/Dropdown.svelte +1 -1
- package/dist/components/Field.svelte +1 -1
- package/dist/components/FileInput.svelte +1 -1
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Modal.svelte +32 -12
- package/dist/components/Modal.svelte.d.ts +11 -2
- package/dist/components/MultiSelect.svelte +2 -2
- package/dist/components/NumberInput.svelte +1 -1
- package/dist/components/PasswordInput.svelte +1 -1
- package/dist/components/Radio.svelte +1 -1
- package/dist/components/RadioGroup.svelte +1 -0
- package/dist/components/SearchInput.svelte +1 -1
- package/dist/components/SegmentedControl.svelte +1 -1
- package/dist/components/Select.svelte +2 -2
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +1 -1
- package/dist/components/Toaster.svelte +1 -1
- package/dist/components/dialog/Dialog.svelte +2 -2
- package/dist/components/dialog/types.d.ts +3 -1
- package/dist/components/dialog/types.js +2 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/internal/field.d.ts +20 -0
- package/dist/internal/field.js +23 -0
- package/dist/internal/layout.d.ts +34 -0
- package/dist/internal/layout.js +47 -3
- package/dist/motion.js +7 -2
- package/dist/styles/theme.css +26 -1
- package/package.json +1 -1
- package/src/lib/styles/theme.css +26 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
|
|
|
28
28
|
|
|
29
29
|
## What's in the box
|
|
30
30
|
|
|
31
|
-
- **
|
|
31
|
+
- **68 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
|
|
32
32
|
- **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
|
|
33
33
|
- **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token; the rest is just Tailwind.
|
|
34
34
|
- **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
|
|
@@ -37,10 +37,10 @@ No config file, no theme provider, no setup ceremony.
|
|
|
37
37
|
## Component list
|
|
38
38
|
|
|
39
39
|
<details>
|
|
40
|
-
<summary>
|
|
40
|
+
<summary>68 components, organized by purpose</summary>
|
|
41
41
|
|
|
42
42
|
**Layout and structure**
|
|
43
|
-
Card, Panel, AppShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
|
|
43
|
+
Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
|
|
44
44
|
|
|
45
45
|
**Forms and inputs**
|
|
46
46
|
Button, ButtonGroup, Input, PasswordInput, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
|
|
@@ -196,19 +196,45 @@ leaves through the same presets the kit's overlays use:
|
|
|
196
196
|
</script>
|
|
197
197
|
|
|
198
198
|
{#if open}
|
|
199
|
-
<div transition:motion.popover>...</div>
|
|
199
|
+
<div transition:motion.popover|global>...</div>
|
|
200
200
|
{/if}
|
|
201
201
|
|
|
202
202
|
{#each items as item (item.id)}
|
|
203
|
-
<li transition:motion.toast animate:motion.reorder>...</li>
|
|
203
|
+
<li transition:motion.toast|global animate:motion.reorder>...</li>
|
|
204
204
|
{/each}
|
|
205
205
|
```
|
|
206
206
|
|
|
207
207
|
`dialog`, `scrim`, `drawer`, `popover` and `toast` read the tokens at run
|
|
208
208
|
time, so retuning a token retunes them, and every one of them plays nothing
|
|
209
|
-
for a reader who has asked for reduced motion.
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
for a reader who has asked for reduced motion. The `|global` modifier is what
|
|
210
|
+
lets the exit play when a parent block removes the surface, so keep it. Do not
|
|
211
|
+
write `duration-150`, `ease-out` or `transition-all` beside a kit class: they
|
|
212
|
+
are a fifth speed and a fourth curve, and the kit's own test suite refuses
|
|
213
|
+
them.
|
|
214
|
+
|
|
215
|
+
## Sizing
|
|
216
|
+
|
|
217
|
+
Two ladders, both declared as tokens and both read by name.
|
|
218
|
+
|
|
219
|
+
A page picks a role and `PageShell` picks the cap:
|
|
220
|
+
|
|
221
|
+
| `width` | Cap | For |
|
|
222
|
+
| --------- | ------ | -------------------------------------- |
|
|
223
|
+
| `narrow` | 896px | one column: a form, a settings pane |
|
|
224
|
+
| `default` | 1152px | a page of stacked cards |
|
|
225
|
+
| `wide` | 1536px | a data page whose table needs the room |
|
|
226
|
+
| `full` | none | a canvas or a split pane |
|
|
227
|
+
|
|
228
|
+
A surface lifted off the page - `Modal`, `Drawer`, a dialog - takes a rung of
|
|
229
|
+
one shared ladder, so the same form is the same size whichever of the three a
|
|
230
|
+
page opens it in: `sm` 448px, `md` 576px, `lg` 704px, `xl` 896px, and `full`
|
|
231
|
+
1088px for a dialog holding a table.
|
|
232
|
+
|
|
233
|
+
`Modal` and `Drawer` default to `size="auto"` and take the rung their body
|
|
234
|
+
earns: `md` up to four fields, `lg` past four, `xl` past eight. The count is
|
|
235
|
+
the fields the panel actually rendered, re-read when the form reveals more, and
|
|
236
|
+
a radio or checkbox group counts as the one question it asks. Name a rung and
|
|
237
|
+
it is kept.
|
|
212
238
|
|
|
213
239
|
## Local development
|
|
214
240
|
|
|
@@ -227,7 +253,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
|
|
|
227
253
|
```
|
|
228
254
|
src/
|
|
229
255
|
└── lib/ # → published as @lyeve-labs/ui-kit
|
|
230
|
-
├── components/ #
|
|
256
|
+
├── components/ # 68 .svelte files
|
|
231
257
|
├── stores/ # toast.svelte.ts
|
|
232
258
|
├── styles/ # theme.css (the one stylesheet)
|
|
233
259
|
├── utils/ # cn.ts, theme.ts
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* app header.
|
|
5
5
|
*
|
|
6
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
|
|
7
|
+
* in the bottom left corner of the sidebar and the third console put it in the
|
|
8
8
|
* header, so the same account block was in two places depending on which of
|
|
9
9
|
* our own products you were looking at. The sidebar is also the worst of the
|
|
10
10
|
* two: it is already full height, so opening a menu in its last row pushes
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* app header.
|
|
4
4
|
*
|
|
5
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
|
|
6
|
+
* in the bottom left corner of the sidebar and the third console put it in the
|
|
7
7
|
* header, so the same account block was in two places depending on which of
|
|
8
8
|
* our own products you were looking at. The sidebar is also the worst of the
|
|
9
9
|
* two: it is already full height, so opening a menu in its last row pushes
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* The authed application frame: the sidebar, the header bar and the content
|
|
17
17
|
* column, owned once so three apps cannot each invent their own.
|
|
18
18
|
*
|
|
19
|
-
* They did. The admin, the customer portal and the
|
|
19
|
+
* They did. The admin, the customer portal and the third console each hand
|
|
20
20
|
* rolled this shell, and no two agreed: the sidebar was 224px in one and
|
|
21
21
|
* 240px in the other two, opaque in two and 30% translucent in the third,
|
|
22
22
|
* built from the kit's SidebarNav in one and from inline anchors in the
|
|
@@ -12,7 +12,7 @@ export interface SidebarState {
|
|
|
12
12
|
* The authed application frame: the sidebar, the header bar and the content
|
|
13
13
|
* column, owned once so three apps cannot each invent their own.
|
|
14
14
|
*
|
|
15
|
-
* They did. The admin, the customer portal and the
|
|
15
|
+
* They did. The admin, the customer portal and the third console each hand
|
|
16
16
|
* rolled this shell, and no two agreed: the sidebar was 224px in one and
|
|
17
17
|
* 240px in the other two, opaque in two and 30% translucent in the third,
|
|
18
18
|
* built from the kit's SidebarNav in one and from inline anchors in the
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Card from './Card.svelte';
|
|
4
|
+
import Logo from './Logo.svelte';
|
|
5
|
+
|
|
6
|
+
type Width = 'md' | 'lg';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The one heading on the page. A sign-in swaps it for the second-factor step. */
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Where the lockup links. Left unset, it is a mark and not a link. */
|
|
13
|
+
href?: string;
|
|
14
|
+
/** `md` is a form; `lg` is a walkthrough with more than one column. */
|
|
15
|
+
width?: Width;
|
|
16
|
+
/** Controls at the top end of the column: a theme toggle, a language switch. */
|
|
17
|
+
actions?: Snippet;
|
|
18
|
+
/** Under the card: the one link that leads off the page, such as "Create an account". */
|
|
19
|
+
footer?: Snippet;
|
|
20
|
+
children: Snippet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
title,
|
|
25
|
+
description = undefined,
|
|
26
|
+
href = undefined,
|
|
27
|
+
width = 'md',
|
|
28
|
+
actions = undefined,
|
|
29
|
+
footer = undefined,
|
|
30
|
+
children,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
const widths: Record<Width, string> = { md: 'max-w-md', lg: 'max-w-3xl' };
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<!--
|
|
37
|
+
The frame for a page the app shell does not wrap: sign in, sign up, a
|
|
38
|
+
password reset, an invitation, first-run setup. PageShell is the wrong
|
|
39
|
+
frame for these: they carry the product name, not a page title, and there
|
|
40
|
+
is no navigation to sit beside. Every console hand-wrote this main, column,
|
|
41
|
+
lockup, heading and card, and no two agreed: three heading sizes, a lockup
|
|
42
|
+
on some pages, a theme control on others, and one class that named no
|
|
43
|
+
token at all. Stated once here, the way AppShell states the signed-in frame.
|
|
44
|
+
-->
|
|
45
|
+
<main class="flex min-h-screen items-center justify-center bg-ink px-4 py-10">
|
|
46
|
+
<div class="w-full {widths[width]}">
|
|
47
|
+
{#if actions}
|
|
48
|
+
<div class="mb-4 flex justify-end">{@render actions()}</div>
|
|
49
|
+
{/if}
|
|
50
|
+
|
|
51
|
+
<div class="mb-8 text-center">
|
|
52
|
+
{#if href}
|
|
53
|
+
<a
|
|
54
|
+
{href}
|
|
55
|
+
class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
56
|
+
>
|
|
57
|
+
<Logo size="lg" />
|
|
58
|
+
</a>
|
|
59
|
+
{:else}
|
|
60
|
+
<Logo size="lg" class="mb-5" />
|
|
61
|
+
{/if}
|
|
62
|
+
<h1 class="text-2xl font-bold text-fg">{title}</h1>
|
|
63
|
+
{#if description}
|
|
64
|
+
<p class="mt-2 text-muted">{description}</p>
|
|
65
|
+
{/if}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<Card pad="lg">
|
|
69
|
+
{@render children()}
|
|
70
|
+
</Card>
|
|
71
|
+
|
|
72
|
+
{#if footer}
|
|
73
|
+
<p class="mt-6 text-center text-sm text-muted">{@render footer()}</p>
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|
|
76
|
+
</main>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Width = 'md' | 'lg';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** The one heading on the page. A sign-in swaps it for the second-factor step. */
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Where the lockup links. Left unset, it is a mark and not a link. */
|
|
8
|
+
href?: string;
|
|
9
|
+
/** `md` is a form; `lg` is a walkthrough with more than one column. */
|
|
10
|
+
width?: Width;
|
|
11
|
+
/** Controls at the top end of the column: a theme toggle, a language switch. */
|
|
12
|
+
actions?: Snippet;
|
|
13
|
+
/** Under the card: the one link that leads off the page, such as "Create an account". */
|
|
14
|
+
footer?: Snippet;
|
|
15
|
+
children: Snippet;
|
|
16
|
+
}
|
|
17
|
+
declare const AuthShell: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type AuthShell = ReturnType<typeof AuthShell>;
|
|
19
|
+
export default AuthShell;
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
}
|
|
174
174
|
</script>
|
|
175
175
|
|
|
176
|
-
<div class="{FIELD_WRAP} {cls}">
|
|
176
|
+
<div data-field class="{FIELD_WRAP} {cls}">
|
|
177
177
|
{#if label}
|
|
178
178
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
179
179
|
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
{/if}
|
|
236
236
|
|
|
237
237
|
{#if box.open}
|
|
238
|
-
<div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
|
|
238
|
+
<div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
|
|
239
239
|
<div class={PANEL_LIST} data-panel-list use:panel {...box.listAttrs}>
|
|
240
240
|
{#each rows as option, index (option.value)}
|
|
241
241
|
{@const isSelected = option.value === value}
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
{/if}
|
|
168
168
|
{/snippet}
|
|
169
169
|
|
|
170
|
-
<div class="{rootClass} {cls}">
|
|
170
|
+
<div data-field class="{rootClass} {cls}">
|
|
171
171
|
{#if variant === 'card'}
|
|
172
172
|
<!-- The input covers the whole card, so the card surface is the element the
|
|
173
173
|
peer ring can reach and the box inside it is not. That is deliberate:
|
|
@@ -117,7 +117,12 @@
|
|
|
117
117
|
asterisk stays decoration, and the hint, when the caller writes one, says what
|
|
118
118
|
is required and reaches the reader through aria-describedby.
|
|
119
119
|
-->
|
|
120
|
-
<fieldset
|
|
120
|
+
<fieldset
|
|
121
|
+
data-field
|
|
122
|
+
class="{CHOICE_GROUP} {cls}"
|
|
123
|
+
{disabled}
|
|
124
|
+
aria-describedby={describedBy(uid, error, hint)}
|
|
125
|
+
>
|
|
121
126
|
<!--
|
|
122
127
|
The legend stays a legend when it is hidden. Swapping it for an aria-label
|
|
123
128
|
on the fieldset would name the group and drop it out of the reading order,
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
});
|
|
177
177
|
</script>
|
|
178
178
|
|
|
179
|
-
<div class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
|
|
179
|
+
<div data-field class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
|
|
180
180
|
{#if label}
|
|
181
181
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
182
182
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
role="dialog"
|
|
255
255
|
aria-label="Choose date"
|
|
256
256
|
use:placePanel
|
|
257
|
-
transition:motion.popover
|
|
257
|
+
transition:motion.popover|global
|
|
258
258
|
class="{PANEL_SURFACE} w-68"
|
|
259
259
|
>
|
|
260
260
|
<div class="overflow-y-auto overscroll-contain p-3" data-panel-list={PANEL_LIST_UNCAPPED}>
|
|
@@ -275,7 +275,7 @@
|
|
|
275
275
|
}
|
|
276
276
|
</script>
|
|
277
277
|
|
|
278
|
-
<div class="{FIELD_WRAP} {klass}">
|
|
278
|
+
<div data-field class="{FIELD_WRAP} {klass}">
|
|
279
279
|
{#if label}
|
|
280
280
|
<!-- `for` the date trigger. A button is labelable, so the field's own label
|
|
281
281
|
names it and clicking that label opens the calendar. The group below
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { countFields } from '../internal/field.js';
|
|
4
|
+
import { fitOverlay, OVERLAY_WIDTH, type OverlaySize } from '../internal/layout.js';
|
|
3
5
|
import { HIT_AREA } from '../internal/touch.js';
|
|
4
6
|
import { overlay } from '../internal/overlay.js';
|
|
5
7
|
import * as motion from '../motion.js';
|
|
6
8
|
|
|
7
9
|
type Side = 'left' | 'right';
|
|
8
|
-
|
|
10
|
+
/** The overlay ladder, minus the rung a docked panel has no business taking. */
|
|
11
|
+
type Size = Exclude<OverlaySize, 'full'>;
|
|
9
12
|
|
|
10
13
|
interface Props {
|
|
11
14
|
open?: boolean;
|
|
12
15
|
title?: string;
|
|
13
16
|
description?: string;
|
|
14
17
|
side?: Side;
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A rung of the shared overlay ladder, or `auto` to take the one the body
|
|
20
|
+
* needs: `md` up to four fields, `lg` past four, `xl` past eight.
|
|
21
|
+
*
|
|
22
|
+
* `auto` is the default because the caller was the wrong one to ask. Every
|
|
23
|
+
* drawer in three consoles asked for the widest rung the kit had, which is
|
|
24
|
+
* what a ladder that stops too early looks like from the outside, and the
|
|
25
|
+
* body is the only thing that knows whether it is two fields or twelve.
|
|
26
|
+
*/
|
|
27
|
+
size?: Size | 'auto';
|
|
16
28
|
onclose?: () => void;
|
|
17
29
|
children: Snippet;
|
|
18
30
|
footer?: Snippet;
|
|
@@ -23,18 +35,36 @@
|
|
|
23
35
|
title = undefined,
|
|
24
36
|
description = undefined,
|
|
25
37
|
side = 'right',
|
|
26
|
-
size = '
|
|
38
|
+
size = 'auto',
|
|
27
39
|
onclose = undefined,
|
|
28
40
|
children,
|
|
29
41
|
footer,
|
|
30
42
|
}: Props = $props();
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
let body: HTMLElement | undefined = $state();
|
|
45
|
+
let fields = $state(0);
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* The count is measured rather than declared, so it cannot go stale. A form
|
|
49
|
+
* that reveals two more fields when a period is set to custom, or drops a
|
|
50
|
+
* whole section behind a toggle, changes what it needs while it is open,
|
|
51
|
+
* which is why the observer stays for as long as the panel does.
|
|
52
|
+
*
|
|
53
|
+
* It runs before the browser paints the panel, so a drawer opens at the
|
|
54
|
+
* width it will keep: the effect is flushed in the same task as the mount,
|
|
55
|
+
* and the entrance animates transform alone.
|
|
56
|
+
*/
|
|
57
|
+
$effect(() => {
|
|
58
|
+
const el = body;
|
|
59
|
+
if (!open || !el) return;
|
|
60
|
+
const measure = () => (fields = countFields(el));
|
|
61
|
+
measure();
|
|
62
|
+
const observer = new MutationObserver(measure);
|
|
63
|
+
observer.observe(el, { childList: true, subtree: true });
|
|
64
|
+
return () => observer.disconnect();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const rung = $derived(size === 'auto' ? fitOverlay(fields) : size);
|
|
38
68
|
|
|
39
69
|
const headingId = $props.id();
|
|
40
70
|
const descriptionId = `${headingId}-description`;
|
|
@@ -65,13 +95,13 @@
|
|
|
65
95
|
aria-hidden="true"
|
|
66
96
|
class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
|
|
67
97
|
onclick={close}
|
|
68
|
-
transition:motion.scrim
|
|
98
|
+
transition:motion.scrim|global
|
|
69
99
|
></button>
|
|
70
100
|
|
|
71
101
|
<div
|
|
72
102
|
use:overlay
|
|
73
|
-
transition:motion.drawer={{ side }}
|
|
74
|
-
class="relative flex h-full
|
|
103
|
+
transition:motion.drawer|global={{ side }}
|
|
104
|
+
class="relative flex h-full w-full flex-col {OVERLAY_WIDTH[rung]} bg-surface shadow-2xl
|
|
75
105
|
{side === 'right' ? 'border-s' : 'border-e'} border-line"
|
|
76
106
|
role="dialog"
|
|
77
107
|
aria-modal="true"
|
|
@@ -107,7 +137,7 @@
|
|
|
107
137
|
</div>
|
|
108
138
|
{/if}
|
|
109
139
|
|
|
110
|
-
<div class="flex-1 overflow-y-auto px-5 py-4">
|
|
140
|
+
<div bind:this={body} class="flex-1 overflow-y-auto px-5 py-4">
|
|
111
141
|
{@render children()}
|
|
112
142
|
</div>
|
|
113
143
|
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type OverlaySize } from '../internal/layout.js';
|
|
2
3
|
type Side = 'left' | 'right';
|
|
3
|
-
|
|
4
|
+
/** The overlay ladder, minus the rung a docked panel has no business taking. */
|
|
5
|
+
type Size = Exclude<OverlaySize, 'full'>;
|
|
4
6
|
interface Props {
|
|
5
7
|
open?: boolean;
|
|
6
8
|
title?: string;
|
|
7
9
|
description?: string;
|
|
8
10
|
side?: Side;
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A rung of the shared overlay ladder, or `auto` to take the one the body
|
|
13
|
+
* needs: `md` up to four fields, `lg` past four, `xl` past eight.
|
|
14
|
+
*
|
|
15
|
+
* `auto` is the default because the caller was the wrong one to ask. Every
|
|
16
|
+
* drawer in three consoles asked for the widest rung the kit had, which is
|
|
17
|
+
* what a ladder that stops too early looks like from the outside, and the
|
|
18
|
+
* body is the only thing that knows whether it is two fields or twelve.
|
|
19
|
+
*/
|
|
20
|
+
size?: Size | 'auto';
|
|
10
21
|
onclose?: () => void;
|
|
11
22
|
children: Snippet;
|
|
12
23
|
footer?: Snippet;
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
bind:this={menuEl}
|
|
257
257
|
role="menu"
|
|
258
258
|
use:placePanel
|
|
259
|
-
transition:motion.popover
|
|
259
|
+
transition:motion.popover|global
|
|
260
260
|
class="{PANEL_SURFACE} min-w-36 {align === 'right' ? 'end-0' : 'start-0'}"
|
|
261
261
|
>
|
|
262
262
|
<div class={PANEL_LIST} data-panel-list>
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
|
|
52
52
|
</script>
|
|
53
53
|
|
|
54
|
-
<div class="{FIELD_WRAP} {klass}">
|
|
54
|
+
<div data-field class="{FIELD_WRAP} {klass}">
|
|
55
55
|
{#if label}
|
|
56
56
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
57
57
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { countFields } from '../internal/field.js';
|
|
4
|
+
import { fitOverlay, OVERLAY_WIDTH, type OverlaySize } from '../internal/layout.js';
|
|
3
5
|
import { HIT_AREA } from '../internal/touch.js';
|
|
4
6
|
import { overlay } from '../internal/overlay.js';
|
|
5
7
|
import * as motion from '../motion.js';
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
/** The shared overlay ladder, minus the rung the dialog stack keeps for a table. */
|
|
10
|
+
type Size = Exclude<OverlaySize, 'full'>;
|
|
8
11
|
|
|
9
12
|
interface Props {
|
|
10
13
|
open?: boolean;
|
|
11
14
|
title?: string;
|
|
12
15
|
description?: string;
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* A rung of the shared overlay ladder, or `auto` to take the one the body
|
|
18
|
+
* needs: `md` up to four fields, `lg` past four, `xl` past eight.
|
|
19
|
+
*
|
|
20
|
+
* The same rule a Drawer follows, so an edit form reads the same size
|
|
21
|
+
* whichever of the two a page opens it in.
|
|
22
|
+
*/
|
|
23
|
+
size?: Size | 'auto';
|
|
14
24
|
onclose?: () => void;
|
|
15
25
|
children: Snippet;
|
|
16
26
|
footer?: Snippet;
|
|
@@ -20,17 +30,27 @@
|
|
|
20
30
|
open = $bindable(false),
|
|
21
31
|
title = undefined,
|
|
22
32
|
description = undefined,
|
|
23
|
-
size = '
|
|
33
|
+
size = 'auto',
|
|
24
34
|
onclose = undefined,
|
|
25
35
|
children,
|
|
26
36
|
footer,
|
|
27
37
|
}: Props = $props();
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
let body: HTMLElement | undefined = $state();
|
|
40
|
+
let fields = $state(0);
|
|
41
|
+
|
|
42
|
+
/* Measured, and kept measured: see Drawer, which sizes itself the same way. */
|
|
43
|
+
$effect(() => {
|
|
44
|
+
const el = body;
|
|
45
|
+
if (!open || !el) return;
|
|
46
|
+
const measure = () => (fields = countFields(el));
|
|
47
|
+
measure();
|
|
48
|
+
const observer = new MutationObserver(measure);
|
|
49
|
+
observer.observe(el, { childList: true, subtree: true });
|
|
50
|
+
return () => observer.disconnect();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const rung = $derived(size === 'auto' ? fitOverlay(fields) : size);
|
|
34
54
|
|
|
35
55
|
// aria-labelledby needs an id that is unique per instance, because two modals
|
|
36
56
|
// can be mounted at once while one animates out.
|
|
@@ -60,13 +80,13 @@
|
|
|
60
80
|
aria-hidden="true"
|
|
61
81
|
class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
|
|
62
82
|
onclick={close}
|
|
63
|
-
transition:motion.scrim
|
|
83
|
+
transition:motion.scrim|global
|
|
64
84
|
></button>
|
|
65
85
|
|
|
66
86
|
<div
|
|
67
87
|
use:overlay
|
|
68
|
-
transition:motion.dialog
|
|
69
|
-
class="relative flex max-h-[calc(100dvh-2rem)] w-full {
|
|
88
|
+
transition:motion.dialog|global
|
|
89
|
+
class="relative flex max-h-[calc(100dvh-2rem)] w-full {OVERLAY_WIDTH[rung]} flex-col
|
|
70
90
|
overflow-hidden rounded-xl border border-line bg-surface shadow-2xl"
|
|
71
91
|
role="dialog"
|
|
72
92
|
aria-modal="true"
|
|
@@ -103,7 +123,7 @@
|
|
|
103
123
|
</div>
|
|
104
124
|
{/if}
|
|
105
125
|
|
|
106
|
-
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
126
|
+
<div bind:this={body} class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
107
127
|
{@render children()}
|
|
108
128
|
</div>
|
|
109
129
|
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
2
|
+
import { type OverlaySize } from '../internal/layout.js';
|
|
3
|
+
/** The shared overlay ladder, minus the rung the dialog stack keeps for a table. */
|
|
4
|
+
type Size = Exclude<OverlaySize, 'full'>;
|
|
3
5
|
interface Props {
|
|
4
6
|
open?: boolean;
|
|
5
7
|
title?: string;
|
|
6
8
|
description?: string;
|
|
7
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A rung of the shared overlay ladder, or `auto` to take the one the body
|
|
11
|
+
* needs: `md` up to four fields, `lg` past four, `xl` past eight.
|
|
12
|
+
*
|
|
13
|
+
* The same rule a Drawer follows, so an edit form reads the same size
|
|
14
|
+
* whichever of the two a page opens it in.
|
|
15
|
+
*/
|
|
16
|
+
size?: Size | 'auto';
|
|
8
17
|
onclose?: () => void;
|
|
9
18
|
children: Snippet;
|
|
10
19
|
footer?: Snippet;
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
});
|
|
160
160
|
</script>
|
|
161
161
|
|
|
162
|
-
<div class="{FIELD_WRAP} {cls}">
|
|
162
|
+
<div data-field class="{FIELD_WRAP} {cls}">
|
|
163
163
|
{#if label}
|
|
164
164
|
<label id="{fieldId}-label" for={fieldId} class={FIELD_LABEL}>
|
|
165
165
|
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
@@ -254,7 +254,7 @@
|
|
|
254
254
|
</div>
|
|
255
255
|
|
|
256
256
|
{#if box.open}
|
|
257
|
-
<div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
|
|
257
|
+
<div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
|
|
258
258
|
{#if searchable}
|
|
259
259
|
<div class="border-b border-line p-2">
|
|
260
260
|
<input
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
'disabled:opacity-50 disabled:cursor-not-allowed';
|
|
77
77
|
</script>
|
|
78
78
|
|
|
79
|
-
<div class="{FIELD_WRAP} {cls}">
|
|
79
|
+
<div data-field class="{FIELD_WRAP} {cls}">
|
|
80
80
|
{#if label}
|
|
81
81
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
82
82
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
}
|
|
84
84
|
</script>
|
|
85
85
|
|
|
86
|
-
<div class="{FIELD_WRAP} {klass}">
|
|
86
|
+
<div data-field class="{FIELD_WRAP} {klass}">
|
|
87
87
|
{#if label}
|
|
88
88
|
<label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
89
89
|
{label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
{/if}
|
|
158
158
|
{/snippet}
|
|
159
159
|
|
|
160
|
-
<div class="{rootClass} {cls}">
|
|
160
|
+
<div data-field class="{rootClass} {cls}">
|
|
161
161
|
{#if variant === 'card'}
|
|
162
162
|
<!-- The input covers the whole card, so the card surface is the element the
|
|
163
163
|
peer ring can reach and the ring inside it is not. That is deliberate:
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
}
|
|
173
173
|
</script>
|
|
174
174
|
|
|
175
|
-
<div class="{FIELD_WRAP} items-start {klass}">
|
|
175
|
+
<div data-field class="{FIELD_WRAP} items-start {klass}">
|
|
176
176
|
{#if !labelHidden}
|
|
177
177
|
<!-- A caption, not a label element. The group is named by aria-label, and a
|
|
178
178
|
label has nothing to point at here: role="radiogroup" is not a form
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
</button>
|
|
346
346
|
{/snippet}
|
|
347
347
|
|
|
348
|
-
<div class="{FIELD_WRAP} {cls}">
|
|
348
|
+
<div data-field class="{FIELD_WRAP} {cls}">
|
|
349
349
|
{#if label}
|
|
350
350
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
351
351
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
@@ -409,7 +409,7 @@
|
|
|
409
409
|
<input type="hidden" {name} {disabled} value={value ?? ''} />
|
|
410
410
|
|
|
411
411
|
{#if box.open}
|
|
412
|
-
<div use:placePanel transition:motion.popover class="{PANEL_SURFACE} w-full">
|
|
412
|
+
<div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
|
|
413
413
|
{#if searchable}
|
|
414
414
|
<div class="border-b border-line p-2">
|
|
415
415
|
<!--
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
|
|
52
52
|
</script>
|
|
53
53
|
|
|
54
|
-
<div class="{FIELD_WRAP} {cls}">
|
|
54
|
+
<div data-field class="{FIELD_WRAP} {cls}">
|
|
55
55
|
{#if label}
|
|
56
56
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
57
57
|
{label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
|
|
@@ -395,7 +395,7 @@
|
|
|
395
395
|
}
|
|
396
396
|
</script>
|
|
397
397
|
|
|
398
|
-
<div class="{FIELD_WRAP} {klass}">
|
|
398
|
+
<div data-field class="{FIELD_WRAP} {klass}">
|
|
399
399
|
{#if label}
|
|
400
400
|
<!-- `for` the hour so a click lands somewhere useful, while the group below
|
|
401
401
|
takes its name from this same element. The hour keeps its own aria-label,
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
>
|
|
28
28
|
{#each toast.items as t (t.id)}
|
|
29
29
|
<div
|
|
30
|
-
transition:motion.toast
|
|
30
|
+
transition:motion.toast|global
|
|
31
31
|
animate:motion.reorder
|
|
32
32
|
class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
|
|
33
33
|
bg-surface ps-0 pe-3 py-3 shadow-xl"
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
<!-- Backdrop -->
|
|
94
94
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
95
95
|
<div
|
|
96
|
-
transition:motion.scrim
|
|
96
|
+
transition:motion.scrim|global
|
|
97
97
|
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
98
98
|
onclick={handleBackdropClick}
|
|
99
99
|
onkeydown={(e: KeyboardEvent) => {
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
<div
|
|
108
108
|
bind:this={dialogEl}
|
|
109
109
|
use:overlay
|
|
110
|
-
transition:motion.dialog
|
|
110
|
+
transition:motion.dialog|global
|
|
111
111
|
class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
|
|
112
112
|
bg-surface border border-line rounded-xl shadow-2xl
|
|
113
113
|
transition-transform duration-slow
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
2
|
+
import { type OverlaySize } from '../../internal/layout.js';
|
|
3
|
+
/** The shared overlay ladder, whole: a dialog is the one surface that may hold a table. */
|
|
4
|
+
export type DialogSize = OverlaySize;
|
|
3
5
|
export interface DialogOptions<T = void> {
|
|
4
6
|
/** Unique id - auto-generated if omitted */
|
|
5
7
|
id?: string;
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
sm: 'max-w-sm',
|
|
3
|
-
md: 'max-w-md',
|
|
4
|
-
lg: 'max-w-lg',
|
|
5
|
-
xl: 'max-w-xl',
|
|
6
|
-
full: 'max-w-3xl',
|
|
7
|
-
};
|
|
1
|
+
import { OVERLAY_WIDTH } from '../../internal/layout.js';
|
|
8
2
|
export function sizeClass(size) {
|
|
9
|
-
return
|
|
3
|
+
return OVERLAY_WIDTH[size];
|
|
10
4
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export { default as Card } from './components/Card.svelte';
|
|
10
10
|
export { default as Panel } from './components/Panel.svelte';
|
|
11
11
|
export { default as AppShell } from './components/AppShell.svelte';
|
|
12
|
+
export { default as AuthShell } from './components/AuthShell.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';
|
|
@@ -92,4 +93,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
92
93
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
93
94
|
export * as motion from './motion.js';
|
|
94
95
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
95
|
-
export declare const VERSION = "0.
|
|
96
|
+
export declare const VERSION = "0.26.0";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
export { default as Card } from './components/Card.svelte';
|
|
11
11
|
export { default as Panel } from './components/Panel.svelte';
|
|
12
12
|
export { default as AppShell } from './components/AppShell.svelte';
|
|
13
|
+
export { default as AuthShell } from './components/AuthShell.svelte';
|
|
13
14
|
export { default as PageShell } from './components/PageShell.svelte';
|
|
14
15
|
export { default as PageHeader } from './components/PageHeader.svelte';
|
|
15
16
|
export { default as SectionHeading } from './components/SectionHeading.svelte';
|
|
@@ -102,4 +103,4 @@ export * as motion from './motion.js';
|
|
|
102
103
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
103
104
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
104
105
|
// this line; the build and the test suite fail when the two disagree.
|
|
105
|
-
export const VERSION = '0.
|
|
106
|
+
export const VERSION = '0.26.0';
|
package/dist/internal/field.d.ts
CHANGED
|
@@ -11,6 +11,26 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/** Vertical rhythm inside a labelled field: label, control, hint/error. */
|
|
13
13
|
export declare const FIELD_WRAP = "flex flex-col gap-1.5";
|
|
14
|
+
/**
|
|
15
|
+
* The marker every labelled field carries on its outermost element, and the
|
|
16
|
+
* only way a surface can ask how much form it is holding.
|
|
17
|
+
*
|
|
18
|
+
* A class would not do: FIELD_WRAP is three utilities a caller may legitimately
|
|
19
|
+
* write by hand, so counting it would count any column with a 6px gap. The
|
|
20
|
+
* attribute says what the element is rather than how it looks, and a radio or
|
|
21
|
+
* checkbox group carries it on the fieldset, so a group of eight options is
|
|
22
|
+
* one field and not eight.
|
|
23
|
+
*/
|
|
24
|
+
export declare const FIELD_MARKER = "[data-field]";
|
|
25
|
+
/**
|
|
26
|
+
* How many fields a subtree renders, counting a group as one.
|
|
27
|
+
*
|
|
28
|
+
* Only the outermost markers count. A group marks its fieldset and each option
|
|
29
|
+
* inside it marks its own wrapper, so a plain `querySelectorAll` length would
|
|
30
|
+
* read a five-option radio group as six fields and size a panel for a form
|
|
31
|
+
* that is not there.
|
|
32
|
+
*/
|
|
33
|
+
export declare function countFields(root: ParentNode): number;
|
|
14
34
|
/** The label above a control. */
|
|
15
35
|
export declare const FIELD_LABEL = "text-sm font-medium text-fg";
|
|
16
36
|
/** Hint text below a control. Shown only when there is no error. */
|
package/dist/internal/field.js
CHANGED
|
@@ -11,6 +11,29 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/** Vertical rhythm inside a labelled field: label, control, hint/error. */
|
|
13
13
|
export const FIELD_WRAP = 'flex flex-col gap-1.5';
|
|
14
|
+
/**
|
|
15
|
+
* The marker every labelled field carries on its outermost element, and the
|
|
16
|
+
* only way a surface can ask how much form it is holding.
|
|
17
|
+
*
|
|
18
|
+
* A class would not do: FIELD_WRAP is three utilities a caller may legitimately
|
|
19
|
+
* write by hand, so counting it would count any column with a 6px gap. The
|
|
20
|
+
* attribute says what the element is rather than how it looks, and a radio or
|
|
21
|
+
* checkbox group carries it on the fieldset, so a group of eight options is
|
|
22
|
+
* one field and not eight.
|
|
23
|
+
*/
|
|
24
|
+
export const FIELD_MARKER = '[data-field]';
|
|
25
|
+
/**
|
|
26
|
+
* How many fields a subtree renders, counting a group as one.
|
|
27
|
+
*
|
|
28
|
+
* Only the outermost markers count. A group marks its fieldset and each option
|
|
29
|
+
* inside it marks its own wrapper, so a plain `querySelectorAll` length would
|
|
30
|
+
* read a five-option radio group as six fields and size a panel for a form
|
|
31
|
+
* that is not there.
|
|
32
|
+
*/
|
|
33
|
+
export function countFields(root) {
|
|
34
|
+
const all = [...root.querySelectorAll(FIELD_MARKER)];
|
|
35
|
+
return all.filter((el) => !all.some((other) => other !== el && other.contains(el))).length;
|
|
36
|
+
}
|
|
14
37
|
/** The label above a control. */
|
|
15
38
|
export const FIELD_LABEL = 'text-sm font-medium text-fg';
|
|
16
39
|
/** Hint text below a control. Shown only when there is no error. */
|
|
@@ -40,6 +40,40 @@ export declare const PAGE_PAD = "mx-auto w-full px-page-x py-page-y";
|
|
|
40
40
|
* The names carry the decision, so a page picks a role rather than a number.
|
|
41
41
|
*/
|
|
42
42
|
export declare const PAGE_WIDTH: Record<PageWidth, string>;
|
|
43
|
+
/** How wide a surface lifted off the page may get. */
|
|
44
|
+
export type OverlaySize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
45
|
+
/**
|
|
46
|
+
* One ladder for Modal, Drawer and the dialog stack.
|
|
47
|
+
*
|
|
48
|
+
* The three carried three ladders and none of them agreed: a modal's `lg` was
|
|
49
|
+
* 672px, a drawer's was 384px, and a dialog's was 512px, so the same form read
|
|
50
|
+
* as three different sizes depending on which surface a page happened to open
|
|
51
|
+
* it in. Every rung here is wider than the widest of the three it replaces.
|
|
52
|
+
*
|
|
53
|
+
* A component takes the slice of the ladder its role allows, which is why the
|
|
54
|
+
* names and not the numbers are its prop: a drawer stops at `xl` because a
|
|
55
|
+
* panel docked to an edge that covers the page is a modal with extra steps.
|
|
56
|
+
*
|
|
57
|
+
* Spelled out in full rather than composed from the rung name. Tailwind
|
|
58
|
+
* generates a utility only for a class it can read whole in the source, so
|
|
59
|
+
* `max-w-${rung}` would compile to nothing at all.
|
|
60
|
+
*/
|
|
61
|
+
export declare const OVERLAY_WIDTH: Record<OverlaySize, string>;
|
|
62
|
+
/**
|
|
63
|
+
* The rung a body of this many labelled fields needs.
|
|
64
|
+
*
|
|
65
|
+
* A panel is sized by its caller today, and the caller is guessing: all 18
|
|
66
|
+
* drawers measured across three consuming applications ask for the widest rung
|
|
67
|
+
* the ladder had, and that rung still puts a two-column field grid into two
|
|
68
|
+
* 170px columns. The content knows the answer, so it gives it: four fields fit
|
|
69
|
+
* a single column, more than four is where a form starts pairing them, and
|
|
70
|
+
* past eight it is a page that happens to be in a panel.
|
|
71
|
+
*
|
|
72
|
+
* Fields, not controls. A radio group is one field however many inputs it
|
|
73
|
+
* renders, and the marker the count reads sits on the field wrapper for
|
|
74
|
+
* exactly that reason.
|
|
75
|
+
*/
|
|
76
|
+
export declare function fitOverlay(fields: number): OverlaySize;
|
|
43
77
|
/**
|
|
44
78
|
* The vertical rhythm between a page's top-level sections. A property of the
|
|
45
79
|
* shell, so a page cannot choose its own.
|
package/dist/internal/layout.js
CHANGED
|
@@ -36,11 +36,55 @@ export const PAGE_PAD = 'mx-auto w-full px-page-x py-page-y';
|
|
|
36
36
|
* The names carry the decision, so a page picks a role rather than a number.
|
|
37
37
|
*/
|
|
38
38
|
export const PAGE_WIDTH = {
|
|
39
|
-
narrow: 'max-w-
|
|
40
|
-
default: 'max-w-
|
|
41
|
-
wide: 'max-w-
|
|
39
|
+
narrow: 'max-w-page-narrow',
|
|
40
|
+
default: 'max-w-page-default',
|
|
41
|
+
wide: 'max-w-page-wide',
|
|
42
42
|
full: 'max-w-full',
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* One ladder for Modal, Drawer and the dialog stack.
|
|
46
|
+
*
|
|
47
|
+
* The three carried three ladders and none of them agreed: a modal's `lg` was
|
|
48
|
+
* 672px, a drawer's was 384px, and a dialog's was 512px, so the same form read
|
|
49
|
+
* as three different sizes depending on which surface a page happened to open
|
|
50
|
+
* it in. Every rung here is wider than the widest of the three it replaces.
|
|
51
|
+
*
|
|
52
|
+
* A component takes the slice of the ladder its role allows, which is why the
|
|
53
|
+
* names and not the numbers are its prop: a drawer stops at `xl` because a
|
|
54
|
+
* panel docked to an edge that covers the page is a modal with extra steps.
|
|
55
|
+
*
|
|
56
|
+
* Spelled out in full rather than composed from the rung name. Tailwind
|
|
57
|
+
* generates a utility only for a class it can read whole in the source, so
|
|
58
|
+
* `max-w-${rung}` would compile to nothing at all.
|
|
59
|
+
*/
|
|
60
|
+
export const OVERLAY_WIDTH = {
|
|
61
|
+
sm: 'max-w-overlay-sm',
|
|
62
|
+
md: 'max-w-overlay-md',
|
|
63
|
+
lg: 'max-w-overlay-lg',
|
|
64
|
+
xl: 'max-w-overlay-xl',
|
|
65
|
+
full: 'max-w-overlay-full',
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The rung a body of this many labelled fields needs.
|
|
69
|
+
*
|
|
70
|
+
* A panel is sized by its caller today, and the caller is guessing: all 18
|
|
71
|
+
* drawers measured across three consuming applications ask for the widest rung
|
|
72
|
+
* the ladder had, and that rung still puts a two-column field grid into two
|
|
73
|
+
* 170px columns. The content knows the answer, so it gives it: four fields fit
|
|
74
|
+
* a single column, more than four is where a form starts pairing them, and
|
|
75
|
+
* past eight it is a page that happens to be in a panel.
|
|
76
|
+
*
|
|
77
|
+
* Fields, not controls. A radio group is one field however many inputs it
|
|
78
|
+
* renders, and the marker the count reads sits on the field wrapper for
|
|
79
|
+
* exactly that reason.
|
|
80
|
+
*/
|
|
81
|
+
export function fitOverlay(fields) {
|
|
82
|
+
if (fields > 8)
|
|
83
|
+
return 'xl';
|
|
84
|
+
if (fields > 4)
|
|
85
|
+
return 'lg';
|
|
86
|
+
return 'md';
|
|
87
|
+
}
|
|
44
88
|
/**
|
|
45
89
|
* The vertical rhythm between a page's top-level sections. A property of the
|
|
46
90
|
* shell, so a page cannot choose its own.
|
package/dist/motion.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The entrances and exits, as Svelte transitions that read the motion tokens.
|
|
3
3
|
*
|
|
4
|
-
* <div
|
|
5
|
-
* <div transition:motion.popover>
|
|
4
|
+
* <div transition:motion.dialog|global>
|
|
5
|
+
* <div transition:motion.popover|global>
|
|
6
6
|
* <li animate:motion.reorder>
|
|
7
7
|
*
|
|
8
|
+
* `|global` is not optional. A Svelte transition is local by default and
|
|
9
|
+
* plays only when its own block toggles; a page that wraps a Modal in its own
|
|
10
|
+
* `{#if}` to reset the form each time removes the whole component, and a
|
|
11
|
+
* local exit never runs. Global plays it on any ancestor change.
|
|
12
|
+
*
|
|
8
13
|
* A CSS animation plays an entrance and nothing else: the element it ran on
|
|
9
14
|
* is gone the moment `{#if}` turns false, so a dialog that eased open snapped
|
|
10
15
|
* shut. A Svelte transition keeps the element until the exit has played, and
|
package/dist/styles/theme.css
CHANGED
|
@@ -291,6 +291,31 @@
|
|
|
291
291
|
--spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
|
|
292
292
|
--spacing-stack: 1rem; /* 16px - default vertical stack gap */
|
|
293
293
|
--spacing-inline: 0.5rem; /* 8px - default inline gap */
|
|
294
|
+
|
|
295
|
+
/* ── Widths · how much room a surface is allowed to take ───── *
|
|
296
|
+
* Tailwind reads --container-*, so each name below is a max-w-* *
|
|
297
|
+
* and a w-* utility. Named for the surface rather than for a *
|
|
298
|
+
* t-shirt size, so the cap is chosen by role. *
|
|
299
|
+
* *
|
|
300
|
+
* Measured 2026-09-22 across three consuming applications: the *
|
|
301
|
+
* page cap was picked four different ways for the same kind of *
|
|
302
|
+
* screen (45 wide pages in one, 17 uncapped ones in another, *
|
|
303
|
+
* and two settings pages in a 768px column), and all 18 of their *
|
|
304
|
+
* drawers asked for the widest rung there was, which is what a *
|
|
305
|
+
* ladder that stops too early looks like from the outside. */
|
|
306
|
+
--container-page-narrow: 56rem; /* 896px - one column: a form, a settings pane */
|
|
307
|
+
--container-page-default: 72rem; /* 1152px - a page of stacked cards */
|
|
308
|
+
--container-page-wide: 96rem; /* 1536px - a data page whose table needs the room */
|
|
309
|
+
|
|
310
|
+
/* One ladder for every surface lifted off the page, so a drawer and
|
|
311
|
+
* a modal opened from the same screen are the same size. Each rung
|
|
312
|
+
* is the one above 3:2 of the one below, which is wide enough that a
|
|
313
|
+
* two-column field grid gets real columns at every step. */
|
|
314
|
+
--container-overlay-sm: 28rem; /* 448px - a confirmation, a short form */
|
|
315
|
+
--container-overlay-md: 36rem; /* 576px - up to four fields */
|
|
316
|
+
--container-overlay-lg: 44rem; /* 704px - a form with paired fields */
|
|
317
|
+
--container-overlay-xl: 56rem; /* 896px - a form beside a preview */
|
|
318
|
+
--container-overlay-full: 68rem; /* 1088px - a table inside a dialog */
|
|
294
319
|
}
|
|
295
320
|
|
|
296
321
|
/*
|
|
@@ -478,7 +503,7 @@ html[data-theme='light'] {
|
|
|
478
503
|
* Print.
|
|
479
504
|
*
|
|
480
505
|
* The consuming applications had no `@media print` rule anywhere, and three surfaces print:
|
|
481
|
-
* the portal prints an invoice,
|
|
506
|
+
* the portal prints an invoice, an operations console prints an audit log, the admin
|
|
482
507
|
* prints a subject-access export. All three printed the sidebar, the theme
|
|
483
508
|
* toggle and the nav, in a dark palette, and all three printed one screenful
|
|
484
509
|
* and stopped, because the shell is `h-screen` with the content column set to
|
package/package.json
CHANGED
package/src/lib/styles/theme.css
CHANGED
|
@@ -291,6 +291,31 @@
|
|
|
291
291
|
--spacing-sidebar: 14rem; /* 224px - the expanded sidebar */
|
|
292
292
|
--spacing-stack: 1rem; /* 16px - default vertical stack gap */
|
|
293
293
|
--spacing-inline: 0.5rem; /* 8px - default inline gap */
|
|
294
|
+
|
|
295
|
+
/* ── Widths · how much room a surface is allowed to take ───── *
|
|
296
|
+
* Tailwind reads --container-*, so each name below is a max-w-* *
|
|
297
|
+
* and a w-* utility. Named for the surface rather than for a *
|
|
298
|
+
* t-shirt size, so the cap is chosen by role. *
|
|
299
|
+
* *
|
|
300
|
+
* Measured 2026-09-22 across three consuming applications: the *
|
|
301
|
+
* page cap was picked four different ways for the same kind of *
|
|
302
|
+
* screen (45 wide pages in one, 17 uncapped ones in another, *
|
|
303
|
+
* and two settings pages in a 768px column), and all 18 of their *
|
|
304
|
+
* drawers asked for the widest rung there was, which is what a *
|
|
305
|
+
* ladder that stops too early looks like from the outside. */
|
|
306
|
+
--container-page-narrow: 56rem; /* 896px - one column: a form, a settings pane */
|
|
307
|
+
--container-page-default: 72rem; /* 1152px - a page of stacked cards */
|
|
308
|
+
--container-page-wide: 96rem; /* 1536px - a data page whose table needs the room */
|
|
309
|
+
|
|
310
|
+
/* One ladder for every surface lifted off the page, so a drawer and
|
|
311
|
+
* a modal opened from the same screen are the same size. Each rung
|
|
312
|
+
* is the one above 3:2 of the one below, which is wide enough that a
|
|
313
|
+
* two-column field grid gets real columns at every step. */
|
|
314
|
+
--container-overlay-sm: 28rem; /* 448px - a confirmation, a short form */
|
|
315
|
+
--container-overlay-md: 36rem; /* 576px - up to four fields */
|
|
316
|
+
--container-overlay-lg: 44rem; /* 704px - a form with paired fields */
|
|
317
|
+
--container-overlay-xl: 56rem; /* 896px - a form beside a preview */
|
|
318
|
+
--container-overlay-full: 68rem; /* 1088px - a table inside a dialog */
|
|
294
319
|
}
|
|
295
320
|
|
|
296
321
|
/*
|
|
@@ -478,7 +503,7 @@ html[data-theme='light'] {
|
|
|
478
503
|
* Print.
|
|
479
504
|
*
|
|
480
505
|
* The consuming applications had no `@media print` rule anywhere, and three surfaces print:
|
|
481
|
-
* the portal prints an invoice,
|
|
506
|
+
* the portal prints an invoice, an operations console prints an audit log, the admin
|
|
482
507
|
* prints a subject-access export. All three printed the sidebar, the theme
|
|
483
508
|
* toggle and the nav, in a dark palette, and all three printed one screenful
|
|
484
509
|
* and stopped, because the shell is `h-screen` with the content column set to
|