Add collapse/expand for the DM and Room sidebar sections (#62)

Each section header is now a button with a disclosure chevron;
collapsed/expanded state persists per section in localStorage (same
pattern as the existing sidebar-width preference) and the two sections
toggle independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 18:41:22 -06:00
co-authored by Claude Sonnet 5
parent 66e9c80422
commit 51d0092bd3
2 changed files with 128 additions and 21 deletions
+23
View File
@@ -99,12 +99,35 @@
} }
.sidebar-section-label { .sidebar-section-label {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 16px 6px; padding: 10px 16px 6px;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--ds-muted); color: var(--ds-muted);
text-transform: uppercase; text-transform: uppercase;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.sidebar-section-label:hover {
color: var(--ds-text);
}
.sidebar-section-chevron {
flex: none;
/* Points down (expanded) by default -- rotated to point right when the
section is collapsed, the standard disclosure-triangle convention. */
transition: transform 0.15s ease;
}
.sidebar-section-chevron-collapsed {
transform: rotate(-90deg);
} }
.sidebar-offline-note { .sidebar-offline-note {
+86 -2
View File
@@ -1,8 +1,64 @@
import { useState } from 'react'
import { useResizableWidth } from '../hooks/useResizableWidth' import { useResizableWidth } from '../hooks/useResizableWidth'
import type { MyRoomItem } from '../types' import type { MyRoomItem } from '../types'
import { RoomRow } from './RoomRow' import { RoomRow } from './RoomRow'
import './Sidebar.css' import './Sidebar.css'
// #62: which of the two sections (keyed 'dm'/'rooms') are collapsed --
// persisted the same way sidebar-width already is (see useResizableWidth),
// a per-viewer cosmetic preference with no reason to live server-side.
const COLLAPSED_SECTIONS_KEY = 'sidebar-collapsed-sections'
function loadCollapsedSections(): Set<string> {
try {
const raw = localStorage.getItem(COLLAPSED_SECTIONS_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.filter((s) => typeof s === 'string')) : new Set()
} catch {
return new Set()
}
}
function saveCollapsedSections(sections: Set<string>): void {
try {
localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify([...sections]))
} catch {
// storage unavailable (private browsing, quota) -- collapse state just
// won't persist this session, not fatal.
}
}
interface SidebarSectionHeaderProps {
label: string
collapsed: boolean
onToggle: () => void
}
function SidebarSectionHeader({ label, collapsed, onToggle }: SidebarSectionHeaderProps) {
return (
<button type="button" className="sidebar-section-label" onClick={onToggle} aria-expanded={!collapsed}>
<svg
className={`sidebar-section-chevron${collapsed ? ' sidebar-section-chevron-collapsed' : ''}`}
width="10"
height="10"
viewBox="0 0 10 10"
aria-hidden="true"
>
<path
d="M2 3.5 5 7 8 3.5"
stroke="currentColor"
strokeWidth="1.4"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{label}
</button>
)
}
interface SidebarProps { interface SidebarProps {
rooms: MyRoomItem[] rooms: MyRoomItem[]
activeRoomId: string | undefined activeRoomId: string | undefined
@@ -24,6 +80,18 @@ export function Sidebar({
onOpenPeople, onOpenPeople,
unavailableOffline, unavailableOffline,
}: SidebarProps) { }: SidebarProps) {
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(loadCollapsedSections)
function toggleSection(key: string) {
setCollapsedSections((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
saveCollapsedSections(next)
return next
})
}
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
// A DM's `name` is an internal token, never what a user would search for // A DM's `name` is an internal token, never what a user would search for
// -- matched against the partner's display name/username instead. // -- matched against the partner's display name/username instead.
@@ -103,7 +171,12 @@ export function Sidebar({
<> <>
{directMessages.length > 0 && ( {directMessages.length > 0 && (
<> <>
<div className="sidebar-section-label">Direct Messages</div> <SidebarSectionHeader
label="Direct Messages"
collapsed={collapsedSections.has('dm')}
onToggle={() => toggleSection('dm')}
/>
{!collapsedSections.has('dm') && (
<nav> <nav>
{directMessages.map((room, i) => ( {directMessages.map((room, i) => (
<RoomRow <RoomRow
@@ -114,9 +187,17 @@ export function Sidebar({
/> />
))} ))}
</nav> </nav>
)}
</> </>
)} )}
{regularRooms.length > 0 && <div className="sidebar-section-label">Rooms</div>} {regularRooms.length > 0 && (
<>
<SidebarSectionHeader
label="Rooms"
collapsed={collapsedSections.has('rooms')}
onToggle={() => toggleSection('rooms')}
/>
{!collapsedSections.has('rooms') && (
<nav> <nav>
{regularRooms.map((room, i) => ( {regularRooms.map((room, i) => (
<RoomRow <RoomRow
@@ -127,6 +208,9 @@ export function Sidebar({
/> />
))} ))}
</nav> </nav>
)}
</>
)}
</> </>
)} )}
</div> </div>