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 inventory for 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 …

btn-outline-primary if implemented; otherwise disabled-with-tooltip until the import flow lands.

Add <entity>

btn-outline-secondary. Modal or routed form, screen-specific.

Export

btn-outline-secondary. Downloads the current filter set (not just the visible page); see server-side filtering only.

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 }} selected muted label on the left.

  • Bulk verbs in the middle (Export selected, Delete selected, etc.) — danger verbs use btn-outline-danger.

  • Clear link 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-primary for neutral, btn-outline-warning for 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 — Total typically 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 Only suffix when the back-end criterion narrows the population (firstTimeOnly, returningOnly) and uses bare names for additive predicates (unpaid, unassignedNumber). Stay consistent — the operator reads "Returning only" as exclusive, "Unpaid" as a filter on a status column.

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 all link 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

{{ totalRecords }} matching <entities>

Muted text on the left. Server-supplied via X-Total-Count.

Scope toggle (Show inactive (N) / Hide inactive)

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 form-select-sm with options 25 / 50 / 100 (extend per screen need).

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=N per substrate URL contract; setting the page navigates.

@angular/localize is a required polyfill for ng-bootstrap’s pagination — without it the table body silently fails to render the moment totalRecords > pageSize. Surface as polyfills: ["@angular/localize/init"] in angular.json. See Filterable List Pattern → Caveats.

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, Escape key closes (host listener with tabindex="-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 detail link 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.currentTarget from the row affordance click into a *ReturnTarget: HTMLElement | null field; on close, schedule a microtask to target.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.

Loading… (no spinner — keep it lightweight; the network round-trip is brief).

Empty (no data yet)

Inside the table body — full-width row.

No <entities> found. Plus a subdued CTA pointing at the relevant import / add affordance, e.g. Import participants for E02.

Empty (filter narrowed to nothing)

Inside the table body — full-width row.

No matching <entities>. Plus a Clear filters link that calls clearAllFilters().

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.