@sveltia/ui 0.73.0 → 0.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -49,9 +49,10 @@
|
|
|
49
49
|
/** @type {HTMLElement | undefined} */
|
|
50
50
|
let grid = $state();
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
// `focused` class and `aria-activedescendant`, and
|
|
54
|
-
//
|
|
52
|
+
// Keep the keyboard cursor on the selected day. The group service tracks the cursor with the
|
|
53
|
+
// `focused` class and `aria-activedescendant`, and moves both along with the selection it makes
|
|
54
|
+
// itself; this covers the starting point and a `value` set from outside, which may land in
|
|
55
|
+
// another month while the cell the cursor was on is still on the grid.
|
|
55
56
|
$effect(() => {
|
|
56
57
|
void value;
|
|
57
58
|
|
|
@@ -64,16 +65,17 @@
|
|
|
64
65
|
const listbox = grid;
|
|
65
66
|
|
|
66
67
|
tick().then(() => {
|
|
67
|
-
if (listbox.querySelector('.focused')) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
68
|
const option = listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
72
69
|
|
|
73
|
-
if (option) {
|
|
74
|
-
|
|
75
|
-
listbox.setAttribute('aria-activedescendant', option.id);
|
|
70
|
+
if (!option || option.classList.contains('focused')) {
|
|
71
|
+
return;
|
|
76
72
|
}
|
|
73
|
+
|
|
74
|
+
listbox.querySelectorAll('.focused').forEach((element) => {
|
|
75
|
+
element.classList.remove('focused');
|
|
76
|
+
});
|
|
77
|
+
option.classList.add('focused');
|
|
78
|
+
listbox.setAttribute('aria-activedescendant', option.id);
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
</script>
|
|
@@ -12,6 +12,11 @@ export class Group {
|
|
|
12
12
|
constructor(parent: HTMLElement, { clickToSelect }?: {
|
|
13
13
|
clickToSelect?: boolean | undefined;
|
|
14
14
|
});
|
|
15
|
+
/**
|
|
16
|
+
* Whether {@link activate} has run. Until then, members are left as they are rendered.
|
|
17
|
+
* @type {boolean}
|
|
18
|
+
*/
|
|
19
|
+
activated: boolean;
|
|
15
20
|
parent: HTMLElement;
|
|
16
21
|
role: string;
|
|
17
22
|
multi: boolean;
|
|
@@ -56,6 +61,13 @@ export class Group {
|
|
|
56
61
|
* items would take three tab presses to step over.
|
|
57
62
|
*/
|
|
58
63
|
updateTabStop(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Count the columns of a grid layout from where the members sit, rather than from their widths:
|
|
66
|
+
* a row of a data grid spans the full width, a tile in a grid listbox doesn’t, and a header row
|
|
67
|
+
* may have no box at all.
|
|
68
|
+
* @returns {number} Number of members per visual row, at least 1.
|
|
69
|
+
*/
|
|
70
|
+
get columnCount(): number;
|
|
59
71
|
/**
|
|
60
72
|
* CSS selector to retrieve the members.
|
|
61
73
|
* @type {string}
|
|
@@ -32,6 +32,17 @@ export const normalize = (value) => {
|
|
|
32
32
|
return value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Set an element’s `tabindex` attribute, leaving the DOM alone if it already holds that value.
|
|
37
|
+
* @param {HTMLElement} element Element.
|
|
38
|
+
* @param {number} tabIndex New value.
|
|
39
|
+
*/
|
|
40
|
+
const setTabIndex = (element, tabIndex) => {
|
|
41
|
+
if (element.getAttribute('tabindex') !== String(tabIndex)) {
|
|
42
|
+
element.tabIndex = tabIndex;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
35
46
|
/**
|
|
36
47
|
* @type {{ [role: string]: {
|
|
37
48
|
* orientation: 'vertical' | 'horizontal',
|
|
@@ -49,7 +60,9 @@ const config = {
|
|
|
49
60
|
childRoles: ['row'],
|
|
50
61
|
childSelectedAttr: 'aria-selected',
|
|
51
62
|
focusChild: true,
|
|
52
|
-
|
|
63
|
+
// Rows are data, not a choice the widget has to make on the user’s behalf: nothing is
|
|
64
|
+
// selected until the user selects it. The first row is only the tab stop.
|
|
65
|
+
selectFirst: false,
|
|
53
66
|
controlsPanel: false,
|
|
54
67
|
rovingTabStop: 'selected',
|
|
55
68
|
},
|
|
@@ -178,6 +191,12 @@ const getMenuOpener = (element) => {
|
|
|
178
191
|
* Implement keyboard and mouse interactions for a grouping composite widget.
|
|
179
192
|
*/
|
|
180
193
|
export class Group {
|
|
194
|
+
/**
|
|
195
|
+
* Whether {@link activate} has run. Until then, members are left as they are rendered.
|
|
196
|
+
* @type {boolean}
|
|
197
|
+
*/
|
|
198
|
+
activated = false;
|
|
199
|
+
|
|
181
200
|
/**
|
|
182
201
|
* Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
|
|
183
202
|
* @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
|
|
@@ -302,8 +321,15 @@ export class Group {
|
|
|
302
321
|
// The members can be added, removed, disabled or hidden at any time, which is what invalidates
|
|
303
322
|
// the cached lists. Only the attributes that decide membership are watched, so the group’s own
|
|
304
323
|
// writes — the selected state and the roving `tabindex` — don’t needlessly discard the cache.
|
|
305
|
-
this.observer = new globalThis.MutationObserver(() => {
|
|
324
|
+
this.observer = new globalThis.MutationObserver((records) => {
|
|
306
325
|
this.#memberCache = undefined;
|
|
326
|
+
|
|
327
|
+
// Members rendered after activation — rows that arrive with the data, say — start out with
|
|
328
|
+
// whatever `tabindex` their component gives them, so the roving tab stop has to be redone
|
|
329
|
+
// for them to become a single stop
|
|
330
|
+
if (this.activated && records.some(({ type }) => type === 'childList')) {
|
|
331
|
+
this.updateTabStop();
|
|
332
|
+
}
|
|
307
333
|
});
|
|
308
334
|
|
|
309
335
|
this.observer.observe(parent, {
|
|
@@ -326,6 +352,8 @@ export class Group {
|
|
|
326
352
|
activate() {
|
|
327
353
|
const { parent, allMembers, selected: defaultSelected } = this;
|
|
328
354
|
|
|
355
|
+
this.activated = true;
|
|
356
|
+
|
|
329
357
|
allMembers.forEach((element, index) => {
|
|
330
358
|
// Select the first one if no member has the `selected` attribute
|
|
331
359
|
const isSelected =
|
|
@@ -385,18 +413,55 @@ export class Group {
|
|
|
385
413
|
return;
|
|
386
414
|
}
|
|
387
415
|
|
|
416
|
+
// The stop stays where the user is. This runs again whenever the subtree changes, so a member
|
|
417
|
+
// the user has moved to must not be handed back to the selected or first one just because a
|
|
418
|
+
// cell re-rendered or a row arrived. Only when there is no single stop — at activation, where
|
|
419
|
+
// every member may render as one, or after new members have — is one picked.
|
|
420
|
+
const { activeElement } = document;
|
|
421
|
+
|
|
422
|
+
const focused = activeMembers.find(
|
|
423
|
+
(element) => element === activeElement || element.contains(activeElement),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const holders = activeMembers.filter((element) => element.getAttribute('tabindex') === '0');
|
|
427
|
+
|
|
388
428
|
const tabStop =
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
429
|
+
focused ??
|
|
430
|
+
(holders.length === 1
|
|
431
|
+
? holders[0]
|
|
432
|
+
: ((this.rovingTabStop === 'selected'
|
|
433
|
+
? activeMembers.find(
|
|
434
|
+
(element) => element.getAttribute(this.childSelectedAttr) === 'true',
|
|
435
|
+
)
|
|
436
|
+
: undefined) ?? activeMembers[0]));
|
|
394
437
|
|
|
395
438
|
allMembers.forEach((element) => {
|
|
396
|
-
|
|
439
|
+
// Only touch the DOM when it changes; this runs on every mutation of a large widget. The
|
|
440
|
+
// attribute is what’s compared: a `<button>` reports a `tabIndex` of 0 without one, and the
|
|
441
|
+
// popup looks the tab stop up by attribute.
|
|
442
|
+
setTabIndex(element, element === tabStop ? 0 : -1);
|
|
397
443
|
});
|
|
398
444
|
}
|
|
399
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Count the columns of a grid layout from where the members sit, rather than from their widths:
|
|
448
|
+
* a row of a data grid spans the full width, a tile in a grid listbox doesn’t, and a header row
|
|
449
|
+
* may have no box at all.
|
|
450
|
+
* @returns {number} Number of members per visual row, at least 1.
|
|
451
|
+
*/
|
|
452
|
+
get columnCount() {
|
|
453
|
+
// Members without a layout box don’t occupy a column
|
|
454
|
+
const laidOut = this.allMembers.filter((member) => member.getClientRects().length);
|
|
455
|
+
const firstTop = laidOut[0]?.getBoundingClientRect().top;
|
|
456
|
+
|
|
457
|
+
const count = laidOut.findIndex(
|
|
458
|
+
(member) => Math.abs(member.getBoundingClientRect().top - firstTop) > 1,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// Everything on one visual row when nothing wraps
|
|
462
|
+
return Math.max(1, count === -1 ? laidOut.length : count);
|
|
463
|
+
}
|
|
464
|
+
|
|
400
465
|
/**
|
|
401
466
|
* CSS selector to retrieve the members.
|
|
402
467
|
* @type {string}
|
|
@@ -728,18 +793,17 @@ export class Group {
|
|
|
728
793
|
});
|
|
729
794
|
|
|
730
795
|
if (this.focusChild) {
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
if (targetAffected) {
|
|
739
|
-
newTarget.focus();
|
|
740
|
-
newTarget.dispatchEvent(new CustomEvent('Focus'));
|
|
741
|
-
}
|
|
796
|
+
// Done right away rather than on the next frame: a key that repeats, or a second press
|
|
797
|
+
// before the frame, has to start from the member that was just reached, which it reads
|
|
798
|
+
// from the focus
|
|
799
|
+
affected.forEach((element) => {
|
|
800
|
+
setTabIndex(element, element === newTarget ? 0 : -1);
|
|
742
801
|
});
|
|
802
|
+
|
|
803
|
+
if (targetAffected) {
|
|
804
|
+
newTarget.focus();
|
|
805
|
+
newTarget.dispatchEvent(new CustomEvent('Focus'));
|
|
806
|
+
}
|
|
743
807
|
}
|
|
744
808
|
|
|
745
809
|
this.parent.dispatchEvent(
|
|
@@ -793,16 +857,27 @@ export class Group {
|
|
|
793
857
|
const { allMembers, activeMembers } = this;
|
|
794
858
|
|
|
795
859
|
/** @type {HTMLElement | undefined} */
|
|
860
|
+
// A field the user is typing in keeps its keys: the caret moves, the text changes. Escape and
|
|
861
|
+
// Tab still reach the group, so a menu holding a field can be left the usual ways.
|
|
862
|
+
if (
|
|
863
|
+
target !== this.parent &&
|
|
864
|
+
target.matches('input, textarea, select, [contenteditable]:not([contenteditable="false"])') &&
|
|
865
|
+
key !== 'Escape' &&
|
|
866
|
+
key !== 'Tab'
|
|
867
|
+
) {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
796
871
|
const currentTarget = (() => {
|
|
797
872
|
if (!this.focusChild) {
|
|
798
873
|
return activeMembers.find((member) => member.matches('.focused'));
|
|
799
874
|
}
|
|
800
875
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
876
|
+
// A key pressed on a control inside a member, such as a checkbox in a grid row, moves from
|
|
877
|
+
// that member
|
|
878
|
+
const member = /** @type {HTMLElement | null} */ (target.closest(this.selector));
|
|
804
879
|
|
|
805
|
-
return undefined;
|
|
880
|
+
return member && this.parent.contains(member) ? member : undefined;
|
|
806
881
|
})();
|
|
807
882
|
|
|
808
883
|
if (['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
|
|
@@ -917,17 +992,31 @@ export class Group {
|
|
|
917
992
|
let newTarget;
|
|
918
993
|
|
|
919
994
|
if (this.grid) {
|
|
920
|
-
const colCount =
|
|
995
|
+
const colCount = this.columnCount;
|
|
996
|
+
const lastIndex = allMembers.length - 1;
|
|
921
997
|
const _isRTL = isRTL();
|
|
922
998
|
|
|
923
999
|
index = currentTarget ? allMembers.indexOf(currentTarget) : -1;
|
|
924
1000
|
|
|
1001
|
+
// With nothing focused yet, the arrows start from either end, as in a list
|
|
1002
|
+
if (index === -1) {
|
|
1003
|
+
const forward = key === 'ArrowDown' || key === (_isRTL ? 'ArrowLeft' : 'ArrowRight');
|
|
1004
|
+
const backward = key === 'ArrowUp' || key === (_isRTL ? 'ArrowRight' : 'ArrowLeft');
|
|
1005
|
+
|
|
1006
|
+
if (forward) {
|
|
1007
|
+
[newTarget] = activeMembers;
|
|
1008
|
+
} else if (backward) {
|
|
1009
|
+
newTarget = activeMembers[activeMembers.length - 1];
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
925
1013
|
if (key === 'ArrowUp' && index > 0) {
|
|
926
|
-
newTarget = allMembers[index - colCount];
|
|
1014
|
+
newTarget = allMembers[Math.max(index - colCount, 0)];
|
|
927
1015
|
}
|
|
928
1016
|
|
|
929
|
-
if (key === 'ArrowDown' && index
|
|
930
|
-
|
|
1017
|
+
if (key === 'ArrowDown' && index !== -1 && index < lastIndex) {
|
|
1018
|
+
// A partial last row still gets reached
|
|
1019
|
+
newTarget = allMembers[Math.min(index + colCount, lastIndex)];
|
|
931
1020
|
}
|
|
932
1021
|
|
|
933
1022
|
// In RTL, ArrowLeft moves right (next), ArrowRight moves left (previous)
|
|
@@ -935,7 +1024,7 @@ export class Group {
|
|
|
935
1024
|
newTarget = allMembers[index + (_isRTL ? 1 : -1)];
|
|
936
1025
|
}
|
|
937
1026
|
|
|
938
|
-
if (key === 'ArrowRight' && index
|
|
1027
|
+
if (key === 'ArrowRight' && index !== -1 && index < lastIndex) {
|
|
939
1028
|
newTarget = allMembers[index + (_isRTL ? -1 : 1)];
|
|
940
1029
|
}
|
|
941
1030
|
|