@urbicon-ui/blocks 6.21.3 → 6.23.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.
@@ -441,6 +441,14 @@
441
441
  onMonthChange?.(clamped.month, clamped.year);
442
442
  }
443
443
 
444
+ // Jump the reference date to a concrete day (mini-calendar drill-down into
445
+ // week/day view). Delegates to the controller, which clamps to
446
+ // [minDate, maxDate] and reports back through handleNavigate like every
447
+ // other controller-driven navigation path.
448
+ function goToDate(date: Date) {
449
+ controller.goTo(date);
450
+ }
451
+
444
452
  function setView(v: CalendarViewMode) {
445
453
  view = v;
446
454
  onViewChange?.(v);
@@ -572,6 +580,7 @@
572
580
  navigateYear,
573
581
  goToToday,
574
582
  goToMonth,
583
+ goToDate,
575
584
  selectDate,
576
585
  setFocusedDate,
577
586
  moveFocus,
@@ -52,13 +52,14 @@
52
52
  function handleDayClick(date: Date) {
53
53
  // Navigate main calendar to this date and select it
54
54
  ctx.selectDate(date);
55
- // Also sync view to the clicked day's month
56
- if (date.getMonth() !== ctx.displayedMonth || date.getFullYear() !== ctx.displayedYear) {
57
- ctx.goToMonth(date.getMonth(), date.getFullYear());
58
- }
59
- // If in week or day view, update displayed date
60
55
  if (ctx.view === 'week' || ctx.view === 'day') {
61
- ctx.navigateDay(0); // triggers a re-render with the newly selected date
56
+ // selectDate only updates the selection it never moves the reference
57
+ // date, so jump the week/day grid to the clicked day explicitly.
58
+ ctx.goToDate(date);
59
+ } else if (date.getMonth() !== ctx.displayedMonth || date.getFullYear() !== ctx.displayedYear) {
60
+ // Month-based views (month handled its own spill-jump in selectDate;
61
+ // agenda lands here) only need to sync to the clicked day's month.
62
+ ctx.goToMonth(date.getMonth(), date.getFullYear());
62
63
  }
63
64
  }
64
65
 
@@ -140,9 +141,10 @@
140
141
  </Button>
141
142
  </div>
142
143
 
143
- <!-- Weekday headers -->
144
+ <!-- Weekday headers — narrow names duplicate in many locales (de-DE: M, D,
145
+ M, D, F, S, S), so the key needs the column position to stay unique. -->
144
146
  <div class="grid grid-cols-7">
145
- {#each weekdayNames as name (name)}
147
+ {#each weekdayNames as name, i (`${i}-${name}`)}
146
148
  <span class={slot('miniCalendarWeekday')}>{name}</span>
147
149
  {/each}
148
150
  </div>
@@ -17,7 +17,10 @@
17
17
  <span class={slot('weekNumber')} role="columnheader" aria-label={bt('calendar.weekNumber')}
18
18
  ></span>
19
19
  {/if}
20
- {#each ctx.weekdays as day (day)}
20
+ <!-- Short/narrow weekday names duplicate in many locales (de-DE narrow:
21
+ M, D, M, D, F, S, S), so the key needs the column position to stay
22
+ unique — a bare name key crashes dev-mode client renders. -->
23
+ {#each ctx.weekdays as day, i (`${i}-${day}`)}
21
24
  <span class={slot('weekday')} role="columnheader" aria-label={day}>
22
25
  {day}
23
26
  </span>
@@ -51,6 +51,9 @@ export interface CalendarContext {
51
51
  setView: (view: CalendarViewMode) => void;
52
52
  /** Navigate to a specific month (used by year grid drill-down). */
53
53
  goToMonth: (month: number, year: number) => void;
54
+ /** Jump the reference date to a specific day, clamped to [minDate, maxDate]
55
+ * (mini-calendar drill-down into week/day view). */
56
+ goToDate: (date: Date) => void;
54
57
  isDateDisabled: (date: Date) => boolean;
55
58
  isDateSelected: (date: Date) => boolean;
56
59
  isDateToday: (date: Date) => boolean;
@@ -194,11 +194,8 @@ export function resizableEvent(opts) {
194
194
  return;
195
195
  isResizing = false;
196
196
  handle.releasePointerCapture(e.pointerId);
197
- handle.removeEventListener('pointermove', handlePointerMove);
198
- handle.removeEventListener('pointerup', handlePointerUp);
199
- handle.removeEventListener('pointercancel', handlePointerUp);
200
- document.body.style.cursor = '';
201
- // Calculate new end time based on final position
197
+ // Calculate new end time based on final position — measured BEFORE
198
+ // cleanup(), which resets the inline height the calculation reads.
202
199
  const gridRect = opts.gridEl.getBoundingClientRect();
203
200
  const eventRect = opts.eventEl.getBoundingClientRect();
204
201
  const gridTotalMinutes = (opts.endHour - opts.startHour) * 60;
@@ -219,14 +216,29 @@ export function resizableEvent(opts) {
219
216
  // Compute new end date
220
217
  const newEnd = new Date(opts.event.start);
221
218
  newEnd.setHours(Math.floor(clampedMinutes / 60), clampedMinutes % 60, 0, 0);
222
- // Reset inline height — the parent will re-render with new props
223
- opts.eventEl.style.height = '';
219
+ cleanup();
224
220
  opts.onResizeEnd?.(opts.event, newEnd);
225
221
  }
222
+ // Teardown of everything a live resize holds: document-level cursor, the
223
+ // move/up/cancel listeners, and the event block's inline height (the parent
224
+ // re-renders the committed height from props).
225
+ function cleanup() {
226
+ handle.removeEventListener('pointermove', handlePointerMove);
227
+ handle.removeEventListener('pointerup', handlePointerUp);
228
+ handle.removeEventListener('pointercancel', handlePointerUp);
229
+ document.body.style.cursor = '';
230
+ opts.eventEl.style.height = '';
231
+ }
226
232
  handle.addEventListener('pointerdown', handlePointerDown);
227
233
  handle.style.touchAction = 'none';
228
234
  return () => {
229
235
  handle.removeEventListener('pointerdown', handlePointerDown);
236
+ // Clean up any in-progress resize on unmount (mirrors draggableEvent) —
237
+ // otherwise the row-resize cursor and inline height would leak.
238
+ if (isResizing) {
239
+ cleanup();
240
+ isResizing = false;
241
+ }
230
242
  };
231
243
  };
232
244
  }
@@ -263,10 +263,14 @@
263
263
  </div>
264
264
  {/if}
265
265
 
266
- <!-- Bar -->
266
+ <!-- Bar — role="group", not "img": img flattens the subtree in the
267
+ accessibility tree, which would hide the interactive segment <button>s
268
+ inside. Convention: interactive charts (Sankey, CompositionBar) are a
269
+ named group; purely static charts (ChartFrame, DonutChart) keep
270
+ role="img" — atomic is only correct without interactive descendants. -->
267
271
  <div
268
272
  bind:this={barRef}
269
- role="img"
273
+ role="group"
270
274
  aria-label={ariaSummary}
271
275
  class={unstyled ? (slotClasses?.bar ?? '') : styles.bar({ class: slotClasses?.bar })}
272
276
  >
@@ -45,6 +45,7 @@
45
45
  nodeContent: nodeContentSnippet,
46
46
  linkContent: linkContentSnippet,
47
47
  tooltip: tooltipSnippet,
48
+ onmousemove: userOnMouseMove,
48
49
  ...restProps
49
50
  }: SankeyProps = $props();
50
51
 
@@ -191,6 +192,16 @@
191
192
  visible: true
192
193
  };
193
194
  }
195
+ // The wrapper hardcodes onmousemove for tooltip tracking, so a consumer's
196
+ // onmousemove would otherwise silently replace it via the restProps spread.
197
+ // Run the internal handler first, then forward to the consumer (same pattern
198
+ // as Input's onkeydown / Textarea's oninput merge).
199
+ function handleWrapperMouseMove(
200
+ event: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }
201
+ ) {
202
+ moveTooltip(event);
203
+ userOnMouseMove?.(event);
204
+ }
194
205
  function hideTooltip() {
195
206
  hovered = null;
196
207
  tooltipPos = { ...tooltipPos, visible: false };
@@ -264,11 +275,16 @@
264
275
  ? [slotClasses?.wrapper, className].filter(Boolean).join(' ')
265
276
  : styles.wrapper({ class: [slotClasses?.wrapper, className] })}
266
277
  style="height: {effectiveHeight}px"
267
- onmousemove={moveTooltip}
278
+ onmousemove={handleWrapperMouseMove}
268
279
  {...restProps}
269
280
  >
281
+ <!-- role="group", not "img": img flattens the subtree in the accessibility
282
+ tree, which would hide the interactive role="button" nodes/paths inside.
283
+ Convention: interactive charts (Sankey, CompositionBar) are a named
284
+ group; purely static charts (ChartFrame, DonutChart) keep role="img" —
285
+ atomic is only correct without interactive descendants. -->
270
286
  <svg
271
- role="img"
287
+ role="group"
272
288
  aria-label={ariaSummary}
273
289
  class={unstyled ? (slotClasses?.svg ?? '') : styles.svg({ class: slotClasses?.svg })}
274
290
  viewBox="0 0 {effectiveWidth} {effectiveHeight}"
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { buttonGroupVariants, type ButtonGroupVariants } from '..';
3
3
  import { getBlocksConfig, resolveSlotClasses } from '../../provider';
4
+ import { edgeEnabledIndex, nextEnabledIndex } from '../../utils';
4
5
  import { getTierContext, setTierContext } from '../../utils/tier-context';
5
6
  import type { ButtonGroupContext, ButtonGroupProps } from './index';
6
7
  import { setButtonGroupContext } from './buttonGroup.context';
@@ -65,7 +66,15 @@
65
66
  }
66
67
  });
67
68
 
69
+ // Child Button values in registration (= document) order. Buttons register
70
+ // during their render pass (before any effect runs), so this is fully
71
+ // populated in DOM order by the time roving reads it — it lets the radiogroup
72
+ // map the reactive selection back to a radio's position without the shared
73
+ // <Button> exposing its value in the DOM.
74
+ const buttonOrder: string[] = [];
75
+
68
76
  function registerButton(buttonValue: string | undefined) {
77
+ if (buttonValue && !buttonOrder.includes(buttonValue)) buttonOrder.push(buttonValue);
69
78
  return {
70
79
  get isSelected() {
71
80
  return buttonValue ? selectedValues.has(buttonValue) : false;
@@ -91,8 +100,14 @@
91
100
  onSelectionChange?.(value, Array.from(selectedValues));
92
101
  },
93
102
  getButtonProps() {
94
- if (selection === 'none') return {};
95
- const checked = buttonValue ? selectedValues.has(buttonValue) : false;
103
+ // A value-less Button is an action button, not a selection option, so it
104
+ // gets no radio/checkbox role. This also keeps `rovingRadios()` (which
105
+ // collects `[role="radio"]`) index-aligned with `buttonOrder` (gated on
106
+ // `buttonValue`): a value-less radio would otherwise sit in the roving
107
+ // array but not the order registry, drifting the two index spaces and
108
+ // misplacing the tab stop / arrow-nav origin.
109
+ if (selection === 'none' || !buttonValue) return {};
110
+ const checked = selectedValues.has(buttonValue);
96
111
  return {
97
112
  role: selection === 'single' ? ('radio' as const) : ('checkbox' as const),
98
113
  'aria-checked': checked
@@ -132,17 +147,119 @@
132
147
  registerButton
133
148
  });
134
149
 
150
+ let containerElement = $state<HTMLDivElement>();
151
+
152
+ // Single-select is a WAI-ARIA radiogroup: ONE tab stop, arrow keys move the
153
+ // selection (roving tabindex). Tab/SegmentGroup drive this from a value→element
154
+ // registry their own item components populate; ButtonGroup's items are shared
155
+ // <Button>s (outside this component), so the radiogroup owns the roving from
156
+ // the container instead — it queries the radio *elements* from the DOM (for
157
+ // focus + disabled state), reads the *selected* one from reactive state (see
158
+ // selectedRadioIndex), reuses the shared index math (utils/roving), skips
159
+ // disabled radios, and drives selection by clicking the target radio (Button's
160
+ // own click handler does the rest). `multiple`/`none` keep every button
161
+ // natively tabbable (checkbox-group / plain toolbar convention — not roved).
162
+ function rovingRadios(): HTMLButtonElement[] {
163
+ return containerElement
164
+ ? Array.from(containerElement.querySelectorAll<HTMLButtonElement>('[role="radio"]'))
165
+ : [];
166
+ }
167
+
168
+ // Position of the selected radio, read from the reactive selection (not the
169
+ // DOM's aria-checked) so it is correct even on the first paint — a parent
170
+ // effect can run before the child <Button>s commit their aria, so the DOM
171
+ // isn't a reliable source there. `buttonOrder` is in document order, matching
172
+ // the radios queried above.
173
+ function selectedRadioIndex(): number {
174
+ for (let i = 0; i < buttonOrder.length; i++) {
175
+ if (selectedValues.has(buttonOrder[i])) return i;
176
+ }
177
+ return -1;
178
+ }
179
+
180
+ $effect(() => {
181
+ if (selection !== 'single' || !containerElement) return;
182
+ const radios = rovingRadios();
183
+ if (radios.length === 0) return;
184
+
185
+ const checkedIndex = selectedRadioIndex();
186
+ // Nothing selected yet → the first enabled radio holds the tab stop, so the
187
+ // group stays reachable with Tab (standard radiogroup entry behaviour).
188
+ const activeIndex =
189
+ checkedIndex >= 0
190
+ ? checkedIndex
191
+ : edgeEnabledIndex(radios.length, 1, (i) => radios[i].disabled);
192
+
193
+ radios.forEach((radio, i) => {
194
+ radio.tabIndex = i === activeIndex ? 0 : -1;
195
+ });
196
+
197
+ // Restore native tabbability when the group stops roving (selection flips away
198
+ // from single, or it unmounts) — otherwise the imposed -1 would strand the
199
+ // buttons out of the tab order.
200
+ return () => {
201
+ for (const radio of radios) radio.removeAttribute('tabindex');
202
+ };
203
+ });
204
+
205
+ // A disabled radio renders a `<button disabled>`, which can't hold focus, so
206
+ // arrow navigation must step over it — otherwise selection strands on an
207
+ // unfocusable radio (aria-checked set, focus stuck on the previous one). The
208
+ // index math lives in the shared roving helpers (utils/roving).
209
+ function handleKeyDown(event: KeyboardEvent) {
210
+ if (disabled || selection !== 'single') return;
211
+
212
+ const radios = rovingRadios();
213
+ if (radios.length === 0) return;
214
+
215
+ const currentIndex = selectedRadioIndex();
216
+ const isDisabled = (i: number) => radios[i].disabled;
217
+ let newIndex: number;
218
+
219
+ switch (event.key) {
220
+ case 'ArrowRight':
221
+ case 'ArrowDown':
222
+ event.preventDefault();
223
+ newIndex = nextEnabledIndex(radios.length, currentIndex, 1, isDisabled);
224
+ break;
225
+ case 'ArrowLeft':
226
+ case 'ArrowUp':
227
+ event.preventDefault();
228
+ newIndex = nextEnabledIndex(radios.length, currentIndex, -1, isDisabled);
229
+ break;
230
+ case 'Home':
231
+ event.preventDefault();
232
+ newIndex = edgeEnabledIndex(radios.length, 1, isDisabled);
233
+ break;
234
+ case 'End':
235
+ event.preventDefault();
236
+ newIndex = edgeEnabledIndex(radios.length, -1, isDisabled);
237
+ break;
238
+ default:
239
+ return;
240
+ }
241
+
242
+ if (newIndex !== currentIndex && newIndex >= 0) {
243
+ const target = radios[newIndex];
244
+ target.click(); // Button's own click handler performs the selection
245
+ target.focus();
246
+ }
247
+ }
248
+
135
249
  const ariaRole = $derived(selection === 'single' ? 'radiogroup' : 'group');
136
250
  </script>
137
251
 
138
252
  <div
253
+ bind:this={containerElement}
139
254
  role={ariaRole}
140
255
  class={unstyled
141
256
  ? [slotClasses?.base, className].filter(Boolean).join(' ')
142
257
  : styles.base({ class: [slotClasses?.base, className] })}
143
258
  aria-label={ariaLabel}
144
259
  aria-labelledby={ariaLabelledBy}
145
- aria-disabled={disabled}
260
+ aria-orientation={orientation}
261
+ aria-disabled={disabled || undefined}
262
+ onkeydown={handleKeyDown}
146
263
  {...restProps}
147
264
  >
148
265
  {@render children?.()}
@@ -66,10 +66,10 @@ export const cardVariants = tv({
66
66
  footer: 'pt-5'
67
67
  }
68
68
  },
69
- // Opt-in slot separators. Default `false` (Lighter): header/footer
69
+ // Opt-in slot separators. Default `false`: header/footer
70
70
  // sit flush against the body, the slots are separated by spacing
71
71
  // only. Set `dividers={true}` for traditional card-with-header look.
72
- // Uses `border-hairline` (Lighter token) — leiser als border-subtle.
72
+ // Uses `border-hairline` — leiser als border-subtle.
73
73
  dividers: {
74
74
  true: {
75
75
  header: 'border-b border-border-hairline',
@@ -32,6 +32,7 @@
32
32
  unstyled: unstyledProp = false,
33
33
  slotClasses: slotClassesProp = {},
34
34
  preset,
35
+ 'aria-describedby': ariaDescribedby,
35
36
  ...restProps
36
37
  }: CheckboxProps = $props();
37
38
 
@@ -51,6 +52,14 @@
51
52
  disabled
52
53
  }));
53
54
 
55
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
56
+ // outside the component) merges with the internal error/helper chain instead
57
+ // of replacing it — internal descriptions first, the consumer's supplemental
58
+ // one last (mirrors the Input role model, XC-2).
59
+ const describedBy = $derived(
60
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
61
+ );
62
+
54
63
  const blocksConfig = getBlocksConfig();
55
64
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
56
65
 
@@ -128,7 +137,7 @@
128
137
  class="peer sr-only"
129
138
  aria-checked={indeterminate ? 'mixed' : undefined}
130
139
  aria-invalid={ff.invalid ? 'true' : undefined}
131
- aria-describedby={ff.describedBy}
140
+ aria-describedby={describedBy}
132
141
  onchange={handleChange}
133
142
  {...restProps}
134
143
  />
@@ -139,15 +148,24 @@
139
148
  aria-hidden="true"
140
149
  data-state={dataState}
141
150
  >
142
- {#if indeterminate}
143
- <IndeterminateIcon
144
- class={unstyled ? (slotClasses?.icon ?? '') : styles.icon({ class: slotClasses?.icon })}
145
- />
146
- {:else if checked}
147
- <CheckMarkIcon
148
- class={unstyled ? (slotClasses?.icon ?? '') : styles.icon({ class: slotClasses?.icon })}
149
- strokeWidth={3}
150
- />
151
+ {#if unstyled}
152
+ <!-- Unstyled keeps the mount-on-state contract: consumers style via
153
+ `data-state` on the box and rely on the icon only existing while
154
+ checked/indeterminate. -->
155
+ {#if indeterminate}
156
+ <IndeterminateIcon class={slotClasses?.icon ?? ''} />
157
+ {:else if checked}
158
+ <CheckMarkIcon class={slotClasses?.icon ?? ''} strokeWidth={3} />
159
+ {/if}
160
+ {:else if indeterminate}
161
+ <IndeterminateIcon class={styles.icon({ class: slotClasses?.icon })} strokeWidth={3} />
162
+ {:else}
163
+ <!-- Styled mode keeps the icon mounted even while unchecked (hidden by
164
+ `opacity-0` + full stroke-dash offset) — a conditional mount would
165
+ insert the element already at its resolved classes and the draw-in
166
+ transition could never run. strokeWidth 3 matches the check and
167
+ the minus so both states carry the same stroke weight. -->
168
+ <CheckMarkIcon class={styles.icon({ class: slotClasses?.icon })} strokeWidth={3} />
151
169
  {/if}
152
170
  </span>
153
171
 
@@ -7,12 +7,27 @@ export const checkboxVariants = tv({
7
7
  // the checkbox box is an input-tap surface.
8
8
  box: [
9
9
  'relative flex items-center justify-center shrink-0 border',
10
- 'transition-[color,background-color,border-color,box-shadow] duration-[var(--blocks-duration-fast)] ease-out',
10
+ 'transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--blocks-duration-fast)] ease-out',
11
+ // Press feedback on the control surface — same small-element press cue
12
+ // as Badge/Avatar (`scale-95`); `group-active` so pressing the label
13
+ // squeezes the box too. `scale` is in the transition list above, and
14
+ // reduced motion collapses `--blocks-duration-fast` to 1ms.
15
+ 'group-active:scale-95',
11
16
  'peer-focus-visible:ring-2 peer-focus-visible:ring-primary/50',
12
17
  'peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-surface-base'
13
18
  ],
14
19
  icon: [
15
- 'opacity-0 scale-75 transition-[opacity,transform] duration-[var(--blocks-duration-fast)] ease-out'
20
+ // Check/minus draw in along their stroke: the paths hide behind a full
21
+ // dash offset and the checked/indeterminate variants pull it to 0.
22
+ // 22px covers both geometries (check ≈ 20.2, minus = 14 user units);
23
+ // the offset sits 1px past the dash edge so no round-cap dot can bleed
24
+ // at the path start. A fast opacity fade rides along so the un-draw on
25
+ // uncheck can't flash in the inherited text colour once the box's
26
+ // intent fill (and its `text-on-*`) has left. Both durations are
27
+ // tokens, so `prefers-reduced-motion` collapses the whole draw to 1ms.
28
+ 'opacity-0 transition-opacity duration-[var(--blocks-duration-fast)] ease-out',
29
+ '[&_path]:[stroke-dasharray:22px] [&_path]:[stroke-dashoffset:23px]',
30
+ '[&_path]:transition-[stroke-dashoffset] [&_path]:duration-[var(--blocks-duration-normal)] [&_path]:ease-out'
16
31
  ],
17
32
  label: ['text-text-primary select-none'],
18
33
  message: ['text-xs text-text-tertiary']
@@ -63,12 +78,12 @@ export const checkboxVariants = tv({
63
78
  },
64
79
  checked: {
65
80
  true: {
66
- icon: 'opacity-100 scale-100'
81
+ icon: 'opacity-100 [&_path]:[stroke-dashoffset:0]'
67
82
  }
68
83
  },
69
84
  indeterminate: {
70
85
  true: {
71
- icon: 'opacity-100 scale-100'
86
+ icon: 'opacity-100 [&_path]:[stroke-dashoffset:0]'
72
87
  }
73
88
  },
74
89
  disabled: {
@@ -103,66 +118,93 @@ export const checkboxVariants = tv({
103
118
  class: { box: 'bg-transparent border-transparent group-hover:bg-surface-subtle' }
104
119
  },
105
120
  // ── Checked intent colors (identical across all variants) ──
121
+ // Hover/active darken through the intent interaction-layer tokens —
122
+ // the same `bg-<intent>-hover` / `bg-<intent>-active` ladder Button
123
+ // uses — via `group-*` so hovering/pressing the label counts too.
106
124
  {
107
125
  checked: true,
108
126
  intent: 'primary',
109
- class: { box: 'bg-primary border-primary text-text-on-primary' }
127
+ class: {
128
+ box: 'bg-primary border-primary text-text-on-primary group-hover:bg-primary-hover group-active:bg-primary-active'
129
+ }
110
130
  },
111
131
  {
112
132
  checked: true,
113
133
  intent: 'secondary',
114
- class: { box: 'bg-secondary border-secondary text-text-on-primary' }
134
+ class: {
135
+ box: 'bg-secondary border-secondary text-text-on-primary group-hover:bg-secondary-hover group-active:bg-secondary-active'
136
+ }
115
137
  },
116
138
  {
117
139
  checked: true,
118
140
  intent: 'success',
119
- class: { box: 'bg-success border-success text-text-on-primary' }
141
+ class: {
142
+ box: 'bg-success border-success text-text-on-primary group-hover:bg-success-hover group-active:bg-success-active'
143
+ }
120
144
  },
121
145
  {
122
146
  checked: true,
123
147
  intent: 'warning',
124
- class: { box: 'bg-warning border-warning text-text-on-surface' }
148
+ class: {
149
+ box: 'bg-warning border-warning text-text-on-surface group-hover:bg-warning-hover group-active:bg-warning-active'
150
+ }
125
151
  },
126
152
  {
127
153
  checked: true,
128
154
  intent: 'danger',
129
- class: { box: 'bg-danger border-danger text-text-on-primary' }
155
+ class: {
156
+ box: 'bg-danger border-danger text-text-on-primary group-hover:bg-danger-hover group-active:bg-danger-active'
157
+ }
130
158
  },
131
159
  {
132
160
  checked: true,
133
161
  intent: 'neutral',
134
- class: { box: 'bg-neutral border-neutral text-text-on-primary' }
162
+ class: {
163
+ box: 'bg-neutral border-neutral text-text-on-primary group-hover:bg-neutral-hover group-active:bg-neutral-active'
164
+ }
135
165
  },
136
166
  // ── Indeterminate mirrors checked ──
137
167
  {
138
168
  indeterminate: true,
139
169
  intent: 'primary',
140
- class: { box: 'bg-primary border-primary text-text-on-primary' }
170
+ class: {
171
+ box: 'bg-primary border-primary text-text-on-primary group-hover:bg-primary-hover group-active:bg-primary-active'
172
+ }
141
173
  },
142
174
  {
143
175
  indeterminate: true,
144
176
  intent: 'secondary',
145
- class: { box: 'bg-secondary border-secondary text-text-on-primary' }
177
+ class: {
178
+ box: 'bg-secondary border-secondary text-text-on-primary group-hover:bg-secondary-hover group-active:bg-secondary-active'
179
+ }
146
180
  },
147
181
  {
148
182
  indeterminate: true,
149
183
  intent: 'success',
150
- class: { box: 'bg-success border-success text-text-on-primary' }
184
+ class: {
185
+ box: 'bg-success border-success text-text-on-primary group-hover:bg-success-hover group-active:bg-success-active'
186
+ }
151
187
  },
152
188
  {
153
189
  indeterminate: true,
154
190
  intent: 'warning',
155
- class: { box: 'bg-warning border-warning text-text-on-surface' }
191
+ class: {
192
+ box: 'bg-warning border-warning text-text-on-surface group-hover:bg-warning-hover group-active:bg-warning-active'
193
+ }
156
194
  },
157
195
  {
158
196
  indeterminate: true,
159
197
  intent: 'danger',
160
- class: { box: 'bg-danger border-danger text-text-on-primary' }
198
+ class: {
199
+ box: 'bg-danger border-danger text-text-on-primary group-hover:bg-danger-hover group-active:bg-danger-active'
200
+ }
161
201
  },
162
202
  {
163
203
  indeterminate: true,
164
204
  intent: 'neutral',
165
- class: { box: 'bg-neutral border-neutral text-text-on-primary' }
205
+ class: {
206
+ box: 'bg-neutral border-neutral text-text-on-primary group-hover:bg-neutral-hover group-active:bg-neutral-active'
207
+ }
166
208
  },
167
209
  // ── Error overrides unchecked border ──
168
210
  {
@@ -56,6 +56,8 @@
56
56
  slotClasses: slotClassesProp = {},
57
57
  preset,
58
58
  id: idProp,
59
+ 'aria-describedby': ariaDescribedby,
60
+ 'aria-label': ariaLabel,
59
61
  ...restProps
60
62
  }: ComboboxProps<T> = $props();
61
63
 
@@ -80,6 +82,15 @@
80
82
  disabled
81
83
  }));
82
84
 
85
+ // Consumer-supplied `aria-describedby` (e.g. an external hint) merges with the
86
+ // internal error/helper chain instead of being dropped — restProps land on the
87
+ // wrapper div, so without this the description would never reach the focusable
88
+ // input. Internal descriptions first, the consumer's supplemental one last
89
+ // (mirrors Select + Input).
90
+ const describedBy = $derived(
91
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
92
+ );
93
+
83
94
  const blocksConfig = getBlocksConfig();
84
95
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
85
96
 
@@ -207,6 +218,18 @@
207
218
  filteredGroups ? filteredGroups.flatMap((g) => g.options) : allOptions.filter(matchesQuery)
208
219
  );
209
220
 
221
+ // Flat index of each option within `filtered`, precomputed so the grouped
222
+ // render path reads an option's keyboard-cursor index in O(1) instead of
223
+ // `filtered.indexOf(opt)` per option (O(n²) per keystroke on a large grouped
224
+ // list). Same first-occurrence semantics as `indexOf`.
225
+ const filteredIndexByOption = $derived.by(() => {
226
+ const map = new Map<ComboboxOption<T>, number>();
227
+ filtered.forEach((opt, i) => {
228
+ if (!map.has(opt)) map.set(opt, i);
229
+ });
230
+ return map;
231
+ });
232
+
210
233
  // Debounced query runner. The effect tracks `query` (+ debounceMs); each change
211
234
  // resets the timer, and the fetch runs a tick later so bursty typing collapses
212
235
  // into one request. A per-run AbortController lets a newer query cancel an
@@ -750,7 +773,7 @@
750
773
  {noResultsText}
751
774
  </div>
752
775
  {:else if filteredGroups}
753
- {#each filteredGroups as group (group.label)}
776
+ {#each filteredGroups as group, i (`${group.label}-${i}`)}
754
777
  <div
755
778
  class={unstyled
756
779
  ? (slotClasses?.group ?? '')
@@ -766,7 +789,7 @@
766
789
  {group.label}
767
790
  </div>
768
791
  {#each group.options as opt (opt.value)}
769
- {@render optionButton(opt, filtered.indexOf(opt))}
792
+ {@render optionButton(opt, filteredIndexByOption.get(opt) ?? -1)}
770
793
  {/each}
771
794
  </div>
772
795
  {/each}
@@ -855,6 +878,14 @@
855
878
  wiring lives in one place. `required` is single-mode only — in multi the input
856
879
  is transient search text (cleared after each pick), so a native `required` on
857
880
  it would wrongly block submit even with tags selected.
881
+
882
+ A consumer `aria-label` is forwarded onto the input (destructured out of
883
+ restProps so it never lands on the role-less wrapper `<div>` — axe
884
+ aria-prohibited-attr), but ONLY when no visible `label` renders. Unlike Select —
885
+ which names its trigger via `aria-labelledby` (HIGHER ARIA precedence than
886
+ aria-label) — Combobox names the input via a native `<label for>` (LOWER
887
+ precedence than aria-label), so an unconditional aria-label would override a
888
+ visible label. Gating on `label` keeps the visible label authoritative.
858
889
  -->
859
890
  {#snippet searchField(cls: string)}
860
891
  <input
@@ -867,7 +898,8 @@
867
898
  aria-activedescendant={activeDescendant}
868
899
  aria-autocomplete="list"
869
900
  aria-invalid={ff.invalid ? 'true' : undefined}
870
- aria-describedby={ff.describedBy}
901
+ aria-describedby={describedBy}
902
+ aria-label={label ? undefined : ariaLabel}
871
903
  autocomplete="off"
872
904
  class={cls}
873
905
  placeholder={effectivePlaceholder}
@@ -41,10 +41,10 @@ export interface DialogProps extends Omit<HTMLDialogAttributes, 'children' | 'op
41
41
  /** Vertical placement within the viewport. Use 'top' for command-palette style positioning. @default 'center' */
42
42
  placement?: DialogVariants['placement'];
43
43
  /**
44
- * Semantic purpose marker (e.g. `danger` for destructive actions). After the
45
- * Lighter-Refactor, the Dialog itself no longer paints an accent bar — the
46
- * value is exposed on the panel as `data-intent="…"` so consumers can hook
47
- * presets, CSS overrides, or icon/title color via their own snippets.
44
+ * Semantic purpose marker (e.g. `danger` for destructive actions). The
45
+ * Dialog itself no longer paints an accent bar — the value is exposed on
46
+ * the panel as `data-intent="…"` so consumers can hook presets, CSS
47
+ * overrides, or icon/title color via their own snippets.
48
48
  * @default 'neutral'
49
49
  */
50
50
  intent?: DialogVariants['intent'];
@@ -43,6 +43,7 @@
43
43
  persistVersion = 1,
44
44
  persistNamespace,
45
45
  onkeydown: userOnKeydown,
46
+ 'aria-describedby': ariaDescribedby,
46
47
  ...restProps
47
48
  }: InputProps = $props();
48
49
 
@@ -126,6 +127,14 @@
126
127
  disabled
127
128
  }));
128
129
 
130
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
131
+ // outside the component) merges with the internal error/helper chain
132
+ // instead of being clobbered by it — internal descriptions first, the
133
+ // consumer's supplemental one last.
134
+ const describedBy = $derived(
135
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
136
+ );
137
+
129
138
  $effect(() => {
130
139
  if (inputRef && mint && mint !== 'none' && !disabled) {
131
140
  return mintRegistry.apply(inputRef, mint);
@@ -222,7 +231,7 @@
222
231
  {readonly}
223
232
  {required}
224
233
  aria-invalid={ff.invalid ? 'true' : undefined}
225
- aria-describedby={ff.describedBy}
234
+ aria-describedby={describedBy}
226
235
  onkeydown={handleKeydown}
227
236
  />
228
237
 
@@ -26,6 +26,7 @@
26
26
  slotClasses: slotClassesProp = {},
27
27
  preset,
28
28
  id,
29
+ 'aria-describedby': ariaDescribedby,
29
30
  ...restProps
30
31
  }: RadioGroupProps = $props();
31
32
 
@@ -52,6 +53,14 @@
52
53
  disabled
53
54
  }));
54
55
 
56
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
57
+ // outside the component) merges with the internal error/helper chain instead
58
+ // of replacing it — internal descriptions first, the consumer's supplemental
59
+ // one last (mirrors the Input role model, XC-2). Applied to the group element.
60
+ const describedBy = $derived(
61
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
62
+ );
63
+
55
64
  // Variant props feed both the tv() style computation and the slot-class
56
65
  // cascade — extracted into one derived so `resolveSlotClasses` can match
57
66
  // conditional `overrides` against the group's active variants.
@@ -155,7 +164,7 @@
155
164
  id={groupId}
156
165
  class={unstyled ? (slotClasses?.group ?? '') : styles.group({ class: slotClasses?.group })}
157
166
  aria-labelledby={labelId}
158
- aria-describedby={ff.describedBy}
167
+ aria-describedby={describedBy}
159
168
  aria-required={required || undefined}
160
169
  aria-invalid={ff.invalid ? 'true' : undefined}
161
170
  onkeydown={handleKeydown}
@@ -53,6 +53,8 @@
53
53
  slotClasses: slotClassesProp = {},
54
54
  preset,
55
55
  id: idProp,
56
+ 'aria-describedby': ariaDescribedby,
57
+ 'aria-label': ariaLabel,
56
58
  ...restProps
57
59
  }: SelectProps<T> = $props();
58
60
 
@@ -108,6 +110,14 @@
108
110
  disabled
109
111
  }));
110
112
 
113
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
114
+ // outside the component) merges with the internal error/helper chain —
115
+ // restProps land on the wrapper div, so without this the description
116
+ // would never reach the focusable trigger.
117
+ const describedBy = $derived(
118
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
119
+ );
120
+
111
121
  let activeIndex = $state(-1);
112
122
  // Tracks whether the most recent open was keyboard-driven, so the initial
113
123
  // active-option highlight only defaults to the first row for keyboard users.
@@ -145,6 +155,19 @@
145
155
 
146
156
  const enabledOptions = $derived(allOptions.filter((o) => !o.disabled));
147
157
 
158
+ // Flat index of each enabled option, precomputed so a grouped or flat option
159
+ // reads its keyboard-cursor index in O(1) rather than an O(n) `indexOf` per
160
+ // render — which made a large grouped listbox O(n²) per keystroke. Disabled
161
+ // options are absent from `enabledOptions`, so they resolve to -1 here, exactly
162
+ // as the previous `enabledOptions.indexOf(option)` did.
163
+ const enabledIndexByOption = $derived.by(() => {
164
+ const map = new Map<SelectOption<T>, number>();
165
+ enabledOptions.forEach((o, i) => {
166
+ if (!map.has(o)) map.set(o, i);
167
+ });
168
+ return map;
169
+ });
170
+
148
171
  /**
149
172
  * Currently selected option(s), normalized to an array regardless of mode.
150
173
  *
@@ -456,7 +479,8 @@
456
479
  aria-haspopup="listbox"
457
480
  aria-controls={listboxId}
458
481
  aria-labelledby={labelId}
459
- aria-describedby={ff.describedBy}
482
+ aria-label={labelId ? undefined : ariaLabel}
483
+ aria-describedby={describedBy}
460
484
  aria-invalid={ff.invalid ? 'true' : undefined}
461
485
  aria-activedescendant={activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
462
486
  onclick={toggle}
@@ -551,7 +575,7 @@
551
575
  >
552
576
  {#if open}
553
577
  {#if groups}
554
- {#each groups as group (group.label)}
578
+ {#each groups as group, i (`${group.label}-${i}`)}
555
579
  <div
556
580
  class={unstyled
557
581
  ? (slotClasses?.group ?? '')
@@ -567,8 +591,8 @@
567
591
  {group.label}
568
592
  </div>
569
593
  {#each group.options as option (option.value)}
570
- {@const optIdx = enabledOptions.indexOf(option)}
571
- {@const isActive = optIdx === activeIndex}
594
+ {@const optIdx = enabledIndexByOption.get(option) ?? -1}
595
+ {@const isActive = optIdx >= 0 && optIdx === activeIndex}
572
596
  {@const isSel = isOptionSelected(option)}
573
597
  <!--
574
598
  ARIA Listbox pattern: options are explicitly NOT in the
@@ -638,8 +662,8 @@
638
662
  {/each}
639
663
  {:else}
640
664
  {#each allOptions as option (option.value)}
641
- {@const optIdx = enabledOptions.indexOf(option)}
642
- {@const isActive = optIdx === activeIndex}
665
+ {@const optIdx = enabledIndexByOption.get(option) ?? -1}
666
+ {@const isActive = optIdx >= 0 && optIdx === activeIndex}
643
667
  {@const isSel = isOptionSelected(option)}
644
668
  <!-- Options stay out of the tab order — see ARIA Listbox note above. -->
645
669
  <!-- svelte-ignore a11y_interactive_supports_focus -->
@@ -34,6 +34,7 @@
34
34
  unstyled: unstyledProp = false,
35
35
  slotClasses: slotClassesProp = {},
36
36
  preset,
37
+ 'aria-describedby': ariaDescribedby,
37
38
  ...restProps
38
39
  }: SliderProps = $props();
39
40
 
@@ -240,12 +241,14 @@
240
241
  resolveSlotClasses(blocksConfig, 'Slider', preset, variantProps, slotClassesProp)
241
242
  );
242
243
 
243
- // aria-describedby chain: error > helper, plus rangeStatus when active.
244
- // `ff.describedBy` already handles error/hint in the canonical order;
245
- // we append the optional statusId.
244
+ // aria-describedby chain: error > helper, plus rangeStatus when active,
245
+ // plus a consumer-supplied `aria-describedby` (e.g. an external hint)
246
+ // restProps land on the wrapper div, so without the merge the consumer's
247
+ // description would never reach the focusable thumbs.
246
248
  const describedBy = $derived(
247
- [ff.describedBy, hasRangeConstraints ? statusId : undefined].filter(Boolean).join(' ') ||
248
- undefined
249
+ [ff.describedBy, hasRangeConstraints ? statusId : undefined, ariaDescribedby]
250
+ .filter(Boolean)
251
+ .join(' ') || undefined
249
252
  );
250
253
 
251
254
  $effect(() => {
@@ -29,6 +29,7 @@
29
29
  slotClasses: slotClassesProp = {},
30
30
  preset,
31
31
  oninput: userOnInput,
32
+ 'aria-describedby': ariaDescribedby,
32
33
  ...restProps
33
34
  }: TextareaProps = $props();
34
35
 
@@ -50,6 +51,14 @@
50
51
  disabled
51
52
  }));
52
53
 
54
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
55
+ // outside the component) merges with the internal error/helper chain instead
56
+ // of replacing it — internal descriptions first, the consumer's supplemental
57
+ // one last (mirrors the Input role model, XC-2).
58
+ const describedBy = $derived(
59
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
60
+ );
61
+
53
62
  const charCount = $derived(typeof value === 'string' ? value.length : 0);
54
63
  const counterState = $derived.by(() => {
55
64
  if (!maxlength) return 'normal' as const;
@@ -145,7 +154,7 @@
145
154
  {readonly}
146
155
  {required}
147
156
  aria-invalid={ff.invalid ? 'true' : undefined}
148
- aria-describedby={ff.describedBy}
157
+ aria-describedby={describedBy}
149
158
  oninput={handleInput}></textarea>
150
159
 
151
160
  {#if showFooter}
@@ -56,6 +56,36 @@
56
56
  return toaster.registerSubscriber();
57
57
  });
58
58
 
59
+ // Sonner-style hover-to-pause. The pointer or keyboard focus being anywhere in
60
+ // the toaster region freezes the whole visible stack (all auto-dismiss timers
61
+ // + the progress bars) and leaving resumes each from its remaining time.
62
+ //
63
+ // These are bubbling over/out + focusin/focusout, NOT pointerenter/leave: the
64
+ // container is `pointer-events-none` (so clicks fall through the gaps between
65
+ // toasts to the page), which means it never becomes a pointer target itself —
66
+ // only its `pointer-events-auto` toast children do. enter/leave don't bubble
67
+ // and so would never fire here; over/out bubble up from the toasts. The
68
+ // `relatedTarget` containment check collapses the noisy per-descendant out/blur
69
+ // events into a single "the pointer/focus actually left the region" signal.
70
+ let pointerInside = $state(false);
71
+ let focusInside = $state(false);
72
+
73
+ $effect(() => {
74
+ if (pointerInside || focusInside) toaster.pause();
75
+ else toaster.resume();
76
+ });
77
+
78
+ // Safety net: if the Toaster unmounts while the stack is frozen (pointer/focus
79
+ // was still inside), un-freeze the singleton store so its timers aren't stranded.
80
+ $effect(() => {
81
+ return () => toaster.resume();
82
+ });
83
+
84
+ function leftRegion(event: PointerEvent | FocusEvent) {
85
+ const next = event.relatedTarget as Node | null;
86
+ return !(event.currentTarget as HTMLElement).contains(next);
87
+ }
88
+
59
89
  const visibleToasts = $derived(toaster.toasts.slice(-max));
60
90
 
61
91
  const styles = $derived(
@@ -124,6 +154,14 @@
124
154
  class={[slot('container'), className].filter(Boolean).join(' ')}
125
155
  aria-live="polite"
126
156
  aria-relevant="additions removals"
157
+ onpointerover={() => (pointerInside = true)}
158
+ onpointerout={(e) => {
159
+ if (leftRegion(e)) pointerInside = false;
160
+ }}
161
+ onfocusin={() => (focusInside = true)}
162
+ onfocusout={(e) => {
163
+ if (leftRegion(e)) focusInside = false;
164
+ }}
127
165
  {...restProps}
128
166
  >
129
167
  {#each visibleToasts as toast (toast.id)}
@@ -177,10 +215,16 @@
177
215
  </button>
178
216
  {/if}
179
217
 
180
- {#if toast.showProgress && toast.duration > 0}
218
+ {#if toast.showProgress && Number.isFinite(toast.duration) && toast.duration > 0}
219
+ <!-- Duration comes from the toast itself (the real countdown), not a token.
220
+ `animation-play-state` tracks the store's paused flag so the bar freezes
221
+ with the timer on hover/focus and resumes in place — changing only the
222
+ play-state never restarts the animation. -->
181
223
  <div
182
224
  class={slot('progress', toast.intent)}
183
- style="animation: blocks-toast-progress {toast.duration}ms linear forwards;"
225
+ style="animation: blocks-toast-progress {toast.duration}ms linear forwards; animation-play-state: {toaster.paused
226
+ ? 'paused'
227
+ : 'running'};"
184
228
  ></div>
185
229
  {/if}
186
230
  </div>
@@ -14,6 +14,13 @@ import type { ToastPlacement } from './toast.variants.js';
14
14
  declare class ToastStore {
15
15
  toasts: ToastData[];
16
16
  placement: ToastPlacement;
17
+ /**
18
+ * Whether the whole visible stack is currently frozen because the pointer or
19
+ * keyboard focus is inside the toaster region. Drives each progress bar's
20
+ * `animation-play-state`; the auto-dismiss timers freeze together with it. Set
21
+ * by `<Toaster>` via {@link pause} / {@link resume}.
22
+ */
23
+ paused: boolean;
17
24
  private timers;
18
25
  private subscriberCount;
19
26
  private hasWarnedNoSubscriber;
@@ -22,6 +29,15 @@ declare class ToastStore {
22
29
  * the store knows whether a renderer is present. Returns an unsubscribe fn.
23
30
  */
24
31
  registerSubscriber(): () => void;
32
+ /**
33
+ * Arm the auto-dismiss timer for `id` — or, if the stack is currently paused,
34
+ * pre-register it frozen so it starts counting when the stack resumes.
35
+ * Persistent toasts (`duration <= 0` or non-finite, e.g. `Infinity`, and the
36
+ * loading leg of a promise toast) get no timer at all.
37
+ */
38
+ private armTimer;
39
+ /** Cancel and forget the timer for `id` (if any). */
40
+ private clearTimer;
25
41
  /** Create a toast with full control over all options. Returns the toast ID for programmatic dismissal. */
26
42
  add(input: ToastInput): string;
27
43
  /**
@@ -50,8 +66,23 @@ declare class ToastStore {
50
66
  promise<T>(promise: Promise<T>, opts: ToastPromiseOptions<T>): string;
51
67
  /** Remove a single toast by ID. Clears its auto-dismiss timer. */
52
68
  dismiss(id: string): void;
53
- /** Remove all toasts and cancel their timers. */
69
+ /** Remove all toasts, cancel their timers, and un-freeze the stack. */
54
70
  clear(): void;
71
+ /**
72
+ * Freeze the whole visible stack (Sonner-style hover-to-pause): cancel every
73
+ * running auto-dismiss timer, banking the time each has left, and flag
74
+ * {@link paused} so the progress bars stop. Idempotent — `<Toaster>` calls it
75
+ * on pointer-enter / focus-in of the region. Persistent toasts and a promise's
76
+ * loading leg have no timer and are untouched; the banked remainders are
77
+ * restarted by {@link resume}.
78
+ */
79
+ pause(): void;
80
+ /**
81
+ * Resume the stack: restart every frozen timer from its banked remaining time
82
+ * (not from the full duration) and clear {@link paused} so the progress bars
83
+ * run again. Idempotent — called on pointer-leave / focus-out of the region.
84
+ */
85
+ resume(): void;
55
86
  /** Shorthand for `add()` with `intent: 'info'`. Shows an info icon. */
56
87
  info(title: string, opts?: ToastShorthandOpts): string;
57
88
  /** Shorthand for `add()` with `intent: 'success'`. Shows a check icon. */
@@ -16,6 +16,13 @@ function uid() {
16
16
  class ToastStore {
17
17
  toasts = $state([]);
18
18
  placement = $state('bottom-right');
19
+ /**
20
+ * Whether the whole visible stack is currently frozen because the pointer or
21
+ * keyboard focus is inside the toaster region. Drives each progress bar's
22
+ * `animation-play-state`; the auto-dismiss timers freeze together with it. Set
23
+ * by `<Toaster>` via {@link pause} / {@link resume}.
24
+ */
25
+ paused = $state(false);
19
26
  timers = new Map();
20
27
  subscriberCount = 0;
21
28
  hasWarnedNoSubscriber = false;
@@ -31,6 +38,34 @@ class ToastStore {
31
38
  this.subscriberCount = Math.max(0, this.subscriberCount - 1);
32
39
  };
33
40
  }
41
+ /**
42
+ * Arm the auto-dismiss timer for `id` — or, if the stack is currently paused,
43
+ * pre-register it frozen so it starts counting when the stack resumes.
44
+ * Persistent toasts (`duration <= 0` or non-finite, e.g. `Infinity`, and the
45
+ * loading leg of a promise toast) get no timer at all.
46
+ */
47
+ armTimer(id, duration) {
48
+ if (!Number.isFinite(duration) || duration <= 0)
49
+ return;
50
+ if (this.paused) {
51
+ // A toast that arrives while the user is hovering the stack starts frozen;
52
+ // resume() lights its timer once the pointer/focus leaves.
53
+ this.timers.set(id, { handle: undefined, remaining: duration, startedAt: 0 });
54
+ return;
55
+ }
56
+ this.timers.set(id, {
57
+ handle: setTimeout(() => this.dismiss(id), duration),
58
+ remaining: duration,
59
+ startedAt: Date.now()
60
+ });
61
+ }
62
+ /** Cancel and forget the timer for `id` (if any). */
63
+ clearTimer(id) {
64
+ const entry = this.timers.get(id);
65
+ if (entry?.handle !== undefined)
66
+ clearTimeout(entry.handle);
67
+ this.timers.delete(id);
68
+ }
34
69
  /** Create a toast with full control over all options. Returns the toast ID for programmatic dismissal. */
35
70
  add(input) {
36
71
  if (this.subscriberCount === 0 &&
@@ -56,9 +91,7 @@ class ToastStore {
56
91
  loading: input.loading ?? false
57
92
  };
58
93
  this.toasts = [...this.toasts, toast];
59
- if (toast.duration > 0) {
60
- this.timers.set(id, setTimeout(() => this.dismiss(id), toast.duration));
61
- }
94
+ this.armTimer(id, toast.duration);
62
95
  return id;
63
96
  }
64
97
  /**
@@ -73,14 +106,9 @@ class ToastStore {
73
106
  return;
74
107
  const next = { ...this.toasts[idx], ...input };
75
108
  this.toasts = [...this.toasts.slice(0, idx), next, ...this.toasts.slice(idx + 1)];
76
- const timer = this.timers.get(id);
77
- if (timer) {
78
- clearTimeout(timer);
79
- this.timers.delete(id);
80
- }
81
- if (next.duration > 0) {
82
- this.timers.set(id, setTimeout(() => this.dismiss(id), next.duration));
83
- }
109
+ // Reset the auto-dismiss clock to the new duration (respecting a current pause).
110
+ this.clearTimer(id);
111
+ this.armTimer(id, next.duration);
84
112
  }
85
113
  /**
86
114
  * Drive a toast through a promise's lifecycle (Sonner-style). Shows a
@@ -150,19 +178,56 @@ class ToastStore {
150
178
  }
151
179
  /** Remove a single toast by ID. Clears its auto-dismiss timer. */
152
180
  dismiss(id) {
153
- const timer = this.timers.get(id);
154
- if (timer) {
155
- clearTimeout(timer);
156
- this.timers.delete(id);
157
- }
181
+ this.clearTimer(id);
158
182
  this.toasts = this.toasts.filter((t) => t.id !== id);
159
183
  }
160
- /** Remove all toasts and cancel their timers. */
184
+ /** Remove all toasts, cancel their timers, and un-freeze the stack. */
161
185
  clear() {
162
- for (const timer of this.timers.values())
163
- clearTimeout(timer);
186
+ for (const entry of this.timers.values()) {
187
+ if (entry.handle !== undefined)
188
+ clearTimeout(entry.handle);
189
+ }
164
190
  this.timers.clear();
165
191
  this.toasts = [];
192
+ this.paused = false;
193
+ }
194
+ /**
195
+ * Freeze the whole visible stack (Sonner-style hover-to-pause): cancel every
196
+ * running auto-dismiss timer, banking the time each has left, and flag
197
+ * {@link paused} so the progress bars stop. Idempotent — `<Toaster>` calls it
198
+ * on pointer-enter / focus-in of the region. Persistent toasts and a promise's
199
+ * loading leg have no timer and are untouched; the banked remainders are
200
+ * restarted by {@link resume}.
201
+ */
202
+ pause() {
203
+ if (this.paused)
204
+ return;
205
+ this.paused = true;
206
+ const now = Date.now();
207
+ for (const entry of this.timers.values()) {
208
+ if (entry.handle === undefined)
209
+ continue;
210
+ clearTimeout(entry.handle);
211
+ entry.remaining = Math.max(0, entry.remaining - (now - entry.startedAt));
212
+ entry.handle = undefined;
213
+ }
214
+ }
215
+ /**
216
+ * Resume the stack: restart every frozen timer from its banked remaining time
217
+ * (not from the full duration) and clear {@link paused} so the progress bars
218
+ * run again. Idempotent — called on pointer-leave / focus-out of the region.
219
+ */
220
+ resume() {
221
+ if (!this.paused)
222
+ return;
223
+ this.paused = false;
224
+ const now = Date.now();
225
+ for (const [id, entry] of this.timers) {
226
+ if (entry.handle !== undefined)
227
+ continue;
228
+ entry.startedAt = now;
229
+ entry.handle = setTimeout(() => this.dismiss(id), entry.remaining);
230
+ }
166
231
  }
167
232
  /** Shorthand for `add()` with `intent: 'info'`. Shows an info icon. */
168
233
  info(title, opts) {
@@ -28,6 +28,7 @@
28
28
  slotClasses: slotClassesProp = {},
29
29
  preset,
30
30
  withBorder = false,
31
+ 'aria-describedby': ariaDescribedby,
31
32
  ...restProps
32
33
  }: ToggleProps = $props();
33
34
 
@@ -42,6 +43,14 @@
42
43
  disabled
43
44
  }));
44
45
 
46
+ // Consumer-supplied `aria-describedby` (e.g. an external hint rendered
47
+ // outside the component) merges with the internal error/helper chain instead
48
+ // of replacing it — internal descriptions first, the consumer's supplemental
49
+ // one last (mirrors the Input role model, XC-2).
50
+ const describedBy = $derived(
51
+ [ff.describedBy, ariaDescribedby].filter(Boolean).join(' ') || undefined
52
+ );
53
+
45
54
  const blocksConfig = getBlocksConfig();
46
55
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
47
56
 
@@ -113,7 +122,7 @@
113
122
  class="peer sr-only"
114
123
  aria-checked={checked}
115
124
  aria-label={label ? undefined : bt('accessibility.toggle') || 'Toggle'}
116
- aria-describedby={ff.describedBy}
125
+ aria-describedby={describedBy}
117
126
  aria-invalid={ff.invalid ? 'true' : undefined}
118
127
  onchange={handleChange}
119
128
  {...restProps}
@@ -153,7 +153,7 @@
153
153
  --color-chart-6: light-dark(var(--color-danger-500), var(--color-danger-400));
154
154
 
155
155
  /* === BORDERS ===
156
- border-hairline (Lighter-Refactor) is the quietest tier — structural
156
+ border-hairline is the quietest tier — structural
157
157
  trennlinien (row dividers, Card header/footer separators, inline
158
158
  section dividers). Sits below border-subtle, which is now reserved
159
159
  for quiet container outlines (e. g. Popover). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.21.3",
3
+ "version": "6.23.0",
4
4
  "description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,8 +91,8 @@
91
91
  "@sveltejs/package": "^2.5.8",
92
92
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
93
93
  "@tailwindcss/vite": "^4.3.1",
94
- "@urbicon-ui/i18n": "6.21.3",
95
- "@urbicon-ui/shared-types": "6.21.3",
94
+ "@urbicon-ui/i18n": "6.23.0",
95
+ "@urbicon-ui/shared-types": "6.23.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",