@gjsify/adwaita-web 0.40.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.
@@ -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
  };
@@ -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 {
@@ -57,6 +57,9 @@ export type AdwTabViewPageSpec = AdwTabPageSpec<HTMLElement>;
57
57
  /** The live `<adw-tab-page>` properties, each mapped onto a state setter. */
58
58
  const PAGE_ATTRIBUTES = ['title', 'tooltip', 'icon', 'indicator-icon', 'loading', 'needs-attention', 'pinned'];
59
59
 
60
+ /** `SPACING`: the slack `scroll_to_tab_full` allows before it scrolls (adw-tab-box.c:24). */
61
+ const TAB_SPACING = 5;
62
+
60
63
  /**
61
64
  * A single page. Declared as a child of <adw-tab-view>; the element itself becomes the
62
65
  * page panel, so its attributes keep driving the tab after connect — a renderer that
@@ -581,6 +584,20 @@ export class AdwTabView extends HTMLElement {
581
584
  this._refreshCloseVisibility(id);
582
585
  }
583
586
 
587
+ /**
588
+ * `get_tab_position` in the BAR's scroll space: how far the chip sits from the
589
+ * strip's scroll origin, the one coordinate both `scroll_to_tab_full` and
590
+ * `update_visible` compare against the adjustment.
591
+ *
592
+ * Never `offsetLeft`. Nothing in this widget is positioned, so a chip's offsetParent
593
+ * is whichever positioned ancestor the HOST page happens to have; a tab view that
594
+ * merely sits indented then measured every chip against the wrong origin and
595
+ * reported all of them clipped.
596
+ */
597
+ private _tabPosition(tab: HTMLElement): number {
598
+ return tab.getBoundingClientRect().left - this._barEl.getBoundingClientRect().left + this._barEl.scrollLeft;
599
+ }
600
+
584
601
  /**
585
602
  * `tabCloseVisible`, with its pinned gate. `dragging` is constantly false — tab
586
603
  * drag-and-drop is compositor work and is not modelled — and `fullyVisible` is
@@ -595,8 +612,14 @@ export class AdwTabView extends HTMLElement {
595
612
  if (!close) return;
596
613
 
597
614
  const barLeft = this._barEl.scrollLeft;
598
- const fullyVisible =
599
- tab.offsetLeft >= barLeft && tab.offsetLeft + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
615
+ const pos = this._tabPosition(tab);
616
+ // `update_visible` (adw-tab-box.c:769-797). C also demands SPACING of slack on
617
+ // both sides; that term is deliberately NOT transplanted, because it is not
618
+ // slack there either: the allocation loop starts the first tab at `pos = SPACING`
619
+ // and puts SPACING between tabs (:3270-3286), so `pos - SPACING >= value` is
620
+ // exactly "flush against the leading edge". This strip has no such gaps, so the
621
+ // same term would report the first chip clipped and hide its close button.
622
+ const fullyVisible = pos >= barLeft && pos + tab.offsetWidth <= barLeft + this._barEl.clientWidth;
600
623
  close.hidden = !tabCloseVisible({
601
624
  hovering: this._hovered.has(id),
602
625
  fullyVisible,
@@ -608,6 +631,7 @@ export class AdwTabView extends HTMLElement {
608
631
 
609
632
  private _onSelectionChange(change: TabViewSelectionChange): void {
610
633
  this._applyActiveState();
634
+ this._scrollSelectedTabIntoBar();
611
635
  this._reflectSelected();
612
636
  this.dispatchEvent(
613
637
  new CustomEvent('notify::selected-page', {
@@ -641,6 +665,34 @@ export class AdwTabView extends HTMLElement {
641
665
  }
642
666
  }
643
667
 
668
+ /**
669
+ * `scroll_to_tab_full` (adw-tab-box.c:928-961), called on every selection the way
670
+ * `select_page` calls it (:1728): bring the selected chip inside the BAR's own
671
+ * scroll window, padded by half of whichever is smaller, the chip or the leftover.
672
+ *
673
+ * The bar is the only thing C ever scrolls for a tab: `scroll_to_tab_full` writes
674
+ * `self->adjustment`, the strip's own adjustment, and nothing else. The DOM has no
675
+ * such narrow API: `focus()` and `scrollIntoView()` both walk EVERY scrollable
676
+ * ancestor up to the window. So this moves `scrollLeft` by hand, and the focus call
677
+ * below passes `preventScroll`.
678
+ */
679
+ private _scrollSelectedTabIntoBar(): void {
680
+ const id = this._state.selectedId;
681
+ const tab = id === null ? undefined : this._tabs.get(id);
682
+ if (!tab) return;
683
+ const pageSize = this._barEl.clientWidth;
684
+ const width = tab.offsetWidth;
685
+ // Unmeasured (detached, or still inside connectedCallback): nothing to scroll to.
686
+ if (pageSize <= 0 || width <= 0) return;
687
+
688
+ const value = this._barEl.scrollLeft;
689
+ const pos = this._tabPosition(tab);
690
+ const padding = Math.min(width, pageSize - width) / 2;
691
+ if (pos - TAB_SPACING < value) this._barEl.scrollLeft = pos - padding;
692
+ else if (pos + width + TAB_SPACING > value + pageSize)
693
+ this._barEl.scrollLeft = pos + width + padding - pageSize;
694
+ }
695
+
644
696
  private _applyBarVisibility(): void {
645
697
  const revealed = tabsRevealed({
646
698
  autohide: this.autohide,
@@ -710,9 +762,17 @@ export class AdwTabView extends HTMLElement {
710
762
  event.preventDefault();
711
763
  // Roving tabindex: the newly-active tab is the only focusable one, so
712
764
  // focus has to travel with it or the next keypress goes nowhere.
765
+ //
766
+ // `preventScroll` because a bare `focus()` scrolls every scrollable ancestor
767
+ // up to and including the WINDOW, while C only ever moves the tab strip
768
+ // (`scroll_to_tab_full` writes `self->adjustment`, adw-tab-box.c:928-961).
769
+ // Measured in a long documentation page: one ArrowRight took the window from
770
+ // y=1800 to y=8367, i.e. switching a tab yanked the whole document to the widget.
771
+ // `_scrollSelectedTabIntoBar` (already run by the selection change) is the
772
+ // strip-local scroll that is supposed to happen instead.
713
773
  if (inBar) {
714
774
  const id = this._state.selectedId;
715
- if (id !== null) this._tabs.get(id)?.focus();
775
+ if (id !== null) this._tabs.get(id)?.focus({ preventScroll: true });
716
776
  }
717
777
  }
718
778
 
@@ -30,6 +30,7 @@ import {
30
30
  parseToolbarStyle,
31
31
  toolbarViewClasses,
32
32
  } from '@gjsify/adwaita-core';
33
+ import { AdwScrollShading } from '../scroll-shading.js';
33
34
 
34
35
  export class AdwToolbarView extends HTMLElement {
35
36
  private _topEl!: HTMLDivElement;
@@ -37,6 +38,14 @@ export class AdwToolbarView extends HTMLElement {
37
38
  private _bottomEl!: HTMLDivElement;
38
39
  private _initialized = false;
39
40
  private _resize: ResizeObserver | null = null;
41
+ /**
42
+ * The scroll indicators for every scroller under this view.
43
+ *
44
+ * Owned HERE and not by a scroller widget, because upstream scopes them here:
45
+ * `toolbarview.undershoot-top scrolledwindow` shades whatever scrolls inside,
46
+ * however deeply nested — a split view's two panes included.
47
+ */
48
+ private _shading: AdwScrollShading | null = null;
40
49
 
41
50
  static get observedAttributes() {
42
51
  return ['top-bar-style', 'bottom-bar-style', 'extend-content-to-top-edge', 'extend-content-to-bottom-edge'];
@@ -87,6 +96,10 @@ export class AdwToolbarView extends HTMLElement {
87
96
 
88
97
  // `update_undershoots` runs from size_allocate, so the classes have to
89
98
  // follow the bars' real heights rather than a one-shot read.
99
+ // Before `_syncClasses`, which hands it the two undershoot flags it gates on.
100
+ this._shading = new AdwScrollShading(this._contentEl);
101
+ this._shading.connect();
102
+
90
103
  this._resize = new ResizeObserver(() => this._syncClasses());
91
104
  this._resize.observe(this._topEl);
92
105
  this._resize.observe(this._bottomEl);
@@ -96,6 +109,8 @@ export class AdwToolbarView extends HTMLElement {
96
109
  disconnectedCallback() {
97
110
  this._resize?.disconnect();
98
111
  this._resize = null;
112
+ this._shading?.disconnect();
113
+ this._shading = null;
99
114
  }
100
115
 
101
116
  attributeChangedCallback() {
@@ -117,6 +132,13 @@ export class AdwToolbarView extends HTMLElement {
117
132
  this._topEl.classList.toggle(cls, topBar.includes(cls));
118
133
  this._bottomEl.classList.toggle(cls, bottomBar.includes(cls));
119
134
  }
135
+
136
+ // The same two flags reach the scrollers, where the shade is actually
137
+ // drawn — a raised bar brings its own shadow and must not get a second one.
138
+ this._shading?.setUndershootEdges({
139
+ top: view.includes('undershoot-top'),
140
+ bottom: view.includes('undershoot-bottom'),
141
+ });
120
142
  }
121
143
  }
122
144
 
package/src/index.ts CHANGED
@@ -34,6 +34,12 @@ export {
34
34
  } from './accent.js';
35
35
  export type { ApplyAccentOptions } from './accent.js';
36
36
 
37
+ // GtkScrolledWindow's undershoot/overshoot indicators. `adw-toolbar-view` drives
38
+ // this itself; exported so a consumer that owns its own chrome can shade a scroller
39
+ // without one.
40
+ export { AdwScrollShading } from './scroll-shading.js';
41
+ export type { AdwUndershootEdges } from './scroll-shading.js';
42
+
37
43
  export { AdwAvatar } from './elements/adw-avatar.js';
38
44
  export { AdwBanner } from './elements/adw-banner.js';
39
45
  export { AdwCard } from './elements/adw-card.js';