@gjsify/adwaita-web 0.31.0 → 0.32.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 (176) hide show
  1. package/dist/adwaita-web.css +786 -272
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-about-dialog.spec.d.ts +1 -0
  4. package/lib/types/adw-action-rows.spec.d.ts +1 -0
  5. package/lib/types/adw-avatar.spec.d.ts +1 -0
  6. package/lib/types/adw-banner.spec.d.ts +1 -0
  7. package/lib/types/adw-button-content.spec.d.ts +1 -0
  8. package/lib/types/adw-carousel.spec.d.ts +1 -0
  9. package/lib/types/adw-checks.spec.d.ts +1 -0
  10. package/lib/types/adw-header-bar.spec.d.ts +1 -0
  11. package/lib/types/adw-icon.spec.d.ts +1 -0
  12. package/lib/types/adw-progress-bar.spec.d.ts +1 -0
  13. package/lib/types/adw-sidebar.spec.d.ts +1 -0
  14. package/lib/types/adw-switch.spec.d.ts +1 -0
  15. package/lib/types/adw-wrap-box.spec.d.ts +1 -0
  16. package/lib/types/bottom-sheet.spec.d.ts +1 -0
  17. package/lib/types/breakpoints.d.ts +22 -0
  18. package/lib/types/breakpoints.spec.d.ts +1 -0
  19. package/lib/types/chrome.spec.d.ts +1 -0
  20. package/lib/types/elements/adw-about-dialog.d.ts +47 -8
  21. package/lib/types/elements/adw-action-row.d.ts +35 -0
  22. package/lib/types/elements/adw-avatar.d.ts +11 -0
  23. package/lib/types/elements/adw-bottom-sheet.d.ts +33 -4
  24. package/lib/types/elements/adw-button-content.d.ts +12 -0
  25. package/lib/types/elements/adw-button-row.d.ts +10 -1
  26. package/lib/types/elements/adw-carousel.d.ts +111 -8
  27. package/lib/types/elements/adw-checks.d.ts +57 -0
  28. package/lib/types/elements/adw-clamp.d.ts +4 -1
  29. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  30. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  31. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  32. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  33. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  34. package/lib/types/elements/adw-icon.d.ts +25 -0
  35. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -11
  36. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  37. package/lib/types/elements/adw-navigation-split-view.d.ts +6 -0
  38. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  39. package/lib/types/elements/adw-overlay-split-view.d.ts +38 -0
  40. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  41. package/lib/types/elements/adw-popover.d.ts +58 -0
  42. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  43. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  44. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  45. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  46. package/lib/types/elements/adw-split-button.d.ts +53 -7
  47. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  48. package/lib/types/elements/adw-switch.d.ts +15 -0
  49. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  50. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  51. package/lib/types/elements/adw-view-stack.d.ts +27 -19
  52. package/lib/types/elements/adw-view-switcher-bar.d.ts +23 -7
  53. package/lib/types/elements/adw-view-switcher.d.ts +22 -11
  54. package/lib/types/elements/adw-window-title.d.ts +3 -1
  55. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  56. package/lib/types/entry-rows.spec.d.ts +1 -0
  57. package/lib/types/index.d.ts +7 -0
  58. package/lib/types/navigation-view.spec.d.ts +1 -0
  59. package/lib/types/preferences-search.d.ts +20 -0
  60. package/lib/types/preferences.spec.d.ts +1 -0
  61. package/lib/types/split-button.spec.d.ts +1 -0
  62. package/lib/types/split-views.spec.d.ts +1 -0
  63. package/lib/types/styles.generated.d.ts +1 -1
  64. package/lib/types/tab-view.spec.d.ts +1 -0
  65. package/lib/types/view-stack.spec.d.ts +1 -0
  66. package/lib/types/view-switcher.spec.d.ts +1 -0
  67. package/package.json +6 -6
  68. package/scss/_banner.scss +13 -0
  69. package/scss/_bottom_sheet.scss +5 -6
  70. package/scss/_button.scss +3 -26
  71. package/scss/_button_content.scss +28 -0
  72. package/scss/_checks.scss +228 -0
  73. package/scss/_clamp.scss +9 -3
  74. package/scss/_data_grid.scss +15 -7
  75. package/scss/_drop_down.scss +12 -55
  76. package/scss/_expander_row.scss +5 -50
  77. package/scss/_icon.scss +51 -0
  78. package/scss/_icons.generated.scss +6 -0
  79. package/scss/_inline_view_switcher.scss +34 -0
  80. package/scss/_labels.scss +135 -0
  81. package/scss/_menu_button.scss +10 -70
  82. package/scss/_popover.scss +186 -0
  83. package/scss/_preferences.scss +29 -0
  84. package/scss/_preferences_dialog.scss +17 -0
  85. package/scss/_progress_bar.scss +161 -0
  86. package/scss/_reset.scss +18 -7
  87. package/scss/_spinner.scss +7 -3
  88. package/scss/_split_button.scss +6 -50
  89. package/scss/_switch.scss +90 -0
  90. package/scss/_switch_row.scss +6 -52
  91. package/scss/_tab_view.scss +64 -0
  92. package/scss/_theme.scss +10 -0
  93. package/scss/_toolbar_view.scss +74 -0
  94. package/scss/_variables.scss +34 -0
  95. package/scss/_view_switcher.scss +40 -0
  96. package/scss/_view_switcher_bar.scss +37 -0
  97. package/scss/_wrap_box.scss +56 -6
  98. package/scss/adwaita-skin.scss +23 -0
  99. package/src/adw-about-dialog.spec.ts +350 -0
  100. package/src/adw-action-rows.spec.ts +342 -0
  101. package/src/adw-avatar.spec.ts +139 -0
  102. package/src/adw-banner.spec.ts +152 -0
  103. package/src/adw-button-content.spec.ts +183 -0
  104. package/src/adw-button-row.spec.ts +12 -6
  105. package/src/adw-carousel.spec.ts +389 -0
  106. package/src/adw-checks.spec.ts +368 -0
  107. package/src/adw-drop-down.spec.ts +123 -0
  108. package/src/adw-header-bar.spec.ts +83 -0
  109. package/src/adw-icon.spec.ts +167 -0
  110. package/src/adw-progress-bar.spec.ts +173 -0
  111. package/src/adw-sidebar.spec.ts +332 -0
  112. package/src/adw-switch.spec.ts +242 -0
  113. package/src/adw-tab-view.spec.ts +12 -3
  114. package/src/adw-view-switcher-bar.spec.ts +61 -13
  115. package/src/adw-wrap-box.spec.ts +194 -0
  116. package/src/bottom-sheet.spec.ts +325 -0
  117. package/src/breakpoints.spec.ts +147 -0
  118. package/src/breakpoints.ts +67 -0
  119. package/src/chrome.spec.ts +301 -0
  120. package/src/elements/adw-about-dialog.ts +223 -163
  121. package/src/elements/adw-action-row.ts +120 -10
  122. package/src/elements/adw-avatar.ts +48 -54
  123. package/src/elements/adw-banner.ts +46 -10
  124. package/src/elements/adw-bottom-sheet.ts +145 -60
  125. package/src/elements/adw-button-content.ts +107 -33
  126. package/src/elements/adw-button-row.ts +61 -37
  127. package/src/elements/adw-button.ts +5 -7
  128. package/src/elements/adw-carousel.ts +467 -105
  129. package/src/elements/adw-checks.ts +299 -0
  130. package/src/elements/adw-clamp.ts +80 -10
  131. package/src/elements/adw-combo-row.ts +15 -7
  132. package/src/elements/adw-data-grid.ts +43 -91
  133. package/src/elements/adw-drop-down.ts +117 -128
  134. package/src/elements/adw-entry-row.ts +404 -36
  135. package/src/elements/adw-expander-row.ts +61 -29
  136. package/src/elements/adw-header-bar.ts +31 -6
  137. package/src/elements/adw-icon.ts +148 -0
  138. package/src/elements/adw-inline-view-switcher.ts +240 -129
  139. package/src/elements/adw-menu-button.ts +93 -70
  140. package/src/elements/adw-navigation-split-view.ts +48 -7
  141. package/src/elements/adw-navigation-view.ts +286 -148
  142. package/src/elements/adw-overlay-split-view.ts +164 -14
  143. package/src/elements/adw-password-entry-row.ts +111 -89
  144. package/src/elements/adw-popover.ts +283 -0
  145. package/src/elements/adw-preferences-dialog.ts +120 -21
  146. package/src/elements/adw-preferences-group.ts +128 -22
  147. package/src/elements/adw-progress-bar.ts +189 -0
  148. package/src/elements/adw-sidebar.ts +301 -142
  149. package/src/elements/adw-spin-row.ts +11 -5
  150. package/src/elements/adw-spinner.ts +23 -8
  151. package/src/elements/adw-split-button.ts +371 -92
  152. package/src/elements/adw-status-page.ts +9 -9
  153. package/src/elements/adw-switch-row.ts +100 -30
  154. package/src/elements/adw-switch.ts +129 -0
  155. package/src/elements/adw-tab-view.ts +682 -129
  156. package/src/elements/adw-toggle-group.ts +5 -7
  157. package/src/elements/adw-toolbar-view.ts +84 -21
  158. package/src/elements/adw-view-stack.ts +162 -85
  159. package/src/elements/adw-view-switcher-bar.ts +163 -81
  160. package/src/elements/adw-view-switcher.ts +231 -112
  161. package/src/elements/adw-window-title.ts +44 -11
  162. package/src/elements/adw-wrap-box.ts +131 -35
  163. package/src/elements/view-switcher-dom.ts +108 -0
  164. package/src/entry-rows.spec.ts +391 -0
  165. package/src/index.ts +23 -0
  166. package/src/navigation-view.spec.ts +435 -0
  167. package/src/preferences-search.ts +135 -0
  168. package/src/preferences.spec.ts +250 -0
  169. package/src/source-view/adw-source-view.ts +2 -5
  170. package/src/split-button.spec.ts +470 -0
  171. package/src/split-views.spec.ts +309 -0
  172. package/src/styles.generated.ts +1 -1
  173. package/src/tab-view.spec.ts +451 -0
  174. package/src/test.browser.mts +49 -0
  175. package/src/view-stack.spec.ts +272 -0
  176. package/src/view-switcher.spec.ts +446 -0
@@ -1,59 +1,156 @@
1
1
  // <adw-tab-view> — A dynamic tabbed container with a tab bar header (the web
2
- // counterpart of Adw.TabView + Adw.TabBar). One page is shown at a time; the
3
- // tab bar renders one chip per page (title + close affordance) over a
2
+ // counterpart of Adw.TabView + Adw.TabBar). One page is shown at a time; the tab
3
+ // bar renders one chip per page (title + close affordance) over a
4
4
  // headerbar-colored strip, with the selected chip raised onto the view
5
- // background. Pages are declared as <adw-tab-page> children (each with a
6
- // `title` attribute and arbitrary content); the view snapshots them at connect
7
- // time, builds the bar, and toggles the active page's visibility.
5
+ // background.
6
+ //
7
+ // The MODEL is HEADLESS and lives in `@gjsify/adwaita-core` (ADR 0004) as
8
+ // {@link TabViewState}, shared with the NativeScript twin and specced by the
9
+ // conformance vectors in `@gjsify/adwaita-core/conformance`. This element is the
10
+ // DOM half only: it turns declared markup into pages, builds and MOVES tab
11
+ // chips, toggles visibility, reflects the selection to an attribute, re-emits
12
+ // changes as DOM events, and maps key chords onto the state machine.
13
+ //
14
+ // What the lift fixed here, all of it C-derived:
15
+ // - `close-page` removed NOTHING, so every close was permanently denied. The
16
+ // default now closes a non-pinned page and denies a pinned one, exactly as
17
+ // `close_page_cb` does (adw-tab-view.c:1986-1993), and the event is
18
+ // CANCELABLE so `preventDefault()` defers the close until
19
+ // `closePageFinish()` — the "save before closing?" seam that did not exist.
20
+ // - `selected="99"` CLAMPED to the last page and `selected="-1"` to the first;
21
+ // libadwaita ignores both (:2145, :4126-4134).
22
+ // - `view.selected = 2` changed the visible page and fired nothing, while a
23
+ // tab click fired; C notifies on every path (:1854).
24
+ // - `<adw-tab-page>` declared `observedAttributes = ['title']` with no
25
+ // `attributeChangedCallback` behind it, so the declaration was dead and a
26
+ // title could never change. Titles, tooltips, icons and the loading state
27
+ // are now live, as `AdwTab` keeps them (adw-tab.c:930-931).
28
+ // - the bar hid on `pages <= 1` with no pinned clause, so a single PINNED tab
29
+ // hid the bar where libadwaita keeps it (adw-tab-bar.c:163).
30
+ // - the close button was always visible; it now follows `tabCloseVisible`
31
+ // (adw-tab.c:124 + the pinned gate at :645-650).
32
+ // - a roving tabindex was installed with NOTHING wired to move it, so every
33
+ // inactive tab was keyboard-unreachable under role=tablist.
34
+ //
35
+ // Pages are declared as <adw-tab-page> children (the element itself becomes the
36
+ // page panel, so its attributes stay live) or added imperatively.
8
37
  // Attributes:
9
- // selected (zero-based index of the visible page, default 0)
10
- // autohide (boolean — hide the tab bar when only one page remains, mirroring
11
- // the Adw.TabBar `autohide` property)
12
- // expand-tabs (boolean — tabs stretch to fill the bar evenly, mirroring the
13
- // Adw.TabBar `expand-tabs` property)
14
- // no-close (boolean — render no close affordance; web-specific escape hatch
15
- // for static tab sets such as documentation command tabs)
38
+ // selected — zero-based index of the visible page; out-of-range is IGNORED.
39
+ // autohide — hide the tab bar when it has nothing to show. NB Adw.TabBar
40
+ // defaults this to TRUE (`DEFAULT_TAB_AUTOHIDE`); an HTML boolean attribute
41
+ // cannot, so this stays opt-in — a deliberate, citable inversion.
42
+ // expand-tabs — tabs stretch to fill the bar evenly (Adw.TabBar `expand-tabs`).
43
+ // no-close — render no close affordance; web-specific escape hatch for
44
+ // static tab sets such as documentation command tabs.
45
+ // <adw-tab-page> attributes: page-id, title, tooltip, icon, indicator-icon,
46
+ // loading, needs-attention, pinned.
16
47
  // Events:
17
- // `notify::selected-page` (CustomEvent, bubbles, `detail = { selected }`) when
18
- // the visible page changes — mirrors the Adw.TabView `selected-page` property.
19
- // `close-page` (CustomEvent, bubbles, `detail = { index }`) when a page's close
20
- // affordance is activated — mirrors the Adw.TabView `close-page` signal.
48
+ // `notify::selected-page` (CustomEvent, bubbles,
49
+ // detail = { selected, selectedId, previousId, interactive }).
50
+ // `close-page` (CustomEvent, bubbles, CANCELABLE,
51
+ // detail = { index, id }) — one per close ATTEMPT. `preventDefault()` holds
52
+ // the page open until `closePageFinish(id, confirm)`.
53
+ // `page-attached` / `page-detached` / `page-reordered` / `page-pinned` /
54
+ // `page-updated` (CustomEvent, bubbles,
55
+ // detail = { id, position, previousPosition }) — one per page-list change,
56
+ // so a bound tab bar can move ONE chip instead of rebuilding.
21
57
  // Reference: refs/libadwaita/src/adw-tab-view.c (AdwTabView behaviour)
22
58
  // Reference: refs/libadwaita/src/adw-tab-bar.c (AdwTabBar autohide)
59
+ // Reference: refs/libadwaita/src/adw-tab.c (AdwTab chip)
23
60
  // Reference: refs/libadwaita/src/stylesheet/widgets/_tab-view.scss
24
61
  // Reference: refs/adwaita-web/adwaita-web/scss/_tabs.scss
62
+ // Reference: packages/nativescript-bridge/adwaita/src/widgets/adw-tab-view.ts (NS twin)
25
63
  // Copyright (c) 2020-2022 Purism SPC / GNOME contributors (libadwaita). LGPLv2.1+.
26
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
64
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
65
+ // tab icon + indicator nodes are <adw-icon>.
27
66
 
28
- /** A single page. Children of <adw-tab-view>; consumed at connect time. */
67
+ import { TabViewState, tabCloseVisible, tabIconState, tabTooltip, tabsRevealed } from '@gjsify/adwaita-core';
68
+ import type { AdwTabPageSpec, AdwTabPageState, TabViewPagesChange, TabViewSelectionChange } from '@gjsify/adwaita-core';
69
+
70
+ import { type AdwIcon, createAdwIcon } from './adw-icon.js';
71
+
72
+ /** A page descriptor as `pages` exposes it. */
73
+ export type AdwTabViewPage = AdwTabPageState<HTMLElement>;
74
+
75
+ /** The page spec the imperative insertion methods take. */
76
+ export type AdwTabViewPageSpec = AdwTabPageSpec<HTMLElement>;
77
+
78
+ /** The live `<adw-tab-page>` properties, each mapped onto a state setter. */
79
+ const PAGE_ATTRIBUTES = ['title', 'tooltip', 'icon', 'indicator-icon', 'loading', 'needs-attention', 'pinned'];
80
+
81
+ /**
82
+ * A single page. Declared as a child of <adw-tab-view>; the element itself
83
+ * becomes the page panel, so its attributes keep driving the tab after connect —
84
+ * the old implementation moved the children out and discarded the element, which
85
+ * is why its `observedAttributes` declaration had nothing to act on.
86
+ */
29
87
  export class AdwTabPage extends HTMLElement {
30
88
  static get observedAttributes() {
31
- return ['title'];
89
+ return PAGE_ATTRIBUTES;
90
+ }
91
+
92
+ attributeChangedCallback(name: string, _old: string | null, value: string | null) {
93
+ // `closest` still finds the view after adoption: the element is moved
94
+ // into the view's page container, not replaced by a wrapper.
95
+ const view = this.closest('adw-tab-view') as AdwTabView | null;
96
+ view?.syncDeclaredPage(this, name, value);
32
97
  }
33
98
  }
34
99
 
35
100
  export class AdwTabView extends HTMLElement {
36
- private _barEl!: HTMLDivElement;
37
- private _pagesEl!: HTMLDivElement;
38
- private _tabs: HTMLButtonElement[] = [];
39
- private _pages: HTMLDivElement[] = [];
40
- private _selected = 0;
101
+ private readonly _state = new TabViewState<HTMLElement>({
102
+ onClosePage: (page) => this._requestClose(page),
103
+ });
104
+ private readonly _barEl: HTMLDivElement;
105
+ private readonly _tabBoxEl: HTMLDivElement;
106
+ private readonly _pagesEl: HTMLDivElement;
107
+ /** Tab chips keyed by page id — the bar is edited in place, never rebuilt. */
108
+ private readonly _tabs = new Map<string, HTMLButtonElement>();
109
+ /** Page panels keyed by page id; kept so a DETACHED page's node is still reachable. */
110
+ private readonly _panels = new Map<string, HTMLElement>();
111
+ private readonly _hovered = new Set<string>();
41
112
  private _initialized = false;
113
+ private _generatedIds = 0;
114
+ /** Guards the `selected` attribute reflection against re-entering the model. */
115
+ private _reflecting = false;
42
116
 
43
117
  static get observedAttributes() {
44
118
  return ['selected', 'autohide', 'expand-tabs', 'no-close'];
45
119
  }
46
120
 
47
- /** Zero-based index of the visible page. */
121
+ constructor() {
122
+ super();
123
+ // Detached nodes only: a custom element constructor may not add children.
124
+ // They are attached in connectedCallback, and pages added BEFORE that
125
+ // land in the detached container and come along with it.
126
+ this._barEl = document.createElement('div');
127
+ this._barEl.className = 'adw-tab-bar';
128
+ this._barEl.setAttribute('role', 'tablist');
129
+ this._tabBoxEl = document.createElement('div');
130
+ this._tabBoxEl.className = 'adw-tab-box';
131
+ this._barEl.appendChild(this._tabBoxEl);
132
+ this._pagesEl = document.createElement('div');
133
+ this._pagesEl.className = 'adw-tab-view-pages';
134
+
135
+ this._state.subscribePages((change) => this._onPagesChange(change));
136
+ this._state.subscribe((change) => this._onSelectionChange(change));
137
+ }
138
+
139
+ // --- Declarative surface ------------------------------------------------
140
+
141
+ /** Zero-based index of the visible page, `-1` when the view is empty. */
48
142
  get selected(): number {
49
- return this._selected;
143
+ return this._state.selectedIndex;
50
144
  }
51
145
 
52
146
  set selected(value: number) {
53
- this.setAttribute('selected', String(value));
147
+ this._state.selectNthPage(value);
54
148
  }
55
149
 
56
- /** Whether the tab bar hides itself when only one page remains. */
150
+ /**
151
+ * Whether the tab bar hides itself when it has nothing to show —
152
+ * `tabsRevealed`, which keeps the bar up for a single PINNED tab.
153
+ */
57
154
  get autohide(): boolean {
58
155
  return this.hasAttribute('autohide');
59
156
  }
@@ -87,120 +184,576 @@ export class AdwTabView extends HTMLElement {
87
184
  if (this._initialized) return;
88
185
  this._initialized = true;
89
186
 
90
- // Snapshot the declared <adw-tab-page> children before we take over the
91
- // subtree — their title attribute + content become the rendered pages.
92
- const pages = Array.from(this.querySelectorAll('adw-tab-page')) as AdwTabPage[];
187
+ // Read the declared selection BEFORE adopting anything: the auto-select
188
+ // of the first page reflects its own index into this very attribute.
189
+ const declaredSelection = this.getAttribute('selected');
93
190
 
94
- // The tab bar (headerbar-colored strip of chips), built first so it sits
95
- // above the pages.
96
- this._barEl = document.createElement('div');
97
- this._barEl.className = 'adw-tab-bar';
98
- this._barEl.setAttribute('role', 'tablist');
191
+ // Snapshot the declared pages, then take over the subtree. The elements
192
+ // themselves become the panels, so they must survive replaceChildren.
193
+ const declared = Array.from(this.querySelectorAll(':scope > adw-tab-page')) as AdwTabPage[];
194
+ this.replaceChildren(this._barEl, this._pagesEl);
195
+ for (const pageEl of declared) this._adoptDeclaredPage(pageEl);
99
196
 
100
- const tabBox = document.createElement('div');
101
- tabBox.className = 'adw-tab-box';
102
- this._barEl.appendChild(tabBox);
197
+ if (declaredSelection !== null) this._applySelectedAttribute(declaredSelection);
198
+ this._applyBarVisibility();
199
+ this._reflectSelected();
103
200
 
104
- this._pagesEl = document.createElement('div');
105
- this._pagesEl.className = 'adw-tab-view-pages';
201
+ this.addEventListener('keydown', (event) => this._onKeyDown(event));
202
+ }
106
203
 
107
- this._tabs = [];
108
- this._pages = [];
109
-
110
- pages.forEach((page, index) => {
111
- const title = page.getAttribute('title') ?? '';
112
-
113
- const tab = document.createElement('button');
114
- tab.type = 'button';
115
- tab.className = 'adw-tab';
116
- tab.setAttribute('role', 'tab');
117
-
118
- const label = document.createElement('span');
119
- label.className = 'adw-tab-title';
120
- label.textContent = title;
121
- tab.appendChild(label);
122
-
123
- // Close affordance — a small flat button drawn with a CSS "×" glyph
124
- // (no symbolic close icon ships in @gjsify/adwaita-icons).
125
- const close = document.createElement('button');
126
- close.type = 'button';
127
- close.className = 'adw-tab-close';
128
- close.setAttribute('aria-label', 'Close tab');
129
- close.addEventListener('click', (event) => {
130
- event.stopPropagation();
131
- this.dispatchEvent(new CustomEvent('close-page', { bubbles: true, detail: { index } }));
132
- });
133
- tab.appendChild(close);
134
-
135
- tab.addEventListener('click', () => this._selectIndex(index));
136
- tabBox.appendChild(tab);
137
- this._tabs.push(tab);
138
-
139
- // The page content area — the declared element's children are moved
140
- // into a panel that the view toggles visible.
141
- const panel = document.createElement('div');
142
- panel.className = 'adw-tab-page';
143
- panel.setAttribute('role', 'tabpanel');
144
- for (const child of Array.from(page.childNodes)) panel.appendChild(child);
145
- this._pagesEl.appendChild(panel);
146
- this._pages.push(panel);
147
- });
204
+ attributeChangedCallback(name: string, _old: string | null, _value: string | null) {
205
+ if (!this._initialized) return;
206
+ if (name === 'selected') {
207
+ // The LIVE attribute, not the value captured when the reaction was
208
+ // queued: reflection writes this attribute, and a custom-element
209
+ // reaction can run after a later write has already superseded it.
210
+ if (this._reflecting) return;
211
+ const current = this.getAttribute('selected');
212
+ if (current !== null) this._applySelectedAttribute(current);
213
+ return;
214
+ }
215
+ // autohide gates the bar; expand-tabs and no-close are styling only.
216
+ this._applyBarVisibility();
217
+ }
148
218
 
149
- this.replaceChildren(this._barEl, this._pagesEl);
219
+ /**
220
+ * Push a live `<adw-tab-page>` attribute change into the model. Called by
221
+ * {@link AdwTabPage.attributeChangedCallback}; not part of the public API.
222
+ */
223
+ syncDeclaredPage(pageEl: AdwTabPage, name: string, value: string | null): void {
224
+ const id = pageEl.dataset.pageId;
225
+ if (id === undefined) return;
226
+ switch (name) {
227
+ case 'title':
228
+ this._state.setPageTitle(id, value);
229
+ return;
230
+ case 'tooltip':
231
+ this._state.setPageTooltip(id, value);
232
+ return;
233
+ case 'icon':
234
+ this._state.setPageIcon(id, value);
235
+ return;
236
+ case 'indicator-icon':
237
+ this._state.setPageIndicatorIcon(id, value);
238
+ return;
239
+ case 'loading':
240
+ this._state.setPageLoading(id, value !== null);
241
+ return;
242
+ case 'needs-attention':
243
+ this._state.setPageNeedsAttention(id, value !== null);
244
+ return;
245
+ case 'pinned':
246
+ this._state.setPagePinned(id, value !== null);
247
+ return;
248
+ }
249
+ }
250
+
251
+ // --- Model surface (thin delegations to TabViewState) --------------------
150
252
 
151
- this._selected = this._readSelectedAttr();
152
- this._render();
253
+ /** All pages in order. */
254
+ get pages(): readonly AdwTabViewPage[] {
255
+ return this._state.pages;
153
256
  }
154
257
 
155
- attributeChangedCallback(name: string) {
156
- if (!this._initialized) return;
157
- if (name === 'selected') {
158
- const next = this._readSelectedAttr();
159
- if (next !== this._selected) {
160
- this._selected = next;
161
- this._render();
258
+ /** Number of pages. */
259
+ get nPages(): number {
260
+ return this._state.nPages;
261
+ }
262
+
263
+ /** Length of the pinned prefix. */
264
+ get nPinnedPages(): number {
265
+ return this._state.nPinnedPages;
266
+ }
267
+
268
+ /** Id of the selected page, `null` when the view is empty. */
269
+ get selectedId(): string | null {
270
+ return this._state.selectedId;
271
+ }
272
+
273
+ /** Index of the selected page, `-1` when the view is empty. */
274
+ get selectedIndex(): number {
275
+ return this._state.selectedIndex;
276
+ }
277
+
278
+ /** The selected page's panel element, or `null`. */
279
+ get selectedContent(): HTMLElement | null {
280
+ return this._state.selectedPage?.content ?? null;
281
+ }
282
+
283
+ isClosing(id: string): boolean {
284
+ return this._state.isClosing(id);
285
+ }
286
+
287
+ setSelectedPage(id: string | null): boolean {
288
+ return this._state.setSelectedPage(id);
289
+ }
290
+
291
+ selectNthPage(n: number): boolean {
292
+ return this._state.selectNthPage(n);
293
+ }
294
+
295
+ selectPreviousPage(): boolean {
296
+ return this._state.selectPreviousPage();
297
+ }
298
+
299
+ selectNextPage(): boolean {
300
+ return this._state.selectNextPage();
301
+ }
302
+
303
+ selectFirstPage(): boolean {
304
+ return this._state.selectFirstPage();
305
+ }
306
+
307
+ selectLastPage(): boolean {
308
+ return this._state.selectLastPage();
309
+ }
310
+
311
+ /** Ctrl+Tab — the next page, wrapping to the first. */
312
+ cycleNextPage(): boolean {
313
+ return this._state.cycleNextPage();
314
+ }
315
+
316
+ /** Ctrl+Shift+Tab — the previous page, wrapping to the last. */
317
+ cyclePreviousPage(): boolean {
318
+ return this._state.cyclePreviousPage();
319
+ }
320
+
321
+ /** Add a page opened FROM `parentId`, deriving its position (Adw.TabView.add_page). */
322
+ addPage(spec: AdwTabViewPageSpec, parentId: string | null = null): number {
323
+ return this._state.addPage(this._withPanel(spec), parentId);
324
+ }
325
+
326
+ insertPage(spec: AdwTabViewPageSpec, position: number): number {
327
+ return this._state.insertPage(this._withPanel(spec), position);
328
+ }
329
+
330
+ prependPage(spec: AdwTabViewPageSpec): number {
331
+ return this._state.prependPage(this._withPanel(spec));
332
+ }
333
+
334
+ appendPage(spec: AdwTabViewPageSpec): number {
335
+ return this._state.appendPage(this._withPanel(spec));
336
+ }
337
+
338
+ insertPinnedPage(spec: AdwTabViewPageSpec, position: number): number {
339
+ return this._state.insertPinnedPage(this._withPanel(spec), position);
340
+ }
341
+
342
+ prependPinnedPage(spec: AdwTabViewPageSpec): number {
343
+ return this._state.prependPinnedPage(this._withPanel(spec));
344
+ }
345
+
346
+ appendPinnedPage(spec: AdwTabViewPageSpec): number {
347
+ return this._state.appendPinnedPage(this._withPanel(spec));
348
+ }
349
+
350
+ /** Pin or unpin a page, re-ordering it in the same step. Returns its new position. */
351
+ setPagePinned(id: string, pinned: boolean): number {
352
+ return this._state.setPagePinned(id, pinned);
353
+ }
354
+
355
+ /**
356
+ * Request a close. Dispatches a CANCELABLE `close-page`; calling
357
+ * `preventDefault()` on it holds the page open until {@link closePageFinish}.
358
+ */
359
+ closePage(id: string): boolean {
360
+ return this._state.closePage(id);
361
+ }
362
+
363
+ closePageFinish(id: string, confirm: boolean): boolean {
364
+ return this._state.closePageFinish(id, confirm);
365
+ }
366
+
367
+ closeOtherPages(id: string): void {
368
+ this._state.closeOtherPages(id);
369
+ }
370
+
371
+ closePagesBefore(id: string): void {
372
+ this._state.closePagesBefore(id);
373
+ }
374
+
375
+ closePagesAfter(id: string): void {
376
+ this._state.closePagesAfter(id);
377
+ }
378
+
379
+ /** Remove a page unconditionally, running the successor rule first. */
380
+ detachPage(id: string): AdwTabViewPage | null {
381
+ return this._state.detachPage(id);
382
+ }
383
+
384
+ reorderPage(id: string, position: number): boolean {
385
+ return this._state.reorderPage(id, position);
386
+ }
387
+
388
+ reorderBackward(id: string): boolean {
389
+ return this._state.reorderBackward(id);
390
+ }
391
+
392
+ reorderForward(id: string): boolean {
393
+ return this._state.reorderForward(id);
394
+ }
395
+
396
+ reorderFirst(id: string): boolean {
397
+ return this._state.reorderFirst(id);
398
+ }
399
+
400
+ reorderLast(id: string): boolean {
401
+ return this._state.reorderLast(id);
402
+ }
403
+
404
+ setPageTitle(id: string, title: string | null): boolean {
405
+ return this._state.setPageTitle(id, title);
406
+ }
407
+
408
+ setPageTooltip(id: string, tooltip: string | null): boolean {
409
+ return this._state.setPageTooltip(id, tooltip);
410
+ }
411
+
412
+ setPageIcon(id: string, icon: string | null): boolean {
413
+ return this._state.setPageIcon(id, icon);
414
+ }
415
+
416
+ setPageLoading(id: string, loading: boolean): boolean {
417
+ return this._state.setPageLoading(id, loading);
418
+ }
419
+
420
+ setPageNeedsAttention(id: string, needsAttention: boolean): boolean {
421
+ return this._state.setPageNeedsAttention(id, needsAttention);
422
+ }
423
+
424
+ /** Every precondition the model refused, as C would have warned about it. */
425
+ get diagnostics(): readonly string[] {
426
+ return this._state.diagnostics;
427
+ }
428
+
429
+ // --- Adoption -----------------------------------------------------------
430
+
431
+ private _adoptDeclaredPage(pageEl: AdwTabPage): void {
432
+ // The declared element IS the panel — attributes stay live on it, which
433
+ // is what makes `<adw-tab-page title>` observable at all.
434
+ const spec: AdwTabViewPageSpec = {
435
+ id: pageEl.getAttribute('page-id') ?? this._nextId(),
436
+ title: pageEl.getAttribute('title'),
437
+ tooltip: pageEl.getAttribute('tooltip'),
438
+ icon: pageEl.getAttribute('icon'),
439
+ indicatorIcon: pageEl.getAttribute('indicator-icon'),
440
+ loading: pageEl.hasAttribute('loading'),
441
+ needsAttention: pageEl.hasAttribute('needs-attention'),
442
+ content: pageEl,
443
+ };
444
+ if (pageEl.hasAttribute('pinned')) this.appendPinnedPage(spec);
445
+ else this.appendPage(spec);
446
+ }
447
+
448
+ /** Give a spec its panel, so `append({ id })` with no content is legal. */
449
+ private _withPanel(spec: AdwTabViewPageSpec): AdwTabViewPageSpec {
450
+ const content = spec.content ?? document.createElement('adw-tab-page');
451
+ content.classList.add('adw-tab-page');
452
+ content.setAttribute('role', 'tabpanel');
453
+ content.dataset.pageId = spec.id;
454
+ return { ...spec, content };
455
+ }
456
+
457
+ private _nextId(): string {
458
+ // Monotonic and never reused: an id is the stand-in for a page POINTER,
459
+ // so recycling one would silently alias a closed tab to a new one.
460
+ this._generatedIds += 1;
461
+ return `tab-${this._generatedIds}`;
462
+ }
463
+
464
+ // --- Rendering ----------------------------------------------------------
465
+
466
+ private _onPagesChange(change: TabViewPagesChange): void {
467
+ switch (change.kind) {
468
+ case 'attached': {
469
+ const page = this._state.getPage(change.id);
470
+ if (page) this._insertTab(page, change.position);
471
+ break;
162
472
  }
163
- return;
473
+ case 'detached': {
474
+ this._tabs.get(change.id)?.remove();
475
+ this._tabs.delete(change.id);
476
+ this._hovered.delete(change.id);
477
+ // The page is already out of the model, so its node has to come
478
+ // from here rather than from `getPage(id)?.content`.
479
+ this._panels.get(change.id)?.remove();
480
+ this._panels.delete(change.id);
481
+ break;
482
+ }
483
+ case 'reordered':
484
+ case 'pinned': {
485
+ this._moveTab(change.id, change.position);
486
+ this._refreshTab(change.id);
487
+ break;
488
+ }
489
+ case 'updated':
490
+ this._refreshTab(change.id);
491
+ break;
164
492
  }
165
- // autohide is styling/visibility-only.
166
- this._render();
167
- }
168
-
169
- private _readSelectedAttr(): number {
170
- const raw = Number.parseInt(this.getAttribute('selected') ?? '0', 10);
171
- if (Number.isNaN(raw)) return 0;
172
- const max = Math.max(0, this._pages.length - 1);
173
- return Math.min(Math.max(raw, 0), max);
174
- }
175
-
176
- private _selectIndex(index: number): void {
177
- if (index === this._selected) return;
178
- this._selected = index;
179
- // Keep the attribute in sync without re-entering via the guarded
180
- // attributeChangedCallback (value already matches, so it no-ops).
181
- this.setAttribute('selected', String(index));
182
- this._render();
183
- this.dispatchEvent(new CustomEvent('notify::selected-page', { bubbles: true, detail: { selected: index } }));
184
- }
185
-
186
- private _render(): void {
187
- // Autohide: collapse the bar when only one page (or none) remains.
188
- const hidden = this.autohide && this._pages.length <= 1;
189
- this._barEl.hidden = hidden;
190
- this._barEl.classList.toggle('single-tab', this._pages.length <= 1);
191
-
192
- this._tabs.forEach((tab, index) => {
193
- const isActive = index === this._selected;
194
- tab.classList.toggle('active', isActive);
195
- tab.setAttribute('aria-selected', String(isActive));
196
- tab.tabIndex = isActive ? 0 : -1;
493
+ this._applyBarVisibility();
494
+ this._applyActiveState();
495
+ this.dispatchEvent(
496
+ new CustomEvent(`page-${change.kind}`, {
497
+ bubbles: true,
498
+ detail: { id: change.id, position: change.position, previousPosition: change.previousPosition },
499
+ }),
500
+ );
501
+ }
502
+
503
+ private _insertTab(page: AdwTabViewPage, position: number): void {
504
+ const tab = document.createElement('button');
505
+ tab.type = 'button';
506
+ tab.className = 'adw-tab';
507
+ tab.setAttribute('role', 'tab');
508
+ tab.dataset.pageId = page.id;
509
+
510
+ // Decorative mask-image nodes — <adw-icon> carries the convention (and
511
+ // the `-symbolic` strip, which is that convention and not C: the name
512
+ // reaches `GtkImage` untouched there).
513
+ const icon = createAdwIcon(null, 'adw-tab-icon');
514
+ tab.appendChild(icon);
515
+
516
+ const label = document.createElement('span');
517
+ label.className = 'adw-tab-title';
518
+ tab.appendChild(label);
519
+
520
+ tab.appendChild(createAdwIcon(null, 'adw-tab-indicator'));
521
+
522
+ // Close affordance — a small flat button drawn with a CSS "×" glyph (no
523
+ // symbolic close icon ships in @gjsify/adwaita-icons). `can-focus=False`
524
+ // in adw-tab.ui:72, so it stays out of the tab order here too.
525
+ const close = document.createElement('button');
526
+ close.type = 'button';
527
+ close.className = 'adw-tab-close';
528
+ close.tabIndex = -1;
529
+ close.setAttribute('aria-label', 'Close tab');
530
+ close.addEventListener('click', (event) => {
531
+ event.stopPropagation();
532
+ this._state.closePage(page.id);
533
+ });
534
+ tab.appendChild(close);
535
+
536
+ tab.addEventListener('click', () => this._state.setSelectedPage(page.id));
537
+ tab.addEventListener('pointerenter', () => {
538
+ this._hovered.add(page.id);
539
+ this._refreshCloseVisibility(page.id);
540
+ });
541
+ tab.addEventListener('pointerleave', () => {
542
+ this._hovered.delete(page.id);
543
+ this._refreshCloseVisibility(page.id);
544
+ });
545
+
546
+ this._tabBoxEl.insertBefore(tab, this._tabBoxEl.children[position] ?? null);
547
+ this._tabs.set(page.id, tab);
548
+
549
+ const panel = page.content;
550
+ if (panel) {
551
+ this._panels.set(page.id, panel);
552
+ this._pagesEl.insertBefore(panel, this._pagesEl.children[position] ?? null);
553
+ }
554
+ this._refreshTab(page.id);
555
+ }
556
+
557
+ /** Remove-then-insert, the same shape the model's own reorder has. */
558
+ private _moveTab(id: string, position: number): void {
559
+ const tab = this._tabs.get(id);
560
+ if (tab) {
561
+ tab.remove();
562
+ this._tabBoxEl.insertBefore(tab, this._tabBoxEl.children[position] ?? null);
563
+ }
564
+ const panel = this._panels.get(id);
565
+ if (panel) {
566
+ panel.remove();
567
+ this._pagesEl.insertBefore(panel, this._pagesEl.children[position] ?? null);
568
+ }
569
+ }
570
+
571
+ private _refreshTab(id: string): void {
572
+ const page = this._state.getPage(id);
573
+ const tab = this._tabs.get(id);
574
+ if (!page || !tab) return;
575
+
576
+ const label = tab.querySelector('.adw-tab-title') as HTMLElement | null;
577
+ if (label) label.textContent = page.title;
578
+ // The tooltip is Pango MARKUP when the page sets one of its own
579
+ // (adw-tab.c:141-146). The DOM `title` attribute is a TEXT sink, so the
580
+ // markup is shown verbatim rather than being pushed through an HTML sink
581
+ // — a renderer that interpreted it would be executing page-supplied markup.
582
+ tab.title = tabTooltip(page);
583
+ tab.classList.toggle('pinned', page.pinned);
584
+ tab.classList.toggle('needs-attention', page.needsAttention);
585
+ tab.classList.toggle('closing', page.closing);
586
+
587
+ const icons = tabIconState(page, this.getAttribute('default-icon'));
588
+ const icon = tab.querySelector('.adw-tab-icon') as AdwIcon | null;
589
+ if (icon) {
590
+ icon.iconName = icons.icon;
591
+ icon.classList.toggle('loading', icons.spinner);
592
+ icon.hidden = !icons.iconVisible;
593
+ }
594
+ const indicator = tab.querySelector('.adw-tab-indicator') as AdwIcon | null;
595
+ if (indicator) {
596
+ indicator.iconName = page.indicatorIcon;
597
+ indicator.hidden = !icons.indicatorVisible;
598
+ }
599
+ this._refreshCloseVisibility(id);
600
+ }
601
+
602
+ /**
603
+ * `tabCloseVisible` (adw-tab.c:124 + the pinned gate at :645-650). `dragging`
604
+ * is constantly false — tab drag-and-drop is compositor work and is not
605
+ * modelled — and `fullyVisible` is measured against the bar's scroll window,
606
+ * which is exactly what the C term means for a horizontally scrolled bar.
607
+ */
608
+ private _refreshCloseVisibility(id: string): void {
609
+ const page = this._state.getPage(id);
610
+ const tab = this._tabs.get(id);
611
+ if (!page || !tab) return;
612
+ const close = tab.querySelector('.adw-tab-close') as HTMLElement | null;
613
+ if (!close) return;
614
+
615
+ const barLeft = this._barEl.scrollLeft;
616
+ const fullyVisible =
617
+ tab.offsetLeft >= barLeft && tab.offsetLeft + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
618
+ close.hidden = !tabCloseVisible({
619
+ hovering: this._hovered.has(id),
620
+ fullyVisible,
621
+ selected: this._state.selectedId === id,
622
+ dragging: false,
623
+ pinned: page.pinned,
197
624
  });
625
+ }
198
626
 
199
- this._pages.forEach((panel, index) => {
200
- const isActive = index === this._selected;
201
- panel.classList.toggle('active-page', isActive);
202
- panel.hidden = !isActive;
627
+ private _onSelectionChange(change: TabViewSelectionChange): void {
628
+ this._applyActiveState();
629
+ this._reflectSelected();
630
+ this.dispatchEvent(
631
+ new CustomEvent('notify::selected-page', {
632
+ bubbles: true,
633
+ detail: {
634
+ selected: change.selectedIndex,
635
+ selectedId: change.selectedId,
636
+ previousId: change.previousId,
637
+ interactive: change.interactive,
638
+ },
639
+ }),
640
+ );
641
+ }
642
+
643
+ private _applyActiveState(): void {
644
+ const selected = this._state.selectedId;
645
+ for (const page of this._state.pages) {
646
+ const isActive = page.id === selected;
647
+ const tab = this._tabs.get(page.id);
648
+ if (tab) {
649
+ tab.classList.toggle('active', isActive);
650
+ tab.setAttribute('aria-selected', String(isActive));
651
+ tab.tabIndex = isActive ? 0 : -1;
652
+ }
653
+ const panel = page.content;
654
+ if (panel) {
655
+ panel.classList.toggle('active-page', isActive);
656
+ panel.hidden = !isActive;
657
+ }
658
+ this._refreshCloseVisibility(page.id);
659
+ }
660
+ }
661
+
662
+ private _applyBarVisibility(): void {
663
+ const revealed = tabsRevealed({
664
+ autohide: this.autohide,
665
+ nPages: this._state.nPages,
666
+ nPinnedPages: this._state.nPinnedPages,
667
+ // Tab transfer between views is drag-and-drop, i.e. compositor work,
668
+ // and is not modelled here.
669
+ isTransferringPage: false,
203
670
  });
671
+ this._barEl.hidden = !revealed;
672
+ this._barEl.classList.toggle('single-tab', this._state.nPages <= 1);
673
+ }
674
+
675
+ private _reflectSelected(): void {
676
+ this._reflecting = true;
677
+ const index = this._state.selectedIndex;
678
+ if (index < 0) this.removeAttribute('selected');
679
+ else if (this.getAttribute('selected') !== String(index)) this.setAttribute('selected', String(index));
680
+ this._reflecting = false;
681
+ }
682
+
683
+ private _applySelectedAttribute(value: string): void {
684
+ const index = Number.parseInt(value, 10);
685
+ // An unparseable value is IGNORED rather than treated as 0: the old
686
+ // `Number.isNaN(raw) ? 0 : clamp(raw)` made `selected="oops"` select the
687
+ // first page, and `selected="99"` the last, where libadwaita refuses both
688
+ // (adw-tab-view.c:2145, :4126-4134).
689
+ if (Number.isNaN(index)) return;
690
+ this._state.selectNthPage(index);
691
+ }
692
+
693
+ // --- Keyboard -----------------------------------------------------------
694
+
695
+ /**
696
+ * The `Adw.TabView` shortcut table (`init_shortcuts`, adw-tab-view.c:2192+),
697
+ * plus the ArrowLeft/ArrowRight movement `role=tablist` promises.
698
+ *
699
+ * Ctrl+Tab WRAPS and Ctrl+Page-Up/Down does not — the same `last` flag that
700
+ * separates them in `select_page_cb` (:2017-2041). Alt+0 is page index 9,
701
+ * because pages count from 0 (:2149-2150).
702
+ */
703
+ private _onKeyDown(event: KeyboardEvent): void {
704
+ const inBar = event.target instanceof Node && this._barEl.contains(event.target);
705
+ let handled = false;
706
+
707
+ if (event.ctrlKey && event.key === 'Tab') {
708
+ handled = event.shiftKey ? this._state.cyclePreviousPage() : this._state.cycleNextPage();
709
+ } else if (event.ctrlKey && event.shiftKey && (event.key === 'PageUp' || event.key === 'PageDown')) {
710
+ const id = this._state.selectedId;
711
+ if (id !== null) {
712
+ handled = event.key === 'PageUp' ? this._state.reorderBackward(id) : this._state.reorderForward(id);
713
+ }
714
+ } else if (event.ctrlKey && event.shiftKey && (event.key === 'Home' || event.key === 'End')) {
715
+ const id = this._state.selectedId;
716
+ if (id !== null) {
717
+ handled = event.key === 'Home' ? this._state.reorderFirst(id) : this._state.reorderLast(id);
718
+ }
719
+ } else if (event.ctrlKey && (event.key === 'PageUp' || event.key === 'PageDown')) {
720
+ handled = event.key === 'PageUp' ? this._state.selectPreviousPage() : this._state.selectNextPage();
721
+ } else if (event.ctrlKey && (event.key === 'Home' || event.key === 'End')) {
722
+ handled = event.key === 'Home' ? this._state.selectFirstPage() : this._state.selectLastPage();
723
+ } else if (event.altKey && /^[0-9]$/.test(event.key)) {
724
+ const digit = Number.parseInt(event.key, 10);
725
+ handled = this._state.selectNthPage(digit === 0 ? 9 : digit - 1);
726
+ } else if (inBar && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
727
+ handled = event.key === 'ArrowLeft' ? this._state.selectPreviousPage() : this._state.selectNextPage();
728
+ } else if (inBar && (event.key === 'Home' || event.key === 'End')) {
729
+ handled = event.key === 'Home' ? this._state.selectFirstPage() : this._state.selectLastPage();
730
+ }
731
+
732
+ if (!handled) return;
733
+ event.preventDefault();
734
+ // Roving tabindex: the newly-active tab is the only focusable one, so
735
+ // focus has to travel with it or the next keypress goes nowhere.
736
+ if (inBar) {
737
+ const id = this._state.selectedId;
738
+ if (id !== null) this._tabs.get(id)?.focus();
739
+ }
740
+ }
741
+
742
+ /**
743
+ * The `close-page` seam. A CANCELABLE DOM event is the idiomatic spelling of
744
+ * the C signal's return value: letting it through takes libadwaita's default
745
+ * (`!pinned`, adw-tab-view.c:1990-1991), and `preventDefault()` defers the
746
+ * close until the app calls {@link closePageFinish}.
747
+ */
748
+ private _requestClose(page: AdwTabViewPage): boolean | 'defer' {
749
+ const proceed = this.dispatchEvent(
750
+ new CustomEvent('close-page', {
751
+ bubbles: true,
752
+ cancelable: true,
753
+ detail: { index: this._state.getPagePosition(page.id), id: page.id },
754
+ }),
755
+ );
756
+ return proceed ? !page.pinned : 'defer';
204
757
  }
205
758
  }
206
759