@urbicon-ui/blocks 6.23.0 → 6.24.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.
@@ -7,6 +7,7 @@
7
7
  data,
8
8
  width = 96,
9
9
  height = 24,
10
+ fluid = false,
10
11
  area = false,
11
12
  showEndPoint = false,
12
13
  color = 'var(--color-chart-1)',
@@ -40,15 +41,19 @@
40
41
 
41
42
  <span
42
43
  {...rest}
43
- class={[!unstyled && 'inline-block align-middle', slot('root'), className]}
44
+ class={[
45
+ !unstyled && (fluid ? 'block w-full' : 'inline-block align-middle'),
46
+ slot('root'),
47
+ className
48
+ ]}
44
49
  role={ariaLabel ? 'img' : undefined}
45
50
  aria-label={ariaLabel}
46
51
  aria-hidden={ariaLabel ? undefined : 'true'}
47
52
  >
48
53
  <svg
49
- class={slot('svg')}
50
- {width}
51
- {height}
54
+ class={[fluid && 'w-full h-auto', slot('svg')]}
55
+ width={fluid ? undefined : width}
56
+ height={fluid ? undefined : height}
52
57
  viewBox="0 0 {width} {height}"
53
58
  preserveAspectRatio="none"
54
59
  >
@@ -70,6 +75,7 @@
70
75
  stroke-width={strokeWidth}
71
76
  stroke-linejoin="round"
72
77
  stroke-linecap="round"
78
+ vector-effect={fluid ? 'non-scaling-stroke' : undefined}
73
79
  />
74
80
  {/if}
75
81
  {#if showEndPoint && geometry.last}
@@ -4,7 +4,8 @@ export type SparklineSlotClasses = Partial<Record<'root' | 'svg' | 'line' | 'are
4
4
  /**
5
5
  * @description Tiny inline trend line — no axes, no labels — sized to flow in
6
6
  * table cells, cards, or running text. Zero-dependency SVG, optional area fill
7
- * and end-point dot. Aria-hidden by default with an optional `ariaLabel`.
7
+ * and end-point dot. Fixed `width`/`height` by default, or set `fluid` to fill
8
+ * the container width. Aria-hidden by default with an optional `ariaLabel`.
8
9
  *
9
10
  * @tag display
10
11
  * @tag data
@@ -23,6 +24,13 @@ export interface SparklineProps extends Omit<HTMLAttributes<HTMLElement>, 'child
23
24
  width?: number;
24
25
  /** Height in px. @default 24 */
25
26
  height?: number;
27
+ /**
28
+ * Fill the container width instead of a fixed pixel size. Drops the svg's
29
+ * `width`/`height` attributes (they still set the `viewBox` aspect ratio) so
30
+ * it scales to its container; strokes stay crisp via
31
+ * `vector-effect="non-scaling-stroke"`. @default false
32
+ */
33
+ fluid?: boolean;
26
34
  /** Fill the area under the line. @default false */
27
35
  area?: boolean;
28
36
  /** Mark the last point with a dot. @default false */
@@ -34,7 +34,7 @@
34
34
  onRemove,
35
35
  onclick,
36
36
  onHover,
37
- role = 'status',
37
+ role,
38
38
  ...restProps
39
39
  }: BadgeProps = $props();
40
40
 
@@ -56,6 +56,12 @@
56
56
  const isInteractive = $derived(purpose === 'chip' || interactive || !!onclick);
57
57
  const isRemovable = $derived(removable && !isDot);
58
58
 
59
+ // An interactive badge (clickable chip / onclick) carries button semantics:
60
+ // it is focusable and Enter/Space-activatable, so screen readers must hear a
61
+ // button, not a `status` region. An explicit `role` always wins; a disabled
62
+ // badge is inert (pointer-events-none, guarded handlers) so it stays `status`.
63
+ const effRole = $derived(role ?? (isInteractive && !disabled ? 'button' : 'status'));
64
+
59
65
  const variantProps: BadgeVariants = $derived({
60
66
  tier: effectiveTier,
61
67
  intent,
@@ -124,7 +130,7 @@
124
130
  ? [slotClasses?.base, className].filter(Boolean).join(' ')
125
131
  : styles.base({ class: [slotClasses?.base, className] })
126
132
  ]}
127
- {role}
133
+ role={effRole}
128
134
  data-purpose={purpose}
129
135
  tabindex={isInteractive && !disabled ? 0 : undefined}
130
136
  onmouseenter={handleMouseEnter}
@@ -3,24 +3,13 @@ import type { HTMLAttributes } from 'svelte/elements';
3
3
  import type { MintProp } from '../../mint/index.js';
4
4
  import type { BadgePlacement, BadgeSlots, BadgeVariants } from './badge.variants.js';
5
5
  /**
6
- * Shared fields that apply to every Badge variant `dot` and the
7
- * label-style variants both accept these.
6
+ * Shared fields that apply to every Badge arm. `variant`, `purpose`, and the
7
+ * label-only props (`children` / `counter` / `removable` / `interactive` /
8
+ * `onRemove`) are declared per-arm instead — that split is what lets *both*
9
+ * dot spellings (`variant="dot"` and the canonical `purpose="dot"`) forbid the
10
+ * label-only props at the type level.
8
11
  */
9
- interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omit<HTMLAttributes<HTMLElement>, 'children'> {
10
- /**
11
- * The badge's semantic purpose — the canonical axis that resolves what the
12
- * badge *is*, since a bare Badge served five overlapping roles. Orchestrates
13
- * the low-level visual props so you rarely set them directly:
14
- * - `status` — a state marker (Active, Failed); pairs with `intent`.
15
- * - `tag` — a neutral inline label (category, version).
16
- * - `counter` — a compact numeric pill (replaces the `counter` boolean).
17
- * - `dot` — a pure indicator, content hidden (replaces `variant="dot"`).
18
- * - `chip` — a removable, interactive filter chip; pair with `removable`.
19
- *
20
- * Leave unset to drive the badge purely by the low-level props (back-compat).
21
- * When set, `purpose` wins over `variant="dot"` / the `counter` boolean.
22
- */
23
- purpose?: 'status' | 'tag' | 'counter' | 'dot' | 'chip';
12
+ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter' | 'removable' | 'interactive'>, Omit<HTMLAttributes<HTMLElement>, 'children'> {
24
13
  /** Add a pulsing animation to draw attention (e.g. for live indicators). */
25
14
  pulse?: boolean;
26
15
  /** Visually disable the badge (reduced opacity, no pointer events). */
@@ -29,7 +18,7 @@ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omi
29
18
  border?: boolean;
30
19
  /** Anchor the badge absolutely within a `position: relative` parent. */
31
20
  placement?: BadgePlacement;
32
- /** Click handler. Automatically enables interactive styles. */
21
+ /** Click handler. Automatically enables interactive styles (and `role="button"`). */
33
22
  onclick?: (event: MouseEvent) => void;
34
23
  /** Called when the hover state changes. */
35
24
  onHover?: (hovered: boolean) => void;
@@ -46,30 +35,73 @@ interface BadgeBaseProps extends Omit<BadgeVariants, 'variant' | 'counter'>, Omi
46
35
  * and make the custom look reusable across the project.
47
36
  */
48
37
  preset?: string;
49
- /** ARIA role. Defaults to `"status"`. Use `"alert"` for time-sensitive notifications. */
50
- role?: 'status' | 'alert' | 'badge';
38
+ /**
39
+ * ARIA role. A static badge is announced as `"status"`; an interactive badge
40
+ * (`onclick` or `purpose="chip"`, when not `disabled`) defaults to `"button"`
41
+ * so assistive tech announces its activation semantics. Set explicitly to
42
+ * override — e.g. `"alert"` for time-sensitive notifications. An explicit
43
+ * value always wins over the derived default.
44
+ */
45
+ role?: 'status' | 'alert' | 'badge' | 'button';
51
46
  /** Micro-interaction preset. Only applies when the badge is interactive. */
52
47
  mint?: MintProp;
53
48
  }
54
49
  /**
55
- * Dot-style badge a pure indicator. Content is `sr-only` (visually
56
- * hidden), so `children` / `counter` / `removable` / `interactive` /
57
- * `onRemove` are excluded by the type: an invisible remove-button or
58
- * hover-scale on a 2.5 × 2.5 px dot is never the intended UI.
50
+ * The label-only props a pure-indicator dot forbids. An invisible remove
51
+ * button, counter shape, or hover-scale on a 2.5 × 2.5 px dot is never the
52
+ * intended UI, so every dot arm excludes them at the type level.
59
53
  */
60
- interface BadgeDotProps extends BadgeBaseProps {
61
- /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. */
62
- variant: 'dot';
54
+ interface BadgeDotForbiddenProps {
63
55
  children?: never;
64
56
  counter?: never;
65
57
  removable?: never;
66
58
  interactive?: never;
67
59
  onRemove?: never;
68
60
  }
61
+ /**
62
+ * Dot badge selected by the canonical `purpose="dot"` — a pure indicator whose
63
+ * content is hidden. `variant` is inert here (the dot look always wins), and
64
+ * the label-only props are excluded by {@link BadgeDotForbiddenProps}.
65
+ */
66
+ interface BadgeDotByPurposeProps extends BadgeBaseProps, BadgeDotForbiddenProps {
67
+ /**
68
+ * The canonical dot spelling — forces the pure-indicator look regardless of
69
+ * `variant`. For the label roles use `status` / `tag` / `counter` / `chip`
70
+ * (their own arm).
71
+ */
72
+ purpose: 'dot';
73
+ /** Inert under `purpose="dot"`; accepted only so a leftover `variant` compiles. */
74
+ variant?: 'filled' | 'outlined' | 'soft' | 'dot';
75
+ }
76
+ /**
77
+ * Dot badge selected by the deprecated `variant="dot"`. Prefer `purpose="dot"`.
78
+ * Same exclusions as {@link BadgeDotByPurposeProps}.
79
+ */
80
+ interface BadgeDotProps extends BadgeBaseProps, BadgeDotForbiddenProps {
81
+ /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. */
82
+ variant: 'dot';
83
+ /** Only the dot purpose is non-contradictory with `variant="dot"`. */
84
+ purpose?: 'dot';
85
+ }
69
86
  /**
70
87
  * Label-style badge — accepts content, counter shape, remove button, etc.
71
88
  */
72
89
  interface BadgeStandardProps extends BadgeBaseProps {
90
+ /**
91
+ * The badge's semantic purpose — the canonical axis that resolves what the
92
+ * badge *is*, since a bare Badge served overlapping roles. Orchestrates the
93
+ * low-level visual props so you rarely set them directly:
94
+ * - `status` — a state marker (Active, Failed); pairs with `intent`.
95
+ * - `tag` — a neutral inline label (category, version).
96
+ * - `counter` — a compact numeric pill (replaces the `counter` boolean).
97
+ * - `chip` — a removable, interactive filter chip; pair with `removable`.
98
+ *
99
+ * For a pure indicator use `purpose="dot"` (its own arm — it forbids
100
+ * content / counter / remove). Leave unset to drive the badge purely by the
101
+ * low-level props (back-compat); when set, `purpose` wins over the `counter`
102
+ * boolean.
103
+ */
104
+ purpose?: 'status' | 'tag' | 'counter' | 'chip';
73
105
  /** Visual variant. `dot` renders a pure indicator (content hidden); the label variants accept the full surface. @default 'filled' */
74
106
  variant?: 'filled' | 'outlined' | 'soft';
75
107
  /** Badge content (text, icons, numbers). */
@@ -93,10 +125,13 @@ interface BadgeStandardProps extends BadgeBaseProps {
93
125
  * @related Alert
94
126
  * @related Toast
95
127
  *
96
- * Badge props are a discriminated union on `variant`: `variant="dot"`
97
- * forbids `children` / `counter` / `removable` / `interactive` /
98
- * `onRemove` at the type level, while `filled` / `outlined` / `soft`
99
- * accept the full surface.
128
+ * Badge props are a discriminated union. The pure-indicator dot — spelled
129
+ * canonically as `purpose="dot"` or via the deprecated `variant="dot"`
130
+ * forbids `children` / `counter` / `removable` / `interactive` / `onRemove`
131
+ * at the type level, while the label arms (`filled` / `outlined` / `soft`;
132
+ * `purpose` `status` / `tag` / `counter` / `chip`) accept the full surface.
133
+ * An interactive badge (`onclick` or `purpose="chip"`) is announced as a
134
+ * `button`, a static one as `status` (override via `role`).
100
135
  *
101
136
  * @example Purpose-driven (canonical) — the intent reads from `purpose`
102
137
  * ```svelte
@@ -114,6 +149,6 @@ interface BadgeStandardProps extends BadgeBaseProps {
114
149
  * </div>
115
150
  * ```
116
151
  */
117
- export type BadgeProps = BadgeDotProps | BadgeStandardProps;
152
+ export type BadgeProps = BadgeDotByPurposeProps | BadgeDotProps | BadgeStandardProps;
118
153
  export { default as Badge } from './Badge.svelte';
119
154
  export { type BadgePlacement, type BadgeVariants, badgeVariants, PLACEMENT_VALUES } from './badge.variants.js';
@@ -126,6 +126,7 @@
126
126
  for={id}
127
127
  >
128
128
  <input
129
+ {...restProps}
129
130
  bind:this={inputRef}
130
131
  {id}
131
132
  type="checkbox"
@@ -139,7 +140,6 @@
139
140
  aria-invalid={ff.invalid ? 'true' : undefined}
140
141
  aria-describedby={describedBy}
141
142
  onchange={handleChange}
142
- {...restProps}
143
143
  />
144
144
 
145
145
  <span
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLDialogAttributes } from 'svelte/elements';
2
3
  import type { DialogIntent } from '../Dialog/index.js';
3
4
  import type { DialogSlots } from '../Dialog/dialog.variants.js';
4
5
  /**
@@ -36,7 +37,7 @@ export type ConfirmIntent = Exclude<DialogIntent, 'neutral'>;
36
37
  * />
37
38
  * ```
38
39
  */
39
- export interface ConfirmDialogProps {
40
+ export interface ConfirmDialogProps extends Omit<HTMLDialogAttributes, 'children' | 'open' | 'title' | 'draggable'> {
40
41
  /** Controls visibility. Supports bind:open. */
41
42
  open?: boolean;
42
43
  /** Heading shown in the dialog header. */
@@ -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}
@@ -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
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { useBlocksI18n } from '../..';
3
+ import { untrack } from 'svelte';
3
4
  import { fly } from 'svelte/transition';
4
5
  import { getOverlayMotion } from '../../utils';
5
6
  import { toastVariants, type ToastVariants } from './toast.variants';
@@ -88,6 +89,42 @@
88
89
 
89
90
  const visibleToasts = $derived(toaster.toasts.slice(-max));
90
91
 
92
+ // Stranded-pause guard. A toast removed from under the cursor (e.g. its own
93
+ // close button) does not reliably fire `pointerout`, so `pointerInside` can
94
+ // stay stuck `true` and freeze every remaining timer — worst case a fresh
95
+ // toast is then born paused and never counts down. Rather than trust the flag,
96
+ // when the rendered stack SHRINKS we re-derive containment from the real cursor
97
+ // position. The cursor is tracked from the region's own bubbling pointer events
98
+ // (the container is `pointer-events-none`, so these fire only over a toast
99
+ // child, which is exactly when the coords matter).
100
+ let containerEl: HTMLElement | undefined;
101
+ let pointerX = 0;
102
+ let pointerY = 0;
103
+ let prevVisibleCount = 0;
104
+
105
+ function reconcilePointerInside() {
106
+ // Only a stranded `true` needs rescuing; SSR-safe — effects/handlers never
107
+ // run on the server, and `document` is guarded regardless.
108
+ if (typeof document === 'undefined' || !pointerInside || !containerEl) return;
109
+ const hit = document.elementFromPoint(pointerX, pointerY);
110
+ const toastEl = (hit?.closest('[data-toast-id]') ?? null) as HTMLElement | null;
111
+ // Inside only if the cursor sits over a toast that is (a) in THIS region and
112
+ // (b) still live — not the just-removed one, whose node lingers through its
113
+ // fly-out outro.
114
+ pointerInside =
115
+ !!toastEl &&
116
+ containerEl.contains(toastEl) &&
117
+ visibleToasts.some((t) => t.id === toastEl.dataset.toastId);
118
+ }
119
+
120
+ $effect(() => {
121
+ const count = visibleToasts.length;
122
+ untrack(() => {
123
+ if (count < prevVisibleCount) reconcilePointerInside();
124
+ prevVisibleCount = count;
125
+ });
126
+ });
127
+
91
128
  const styles = $derived(
92
129
  unstyled
93
130
  ? {
@@ -151,10 +188,19 @@
151
188
  </script>
152
189
 
153
190
  <div
191
+ bind:this={containerEl}
154
192
  class={[slot('container'), className].filter(Boolean).join(' ')}
155
193
  aria-live="polite"
156
194
  aria-relevant="additions removals"
157
- onpointerover={() => (pointerInside = true)}
195
+ onpointerover={(e) => {
196
+ pointerX = e.clientX;
197
+ pointerY = e.clientY;
198
+ pointerInside = true;
199
+ }}
200
+ onpointermove={(e) => {
201
+ pointerX = e.clientX;
202
+ pointerY = e.clientY;
203
+ }}
158
204
  onpointerout={(e) => {
159
205
  if (leftRegion(e)) pointerInside = false;
160
206
  }}
@@ -166,7 +212,13 @@
166
212
  >
167
213
  {#each visibleToasts as toast (toast.id)}
168
214
  {@const IntentIcon = INTENT_ICON_MAP[toast.intent] ?? INTENT_ICON_MAP.neutral}
169
- <div class={slot('toast', toast.intent)} role="alert" transition:fly={flyParams()}>
215
+ <div
216
+ class={slot('toast', toast.intent)}
217
+ role="alert"
218
+ aria-atomic="true"
219
+ data-toast-id={toast.id}
220
+ transition:fly={flyParams()}
221
+ >
170
222
  {#if toast.loading}
171
223
  <span class={slot('icon', toast.intent)}><Spinner size="sm" /></span>
172
224
  {:else}
@@ -216,13 +268,17 @@
216
268
  {/if}
217
269
 
218
270
  {#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
271
+ <!-- The countdown animation lives in the `<style>` rule below (not inline)
272
+ so `@media (prefers-reduced-motion: reduce)` can reach it an inline
273
+ `animation` shorthand is invisible to media queries. Only the real
274
+ per-toast duration is passed inline, as a custom property.
275
+ `animation-play-state` (also inline, bound to the store's paused flag)
276
+ freezes the bar with the timer on hover/focus and resumes it in place;
277
+ inline wins over the rule's implicit `running`, and toggling only the
222
278
  play-state never restarts the animation. -->
223
279
  <div
224
- class={slot('progress', toast.intent)}
225
- style="animation: blocks-toast-progress {toast.duration}ms linear forwards; animation-play-state: {toaster.paused
280
+ class={[slot('progress', toast.intent), 'blocks-toast-progress-bar']}
281
+ style="--toast-progress-duration: {toast.duration}ms; animation-play-state: {toaster.paused
226
282
  ? 'paused'
227
283
  : 'running'};"
228
284
  ></div>
@@ -240,4 +296,22 @@
240
296
  width: 0%;
241
297
  }
242
298
  }
299
+
300
+ /* The countdown bar animates via this class (not an inline `animation`) so the
301
+ reduced-motion query below can reach it; the per-toast duration arrives as
302
+ the `--toast-progress-duration` custom property. `:global` keeps it off
303
+ Svelte's scoping, consistent with the global keyframe above. */
304
+ :global(.blocks-toast-progress-bar) {
305
+ animation: blocks-toast-progress var(--toast-progress-duration) linear forwards;
306
+ }
307
+
308
+ /* A countdown bar carries no information once it can't animate, so under
309
+ reduced motion we hide it outright rather than leave it frozen. This mirrors
310
+ interaction.css collapsing the motion tokens; the auto-dismiss timing itself
311
+ is unaffected. */
312
+ @media (prefers-reduced-motion: reduce) {
313
+ :global(.blocks-toast-progress-bar) {
314
+ display: none;
315
+ }
316
+ }
243
317
  </style>
@@ -29,6 +29,7 @@
29
29
  preset,
30
30
  withBorder = false,
31
31
  'aria-describedby': ariaDescribedby,
32
+ 'aria-label': ariaLabel,
32
33
  ...restProps
33
34
  }: ToggleProps = $props();
34
35
 
@@ -110,7 +111,11 @@
110
111
  : styles.control({ class: slotClasses?.control })}
111
112
  for={ff.fieldId}
112
113
  >
114
+ <!-- aria-label: a visible `label` wins (stays undefined); with no label a
115
+ consumer-supplied aria-label is preferred over the generic i18n fallback
116
+ so external labelling survives the restProps-first spread. -->
113
117
  <input
118
+ {...restProps}
114
119
  id={ff.fieldId}
115
120
  type="checkbox"
116
121
  role="switch"
@@ -121,11 +126,10 @@
121
126
  {required}
122
127
  class="peer sr-only"
123
128
  aria-checked={checked}
124
- aria-label={label ? undefined : bt('accessibility.toggle') || 'Toggle'}
129
+ aria-label={label ? undefined : (ariaLabel ?? (bt('accessibility.toggle') || 'Toggle'))}
125
130
  aria-describedby={describedBy}
126
131
  aria-invalid={ff.invalid ? 'true' : undefined}
127
132
  onchange={handleChange}
128
- {...restProps}
129
133
  />
130
134
 
131
135
  <span
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.23.0",
3
+ "version": "6.24.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.23.0",
95
- "@urbicon-ui/shared-types": "6.23.0",
94
+ "@urbicon-ui/i18n": "6.24.0",
95
+ "@urbicon-ui/shared-types": "6.24.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",