@urbicon-ui/blocks 6.19.3 → 6.21.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.
@@ -161,6 +161,9 @@
161
161
  if (month == null && year == null) return today;
162
162
  return new Date(year ?? today.getFullYear(), month ?? today.getMonth(), 1);
163
163
  }
164
+ // Uncontrolled seed: capture only the initial default*; later changes to the
165
+ // default* props must not move the user's navigated month.
166
+ // svelte-ignore state_referenced_locally
164
167
  let referenceDate = $state(
165
168
  resolveInitialReference(value, defaultDate, defaultMonth, defaultYear)
166
169
  );
@@ -4,6 +4,9 @@
4
4
 
5
5
  let { icons, children }: { icons: Partial<IconSet>; children: Snippet } = $props();
6
6
 
7
+ // Provider config is applied once at init — icon overrides are not meant to
8
+ // swap reactively after mount.
9
+ // svelte-ignore state_referenced_locally
7
10
  setIcons(icons);
8
11
  </script>
9
12
 
@@ -177,7 +177,7 @@
177
177
 
178
178
  /* === HIGH CONTRAST MODE === */
179
179
 
180
- @media (prefers-contrast: high) {
180
+ @media (prefers-contrast: more) {
181
181
  .blocks-mint-glow {
182
182
  box-shadow: 0 0 0 2px currentColor;
183
183
  }
@@ -36,6 +36,9 @@
36
36
  return Array.isArray(v) ? v : [v];
37
37
  }
38
38
 
39
+ // Uncontrolled seed: capture only the initial `defaultValue`; later changes
40
+ // must not clobber user interaction.
41
+ // svelte-ignore state_referenced_locally
39
42
  let internalValue = $state<string[]>(normalise(defaultValue));
40
43
 
41
44
  const openItems = $derived(value !== undefined ? normalise(value) : internalValue);
@@ -17,6 +17,7 @@
17
17
  intent = 'neutral',
18
18
  status,
19
19
  statusPosition = 'bottom-right',
20
+ pulse = false,
20
21
  ring = false,
21
22
  ringIntent = 'primary',
22
23
  ringColor,
@@ -38,7 +39,6 @@
38
39
 
39
40
  let avatarElement = $state<HTMLElement>();
40
41
  let imageError = $state(false);
41
- let isHovered = $state(false);
42
42
 
43
43
  const isInteractive = $derived(clickable || interactive || !!onclick);
44
44
 
@@ -91,6 +91,7 @@
91
91
  intent: randomColor ? undefined : intent,
92
92
  status,
93
93
  statusPosition,
94
+ pulse,
94
95
  ring,
95
96
  ringIntent: ringColor ? undefined : ringIntent,
96
97
  interactive: isInteractive
@@ -102,31 +103,20 @@
102
103
  resolveSlotClasses(blocksConfig, 'Avatar', preset, variantProps, slotClassesProp)
103
104
  );
104
105
 
105
- const dynamicStyles = $derived.by(() => {
106
- const styles: Record<string, string> = {};
107
-
108
- if (randomColor && randomBg) {
109
- // Inline style property names must be kebab-case, not JS camelCase.
110
- // Previously `styles.backgroundColor` serialised as-is, so browsers
111
- // ignored it and the avatar fell back to surface-interactive + white
112
- // text (1.15:1 contrast).
113
- styles['background-color'] = randomBg;
114
- styles.color = 'white';
115
- }
116
-
117
- if (ringColor) {
118
- styles['border-color'] = ringColor;
119
- }
120
-
121
- return styles;
122
- });
123
-
124
- const styleString = $derived(
125
- Object.entries(dynamicStyles)
126
- .map(([key, value]) => `${key}: ${value}`)
127
- .join('; ')
106
+ // `randomColor` paints the visible fill, so it targets the `frame` slot.
107
+ // Inline property names stay kebab-case — a previous camelCase
108
+ // `backgroundColor` was silently ignored by browsers, dropping the avatar back
109
+ // to surface-interactive + white text.
110
+ const frameStyle = $derived(
111
+ randomColor && randomBg ? `background-color: ${randomBg}; color: white` : undefined
128
112
  );
129
113
 
114
+ // `ringColor` recolours the ring on the outer `base`. The ring is a
115
+ // Tailwind `ring-2` box-shadow, so it reads `--tw-ring-color` — setting
116
+ // `border-color` (the previous approach) was a no-op, as the avatar has no
117
+ // border. Only meaningful together with `ring`.
118
+ const baseStyle = $derived(ringColor ? `--tw-ring-color: ${ringColor}` : undefined);
119
+
130
120
  $effect(() => {
131
121
  if (avatarElement && mint && mint !== 'none' && isInteractive) {
132
122
  return mintRegistry.apply(avatarElement, mint);
@@ -134,12 +124,10 @@
134
124
  });
135
125
 
136
126
  function handleMouseEnter() {
137
- isHovered = true;
138
127
  onHover?.(true);
139
128
  }
140
129
 
141
130
  function handleMouseLeave() {
142
- isHovered = false;
143
131
  onHover?.(false);
144
132
  }
145
133
 
@@ -165,12 +153,13 @@
165
153
  <div
166
154
  bind:this={avatarElement}
167
155
  class={[
156
+ 'blocks-avatar',
168
157
  `blocks-intent-${intent}`,
169
158
  unstyled
170
159
  ? [slotClasses?.base, className].filter(Boolean).join(' ')
171
160
  : styles.base({ class: [slotClasses?.base, className] })
172
161
  ]}
173
- style={styleString}
162
+ style={baseStyle}
174
163
  role={isInteractive ? 'button' : undefined}
175
164
  tabindex={isInteractive ? 0 : undefined}
176
165
  aria-label={isInteractive ? alt || name || bt('accessibility.avatar') : undefined}
@@ -180,24 +169,31 @@
180
169
  onkeydown={handleKeydown}
181
170
  {...restProps}
182
171
  >
183
- {#if src && !imageError}
184
- <img
185
- {src}
186
- alt={alt || name || bt('accessibility.avatar')}
187
- class={unstyled ? (slotClasses?.image ?? '') : styles.image({ class: slotClasses?.image })}
188
- onerror={handleImageError}
189
- />
190
- {:else if children}
191
- {@render children()}
192
- {:else}
193
- <span
194
- class={unstyled
195
- ? (slotClasses?.fallback ?? '')
196
- : styles.fallback({ class: slotClasses?.fallback })}
197
- >
198
- {getInitials(name)}
199
- </span>
200
- {/if}
172
+ <!-- `frame` clips the image/initials to the avatar shape; the status dot
173
+ below is a sibling so it renders outside the clip and stays whole. -->
174
+ <div
175
+ class={unstyled ? (slotClasses?.frame ?? '') : styles.frame({ class: slotClasses?.frame })}
176
+ style={frameStyle}
177
+ >
178
+ {#if src && !imageError}
179
+ <img
180
+ {src}
181
+ alt={alt || name || bt('accessibility.avatar')}
182
+ class={unstyled ? (slotClasses?.image ?? '') : styles.image({ class: slotClasses?.image })}
183
+ onerror={handleImageError}
184
+ />
185
+ {:else if children}
186
+ {@render children()}
187
+ {:else}
188
+ <span
189
+ class={unstyled
190
+ ? (slotClasses?.fallback ?? '')
191
+ : styles.fallback({ class: slotClasses?.fallback })}
192
+ >
193
+ {getInitials(name)}
194
+ </span>
195
+ {/if}
196
+ </div>
201
197
 
202
198
  {#if status}
203
199
  <span
@@ -229,14 +225,44 @@
229
225
  }
230
226
  }
231
227
 
228
+ /* AVT-3: opt-in "live" pulse on the status dot. A radar ring radiates via an
229
+ animated box-shadow — no extra DOM — coloured to match the status through
230
+ the per-status `--blocks-avatar-pulse-color` set in avatar.variants.ts.
231
+ The dot now sits outside the frame's overflow-hidden clip, so the ring is
232
+ never cut off. */
233
+ :global(.blocks-avatar-status-pulse) {
234
+ animation: avatar-status-pulse 1.6s var(--blocks-ease-confident) infinite;
235
+ }
236
+
237
+ /* The ring must reach its brightest while it is already clear of the dot —
238
+ a shadow at spread 0 hides behind the opaque dot, so a plain from/to fade
239
+ only ever shows the faint, expanded tail. The mid stop lights it up at a
240
+ visible radius; it then keeps growing and fades to nothing. */
241
+ @keyframes avatar-status-pulse {
242
+ 0% {
243
+ box-shadow: 0 0 0 0 color-mix(in oklab, var(--blocks-avatar-pulse-color) 0%, transparent);
244
+ }
245
+ 35% {
246
+ box-shadow: 0 0 0 0.22rem
247
+ color-mix(in oklab, var(--blocks-avatar-pulse-color) 60%, transparent);
248
+ }
249
+ 100% {
250
+ box-shadow: 0 0 0 0.6rem color-mix(in oklab, var(--blocks-avatar-pulse-color) 0%, transparent);
251
+ }
252
+ }
253
+
232
254
  @media (prefers-reduced-motion: reduce) {
233
- :global(.blocks-mint-pulse) {
255
+ :global(.blocks-mint-pulse),
256
+ :global(.blocks-avatar-status-pulse) {
234
257
  animation: none;
235
258
  }
236
259
  }
237
260
 
238
- @media (prefers-contrast: high) {
239
- :global([class*='blocks-avatar']) {
261
+ /* High-contrast delineation for the avatar disc. Targets the stable
262
+ `blocks-avatar` root marker exactly — not `[class*='blocks-avatar']`, which
263
+ would also catch the `blocks-avatar-status-pulse` dot and ring it. */
264
+ @media (prefers-contrast: more) {
265
+ :global(.blocks-avatar) {
240
266
  outline: 2px solid currentColor;
241
267
  outline-offset: -2px;
242
268
  }
@@ -29,35 +29,38 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
29
29
  variant: {
30
30
  circle: {
31
31
  base: string;
32
+ frame: string;
32
33
  image: string;
33
34
  };
34
35
  rounded: {
35
36
  base: string;
37
+ frame: string;
36
38
  image: string;
37
39
  };
38
40
  square: {
39
41
  base: string;
42
+ frame: string;
40
43
  image: string;
41
44
  };
42
45
  };
43
46
  intent: {
44
47
  primary: {
45
- base: string;
48
+ frame: string;
46
49
  };
47
50
  secondary: {
48
- base: string;
51
+ frame: string;
49
52
  };
50
53
  success: {
51
- base: string;
54
+ frame: string;
52
55
  };
53
56
  warning: {
54
- base: string;
57
+ frame: string;
55
58
  };
56
59
  danger: {
57
- base: string;
60
+ frame: string;
58
61
  };
59
62
  neutral: {
60
- base: string;
63
+ frame: string;
61
64
  };
62
65
  };
63
66
  status: {
@@ -88,6 +91,11 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
88
91
  status: string;
89
92
  };
90
93
  };
94
+ pulse: {
95
+ true: {
96
+ status: string;
97
+ };
98
+ };
91
99
  ring: {
92
100
  true: {
93
101
  base: string;
@@ -125,6 +133,21 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
125
133
  intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
126
134
  status?: "offline" | "online" | "away" | "busy" | undefined;
127
135
  statusPosition?: "bottom-right" | "top-right" | "bottom-left" | "top-left" | undefined;
136
+ pulse?: boolean | undefined;
137
+ ring?: boolean | undefined;
138
+ ringIntent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
139
+ interactive?: boolean | undefined;
140
+ } & {
141
+ class?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
142
+ className?: string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | (string | false | /*elided*/ any | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined)[] | null | undefined;
143
+ }) | undefined) => string;
144
+ frame: (props?: ({
145
+ size?: "sm" | "md" | "lg" | "xl" | "xs" | "2xl" | undefined;
146
+ variant?: "rounded" | "circle" | "square" | undefined;
147
+ intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
148
+ status?: "offline" | "online" | "away" | "busy" | undefined;
149
+ statusPosition?: "bottom-right" | "top-right" | "bottom-left" | "top-left" | undefined;
150
+ pulse?: boolean | undefined;
128
151
  ring?: boolean | undefined;
129
152
  ringIntent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
130
153
  interactive?: boolean | undefined;
@@ -138,6 +161,7 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
138
161
  intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
139
162
  status?: "offline" | "online" | "away" | "busy" | undefined;
140
163
  statusPosition?: "bottom-right" | "top-right" | "bottom-left" | "top-left" | undefined;
164
+ pulse?: boolean | undefined;
141
165
  ring?: boolean | undefined;
142
166
  ringIntent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
143
167
  interactive?: boolean | undefined;
@@ -151,6 +175,7 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
151
175
  intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
152
176
  status?: "offline" | "online" | "away" | "busy" | undefined;
153
177
  statusPosition?: "bottom-right" | "top-right" | "bottom-left" | "top-left" | undefined;
178
+ pulse?: boolean | undefined;
154
179
  ring?: boolean | undefined;
155
180
  ringIntent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
156
181
  interactive?: boolean | undefined;
@@ -164,6 +189,7 @@ export declare const avatarVariants: (props?: import("../../utils/variants.js").
164
189
  intent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
165
190
  status?: "offline" | "online" | "away" | "busy" | undefined;
166
191
  statusPosition?: "bottom-right" | "top-right" | "bottom-left" | "top-left" | undefined;
192
+ pulse?: boolean | undefined;
167
193
  ring?: boolean | undefined;
168
194
  ringIntent?: "primary" | "secondary" | "success" | "warning" | "danger" | "neutral" | undefined;
169
195
  interactive?: boolean | undefined;
@@ -1,10 +1,19 @@
1
1
  import { tv } from '../../utils/variants.js';
2
2
  export const avatarVariants = tv({
3
3
  slots: {
4
+ // Outer positioning context. Carries sizing, the ring, interactive/mint
5
+ // effects — but NOT `overflow-hidden`, so the status dot (a sibling of
6
+ // `frame`) is never clipped (AVT-1). The radius is mirrored here purely so
7
+ // the ring follows the avatar's shape.
4
8
  base: [
5
- 'relative inline-flex items-center justify-center',
6
- 'overflow-hidden font-semibold text-center select-none shrink-0',
7
- 'transition-[color,background-color,border-color,box-shadow,opacity] duration-[var(--blocks-duration-fast)] ease-out',
9
+ 'relative inline-flex shrink-0',
10
+ 'transition-[color,background-color,border-color,box-shadow,opacity] duration-[var(--blocks-duration-fast)] ease-out'
11
+ ],
12
+ // The visible disc. This is the element that clips the image/initials to
13
+ // the avatar shape; the status dot lives outside it so it stays whole.
14
+ frame: [
15
+ 'flex items-center justify-center w-full h-full overflow-hidden',
16
+ 'font-semibold text-center select-none',
8
17
  'bg-surface-interactive text-text-secondary'
9
18
  ],
10
19
  image: ['w-full h-full object-cover'],
@@ -42,61 +51,68 @@ export const avatarVariants = tv({
42
51
  // people and stand apart from the commit/modify/contain semantic system —
43
52
  // a brand that flattens commit-tier surfaces (e.g. squared pill-buttons)
44
53
  // should not also lose circular avatars. The radii below are physical
45
- // shape tokens, not tier tokens.
54
+ // shape tokens, not tier tokens. `frame` clips the content to the shape;
55
+ // `base` mirrors it so the ring tracks the same silhouette.
46
56
  variant: {
47
57
  circle: {
48
58
  base: 'rounded-full',
59
+ frame: 'rounded-full',
49
60
  image: 'rounded-full'
50
61
  },
51
62
  rounded: {
52
63
  // Avatar-specific soft-square. Maps to --radius-xl (foundation scale);
53
64
  // brands tune via the foundation token, not via a tier override.
54
65
  base: 'rounded-xl',
66
+ frame: 'rounded-xl',
55
67
  image: 'rounded-xl'
56
68
  },
57
69
  square: {
58
70
  base: 'rounded-none',
71
+ frame: 'rounded-none',
59
72
  image: 'rounded-none'
60
73
  }
61
74
  },
62
75
  intent: {
63
76
  primary: {
64
- base: 'bg-primary-subtle text-primary-emphasis'
77
+ frame: 'bg-primary-subtle text-primary-emphasis'
65
78
  },
66
79
  secondary: {
67
- base: 'bg-secondary-subtle text-secondary-emphasis'
80
+ frame: 'bg-secondary-subtle text-secondary-emphasis'
68
81
  },
69
82
  success: {
70
- base: 'bg-success-subtle text-success-emphasis'
83
+ frame: 'bg-success-subtle text-success-emphasis'
71
84
  },
72
85
  warning: {
73
- base: 'bg-warning-subtle text-warning-emphasis'
86
+ frame: 'bg-warning-subtle text-warning-emphasis'
74
87
  },
75
88
  danger: {
76
- base: 'bg-danger-subtle text-danger-emphasis'
89
+ frame: 'bg-danger-subtle text-danger-emphasis'
77
90
  },
78
91
  neutral: {
79
- base: 'bg-surface-interactive text-text-secondary'
92
+ frame: 'bg-surface-interactive text-text-secondary'
80
93
  }
81
94
  },
95
+ // Each status also publishes its colour as `--blocks-avatar-pulse-color`
96
+ // so the opt-in `pulse` ring (see Avatar.svelte) radiates in the matching
97
+ // hue without a second colour source.
82
98
  status: {
83
99
  online: {
84
- status: 'bg-success'
100
+ status: 'bg-success [--blocks-avatar-pulse-color:var(--color-success)]'
85
101
  },
86
102
  offline: {
87
- status: 'bg-text-quaternary'
103
+ status: 'bg-text-quaternary [--blocks-avatar-pulse-color:var(--color-text-quaternary)]'
88
104
  },
89
105
  away: {
90
- status: 'bg-warning'
106
+ status: 'bg-warning [--blocks-avatar-pulse-color:var(--color-warning)]'
91
107
  },
92
108
  busy: {
93
- status: 'bg-danger'
109
+ status: 'bg-danger [--blocks-avatar-pulse-color:var(--color-danger)]'
94
110
  }
95
111
  },
96
- // Status dot is translated out of the avatar's overflow-hidden edge so it
97
- // remains fully visible on circular avatars (AVT-1). Translation magnitude
98
- // is intentionally fractional — the dot sits half-overlapping the edge,
99
- // which matches the conventional badge look (Slack, Discord, Linear).
112
+ // Status dot is translated out of the avatar's edge so it half-overlaps the
113
+ // corner (AVT-1). Translation magnitude is intentionally fractional — the
114
+ // dot sits half-overlapping the edge, which matches the conventional badge
115
+ // look (Slack, Discord, Linear).
100
116
  statusPosition: {
101
117
  'bottom-right': {
102
118
  status: 'bottom-0 right-0 translate-x-1/4 translate-y-1/4'
@@ -111,6 +127,14 @@ export const avatarVariants = tv({
111
127
  status: 'top-0 left-0 -translate-x-1/4 -translate-y-1/4'
112
128
  }
113
129
  },
130
+ // Opt-in "live" pulse on the status dot — a radar ring that draws the eye to
131
+ // presence changes. Only has a visible effect together with `status`; the
132
+ // animation itself + reduced-motion handling live in Avatar.svelte.
133
+ pulse: {
134
+ true: {
135
+ status: 'blocks-avatar-status-pulse'
136
+ }
137
+ },
114
138
  ring: {
115
139
  true: {
116
140
  base: 'ring-2 ring-offset-2 ring-offset-surface-base'
@@ -184,6 +208,7 @@ export const avatarVariants = tv({
184
208
  variant: 'circle',
185
209
  intent: 'neutral',
186
210
  statusPosition: 'bottom-right',
211
+ pulse: false,
187
212
  ring: false,
188
213
  ringIntent: 'primary',
189
214
  interactive: false
@@ -17,6 +17,11 @@ import type { AvatarSlots, AvatarVariants } from './avatar.variants.js';
17
17
  * ```svelte
18
18
  * <Avatar name="Jane Smith" randomColor clickable onclick={() => showProfile()} />
19
19
  * ```
20
+ *
21
+ * @example
22
+ * ```svelte
23
+ * <Avatar name="Ada Lovelace" status="online" pulse />
24
+ * ```
20
25
  */
21
26
  export interface AvatarProps extends AvatarVariants, Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
22
27
  /** Image URL. Falls back to initials or `children` when empty or on load error. */
@@ -43,7 +48,7 @@ export interface AvatarProps extends AvatarVariants, Omit<HTMLAttributes<HTMLDiv
43
48
  class?: string;
44
49
  /** Remove all default tv classes. */
45
50
  unstyled?: boolean;
46
- /** Per-slot class overrides merged with tv styles. Slots: base | image | fallback | status */
51
+ /** Per-slot class overrides merged with tv styles. Slots: base | frame | image | fallback | status */
47
52
  slotClasses?: Partial<Record<AvatarSlots, string>>;
48
53
  /**
49
54
  * Apply a named preset registered via `<BlocksProvider presets={{ Avatar: {...} }}>`.
@@ -28,6 +28,9 @@
28
28
  const blocksConfig = getBlocksConfig();
29
29
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
30
30
 
31
+ // Uncontrolled seed: capture only the initial `defaultOpen`; later changes
32
+ // must not clobber user interaction.
33
+ // svelte-ignore state_referenced_locally
31
34
  let internalOpen = $state(defaultOpen ?? false);
32
35
  const isOpen = $derived(open !== undefined ? open : internalOpen);
33
36
 
@@ -74,6 +74,18 @@ export interface ConfirmDialogProps {
74
74
  closeOnBackdropClick?: boolean;
75
75
  /** Whether Escape cancels. @default true */
76
76
  closeOnEscape?: boolean;
77
+ /**
78
+ * Override the enter/exit animation duration in milliseconds, forwarded to
79
+ * the underlying {@link Dialog}. Defaults to the overlay token
80
+ * `--blocks-overlay-enter-duration` / `--blocks-overlay-exit-duration`
81
+ * (200ms / 180ms). Respects `prefers-reduced-motion`.
82
+ */
83
+ transitionDuration?: number;
84
+ /**
85
+ * Override the enter/exit easing function, forwarded to the underlying
86
+ * {@link Dialog}. Defaults to the overlay token easing (`quintOut`).
87
+ */
88
+ transitionEasing?: (t: number) => number;
77
89
  /** Optional richer markup rendered below `description`. */
78
90
  children?: Snippet;
79
91
  }
@@ -41,6 +41,8 @@
41
41
  ...restProps
42
42
  }: DialogProps = $props();
43
43
 
44
+ // One-shot DEV sanity check on the initial close-path configuration.
45
+ // svelte-ignore state_referenced_locally
44
46
  if (
45
47
  import.meta.env?.DEV &&
46
48
  hideCloseButton &&
@@ -41,6 +41,8 @@
41
41
  ...restProps
42
42
  }: DrawerProps = $props();
43
43
 
44
+ // One-shot DEV sanity check on the initial close-path configuration.
45
+ // svelte-ignore state_referenced_locally
44
46
  if (
45
47
  import.meta.env?.DEV &&
46
48
  hideCloseButton &&
@@ -61,7 +63,7 @@
61
63
  let previouslyFocused: HTMLElement | null = null;
62
64
 
63
65
  const uid = $props.id();
64
- const titleId = title ? `drawer-title-${uid}` : undefined;
66
+ const titleId = $derived(title ? `drawer-title-${uid}` : undefined);
65
67
  const bodyId = `drawer-body-${uid}`;
66
68
  const overlayId = `drawer-${uid}`;
67
69
 
@@ -43,6 +43,7 @@
43
43
 
44
44
  // Provide parent id to descendants exactly once per component instance
45
45
  // so that nested MenuItem components can register with correct parent.
46
+ // svelte-ignore state_referenced_locally
46
47
  setMenuParentId(id);
47
48
 
48
49
  function toggleOpen() {
@@ -63,7 +63,7 @@
63
63
  // `<Select bind:value={x: T | null}>` narrow correctly. Internally we
64
64
  // dispatch through a loose alias so the component code can hand either
65
65
  // shape into `onValueChange` without per-branch casts at every call site.
66
- const dispatchValueChange = onValueChange as ((v: any) => void) | undefined;
66
+ const dispatchValueChange = $derived(onValueChange as ((v: any) => void) | undefined);
67
67
 
68
68
  /** Resolved null option config — `null` when prop is unset or `groups` is in use. */
69
69
  const nullOptionConfig = $derived.by(() => {
@@ -38,6 +38,9 @@
38
38
  let tabListElement = $state<HTMLDivElement>();
39
39
  let indicatorStyle = $state('');
40
40
 
41
+ // Uncontrolled seed: capture only the initial `defaultValue`; later changes
42
+ // must not clobber user interaction.
43
+ // svelte-ignore state_referenced_locally
41
44
  let internalValue = $state(defaultValue || '');
42
45
 
43
46
  const activeValue = $derived(value !== undefined ? value : internalValue);
@@ -157,7 +157,7 @@
157
157
  }
158
158
 
159
159
  /* ===== HIGH CONTRAST MODE ===== */
160
- @media (prefers-contrast: high) {
160
+ @media (prefers-contrast: more) {
161
161
  :root {
162
162
  /* Increase contrast ratios — overrides the semantic tokens that
163
163
  components actually consume. */
@@ -27,6 +27,8 @@
27
27
  buttonGroup: () => setButtonGroupContext(undefined)
28
28
  };
29
29
 
30
+ // Isolation is applied once at init — the prop is not meant to change reactively.
31
+ // svelte-ignore state_referenced_locally
30
32
  for (const key of isolate) {
31
33
  ISOLATIONS[key]?.();
32
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.19.3",
3
+ "version": "6.21.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.19.3",
95
- "@urbicon-ui/shared-types": "6.19.3",
94
+ "@urbicon-ui/i18n": "6.21.0",
95
+ "@urbicon-ui/shared-types": "6.21.0",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",