Skip to content

[Proposal] Centralize sidebar disclosure state and isolate hidden navigation #41

Description

@imbajin

Note

Background

OINK already renders a usable server-side sidebar and provides drawer focus trapping, focus return, desktop collapse persistence, breakpoint cleanup, and a visible restore control. Issue #24 fixed cached markup and no-JavaScript visibility; this proposal concerns runtime state after that markup is available.

Problem

Apache HugeGraph encountered this gap while migrating its documentation site from Docsy to OINK. Its bilingual, versioned documentation needed reader-selected tree disclosures to survive navigation, but OINK exposes no public setter or committed-state notification.

Inside OINK, multiple private code paths write the same disclosure state independently. Closed or collapsed navigation is moved or faded off-screen but is not explicitly removed from the focus and accessibility trees.

This proposal routes every disclosure write through one OINK-owned controller and isolates hidden navigation while preserving current shell behavior. Persistence remains optional and downstream-owned.

Before and after architecture: scattered private sidebar writes become one committed disclosure contract, with hidden navigation explicitly isolated

Confirmed behavior on current main

Verified against main@93ac292. The table below separates existing behavior from confirmed gaps.

Concern Current implementation Confirmed gap
User disclosure initTreeToggles() contains a private setExpanded() closure. No public setter, getter, or committed-state event.
Cached active path sidebar-nav.js writes aria-expanded, the localized label, and td-is-open directly. This write cannot be observed through an OINK contract.
Responsive aside relocation setGroups() independently writes the same disclosure fields. A click-only wrapper misses programmatic transitions.
Downstream persistence A consumer can only add another click listener and defer a read until OINK's private handler has run. Behavior depends on listener/script order and private DOM attributes.
Mobile closed state The panel is translated off-screen with CSS. Hidden links and controls are not explicitly inert or aria-hidden.
Desktop collapsed state The panel is translated to a 16px hover target and faded to zero opacity. Invisible navigation remains potentially focusable; isolation must not disable the intentional hover target.

The design gap is limited to OINK's supported runtime surface: a consumer that reasonably needs committed disclosure state has no public setter or notification, and closed/collapsed navigation has no OINK-owned interaction isolation. A particular downstream workaround or custom visual treatment is not evidence of an upstream defect.

Existing behavior to preserve

This is not a sidebar redesign. The implementation must retain the following current OINK behavior:

  • The drawer stores its opener, traps Tab while open, restores focus on close, and closes at the desktop breakpoint: initDrawer().
  • Desktop collapse remains persisted and retains the deliberate 16px pointer target and hover overlay: initCollapse().
  • Scroll locking continues to derive from the drawer/search root state, so removing the drawer-open attribute releases it: _tokens.scss.
  • The desktop floating restore control remains available after explicit collapse: floating-pill.html.
  • Server-rendered authored state and the visible, navigable no-JavaScript fallback from #24 remain authoritative before runtime enhancement.

1. Centralize disclosure ownership

All internal and public disclosure transitions should use one controller. The exact namespace is open for discussion; a concrete minimum shape is:

OinkSidebar.setExpanded(controlledRegionID, expanded, {
  source: "user" | "active-path" | "responsive" | "api"
});

OinkSidebar.getState(controlledRegionID);

document.addEventListener("oink:sidebar-disclosure", event => {
  // event.detail = {
  //   id: controlledRegionID,
  //   expanded: true,
  //   source: "user"
  // }
});

controlledRegionID is the target named by the toggle's aria-controls, not a title or position-dependent index. The controller must scope lookup to the sidebar, reject or report unknown IDs predictably, and make repeated writes of the current value a no-op.

One atomic commit updates the toggle's aria-expanded, the controlled region's td-is-open class, and the localized expand/collapse label. The event fires exactly once, after all three values agree, and only when committed state changes.

The user-click handler, cached active-path hydration, responsive aside relocation, and public setter must all call this controller. A wrapper that consumes the event must never need a MutationObserver, a second click handler, or a deferred DOM read.

2. Keep persistence optional and downstream-owned

The contract should make persistence reliable without requiring OINK to own a version/locale storage schema. A downstream site may listen for committed user disclosures, store valid controlled-region IDs under its own context key, then restore them through setExpanded().

Active-path expansion remains authoritative, unknown/stale IDs are ignored, and unavailable storage falls back to server-rendered authored state. These are integration acceptance requirements, not a mandatory OINK localStorage implementation.

If script ordering cannot make restoration deterministic, expose a late-consumer-safe readiness primitive such as OinkSidebar.ready or a persistent ready marker plus event. A fire-once event without a readable marker is insufficient because a consumer loaded later can miss it.

3. Isolate hidden navigation

Disclosure state and whole-sidebar visibility are separate state machines, but OINK owns enough information to keep them consistent. Hidden navigation must not remain keyboard-focusable or exposed to assistive technology.

Surface state Required isolation behavior
Desktop expanded Navigation content is active and exposed.
Desktop collapsed Navigation content is inert and aria-hidden; the external restore control and deliberate 16px hover sensor remain operable.
Desktop hover overlay Remove isolation before revealing the panel or moving focus inside; reapply it after the overlay closes.
Mobile drawer closed The off-screen panel is inert and aria-hidden.
Mobile drawer open Remove isolation before focusing the close button; on close, restore focus outside the panel before reapplying isolation.
Breakpoint/navigation cleanup Close transient surfaces and release the root scroll lock without leaving stale isolation attributes.

Do not put inert on an element that also owns the collapsed desktop pointer-enter sensor; isolate the navigation content or use a separate non-inert edge trigger so the existing hover-overlay behavior remains reachable.

Acceptance tests

  1. Pointer and keyboard disclosure update aria-expanded, td-is-open, and the localized label atomically, then emit exactly one final-state event.
  2. Writing the current state again emits no event; invalid IDs cannot mutate content outside the sidebar.
  3. User click, active-path hydration, responsive relocation, and public API calls all pass through the same controller and report the correct source.
  4. A downstream persistence fixture can restore version/locale-scoped IDs through the public setter; active path wins, stale IDs are ignored, and blocked storage keeps authored state.
  5. Mobile closed and desktop collapsed navigation have no focusable or accessibility-visible descendants, while the desktop restore control and hover sensor remain usable.
  6. Opening removes isolation before focus enters; closing restores focus outside before isolation returns.
  7. Drawer focus trap, Escape/backdrop close, breakpoint cleanup, scroll unlock, desktop hover overlay, and floating restore behavior do not regress.
  8. Duplicate initialization or downstream event consumption does not create duplicate listeners, oscillating state, or repeated events.
  9. With JavaScript disabled, the server-rendered sidebar remains visible and navigable exactly as required by #24.

Non-goals

  • Do not introduce a second sidebar tree or replace server-rendered authored state.
  • Do not require OINK to understand a downstream site's release, version, locale, or storage migration policy.
  • Do not remove the desktop hover overlay, drawer focus trap, focus return, scroll locking, or floating restore control.
  • Do not make JavaScript necessary for basic sidebar visibility or navigation.
  • Do not reopen the cache-key and no-JavaScript rendering problem resolved by #24.

Downstream compatibility

Downstream sites can retain their current wrappers until this contract is available in a tagged OINK release. Upstream acceptance therefore does not block downstream deployment; after adoption, post-click persistence listeners and root-attribute MutationObserver wrappers can be removed in favor of the public committed-state contract.

Open design decisions

  • Should the API live on OinkSidebar, or should disclosure and surface state use separate namespaces?
  • Should an unknown controlled-region ID throw, return false, or produce a development warning?
  • Is OinkSidebar.ready needed, or can registration/restoration be deterministic through bundle order and a persistent ready marker?
  • Should surface open/close transitions publish a separate oink:sidebar-surface event, or remain internal while only disclosure state is public?

Activity

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

Metadata

Metadata

Assignees

No one assigned

    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