@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.
Files changed (113) hide show
  1. package/README.md +98 -3
  2. package/dist/adwaita-web.css +142 -63
  3. package/dist/adwaita-web.css.map +1 -1
  4. package/lib/types/adw-fonts.spec.d.ts +2 -0
  5. package/lib/types/breakpoints.d.ts +10 -1
  6. package/lib/types/connect-lifecycle.spec.d.ts +1 -0
  7. package/lib/types/elements/adw-about-dialog.d.ts +1 -0
  8. package/lib/types/elements/adw-action-row.d.ts +2 -0
  9. package/lib/types/elements/adw-alert-dialog.d.ts +14 -2
  10. package/lib/types/elements/adw-carousel.d.ts +48 -0
  11. package/lib/types/elements/adw-dialog.d.ts +2 -7
  12. package/lib/types/elements/adw-icon.d.ts +17 -1
  13. package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
  14. package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
  15. package/lib/types/elements/adw-popover.d.ts +1 -0
  16. package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
  17. package/lib/types/elements/adw-switch.d.ts +10 -0
  18. package/lib/types/elements/adw-tab-view.d.ts +23 -0
  19. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  20. package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
  21. package/lib/types/elements/modal-surface.d.ts +59 -0
  22. package/lib/types/elements/roving-focus.d.ts +26 -0
  23. package/lib/types/elements/row-activation.d.ts +26 -0
  24. package/lib/types/empty-sections.d.ts +15 -0
  25. package/lib/types/empty-sections.spec.d.ts +1 -0
  26. package/lib/types/fonts.d.ts +1 -0
  27. package/lib/types/icon-registry.d.ts +35 -0
  28. package/lib/types/icon-registry.spec.d.ts +18 -0
  29. package/lib/types/index.d.ts +3 -1
  30. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  31. package/lib/types/scroll-shading.d.ts +56 -0
  32. package/lib/types/styles.generated.d.ts +1 -1
  33. package/package.json +10 -6
  34. package/scss/_banner.scss +1 -1
  35. package/scss/_carousel.scss +15 -1
  36. package/scss/_checks.scss +8 -8
  37. package/scss/_combo_row.scss +1 -1
  38. package/scss/_drop_down.scss +1 -1
  39. package/scss/_expander_row.scss +1 -1
  40. package/scss/_headerbar.scss +35 -6
  41. package/scss/_icon.scss +47 -0
  42. package/scss/_icons.generated.scss +36 -6
  43. package/scss/_password_entry_row.scss +1 -1
  44. package/scss/_preferences_dialog.scss +3 -2
  45. package/scss/_progress_bar.scss +1 -1
  46. package/scss/_row.scss +1 -1
  47. package/scss/_scrolling.scss +107 -0
  48. package/scss/_status_page.scss +1 -1
  49. package/scss/_style_classes.scss +21 -8
  50. package/scss/_tab_view.scss +1 -1
  51. package/scss/_theme.scss +1 -0
  52. package/scss/_toast.scss +6 -24
  53. package/scss/_toolbar_view.scss +52 -22
  54. package/scss/_variables.scss +30 -3
  55. package/scss/_view_switcher.scss +80 -20
  56. package/scss/adwaita-skin.scss +1 -0
  57. package/src/adw-action-rows.spec.ts +21 -9
  58. package/src/adw-button-content.spec.ts +11 -6
  59. package/src/adw-carousel.spec.ts +141 -1
  60. package/src/adw-fonts.spec.ts +188 -0
  61. package/src/adw-header-bar.spec.ts +92 -0
  62. package/src/adw-icon.spec.ts +17 -8
  63. package/src/adw-row-state.spec.ts +11 -1
  64. package/src/adw-tab-view.spec.ts +151 -0
  65. package/src/bottom-sheet.spec.ts +87 -0
  66. package/src/breakpoints.ts +18 -4
  67. package/src/chrome.spec.ts +233 -0
  68. package/src/connect-lifecycle.spec.ts +351 -0
  69. package/src/elements/adw-about-dialog.ts +24 -18
  70. package/src/elements/adw-action-row.ts +14 -2
  71. package/src/elements/adw-alert-dialog.ts +39 -20
  72. package/src/elements/adw-button-row.ts +3 -0
  73. package/src/elements/adw-carousel.ts +121 -4
  74. package/src/elements/adw-clamp.ts +3 -2
  75. package/src/elements/adw-data-grid.ts +5 -2
  76. package/src/elements/adw-dialog.ts +16 -63
  77. package/src/elements/adw-expander-row.ts +18 -4
  78. package/src/elements/adw-header-bar.ts +2 -0
  79. package/src/elements/adw-icon.ts +32 -6
  80. package/src/elements/adw-inline-view-switcher.ts +12 -0
  81. package/src/elements/adw-menu-button.ts +17 -10
  82. package/src/elements/adw-navigation-split-view.ts +30 -7
  83. package/src/elements/adw-overlay-split-view.ts +15 -3
  84. package/src/elements/adw-popover.ts +11 -0
  85. package/src/elements/adw-preferences-dialog.ts +18 -16
  86. package/src/elements/adw-preferences-group.ts +7 -0
  87. package/src/elements/adw-sidebar.ts +15 -0
  88. package/src/elements/adw-status-page.ts +12 -4
  89. package/src/elements/adw-switch-row.ts +7 -0
  90. package/src/elements/adw-switch.ts +19 -1
  91. package/src/elements/adw-tab-view.ts +70 -7
  92. package/src/elements/adw-toast-overlay.ts +8 -1
  93. package/src/elements/adw-toggle-group.ts +94 -2
  94. package/src/elements/adw-toolbar-view.ts +28 -2
  95. package/src/elements/adw-view-switcher-bar.ts +18 -0
  96. package/src/elements/adw-view-switcher.ts +14 -0
  97. package/src/elements/modal-surface.ts +180 -0
  98. package/src/elements/roving-focus.ts +154 -0
  99. package/src/elements/row-activation.ts +92 -0
  100. package/src/empty-sections.spec.ts +81 -0
  101. package/src/empty-sections.ts +54 -0
  102. package/src/fonts.ts +25 -0
  103. package/src/icon-registry.spec.ts +240 -0
  104. package/src/icon-registry.ts +157 -0
  105. package/src/index.ts +30 -1
  106. package/src/keyboard-operable.spec.ts +755 -0
  107. package/src/scroll-shading.ts +255 -0
  108. package/src/source-view/theme.ts +15 -3
  109. package/src/split-button.spec.ts +6 -2
  110. package/src/style-classes.spec.ts +6 -0
  111. package/src/styles.generated.ts +1 -1
  112. package/src/test.browser.mts +14 -0
  113. 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
+ };