SPA design system: Popover, Select, and Menu¶
The SPA's dropdowns render through three components in campuscore_app/web/src/components/ui/.
One primitive owns the shared behavior; two components own the two dropdown meanings.
Popover - the shared panel¶
Popover.tsx is the anchored panel every dropdown uses.
It owns the one panel skin (border, radius, shadow, z-index, dark pair), so surfaces cannot drift apart, plus the behaviors every popover shares:
- Placement and flip.
placementisbottom-start | bottom-end | top-start | top-end, and the panel flips vertically before paint when the default side would cross the viewport edge and the opposite side has room. AResizeObserverre-measures when the panel's content grows while open; a flip is latched until close, so scrolling or resizing the window cannot un-flip an open panel. - Escape is consumed. The handler calls
preventDefault()on the keydown, so dismissing a popover inside a modal<dialog>never dismisses the dialog with it. - Dismissal. An outside
pointerdowncloses without moving focus. Escape is handled at the document level, so it dismisses whether or not focus ever entered the panel; focus returns to the trigger only when it was inside. - Role passthrough.
Menusetsrole="menu",Selectsetsrole="listbox", and a caller wrapping content that declares its own role (the analytics date-range calendar'srole="dialog") sets none.
Panels render in-tree, not in a portal: the caller owns a relative root wrapping the trigger and the panel.
That means an overflow: hidden ancestor still clips a panel - the flip handles the viewport edge, not ancestor clipping.
Select - picking a value¶
Select.tsx is the value-picking dropdown: a trigger button (aria-haspopup="listbox") and a panel of role="option" buttons under roving arrow-key focus.
Single mode (value: string) commits on pick and closes; multiple mode (value: string[]) toggles and keeps the panel open, with aria-multiselectable.
Typing while open is type-to-jump: printable characters accumulate a case-insensitive search buffer, and typing never changes the value.
A single or repeated character searches forward from just past the focused option and wraps, so repeats cycle through the options sharing that initial.
A growing multi-character prefix searches from the focused option itself, so it stays put while the prefix still matches.
The buffer resets after 500ms idle or on arrow/Home/End navigation; bare Space still activates the focused option, while Space typed mid-buffer joins the search.
Values are strings; a call site with numeric domain values owns the conversion (see FolderScopeMenu).
Options are {value, label, meta?, disabled?, disabledReason?}.
A disabled option is aria-disabled and inert rather than unfocusable, so keyboard and screen-reader users can still reach its reason.
size is md (matches the FilterBar control recipe) or sm (the compact toolbar recipe); trigger/triggerClassName replace the default selected-label-plus-chevron trigger, and footer renders below the options (the folder picker's "Clear selection").
A filter whose empty value means "all" keeps that as a real option ("All roles", "All topics"), never a placeholder - otherwise the filter could not be cleared.
placeholder is only for a value nobody returns to ("Choose a role…").
FilterSelect in FilterBar.tsx stays the filter-bar wrapper and renders a Select internally.
Menu - running a command¶
Menu.tsx is the command menu: role="menu" with role="menuitem" buttons, focus moving into the first item on open, and items {label, onSelect, destructive?, disabled?, title?} where title names why a disabled item is disabled.
The default trigger is the ⋯ glyph; trigger/triggerClassName make it a labelled toolbar button (the connectors "Bulk actions" dropdown).
The lint guard¶
eslint.config.js bans JSX <select> outside ui/Select.tsx via no-restricted-syntax, so the native element cannot quietly return.
Choose by meaning: picking a value is a Select, running a command is a Menu; if neither fits, the surface is probably not a dropdown (segmented controls, disclosures, and in-dialog checkbox lists stay what they are).