Skip to content

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. placement is bottom-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. A ResizeObserver re-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 pointerdown closes 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. Menu sets role="menu", Select sets role="listbox", and a caller wrapping content that declares its own role (the analytics date-range calendar's role="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.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).