A box first in every row and one over the column for the rows shown, the selected row tinted. A filter keeps what it hides selected; another list starts over; a row that left the list drops out. Watch later has none. A box no longer counts as a typing target: a click on one left the focus there and the page's keys went dead.
94 lines
23 KiB
Markdown
94 lines
23 KiB
Markdown
# `components/ui/` — the platform layer
|
|
|
|
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
|
|
Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels,
|
|
playlists, Plex or downloads.
|
|
|
|
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
|
|
~160 feature components, and the platform was invisible — several of them literally describe
|
|
themselves as "the one place", "shared", "canonical", "app-wide", and were still routinely bypassed
|
|
by a hand-rolled `<button>` or a re-implemented popover. A layer you cannot see is a layer nobody
|
|
uses.
|
|
|
|
## The rule
|
|
|
|
A component belongs here **iff its props and imports name no domain concept**. It may import:
|
|
|
|
- sibling primitives in this directory,
|
|
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
|
|
- i18n, React, and third-party UI libraries.
|
|
|
|
It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
|
|
(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
|
|
by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
|
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
|
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
|
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
|
`hint`).
|
|
|
|
The direction is one-way from below, too: `lib/` may not import from `components/` — this directory
|
|
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
|
|
/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import.
|
|
|
|
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters),
|
|
`plexCards`, `downloadRow`, `channelColumns` — all shared, none domain-free.
|
|
|
|
## What lives here
|
|
|
|
| area | components |
|
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
|
|
|
## The size contract (E10 S1c)
|
|
|
|
The app supports **tablet portrait and landscape and every size above them** (from 768px wide; phones
|
|
are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Device adaptation). Measure a UI
|
|
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
|
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
|
|
|
|
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
|
|
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
|
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
|
|
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
|
|
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
|
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
|
— `VirtualGrid`'s `minCol` / `colMaxWidth` and `gridColumns`' `GRID_GAP` — which are compared
|
|
against a container's own pixel width rather than sized against text. A px-frozen BOX utility
|
|
(`w-[Npx]`, `max-w-[Npx]`, …) is an eslint error too, since the 2026-09 sweep: use the spacing
|
|
scale (`w-37.5` is 150px at the default size and grows with the slider).
|
|
- **Minimum target: 2.75rem, on coarse pointers only** (the `touch-target` utility, index.css). A 32px icon
|
|
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
|
|
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
|
|
on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button.
|
|
- The link variants carry it too (and the 24 mouse floor): every one stands on its own. Text inside a
|
|
sentence is a plain `<a>`, which WCAG 2.5.8 exempts. On a coarse pointer the target wins over a
|
|
caller's own `min-h-6` / `min-w-6` (it is `!important` there), or a control carrying both stays 24.
|
|
|
|
E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay
|
|
buttons last), after which a raw `<button>` — or a bare `FocusSafeButton` — outside the control
|
|
primitives (eslint `BUTTON_PRIMITIVES`; not the rest of this directory) is a lint error.
|
|
|
|
## Keeping it honest
|
|
|
|
Two of this directory's promises are load-bearing enough that prose was not enough, so each has a
|
|
test rather than a paragraph:
|
|
|
|
- the import boundary above → `lib/uiBoundary.contract.test.ts` runs eslint over fixture imports.
|
|
It exists because the first cut of that rule wrote its regexes in plain `"..."` literals, where
|
|
`\.` collapses to the any-char `.` — it read like a boundary and let the two commonest violations
|
|
through, with a green gate saying nothing (E10 review round 1).
|
|
- the chip lanes' clip → `lib/chipLane.contract.test.ts` counts measurers against lanes per file.
|
|
|
|
If you add a rule here, ask what would fail if it stopped being true — and if the answer is
|
|
"nothing", write the test instead of the sentence.
|
|
|
|
## Adding one
|
|
|
|
Move a component in when a second module needs it and it is domain-free — not on speculation. Adding
|
|
a variant to an existing primitive beats adding a primitive; a second component that paints the same
|
|
thing differently is how the last inconsistency happened.
|