@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.
- package/dist/components/Calendar/Calendar.svelte +9 -0
- package/dist/components/Calendar/CalendarMiniMonth.svelte +10 -8
- package/dist/components/Calendar/CalendarWeekdayHeader.svelte +4 -1
- package/dist/components/Calendar/calendar.context.d.ts +3 -0
- package/dist/components/Calendar/calendar.drag.js +19 -7
- package/dist/components/CompositionBar/CompositionBar.svelte +6 -2
- package/dist/components/Sankey/Sankey.svelte +18 -2
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +120 -3
- package/dist/primitives/Card/card.variants.js +2 -2
- package/dist/primitives/Checkbox/Checkbox.svelte +28 -10
- package/dist/primitives/Checkbox/checkbox.variants.js +58 -16
- package/dist/primitives/Combobox/Combobox.svelte +35 -3
- package/dist/primitives/Dialog/index.d.ts +4 -4
- package/dist/primitives/Input/Input.svelte +10 -1
- package/dist/primitives/RadioGroup/RadioGroup.svelte +10 -1
- package/dist/primitives/Select/Select.svelte +30 -6
- package/dist/primitives/Slider/Slider.svelte +8 -5
- package/dist/primitives/Textarea/Textarea.svelte +10 -1
- package/dist/primitives/Toast/Toaster.svelte +46 -2
- package/dist/primitives/Toast/toast.store.svelte.d.ts +32 -1
- package/dist/primitives/Toast/toast.store.svelte.js +84 -19
- package/dist/primitives/Toggle/Toggle.svelte +10 -1
- package/dist/style/semantic.css +1 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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="
|
|
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={
|
|
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="
|
|
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
|
-
|
|
95
|
-
|
|
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-
|
|
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
|
|
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`
|
|
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={
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
icon: 'opacity-100 [&_path]:[stroke-dashoffset:0]'
|
|
67
82
|
}
|
|
68
83
|
},
|
|
69
84
|
indeterminate: {
|
|
70
85
|
true: {
|
|
71
|
-
icon: 'opacity-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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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,
|
|
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={
|
|
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).
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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={
|
|
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={
|
|
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-
|
|
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 =
|
|
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 =
|
|
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
|
-
// `
|
|
245
|
-
//
|
|
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]
|
|
248
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
184
|
+
/** Remove all toasts, cancel their timers, and un-freeze the stack. */
|
|
161
185
|
clear() {
|
|
162
|
-
for (const
|
|
163
|
-
|
|
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={
|
|
125
|
+
aria-describedby={describedBy}
|
|
117
126
|
aria-invalid={ff.invalid ? 'true' : undefined}
|
|
118
127
|
onchange={handleChange}
|
|
119
128
|
{...restProps}
|
package/dist/style/semantic.css
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
95
|
-
"@urbicon-ui/shared-types": "6.
|
|
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",
|