@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.
- package/dist/adwaita-web.css +112 -40
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/adw-button.spec.d.ts +1 -0
- package/lib/types/adw-entry.spec.d.ts +1 -0
- package/lib/types/adw-menu-button.spec.d.ts +1 -0
- package/lib/types/elements/adw-carousel.d.ts +48 -0
- package/lib/types/elements/adw-entry.d.ts +6 -0
- package/lib/types/elements/adw-header-bar.d.ts +5 -4
- package/lib/types/elements/adw-menu-button.d.ts +18 -7
- 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-button.spec.ts +59 -0
- package/src/adw-carousel.spec.ts +141 -1
- package/src/adw-entry.spec.ts +73 -0
- package/src/adw-header-bar.spec.ts +127 -0
- package/src/adw-menu-button.spec.ts +52 -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-button.ts +8 -10
- package/src/elements/adw-carousel.ts +121 -4
- package/src/elements/adw-entry.ts +53 -8
- package/src/elements/adw-header-bar.ts +32 -9
- package/src/elements/adw-menu-button.ts +20 -19
- package/src/elements/adw-status-page.ts +7 -2
- 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/test.browser.mts +6 -0
- package/src/view-switcher.spec.ts +149 -0
package/src/bottom-sheet.spec.ts
CHANGED
|
@@ -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.
|
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
|
};
|
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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)
|
|
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 {
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
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;
|