Skip to content

WEBDEV-8458 Drive the item navigator's panels from their own state - #70

Merged
iisa merged 10 commits into
WEBDEV-8458-migrate-item-navigatorfrom
WEBDEV-8458-item-nav-open-close-semantics
Aug 27, 2026
Merged

WEBDEV-8458 Drive the item navigator's panels from their own state#70
iisa merged 10 commits into
WEBDEV-8458-migrate-item-navigatorfrom
WEBDEV-8458-item-nav-open-close-semantics

Conversation

@iisa

@iisa iisa commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #64, targeting that branch so it reviews on its own.

The problem

@jbuckner asked whether the 350ms setTimeout before scrolling the active file into view could be more deterministic. It can — but the timer was a symptom, not the cause.

Nothing modelled the panel's open/close as state. The navigator held menuOpened/openMenu; the slider held its own selectedMenu, plus open and animateMenuOpen. Because menuTypeSelected is composed, selecting a channel toggled both copies independently, then the navigator overwrote the slider's — they agreed only because the two toggle expressions happened to match. A static attribute set slider.open once, so it stayed true forever. Nothing ever set animateMenuOpen, so the slider's .animate rule never fired.

With no single fact to react to, the scroll had to guess when layout would settle. Hence 350ms, picked to outlast a 200ms transition.

What changed

The navigator now owns whether the drawer and a panel are open. The slider reports what the user did and renders what comes back — one copy of the state instead of two. slider.open, animateMenuOpen and manuallyHandleClose are gone.

The rest follows from that:

  • The scroll reacts to the file list or selection changing, so it waits for nothing. That also closes two gaps the timer hid: bookreader reuses one panel element, so firstUpdated fired only on the first open; offshoot reuses it on a file click, so the active row could move without the panel following.
  • Closed means closed. A closed panel is inert, so the tab order and the accessibility tree match the screen, and aria-expanded reports what's actually open instead of a hardcoded false.
  • Focus follows the state. Opening a surface moves focus into it; closing hands focus back to whatever opened it — including from the shortcut rail, which used to hide the focused button mid-frame and drop focus to <body>.
  • Closing the drawer closes its panel, so a stale panel can't reappear on the next open.
  • Menu buttons carry an aria-label, so their name no longer depends on a styling variable.

The transitions themselves don't change. They just follow the state flip now instead of being something to schedule around.

Also here: two CSS custom-property commits

Auditing the knobs while in this code turned up the same class of problem — a value duplicated per file rather than declared once.

6078c78 — one knob per icon size. --item-navigator-icon-width/-height and --item-navigator-header-icon-width/-height always moved together, since every glyph is square. Each pair collapses to one knob, taking the public surface from 18 to 16. That also fixed a live defect: the header pair declared 2em in the slider but 18px in the sort button, so the close and sort icons rendered 20×20 next to 18×18 in the same header. One knob can only carry one default.

057362e — declare the base font size like every other knob. Each component spelled out var(--item-navigator-base-font-size, 10px) where it needed the base, putting the default in ten places. It now uses the private-alias pattern the other knobs already follow. Same public name, same 10px, no visual change.

Verifying

The check that matters is running with the animation off. If behaviour holds at --item-navigator-animation-timing: 0ms, nothing is timing against it. It holds.

Drawer opens aria-expanded false → true, panel still inert, focus on the first menu button
Channel opens panel no longer inert, focus lands inside the panel
Escape closes the panel, focus returns to its menu button
Escape again closes the drawer, focus returns to the toggle
Shortcut rail focus lands in the drawer instead of on <body>
Drawer close clears the panel too, so nothing stale reappears

The closed panel's close button takes no focus. No console errors.

For the knob commits: icons measure 20/20/20 in the header and 24 in the drawer, and setting --item-navigator-base-font-size: 20px doubles all of them — so the aliases are wired, not merely declared.

279 tests pass with 100% coverage on the component; build and lint clean. The two scroll tests that each slept 400ms and only asserted "didn't throw" now mount the panel in a real scrolling box and assert the active row lands in view. The slider's tests now assert it reports selections rather than deciding them.

Decisions

  • Overlay stays non-modal. Below 600px the drawer covers the theater, but nothing traps focus — so it carries role="group", not dialog. It shouldn't claim to be modal when it isn't.
  • aria-controls only on the toggle. ARIA IDREFs can't cross shadow roots, so the shortcuts and menu buttons can't point at the panel. A reference that resolves beats one that dangles.
  • A host can still preselect a channel while the drawer is closed via manageSideMenuEvents('toggle') — an explicit instruction rather than a user action, so I left it alone.

The panel's open/close was spread across two components, so the code
worked around the animation rather than the behaviour that caused it —
most visibly a 350ms timer standing in for 'the list is ready'.

The navigator now owns whether the drawer and a panel are open, and the
slider reports what the user did and renders what comes back. That
retires a second copy of the selection that only agreed with the first
by coincidence, plus two properties that never did anything: the
slider's own open flag, frozen true by a static attribute, and
animateMenuOpen, which nothing set.

Because the state is now in one place it can be stated in the markup:
aria-expanded reflects whether a surface is actually open rather than
being hardcoded false, the drawer and panel carry roles and names, the
panel is named by its own heading, and closed panels are inert so the
tab order and the accessibility tree agree with the screen. Opening a
surface moves focus into it and closing returns focus to whatever
opened it — including from the shortcut rail, which used to hide the
focused button and drop focus to the document. Closing the drawer also
closes the panel inside it, so a stale panel can't reappear.

The scroll follows the same rule: it runs when the list or the
selection changes, so there is nothing to wait for. Menu buttons also
carry an aria-label, keeping their name from depending on a styling
variable a consumer might not set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-27 19:20 UTC

iisa and others added 9 commits August 25, 2026 11:10
Every glyph is square and all eight call sites set width and height
together, so the separate width and height knobs only doubled the API.
They also disagreed: the header icon defaulted to 2em in the slider and
18px in the sort button, and both render in the same panel header — the
close icon came out 20px next to an 18px sort icon.

One knob per icon group can only carry one default, so the two can no
longer drift. The header settles on 2em, which the close buttons
already used and which follows the em-against-10px sizing the rest of
the component uses.

That exposed why the sort button had been pinned to a pixel value:
buttons don't inherit font-size, so it was sizing its glyph against the
UA default rather than the component base. It now resets font the same
way the close button beside it does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each component spelled out var(--item-navigator-base-font-size, 10px)
where it needed the base, so the default lived in ten places. Every
other knob in the component declares a private alias once and reads
that, which is what keeps a default from drifting between files — the
habit that was missing when the header icon knob ended up 2em in the
slider and 18px in the sort button.

Same public name, same 10px default, no visual change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening straight to a panel from a shortcut looked a beat slow. The
panel is nested inside the drawer, so their transforms compose: the
panel covered 640px in the time the drawer covered 320px, staying
clipped for the first quarter of the animation and then arriving at
double speed. Opening the same panel from a menu button was fine
because the drawer is already in place and the panel travels its own
320px.

Opening straight to a panel is one movement, so the panel now holds
still and lets the drawer carry it in. CSS cannot tell the two apart on
its own — both paths end that frame with the drawer and the panel open
— so openShortcut marks the render where the drawer was actually
closed, and the navigator hands the slider a transition value through
an inherited custom property.

Every other path clears the mark, rather than waiting on the drawer's
transitionend: a zero-duration transition never fires one, so with
animations turned off the flag would have stranded on and suppressed
the panel's animation for good.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every menu icon sat at z-index 2, above the panel at z-index 1, so
opening or closing a panel showed its contents travelling behind the
icon column — the inactive icons have no background of their own, so
there was nothing to hide it.

Only the open entry's icon needs to be above: it borrows the panel's
background and rounds into it, and the two have to read as one shape.
The rest now sit below the panel and are simply covered as it moves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel's movement had no tests, so the recent fixes to it rested on
manual checks. These pin the vocabulary: the panel slides on the edges
between empty and open, switching between panels swaps contents without
re-running the slide, and the host can suppress the slide so the drawer
carries the panel in.

Also covers the marker's lifecycle — set only when the drawer was
actually closed, and cleared by selecting another channel, closing the
panel or closing the drawer — and that only the open entry's icon lifts
above the panel, which is what kept the panel from sliding visibly
behind the others.

Positions are read with animations disabled, since a computed transform
mid-transition reports wherever the animation has reached rather than
where it is headed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CustomTheaterInterface described a host that feeds menu providers and
shortcuts to the navigator, but nothing has ever been that host. It had
a single reference in the repo — its own declaration — and no
implementer, importer or consumer in elements, offshoot, bookreader or
petabox. It was already dead in iaux-item-navigator and came across
verbatim when the component moved out of labs.

Eight of its twelve members appear nowhere in the navigator; the four
that do are properties the navigator declares on itself, sharing only a
name. Its real seam with a theater is the header and main slots plus
the shortcuts event, which stay deliberately untyped because the
navigator is a shell.

An interface nothing implements cannot be checked, so it drifts: this
one carried a modal member until that turned out to be unused too.

Also drops a binding in the slider tests that stopped being read when
those assertions moved to the rendered box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The demo drew every viewable file from a pile of cat items, which
covered images, PDFs and a video but left the navigator's other
theaters unshown. It now runs on Dolly Parton across six real,
public items: two images, a video, an album, a scanned book and a
magazine PDF.

The album and the book are lending items, so they embed as samples
and preview pages rather than the whole work — which is what a
consumer hits in production, and worth having in the demo for that
reason.

Media types grow by two to carry audio and book; both take the
standard embed path, so only PDFs still load their file directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The navigator is a shell that doesn't know what it is hosting, so it
shouldn't decide what loading looks like either. It shipped an animated
book-and-ring glyph and, in fullscreen, an 'Internet Archive' caption —
branding and motion a consumer may already have its own version of, with
no way to replace or suppress it.

The loaded property stays and still gates the reader, so consumers keep
the hook they use today; what changes is that the frame is simply empty
until loaded rather than showing our spinner. A consumer that wants an
indicator can slot its own, which is the same seam the theater already
uses.

This also retires the last inline SVG in the component — it stayed
inline through the mask-image migration because a mask can't animate the
spinning ring on its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iisa
iisa merged commit a950807 into WEBDEV-8458-migrate-item-navigator Aug 27, 2026
1 check passed
@iisa
iisa deleted the WEBDEV-8458-item-nav-open-close-semantics branch August 27, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant