@gjsify/adwaita-web 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adwaita-web.css +72 -50
- package/dist/adwaita-web.css.map +1 -1
- package/lib/types/elements/adw-combo-row.d.ts +10 -0
- package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
- package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
- package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
- package/lib/types/elements/adw-spinner.d.ts +23 -0
- package/lib/types/elements/adw-view-stack.d.ts +12 -0
- package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
- package/lib/types/elements/adw-view-switcher.d.ts +55 -3
- package/lib/types/elements/adw-wrap-box.d.ts +58 -3
- package/lib/types/styles.generated.d.ts +1 -1
- package/package.json +6 -6
- package/scss/_combo_row.scss +19 -0
- package/scss/_navigation_split_view.scss +47 -8
- package/scss/_overlay_split_view.scss +117 -32
- package/scss/_spinner.scss +35 -21
- package/scss/_tab_view.scss +12 -13
- package/scss/_wrap_box.scss +9 -0
- package/src/adw-avatar.spec.ts +37 -0
- package/src/adw-button-content.spec.ts +24 -0
- package/src/adw-row-state.spec.ts +49 -0
- package/src/adw-wrap-box.spec.ts +148 -1
- package/src/chrome.spec.ts +111 -12
- package/src/elements/adw-action-row.ts +11 -1
- package/src/elements/adw-combo-row.ts +17 -0
- package/src/elements/adw-inline-view-switcher.ts +91 -8
- package/src/elements/adw-navigation-split-view.ts +179 -14
- package/src/elements/adw-overlay-split-view.ts +378 -20
- package/src/elements/adw-spinner.ts +165 -21
- package/src/elements/adw-tab-view.ts +20 -2
- package/src/elements/adw-view-stack.ts +21 -0
- package/src/elements/adw-view-switcher-bar.ts +13 -5
- package/src/elements/adw-view-switcher.ts +103 -11
- package/src/elements/adw-wrap-box.ts +290 -135
- package/src/split-views.spec.ts +586 -2
- package/src/styles.generated.ts +1 -1
- package/src/view-switcher.spec.ts +219 -0
package/src/adw-wrap-box.spec.ts
CHANGED
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
import { describe, expect, it } from '@gjsify/unit';
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
WRAP_BOX_CHILD_ORDER_VECTORS,
|
|
27
|
+
WRAP_BOX_LENGTH_VECTORS,
|
|
26
28
|
WRAP_BOX_LINE_VECTORS,
|
|
29
|
+
WRAP_BOX_NATURAL_LENGTH_VECTORS,
|
|
30
|
+
WRAP_BOX_NOTIFY_PROPERTIES,
|
|
31
|
+
WRAP_BOX_POLICY_VECTORS,
|
|
27
32
|
WRAP_BOX_SPACING_NOTIFY_VECTORS,
|
|
28
33
|
WRAP_BOX_SPACING_VECTORS,
|
|
29
34
|
} from '@gjsify/adwaita-core/conformance';
|
|
@@ -49,7 +54,7 @@ function unmountAll(): void {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** A vector value as it reads in a test name (`JSON.stringify` flattens NaN to null). */
|
|
52
|
-
function label(value:
|
|
57
|
+
function label(value: unknown): string {
|
|
53
58
|
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -191,4 +196,146 @@ export const AdwWrapBoxTest = async () => {
|
|
|
191
196
|
});
|
|
192
197
|
}
|
|
193
198
|
});
|
|
199
|
+
|
|
200
|
+
await describe('<adw-wrap-box> the properties neither port had (#1048)', async () => {
|
|
201
|
+
for (const { value, policy, flexShrink, rule } of WRAP_BOX_POLICY_VECTORS) {
|
|
202
|
+
await it(`wrap-policy ${label(value)} → ${policy}: ${rule}`, () => {
|
|
203
|
+
const attrs: Record<string, string> = {};
|
|
204
|
+
if (value !== null) attrs['wrap-policy'] = String(value);
|
|
205
|
+
const { box, children } = mountWrapBox(attrs);
|
|
206
|
+
expect((box as HTMLElement & { wrapPolicy: string }).wrapPolicy).toBe(policy);
|
|
207
|
+
expect(getComputedStyle(children[0]).flexShrink).toBe(String(flexShrink));
|
|
208
|
+
unmountAll();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await it('a box with no wrap-policy forbids shrinking — CSS defaults to the OTHER one', () => {
|
|
213
|
+
const { children } = mountWrapBox({});
|
|
214
|
+
expect(getComputedStyle(children[0]).flexShrink).toBe('0');
|
|
215
|
+
unmountAll();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
for (const { value, unit, dpi, px, rule } of WRAP_BOX_LENGTH_VECTORS) {
|
|
219
|
+
// The element resolves at the default dpi; the scaled rows are the
|
|
220
|
+
// core suite's, since a browser has no `gtk-xft-dpi` to report.
|
|
221
|
+
if (dpi !== 96 || value < 0) continue;
|
|
222
|
+
await it(`child-spacing ${value}${String(unit)} → ${px}px: ${rule}`, () => {
|
|
223
|
+
const attrs: Record<string, string> = { 'child-spacing': String(value) };
|
|
224
|
+
if (unit !== null) attrs['child-spacing-unit'] = String(unit);
|
|
225
|
+
const { box } = mountWrapBox(attrs);
|
|
226
|
+
expect(getComputedStyle(box).columnGap).toBe(`${px}px`);
|
|
227
|
+
unmountAll();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const { value, length, rule } of WRAP_BOX_NATURAL_LENGTH_VECTORS) {
|
|
232
|
+
await it(`natural-line-length ${label(value)} → ${length}: ${rule}`, () => {
|
|
233
|
+
const attrs: Record<string, string> = {};
|
|
234
|
+
if (value !== null) attrs['natural-line-length'] = String(value);
|
|
235
|
+
const { box } = mountWrapBox(attrs);
|
|
236
|
+
expect((box as HTMLElement & { naturalLineLength: number }).naturalLineLength).toBe(length);
|
|
237
|
+
// -1 is UNSET, not a length: it must not reach the style at all.
|
|
238
|
+
expect(box.style.maxWidth).toBe(length < 0 ? '' : `${length}px`);
|
|
239
|
+
unmountAll();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await it('a VERTICAL box caps the block axis, because that is where its lines run', () => {
|
|
244
|
+
const { box } = mountWrapBox({ orientation: 'vertical', 'natural-line-length': '400' });
|
|
245
|
+
expect(box.style.maxHeight).toBe('400px');
|
|
246
|
+
expect(box.style.maxWidth).toBe('');
|
|
247
|
+
unmountAll();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await it('pack-direction and wrap-reverse reverse the axes they own', () => {
|
|
251
|
+
const packed = mountWrapBox({ 'pack-direction': 'end-to-start' });
|
|
252
|
+
expect(getComputedStyle(packed.box).flexDirection).toBe('row-reverse');
|
|
253
|
+
unmountAll();
|
|
254
|
+
|
|
255
|
+
const reversed = mountWrapBox({ 'wrap-reverse': '' });
|
|
256
|
+
expect(getComputedStyle(reversed.box).flexWrap).toBe('wrap-reverse');
|
|
257
|
+
unmountAll();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await it('notifies for EVERY property, not just the two spacings', () => {
|
|
261
|
+
const { box } = mountWrapBox({});
|
|
262
|
+
const seen: string[] = [];
|
|
263
|
+
for (const property of WRAP_BOX_NOTIFY_PROPERTIES) {
|
|
264
|
+
box.addEventListener(`notify::${property}`, () => seen.push(property));
|
|
265
|
+
}
|
|
266
|
+
// One real change per property, each different from the default.
|
|
267
|
+
box.setAttribute('child-spacing', '6');
|
|
268
|
+
box.setAttribute('child-spacing-unit', 'sp');
|
|
269
|
+
box.setAttribute('pack-direction', 'end-to-start');
|
|
270
|
+
box.setAttribute('align', '1');
|
|
271
|
+
box.setAttribute('justify', 'fill');
|
|
272
|
+
box.toggleAttribute('justify-last-line', true);
|
|
273
|
+
box.setAttribute('line-spacing', '4');
|
|
274
|
+
box.setAttribute('line-spacing-unit', 'pt');
|
|
275
|
+
box.toggleAttribute('line-homogeneous', true);
|
|
276
|
+
box.setAttribute('natural-line-length', '400');
|
|
277
|
+
box.setAttribute('natural-line-length-unit', 'sp');
|
|
278
|
+
box.toggleAttribute('wrap-reverse', true);
|
|
279
|
+
box.setAttribute('wrap-policy', 'minimum');
|
|
280
|
+
box.setAttribute('orientation', 'vertical');
|
|
281
|
+
|
|
282
|
+
expect([...seen].sort()).toStrictEqual([...WRAP_BOX_NOTIFY_PROPERTIES].sort());
|
|
283
|
+
unmountAll();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await it('and stays silent for a value that normalises to what it already holds', () => {
|
|
287
|
+
const { box } = mountWrapBox({ 'wrap-policy': 'minimum' });
|
|
288
|
+
let notified = 0;
|
|
289
|
+
box.addEventListener('notify::wrap-policy', () => {
|
|
290
|
+
notified++;
|
|
291
|
+
});
|
|
292
|
+
box.setAttribute('wrap-policy', 'not-a-policy'); // → the default, a change
|
|
293
|
+
expect(notified).toBe(1);
|
|
294
|
+
box.setAttribute('wrap-policy', 'also-not-a-policy'); // → the default again
|
|
295
|
+
expect(notified).toBe(1);
|
|
296
|
+
unmountAll();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await describe('<adw-wrap-box> child list (insert/reorder after, remove_all)', async () => {
|
|
301
|
+
for (const { op, children, child, sibling, result, rule } of WRAP_BOX_CHILD_ORDER_VECTORS) {
|
|
302
|
+
const name = `${op} ${child} after ${sibling ?? 'NULL'} in [${children.join(',')}]`;
|
|
303
|
+
await it(`${name}: ${rule}`, () => {
|
|
304
|
+
const box = document.createElement('adw-wrap-box') as HTMLElement & {
|
|
305
|
+
insertChildAfter(c: Element, s?: Element | null): boolean;
|
|
306
|
+
reorderChildAfter(c: Element, s?: Element | null): boolean;
|
|
307
|
+
};
|
|
308
|
+
document.body.appendChild(box);
|
|
309
|
+
const byId = new Map<string, HTMLElement>();
|
|
310
|
+
const make = (id: string) => {
|
|
311
|
+
const view = document.createElement('div');
|
|
312
|
+
view.dataset.id = id;
|
|
313
|
+
byId.set(id, view);
|
|
314
|
+
return view;
|
|
315
|
+
};
|
|
316
|
+
for (const id of children) box.appendChild(make(id));
|
|
317
|
+
// The child being placed may be one of the mounted ones (a
|
|
318
|
+
// reorder, or the "already parented" refusal) or a fresh one.
|
|
319
|
+
const target = byId.get(child) ?? make(child);
|
|
320
|
+
// A sibling the box does not have still has to be a real element.
|
|
321
|
+
const after = sibling === null ? null : (byId.get(sibling) ?? make(sibling));
|
|
322
|
+
|
|
323
|
+
const ok =
|
|
324
|
+
op === 'insert-after' ? box.insertChildAfter(target, after) : box.reorderChildAfter(target, after);
|
|
325
|
+
expect(ok).toBe(result !== null);
|
|
326
|
+
const order = [...box.children].map((view) => (view as HTMLElement).dataset.id);
|
|
327
|
+
expect(order).toStrictEqual(result === null ? [...children] : [...result]);
|
|
328
|
+
unmountAll();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await it('removeAll empties the box, leaving the box itself mounted', () => {
|
|
333
|
+
const { box } = mountWrapBox({}, 4);
|
|
334
|
+
expect(box.children.length).toBe(4);
|
|
335
|
+
(box as HTMLElement & { removeAll(): void }).removeAll();
|
|
336
|
+
expect(box.children.length).toBe(0);
|
|
337
|
+
expect(box.isConnected).toBe(true);
|
|
338
|
+
unmountAll();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
194
341
|
};
|
package/src/chrome.spec.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// faking one.
|
|
15
15
|
import { describe, expect, it } from '@gjsify/unit';
|
|
16
16
|
|
|
17
|
-
import { ADW_SPINNER_MIN_SIZE, spinnerGeometry } from '@gjsify/adwaita-core';
|
|
17
|
+
import { ADW_SPINNER_MIN_SIZE, ADW_SPINNER_TRACK_OPACITY, spinnerGeometry } from '@gjsify/adwaita-core';
|
|
18
18
|
import {
|
|
19
19
|
CLAMP_ALLOCATE_VECTORS,
|
|
20
20
|
CLAMP_PROPERTY_VECTORS,
|
|
@@ -201,52 +201,86 @@ export const AdwChromeTest = async () => {
|
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
await describe('adw-spinner geometry (AdwSpinnerPaintable conformance vectors)', async () => {
|
|
204
|
+
/** The `<svg>` the element draws its ring into. */
|
|
205
|
+
const ringOf = (spinner: HTMLElement) => spinner.querySelector('svg') as SVGSVGElement;
|
|
206
|
+
|
|
204
207
|
for (const { width, height, diameter, lineWidth, rule } of SPINNER_GEOMETRY_VECTORS) {
|
|
205
|
-
//
|
|
206
|
-
//
|
|
208
|
+
// Only square, allocatable boxes describe what a `size` attribute
|
|
209
|
+
// can produce; the rectangular rows exercise the paintable directly.
|
|
207
210
|
if (width !== height || width < ADW_SPINNER_MIN_SIZE) continue;
|
|
208
211
|
|
|
209
212
|
await it(`size ${width} draws a ${diameter}px ring stroked ${lineWidth}px — ${rule}`, () => {
|
|
210
|
-
const { host } = mountSized(
|
|
213
|
+
const { host } = mountSized(400);
|
|
211
214
|
const spinner = document.createElement('adw-spinner');
|
|
212
215
|
spinner.setAttribute('size', String(width));
|
|
213
216
|
host.appendChild(spinner);
|
|
214
217
|
|
|
215
|
-
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
218
|
+
const svg = ringOf(spinner);
|
|
219
|
+
expect(svg.getAttribute('width')).toBe(String(diameter));
|
|
220
|
+
expect(svg.getAttribute('height')).toBe(String(diameter));
|
|
221
|
+
const arc = svg.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
222
|
+
expect(Number(arc.getAttribute('stroke-width'))).toBe(lineWidth);
|
|
218
223
|
host.remove();
|
|
219
224
|
});
|
|
220
225
|
}
|
|
221
226
|
|
|
222
227
|
await it('strokes 3px at 24 and 6px at 48, where it used to stroke 2 and 4', () => {
|
|
223
|
-
const { host } = mountSized(
|
|
228
|
+
const { host } = mountSized(400);
|
|
224
229
|
const spinner = document.createElement('adw-spinner');
|
|
225
230
|
host.appendChild(spinner);
|
|
226
231
|
const strokeAt = (size: number) => {
|
|
227
232
|
spinner.setAttribute('size', String(size));
|
|
228
|
-
return Number
|
|
233
|
+
return Number(ringOf(spinner).querySelector('circle')?.getAttribute('stroke-width'));
|
|
229
234
|
};
|
|
230
235
|
expect(strokeAt(24)).toBe(3);
|
|
231
236
|
expect(strokeAt(48)).toBe(6);
|
|
232
237
|
expect(strokeAt(64)).toBe(8);
|
|
233
238
|
host.remove();
|
|
234
239
|
});
|
|
240
|
+
|
|
241
|
+
await it('rounds its arc ends — GSK_LINE_CAP_ROUND, square-cut before', () => {
|
|
242
|
+
const { host } = mountSized(400);
|
|
243
|
+
const spinner = document.createElement('adw-spinner');
|
|
244
|
+
spinner.setAttribute('size', '64');
|
|
245
|
+
host.appendChild(spinner);
|
|
246
|
+
const [track, arc] = [...ringOf(spinner).querySelectorAll('circle')];
|
|
247
|
+
expect(arc.getAttribute('stroke-linecap')).toBe('round');
|
|
248
|
+
// The TRACK is the widget colour at CIRCLE_OPACITY, not a grey.
|
|
249
|
+
expect(track.getAttribute('stroke')).toBe('currentColor');
|
|
250
|
+
expect(Number(track.getAttribute('stroke-opacity'))).toBe(ADW_SPINNER_TRACK_OPACITY);
|
|
251
|
+
host.remove();
|
|
252
|
+
});
|
|
235
253
|
});
|
|
236
254
|
|
|
237
|
-
await describe('adw-spinner sizing
|
|
255
|
+
await describe('adw-spinner sizing — the BOX and the RING are different things', async () => {
|
|
238
256
|
for (const { value, size, rule } of SPINNER_SIZE_VECTORS) {
|
|
239
|
-
await it(`size ${JSON.stringify(value ?? null)} → a ${
|
|
257
|
+
await it(`size ${JSON.stringify(value ?? null)} → a ${size}px box — ${rule}`, () => {
|
|
240
258
|
const { host } = mountSized(400);
|
|
241
259
|
const spinner = document.createElement('adw-spinner');
|
|
242
260
|
if (value !== null && value !== undefined) spinner.setAttribute('size', String(value));
|
|
243
261
|
host.appendChild(spinner);
|
|
244
262
|
|
|
245
|
-
|
|
263
|
+
// THE BOX takes the whole request — this suite used to assert the
|
|
264
|
+
// ring's diameter here, contradicting the core table it drives.
|
|
265
|
+
expect(spinner.offsetWidth).toBe(size);
|
|
266
|
+
expect(spinner.offsetHeight).toBe(size);
|
|
267
|
+
// THE RING is capped and centred inside it.
|
|
268
|
+
const svg = spinner.querySelector('svg') as SVGSVGElement;
|
|
269
|
+
expect(Number(svg.getAttribute('width'))).toBe(spinnerGeometry(size, size).diameter);
|
|
246
270
|
host.remove();
|
|
247
271
|
});
|
|
248
272
|
}
|
|
249
273
|
|
|
274
|
+
await it('a 200px spinner occupies 200px and draws a 64px ring', () => {
|
|
275
|
+
const { host } = mountSized(400);
|
|
276
|
+
const spinner = document.createElement('adw-spinner');
|
|
277
|
+
spinner.setAttribute('size', '200');
|
|
278
|
+
host.appendChild(spinner);
|
|
279
|
+
expect(spinner.offsetWidth).toBe(200);
|
|
280
|
+
expect(Number((spinner.querySelector('svg') as SVGSVGElement).getAttribute('width'))).toBe(64);
|
|
281
|
+
host.remove();
|
|
282
|
+
});
|
|
283
|
+
|
|
250
284
|
await it('defaults to the measured 16, not the invented 24', () => {
|
|
251
285
|
const { host } = mountSized(400);
|
|
252
286
|
const spinner = document.createElement('adw-spinner');
|
|
@@ -256,6 +290,71 @@ export const AdwChromeTest = async () => {
|
|
|
256
290
|
});
|
|
257
291
|
});
|
|
258
292
|
|
|
293
|
+
await describe('adw-spinner animation + accessibility (#1066)', async () => {
|
|
294
|
+
await it('announces itself — role progressbar, aria-busy (zero hits before)', () => {
|
|
295
|
+
const { host } = mountSized(400);
|
|
296
|
+
const spinner = document.createElement('adw-spinner');
|
|
297
|
+
host.appendChild(spinner);
|
|
298
|
+
expect(spinner.getAttribute('role')).toBe('progressbar');
|
|
299
|
+
expect(spinner.getAttribute('aria-busy')).toBe('true');
|
|
300
|
+
host.remove();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await it('BREATHES — the drawn arc length changes across a turn', () => {
|
|
304
|
+
const { host } = mountSized(400);
|
|
305
|
+
const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
|
|
306
|
+
spinner.setAttribute('size', '64');
|
|
307
|
+
host.appendChild(spinner);
|
|
308
|
+
const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
309
|
+
|
|
310
|
+
const drawnAt = (ms: number) => {
|
|
311
|
+
spinner.drawAt(ms);
|
|
312
|
+
return Number.parseFloat((arc.getAttribute('stroke-dasharray') ?? '0').split(' ')[0]);
|
|
313
|
+
};
|
|
314
|
+
// `drawAt` takes the FIRST call as the origin, so these are offsets
|
|
315
|
+
// into one animation, sampled across a cycle.
|
|
316
|
+
const lengths = [0, 200, 500, 900, 1300, 1900].map(drawnAt);
|
|
317
|
+
const spread = Math.max(...lengths) - Math.min(...lengths);
|
|
318
|
+
// A fixed 90-degree border chase — what this element used to draw —
|
|
319
|
+
// has a spread of exactly zero.
|
|
320
|
+
expect(spread).toBeGreaterThan(0);
|
|
321
|
+
host.remove();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await it('KEEPS SPINNING under reduced motion — a frozen busy indicator reads as a hang', () => {
|
|
325
|
+
// libadwaita opts the spinner out of the animation setting on purpose
|
|
326
|
+
// (adw-spinner-paintable.c:537). The old stylesheet had
|
|
327
|
+
// `@media (prefers-reduced-motion: reduce) { animation: none }`, so
|
|
328
|
+
// this asserts the ABSENCE of any CSS animation to switch off: the
|
|
329
|
+
// arc is driven per frame from JS and cannot be disabled by a query.
|
|
330
|
+
const { host } = mountSized(400);
|
|
331
|
+
const spinner = document.createElement('adw-spinner');
|
|
332
|
+
host.appendChild(spinner);
|
|
333
|
+
expect(getComputedStyle(spinner).animationName).toBe('none');
|
|
334
|
+
host.remove();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await it('advances while mapped and stops when it is not', () => {
|
|
338
|
+
const { host } = mountSized(400);
|
|
339
|
+
const spinner = document.createElement('adw-spinner') as HTMLElement & { drawAt(now: number): void };
|
|
340
|
+
host.appendChild(spinner);
|
|
341
|
+
const arc = spinner.querySelectorAll('circle')[1] as SVGCircleElement;
|
|
342
|
+
spinner.drawAt(0);
|
|
343
|
+
const first = arc.getAttribute('stroke-dashoffset');
|
|
344
|
+
spinner.drawAt(400);
|
|
345
|
+
expect(arc.getAttribute('stroke-dashoffset')).not.toBe(first);
|
|
346
|
+
|
|
347
|
+
// Unmapping removes it from the shared ticker — `widget_map_cb`
|
|
348
|
+
// (adw-spinner-paintable.c:181-185) is what plays the animation, so
|
|
349
|
+
// an off-screen spinner burns nothing.
|
|
350
|
+
spinner.remove();
|
|
351
|
+
const parked = arc.getAttribute('stroke-dashoffset');
|
|
352
|
+
spinner.drawAt(800); // only reachable directly; the ticker has dropped it
|
|
353
|
+
expect(arc.getAttribute('stroke-dashoffset')).not.toBe(parked);
|
|
354
|
+
host.remove();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
259
358
|
await describe('adw-toolbar-view style classes (update_undershoots + the style setters)', async () => {
|
|
260
359
|
for (const { input, view, topBar, bottomBar, rule } of TOOLBAR_VIEW_CLASS_VECTORS) {
|
|
261
360
|
await it(`${input.topBarStyle}/${input.bottomBarStyle} → [${view.join(' ')}] — ${rule}`, () => {
|
|
@@ -84,7 +84,17 @@ export class AdwActionRow extends HTMLElement {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
connectedCallback() {
|
|
87
|
-
if (this._initialized)
|
|
87
|
+
if (this._initialized) {
|
|
88
|
+
// Re-entering a document. `disconnectedCallback` drops the
|
|
89
|
+
// sensitivity observer, and returning here without rebuilding it
|
|
90
|
+
// left a MOVED row permanently blind to its activatable widget
|
|
91
|
+
// becoming insensitive — the same shape that broke the overlay
|
|
92
|
+
// split view's ResizeObserver and both view switchers' page
|
|
93
|
+
// observers. Anything torn down on the way out has to be rebuilt
|
|
94
|
+
// on the way back in.
|
|
95
|
+
this._observeSensitivity(this._state.activatableWidget);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
88
98
|
this._initialized = true;
|
|
89
99
|
|
|
90
100
|
const prefixChildren = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
|
|
@@ -85,6 +85,7 @@ export class AdwComboRow extends HTMLElement {
|
|
|
85
85
|
this._state.subscribe((change) => {
|
|
86
86
|
this._valueEl.textContent = change.label;
|
|
87
87
|
if (this._select.selectedIndex !== change.selected) this._select.selectedIndex = change.selected;
|
|
88
|
+
this._syncChooser();
|
|
88
89
|
if (!change.interactive) return;
|
|
89
90
|
this.setAttribute('selected', String(change.selected));
|
|
90
91
|
this.dispatchEvent(
|
|
@@ -101,6 +102,7 @@ export class AdwComboRow extends HTMLElement {
|
|
|
101
102
|
this._state.select(this._select.selectedIndex);
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
this._syncChooser();
|
|
104
106
|
this._renderText();
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -115,6 +117,21 @@ export class AdwComboRow extends HTMLElement {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
/**
|
|
121
|
+
* `model_changed` (adw-combo-row.c:187-195) — one option or none is not a
|
|
122
|
+
* choice, so the arrow goes and the row stops being activatable.
|
|
123
|
+
*
|
|
124
|
+
* Both halves have to be expressed: `data-presents-chooser` gates the
|
|
125
|
+
* `.adw-row-value::after` mask in the stylesheet, and DISABLING the overlaid
|
|
126
|
+
* `<select>` is what makes the row inert — hiding the arrow alone would leave
|
|
127
|
+
* a row that still opens a one-entry popup on click.
|
|
128
|
+
*/
|
|
129
|
+
private _syncChooser() {
|
|
130
|
+
const presents = this._state.presentsChooser;
|
|
131
|
+
this.dataset.presentsChooser = presents ? 'true' : 'false';
|
|
132
|
+
this._select.disabled = !presents;
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
private _renderText() {
|
|
119
136
|
// The `string_is_not_empty` label rule, from core — this block was one of
|
|
120
137
|
// six hand-rolled copies, all of which omitted the TITLE half.
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
isInlineViewSwitcherDisplayMode,
|
|
54
54
|
toggleIndexForPage,
|
|
55
55
|
} from '@gjsify/adwaita-core';
|
|
56
|
+
import type { ViewSwitcherScheduler } from '@gjsify/adwaita-core';
|
|
56
57
|
import type {
|
|
57
58
|
AdwInlineViewSwitcherDisplayMode,
|
|
58
59
|
AdwViewSwitcherPage,
|
|
@@ -75,21 +76,43 @@ export class AdwViewStackPage extends HTMLElement {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export class AdwInlineViewSwitcher extends HTMLElement {
|
|
78
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The dwell timer, injectable.
|
|
81
|
+
*
|
|
82
|
+
* It used to be `new ViewSwitcherState({ scheduler: domViewSwitcherScheduler })`
|
|
83
|
+
* inline, which left NOWHERE to hand a clock in — so
|
|
84
|
+
* `VIEW_SWITCHER_DRAG_VECTORS` was driven by the core suite alone, and the
|
|
85
|
+
* conformance header claimed all three suites drove it. `scheduler` is a
|
|
86
|
+
* property so a test can replace it before the element connects; the DOM one
|
|
87
|
+
* is the default, exactly as before.
|
|
88
|
+
*/
|
|
89
|
+
scheduler: ViewSwitcherScheduler = domViewSwitcherScheduler;
|
|
90
|
+
private _stateInstance: ViewSwitcherState | null = null;
|
|
91
|
+
|
|
92
|
+
/** Built on first access, so a `scheduler` set before connect is the one used. */
|
|
93
|
+
private get _state(): ViewSwitcherState {
|
|
94
|
+
if (!this._stateInstance) {
|
|
95
|
+
this._stateInstance = new ViewSwitcherState({ scheduler: this.scheduler });
|
|
96
|
+
this._stateInstance.subscribe((change) => this._onStateChange(change));
|
|
97
|
+
}
|
|
98
|
+
return this._stateInstance;
|
|
99
|
+
}
|
|
79
100
|
private _groupEl!: HTMLDivElement;
|
|
80
101
|
private _pagesEl!: HTMLDivElement;
|
|
81
102
|
private _panels: HTMLDivElement[] = [];
|
|
82
103
|
private _toggles: HTMLButtonElement[] = [];
|
|
83
104
|
private _displayMode: AdwInlineViewSwitcherDisplayMode = 'labels';
|
|
84
105
|
private _initialized = false;
|
|
106
|
+
/** The page ELEMENTS, kept because this element detaches them from the tree. */
|
|
107
|
+
private _declared: HTMLElement[] = [];
|
|
108
|
+
private _pageObserver: MutationObserver | undefined;
|
|
85
109
|
|
|
86
110
|
static get observedAttributes() {
|
|
87
|
-
return ['active', 'display-mode', 'flat', 'round'];
|
|
111
|
+
return ['active', 'display-mode', 'flat', 'round', 'osd'];
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
constructor() {
|
|
91
115
|
super();
|
|
92
|
-
this._state.subscribe((change) => this._onStateChange(change));
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
/** Zero-based index of the visible PAGE, `-1` when nothing is selected. */
|
|
@@ -147,7 +170,13 @@ export class AdwInlineViewSwitcher extends HTMLElement {
|
|
|
147
170
|
}
|
|
148
171
|
|
|
149
172
|
connectedCallback() {
|
|
150
|
-
if (this._initialized)
|
|
173
|
+
if (this._initialized) {
|
|
174
|
+
// Re-entering a document — same reason as `adw-view-switcher.ts`:
|
|
175
|
+
// the page observer is dropped on disconnect, so it has to be
|
|
176
|
+
// rebuilt here or a moved switcher goes deaf permanently.
|
|
177
|
+
this._watchPages();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
151
180
|
|
|
152
181
|
// `:scope >` rather than a subtree scan — `<adw-view-stack>` uses the
|
|
153
182
|
// same tag for its pages, so an unscoped query adopted a nested stack's.
|
|
@@ -176,6 +205,8 @@ export class AdwInlineViewSwitcher extends HTMLElement {
|
|
|
176
205
|
this._displayMode = this._readDisplayModeAttr();
|
|
177
206
|
this._initialized = true;
|
|
178
207
|
|
|
208
|
+
this._declared = declared;
|
|
209
|
+
this._watchPages();
|
|
179
210
|
this._state.setPages(declared.map((pageEl) => readSwitcherPage(pageEl)));
|
|
180
211
|
if (declaredActive !== null) {
|
|
181
212
|
const index = Number.parseInt(declaredActive, 10);
|
|
@@ -186,6 +217,37 @@ export class AdwInlineViewSwitcher extends HTMLElement {
|
|
|
186
217
|
|
|
187
218
|
disconnectedCallback() {
|
|
188
219
|
this._state.cancelDrag();
|
|
220
|
+
this._pageObserver?.disconnect();
|
|
221
|
+
this._pageObserver = undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Re-read every declared page and push it into the state.
|
|
226
|
+
*
|
|
227
|
+
* Driven by {@link _watchPages}, which is what makes a runtime
|
|
228
|
+
* `badge-number` or `icon-name` change reach the toggle — C rebinds on every
|
|
229
|
+
* page `notify` (adw-view-switcher.c:184-193) and this element did not.
|
|
230
|
+
*/
|
|
231
|
+
refreshPages(): void {
|
|
232
|
+
if (!this._initialized) return;
|
|
233
|
+
this._state.setPages(this._declared.map((pageEl) => readSwitcherPage(pageEl)));
|
|
234
|
+
this._rebuildToggles();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Watch the declared pages for attribute changes — the DOM's `notify::` on
|
|
239
|
+
* `AdwViewStackPage`, which C binds per page (adw-view-switcher.c:184-193).
|
|
240
|
+
*
|
|
241
|
+
* A `MutationObserver` rather than an `attributeChangedCallback`: this
|
|
242
|
+
* element consumes the page elements at connect and `replaceChildren`s them
|
|
243
|
+
* out of the tree, so a detached page cannot find its owner. It delivers on
|
|
244
|
+
* a MICROTASK where GTK's notify is synchronous — invisible to a user, and
|
|
245
|
+
* nothing here reads the toggle back inside the setter.
|
|
246
|
+
*/
|
|
247
|
+
private _watchPages(): void {
|
|
248
|
+
this._pageObserver?.disconnect();
|
|
249
|
+
this._pageObserver = new MutationObserver(() => this.refreshPages());
|
|
250
|
+
for (const pageEl of this._declared) this._pageObserver.observe(pageEl, { attributes: true });
|
|
189
251
|
}
|
|
190
252
|
|
|
191
253
|
attributeChangedCallback(name: string) {
|
|
@@ -265,8 +327,21 @@ export class AdwInlineViewSwitcher extends HTMLElement {
|
|
|
265
327
|
toggle.addEventListener('click', () => {
|
|
266
328
|
this._state.setSelected(model.pageIndex);
|
|
267
329
|
});
|
|
268
|
-
toggle
|
|
269
|
-
|
|
330
|
+
// The `relatedTarget` test is load-bearing: a toggle holds an icon,
|
|
331
|
+
// a label and an indicator, and the DOM fires `dragleave` +
|
|
332
|
+
// `dragenter` when the pointer crosses BETWEEN them — which cleared
|
|
333
|
+
// the dwell timer and re-armed a fresh 500ms, so a drag that merely
|
|
334
|
+
// slid across the toggle never switched the page. C has one
|
|
335
|
+
// `GtkDropControllerMotion` per BUTTON widget
|
|
336
|
+
// (adw-view-switcher-button.c:79-96), which has no internal edges.
|
|
337
|
+
toggle.addEventListener('dragenter', (event) => {
|
|
338
|
+
if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
|
|
339
|
+
this._state.dragEnter(model.pageIndex);
|
|
340
|
+
});
|
|
341
|
+
toggle.addEventListener('dragleave', (event) => {
|
|
342
|
+
if (toggle.contains((event as DragEvent).relatedTarget as Node | null)) return;
|
|
343
|
+
this._state.dragLeave(model.pageIndex);
|
|
344
|
+
});
|
|
270
345
|
|
|
271
346
|
this._groupEl.appendChild(toggle);
|
|
272
347
|
return toggle;
|
|
@@ -314,8 +389,16 @@ export class AdwInlineViewSwitcher extends HTMLElement {
|
|
|
314
389
|
}
|
|
315
390
|
|
|
316
391
|
private _applyStyleClasses(): void {
|
|
317
|
-
|
|
318
|
-
|
|
392
|
+
// `css_classes_changed_cb` (adw-inline-view-switcher.c:528-545) forwards
|
|
393
|
+
// THREE classes onto the toggle group, not two: `flat`, `round` and
|
|
394
|
+
// `osd`. This element carried the first two and only onto the host, so an
|
|
395
|
+
// `osd` inline switcher — the one in a floating overlay, where the class
|
|
396
|
+
// exists to make it legible — styled nothing at all.
|
|
397
|
+
for (const name of ['flat', 'round', 'osd'] as const) {
|
|
398
|
+
const present = this.hasAttribute(name);
|
|
399
|
+
this.classList.toggle(name, present);
|
|
400
|
+
this._groupEl?.classList.toggle(name, present);
|
|
401
|
+
}
|
|
319
402
|
// The group carries the display-mode class, mirroring libadwaita's
|
|
320
403
|
// inline-view-switcher > toggle-group.{labels,icons,both} (:826-850).
|
|
321
404
|
for (const mode of INLINE_VIEW_SWITCHER_DISPLAY_MODES) {
|