List Screen Affordances
1. Overview
C07 Data Table specifies the table itself — rows, headers, selection chrome, bulk shell. Filterable List Pattern specifies the substrate — column config, query adapter, filter widgets. This page specifies everything between and around the substrate and the table: the chrome that hosts a list screen.
These affordances appear above and beside every admin-portal list (E02 Event Participants is the canonical bake; T02 Number Stock, M02 Membership Members, E04 Event Results, future Audit Log and Affiliations lists will compose them similarly). Capturing them once, here, prevents per-screen drift and gives the next list-screen author a checklist.
The behavioural composition (which signal owns what, how they wire together) is in Filterable List Pattern → List Screen Composition. This page is the visual + interaction spec.
2. Layout
The vertical stack from top of viewport down:
┌─ Page header ────────────────────────────────────────────┐
│ <Title> <subtitle> <Action toolbar | Bulk bar>│
├─ Stats strip ────────────────────────────────────────────┤
│ [count1] Label1 [count2] Label2 [count3] Label3 … │
├─ Filter chip strip (active filters) ─────────────────────┤
│ Pinned: Event 42 ⊠ Filter: status=Paid ⊗ Clear all │
├─ Utilities row ──────────────────────────────────────────┤
│ N matching [Show inactive (M)] Rows per page: 25 ▾│
├─ <C07 Data Table> ───────────────────────────────────────┤
│ … table rows … │
├─ Pagination ─────────────────────────────────────────────┤
│ « ‹ 1 2 3 … N › » │
└──────────────────────────────────────────────────────────┘
┌─ Side panel ─┐
│ row detail │
│ (slides in) │
└──────────────┘
Every band is optional except the table itself; the chrome scales down to a bare table when the screen has no stats, no filters set, no scope toggle, and no bulk affordances.
3. Page Header
3.1. Title + subtitle
-
<h2>with the entity name (Participants, Members, Numbers, Orders, …). -
Subtitle in
<small class="text-muted fs-6">for the parent context —— Event {{ event.name }}for a path-pinned list,— Tenant inventoryfor an unpinned list, etc. -
No icons or counts on the title itself; counts belong on the stats strip.
3.2. Action toolbar (right of title)
A horizontal role="toolbar" group containing the screen’s primary verbs.
| Action | Convention |
|---|---|
Import … |
|
Add <entity> |
|
Export |
|
Domain-specific verb (Pre-assignment / Reassign / Issue / …) |
Last in the toolbar; phrasing comes from the use case spec. |
Disabled stub affordances. Per the project’s UI Design Principles, every action in the eventual UI should be present from v1 — disabled with a title="Coming soon" tooltip on a wrapper <span> (disabled buttons don’t dispatch hover events on most browsers). Hide-when-not-implemented breaks operator mental models; disabled-with-tooltip surfaces the eventual surface and tells the operator what to expect.
3.3. Bulk-action toolbar (replaces action toolbar at ≥N selected)
When the screen’s selection threshold is met (E02 uses ≥2; another consumer might use ≥1), the action toolbar is replaced by a bulk bar in the same position:
-
{{ count }} selectedmuted label on the left. -
Bulk verbs in the middle (
Export selected,Delete selected, etc.) — danger verbs usebtn-outline-danger. -
Clearlink button on the right.
Page chrome above and below the toolbar does not change height — same <div class="d-flex justify-content-between"> slot, just different content. Layout jitter on bulk-mode entry/exit is a regression.
The bulk bar in this position is distinct from C07’s bonded DTBulkBar (which sits welded to the table’s top edge and renders only inside C07’s bulk-engaged shell). The page-header bulk bar surfaces the operator’s intent; C07’s bonded bar surfaces the spatial link to the rows being acted on. Both render simultaneously when bulk mode is engaged. See C07 Visual Treatment for the bonded bar.
4. Stats Strip
A horizontal row of summary chips below the page header. Each chip:
-
A bold count.
-
A muted label describing the metric (
Total,New,Returning,Unpaid,Unassigned numbers, …). -
Default state: outline button (
btn-outline-primaryfor neutral,btn-outline-warningfor attention metrics). -
Pressed state: filled button when the chip’s filter dimension is active on the URL.
4.1. Counts
-
The count is a server-computed metric over the full event/tenant scope, not the current filter set. The metric reflects "how many EPs have $thing", not "how many EPs match the current filters AND have $thing".
-
Counts are fetched from a dedicated stats endpoint (e.g.
GET /api/event-participants/stats?eventId=42) — not derived client-side from the row response. -
Counts are signal-driven; refresh only when the parent scope (eventId, tenantId, …) changes — chip toggles don’t change the counts, they just toggle the corresponding filter.
-
Falls back to
—placeholder while loading or on stats-fetch failure (chips stay clickable; the count just reads—).
4.2. Toggle behaviour
-
Click toggles the corresponding boolean filter on the URL (e.g.
?unpaid=true). -
Some chips are display-only anchors —
Totaltypically has no filter; render disabled. -
The chip flag dimension is out-of-band with respect to the column substrate; see Filterable List Pattern → Out-of-Band Filter Dimensions.
|
The naming convention for chip flags pairs an existing list-scope filter with a |
5. Filter Chip Strip
Renders below the stats strip — the <ems-active-filter-chips> component from the substrate.
-
One chip per active substrate filter.
-
Path-pinned chips render with a lock icon and no close button (the route owns the value).
-
Query-set chips render with an
⊗close button. -
Clear alllink on the right when any removable filter is active.
The chip strip never includes chip-flag pills (?unpaid=true) because those are already represented by the pressed state of the corresponding stats-strip chip. Surfacing them in both places would double-render the same intent.
The strip auto-hides when there are no filters to display — no empty band, no fixed height.
6. Utilities Row
A horizontal band below the chip strip, above the table:
| Element | Position + behaviour |
|---|---|
|
Muted text on the left. Server-supplied via |
Scope toggle ( |
Next to the count. Outline button when off, filled button when on. Badge shows the count of rows the toggle would reveal if flipped — operator sees the cost before paying it. |
Rows per page select |
Right-aligned. Bootstrap |
The scope toggle is screen-specific (EP has showInactive; T03 stock-return might have showReturned; M02 might have showLapsed). Treat each as an out-of-band filter dimension — see Out-of-Band Filter Dimensions for the discipline (single source of truth, strip leaked substrate params, reset pagination on toggle).
The page-size select honours the substrate’s ?size=N URL contract — selection round-trips through the URL.
7. Pagination
Below the table — Bootstrap <ngb-pagination>:
-
[maxSize]="5" [rotate]="true" [boundaryLinks]="true". -
Hidden when
totalRecords ⇐ state().rows(no point rendering "1" alone). -
Page is read from
?page=Nper substrate URL contract; setting the page navigates.
|
|
8. Side Panel
A row-detail panel that slides in from the right when the operator clicks a row’s View affordance.
-
URL-driven: opening adds
?view=<rowId>to the URL; closing removes it. Browser back-button closes the panel naturally — no separate close-state. -
Off-canvas overlay: full-height fixed-position aside, backdrop click closes,
Escapekey closes (host listener withtabindex="-1"and(keydown.escape)). -
Survives data churn: the panel content survives sort / filter / pagination changes via a snapshot signal (see Side Panel via
?view=<id>+ Snapshot Fallback). -
Sections: header (title + close button), summary, detail sections, footer with
Open detaillink to the full-page detail screen (E09 for EP). -
Stub-friendly: detail sections that depend on backend projections not yet shipped render as muted "Coming soon" placeholders, not as empty/zero states. The placeholder telegraphs "this UI will be richer soon"; an empty state telegraphs "there is nothing here", which is a different (and misleading) claim.
9. Row-Summoned Modal
A modal launched from a row-level affordance (chip click, action menu, etc.). The C06 order cluster modal is the canonical bake. Discipline:
-
Open state lives in a
*ModalId: signal<rowId | null>on the host list component. -
Snapshot fallback mirrors the side-panel pattern — snapshot the cluster/row at open time so the modal survives sort/filter/page churn that moves the row off the visible page.
-
Return-focus: capture
event.currentTargetfrom the row affordance click into a*ReturnTarget: HTMLElement | nullfield; on close, schedule a microtask totarget.focus(). Without this the operator’s keyboard context is lost. -
Escape + backdrop close with the same
tabindex="-1"+(keydown.escape)recipe as the side panel.
A row-summoned modal and the side panel can coexist on the same screen (EP has both). They occupy distinct URL channels (?view= for the panel; modal state is component-local because modals are short-lived and shouldn’t litter history) and don’t compete for screen real estate.
10. Hover Panel (Rich Tooltip)
A pop-out panel triggered by mouseenter on a chip or icon — used when a tooltip’s plain-text quota is too small (the C06 chip cluster is the canonical bake — it shows up to several order rows with status, date, total).
The non-obvious bit: anchor the chip and the panel inside one wrapper element with (mouseenter) / (mouseleave) on the wrapper, NOT on the chip itself.
<div class="hover-anchor"
(mouseenter)="onHoverEnter(row.id)"
(mouseleave)="onHoverLeave()">
<button class="my-chip">…</button>
@if (hoveredId() === row.id) {
<div class="hover-panel-anchor">
<my-hover-panel [data]="row"/>
</div>
}
</div>
The wrapper spans the chip and the panel surface. Without the wrapper, the cursor crossing from chip into panel triggers mouseleave on the chip (the panel is a sibling because buttons can’t legally contain block elements) and the panel unmounts mid-read. With the wrapper, mouseleave fires only when the cursor leaves both the chip and panel surface entirely.
Touch suppression is naturally provided by mouseenter not firing on touch devices — no media-query check needed.
11. Empty + Loading States
Three distinct empty states, three distinct surfaces — don’t conflate them:
| State | Surface | Copy |
|---|---|---|
Loading |
Inside the table body — single full-width row spanning every column. |
|
Empty (no data yet) |
Inside the table body — full-width row. |
|
Empty (filter narrowed to nothing) |
Inside the table body — full-width row. |
|
The first row’s colspan is visibleColumns().length + N where N counts the selection + row-action columns (typically 2). Don’t hardcode — derive it.
12. Status
This document specifies chrome conventions baked in by the EP list (E02). Future list screens compose the same chrome with screen-specific verbs and metrics. Variations that should propagate (a new affordance position, a new chrome band) are designed in a journal entry, then upstreamed here.
13. References
-
Filterable List Pattern — substrate (column config, query adapter, widgets) + composition (effects discipline, navigate, side-panel signal recipe).
-
C07 Data Table — the table itself (rows, headers, bulk shell). C07 explicitly disclaims chrome above the table; this page is the disclaimed half.
-
E02 Event Participants — first canonical consumer of these affordances.
-
C06 Order Detail — chip cluster modal + hover panel; the canonical bake for Row-Summoned Modal and Hover Panel.
-
UI Design Principles — disabled-with-tooltip rule, project-wide UI conventions.