@urbicon-ui/blocks 6.29.2 → 6.30.1

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.
@@ -120,6 +120,7 @@
120
120
  onclick={handleClick}
121
121
  role={ariaProps.role}
122
122
  aria-checked={ariaProps['aria-checked']}
123
+ data-value={ariaProps['data-value']}
123
124
  aria-pressed={ariaProps.role ? undefined : effectiveActive || effectivePressed || undefined}
124
125
  aria-disabled={effectiveDisabled}
125
126
  aria-busy={loading}
@@ -66,15 +66,26 @@
66
66
  }
67
67
  });
68
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[] = [];
69
+ // Bumped (deferred to a microtask — registration runs during the child's
70
+ // render, where a synchronous $state write would be state_unsafe_mutation)
71
+ // whenever a Button registers, so the roving effect below re-runs for
72
+ // buttons mounted *after* the group's initial render. Without it a
73
+ // runtime-added radio kept its native tabbability until the next selection
74
+ // change a second tab stop in the radiogroup.
75
+ let registryVersion = $state(0);
76
+ let registryBumpQueued = false;
77
+
78
+ function noteRegistration() {
79
+ if (registryBumpQueued) return;
80
+ registryBumpQueued = true;
81
+ queueMicrotask(() => {
82
+ registryBumpQueued = false;
83
+ registryVersion++;
84
+ });
85
+ }
75
86
 
76
87
  function registerButton(buttonValue: string | undefined) {
77
- if (buttonValue && !buttonOrder.includes(buttonValue)) buttonOrder.push(buttonValue);
88
+ noteRegistration();
78
89
  return {
79
90
  get isSelected() {
80
91
  return buttonValue ? selectedValues.has(buttonValue) : false;
@@ -100,17 +111,18 @@
100
111
  onSelectionChange?.(value, Array.from(selectedValues));
101
112
  },
102
113
  getButtonProps() {
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.
114
+ // A value-less Button is an action button, not a selection option, so
115
+ // it gets no radio/checkbox role. Selection options additionally carry
116
+ // their value as `data-value`, committed in the same render as the
117
+ // role the radiogroup resolves the selected radio by *matching
118
+ // value* against the queried elements, so duplicate values and
119
+ // runtime-mounted/removed buttons can never drift an index space.
109
120
  if (selection === 'none' || !buttonValue) return {};
110
121
  const checked = selectedValues.has(buttonValue);
111
122
  return {
112
123
  role: selection === 'single' ? ('radio' as const) : ('checkbox' as const),
113
- 'aria-checked': checked
124
+ 'aria-checked': checked,
125
+ 'data-value': buttonValue
114
126
  };
115
127
  }
116
128
  };
@@ -165,24 +177,29 @@
165
177
  : [];
166
178
  }
167
179
 
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;
180
+ // Position of the selected radio within the queried elements: each radio
181
+ // carries its value as `data-value` (same render commit as its role), and
182
+ // the *selected* one is read from the reactive selection not the DOM's
183
+ // aria-checked, which a parent effect can observe before the child
184
+ // <Button>s commit it. Matching by value (not by a parallel registration
185
+ // index) keeps duplicates and runtime-mounted/removed buttons correct: the
186
+ // tab stop sticks to the element whose value is selected, wherever it sits.
187
+ function selectedRadioIndex(radios: readonly HTMLButtonElement[]): number {
188
+ return radios.findIndex((radio) => {
189
+ const v = radio.dataset.value;
190
+ return v !== undefined && selectedValues.has(v);
191
+ });
178
192
  }
179
193
 
180
194
  $effect(() => {
195
+ // `registryVersion` re-runs the assignment when Buttons mount after the
196
+ // group's initial render (the DOM query itself is not reactive).
197
+ void registryVersion;
181
198
  if (selection !== 'single' || !containerElement) return;
182
199
  const radios = rovingRadios();
183
200
  if (radios.length === 0) return;
184
201
 
185
- const checkedIndex = selectedRadioIndex();
202
+ const checkedIndex = selectedRadioIndex(radios);
186
203
  // Nothing selected yet → the first enabled radio holds the tab stop, so the
187
204
  // group stays reachable with Tab (standard radiogroup entry behaviour).
188
205
  const activeIndex =
@@ -194,11 +211,12 @@
194
211
  radio.tabIndex = i === activeIndex ? 0 : -1;
195
212
  });
196
213
 
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.
214
+ // Restore native tabbability when the group stops roving (selection flips
215
+ // away from single, or it unmounts) — otherwise the imposed -1 would strand
216
+ // the buttons out of the tab order. Queried fresh at teardown time so
217
+ // radios mounted since this run are restored too.
200
218
  return () => {
201
- for (const radio of radios) radio.removeAttribute('tabindex');
219
+ for (const radio of rovingRadios()) radio.removeAttribute('tabindex');
202
220
  };
203
221
  });
204
222
 
@@ -212,7 +230,7 @@
212
230
  const radios = rovingRadios();
213
231
  if (radios.length === 0) return;
214
232
 
215
- const currentIndex = selectedRadioIndex();
233
+ const currentIndex = selectedRadioIndex(radios);
216
234
  const isDisabled = (i: number) => radios[i].disabled;
217
235
  let newIndex: number;
218
236
 
@@ -102,6 +102,12 @@ export interface ButtonGroupContext {
102
102
  getButtonProps: () => {
103
103
  role?: 'radio' | 'checkbox';
104
104
  'aria-checked'?: boolean;
105
+ /**
106
+ * The Button's selection value, exposed on the element so the group can
107
+ * resolve the selected radio by matching value instead of by position
108
+ * (robust against duplicate values and runtime add/remove).
109
+ */
110
+ 'data-value'?: string;
105
111
  };
106
112
  };
107
113
  }
@@ -28,6 +28,7 @@
28
28
  id: idProp,
29
29
  mint = 'none',
30
30
  onCheckedChange,
31
+ onchange: userOnChange,
31
32
  class: className = '',
32
33
  unstyled: unstyledProp = false,
33
34
  slotClasses: slotClassesProp = {},
@@ -103,7 +104,7 @@
103
104
  }
104
105
  });
105
106
 
106
- function handleChange(event: Event) {
107
+ function handleChange(event: Event & { currentTarget: EventTarget & HTMLInputElement }) {
107
108
  if (disabled) return;
108
109
  if (indeterminate) indeterminate = false;
109
110
  // Read the new value directly from the input — `bind:checked`
@@ -111,6 +112,14 @@
111
112
  const next = (event.target as HTMLInputElement).checked;
112
113
  checked = next;
113
114
  onCheckedChange?.(next);
115
+ // Forward the consumer's native onchange — Checkbox's own handler sits
116
+ // after `{...restProps}` on the <input>, so without this it would be
117
+ // silently swallowed (the exact failure that broke Table's selection
118
+ // wiring, d7b4dfe; Textarea's oninput forward is the precedent).
119
+ // `onCheckedChange(checked)` stays the canonical callback; the forward
120
+ // runs after the internal state write so the consumer observes the
121
+ // committed value.
122
+ userOnChange?.(event);
114
123
  }
115
124
  </script>
116
125
 
@@ -36,6 +36,7 @@
36
36
  debounceMs = 250,
37
37
  loadingText = 'Loading…',
38
38
  onError,
39
+ seedOptions = [],
39
40
  tier,
40
41
  variant = 'outlined',
41
42
  size = 'md',
@@ -129,12 +130,16 @@
129
130
  // Single-mode selected option. `undefined` in multi mode, which makes every
130
131
  // single-only path keyed on it (the label-restore effect, the query shortcut
131
132
  // in `matchesQuery`, the click-outside/chevron label restore) inert without a
132
- // per-site `!multiple` guard.
133
+ // per-site `!multiple` guard. Lookup order: live options → the pick-cache →
134
+ // the consumer's `seedOptions` (labels for values pre-bound before any
135
+ // options exist, e.g. async mode on mount) — the seed is last so it can
136
+ // never shadow a live option.
133
137
  const selectedOption = $derived(
134
138
  multiple
135
139
  ? undefined
136
140
  : (allOptions.find((o) => o.value === value) ??
137
- (selectedCache && selectedCache.value === value ? selectedCache : undefined))
141
+ (selectedCache && selectedCache.value === value ? selectedCache : undefined) ??
142
+ seedOptions.find((o) => o.value === value))
138
143
  );
139
144
 
140
145
  // ── Multi-select state (multiple) ─────────────────────────────────────────
@@ -147,15 +152,16 @@
147
152
  // the multi-select analogue of `selectedCache`.
148
153
  const tagCache = new SvelteMap<T, ComboboxOption<T>>();
149
154
 
150
- // Resolved options for the selected values, in selection order. Falls back to
151
- // the cache, then to a bare `{ label: String(value) }` for an orphan value
152
- // (bound but never in the options) so the tag still renders and stays
153
- // removable rather than silently vanishing from the array.
155
+ // Resolved options for the selected values, in selection order. Lookup order
156
+ // mirrors `selectedOption`: live options the pick-cache `seedOptions`,
157
+ // then a bare `{ label: String(value) }` for a true orphan (bound but in no
158
+ // source) so the tag still renders and stays removable rather than silently
159
+ // vanishing from the array.
154
160
  //
155
- // The dev-warn is gated to *sync* mode: with `queryFn`, a pre-bound value that
156
- // isn't in the current result set is expected, not a bug (there is no API to
157
- // seed labels for it), so warning there would cry wolf on a legitimate pattern.
158
- // See technical-debt (async pre-selected labels).
161
+ // The dev-warn covers BOTH modes since `seedOptions` exists: a value that is
162
+ // in neither the options, the cache, nor the seed is a consumer gap in async
163
+ // mode too the warn names the API that closes it. (Before the seed there
164
+ // was no way to supply async labels, so warning there would have cried wolf.)
159
165
  //
160
166
  // Warn dedup: `selectedTags` recomputes on every fresh `options` array a
161
167
  // parent re-render passes in (the common `options={items.map(…)}` idiom), so
@@ -166,12 +172,16 @@
166
172
  const warnedOrphanValues = new Set<T>();
167
173
  const selectedTags = $derived.by<ComboboxOption<T>[]>(() =>
168
174
  selectedValues.map((v) => {
169
- const found = allOptions.find((o) => o.value === v) ?? tagCache.get(v);
175
+ const found =
176
+ allOptions.find((o) => o.value === v) ??
177
+ tagCache.get(v) ??
178
+ seedOptions.find((o) => o.value === v);
170
179
  if (found) return found;
171
- if (!queryFn && import.meta.env?.DEV && !warnedOrphanValues.has(v)) {
180
+ if (import.meta.env?.DEV && !warnedOrphanValues.has(v)) {
172
181
  warnedOrphanValues.add(v);
173
182
  console.warn(
174
- `[Combobox] value ${JSON.stringify(v)} has no matching option — tag falls back to its raw value.`
183
+ `[Combobox] value ${JSON.stringify(v)} has no matching option — tag falls back to its raw value. ` +
184
+ 'For pre-selected values (e.g. async mode on mount), supply its label via `seedOptions`.'
175
185
  );
176
186
  }
177
187
  return { label: String(v), value: v } satisfies ComboboxOption<T>;
@@ -75,6 +75,18 @@ interface ComboboxBaseProps<T extends SelectValue = string> extends ComboboxVari
75
75
  * production; it never escapes as an unhandled promise rejection.
76
76
  */
77
77
  onError?: (error: unknown) => void;
78
+ /**
79
+ * Label seed for pre-selected values whose options are not (yet) in the
80
+ * option list — the async-mode pattern of binding `value` on mount before
81
+ * any `queryFn` result has arrived. Consulted as the LAST lookup source when
82
+ * resolving a selected value's label (current options first, then the
83
+ * pick-cache, then this seed), so it can never shadow a live option, and it
84
+ * works identically for single and multi selection. Without a matching seed
85
+ * such a value renders as its raw `String(value)` (and warns DEV-only).
86
+ * Declarative and idempotent — not a second selection source: `value` alone
87
+ * decides what is selected; `seedOptions` only supplies labels.
88
+ */
89
+ seedOptions?: ComboboxOption<T>[];
78
90
  /** Show a clear button when a value is selected. Click or press Escape to reset. @default false */
79
91
  clearable?: boolean;
80
92
  /** Disable the entire combobox. @default false */
@@ -83,6 +83,14 @@ export function focusFirstElement(container) {
83
83
  if (!isBrowser)
84
84
  return;
85
85
  tick().then(() => {
86
+ // The consumer may have already moved focus inside the overlay — the
87
+ // focus-the-heading a11y pattern focuses a `tabindex="-1"` element, which
88
+ // getFocusableElements deliberately excludes, so without this guard the
89
+ // fallback below would steal exactly that focus a tick later. Mirrors the
90
+ // contains-guard trapFocus applies to the same case; `contains` includes
91
+ // the container itself, so a pre-focused panel also stays put.
92
+ if (container?.contains(document.activeElement))
93
+ return;
86
94
  const focusable = getFocusableElements(container);
87
95
  if (focusable.length > 0) {
88
96
  focusable[0].focus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.29.2",
3
+ "version": "6.30.1",
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": {
@@ -94,8 +94,8 @@
94
94
  "@sveltejs/package": "^2.5.8",
95
95
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
96
96
  "@tailwindcss/vite": "^4.3.1",
97
- "@urbicon-ui/i18n": "6.29.2",
98
- "@urbicon-ui/shared-types": "6.29.2",
97
+ "@urbicon-ui/i18n": "6.30.1",
98
+ "@urbicon-ui/shared-types": "6.30.1",
99
99
  "prettier": "^3.8.4",
100
100
  "prettier-plugin-svelte": "^4.1.1",
101
101
  "prettier-plugin-tailwindcss": "^0.8.0",