@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
@@ -57,6 +57,9 @@ export type AdwTabViewPageSpec = AdwTabPageSpec<HTMLElement>;
57
57
  /** The live `<adw-tab-page>` properties, each mapped onto a state setter. */
58
58
  const PAGE_ATTRIBUTES = ['title', 'tooltip', 'icon', 'indicator-icon', 'loading', 'needs-attention', 'pinned'];
59
59
 
60
+ /** `SPACING`: the slack `scroll_to_tab_full` allows before it scrolls (adw-tab-box.c:24). */
61
+ const TAB_SPACING = 5;
62
+
60
63
  /**
61
64
  * A single page. Declared as a child of <adw-tab-view>; the element itself becomes the
62
65
  * page panel, so its attributes keep driving the tab after connect — a renderer that
@@ -497,10 +500,13 @@ export class AdwTabView extends HTMLElement {
497
500
 
498
501
  tab.appendChild(createAdwIcon(null, 'adw-tab-indicator'));
499
502
 
500
- // Close affordance — a small flat button drawn with a CSS "×" glyph, because
501
- // `window-close` has no mask class: it is not in the ICONS map in
502
- // `scripts/build-scss.mjs` that generates them. `can-focus=False` in C, so it
503
- // stays out of the tab order here too.
503
+ // Close affordance — a small flat button drawn with a CSS "×" glyph. NOT because
504
+ // the glyph is unavailable: `window-close` is in the ICONS map and has a mask class
505
+ // (the comment that used to stand here said otherwise, and was read by two more
506
+ // widgets). It is a SIZING difference — upstream's close button is 24px around a
507
+ // 16px symbolic (adw-tab.ui:77), this strip's chip budgets 18px, and a 16px-grid
508
+ // symbolic scaled into it is a design change, not a rename.
509
+ // `can-focus=False` in C, so it stays out of the tab order here too.
504
510
  const close = document.createElement('button');
505
511
  close.type = 'button';
506
512
  close.className = 'adw-tab-close';
@@ -581,6 +587,20 @@ export class AdwTabView extends HTMLElement {
581
587
  this._refreshCloseVisibility(id);
582
588
  }
583
589
 
590
+ /**
591
+ * `get_tab_position` in the BAR's scroll space: how far the chip sits from the
592
+ * strip's scroll origin, the one coordinate both `scroll_to_tab_full` and
593
+ * `update_visible` compare against the adjustment.
594
+ *
595
+ * Never `offsetLeft`. Nothing in this widget is positioned, so a chip's offsetParent
596
+ * is whichever positioned ancestor the HOST page happens to have; a tab view that
597
+ * merely sits indented then measured every chip against the wrong origin and
598
+ * reported all of them clipped.
599
+ */
600
+ private _tabPosition(tab: HTMLElement): number {
601
+ return tab.getBoundingClientRect().left - this._barEl.getBoundingClientRect().left + this._barEl.scrollLeft;
602
+ }
603
+
584
604
  /**
585
605
  * `tabCloseVisible`, with its pinned gate. `dragging` is constantly false — tab
586
606
  * drag-and-drop is compositor work and is not modelled — and `fullyVisible` is
@@ -595,8 +615,14 @@ export class AdwTabView extends HTMLElement {
595
615
  if (!close) return;
596
616
 
597
617
  const barLeft = this._barEl.scrollLeft;
598
- const fullyVisible =
599
- tab.offsetLeft >= barLeft && tab.offsetLeft + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
618
+ const pos = this._tabPosition(tab);
619
+ // `update_visible` (adw-tab-box.c:769-797). C also demands SPACING of slack on
620
+ // both sides; that term is deliberately NOT transplanted, because it is not
621
+ // slack there either: the allocation loop starts the first tab at `pos = SPACING`
622
+ // and puts SPACING between tabs (:3270-3286), so `pos - SPACING >= value` is
623
+ // exactly "flush against the leading edge". This strip has no such gaps, so the
624
+ // same term would report the first chip clipped and hide its close button.
625
+ const fullyVisible = pos >= barLeft && pos + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
600
626
  close.hidden = !tabCloseVisible({
601
627
  hovering: this._hovered.has(id),
602
628
  fullyVisible,
@@ -608,6 +634,7 @@ export class AdwTabView extends HTMLElement {
608
634
 
609
635
  private _onSelectionChange(change: TabViewSelectionChange): void {
610
636
  this._applyActiveState();
637
+ this._scrollSelectedTabIntoBar();
611
638
  this._reflectSelected();
612
639
  this.dispatchEvent(
613
640
  new CustomEvent('notify::selected-page', {
@@ -641,6 +668,34 @@ export class AdwTabView extends HTMLElement {
641
668
  }
642
669
  }
643
670
 
671
+ /**
672
+ * `scroll_to_tab_full` (adw-tab-box.c:928-961), called on every selection the way
673
+ * `select_page` calls it (:1728): bring the selected chip inside the BAR's own
674
+ * scroll window, padded by half of whichever is smaller, the chip or the leftover.
675
+ *
676
+ * The bar is the only thing C ever scrolls for a tab: `scroll_to_tab_full` writes
677
+ * `self->adjustment`, the strip's own adjustment, and nothing else. The DOM has no
678
+ * such narrow API: `focus()` and `scrollIntoView()` both walk EVERY scrollable
679
+ * ancestor up to the window. So this moves `scrollLeft` by hand, and the focus call
680
+ * below passes `preventScroll`.
681
+ */
682
+ private _scrollSelectedTabIntoBar(): void {
683
+ const id = this._state.selectedId;
684
+ const tab = id === null ? undefined : this._tabs.get(id);
685
+ if (!tab) return;
686
+ const pageSize = this._barEl.clientWidth;
687
+ const width = tab.offsetWidth;
688
+ // Unmeasured (detached, or still inside connectedCallback): nothing to scroll to.
689
+ if (pageSize <= 0 || width <= 0) return;
690
+
691
+ const value = this._barEl.scrollLeft;
692
+ const pos = this._tabPosition(tab);
693
+ const padding = Math.min(width, pageSize - width) / 2;
694
+ if (pos - TAB_SPACING < value) this._barEl.scrollLeft = pos - padding;
695
+ else if (pos + width + TAB_SPACING > value + pageSize)
696
+ this._barEl.scrollLeft = pos + width + padding - pageSize;
697
+ }
698
+
644
699
  private _applyBarVisibility(): void {
645
700
  const revealed = tabsRevealed({
646
701
  autohide: this.autohide,
@@ -710,9 +765,17 @@ export class AdwTabView extends HTMLElement {
710
765
  event.preventDefault();
711
766
  // Roving tabindex: the newly-active tab is the only focusable one, so
712
767
  // focus has to travel with it or the next keypress goes nowhere.
768
+ //
769
+ // `preventScroll` because a bare `focus()` scrolls every scrollable ancestor
770
+ // up to and including the WINDOW, while C only ever moves the tab strip
771
+ // (`scroll_to_tab_full` writes `self->adjustment`, adw-tab-box.c:928-961).
772
+ // Measured in a long documentation page: one ArrowRight took the window from
773
+ // y=1800 to y=8367, i.e. switching a tab yanked the whole document to the widget.
774
+ // `_scrollSelectedTabIntoBar` (already run by the selection change) is the
775
+ // strip-local scroll that is supposed to happen instead.
713
776
  if (inBar) {
714
777
  const id = this._state.selectedId;
715
- if (id !== null) this._tabs.get(id)?.focus();
778
+ if (id !== null) this._tabs.get(id)?.focus({ preventScroll: true });
716
779
  }
717
780
  }
718
781
 
@@ -18,6 +18,8 @@
18
18
  // queue/auto-dismiss state machine composed from @gjsify/adwaita-core.
19
19
 
20
20
  import { AdwToast, AdwToastQueue } from '@gjsify/adwaita-core';
21
+
22
+ import { createAdwIcon } from './adw-icon.js';
21
23
  import type { ToastScheduler, ToastTimerHandle } from '@gjsify/adwaita-core';
22
24
 
23
25
  // The timing seam is re-exported so a consumer can type a custom scheduler for
@@ -183,11 +185,16 @@ export class AdwToastOverlay extends HTMLElement {
183
185
  el.appendChild(button);
184
186
  }
185
187
 
186
- // Close affordance: a CSS-drawn "×", as in adw-tab-view's close button.
188
+ // Close affordance — the real `window-close` glyph, which is what upstream puts
189
+ // here (adw-toast-widget.ui:64) and what this port drew with two rotated CSS bars
190
+ // under a comment claiming no such icon shipped. It does, and the toast is the one
191
+ // close button in the package already sized the way upstream sizes it: a 16px
192
+ // symbolic in a 24px circle, so the swap is the name and nothing else.
187
193
  const close = document.createElement('button');
188
194
  close.type = 'button';
189
195
  close.className = 'adw-toast-close-button';
190
196
  close.setAttribute('aria-label', 'Close');
197
+ close.appendChild(createAdwIcon('window-close'));
191
198
  close.addEventListener('click', () => this._queue.dismiss());
192
199
  el.appendChild(close);
193
200
 
@@ -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 / `aria-pressed` reflection and the event.
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('aria-pressed', String(isActive));
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,8 @@ import {
30
30
  parseToolbarStyle,
31
31
  toolbarViewClasses,
32
32
  } from '@gjsify/adwaita-core';
33
+ import { bindEmptySections } from '../empty-sections.js';
34
+ import { AdwScrollShading } from '../scroll-shading.js';
33
35
 
34
36
  export class AdwToolbarView extends HTMLElement {
35
37
  private _topEl!: HTMLDivElement;
@@ -37,6 +39,14 @@ export class AdwToolbarView extends HTMLElement {
37
39
  private _bottomEl!: HTMLDivElement;
38
40
  private _initialized = false;
39
41
  private _resize: ResizeObserver | null = null;
42
+ /**
43
+ * The scroll indicators for every scroller under this view.
44
+ *
45
+ * Owned HERE and not by a scroller widget, because upstream scopes them here:
46
+ * `toolbarview.undershoot-top scrolledwindow` shades whatever scrolls inside,
47
+ * however deeply nested — a split view's two panes included.
48
+ */
49
+ private _shading: AdwScrollShading | null = null;
40
50
 
41
51
  static get observedAttributes() {
42
52
  return ['top-bar-style', 'bottom-bar-style', 'extend-content-to-top-edge', 'extend-content-to-bottom-edge'];
@@ -79,14 +89,21 @@ export class AdwToolbarView extends HTMLElement {
79
89
  this._bottomEl.className = 'adw-toolbar-view-bottom';
80
90
  for (const child of bottomChildren) this._bottomEl.appendChild(child);
81
91
 
82
- this._topEl.hidden = this._topEl.childElementCount === 0;
83
- this._bottomEl.hidden = this._bottomEl.childElementCount === 0;
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);
84
97
 
85
98
  this.replaceChildren(this._topEl, this._contentEl, this._bottomEl);
86
99
  }
87
100
 
88
101
  // `update_undershoots` runs from size_allocate, so the classes have to
89
102
  // follow the bars' real heights rather than a one-shot read.
103
+ // Before `_syncClasses`, which hands it the two undershoot flags it gates on.
104
+ this._shading = new AdwScrollShading(this._contentEl);
105
+ this._shading.connect();
106
+
90
107
  this._resize = new ResizeObserver(() => this._syncClasses());
91
108
  this._resize.observe(this._topEl);
92
109
  this._resize.observe(this._bottomEl);
@@ -96,6 +113,8 @@ export class AdwToolbarView extends HTMLElement {
96
113
  disconnectedCallback() {
97
114
  this._resize?.disconnect();
98
115
  this._resize = null;
116
+ this._shading?.disconnect();
117
+ this._shading = null;
99
118
  }
100
119
 
101
120
  attributeChangedCallback() {
@@ -117,6 +136,13 @@ export class AdwToolbarView extends HTMLElement {
117
136
  this._topEl.classList.toggle(cls, topBar.includes(cls));
118
137
  this._bottomEl.classList.toggle(cls, bottomBar.includes(cls));
119
138
  }
139
+
140
+ // The same two flags reach the scrollers, where the shade is actually
141
+ // drawn — a raised bar brings its own shadow and must not get a second one.
142
+ this._shading?.setUndershootEdges({
143
+ top: view.includes('undershoot-top'),
144
+ bottom: view.includes('undershoot-bottom'),
145
+ });
120
146
  }
121
147
  }
122
148
 
@@ -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
+ }