import type { ReactNode } from 'react' import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react' import { useRender } from '@base-ui/react/use-render' import { cn } from './cn' import { COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks' import { IconButton } from './IconButton' import { Menu } from './Menu' import { SectionLabel } from './SectionLabel' /** * The 32px row a section starts with (Peek's SectionHeader, 2026-09-01): * a SectionLabel, an optional collapse chevron that makes the title the * toggle, and actions that appear while the row is hovered or keyboard-focused, * as IconButtons with tooltips. * * Three things changed on 2026-09-09, for `CollapsibleSection`: * * - **The title is a button when it toggles.** It was a `div` with an * `onClick`, so the keyboard could not open or close a section at all. * The button fills the row up to the actions, so the whole row is still * the hit target; the actions sit beside it rather than inside it, * because a button inside a button is invalid HTML — and was why every * action had to stop its click from also toggling. * - **The hover is CSS.** The fill and the actions' reveal were React * state (`isHovered`), which the README forbids — a mount cannot stay in * step with a transition. `group-hover` now; the actions are in the row * at `opacity-0` until hovered or focused, so a keyboard user reaches * them too. * - **`render`** lets `CollapsibleSection` hand in Base UI's * `Collapsible.Trigger` as the title button — one header for the * hand-held section (`isExpanded` / `onToggle`) and the Base UI one, * with no second click handler. * * One API change from Peek's original, safe because no call site used the * old shape yet: the two hard-wired action slots (add, sort) became * `actions` — the caller brings the icon, the tooltip and the handler. */ export interface SectionAction { /** 16px, stroke 1.5. */ icon: ReactNode tooltip: string onClick: () => void } export interface SectionHeaderProps { title: string /** Collapsible: draws the chevron and makes the title a button that toggles. */ chevron?: boolean /** * 16px, stroke 1.5, before the title — a folder's (Katerina, 5 October: * Peek's Folders, the standard heading with a folder icon). With `chevron` * the icon is what shows at rest and the arrow takes its place under the * pointer or the keyboard, as a NavItem's icon turns into its arrow. */ icon?: ReactNode isExpanded?: boolean onToggle?: () => void /** * Beside the title and always visible, before the actions — a count. An * `UnreadDot` instead sits in the last action's place (the ⋮'s, with a * `menu`) and steps aside while the actions show, as a NavItem's dot does. * * The actions come and go with the hover; this does not, because a count is * information rather than an affordance. Peek's Screener header carries its * number as a `Chip` here, and was the last hand-drawn folding header in the * app for want of the slot (ADOPTION B23). */ trailing?: ReactNode /** * How many there are, 8px after the title — not at the row's end, where * `trailing` goes (Katerina, 7 October: a heading over a list says its * count beside its name, as a tab does). */ count?: number /** Right-aligned, in the order given. */ actions?: SectionAction[] /** * The heading's "More options" menu: `MenuItem`s — what acts on the whole * section, a folder's Rename or Archive (Katerina, 5 October: Peek's Folders * page goes, and its folder actions move onto the folder's heading). The ⋮ * is NavItem's, after the actions and revealed with them; it stays while its * menu is open. */ menu?: ReactNode /** The ⋮'s accessible name. Default "More options for" the title. */ menuLabel?: string /** `hover` reveals the actions while the row is hovered or keyboard-focused (always on a screen with no hover); `always` keeps them. */ showActions?: 'hover' | 'always' /** * `fill` lights the row under the pointer when it does something (a toggle, * actions); `none` keeps it still — a heading whose actions are always shown, * where the buttons light up on their own (UIG-14, Katerina, 19 September). */ hover?: 'fill' | 'none' /** * `heading` (default): a section's title, the `SectionLabel`. `quiet`: a * small grey label over a list (12px, muted) — "Folders" over the folders, * which never reads as one of them. `row`: a row among rows — the chevron * and the title at a row's weight and colour, as a sidebar row is (Katerina, * 30 September: Leaf's folders, in Ship). */ look?: 'heading' | 'quiet' | 'row' /** * What the title renders as, in Base UI's manner. A plain button with * `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`. */ render?: useRender.RenderProp className?: string } export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, count, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) { const row = look === 'row' /* One arrow for everything that folds, a NavItem's 16px, and the title 8px after it as a row's label is (Katerina, 5 October: a folder heading and the rows under it fold with one arrow; then every folding heading, Peek's Starred too). It was 12px with a 4px gap; with an icon, the arrow now fills the icon's place exactly. */ const acts = (actions && actions.length > 0) || menu != null /* A dot sits in the place of the last button and steps aside while the buttons show — the rows' dot and ⋮ share a place the same way (Katerina, 5 October). A count stays beside them: it is information (09-09). "A dot" is whatever holds an `UnreadDot`, found by the `data-unread` it draws, so the CSS decides (`has-[[data-unread]]`) rather than a check of the element's type: that missed a dot in a tooltip or in an app's own wrapper, which then sat beside the buttons as a count does. With no buttons it sits on their axis all the same (`-mr-1`, as they are pulled). In the last button's place it is the 24px slot at `right-1`, where the buttons' box ends, gone while they show — on a screen with no hover, then, always. */ const trailingClasses = !acts ? 'has-[[data-unread]]:-mr-1' : showActions === 'hover' && 'has-[[data-unread]]:pointer-events-none has-[[data-unread]]:absolute has-[[data-unread]]:inset-y-0 has-[[data-unread]]:right-1 has-[[data-unread]]:w-6 has-[[data-unread]]:justify-center has-[[data-unread]]:transition-opacity group-hover:has-[[data-unread]]:opacity-0 group-has-[:focus-visible]:has-[[data-unread]]:opacity-0 group-has-[[data-section-menu]_[aria-expanded=true]]:has-[[data-unread]]:opacity-0 [@media(hover:none)]:has-[[data-unread]]:opacity-0' /* The buttons show under the pointer, while the keyboard is anywhere in the row — the title's toggle included, as a NavItem's ⋮ shows once its link has keyboard focus; it was only one of the buttons themselves — and while the menu is open. `aria-expanded` is asked of the buttons' own box: the toggle says it too, and an open section would keep them for good. A screen with no hover (a phone, a tablet) shows them always: nothing there can point at the row, so hidden they could never be reached. */ const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100' const titleElement = useRender({ render: render ?? (chevron ?