@juspay/svelte-ui-components 4.51.0 → 4.52.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/Animations/tokenizedFly.js +9 -2
- package/dist/AreaChart/AreaChart.svelte +41 -13
- package/dist/AreaChart/properties.d.ts +12 -0
- package/dist/AttachmentChipRow/AttachmentChipRow.svelte +7 -6
- package/dist/BarChart/BarChart.svelte +54 -6
- package/dist/BarChart/properties.d.ts +12 -0
- package/dist/Calendar/Calendar.svelte +124 -51
- package/dist/Card/Card.svelte +5 -0
- package/dist/ChatSuggestions/ChatSuggestions.svelte +1 -0
- package/dist/CheckListItem/CheckListItem.svelte +27 -5
- package/dist/ColorPicker/ColorPicker.svelte +40 -9
- package/dist/ColorPicker/properties.d.ts +8 -0
- package/dist/Combobox/Combobox.svelte +34 -2
- package/dist/CommandMenu/CommandMenu.svelte +35 -8
- package/dist/CommandMenu/properties.d.ts +6 -0
- package/dist/DateRangePicker/DateRangePicker.svelte +5 -5
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +41 -7
- package/dist/DualAxisBarChart/properties.d.ts +4 -0
- package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +104 -10
- package/dist/FileDropzoneTrigger/properties.d.ts +14 -4
- package/dist/FileInput/FileInput.svelte +38 -9
- package/dist/FileInput/context.d.ts +20 -0
- package/dist/FileInput/context.js +8 -0
- package/dist/FileInput/properties.d.ts +25 -1
- package/dist/FunnelChart/FunnelChart.svelte +20 -1
- package/dist/FunnelChart/properties.d.ts +4 -0
- package/dist/Gallery/Gallery.svelte +72 -27
- package/dist/HITL/HITL.svelte +12 -3
- package/dist/LineChart/LineChart.svelte +105 -33
- package/dist/LineChart/properties.d.ts +12 -0
- package/dist/MediaPlayer/MediaPlayer.svelte +172 -2
- package/dist/MediaPlayer/MediaPlayer.svelte.d.ts +1 -1
- package/dist/MediaPlayer/properties.d.ts +31 -0
- package/dist/Menu/Menu.svelte +6 -4
- package/dist/Modal/Modal.svelte +15 -12
- package/dist/Modal/properties.d.ts +29 -1
- package/dist/Pagination/Pagination.svelte +1 -0
- package/dist/PieChart/PieChart.svelte +51 -29
- package/dist/PieChart/properties.d.ts +4 -0
- package/dist/Pill/Pill.svelte +5 -0
- package/dist/Radio/Radio.svelte +11 -2
- package/dist/Radio/properties.d.ts +7 -2
- package/dist/SankeyChart/SankeyChart.svelte +339 -211
- package/dist/SankeyChart/properties.d.ts +4 -0
- package/dist/Scroller/Scroller.svelte +236 -4
- package/dist/Scroller/properties.d.ts +8 -0
- package/dist/Select/Select.svelte +43 -5
- package/dist/Select/properties.d.ts +17 -1
- package/dist/Sheet/Sheet.svelte +31 -4
- package/dist/Sheet/properties.d.ts +6 -0
- package/dist/SplitInput/SplitInput.svelte +17 -0
- package/dist/SplitInput/field-name.d.ts +29 -0
- package/dist/SplitInput/field-name.js +50 -0
- package/dist/SplitInput/properties.d.ts +18 -4
- package/dist/Status/Status.svelte +24 -12
- package/dist/Status/properties.d.ts +11 -0
- package/dist/Table/BuiltinCell.svelte +13 -5
- package/dist/Table/BuiltinCell.svelte.d.ts +1 -0
- package/dist/Table/Table.svelte +78 -20
- package/dist/Table/a11y.d.ts +7 -0
- package/dist/Table/a11y.js +71 -0
- package/dist/Table/cellData.js +3 -0
- package/dist/Table/properties.d.ts +13 -5
- package/dist/ThemeSwitcher/ThemeSwitcher.svelte +4 -0
- package/dist/ThinkingIndicator/ThinkingIndicator.svelte +48 -9
- package/dist/Toast/Toast.svelte +40 -4
- package/dist/Toggle/Toggle.svelte +2 -0
- package/dist/Toggle/properties.d.ts +2 -0
- package/dist/_chart/Axis.svelte +10 -0
- package/dist/_chart/ChartContainer.svelte +32 -1
- package/dist/_chart/ChartTooltip.svelte +18 -3
- package/dist/_chart/LabelPlate.svelte +114 -0
- package/dist/_chart/LabelPlate.svelte.d.ts +7 -0
- package/dist/_chart/a11y.d.ts +29 -0
- package/dist/_chart/a11y.js +44 -0
- package/dist/_chart/measure.d.ts +2 -0
- package/dist/_chart/measure.js +4 -0
- package/dist/_chart/tooltipPosition.d.ts +6 -0
- package/dist/_chart/tooltipPosition.js +30 -2
- package/dist/_chart/types.d.ts +22 -0
- package/dist/_interaction/focusable.d.ts +47 -0
- package/dist/_interaction/focusable.js +139 -0
- package/dist/assets/captions-on.svg +3 -0
- package/dist/assets/captions.svg +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/styles/theme-dark.css +6 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +18 -2
- package/dist/utils.js +73 -28
- package/dist-codemod/codemod/migrate-audit.js +9 -7
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +9 -1
- package/dist-codemod/codemod/migration-assets-layout.js +61 -0
- package/dist-wc/index.js +5653 -4148
- package/package.json +4 -2
|
@@ -64,6 +64,10 @@ export type OptionalSankeyChartProperties = {
|
|
|
64
64
|
*/
|
|
65
65
|
tooltipPortal?: boolean;
|
|
66
66
|
empty?: Snippet;
|
|
67
|
+
/** Accessible name of the drawing; omit to derive one from the plotted data. */
|
|
68
|
+
ariaLabel?: string;
|
|
69
|
+
/** Longer chart description, resolved inside the drawing's own DOM/shadow root. */
|
|
70
|
+
ariaDescription?: string;
|
|
67
71
|
testId?: string;
|
|
68
72
|
classes?: string;
|
|
69
73
|
/** Labels rendered above each column, indexed by column position (0-based). */
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { prefersReducedMotion } from '../utils';
|
|
4
4
|
import type { ScrollerProperties, ScrollPosition } from './properties';
|
|
5
5
|
import Button from '../Button/Button.svelte';
|
|
6
|
+
import {
|
|
7
|
+
contentElements,
|
|
8
|
+
composedParent,
|
|
9
|
+
hasTabbableContent,
|
|
10
|
+
interactiveElements
|
|
11
|
+
} from '../_interaction/focusable';
|
|
6
12
|
import chevronLeft from '../assets/chevron-left.svg?raw';
|
|
7
13
|
import chevronRight from '../assets/chevron-right.svg?raw';
|
|
8
14
|
import chevronUp from '../assets/chevron-up.svg?raw';
|
|
@@ -23,7 +29,8 @@
|
|
|
23
29
|
arrowPrevious,
|
|
24
30
|
arrowNext,
|
|
25
31
|
onscrollposition,
|
|
26
|
-
classes
|
|
32
|
+
classes,
|
|
33
|
+
ariaLabel
|
|
27
34
|
}: ScrollerProperties = $props();
|
|
28
35
|
|
|
29
36
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
@@ -31,9 +38,38 @@
|
|
|
31
38
|
let canScrollNext = $state(false);
|
|
32
39
|
let isTouchDevice = $state(false);
|
|
33
40
|
let isDragging = $state(false);
|
|
41
|
+
let isOverflowing = $state(false);
|
|
42
|
+
let contentIsTabbable = $state(false);
|
|
34
43
|
let dragStartPos = 0;
|
|
35
44
|
let dragScrollStart = 0;
|
|
36
45
|
let resizeObserver: ResizeObserver | null = null;
|
|
46
|
+
let mutationObserver: MutationObserver | null = null;
|
|
47
|
+
let refreshFrame: number | null = null;
|
|
48
|
+
/** Whether the frame already scheduled must also re-point the observers, not just re-scan. */
|
|
49
|
+
let refreshRebinds = false;
|
|
50
|
+
|
|
51
|
+
/** Pixels one arrow-key press scrolls, close to what a browser's own line scroll does. */
|
|
52
|
+
const KEY_SCROLL_STEP = 40;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Attributes whose change can add or remove a Tab stop inside the content. `class` and
|
|
56
|
+
* `style` are here because showing or hiding a control (`display: none`, `visibility`)
|
|
57
|
+
* is almost always done through one of them, and that changes whether Tab can reach it
|
|
58
|
+
* without resizing anything the resize observers watch: a control hidden inside a
|
|
59
|
+
* fixed-width child leaves every box around it exactly as large as before.
|
|
60
|
+
*/
|
|
61
|
+
const TAB_ORDER_ATTRIBUTES = [
|
|
62
|
+
'tabindex',
|
|
63
|
+
'disabled',
|
|
64
|
+
'hidden',
|
|
65
|
+
'inert',
|
|
66
|
+
'href',
|
|
67
|
+
'contenteditable',
|
|
68
|
+
'controls',
|
|
69
|
+
'open',
|
|
70
|
+
'class',
|
|
71
|
+
'style'
|
|
72
|
+
];
|
|
37
73
|
|
|
38
74
|
function getScrollProps(el: HTMLElement) {
|
|
39
75
|
if (direction === 'horizontal') {
|
|
@@ -57,6 +93,7 @@
|
|
|
57
93
|
const { scrollOffset, scrollSize, clientSize } = getScrollProps(containerEl);
|
|
58
94
|
canScrollPrev = scrollOffset > 1;
|
|
59
95
|
canScrollNext = scrollOffset < scrollSize - clientSize - 1;
|
|
96
|
+
isOverflowing = scrollSize - clientSize > 1;
|
|
60
97
|
|
|
61
98
|
if (typeof onscrollposition === 'function') {
|
|
62
99
|
const maxScroll = scrollSize - clientSize;
|
|
@@ -95,6 +132,54 @@
|
|
|
95
132
|
scrollBy(1);
|
|
96
133
|
}
|
|
97
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Arrow-key scrolling for the scroll region itself, on its own axis only.
|
|
137
|
+
*
|
|
138
|
+
* Browsers scroll a focused overflow element with the arrow keys, but not uniformly
|
|
139
|
+
* across engines and axes: in WebKit 26.4 a focused vertical region did not respond to
|
|
140
|
+
* ArrowUp/ArrowDown at all, and the distance and timing of the ones that do scroll differ.
|
|
141
|
+
* Handling the axis keys here makes the keyboard route do the same thing everywhere.
|
|
142
|
+
* Only the keys for this component's own axis are taken, so the other axis still scrolls
|
|
143
|
+
* the page, and only when the region itself holds focus -- a control inside the content
|
|
144
|
+
* keeps every key it already handles.
|
|
145
|
+
*/
|
|
146
|
+
function handleRegionKeydown(event: KeyboardEvent) {
|
|
147
|
+
if (
|
|
148
|
+
containerEl === null ||
|
|
149
|
+
event.target !== containerEl ||
|
|
150
|
+
event.defaultPrevented ||
|
|
151
|
+
event.altKey ||
|
|
152
|
+
event.ctrlKey ||
|
|
153
|
+
event.metaKey ||
|
|
154
|
+
event.shiftKey
|
|
155
|
+
) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const [backwardKey, forwardKey] =
|
|
159
|
+
direction === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
|
|
160
|
+
if (event.key !== backwardKey && event.key !== forwardKey) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
// A mandatory snap point swallows a 40px nudge -- the region settles back where it
|
|
165
|
+
// started -- so snapping regions move by the same amount the arrow buttons do.
|
|
166
|
+
const { clientSize } = getScrollProps(containerEl);
|
|
167
|
+
const step = snapToItem ? (scrollAmount ?? clientSize) : KEY_SCROLL_STEP;
|
|
168
|
+
const delta = event.key === forwardKey ? step : -step;
|
|
169
|
+
// Immediate, not animated: a smooth scrollBy measures from wherever the previous
|
|
170
|
+
// animation currently is, so held or rapid presses fell short of their total in
|
|
171
|
+
// Firefox (four presses landed at about 115px instead of 160px) while Chromium
|
|
172
|
+
// reached it. A jump keeps the distance exact in every engine, and is also what the
|
|
173
|
+
// OS reduced-motion setting asks for.
|
|
174
|
+
const options: ScrollToOptions = { behavior: 'instant' };
|
|
175
|
+
if (direction === 'horizontal') {
|
|
176
|
+
options.left = delta;
|
|
177
|
+
} else {
|
|
178
|
+
options.top = delta;
|
|
179
|
+
}
|
|
180
|
+
containerEl.scrollBy(options);
|
|
181
|
+
}
|
|
182
|
+
|
|
98
183
|
function handleDragStart(event: MouseEvent) {
|
|
99
184
|
if (!dragToScroll || containerEl === null) {
|
|
100
185
|
return;
|
|
@@ -151,14 +236,131 @@
|
|
|
151
236
|
containerEl.style.userSelect = '';
|
|
152
237
|
}
|
|
153
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Re-points both observers at what the region currently contains. The region's own size
|
|
241
|
+
* is not the only thing that decides overflow: content that grows, shrinks or is swapped
|
|
242
|
+
* changes `scrollWidth` without resizing the region, so each piece of content is watched
|
|
243
|
+
* too. Through `<sui-scroller>` the content is slotted light-DOM markup that a mutation
|
|
244
|
+
* observer on the region cannot see, hence `contentElements` and the per-element
|
|
245
|
+
* subtree observers.
|
|
246
|
+
*/
|
|
247
|
+
function observeContent() {
|
|
248
|
+
if (containerEl === null || resizeObserver === null || mutationObserver === null) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
resizeObserver.disconnect();
|
|
252
|
+
mutationObserver.disconnect();
|
|
253
|
+
resizeObserver.observe(containerEl);
|
|
254
|
+
mutationObserver.observe(containerEl, { childList: true, subtree: true, characterData: true });
|
|
255
|
+
// An enclosing fieldset can disable the only child control without changing
|
|
256
|
+
// any content box or any attribute inside the region (including slotted WCs).
|
|
257
|
+
for (
|
|
258
|
+
let ancestor = composedParent(containerEl);
|
|
259
|
+
ancestor;
|
|
260
|
+
ancestor = composedParent(ancestor)
|
|
261
|
+
) {
|
|
262
|
+
mutationObserver.observe(ancestor, {
|
|
263
|
+
attributes: true,
|
|
264
|
+
childList: true,
|
|
265
|
+
attributeFilter: TAB_ORDER_ATTRIBUTES
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
for (const element of contentElements(containerEl)) {
|
|
269
|
+
resizeObserver.observe(element);
|
|
270
|
+
mutationObserver.observe(element, {
|
|
271
|
+
childList: true,
|
|
272
|
+
subtree: true,
|
|
273
|
+
characterData: true,
|
|
274
|
+
attributes: true,
|
|
275
|
+
attributeFilter: TAB_ORDER_ATTRIBUTES
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// A stylesheet can hide or show a control with no write to the content and no change to
|
|
279
|
+
// any box above: a class on an ancestor outside this component, a media query. The
|
|
280
|
+
// control itself is the one thing that always changes then -- its box goes to nothing
|
|
281
|
+
// or comes back -- so each one is watched too, including those hidden right now.
|
|
282
|
+
for (const control of interactiveElements(containerEl, { includeHidden: true })) {
|
|
283
|
+
resizeObserver.observe(control);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Whether anything inside the content is reachable with Tab right now. */
|
|
288
|
+
function scanTabbableContent() {
|
|
289
|
+
if (containerEl !== null) {
|
|
290
|
+
contentIsTabbable = hasTabbableContent(containerEl);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Content was added, removed or swapped: re-aim the observers at what is there now,
|
|
296
|
+
* then re-measure and re-scan for Tab stops.
|
|
297
|
+
*/
|
|
298
|
+
function refreshContent() {
|
|
299
|
+
if (containerEl === null) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
observeContent();
|
|
303
|
+
updateScrollState();
|
|
304
|
+
scanTabbableContent();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Content kept its structure but may have changed size or visibility -- a class or
|
|
309
|
+
* inline style hid or showed a control, a media query flipped, the viewport moved.
|
|
310
|
+
* Re-measures overflow and re-scans Tab stops without re-aiming the observers, which is
|
|
311
|
+
* the expensive part and unnecessary here. Deliberately does not report a scroll position:
|
|
312
|
+
* nothing scrolled, and a content element animating its style would otherwise make
|
|
313
|
+
* `onscrollposition` fire every frame.
|
|
314
|
+
*/
|
|
315
|
+
function rescanTabOrder() {
|
|
316
|
+
if (containerEl === null) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const { scrollSize, clientSize } = getScrollProps(containerEl);
|
|
320
|
+
isOverflowing = scrollSize - clientSize > 1;
|
|
321
|
+
scanTabbableContent();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** One refresh per frame however many mutations arrive; a structural one wins over a rescan. */
|
|
325
|
+
function scheduleRefresh(rebind: boolean) {
|
|
326
|
+
refreshRebinds ||= rebind;
|
|
327
|
+
if (refreshFrame !== null) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
refreshFrame = requestAnimationFrame(() => {
|
|
331
|
+
refreshFrame = null;
|
|
332
|
+
const rebinds = refreshRebinds;
|
|
333
|
+
refreshRebinds = false;
|
|
334
|
+
if (rebinds) {
|
|
335
|
+
refreshContent();
|
|
336
|
+
} else {
|
|
337
|
+
rescanTabOrder();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const handleSlotChange = () => scheduleRefresh(true);
|
|
343
|
+
// A media query can show or hide a control with no change to the DOM or to any box the
|
|
344
|
+
// observers watch, so a viewport change is its own trigger.
|
|
345
|
+
const handleWindowResize = () => scheduleRefresh(false);
|
|
346
|
+
|
|
154
347
|
onMount(() => {
|
|
155
348
|
isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
156
349
|
if (containerEl !== null) {
|
|
157
|
-
updateScrollState();
|
|
158
350
|
resizeObserver = new ResizeObserver(() => {
|
|
351
|
+
// Content that resizes can fit or overflow, and can also be a control that was
|
|
352
|
+
// hidden and is now back, so a resize re-scans the Tab order as well as the offsets.
|
|
159
353
|
updateScrollState();
|
|
354
|
+
scanTabbableContent();
|
|
160
355
|
});
|
|
161
|
-
|
|
356
|
+
mutationObserver = new MutationObserver((records) => {
|
|
357
|
+
scheduleRefresh(records.some((record) => record.type !== 'attributes'));
|
|
358
|
+
});
|
|
359
|
+
refreshContent();
|
|
360
|
+
// Slot assignment changes (a `sui-scroller` whose slotted content is replaced)
|
|
361
|
+
// bubble up from the slot element that the web-component wrapper renders.
|
|
362
|
+
containerEl.addEventListener('slotchange', handleSlotChange);
|
|
363
|
+
window.addEventListener('resize', handleWindowResize);
|
|
162
364
|
|
|
163
365
|
if (dragToScroll) {
|
|
164
366
|
window.addEventListener('mousemove', handleDragMove);
|
|
@@ -167,6 +369,13 @@
|
|
|
167
369
|
}
|
|
168
370
|
return () => {
|
|
169
371
|
resizeObserver?.disconnect();
|
|
372
|
+
mutationObserver?.disconnect();
|
|
373
|
+
containerEl?.removeEventListener('slotchange', handleSlotChange);
|
|
374
|
+
window.removeEventListener('resize', handleWindowResize);
|
|
375
|
+
if (refreshFrame !== null) {
|
|
376
|
+
cancelAnimationFrame(refreshFrame);
|
|
377
|
+
refreshFrame = null;
|
|
378
|
+
}
|
|
170
379
|
if (dragToScroll) {
|
|
171
380
|
window.removeEventListener('mousemove', handleDragMove);
|
|
172
381
|
window.removeEventListener('mouseup', handleDragEnd);
|
|
@@ -176,6 +385,17 @@
|
|
|
176
385
|
|
|
177
386
|
let showArrowControls = $derived(showArrows && !(hideArrowsOnTouch && isTouchDevice));
|
|
178
387
|
let showGradientOverlay = $derived(showGradient && !(hideArrowsOnTouch && isTouchDevice));
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The region is a Tab stop only while it is the sole keyboard route to its content:
|
|
391
|
+
* it overflows, no arrow buttons are rendered to scroll it, and nothing inside it is
|
|
392
|
+
* focusable already (focus moving to such a child scrolls it into view by itself).
|
|
393
|
+
* Anything else would add a stop that leads nowhere, or one more stop before the
|
|
394
|
+
* controls that already work.
|
|
395
|
+
*/
|
|
396
|
+
let regionIsKeyboardRoute = $derived(isOverflowing && !showArrowControls && !contentIsTabbable);
|
|
397
|
+
// A focusable region must be named; a region the consumer named keeps that name always.
|
|
398
|
+
let regionLabel = $derived(ariaLabel ?? (regionIsKeyboardRoute ? 'Scrollable content' : null));
|
|
179
399
|
</script>
|
|
180
400
|
|
|
181
401
|
<div
|
|
@@ -209,6 +429,7 @@
|
|
|
209
429
|
{/if}
|
|
210
430
|
|
|
211
431
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
432
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
212
433
|
<div
|
|
213
434
|
class="scroll-container"
|
|
214
435
|
class:hide-scrollbar={hideScrollbar}
|
|
@@ -217,8 +438,10 @@
|
|
|
217
438
|
bind:this={containerEl}
|
|
218
439
|
onscroll={updateScrollState}
|
|
219
440
|
onmousedown={dragToScroll ? handleDragStart : null}
|
|
441
|
+
onkeydown={handleRegionKeydown}
|
|
220
442
|
role="region"
|
|
221
|
-
|
|
443
|
+
aria-label={regionLabel}
|
|
444
|
+
tabindex={regionIsKeyboardRoute ? 0 : -1}
|
|
222
445
|
>
|
|
223
446
|
{@render children()}
|
|
224
447
|
</div>
|
|
@@ -348,6 +571,15 @@
|
|
|
348
571
|
cursor: grabbing;
|
|
349
572
|
}
|
|
350
573
|
|
|
574
|
+
/* :focus-visible so a pointer press on the region does not ring it, while the Tab stop
|
|
575
|
+
it becomes when nothing else can scroll it always does. Inset by default: the ring
|
|
576
|
+
sits inside the region's own box, so it is not cut off by an `overflow: hidden`
|
|
577
|
+
ancestor the way an outset ring would be. */
|
|
578
|
+
.scroll-container:focus-visible {
|
|
579
|
+
outline: var(--scroller-focus-outline, 2px solid #2563eb);
|
|
580
|
+
outline-offset: var(--scroller-focus-outline-offset, -2px);
|
|
581
|
+
}
|
|
582
|
+
|
|
351
583
|
.arrow {
|
|
352
584
|
display: flex;
|
|
353
585
|
align-items: center;
|
|
@@ -24,6 +24,14 @@ export type OptionalScrollerProperties = {
|
|
|
24
24
|
arrowPrevious?: Snippet;
|
|
25
25
|
arrowNext?: Snippet;
|
|
26
26
|
classes?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Accessible name for the scroll region. While the region is the only keyboard route to
|
|
29
|
+
* overflowing content (no arrows, nothing focusable inside) it is a Tab stop and is named
|
|
30
|
+
* `Scrollable content` unless this says otherwise; a region given a name keeps it whether
|
|
31
|
+
* or not it is currently a Tab stop. Prefer a name that says what scrolls, e.g.
|
|
32
|
+
* `Release timeline`.
|
|
33
|
+
*/
|
|
34
|
+
ariaLabel?: string;
|
|
27
35
|
};
|
|
28
36
|
export type ScrollerEventProperties = {
|
|
29
37
|
onscrollposition?: (position: ScrollPosition) => void;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
searchPosition = 'trigger',
|
|
19
19
|
placeholder = '',
|
|
20
20
|
ariaLabel,
|
|
21
|
+
ariaLabelledby,
|
|
21
22
|
disabled = false,
|
|
22
23
|
error = false,
|
|
23
24
|
errorMessage,
|
|
@@ -161,6 +162,22 @@
|
|
|
161
162
|
})
|
|
162
163
|
);
|
|
163
164
|
|
|
165
|
+
/* The field's name, applied to every element a user can land on: the combobox
|
|
166
|
+
trigger, the text input that replaces its label when the filter lives in the
|
|
167
|
+
trigger (which is the control that actually takes focus there, and would
|
|
168
|
+
otherwise be named only by its placeholder), and the listbox they open.
|
|
169
|
+
Blank strings are dropped rather than emitted, so `aria-label=""` can never
|
|
170
|
+
erase the name an element would otherwise get. The component API gives
|
|
171
|
+
`ariaLabel` priority over `ariaLabelledby`, following Slider's contract. */
|
|
172
|
+
const accessibleName = $derived(
|
|
173
|
+
typeof ariaLabel === 'string' && ariaLabel.trim() !== '' ? ariaLabel : null
|
|
174
|
+
);
|
|
175
|
+
const accessibleLabelledby = $derived(
|
|
176
|
+
accessibleName === null && typeof ariaLabelledby === 'string' && ariaLabelledby.trim() !== ''
|
|
177
|
+
? ariaLabelledby.trim()
|
|
178
|
+
: null
|
|
179
|
+
);
|
|
180
|
+
|
|
164
181
|
/* Nothing selected means nothing to clear, so the control is not rendered at
|
|
165
182
|
all rather than rendered inert -- a disabled × that never enables reads as
|
|
166
183
|
broken. Same for `disabled`, where the whole field is inert already. */
|
|
@@ -680,7 +697,19 @@
|
|
|
680
697
|
return;
|
|
681
698
|
}
|
|
682
699
|
query = event.target.value;
|
|
683
|
-
|
|
700
|
+
/* An input event is only a request to open when the field it came from can be
|
|
701
|
+
typed into while the panel is closed -- the in-trigger input, whose typing
|
|
702
|
+
is how a closed Select opens. The in-menu search box exists only while the
|
|
703
|
+
panel is open, so an event arriving from it with `open` already false is
|
|
704
|
+
not the user typing: it is the browser clearing the field as Escape
|
|
705
|
+
dismisses the panel. Firefox does that for an <input type="search"> and
|
|
706
|
+
dispatches the input event synchronously inside the same keydown, before
|
|
707
|
+
Svelte has flushed the close to the DOM -- so honouring it re-opened the
|
|
708
|
+
panel that Escape had just closed (and fired onopen straight after
|
|
709
|
+
onclose), leaving aria-expanded true and the next trigger click toggling
|
|
710
|
+
it shut. Chromium clears after the flush and WebKit does not clear at all,
|
|
711
|
+
which is why only Firefox showed it. */
|
|
712
|
+
if (!open && !searchInMenu) {
|
|
684
713
|
openDropdown();
|
|
685
714
|
}
|
|
686
715
|
highlightedIndex = -1;
|
|
@@ -755,10 +784,12 @@
|
|
|
755
784
|
onclick={handleTriggerClick}
|
|
756
785
|
onkeydown={handleKeydown}
|
|
757
786
|
role="combobox"
|
|
758
|
-
aria-label={ariaLabel}
|
|
759
787
|
aria-expanded={open}
|
|
760
788
|
aria-haspopup="listbox"
|
|
761
789
|
aria-controls={listboxId}
|
|
790
|
+
aria-label={accessibleName}
|
|
791
|
+
aria-labelledby={accessibleLabelledby}
|
|
792
|
+
aria-disabled={disabled ? 'true' : null}
|
|
762
793
|
aria-invalid={field.ariaInvalid}
|
|
763
794
|
aria-describedby={field.describedBy}
|
|
764
795
|
{...highlightedOptionId !== null ? { 'aria-activedescendant': highlightedOptionId } : {}}
|
|
@@ -781,13 +812,14 @@
|
|
|
781
812
|
<input
|
|
782
813
|
class="select-search"
|
|
783
814
|
type="text"
|
|
784
|
-
aria-label={ariaLabel}
|
|
785
815
|
value={query}
|
|
786
816
|
oninput={handleSearchInput}
|
|
787
817
|
onfocus={handleSearchFocus}
|
|
788
818
|
bind:this={searchInputEl}
|
|
789
819
|
placeholder={value.length === 0 ? placeholder : ''}
|
|
790
820
|
{disabled}
|
|
821
|
+
aria-label={accessibleName}
|
|
822
|
+
aria-labelledby={accessibleLabelledby}
|
|
791
823
|
aria-invalid={field.ariaInvalid}
|
|
792
824
|
aria-describedby={field.describedBy}
|
|
793
825
|
autocomplete="off"
|
|
@@ -810,13 +842,14 @@
|
|
|
810
842
|
<input
|
|
811
843
|
class="select-search"
|
|
812
844
|
type="text"
|
|
813
|
-
aria-label={ariaLabel}
|
|
814
845
|
value={query}
|
|
815
846
|
oninput={handleSearchInput}
|
|
816
847
|
onfocus={handleSearchFocus}
|
|
817
848
|
bind:this={searchInputEl}
|
|
818
849
|
placeholder={value.length === 0 ? placeholder : ''}
|
|
819
850
|
{disabled}
|
|
851
|
+
aria-label={accessibleName}
|
|
852
|
+
aria-labelledby={accessibleLabelledby}
|
|
820
853
|
aria-invalid={field.ariaInvalid}
|
|
821
854
|
aria-describedby={field.describedBy}
|
|
822
855
|
autocomplete="off"
|
|
@@ -832,13 +865,14 @@
|
|
|
832
865
|
<input
|
|
833
866
|
class="select-search"
|
|
834
867
|
type="text"
|
|
835
|
-
aria-label={ariaLabel}
|
|
836
868
|
value={open ? query : displayText}
|
|
837
869
|
oninput={handleSearchInput}
|
|
838
870
|
onfocus={handleSearchFocus}
|
|
839
871
|
bind:this={searchInputEl}
|
|
840
872
|
placeholder={searchPlaceholder}
|
|
841
873
|
{disabled}
|
|
874
|
+
aria-label={accessibleName}
|
|
875
|
+
aria-labelledby={accessibleLabelledby}
|
|
842
876
|
aria-invalid={field.ariaInvalid}
|
|
843
877
|
aria-describedby={field.describedBy}
|
|
844
878
|
autocomplete="off"
|
|
@@ -1059,6 +1093,8 @@
|
|
|
1059
1093
|
class="select-menu-list"
|
|
1060
1094
|
role="listbox"
|
|
1061
1095
|
id={listboxId}
|
|
1096
|
+
aria-label={accessibleName}
|
|
1097
|
+
aria-labelledby={accessibleLabelledby}
|
|
1062
1098
|
aria-multiselectable={multiple}
|
|
1063
1099
|
tabindex="-1"
|
|
1064
1100
|
>
|
|
@@ -1088,6 +1124,8 @@
|
|
|
1088
1124
|
bind:clientHeight={dropdownHeight}
|
|
1089
1125
|
role="listbox"
|
|
1090
1126
|
id={listboxId}
|
|
1127
|
+
aria-label={accessibleName}
|
|
1128
|
+
aria-labelledby={accessibleLabelledby}
|
|
1091
1129
|
aria-multiselectable={multiple}
|
|
1092
1130
|
style={portalStyle}
|
|
1093
1131
|
use:portalToRoot
|
|
@@ -55,8 +55,24 @@ export type OptionalSelectProperties = {
|
|
|
55
55
|
*/
|
|
56
56
|
searchPosition?: SelectSearchPosition;
|
|
57
57
|
placeholder?: string;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Names the field for assistive tech, as `aria-label` on the combobox trigger
|
|
60
|
+
* (and on the text input inside it for `searchable` with the input in the
|
|
61
|
+
* trigger, which is the control that actually takes focus there, and on the
|
|
62
|
+
* open listbox). The placeholder and the selected option are the value, not
|
|
63
|
+
* the field's identity, so a Select with neither visible label text nor this
|
|
64
|
+
* announces only "Choose a fruit, combobox" or "Apple, combobox".
|
|
65
|
+
* The name does not change with the selection, so it stays meaningful when
|
|
66
|
+
* the Select is empty, selected, invalid or disabled.
|
|
67
|
+
*/
|
|
59
68
|
ariaLabel?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Ids of the elements that name the field (`aria-labelledby`), for a label that is
|
|
71
|
+
* already on screen. `ariaLabel` wins if both are given. Ids resolve in the same
|
|
72
|
+
* DOM root as the Select; for `<sui-select>` the wrapper resolves them from the
|
|
73
|
+
* host's root, because an id reference does not cross a shadow boundary.
|
|
74
|
+
*/
|
|
75
|
+
ariaLabelledby?: string;
|
|
60
76
|
disabled?: boolean;
|
|
61
77
|
/**
|
|
62
78
|
* Renders the error treatment on the trigger — a red border that outranks
|
package/dist/Sheet/Sheet.svelte
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
import type { SheetProperties } from './properties';
|
|
4
4
|
import { fade } from 'svelte/transition';
|
|
5
5
|
import { tokenizedFly } from '../Animations/tokenizedFly';
|
|
6
|
-
import {
|
|
7
|
-
import { tick } from 'svelte';
|
|
6
|
+
import { acquireScrollLock, resolveScrollContainer } from '../utils';
|
|
7
|
+
import { tick, onMount, untrack } from 'svelte';
|
|
8
|
+
import { toStore } from 'svelte/store';
|
|
8
9
|
import Button from '../Button/Button.svelte';
|
|
9
10
|
import { focusTrapTabTarget, getActiveElement } from '../_interaction/focus';
|
|
10
11
|
import { registerDismissible } from '../_interaction/dismissal';
|
|
11
12
|
|
|
12
13
|
let {
|
|
13
14
|
open = $bindable(false),
|
|
15
|
+
scrollContainer,
|
|
14
16
|
side = 'right',
|
|
15
17
|
title,
|
|
16
18
|
showOverlay = true,
|
|
@@ -150,6 +152,27 @@
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
let scrollLockNode: HTMLElement | null = null;
|
|
156
|
+
let releaseScrollLock: (() => void) | null = null;
|
|
157
|
+
let scrollLockSpanEnded = false;
|
|
158
|
+
|
|
159
|
+
onMount(() =>
|
|
160
|
+
toStore(() => open).subscribe((isOpen) => {
|
|
161
|
+
if (!isOpen) {
|
|
162
|
+
scrollLockSpanEnded = true;
|
|
163
|
+
} else if (scrollLockSpanEnded && scrollLockNode !== null) {
|
|
164
|
+
// Reversing an outro reuses the action. Resolve the next logical open's
|
|
165
|
+
// owner without subscribing to ownership changes during that open.
|
|
166
|
+
scrollLockSpanEnded = false;
|
|
167
|
+
const releaseNext = untrack(() =>
|
|
168
|
+
acquireScrollLock(resolveScrollContainer(scrollLockNode, scrollContainer))
|
|
169
|
+
);
|
|
170
|
+
releaseScrollLock?.();
|
|
171
|
+
releaseScrollLock = releaseNext;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
|
|
153
176
|
function scrollLockAction(node: HTMLElement) {
|
|
154
177
|
// getActiveElement rather than document.activeElement: when the sheet is
|
|
155
178
|
// used through its custom element the opener lives inside a shadow root,
|
|
@@ -157,7 +180,9 @@
|
|
|
157
180
|
// could never be handed focus back.
|
|
158
181
|
const previousFocus = getActiveElement(node);
|
|
159
182
|
let active = true;
|
|
160
|
-
|
|
183
|
+
scrollLockNode = node;
|
|
184
|
+
scrollLockSpanEnded = false;
|
|
185
|
+
releaseScrollLock = acquireScrollLock(resolveScrollContainer(node, scrollContainer));
|
|
161
186
|
// Registered here rather than in an onMount/onDestroy pair: this action's
|
|
162
187
|
// mount/destroy already brackets exactly the open span (the overlay only
|
|
163
188
|
// exists while `open` is true, per the `{#if open}` below), the same
|
|
@@ -177,7 +202,9 @@
|
|
|
177
202
|
destroy() {
|
|
178
203
|
active = false;
|
|
179
204
|
releaseDismissible();
|
|
180
|
-
|
|
205
|
+
releaseScrollLock?.();
|
|
206
|
+
releaseScrollLock = null;
|
|
207
|
+
scrollLockNode = null;
|
|
181
208
|
// Only take focus back when it is still somewhere the sheet is
|
|
182
209
|
// responsible for -- inside the panel, or nowhere at all. If the user
|
|
183
210
|
// has already moved on to another control, stealing it back would be
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ScrollContainer } from '../types';
|
|
1
2
|
import type { Snippet } from 'svelte';
|
|
2
3
|
export type SheetSide = 'left' | 'right' | 'top' | 'bottom' | 'center';
|
|
3
4
|
export type SheetProperties = MandatorySheetProperties & OptionalSheetProperties & SheetEventProperties;
|
|
@@ -5,6 +6,11 @@ export type MandatorySheetProperties = {
|
|
|
5
6
|
content: Snippet;
|
|
6
7
|
};
|
|
7
8
|
export type OptionalSheetProperties = {
|
|
9
|
+
/** Scroll owner, resolved once per open span. Omit for the overlay's ownerDocument.body.
|
|
10
|
+
* Supply the embedded scroll element or a getter; explicit null means no lock.
|
|
11
|
+
* Changing ownership while open takes effect on the next open, and teardown
|
|
12
|
+
* always releases the originally acquired container. */
|
|
13
|
+
scrollContainer?: ScrollContainer;
|
|
8
14
|
open?: boolean;
|
|
9
15
|
side?: SheetSide;
|
|
10
16
|
title?: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { tick } from 'svelte';
|
|
3
3
|
import { describeField } from '../_field/description';
|
|
4
4
|
import Input from '../Input/Input.svelte';
|
|
5
|
+
import { resolveFieldName } from './field-name';
|
|
5
6
|
import type { FieldConfig, SplitInputProperties } from './properties';
|
|
6
7
|
|
|
7
8
|
let {
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
testId,
|
|
15
16
|
classes,
|
|
16
17
|
ariaLabel,
|
|
18
|
+
positionLabel,
|
|
17
19
|
errorMessage,
|
|
18
20
|
infoMessage,
|
|
19
21
|
invalid = false,
|
|
@@ -43,6 +45,20 @@
|
|
|
43
45
|
|
|
44
46
|
let fieldCount = $derived(typeof fields !== 'undefined' ? fields.length : length);
|
|
45
47
|
|
|
48
|
+
/* A box is named by what it is, not left to the group's name alone: see
|
|
49
|
+
`resolveFieldName` for the precedence. Each box is an `Input` with
|
|
50
|
+
`actionInput`, which never renders Input's own <label>, so `ariaLabel` on the
|
|
51
|
+
inner Input is the only way its name can be set. */
|
|
52
|
+
function fieldName(index: number, config: FieldConfig): string {
|
|
53
|
+
return resolveFieldName({
|
|
54
|
+
index,
|
|
55
|
+
total: fieldCount,
|
|
56
|
+
config,
|
|
57
|
+
groupLabel: ariaLabel,
|
|
58
|
+
positionLabel
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
let fieldConfigs: FieldConfig[] = $derived(
|
|
47
63
|
typeof fields !== 'undefined'
|
|
48
64
|
? fields
|
|
@@ -273,6 +289,7 @@
|
|
|
273
289
|
validators={config.validators ?? []}
|
|
274
290
|
autoComplete={config.autoComplete ?? 'on'}
|
|
275
291
|
inputMode={config.inputMode}
|
|
292
|
+
ariaLabel={fieldName(index, config)}
|
|
276
293
|
disable={disabled}
|
|
277
294
|
actionInput={true}
|
|
278
295
|
classes="field-group-input"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FieldConfig } from './properties';
|
|
2
|
+
type NamedFieldConfig = Pick<FieldConfig, 'ariaLabel' | 'label' | 'dataType' | 'maxLength'>;
|
|
3
|
+
export type FieldNameContext = {
|
|
4
|
+
readonly index: number;
|
|
5
|
+
readonly total: number;
|
|
6
|
+
readonly config: NamedFieldConfig;
|
|
7
|
+
/** The group's own name (`SplitInput`'s `ariaLabel`), when it has one. */
|
|
8
|
+
readonly groupLabel?: string;
|
|
9
|
+
readonly positionLabel?: (position: number, total: number) => string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The accessible name for one box of a SplitInput.
|
|
13
|
+
*
|
|
14
|
+
* A box on its own is meaningless -- "edit text, blank", six times -- so each one
|
|
15
|
+
* has to say which field it is. In order of how specific the caller was:
|
|
16
|
+
*
|
|
17
|
+
* 1. `config.ariaLabel`: the caller named this box by purpose ("Red", "Area
|
|
18
|
+
* code"). Used exactly as given; it already says which field it is.
|
|
19
|
+
* 2. `config.label`: the visible caption under the box ("R"), which is what the
|
|
20
|
+
* box is already called on screen -- the name has to contain it.
|
|
21
|
+
* 3. Otherwise the box's position: "digit 2 of 6".
|
|
22
|
+
*
|
|
23
|
+
* For 2 and 3 the group's name leads, because a screen reader's form-field list
|
|
24
|
+
* shows each control without its group: "One-time code, digit 2 of 6" is
|
|
25
|
+
* identifiable there, "digit 2 of 6" is not. With no group name the position is
|
|
26
|
+
* all there is, so the group should be named -- see `SplitInput`'s `ariaLabel`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const resolveFieldName: ({ index, total, config, groupLabel, positionLabel }: FieldNameContext) => string;
|
|
29
|
+
export {};
|