@gjsify/adwaita-web 0.41.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 (93) hide show
  1. package/README.md +98 -3
  2. package/dist/adwaita-web.css +30 -23
  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-dialog.d.ts +2 -7
  11. package/lib/types/elements/adw-icon.d.ts +17 -1
  12. package/lib/types/elements/adw-navigation-split-view.d.ts +7 -0
  13. package/lib/types/elements/adw-overlay-split-view.d.ts +6 -0
  14. package/lib/types/elements/adw-popover.d.ts +1 -0
  15. package/lib/types/elements/adw-preferences-dialog.d.ts +1 -0
  16. package/lib/types/elements/adw-switch.d.ts +10 -0
  17. package/lib/types/elements/adw-toggle-group.d.ts +2 -0
  18. package/lib/types/elements/modal-surface.d.ts +59 -0
  19. package/lib/types/elements/roving-focus.d.ts +26 -0
  20. package/lib/types/elements/row-activation.d.ts +26 -0
  21. package/lib/types/empty-sections.d.ts +15 -0
  22. package/lib/types/empty-sections.spec.d.ts +1 -0
  23. package/lib/types/fonts.d.ts +1 -0
  24. package/lib/types/icon-registry.d.ts +35 -0
  25. package/lib/types/icon-registry.spec.d.ts +18 -0
  26. package/lib/types/index.d.ts +1 -1
  27. package/lib/types/keyboard-operable.spec.d.ts +1 -0
  28. package/lib/types/styles.generated.d.ts +1 -1
  29. package/package.json +10 -6
  30. package/scss/_banner.scss +1 -1
  31. package/scss/_checks.scss +8 -8
  32. package/scss/_combo_row.scss +1 -1
  33. package/scss/_drop_down.scss +1 -1
  34. package/scss/_expander_row.scss +1 -1
  35. package/scss/_icon.scss +47 -0
  36. package/scss/_icons.generated.scss +18 -6
  37. package/scss/_password_entry_row.scss +1 -1
  38. package/scss/_preferences_dialog.scss +3 -2
  39. package/scss/_progress_bar.scss +1 -1
  40. package/scss/_row.scss +1 -1
  41. package/scss/_status_page.scss +1 -1
  42. package/scss/_tab_view.scss +1 -1
  43. package/scss/_toast.scss +6 -24
  44. package/scss/_variables.scss +23 -3
  45. package/src/adw-action-rows.spec.ts +21 -9
  46. package/src/adw-button-content.spec.ts +11 -6
  47. package/src/adw-fonts.spec.ts +188 -0
  48. package/src/adw-icon.spec.ts +17 -8
  49. package/src/adw-row-state.spec.ts +11 -1
  50. package/src/breakpoints.ts +18 -4
  51. package/src/connect-lifecycle.spec.ts +351 -0
  52. package/src/elements/adw-about-dialog.ts +24 -18
  53. package/src/elements/adw-action-row.ts +14 -2
  54. package/src/elements/adw-alert-dialog.ts +39 -20
  55. package/src/elements/adw-button-row.ts +3 -0
  56. package/src/elements/adw-clamp.ts +3 -2
  57. package/src/elements/adw-data-grid.ts +5 -2
  58. package/src/elements/adw-dialog.ts +16 -63
  59. package/src/elements/adw-expander-row.ts +18 -4
  60. package/src/elements/adw-header-bar.ts +2 -0
  61. package/src/elements/adw-icon.ts +32 -6
  62. package/src/elements/adw-inline-view-switcher.ts +12 -0
  63. package/src/elements/adw-menu-button.ts +17 -10
  64. package/src/elements/adw-navigation-split-view.ts +30 -7
  65. package/src/elements/adw-overlay-split-view.ts +15 -3
  66. package/src/elements/adw-popover.ts +11 -0
  67. package/src/elements/adw-preferences-dialog.ts +18 -16
  68. package/src/elements/adw-preferences-group.ts +7 -0
  69. package/src/elements/adw-sidebar.ts +15 -0
  70. package/src/elements/adw-status-page.ts +12 -4
  71. package/src/elements/adw-switch-row.ts +7 -0
  72. package/src/elements/adw-switch.ts +19 -1
  73. package/src/elements/adw-tab-view.ts +7 -4
  74. package/src/elements/adw-toast-overlay.ts +8 -1
  75. package/src/elements/adw-toggle-group.ts +94 -2
  76. package/src/elements/adw-toolbar-view.ts +6 -2
  77. package/src/elements/adw-view-switcher-bar.ts +18 -0
  78. package/src/elements/adw-view-switcher.ts +14 -0
  79. package/src/elements/modal-surface.ts +180 -0
  80. package/src/elements/roving-focus.ts +154 -0
  81. package/src/elements/row-activation.ts +92 -0
  82. package/src/empty-sections.spec.ts +81 -0
  83. package/src/empty-sections.ts +54 -0
  84. package/src/fonts.ts +25 -0
  85. package/src/icon-registry.spec.ts +240 -0
  86. package/src/icon-registry.ts +157 -0
  87. package/src/index.ts +24 -1
  88. package/src/keyboard-operable.spec.ts +755 -0
  89. package/src/source-view/theme.ts +15 -3
  90. package/src/split-button.spec.ts +6 -2
  91. package/src/style-classes.spec.ts +6 -0
  92. package/src/styles.generated.ts +1 -1
  93. package/src/test.browser.mts +14 -0
@@ -24,6 +24,7 @@ import { ViewSwitcherBarState, buildViewSwitcherButtons, viewSwitcherPagesFromSt
24
24
  import type { AdwViewStack } from './adw-view-stack.js';
25
25
  import { applyDescription, applyIndicator, applySwitcherIcon } from './view-switcher-dom.js';
26
26
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
27
+ import { attachRovingFocus } from './roving-focus.js';
27
28
 
28
29
  /** The DOM nodes one bar button owns — the bar paints the derived model onto them. */
29
30
  interface BarButtonNodes {
@@ -88,6 +89,20 @@ export class AdwViewSwitcherBar extends HTMLElement {
88
89
  this._barEl.className = 'adw-view-switcher-bar-box';
89
90
  this._barEl.setAttribute('role', 'tablist');
90
91
  this.replaceChildren(this._barEl);
92
+ // What makes the roving tabindex below navigable: without it the unselected
93
+ // buttons are reachable by no key at all.
94
+ attachRovingFocus({
95
+ host: this,
96
+ orientation: 'horizontal',
97
+ items: () => this._nodes.map((nodes) => nodes.button).filter((button) => !button.hidden),
98
+ select: (item) => {
99
+ const stack = this._stack;
100
+ if (!stack) return;
101
+ const index = Number(item.dataset.pageIndex);
102
+ // Re-assigning the current index would re-run the stack's transition.
103
+ if (stack.visibleChildIndex !== index) stack.visibleChildIndex = index;
104
+ },
105
+ });
91
106
  }
92
107
  this._barState.setReveal(this._readReveal());
93
108
 
@@ -208,6 +223,9 @@ export class AdwViewSwitcherBar extends HTMLElement {
208
223
  button.type = 'button';
209
224
  button.className = 'adw-view-switcher-bar-button';
210
225
  button.setAttribute('role', 'tab');
226
+ // The page index, so a keyboard move resolves an item back to a page without
227
+ // recomputing the mapping — the spelling `<adw-inline-view-switcher>` uses.
228
+ button.dataset.pageIndex = String(pageIndex);
211
229
 
212
230
  const icon = createAdwIcon(null, 'adw-view-switcher-bar-icon');
213
231
 
@@ -55,6 +55,7 @@ import {
55
55
  readSwitcherPage,
56
56
  } from './view-switcher-dom.js';
57
57
  import { type AdwIcon, createAdwIcon } from './adw-icon.js';
58
+ import { attachRovingFocus } from './roving-focus.js';
58
59
 
59
60
  /** The DOM nodes one page owns — the switcher paints the derived model onto them. */
60
61
  interface PageNodes {
@@ -171,6 +172,16 @@ export class AdwViewSwitcher extends HTMLElement {
171
172
  this._barEl = document.createElement('div');
172
173
  this._barEl.className = 'adw-view-switcher-bar';
173
174
  this._barEl.setAttribute('role', 'tablist');
175
+ // What makes the roving tabindex below navigable: without it the unselected
176
+ // buttons are reachable by no key at all.
177
+ attachRovingFocus({
178
+ host: this,
179
+ orientation: 'horizontal',
180
+ items: () => this._nodes.map((nodes) => nodes.button).filter((button) => !button.hidden),
181
+ select: (item) => {
182
+ this._state.setSelected(Number(item.dataset.pageIndex));
183
+ },
184
+ });
174
185
 
175
186
  this._contentEl = document.createElement('div');
176
187
  this._contentEl.className = 'adw-view-switcher-content';
@@ -254,6 +265,9 @@ export class AdwViewSwitcher extends HTMLElement {
254
265
  button.type = 'button';
255
266
  button.className = 'adw-view-switcher-button';
256
267
  button.setAttribute('role', 'tab');
268
+ // The page index, so a keyboard move resolves an item back to a page without
269
+ // recomputing the mapping — the spelling `<adw-inline-view-switcher>` uses.
270
+ button.dataset.pageIndex = String(index);
257
271
 
258
272
  // Both children always exist, as in AdwViewSwitcherButton's template — the icon
259
273
  // carries `image-missing` rather than disappearing.
@@ -0,0 +1,180 @@
1
+ // The one focus trap behind every `aria-modal="true"` surface in this package.
2
+ //
3
+ // THE INCIDENT
4
+ //
5
+ // Four elements set `aria-modal="true"`. One of them honoured it. Measured in Firefox
6
+ // on a page whose next tabbable element was a view-switcher button: focus the last
7
+ // control inside `<adw-alert-dialog>`, press Tab once, and focus landed on
8
+ // `.adw-view-switcher-bar-button` — outside the dialog, behind its own scrim, on a
9
+ // control the user cannot see. `<adw-about-dialog>` and `<adw-preferences-dialog>` did
10
+ // the same. Closing the dialog then left focus there: none of the three returned it to
11
+ // whatever opened them, so a keyboard user finished the interaction somewhere they never
12
+ // asked to be. `aria-modal` tells assistive technology the rest of the page is inert; it
13
+ // changes NOTHING about where the browser sends Tab. Only `<adw-dialog>` implemented the
14
+ // half that is not free, and this module is that half, lifted out of it verbatim.
15
+ //
16
+ // WHY IT IS A MODULE AND NOT A COPY
17
+ //
18
+ // `aria-modal` — the attribute and the reflected `.ariaModal` property alike — now lives
19
+ // HERE and nowhere else, held by the `[trap]` arm of
20
+ // `scripts/check-adwaita-keyboard-contract.mjs`. That is what makes a fifth modal surface
21
+ // impossible to add without a trap: the declaration and the behaviour it promises are
22
+ // the same call.
23
+ //
24
+ // Reference: refs/libadwaita/src/adw-dialog.c — GTK_ACCESSIBLE_ROLE_DIALOG (:1290) and
25
+ // the dialog's own focus handling, which cannot leave the widget because GTK confines
26
+ // focus to the presented dialog by construction. The DOM has no such confinement, so it
27
+ // is spelled out.
28
+ // Reference: refs/libadwaita/src/adw-alert-dialog.c:1103 — GTK_ACCESSIBLE_ROLE_ALERT_DIALOG.
29
+ // Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ — the Tab/Shift+Tab
30
+ // wrap and the return-focus rule, for the part upstream leaves to the platform.
31
+
32
+ /**
33
+ * Everything the browser will hand focus to on Tab. `[tabindex="-1"]` is excluded
34
+ * deliberately: it is programmatically focusable and NOT in the tab order, so treating
35
+ * it as a trap boundary would make Tab stop on a scrim.
36
+ */
37
+ const FOCUSABLE_SELECTOR = [
38
+ 'a[href]',
39
+ 'button:not([disabled])',
40
+ 'input:not([disabled])',
41
+ 'select:not([disabled])',
42
+ 'textarea:not([disabled])',
43
+ '[tabindex]:not([tabindex="-1"])',
44
+ ].join(',');
45
+
46
+ /** The two roles a modal surface in this package declares. */
47
+ export type AdwModalRole = 'dialog' | 'alertdialog';
48
+
49
+ export interface AdwModalSurfaceInit {
50
+ /**
51
+ * The custom element itself. It receives the keydown listener, because a dialog that
52
+ * moves focus to its HOST — `<adw-preferences-dialog>` does — would otherwise press
53
+ * Tab against a listener on a descendant that never sees the event.
54
+ */
55
+ host: HTMLElement;
56
+ /** The element carrying the role, `aria-modal`, and the boundary focus may not cross. */
57
+ surface: HTMLElement;
58
+ /** The WAI-ARIA role for {@link AdwModalSurfaceInit.surface}. */
59
+ role: AdwModalRole;
60
+ /** Whether the dialog is presented right now. */
61
+ isOpen: () => boolean;
62
+ /** Escape while open. Dismissal is the element's own business (`can-close`, close-response). */
63
+ onEscape: () => void;
64
+ /**
65
+ * Where focus goes on present, when the element wants something other than the first
66
+ * focusable control — `<adw-alert-dialog>` starts on its DEFAULT response, not on the
67
+ * leftmost button. It is handed {@link AdwModalSurface.focusables}, so a rule like
68
+ * "the first control in the content area" is a `find` over that list rather than a
69
+ * second copy of the filter.
70
+ *
71
+ * Returning nothing — or an element that is not in that list — falls back to the
72
+ * first focusable, then to the surface itself.
73
+ */
74
+ initialFocus?: (focusables: readonly HTMLElement[]) => HTMLElement | null | undefined;
75
+ }
76
+
77
+ /**
78
+ * A modal surface: it declares `aria-modal`, keeps Tab inside itself, and gives focus
79
+ * back to whatever had it when the dialog appeared.
80
+ *
81
+ * The element drives it with two calls — {@link present} when it opens and
82
+ * {@link dismiss} when it closes — and keeps its own dismissal rules.
83
+ */
84
+ export class AdwModalSurface {
85
+ private readonly _init: AdwModalSurfaceInit;
86
+ /** Focused before `present()`, restored by `dismiss()`. */
87
+ private _previouslyFocused: HTMLElement | null = null;
88
+
89
+ constructor(init: AdwModalSurfaceInit) {
90
+ this._init = init;
91
+ init.surface.setAttribute('role', init.role);
92
+ init.surface.setAttribute('aria-modal', 'true');
93
+ // The surface is the fallback focus target, so it has to be able to hold focus
94
+ // even when it contains no control at all. Not in the tab order: -1.
95
+ if (!init.surface.hasAttribute('tabindex')) init.surface.tabIndex = -1;
96
+ init.host.addEventListener('keydown', (event) => this._onKeyDown(event));
97
+ }
98
+
99
+ /** The controls inside the surface, in tab order, that a `focus()` can actually reach. */
100
+ focusables(): HTMLElement[] {
101
+ return Array.from(this._init.surface.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
102
+ // `getClientRects()` and not `hidden`: `<adw-about-dialog>` keeps every
103
+ // navigation page in the DOM and shows one, so the non-visible pages'
104
+ // buttons match the selector, are not `[hidden]`, and cannot take focus.
105
+ // Tabbing onto one silently leaves focus where it was — a trap that lets go.
106
+ (el) => !el.hasAttribute('disabled') && !el.hidden && el.getClientRects().length > 0,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Remember where focus was, then move it inside.
112
+ *
113
+ * SYNCHRONOUS, and that was measured rather than assumed. `<adw-about-dialog>` used to
114
+ * defer this by a `requestAnimationFrame`, which reads as "the sheet is not laid out
115
+ * yet" — it is not. Every element here toggles its `open` class before calling this,
116
+ * so the surface is already displayed; reading `document.activeElement` in the same
117
+ * task that sets `open` finds it inside the dialog for all four. A retry frame would
118
+ * be a branch nothing can reach, and a spec asserting focus one frame late would pass
119
+ * over a dialog that never focused anything at all.
120
+ */
121
+ present(): void {
122
+ const active = document.activeElement;
123
+ this._previouslyFocused = active instanceof HTMLElement && active !== this._init.host ? active : null;
124
+ const focusables = this.focusables();
125
+ // A `??` chain falls through when the element asks for NOTHING, never when it asks
126
+ // for something the browser refuses — so a request is honoured only if it is in the
127
+ // list, the same test the trap uses for its edges. Measured in Firefox before this
128
+ // filter: an `<adw-alert-dialog>` whose default response was DISABLED handed that
129
+ // button back, `focus()` on it was a no-op, and the dialog opened with
130
+ // `document.activeElement` still on the opener OUTSIDE it. The keydown listener
131
+ // sits on the host, so focus never entered, no key ever reached the trap, and a
132
+ // real Escape left `open` true — unreachable and undismissable at once.
133
+ const wanted = this._init.initialFocus?.(focusables);
134
+ const target =
135
+ (wanted && focusables.includes(wanted) ? wanted : undefined) ?? focusables[0] ?? this._init.surface;
136
+ target.focus();
137
+ }
138
+
139
+ /** Give focus back to whatever had it before `present()`. */
140
+ dismiss(): void {
141
+ const restore = this._previouslyFocused;
142
+ this._previouslyFocused = null;
143
+ if (restore && restore.isConnected) restore.focus();
144
+ }
145
+
146
+ private _onKeyDown(event: KeyboardEvent): void {
147
+ if (!this._init.isOpen()) return;
148
+ if (event.key === 'Escape') {
149
+ event.stopPropagation();
150
+ this._init.onEscape();
151
+ return;
152
+ }
153
+ if (event.key !== 'Tab') return;
154
+
155
+ // The INNERMOST open surface owns Tab: a dialog presented from inside another one
156
+ // bubbles its keydown to the outer host, which would re-run this wrap against its
157
+ // own edges and undo the move that just happened.
158
+ event.stopPropagation();
159
+
160
+ const focusables = this.focusables();
161
+ if (focusables.length === 0) {
162
+ // Nothing tabbable inside — Tab would leave immediately, so it goes nowhere.
163
+ event.preventDefault();
164
+ this._init.surface.focus();
165
+ return;
166
+ }
167
+ const first = focusables[0];
168
+ const last = focusables[focusables.length - 1];
169
+ const active = document.activeElement;
170
+ // The wrap is asserted at the EDGES only: in between, the browser's own tab order
171
+ // is already correct and intercepting it would break Shift+Tab through a form.
172
+ if (event.shiftKey && (active === first || active === this._init.surface || active === this._init.host)) {
173
+ event.preventDefault();
174
+ last.focus();
175
+ } else if (!event.shiftKey && (active === last || active === this._init.host)) {
176
+ event.preventDefault();
177
+ first.focus();
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,154 @@
1
+ // Arrow-key movement for the composites that hand out a roving tabindex.
2
+ //
3
+ // THE INCIDENT
4
+ //
5
+ // `<adw-view-switcher>`, `<adw-view-switcher-bar>`, `<adw-inline-view-switcher>` and
6
+ // `<adw-sidebar>` each declared `role="tablist"` or `role="listbox"`, then set
7
+ // `tabIndex = -1` on every item except the selected one — the roving tabindex — and
8
+ // registered no keydown listener at all. Measured in Firefox: three items, tabIndex
9
+ // `[0, -1, -1]`, and ArrowRight, ArrowLeft, ArrowDown, Home and End all left
10
+ // `document.activeElement` exactly where it was. The two unselected items were reachable
11
+ // by NO key: the roving tabindex had taken them out of the Tab order and nothing had put
12
+ // them back. That is strictly worse than the plain tab stops they replaced — the widget
13
+ // went from clumsy to unusable by adding an accessibility feature half of it.
14
+ //
15
+ // The rule this module implements is the one `<adw-tab-view>` already states in its own
16
+ // header: "the roving tabindex must keep moving: under `role=tablist` a frozen one leaves
17
+ // every inactive tab keyboard-unreachable". `<adw-tab-view>` keeps its own handler — it
18
+ // also owns Ctrl+Tab, Ctrl+Page-Up/Down and Alt+digit from `Adw.TabView`'s shortcut table,
19
+ // plus a strip-local scroll a shared helper has no business knowing about.
20
+ //
21
+ // WHAT THE KEYS DO, and where each behaviour comes from
22
+ //
23
+ // The SHAPE — Tab enters and leaves at the active item, arrows travel inside — is what
24
+ // the C establishes:
25
+ // - `refs/libadwaita/src/adw-toggle-group.c:1045` (`adw_toggle_group_focus`) — Tab
26
+ // forward/backward is PROPAGATED, i.e. focus leaves the group, and every other
27
+ // direction goes to `adw_widget_focus_child`, i.e. moves within it.
28
+ // `AdwInlineViewSwitcher` builds exactly this widget with
29
+ // `accessible-role: GTK_ACCESSIBLE_ROLE_TAB_LIST` (adw-inline-view-switcher.c:702).
30
+ // - `refs/libadwaita/src/adw-sidebar.c:2168` — `gtk_list_box_set_tab_behavior (…,
31
+ // GTK_LIST_TAB_ITEM)`: Tab moves BETWEEN rows and their contents, i.e. the list is one
32
+ // stop from outside, which is the roving tabindex spelled in GTK's own vocabulary.
33
+ //
34
+ // That SELECTION follows focus is grounded for the sidebar and conventional for the two
35
+ // tab lists, and the difference is worth keeping straight:
36
+ // - `refs/libadwaita/src/adw-sidebar.c:2167` — the sidebar's list is a `GtkListBox` in
37
+ // `GTK_SELECTION_SINGLE`, where moving the cursor with an unmodified arrow key selects
38
+ // the row it lands on. The WAI-ARIA listbox practice for a single-select listbox says
39
+ // the same.
40
+ // - `refs/libadwaita/src/adw-view-switcher.c:467` and `adw-view-switcher-button.c:337`
41
+ // declare `TAB_LIST` / `TAB`, but nothing in `AdwToggleGroup` changes `active` on
42
+ // focus: `set_active_toggle` is reached only from `toggle_active_cb`
43
+ // (adw-toggle-group.c:818), which fires on a real toggle. So GTK arrows those two
44
+ // WITHOUT switching the page — manual activation. Selection-follows-focus here is the
45
+ // WAI-ARIA tabs AUTOMATIC-activation pattern, chosen for consistency with
46
+ // `<adw-tab-view>`, which already behaves that way. Not a claim about the C.
47
+ //
48
+ // SELECTION IS NOT ACTIVATION. `<adw-sidebar>` emits `activated` — the documented way to
49
+ // reveal a split view's content pane — and an arrow key must not fire it, or every press
50
+ // navigates. `GtkListBox` splits the same two: an arrow key emits `row-selected`, and only
51
+ // Enter/Space/click emit `row-activated`. So the callback below is a SELECT, and Enter and
52
+ // Space stay the browser's own activation of the `<button>` the item already is.
53
+ //
54
+ // No wrap at the ends, following `<adw-tab-view>`, whose arrows use the non-wrapping
55
+ // `selectPreviousPage`/`selectNextPage` (the wrapping pair is Ctrl+Tab's), and GTK, whose
56
+ // `adw_widget_focus_child` stops at the last child.
57
+
58
+ /** Which axis the items are laid out along — it decides which arrow keys move. */
59
+ export type AdwRovingOrientation = 'horizontal' | 'vertical';
60
+
61
+ export interface AdwRovingFocusInit {
62
+ /**
63
+ * The element the listener sits on — the custom element itself, so items rebuilt at
64
+ * any time are still covered.
65
+ */
66
+ host: HTMLElement;
67
+ orientation: AdwRovingOrientation;
68
+ /**
69
+ * The items a key may land on, in visual order. The widget filters: a `hidden` or
70
+ * `disabled` item is not navigable, and leaving one in would strand the user on a
71
+ * `focus()` the browser refuses.
72
+ */
73
+ items: () => readonly HTMLElement[];
74
+ /**
75
+ * Select `item`. Whether the selection actually MOVED is the widget's own business:
76
+ * focus travels with the key either way ({@link attachRovingFocus}).
77
+ */
78
+ select: (item: HTMLElement) => void;
79
+ }
80
+
81
+ /** The arrow pair that moves along each axis. */
82
+ const AXIS_KEYS: Record<AdwRovingOrientation, { previous: string; next: string }> = {
83
+ horizontal: { previous: 'ArrowLeft', next: 'ArrowRight' },
84
+ vertical: { previous: 'ArrowUp', next: 'ArrowDown' },
85
+ };
86
+
87
+ /**
88
+ * Make a roving tabindex navigable: arrows step, Home/End jump, and focus travels with
89
+ * the selection so the next keypress has somewhere to start from.
90
+ */
91
+ export function attachRovingFocus(init: AdwRovingFocusInit): void {
92
+ const { previous, next } = AXIS_KEYS[init.orientation];
93
+
94
+ init.host.addEventListener('keydown', (event) => {
95
+ // A modifier makes it someone else's shortcut: Ctrl+Home is "top of the document",
96
+ // and `Adw.TabView`'s table is full of Ctrl/Alt combinations.
97
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
98
+
99
+ const items = init.items();
100
+ // `contains`, not identity: an item is a button holding an icon, a label and a
101
+ // badge, and a key pressed while one of those is the event target still belongs
102
+ // to the item.
103
+ const from = items.findIndex((item) => item === event.target || item.contains(event.target as Node));
104
+ if (from < 0) return;
105
+
106
+ let to: number;
107
+ switch (event.key) {
108
+ case previous:
109
+ to = from - 1;
110
+ break;
111
+ case next:
112
+ to = from + 1;
113
+ break;
114
+ case 'Home':
115
+ to = 0;
116
+ break;
117
+ case 'End':
118
+ to = items.length - 1;
119
+ break;
120
+ default:
121
+ return;
122
+ }
123
+
124
+ // Claimed even when nothing moves: an ArrowDown at the last row that fell through
125
+ // to the browser would scroll the page out from under a user who is inside the
126
+ // widget.
127
+ event.preventDefault();
128
+ const target = items[to];
129
+ if (target === undefined || target === items[from]) return;
130
+
131
+ // Selection is attempted, and focus moves REGARDLESS of whether it took. Gating
132
+ // the focus move on the selection having changed put the user back in the state
133
+ // this module exists to remove: with focus on row 0 and the selection already on
134
+ // row 1 — reachable by setting `selected` while focus sits elsewhere — the
135
+ // sidebar's `select` returned false for the row the arrow key targeted, so the
136
+ // press was swallowed and focus stayed on a row the roving tabindex had taken out
137
+ // of the Tab order. Measured in Firefox: tabIndex `[-1, 0, -1]`, two ArrowDowns,
138
+ // `document.activeElement` unmoved both times.
139
+ init.select(target);
140
+
141
+ // Focus travels with the roving tabindex or the next keypress goes nowhere — the
142
+ // rule `<adw-tab-view>` states. Plain `focus()`, deliberately, and NOT because
143
+ // these widgets have no scroller of their own: `adw-sidebar` is `overflow-y: auto`
144
+ // (scss/_sidebar.scss) and does. `focus()` walks every scrollable ancestor up to
145
+ // the window, and here that is what a GtkListBox cursor move does too — the
146
+ // enclosing scrolled window scrolls, and an outer one follows. Measured in Firefox
147
+ // on a 4000 px page: 25 ArrowDowns moved the sidebar's own `scrollTop` 0 → 898 and
148
+ // left `window.scrollY` at 0. `<adw-tab-view>` needs `preventScroll` for the
149
+ // opposite reason — C only ever writes the strip's own adjustment
150
+ // (`scroll_to_tab_full`), so a window scroll there is a divergence, and one
151
+ // ArrowRight was measured taking the window from y=1800 to y=8367.
152
+ init.items()[to]?.focus();
153
+ });
154
+ }
@@ -0,0 +1,92 @@
1
+ // Tab stop and Enter/Space activation for the rows that libadwaita makes activatable.
2
+ //
3
+ // THE INCIDENT
4
+ //
5
+ // Measured in Firefox on an `<adw-preferences-group>` holding an activatable
6
+ // `<adw-action-row>`, an `<adw-button-row>` and an `<adw-expander-row>`: Tab reached NONE
7
+ // of them. Every one reported `tabIndex` `-1` and `role` `null` while all three activated
8
+ // on click, so the whole group was operable by mouse and invisible to a keyboard. The only
9
+ // things Tab did reach inside a group were the NATIVE controls a row happens to contain —
10
+ // `<adw-switch-row>`'s bare checkbox and `<adw-combo-row>`'s `<select>` — i.e. the row was
11
+ // never the thing that took focus, even where upstream says it is.
12
+ //
13
+ // GTK gives this free and the web renderer does not inherit it: every one of these extends
14
+ // `GtkListBoxRow`, which is focusable by construction and activates on Enter and Space.
15
+ //
16
+ // WHICH ROWS, AND WHY EACH
17
+ //
18
+ // Derived from the C, not chosen — a row that is a tab stop it should not be turns every
19
+ // static label into a stop, and one that is not becomes unreachable:
20
+ //
21
+ // - `<adw-action-row>` only while `activatable`. A plain row is a label.
22
+ // - `<adw-button-row>` always: `adw-button-row.c:31` says "AdwButtonRow is always
23
+ // activatable", and the upstream template hardcodes it.
24
+ // - `<adw-expander-row>` always, on its HEADER — `adw_expander_row_grab_focus`
25
+ // (adw-expander-row.c:123) forwards focus to `priv->action_row`, so the header row is
26
+ // the focus target and the disclosure control at once.
27
+ // - `<adw-switch-row>` on the ROW, and its slider stops being a tab stop:
28
+ // `adw_switch_row_init` sets `gtk_widget_set_can_focus (self->slider, FALSE)`
29
+ // (adw-switch-row.c:159) and `gtk_list_box_row_set_activatable (…, TRUE)` (:160).
30
+ //
31
+ // `<adw-combo-row>` is deliberately NOT here. Its control is a native `<select>` stretched
32
+ // over the row: already a tab stop, already a combobox to the accessibility tree, already
33
+ // arrow-navigable, and already `disabled` at one option or fewer — which is exactly
34
+ // `gtk_list_box_row_set_activatable (…, n_items > 1)` (adw-combo-row.c:194) without a line
35
+ // of our own. Making the row the tab stop would take focus off it and put a SECOND
36
+ // combobox in the tree.
37
+ //
38
+ // The key set is `GtkListBoxRow`'s: Enter and Space. Space is prevented because the
39
+ // browser would otherwise scroll the page under the row the user just activated.
40
+
41
+ /** What a row needs to become the keyboard's idea of one activatable thing. */
42
+ export interface AdwRowActivationInit {
43
+ /** The element that becomes the tab stop and receives the keys. */
44
+ row: HTMLElement;
45
+ /** Re-read on every {@link AdwRowActivation.sync}: is the row activatable right now? */
46
+ activatable: () => boolean;
47
+ /**
48
+ * What Enter and Space do. Defaults to `row.click()` so a row keeps ONE activation
49
+ * path — the click handler it already has — rather than growing a second one that can
50
+ * drift from it.
51
+ */
52
+ activate?: () => void;
53
+ }
54
+
55
+ export interface AdwRowActivation {
56
+ /** Re-derive the tab stop after anything that can change `activatable()`. */
57
+ sync(): void;
58
+ }
59
+
60
+ /**
61
+ * Make `row` a tab stop while it is activatable, and activate it on Enter and Space.
62
+ *
63
+ * The listener is the element's OWN and is bound once, without teardown: it survives a
64
+ * re-parent and is collected with the element, so there is nothing here for
65
+ * `check-adwaita-connect-rebind.mjs` to hold. Only bindings that reach outside the element
66
+ * need releasing and re-arming.
67
+ */
68
+ export function attachRowActivation(init: AdwRowActivationInit): AdwRowActivation {
69
+ const { row, activatable } = init;
70
+ const activate = init.activate ?? (() => row.click());
71
+
72
+ row.addEventListener('keydown', (event: KeyboardEvent) => {
73
+ // A child control owns its own keys: a `<select>`, an entry's `<input>` and a
74
+ // suffix button all use Enter or Space for something else, and swallowing them
75
+ // here would break the control to activate the row it sits in.
76
+ if (event.target !== row) return;
77
+ if (!activatable()) return;
78
+ if (event.key !== 'Enter' && event.key !== ' ') return;
79
+ event.preventDefault();
80
+ activate();
81
+ });
82
+
83
+ const sync = () => {
84
+ // `removeAttribute`, not `tabIndex = -1`: a row that is not activatable is not
85
+ // focusable at all upstream, and writing `-1` would make every static label
86
+ // programmatically focusable instead.
87
+ if (activatable()) row.tabIndex = 0;
88
+ else row.removeAttribute('tabindex');
89
+ };
90
+ sync();
91
+ return { sync };
92
+ }
@@ -0,0 +1,81 @@
1
+ // The `hidden` derivation itself, driven in both directions.
2
+ //
3
+ // `connect-lifecycle.spec.ts` drives the WIDGETS: it appends into every container a
4
+ // consumer can reach and requires the child to be laid out. That half alone does not
5
+ // hold the invariant — replacing the whole derivation with `section.hidden = false`
6
+ // left the full adwaita-web browser suite green, because nothing anywhere asserted that
7
+ // an EMPTY section is hidden or that it goes back to hidden when its last child leaves.
8
+ // An empty bar that re-enters the flow is what that mutant ships.
9
+ //
10
+ // Driven against the helper rather than a widget on purpose: the two elements whose
11
+ // sections are private fields (`<adw-alert-dialog>`, `<adw-status-page>`) have no
12
+ // container getter, so the widget driver cannot see them, and giving them one to satisfy
13
+ // a test would be public API invented for the test.
14
+ import { describe, expect, it } from '@gjsify/unit';
15
+
16
+ import { bindEmptySections } from './empty-sections.js';
17
+
18
+ /** A section mounted in the document, since `hidden` is only observable where there is layout. */
19
+ function section(): HTMLElement {
20
+ const el = document.createElement('div');
21
+ document.body.appendChild(el);
22
+ return el;
23
+ }
24
+
25
+ /** One microtask checkpoint — where a MutationObserver callback lands. */
26
+ const settle = () => Promise.resolve();
27
+
28
+ export const AdwEmptySectionsTest = async () => {
29
+ await describe('bindEmptySections', async () => {
30
+ await it('hides a section that starts empty', async () => {
31
+ const el = section();
32
+ bindEmptySections(el);
33
+ const hidden = el.hidden;
34
+ el.remove();
35
+ expect(`empty: hidden=${hidden}`).toBe('empty: hidden=true');
36
+ });
37
+
38
+ await it('shows it when a child lands and hides it again when the last one leaves', async () => {
39
+ const el = section();
40
+ bindEmptySections(el);
41
+
42
+ const child = document.createElement('span');
43
+ el.appendChild(child);
44
+ await settle();
45
+ const filled = el.hidden;
46
+
47
+ child.remove();
48
+ await settle();
49
+ const emptied = el.hidden;
50
+
51
+ el.remove();
52
+ expect(`filled: hidden=${filled} | emptied: hidden=${emptied}`).toBe(
53
+ 'filled: hidden=false | emptied: hidden=true',
54
+ );
55
+ });
56
+
57
+ await it('counts element children, not child nodes', async () => {
58
+ const el = section();
59
+ bindEmptySections(el);
60
+ // The whitespace an indented `<div>\n </div>` carries is a text node, and a
61
+ // section full of indentation is still an empty section. This is also why the
62
+ // CSS `:empty` that this looks like cannot express the rule.
63
+ el.appendChild(document.createTextNode('\n '));
64
+ await settle();
65
+ const hidden = el.hidden;
66
+ el.remove();
67
+ expect(`whitespace only: hidden=${hidden}`).toBe('whitespace only: hidden=true');
68
+ });
69
+
70
+ await it('derives each section it is handed independently', async () => {
71
+ const empty = section();
72
+ const filled = section();
73
+ filled.appendChild(document.createElement('span'));
74
+ bindEmptySections(empty, filled);
75
+ const report = `empty=${empty.hidden} filled=${filled.hidden}`;
76
+ empty.remove();
77
+ filled.remove();
78
+ expect(report).toBe('empty=true filled=false');
79
+ });
80
+ });
81
+ };
@@ -0,0 +1,54 @@
1
+ // "Hidden while it has no children" — the ONE derivation, kept live.
2
+ //
3
+ // THE INCIDENT
4
+ //
5
+ // Eight sections across five elements derived `hidden = childElementCount === 0`
6
+ // exactly once — inside the `_initialized` guard, or inside a `_render()` only
7
+ // `attributeChangedCallback` can reach. Neither hears a childList change. Six of them
8
+ // document themselves as imperative append points (`<adw-toolbar-view>.topBar`/
9
+ // `.bottomBar`, `<adw-action-row>` and `<adw-expander-row>`'s `prefixSection`/
10
+ // `.suffixSection`), and the other two are the child slots of `<adw-alert-dialog>` and
11
+ // `<adw-status-page>`. So a header bar appended AFTER connect sat in the DOM with the
12
+ // right classes at offsetHeight 0, while the identical DECLARED bar rendered at 48px:
13
+ // the compiled sheet ends with a global `[hidden] { display: none !important }`, which
14
+ // the widget's own `display: flex` cannot beat. Measured the same in Firefox and
15
+ // Chromium.
16
+ //
17
+ // `<adw-clamp>` had already paid for the same "evaluated once at connect time" shape
18
+ // and fixed it with a MutationObserver on `{ childList: true }`. This is that TECHNIQUE
19
+ // applied to the `hidden` derivation, with one home instead of an eighth site deriving
20
+ // it an eighth time — the clamp keeps its own observer, because what it re-derives is a
21
+ // width, not `hidden`. `<adw-preferences-group>` is the third live one: `hasHeaderSuffix`
22
+ // feeds a core derivation rather than a `hidden` flag, so its existing host observer
23
+ // watches the suffix instead.
24
+ //
25
+ // NOTHING TO TEAR DOWN, deliberately. The observed nodes are children this element
26
+ // BUILT and owns, so the observer is unreachable the moment the element is, and is
27
+ // collected with it — the same reasoning `<adw-overlay-split-view>` writes above the
28
+ // `keydown` listener it binds once and never removes. Only a binding that reaches
29
+ // OUTSIDE (document, window, a media query) has to be released on disconnect and
30
+ // re-established on every connect. Keeping it bound while detached is also the better
31
+ // behaviour: a bar appended to a parked widget is already right when it comes back.
32
+
33
+ /**
34
+ * Hide each of `sections` exactly while it holds no element children — now, and one
35
+ * MICROTASK after every later childList change.
36
+ *
37
+ * The deferral is what a MutationObserver is; it matters because the derivation used to
38
+ * run synchronously inside `_render()`. It lands before paint, so layout is never wrong,
39
+ * but a caller that appends and measures in the SAME task still reads the old answer —
40
+ * `<adw-toolbar-view>._syncClasses` does exactly that via `offsetHeight`, and its
41
+ * undershoot classes are corrected by the ResizeObserver on the next frame.
42
+ *
43
+ * Element children, not child NODES: whitespace between two slotted bars is a text node
44
+ * and an empty bar full of indentation is still an empty bar — which is also why the
45
+ * CSS `:empty` this looks like cannot express it.
46
+ */
47
+ export function bindEmptySections(...sections: readonly HTMLElement[]): void {
48
+ const derive = () => {
49
+ for (const section of sections) section.hidden = section.childElementCount === 0;
50
+ };
51
+ const observer = new MutationObserver(derive);
52
+ for (const section of sections) observer.observe(section, { childList: true });
53
+ derive();
54
+ }