Skip to content

Toggleable Navigation

Status: Design — authoritative for Phase 7 (CourseSidebar implementation).

This document describes the collapsible sidebar navigation used in the LMS course-entry view. It owns the toggle behaviour, layout architecture, responsive rules, accessibility requirements, and animation approach. Entry gating logic and completion semantics are out of scope here; see entry-pagination.md §Sidebar and §Sequential Gating Policy.

Overview

The /learn/course/\[slug\]/entry/\[entrySlug\] layout renders a two-panel surface: a collapsible CourseSidebar on the left and the entry body on the right. The sidebar lists all entries in the course, grouped visually by module, and reflects each entry's completion state for the current learner. Collapsing the sidebar maximises reading width.

This document and entry-pagination.md divide responsibility as follows:

Concern Owner
Toggle behaviour, layout, animation, responsive rules This document
Entry gating (locked / available / complete) entry-pagination.md §Sequential Gating Policy
Locked-entry UX (toast, deep-link redirect) entry-pagination.md §Sequential Gating Policy — Locked-Entry UX
CourseSidebar component test IDs Both — this doc adds module toggle/link IDs; entry-pagination.md owns course-sidebar, entry-row-<slug>
Project theme (sidebar colours) course-theme.md

Toggle State Management

  • Rail open/closed: shadcn SidebarProvider in CourseShellLayout, persisted via the sidebar_state cookie (same as admin).
  • Module/folder expansion: versioned Zustand store at src/lib/store/sidebar.ts (lms:course-sidebar:v3) — keys expandedModules and expandedFolders only.

Default rail: expanded on desktop. Module expansion defaults to []; the entry layout expands the module containing the current entry on mount via expandModule.

SidebarTrigger (data-testid="course-sidebar-toggle") lives in the sidebar header and remains visible in icon-collapsed mode. Its aria-expanded mirrors SidebarProvider open state.

Do not use react-resizable-panels for this layout. Per AGENTS.md, the library is unreliable when combined with Zustand hydration, and its v4.6 API (orientation, panelRef, PanelImperativeHandle) is sufficiently unstable to avoid.

Layout Architecture

Admin and course-entry shells use shadcn sidebar with layout="flow" on SidebarProvider so the sidebar column sits in document flow below data-testid="site-header" (not viewport-fixed). Entry content renders in SidebarInset (data-slot="sidebar-inset", data-testid="entry-main").

Horizontal alignment: On admin and course-entry routes, MainSiteShell selects the full-width SiteShell variant (w-full, px-0 — no outer horizontal gutter) via isAppChromePath. The sidebar sits flush with the viewport left edge; horizontal padding for main content lives on SidebarInset and inner wrappers (p-6 / px-6). The sidebar and inset form a single flex-row row (justify-start): nav column on the left, detail column immediately to its right. Marketing and catalogue pages keep the centered 90% shell with px-6.

<SidebarProvider
  layout="flow"
  mobileBehavior="iconRail"
  collapseOnMobile
  className="w-full flex-1"
  style={{ "--sidebar-width-icon": "2.5rem" }}
>
  <CourseSidebar /> {/* shadcn Sidebar, collapsible="icon" */}
  <SidebarInset>...</SidebarInset>
</SidebarProvider>

Flow desktop layout uses an in-flow sidebar column (no viewport-fixed gap overlay). Icon-rail width transitions apply on the column itself. The course-entry shell overrides --sidebar-width-icon to narrow the collapsed rail without changing shared CMS/admin defaults. Mobile uses the same always-visible icon rail (mobileBehavior="iconRail"), not a Sheet drawer. The whole shell scrolls with the page — the sidebar does not overlap the site header.

When the rail is collapsed, [data-sidebar="content"] is hidden (display: none) while the header and toggle remain visible in the icon rail.

Responsive Behaviour

Breakpoint Default sidebar state
< md (< 768 px) Closed
≥ md (≥ 768 px) Open

The responsive default is configured on SidebarProvider via collapseOnMobile, so feature sidebars do not duplicate viewport effects:

<SidebarProvider layout="flow" mobileBehavior="iconRail" collapseOnMobile>
  {/* Sidebar + inset */}
</SidebarProvider>

Entry States in the Sidebar

Each entry row reflects one of three states (computed by getEntryAccess(...) in src/lib/course/gating.ts; see entry-pagination.md §Sequential Gating Policy):

State Visual treatment Interaction
complete Checkmark icon; full opacity Clickable; navigates to entry
available Active style; current entry highlighted Clickable; navigates to entry
locked Lock icon; opacity-50; aria-disabled="true" Focusable; click/Enter/Space triggers sonner toast with key entry.lock.toast; does not navigate

The active (current) entry row receives aria-current="page" regardless of its completion state.

In admin preview mode (?preview=1) all entries are available and the lock state is never shown. See entry-pagination.md §Admin Preview-as-Student Mode.

Keyboard Accessibility

All interactive elements must be operable by keyboard and must meet WCAG 2.1 AA. Specific requirements:

Toggle button

  • aria-expanded={sidebarOpen} — reflects current state.
  • aria-controls="course-sidebar" — points to the sidebar element by ID.
  • aria-label sourced from translation key sidebar.toggle.label — never a bare icon.
  • id="course-sidebar" — required target for aria-controls above.
  • role="navigation" — landmark so screen-reader users can jump to it.
  • aria-label sourced from translation key sidebar.nav.label.

Module rows

Each module with child pages uses a split control:

  • Chevron buttontoggleModule(slug); aria-expanded; labels from courseSidebar.moduleExpand / courseSidebar.moduleCollapse.
  • Gate icon — same icons as EntryRow: checkmark (complete), lock (locked), FileText / HelpCircle (available, by entry _type). current uses highlight only (no checkmark).
  • Module title — when the module has an overview entry (mapHierarchyToCourse prepends an entry whose slug matches the module slug), the title is a Link to /learn/course/[courseSlug]/entry/[moduleSlug], with gating matching EntryRow (locked → non-navigating span with aria-disabled). The overview entry is omitted from the nested entry list to avoid duplication.

Modules without an overview entry still show the chevron row with a rollup gate state: complete when every entry in the module is complete, locked when any child is locked, otherwise available. The title is not a link (no overview page).

Modules without children (single leaf at module level) have no overview link; learners use the lone EntryRow only.

Folder rows

Non-leaf folder nodes under a module (e.g. advanced with child pages) use the same split control pattern as modules, nested under the expanded module list: chevron, gate icon (matching EntryRow), and title link to the folder overview entry. Folder overview entries are emitted by mapHierarchyToCourse and resolved by getEntry at /learn/course/[courseSlug]/entry/[folderSlug]. Nested folders recurse with additional indent. Folder expand state is persisted in expandedFolders (moduleSlug/folder/... keys).

Entry rows

  • Active row: aria-current="page".
  • Locked rows: aria-disabled="true", tabindex="0" (focusable but not a navigation target), visually-hidden text sourced from entry.lock.lockedLabel ("Locked. Complete previous entries to unlock."). Activation handlers (onClick, onKeyDown) check aria-disabled and short-circuit to show the toast instead of navigating.
  • Available/complete rows: standard anchor or button semantics; no aria-disabled.

Keyboard shortcut to toggle

Use the shared provider shortcut: Cmd+B (macOS) / Ctrl+B (Windows/Linux). Course and admin sidebars intentionally rely on the same provider-level behavior.

Animation & Transition

shadcn sidebar width transitions on sidebar-gap / sidebar-container (duration-200 ease-linear). No Framer Motion (not a project dependency).

Avoid transition-all on entry rows — completion state updates should not animate unrelated properties.

Module Grouping

Entries are grouped under their module's title. The overview entry (when present) is first in the flattened sequence and in module.entries, but only child entries (slug ≠ module slug) appear under the expanded list. Prev/next and gating use the full flattened list including the overview.

Each module group carries data-testid="sidebar-module-<moduleSlug>". Chevron and title link use sidebar-module-toggle-<slug> and sidebar-module-link-<slug>. Folder groups use sidebar-folder-<moduleSlug>-<folderSlug>, sidebar-folder-toggle-..., and sidebar-folder-link-....

Visual hierarchy

The sidebar renders CMS nesting as a collapsible tree. Data comes from flat module.entries plus per-entry breadcrumb arrays; buildModuleSidebarTree in src/lib/course/sidebar-tree.ts groups siblings and nests folder overview rows.

Tree guides

Nested lists use shadcn SidebarMenuSub, which applies a left border (border-l) and inset padding by default. Do not override with border-l-0 on course-entry submenus — the border is the vertical guide between siblings at each level. Deeper folders nest another SidebarMenuSub under an expanded folder row so guides stack with depth.

Depth and sibling alignment

Indentation is computed in sidebar-tree.ts and applied consistently on EntryRow and SidebarNavRow:

Row kind Depth formula Example (module welcome)
Module header 0 (no extra padding) Welcome
Leaf or folder directly under module breadcrumb.length or folderPath.length1 Intro, folder Advanced
Leaf under one folder breadcrumb.length2 Topic 11 (breadcrumb: [welcome, advanced])
  • Siblings at the same level share the same depth (e.g. module-level leaf intro and folder header advanced both use depth 1).
  • Children under an expanded folder use depth +1 (e.g. t11 under advanced uses depth 2).
  • Left padding per depth (pl-2, pl-4, … via sidebarDepthPaddingClass in sidebar-tree.ts). Rows use pr-* for right inset so tailwind-merge does not drop depth padding when merging with horizontal padding utilities. Depth is clamped only for layout safety at very deep CMS trees, not as a product limit.
  • Rows expose data-depth for tests; folder/module wrappers use aria-level (depth + 1).

The logical Course → Module → Entry model in hierarchy-structure.md describes authoring and prev/next flattening. CMS folders may nest deeper than three levels; the sidebar follows full breadcrumb depth while prev/next still uses the flattened _order sequence.

Current Entry Highlight

The entry row whose slug matches params.entrySlug (from the Next.js route) receives:

  • aria-current="page" — informs screen readers this is the current page.
  • An active visual style: a left border accent (border-l-2 border-primary) and a background tint (bg-muted/50).

On mount, when the sidebar is open, the active row is scrolled into view:

activeRowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });

Components & Test IDs

Per the AGENTS.md data-testid convention, every custom component and interactive sub-element exposes a stable selector.

Component / element data-testid Notes
CourseSidebar course-sidebar aria-label from courseSidebar.ariaLabel
Sidebar toggle (SidebarTrigger) course-sidebar-toggle aria-expanded for rail open/closed
Module group sidebar-module-<moduleSlug> <li> wrapper per module
Module expand toggle sidebar-module-toggle-<slug> Chevron only; does not navigate
Module title link sidebar-module-link-<slug> Overview link or locked control
Folder group sidebar-folder-<moduleSlug>-<folderSlug> Nested <li> under module
Folder expand toggle sidebar-folder-toggle-<module>-<folder> Chevron only
Folder title link sidebar-folder-link-<module>-<folder> Folder overview link or locked control
Entry row (any state) entry-row-<entrySlug> aria-current, aria-disabled as applicable

The course-sidebar and entry-row-<entrySlug> test IDs are also listed in entry-pagination.md §Components & Test IDs — both tables must stay in sync.

CourseSidebar ships with a co-located course-sidebar.test.tsx covering: chevron expand/collapse, module and folder overview links, locked-title toast, nested folder auto-expand, and light/dark theme variants. Locked activation is shared via src/lib/course/locked-entry-activation.ts (entry.lock.toast).

Internationalisation

All user-visible strings are translation-keyed. Required keys — add to all six locale files (en/es/fr/de/pt/zh) in the same change that introduces the component (a missing key fails the build):

  • courseSidebar.expand / courseSidebar.collapse — rail toggle button labels.
  • courseSidebar.ariaLabel<aside> landmark label ({course} interpolation).
  • courseSidebar.moduleExpand / courseSidebar.moduleCollapse — chevron button labels ({module}).
  • courseSidebar.moduleLinkLabel — overview link accessible name ({module}).
  • entry.lock.lockedLabel — visually hidden text on locked rows ("Locked. Complete previous entries to unlock."). This key is also required by entry-pagination.md §Internationalisation; do not duplicate it.
  • entry-pagination.md — entry gating, completion semantics, locked-entry UX, CourseSidebar in §Sidebar, entry-row-<slug> test ID, shared i18n keys.
  • hierarchy-structure.md — Course / Module / Entry shape that drives sidebar grouping and module ordering.
  • user-management.md — preview-mode bypass; sidebar lock states do not apply when getEntryAccess(...) returns { mode: 'preview' }.
  • implementation.md — Phase 7 (binding implementation phase for entry pagination and CourseSidebar).
  • AGENTS.md — Zustand persist hydration warning; react-resizable-panels instability warning.