Keep the dock's tabs in sight around the date strip, on phones and narrow desktop windows

67a21b4 let the dock's tabs give way to the date and range strips and
scroll, and measured phones only after Go. Reviewers found four things it
did not measure:
- At the present, the state the map opens in, the Range strip, now 121 px
  and unshrinkable, took the tabs' width on a portrait phone: Display showed
  0 of 79 px at 360, 390 and 412 wide, and Bookmarks 0, 27 and 49 of 95.
  Before, the strip sat off screen at x 406-502. It is now hidden below sm.
- After Go on a phone, focus went back to the Display tab while the list
  was still wide; the strip then narrowed it and its scroll stayed, so the
  focused tab was 0 of 79 px in sight (a focus ring off screen), and on
  Saturn's page the Clock tab 28 of 63. The focused tab, else the selected
  one, is now scrolled into view after each render in which the strip comes
  or goes (afterRenderEffect on whether a date is shown).
- On desktop windows 600 to 770 px wide the tab list drew the browser's
  classic scrollbar, light and 15 px tall, in the dark dock, and at 640 the
  open Display panel's tab was scrolled out of sight. The list now asks for
  a thin dark one (scheme-dark, scrollbar-width: thin); the tab is brought
  back as above.
- The range strip's shrink-0 had no test: without it '417 AU' wraps and the
  row grows from 38 to 58 px.

Measured on :4301 in the Sun's system, keyboard only (Tab to Display,
Enter, Tab to the date, 2020-12-21T18:00, Enter), visible px of each tab:
- 360x640 phone, at the present: Bookmarks 95/95, Display 24/79 (was 0),
  range hidden. After Go: the focused Display 78/79 (was 0), the date on
  screen, no page scroll. 390x844: 54/79 then 78/79. 412x915: 76/79 then
  78/79.
- Saturn's page at 360x640, after Go: the focused Clock tab 63/63.
- 600x800 desktop: every tab in full before and after Go, no scrollbar.
- 640x900, 700x900, 768x1024 desktop, after Go: the open Display tab 87/87,
  a dark scrollbar 10 px tall (row 43 px, was 48 with a light one).
- 1400x900: unchanged, row 38 px.
With no strip at all, a phone's row is now 33 px tall at the present and
38 once a date is set; the off-screen range strip used to hold it at 38.

Tests: 'keeps the date strip on screen on a phone' now also sets a range
and expects it shrink-0 and max-sm:hidden, and the list scheme-dark and a
thin scrollbar; 'brings the tab that matters back into view once the date
strip has narrowed the tabs' checks the focused tab on a phone and the
selected tab on a wide screen. Guarded mutants, each failing its named test
alone: the range's shrink-0 dropped, the range shown on a phone, the
default scrollbar, no tab scrolled into view, the selected tab only, and
the effect not keyed on the strip. Unit suite 870 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:44:59 +02:00
co-authored by Claude Opus 5.5
parent 4951fd8657
commit 0a88797ff4
2 changed files with 70 additions and 3 deletions
@@ -485,10 +485,51 @@ describe('HudDockComponent', () => {
// At 360 px the system view's five tabs take 397 px, and pushed the strip past the right // At 360 px the system view's five tabs take 397 px, and pushed the strip past the right
// edge, where nothing scrolls: after Go on a phone the date was nowhere on screen. // edge, where nothing scrolls: after Go on a phone the date was nowhere on screen.
fixture.componentRef.setInput('date', '2020-12-21'); fixture.componentRef.setInput('date', '2020-12-21');
fixture.componentRef.setInput('range', '417 AU');
fixture.detectChanges(); fixture.detectChanges();
const tabs = host().querySelector('[role="tablist"]')!.classList; const tabs = host().querySelector('[role="tablist"]')!.classList;
expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true); expect(tabs.contains('min-w-0') && tabs.contains('overflow-x-auto')).toBe(true);
// Its own scrollbar, where the browser draws one, thin and dark: a desktop's default was a
// light bar 15 px tall across the dock.
expect(tabs.contains('scheme-dark') && tabs.contains('[scrollbar-width:thin]')).toBe(true);
expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true); expect(host().querySelector('[data-testid="hud-date"]')!.classList.contains('shrink-0')).toBe(true);
// The range keeps its width where it is shown, or '417 AU' wraps and the row grows 20 px; on a
// phone it is not shown, where at the present it took the Display tab out of sight.
const range = [...host().querySelectorAll('p')].find((p) => p.textContent?.includes('Range'))!.classList;
expect(range.contains('shrink-0') && range.contains('max-sm:hidden')).toBe(true);
});
it('brings the tab that matters back into view once the date strip has narrowed the tabs', () => {
// jsdom lays nothing out; what is checked is which tab is asked to be in view, and when.
const scrolled: string[] = [];
HTMLElement.prototype.scrollIntoView = function (this: HTMLElement) {
scrolled.push(this.id);
};
try {
// On a phone: the tab focus went back to, which after Go sat wholly out of sight.
viewport.wide = false;
host().querySelector<HTMLInputElement>('#clock-date')!.value = '2020-12-21T18:00';
button('Go').click();
fixture.detectChanges();
scrolled.length = 0;
fixture.componentRef.setInput('date', '2020-12-21');
fixture.detectChanges();
expect(scrolled).toEqual(['dock-tab-display']);
// On a wider window, where the panel stays open: its tab, focus being in the panel.
viewport.wide = true;
fixture.componentRef.setInput('date', '');
fixture.detectChanges();
tab('Display').click();
fixture.detectChanges();
host().querySelector<HTMLInputElement>('#clock-date')!.focus();
scrolled.length = 0;
fixture.componentRef.setInput('date', '2020-12-21');
fixture.detectChanges();
expect(scrolled).toEqual(['dock-tab-display']);
} finally {
viewport.wide = true;
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
}
}); });
it('keeps the panel open on a wide screen, where it covers little of the scene', () => { it('keeps the panel open on a wide screen, where it covers little of the scene', () => {
+29 -3
View File
@@ -1,4 +1,5 @@
import { import {
afterRenderEffect,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
@@ -416,8 +417,14 @@ function isWideViewport(): boolean {
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch"> <div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
<!-- The tabs give way to the date and the range, and scroll: the five of the system view <!-- The tabs give way to the date and the range, and scroll: the five of the system view
take 397 px, and on a portrait phone they pushed the date off the right edge. --> take 397 px, and on a portrait phone they pushed the date off the right edge. Where
<div role="tablist" aria-label="Dock" class="flex min-w-0 items-stretch divide-x divide-border/40 overflow-x-auto"> the browser draws a scrollbar of its own, it is a thin dark one: on a desktop window
under 770 px wide its default was a light bar 15 px tall across the dark dock. -->
<div
role="tablist"
aria-label="Dock"
class="flex min-w-0 scheme-dark items-stretch divide-x divide-border/40 overflow-x-auto [scrollbar-width:thin]"
>
@for (tab of tabs(); track tab) { @for (tab of tabs(); track tab) {
<button <button
type="button" type="button"
@@ -448,9 +455,11 @@ function isWideViewport(): boolean {
<span class="text-sm text-accent tabular-nums">{{ date() }}</span> <span class="text-sm text-accent tabular-nums">{{ date() }}</span>
</p> </p>
} }
<!-- Not on a phone, where it never showed before the tabs gave way to it: kept, its 121 px
took the Display tab out of sight at the present, the state the map opens in. -->
@if (range()) { @if (range()) {
<p <p
class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4" class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 max-sm:hidden sm:px-4"
[class.ml-auto]="!date()" [class.ml-auto]="!date()"
> >
<span class="type-label text-muted">Range</span> <span class="type-label text-muted">Range</span>
@@ -527,6 +536,23 @@ export class HudDockComponent implements OnInit {
private readonly search = viewChild(SearchComponent); private readonly search = viewChild(SearchComponent);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly dateShown = computed(() => this.date() !== '');
/**
* The tab list gives way to the date strip but keeps its scroll, so once the strip is drawn the
* tab that matters can be wholly out of sight: after Go on a phone, the tab focus went back to
* (0 of its 79 px at 360, 390 and 412 wide), and on a desktop window 640 to 770 px wide, the tab
* of the panel left open. Brought back into view whenever the strip comes or goes: the focused
* tab, else the selected one.
*/
private readonly keepTabInView = afterRenderEffect(() => {
this.dateShown();
const list = this.host.nativeElement.querySelector('[role="tablist"]');
const focused = document.activeElement;
const tab = focused && list?.contains(focused) ? focused : list?.querySelector('[aria-selected="true"]');
// Optional: jsdom, which the unit tests run in, lays nothing out and has no scrollIntoView.
tab?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
});
ngOnInit(): void { ngOnInit(): void {
this.activeTab.set(isWideViewport() ? this.defaultTab() : null); this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
this.fillDateField(); this.fillDateField();