Skip to content

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

Open
iisa wants to merge 1 commit into
WEBDEV-8458-migrate-item-navigatorfrom
WEBDEV-8458-item-nav-open-close-semantics
Open

WEBDEV-8458 Drive the item navigator's panels from their own state#70
iisa wants to merge 1 commit 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 the change reviews on its own.

Why

@jbuckner asked whether the 350ms setTimeout guarding a scroll could be more deterministic. Looking into it, the timer was a symptom: the panel's open/close wasn't modelled as state, so the code worked around the animation instead of the behaviour that caused it.

Two things were true underneath:

The state was in two places. 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 and the navigator then wrote its value back over the slider's — they agreed only because the two toggle expressions happened to be equivalent. slider.open was set as a static attribute, so Lit wrote it once and it stayed true forever; animateMenuOpen was never set by anything, so the slider's .animate rule never applied. Closing the drawer left the panel open underneath it.

The semantics didn't describe any of it. aria-expanded="false" was a hardcoded string on the toggle and every shortcut — announcing "collapsed" while open. The drawer was an unlabelled div. The closed panel was only translated off-canvas, so its close button stayed in the tab order and the accessibility tree while invisible. Both close buttons were named "Close this menu" but closed different things. Opening from the shortcut rail hid the focused button in the same frame and dropped focus to <body>.

What changed

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

With the state in one place it can be stated in the markup: aria-expanded reflects what's actually open, 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.

Focus follows the same rule — opening a surface moves focus into it, closing returns focus to whatever opened it. Closing the drawer now also closes the panel inside it.

The scroll follows it too: it runs when the file list or the selection changes. That also fixes two gaps the timer was hiding — bookreader reuses one panel element, so firstUpdated only ever fired on the first open, and offshoot reuses the element on a file click, so the active row could move without the panel following it.

Menu buttons now carry an aria-label, so their accessible name doesn't depend on --item-navigator-menu-button-label-display being set. Worth knowing for the migration: both consumers currently set the upstream name for that variable (--menuButtonLabelDisplay), which our port renamed — so the labels would otherwise have quietly disappeared. Tracked in WEBDEV-8856/8857.

Verifying

The useful check is running it with the animation switched off — if the behaviour is identical at --item-navigator-animation-timing: 0ms, nothing is timing against it. It is:

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 is not focusable. The rail is a real ul/li with role="list". No console errors.

279 tests pass with 100% coverage on the component; build and lint clean. Tests that pinned the removed state were rewritten rather than dropped — the slider's tests now assert that it reports selections instead of deciding them, and the two scroll tests that slept 400ms each and only asserted "didn't throw" now mount the panel in a real scrolling box and check the active row is actually in view.

Worth a second opinion

  • Overlay mode isn't modal. Below 600px the drawer covers the theater, but there's no focus trap and nothing behind it is inert. I've left it non-modal and given the drawer role="group" rather than dialog, so it doesn't claim to be something it isn't — happy to make it genuinely modal instead if you'd prefer.
  • aria-controls only where it resolves. IDREFs can't cross shadow roots, so it's on the toggle (same root as #menu) but not on the shortcuts or menu buttons, which live in different roots from the panel. Better than shipping references that point at nothing.
  • A host can still set a channel while the drawer is closed via manageSideMenuEvents('toggle'). That's an explicit instruction rather than a user action, so I left it — and its test still passes.

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

🚀 View preview at
https://internetarchive.github.io/elements/pr/pr-70/

Built to branch ghpages at 2026-08-06 01:07 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

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