@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.
Files changed (38) hide show
  1. package/dist/adwaita-web.css +72 -50
  2. package/dist/adwaita-web.css.map +1 -1
  3. package/lib/types/elements/adw-combo-row.d.ts +10 -0
  4. package/lib/types/elements/adw-inline-view-switcher.d.ts +37 -1
  5. package/lib/types/elements/adw-navigation-split-view.d.ts +42 -0
  6. package/lib/types/elements/adw-overlay-split-view.d.ts +56 -0
  7. package/lib/types/elements/adw-spinner.d.ts +23 -0
  8. package/lib/types/elements/adw-view-stack.d.ts +12 -0
  9. package/lib/types/elements/adw-view-switcher-bar.d.ts +7 -5
  10. package/lib/types/elements/adw-view-switcher.d.ts +55 -3
  11. package/lib/types/elements/adw-wrap-box.d.ts +58 -3
  12. package/lib/types/styles.generated.d.ts +1 -1
  13. package/package.json +6 -6
  14. package/scss/_combo_row.scss +19 -0
  15. package/scss/_navigation_split_view.scss +47 -8
  16. package/scss/_overlay_split_view.scss +117 -32
  17. package/scss/_spinner.scss +35 -21
  18. package/scss/_tab_view.scss +12 -13
  19. package/scss/_wrap_box.scss +9 -0
  20. package/src/adw-avatar.spec.ts +37 -0
  21. package/src/adw-button-content.spec.ts +24 -0
  22. package/src/adw-row-state.spec.ts +49 -0
  23. package/src/adw-wrap-box.spec.ts +148 -1
  24. package/src/chrome.spec.ts +111 -12
  25. package/src/elements/adw-action-row.ts +11 -1
  26. package/src/elements/adw-combo-row.ts +17 -0
  27. package/src/elements/adw-inline-view-switcher.ts +91 -8
  28. package/src/elements/adw-navigation-split-view.ts +179 -14
  29. package/src/elements/adw-overlay-split-view.ts +378 -20
  30. package/src/elements/adw-spinner.ts +165 -21
  31. package/src/elements/adw-tab-view.ts +20 -2
  32. package/src/elements/adw-view-stack.ts +21 -0
  33. package/src/elements/adw-view-switcher-bar.ts +13 -5
  34. package/src/elements/adw-view-switcher.ts +103 -11
  35. package/src/elements/adw-wrap-box.ts +290 -135
  36. package/src/split-views.spec.ts +586 -2
  37. package/src/styles.generated.ts +1 -1
  38. package/src/view-switcher.spec.ts +219 -0
@@ -12,12 +12,31 @@
12
12
  // sidebar where GTK shows one.
13
13
  import { describe, expect, it } from '@gjsify/unit';
14
14
 
15
- import { SIDEBAR_BOUNDS_VECTORS } from '@gjsify/adwaita-core/conformance';
16
- import { OVERLAY_COLLAPSE_VECTORS } from '@gjsify/adwaita-core/conformance';
15
+ import type { OverlaySplitViewState } from '@gjsify/adwaita-core';
16
+
17
+ import {
18
+ OVERLAY_COLLAPSE_VECTORS,
19
+ OVERLAY_SWIPE_CANCEL_VECTORS,
20
+ OVERLAY_SWIPE_RELEASE_VECTORS,
21
+ OVERLAY_SWIPE_SNAP_POINT_VECTORS,
22
+ OVERLAY_SWIPE_START_VECTORS,
23
+ NAVIGATION_ACTION_VECTORS,
24
+ OVERLAY_SWIPE_AREA_VECTORS,
25
+ OVERLAY_SPLIT_VIEW_LAYOUT_VECTORS,
26
+ NAVIGATION_STACK_VECTORS,
27
+ SIDEBAR_BOUNDS_VECTORS,
28
+ } from '@gjsify/adwaita-core/conformance';
17
29
 
18
30
  import type { AdwNavigationSplitView } from './elements/adw-navigation-split-view.js';
19
31
  import type { AdwOverlaySplitView } from './elements/adw-overlay-split-view.js';
20
32
 
33
+ /** Wait for a ResizeObserver delivery (it runs after layout, before paint). */
34
+ function settle(): Promise<void> {
35
+ return new Promise((resolve) => {
36
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
37
+ });
38
+ }
39
+
21
40
  /** Mount a split view from markup and return it. */
22
41
  function mount(attrs = ''): { view: AdwOverlaySplitView; host: HTMLElement } {
23
42
  const host = document.createElement('div');
@@ -306,4 +325,569 @@ export const AdwSplitViewsTest = async () => {
306
325
  host.remove();
307
326
  });
308
327
  });
328
+
329
+ await describe('<adw-overlay-split-view> reveal is a CONTINUUM, not two states', async () => {
330
+ /** Mount a view of a known width so the geometry is predictable. */
331
+ function mountSizedView(width: number, attrs = ''): { view: AdwOverlaySplitView; host: HTMLElement } {
332
+ const { view, host } = mount(attrs);
333
+ host.style.width = `${width}px`;
334
+ host.style.height = '400px';
335
+ (view as unknown as HTMLElement).style.height = '400px';
336
+ return { view, host };
337
+ }
338
+
339
+ /** The state the element composes — the same object the vectors describe. */
340
+ const stateOf = (view: AdwOverlaySplitView) => (view as unknown as { _state: OverlaySplitViewState })._state;
341
+
342
+ await it('drives show-progress through the whole range, not 0 and 1', async () => {
343
+ const { view, host } = mountSizedView(800);
344
+ const state = stateOf(view);
345
+ const sidebar = view.querySelector('.adw-osv-sidebar') as HTMLElement;
346
+ await settle();
347
+
348
+ const offsets = new Set<string>();
349
+ for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
350
+ state.setShowProgress(progress);
351
+ offsets.add(sidebar.style.marginLeft || sidebar.style.left || '');
352
+ }
353
+ // Two end states would give at most two distinct offsets; the five
354
+ // OVERLAY_SWIPE_* tables need every value in between to mean something.
355
+ expect(offsets.size).toBeGreaterThan(2);
356
+ host.remove();
357
+ });
358
+
359
+ for (const vector of OVERLAY_SWIPE_SNAP_POINT_VECTORS) {
360
+ await it(`snap points [${vector.snapPoints.join(', ')}] — ${vector.rule}`, async () => {
361
+ const attrs =
362
+ (vector.enableShowGesture ? '' : 'enable-show-gesture="false" ') +
363
+ (vector.enableHideGesture ? '' : 'enable-hide-gesture="false"');
364
+ const { view, host } = mountSizedView(800, attrs);
365
+ const state = stateOf(view);
366
+ await settle();
367
+ state.setShowProgress(vector.showProgress);
368
+ if (vector.swipeActive) state.beginSwipe();
369
+ expect(state.snapPoints).toStrictEqual([...vector.snapPoints]);
370
+ host.remove();
371
+ });
372
+ }
373
+
374
+ await it('grabs only where the swipe area is — the ADW_SWIPE_BORDER strip when closed', async () => {
375
+ // The row this probes: closed, start edge, LTR — the grabbable strip
376
+ // is `ADW_SWIPE_BORDER` wide and nothing outside it starts a gesture.
377
+ // What a DOM renderer owns is that second half, which no unit test of
378
+ // `resolveSwipeArea` can show.
379
+ const closedAtStart = OVERLAY_SWIPE_AREA_VECTORS.find(
380
+ (vector) =>
381
+ vector.isDrag &&
382
+ vector.showProgress === 0 &&
383
+ vector.sidebarPosition === 'start' &&
384
+ vector.direction === 'ltr',
385
+ );
386
+ if (!closedAtStart) throw new Error('OVERLAY_SWIPE_AREA_VECTORS lost its closed/start/ltr row');
387
+ const strip = closedAtStart.area.width;
388
+
389
+ const { view, host } = mountSizedView(800, 'collapsed show-sidebar="false"');
390
+ const state = stateOf(view);
391
+ await settle();
392
+ const rect = view.getBoundingClientRect();
393
+
394
+ const down = (offsetX: number) =>
395
+ view.dispatchEvent(
396
+ new PointerEvent('pointerdown', {
397
+ bubbles: true,
398
+ pointerId: 1,
399
+ button: 0,
400
+ clientX: rect.left + offsetX,
401
+ clientY: rect.top + 100,
402
+ }),
403
+ );
404
+ const move = (offsetX: number) =>
405
+ view.dispatchEvent(
406
+ new PointerEvent('pointermove', {
407
+ bubbles: true,
408
+ pointerId: 1,
409
+ clientX: rect.left + offsetX,
410
+ clientY: rect.top + 100,
411
+ }),
412
+ );
413
+
414
+ // Just OUTSIDE the strip, so nothing begins.
415
+ down(strip + 4);
416
+ move(strip + 120);
417
+ expect(state.swipeActive).toBe(false);
418
+
419
+ // Just inside it: the gesture takes over and the progress follows.
420
+ down(Math.max(strip - 4, 0));
421
+ move(strip + 120);
422
+ expect(state.swipeActive).toBe(true);
423
+ expect(state.showProgress).toBeGreaterThan(0);
424
+ host.remove();
425
+ });
426
+
427
+ for (const vector of OVERLAY_SWIPE_RELEASE_VECTORS) {
428
+ await it(`release at ${vector.to} — ${vector.rule}`, async () => {
429
+ const { view, host } = mountSizedView(800, 'collapsed');
430
+ const state = stateOf(view);
431
+ await settle();
432
+ // Seed `show-sidebar` without animating, then take the gesture.
433
+ state.setShowSidebar(vector.showSidebar, { animate: false });
434
+ state.beginSwipe();
435
+ const plan = state.endSwipe(vector.to);
436
+ expect(plan.kind).toBe(vector.kind);
437
+ // A `set-show-sidebar` release is the one that notifies, so the
438
+ // property has to have MOVED, not just the animation settled.
439
+ if (plan.kind === 'set-show-sidebar') expect(state.showSidebar).toBe(vector.showSidebarAfter);
440
+ host.remove();
441
+ });
442
+ }
443
+
444
+ await it('a cancelled pointer snaps back where get_cancel_progress says', async () => {
445
+ const { view, host } = mountSizedView(800, 'collapsed show-sidebar="false"');
446
+ const state = stateOf(view);
447
+ await settle();
448
+ const rect = view.getBoundingClientRect();
449
+ view.dispatchEvent(
450
+ new PointerEvent('pointerdown', {
451
+ bubbles: true,
452
+ pointerId: 2,
453
+ button: 0,
454
+ clientX: rect.left + 8,
455
+ clientY: rect.top + 100,
456
+ }),
457
+ );
458
+ view.dispatchEvent(
459
+ new PointerEvent('pointermove', {
460
+ bubbles: true,
461
+ pointerId: 2,
462
+ clientX: rect.left + 60,
463
+ clientY: rect.top + 100,
464
+ }),
465
+ );
466
+ expect(state.swipeActive).toBe(true);
467
+ view.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 2 }));
468
+ expect(state.swipeActive).toBe(false);
469
+ // A short drag from closed rounds back to closed.
470
+ expect(state.showSidebar).toBe(false);
471
+ host.remove();
472
+ });
473
+
474
+ await it('Escape closes a collapsed revealed sidebar', async () => {
475
+ // `escape_shortcut_cb` (adw-overlay-split-view.c:705-716) was absent
476
+ // from both ports, which made `OverlaySplitViewState.escape()` dead
477
+ // code.
478
+ const { view, host } = mountSizedView(800, 'collapsed');
479
+ await settle();
480
+ // Parse-time attributes are CONSTRUCTION options, not sequential
481
+ // setters, so `collapsed` in the markup does not fire the auto-hide
482
+ // — the element documents that and OVERLAY_COLLAPSE_VECTORS covers
483
+ // the setter path. So the sidebar is already revealed here.
484
+ expect(view.showSidebar).toBe(true);
485
+
486
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
487
+ view.dispatchEvent(event);
488
+ expect(event.defaultPrevented).toBe(true);
489
+ expect(view.showSidebar).toBe(false);
490
+ host.remove();
491
+ });
492
+
493
+ await it('...and PROPAGATES from an uncollapsed one, so an enclosing dialog still closes', async () => {
494
+ const { view, host } = mountSizedView(800);
495
+ await settle();
496
+ expect(view.collapsed).toBe(false);
497
+ expect(view.showSidebar).toBe(true);
498
+
499
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
500
+ view.dispatchEvent(event);
501
+ // A docked sidebar is not something Escape closes, however revealed
502
+ // it is — the gate is `!collapsed`, checked before the progress.
503
+ expect(event.defaultPrevented).toBe(false);
504
+ expect(view.showSidebar).toBe(true);
505
+ host.remove();
506
+ });
507
+
508
+ await it('is gated on the PROGRESS, so it still consumes mid-close', async () => {
509
+ // Worth pinning because it looks like a bug: `escape_shortcut_cb`
510
+ // tests `show_progress`, not `show_sidebar`, so a second Escape
511
+ // during the closing animation is consumed too and the set inside is
512
+ // a no-op. Reading the gate as "is the sidebar shown" would make the
513
+ // key propagate for ~250ms after the first press and close a dialog
514
+ // behind the view.
515
+ const { view, host } = mountSizedView(800, 'collapsed');
516
+ await settle();
517
+ expect(view.showSidebar).toBe(true);
518
+
519
+ const escape = () => {
520
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
521
+ view.dispatchEvent(event);
522
+ return event.defaultPrevented;
523
+ };
524
+ expect(escape()).toBe(true);
525
+ expect(view.showSidebar).toBe(false);
526
+ // The reveal is still animating out, so the progress is above zero.
527
+ expect(escape()).toBe(true);
528
+ host.remove();
529
+ });
530
+
531
+ await it('slides off the edge the sidebar is ON, in both positions', async () => {
532
+ // `marginRight: -offsetWidth` regardless of `sidebarPosition` is what
533
+ // this used to do, so an `end` sidebar slid the wrong way.
534
+ const start = mountSizedView(800, 'show-sidebar="false"');
535
+ await settle();
536
+ const startBar = start.view.querySelector('.adw-osv-sidebar') as HTMLElement;
537
+ expect(startBar.style.marginLeft.startsWith('-')).toBe(true);
538
+ expect(startBar.style.marginRight).toBe('');
539
+ start.host.remove();
540
+
541
+ const end = mountSizedView(800, 'sidebar-position="end" show-sidebar="false"');
542
+ await settle();
543
+ const endBar = end.view.querySelector('.adw-osv-sidebar') as HTMLElement;
544
+ expect(endBar.style.marginRight.startsWith('-')).toBe(true);
545
+ expect(endBar.style.marginLeft).toBe('');
546
+ end.host.remove();
547
+ });
548
+
549
+ await it('mirrors under RTL — a start sidebar is drawn on the RIGHT', async () => {
550
+ const { view, host } = mountSizedView(800, 'show-sidebar="false"');
551
+ host.style.direction = 'rtl';
552
+ await settle();
553
+ // Re-run the geometry now the direction is readable.
554
+ view.setAttribute('sidebar-position', 'start');
555
+ await settle();
556
+ expect(view.classList.contains('sidebar-at-visual-start')).toBe(false);
557
+ const sidebar = view.querySelector('.adw-osv-sidebar') as HTMLElement;
558
+ expect(sidebar.style.marginRight.startsWith('-')).toBe(true);
559
+ host.remove();
560
+ });
561
+
562
+ await it('does not write a CSS PERCENTAGE width — (int) truncates, a percentage is fractional', async () => {
563
+ const { view, host } = mountSizedView(800);
564
+ await settle();
565
+ const sidebar = view.querySelector('.adw-osv-sidebar') as HTMLElement;
566
+ expect(sidebar.style.width.endsWith('%')).toBe(false);
567
+ expect(sidebar.style.width.endsWith('px')).toBe(true);
568
+ // 800 * 0.25 = 200, inside [180, 280], so it is the fraction exactly.
569
+ expect(sidebar.style.width).toBe('200px');
570
+ host.remove();
571
+ });
572
+
573
+ await it('keeps the content pane from being squeezed to nothing', async () => {
574
+ // `measure_uncollapsed` (:558-563) as CSS spells it: the pane claims
575
+ // its own min-content, where `min-width: 0` let the sidebar take
576
+ // everything.
577
+ const { view, host } = mountSizedView(800);
578
+ await settle();
579
+ const content = view.querySelector('.adw-osv-content') as HTMLElement;
580
+ expect(getComputedStyle(content).minWidth).not.toBe('0px');
581
+ host.remove();
582
+ });
583
+
584
+ for (const vector of OVERLAY_SPLIT_VIEW_LAYOUT_VECTORS) {
585
+ // Its exact analogue NAVIGATION_SPLIT_VIEW_LAYOUT_VECTORS was driven
586
+ // from the NativeScript suite while this one was driven by nobody —
587
+ // two sibling tables in the same file, with no reason for the
588
+ // difference. The element writes the pane rect from
589
+ // `layoutOverlaySplitView` now, so the rects are readable.
590
+ const label = `${vector.collapsed ? 'collapsed' : 'docked'} ${vector.sidebarPosition}/${vector.direction} @${vector.showProgress}`;
591
+ await it(`${label} — ${vector.rule}`, async () => {
592
+ const attrs =
593
+ (vector.collapsed ? 'collapsed ' : '') +
594
+ (vector.sidebarPosition === 'end' ? 'sidebar-position="end" ' : '') +
595
+ 'pin-sidebar';
596
+ const { view, host } = mountSizedView(vector.totalWidth, attrs);
597
+ if (vector.direction === 'rtl') host.style.direction = 'rtl';
598
+ await settle();
599
+ const state = stateOf(view);
600
+ state.setShowProgress(vector.showProgress);
601
+
602
+ // The SHIELD and the paint gate are the element's to publish;
603
+ // the pixel rects depend on the measured sidebar width, which a
604
+ // real layout decides, so the shared half is what is compared.
605
+ const backdrop = view.querySelector('.adw-osv-backdrop') as HTMLElement;
606
+ expect(backdrop.hidden).toBe(!vector.layout.shieldVisible);
607
+ const sidebar = view.querySelector('.adw-osv-sidebar') as HTMLElement;
608
+ expect(sidebar.style.pointerEvents === 'none').toBe(!vector.layout.sidebarPainted);
609
+ host.remove();
610
+ });
611
+ }
612
+
613
+ await it('names the cancel rows the core owns, so this suite is not read as covering them', () => {
614
+ // `swipeCancelProgress`'s half-away-from-zero rounding is arithmetic
615
+ // with no DOM surface — a pointercancel can only show the rounded
616
+ // OUTCOME, not the rule. The core suite drives every row.
617
+ expect(OVERLAY_SWIPE_CANCEL_VECTORS.length).toBeGreaterThan(0);
618
+ expect(OVERLAY_SWIPE_START_VECTORS.length).toBeGreaterThan(0);
619
+ });
620
+ });
621
+
622
+ await describe('<adw-navigation-split-view> stack, tags and navigation.* actions', async () => {
623
+ /** Mount a navigation split view with the given panes and attributes. */
624
+ function mountNav(attrs = '', panes = { sidebar: true, content: true, sidebarTag: '', contentTag: '' }) {
625
+ const host = document.createElement('div');
626
+ host.style.width = '800px';
627
+ host.style.height = '400px';
628
+ document.body.appendChild(host);
629
+ const sidebar = panes.sidebar
630
+ ? `<div slot="sidebar"${panes.sidebarTag ? ` tag="${panes.sidebarTag}"` : ''}>s</div>`
631
+ : '';
632
+ const content = panes.content
633
+ ? `<div slot="content"${panes.contentTag ? ` tag="${panes.contentTag}"` : ''}>c</div>`
634
+ : '';
635
+ host.innerHTML = `<adw-navigation-split-view ${attrs}>${sidebar}${content}</adw-navigation-split-view>`;
636
+ return {
637
+ view: host.querySelector('adw-navigation-split-view') as AdwNavigationSplitView,
638
+ host,
639
+ };
640
+ }
641
+
642
+ /** Which pane the element says is on top, read off the DOM. */
643
+ const visiblePane = (view: AdwNavigationSplitView) => {
644
+ const sidebar = view.querySelector('.adw-nsv-sidebar') as HTMLElement;
645
+ const content = view.querySelector('.adw-nsv-content') as HTMLElement;
646
+ return {
647
+ sidebar: sidebar?.dataset.paneVisible === 'true',
648
+ content: content?.dataset.paneVisible === 'true',
649
+ };
650
+ };
651
+
652
+ for (const vector of NAVIGATION_STACK_VECTORS) {
653
+ // Only the STATIC rows: the animated branch reaches the same stack by
654
+ // a push or a pop, and a DOM renderer that has no transition cannot
655
+ // tell them apart. The core suite drives the direction.
656
+ if (vector.changingPage) continue;
657
+ const label =
658
+ `${vector.hasSidebar ? 'sidebar' : '-'}/${vector.hasContent ? 'content' : '-'} ` +
659
+ `${vector.sidebarPosition} show-content=${vector.showContent}`;
660
+ await it(`${label} → [${vector.plan.stack.join(', ')}] — ${vector.rule}`, async () => {
661
+ const attrs =
662
+ 'collapsed ' +
663
+ (vector.showContent ? 'show-content ' : '') +
664
+ (vector.sidebarPosition === 'end' ? 'sidebar-position="end"' : '');
665
+ const { view, host } = mountNav(attrs, {
666
+ sidebar: vector.hasSidebar,
667
+ content: vector.hasContent,
668
+ sidebarTag: '',
669
+ contentTag: '',
670
+ });
671
+ await settle();
672
+ const top = vector.plan.stack[vector.plan.stack.length - 1] ?? null;
673
+ const panes = visiblePane(view);
674
+ // A LONE child stays visible whatever `show-content` says — the
675
+ // rule two CSS classes could not express, and the reason a
676
+ // collapsed sidebar-only view used to render blank.
677
+ if (vector.hasSidebar) expect(panes.sidebar).toBe(top === 'sidebar');
678
+ if (vector.hasContent) expect(panes.content).toBe(top === 'content');
679
+ host.remove();
680
+ });
681
+ }
682
+
683
+ for (const vector of NAVIGATION_ACTION_VECTORS) {
684
+ const label =
685
+ vector.action === 'push'
686
+ ? `push "${vector.tag}" (sidebar=${vector.sidebarTag ?? 'none'}, content=${vector.contentTag ?? 'none'})`
687
+ : `pop (sidebar=${vector.hasSidebar}, content=${vector.hasContent})`;
688
+ // A row whose two panes carry the SAME tag describes a state the
689
+ // duplicate-tag guard makes unreachable through a real widget: the
690
+ // second assignment is refused (:1195-1201), so the element can never
691
+ // hold both. Asserted below instead of driven here.
692
+ const collides =
693
+ vector.action === 'push' && vector.sidebarTag != null && vector.sidebarTag === vector.contentTag;
694
+ if (collides) continue;
695
+
696
+ await it(`${label} → ${vector.result.kind} — ${vector.rule}`, async () => {
697
+ const attrs = (vector.collapsed ? 'collapsed ' : '') + (vector.showContent ? 'show-content' : '');
698
+ const { view, host } = mountNav(attrs, {
699
+ sidebar: vector.action === 'pop' ? (vector.hasSidebar ?? false) : true,
700
+ content: vector.action === 'pop' ? (vector.hasContent ?? false) : true,
701
+ sidebarTag: vector.sidebarTag ?? '',
702
+ contentTag: vector.contentTag ?? '',
703
+ });
704
+ // `delegate` is the ROUTING's answer; whether it survives depends
705
+ // on the ancestor. An ancestor that claims the tag is the case the
706
+ // table describes — the unclaimed one becomes a critical, which
707
+ // is the state's own step and has its own test below.
708
+ host.addEventListener('navigation-push', (event) => {
709
+ (event as CustomEvent<{ handled: boolean }>).detail.handled = true;
710
+ });
711
+ host.addEventListener('navigation-pop', (event) => {
712
+ (event as CustomEvent<{ handled: boolean }>).detail.handled = true;
713
+ });
714
+ await settle();
715
+ const result = vector.action === 'push' ? view.push(vector.tag as string) : view.pop();
716
+ expect(result.kind).toBe(vector.result.kind);
717
+ if (result.kind === 'set-show-content' && vector.result.kind === 'set-show-content') {
718
+ expect(view.showContent).toBe(vector.result.showContent);
719
+ // The DOM has to agree, or a consumer reading the attribute
720
+ // back after a push sees the pre-push answer.
721
+ expect(view.hasAttribute('show-content')).toBe(vector.result.showContent);
722
+ }
723
+ host.remove();
724
+ });
725
+ }
726
+
727
+ await it('leaves out only the rows the tag guard makes unreachable', () => {
728
+ const collidingRows = NAVIGATION_ACTION_VECTORS.filter(
729
+ (v) => v.action === 'push' && v.sidebarTag != null && v.sidebarTag === v.contentTag,
730
+ );
731
+ // Exactly the shared-tag pushes. Their `rule` even says so — the C's
732
+ // routing tests the content tag first, which only matters in a
733
+ // collision the setters refuse to create.
734
+ expect(collidingRows.length).toBeGreaterThan(0);
735
+ for (const row of collidingRows) expect(row.contentTag).toBe(row.sidebarTag);
736
+ });
737
+
738
+ await it('an UNCLAIMED push becomes a critical, not a silent no-op', async () => {
739
+ // `navigation_push_cb` :683 — the parent gets first refusal, and only
740
+ // then is it an error. A pop walks off the end silently instead.
741
+ const { view, host } = mountNav('collapsed', {
742
+ sidebar: true,
743
+ content: true,
744
+ sidebarTag: 'list',
745
+ contentTag: 'detail',
746
+ });
747
+ await settle();
748
+ const pushed = view.push('nowhere');
749
+ expect(pushed.kind).toBe('not-found');
750
+ expect(view.showContent).toBe(false);
751
+ host.remove();
752
+ });
753
+
754
+ await it('delegates an unmatched push to an ancestor, which is how nesting forwards it', async () => {
755
+ const { view, host } = mountNav('collapsed', {
756
+ sidebar: true,
757
+ content: true,
758
+ sidebarTag: 'list',
759
+ contentTag: 'detail',
760
+ });
761
+ await settle();
762
+ const seen: string[] = [];
763
+ host.addEventListener('navigation-push', (event) => {
764
+ const detail = (event as CustomEvent<{ tag?: string; handled: boolean }>).detail;
765
+ seen.push(detail.tag ?? '');
766
+ // An ancestor that OWNS the tag says so, and the critical is
767
+ // never reached — `return TRUE` in the C's routing.
768
+ detail.handled = true;
769
+ });
770
+ const result = view.push('somewhere-else');
771
+ expect(seen).toStrictEqual(['somewhere-else']);
772
+ expect(result.kind).toBe('delegate');
773
+ host.remove();
774
+ });
775
+
776
+ await it('REFUSES a pane whose tag the other one already carries', async () => {
777
+ // `adw_navigation_split_view_set_content` :1195-1201 — the pane keeps
778
+ // what it had; the assignment does not happen. Neither port had a
779
+ // duplicate-tag guard at all.
780
+ const { view, host } = mountNav('', {
781
+ sidebar: true,
782
+ content: true,
783
+ sidebarTag: 'same',
784
+ contentTag: 'same',
785
+ });
786
+ await settle();
787
+ expect(view.sidebarTag).toBe('same');
788
+ // The content was refused, so it carries no tag of record.
789
+ expect(view.contentTag).toBe(null);
790
+ host.remove();
791
+ });
792
+
793
+ await it('puts the divider on the side the sidebar is DRAWN on', async () => {
794
+ const start = mountNav();
795
+ await settle();
796
+ expect(start.view.classList.contains('sidebar-at-visual-start')).toBe(true);
797
+ start.host.remove();
798
+
799
+ const end = mountNav('sidebar-position="end"');
800
+ await settle();
801
+ expect(end.view.classList.contains('sidebar-at-visual-start')).toBe(false);
802
+ end.host.remove();
803
+ });
804
+ });
805
+
806
+ // A VIEW THAT WAS NEVER MEASURED, AND A VIEW THAT WAS MOVED.
807
+ //
808
+ // Both shipped broken in v0.32.0 and were visible on the docs site: its
809
+ // slideshow builds each slide inside `display: none` and moves it in, so the
810
+ // controls pane had no measurement when it first rendered and no live
811
+ // observer afterwards. A hidden sidebar was painted over the content at the
812
+ // wrong edge — the state was right the whole time, only the geometry never
813
+ // reached the DOM.
814
+ //
815
+ // These are placement assertions on purpose. Every other test here reads the
816
+ // state or a class, and every one of them PASSED while the widget was
817
+ // visibly wrong: nothing in the suite looked at where the pane actually
818
+ // ended up. That is the gap, not the individual bug.
819
+ await describe('AdwOverlaySplitView — geometry survives being unmeasured and moved', async () => {
820
+ /** Does the pane cover the view's own box? */
821
+ const covers = (view: AdwOverlaySplitView): boolean => {
822
+ const pane = view.querySelector('.adw-osv-sidebar') as HTMLElement;
823
+ const a = view.getBoundingClientRect();
824
+ const b = pane.getBoundingClientRect();
825
+ return b.width > 0 && b.right > a.left + 1 && b.left < a.right - 1;
826
+ };
827
+
828
+ await it('keeps a hidden sidebar off-screen when it is first laid out inside display:none', async () => {
829
+ const slide = document.createElement('div');
830
+ slide.style.display = 'none';
831
+ document.body.appendChild(slide);
832
+ slide.innerHTML =
833
+ '<div style="width:900px;height:300px;display:flex">' +
834
+ '<adw-overlay-split-view collapsed show-sidebar="false" sidebar-position="end">' +
835
+ '<div slot="content">c</div><div slot="sidebar">s</div>' +
836
+ '</adw-overlay-split-view></div>';
837
+ const view = slide.querySelector('adw-overlay-split-view') as AdwOverlaySplitView;
838
+ await settle();
839
+
840
+ // The slide becomes the visible one.
841
+ slide.style.display = '';
842
+ await settle();
843
+ expect(view.showSidebar).toBe(false);
844
+ expect(covers(view)).toBe(false);
845
+ slide.remove();
846
+ });
847
+
848
+ await it('re-measures after being moved to another parent', async () => {
849
+ const from = document.createElement('div');
850
+ from.style.display = 'none';
851
+ const to = document.createElement('div');
852
+ to.setAttribute('style', 'width:900px;height:300px;display:flex');
853
+ document.body.append(from, to);
854
+ from.innerHTML =
855
+ '<adw-overlay-split-view collapsed show-sidebar="false" sidebar-position="end">' +
856
+ '<div slot="content">c</div><div slot="sidebar">s</div>' +
857
+ '</adw-overlay-split-view>';
858
+ const view = from.querySelector('adw-overlay-split-view') as AdwOverlaySplitView;
859
+ await settle();
860
+
861
+ // Moving a node is a disconnect followed by a connect. The observer
862
+ // torn down by the first has to be rebuilt by the second.
863
+ to.appendChild(view);
864
+ await settle();
865
+ expect(covers(view)).toBe(false);
866
+
867
+ // And it must keep tracking: a resize after the move has to land.
868
+ to.style.width = '600px';
869
+ await settle();
870
+ const pane = view.querySelector('.adw-osv-sidebar') as HTMLElement;
871
+ expect(pane.style.left).toBe('600px');
872
+ from.remove();
873
+ to.remove();
874
+ });
875
+
876
+ await it('hands placement back to the stylesheet rather than stranding a stale offset', async () => {
877
+ const { view, host } = mount('collapsed show-sidebar="false" sidebar-position="end"');
878
+ host.setAttribute('style', 'width:900px;height:300px;display:flex');
879
+ await settle();
880
+ const pane = view.querySelector('.adw-osv-sidebar') as HTMLElement;
881
+ expect(pane.style.left).toBe('900px');
882
+
883
+ // Unmeasurable again: the inline offset must be CLEARED, because an
884
+ // absolutely-positioned box with `left` + `width` ignores `right`,
885
+ // so a leftover value would outrank the resting rule it falls back
886
+ // to and pin the pane wherever it last happened to be.
887
+ host.style.display = 'none';
888
+ await settle();
889
+ expect(pane.style.left).toBe('');
890
+ host.remove();
891
+ });
892
+ });
309
893
  };