Skip to content

Foldable, resizable desktop sidebar - #279

Merged
BabuPlk merged 9 commits into
devfrom
feature/sidebar-collapse-resize
Sep 29, 2026
Merged

BabuPlk merged 9 commits into
devfrom
feature/sidebar-collapse-resize

Conversation

@BabuPlk

@BabuPlk BabuPlk commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

What

The desktop sidebar can fold to an icon-only rail and be resized, and remembers both per browser.

  • Fold / unfold: click the SprintStart logo, or press Alt+S. A tooltip on hover says "Collapse sidebar" / "Expand sidebar" with the chord. Enter on the resize edge works too.
  • Resize: drag the right edge between 240 and 400px, or focus it and use the arrow keys, Home and End. A double click resets it to 286px.
  • Drag to fold / unfold: dragging the edge well past the minimum folds the sidebar, and dragging the folded rail's edge out opens it again at the width under the pointer. Both snap as the pointer crosses the threshold, not on release.
  • Folded rail: every entry keeps its name for assistive technology and shows it as a tooltip on hover and keyboard focus, together with its count or status. Counts become a bubble on the icon, and section headings become a short rule.
  • Folded footer: a single avatar button. Hovering it slides the full footer card (profile, settings, project switcher, logout) out beside the rail. A click pins the card open until Escape, a click elsewhere or a second click, and navigating also closes it. The card stays mounted while hidden, so ⌘/Ctrl+K and the project modal keep working.
  • Five-click easter egg: still works. A click within 600ms of the previous one only counts towards the egg and does not fold the sidebar.
  • Remembered layout: width and folded state are kept in localStorage, per browser.
  • Unchanged: the mobile drawer.

Refs #244, the desktop collapse the SIDEBAR_TOGGLE_SHORTCUT comment pointed to. Alt+S now toggles whichever sidebar is on screen: the drawer below lg, the fold to icons from lg up. The shortcuts list drops its "small screens" note. Trade-off: on a wide screen the app now takes Alt+S, which is Firefox's History menu on Windows.

Notes

  • Includes a backmerge of dev (keyboard shortcuts, feat(shortcuts): app-wide keyboard shortcuts and the help registry (#243) #276). The conflicts were in SideBar.tsx, ProjectSwitcher.tsx and SideBar.test.tsx. The Settings link keeps dev's shortcut label, and ProjectSwitcher is dev's version (its compact mode is no longer needed).
  • Touches only src/components/layout/*, src/App.tsx, src/styles/index.css, the ProjectSwitcher (via the merge) and tests.

Testing

  • vitest run: full suite green, apart from one layout test that failed on the first run and was fixed in the last commit (the footer card closed on hover-then-click). The layout and a11y SideBar tests pass after the fix (68/68).
  • tsc -b, eslint src tests, prettier --check src tests and vite build all pass.
  • After the Alt+S commit: layout, a11y and shortcuts tests pass (177/177).
  • New or updated tests: Alt+S folds and unfolds on desktop widths without touching the drawer, folding and unfolding from the logo (and not on the egg's quick repeat clicks), opening from the folded edge by keyboard, the folded footer card (opens, shows Logout and Settings, closes on Escape), and axe while folded.

A toggle in the sidebar's header folds it to an icon-only rail and back. Folded, every entry keeps its name for assistive technology and shows it as a tooltip on hover and keyboard focus (with its count or status, if it has one); counts become a bubble on the icon, section headings a short rule, and the footer shows the avatar, settings, the project's monogram and a logout icon.

Open, its right edge can be dragged between 240 and 400px, or moved with the arrow keys, Home and End as a focusable separator; a double click resets it to 286px, and dragging well past the minimum folds it. Width and folded state are kept in localStorage per browser. The mobile drawer is unchanged.
…on for the toggle

The resize edge now stays on the folded rail: dragging it out past 160px opens the sidebar at the width under the pointer, and dragging an open one back past that point folds it. Both happen as the pointer crosses the threshold rather than on release, so the rail snaps open or shut under the mouse. Folded, the right arrow and End open it from the keyboard, Enter toggles, and a double click opens it at the default width.

The toggle button shows a single double chevron that turns round as the sidebar folds and leans the way it would move on hover, instead of the two panel icons.
… on the edge

Folded, the footer is a single avatar button instead of the card squeezed into the rail. Hovering it, or clicking or pressing it, slides the full footer card (profile, settings, project switcher, logout) out beside the rail; it closes when the pointer leaves, on Escape, on a click elsewhere and on navigating. The card stays mounted while hidden, so the project switcher's Cmd/Ctrl+K and modal keep working.

The fold toggle moves out of the header onto the sidebar's edge at the logo's height, so it no longer jumps between two places. Its icon is now a drawn window with a rail that is wide while open and a sliver while folded, and leans towards the other state on hover.
A click on the logo in the desktop sidebar folds or unfolds it, and hovering it shows "Collapse sidebar" or "Expand sidebar" (beside the badge on the folded rail, below it while open). The edge button stays, as the keyboard's way to the same thing.

A click within 600ms of the previous one only counts towards the five-click gravity egg, so the egg drops the badge instead of flapping the sidebar while it falls. The mobile header's logo is unchanged.
The round button on the sidebar's edge doubled the logo, which folds and unfolds the sidebar with its own tooltip, and read as a stray glitch beside it; it and its drawn panel icon go. From the keyboard, Enter on the resize edge still folds and unfolds. The tests fold through the logo instead of the button.
…se-resize

# Conflicts:
#	src/components/layout/SideBar.tsx
#	src/features/projects/components/ProjectSwitcher.tsx
#	tests/unit/components/layout/SideBar.test.tsx
Hovering the avatar opened the card and the click that followed toggled it shut again, so the natural mouse gesture closed what it had just opened. The card now tells a hover-open (follows the pointer) from a click-open (stays until Escape, a click elsewhere or a second click), and a click on a hover-open card pins it. The test looks the Settings link up by prefix, since dev now puts its shortcut in the name.
Alt+S now toggles whichever sidebar is on screen: the drawer below lg, as before, and the fold to icons from lg up (#244), which the chord had been left free for. The drawer's state still never flips on a wide screen. The shortcuts list drops its "small screens" note, and the logo's tooltip shows the chord beside "Collapse/Expand sidebar".
@BabuPlk
BabuPlk requested a review from DavidLeuter September 29, 2026 14:47

@DavidLeuter DavidLeuter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lovely piece of work — the fold/resize/drag-past-the-threshold model is coherent, the CSS-variable-during-drag trick is the right call, and the comments explain the why everywhere I looked. Verified on bf6c2338 in a clean worktree: tsc -b, eslint src tests, prettier --check src tests clean, and the full suite green (394 files / 3366 tests).

Two things to fix before this goes in, both in SidebarNavLink; the rest are notes.

1. The shortcut chip is still rendered in the folded rail

src/components/layout/SidebarNavLink.tsx

{shortcut && (
  <ShortcutHint keys={shortcut} className="border-app-border text-app-text-muted" />
)}

The label, the count and the attention dot all learned about collapsed; this one did not. ShortcutHint is sm:inline-block and only opacity-0, so in the rail it still takes layout width — and it is revealed by group-hover and group-focus-visible, i.e. at exactly the moment the new tooltip appears.

Measured with the real classes at a 76px rail (nav px-[16px], link px-[12px], so 20px of content box): the icon is shrink-0 and stays put, and the chip is laid out at x 86 → 108 inside a nav that clips at 99. It gets squeezed to 22px, wraps to three lines (Al / + / H), and is cut in half by the nav's overflow-x-hidden against the sidebar border. Four to six rows do this, on hover and on keyboard focus.

{shortcut && !collapsed && (
  <ShortcutHint keys={shortcut} className="border-app-border text-app-text-muted" />
)}

The chord is not lost: it stays in the link's own title ("Dashboard (Alt + H)") and in the shortcuts modal.

2. The count's new title is applied in both states

Same file:

<span
  className={collapsed ? "absolute -top-2 -right-2.5 flex items-center" : "ml-auto flex items-center"}
  title={countLabel?.(count)}
>

The className is conditional, the title is not. In the open sidebar this puts a native tooltip on the number that shadows the link's own title whenever the pointer is over it — "3 open escalations" instead of "Escalation Inbox (Alt + …)". The comment above it says this is for the folded rail, so:

title={collapsed ? countLabel?.(count) : undefined}

Notes, not blocking

  • data-sidebar-resizing can be left behind. SidebarResizeHandle sets it on pointerdown and clears it only in endDrag. If the handle unmounts mid-drag (focus mode swapping the wrapper, a route change that tears the shell down, HMR), the attribute stays on <html> and .app-sidebar-eases is transition-less for the rest of the session. A cleanup effect (useEffect(() => () => setDragging(false), [])) or an onLostPointerCapture covers it.
  • The logo's tooltip is only cleared on click. handleLogoClick nulls hintAt because the badge moves when it folds — but Alt+S folds it too. With the pointer resting on the badge, the hint re-renders with the new text at the old position (the badge goes from px-[24px] to centred). Clearing it when collapsed changes would do it.
  • A pinned flyout survives focus mode sliding the sidebar away. The card is portalled to document.body, outside the wrapper App translates by -100%, so folded + pinned + entering focus mode leaves the card floating at left: rail + 10px with no sidebar behind it. Narrow, just noting it.
  • Only the mouse is told that Enter folds. Dropping the edge's fold button is the right call visually, but it leaves the logo as the sole pointer control, and it is (deliberately) unfocusable and unnamed. The separator's aria-label says "Resize sidebar" and its title says "Drag to resize, double-click to reset" — neither mentions the fold, while the mouse tooltip does. Alt+S in the shortcuts modal carries it, so this is discoverability rather than a gap; a few words in the title would close it.
  • The egg guard has a band. TOGGLE_REPEAT_GUARD_MS is 600ms, useRepeatClicks's window is 5 × 1000ms. Five clicks about 700ms apart therefore both flap the sidebar five times and drop the badge. Tiny, and the fast case the comment describes works.

One thing I liked: writing the width straight into the CSS variable during the drag and only handing it back on release, with the state update reserved for the threshold crossing. That is the distinction most implementations of this get wrong.

Happy to approve as soon as 1 and 2 are in.

… loose ends

- The shortcut chip is no longer rendered on the folded rail, where it took layout width and was revealed (squeezed and clipped) on hover and focus; the chord stays in the link's title.
- The count's words are its title only on the folded rail; open, it no longer shadows the link's own title.
- The resize handle clears its drag mark on unmount, so a drag cut short cannot leave the sidebar's easing off.
- The logo's tooltip clears whenever the sidebar folds or unfolds, Alt+S included, not only on its own click.
- The folded footer card closes when focus mode slides the sidebar away.
- The resize edge's title says Enter folds or opens it.
@BabuPlk

BabuPlk commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review! Addressed in add2060:

  1. Shortcut chip on the folded rail — no longer rendered when collapsed; the chord stays in the link's title. Added a test (chip gone after folding, Dashboard (Alt + H) title still there).
  2. Count title — now collapsed ? countLabel?.(count) : undefined, so the open row keeps the link's own title.

From the notes:

  • data-sidebar-resizing left behind — SidebarResizeHandle clears it in an unmount cleanup.
  • Logo tooltip after Alt+S — the hint now clears whenever collapsed changes (adjusted during render), not only on its own click.
  • Pinned flyout in focus mode — the card closes when focus mode toggles, the same way it already closes on a route change.
  • Enter only told to the mouse — the separator's title now says "Enter to collapse" / "Enter to expand".
  • Egg guard band — left as is: closing it fully would mean ignoring a deliberate fold-then-unfold within the egg's 5s window, which felt worse than the rare double effect at ~700ms spacing.

Layout, a11y and shortcuts tests: 178/178, tsc -b clean.

@BabuPlk
BabuPlk requested a review from DavidLeuter September 29, 2026 16:19

@DavidLeuter DavidLeuter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All six addressed in add20608, and the two blocking ones exactly as suggested. Approving.

  • Shortcut chip — gated on !collapsed, with a test that checks the chip both before and after folding and confirms the chord survives in the link's own title. That is the assertion I would have written.
  • Count title — collapsed ? countLabel?.(count) : undefined; the link's title is no longer shadowed in the open sidebar.
  • data-sidebar-resizing — cleared on unmount.
  • Logo tooltip — the render-phase reset on collapsed covers all three ways the sidebar folds, not just the badge's own click. The explicit setHintAt(null) in handleLogoClick is still doing work on the egg's quick-repeat path, where collapsed does not change, so it is right that it stayed.
  • Flyout in focus mode — keying the reset on `${pathname}|${isFocused}` is neater than the separate piece of state I had in mind.
  • Enter on the edge — now in the title.

Nothing new opened up: useFocusMode returns an inert context outside its provider, which the sidebar tests rely on since they render SideBar without FocusModeProvider, and the render-phase state adjustments follow the pattern CODING_STANDARDS.md §3 names.

Verified on add20608 in a clean worktree: tsc -b, eslint src tests and prettier --check src tests clean. The full suite came back 3366 passed / 1 failed, but the failure is tests/unit/features/chatbot/useChat.test.tsx:490 — a waitFor on a streamed SSE chunk, in a run that took 1346s against 591s for my earlier one because I had a lint job competing with it for CPU. On its own the file is 25/25 in 10s, the branch touches no chat code, and CI's npm run unit is green on this commit. Not yours.

One residual on the flyout, for a follow-up at most: what is fixed is entering and leaving focus mode. Already inside it, hovering the sidebar out, clicking the card to pin it and then moving away still leaves it floating for a moment — closeSoon() is deliberately a no-op while pinned, and Escape or a click elsewhere clears it. Focus mode plus folded plus pinned is narrow enough that I would leave it.

Nice feature. The drag model in particular — width straight into the CSS variable, state reserved for the threshold crossing — is the part most implementations of this get wrong.

@BabuPlk
BabuPlk merged commit c6212bb into dev Sep 29, 2026
4 checks passed
@BabuPlk
BabuPlk deleted the feature/sidebar-collapse-resize branch October 1, 2026 12:37
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.

2 participants