@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,136 +1,480 @@
1
- // <adw-carousel> — a horizontally swipeable pager (the web counterpart of
2
- // Adw.Carousel). Pages are declared as direct children; the carousel lays them
3
- // out in a horizontal scroll-snap strip, tracks the fractional scroll position,
4
- // and exposes a `position` (fractional page index) + `n-pages`, mirroring the
5
- // AdwCarousel properties. Navigation: drag/scroll-snap (touch + trackpad),
6
- // optional scroll-wheel paging, and `scroll-to(index)`.
1
+ // <adw-carousel> — a swipeable pager (the web counterpart of Adw.Carousel).
2
+ // Pages are declared as direct children; the carousel lays them out in a
3
+ // scroll-snap strip, tracks the fractional scroll position, and exposes the
4
+ // AdwCarousel properties and signals.
7
5
  //
8
- // Indicators are SEPARATE elements bound to a carousel (exactly as the native
9
- // Adw.CarouselIndicatorDots / Adw.CarouselIndicatorLines bind to an
10
- // Adw.Carousel via their `carousel` property): set `el.carousel = carouselEl`
11
- // or place a `for` attribute referencing the carousel's id. The indicator
12
- // listens to the carousel's `notify::position` / `notify::n-pages` events and
13
- // repaints; clicking an indicator scrolls the carousel to that page.
6
+ // The BEHAVIOUR is headless and lives in `@gjsify/adwaita-core` (ADR 0004) as
7
+ // {@link CarouselState}, shared with the NativeScript twin and pinned by the
8
+ // conformance vectors in `@gjsify/adwaita-core/conformance`: snap points, the
9
+ // range and its clamp, which page a position settles on, the wheel axis rules
10
+ // and their 150 ms lockout, the keynav step, and the insert/reorder/remove
11
+ // ordering with its position compensation. This element is the DOM half only —
12
+ // it turns children into a scroll-snap strip, converts scroll offsets to and
13
+ // from positions, and re-emits changes as DOM events.
14
+ //
15
+ // What the lift fixed here, all of it C-derived:
16
+ // - `allow-scroll-wheel` defaults to TRUE (adw-carousel.c:1103-1106, :1201).
17
+ // It was read as a bare attribute presence, so a plain <adw-carousel>
18
+ // ignored the wheel entirely while the header comment claimed the opposite,
19
+ // and every doc example had to opt in.
20
+ // - the wheel now follows `scroll_cb` (:537-574): a touchpad propagates, a
21
+ // mouse wheel pages a horizontal carousel, a horizontal delta is the
22
+ // fallback the old `|dy| <= |dx|` early-return dropped, exactly one page per
23
+ // notch behind a 150 ms lockout instead of a non-decaying accumulator (which
24
+ // cancelled +30 then −30 out), and the event is consumed ONLY when it paged.
25
+ // - `page-changed` fires on every settle, including back onto the same page,
26
+ // and can report -1 (:363-376, :1150-1151); it used to be gated on an index
27
+ // change, which made -1 unreachable.
28
+ // - a fractional position resolves to a page with C's half-DOWN tie-break
29
+ // (:198-201), where `Math.round` picked the later page at every .5.
30
+ // - `allow-long-swipes` was dead code (`step * 1` vs `step`). It now maps onto
31
+ // `scroll-snap-stop`, which is the same rule in CSS: one flick, one page.
32
+ // - `<adw-carousel position="2">` used to be dropped, because attribute
33
+ // changes are delivered before `connectedCallback`; and the attribute path
34
+ // parsed with `parseInt` while the property path rounded. Both now parse a
35
+ // float and resolve it through the same page lookup.
36
+ // - the page list is no longer frozen at `connectedCallback`: `insertPage` /
37
+ // `removePage` / `reorderPage` mirror the C methods (:1370-1532).
14
38
  //
15
39
  // adw-carousel
16
- // Attributes: allow-scroll-wheel (bool, default present), allow-long-swipes
17
- // (bool — a flick can skip multiple pages at once), position (number,
18
- // fractional page index — reflected, settable to snap to a page).
40
+ // Attributes: allow-scroll-wheel (default TRUE, set "false" to disable),
41
+ // allow-long-swipes (default false), interactive (default TRUE),
42
+ // spacing (px between pages, default 0), position (INITIAL/declarative page
43
+ // to show — write-only, the live position is the `position` property and the
44
+ // `notify::position` event).
19
45
  // Events: `notify::position` (fractional scroll position changed),
20
- // `notify::n-pages` (page count changed), `page-changed` (settled on a new
21
- // page; `detail = { index }`) — mirroring the AdwCarousel signals the native
22
- // story uses.
46
+ // `notify::n-pages` (page count changed), `page-changed`
47
+ // (`detail = { index }`, -1 when empty).
23
48
  // adw-carousel-indicator-dots / adw-carousel-indicator-lines
24
49
  // Properties: carousel (the bound AdwCarousel element), or a `for` attribute
25
50
  // with the carousel element's id.
26
51
  //
27
52
  // Reference: refs/libadwaita/src/adw-carousel.c (AdwCarousel behaviour)
28
53
  // Reference: refs/libadwaita/src/adw-carousel-indicator-dots.c
29
- // (DOTS_RADIUS 3, DOTS_RADIUS_SELECTED 4, DOTS_OPACITY 0.3,
30
- // DOTS_OPACITY_SELECTED 0.9, DOTS_SPACING 7)
31
54
  // Reference: refs/libadwaita/src/adw-carousel-indicator-lines.c
32
- // (LINE_WIDTH 3, LINE_LENGTH 35, LINE_SPACING 5,
33
- // LINE_OPACITY 0.3, LINE_OPACITY_ACTIVE 0.9)
34
55
  // Reference: refs/adwaita-web/adwaita-web/scss/_carousel_indicators.scss
35
56
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
36
57
  // Modifications: Implemented as Web Components for @gjsify/adwaita-web.
37
58
 
59
+ import { CarouselState } from '@gjsify/adwaita-core';
60
+ import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
61
+
38
62
  /** Common surface an indicator binds to. The concrete element is AdwCarousel. */
39
63
  interface CarouselHost extends HTMLElement {
40
64
  readonly nPages: number;
41
65
  readonly position: number;
66
+ /** The page the current position settles on, `-1` when empty. */
67
+ readonly currentPage: number;
42
68
  scrollToPage(index: number): void;
43
69
  addEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
44
70
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
45
71
  }
46
72
 
73
+ /** A mouse wheel notch is at least this many CSS pixels — below it, assume a touchpad. */
74
+ const WHEEL_NOTCH_PIXELS = 40;
75
+
76
+ /**
77
+ * Which `GdkInputSource` a `WheelEvent` most likely came from.
78
+ *
79
+ * The web platform exposes no device class, so this is a HEURISTIC where GDK has
80
+ * a fact (`gdk_device_get_source`, adw-carousel.c:535-536) — but the distinction
81
+ * is load-bearing: libadwaita ignores touchpad scrolling because the touchpad's
82
+ * own kinetic scroll already drives the swipe, which here is the track's native
83
+ * scroll-snap. Line- and page-mode deltas only ever come from a wheel; a
84
+ * touchpad reports small or fractional pixel deltas, and is the only thing that
85
+ * produces a horizontal component alongside a vertical one.
86
+ */
87
+ function wheelSource(event: WheelEvent): CarouselScrollSource {
88
+ if (event.deltaMode !== 0) return 'mouse';
89
+ const dy = Math.abs(event.deltaY);
90
+ const dx = Math.abs(event.deltaX);
91
+ if (dx > 0 && dy > 0) return 'touchpad';
92
+ const dominant = Math.max(dx, dy);
93
+ if (!Number.isInteger(dominant) || dominant < WHEEL_NOTCH_PIXELS) return 'touchpad';
94
+ return 'mouse';
95
+ }
96
+
47
97
  export class AdwCarousel extends HTMLElement implements CarouselHost {
98
+ private readonly _state = new CarouselState({
99
+ // The DOM owns the scroll animation: `scrollTo` only asks, and the
100
+ // scroll handler feeds the ramp back through `setPosition`.
101
+ animateScroll: true,
102
+ onScrollTo: (request) => this._performScroll(request),
103
+ });
48
104
  private _trackEl!: HTMLDivElement;
49
- private _pages: HTMLElement[] = [];
50
- private _position = 0;
105
+ /** Page id → its `.adw-carousel-page` slot. Ids are internal; the DOM node is the public handle. */
106
+ private readonly _slots = new Map<string, HTMLDivElement>();
107
+ private _nextPageId = 0;
51
108
  private _scrollRaf = 0;
52
- private _wheelAccum = 0;
53
109
  private _initialized = false;
110
+ /** A declared `position` waiting for the track to have a width. */
111
+ private _pendingPosition: number | null = null;
112
+ private _pendingLayout: ResizeObserver | undefined;
113
+ /**
114
+ * `interactive` for the scroll now in flight: the flag of the request that
115
+ * started it, or `null` for a scroll we did not start — which is a user drag,
116
+ * and therefore interactive.
117
+ */
118
+ private _pendingInteractive: boolean | null = null;
54
119
 
55
120
  static get observedAttributes() {
56
- return ['allow-scroll-wheel', 'allow-long-swipes', 'position'];
121
+ return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
122
+ }
123
+
124
+ constructor() {
125
+ super();
126
+ // Subscribing touches no attributes and no children, so it is legal in a
127
+ // custom element constructor — unlike anything DOM-shaped.
128
+ this._state.subscribe((change) => this._onStateChange(change));
129
+ this._state.onPageChanged((index) => {
130
+ this.dispatchEvent(new CustomEvent('page-changed', { bubbles: true, detail: { index } }));
131
+ });
57
132
  }
58
133
 
59
134
  /** Number of pages in the carousel. */
60
135
  get nPages(): number {
61
- return this._pages.length;
136
+ return this._state.nPages;
62
137
  }
63
138
 
64
139
  /** Fractional scroll position (0 = first page, 1 = second, …). */
65
140
  get position(): number {
66
- return this._position;
141
+ return this._state.position;
67
142
  }
68
143
 
144
+ /**
145
+ * Scroll to the page this position settles on.
146
+ *
147
+ * `AdwCarousel:position` is READ-ONLY in C (adw-carousel.c:1036-1041) — the
148
+ * setter is a port convenience, and it resolves the value with the same
149
+ * `get_page_at_position` rule the carousel uses everywhere else, so setting
150
+ * 0.5 lands on page 0 exactly as scrolling there would.
151
+ */
69
152
  set position(value: number) {
70
- const clamped = Math.min(Math.max(value, 0), Math.max(0, this.nPages - 1));
71
- this.scrollToPage(Math.round(clamped));
153
+ this._scrollToPosition(value);
72
154
  }
73
155
 
74
- /** Whether the scroll wheel navigates between pages. */
156
+ /** The page the current position settles on, `-1` when the carousel is empty. */
157
+ get currentPage(): number {
158
+ return this._state.pageAt(this._state.position);
159
+ }
160
+
161
+ /** Whether the scroll wheel navigates between pages. Defaults to TRUE, as in libadwaita. */
75
162
  get allowScrollWheel(): boolean {
76
- return this.hasAttribute('allow-scroll-wheel');
163
+ return this._state.allowScrollWheel;
77
164
  }
78
165
 
79
166
  set allowScrollWheel(value: boolean) {
80
- this.toggleAttribute('allow-scroll-wheel', value);
167
+ this.setAttribute('allow-scroll-wheel', value ? 'true' : 'false');
81
168
  }
82
169
 
83
- /** Whether a single flick can skip more than one page. */
170
+ /** Whether a single flick may cross more than one page. */
84
171
  get allowLongSwipes(): boolean {
85
- return this.hasAttribute('allow-long-swipes');
172
+ return this._state.allowLongSwipes;
86
173
  }
87
174
 
88
175
  set allowLongSwipes(value: boolean) {
89
176
  this.toggleAttribute('allow-long-swipes', value);
90
177
  }
91
178
 
179
+ /** Whether the carousel can be navigated at all. Defaults to TRUE. */
180
+ get interactive(): boolean {
181
+ return this._state.interactive;
182
+ }
183
+
184
+ set interactive(value: boolean) {
185
+ this.setAttribute('interactive', value ? 'true' : 'false');
186
+ }
187
+
188
+ /** Spacing between pages in px (`AdwCarousel:spacing`). */
189
+ get spacing(): number {
190
+ return this._state.spacing;
191
+ }
192
+
193
+ set spacing(value: number) {
194
+ this.setAttribute('spacing', String(value));
195
+ }
196
+
92
197
  connectedCallback() {
93
198
  if (this._initialized) return;
94
199
  this._initialized = true;
95
200
 
96
- // The declared children become the carousel's pages. Snapshot them
97
- // before we move them into the scroll track.
98
- this._pages = Array.from(this.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
99
-
100
201
  this._trackEl = document.createElement('div');
101
202
  this._trackEl.className = 'adw-carousel-track';
102
- for (const page of this._pages) {
103
- const slot = document.createElement('div');
104
- slot.className = 'adw-carousel-page';
105
- slot.appendChild(page);
106
- this._trackEl.appendChild(slot);
107
- }
203
+ // The scroll animation is CSS, not a hard-coded `behavior: 'smooth'`, so
204
+ // it stays overridable: a consumer honouring prefers-reduced-motion, or a
205
+ // test that needs the scroll to land now, sets `scroll-behavior: auto` on
206
+ // the track and every scroll becomes instant.
207
+ this._trackEl.style.setProperty('scroll-behavior', 'smooth');
208
+
209
+ // Read the declared properties BEFORE adopting children: `spacing` is
210
+ // part of the page pitch, so a page added first would be measured
211
+ // against the wrong distance.
212
+ this._applyAttribute('allow-scroll-wheel', this.getAttribute('allow-scroll-wheel'));
213
+ this._applyAttribute('allow-long-swipes', this.getAttribute('allow-long-swipes'));
214
+ this._applyAttribute('interactive', this.getAttribute('interactive'));
215
+ this._applyAttribute('spacing', this.getAttribute('spacing'));
216
+
217
+ const declared = Array.from(this.children).filter(
218
+ (child): child is HTMLElement => child instanceof HTMLElement,
219
+ );
108
220
  this.replaceChildren(this._trackEl);
221
+ for (const page of declared) this._insert(page, -1);
109
222
 
110
223
  this._trackEl.addEventListener('scroll', this._onScroll, { passive: true });
111
224
  this._trackEl.addEventListener('wheel', this._onWheel, { passive: false });
112
225
 
113
- // Surface the page count once the DOM is wired so indicators can paint.
114
- this.dispatchEvent(new CustomEvent('notify::n-pages', { bubbles: true, detail: { nPages: this.nPages } }));
115
- this._emitPosition();
226
+ // A declared `position` is applied last, once the pages it names exist.
227
+ // Custom-element upgrade delivers attributes BEFORE connectedCallback, so
228
+ // reading it here is the only way `<adw-carousel position="2">` works at
229
+ // all — it used to be dropped on the floor.
230
+ this._applyAttribute('position', this.getAttribute('position'));
116
231
  }
117
232
 
118
233
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
119
234
  if (!this._initialized) return;
120
- if (name === 'position' && newValue !== null) {
121
- const next = Number.parseInt(newValue, 10);
122
- if (!Number.isNaN(next) && next !== Math.round(this._position)) this.scrollToPage(next);
235
+ this._applyAttribute(name, newValue);
236
+ }
237
+
238
+ disconnectedCallback() {
239
+ // A pending declared position watches the track for its first layout;
240
+ // a carousel removed before that happens must not keep an observer alive.
241
+ this._cancelPendingPosition();
242
+ }
243
+
244
+ /** One attribute → one state property. Shared by the initial read and later changes. */
245
+ private _applyAttribute(name: string, value: string | null): void {
246
+ switch (name) {
247
+ case 'allow-scroll-wheel':
248
+ // Absent means TRUE here: the property defaults TRUE in C, so the
249
+ // usual "presence = on" spelling would invert it.
250
+ this._state.setAllowScrollWheel(value !== 'false');
251
+ return;
252
+ case 'allow-long-swipes':
253
+ this._state.setAllowLongSwipes(value !== null && value !== 'false');
254
+ this._applySnapStop();
255
+ return;
256
+ case 'interactive':
257
+ this._state.setInteractive(value !== 'false');
258
+ return;
259
+ case 'spacing': {
260
+ const spacing = value === null ? 0 : Number.parseFloat(value);
261
+ if (Number.isFinite(spacing)) this._state.setSpacing(Math.max(0, spacing));
262
+ this._trackEl.style.columnGap = `${this._state.spacing}px`;
263
+ return;
264
+ }
265
+ case 'position':
266
+ // One parse for both paths. The attribute used to go through
267
+ // parseInt while the property rounded, so "1.7" meant page 1
268
+ // declaratively and page 2 imperatively.
269
+ if (value !== null) this._positionWhenMeasurable(Number.parseFloat(value));
123
270
  }
124
- // allow-scroll-wheel / allow-long-swipes are read live in the handlers.
125
271
  }
126
272
 
127
- /** Scroll (snap) to the page at `index`, clamped to the valid range. */
273
+ /**
274
+ * Scroll to `value`, waiting for the first layout when the track has no
275
+ * width yet.
276
+ *
277
+ * `connectedCallback` runs DURING insertion, so the track is still 0px wide
278
+ * and a scroll offset has nowhere to land — a declared
279
+ * `<adw-carousel position="2">` silently stayed on page 0. Deferring to the
280
+ * first non-zero measurement is the only point at which the offset means
281
+ * anything; a later assignment simply replaces the pending one.
282
+ */
283
+ private _positionWhenMeasurable(value: number): void {
284
+ if (!Number.isFinite(value)) return;
285
+ if (this._trackEl.clientWidth > 0) {
286
+ this._cancelPendingPosition();
287
+ this._scrollInstantly(value);
288
+ return;
289
+ }
290
+ this._pendingPosition = value;
291
+ if (this._pendingLayout) return;
292
+ this._pendingLayout = new ResizeObserver(() => {
293
+ if (this._trackEl.clientWidth <= 0) return;
294
+ const pending = this._pendingPosition;
295
+ this._cancelPendingPosition();
296
+ if (pending !== null) this._scrollInstantly(pending);
297
+ });
298
+ this._pendingLayout.observe(this._trackEl);
299
+ }
300
+
301
+ /**
302
+ * Jump to `value` without the reveal animation.
303
+ *
304
+ * A DECLARED position is where the carousel STARTS, not somewhere it travels
305
+ * to — `<adw-carousel position="2">` should render already showing page 2.
306
+ * Left smooth, the track is still at offset 0 while the animation runs, and
307
+ * the `scroll` event that fires from there recomputes the position back to
308
+ * page 0. So the one assignment is made instant and the caller's
309
+ * `scroll-behavior` is put back.
310
+ */
311
+ private _scrollInstantly(value: number): void {
312
+ const previous = this._trackEl.style.getPropertyValue('scroll-behavior');
313
+ this._trackEl.style.setProperty('scroll-behavior', 'auto');
314
+ try {
315
+ this._scrollToPosition(value);
316
+ } finally {
317
+ if (previous) this._trackEl.style.setProperty('scroll-behavior', previous);
318
+ else this._trackEl.style.removeProperty('scroll-behavior');
319
+ }
320
+ }
321
+
322
+ /** Drop a pending declared position and stop watching for a layout. */
323
+ private _cancelPendingPosition(): void {
324
+ this._pendingLayout?.disconnect();
325
+ this._pendingLayout = undefined;
326
+ this._pendingPosition = null;
327
+ }
328
+
329
+ /** Resolve a fractional position to a page and scroll there; a no-op when there is none. */
330
+ private _scrollToPosition(value: number): void {
331
+ if (!Number.isFinite(value)) return;
332
+ const page = this._state.pageAt(value);
333
+ if (page >= 0) this.scrollToPage(page);
334
+ }
335
+
336
+ /**
337
+ * Insert `page` at `position` — `adw_carousel_insert` (adw-carousel.c:1370-1407).
338
+ * `-1` or a position past the end appends. Returns whether it was added.
339
+ */
340
+ insertPage(page: HTMLElement, position = -1): boolean {
341
+ if (!this._initialized) {
342
+ this.appendChild(page);
343
+ return true;
344
+ }
345
+ return this._insert(page, position);
346
+ }
347
+
348
+ /** `adw_carousel_append` (adw-carousel.c:1348-1357). */
349
+ appendPage(page: HTMLElement): boolean {
350
+ return this.insertPage(page, -1);
351
+ }
352
+
353
+ /** `adw_carousel_prepend` (adw-carousel.c:1330-1339). */
354
+ prependPage(page: HTMLElement): boolean {
355
+ return this.insertPage(page, 0);
356
+ }
357
+
358
+ /** Remove `page` — `adw_carousel_remove` (adw-carousel.c:1508-1532). */
359
+ removePage(page: HTMLElement): boolean {
360
+ const id = this._idOf(page);
361
+ if (id === null) return false;
362
+ if (!this._state.removePage(id)) return false;
363
+ // reveal-duration is 0 by default and this port has no reveal ramp, so
364
+ // the shrink completes at once — `adw_animation_skip` (:331).
365
+ this._state.skipReveal(id);
366
+ this._syncTrack();
367
+ return true;
368
+ }
369
+
370
+ /** Move `page` — `adw_carousel_reorder` (adw-carousel.c:1419-1499). */
371
+ reorderPage(page: HTMLElement, position: number): boolean {
372
+ const id = this._idOf(page);
373
+ if (id === null) return false;
374
+ if (!this._state.reorderPage(id, position)) return false;
375
+ this._syncTrack();
376
+ return true;
377
+ }
378
+
379
+ /**
380
+ * Scroll to the page at `index`. Out-of-range, fractional and NaN indices are
381
+ * refused rather than clamped, matching `adw_carousel_get_nth_page`'s
382
+ * precondition (adw-carousel.c:1616).
383
+ */
128
384
  scrollToPage(index: number): void {
385
+ this._state.scrollTo(index);
386
+ }
387
+
388
+ /** One keynav step — `navigate_to_direction` (adw-carousel.c:475-508). */
389
+ navigate(direction: 'back' | 'forward'): boolean {
390
+ return this._state.navigate(direction);
391
+ }
392
+
393
+ private _insert(page: HTMLElement, position: number): boolean {
394
+ const id = `page-${this._nextPageId++}`;
395
+ if (!this._state.insertPage(id, position)) return false;
396
+
397
+ const slot = document.createElement('div');
398
+ slot.className = 'adw-carousel-page';
399
+ slot.appendChild(page);
400
+ this._slots.set(id, slot);
401
+
402
+ // No reveal ramp in this port: with reveal-duration 0 the page is at full
403
+ // size immediately, which is what a zero-duration AdwTimedAnimation does.
404
+ this._state.skipReveal(id);
405
+ this._syncTrack();
406
+ return true;
407
+ }
408
+
409
+ /** The state's id for a page element, or `null` when it is not one of ours. */
410
+ private _idOf(page: HTMLElement): string | null {
411
+ for (const [id, slot] of this._slots) if (slot.contains(page)) return id;
412
+ return null;
413
+ }
414
+
415
+ /** Make the DOM order match the model order, and drop slots whose page is gone. */
416
+ private _syncTrack(): void {
417
+ const ids = this._state.ids;
418
+ const live = new Set(ids);
419
+ for (const [id, slot] of this._slots) {
420
+ if (live.has(id)) continue;
421
+ slot.remove();
422
+ this._slots.delete(id);
423
+ }
424
+ ids.forEach((id, index) => {
425
+ const slot = this._slots.get(id);
426
+ if (!slot) return;
427
+ if (this._trackEl.children[index] !== slot) {
428
+ this._trackEl.insertBefore(slot, this._trackEl.children[index] ?? null);
429
+ }
430
+ });
431
+ this._applySnapStop();
432
+ this._applyScrollFromModel();
433
+ }
434
+
435
+ /**
436
+ * Put the scroll offset back where the model says it is, instantly.
437
+ *
438
+ * A page revealed at or before the current one moves the position with it
439
+ * (`shift_position`, adw-carousel.c:241-258) precisely so the page on screen
440
+ * does not slide sideways — but a scroll container knows nothing about that
441
+ * and would keep its old offset, showing the newly-inserted page instead. GTK
442
+ * gets this for free because the same allocation that adds the child also
443
+ * lays it out; here it takes one write.
444
+ */
445
+ private _applyScrollFromModel(): void {
446
+ const distance = this._distance();
447
+ if (!(distance > 0)) return;
448
+ this._trackEl.scrollTo({ left: this._state.position * distance, behavior: 'instant' });
449
+ }
450
+
451
+ /**
452
+ * `allow-long-swipes` in CSS: `scroll-snap-stop: always` forces the scroll to
453
+ * stop at every page, which is exactly what the property being FALSE means —
454
+ * "each swipe can only move to the adjacent pages" (adw-carousel.c:1111-1113).
455
+ */
456
+ private _applySnapStop(): void {
457
+ const stop = this._state.allowLongSwipes ? 'normal' : 'always';
458
+ for (const slot of this._slots.values()) slot.style.setProperty('scroll-snap-stop', stop);
459
+ }
460
+
461
+ /** The px pitch between two page origins — `self->distance` (adw-carousel.c:767). */
462
+ private _distance(): number {
463
+ const first = this._trackEl?.firstElementChild as HTMLElement | null;
464
+ if (!first) return 0;
465
+ return this._state.pageDistance(first.getBoundingClientRect().width);
466
+ }
467
+
468
+ /** Run the scroll the state asked for, in the track's own scroll coordinates. */
469
+ private _performScroll(request: CarouselScrollRequest): void {
129
470
  if (!this._trackEl) return;
130
- const clamped = Math.min(Math.max(index, 0), Math.max(0, this.nPages - 1));
131
- const slot = this._trackEl.children[clamped] as HTMLElement | undefined;
132
- if (!slot) return;
133
- this._trackEl.scrollTo({ left: slot.offsetLeft, behavior: 'smooth' });
471
+ const distance = this._distance();
472
+ if (!(distance > 0)) return;
473
+ this._pendingInteractive = request.interactive;
474
+ // `offset = distance * position` (adw-carousel.c:797-801). The old code
475
+ // used `slot.offsetLeft`, which is measured from the nearest POSITIONED
476
+ // ancestor and only coincides with the scroll offset by accident.
477
+ this._trackEl.scrollTo({ left: request.index * distance });
134
478
  }
135
479
 
136
480
  private _onScroll = (): void => {
@@ -142,47 +486,49 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
142
486
  };
143
487
 
144
488
  private _onWheel = (event: WheelEvent): void => {
145
- if (!this.allowScrollWheel || this.nPages === 0) return;
146
- // A horizontal scroll already pans the snap strip natively; only the
147
- // vertical wheel needs translating into page steps (mirrors AdwCarousel,
148
- // which lets vertical scrolling page a horizontal carousel).
149
- if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
150
- event.preventDefault();
151
- this._wheelAccum += event.deltaY;
152
- const threshold = 40;
153
- if (Math.abs(this._wheelAccum) < threshold) return;
154
- const step = this._wheelAccum > 0 ? 1 : -1;
155
- this._wheelAccum = 0;
156
- const current = Math.round(this._position);
157
- const target = this.allowLongSwipes ? current + step * 1 : current + step;
158
- this.scrollToPage(target);
489
+ const step = this._state.handleWheel({
490
+ deltaX: event.deltaX,
491
+ deltaY: event.deltaY,
492
+ source: wheelSource(event),
493
+ });
494
+ // GDK_EVENT_STOP only when it actually paged (adw-carousel.c:561-562 vs
495
+ // :576). The old code preventDefault()ed before its threshold test, so it
496
+ // swallowed sub-threshold scrolls without doing anything with them.
497
+ if (step !== 0) event.preventDefault();
159
498
  };
160
499
 
161
500
  private _updatePositionFromScroll(): void {
162
- if (!this._trackEl) return;
163
- const slotWidth = this._slotWidth();
164
- if (slotWidth <= 0) return;
165
- const next = this._trackEl.scrollLeft / slotWidth;
166
- const settledBefore = Math.round(this._position);
167
- this._position = next;
168
- this._emitPosition();
169
- const settledAfter = Math.round(next);
170
- // Snap landed cleanly on a page boundary → page-changed (within 0.02 of
171
- // an integer index, matching a finished scroll-snap).
172
- if (settledAfter !== settledBefore && Math.abs(next - settledAfter) < 0.02) {
173
- this.dispatchEvent(new CustomEvent('page-changed', { bubbles: true, detail: { index: settledAfter } }));
174
- }
175
- }
176
-
177
- private _slotWidth(): number {
178
- const first = this._trackEl?.children[0] as HTMLElement | undefined;
179
- return first ? first.offsetWidth : 0;
501
+ const distance = this._distance();
502
+ if (!(distance > 0)) return;
503
+
504
+ const next = this._trackEl.scrollLeft / distance;
505
+ // A scroll event that does not MOVE the position carries no news: it is
506
+ // the echo of the offset `_applyScrollFromModel` just wrote, and reading
507
+ // it back as an arrival would announce a `page-changed` for an insert
508
+ // that deliberately kept the same page on screen. The notification still
509
+ // goes out — C's notify at :281 is unconditional.
510
+ if (!this._state.setPosition(next, this._pendingInteractive ?? true)) return;
511
+ // `scroll_animation_done_cb` has an animation to tell it the scroll
512
+ // finished; scroll-snap has only the offset, so the core stands ARRIVAL
513
+ // at a snap point in for it (adw-carousel.c:363-376).
514
+ if (this._state.settleIfArrived()) this._pendingInteractive = null;
180
515
  }
181
516
 
182
- private _emitPosition(): void {
183
- this.dispatchEvent(
184
- new CustomEvent('notify::position', { bubbles: true, detail: { position: this._position } }),
185
- );
517
+ private _onStateChange(change: CarouselStateChange): void {
518
+ if (change.reason === 'n-pages') {
519
+ this.dispatchEvent(
520
+ new CustomEvent('notify::n-pages', { bubbles: true, detail: { nPages: change.nPages } }),
521
+ );
522
+ return;
523
+ }
524
+ if (change.reason === 'position') {
525
+ this.dispatchEvent(
526
+ new CustomEvent('notify::position', {
527
+ bubbles: true,
528
+ detail: { position: change.position, page: change.page, interactive: change.interactive },
529
+ }),
530
+ );
531
+ }
186
532
  }
187
533
  }
188
534
 
@@ -261,6 +607,10 @@ abstract class AdwCarouselIndicator extends HTMLElement {
261
607
  return this._carousel?.position ?? 0;
262
608
  }
263
609
 
610
+ protected currentPage(): number {
611
+ return this._carousel?.currentPage ?? -1;
612
+ }
613
+
264
614
  protected goTo(index: number): void {
265
615
  this._carousel?.scrollToPage(index);
266
616
  }
@@ -270,29 +620,43 @@ abstract class AdwCarouselIndicator extends HTMLElement {
270
620
 
271
621
  export class AdwCarouselIndicatorDots extends AdwCarouselIndicator {
272
622
  protected _render(): void {
273
- renderIndicator(this, 'adw-carousel-dot', this.nPages(), this.position(), (i) => this.goTo(i));
623
+ renderIndicator(this, 'adw-carousel-dot', this.nPages(), this.position(), this.currentPage(), (i) =>
624
+ this.goTo(i),
625
+ );
274
626
  }
275
627
  }
276
628
 
277
629
  export class AdwCarouselIndicatorLines extends AdwCarouselIndicator {
278
630
  protected _render(): void {
279
- renderIndicator(this, 'adw-carousel-line', this.nPages(), this.position(), (i) => this.goTo(i));
631
+ renderIndicator(this, 'adw-carousel-line', this.nPages(), this.position(), this.currentPage(), (i) =>
632
+ this.goTo(i),
633
+ );
280
634
  }
281
635
  }
282
636
 
283
637
  /**
284
- * Shared rebuild for both indicator kinds: one child per page, the marker
285
- * closest to the fractional position carries `.active`, and clicking scrolls
286
- * the carousel. The dot/line look (size, growth, opacity ramp) is in SCSS; the
287
- * `--adw-carousel-progress` custom property carries the per-marker proximity so
288
- * the active marker can grow/brighten the way libadwaita lerps between the
289
- * inactive and selected states.
638
+ * Shared rebuild for both indicator kinds: one child per page, the CURRENT page
639
+ * carries `.active`, and clicking scrolls the carousel. The dot/line look (size,
640
+ * growth, opacity ramp) is in SCSS, driven by a per-marker
641
+ * `--adw-carousel-progress`.
642
+ *
643
+ * `current` comes from the carousel's own `get_page_at_position` rather than
644
+ * from the progress ramp: the old `progress > 0.5` test left NO dot marked at an
645
+ * exact half-way position — with two pages at position 0.5 both dots scored
646
+ * exactly 0.5 and every one of them reported `aria-current="false"`.
647
+ *
648
+ * The ramp itself is still the `1 − |position − i|` approximation, which is
649
+ * exact only while every page is fully revealed. libadwaita's real per-dot
650
+ * progress (`snapshot_dots`, adw-carousel-indicator-dots.c:114-134) also scales
651
+ * radius and opacity by that page's size, which needs a reveal ramp this port
652
+ * does not have.
290
653
  */
291
654
  function renderIndicator(
292
655
  host: HTMLElement,
293
656
  markerClass: string,
294
657
  nPages: number,
295
658
  position: number,
659
+ current: number,
296
660
  onClick: (index: number) => void,
297
661
  ): void {
298
662
  if (host.childElementCount !== nPages) {
@@ -308,13 +672,11 @@ function renderIndicator(
308
672
  host.replaceChildren(...markers);
309
673
  }
310
674
  Array.from(host.children).forEach((child, i) => {
311
- // Proximity in [0,1]: 1 on the focused page, ramping to 0 one page away
312
- // (matches AdwCarousel's per-dot lerp progress = 1 - |position - i|).
313
675
  const progress = Math.max(0, 1 - Math.abs(position - i));
314
676
  const marker = child as HTMLElement;
315
677
  marker.style.setProperty('--adw-carousel-progress', progress.toFixed(3));
316
- marker.classList.toggle('active', progress > 0.5);
317
- marker.setAttribute('aria-current', progress > 0.5 ? 'true' : 'false');
678
+ marker.classList.toggle('active', i === current);
679
+ marker.setAttribute('aria-current', i === current ? 'true' : 'false');
318
680
  });
319
681
  }
320
682