Skip to content

Starlight mobile menu leaves focusable button aria-expanded stale #160

Closed

Description

@aitrawog

Expected behavior

The mobile menu's focusable button should expose aria-expanded="true" when its controlled sidebar opens, and false when it closes.

Observed behavior

During docs PR #157 browser verification with @astrojs/starlight 0.41.11, clicking the default mobile menu opens the sidebar and makes .main-frame inert. The outer <starlight-menu-button> changes to aria-expanded="true"; its child <button aria-label="Menu" aria-controls="starlight__sidebar"> retains aria-expanded="false".

The visible menu operates, but assistive technology querying the focused button receives the wrong expanded state. This is independent of PR #157's Hero and SiteTitle overrides; that PR does not override Header, MobileMenuToggle or Sidebar.

Root-cause analysis

  • Mechanism: upstream components/MobileMenuToggle.astro initializes aria-expanded="false" on button. StarlightMenuButton.setExpanded() calls this.setAttribute('aria-expanded', String(expanded)) on the custom element, not this.btn. CSS reads the wrapper state, so visual checks pass while the accessible button stays stale.
  • Missed control: default-component behavior was trusted; build and visual smoke tests do not compare the focusable button's accessibility state with the rendered sidebar.
  • Blast radius: mobile menu screen-reader state on every docs route using this Starlight version. Search, theme, menu focus trap and branding are separate.
  • Root fix: correct the upstream component to update the actual button state alongside its wrapper/CSS state; adopt the fixed release and add a behavioral assertion that the focused button's expanded state tracks the menu open/close cycle. Avoid copying the whole upstream component into this site.

Evidence and classification

Installed upstream source: node_modules/@astrojs/starlight/components/MobileMenuToggle.astro in the PR #157 test worktree. Chromium 154 CDP check observed wrapper expanded: true, main inert: true, sidebar visibility: visible, while the inner button still reported false.

Separable, non-blocking finding: the changed branding uses the unchanged default menu. Track the upstream correction and dependency adoption here; PR #157 will list this issue under Filed issues.

Activity

  1. aitrawog commented on Oct 16, 2026

    @aitrawog
    ContributorAuthor

    STATUS — the fix is upstream's, the adoption is docs#161, and the defect's mechanism is gone from the built output.

    Agent: dsh-opencharly

    What the issue asked for, and what the measurement found

    The issue's own root fix was: "correct the upstream component … adopt the fixed release and add a behavioural [test]". I checked whether the fixed release already exists, and it does — as a REWRITE rather than a patch:

    # the published package, 0.42.6, packages/@astrojs/starlight/components/MobileMenuToggle.astro:5
    ** <button popovertarget="starlight__sidebar" class="sl-menu-button sl-flex md:sl-hidden print:hidden"> **
    and: ** no `aria-expanded` attribute anywhere in the file **
    

    In 0.41.x the button carried aria-expanded="false" and StarlightMenuButton.setExpanded() updated the wrapper — the mechanism the issue's RCA names. In 0.42.x the button is a native popover invoker, so the browser owns the expanded state and nothing manual can go stale. The defect CLASS is removed, not patched.

    And the pin was excluding it: docs/package.json had ^0.41.6, and for a 0.x version a caret pins the MINOR — so 0.42.x was never installable from that range. The bump is what makes the fix reachable.

    What landed

    opencharly/docs#161 — "fix(deps): bump @astrojs/starlight to 0.42.6, which removes the stale mobile-menu aria-expanded"

    package.json        (+1/-1)     "^0.41.6" -> "^0.42.6"
    package-lock.json   (+617/-1927)  resolved to 0.42.6 by npm install --package-lock-only
    

    Gates, on the bumped tree — the same npm ci + production build the docs-site candy runs, which is this surface's own gate and the failure mode its skill names:

    $ npm ci --no-audit --no-fund        added 301 packages in 4s              rc=0
    $ npm run build                      [starlight:pagefind] Found 1503 HTML files.
                                         [build] ** 1503 page(s) built in 1m 2s **   Complete!   rc=0
    

    And the emitted artefact, verified at its actual boundary rather than in the source:

    $ grep -o '<button[^>]*sl-menu-button[^>]*>' dist/index.html
      <button popovertarget="starlight__sidebar" class="sl-menu-button …">
    $ grep -c 'aria-expanded' dist/index.html                     ->  ** 0 **
    $ grep -c 'popovertarget="starlight__sidebar"' dist/index.html ->  ** 1 **
    

    One nuance about the acceptance criterion, stated rather than glossed

    The issue words the desired behaviour as aria-expanded="true"/"false". The fixed release satisfies the intent — the focused button no longer reports a stale expanded state — by different means: the attribute is absent by design, because a native popover invoker's state comes from the popover itself (:popover-open), which is what assistive technology reads. So a behavioural test for this should assert "the button is a popover invoker and no stale aria-expanded is emitted" — which is what the greps above check — rather than "aria-expanded flips to true on click". Recording the difference so the assertion written later matches the fixed behaviour instead of the broken one's shape.

    The consumer step, named

    docs-site (the candy in charly) pins DOCS_REF to an immutable docs commit, and the pin is TWO occurrences — DOCS_REF and the literal in the docs-site-pinned-commit check's contains: matcher. task docs:pin fails until both name this repo's new main head, so the sequence is: docs#161 merges → charly re-pins both → the check-docs bed proves the bumped site. That step cannot begin until this lands, which is why it is named here rather than promised.

    Nothing further from me on this thread until the PR has a verdict.

    Agent: dsh-opencharly
    Assisted-by: dsh deepseek-v4.1-flash (fully tested and validated)

  2. self-assigned this
    on Oct 16, 2026
  3. aitrawog commented on Oct 26, 2026

    @aitrawog
    ContributorAuthor

    STATUS — #160 — slug umbrella-prose-truth — this issue is closed by #187, which carries the fix the same dependency update brings.

    The mechanism, and where it ends. The mobile-menu button carried aria-expanded="false", and StarlightMenuButton.setExpanded() updated the wrapper rather than the button, so assistive technology read a stale expanded state on every docs route. The 0.42 line rewrites that button onto the native Popover API (popovertarget="starlight__sidebar"), so the browser owns the expanded state and it cannot fall out of sync — the manual attribute is gone rather than kept in step.

    The proof, on the successor's tree: the built site carries the popover invoker on 1698 pages and aria-expanded on none; the base build, built with the same commands, carries aria-expanded="false" on 1698 pages and the invoker on none. The greps are pasted in the successor's body.

    The other issue this successor closes: #159 (GHSA-rj75-hqrm-r3gf), fixed through the upstream release that issue's acceptance asks for.

    It supersedes #161, whose branch is conflicting against main. This issue is closed when the successor lands.

    Agent: umbrella-prose-truth
    Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)

  4. aitrawog commented on Oct 26, 2026

    @aitrawog
    ContributorAuthor

    RESOLVED — #160 — the stale aria-expanded is gone from the built output, and the fix is landed.

    What landed. #187 — squash b5cb81050283e0c130029ee8abd73f74ca572351, tag v2026.299.0712, changelog CHANGELOG/2026.299.0712.md. The Starlight 0.42 line rewrites the mobile-menu button onto the native Popover API (popovertarget="starlight__sidebar") and drops the manual aria-expanded attribute, so the browser owns the expanded state and it cannot fall out of sync with the wrapper.

    The evidence, in the artefact that is actually shipped. The built site of the landed tree carries the popover invoker on 1698 pages and aria-expanded on none; the base build carries aria-expanded="false" on 1698 pages and the invoker on none. The <starlight-menu-button> custom element and its toggleExpanded() script are gone from all 1698 pages, and <sl-sidebar-pane popover> is on all 1698. The 1699th page is 404.html, which carries no sidebar.

    The mechanism this issue named is gone. StarlightMenuButton.setExpanded() updated the wrapper and not the button; the manual attribute is now not maintained by anything, because nothing maintains it — the popover's expanded state belongs to the browser.

    Why this issue stayed open past the merge. The merge was performed by github-actions[bot], and a bot merge does not close a Closes #N line — so the issue is closed here, by hand, on the merged commit.

    What is still open. The dev tree's undici advisories through wrangler → miniflare — #163, claimed and in flight.

    Agent: umbrella-prose-truth

    Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions