@gjsify/adwaita-web 0.39.0 → 0.41.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 (46) hide show
  1. package/dist/adwaita-web.css +112 -40
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/adw-button.spec.d.ts +1 -0
  4. package/lib/types/adw-entry.spec.d.ts +1 -0
  5. package/lib/types/adw-menu-button.spec.d.ts +1 -0
  6. package/lib/types/elements/adw-carousel.d.ts +48 -0
  7. package/lib/types/elements/adw-entry.d.ts +6 -0
  8. package/lib/types/elements/adw-header-bar.d.ts +5 -4
  9. package/lib/types/elements/adw-menu-button.d.ts +18 -7
  10. package/lib/types/elements/adw-tab-view.d.ts +23 -0
  11. package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
  12. package/lib/types/index.d.ts +2 -0
  13. package/lib/types/scroll-shading.d.ts +56 -0
  14. package/lib/types/styles.generated.d.ts +1 -1
  15. package/package.json +6 -6
  16. package/scss/_carousel.scss +15 -1
  17. package/scss/_headerbar.scss +35 -6
  18. package/scss/_icons.generated.scss +18 -0
  19. package/scss/_scrolling.scss +107 -0
  20. package/scss/_style_classes.scss +21 -8
  21. package/scss/_theme.scss +1 -0
  22. package/scss/_toolbar_view.scss +52 -22
  23. package/scss/_variables.scss +7 -0
  24. package/scss/_view_switcher.scss +80 -20
  25. package/scss/adwaita-skin.scss +1 -0
  26. package/src/adw-button.spec.ts +59 -0
  27. package/src/adw-carousel.spec.ts +141 -1
  28. package/src/adw-entry.spec.ts +73 -0
  29. package/src/adw-header-bar.spec.ts +127 -0
  30. package/src/adw-menu-button.spec.ts +52 -0
  31. package/src/adw-tab-view.spec.ts +151 -0
  32. package/src/bottom-sheet.spec.ts +87 -0
  33. package/src/chrome.spec.ts +233 -0
  34. package/src/elements/adw-button.ts +8 -10
  35. package/src/elements/adw-carousel.ts +121 -4
  36. package/src/elements/adw-entry.ts +53 -8
  37. package/src/elements/adw-header-bar.ts +32 -9
  38. package/src/elements/adw-menu-button.ts +20 -19
  39. package/src/elements/adw-status-page.ts +7 -2
  40. package/src/elements/adw-tab-view.ts +63 -3
  41. package/src/elements/adw-toolbar-view.ts +22 -0
  42. package/src/index.ts +6 -0
  43. package/src/scroll-shading.ts +255 -0
  44. package/src/styles.generated.ts +1 -1
  45. package/src/test.browser.mts +6 -0
  46. package/src/view-switcher.spec.ts +149 -0
@@ -201,6 +201,93 @@ export const AdwBottomSheetTest = async () => {
201
201
  sheet.remove();
202
202
  });
203
203
 
204
+ await it('a dismissed sheet re-opens, through either toggle spelling', () => {
205
+ // Reported as "the sheet never comes back": a preview that mounts open, gets
206
+ // dismissed by a scrim click, and then stays closed. Every other case here
207
+ // stops at its FIRST close, so a close path that latched (a listener torn
208
+ // down and never re-attached, a state that only travels one way) could have
209
+ // kept the suite green. The two spellings are the two a host actually
210
+ // writes: `sheet.open = !sheet.open`, and `toggleAttribute('open')` (what the
211
+ // storybook story uses), which only works while the element keeps reflecting
212
+ // the state back onto the attribute.
213
+ const sheet = document.createElement('adw-bottom-sheet') as AdwBottomSheet;
214
+ // `open="true"`, not a bare `open`: that is what Astro's MDX compiler emits
215
+ // for the documentation preview, so it is the markup that gets upgraded.
216
+ sheet.setAttribute('open', 'true');
217
+ sheet.innerHTML =
218
+ '<adw-bottom-sheet-content><button id="under">Under</button></adw-bottom-sheet-content>' +
219
+ '<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>';
220
+ document.body.appendChild(sheet);
221
+ expect(sheet.open).toBe(true);
222
+
223
+ const payloads: boolean[] = [];
224
+ sheet.addEventListener('notify::open', (event) => {
225
+ payloads.push((event as CustomEvent).detail.open);
226
+ });
227
+
228
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
229
+ expect(sheet.open).toBe(false);
230
+ expect(sheet.hasAttribute('open')).toBe(false);
231
+
232
+ sheet.open = true;
233
+ expect(sheet.open).toBe(true);
234
+ expect(sheet.hasAttribute('open')).toBe(true);
235
+ expect(sheet.classList.contains('open')).toBe(true);
236
+ // The scrim has to become interactive again, or the sheet is dismissible
237
+ // exactly once even though the state reopened.
238
+ expect(dimming(sheet).classList.contains('visible')).toBe(true);
239
+
240
+ sheet.toggleAttribute('open');
241
+ expect(sheet.open).toBe(false);
242
+ sheet.toggleAttribute('open');
243
+ expect(sheet.open).toBe(true);
244
+
245
+ expect(payloads).toStrictEqual([false, true, false, true]);
246
+ sheet.remove();
247
+ });
248
+
249
+ await it('survives the template-clone mount the docs preview uses, and reopens from it', () => {
250
+ // Every other case here builds the sheet with createElement, so the element
251
+ // is upgraded before it has children. The documentation site never does
252
+ // that: it clones an inert <template> and appends the whole subtree in ONE
253
+ // insertion, so the sheet is constructed, gets its attributes and is
254
+ // connected while the same walk is still upgrading its descendants, and its
255
+ // connectedCallback moves those descendants into the content/sheet layers
256
+ // mid-walk. That is the mount the reported "the sheet never comes back" was
257
+ // seen in, and the toggle button has to come back out of it wired and
258
+ // reachable or the preview has no way back from a scrim dismissal.
259
+ const tpl = document.createElement('template');
260
+ tpl.innerHTML =
261
+ '<adw-bottom-sheet open="true" modal="true" can-close="true">' +
262
+ '<adw-bottom-sheet-content><adw-button pill>Toggle sheet</adw-button></adw-bottom-sheet-content>' +
263
+ '<adw-bottom-sheet-sheet><button id="inside">Inside</button></adw-bottom-sheet-sheet>' +
264
+ '</adw-bottom-sheet>';
265
+ const stage = document.createElement('div');
266
+ document.body.appendChild(stage);
267
+ stage.append(tpl.content.cloneNode(true));
268
+
269
+ const sheet = stage.querySelector('adw-bottom-sheet') as AdwBottomSheet;
270
+ expect(sheet.open).toBe(true);
271
+
272
+ const toggle = sheet.querySelector('.adw-bottom-sheet-content adw-button') as HTMLElement;
273
+ expect(toggle).toBeTruthy();
274
+ expect(insideSheet(sheet).id).toBe('inside');
275
+ // What the page binds: a listener on the custom element, driven by a click
276
+ // on the inner native button.
277
+ toggle.addEventListener('click', () => sheet.toggleAttribute('open'));
278
+ const inner = toggle.querySelector('button') as HTMLElement;
279
+
280
+ dimming(sheet).dispatchEvent(new MouseEvent('click', { bubbles: true }));
281
+ expect(sheet.open).toBe(false);
282
+
283
+ inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));
284
+ expect(sheet.open).toBe(true);
285
+ expect(sheet.classList.contains('open')).toBe(true);
286
+ expect(dimming(sheet).classList.contains('visible')).toBe(true);
287
+
288
+ stage.remove();
289
+ });
290
+
204
291
  await it('a dimming click on a CLOSED sheet does nothing and signals nothing', () => {
205
292
  // The scrim is `can_target = open`, so it is not reachable at all — unlike
206
293
  // Escape, it does not signal.
@@ -90,6 +90,92 @@ function mountToolbarView(topBarHeight: number, bottomBarHeight: number): { host
90
90
  return { host, view };
91
91
  }
92
92
 
93
+ /**
94
+ * A widget mounted the way the docs site mounts a preview: a fixed-height flex
95
+ * row, the markup attached in ONE piece from an inert `<template>`.
96
+ *
97
+ * The template is not decoration. A nested composite (a split view holding
98
+ * toolbar views) upgraded child-by-child by the parser can lose its slotted
99
+ * children to an upgrade-order race, so both the docs site and these tests build
100
+ * the subtree first and attach it afterwards.
101
+ */
102
+ function mountPreview(markup: string, height = 340, width = 640): HTMLElement {
103
+ const host = document.createElement('div');
104
+ host.style.cssText = `height: ${height}px; width: ${width}px; display: flex; overflow: hidden;`;
105
+ const template = document.createElement('template');
106
+ template.innerHTML = markup;
107
+ host.append(template.content.cloneNode(true));
108
+ document.body.append(host);
109
+ return host;
110
+ }
111
+
112
+ /** Rounded to the pixel it renders at, as `clampedWidth` does for the clamp. */
113
+ function heightOf(el: Element | null): number {
114
+ return el ? Math.round(el.getBoundingClientRect().height) : -1;
115
+ }
116
+
117
+ /** The sidebar pane of the Navigation Split View preview (docs/adwaita/navigation.mdx). */
118
+ const SIDEBAR_PANE_PREVIEW = `
119
+ <adw-toolbar-view style="flex: 1;">
120
+ <adw-header-bar slot="top">
121
+ <adw-window-title slot="center" title="Mailboxes"></adw-window-title>
122
+ </adw-header-bar>
123
+ <adw-sidebar mode="sidebar" selected="0">
124
+ <adw-sidebar-section>
125
+ <adw-sidebar-item title="All Mail" subtitle="128 messages" icon-name="mail-unread-symbolic"></adw-sidebar-item>
126
+ <adw-sidebar-item title="Starred" subtitle="6 messages" icon-name="starred-symbolic"></adw-sidebar-item>
127
+ <adw-sidebar-item title="Drafts" subtitle="2 messages" icon-name="document-edit-symbolic"></adw-sidebar-item>
128
+ <adw-sidebar-item title="Archive" subtitle="512 messages" icon-name="folder-symbolic"></adw-sidebar-item>
129
+ </adw-sidebar-section>
130
+ </adw-sidebar>
131
+ </adw-toolbar-view>`;
132
+
133
+ /** The content pane of the same preview: a status page that centres itself. */
134
+ const STATUS_PANE_PREVIEW = `
135
+ <adw-toolbar-view style="flex: 1;">
136
+ <adw-header-bar slot="top">
137
+ <adw-window-title slot="center" title="All Mail"></adw-window-title>
138
+ </adw-header-bar>
139
+ <adw-status-page icon="mail-unread-symbolic" title="All Mail" description="Select a conversation from the list to read it here."></adw-status-page>
140
+ </adw-toolbar-view>`;
141
+
142
+ /**
143
+ * The root page of the Navigation View preview: a lone action button.
144
+ *
145
+ * A GTK button asked to sit in the middle of a ToolbarView says
146
+ * `halign/valign: CENTER`; the web spelling is a centring box around it, which is
147
+ * what the preview now carries. It used to carry a bare
148
+ * `class="adw-navigation-view-root-action"` matching no rule in any stylesheet.
149
+ */
150
+ const ROOT_ACTION_PREVIEW = `
151
+ <adw-toolbar-view style="flex: 1;">
152
+ <adw-header-bar slot="top">
153
+ <adw-window-title slot="center" title="Contacts"></adw-window-title>
154
+ </adw-header-bar>
155
+ <div style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
156
+ <adw-button label="Open contact" pill suggested></adw-button>
157
+ </div>
158
+ </adw-toolbar-view>`;
159
+
160
+ /**
161
+ * A tab view in a toolbar view: the horizontal scroller the shading also reaches.
162
+ *
163
+ * `expand-tabs` is off on purpose, so enough tabs really do overflow the strip
164
+ * rather than being squeezed into it.
165
+ */
166
+ const TAB_STRIP_PREVIEW = (pages: number): string => `
167
+ <adw-toolbar-view style="flex: 1;">
168
+ <adw-header-bar slot="top">
169
+ <adw-window-title slot="center" title="Editor"></adw-window-title>
170
+ </adw-header-bar>
171
+ <adw-tab-view no-close="true">
172
+ ${Array.from(
173
+ { length: pages },
174
+ (_, i) => `<adw-tab-page title="A rather long tab title ${i}"><div>page ${i}</div></adw-tab-page>`,
175
+ ).join('')}
176
+ </adw-tab-view>
177
+ </adw-toolbar-view>`;
178
+
93
179
  export const AdwChromeTest = async () => {
94
180
  await describe('adw-clamp allocation (AdwClampLayout conformance vectors)', async () => {
95
181
  // The browser resolves the child's own minimum through the normal `min-width`
@@ -389,4 +475,151 @@ export const AdwChromeTest = async () => {
389
475
  host.remove();
390
476
  });
391
477
  });
478
+
479
+ // `adw_toolbar_view_size_allocate` hands its ONE content widget the whole
480
+ // content rect; a widget that wants less says so with its own alignment.
481
+ // This port collected the content into a flex COLUMN, where an item that
482
+ // declares no `flex-grow` is content-sized instead, so the child stopped
483
+ // wherever its content did and the rest of the pane stayed empty.
484
+ await describe('adw-toolbar-view content allocation (size_allocate gives the content the whole rect)', async () => {
485
+ await it('stretches a lone content child past its own content height', async () => {
486
+ const host = mountPreview(SIDEBAR_PANE_PREVIEW);
487
+ await settle();
488
+ const area = host.querySelector('.adw-toolbar-view-content');
489
+ const sidebar = host.querySelector('adw-sidebar') as HTMLElement;
490
+
491
+ // The two discriminators, without which this passes on a broken build:
492
+ // the bar must really have taken height off the top, and the sidebar's
493
+ // own content must really be shorter than the area it has to fill,
494
+ // otherwise "child height == area height" is a coincidence. The rows are
495
+ // measured through the LIST, not through the sidebar's own scrollHeight:
496
+ // once the sidebar fills, its scrollHeight is its new height and the
497
+ // discriminator would have measured the fix instead of the content.
498
+ expect(heightOf(area) > 0 && heightOf(area) < 340).toBe(true);
499
+ expect(heightOf(sidebar.querySelector('.adw-sidebar-list')) < heightOf(area)).toBe(true);
500
+
501
+ expect(heightOf(sidebar)).toBe(heightOf(area));
502
+ host.remove();
503
+ });
504
+
505
+ await it('centres a status page in the pane rather than against its top bar', async () => {
506
+ const host = mountPreview(STATUS_PANE_PREVIEW);
507
+ await settle();
508
+ const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
509
+ const status = host.querySelector('adw-status-page') as HTMLElement;
510
+
511
+ expect(heightOf(area) > 0 && heightOf(area) < 340).toBe(true);
512
+ expect(heightOf(status)).toBe(heightOf(area));
513
+
514
+ // What the reader actually sees, and the discriminator at the same time:
515
+ // an Adw.StatusPage centres its own column, so equal air above the icon
516
+ // and below the description is only reachable once it owns the whole
517
+ // pane. Top-aligned in the pane it measured 24px of padding above and
518
+ // some 60px of window background below.
519
+ const areaBox = area.getBoundingClientRect();
520
+ const above =
521
+ (status.querySelector('.adw-status-page-icon') as HTMLElement).getBoundingClientRect().top -
522
+ areaBox.top;
523
+ const description = status.querySelector('.adw-status-page-description') as HTMLElement;
524
+ const below = areaBox.bottom - description.getBoundingClientRect().bottom;
525
+ expect(Math.abs(above - below) <= 2).toBe(true);
526
+ host.remove();
527
+ });
528
+
529
+ await it('stretches a centring box and lets IT place the button, which keeps its own size', async () => {
530
+ const host = mountPreview(ROOT_ACTION_PREVIEW);
531
+ await settle();
532
+ const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
533
+ const button = host.querySelector('adw-button') as HTMLElement;
534
+
535
+ expect(heightOf(area.firstElementChild)).toBe(heightOf(area));
536
+ // A pill button that grew to the pane's height would be the fill applied
537
+ // one level too deep. The alignment box exists precisely to absorb it.
538
+ expect(heightOf(button) > 0 && heightOf(button) < heightOf(area)).toBe(true);
539
+
540
+ const areaBox = area.getBoundingClientRect();
541
+ const buttonBox = button.getBoundingClientRect();
542
+ expect(Math.abs(buttonBox.top - areaBox.top - (areaBox.bottom - buttonBox.bottom)) <= 2).toBe(true);
543
+ expect(Math.abs(buttonBox.left - areaBox.left - (areaBox.right - buttonBox.right)) <= 2).toBe(true);
544
+ host.remove();
545
+ });
546
+ });
547
+
548
+ // The undershoot says "there IS content past this edge". A scroller whose
549
+ // content fits has none, so it must draw neither edge. That is the state the
550
+ // storybook got wrong for every scroller at rest, and the reason
551
+ // `scrollUndershootClasses` exists. Asserted here on a REAL scroller,
552
+ // because the core spec only ever sees the arithmetic.
553
+ await describe('adw-toolbar-view scroll shading (no undershoot where nothing can scroll)', async () => {
554
+ await it('leaves both edges clean while the content fits', async () => {
555
+ const host = mountPreview(SIDEBAR_PANE_PREVIEW);
556
+ await settle();
557
+ const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
558
+
559
+ // Both scrollers under the view: the content area and the sidebar's own
560
+ // list, which is the node upstream shades (`AdwSidebar` wraps a
561
+ // GtkScrolledWindow) once the pane is tall enough to hold every row.
562
+ for (const el of [area, host.querySelector('adw-sidebar') as HTMLElement]) {
563
+ // The discriminator: the shading really is wired to this scroller, so
564
+ // "no undershoot class" is a decision and not a controller that never ran.
565
+ expect(el.classList.contains('adw-scroll-shaded')).toBe(true);
566
+ expect(el.scrollHeight).toBe(el.clientHeight);
567
+ expect(el.classList.contains('undershoot-top')).toBe(false);
568
+ expect(el.classList.contains('undershoot-bottom')).toBe(false);
569
+ }
570
+ host.remove();
571
+ });
572
+
573
+ await it('draws the top edge once there IS content past it', async () => {
574
+ // A status page, not the sidebar: a status page cannot scroll on its own,
575
+ // so the content area is the scroller and the shade belongs to it. And the
576
+ // TOP edge, because `update_undershoots` gates the bottom one on a bottom
577
+ // bar this view does not have, so asserting the bottom edge here would pass
578
+ // whatever the controller decided.
579
+ const host = mountPreview(STATUS_PANE_PREVIEW, 120);
580
+ await settle();
581
+ const area = host.querySelector('.adw-toolbar-view-content') as HTMLElement;
582
+ expect(area.scrollHeight > area.clientHeight).toBe(true);
583
+ expect(area.classList.contains('undershoot-top')).toBe(false);
584
+
585
+ // A REAL scroll, then the frames the `scroll` event is delivered in.
586
+ area.scrollTop = area.scrollHeight;
587
+ await settle();
588
+ await settle();
589
+ expect(area.classList.contains('undershoot-top')).toBe(true);
590
+
591
+ // ONE owner draws the shade. `undershoot-top` is also a documented
592
+ // libadwaita style class with its own inset host shadow in
593
+ // `_style_classes.scss`, so a scroller wearing the state class used to
594
+ // get that 6px blurred shadow UNDER the 1px line + 4px fade this package
595
+ // paints on the pseudo-element: the same edge, twice, in two sizes.
596
+ expect(getComputedStyle(area).boxShadow).toBe('none');
597
+ expect(getComputedStyle(area, '::before').boxShadow === 'none').toBe(false);
598
+ host.remove();
599
+ });
600
+
601
+ await it('leaves a horizontally overflowing tab strip its own hairline', async () => {
602
+ // The horizontal pair has no rule in `_scrolling.scss` at all, because
603
+ // neither AdwTabBox nor AdwCarousel has a `scrolledwindow` node for GTK to
604
+ // shade. The style class did have one, and it is `box-shadow` too — so an
605
+ // overflowing strip silently traded its `inset 0 -1px` bottom shade for an
606
+ // edge shadow libadwaita never draws there.
607
+ const wide = mountPreview(TAB_STRIP_PREVIEW(8), 260, 320);
608
+ const narrow = mountPreview(TAB_STRIP_PREVIEW(1), 260, 320);
609
+ await settle();
610
+ await settle();
611
+ const overflowing = wide.querySelector('.adw-tab-bar') as HTMLElement;
612
+ const fitting = narrow.querySelector('.adw-tab-bar') as HTMLElement;
613
+
614
+ // The two discriminators: the strip really does overflow, and the shading
615
+ // controller really did reach it. Without them this is two identical bars.
616
+ expect(overflowing.scrollWidth > overflowing.clientWidth).toBe(true);
617
+ expect(overflowing.classList.contains('undershoot-end')).toBe(true);
618
+ expect(fitting.scrollWidth).toBe(fitting.clientWidth);
619
+
620
+ expect(getComputedStyle(overflowing).boxShadow).toBe(getComputedStyle(fitting).boxShadow);
621
+ wide.remove();
622
+ narrow.remove();
623
+ });
624
+ });
392
625
  };
@@ -9,15 +9,12 @@
9
9
  // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
10
10
  // icon node is <adw-icon>.
11
11
 
12
+ import { buttonStyleClasses } from '@gjsify/adwaita-core';
13
+
12
14
  import { createAdwIcon } from './adw-icon.js';
13
15
 
14
- const VARIANT_CLASSES: Record<string, string> = {
15
- flat: 'flat',
16
- suggested: 'suggested-action',
17
- destructive: 'destructive-action',
18
- circular: 'circular',
19
- pill: 'pill',
20
- };
16
+ /** The boolean attributes that select a style class; the mapping lives in the core. */
17
+ const STYLE_ATTRIBUTES = ['flat', 'suggested', 'destructive', 'circular', 'pill'] as const;
21
18
 
22
19
  export class AdwButton extends HTMLElement {
23
20
  private _button!: HTMLButtonElement;
@@ -50,9 +47,10 @@ export class AdwButton extends HTMLElement {
50
47
  private _render() {
51
48
  const btn = this._button;
52
49
  btn.className = 'adw-button';
53
- for (const [attr, cls] of Object.entries(VARIANT_CLASSES)) {
54
- if (this.hasAttribute(attr)) btn.classList.add(cls);
55
- }
50
+ // The attribute → class mapping is `@gjsify/adwaita-core`'s, so this element
51
+ // and the NativeScript one cannot disagree about which classes exist —
52
+ // `circular` was in this table and missing from that one.
53
+ btn.classList.add(...buttonStyleClasses(STYLE_ATTRIBUTES.filter((attr) => this.hasAttribute(attr))));
56
54
 
57
55
  const icon = this.getAttribute('icon');
58
56
  const label = (this.getAttribute('label') ?? this._label).trim();
@@ -27,7 +27,7 @@
27
27
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
28
28
  // Modifications: Implemented as Web Components for @gjsify/adwaita-web.
29
29
 
30
- import { CarouselState } from '@gjsify/adwaita-core';
30
+ import { CarouselState, carouselPageAllocation } from '@gjsify/adwaita-core';
31
31
  import type { CarouselScrollRequest, CarouselScrollSource, CarouselStateChange } from '@gjsify/adwaita-core';
32
32
 
33
33
  /** Common surface an indicator binds to. The concrete element is AdwCarousel. */
@@ -79,12 +79,21 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
79
79
  /** A declared `position` waiting for the track to have a width. */
80
80
  private _pendingPosition: number | null = null;
81
81
  private _pendingLayout: ResizeObserver | undefined;
82
+ /** Watches the carousel's own width, which caps the page size. */
83
+ private _layoutObserver: ResizeObserver | undefined;
82
84
  /**
83
85
  * `interactive` for the scroll now in flight: the flag of the request that
84
86
  * started it, or `null` for a scroll we did not start — which is a user drag,
85
87
  * and therefore interactive.
86
88
  */
87
89
  private _pendingInteractive: boolean | null = null;
90
+ /**
91
+ * The page an issued scroll is travelling TO, `null` once it has arrived:
92
+ * `self->animation_target_child`. Held as the page's ID and not its index, for the
93
+ * same reason C holds a widget, since an insert or a reorder in between moves the
94
+ * index while the target page stays the target.
95
+ */
96
+ private _scrollTargetId: string | null = null;
88
97
 
89
98
  static get observedAttributes() {
90
99
  return ['allow-scroll-wheel', 'allow-long-swipes', 'interactive', 'spacing', 'position'];
@@ -159,7 +168,13 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
159
168
  }
160
169
 
161
170
  connectedCallback() {
162
- if (this._initialized) return;
171
+ if (!this._initialized) this._build();
172
+ // Re-armed on every connect, because `disconnectedCallback` drops it: a
173
+ // carousel moved into another parent has a new width to follow.
174
+ this._observeLayout();
175
+ }
176
+
177
+ private _build(): void {
163
178
  this._initialized = true;
164
179
 
165
180
  this._trackEl = document.createElement('div');
@@ -200,6 +215,28 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
200
215
  // A pending declared position watches the track for its first layout; a
201
216
  // carousel removed before that must not keep the observer alive.
202
217
  this._cancelPendingPosition();
218
+ this._layoutObserver?.disconnect();
219
+ this._layoutObserver = undefined;
220
+ }
221
+
222
+ /**
223
+ * Re-run the page allocation whenever the carousel's own width changes: the page
224
+ * size is capped by it, so a resize can turn a peek into a full-width page and back.
225
+ */
226
+ private _observeLayout(): void {
227
+ if (this._layoutObserver) return;
228
+ this._layoutObserver = new ResizeObserver(() => {
229
+ // Only when the PITCH changed. The scroll offset is `position * distance`,
230
+ // so a resize that leaves the page size alone leaves the offset meaning
231
+ // exactly what it meant. Re-applying it unconditionally rewound every scroll
232
+ // still in flight: the observer's first delivery lands in the same frame as
233
+ // the wheel or drag that started one, and the model only learns the new
234
+ // offset from the scroll event that has not arrived yet.
235
+ if (this._allocatePages()) this._applyScrollFromModel();
236
+ });
237
+ // The BORDER box: the inset is written as track padding, and observing the
238
+ // content box would report that write back to us as a resize.
239
+ this._layoutObserver.observe(this._trackEl, { box: 'border-box' });
203
240
  }
204
241
 
205
242
  private _applyAttribute(name: string, value: string | null): void {
@@ -378,9 +415,64 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
378
415
  }
379
416
  });
380
417
  this._applySnapStop();
418
+ this._allocatePages();
381
419
  this._applyScrollFromModel();
382
420
  }
383
421
 
422
+ /**
423
+ * Size the pages as `adw_carousel_size_allocate` does, and centre the strip in the
424
+ * carousel.
425
+ *
426
+ * A page is NOT the carousel's width. Every page is allocated the same size, the
427
+ * largest natural one capped at the carousel (adw-carousel.c:748-765), and the strip
428
+ * is drawn at an offset of `-(width - child_width) / 2` (:801), so whatever is left
429
+ * over shows the previous and next page at the two edges. The `flex: 0 0 100%` this
430
+ * replaces reproduced only the case where nothing is left over: the 440px pages of
431
+ * the Carousel story sat in a 480px carousel with both neighbours entirely off
432
+ * screen, where GTK shows 20px of each.
433
+ *
434
+ * The inset is track PADDING paired with `scroll-snap-align: center`, which is what
435
+ * keeps `scrollLeft = position * distance` true either way: page i then snaps at
436
+ * `inset + i * distance + pageSize / 2 - available / 2`, and the two insets cancel.
437
+ *
438
+ * Returns whether the page size, and with it the scroll pitch, actually moved.
439
+ */
440
+ private _allocatePages(): boolean {
441
+ const slots = [...this._slots.values()];
442
+ if (slots.length === 0) return false;
443
+
444
+ // What the pages are laid out at RIGHT NOW, read before the reset below. "Did
445
+ // the pitch change" has to be answered from the DOM rather than from a
446
+ // remembered write, or the first pass over an already-correct layout counts as
447
+ // a change and moves a scroll that had no reason to move.
448
+ const before = slots[0]!.getBoundingClientRect().width;
449
+
450
+ // Measure against the FULL-width allocation: a page whose content is
451
+ // `width: 100%` reports back whatever the slot currently is, so measuring it
452
+ // inside an already narrowed slot would shrink the pages again on every pass.
453
+ this._trackEl.style.removeProperty('padding-inline');
454
+ for (const slot of slots) slot.style.removeProperty('flex-basis');
455
+
456
+ const available = this._trackEl.clientWidth;
457
+ if (!(available > 0)) return false;
458
+
459
+ // `natural` only: CSS has already resolved a page's own `min-width` and every
460
+ // spelling of "fill the carousel" into the width it renders at, so there is no
461
+ // separate minimum or hexpand left for the renderer to pass.
462
+ const measurements = slots.map((slot) => ({
463
+ natural: (slot.firstElementChild as HTMLElement | null)?.getBoundingClientRect().width ?? 0,
464
+ }));
465
+ const { pageSize, leadingInset } = carouselPageAllocation(available, measurements);
466
+ // A page that measures nothing yet (an image still loading, a font not
467
+ // applied) would collapse the whole strip; the full-width fallback stands
468
+ // until the resize that gives it a size.
469
+ if (!(pageSize > 0)) return false;
470
+
471
+ for (const slot of slots) slot.style.flexBasis = `${pageSize}px`;
472
+ if (leadingInset > 0) this._trackEl.style.paddingInline = `${leadingInset}px`;
473
+ return Math.round(pageSize) !== Math.round(before);
474
+ }
475
+
384
476
  /**
385
477
  * Put the scroll offset back where the model says it is, instantly. A page revealed
386
478
  * at or before the current one moves the position with it (`shift_position`)
@@ -391,7 +483,29 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
391
483
  private _applyScrollFromModel(): void {
392
484
  const distance = this._distance();
393
485
  if (!(distance > 0)) return;
394
- this._trackEl.scrollTo({ left: this._state.position * distance, behavior: 'instant' });
486
+ this._trackEl.scrollTo({ left: this._restorePosition() * distance, behavior: 'instant' });
487
+ }
488
+
489
+ /**
490
+ * Which position that write must aim at. Normally the model's, but a scroll still
491
+ * in flight has not reached the model yet: `scrollTo` only ASKS, and `position`
492
+ * catches up from the `scroll` event a frame later. Re-applying the stale position
493
+ * there parks the carousel back where it started and swallows the scroll, which is
494
+ * how `<adw-carousel position="2">` lost its page: the pending-position observer
495
+ * and the layout observer are delivered in ONE resize pass, so the very first
496
+ * allocation landed on a scroll it had just issued.
497
+ *
498
+ * `size_allocate` reaches the same moment and re-aims rather than rewinds: it
499
+ * pushes the recomputed snap point of `animation_target_child` into the running
500
+ * animation (adw-carousel.c:786-788).
501
+ */
502
+ private _restorePosition(): number {
503
+ if (this._scrollTargetId === null) return this._state.position;
504
+ const index = this._state.ids.indexOf(this._scrollTargetId);
505
+ // The target page was removed while the scroll ran; the model's position is
506
+ // then the only thing left that means anything.
507
+ if (index < 0) return this._state.position;
508
+ return this._state.snapPointOf(index) ?? this._state.position;
395
509
  }
396
510
 
397
511
  /**
@@ -416,6 +530,7 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
416
530
  const distance = this._distance();
417
531
  if (!(distance > 0)) return;
418
532
  this._pendingInteractive = request.interactive;
533
+ this._scrollTargetId = this._state.ids[request.index] ?? null;
419
534
  // `offset = distance * position`, NOT `slot.offsetLeft` — that is measured from
420
535
  // the nearest POSITIONED ancestor and coincides with the scroll offset only by
421
536
  // accident.
@@ -454,7 +569,9 @@ export class AdwCarousel extends HTMLElement implements CarouselHost {
454
569
  // `scroll_animation_done_cb` has an animation to tell it the scroll
455
570
  // finished; scroll-snap has only the offset, so the core stands ARRIVAL
456
571
  // at a snap point in for it.
457
- if (this._state.settleIfArrived()) this._pendingInteractive = null;
572
+ if (!this._state.settleIfArrived()) return;
573
+ this._pendingInteractive = null;
574
+ this._scrollTargetId = null;
458
575
  }
459
576
 
460
577
  private _onStateChange(change: CarouselStateChange): void {
@@ -1,18 +1,33 @@
1
1
  // <adw-entry> — Adwaita single-line text entry (e.g. a browser URL bar).
2
- // Attributes: value, placeholder, type, disabled.
3
- // Property: value (get/set, proxies the inner input).
2
+ // Attributes: value, placeholder, type, disabled, maxlength.
3
+ // Properties: value (get/set, proxies the inner input), maxLength, textLength.
4
4
  // Events: native `input` bubbles from the inner input; `activate` (CustomEvent)
5
5
  // fires on Enter — mirroring Gtk.Entry's `activate` signal.
6
+ //
7
+ // The character arithmetic is HEADLESS and lives in `@gjsify/adwaita-core`
8
+ // (ADR 0004): `entryTextLength` counts CODE POINTS and `clampEntryText` truncates
9
+ // on them. `@gjsify/adwaita-nativescript`'s AdwEntry has composed the same two
10
+ // since it shipped; this element counted nothing at all, so the same consumer
11
+ // got a max length on one renderer and none on the other — and the native
12
+ // `maxlength` attribute would not have closed the gap, because the browser
13
+ // counts UTF-16 code units: `'🔒é'` is 2 characters to GTK and to NativeScript,
14
+ // and 3 to `input.maxLength`. That is why the clamp is applied here rather than
15
+ // handed to the platform.
16
+ //
6
17
  // Reference: refs/libadwaita/src/stylesheet/widgets/_entries.scss
7
18
  // Copyright (c) GNOME contributors (libadwaita). LGPLv2.1+.
8
- // Modifications: Implemented as a Web Component for @gjsify/adwaita-web.
19
+ // Modifications: Implemented as a Web Component for @gjsify/adwaita-web; the
20
+ // length arithmetic composed from @gjsify/adwaita-core.
21
+
22
+ import { ENTRY_ROW_MAX_LENGTH_LIMIT, clampEntryText, entryTextLength } from '@gjsify/adwaita-core';
9
23
 
10
24
  export class AdwEntry extends HTMLElement {
11
25
  private _input!: HTMLInputElement;
12
26
  private _initialized = false;
27
+ private _maxLength = 0;
13
28
 
14
29
  static get observedAttributes() {
15
- return ['value', 'placeholder', 'type', 'disabled'];
30
+ return ['value', 'placeholder', 'type', 'disabled', 'maxlength'];
16
31
  }
17
32
 
18
33
  get value(): string {
@@ -20,8 +35,26 @@ export class AdwEntry extends HTMLElement {
20
35
  }
21
36
 
22
37
  set value(v: string) {
23
- if (this._input) this._input.value = v;
24
- else this.setAttribute('value', v);
38
+ const clamped = clampEntryText(v ?? '', this._maxLength);
39
+ if (this._input) this._input.value = clamped;
40
+ else this.setAttribute('value', clamped);
41
+ }
42
+
43
+ /** `Gtk.Entry:max-length` — 0 means unlimited. Counted in CODE POINTS. */
44
+ get maxLength(): number {
45
+ return this._maxLength;
46
+ }
47
+
48
+ set maxLength(value: number) {
49
+ this._maxLength = Number.isFinite(value)
50
+ ? Math.min(ENTRY_ROW_MAX_LENGTH_LIMIT, Math.max(0, Math.trunc(value)))
51
+ : 0;
52
+ if (this._input) this._input.value = clampEntryText(this._input.value, this._maxLength);
53
+ }
54
+
55
+ /** `Gtk.Entry:text-length` — code points, not UTF-16 units. */
56
+ get textLength(): number {
57
+ return entryTextLength(this.value);
25
58
  }
26
59
 
27
60
  /** The inner native input (for focus/selection). */
@@ -33,12 +66,20 @@ export class AdwEntry extends HTMLElement {
33
66
  if (this._initialized) return;
34
67
  this._initialized = true;
35
68
 
69
+ this.maxLength = Number(this.getAttribute('maxlength') ?? 0);
70
+
36
71
  const input = document.createElement('input');
37
72
  input.className = 'adw-entry';
38
73
  input.type = this.getAttribute('type') || 'text';
39
- input.value = this.getAttribute('value') ?? '';
74
+ input.value = clampEntryText(this.getAttribute('value') ?? '', this._maxLength);
40
75
  input.placeholder = this.getAttribute('placeholder') ?? '';
41
76
  input.disabled = this.hasAttribute('disabled');
77
+ // Typing past the limit is clamped here, on the way in — the same place
78
+ // NativeScript clamps it, so both renderers refuse the same character.
79
+ input.addEventListener('input', () => {
80
+ const clamped = clampEntryText(input.value, this._maxLength);
81
+ if (clamped !== input.value) input.value = clamped;
82
+ });
42
83
  input.addEventListener('keydown', (e) => {
43
84
  if (e.key === 'Enter') {
44
85
  this.dispatchEvent(new CustomEvent('activate', { bubbles: true, detail: { value: input.value } }));
@@ -50,8 +91,12 @@ export class AdwEntry extends HTMLElement {
50
91
  }
51
92
 
52
93
  attributeChangedCallback(name: string, _old: string | null, value: string | null) {
94
+ if (name === 'maxlength') {
95
+ this.maxLength = Number(value ?? 0);
96
+ return;
97
+ }
53
98
  if (!this._input) return;
54
- if (name === 'value') this._input.value = value ?? '';
99
+ if (name === 'value') this._input.value = clampEntryText(value ?? '', this._maxLength);
55
100
  else if (name === 'placeholder') this._input.placeholder = value ?? '';
56
101
  else if (name === 'type') this._input.type = value || 'text';
57
102
  else if (name === 'disabled') this._input.disabled = value !== null;