@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.
- package/dist/primitives/Button/Button.svelte +1 -0
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +48 -30
- package/dist/primitives/ButtonGroup/index.d.ts +6 -0
- package/dist/primitives/Checkbox/Checkbox.svelte +10 -1
- package/dist/primitives/Combobox/Combobox.svelte +23 -13
- package/dist/primitives/Combobox/index.d.ts +12 -0
- package/dist/utils/overlay.js +8 -0
- package/package.json +3 -3
|
@@ -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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
// gets no radio/checkbox role.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
198
|
-
// from single, or it unmounts) — otherwise the imposed -1 would strand
|
|
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
|
|
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.
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// removable rather than silently
|
|
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
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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 =
|
|
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 (
|
|
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 */
|
package/dist/utils/overlay.js
CHANGED
|
@@ -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.
|
|
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.
|
|
98
|
-
"@urbicon-ui/shared-types": "6.
|
|
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",
|