@lyeve-labs/ui-kit 0.24.0 → 0.25.1
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 +43 -0
- package/dist/components/AccordionItem.svelte +3 -5
- package/dist/components/AccountMenu.svelte +4 -3
- package/dist/components/Alert.svelte +2 -1
- package/dist/components/AppShell.svelte +166 -54
- package/dist/components/AppShell.svelte.d.ts +41 -6
- package/dist/components/Autocomplete.svelte +3 -2
- package/dist/components/Badge.svelte +10 -5
- package/dist/components/Banner.svelte +2 -1
- package/dist/components/Breadcrumb.svelte +7 -2
- package/dist/components/Button.svelte +15 -2
- package/dist/components/Card.svelte +1 -1
- package/dist/components/Collapsible.svelte +5 -4
- package/dist/components/CopyButton.svelte +2 -1
- package/dist/components/DatePicker.svelte +77 -62
- package/dist/components/Drawer.svelte +6 -5
- package/dist/components/Dropdown.svelte +5 -2
- package/dist/components/FileInput.svelte +1 -1
- package/dist/components/Modal.svelte +6 -3
- package/dist/components/MultiSelect.svelte +8 -6
- package/dist/components/NumberInput.svelte +2 -2
- package/dist/components/PageHeader.svelte +13 -1
- package/dist/components/PageShell.svelte +37 -19
- package/dist/components/PageShell.svelte.d.ts +12 -0
- package/dist/components/Pagination.svelte +7 -2
- package/dist/components/PasswordInput.svelte +2 -1
- package/dist/components/Progress.svelte +1 -1
- package/dist/components/SearchInput.svelte +3 -2
- package/dist/components/SectionHeading.svelte +10 -1
- package/dist/components/SegmentedControl.svelte +101 -27
- package/dist/components/SegmentedControl.svelte.d.ts +9 -0
- package/dist/components/Select.svelte +4 -3
- package/dist/components/SidebarNav.svelte +26 -17
- package/dist/components/StepIndicator.svelte +7 -9
- package/dist/components/Table.svelte +39 -29
- package/dist/components/Tabs.svelte +128 -24
- package/dist/components/Tag.svelte +2 -1
- package/dist/components/ThemeToggle.svelte +2 -1
- package/dist/components/Toaster.svelte +6 -2
- package/dist/components/Toggle.svelte +3 -2
- package/dist/components/Tooltip.svelte +10 -5
- package/dist/components/TreeView.svelte +2 -2
- package/dist/components/dialog/Dialog.svelte +15 -32
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/internal/choice.js +10 -3
- package/dist/internal/field.js +3 -3
- package/dist/internal/layout.d.ts +10 -1
- package/dist/internal/layout.js +10 -1
- package/dist/internal/panel.d.ts +13 -3
- package/dist/internal/panel.js +16 -5
- package/dist/internal/scroll-edges.d.ts +33 -0
- package/dist/internal/scroll-edges.js +33 -0
- package/dist/internal/touch.d.ts +28 -0
- package/dist/internal/touch.js +28 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +170 -0
- package/dist/styles/theme.css +131 -63
- package/package.json +1 -1
- package/src/lib/styles/theme.css +131 -63
package/README.md
CHANGED
|
@@ -168,6 +168,49 @@ the import:
|
|
|
168
168
|
}
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
## Motion
|
|
172
|
+
|
|
173
|
+
Every component moves on the same four durations and three curves, declared
|
|
174
|
+
in `styles.css` beside the palette:
|
|
175
|
+
|
|
176
|
+
| Token | Value | For |
|
|
177
|
+
| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
178
|
+
| `--duration-fast` | 120ms | a state change in place: colour, opacity, border, shadow, focus |
|
|
179
|
+
| `--duration-base` | 200ms | movement in place (a knob, a chevron, a collapse) and a small surface arriving (menu, popover, tooltip, toast) |
|
|
180
|
+
| `--duration-slow` | 320ms | a large surface arriving (dialog, drawer) |
|
|
181
|
+
| `--duration-progress` | 500ms | a value moving (a progress bar's width) |
|
|
182
|
+
| `--ease-enter` | `cubic-bezier(0.22, 1, 0.36, 1)` | arriving: starts fast and settles |
|
|
183
|
+
| `--ease-exit` | `cubic-bezier(0.32, 0, 0.67, 0)` | leaving: starts slow and accelerates away |
|
|
184
|
+
| `--ease-move` | `cubic-bezier(0.65, 0, 0.35, 1)` | changing in place |
|
|
185
|
+
|
|
186
|
+
An exit runs one rung faster than its entrance, on the exit curve. The
|
|
187
|
+
`transition-*` utilities default to the fast rung and the move curve, so
|
|
188
|
+
`transition-colors` on its own is complete; `duration-base`, `duration-slow`,
|
|
189
|
+
`duration-progress`, `ease-enter`, `ease-exit` and `ease-move` are utilities
|
|
190
|
+
for the rest. A surface of your own that mounts and unmounts enters and
|
|
191
|
+
leaves through the same presets the kit's overlays use:
|
|
192
|
+
|
|
193
|
+
```svelte
|
|
194
|
+
<script>
|
|
195
|
+
import { motion } from '@lyeve-labs/ui-kit';
|
|
196
|
+
</script>
|
|
197
|
+
|
|
198
|
+
{#if open}
|
|
199
|
+
<div transition:motion.popover|global>...</div>
|
|
200
|
+
{/if}
|
|
201
|
+
|
|
202
|
+
{#each items as item (item.id)}
|
|
203
|
+
<li transition:motion.toast|global animate:motion.reorder>...</li>
|
|
204
|
+
{/each}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`dialog`, `scrim`, `drawer`, `popover` and `toast` read the tokens at run
|
|
208
|
+
time; the `|global` modifier is what lets the exit play when a parent block
|
|
209
|
+
removes the surface, so keep it. They so retuning a token retunes them, and every one of them plays nothing
|
|
210
|
+
for a reader who has asked for reduced motion. Do not write `duration-150`,
|
|
211
|
+
`ease-out` or `transition-all` beside a kit class: they are a fifth speed and
|
|
212
|
+
a fourth curve, and the kit's own test suite refuses them.
|
|
213
|
+
|
|
171
214
|
## Local development
|
|
172
215
|
|
|
173
216
|
```bash
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
aria-expanded={open}
|
|
35
35
|
aria-controls={panelId}
|
|
36
36
|
class="flex w-full items-center justify-between px-5 py-4 text-start text-sm font-medium
|
|
37
|
-
text-fg transition-colors
|
|
37
|
+
text-fg transition-colors hover:bg-surface-2/50
|
|
38
38
|
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
39
39
|
{open ? 'bg-surface-2/40' : ''}"
|
|
40
40
|
>
|
|
@@ -48,9 +48,7 @@
|
|
|
48
48
|
stroke-width="2"
|
|
49
49
|
stroke-linecap="round"
|
|
50
50
|
stroke-linejoin="round"
|
|
51
|
-
class="shrink-0 text-faint transition-transform duration-
|
|
52
|
-
? 'rotate-180'
|
|
53
|
-
: ''}"
|
|
51
|
+
class="shrink-0 text-faint transition-transform duration-base {open ? 'rotate-180' : ''}"
|
|
54
52
|
aria-hidden="true"
|
|
55
53
|
>
|
|
56
54
|
<path d="M6 9l6 6 6-6" />
|
|
@@ -66,7 +64,7 @@
|
|
|
66
64
|
clipped rather than spilling while the row grows.
|
|
67
65
|
-->
|
|
68
66
|
<div
|
|
69
|
-
class="grid transition-[grid-template-rows] duration-
|
|
67
|
+
class="grid transition-[grid-template-rows] duration-base {open
|
|
70
68
|
? 'grid-rows-[1fr]'
|
|
71
69
|
: 'grid-rows-[0fr]'}"
|
|
72
70
|
>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* to sign out.
|
|
18
18
|
*/
|
|
19
19
|
import type { Snippet } from 'svelte';
|
|
20
|
+
import { TOUCH_GROW } from '../internal/touch.js';
|
|
20
21
|
import { ChevronDown } from '@lucide/svelte';
|
|
21
22
|
import type { AccentTone } from '../internal/tone.js';
|
|
22
23
|
|
|
@@ -98,7 +99,7 @@
|
|
|
98
99
|
|
|
99
100
|
<details bind:this={root} class="group relative {klass}">
|
|
100
101
|
<summary
|
|
101
|
-
class="flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors
|
|
102
|
+
class="{TOUCH_GROW} flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
102
103
|
>
|
|
103
104
|
<span
|
|
104
105
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold {AVATAR_TONE[
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
<span class="hidden min-w-0 max-w-[10rem] truncate text-sm text-fg sm:block">{name}</span>
|
|
112
113
|
<ChevronDown
|
|
113
114
|
size={14}
|
|
114
|
-
class="shrink-0 text-faint transition-transform duration-
|
|
115
|
+
class="shrink-0 text-faint transition-transform duration-base group-open:rotate-180"
|
|
115
116
|
/>
|
|
116
117
|
</summary>
|
|
117
118
|
|
|
@@ -120,7 +121,7 @@
|
|
|
120
121
|
rule are the card treatment, because lifted off the header it is a
|
|
121
122
|
surface of its own. -->
|
|
122
123
|
<div
|
|
123
|
-
class="absolute end-0 top-full z-dropdown mt-2 flex w-64 flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl"
|
|
124
|
+
class="absolute end-0 top-full z-dropdown mt-2 flex w-64 origin-top-right flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl animate-popover-in"
|
|
124
125
|
>
|
|
125
126
|
<div class="border-b border-line px-2 pb-2">
|
|
126
127
|
<p class="truncate text-sm font-medium text-fg">{name}</p>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
3
4
|
import {
|
|
4
5
|
TONE_GLYPH,
|
|
5
6
|
statusTone,
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
<button
|
|
70
71
|
type="button"
|
|
71
72
|
onclick={ondismiss}
|
|
72
|
-
class="shrink-0 text-faint transition-colors
|
|
73
|
+
class="{HIT_AREA} shrink-0 text-faint transition-colors hover:text-fg"
|
|
73
74
|
aria-label="Dismiss"
|
|
74
75
|
>
|
|
75
76
|
<svg
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* What the sidebar snippets are told about their column. `rail` is true
|
|
4
|
+
* while the sidebar is the 56px icon rail, so a SidebarNav takes it as
|
|
5
|
+
* `collapsed`, a brand row renders its mark without the wordmark, and a
|
|
6
|
+
* footer band renders what fits. A snippet that ignores the argument still
|
|
7
|
+
* renders; it is simply drawn at 56px and clipped.
|
|
8
|
+
*/
|
|
9
|
+
export interface SidebarState {
|
|
10
|
+
rail: boolean;
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
1
14
|
<script lang="ts">
|
|
2
15
|
/**
|
|
3
16
|
* The authed application frame: the sidebar, the header bar and the content
|
|
@@ -23,7 +36,14 @@
|
|
|
23
36
|
import type { Snippet } from 'svelte';
|
|
24
37
|
import { Menu, PanelLeft, PanelLeftClose } from '@lucide/svelte';
|
|
25
38
|
import { overlay } from '../internal/overlay.js';
|
|
26
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
APP_BRAND,
|
|
41
|
+
APP_HEADER,
|
|
42
|
+
APP_SIDEBAR,
|
|
43
|
+
APP_SIDEBAR_BAND,
|
|
44
|
+
APP_SIDEBAR_RAIL,
|
|
45
|
+
APP_SIDEBAR_WIDE,
|
|
46
|
+
} from '../internal/layout.js';
|
|
27
47
|
|
|
28
48
|
interface Props {
|
|
29
49
|
/** The current section, shown in the header. Not a heading: the page owns its h1. */
|
|
@@ -47,16 +67,41 @@
|
|
|
47
67
|
* decision. Nothing persists it for you.
|
|
48
68
|
*/
|
|
49
69
|
collapsed?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the header bar is put away, above md: only. Below md: the header
|
|
72
|
+
* carries the hamburger, which is the only way into the drawer, so the bar
|
|
73
|
+
* stays whatever this says.
|
|
74
|
+
*
|
|
75
|
+
* For a page that owns the viewport and wants all of it: a canvas in a
|
|
76
|
+
* focus mode. The page asking for it must keep a way back on screen, since
|
|
77
|
+
* the shell's own controls go with the bar. Bindable for the same reason
|
|
78
|
+
* `collapsed` is: where it is remembered is the app's decision.
|
|
79
|
+
*/
|
|
80
|
+
headerHidden?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Narrows the sidebar to the icon rail at every width above md:.
|
|
83
|
+
*
|
|
84
|
+
* The rail is automatic between md: and lg:, where the 224px column left a
|
|
85
|
+
* 768px window 496px for the page. A page that owns the viewport - a
|
|
86
|
+
* canvas, a split pane - asks for it at every width with this; the labels
|
|
87
|
+
* still come back under a pointer or the keyboard. Below md: the sidebar
|
|
88
|
+
* is the drawer whatever this says.
|
|
89
|
+
*/
|
|
90
|
+
rail?: boolean;
|
|
50
91
|
/** The sidebar landmark's accessible name. */
|
|
51
92
|
sidebarLabel?: string;
|
|
52
93
|
/** The drawer's accessible name, below md: where the sidebar is a dialog. */
|
|
53
94
|
drawerLabel?: string;
|
|
54
|
-
/** The product mark, in the sidebar's brand row. */
|
|
55
|
-
brand?: Snippet
|
|
56
|
-
/**
|
|
57
|
-
|
|
95
|
+
/** The product mark, in the sidebar's brand row. Told whether the column is the rail. */
|
|
96
|
+
brand?: Snippet<[SidebarState]>;
|
|
97
|
+
/**
|
|
98
|
+
* The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"` and
|
|
99
|
+
* `collapsed={rail}` from the argument, so the rail shows icons and the
|
|
100
|
+
* labels return when the rail opens.
|
|
101
|
+
*/
|
|
102
|
+
nav?: Snippet<[SidebarState]>;
|
|
58
103
|
/** A status band at the foot of the sidebar. Not the account: that is header work. */
|
|
59
|
-
sidebarFooter?: Snippet
|
|
104
|
+
sidebarFooter?: Snippet<[SidebarState]>;
|
|
60
105
|
/** The right end of the header bar. The theme toggle and the account menu live here. */
|
|
61
106
|
headerActions?: Snippet;
|
|
62
107
|
class?: string;
|
|
@@ -68,6 +113,8 @@
|
|
|
68
113
|
navOpen = $bindable(false),
|
|
69
114
|
collapsible = false,
|
|
70
115
|
collapsed = $bindable(false),
|
|
116
|
+
headerHidden = $bindable(false),
|
|
117
|
+
rail = false,
|
|
71
118
|
sidebarLabel = 'Sidebar',
|
|
72
119
|
drawerLabel = 'Navigation',
|
|
73
120
|
brand,
|
|
@@ -84,24 +131,61 @@
|
|
|
84
131
|
* anyone whose JavaScript never arrives.
|
|
85
132
|
*/
|
|
86
133
|
let isMobile = $state(false);
|
|
134
|
+
/** md: to lg:, where the expanded column costs the page more than it gives. */
|
|
135
|
+
let isNarrow = $state(false);
|
|
87
136
|
const drawerOpen = $derived(isMobile && navOpen);
|
|
88
137
|
/**
|
|
89
138
|
* Collapsing is a desktop gesture. Below md: the same aside is the drawer,
|
|
90
139
|
* so honouring `collapsed` there would leave the hamburger opening nothing.
|
|
91
140
|
*/
|
|
92
141
|
const railHidden = $derived(collapsible && collapsed && !isMobile);
|
|
142
|
+
/** Same rule for the header: a desktop gesture, and the drawer's button stays. */
|
|
143
|
+
const barHidden = $derived(headerHidden && !isMobile);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The rail: the icon column, and the labels on demand.
|
|
147
|
+
*
|
|
148
|
+
* While the sidebar is the rail it is 56px wide and the nav is told so. A
|
|
149
|
+
* pointer over it, or focus inside it, opens it to the full column over the
|
|
150
|
+
* page rather than beside it, so the page keeps its width and the reader
|
|
151
|
+
* still gets every label; it closes when the pointer or the focus leaves.
|
|
152
|
+
* State rather than a hover rule, because the nav snippet has to be told
|
|
153
|
+
* the labels are wanted, and a stylesheet cannot tell it.
|
|
154
|
+
*/
|
|
155
|
+
const railMode = $derived(!isMobile && !railHidden && (rail || isNarrow));
|
|
156
|
+
let railOpen = $state(false);
|
|
157
|
+
const railIcons = $derived(railMode && !railOpen);
|
|
93
158
|
|
|
94
159
|
$effect(() => {
|
|
95
|
-
const
|
|
160
|
+
const mobile = window.matchMedia('(max-width: 767px)');
|
|
161
|
+
const narrow = window.matchMedia('(min-width: 768px) and (max-width: 1023px)');
|
|
96
162
|
const sync = () => {
|
|
97
|
-
isMobile =
|
|
98
|
-
|
|
163
|
+
isMobile = mobile.matches;
|
|
164
|
+
isNarrow = narrow.matches && !mobile.matches;
|
|
165
|
+
if (!mobile.matches) navOpen = false;
|
|
166
|
+
railOpen = false;
|
|
99
167
|
};
|
|
100
168
|
sync();
|
|
101
|
-
|
|
102
|
-
|
|
169
|
+
mobile.addEventListener('change', sync);
|
|
170
|
+
narrow.addEventListener('change', sync);
|
|
171
|
+
return () => {
|
|
172
|
+
mobile.removeEventListener('change', sync);
|
|
173
|
+
narrow.removeEventListener('change', sync);
|
|
174
|
+
};
|
|
103
175
|
});
|
|
104
176
|
|
|
177
|
+
/** Focus moving from one rail link to the next stays inside; only leaving the rail closes it. */
|
|
178
|
+
function onRailFocusOut(event: FocusEvent) {
|
|
179
|
+
const next = event.relatedTarget;
|
|
180
|
+
if (
|
|
181
|
+
next instanceof Node &&
|
|
182
|
+
event.currentTarget instanceof Node &&
|
|
183
|
+
event.currentTarget.contains(next)
|
|
184
|
+
)
|
|
185
|
+
return;
|
|
186
|
+
railOpen = false;
|
|
187
|
+
}
|
|
188
|
+
|
|
105
189
|
/** The backdrop is the pointer's way out of the drawer. A keyboard has none. */
|
|
106
190
|
function onWindowKeydown(event: KeyboardEvent) {
|
|
107
191
|
if (event.key === 'Escape' && navOpen) navOpen = false;
|
|
@@ -154,55 +238,77 @@
|
|
|
154
238
|
aria-label={drawerLabel}
|
|
155
239
|
use:overlay
|
|
156
240
|
>
|
|
157
|
-
{@render sidebar(false)}
|
|
241
|
+
{@render sidebar(false, false)}
|
|
242
|
+
</div>
|
|
243
|
+
{:else if railMode}
|
|
244
|
+
<!--
|
|
245
|
+
The column keeps the rail's width whatever the aside inside it does. The
|
|
246
|
+
aside is taken out of the column's flow so that, opened, it lies over
|
|
247
|
+
the page at the dropdown layer instead of pushing it 168px sideways and
|
|
248
|
+
back on every pass of the pointer.
|
|
249
|
+
-->
|
|
250
|
+
<div
|
|
251
|
+
data-testid="app-rail"
|
|
252
|
+
role="presentation"
|
|
253
|
+
class="relative hidden {APP_SIDEBAR_RAIL} shrink-0 md:block"
|
|
254
|
+
onpointerenter={() => (railOpen = true)}
|
|
255
|
+
onpointerleave={() => (railOpen = false)}
|
|
256
|
+
onfocusin={() => (railOpen = true)}
|
|
257
|
+
onfocusout={onRailFocusOut}
|
|
258
|
+
>
|
|
259
|
+
<div class="absolute inset-y-0 start-0 z-dropdown flex {railOpen ? 'shadow-2xl' : ''}">
|
|
260
|
+
{@render sidebar(isMobile, railIcons)}
|
|
261
|
+
</div>
|
|
158
262
|
</div>
|
|
159
263
|
{:else if !railHidden}
|
|
160
264
|
<div class="hidden md:flex">
|
|
161
|
-
{@render sidebar(isMobile)}
|
|
265
|
+
{@render sidebar(isMobile, false)}
|
|
162
266
|
</div>
|
|
163
267
|
{/if}
|
|
164
268
|
|
|
165
269
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
270
|
+
{#if !barHidden}
|
|
271
|
+
<header data-print="hide" class={APP_HEADER}>
|
|
272
|
+
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
273
|
+
{#if isMobile}
|
|
274
|
+
<!-- 44px square, the smallest tap target SC 2.5.5 accepts. The -ms-2
|
|
170
275
|
pulls it back to the header's own gutter so the row it starts
|
|
171
276
|
still lines up with the page content below. -->
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
class="-ms-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted outline-none transition-colors hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
280
|
+
aria-label={navOpen ? 'Close navigation' : 'Open navigation'}
|
|
281
|
+
aria-expanded={navOpen}
|
|
282
|
+
onclick={() => (navOpen = !navOpen)}
|
|
283
|
+
>
|
|
284
|
+
<Menu size={20} />
|
|
285
|
+
</button>
|
|
286
|
+
{:else if collapsible}
|
|
287
|
+
<!-- Same square and the same gutter as the hamburger it replaces, so
|
|
183
288
|
the header's first control sits in one place at every width. -->
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
data-testid="app-sidebar-toggle"
|
|
292
|
+
class="-ms-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted outline-none transition-colors hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
293
|
+
aria-label={collapsed ? 'Show sidebar' : 'Hide sidebar'}
|
|
294
|
+
aria-expanded={!collapsed}
|
|
295
|
+
onclick={() => (collapsed = !collapsed)}
|
|
296
|
+
>
|
|
297
|
+
{#if collapsed}<PanelLeft size={18} />{:else}<PanelLeftClose size={18} />{/if}
|
|
298
|
+
</button>
|
|
299
|
+
{/if}
|
|
300
|
+
{#if section}
|
|
301
|
+
<span data-testid="app-section" class="truncate text-sm font-semibold text-fg">
|
|
302
|
+
{section}
|
|
303
|
+
</span>
|
|
304
|
+
{/if}
|
|
305
|
+
</div>
|
|
201
306
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
307
|
+
{#if headerActions}
|
|
308
|
+
<div class="flex shrink-0 items-center gap-1">{@render headerActions()}</div>
|
|
309
|
+
{/if}
|
|
310
|
+
</header>
|
|
311
|
+
{/if}
|
|
206
312
|
|
|
207
313
|
<!-- tabindex -1 is what makes the skip link work. A fragment link scrolls
|
|
208
314
|
to its target and moves focus to it only if the target can hold focus,
|
|
@@ -222,22 +328,28 @@
|
|
|
222
328
|
</div>
|
|
223
329
|
</div>
|
|
224
330
|
|
|
225
|
-
{#snippet sidebar(hidden: boolean)}
|
|
331
|
+
{#snippet sidebar(hidden: boolean, rail: boolean)}
|
|
332
|
+
<!-- The bands clip on the rail: a brand row that ignores the argument still
|
|
333
|
+
holds its wordmark, and 56px of it is better than the wordmark running
|
|
334
|
+
out over the page. -->
|
|
226
335
|
<aside
|
|
227
336
|
aria-label={sidebarLabel}
|
|
228
337
|
data-print="hide"
|
|
229
|
-
|
|
338
|
+
data-testid="app-sidebar"
|
|
339
|
+
class="{APP_SIDEBAR} {rail ? APP_SIDEBAR_RAIL : APP_SIDEBAR_WIDE} flex"
|
|
230
340
|
inert={hidden}
|
|
231
341
|
aria-hidden={hidden ? 'true' : undefined}
|
|
232
342
|
>
|
|
233
343
|
{#if brand}
|
|
234
|
-
<div class={APP_BRAND}>{@render brand()}</div>
|
|
344
|
+
<div class="{APP_BRAND} {rail ? 'overflow-hidden' : ''}">{@render brand({ rail })}</div>
|
|
235
345
|
{/if}
|
|
236
346
|
{#if nav}
|
|
237
|
-
{@render nav()}
|
|
347
|
+
{@render nav({ rail })}
|
|
238
348
|
{/if}
|
|
239
349
|
{#if sidebarFooter}
|
|
240
|
-
<div class={APP_SIDEBAR_BAND}
|
|
350
|
+
<div class="{APP_SIDEBAR_BAND} {rail ? 'overflow-hidden' : ''}">
|
|
351
|
+
{@render sidebarFooter({ rail })}
|
|
352
|
+
</div>
|
|
241
353
|
{/if}
|
|
242
354
|
</aside>
|
|
243
355
|
{/snippet}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the sidebar snippets are told about their column. `rail` is true
|
|
3
|
+
* while the sidebar is the 56px icon rail, so a SidebarNav takes it as
|
|
4
|
+
* `collapsed`, a brand row renders its mark without the wordmark, and a
|
|
5
|
+
* footer band renders what fits. A snippet that ignores the argument still
|
|
6
|
+
* renders; it is simply drawn at 56px and clipped.
|
|
7
|
+
*/
|
|
8
|
+
export interface SidebarState {
|
|
9
|
+
rail: boolean;
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* The authed application frame: the sidebar, the header bar and the content
|
|
3
13
|
* column, owned once so three apps cannot each invent their own.
|
|
@@ -42,21 +52,46 @@ interface Props {
|
|
|
42
52
|
* decision. Nothing persists it for you.
|
|
43
53
|
*/
|
|
44
54
|
collapsed?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the header bar is put away, above md: only. Below md: the header
|
|
57
|
+
* carries the hamburger, which is the only way into the drawer, so the bar
|
|
58
|
+
* stays whatever this says.
|
|
59
|
+
*
|
|
60
|
+
* For a page that owns the viewport and wants all of it: a canvas in a
|
|
61
|
+
* focus mode. The page asking for it must keep a way back on screen, since
|
|
62
|
+
* the shell's own controls go with the bar. Bindable for the same reason
|
|
63
|
+
* `collapsed` is: where it is remembered is the app's decision.
|
|
64
|
+
*/
|
|
65
|
+
headerHidden?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Narrows the sidebar to the icon rail at every width above md:.
|
|
68
|
+
*
|
|
69
|
+
* The rail is automatic between md: and lg:, where the 224px column left a
|
|
70
|
+
* 768px window 496px for the page. A page that owns the viewport - a
|
|
71
|
+
* canvas, a split pane - asks for it at every width with this; the labels
|
|
72
|
+
* still come back under a pointer or the keyboard. Below md: the sidebar
|
|
73
|
+
* is the drawer whatever this says.
|
|
74
|
+
*/
|
|
75
|
+
rail?: boolean;
|
|
45
76
|
/** The sidebar landmark's accessible name. */
|
|
46
77
|
sidebarLabel?: string;
|
|
47
78
|
/** The drawer's accessible name, below md: where the sidebar is a dialog. */
|
|
48
79
|
drawerLabel?: string;
|
|
49
|
-
/** The product mark, in the sidebar's brand row. */
|
|
50
|
-
brand?: Snippet
|
|
51
|
-
/**
|
|
52
|
-
|
|
80
|
+
/** The product mark, in the sidebar's brand row. Told whether the column is the rail. */
|
|
81
|
+
brand?: Snippet<[SidebarState]>;
|
|
82
|
+
/**
|
|
83
|
+
* The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"` and
|
|
84
|
+
* `collapsed={rail}` from the argument, so the rail shows icons and the
|
|
85
|
+
* labels return when the rail opens.
|
|
86
|
+
*/
|
|
87
|
+
nav?: Snippet<[SidebarState]>;
|
|
53
88
|
/** A status band at the foot of the sidebar. Not the account: that is header work. */
|
|
54
|
-
sidebarFooter?: Snippet
|
|
89
|
+
sidebarFooter?: Snippet<[SidebarState]>;
|
|
55
90
|
/** The right end of the header bar. The theme toggle and the account menu live here. */
|
|
56
91
|
headerActions?: Snippet;
|
|
57
92
|
class?: string;
|
|
58
93
|
children: Snippet;
|
|
59
94
|
}
|
|
60
|
-
declare const AppShell: import("svelte").Component<Props, {}, "navOpen" | "collapsed">;
|
|
95
|
+
declare const AppShell: import("svelte").Component<Props, {}, "navOpen" | "collapsed" | "headerHidden">;
|
|
61
96
|
type AppShell = ReturnType<typeof AppShell>;
|
|
62
97
|
export default AppShell;
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
panelOption,
|
|
50
50
|
placePanel,
|
|
51
51
|
} from '../internal/panel.js';
|
|
52
|
+
import * as motion from '../motion.js';
|
|
52
53
|
|
|
53
54
|
interface Props {
|
|
54
55
|
/** The chosen option's value. Empty for none. */
|
|
@@ -216,7 +217,7 @@
|
|
|
216
217
|
type="button"
|
|
217
218
|
aria-label="Clear"
|
|
218
219
|
onclick={clear}
|
|
219
|
-
class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint transition-colors
|
|
220
|
+
class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint transition-colors hover:text-fg"
|
|
220
221
|
>
|
|
221
222
|
<svg
|
|
222
223
|
width="12"
|
|
@@ -234,7 +235,7 @@
|
|
|
234
235
|
{/if}
|
|
235
236
|
|
|
236
237
|
{#if box.open}
|
|
237
|
-
<div use:placePanel class="{PANEL_SURFACE} w-full">
|
|
238
|
+
<div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
|
|
238
239
|
<div class={PANEL_LIST} data-panel-list use:panel {...box.listAttrs}>
|
|
239
240
|
{#each rows as option, index (option.value)}
|
|
240
241
|
{@const isSelected = option.value === value}
|
|
@@ -38,11 +38,16 @@
|
|
|
38
38
|
};
|
|
39
39
|
</script>
|
|
40
40
|
|
|
41
|
+
<!-- whitespace-nowrap: a badge is one token and never breaks inside itself.
|
|
42
|
+
Under a Table's default `overflow-wrap: anywhere` a status rendered as
|
|
43
|
+
`dra ft` and a role as `sup er_a dmi n`. The label sits in a truncating
|
|
44
|
+
span so a badge whose caller caps its width ends in an ellipsis instead of
|
|
45
|
+
painting past its own border; with no cap the span never shrinks. -->
|
|
41
46
|
<span
|
|
42
|
-
class="inline-flex items-center font-medium rounded-full border {tones[
|
|
43
|
-
|
|
44
|
-
]} {klass}"
|
|
47
|
+
class="inline-flex max-w-full items-center font-medium whitespace-nowrap rounded-full border {tones[
|
|
48
|
+
tone
|
|
49
|
+
]} {sizes[size]} {klass}"
|
|
45
50
|
>
|
|
46
|
-
{#if dot}<span class="w-1.5 h-1.5 rounded-full {dotColor[tone]}"></span>{/if}
|
|
47
|
-
{@render children()}
|
|
51
|
+
{#if dot}<span class="w-1.5 h-1.5 shrink-0 rounded-full {dotColor[tone]}"></span>{/if}
|
|
52
|
+
<span class="min-w-0 truncate">{@render children()}</span>
|
|
48
53
|
</span>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { statusTone, type StatusTone, type StatusToneInput } from '../internal/tone.js';
|
|
3
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
3
4
|
import type { Snippet } from 'svelte';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
type="button"
|
|
57
58
|
onclick={dismiss}
|
|
58
59
|
aria-label="Dismiss"
|
|
59
|
-
class="shrink-0 text-faint transition-colors
|
|
60
|
+
class="{HIT_AREA} shrink-0 text-faint transition-colors hover:text-fg"
|
|
60
61
|
>
|
|
61
62
|
<svg
|
|
62
63
|
width="16"
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
3
|
+
|
|
2
4
|
let {
|
|
3
5
|
items,
|
|
4
6
|
class: cls = '',
|
|
@@ -32,11 +34,14 @@
|
|
|
32
34
|
{/if}
|
|
33
35
|
|
|
34
36
|
{#if item.href && i < items.length - 1}
|
|
37
|
+
<!-- The link is a flex box around a truncating span rather than
|
|
38
|
+
truncating itself: `truncate` clips overflow, and the hit box a
|
|
39
|
+
finger gets is drawn outside the link's own edges. -->
|
|
35
40
|
<a
|
|
36
41
|
href={safeUrl(item.href)}
|
|
37
|
-
class="text-muted hover:text-fg transition-colors
|
|
42
|
+
class="{HIT_AREA} inline-flex min-w-0 text-muted hover:text-fg transition-colors"
|
|
38
43
|
>
|
|
39
|
-
{item.label}
|
|
44
|
+
<span class="min-w-0 truncate">{item.label}</span>
|
|
40
45
|
</a>
|
|
41
46
|
{:else}
|
|
42
47
|
<span
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import Spinner from './Spinner.svelte';
|
|
5
5
|
import Tooltip from './Tooltip.svelte';
|
|
6
6
|
import { safeHref } from '../internal/href.js';
|
|
7
|
+
import { TOUCH_GROW } from '../internal/touch.js';
|
|
7
8
|
|
|
8
9
|
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
|
|
9
10
|
type Size = 'sm' | 'md' | 'lg';
|
|
@@ -102,9 +103,21 @@
|
|
|
102
103
|
};
|
|
103
104
|
|
|
104
105
|
const spinnerSize = $derived(size === 'lg' ? 18 : 15);
|
|
106
|
+
/*
|
|
107
|
+
* whitespace-nowrap: a label never breaks inside itself. Under a Table's
|
|
108
|
+
* `overflow-wrap: anywhere` a two-word ghost button in a flex row rendered
|
|
109
|
+
* on two lines, and a status badge beside it as `dra ft`. A caller that
|
|
110
|
+
* caps the width wraps its label in a `truncate` span; the button cannot
|
|
111
|
+
* do that for it, because the label and any icon before it are flex items
|
|
112
|
+
* and text-overflow does not reach into one.
|
|
113
|
+
*
|
|
114
|
+
* TOUCH_GROW: under a coarse pointer the button takes the control height,
|
|
115
|
+
* which is 44px there, so a button and the input beside it grow together.
|
|
116
|
+
* A mouse sees 36px, 32px and 44px by size, as before.
|
|
117
|
+
*/
|
|
105
118
|
const cls = $derived(
|
|
106
|
-
`inline-flex items-center justify-center font-medium transition-colors
|
|
107
|
-
`disabled:opacity-50 disabled:cursor-not-allowed select-none ` +
|
|
119
|
+
`inline-flex items-center justify-center font-medium whitespace-nowrap transition-colors ` +
|
|
120
|
+
`disabled:opacity-50 disabled:cursor-not-allowed select-none ${TOUCH_GROW} ` +
|
|
108
121
|
`${full ? 'w-full' : ''} ${variants[variant]} ${sizes[size]} ${klass}`,
|
|
109
122
|
);
|
|
110
123
|
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
84
84
|
<div
|
|
85
85
|
data-print="keep"
|
|
86
|
-
class="bg-surface border border-line rounded-xl overflow-hidden transition-colors
|
|
86
|
+
class="bg-surface border border-line rounded-xl overflow-hidden transition-colors {hover
|
|
87
87
|
? 'hover:border-line/0 hover:ring-1 hover:ring-brand/30'
|
|
88
88
|
: ''} {onclick ? 'cursor-pointer' : ''} {klass}"
|
|
89
89
|
role={onclick ? 'button' : undefined}
|