@lyeve-labs/ui-kit 0.23.3 → 0.25.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.
Files changed (60) hide show
  1. package/README.md +42 -0
  2. package/dist/components/AccordionItem.svelte +3 -5
  3. package/dist/components/AccountMenu.svelte +4 -3
  4. package/dist/components/Alert.svelte +2 -1
  5. package/dist/components/AppShell.svelte +166 -54
  6. package/dist/components/AppShell.svelte.d.ts +41 -6
  7. package/dist/components/Autocomplete.svelte +3 -2
  8. package/dist/components/Badge.svelte +10 -5
  9. package/dist/components/Banner.svelte +2 -1
  10. package/dist/components/Breadcrumb.svelte +7 -2
  11. package/dist/components/Button.svelte +15 -2
  12. package/dist/components/Card.svelte +1 -1
  13. package/dist/components/Collapsible.svelte +5 -4
  14. package/dist/components/CopyButton.svelte +2 -1
  15. package/dist/components/DatePicker.svelte +77 -62
  16. package/dist/components/Drawer.svelte +6 -5
  17. package/dist/components/Dropdown.svelte +5 -2
  18. package/dist/components/FileInput.svelte +1 -1
  19. package/dist/components/Modal.svelte +6 -3
  20. package/dist/components/MultiSelect.svelte +8 -6
  21. package/dist/components/NumberInput.svelte +2 -2
  22. package/dist/components/PageHeader.svelte +13 -1
  23. package/dist/components/PageShell.svelte +62 -7
  24. package/dist/components/PageShell.svelte.d.ts +27 -1
  25. package/dist/components/Pagination.svelte +7 -2
  26. package/dist/components/PasswordInput.svelte +2 -1
  27. package/dist/components/Progress.svelte +1 -1
  28. package/dist/components/SearchInput.svelte +3 -2
  29. package/dist/components/SectionHeading.svelte +10 -1
  30. package/dist/components/SegmentedControl.svelte +101 -27
  31. package/dist/components/SegmentedControl.svelte.d.ts +9 -0
  32. package/dist/components/Select.svelte +4 -3
  33. package/dist/components/SidebarNav.svelte +26 -17
  34. package/dist/components/StepIndicator.svelte +7 -9
  35. package/dist/components/Table.svelte +39 -29
  36. package/dist/components/Tabs.svelte +128 -24
  37. package/dist/components/Tag.svelte +2 -1
  38. package/dist/components/ThemeToggle.svelte +2 -1
  39. package/dist/components/Toaster.svelte +6 -2
  40. package/dist/components/Toggle.svelte +23 -5
  41. package/dist/components/Tooltip.svelte +10 -5
  42. package/dist/components/TreeView.svelte +2 -2
  43. package/dist/components/dialog/Dialog.svelte +15 -32
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.js +7 -1
  46. package/dist/internal/choice.js +10 -3
  47. package/dist/internal/field.js +3 -3
  48. package/dist/internal/layout.d.ts +10 -1
  49. package/dist/internal/layout.js +10 -1
  50. package/dist/internal/panel.d.ts +13 -3
  51. package/dist/internal/panel.js +16 -5
  52. package/dist/internal/scroll-edges.d.ts +33 -0
  53. package/dist/internal/scroll-edges.js +33 -0
  54. package/dist/internal/touch.d.ts +28 -0
  55. package/dist/internal/touch.js +28 -0
  56. package/dist/motion.d.ts +63 -0
  57. package/dist/motion.js +165 -0
  58. package/dist/styles/theme.css +131 -63
  59. package/package.json +1 -1
  60. package/src/lib/styles/theme.css +131 -63
package/README.md CHANGED
@@ -168,6 +168,48 @@ 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>...</div>
200
+ {/if}
201
+
202
+ {#each items as item (item.id)}
203
+ <li transition:motion.toast animate:motion.reorder>...</li>
204
+ {/each}
205
+ ```
206
+
207
+ `dialog`, `scrim`, `drawer`, `popover` and `toast` read the tokens at run
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. Do not write `duration-150`,
210
+ `ease-out` or `transition-all` beside a kit class: they are a fifth speed and
211
+ a fourth curve, and the kit's own test suite refuses them.
212
+
171
213
  ## Local development
172
214
 
173
215
  ```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 duration-150 hover:bg-surface-2/50
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-200 ease-out {open
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-[var(--duration-collapse)] ease-out {open
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 duration-150 hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
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-150 group-open:rotate-180"
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 duration-150 hover:text-fg"
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 { APP_BRAND, APP_HEADER, APP_SIDEBAR, APP_SIDEBAR_BAND } from '../internal/layout.js';
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
- /** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
57
- nav?: Snippet;
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 mq = window.matchMedia('(max-width: 767px)');
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 = mq.matches;
98
- if (!mq.matches) navOpen = false;
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
- mq.addEventListener('change', sync);
102
- return () => mq.removeEventListener('change', sync);
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
- <header data-print="hide" class={APP_HEADER}>
167
- <div class="flex min-w-0 flex-1 items-center gap-2">
168
- {#if isMobile}
169
- <!-- 44px square, the smallest tap target SC 2.5.5 accepts. The -ms-2
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
- <button
173
- type="button"
174
- 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 active:bg-line active:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
175
- aria-label={navOpen ? 'Close navigation' : 'Open navigation'}
176
- aria-expanded={navOpen}
177
- onclick={() => (navOpen = !navOpen)}
178
- >
179
- <Menu size={20} />
180
- </button>
181
- {:else if collapsible}
182
- <!-- Same square and the same gutter as the hamburger it replaces, so
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
- <button
185
- type="button"
186
- data-testid="app-sidebar-toggle"
187
- 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 active:bg-line active:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
188
- aria-label={collapsed ? 'Show sidebar' : 'Hide sidebar'}
189
- aria-expanded={!collapsed}
190
- onclick={() => (collapsed = !collapsed)}
191
- >
192
- {#if collapsed}<PanelLeft size={18} />{:else}<PanelLeftClose size={18} />{/if}
193
- </button>
194
- {/if}
195
- {#if section}
196
- <span data-testid="app-section" class="truncate text-sm font-semibold text-fg">
197
- {section}
198
- </span>
199
- {/if}
200
- </div>
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
- {#if headerActions}
203
- <div class="flex shrink-0 items-center gap-1">{@render headerActions()}</div>
204
- {/if}
205
- </header>
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
- class="{APP_SIDEBAR} flex"
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}>{@render sidebarFooter()}</div>
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
- /** The navigation itself. A SidebarNav, given `class="min-h-0 flex-1"`. */
52
- nav?: Snippet;
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 duration-150 hover:text-fg"
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 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[tone]} {sizes[
43
- size
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 duration-150 hover:text-fg"
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 duration-150 truncate"
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 duration-150 ` +
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 duration-150 {hover
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}