@lyeve-labs/ui-kit 0.24.0 → 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 +37 -19
  24. package/dist/components/PageShell.svelte.d.ts +12 -0
  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 +3 -2
  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
@@ -1,4 +1,12 @@
1
1
  <script lang="ts">
2
+ import {
3
+ SCROLL_EDGE,
4
+ SCROLL_EDGE_END,
5
+ SCROLL_EDGE_START,
6
+ scrollEdges,
7
+ } from '../internal/scroll-edges.js';
8
+ import { TOUCH_GROW } from '../internal/touch.js';
9
+
2
10
  let {
3
11
  items,
4
12
  active,
@@ -10,30 +18,126 @@
10
18
  class?: string;
11
19
  onchange: (id: string) => void;
12
20
  } = $props();
21
+
22
+ /*
23
+ * The strip scrolls sideways when the tabs do not fit, and says so.
24
+ *
25
+ * It neither wrapped nor scrolled: five tabs are 480px, and at 400px the
26
+ * fifth was past the edge with nothing on screen to say a fifth existed.
27
+ * Wrapping was the workaround, and a wrapped strip breaks the one line the
28
+ * underline runs along. A tab never breaks inside its own label.
29
+ *
30
+ * The scrollbar is not drawn. A strip of tabs with a bar under it reads as
31
+ * a second rule under the real one; the fades carry the same message, the
32
+ * tabs are buttons so the keyboard reaches every one, and the active tab
33
+ * is scrolled into view when it changes.
34
+ */
35
+ let scroller: HTMLElement | undefined = $state();
36
+ let moreBefore = $state(false);
37
+ let moreAfter = $state(false);
38
+
39
+ function measure() {
40
+ const el = scroller;
41
+ if (!el) return;
42
+ const edges = scrollEdges(el);
43
+ moreBefore = edges.before;
44
+ moreAfter = edges.after;
45
+ }
46
+
47
+ $effect(() => {
48
+ const el = scroller;
49
+ if (!el) return;
50
+ measure();
51
+ if (typeof ResizeObserver === 'undefined') return;
52
+ const observer = new ResizeObserver(measure);
53
+ observer.observe(el);
54
+ return () => observer.disconnect();
55
+ });
56
+
57
+ /*
58
+ * The tab for the active id is brought into view when the id changes, so a
59
+ * page that opens on its fifth tab does not open on a strip that hides it.
60
+ * `nearest` on both axes moves the strip and nothing else; a test DOM has
61
+ * no layout and no scrollIntoView, and neither is an error here.
62
+ */
63
+ $effect(() => {
64
+ const el = scroller;
65
+ const id = active;
66
+ if (!el) return;
67
+ const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
68
+ (t) => t.dataset.tab === id,
69
+ );
70
+ if (tab && typeof tab.scrollIntoView === 'function') {
71
+ tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
72
+ }
73
+ });
74
+
75
+ /*
76
+ * The focus ring is inset: the tabs sit in a box that clips to scroll, and
77
+ * the global outline sits 2px outside its element, so on the strip's top
78
+ * edge it was cut off. min-h-control under a finger is the same height the
79
+ * buttons and fields beside the strip take there.
80
+ */
81
+ const TAB =
82
+ `${TOUCH_GROW} relative shrink-0 px-4 py-2.5 text-sm font-medium whitespace-nowrap ` +
83
+ 'transition-colors outline-none ' +
84
+ 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
13
85
  </script>
14
86
 
15
- <div role="tablist" class="flex gap-0.5 border-b border-line {cls}">
16
- {#each items as tab (tab.id)}
17
- <button
18
- type="button"
19
- role="tab"
20
- aria-selected={active === tab.id}
21
- onclick={() => onchange(tab.id)}
22
- class="relative px-4 py-2.5 text-sm font-medium transition-colors duration-150 whitespace-nowrap
23
- {active === tab.id
24
- ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
25
- : 'text-faint hover:text-fg'}"
26
- >
27
- {tab.label}
28
- {#if tab.count !== undefined}
29
- <span
30
- class="ms-1.5 inline-flex items-center justify-center min-w-4.5 h-4.5 px-1
31
- rounded-full text-[10px] font-semibold
32
- {active === tab.id ? 'bg-brand/40 text-brand' : 'bg-surface-2 text-faint'}"
33
- >
34
- {tab.count}
35
- </span>
36
- {/if}
37
- </button>
38
- {/each}
87
+ <div class="relative {cls}">
88
+ <div
89
+ bind:this={scroller}
90
+ onscroll={measure}
91
+ role="tablist"
92
+ data-testid="tabs-scroll"
93
+ class="flex gap-0.5 overflow-x-auto border-b border-line [scrollbar-width:none]"
94
+ >
95
+ {#each items as tab (tab.id)}
96
+ <button
97
+ type="button"
98
+ role="tab"
99
+ data-tab={tab.id}
100
+ aria-selected={active === tab.id}
101
+ onclick={() => onchange(tab.id)}
102
+ class="{TAB}
103
+ {active === tab.id
104
+ ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
105
+ : 'text-faint hover:text-fg'}"
106
+ >
107
+ {tab.label}
108
+ {#if tab.count !== undefined}
109
+ <!-- text-xs, not 10px: the count was the one text on a measured
110
+ page under the 12px floor, and it is the one figure a tab
111
+ carries. -->
112
+ <span
113
+ class="ms-1.5 inline-flex items-center justify-center min-w-4.5 h-4.5 px-1
114
+ rounded-full text-xs font-semibold
115
+ {active === tab.id ? 'bg-brand/40 text-brand' : 'bg-surface-2 text-faint'}"
116
+ >
117
+ {tab.count}
118
+ </span>
119
+ {/if}
120
+ </button>
121
+ {/each}
122
+ </div>
123
+
124
+ <!-- Outside the scroll box and out of the accessibility tree: a picture of
125
+ the scrollbar the phone will not draw, saying nothing the tablist has
126
+ not already said. -->
127
+ {#if moreBefore}
128
+ <div
129
+ aria-hidden="true"
130
+ data-print="hide"
131
+ data-testid="tabs-more-before"
132
+ class="{SCROLL_EDGE} {SCROLL_EDGE_START}"
133
+ ></div>
134
+ {/if}
135
+ {#if moreAfter}
136
+ <div
137
+ aria-hidden="true"
138
+ data-print="hide"
139
+ data-testid="tabs-more-after"
140
+ class="{SCROLL_EDGE} {SCROLL_EDGE_END}"
141
+ ></div>
142
+ {/if}
39
143
  </div>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { AccentTone } from '../internal/tone.js';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
 
4
5
  interface Props {
5
6
  label: string;
@@ -37,7 +38,7 @@
37
38
  type="button"
38
39
  onclick={onremove}
39
40
  aria-label="Remove {label}"
40
- class="ms-0.5 rounded transition-opacity hover:opacity-60 leading-none"
41
+ class="{HIT_AREA} ms-0.5 rounded transition-opacity hover:opacity-60 leading-none"
41
42
  >
42
43
  <svg
43
44
  width="10"
@@ -15,6 +15,7 @@
15
15
  * implies the state.
16
16
  */
17
17
  import { Monitor, Moon, Sun } from '@lucide/svelte';
18
+ import { TOUCH_GROW } from '../internal/touch.js';
18
19
  import {
19
20
  THEME_PREFERENCES,
20
21
  getThemePreference,
@@ -81,7 +82,7 @@
81
82
  data-print="hide"
82
83
  data-theme-preference={preference}
83
84
  aria-label="Theme: {LABEL[preference]}. Switch to {LABEL[upcoming]}."
84
- class="rounded-lg p-2 text-faint transition-colors duration-150 hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg {klass}"
85
+ class="{TOUCH_GROW} rounded-lg p-2 text-faint transition-colors hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg {klass}"
85
86
  >
86
87
  <!-- The icon shows the state the control is in, not the one it moves to. It
87
88
  showed the destination while the label named the same destination, which
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { toast } from '../stores/toast.svelte';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
  import { TONE_GLYPH, statusTone, type StatusTone } from '../internal/tone.js';
5
+ import * as motion from '../motion.js';
4
6
 
5
7
  const styles: Record<StatusTone, { bar: string; icon: string }> = {
6
8
  neutral: { bar: 'bg-muted', icon: 'text-muted' },
@@ -25,8 +27,10 @@
25
27
  >
26
28
  {#each toast.items as t (t.id)}
27
29
  <div
30
+ transition:motion.toast
31
+ animate:motion.reorder
28
32
  class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
29
- bg-surface ps-0 pe-3 py-3 shadow-xl animate-[toast-in_var(--duration-toast-in)_ease-out]"
33
+ bg-surface ps-0 pe-3 py-3 shadow-xl"
30
34
  >
31
35
  <span class="w-1 self-stretch shrink-0 {styles[statusTone(t.tone)].bar}"></span>
32
36
  <span
@@ -51,7 +55,7 @@
51
55
  <p class="flex-1 text-sm text-fg leading-snug">{t.message}</p>
52
56
  <button
53
57
  type="button"
54
- class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
58
+ class="{HIT_AREA} shrink-0 rounded text-faint transition-colors hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
55
59
  aria-label="Dismiss"
56
60
  onclick={() => toast.dismiss(t.id)}
57
61
  >
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { FIELD_HINT } from '../internal/field.js';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
  let {
4
5
  checked = $bindable(false),
5
6
  label,
@@ -79,14 +80,14 @@
79
80
  aria-label={label ?? 'Toggle'}
80
81
  {disabled}
81
82
  onclick={handleClick}
82
- class="relative shrink-0 rounded-full border transition-colors duration-150 outline-none
83
+ class="{HIT_AREA} shrink-0 rounded-full border transition-colors outline-none
83
84
  focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
84
85
  focus-visible:ring-offset-ink mt-0.5
85
86
  {track[size]}
86
87
  {checked ? trackPaint.on : trackPaint.off}"
87
88
  >
88
89
  <span
89
- class="absolute rounded-full bg-fg shadow transition-transform
90
+ class="absolute rounded-full bg-fg shadow transition-transform duration-base
90
91
  {thumb[size]}
91
92
  {checked ? thumbOn[size] : 'translate-x-0'}"
92
93
  ></span>
@@ -125,18 +125,23 @@
125
125
  {onkeydown}
126
126
  >
127
127
  {@render children()}
128
- <!-- Always rendered, hidden by the `hidden` attribute rather than removed:
128
+ <!-- Always rendered, and shut with visibility rather than removed:
129
129
  aria-describedby resolves the text of a hidden element, so the trigger
130
- keeps a stable target. Left hoverable rather than pointer-events-none, so
131
- moving onto it does not dismiss it, which SC 1.4.13 also requires. -->
130
+ keeps a stable target. Visibility is one of the properties Tailwind's
131
+ `transition` moves, discretely, so the hint fades and settles on the way
132
+ in and is invisible only once it has faded out. Left hoverable rather
133
+ than pointer-events-none, so moving onto it does not dismiss it, which
134
+ SC 1.4.13 also requires. -->
132
135
  <span
133
136
  {id}
134
137
  role={describe ? 'tooltip' : undefined}
135
138
  aria-hidden={describe ? undefined : 'true'}
136
- hidden={!visible}
137
139
  style="top: {anchor.top}px; left: {anchor.left}px"
138
140
  class="fixed {pos[position]} z-tooltip w-max max-w-[min(16rem,calc(100vw-2rem))]
139
- rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
141
+ rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl
142
+ transition {visible
143
+ ? 'duration-base ease-enter'
144
+ : 'invisible scale-[0.96] opacity-0 duration-fast ease-exit'}"
140
145
  >
141
146
  {text}
142
147
  </span>
@@ -270,7 +270,7 @@
270
270
  scan, so no rule is generated and every row draws flush left.
271
271
  -->
272
272
  <div
273
- class="flex w-full items-center gap-2 rounded-md py-1.5 pe-2 text-sm transition-colors duration-150 {rowPaint(
273
+ class="flex w-full items-center gap-2 rounded-md py-1.5 pe-2 text-sm transition-colors {rowPaint(
274
274
  selected === node.id,
275
275
  !!node.disabled,
276
276
  )}"
@@ -283,7 +283,7 @@
283
283
  >
284
284
  <ChevronRight
285
285
  size={14}
286
- class="transition-transform duration-150 {open ? 'rotate-90' : 'rtl:rotate-180'}"
286
+ class="transition-transform duration-base {open ? 'rotate-90' : 'rtl:rotate-180'}"
287
287
  />
288
288
  </span>
289
289
  {:else}
@@ -2,18 +2,19 @@
2
2
  /**
3
3
  * Dialog - modal frame component.
4
4
  *
5
- * Handles: focus trap, ESC to close, click-outside to close,
6
- * scale+fade animation, stacked z-indexing, portal to body.
5
+ * Handles: focus trap, ESC to close, click-outside to close, the entrance
6
+ * and exit through the kit's motion presets, stacked z-indexing, portal to
7
+ * body.
7
8
  *
8
9
  * Rendered by DialogContainer for each entry in the dialog stack.
9
10
  * Not meant to be used directly - use openDialog() from dialog-manager.
10
11
  */
11
12
  import { X } from '@lucide/svelte';
12
- import { onMount } from 'svelte';
13
13
  import type { Snippet } from 'svelte';
14
14
  import type { DialogEntry } from './types.js';
15
15
  import { sizeClass } from './types.js';
16
16
  import { overlay } from '../../internal/overlay.js';
17
+ import * as motion from '../../motion.js';
17
18
  import { closeDialog, dismissDialog } from './dialog-manager.svelte';
18
19
  import ConfirmDialog from './ConfirmDialog.svelte';
19
20
 
@@ -25,13 +26,6 @@
25
26
  children?: Snippet;
26
27
  } = $props();
27
28
 
28
- // ──────────────────────────────────────────────────────
29
- // Animation state
30
- // ──────────────────────────────────────────────────────
31
-
32
- let visible = $state(false);
33
- let exiting = $state(false);
34
-
35
29
  // Stacked offset: each deeper dialog shrinks, shifts back and rises
36
30
  let offset = $derived(entry.depth);
37
31
 
@@ -47,12 +41,9 @@
47
41
 
48
42
  // Focus entry, the Tab trap, the body scroll lock and focus restore all come
49
43
  // from `use:overlay` on the panel below, which is the same implementation
50
- // Modal and Drawer use. This is only the enter animation.
51
- onMount(() => {
52
- requestAnimationFrame(() => {
53
- visible = true;
54
- });
55
- });
44
+ // Modal and Drawer use. The entrance and the exit are the same presets too:
45
+ // removing the entry from the stack plays the exit before the element goes,
46
+ // so nothing here waits on a timer to match a duration written elsewhere.
56
47
 
57
48
  // ──────────────────────────────────────────────────────
58
49
  // Handlers
@@ -74,16 +65,11 @@
74
65
  }
75
66
  }
76
67
 
77
- async function handleDismiss(): Promise<void> {
78
- exiting = true;
79
- // Wait for exit animation
80
- await new Promise((r) => setTimeout(r, 200));
68
+ function handleDismiss(): void {
81
69
  dismissDialog(entry.id);
82
70
  }
83
71
 
84
- async function handleClose(value?: unknown): Promise<void> {
85
- exiting = true;
86
- await new Promise((r) => setTimeout(r, 200));
72
+ function handleClose(value?: unknown): void {
87
73
  closeDialog(value, entry.id);
88
74
  }
89
75
  </script>
@@ -107,9 +93,8 @@
107
93
  <!-- Backdrop -->
108
94
  <!-- svelte-ignore a11y_no_static_element_interactions -->
109
95
  <div
110
- class="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200"
111
- class:opacity-0={!visible || exiting}
112
- class:opacity-100={visible && !exiting}
96
+ transition:motion.scrim
97
+ class="absolute inset-0 bg-black/60 backdrop-blur-sm"
113
98
  onclick={handleBackdropClick}
114
99
  onkeydown={(e: KeyboardEvent) => {
115
100
  if (e.key === 'Escape' && !entry.options.persistent) handleDismiss();
@@ -122,14 +107,12 @@
122
107
  <div
123
108
  bind:this={dialogEl}
124
109
  use:overlay
110
+ transition:motion.dialog
125
111
  class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
126
112
  bg-surface border border-line rounded-xl shadow-2xl
127
- transition-all duration-200 ease-out
113
+ transition-transform duration-slow
128
114
  focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
129
- class:opacity-0={!visible || exiting}
130
- class:opacity-100={visible && !exiting}
131
- style="transform: scale({visible && !exiting ? 1 - offset * 0.03 : 0.95}) translateY({offset *
132
- 16}px);
115
+ style="transform: scale({1 - offset * 0.03}) translateY({offset * 16}px);
133
116
  transform-origin: center;"
134
117
  role="dialog"
135
118
  aria-modal="true"
@@ -158,7 +141,7 @@
158
141
  {#if !entry.options.persistent}
159
142
  <button
160
143
  class="inline-flex items-center justify-center w-8 h-8 -me-2 rounded-lg
161
- text-muted hover:text-fg hover:bg-surface-2 transition-colors duration-150 shrink-0"
144
+ text-muted hover:text-fg hover:bg-surface-2 transition-colors shrink-0"
162
145
  onclick={() => handleClose()}
163
146
  aria-label="Close"
164
147
  >
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { default as DescriptionList } from './components/DescriptionList.svelte'
21
21
  export { default as Toolbar } from './components/Toolbar.svelte';
22
22
  export { default as TreeView } from './components/TreeView.svelte';
23
23
  export type { CellFit } from './components/Table.svelte';
24
+ export type { SidebarState } from './components/AppShell.svelte';
24
25
  export type { PageWidth, SectionVariant } from './internal/layout.js';
25
26
  export type { TreeNode, TriState } from './internal/tree.js';
26
27
  export { default as Button } from './components/Button.svelte';
@@ -89,4 +90,6 @@ export type { ConfirmOptions } from './components/dialog/dialog-manager.svelte.j
89
90
  export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
90
91
  export { cn, type ClassValue } from './utils/cn.js';
91
92
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
92
- export declare const VERSION = "0.24.0";
93
+ export * as motion from './motion.js';
94
+ export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
95
+ export declare const VERSION = "0.25.0";
package/dist/index.js CHANGED
@@ -93,7 +93,13 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
93
93
  // ── Utilities ──────────────────────────────────────────────────────────────
94
94
  export { cn } from './utils/cn.js';
95
95
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, } from './utils/theme.js';
96
+ // ── Motion ─────────────────────────────────────────────────────────────────
97
+ // The entrances and exits as Svelte transitions, for a surface a consumer
98
+ // builds that the kit does not ship: `transition:motion.popover`,
99
+ // `animate:motion.reorder`. They read the duration and easing tokens, so a
100
+ // page that uses them cannot drift from the components beside it.
101
+ export * as motion from './motion.js';
96
102
  // ── Version ────────────────────────────────────────────────────────────────
97
103
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
98
104
  // this line; the build and the test suite fail when the two disagree.
99
- export const VERSION = '0.24.0';
105
+ export const VERSION = '0.25.0';
@@ -15,6 +15,7 @@
15
15
  * Not exported from the package entry point - this is an implementation detail.
16
16
  */
17
17
  import { FIELD_HINT, FIELD_LABEL } from './field.js';
18
+ import { HIT_AREA } from './touch.js';
18
19
  /**
19
20
  * The box and the icon slot share one size map, so the label text starts at the
20
21
  * same x whether or not an option carries an icon. 3.5 is 14px, 4 is 16px and 5
@@ -25,8 +26,14 @@ const CHOICE_SQUARE = {
25
26
  md: 'h-4 w-4',
26
27
  lg: 'h-5 w-5',
27
28
  };
28
- const WRAP_INLINE = 'inline-flex select-none items-start gap-2.5';
29
- const WRAP_CARD = 'flex select-none items-start gap-2.5 rounded-lg border p-3 transition-colors duration-150';
29
+ /**
30
+ * The inline wrapper carries the hit box, not the box inside it. A click
31
+ * anywhere on the label reaches the input, so growing the label's hit area
32
+ * under a finger is what makes a 16px checkbox a 44px target without drawing
33
+ * it any larger. A card is its own surface and already clears the size.
34
+ */
35
+ const WRAP_INLINE = `${HIT_AREA} inline-flex select-none items-start gap-2.5`;
36
+ const WRAP_CARD = 'flex select-none items-start gap-2.5 rounded-lg border p-3 transition-colors';
30
37
  /**
31
38
  * The clickable wrapper. Card is a bordered option the whole surface of which
32
39
  * is the target.
@@ -55,7 +62,7 @@ export function choiceWrap(variant, checked, disabled) {
55
62
  * instead, and `peer` is what lets the box below read its focus state.
56
63
  */
57
64
  export const CHOICE_INPUT = 'peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed';
58
- const BOX_BASE = 'pointer-events-none flex shrink-0 items-center justify-center transition-colors duration-150';
65
+ const BOX_BASE = 'pointer-events-none flex shrink-0 items-center justify-center transition-colors';
59
66
  /**
60
67
  * The painted box or circle. Square for a checkbox, round for a radio, sized by
61
68
  * ChoiceSize.
@@ -34,12 +34,12 @@ export const FIELD_ERROR = 'text-xs text-danger';
34
34
  * CONTROL_SEGMENT already pair the two correctly; these did half of it.
35
35
  */
36
36
  export const CONTROL_BASE = 'w-full h-control rounded-lg bg-surface-2 border px-3 text-sm text-fg ' +
37
- 'placeholder:text-faint transition-colors duration-150 outline-none ' +
37
+ 'placeholder:text-faint transition-colors outline-none ' +
38
38
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
39
39
  'disabled:opacity-50 disabled:cursor-not-allowed';
40
40
  /** As CONTROL_BASE, for controls whose height is driven by their content. */
41
41
  export const CONTROL_MULTILINE = 'w-full min-h-control rounded-lg bg-surface-2 border px-3 py-2 text-sm text-fg ' +
42
- 'placeholder:text-faint transition-colors duration-150 outline-none ' +
42
+ 'placeholder:text-faint transition-colors outline-none ' +
43
43
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
44
44
  'disabled:opacity-50 disabled:cursor-not-allowed';
45
45
  /**
@@ -88,7 +88,7 @@ export function describedBy(id, error, hint) {
88
88
  * held the caret.
89
89
  */
90
90
  export const CONTROL_SEGMENTED = 'inline-flex h-control items-center gap-0.5 rounded-lg bg-surface-2 border px-3 ' +
91
- 'text-sm text-fg transition-colors duration-150';
91
+ 'text-sm text-fg transition-colors';
92
92
  /** As controlBorder, for a wrapper that is only ever focused through a child. */
93
93
  export function segmentedBorder(error) {
94
94
  return error
@@ -139,7 +139,16 @@ export declare function sectionHeading(level: 2 | 3, variant?: SectionVariant):
139
139
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
140
140
  * locale flips, and a physical border leaves the rule on the wrong edge.
141
141
  */
142
- export declare const APP_SIDEBAR = "h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface";
142
+ export declare const APP_SIDEBAR = "h-full shrink-0 flex-col border-e border-line bg-surface";
143
+ /**
144
+ * The sidebar's two widths. Expanded is the 224px column every authed screen
145
+ * had. The rail is the 56px icon column the theme had named and nothing used:
146
+ * at 768px the expanded column left 496px for the page, and a flow editor with
147
+ * two docked panes had no canvas at all. The shell picks between them; the
148
+ * width is not part of APP_SIDEBAR so the aside cannot carry both.
149
+ */
150
+ export declare const APP_SIDEBAR_WIDE = "w-sidebar";
151
+ export declare const APP_SIDEBAR_RAIL = "w-nav-rail";
143
152
  /**
144
153
  * The header bar. Its gutter is the page gutter, so the section name in the
145
154
  * header sits directly above the page title under it rather than 8px off.
@@ -163,7 +163,16 @@ export function sectionHeading(level, variant = 'default') {
163
163
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
164
164
  * locale flips, and a physical border leaves the rule on the wrong edge.
165
165
  */
166
- export const APP_SIDEBAR = 'h-full w-sidebar shrink-0 flex-col border-e border-line bg-surface';
166
+ export const APP_SIDEBAR = 'h-full shrink-0 flex-col border-e border-line bg-surface';
167
+ /**
168
+ * The sidebar's two widths. Expanded is the 224px column every authed screen
169
+ * had. The rail is the 56px icon column the theme had named and nothing used:
170
+ * at 768px the expanded column left 496px for the page, and a flow editor with
171
+ * two docked panes had no canvas at all. The shell picks between them; the
172
+ * width is not part of APP_SIDEBAR so the aside cannot carry both.
173
+ */
174
+ export const APP_SIDEBAR_WIDE = 'w-sidebar';
175
+ export const APP_SIDEBAR_RAIL = 'w-nav-rail';
167
176
  /**
168
177
  * The header bar. Its gutter is the page gutter, so the section name in the
169
178
  * header sits directly above the page title under it rather than 8px off.
@@ -125,10 +125,19 @@ export declare function panelOption(state: {
125
125
  }): string;
126
126
  /** Marks the scrolling region inside a surface, so placePanel can find it. */
127
127
  export declare const PANEL_LIST_ATTR = "data-panel-list";
128
+ /**
129
+ * The attribute's value when the region is capped by the room alone.
130
+ *
131
+ * The token cap is sized for a list of rows, and a calendar is not one: six
132
+ * weeks of days stand taller than the token, so under it every month would
133
+ * scroll by a few rows. Such a region still takes the room as its cap, so
134
+ * inside a short modal it scrolls rather than being cut.
135
+ */
136
+ export declare const PANEL_LIST_UNCAPPED = "uncapped";
128
137
  /** The classes that put the surface under its anchor. */
129
- export declare const PANEL_BELOW = "top-full mt-1";
138
+ export declare const PANEL_BELOW = "top-full mt-1 origin-top";
130
139
  /** The classes that put the surface over its anchor. */
131
- export declare const PANEL_ABOVE = "bottom-full mb-1";
140
+ export declare const PANEL_ABOVE = "bottom-full mb-1 origin-bottom";
132
141
  /**
133
142
  * Svelte action for a panel surface. Puts it on the side of its anchor that
134
143
  * has room, and stops its list running past whatever clips it.
@@ -145,7 +154,8 @@ export declare const PANEL_ABOVE = "bottom-full mb-1";
145
154
  * under the modal's scroll edge that nobody can reach without scrolling
146
155
  * first. Whichever side wins, the list's max-height is the smaller of the
147
156
  * token cap and the room on that side, written as a CSS min so the token stays
148
- * the cap without this code knowing its value.
157
+ * the cap without this code knowing its value. A region marked uncapped takes
158
+ * the room alone.
149
159
  *
150
160
  * Measured on mount, on resize and on scroll of the clipping ancestor, since
151
161
  * each moves the anchor relative to the edge. Scroll does not bubble, so the
@@ -73,7 +73,7 @@ export const PANEL_LIST = 'max-h-panel-max overflow-y-auto overscroll-contain px
73
73
  * the active ring follow the row's own corners.
74
74
  */
75
75
  export const PANEL_OPTION = 'flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-start text-sm ' +
76
- 'transition-colors duration-150 outline-none hover:bg-surface-2 ' +
76
+ 'transition-colors outline-none hover:bg-surface-2 ' +
77
77
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
78
78
  /**
79
79
  * The active descendant.
@@ -134,10 +134,19 @@ export function panelOption(state) {
134
134
  }
135
135
  /** Marks the scrolling region inside a surface, so placePanel can find it. */
136
136
  export const PANEL_LIST_ATTR = 'data-panel-list';
137
+ /**
138
+ * The attribute's value when the region is capped by the room alone.
139
+ *
140
+ * The token cap is sized for a list of rows, and a calendar is not one: six
141
+ * weeks of days stand taller than the token, so under it every month would
142
+ * scroll by a few rows. Such a region still takes the room as its cap, so
143
+ * inside a short modal it scrolls rather than being cut.
144
+ */
145
+ export const PANEL_LIST_UNCAPPED = 'uncapped';
137
146
  /** The classes that put the surface under its anchor. */
138
- export const PANEL_BELOW = 'top-full mt-1';
147
+ export const PANEL_BELOW = 'top-full mt-1 origin-top';
139
148
  /** The classes that put the surface over its anchor. */
140
- export const PANEL_ABOVE = 'bottom-full mb-1';
149
+ export const PANEL_ABOVE = 'bottom-full mb-1 origin-bottom';
141
150
  /**
142
151
  * Breathing room between the panel and the edge it is measured against, so a
143
152
  * panel that fits exactly does not sit on the edge of the window or the modal.
@@ -183,7 +192,8 @@ function swap(node, remove, add) {
183
192
  * under the modal's scroll edge that nobody can reach without scrolling
184
193
  * first. Whichever side wins, the list's max-height is the smaller of the
185
194
  * token cap and the room on that side, written as a CSS min so the token stays
186
- * the cap without this code knowing its value.
195
+ * the cap without this code knowing its value. A region marked uncapped takes
196
+ * the room alone.
187
197
  *
188
198
  * Measured on mount, on resize and on scroll of the clipping ancestor, since
189
199
  * each moves the anchor relative to the edge. Scroll does not bubble, so the
@@ -221,7 +231,8 @@ export function placePanel(node) {
221
231
  // taken out of the room so the whole panel fits and not only its list.
222
232
  const chrome = Math.max(0, node.offsetHeight - list.offsetHeight);
223
233
  const room = Math.max(0, Math.round((flip ? above : below) - chrome));
224
- list.style.maxHeight = `min(var(--spacing-panel-max), ${room}px)`;
234
+ const capped = list.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
235
+ list.style.maxHeight = capped ? `min(var(--spacing-panel-max), ${room}px)` : `${room}px`;
225
236
  }
226
237
  measure();
227
238
  const scroller = clip ?? window;