@gjsify/adwaita-web 0.40.0 → 0.42.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/README.md +98 -3
- package/dist/adwaita-web.css +142 -63
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-fonts.spec.d.ts +2 -0
- package/lib/types/breakpoints.d.ts +10 -1
- package/lib/types/connect-lifecycle.spec.d.ts +1 -0
- package/lib/types/elements/adw-about-dialog.d.ts +1 -0
- package/lib/types/elements/adw-action-row.d.ts +2 -0
- package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-dialog.d.ts +2 -7
- package/lib/types/elements/adw-icon.d.ts +17 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
- package/lib/types/elements/adw-popover.d.ts +1 -0
- package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
- package/lib/types/elements/adw-switch.d.ts +10 -0
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toggle-group.d.ts +2 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/elements/modal-surface.d.ts +59 -0
- package/lib/types/elements/roving-focus.d.ts +26 -0
- package/lib/types/elements/row-activation.d.ts +26 -0
- package/lib/types/empty-sections.d.ts +15 -0
- package/lib/types/empty-sections.spec.d.ts +1 -0
- package/lib/types/fonts.d.ts +1 -0
- package/lib/types/icon-registry.d.ts +35 -0
- package/lib/types/icon-registry.spec.d.ts +18 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -1
- package/scss/_carousel.scss +15 -1
- package/scss/_checks.scss +8 -8
- package/scss/_combo_row.scss +1 -1
- package/scss/_drop_down.scss +1 -1
- package/scss/_expander_row.scss +1 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icon.scss +47 -0
- package/scss/_icons.generated.scss +36 -6
- package/scss/_password_entry_row.scss +1 -1
- package/scss/_preferences_dialog.scss +3 -2
- package/scss/_progress_bar.scss +1 -1
- package/scss/_row.scss +1 -1
- package/scss/_scrolling.scss +107 -0
- package/scss/_status_page.scss +1 -1
- package/scss/_style_classes.scss +21 -8
- package/scss/_tab_view.scss +1 -1
- package/scss/_theme.scss +1 -0
- package/scss/_toast.scss +6 -24
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +30 -3
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/breakpoints.ts +18 -4
- package/src/chrome.spec.ts +233 -0
- package/src/connect-lifecycle.spec.ts +351 -0
- package/src/elements/adw-about-dialog.ts +24 -18
- package/src/elements/adw-action-row.ts +14 -2
- package/src/elements/adw-alert-dialog.ts +39 -20
- package/src/elements/adw-button-row.ts +3 -0
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-clamp.ts +3 -2
- package/src/elements/adw-data-grid.ts +5 -2
- package/src/elements/adw-dialog.ts +16 -63
- package/src/elements/adw-expander-row.ts +18 -4
- package/src/elements/adw-header-bar.ts +2 -0
- package/src/elements/adw-icon.ts +32 -6
- package/src/elements/adw-inline-view-switcher.ts +12 -0
- package/src/elements/adw-menu-button.ts +17 -10
- package/src/elements/adw-navigation-split-view.ts +30 -7
- package/src/elements/adw-overlay-split-view.ts +15 -3
- package/src/elements/adw-popover.ts +11 -0
- package/src/elements/adw-preferences-dialog.ts +18 -16
- package/src/elements/adw-preferences-group.ts +7 -0
- package/src/elements/adw-sidebar.ts +15 -0
- package/src/elements/adw-status-page.ts +12 -4
- package/src/elements/adw-switch-row.ts +7 -0
- package/src/elements/adw-switch.ts +19 -1
- package/src/elements/adw-tab-view.ts +70 -7
- package/src/elements/adw-toast-overlay.ts +8 -1
- package/src/elements/adw-toggle-group.ts +94 -2
- package/src/elements/adw-toolbar-view.ts +28 -2
- package/src/elements/adw-view-switcher-bar.ts +18 -0
- package/src/elements/adw-view-switcher.ts +14 -0
- package/src/elements/modal-surface.ts +180 -0
- package/src/elements/roving-focus.ts +154 -0
- package/src/elements/row-activation.ts +92 -0
- package/src/empty-sections.spec.ts +81 -0
- package/src/empty-sections.ts +54 -0
- package/src/fonts.ts +25 -0
- package/src/icon-registry.spec.ts +240 -0
- package/src/icon-registry.ts +157 -0
- package/src/index.ts +30 -1
- package/src/keyboard-operable.spec.ts +755 -0
- package/src/scroll-shading.ts +255 -0
- package/src/source-view/theme.ts +15 -3
- package/src/split-button.spec.ts +6 -2
- package/src/style-classes.spec.ts +6 -0
- package/src/styles.generated.ts +1 -1
- package/src/test.browser.mts +14 -0
- package/src/view-switcher.spec.ts +149 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// Where a KEY PRESS puts focus — the half of accessibility this package declared and
|
|
2
|
+
// did not implement.
|
|
3
|
+
//
|
|
4
|
+
// Runs in a real browser via the tests/browser Playwright harness. Every assertion here
|
|
5
|
+
// reads `document.activeElement`, never a class, an attribute or a state object: the
|
|
6
|
+
// whole defect class this file exists for was GREEN under a suite that read state while
|
|
7
|
+
// the widget was unusable. `aria-modal="true"` was set on four surfaces and honoured by
|
|
8
|
+
// one; `role="tablist"` plus a roving tabindex was set on four composites and navigable
|
|
9
|
+
// by none.
|
|
10
|
+
//
|
|
11
|
+
// SYNTHETIC EVENTS, AND WHY THEY STILL DISCRIMINATE. A dispatched `keydown` is the same
|
|
12
|
+
// event object a real key produces, minus the browser's own default action — and for
|
|
13
|
+
// both shapes the default action is exactly what must NOT happen. A trap that works
|
|
14
|
+
// calls `preventDefault()` and moves focus itself, which is observable here; a trap that
|
|
15
|
+
// does not work leaves focus where it was, so asserting "focus is still inside the
|
|
16
|
+
// dialog" would pass on the bug. The assertion is therefore where focus LANDED — the
|
|
17
|
+
// far edge, not the near one — plus `defaultPrevented`, which is the only discriminator
|
|
18
|
+
// left when a dialog has a single focusable control and first and last are one element.
|
|
19
|
+
// A real Tab press escaping an untrapped dialog is measured one layer up, by
|
|
20
|
+
// `tests/browser/specs/adwaita-keyboard.spec.ts`.
|
|
21
|
+
import { describe, expect, it } from '@gjsify/unit';
|
|
22
|
+
|
|
23
|
+
import type { AdwAlertDialog } from './elements/adw-alert-dialog.js';
|
|
24
|
+
import { AdwToggle, AdwToggleGroup } from './elements/adw-toggle-group.js';
|
|
25
|
+
|
|
26
|
+
/** Unique per element so a spec's `stack="…"` reference cannot pick up an earlier one. */
|
|
27
|
+
let seq = 0;
|
|
28
|
+
const uniqueId = (prefix: string) => `${prefix}-${(seq += 1)}`;
|
|
29
|
+
|
|
30
|
+
function press(target: HTMLElement, key: string, shiftKey = false): KeyboardEvent {
|
|
31
|
+
const event = new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true });
|
|
32
|
+
target.dispatchEvent(event);
|
|
33
|
+
return event;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Modal surfaces
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
interface ModalCase {
|
|
41
|
+
tag: string;
|
|
42
|
+
/** Builds the dialog, connected, with as many focusable controls as it can have. */
|
|
43
|
+
make: () => HTMLElement;
|
|
44
|
+
/** The element carrying `aria-modal` — the boundary focus may not cross. */
|
|
45
|
+
surface: (el: HTMLElement) => HTMLElement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MODAL_CASES: ModalCase[] = [
|
|
49
|
+
{
|
|
50
|
+
tag: 'adw-dialog',
|
|
51
|
+
make: () => {
|
|
52
|
+
const el = document.createElement('adw-dialog');
|
|
53
|
+
el.setAttribute('title', 'Dialog');
|
|
54
|
+
el.innerHTML = '<button class="one">One</button><button class="two">Two</button>';
|
|
55
|
+
return el;
|
|
56
|
+
},
|
|
57
|
+
surface: (el) => el.querySelector('.adw-dialog-box') as HTMLElement,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
tag: 'adw-alert-dialog',
|
|
61
|
+
make: () => {
|
|
62
|
+
const el = document.createElement('adw-alert-dialog');
|
|
63
|
+
el.setAttribute('heading', 'Heading');
|
|
64
|
+
el.innerHTML =
|
|
65
|
+
'<adw-alert-response id="cancel">Cancel</adw-alert-response>' +
|
|
66
|
+
'<adw-alert-response id="ok" appearance="suggested">OK</adw-alert-response>';
|
|
67
|
+
return el;
|
|
68
|
+
},
|
|
69
|
+
surface: (el) => el.querySelector('.adw-alert-dialog-box') as HTMLElement,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
tag: 'adw-about-dialog',
|
|
73
|
+
make: () => {
|
|
74
|
+
const el = document.createElement('adw-about-dialog');
|
|
75
|
+
el.setAttribute('application-name', 'App');
|
|
76
|
+
el.setAttribute('version', '1.0');
|
|
77
|
+
el.setAttribute('website', 'https://example.com');
|
|
78
|
+
return el;
|
|
79
|
+
},
|
|
80
|
+
surface: (el) => el.querySelector('.adw-about-dialog-sheet') as HTMLElement,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
tag: 'adw-preferences-dialog',
|
|
84
|
+
make: () => {
|
|
85
|
+
const el = document.createElement('adw-preferences-dialog');
|
|
86
|
+
el.setAttribute('title', 'Preferences');
|
|
87
|
+
el.innerHTML =
|
|
88
|
+
'<adw-preferences-page title="Page"><adw-preferences-group title="Group">' +
|
|
89
|
+
'<button class="inside">Inside</button>' +
|
|
90
|
+
'</adw-preferences-group></adw-preferences-page>';
|
|
91
|
+
return el;
|
|
92
|
+
},
|
|
93
|
+
surface: (el) => el.querySelector('.adw-preferences-dialog-box') as HTMLElement,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Everything the browser would hand focus to inside `surface`, in tab order. */
|
|
98
|
+
function tabbable(surface: HTMLElement): HTMLElement[] {
|
|
99
|
+
const selector =
|
|
100
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),' +
|
|
101
|
+
'textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
102
|
+
return Array.from(surface.querySelectorAll<HTMLElement>(selector)).filter(
|
|
103
|
+
(el) => !el.hidden && el.getClientRects().length > 0,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Roving-tabindex composites
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
interface RovingCase {
|
|
112
|
+
tag: string;
|
|
113
|
+
/** Builds a connected widget with exactly three navigable items. */
|
|
114
|
+
make: () => HTMLElement;
|
|
115
|
+
/** The navigable items, read from the rendered DOM. */
|
|
116
|
+
items: (el: HTMLElement) => HTMLElement[];
|
|
117
|
+
previous: string;
|
|
118
|
+
next: string;
|
|
119
|
+
/**
|
|
120
|
+
* The arrow pair on the OTHER axis, which this widget must leave to the page.
|
|
121
|
+
*
|
|
122
|
+
* Asserted because the axis SEPARATION was held by nothing: `AXIS_KEYS` is one
|
|
123
|
+
* table, and widening a single row to both pairs kept every test green while
|
|
124
|
+
* every horizontal widget started swallowing the page scroll — `attachRovingFocus`
|
|
125
|
+
* calls `preventDefault()` before it checks whether anything moved.
|
|
126
|
+
*/
|
|
127
|
+
inert: readonly [string, string];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** By TAG, never by index: a case inserted mid-array repoints an index silently, and
|
|
131
|
+
* the one test that would still PASS repointed is the one asserting the least. */
|
|
132
|
+
const rovingCase = (tag: string): RovingCase => {
|
|
133
|
+
const found = ROVING_CASES.find((widget) => widget.tag === tag);
|
|
134
|
+
if (found === undefined) throw new Error(`no roving case for ${tag}`);
|
|
135
|
+
return found;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function makeStack(): HTMLElement {
|
|
139
|
+
const stack = document.createElement('adw-view-stack');
|
|
140
|
+
stack.id = uniqueId('stack');
|
|
141
|
+
stack.innerHTML = ['a', 'b', 'c']
|
|
142
|
+
.map(
|
|
143
|
+
(name) =>
|
|
144
|
+
`<adw-view-stack-page name="${name}" title="${name.toUpperCase()}"><p>${name}</p></adw-view-stack-page>`,
|
|
145
|
+
)
|
|
146
|
+
.join('');
|
|
147
|
+
return stack;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ROVING_CASES: RovingCase[] = [
|
|
151
|
+
{
|
|
152
|
+
tag: 'adw-view-switcher',
|
|
153
|
+
make: () => {
|
|
154
|
+
const el = document.createElement('adw-view-switcher');
|
|
155
|
+
el.innerHTML = ['a', 'b', 'c']
|
|
156
|
+
.map(
|
|
157
|
+
(name) =>
|
|
158
|
+
`<adw-view-switcher-page name="${name}" title="${name.toUpperCase()}"><p>${name}</p></adw-view-switcher-page>`,
|
|
159
|
+
)
|
|
160
|
+
.join('');
|
|
161
|
+
return el;
|
|
162
|
+
},
|
|
163
|
+
items: (el) => Array.from(el.querySelectorAll<HTMLElement>('[role="tab"]')),
|
|
164
|
+
previous: 'ArrowLeft',
|
|
165
|
+
next: 'ArrowRight',
|
|
166
|
+
inert: ['ArrowUp', 'ArrowDown'],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
tag: 'adw-view-switcher-bar',
|
|
170
|
+
make: () => {
|
|
171
|
+
const stack = makeStack();
|
|
172
|
+
document.body.appendChild(stack);
|
|
173
|
+
const el = document.createElement('adw-view-switcher-bar');
|
|
174
|
+
el.setAttribute('stack', stack.id);
|
|
175
|
+
el.setAttribute('reveal', '');
|
|
176
|
+
// The stack is the widget's model, so it has to come down with it.
|
|
177
|
+
el.addEventListener('adw-test-teardown', () => stack.remove());
|
|
178
|
+
return el;
|
|
179
|
+
},
|
|
180
|
+
items: (el) => Array.from(el.querySelectorAll<HTMLElement>('[role="tab"]')),
|
|
181
|
+
previous: 'ArrowLeft',
|
|
182
|
+
next: 'ArrowRight',
|
|
183
|
+
inert: ['ArrowUp', 'ArrowDown'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
tag: 'adw-inline-view-switcher',
|
|
187
|
+
make: () => {
|
|
188
|
+
const el = document.createElement('adw-inline-view-switcher');
|
|
189
|
+
el.innerHTML = ['a', 'b', 'c']
|
|
190
|
+
.map(
|
|
191
|
+
(name) =>
|
|
192
|
+
`<adw-view-stack-page name="${name}" title="${name.toUpperCase()}"><p>${name}</p></adw-view-stack-page>`,
|
|
193
|
+
)
|
|
194
|
+
.join('');
|
|
195
|
+
return el;
|
|
196
|
+
},
|
|
197
|
+
items: (el) => Array.from(el.querySelectorAll<HTMLElement>('[role="tab"]')),
|
|
198
|
+
previous: 'ArrowLeft',
|
|
199
|
+
next: 'ArrowRight',
|
|
200
|
+
inert: ['ArrowUp', 'ArrowDown'],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
tag: 'adw-sidebar',
|
|
204
|
+
make: () => {
|
|
205
|
+
const el = document.createElement('adw-sidebar');
|
|
206
|
+
el.innerHTML =
|
|
207
|
+
'<adw-sidebar-section title="Section">' +
|
|
208
|
+
['One', 'Two', 'Three']
|
|
209
|
+
.map((title) => `<adw-sidebar-item title="${title}"></adw-sidebar-item>`)
|
|
210
|
+
.join('') +
|
|
211
|
+
'</adw-sidebar-section>';
|
|
212
|
+
return el;
|
|
213
|
+
},
|
|
214
|
+
items: (el) => Array.from(el.querySelectorAll<HTMLElement>('[role="option"]')),
|
|
215
|
+
previous: 'ArrowUp',
|
|
216
|
+
next: 'ArrowDown',
|
|
217
|
+
inert: ['ArrowLeft', 'ArrowRight'],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
// Upstream's fifth roving widget, and the one that had none of it: three plain
|
|
221
|
+
// tab stops, no role, and no arrow keys. `[role="radio"]` is the assertion —
|
|
222
|
+
// reading `.adw-toggle` would pass on buttons carrying no role at all.
|
|
223
|
+
tag: 'adw-toggle-group',
|
|
224
|
+
make: () => {
|
|
225
|
+
const el = document.createElement('adw-toggle-group');
|
|
226
|
+
el.innerHTML = ['One', 'Two', 'Three']
|
|
227
|
+
.map((label) => `<adw-toggle label="${label}"></adw-toggle>`)
|
|
228
|
+
.join('');
|
|
229
|
+
return el;
|
|
230
|
+
},
|
|
231
|
+
items: (el) => Array.from(el.querySelectorAll<HTMLElement>('[role="radio"]')),
|
|
232
|
+
previous: 'ArrowLeft',
|
|
233
|
+
next: 'ArrowRight',
|
|
234
|
+
inert: ['ArrowUp', 'ArrowDown'],
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/** A connected `<adw-alert-dialog>` carrying `inner` as its declared markup. */
|
|
239
|
+
function makeAlertDialog(inner: string): AdwAlertDialog {
|
|
240
|
+
const el = document.createElement('adw-alert-dialog') as AdwAlertDialog;
|
|
241
|
+
el.setAttribute('heading', 'Heading');
|
|
242
|
+
el.innerHTML = inner;
|
|
243
|
+
document.body.appendChild(el);
|
|
244
|
+
return el;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Connect, run, disconnect — including whatever model the case put beside the widget. */
|
|
248
|
+
function withWidget(make: () => HTMLElement, body: (el: HTMLElement) => void): void {
|
|
249
|
+
const el = make();
|
|
250
|
+
document.body.appendChild(el);
|
|
251
|
+
try {
|
|
252
|
+
body(el);
|
|
253
|
+
} finally {
|
|
254
|
+
el.dispatchEvent(new CustomEvent('adw-test-teardown'));
|
|
255
|
+
el.remove();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const AdwKeyboardOperableTest = async () => {
|
|
260
|
+
await describe('modal surfaces keep Tab inside themselves', async () => {
|
|
261
|
+
for (const modal of MODAL_CASES) {
|
|
262
|
+
await it(`${modal.tag} declares aria-modal on a surface that traps`, async () => {
|
|
263
|
+
const opener = document.createElement('button');
|
|
264
|
+
document.body.appendChild(opener);
|
|
265
|
+
const el = modal.make();
|
|
266
|
+
document.body.appendChild(el);
|
|
267
|
+
opener.focus();
|
|
268
|
+
el.setAttribute('open', '');
|
|
269
|
+
// `<adw-about-dialog>` appends its close button from a `queueMicrotask`,
|
|
270
|
+
// because `<adw-header-bar>` builds the section it goes in from its own
|
|
271
|
+
// `connectedCallback`. Without this the dialog measures as having no
|
|
272
|
+
// focusable control at all — which is also what its initial focus sees,
|
|
273
|
+
// ledgered in status/open-todos.md.
|
|
274
|
+
await Promise.resolve();
|
|
275
|
+
|
|
276
|
+
const surface = modal.surface(el);
|
|
277
|
+
expect(surface.getAttribute('aria-modal')).toBe('true');
|
|
278
|
+
// Presenting moves focus in — otherwise the first Tab is pressed outside
|
|
279
|
+
// the dialog and the trap never sees it.
|
|
280
|
+
expect(surface.contains(document.activeElement)).toBe(true);
|
|
281
|
+
|
|
282
|
+
const focusables = tabbable(surface);
|
|
283
|
+
expect(focusables.length > 0).toBe(true);
|
|
284
|
+
const first = focusables[0];
|
|
285
|
+
const last = focusables[focusables.length - 1];
|
|
286
|
+
|
|
287
|
+
last.focus();
|
|
288
|
+
const forward = press(last, 'Tab');
|
|
289
|
+
// WRAPPED, not merely "still inside": a dialog with no trap leaves focus
|
|
290
|
+
// on `last`, which is inside too. `defaultPrevented` carries the same
|
|
291
|
+
// claim where `first === last` and the wrap is invisible.
|
|
292
|
+
expect(document.activeElement).toBe(first);
|
|
293
|
+
expect(forward.defaultPrevented).toBe(true);
|
|
294
|
+
|
|
295
|
+
first.focus();
|
|
296
|
+
const backward = press(first, 'Tab', true);
|
|
297
|
+
expect(document.activeElement).toBe(last);
|
|
298
|
+
expect(backward.defaultPrevented).toBe(true);
|
|
299
|
+
|
|
300
|
+
el.removeAttribute('open');
|
|
301
|
+
// Return focus: the interaction ends where it started, not on whatever
|
|
302
|
+
// the escaped Tab happened to land on.
|
|
303
|
+
expect(document.activeElement).toBe(opener);
|
|
304
|
+
|
|
305
|
+
el.remove();
|
|
306
|
+
opener.remove();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await it('adw-alert-dialog with a disabled default response still focuses inside', async () => {
|
|
311
|
+
const opener = document.createElement('button');
|
|
312
|
+
document.body.appendChild(opener);
|
|
313
|
+
const el = makeAlertDialog(
|
|
314
|
+
'<adw-alert-response id="ok">OK</adw-alert-response>' +
|
|
315
|
+
'<adw-alert-response id="cancel">Cancel</adw-alert-response>',
|
|
316
|
+
);
|
|
317
|
+
el.setDefaultResponse('ok');
|
|
318
|
+
el.setResponseEnabled('ok', false);
|
|
319
|
+
opener.focus();
|
|
320
|
+
el.setAttribute('open', '');
|
|
321
|
+
|
|
322
|
+
const surface = el.querySelector('.adw-alert-dialog-box') as HTMLElement;
|
|
323
|
+
// The element asked for a control `focus()` REFUSES. Measured in Firefox before
|
|
324
|
+
// the surface checked that: the dialog opened with focus still on the opener
|
|
325
|
+
// OUTSIDE it, no key reached the trap (its listener is on the host), and Escape
|
|
326
|
+
// left `open` true — unreachable and undismissable at once.
|
|
327
|
+
expect(surface.contains(document.activeElement)).toBe(true);
|
|
328
|
+
expect((document.activeElement as HTMLButtonElement).disabled).toBe(false);
|
|
329
|
+
// The C skips a disabled response and takes the next enabled one
|
|
330
|
+
// (adw-alert-dialog.c:413), which is the only one left here.
|
|
331
|
+
expect(document.activeElement?.textContent).toBe('Cancel');
|
|
332
|
+
|
|
333
|
+
press(el, 'Escape');
|
|
334
|
+
expect(el.hasAttribute('open')).toBe(false);
|
|
335
|
+
expect(document.activeElement).toBe(opener);
|
|
336
|
+
|
|
337
|
+
el.remove();
|
|
338
|
+
opener.remove();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await it('adw-alert-dialog focuses its content before its default response', async () => {
|
|
342
|
+
const el = makeAlertDialog(
|
|
343
|
+
'<input class="field">' +
|
|
344
|
+
'<adw-alert-response id="ok">OK</adw-alert-response>' +
|
|
345
|
+
'<adw-alert-response id="cancel">Cancel</adw-alert-response>',
|
|
346
|
+
);
|
|
347
|
+
el.setDefaultResponse('ok');
|
|
348
|
+
el.setAttribute('open', '');
|
|
349
|
+
|
|
350
|
+
// `adw_alert_dialog_grab_focus` tries the CONTENT first (adw-alert-dialog.c:397)
|
|
351
|
+
// and only then the default widget: an alert carrying an entry is answered by
|
|
352
|
+
// typing into it, not by tabbing back to it.
|
|
353
|
+
expect(document.activeElement).toBe(el.querySelector('.field'));
|
|
354
|
+
|
|
355
|
+
el.remove();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await it('adw-dialog with nothing focusable inside holds focus on the box', async () => {
|
|
359
|
+
const el = document.createElement('adw-dialog');
|
|
360
|
+
el.innerHTML = '<p>Nothing to focus</p>';
|
|
361
|
+
document.body.appendChild(el);
|
|
362
|
+
el.setAttribute('open', '');
|
|
363
|
+
|
|
364
|
+
const box = el.querySelector('.adw-dialog-box') as HTMLElement;
|
|
365
|
+
expect(document.activeElement).toBe(box);
|
|
366
|
+
const event = press(box, 'Tab');
|
|
367
|
+
expect(document.activeElement).toBe(box);
|
|
368
|
+
expect(event.defaultPrevented).toBe(true);
|
|
369
|
+
|
|
370
|
+
el.remove();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await describe('roving tabindex moves under the arrow keys', async () => {
|
|
375
|
+
for (const widget of ROVING_CASES) {
|
|
376
|
+
await it(`${widget.tag} steps forward and back, taking focus along`, async () => {
|
|
377
|
+
withWidget(widget.make, (el) => {
|
|
378
|
+
const items = widget.items(el);
|
|
379
|
+
expect(items.length).toBe(3);
|
|
380
|
+
// The precondition IS the defect: two of three are out of the tab
|
|
381
|
+
// order, so an arrow key is the only way left to reach them.
|
|
382
|
+
expect(items.map((item) => item.tabIndex)).toStrictEqual([0, -1, -1]);
|
|
383
|
+
|
|
384
|
+
items[0].focus();
|
|
385
|
+
press(items[0], widget.next);
|
|
386
|
+
expect(document.activeElement).toBe(items[1]);
|
|
387
|
+
// The roving tabindex travelled with focus, or the next press starts
|
|
388
|
+
// from an item the browser no longer considers focusable.
|
|
389
|
+
expect(items[1].tabIndex).toBe(0);
|
|
390
|
+
expect(items[0].tabIndex).toBe(-1);
|
|
391
|
+
|
|
392
|
+
press(items[1], widget.next);
|
|
393
|
+
expect(document.activeElement).toBe(items[2]);
|
|
394
|
+
|
|
395
|
+
press(items[2], widget.previous);
|
|
396
|
+
expect(document.activeElement).toBe(items[1]);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await it(`${widget.tag} jumps with Home/End and stops at the ends`, async () => {
|
|
401
|
+
withWidget(widget.make, (el) => {
|
|
402
|
+
const items = widget.items(el);
|
|
403
|
+
|
|
404
|
+
items[0].focus();
|
|
405
|
+
press(items[0], 'End');
|
|
406
|
+
expect(document.activeElement).toBe(items[2]);
|
|
407
|
+
|
|
408
|
+
// No wrap, following <adw-tab-view> and `adw_widget_focus_child`.
|
|
409
|
+
const past = press(items[2], widget.next);
|
|
410
|
+
expect(document.activeElement).toBe(items[2]);
|
|
411
|
+
// Still claimed: an ArrowDown falling through at the last row would
|
|
412
|
+
// scroll the page out from under a user who is inside the widget.
|
|
413
|
+
expect(past.defaultPrevented).toBe(true);
|
|
414
|
+
|
|
415
|
+
press(items[2], 'Home');
|
|
416
|
+
expect(document.activeElement).toBe(items[0]);
|
|
417
|
+
|
|
418
|
+
press(items[0], widget.previous);
|
|
419
|
+
expect(document.activeElement).toBe(items[0]);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await it(`${widget.tag} leaves the other axis to the page`, async () => {
|
|
424
|
+
withWidget(widget.make, (el) => {
|
|
425
|
+
const items = widget.items(el);
|
|
426
|
+
items[1].focus();
|
|
427
|
+
|
|
428
|
+
for (const key of widget.inert) {
|
|
429
|
+
const event = press(items[1], key);
|
|
430
|
+
// Focus unmoved AND the key unclaimed: a widget that swallows an
|
|
431
|
+
// off-axis arrow stops the page scrolling under a user who is
|
|
432
|
+
// standing inside it, and `attachRovingFocus` calls
|
|
433
|
+
// `preventDefault()` before it knows whether anything moved — so
|
|
434
|
+
// asserting only the focus would miss half of it.
|
|
435
|
+
expect(document.activeElement).toBe(items[1]);
|
|
436
|
+
expect(event.defaultPrevented).toBe(false);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await it('adw-sidebar arrow selects without activating', async () => {
|
|
443
|
+
withWidget(rovingCase('adw-sidebar').make, (el) => {
|
|
444
|
+
const items = rovingCase('adw-sidebar').items(el);
|
|
445
|
+
const seen: string[] = [];
|
|
446
|
+
el.addEventListener('activated', () => seen.push('activated'));
|
|
447
|
+
el.addEventListener('notify::selected', (event) => {
|
|
448
|
+
seen.push(`selected:${(event as CustomEvent).detail.selected}`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
items[0].focus();
|
|
452
|
+
press(items[0], 'ArrowDown');
|
|
453
|
+
|
|
454
|
+
expect(document.activeElement).toBe(items[1]);
|
|
455
|
+
// GtkListBox splits the two: an arrow key is `row-selected`, and only
|
|
456
|
+
// Enter/Space/click reach `row-activated`. `activated` per keypress would
|
|
457
|
+
// navigate a split view on every press.
|
|
458
|
+
expect(seen).toStrictEqual(['selected:1']);
|
|
459
|
+
|
|
460
|
+
// Enter still activates — the row is a <button>, so this is the browser's
|
|
461
|
+
// own activation and not something this widget re-implements.
|
|
462
|
+
items[1].click();
|
|
463
|
+
expect(seen).toContain('activated');
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await it('adw-sidebar arrow moves focus even when the selection refuses', async () => {
|
|
468
|
+
const el = document.createElement('adw-sidebar');
|
|
469
|
+
el.innerHTML =
|
|
470
|
+
'<adw-sidebar-section title="Section">' +
|
|
471
|
+
['One', 'Two', 'Three'].map((t) => `<adw-sidebar-item title="${t}"></adw-sidebar-item>`).join('') +
|
|
472
|
+
'</adw-sidebar-section>';
|
|
473
|
+
document.body.appendChild(el);
|
|
474
|
+
|
|
475
|
+
const rows = Array.from(el.querySelectorAll<HTMLElement>('[role="option"]'));
|
|
476
|
+
rows[0].focus();
|
|
477
|
+
// Focus and selection on DIFFERENT rows — reachable by setting `selected`
|
|
478
|
+
// while focus sits elsewhere. The arrow now targets the row that is already
|
|
479
|
+
// selected, so the widget's `select` refuses.
|
|
480
|
+
el.setAttribute('selected', '1');
|
|
481
|
+
expect(rows.map((row) => row.tabIndex)).toStrictEqual([-1, 0, -1]);
|
|
482
|
+
expect(document.activeElement).toBe(rows[0]);
|
|
483
|
+
|
|
484
|
+
press(rows[0], 'ArrowDown');
|
|
485
|
+
// Focus travels with the KEY, not with the selection. Gating it on the
|
|
486
|
+
// selection having moved swallowed the press and left the user standing on a
|
|
487
|
+
// row the roving tabindex had taken out of the Tab order — reachable by no
|
|
488
|
+
// key, which is the whole defect this module removes.
|
|
489
|
+
expect(document.activeElement).toBe(rows[1]);
|
|
490
|
+
|
|
491
|
+
el.remove();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await it('adw-sidebar arrow skips a disabled row', async () => {
|
|
495
|
+
const el = document.createElement('adw-sidebar');
|
|
496
|
+
el.innerHTML =
|
|
497
|
+
'<adw-sidebar-section title="Section">' +
|
|
498
|
+
'<adw-sidebar-item title="One"></adw-sidebar-item>' +
|
|
499
|
+
'<adw-sidebar-item title="Two" disabled></adw-sidebar-item>' +
|
|
500
|
+
'<adw-sidebar-item title="Three"></adw-sidebar-item>' +
|
|
501
|
+
'</adw-sidebar-section>';
|
|
502
|
+
document.body.appendChild(el);
|
|
503
|
+
|
|
504
|
+
const rows = Array.from(el.querySelectorAll<HTMLButtonElement>('[role="option"]'));
|
|
505
|
+
expect(rows.length).toBe(3);
|
|
506
|
+
expect(rows[1].disabled).toBe(true);
|
|
507
|
+
|
|
508
|
+
rows[0].focus();
|
|
509
|
+
press(rows[0], 'ArrowDown');
|
|
510
|
+
// A disabled <button> cannot take focus, so leaving it in the walk would
|
|
511
|
+
// strand the user on a `focus()` the browser refuses.
|
|
512
|
+
expect(document.activeElement).toBe(rows[2]);
|
|
513
|
+
|
|
514
|
+
el.remove();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await it('adw-toggle-group declares the radio group upstream defaults to', async () => {
|
|
518
|
+
withWidget(rovingCase('adw-toggle-group').make, (el) => {
|
|
519
|
+
// `GTK_ACCESSIBLE_ROLE_RADIO_GROUP` (adw-toggle-group.c:1191) and
|
|
520
|
+
// `GTK_ACCESSIBLE_ROLE_RADIO` per toggle (:860). The element declared
|
|
521
|
+
// neither, so nothing announced these three as one exclusive choice.
|
|
522
|
+
expect(el.getAttribute('role')).toBe('radiogroup');
|
|
523
|
+
const items = rovingCase('adw-toggle-group').items(el);
|
|
524
|
+
expect(items.length).toBe(3);
|
|
525
|
+
expect(items.map((item) => item.getAttribute('aria-checked'))).toStrictEqual([
|
|
526
|
+
'true',
|
|
527
|
+
'false',
|
|
528
|
+
'false',
|
|
529
|
+
]);
|
|
530
|
+
// `aria-pressed` belongs to the toolbar toggle-BUTTON pattern, where each
|
|
531
|
+
// button is independent. Announcing three separate on/off buttons is what
|
|
532
|
+
// the element did before, so its absence is the assertion.
|
|
533
|
+
expect(items.some((item) => item.hasAttribute('aria-pressed'))).toBe(false);
|
|
534
|
+
// The flex wrapper is web-only — upstream the toggles are the group's own
|
|
535
|
+
// children — so it declares itself out of the accessibility tree rather
|
|
536
|
+
// than sitting as a `generic` between a radio group and its radios.
|
|
537
|
+
expect(el.querySelector('.adw-toggle-group-inner')?.getAttribute('role')).toBe('none');
|
|
538
|
+
|
|
539
|
+
items[0].focus();
|
|
540
|
+
press(items[0], 'ArrowRight');
|
|
541
|
+
expect(document.activeElement).toBe(items[1]);
|
|
542
|
+
expect(items.map((item) => item.getAttribute('aria-checked'))).toStrictEqual([
|
|
543
|
+
'false',
|
|
544
|
+
'true',
|
|
545
|
+
'false',
|
|
546
|
+
]);
|
|
547
|
+
expect(el.getAttribute('active')).toBe('1');
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await it('adw-toggle-group arrow moves focus even when the selection refuses', async () => {
|
|
552
|
+
withWidget(rovingCase('adw-toggle-group').make, (el) => {
|
|
553
|
+
const items = rovingCase('adw-toggle-group').items(el);
|
|
554
|
+
const seen: number[] = [];
|
|
555
|
+
el.addEventListener('notify::active', (event) => {
|
|
556
|
+
seen.push((event as CustomEvent).detail.active as number);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
items[0].focus();
|
|
560
|
+
// Focus and selection on DIFFERENT toggles, so the arrow targets the one
|
|
561
|
+
// that is ALREADY selected and `_selectIndex` genuinely refuses — the
|
|
562
|
+
// only shape that reaches the state machine's no-op guard through the
|
|
563
|
+
// keyboard. Without it the arrow tests only ever make real changes, and
|
|
564
|
+
// the guard could be deleted with the suite still green.
|
|
565
|
+
el.setAttribute('active', '1');
|
|
566
|
+
expect(items.map((item) => item.tabIndex)).toStrictEqual([-1, 0, -1]);
|
|
567
|
+
expect(document.activeElement).toBe(items[0]);
|
|
568
|
+
|
|
569
|
+
press(items[0], 'ArrowRight');
|
|
570
|
+
expect(document.activeElement).toBe(items[1]);
|
|
571
|
+
// No second notify for a selection that did not move.
|
|
572
|
+
expect(seen).toStrictEqual([]);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await it('adw-toggle-group keeps a role it did not choose', async () => {
|
|
577
|
+
withWidget(
|
|
578
|
+
() => {
|
|
579
|
+
const el = document.createElement('adw-toggle-group');
|
|
580
|
+
// Upstream writes no role at all: `gtk_widget_class_set_accessible_role`
|
|
581
|
+
// (adw-toggle-group.c:1191) is a CLASS DEFAULT an instance overrides,
|
|
582
|
+
// and `add_toggle` branches on `TAB_LIST` alone (:857) — so `GROUP`
|
|
583
|
+
// stays `GROUP` and still gets radio children.
|
|
584
|
+
el.setAttribute('role', 'group');
|
|
585
|
+
el.innerHTML = '<adw-toggle label="One"></adw-toggle><adw-toggle label="Two"></adw-toggle>';
|
|
586
|
+
return el;
|
|
587
|
+
},
|
|
588
|
+
(el) => {
|
|
589
|
+
expect(el.getAttribute('role')).toBe('group');
|
|
590
|
+
expect(el.querySelectorAll('[role="radio"]').length).toBe(2);
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await it('adw-toggle-group treats an empty role as no role', async () => {
|
|
596
|
+
withWidget(
|
|
597
|
+
() => {
|
|
598
|
+
const el = document.createElement('adw-toggle-group');
|
|
599
|
+
// `role=""` is not a declared role. Reading it with `?? null` left the
|
|
600
|
+
// group with NO role while its children were radios — a radio outside
|
|
601
|
+
// any group.
|
|
602
|
+
el.setAttribute('role', ' ');
|
|
603
|
+
el.innerHTML = '<adw-toggle label="One"></adw-toggle><adw-toggle label="Two"></adw-toggle>';
|
|
604
|
+
return el;
|
|
605
|
+
},
|
|
606
|
+
(el) => {
|
|
607
|
+
expect(el.getAttribute('role')).toBe('radiogroup');
|
|
608
|
+
expect(el.querySelectorAll('[role="radio"]').length).toBe(2);
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
await it('adw-toggle-group has no orientation yet, so one axis is the whole truth', async () => {
|
|
614
|
+
// The element is keyed to `'horizontal'`, and `inert` above pins ArrowUp/
|
|
615
|
+
// ArrowDown as the page's. Both become WRONG the day the group gains the
|
|
616
|
+
// `orientation` upstream already has — `AdwToggleGroup` implements
|
|
617
|
+
// `GtkOrientable` (adw-toggle-group.c:187), installs PROP_ORIENTATION (:202),
|
|
618
|
+
// reorients its layout (:929) and its separators (:873, :935), and
|
|
619
|
+
// `AdwInlineViewSwitcher` forwards it (:107). In a vertical group Up/Down moves
|
|
620
|
+
// inside and Left/Right propagates, so the axis has to follow the attribute.
|
|
621
|
+
//
|
|
622
|
+
// Prose cannot hold that: this line fails the commit that adds it.
|
|
623
|
+
// `<adw-inline-view-switcher>` has the same gap (status/open-todos.md).
|
|
624
|
+
expect([...AdwToggleGroup.observedAttributes]).toStrictEqual(['active', 'flat', 'round']);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
await it('adw-toggle has no state a roving walk would have to skip', async () => {
|
|
628
|
+
// `<adw-toggle-group>` passes its buttons to `attachRovingFocus` UNFILTERED,
|
|
629
|
+
// which is only safe while no `<adw-toggle>` attribute can produce a disabled
|
|
630
|
+
// or hidden button. That is a decision recorded in two comments and a ledger
|
|
631
|
+
// entry, and this is the line that fails when it stops being true — the first
|
|
632
|
+
// disabled toggle is otherwise a `focus()` the browser refuses, with nothing
|
|
633
|
+
// in the walk to step over it. Grow this list and add the filter and its spec
|
|
634
|
+
// in the same change (status/open-todos.md, `<adw-toggle>` has no `enabled`).
|
|
635
|
+
expect([...AdwToggle.observedAttributes]).toStrictEqual(['label', 'icon-name']);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await it('adw-toggle-group notifies once per arrow, through the click path', async () => {
|
|
639
|
+
withWidget(rovingCase('adw-toggle-group').make, (el) => {
|
|
640
|
+
const seen: number[] = [];
|
|
641
|
+
el.addEventListener('notify::active', (event) => {
|
|
642
|
+
seen.push((event as CustomEvent).detail.active as number);
|
|
643
|
+
});
|
|
644
|
+
const items = rovingCase('adw-toggle-group').items(el);
|
|
645
|
+
|
|
646
|
+
items[0].focus();
|
|
647
|
+
press(items[0], 'ArrowRight');
|
|
648
|
+
press(items[1], 'ArrowRight');
|
|
649
|
+
// One per real change, and none for the press at the end — which is the
|
|
650
|
+
// ROVING module's guard (`target === undefined`), not the state machine's:
|
|
651
|
+
// `_selectIndex` is never reached here. The no-op guard behind it is what
|
|
652
|
+
// the "selection refuses" case beside it presses on.
|
|
653
|
+
press(items[2], 'ArrowRight');
|
|
654
|
+
|
|
655
|
+
expect(seen).toStrictEqual([1, 2]);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await it('adw-toggle-group keeps a declared tab list a tab list', async () => {
|
|
660
|
+
withWidget(
|
|
661
|
+
() => {
|
|
662
|
+
const el = document.createElement('adw-toggle-group');
|
|
663
|
+
// The role upstream reads BEFORE it builds a toggle, under the comment
|
|
664
|
+
// "Special case for AdwInlineViewSwitcher" (adw-toggle-group.c:856) —
|
|
665
|
+
// that switcher builds exactly this widget with `TAB_LIST`
|
|
666
|
+
// (adw-inline-view-switcher.c:702). Without the branch a consumer
|
|
667
|
+
// following upstream gets radios inside a tab list.
|
|
668
|
+
el.setAttribute('role', 'tablist');
|
|
669
|
+
el.innerHTML = ['One', 'Two'].map((label) => `<adw-toggle label="${label}"></adw-toggle>`).join('');
|
|
670
|
+
return el;
|
|
671
|
+
},
|
|
672
|
+
(el) => {
|
|
673
|
+
expect(el.getAttribute('role')).toBe('tablist');
|
|
674
|
+
const tabs = Array.from(el.querySelectorAll<HTMLElement>('[role="tab"]'));
|
|
675
|
+
expect(tabs.length).toBe(2);
|
|
676
|
+
expect(tabs.map((tab) => tab.getAttribute('aria-selected'))).toStrictEqual(['true', 'false']);
|
|
677
|
+
expect(el.querySelectorAll('[role="radio"]').length).toBe(0);
|
|
678
|
+
|
|
679
|
+
tabs[0].focus();
|
|
680
|
+
press(tabs[0], 'ArrowRight');
|
|
681
|
+
expect(document.activeElement).toBe(tabs[1]);
|
|
682
|
+
expect(tabs.map((tab) => tab.getAttribute('aria-selected'))).toStrictEqual(['false', 'true']);
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
await describe('the row family is a tab stop where libadwaita makes one', async () => {
|
|
689
|
+
const mount = (markup: string) => {
|
|
690
|
+
const host = document.createElement('div');
|
|
691
|
+
host.innerHTML = markup;
|
|
692
|
+
document.body.append(host);
|
|
693
|
+
return host;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
await it('takes a tab stop only where the row is activatable', () => {
|
|
697
|
+
const host = mount(
|
|
698
|
+
'<adw-preferences-group title="G">' +
|
|
699
|
+
'<adw-action-row id="k-act" title="Act" activatable></adw-action-row>' +
|
|
700
|
+
'<adw-action-row id="k-plain" title="Plain"></adw-action-row>' +
|
|
701
|
+
'<adw-button-row id="k-btn" title="Btn"></adw-button-row>' +
|
|
702
|
+
'<adw-switch-row id="k-sw" title="Sw"></adw-switch-row>' +
|
|
703
|
+
'</adw-preferences-group>',
|
|
704
|
+
);
|
|
705
|
+
const at = (id: string) => document.getElementById(id) as HTMLElement;
|
|
706
|
+
// `tabIndex` alone cannot tell "not a stop" from "not set": a custom element
|
|
707
|
+
// reports -1 for both. The ATTRIBUTE is the thing the row wrote.
|
|
708
|
+
expect(at('k-act').getAttribute('tabindex')).toBe('0');
|
|
709
|
+
expect(at('k-plain').hasAttribute('tabindex')).toBe(false);
|
|
710
|
+
expect(at('k-btn').getAttribute('tabindex')).toBe('0');
|
|
711
|
+
expect(at('k-sw').getAttribute('tabindex')).toBe('0');
|
|
712
|
+
// adw-switch-row.c:159 — the slider is not a focus target; the row is.
|
|
713
|
+
expect(at('k-sw').querySelector('input')?.tabIndex).toBe(-1);
|
|
714
|
+
host.remove();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
await it('follows `activatable` when it moves', () => {
|
|
718
|
+
const host = mount('<adw-action-row id="k-move" title="Move"></adw-action-row>');
|
|
719
|
+
const row = document.getElementById('k-move') as HTMLElement;
|
|
720
|
+
expect(row.hasAttribute('tabindex')).toBe(false);
|
|
721
|
+
row.toggleAttribute('activatable', true);
|
|
722
|
+
expect(row.getAttribute('tabindex')).toBe('0');
|
|
723
|
+
row.toggleAttribute('activatable', false);
|
|
724
|
+
expect(row.hasAttribute('tabindex')).toBe(false);
|
|
725
|
+
host.remove();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
await it('activates on Enter and Space, and lets a child keep its own keys', () => {
|
|
729
|
+
const host = mount(
|
|
730
|
+
'<adw-action-row id="k-keys" title="Keys" activatable></adw-action-row>' +
|
|
731
|
+
'<adw-combo-row id="k-combo" title="Combo" items=\'["a","b"]\'></adw-combo-row>',
|
|
732
|
+
);
|
|
733
|
+
const row = document.getElementById('k-keys') as HTMLElement;
|
|
734
|
+
let activated = 0;
|
|
735
|
+
row.addEventListener('activated', () => activated++);
|
|
736
|
+
const press = (el: HTMLElement, key: string) =>
|
|
737
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
|
738
|
+
press(row, 'Enter');
|
|
739
|
+
press(row, ' ');
|
|
740
|
+
press(row, 'a');
|
|
741
|
+
expect(activated).toBe(2);
|
|
742
|
+
|
|
743
|
+
// A `<select>` uses Space itself; swallowing it to activate the row around it
|
|
744
|
+
// would break the control to reach the thing it sits in.
|
|
745
|
+
const select = document.getElementById('k-combo')?.querySelector('select') as HTMLElement;
|
|
746
|
+
let prevented: boolean | null = null;
|
|
747
|
+
select.addEventListener('keydown', (event) => {
|
|
748
|
+
prevented = event.defaultPrevented;
|
|
749
|
+
});
|
|
750
|
+
press(select, ' ');
|
|
751
|
+
expect(prevented).toBe(false);
|
|
752
|
+
host.remove();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
};
|