@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.
- package/dist/adwaita-web.css +112 -40
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-tab-view.d.ts +23 -0
- package/lib/types/elements/adw-toolbar-view.d.ts +8 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/scroll-shading.d.ts +56 -0
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_carousel.scss +15 -1
- package/scss/_headerbar.scss +35 -6
- package/scss/_icons.generated.scss +18 -0
- package/scss/_scrolling.scss +107 -0
- package/scss/_style_classes.scss +21 -8
- package/scss/_theme.scss +1 -0
- package/scss/_toolbar_view.scss +52 -22
- package/scss/_variables.scss +7 -0
- package/scss/_view_switcher.scss +80 -20
- package/scss/adwaita-skin.scss +1 -0
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-header-bar.spec.ts +92 -0
- package/src/adw-tab-view.spec.ts +151 -0
- package/src/bottom-sheet.spec.ts +87 -0
- package/src/chrome.spec.ts +233 -0
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-tab-view.ts +63 -3
- package/src/elements/adw-toolbar-view.ts +22 -0
- package/src/index.ts +6 -0
- package/src/scroll-shading.ts +255 -0
- package/src/styles.generated.ts +1 -1
- package/src/view-switcher.spec.ts +149 -0
package/src/chrome.spec.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
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())
|
|
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
|
|
599
|
-
|
|
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';
|