@urbicon-ui/blocks 6.23.0 → 6.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 (68) hide show
  1. package/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.js +5 -3
  4. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  5. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  7. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  8. package/dist/components/CommandPalette/index.d.ts +19 -0
  9. package/dist/components/CompositionBar/CompositionBar.svelte +1 -1
  10. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  11. package/dist/components/Planner/planner.variants.js +1 -1
  12. package/dist/components/Sankey/sankey.variants.js +1 -1
  13. package/dist/components/Sparkline/Sparkline.svelte +10 -4
  14. package/dist/components/Sparkline/index.d.ts +9 -1
  15. package/dist/internal/charts/variants.js +2 -2
  16. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  17. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  18. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  19. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  20. package/dist/primitives/Avatar/index.d.ts +1 -1
  21. package/dist/primitives/Badge/Badge.svelte +8 -2
  22. package/dist/primitives/Badge/index.d.ts +67 -32
  23. package/dist/primitives/Button/Button.svelte +12 -25
  24. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  25. package/dist/primitives/Checkbox/Checkbox.svelte +1 -1
  26. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  27. package/dist/primitives/Collapsible/index.d.ts +13 -2
  28. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  29. package/dist/primitives/ConfirmDialog/index.d.ts +15 -3
  30. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  31. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  32. package/dist/primitives/Input/Input.svelte +7 -1
  33. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  34. package/dist/primitives/Menu/Menu.svelte +8 -0
  35. package/dist/primitives/Menu/index.d.ts +11 -13
  36. package/dist/primitives/Pagination/Pagination.svelte +28 -15
  37. package/dist/primitives/Popover/Popover.svelte +107 -4
  38. package/dist/primitives/Popover/index.d.ts +15 -2
  39. package/dist/primitives/Popover/index.js +1 -1
  40. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  41. package/dist/primitives/Popover/popover.variants.js +35 -0
  42. package/dist/primitives/Progress/progress.variants.js +1 -1
  43. package/dist/primitives/RadioGroup/RadioGroup.svelte +6 -2
  44. package/dist/primitives/RadioGroup/RadioItem.svelte +1 -1
  45. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  46. package/dist/primitives/Select/index.d.ts +8 -1
  47. package/dist/primitives/Slider/Slider.svelte +32 -3
  48. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  49. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  50. package/dist/primitives/Toast/Toaster.svelte +81 -7
  51. package/dist/primitives/Toggle/Toggle.svelte +6 -2
  52. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  53. package/dist/style/foundation.css +30 -3
  54. package/dist/style/interaction.css +19 -2
  55. package/dist/style/semantic.css +72 -1
  56. package/dist/style/themes/neutral.css +10 -1
  57. package/dist/style/themes/ocean.css +5 -1
  58. package/dist/utils/compose-handlers.d.ts +72 -0
  59. package/dist/utils/compose-handlers.js +65 -0
  60. package/dist/utils/figma-token-export.js +1 -1
  61. package/dist/utils/guide.svelte.js +92 -29
  62. package/dist/utils/index.d.ts +1 -1
  63. package/dist/utils/index.js +1 -1
  64. package/dist/utils/overlay-tokens.d.ts +17 -0
  65. package/dist/utils/overlay-tokens.js +30 -0
  66. package/dist/utils/use-floating-panel.svelte.js +9 -3
  67. package/dist/utils/variants.js +5 -1
  68. package/package.json +3 -3
@@ -139,9 +139,10 @@
139
139
  return () => handlePageChange(page);
140
140
  }
141
141
 
142
- // Computed state helpers
143
- const isFirstPage = $derived(currentPage === 1);
144
- const isLastPage = $derived(currentPage === totalPages);
142
+ // Computed state helpers. Edge policy across all layouts is disabled-but-visible:
143
+ // Previous/Next stay mounted and go `disabled` at page 1 / N (never unmounted),
144
+ // so the arrow can't vanish from under the pointer (no layout shift, no focus
145
+ // loss). First/Last are a separate, redundancy-driven concern — see the markup.
145
146
  const hasPreviousPage = $derived(currentPage > 1);
146
147
  const hasNextPage = $derived(currentPage < totalPages);
147
148
 
@@ -220,19 +221,25 @@
220
221
  {/if}
221
222
  </div>
222
223
  {:else if layout === 'navigation'}
223
- <!-- Navigation Layout: Only Previous/Next -->
224
+ <!-- Navigation Layout: Only Previous/Next. Unified edge policy (matches the
225
+ table layout): both arrows stay mounted and go `disabled` at the boundary
226
+ (page 1 / N) rather than unmounting. This row is laid out `justify-between`,
227
+ so unmounting one arm would teleport the survivor across the whole bar
228
+ (start ↔ end) and drop focus — disabled-but-visible pins Previous left and
229
+ Next right. Prev/Next-only pagers therefore grey out the dead end here;
230
+ they do not disappear it. -->
224
231
  <div
225
232
  class={unstyled
226
233
  ? (slotClasses?.controls ?? '')
227
234
  : styles.controls({ class: slotClasses?.controls })}
228
235
  >
229
- {#if showPreviousNext && hasPreviousPage}
236
+ {#if showPreviousNext}
230
237
  <PaginationItem
231
238
  {size}
232
239
  {variant}
233
240
  {intent}
234
241
  {tier}
235
- disabled={disabled || loading}
242
+ disabled={disabled || loading || !hasPreviousPage}
236
243
  onPageClick={goToPrevious}
237
244
  {mint}
238
245
  >
@@ -242,15 +249,13 @@
242
249
  {previousLabel}
243
250
  {/if}
244
251
  </PaginationItem>
245
- {/if}
246
252
 
247
- {#if showPreviousNext && hasNextPage}
248
253
  <PaginationItem
249
254
  {size}
250
255
  {variant}
251
256
  {intent}
252
257
  {tier}
253
- disabled={disabled || loading}
258
+ disabled={disabled || loading || !hasNextPage}
254
259
  onPageClick={goToNext}
255
260
  {mint}
256
261
  >
@@ -276,7 +281,14 @@
276
281
  ? (slotClasses?.controls ?? '')
277
282
  : styles.controls({ class: slotClasses?.controls })}
278
283
  >
279
- {#if showFirstLast && !isFirstPage && showStartEllipsis}
284
+ <!-- First / Last are gated by the ellipsis — by REDUNDANCY, not by the page
285
+ edge. `showStartEllipsis` is true only when page 1 sits OUTSIDE the visible
286
+ window; the moment it re-enters, page 1 is a directly-clickable number and
287
+ a "First" jump button would merely duplicate it. That predicate already
288
+ implies currentPage > 1, so (unlike Previous/Next) First/Last are never a
289
+ dead-end control needing disabled-but-visible: hiding one drops a duplicate,
290
+ not an edge stepper — no layout-shift/focus trap. -->
291
+ {#if showFirstLast && showStartEllipsis}
280
292
  <PaginationItem
281
293
  {size}
282
294
  {variant}
@@ -294,13 +306,13 @@
294
306
  </PaginationItem>
295
307
  {/if}
296
308
 
297
- {#if showPreviousNext && hasPreviousPage}
309
+ {#if showPreviousNext}
298
310
  <PaginationItem
299
311
  {size}
300
312
  {variant}
301
313
  {intent}
302
314
  {tier}
303
- disabled={disabled || loading}
315
+ disabled={disabled || loading || !hasPreviousPage}
304
316
  onPageClick={goToPrevious}
305
317
  {mint}
306
318
  >
@@ -364,13 +376,13 @@
364
376
  </div>
365
377
  {/if}
366
378
 
367
- {#if showPreviousNext && hasNextPage}
379
+ {#if showPreviousNext}
368
380
  <PaginationItem
369
381
  {size}
370
382
  {variant}
371
383
  {intent}
372
384
  {tier}
373
- disabled={disabled || loading}
385
+ disabled={disabled || loading || !hasNextPage}
374
386
  onPageClick={goToNext}
375
387
  {mint}
376
388
  >
@@ -382,7 +394,8 @@
382
394
  </PaginationItem>
383
395
  {/if}
384
396
 
385
- {#if showFirstLast && !isLastPage && showEndEllipsis}
397
+ <!-- See the First-button note above: ellipsis-gated (redundancy), not edge-gated. -->
398
+ {#if showFirstLast && showEndEllipsis}
386
399
  <PaginationItem
387
400
  {size}
388
401
  {variant}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
3
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
- import { useFloatingPanel, floatingPanelHidden } from '../../utils';
4
+ import { useFloatingPanel, floatingPanelHidden, maxTransitionDurationMs } from '../../utils';
5
5
  import { popoverVariants } from './popover.variants';
6
6
  import type { PopoverProps } from './index';
7
7
 
@@ -21,6 +21,9 @@
21
21
  autoTrigger = true,
22
22
  size = 'md',
23
23
 
24
+ transitionDuration,
25
+ transitionEasing,
26
+
24
27
  onOpenChange,
25
28
  onClickOutside: onClickOutsideProp,
26
29
  onEscape: onEscapeProp,
@@ -47,11 +50,64 @@
47
50
  resolveSlotClasses(blocksConfig, 'Popover', preset, { size }, slotClassesProp)
48
51
  );
49
52
 
53
+ // Per-instance motion overrides. Set the shared popover CSS variables inline
54
+ // only when a prop is provided, so the unset default keeps inheriting the
55
+ // reduced-motion-aware token (mirrors Tooltip's transitionDuration path).
56
+ const popoverDurationInline = $derived(
57
+ transitionDuration != null ? `${transitionDuration}ms` : undefined
58
+ );
59
+
50
60
  let internalTriggerElement = $state<HTMLElement | null>(null);
51
61
  let popoverElement = $state<HTMLElement | null>(null);
52
62
 
53
63
  const effectiveTriggerElement = $derived(triggerElement || internalTriggerElement);
54
64
 
65
+ // ── Exit-motion lag (ACC-3 rest) ───────────────────────────
66
+ //
67
+ // Closing used to tear three things down in one flush: `open` flips, the
68
+ // children block unmounts, and `useFloatingPanel` hides the panel. The CSS
69
+ // exit transition (see `popoverMotion` in popover.variants.ts) keeps the
70
+ // *panel element* painted via `allow-discrete`, but the children must
71
+ // outlive `open` or the panel fades out empty. `exiting` lags the teardown
72
+ // by the panel's actual computed transition duration — read from the live
73
+ // style so per-instance props, theme token overrides, and reduced motion all
74
+ // shorten it automatically. A zero duration (jsdom/node tests, browsers
75
+ // without the CSS, `unstyled` without rebuilt motion) tears down
76
+ // synchronously: exactly the pre-motion behaviour.
77
+ //
78
+ // `$effect.pre` matters: the flag must be `true` in the SAME flush that
79
+ // renders `open === false`, otherwise the children unmount one frame before
80
+ // the lag starts. The `prevOpenForExit` tracker (non-reactive, seeded via
81
+ // `untrack` like `prevPopoverMode` below) keeps the effect keyed on real
82
+ // transitions — without it, the `bind:this` assignment would re-run the
83
+ // effect on mount and flash a closed-by-default popover open for one lag.
84
+ const EXIT_MOTION_BUFFER_MS = 50;
85
+ let exiting = $state(false);
86
+ let exitTimer: ReturnType<typeof setTimeout> | undefined;
87
+ let prevOpenForExit = untrack(() => open);
88
+ $effect.pre(() => {
89
+ if (open === prevOpenForExit) return;
90
+ prevOpenForExit = open;
91
+ if (open) {
92
+ clearTimeout(exitTimer);
93
+ exiting = false;
94
+ return;
95
+ }
96
+ const ms = maxTransitionDurationMs(untrack(() => popoverElement));
97
+ if (ms <= 0) return;
98
+ exiting = true;
99
+ exitTimer = setTimeout(() => {
100
+ exiting = false;
101
+ }, ms + EXIT_MOTION_BUFFER_MS);
102
+ });
103
+
104
+ // While `exiting`, the panel is still fading: keep children mounted and (in
105
+ // the in-place modes) keep `display` un-hidden. `useFloatingPanel` stays on
106
+ // the raw `open` on purpose — `hidePopover()` fires immediately and the
107
+ // discrete transition carries the visual exit, so a re-open during the fade
108
+ // reverses smoothly instead of waiting out the lag.
109
+ const panelVisible = $derived(open || exiting);
110
+
55
111
  // ── Floating UI positioning + native show/hide ─────────────
56
112
  //
57
113
  // Delegated to the shared `useFloatingPanel` helper — the same positioning
@@ -291,6 +347,27 @@
291
347
  target?.focus();
292
348
  }
293
349
 
350
+ // Un-arm `dismissedByTrigger` at the start of the NEXT pointer gesture,
351
+ // wherever it lands. Registered as a one-shot capture listener when the
352
+ // guard arms: capture order (document before trigger) guarantees a stale
353
+ // flag is cleared before any new arm, and `once` keeps at most one listener
354
+ // pending. This closes the aborted-click hole (pointerdown on the trigger,
355
+ // pointer released elsewhere → no click ever consumed the flag → the next
356
+ // trigger click was swallowed once).
357
+ function disarmDismissedByTrigger() {
358
+ dismissedByTrigger = false;
359
+ }
360
+
361
+ // Unmount cleanup for the imperative leftovers: a pending exit-lag timer
362
+ // and a still-armed disarm listener. Dependency-free → runs once,
363
+ // teardown on destroy.
364
+ $effect(() => {
365
+ return () => {
366
+ clearTimeout(exitTimer);
367
+ document.removeEventListener('pointerdown', disarmDismissedByTrigger, true);
368
+ };
369
+ });
370
+
294
371
  function handleTriggerPointerDown() {
295
372
  // Arm the "this pointerdown already dismissed it" guard only in auto
296
373
  // mode, where the browser's light dismiss really closes the popover
@@ -298,7 +375,15 @@
298
375
  // re-opening it). In manual mode nothing light-dismisses — the click
299
376
  // itself must toggle-close, so arming the guard there left the trigger
300
377
  // unable to close its own popover.
301
- if (open && popoverMode === 'auto') dismissedByTrigger = true;
378
+ if (open && popoverMode === 'auto') {
379
+ dismissedByTrigger = true;
380
+ // This event has already passed the document's capture phase, so the
381
+ // listener can only fire on a LATER pointerdown.
382
+ document.addEventListener('pointerdown', disarmDismissedByTrigger, {
383
+ capture: true,
384
+ once: true
385
+ });
386
+ }
302
387
  }
303
388
 
304
389
  function handleTriggerClick(event: MouseEvent) {
@@ -361,6 +446,20 @@
361
446
  drops over a modal dialog (Codeberg #23).
362
447
  -->
363
448
 
449
+ <!--
450
+ `data-state` sits after `{...restProps}` like the other load-bearing
451
+ attributes: it drives the enter/exit motion CSS (popoverMotion) and is the
452
+ documented styling hook for consumers rebuilding motion under `unstyled`.
453
+ `{#if panelVisible}` (not `open`) keeps the children mounted while the exit
454
+ transition plays; `style:display` follows the same lagged flag so the
455
+ in-place modes can fade out before they hide.
456
+
457
+ `inert` while closed (GuidePanel's pattern): the exit-fading children are
458
+ still mounted and displayed, so without it a Tab right after dismiss would
459
+ focus into the visually dismissed panel and Enter could re-fire a consumer
460
+ action; pointer-events-none (popoverMotion) only covers mouse/touch. Also
461
+ drops the fading subtree from the a11y tree immediately.
462
+ -->
364
463
  <div
365
464
  bind:this={popoverElement}
366
465
  class={popoverClasses}
@@ -370,12 +469,16 @@
370
469
  style:position={panel.strategy}
371
470
  style:inset="auto"
372
471
  style:margin="0"
373
- style:display={floatingPanelHidden(panel, open) ? 'none' : null}
472
+ style:display={floatingPanelHidden(panel, panelVisible) ? 'none' : null}
473
+ style:--blocks-popover-duration={popoverDurationInline}
474
+ style:--blocks-popover-easing={transitionEasing}
475
+ data-state={open ? 'open' : 'closed'}
476
+ inert={!open || undefined}
374
477
  {role}
375
478
  aria-modal={ariaModal || undefined}
376
479
  {id}
377
480
  >
378
- {#if open}
481
+ {#if panelVisible}
379
482
  {@render children()}
380
483
  {/if}
381
484
  </div>
@@ -70,6 +70,19 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
70
70
  open?: boolean;
71
71
  /** When true (default), the trigger wrapper handles click and keyboard to toggle the popover. Set to `false` to manage `open` yourself. */
72
72
  autoTrigger?: boolean;
73
+ /**
74
+ * Override the enter/exit fade duration in ms. Defaults to the shared token
75
+ * `--blocks-popover-duration` (150ms; collapses to 1ms under
76
+ * `prefers-reduced-motion`).
77
+ */
78
+ transitionDuration?: number;
79
+ /**
80
+ * Override the enter/exit easing as a CSS `<easing-function>` (e.g.
81
+ * `'linear'`, `'ease-out'`, `'cubic-bezier(0.4,0,0.2,1)'`). Defaults to the
82
+ * token `--blocks-popover-easing`. A CSS string, not a JS easing fn, because
83
+ * the popover motion is a pure CSS transition.
84
+ */
85
+ transitionEasing?: string;
73
86
  /**
74
87
  * Whether the popover closes on Escape key. Default `true`.
75
88
  * Set to `false` for cases where Escape should be intercepted by an
@@ -98,7 +111,7 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
98
111
  onEscape?: () => void;
99
112
  /** Extra classes merged onto the floating panel element. */
100
113
  class?: string;
101
- /** Strip all default tv() classes. Combine with `class` or `slotClasses` for full custom styling. */
114
+ /** Strip all default tv() classes (including the enter/exit motion). Combine with `class` or `slotClasses` for full custom styling; the panel always carries `data-state="open" | "closed"`, so custom motion can rebuild on that hook (see `popoverMotion` in popover.variants.ts for the reference implementation). */
102
115
  unstyled?: boolean;
103
116
  /** Per-slot class overrides. Available slots: `base` (the floating panel). */
104
117
  slotClasses?: Partial<Record<'base', string>>;
@@ -111,4 +124,4 @@ export interface PopoverProps extends PopoverVariants, Omit<HTMLAttributes<HTMLD
111
124
  preset?: string;
112
125
  }
113
126
  export { default as Popover } from './Popover.svelte';
114
- export { type PopoverVariants, popoverVariants } from './popover.variants.js';
127
+ export { type PopoverVariants, popoverMotion, popoverVariants } from './popover.variants.js';
@@ -1,2 +1,2 @@
1
1
  export { default as Popover } from './Popover.svelte';
2
- export { popoverVariants } from './popover.variants.js';
2
+ export { popoverMotion, popoverVariants } from './popover.variants.js';
@@ -1,4 +1,29 @@
1
1
  import { type VariantProps } from '../../utils/variants.js';
2
+ /**
3
+ * CSS-native enter/exit motion for the floating panel (ACC-3 rest), keyed on
4
+ * the `data-state` attribute Popover always stamps. Kept as its own fragment
5
+ * because two call sites need exactly these classes: `popoverVariants.base`
6
+ * below, and Menu — which renders its inner Popover `unstyled` (to avoid a
7
+ * double surface) and re-applies the fragment via Popover's `class` prop.
8
+ *
9
+ * How the two halves work:
10
+ * - **Enter** — the panel un-hides via `showPopover()` (top layer) or a
11
+ * `display` flip (in-place mode), so a plain transition has no before-state;
12
+ * `starting:` (`@starting-style`) supplies it.
13
+ * - **Exit** — `hidePopover()` / a native light dismiss yank the panel to
14
+ * `display: none` in the same style recalc that flips `data-state`;
15
+ * `transition-discrete` (`transition-behavior: allow-discrete`) on
16
+ * `display`/`overlay` keeps it painted (and in the top layer) until the
17
+ * fade lands. Popover lags the children-teardown to match — see the
18
+ * exit-motion block in Popover.svelte.
19
+ *
20
+ * Browsers without `@starting-style`/`allow-discrete` simply skip the motion
21
+ * and keep today's instant toggle. Duration/easing resolve through the
22
+ * `--blocks-popover-*` tokens (interaction.css), which reduced motion
23
+ * collapses to 1ms; `motion-reduce:duration-[1ms]` guards the inline
24
+ * per-instance override path, which can't see the media query.
25
+ */
26
+ export declare const popoverMotion: string;
2
27
  export declare const popoverVariants: ((props?: import("../../utils/variants.js").TVProps<{
3
28
  size: {
4
29
  sm: string;
@@ -1,9 +1,44 @@
1
1
  import { tv } from '../../utils/variants.js';
2
+ /**
3
+ * CSS-native enter/exit motion for the floating panel (ACC-3 rest), keyed on
4
+ * the `data-state` attribute Popover always stamps. Kept as its own fragment
5
+ * because two call sites need exactly these classes: `popoverVariants.base`
6
+ * below, and Menu — which renders its inner Popover `unstyled` (to avoid a
7
+ * double surface) and re-applies the fragment via Popover's `class` prop.
8
+ *
9
+ * How the two halves work:
10
+ * - **Enter** — the panel un-hides via `showPopover()` (top layer) or a
11
+ * `display` flip (in-place mode), so a plain transition has no before-state;
12
+ * `starting:` (`@starting-style`) supplies it.
13
+ * - **Exit** — `hidePopover()` / a native light dismiss yank the panel to
14
+ * `display: none` in the same style recalc that flips `data-state`;
15
+ * `transition-discrete` (`transition-behavior: allow-discrete`) on
16
+ * `display`/`overlay` keeps it painted (and in the top layer) until the
17
+ * fade lands. Popover lags the children-teardown to match — see the
18
+ * exit-motion block in Popover.svelte.
19
+ *
20
+ * Browsers without `@starting-style`/`allow-discrete` simply skip the motion
21
+ * and keep today's instant toggle. Duration/easing resolve through the
22
+ * `--blocks-popover-*` tokens (interaction.css), which reduced motion
23
+ * collapses to 1ms; `motion-reduce:duration-[1ms]` guards the inline
24
+ * per-instance override path, which can't see the media query.
25
+ */
26
+ export const popoverMotion = [
27
+ 'transition-[opacity,scale,display,overlay] transition-discrete',
28
+ 'duration-[var(--blocks-popover-duration)] ease-[var(--blocks-popover-easing)]',
29
+ 'motion-reduce:duration-[1ms]',
30
+ // pointer-events-none while closed: during the exit fade the children are
31
+ // still mounted and painted — without this, a quick click on the fading
32
+ // panel would fire consumer handlers from a visually dismissed surface.
33
+ 'data-[state=closed]:opacity-0 data-[state=closed]:scale-[0.98] data-[state=closed]:pointer-events-none',
34
+ 'starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-[0.98]'
35
+ ].join(' ');
2
36
  export const popoverVariants = tv({
3
37
  // tier: contain — floating panel surface.
4
38
  base: [
5
39
  'bg-surface-elevated border border-border-hairline rounded-contain',
6
40
  'shadow-[var(--blocks-shadow-md)] backdrop-blur-sm',
41
+ popoverMotion,
7
42
  // `calc(100dvh-4rem)` is the static design cap; Floating UI's `size`
8
43
  // middleware (via useFloatingPanel) narrows it to the room actually left
9
44
  // between the anchor and the visual viewport edge through
@@ -35,7 +35,7 @@ export const progressVariants = tv({
35
35
  xs: {
36
36
  track: 'h-1',
37
37
  fill: 'h-1',
38
- circularLabel: 'text-[10px]'
38
+ circularLabel: 'text-3xs'
39
39
  },
40
40
  sm: {
41
41
  track: 'h-1.5',
@@ -27,6 +27,7 @@
27
27
  preset,
28
28
  id,
29
29
  'aria-describedby': ariaDescribedby,
30
+ 'aria-labelledby': ariaLabelledby,
30
31
  ...restProps
31
32
  }: RadioGroupProps = $props();
32
33
 
@@ -43,6 +44,9 @@
43
44
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
44
45
 
45
46
  const groupId = $derived(id || `radiogroup-${propsId}`);
47
+ // When a `label` is rendered it owns the group's `aria-labelledby`; otherwise a
48
+ // consumer-supplied `aria-labelledby` (an external heading) is used as the
49
+ // fallback so external labelling survives (see the group element below).
46
50
  const labelId = $derived(label ? `${groupId}-label` : undefined);
47
51
  // ARIA wiring is shared with every form primitive — see XC-2.
48
52
  const ff = useFormField(() => ({
@@ -159,16 +163,16 @@
159
163
  {/if}
160
164
 
161
165
  <div
166
+ {...restProps}
162
167
  bind:this={groupElement}
163
168
  role="radiogroup"
164
169
  id={groupId}
165
170
  class={unstyled ? (slotClasses?.group ?? '') : styles.group({ class: slotClasses?.group })}
166
- aria-labelledby={labelId}
171
+ aria-labelledby={labelId ?? ariaLabelledby}
167
172
  aria-describedby={describedBy}
168
173
  aria-required={required || undefined}
169
174
  aria-invalid={ff.invalid ? 'true' : undefined}
170
175
  onkeydown={handleKeydown}
171
- {...restProps}
172
176
  >
173
177
  {@render children()}
174
178
  </div>
@@ -71,6 +71,7 @@
71
71
  for={id}
72
72
  >
73
73
  <input
74
+ {...restProps}
74
75
  {id}
75
76
  type="radio"
76
77
  name={ctx.name}
@@ -80,7 +81,6 @@
80
81
  class="peer sr-only"
81
82
  tabindex={isChecked || (!ctx.value && !isDisabled) ? 0 : -1}
82
83
  onchange={handleChange}
83
- {...restProps}
84
84
  />
85
85
 
86
86
  <span
@@ -66,7 +66,7 @@ export const radioItemVariants = tv({
66
66
  indicator: 'w-3.5 h-3.5 mt-px',
67
67
  dot: 'w-1.5 h-1.5',
68
68
  label: 'text-xs',
69
- description: 'text-[10px]'
69
+ description: 'text-3xs'
70
70
  },
71
71
  sm: {
72
72
  item: 'gap-2',
@@ -256,7 +256,14 @@ interface SelectBaseProps<T extends SelectValue = string> extends Omit<SelectVar
256
256
  * and make the custom look reusable across the project.
257
257
  */
258
258
  preset?: string;
259
- /** Explicit `id` for the component. Auto-generated if omitted. */
259
+ /**
260
+ * Explicit `id` for the component. Auto-generated if omitted.
261
+ *
262
+ * The id lands on the component's wrapper, which is not a labelable element.
263
+ * The focusable trigger is derived from it as `` `${id}-trigger` `` — that is
264
+ * the id an external `<label for>` has to address, since a `for` pointing at
265
+ * the wrapper focuses nothing.
266
+ */
260
267
  id?: string;
261
268
  }
262
269
  /**
@@ -35,6 +35,7 @@
35
35
  slotClasses: slotClassesProp = {},
36
36
  preset,
37
37
  'aria-describedby': ariaDescribedby,
38
+ 'aria-labelledby': ariaLabelledby,
38
39
  ...restProps
39
40
  }: SliderProps = $props();
40
41
 
@@ -53,6 +54,8 @@
53
54
  disabled
54
55
  }));
55
56
  const statusId = `${uid}-status`;
57
+ const minLabelId = `${uid}-min-label`;
58
+ const maxLabelId = `${uid}-max-label`;
56
59
 
57
60
  let trackRef = $state<HTMLDivElement>();
58
61
  let dragging = $state<'single' | 'start' | 'end' | null>(null);
@@ -251,6 +254,17 @@
251
254
  .join(' ') || undefined
252
255
  );
253
256
 
257
+ // `aria-labelledby` has the same reach problem as `aria-describedby` above:
258
+ // restProps land on the roleless wrapper div, so an external visible label
259
+ // would never name the focusable thumbs. On the range arm one external label
260
+ // would name both thumbs identically ("Padding" / "Padding"), so it is
261
+ // composed with a visually-hidden minimum/maximum qualifier —
262
+ // aria-labelledby concatenates its references in order, yielding
263
+ // "Padding Minimum" / "Padding Maximum" and preserving the distinction the
264
+ // `label`-prop arm gets for free.
265
+ const startLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${minLabelId}` : undefined);
266
+ const endLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${maxLabelId}` : undefined);
267
+
254
268
  $effect(() => {
255
269
  if (trackRef && mint && mint !== 'none' && !disabled) {
256
270
  return mintRegistry.apply(trackRef, mint);
@@ -429,13 +443,22 @@
429
443
  {/if}
430
444
 
431
445
  {#if range}
446
+ {#if ariaLabelledby}
447
+ <span id={minLabelId} class="sr-only">{bt('accessibility.minimum') || 'Minimum'}</span>
448
+ <span id={maxLabelId} class="sr-only">{bt('accessibility.maximum') || 'Maximum'}</span>
449
+ {/if}
432
450
  <div
433
451
  role="slider"
434
452
  tabindex={disabled ? -1 : 0}
435
453
  aria-valuemin={min}
436
454
  aria-valuemax={rangeValue[1]}
437
455
  aria-valuenow={rangeValue[0]}
438
- aria-label={label ? `${label} minimum` : bt('accessibility.minimum') || 'Minimum'}
456
+ aria-labelledby={startLabelledBy}
457
+ aria-label={startLabelledBy
458
+ ? undefined
459
+ : label
460
+ ? `${label} minimum`
461
+ : bt('accessibility.minimum') || 'Minimum'}
439
462
  aria-describedby={describedBy}
440
463
  aria-disabled={disabled || undefined}
441
464
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -449,7 +472,12 @@
449
472
  aria-valuemin={rangeValue[0]}
450
473
  aria-valuemax={max}
451
474
  aria-valuenow={rangeValue[1]}
452
- aria-label={label ? `${label} maximum` : bt('accessibility.maximum') || 'Maximum'}
475
+ aria-labelledby={endLabelledBy}
476
+ aria-label={endLabelledBy
477
+ ? undefined
478
+ : label
479
+ ? `${label} maximum`
480
+ : bt('accessibility.maximum') || 'Maximum'}
453
481
  aria-describedby={describedBy}
454
482
  aria-disabled={disabled || undefined}
455
483
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -464,7 +492,8 @@
464
492
  aria-valuemin={min}
465
493
  aria-valuemax={max}
466
494
  aria-valuenow={singleValue}
467
- aria-label={label || bt('accessibility.slider') || 'Slider'}
495
+ aria-labelledby={ariaLabelledby}
496
+ aria-label={ariaLabelledby ? undefined : label || bt('accessibility.slider') || 'Slider'}
468
497
  aria-describedby={describedBy}
469
498
  aria-disabled={disabled || undefined}
470
499
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -68,7 +68,7 @@ export const stepperVariants = tv({
68
68
  indicator: 'size-7 text-xs',
69
69
  step: 'gap-2',
70
70
  label: 'text-xs',
71
- description: 'text-[11px]',
71
+ description: 'text-2xs',
72
72
  content: 'text-sm',
73
73
  indicatorColumn: 'w-7'
74
74
  },
@@ -29,6 +29,7 @@
29
29
  slotClasses: slotClassesProp = {},
30
30
  preset,
31
31
  oninput: userOnInput,
32
+ id: idProp,
32
33
  'aria-describedby': ariaDescribedby,
33
34
  ...restProps
34
35
  }: TextareaProps = $props();
@@ -42,9 +43,12 @@
42
43
  let textareaRef = $state<HTMLTextAreaElement>();
43
44
 
44
45
  // ARIA wiring is shared with every form primitive — see XC-2.
46
+ // A consumer-supplied `id` wins over the generated one, so an external
47
+ // `<label for>` can address the `<textarea>` (mirrors the Input role model).
45
48
  const propsId = $props.id();
49
+ const fieldId = $derived(idProp ?? `textarea-${propsId}`);
46
50
  const ff = useFormField(() => ({
47
- fieldId: `textarea-${propsId}`,
51
+ fieldId,
48
52
  helper,
49
53
  error,
50
54
  required,