@gjsify/adwaita-web 0.31.0 → 0.33.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 (183) hide show
  1. package/dist/adwaita-web.css +835 -299
  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-combo-row.d.ts +10 -0
  30. package/lib/types/elements/adw-data-grid.d.ts +11 -31
  31. package/lib/types/elements/adw-drop-down.d.ts +17 -20
  32. package/lib/types/elements/adw-entry-row.d.ts +93 -3
  33. package/lib/types/elements/adw-expander-row.d.ts +7 -1
  34. package/lib/types/elements/adw-header-bar.d.ts +15 -0
  35. package/lib/types/elements/adw-icon.d.ts +25 -0
  36. package/lib/types/elements/adw-inline-view-switcher.d.ts +73 -11
  37. package/lib/types/elements/adw-menu-button.d.ts +6 -6
  38. package/lib/types/elements/adw-navigation-split-view.d.ts +48 -0
  39. package/lib/types/elements/adw-navigation-view.d.ts +63 -20
  40. package/lib/types/elements/adw-overlay-split-view.d.ts +94 -0
  41. package/lib/types/elements/adw-password-entry-row.d.ts +19 -10
  42. package/lib/types/elements/adw-popover.d.ts +58 -0
  43. package/lib/types/elements/adw-preferences-dialog.d.ts +57 -2
  44. package/lib/types/elements/adw-preferences-group.d.ts +31 -0
  45. package/lib/types/elements/adw-progress-bar.d.ts +38 -0
  46. package/lib/types/elements/adw-sidebar.d.ts +37 -5
  47. package/lib/types/elements/adw-spinner.d.ts +23 -0
  48. package/lib/types/elements/adw-split-button.d.ts +53 -7
  49. package/lib/types/elements/adw-switch-row.d.ts +11 -2
  50. package/lib/types/elements/adw-switch.d.ts +15 -0
  51. package/lib/types/elements/adw-tab-view.d.ts +129 -12
  52. package/lib/types/elements/adw-toolbar-view.d.ts +5 -0
  53. package/lib/types/elements/adw-view-stack.d.ts +39 -19
  54. package/lib/types/elements/adw-view-switcher-bar.d.ts +25 -7
  55. package/lib/types/elements/adw-view-switcher.d.ts +76 -13
  56. package/lib/types/elements/adw-window-title.d.ts +3 -1
  57. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  58. package/lib/types/elements/view-switcher-dom.d.ts +44 -0
  59. package/lib/types/entry-rows.spec.d.ts +1 -0
  60. package/lib/types/index.d.ts +7 -0
  61. package/lib/types/navigation-view.spec.d.ts +1 -0
  62. package/lib/types/preferences-search.d.ts +20 -0
  63. package/lib/types/preferences.spec.d.ts +1 -0
  64. package/lib/types/split-button.spec.d.ts +1 -0
  65. package/lib/types/split-views.spec.d.ts +1 -0
  66. package/lib/types/styles.generated.d.ts +1 -1
  67. package/lib/types/tab-view.spec.d.ts +1 -0
  68. package/lib/types/view-stack.spec.d.ts +1 -0
  69. package/lib/types/view-switcher.spec.d.ts +1 -0
  70. package/package.json +6 -6
  71. package/scss/_banner.scss +13 -0
  72. package/scss/_bottom_sheet.scss +5 -6
  73. package/scss/_button.scss +3 -26
  74. package/scss/_button_content.scss +28 -0
  75. package/scss/_checks.scss +228 -0
  76. package/scss/_clamp.scss +9 -3
  77. package/scss/_combo_row.scss +19 -0
  78. package/scss/_data_grid.scss +15 -7
  79. package/scss/_drop_down.scss +12 -55
  80. package/scss/_expander_row.scss +5 -50
  81. package/scss/_icon.scss +51 -0
  82. package/scss/_icons.generated.scss +6 -0
  83. package/scss/_inline_view_switcher.scss +34 -0
  84. package/scss/_labels.scss +135 -0
  85. package/scss/_menu_button.scss +10 -70
  86. package/scss/_navigation_split_view.scss +47 -8
  87. package/scss/_overlay_split_view.scss +117 -32
  88. package/scss/_popover.scss +186 -0
  89. package/scss/_preferences.scss +29 -0
  90. package/scss/_preferences_dialog.scss +17 -0
  91. package/scss/_progress_bar.scss +161 -0
  92. package/scss/_reset.scss +18 -7
  93. package/scss/_spinner.scss +37 -19
  94. package/scss/_split_button.scss +6 -50
  95. package/scss/_switch.scss +90 -0
  96. package/scss/_switch_row.scss +6 -52
  97. package/scss/_tab_view.scss +63 -0
  98. package/scss/_theme.scss +10 -0
  99. package/scss/_toolbar_view.scss +74 -0
  100. package/scss/_variables.scss +34 -0
  101. package/scss/_view_switcher.scss +40 -0
  102. package/scss/_view_switcher_bar.scss +37 -0
  103. package/scss/_wrap_box.scss +65 -6
  104. package/scss/adwaita-skin.scss +23 -0
  105. package/src/adw-about-dialog.spec.ts +350 -0
  106. package/src/adw-action-rows.spec.ts +342 -0
  107. package/src/adw-avatar.spec.ts +176 -0
  108. package/src/adw-banner.spec.ts +152 -0
  109. package/src/adw-button-content.spec.ts +207 -0
  110. package/src/adw-button-row.spec.ts +12 -6
  111. package/src/adw-carousel.spec.ts +389 -0
  112. package/src/adw-checks.spec.ts +368 -0
  113. package/src/adw-drop-down.spec.ts +123 -0
  114. package/src/adw-header-bar.spec.ts +83 -0
  115. package/src/adw-icon.spec.ts +167 -0
  116. package/src/adw-progress-bar.spec.ts +173 -0
  117. package/src/adw-row-state.spec.ts +49 -0
  118. package/src/adw-sidebar.spec.ts +332 -0
  119. package/src/adw-switch.spec.ts +242 -0
  120. package/src/adw-tab-view.spec.ts +12 -3
  121. package/src/adw-view-switcher-bar.spec.ts +61 -13
  122. package/src/adw-wrap-box.spec.ts +341 -0
  123. package/src/bottom-sheet.spec.ts +325 -0
  124. package/src/breakpoints.spec.ts +147 -0
  125. package/src/breakpoints.ts +67 -0
  126. package/src/chrome.spec.ts +400 -0
  127. package/src/elements/adw-about-dialog.ts +223 -163
  128. package/src/elements/adw-action-row.ts +131 -11
  129. package/src/elements/adw-avatar.ts +48 -54
  130. package/src/elements/adw-banner.ts +46 -10
  131. package/src/elements/adw-bottom-sheet.ts +145 -60
  132. package/src/elements/adw-button-content.ts +107 -33
  133. package/src/elements/adw-button-row.ts +61 -37
  134. package/src/elements/adw-button.ts +5 -7
  135. package/src/elements/adw-carousel.ts +467 -105
  136. package/src/elements/adw-checks.ts +299 -0
  137. package/src/elements/adw-clamp.ts +80 -10
  138. package/src/elements/adw-combo-row.ts +32 -7
  139. package/src/elements/adw-data-grid.ts +43 -91
  140. package/src/elements/adw-drop-down.ts +117 -128
  141. package/src/elements/adw-entry-row.ts +404 -36
  142. package/src/elements/adw-expander-row.ts +61 -29
  143. package/src/elements/adw-header-bar.ts +31 -6
  144. package/src/elements/adw-icon.ts +148 -0
  145. package/src/elements/adw-inline-view-switcher.ts +325 -131
  146. package/src/elements/adw-menu-button.ts +93 -70
  147. package/src/elements/adw-navigation-split-view.ts +220 -14
  148. package/src/elements/adw-navigation-view.ts +286 -148
  149. package/src/elements/adw-overlay-split-view.ts +536 -28
  150. package/src/elements/adw-password-entry-row.ts +111 -89
  151. package/src/elements/adw-popover.ts +283 -0
  152. package/src/elements/adw-preferences-dialog.ts +120 -21
  153. package/src/elements/adw-preferences-group.ts +128 -22
  154. package/src/elements/adw-progress-bar.ts +189 -0
  155. package/src/elements/adw-sidebar.ts +301 -142
  156. package/src/elements/adw-spin-row.ts +11 -5
  157. package/src/elements/adw-spinner.ts +168 -9
  158. package/src/elements/adw-split-button.ts +371 -92
  159. package/src/elements/adw-status-page.ts +9 -9
  160. package/src/elements/adw-switch-row.ts +100 -30
  161. package/src/elements/adw-switch.ts +129 -0
  162. package/src/elements/adw-tab-view.ts +700 -129
  163. package/src/elements/adw-toggle-group.ts +5 -7
  164. package/src/elements/adw-toolbar-view.ts +84 -21
  165. package/src/elements/adw-view-stack.ts +183 -85
  166. package/src/elements/adw-view-switcher-bar.ts +171 -81
  167. package/src/elements/adw-view-switcher.ts +328 -117
  168. package/src/elements/adw-window-title.ts +44 -11
  169. package/src/elements/adw-wrap-box.ts +329 -78
  170. package/src/elements/view-switcher-dom.ts +108 -0
  171. package/src/entry-rows.spec.ts +391 -0
  172. package/src/index.ts +23 -0
  173. package/src/navigation-view.spec.ts +435 -0
  174. package/src/preferences-search.ts +135 -0
  175. package/src/preferences.spec.ts +250 -0
  176. package/src/source-view/adw-source-view.ts +2 -5
  177. package/src/split-button.spec.ts +470 -0
  178. package/src/split-views.spec.ts +893 -0
  179. package/src/styles.generated.ts +1 -1
  180. package/src/tab-view.spec.ts +451 -0
  181. package/src/test.browser.mts +49 -0
  182. package/src/view-stack.spec.ts +272 -0
  183. package/src/view-switcher.spec.ts +665 -0
@@ -1,35 +1,146 @@
1
1
  // <adw-overlay-split-view> — Responsive sidebar that docks or overlays content.
2
2
  // Reference: Adw.OverlaySplitView from libadwaita.
3
3
  // Docs: refs/adwaita-web/adwaita-web/docs/widgets/overlaysplitview.md
4
+ //
5
+ // `breakpoint` takes an Adwaita condition (e.g. "max-width: 720px") and drives
6
+ // `collapsed` from the element's own box — the browser counterpart to an
7
+ // Adw.Breakpoint with an add_setter(), so the sidebar overlays on narrow widths
8
+ // without the application wiring a media query itself.
9
+
10
+ import {
11
+ DEFAULT_MAX_SIDEBAR_WIDTH,
12
+ DEFAULT_MIN_SIDEBAR_WIDTH,
13
+ DEFAULT_SIDEBAR_WIDTH_FRACTION,
14
+ OverlaySplitViewState,
15
+ isSidebarAtVisualStart,
16
+ layoutOverlaySplitView,
17
+ resolveOverlaySidebarWidth,
18
+ resolveSidebarBounds,
19
+ resolveSwipeArea,
20
+ resolveSwipeSnapPoints,
21
+ resolveSwipeStart,
22
+ swipeCancelProgress,
23
+ type AdwPackType,
24
+ type AdwTextDirection,
25
+ type SplitViewAnimator,
26
+ } from '@gjsify/adwaita-core';
27
+
28
+ import { bindBreakpointSetter } from '../breakpoints.js';
29
+
30
+ /**
31
+ * The reveal spring, as a CSS-timed animator.
32
+ *
33
+ * libadwaita animates with a spring `(1, 0.5, 500)`
34
+ * (adw-overlay-split-view.c:1163-1165). A browser cannot be handed spring
35
+ * parameters, so this drives the progress from `requestAnimationFrame` on a
36
+ * critically-damped approximation and lets the element write the resulting
37
+ * geometry — which is the same seam the NativeScript port fills with
38
+ * `View.animate()`, and the same one a test fills with an instant fake.
39
+ */
40
+ const CSS_SPLIT_VIEW_ANIMATOR: SplitViewAnimator = {
41
+ animate(request) {
42
+ let handle = 0;
43
+ let cancelled = false;
44
+ // 250ms is the visible settle time of libadwaita's spring at these
45
+ // parameters; the CURVE is an approximation and says so, the ENDPOINTS
46
+ // are exact because `onDone` writes `to`.
47
+ const duration = 250;
48
+ const start = performance.now();
49
+ const step = (now: number) => {
50
+ if (cancelled) return;
51
+ const t = Math.min((now - start) / duration, 1);
52
+ // ease-out: fast first, settling — the visible signature of a spring
53
+ // with no overshoot, which is what `clamp` asks for anyway.
54
+ const eased = 1 - (1 - t) * (1 - t);
55
+ request.onValue(request.from + (request.to - request.from) * eased);
56
+ if (t < 1) {
57
+ handle = requestAnimationFrame(step);
58
+ return;
59
+ }
60
+ request.onValue(request.to);
61
+ request.onDone();
62
+ };
63
+ handle = requestAnimationFrame(step);
64
+ return {
65
+ cancel() {
66
+ cancelled = true;
67
+ if (handle) cancelAnimationFrame(handle);
68
+ },
69
+ };
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Read a boolean attribute the way a GTK property with a TRUE default has to be
75
+ * read: absence means the default, not false.
76
+ *
77
+ * HTML boolean attributes are presence-only, which can only ever express a FALSE
78
+ * default — so `show-sidebar` used to start hidden while `Adw.OverlaySplitView`
79
+ * starts shown (adw-overlay-split-view.c:974-976). `show-sidebar="false"` is the
80
+ * explicit off switch.
81
+ */
82
+ function readTriStateAttr(element: Element, name: string, fallback: boolean): boolean {
83
+ const raw = element.getAttribute(name);
84
+ if (raw === null) return fallback;
85
+ return raw !== 'false' && raw !== '0';
86
+ }
4
87
 
5
88
  export class AdwOverlaySplitView extends HTMLElement {
6
89
  private _initialized = false;
7
90
  private _sidebarEl!: HTMLDivElement;
8
91
  private _contentEl!: HTMLDivElement;
9
92
  private _backdropEl!: HTMLDivElement;
93
+ private _disposeBreakpoint: (() => void) | undefined;
94
+ /**
95
+ * The single source of truth for the property interplay, shared with the
96
+ * NativeScript renderer (ADR 0004). The element owns the attributes and the
97
+ * painting; every rule about what `collapsed` + `show-sidebar` + `pin-sidebar`
98
+ * MEAN together lives in core, held to `OVERLAY_COLLAPSE_VECTORS`.
99
+ */
100
+ private _state = new OverlaySplitViewState();
101
+ /** Re-entrancy guard for {@link _reflectShowSidebar}. */
102
+ private _reflecting = false;
103
+ /** The view's own width in CSS px — the size the sidebar fraction is OF. */
104
+ private _measuredWidth = 0;
105
+ private _resizeObserver: ResizeObserver | undefined;
106
+ /** The pointer currently panning the sidebar, if any. */
107
+ private _swipePointer: number | null = null;
108
+ /** Where the pan started, and the progress it started from. */
109
+ private _swipeOrigin = { x: 0, progress: 0 };
10
110
 
111
+ /**
112
+ * Every attribute {@link _readAttribute} handles must be listed here — an
113
+ * unobserved attribute never reaches `attributeChangedCallback`, so its branch
114
+ * is DEAD after construction. `pin-sidebar` was missing, which made the
115
+ * property that suppresses the collapse coupling settable only in the markup
116
+ * a parser saw: `view.setAttribute('pin-sidebar', '')` did nothing at all.
117
+ */
11
118
  static get observedAttributes() {
12
119
  return [
13
120
  'show-sidebar',
14
121
  'collapsed',
122
+ 'pin-sidebar',
15
123
  'sidebar-position',
16
124
  'min-sidebar-width',
17
125
  'max-sidebar-width',
18
126
  'sidebar-width-fraction',
127
+ 'enable-show-gesture',
128
+ 'enable-hide-gesture',
129
+ 'breakpoint',
19
130
  ];
20
131
  }
21
132
 
22
133
  get showSidebar(): boolean {
23
- return this.hasAttribute('show-sidebar');
134
+ return this._state.showSidebar;
24
135
  }
25
136
 
26
137
  set showSidebar(v: boolean) {
27
- if (v) this.setAttribute('show-sidebar', '');
28
- else this.removeAttribute('show-sidebar');
138
+ // `false` has to be spelled out — absence means the libadwaita default.
139
+ this.setAttribute('show-sidebar', v ? '' : 'false');
29
140
  }
30
141
 
31
142
  get collapsed(): boolean {
32
- return this.hasAttribute('collapsed');
143
+ return this._state.collapsed;
33
144
  }
34
145
 
35
146
  set collapsed(v: boolean) {
@@ -37,19 +148,37 @@ export class AdwOverlaySplitView extends HTMLElement {
37
148
  else this.removeAttribute('collapsed');
38
149
  }
39
150
 
151
+ // The three defaults come from the widget, not from an app's taste: 180 /
152
+ // 280 / 0.25 (adw-overlay-split-view.c:1036-1075). They used to be 280 /
153
+ // 400 / 0.30 here, which are the values the three.js and canvas2d showcases
154
+ // set EXPLICITLY in their `.blp` — so an app's preference had been copied
155
+ // into the widget. The visible cost was in the storybook, whose whole
156
+ // purpose is a 1:1 comparison: the GTK story takes the real defaults and the
157
+ // browser story took these, so the two sides showed different sidebars.
40
158
  get minSidebarWidth(): number {
41
- return parseFloat(this.getAttribute('min-sidebar-width') || '280');
159
+ return parseFloat(this.getAttribute('min-sidebar-width') || String(DEFAULT_MIN_SIDEBAR_WIDTH));
42
160
  }
43
161
 
44
162
  get maxSidebarWidth(): number {
45
- return parseFloat(this.getAttribute('max-sidebar-width') || '400');
163
+ return parseFloat(this.getAttribute('max-sidebar-width') || String(DEFAULT_MAX_SIDEBAR_WIDTH));
46
164
  }
47
165
 
48
166
  get sidebarWidthFraction(): number {
49
- return parseFloat(this.getAttribute('sidebar-width-fraction') || '0.30');
167
+ return parseFloat(this.getAttribute('sidebar-width-fraction') || String(DEFAULT_SIDEBAR_WIDTH_FRACTION));
50
168
  }
51
169
 
52
170
  connectedCallback() {
171
+ this._buildOnce();
172
+ // EVERY connect, not only the first — see `_bindToDocument`.
173
+ this._bindToDocument();
174
+ this._reflectShowSidebar();
175
+ this._syncClasses();
176
+ this._syncSidebarWidth();
177
+ this._syncBreakpoint();
178
+ }
179
+
180
+ /** The DOM surgery and the state, which happen once per element lifetime. */
181
+ private _buildOnce() {
53
182
  if (this._initialized) return;
54
183
  this._initialized = true;
55
184
 
@@ -83,16 +212,173 @@ export class AdwOverlaySplitView extends HTMLElement {
83
212
  this.append(this._sidebarEl, this._contentEl, this._backdropEl);
84
213
 
85
214
  // Apply initial state
86
- this._syncClasses();
87
- this._syncSidebarWidth();
215
+ // The attributes present at parse time ARE the construction options —
216
+ // `Adw.OverlaySplitView` is built with its properties, it is not built
217
+ // and then mutated. Applying them as sequential setters instead would
218
+ // fire the collapse transition and auto-hide a sidebar that the markup
219
+ // explicitly asked to keep shown.
220
+ this._state = new OverlaySplitViewState({
221
+ collapsed: this.hasAttribute('collapsed'),
222
+ showSidebar: readTriStateAttr(this, 'show-sidebar', true),
223
+ pinSidebar: this.hasAttribute('pin-sidebar'),
224
+ sidebarPosition: this.getAttribute('sidebar-position') === 'end' ? 'end' : ('start' as AdwPackType),
225
+ enableShowGesture: readTriStateAttr(this, 'enable-show-gesture', true),
226
+ enableHideGesture: readTriStateAttr(this, 'enable-hide-gesture', true),
227
+ animator: CSS_SPLIT_VIEW_ANIMATOR,
228
+ });
229
+ // Every progress step repaints — the reveal is a CONTINUUM now, not two
230
+ // end states, which is what the five OVERLAY_SWIPE_* tables describe.
231
+ this._state.subscribe(() => {
232
+ this._reflectShowSidebar();
233
+ this._syncClasses();
234
+ });
235
+
236
+ // `escape_shortcut_cb` (adw-overlay-split-view.c:705-716) — absent from
237
+ // BOTH ports, which made `OverlaySplitViewState.escape()` dead code.
238
+ // Bound on the element, so it only fires while focus is inside the view
239
+ // and still propagates when the state declines to consume it.
240
+ //
241
+ // Bound ONCE, here, and never removed: a listener on the element itself
242
+ // survives detaching and is collected with the element, so there is
243
+ // nothing to leak — while re-adding it per connect would stack a second
244
+ // handler on every move. Only the two bindings that reach OUTSIDE the
245
+ // element (the observer below and the breakpoint's media query) need a
246
+ // teardown, and they are re-established on every connect.
247
+ this.addEventListener('keydown', this._onKeyDown);
248
+ this._bindSwipe();
88
249
  }
89
250
 
251
+ /**
252
+ * Everything that must be re-established every time the element enters a
253
+ * document — NOT once per lifetime.
254
+ *
255
+ * `connectedCallback` returns early on the second connect (the DOM is
256
+ * already built), so anything torn down in `disconnectedCallback` used to be
257
+ * gone for good: moving the view between parents — which is what a slideshow
258
+ * or a client-side route change does — killed the ResizeObserver and left
259
+ * `_measuredWidth` frozen at whatever it last saw, or at 0 if the view had
260
+ * never been visible. That is the other half of the docs-site regression.
261
+ */
262
+ private _bindToDocument() {
263
+ // The view's own box is the size source, the same one the breakpoints
264
+ // use: the sidebar width is a FRACTION of it and has to be recomputed
265
+ // when it changes, which a one-shot read at connect could not do.
266
+ this._resizeObserver?.disconnect();
267
+ this._resizeObserver = new ResizeObserver((entries) => {
268
+ const entry = entries[entries.length - 1];
269
+ if (!entry) return;
270
+ const box = entry.borderBoxSize?.[0];
271
+ this._measuredWidth = box ? box.inlineSize : entry.contentRect.width;
272
+ this._syncSidebarWidth();
273
+ this._syncClasses();
274
+ });
275
+ this._resizeObserver.observe(this);
276
+ // Seed synchronously so the FIRST paint after a connect is already
277
+ // placed. The observer's initial callback is a frame away, and on a
278
+ // re-connect there may be no size change at all for it to report.
279
+ // Zero is the honest answer while the view is in a `display: none`
280
+ // subtree, and `_syncGeometry` knows what to do with it.
281
+ this._measuredWidth = this.offsetWidth;
282
+ }
283
+
284
+ disconnectedCallback() {
285
+ this._disposeBreakpoint?.();
286
+ this._disposeBreakpoint = undefined;
287
+ this._resizeObserver?.disconnect();
288
+ this._resizeObserver = undefined;
289
+ }
290
+
291
+ /** `escape_shortcut_cb` — only a COLLAPSED view with a revealed sidebar consumes it. */
292
+ private readonly _onKeyDown = (event: KeyboardEvent) => {
293
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
294
+ // The state decides: an uncollapsed view lets Escape through so an
295
+ // enclosing dialog still closes.
296
+ if (this._state.escape()) event.preventDefault();
297
+ };
298
+
90
299
  attributeChangedCallback(_name: string, _old: string | null, _val: string | null) {
91
300
  if (!this._initialized) return;
92
- this._syncClasses();
93
- if (_name === 'min-sidebar-width' || _name === 'max-sidebar-width' || _name === 'sidebar-width-fraction') {
301
+ if (_name === 'breakpoint') {
302
+ this._syncBreakpoint();
303
+ return;
304
+ }
305
+ this._readAttribute(_name);
306
+ // `collapsed` is in the list because `get_sidebar_width` IGNORES the
307
+ // fraction while collapsed and clamps the VIEW width instead
308
+ // (adw-overlay-split-view.c:462-463) — a collapsed sidebar is a
309
+ // different number, not the same one drawn differently. Leaving it out
310
+ // is the frame-late bug the NativeScript port paid for.
311
+ if (
312
+ _name === 'min-sidebar-width' ||
313
+ _name === 'max-sidebar-width' ||
314
+ _name === 'sidebar-width-fraction' ||
315
+ _name === 'collapsed'
316
+ ) {
94
317
  this._syncSidebarWidth();
95
318
  }
319
+ this._syncClasses();
320
+ }
321
+
322
+ /**
323
+ * Push ONE attribute into the state, then reflect back what the state made
324
+ * of it.
325
+ *
326
+ * Per-attribute rather than a re-read of all of them, because `show-sidebar`
327
+ * is a value the state ALSO owns: collapsing auto-hides an unpinned sidebar,
328
+ * and a blanket re-read would immediately clobber that decision with the
329
+ * stale attribute.
330
+ */
331
+ private _readAttribute(name: string) {
332
+ const state = this._state;
333
+ // `pin-sidebar` defaults FALSE (adw-overlay-split-view.c:990-992), so it
334
+ // is an ordinary presence attribute; `show-sidebar` below is the one that
335
+ // cannot be, and `sidebar-position` is an enum defaulting to 'start'.
336
+ if (name === 'pin-sidebar') state.setPinSidebar(this.hasAttribute('pin-sidebar'));
337
+ if (name === 'sidebar-position') {
338
+ const position = this.getAttribute('sidebar-position');
339
+ state.setSidebarPosition(position === 'end' ? 'end' : ('start' as AdwPackType));
340
+ }
341
+ if (name === 'show-sidebar') {
342
+ state.setShowSidebar(readTriStateAttr(this, 'show-sidebar', true));
343
+ }
344
+ // Both default TRUE (adw-overlay-split-view.c:1178-1210), so they read
345
+ // through the tri-state helper for the same reason `show-sidebar` does.
346
+ if (name === 'enable-show-gesture') {
347
+ state.setEnableShowGesture(readTriStateAttr(this, 'enable-show-gesture', true));
348
+ }
349
+ if (name === 'enable-hide-gesture') {
350
+ state.setEnableHideGesture(readTriStateAttr(this, 'enable-hide-gesture', true));
351
+ }
352
+ // Last: it can change `show-sidebar`, so it must not be overwritten after.
353
+ if (name === 'collapsed') state.setCollapsed(this.hasAttribute('collapsed'));
354
+ this._reflectShowSidebar();
355
+ }
356
+
357
+ /**
358
+ * Mirror the state's `showSidebar` back onto the attribute, so the DOM keeps
359
+ * telling the truth after an auto-hide.
360
+ *
361
+ * Guarded: writing the attribute re-enters `attributeChangedCallback`, and
362
+ * without the flag that is an infinite reflection loop rather than a render.
363
+ */
364
+ private _reflectShowSidebar() {
365
+ if (this._reflecting) return;
366
+ const wanted = this._state.showSidebar ? '' : 'false';
367
+ if (this.getAttribute('show-sidebar') === wanted) return;
368
+ this._reflecting = true;
369
+ try {
370
+ this.setAttribute('show-sidebar', wanted);
371
+ } finally {
372
+ this._reflecting = false;
373
+ }
374
+ }
375
+
376
+ /** (Re)bind the `breakpoint` condition to `collapsed`. */
377
+ private _syncBreakpoint() {
378
+ this._disposeBreakpoint?.();
379
+ this._disposeBreakpoint = bindBreakpointSetter(this, this.getAttribute('breakpoint'), (active) => {
380
+ this.collapsed = active;
381
+ });
96
382
  }
97
383
 
98
384
  openSidebar() {
@@ -114,30 +400,252 @@ export class AdwOverlaySplitView extends HTMLElement {
114
400
  );
115
401
  }
116
402
 
403
+ /**
404
+ * The reading direction `start` / `end` are resolved against.
405
+ *
406
+ * `get_start_or_end` (adw-overlay-split-view.c:227-234) returns
407
+ * `GTK_PACK_END` under RTL, so a `start` sidebar is drawn on the RIGHT.
408
+ * Neither renderer mirrored any of it; the browser has the direction for
409
+ * free and only had to read it.
410
+ */
411
+ private get _direction(): AdwTextDirection {
412
+ return getComputedStyle(this).direction === 'rtl' ? 'rtl' : 'ltr';
413
+ }
414
+
415
+ /** The sizing properties as the core takes them. */
416
+ private get _widthSpec() {
417
+ return {
418
+ minSidebarWidth: this.minSidebarWidth,
419
+ maxSidebarWidth: this.maxSidebarWidth,
420
+ sidebarWidthFraction: this.sidebarWidthFraction,
421
+ };
422
+ }
423
+
424
+ /** The sidebar's allocated width in px — `get_sidebar_width` (:441-466). */
425
+ private get _sidebarWidth(): number {
426
+ return resolveOverlaySidebarWidth({
427
+ ...this._widthSpec,
428
+ totalWidth: this._measuredWidth,
429
+ collapsed: this._state.collapsed,
430
+ });
431
+ }
432
+
117
433
  private _syncClasses() {
118
- this.classList.toggle('collapsed', this.collapsed);
119
- this.classList.toggle('show-sidebar', this.showSidebar);
120
- const pos = this.getAttribute('sidebar-position') || 'start';
121
- this.classList.toggle('sidebar-start', pos === 'start');
122
- this.classList.toggle('sidebar-end', pos === 'end');
123
-
124
- // In docked mode, use negative margin to collapse sidebar space
125
- // while keeping the sidebar's intrinsic width (slide animation).
126
- if (this._sidebarEl && !this.collapsed) {
127
- if (!this.showSidebar) {
128
- const w = this._sidebarEl.offsetWidth;
129
- this._sidebarEl.style.marginRight = `-${w}px`;
130
- } else {
131
- this._sidebarEl.style.marginRight = '';
434
+ const state = this._state;
435
+ this.classList.toggle('collapsed', state.collapsed);
436
+ this.classList.toggle('show-sidebar', state.showSidebar);
437
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
438
+ this.classList.toggle('sidebar-start', state.sidebarPosition === 'start');
439
+ this.classList.toggle('sidebar-end', state.sidebarPosition === 'end');
440
+ // The VISUAL side, which under RTL is the opposite of the logical one.
441
+ // The stylesheet needs it for the divider edge, which `border-inline-*`
442
+ // alone cannot get right: the divider follows where the pane is DRAWN.
443
+ this.classList.toggle('sidebar-at-visual-start', atStart);
444
+
445
+ // Derived, not re-decided here: the shield only exists while collapsed
446
+ // AND revealed, and each pane is reachable by keyboard only when it is
447
+ // the one on screen.
448
+ if (this._backdropEl) this._backdropEl.hidden = !state.shieldVisible;
449
+ this._sidebarEl?.setAttribute('aria-hidden', String(!state.sidebarFocusable));
450
+ this._contentEl?.setAttribute('aria-hidden', String(!state.contentFocusable));
451
+
452
+ this._syncGeometry();
453
+ }
454
+
455
+ /**
456
+ * Place the sidebar for the current progress — `allocate_uncollapsed`
457
+ * (:572-610) and `allocate_collapsed` (:653-703), through the core.
458
+ *
459
+ * The slide used to be `marginRight: -offsetWidth` REGARDLESS of which edge
460
+ * the sidebar is on, so an `end` sidebar slid the wrong way; and it was only
461
+ * two states, so a swipe had nothing to drive. `layoutOverlaySplitView`
462
+ * returns the pane rect for any progress including the overshoot, which is
463
+ * what makes a continuous gesture expressible at all.
464
+ */
465
+ private _syncGeometry() {
466
+ const sidebar = this._sidebarEl;
467
+ if (!sidebar) return;
468
+ // NOTHING MEASURED YET — HAND THE PLACEMENT BACK TO THE STYLESHEET.
469
+ //
470
+ // This used to `return` and leave the inline styles alone, which is the
471
+ // one thing it must not do. The stylesheet no longer carries a
472
+ // `transform: translateX(±100%)` for the hidden end state (that is the
473
+ // point of driving the offset per frame), so an absolutely-positioned
474
+ // pane with no `left` resolves to `left: 0` at full opacity: a HIDDEN
475
+ // sidebar painted over the content at the wrong edge. Shipped that way
476
+ // in v0.32.0's `feat(adwaita)` commit and visible on the docs site,
477
+ // whose slideshow builds each slide hidden and moves it in — so the
478
+ // first measurement never arrived and the fallback was all there was.
479
+ //
480
+ // Clearing rather than skipping matters just as much: a STALE inline
481
+ // `left` from an earlier measurement outranks the resting rule (an
482
+ // absolutely-positioned box with `left` + `width` ignores `right`), so
483
+ // leaving the old value behind would defeat the stylesheet exactly when
484
+ // it is needed. `_resting.scss` carries the four end states.
485
+ if (this._measuredWidth <= 0) {
486
+ for (const prop of [
487
+ 'left',
488
+ 'marginLeft',
489
+ 'marginRight',
490
+ 'width',
491
+ 'minWidth',
492
+ 'maxWidth',
493
+ 'opacity',
494
+ 'pointerEvents',
495
+ ] as const) {
496
+ sidebar.style[prop] = '';
132
497
  }
498
+ if (this._backdropEl) this._backdropEl.style.opacity = '';
499
+ return;
500
+ }
501
+ const state = this._state;
502
+ const measured = this._sidebarWidth;
503
+ const layout = layoutOverlaySplitView({
504
+ totalWidth: this._measuredWidth,
505
+ sidebarWidth: measured,
506
+ showProgress: state.showProgress,
507
+ collapsed: state.collapsed,
508
+ sidebarPosition: state.sidebarPosition,
509
+ direction: this._direction,
510
+ });
511
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
512
+
513
+ sidebar.style.width = `${layout.sidebar.width}px`;
514
+ if (state.collapsed) {
515
+ // Floating above the content: absolutely placed at the rect the core
516
+ // returned, so an overshoot widens the pane instead of detaching it.
517
+ sidebar.style.marginLeft = '';
518
+ sidebar.style.marginRight = '';
519
+ sidebar.style.insetInlineStart = '';
520
+ sidebar.style.left = `${layout.sidebar.x}px`;
521
+ } else {
522
+ // Docked: the pane keeps its place in the flex row and the hidden
523
+ // part is taken off the edge it is ON — the left one at visual
524
+ // start, the right one at visual end.
525
+ sidebar.style.left = '';
526
+ const hidden = measured - Math.trunc(measured * Math.min(state.showProgress, 1));
527
+ sidebar.style.marginLeft = atStart ? `${-hidden}px` : '';
528
+ sidebar.style.marginRight = atStart ? '' : `${-hidden}px`;
133
529
  }
530
+ sidebar.style.opacity = state.showProgress <= 0 ? '0' : '1';
531
+ // `sidebarPainted` is the snapshot gate (:757): below zero progress there
532
+ // is nothing on screen, and a pane that is not painted must not take
533
+ // pointer events either.
534
+ sidebar.style.pointerEvents = state.sidebarPainted ? '' : 'none';
535
+ if (this._backdropEl) this._backdropEl.style.opacity = String(1 - layout.shadowProgress);
134
536
  }
135
537
 
136
538
  private _syncSidebarWidth() {
137
539
  if (!this._sidebarEl) return;
138
- this._sidebarEl.style.minWidth = `${this.minSidebarWidth}px`;
139
- this._sidebarEl.style.maxWidth = `${this.maxSidebarWidth}px`;
140
- this._sidebarEl.style.width = `${this.sidebarWidthFraction * 100}%`;
540
+ // Normalise through the core rather than writing the raw attributes:
541
+ // libadwaita never lets `max` fall below `min`, and CSS resolves that
542
+ // conflict the other way round (min-width wins over max-width), so an
543
+ // inverted pair would render differently here than in GTK.
544
+ const bounds = resolveSidebarBounds(this._widthSpec, 0, { ceil: true });
545
+ this._sidebarEl.style.minWidth = `${bounds.min}px`;
546
+ this._sidebarEl.style.maxWidth = `${bounds.max}px`;
547
+ // NOT a CSS percentage. `(int)` truncates toward zero, and a percentage
548
+ // is fractional — core's own header forbids it in as many words, and
549
+ // this element carried one for its whole life.
550
+ this._syncGeometry();
551
+ }
552
+
553
+ // --- swipe (Adw.Swipeable) ---
554
+
555
+ /**
556
+ * The pan gesture the five `OVERLAY_SWIPE_*` tables describe.
557
+ *
558
+ * There was no gesture code in either renderer — grep for `pan` in both
559
+ * split views returned zero — so `showProgress` was only ever 0 or 1 and the
560
+ * tables shipped unexercised. Pointer events give the browser the whole
561
+ * gesture in three handlers; every DECISION in them is core's.
562
+ */
563
+ private _bindSwipe() {
564
+ this.addEventListener('pointerdown', (event) => {
565
+ if (this._swipePointer !== null || event.button !== 0) return;
566
+ const state = this._state;
567
+ const rect = this.getBoundingClientRect();
568
+ const area = resolveSwipeArea({
569
+ isDrag: true,
570
+ sidebarWidth: this._sidebarWidth,
571
+ showProgress: state.showProgress,
572
+ totalWidth: rect.width,
573
+ totalHeight: rect.height,
574
+ sidebarPosition: state.sidebarPosition,
575
+ direction: this._direction,
576
+ });
577
+ const x = event.clientX - rect.left;
578
+ const y = event.clientY - rect.top;
579
+ if (x < area.x || x > area.x + area.width || y < area.y || y > area.y + area.height) return;
580
+ this._swipePointer = event.pointerId;
581
+ this._swipeOrigin = { x: event.clientX, progress: state.showProgress };
582
+ });
583
+
584
+ this.addEventListener('pointermove', (event) => {
585
+ if (event.pointerId !== this._swipePointer) return;
586
+ const state = this._state;
587
+ const width = this._sidebarWidth || 1;
588
+ const atStart = isSidebarAtVisualStart(state.sidebarPosition, this._direction);
589
+ // Dragging AWAY from the sidebar's edge opens it, so the sign of the
590
+ // travel depends on which edge that is — the same `atStart` predicate
591
+ // the layout uses.
592
+ const travel = ((event.clientX - this._swipeOrigin.x) / width) * (atStart ? 1 : -1);
593
+ if (!state.swipeActive) {
594
+ if (
595
+ !resolveSwipeStart({
596
+ showProgress: state.showProgress,
597
+ collapsed: state.collapsed,
598
+ direction: travel >= 0 ? 'forward' : 'back',
599
+ enableShowGesture: state.enableShowGesture,
600
+ enableHideGesture: state.enableHideGesture,
601
+ })
602
+ ) {
603
+ this._swipePointer = null;
604
+ return;
605
+ }
606
+ state.beginSwipe();
607
+ // Capture keeps the move/up events coming once the pointer
608
+ // leaves the element — an optimisation, not a precondition. It
609
+ // THROWS for a pointer id the browser has no active pointer for,
610
+ // which Firefox does strictly and Chromium tolerates: a synthetic
611
+ // event, or one whose pointer was already released, aborted the
612
+ // whole gesture before the progress was ever written. The swipe
613
+ // still works without it, so the failure is swallowed on purpose.
614
+ try {
615
+ this.setPointerCapture(event.pointerId);
616
+ } catch {
617
+ /* no active pointer for this id — the gesture does not need capture */
618
+ }
619
+ }
620
+ state.setShowProgress(this._swipeOrigin.progress + travel);
621
+ });
622
+
623
+ const release = (event: PointerEvent, cancelled: boolean) => {
624
+ if (event.pointerId !== this._swipePointer) return;
625
+ this._swipePointer = null;
626
+ const state = this._state;
627
+ if (!state.swipeActive) return;
628
+ // Same asymmetry on the way out: `hasPointerCapture` is false when the
629
+ // capture above was refused, so the release is already conditional.
630
+ if (this.hasPointerCapture(event.pointerId)) this.releasePointerCapture(event.pointerId);
631
+ // A cancelled gesture snaps back to where it came from
632
+ // (`get_cancel_progress`, :1253-1258); a released one settles on the
633
+ // NEAREST allowed snap point.
634
+ const points = resolveSwipeSnapPoints({
635
+ showProgress: state.showProgress,
636
+ enableShowGesture: state.enableShowGesture,
637
+ enableHideGesture: state.enableHideGesture,
638
+ swipeActive: true,
639
+ });
640
+ const target = cancelled
641
+ ? swipeCancelProgress(state.showProgress)
642
+ : points.reduce((best, point) =>
643
+ Math.abs(point - state.showProgress) < Math.abs(best - state.showProgress) ? point : best,
644
+ );
645
+ state.endSwipe(target);
646
+ };
647
+ this.addEventListener('pointerup', (event) => release(event, false));
648
+ this.addEventListener('pointercancel', (event) => release(event, true));
141
649
  }
142
650
  }
143
651