@sveltia/ui 0.73.0 → 0.74.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 +12 -10
- package/dist/components/grid/grid-body.svelte +103 -5
- package/dist/components/grid/grid-body.svelte.d.ts +42 -2
- package/dist/components/text-editor/shiki/version.d.ts +1 -1
- package/dist/components/text-editor/shiki/version.js +1 -1
- package/dist/services/group.svelte.d.ts +12 -0
- package/dist/services/group.svelte.js +117 -28
- package/package.json +5 -5
|
@@ -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>
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The interactive version of `<TableBody>`.
|
|
3
|
+
The interactive version of `<TableBody>`. A labelled row group can be made collapsible, in which
|
|
4
|
+
case the caption doubles as an expander button that shows or hides the rows.
|
|
4
5
|
@see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tbody
|
|
5
6
|
@see https://w3c.github.io/aria/#rowgroup
|
|
7
|
+
@see https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/
|
|
6
8
|
-->
|
|
7
9
|
<script>
|
|
8
|
-
import { _ } from '@sveltia/i18n';
|
|
10
|
+
import { _, isRTL } from '@sveltia/i18n';
|
|
11
|
+
import Button from '../button/button.svelte';
|
|
12
|
+
import Icon from '../icon/icon.svelte';
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* @import { Snippet } from 'svelte';
|
|
16
|
+
* @import { Attachment } from 'svelte/attachments';
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* @typedef {object} Props
|
|
16
21
|
* @property {string} [class] The `class` attribute on the wrapper element.
|
|
17
22
|
* @property {string} [label] Display label for the row group.
|
|
23
|
+
* @property {boolean} [collapsible] Whether the rows can be hidden with an expander in the
|
|
24
|
+
* caption. Requires `label`.
|
|
25
|
+
* @property {boolean} [expanded] Whether the rows are shown. Only applies when `collapsible`. An
|
|
26
|
+
* alias of the `aria-expanded` attribute on the expander button.
|
|
18
27
|
* @property {Snippet} [children] Primary slot content.
|
|
28
|
+
* @property {Snippet} [chevronIcon] Chevron slot content for the expander.
|
|
29
|
+
* @property {(event: CustomEvent) => void} [onChange] Custom `Change` event handler, called with
|
|
30
|
+
* the new `expanded` state in `detail` when the expander is toggled.
|
|
19
31
|
*/
|
|
20
32
|
|
|
21
33
|
/**
|
|
@@ -23,14 +35,52 @@
|
|
|
23
35
|
*/
|
|
24
36
|
let {
|
|
25
37
|
/* eslint-disable prefer-const */
|
|
38
|
+
expanded = $bindable(true),
|
|
26
39
|
class: className,
|
|
27
40
|
label = '',
|
|
41
|
+
collapsible = false,
|
|
28
42
|
children,
|
|
43
|
+
chevronIcon,
|
|
44
|
+
onChange,
|
|
29
45
|
...restProps
|
|
30
46
|
/* eslint-enable prefer-const */
|
|
31
47
|
} = $props();
|
|
32
48
|
|
|
33
49
|
const id = $props.id();
|
|
50
|
+
const hasExpander = $derived(collapsible && !!label);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Toggle the `expanded` state and notify the consumer.
|
|
54
|
+
*/
|
|
55
|
+
const toggle = () => {
|
|
56
|
+
expanded = !expanded;
|
|
57
|
+
onChange?.(new CustomEvent('change', { detail: { expanded } }));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Toggle the expander when the caption row itself is clicked. The `Grid` treats the caption as a
|
|
62
|
+
* row: the arrow keys land on it, and Enter clicks it, so this is how a keyboard user who has
|
|
63
|
+
* moved to the caption toggles the group. A click bubbling up from the button is left alone, as
|
|
64
|
+
* the button has toggled the group already.
|
|
65
|
+
* @type {Attachment}
|
|
66
|
+
*/
|
|
67
|
+
const handleCaptionClick = (element) => {
|
|
68
|
+
/**
|
|
69
|
+
* Handle the `click` event on the caption row.
|
|
70
|
+
* @param {Event} event `click` event.
|
|
71
|
+
*/
|
|
72
|
+
const listener = (event) => {
|
|
73
|
+
if (hasExpander && event.target === element) {
|
|
74
|
+
toggle();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
element.addEventListener('click', listener);
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
element.removeEventListener('click', listener);
|
|
82
|
+
};
|
|
83
|
+
};
|
|
34
84
|
</script>
|
|
35
85
|
|
|
36
86
|
<div
|
|
@@ -41,14 +91,34 @@
|
|
|
41
91
|
aria-roledescription={_('_sui.role_descriptions.grid_body')}
|
|
42
92
|
>
|
|
43
93
|
{#if label}
|
|
44
|
-
<div role="row" class="row-group-caption">
|
|
94
|
+
<div role="row" class="row-group-caption" {@attach handleCaptionClick}>
|
|
45
95
|
<!-- We need `colspan` here but cannot place `<th>` under `<div>`, so use a hack -->
|
|
46
96
|
<svelte:element this={"th"} role="columnheader" id="{id}-label" colspan="9999">
|
|
47
|
-
{
|
|
97
|
+
{#if hasExpander}
|
|
98
|
+
<!--
|
|
99
|
+
The button is not a tab stop: the grid has a single one, and a keyboard user reaches
|
|
100
|
+
the caption row with the arrow keys and toggles it with Enter
|
|
101
|
+
-->
|
|
102
|
+
<Button class="expander" {label} tabindex="-1" aria-expanded={expanded} onclick={toggle}>
|
|
103
|
+
{#snippet startIcon()}
|
|
104
|
+
<span role="none" class="chevron">
|
|
105
|
+
{#if chevronIcon}
|
|
106
|
+
{@render chevronIcon()}
|
|
107
|
+
{:else}
|
|
108
|
+
<Icon name={isRTL() ? 'chevron_left' : 'chevron_right'} />
|
|
109
|
+
{/if}
|
|
110
|
+
</span>
|
|
111
|
+
{/snippet}
|
|
112
|
+
</Button>
|
|
113
|
+
{:else}
|
|
114
|
+
{label}
|
|
115
|
+
{/if}
|
|
48
116
|
</svelte:element>
|
|
49
117
|
</div>
|
|
50
118
|
{/if}
|
|
51
|
-
{
|
|
119
|
+
{#if !hasExpander || expanded}
|
|
120
|
+
{@render children?.()}
|
|
121
|
+
{/if}
|
|
52
122
|
</div>
|
|
53
123
|
|
|
54
124
|
<style>[role=rowgroup] {
|
|
@@ -66,4 +136,32 @@
|
|
|
66
136
|
background-color: var(--sui-secondary-background-color);
|
|
67
137
|
font-size: var(--sui-font-size-default);
|
|
68
138
|
text-align: start;
|
|
139
|
+
}
|
|
140
|
+
[role=columnheader]:has(> :global(.expander)) {
|
|
141
|
+
padding: 0;
|
|
142
|
+
}
|
|
143
|
+
[role=columnheader] :global(.expander) {
|
|
144
|
+
justify-content: flex-start;
|
|
145
|
+
margin: 0;
|
|
146
|
+
border-radius: 0;
|
|
147
|
+
padding: 4px 8px 4px 4px;
|
|
148
|
+
width: 100%;
|
|
149
|
+
height: auto;
|
|
150
|
+
color: inherit;
|
|
151
|
+
font-size: inherit;
|
|
152
|
+
text-align: start;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.chevron {
|
|
156
|
+
flex: none;
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: center;
|
|
160
|
+
transition: transform 200ms;
|
|
161
|
+
}
|
|
162
|
+
:global(.expander[aria-expanded='true']) > .chevron:dir(ltr) {
|
|
163
|
+
transform: rotate(90deg);
|
|
164
|
+
}
|
|
165
|
+
:global(.expander[aria-expanded='true']) > .chevron:dir(rtl) {
|
|
166
|
+
transform: rotate(-90deg);
|
|
69
167
|
}</style>
|
|
@@ -4,9 +4,11 @@ type GridBody = {
|
|
|
4
4
|
$set?(props: Partial<Props & Record<string, any>>): void;
|
|
5
5
|
};
|
|
6
6
|
/**
|
|
7
|
-
* The interactive version of `<TableBody>`.
|
|
7
|
+
* The interactive version of `<TableBody>`. A labelled row group can be made collapsible, in which
|
|
8
|
+
* case the caption doubles as an expander button that shows or hides the rows.
|
|
8
9
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tbody
|
|
9
10
|
* @see https://w3c.github.io/aria/#rowgroup
|
|
11
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/
|
|
10
12
|
*/
|
|
11
13
|
declare const GridBody: import("svelte").Component<{
|
|
12
14
|
/**
|
|
@@ -17,11 +19,30 @@ declare const GridBody: import("svelte").Component<{
|
|
|
17
19
|
* Display label for the row group.
|
|
18
20
|
*/
|
|
19
21
|
label?: string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the rows can be hidden with an expander in the
|
|
24
|
+
* caption. Requires `label`.
|
|
25
|
+
*/
|
|
26
|
+
collapsible?: boolean | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the rows are shown. Only applies when `collapsible`. An
|
|
29
|
+
* alias of the `aria-expanded` attribute on the expander button.
|
|
30
|
+
*/
|
|
31
|
+
expanded?: boolean | undefined;
|
|
20
32
|
/**
|
|
21
33
|
* Primary slot content.
|
|
22
34
|
*/
|
|
23
35
|
children?: Snippet<[]> | undefined;
|
|
24
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Chevron slot content for the expander.
|
|
38
|
+
*/
|
|
39
|
+
chevronIcon?: Snippet<[]> | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Custom `Change` event handler, called with
|
|
42
|
+
* the new `expanded` state in `detail` when the expander is toggled.
|
|
43
|
+
*/
|
|
44
|
+
onChange?: ((event: CustomEvent) => void) | undefined;
|
|
45
|
+
} & Record<string, any>, {}, "expanded">;
|
|
25
46
|
type Props = {
|
|
26
47
|
/**
|
|
27
48
|
* The `class` attribute on the wrapper element.
|
|
@@ -31,9 +52,28 @@ type Props = {
|
|
|
31
52
|
* Display label for the row group.
|
|
32
53
|
*/
|
|
33
54
|
label?: string | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the rows can be hidden with an expander in the
|
|
57
|
+
* caption. Requires `label`.
|
|
58
|
+
*/
|
|
59
|
+
collapsible?: boolean | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the rows are shown. Only applies when `collapsible`. An
|
|
62
|
+
* alias of the `aria-expanded` attribute on the expander button.
|
|
63
|
+
*/
|
|
64
|
+
expanded?: boolean | undefined;
|
|
34
65
|
/**
|
|
35
66
|
* Primary slot content.
|
|
36
67
|
*/
|
|
37
68
|
children?: Snippet<[]> | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Chevron slot content for the expander.
|
|
71
|
+
*/
|
|
72
|
+
chevronIcon?: Snippet<[]> | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Custom `Change` event handler, called with
|
|
75
|
+
* the new `expanded` state in `detail` when the expander is toggled.
|
|
76
|
+
*/
|
|
77
|
+
onChange?: ((event: CustomEvent) => void) | undefined;
|
|
38
78
|
};
|
|
39
79
|
import type { Snippet } from 'svelte';
|
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltia/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "A collection of Svelte components and utilities for building user interfaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"@sveltejs/kit": "^2.70.3",
|
|
58
58
|
"@sveltejs/package": "^2.5.8",
|
|
59
59
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
60
|
-
"@vitest/browser-playwright": "^5.0.
|
|
61
|
-
"@vitest/coverage-v8": "^5.0.
|
|
62
|
-
"cspell": "^10.3.
|
|
60
|
+
"@vitest/browser-playwright": "^5.0.1",
|
|
61
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
62
|
+
"cspell": "^10.3.2",
|
|
63
63
|
"eslint": "^9.39.5",
|
|
64
64
|
"eslint-config-airbnb-extended": "^3.2.0",
|
|
65
65
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"svelte-preprocess": "^6.0.5",
|
|
87
87
|
"tslib": "^2.8.1",
|
|
88
88
|
"vite": "^8.3.0",
|
|
89
|
-
"vitest": "^5.0.
|
|
89
|
+
"vitest": "^5.0.1",
|
|
90
90
|
"vitest-browser-svelte": "^3.1.0"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|