@gjsify/adwaita-web 0.41.0 → 0.43.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 +30 -23
- 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 +8 -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-toggle-group.d.ts +2 -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/elements/swipe-drag.d.ts +63 -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 +1 -1
- package/lib/types/keyboard-operable.spec.d.ts +1 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +10 -6
- package/scss/_banner.scss +1 -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/_icon.scss +47 -0
- package/scss/_icons.generated.scss +18 -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/_status_page.scss +1 -1
- package/scss/_tab_view.scss +1 -1
- package/scss/_toast.scss +6 -24
- package/scss/_variables.scss +23 -3
- package/src/adw-action-rows.spec.ts +21 -9
- package/src/adw-button-content.spec.ts +11 -6
- package/src/adw-fonts.spec.ts +188 -0
- package/src/adw-icon.spec.ts +17 -8
- package/src/adw-row-state.spec.ts +11 -1
- package/src/breakpoints.ts +18 -4
- 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 +86 -5
- 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 +7 -4
- 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 +6 -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/elements/swipe-drag.ts +450 -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 +24 -1
- package/src/keyboard-operable.spec.ts +755 -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
|
@@ -7,7 +7,44 @@
|
|
|
7
7
|
// The SELECTION state machine (the segment list plus the guarded, no-op-on-same active
|
|
8
8
|
// index) is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
|
|
9
9
|
// {@link ToggleGroupState}; this element keeps only the DOM half — the buttons, the active
|
|
10
|
-
// pill /
|
|
10
|
+
// pill / checked reflection, the keyboard and the event.
|
|
11
|
+
//
|
|
12
|
+
// THE ROLE IS A RADIO GROUP, not a tab list, and the element had neither. Upstream
|
|
13
|
+
// declares `GTK_ACCESSIBLE_ROLE_RADIO_GROUP` (adw-toggle-group.c:1191) and reads the
|
|
14
|
+
// group's CURRENT role when it builds each toggle: a group already declared a tab list
|
|
15
|
+
// gets `GTK_ACCESSIBLE_ROLE_TAB` children, everything else `GTK_ACCESSIBLE_ROLE_RADIO`
|
|
16
|
+
// (:857-865, under the comment "Special case for AdwInlineViewSwitcher" — that switcher
|
|
17
|
+
// builds exactly this widget with `TAB_LIST`, adw-inline-view-switcher.c:702). Both
|
|
18
|
+
// branches are ported, in the same order, because a consumer that declares the tab list
|
|
19
|
+
// upstream sanctions would otherwise get radios inside it.
|
|
20
|
+
//
|
|
21
|
+
// `aria-pressed` was the state before, and it belongs to NEITHER role — it is the
|
|
22
|
+
// toolbar toggle-button pattern, where each button is independent. The state of one of
|
|
23
|
+
// N mutually exclusive choices is `aria-checked` under `radio` and `aria-selected` under
|
|
24
|
+
// `tab`, so a screen reader was told these were three separate on/off buttons.
|
|
25
|
+
//
|
|
26
|
+
// THE KEYBOARD is one tab stop and arrows inside, which is the roving tabindex
|
|
27
|
+
// `elements/roving-focus.ts` implements — and `adw_toggle_group_focus`
|
|
28
|
+
// (adw-toggle-group.c:1045) is the citation that module is built on. Tab in either
|
|
29
|
+
// direction is PROPAGATED (:1059-1060), i.e. focus leaves the group; every other
|
|
30
|
+
// direction goes to `adw_widget_focus_child` (:1062), i.e. moves within it. Which button
|
|
31
|
+
// Tab enters on is `adw_toggle_group_grab_focus` (:1066): the ACTIVE toggle's button,
|
|
32
|
+
// never the first. The web element had three plain tab stops and no arrow keys at all,
|
|
33
|
+
// so it stayed operable — this change turns those three stops into one, which is visible
|
|
34
|
+
// to anything that tabs through a toggle group.
|
|
35
|
+
//
|
|
36
|
+
// HORIZONTAL ONLY, and the reason is not the missing `orientation` property. The C looks
|
|
37
|
+
// orientation-blind — `adw_toggle_group_focus` filters the two Tab directions and hands
|
|
38
|
+
// every other one on without consulting `self->orientation` — but the axis is decided
|
|
39
|
+
// one level down, GEOMETRICALLY. `adw_widget_focus_child` is `focus_move`
|
|
40
|
+
// (adw-widget-utils.c:428), which sorts through `focus_sort` (:388), which dispatches
|
|
41
|
+
// UP/DOWN into `focus_sort_up_down` (:298). That sorter DELETES every sibling with no
|
|
42
|
+
// horizontal overlap with the focused child (:339-342) — which, in a row of toggles, is
|
|
43
|
+
// all of them. The list is left holding only the focused button, `gtk_widget_child_focus`
|
|
44
|
+
// on a leaf that already has focus returns FALSE, and the press PROPAGATES: upstream,
|
|
45
|
+
// ArrowDown in a horizontal toggle group leaves the group. So `'horizontal'` is what the
|
|
46
|
+
// C does for the only layout this element has, it matches the three sibling tab lists,
|
|
47
|
+
// and it leaves ArrowUp/ArrowDown to the page instead of swallowing a scroll.
|
|
11
48
|
//
|
|
12
49
|
// Reference: refs/libadwaita/src/adw-toggle-group.c (AdwToggleGroup behaviour)
|
|
13
50
|
// Reference: refs/libadwaita/src/stylesheet/widgets/_toggle-group.scss
|
|
@@ -20,6 +57,7 @@
|
|
|
20
57
|
import { ToggleGroupState } from '@gjsify/adwaita-core';
|
|
21
58
|
|
|
22
59
|
import { createAdwIcon } from './adw-icon.js';
|
|
60
|
+
import { attachRovingFocus } from './roving-focus.js';
|
|
23
61
|
|
|
24
62
|
/** A single toggle. Children of <adw-toggle-group>; consumed at connect time. */
|
|
25
63
|
export class AdwToggle extends HTMLElement {
|
|
@@ -34,6 +72,8 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
34
72
|
/** The headless segment list + guarded selection state machine (ADR 0004). */
|
|
35
73
|
private readonly _state = new ToggleGroupState();
|
|
36
74
|
private _initialized = false;
|
|
75
|
+
/** `aria-checked` under `radio`, `aria-selected` under `tab` — decided at connect. */
|
|
76
|
+
private _stateAttr: 'aria-checked' | 'aria-selected' = 'aria-checked';
|
|
37
77
|
|
|
38
78
|
static get observedAttributes() {
|
|
39
79
|
return ['active', 'flat', 'round'];
|
|
@@ -56,13 +96,48 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
56
96
|
// subtree — their label / icon-name become the rendered buttons.
|
|
57
97
|
const toggles = Array.from(this.querySelectorAll('adw-toggle')) as AdwToggle[];
|
|
58
98
|
|
|
99
|
+
// `gtk_widget_class_set_accessible_role` sets a CLASS DEFAULT that an instance's
|
|
100
|
+
// construct-time `accessible-role` overrides, and `add_toggle` then reads
|
|
101
|
+
// whatever the instance actually has. So a declared role is kept — only an
|
|
102
|
+
// undeclared one is filled in — and `tablist` is the single branch, exactly as
|
|
103
|
+
// in the C: any other role still gets radio children.
|
|
104
|
+
// `|| null`, not `?? null`: `role=""` is not a declared role, and treating it as
|
|
105
|
+
// one left the group with no role at all while its children were radios.
|
|
106
|
+
const declared = this.getAttribute('role')?.trim() || null;
|
|
107
|
+
if (declared === null) this.setAttribute('role', 'radiogroup');
|
|
108
|
+
const isTabList = declared === 'tablist';
|
|
109
|
+
const toggleRole = isTabList ? 'tab' : 'radio';
|
|
110
|
+
this._stateAttr = isTabList ? 'aria-selected' : 'aria-checked';
|
|
111
|
+
// Read ONCE, which is what upstream allows: `GtkAccessible:accessible-role` is
|
|
112
|
+
// writable but documented "The accessible role cannot be changed once set", and
|
|
113
|
+
// the only public setter is the CLASS-level `gtk_widget_class_set_accessible_role`
|
|
114
|
+
// — there is no instance setter at all. So a later `role=` change has no upstream
|
|
115
|
+
// meaning, and re-labelling the toggles on it would be behaviour this port
|
|
116
|
+
// invented. Declare it in markup, or before the element is connected.
|
|
117
|
+
//
|
|
118
|
+
// (It is NOT construct-only, which an earlier draft of this comment claimed:
|
|
119
|
+
// `Gtk-4.0.gir` marks the property `writable` with no construct flag at all. The
|
|
120
|
+
// rule is the documented one above, which is the stronger citation anyway.)
|
|
121
|
+
|
|
59
122
|
this._innerEl = document.createElement('div');
|
|
60
123
|
this._innerEl.className = 'adw-toggle-group-inner';
|
|
124
|
+
// The toggles are this element's own children upstream; the wrapper is a web-only
|
|
125
|
+
// flex box with no counterpart in the C at all. Without a role it is a `generic`
|
|
126
|
+
// sitting between a radio group and its radios, so it declares itself away.
|
|
127
|
+
//
|
|
128
|
+
// NO upstream precedent is claimed for this, and an earlier draft of this comment
|
|
129
|
+
// invented one: every `GTK_ACCESSIBLE_ROLE_PRESENTATION` in libadwaita is a leaf
|
|
130
|
+
// decoration (an icon, a gizmo), never a layout container between a role-bearing
|
|
131
|
+
// parent and role-bearing children — because GTK has no such container here to
|
|
132
|
+
// annotate. That is exactly why the attribute is needed on this side and nowhere
|
|
133
|
+
// in the C.
|
|
134
|
+
this._innerEl.setAttribute('role', 'none');
|
|
61
135
|
|
|
62
136
|
this._buttons = toggles.map((toggle, index) => {
|
|
63
137
|
const btn = document.createElement('button');
|
|
64
138
|
btn.type = 'button';
|
|
65
139
|
btn.className = 'adw-toggle';
|
|
140
|
+
btn.setAttribute('role', toggleRole);
|
|
66
141
|
|
|
67
142
|
const label = toggle.getAttribute('label') ?? '';
|
|
68
143
|
const icon = toggle.getAttribute('icon-name') ?? '';
|
|
@@ -87,6 +162,20 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
87
162
|
this._state.subscribe(() => this._render());
|
|
88
163
|
this._state.setSelected(this._readActiveAttr());
|
|
89
164
|
this._render();
|
|
165
|
+
|
|
166
|
+
// No `disabled`/`hidden` filter, because no `<adw-toggle>` attribute can produce
|
|
167
|
+
// either — upstream's per-toggle `enabled` (adw-toggle-group.c:871) has no web
|
|
168
|
+
// counterpart yet, and a filter for a state this element cannot reach would be
|
|
169
|
+
// untestable. `AdwToggle.observedAttributes` is pinned in
|
|
170
|
+
// `keyboard-operable.spec.ts` so the day it grows, this decision has to be
|
|
171
|
+
// revisited instead of silently becoming a focus trap.
|
|
172
|
+
attachRovingFocus({
|
|
173
|
+
host: this,
|
|
174
|
+
orientation: 'horizontal',
|
|
175
|
+
items: () => this._buttons,
|
|
176
|
+
// Same path a click takes, so an arrow key cannot drift from a press.
|
|
177
|
+
select: (item) => this._selectIndex(this._buttons.findIndex((btn) => btn === item)),
|
|
178
|
+
});
|
|
90
179
|
}
|
|
91
180
|
|
|
92
181
|
attributeChangedCallback(name: string) {
|
|
@@ -124,7 +213,10 @@ export class AdwToggleGroup extends HTMLElement {
|
|
|
124
213
|
this._buttons.forEach((btn, index) => {
|
|
125
214
|
const isActive = index === active;
|
|
126
215
|
btn.classList.toggle('active', isActive);
|
|
127
|
-
btn.setAttribute(
|
|
216
|
+
btn.setAttribute(this._stateAttr, String(isActive));
|
|
217
|
+
// The roving tabindex: Tab enters on the ACTIVE toggle, the way
|
|
218
|
+
// `adw_toggle_group_grab_focus` grabs it, and leaves the group from there.
|
|
219
|
+
btn.tabIndex = isActive ? 0 : -1;
|
|
128
220
|
});
|
|
129
221
|
}
|
|
130
222
|
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
parseToolbarStyle,
|
|
31
31
|
toolbarViewClasses,
|
|
32
32
|
} from '@gjsify/adwaita-core';
|
|
33
|
+
import { bindEmptySections } from '../empty-sections.js';
|
|
33
34
|
import { AdwScrollShading } from '../scroll-shading.js';
|
|
34
35
|
|
|
35
36
|
export class AdwToolbarView extends HTMLElement {
|
|
@@ -88,8 +89,11 @@ export class AdwToolbarView extends HTMLElement {
|
|
|
88
89
|
this._bottomEl.className = 'adw-toolbar-view-bottom';
|
|
89
90
|
for (const child of bottomChildren) this._bottomEl.appendChild(child);
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// A bar appended imperatively — which `topBar` invites — is a childList
|
|
93
|
+
// change, not an attribute one, so the empty/non-empty decision cannot be a
|
|
94
|
+
// one-shot read here. The ResizeObserver below then sees the bar go 0 → 48
|
|
95
|
+
// and the undershoot classes follow on their own.
|
|
96
|
+
bindEmptySections(this._topEl, this._bottomEl);
|
|
93
97
|
|
|
94
98
|
this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
|
|
95
99
|
}
|
|
@@ -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
|
+
}
|