Component contract
Renders a contextual action bar above (or stickied below) a list when selection is non-empty. Stateless wrt selection — the parent owns the selection state.
selectedCount: number— current selection size.totalMatching?: number— when present, "Select all N matching" affordance enables.scope: "page" | "all"— whether the user has confirmed cross-page selection.onScopeChange?: (next) => void— fires when user clicks "Select all N matching" or "Clear cross-page selection".actions: ActionDef[]— id, label, icon, kind ("default" | "destructive"), disabled?: (selectedCount, scope) => boolean.onAction: (actionId) => voidonDismiss: () => void— clears selection (parent decides).itemNoun: string— singular noun ("guest", "registration"). Pluralized internally.placement?: "top" | "bottom"— sticky position relative to the list.
Interaction surface
-
Bar appears when selectedCount > 0.
Slides in from the placement edge. Slide animation respects prefers-reduced-motion (instant appearance under reduce). Always renders absolute count, never "many".
-
Count message reflects scope.
scope=page: "5 guests selected on this page". scope=all: "All 247 matching guests selected". Pluralization is correct (1 guest / 2 guests).
-
"Select all N matching" affordance offered after page-level select-all.
When selectedCount equals the visible page count AND totalMatching is greater, the bar shows a subtle "Select all 247 matching" button. Clicking it calls
onScopeChange("all"). -
Action buttons render the action list.
Destructive actions (kind=destructive) render with danger styling and a confirmation step (delegates to ui-destructive-confirmation). Default actions execute immediately on click.
-
Dismiss clears the selection.
× button on the right or "Clear" text. Calls
onDismiss. Parent decides whether to also reset scope=page. -
Action with disabled predicate respects scope + count.
An action like "Export selected" might require selectedCount ≤ 1000. The disabled predicate runs on every render with current state. Disabled actions show a tooltip explaining why.
Failure modes
Bar shows "many selected" instead of a number
Trigger: large selection (1000+) renders as "Many guests selected" instead of "1247 guests selected".
Always show the absolute count. Performance is not an excuse — count is a number, not a list iteration. Harness: render with selectedCount=10000, assert text contains "10000" or "10,000".
scope=all shown as scope=page count
Trigger: scope=all but the bar still says "5 guests selected on this page" (the visible page count) instead of "All 247 matching guests selected".
When scope=all, message uses totalMatching. Harness: scope=all, totalMatching=247, assert text matches "All 247 matching".
"Select all N matching" stays visible after scope=all
Trigger: user has already accepted cross-page selection but the affordance still offers it.
After scope=all, the affordance switches to "Clear cross-page selection" (returning to scope=page) or hides entirely. Harness: scope=all, assert no "Select all N matching" button visible.
Destructive action fires without confirmation
Trigger: kind=destructive action triggers onAction immediately on click.
Destructive actions delegate to ui-destructive-confirmation before calling onAction. Harness: click a destructive action, assert ui-destructive-confirmation modal opens, NOT onAction called immediately. After confirm, onAction is called.
Bar disappears mid-action and the user loses the action
Trigger: parent sets selectedCount=0 optimistically before the bulk action completes.
Bar visibility is driven by selectedCount. If parent races, the bar can disappear mid-click. The bar should NOT auto-hide while an action is in flight — the parent passes actionInFlight=true and the bar persists. After completion, the parent updates selectedCount AND clears actionInFlight, then bar hides naturally. Harness: simulate optimistic clear, assert bar still visible if an action is mid-flight.
Pluralization is wrong
Trigger: "1 guests selected" or "2 guest selected" — naïve string concat.
Use Intl.PluralRules-aware pluralization. Component accepts singular noun, derives plural per locale. Harness: render selectedCount=1, assert "1 guest selected"; selectedCount=2, assert "2 guests selected".
Dismiss doesn't return focus to a sensible place
Trigger: × dismiss is clicked, focus is lost (body) or returns to the wrong element.
Dismiss returns focus to the table's first row's selection checkbox (or the table header's select-all if no rows remain selected). Harness: focus dismiss button, click, assert active element is a row checkbox or the page select-all.
Bar overlaps the table content (not sticky)
Trigger: placement=bottom but bar uses absolute positioning and floats over rows.
placement=bottom uses sticky positioning that pushes against the page bottom but does not overlap row content above it. Harness: render with many rows, scroll, assert bar's bounding box does not overlap any visible row.
Accessibility
- Outer wrapper has
role="region"witharia-label="Bulk actions for [noun]". - Count message is in a region with
aria-live="polite"so SR users hear scope changes. - Action buttons follow normal button a11y; destructive actions get
aria-describedbypointing at a danger-explanation span. - Dismiss button has
aria-label="Clear selection". - Color contrast: count text ≥ 4.5:1, destructive button ≥ 4.5:1 against its background.
- axe-clean at severity ≥ serious.
- Animation respects prefers-reduced-motion (instant appearance vs slide).
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-bulk-action-bar | Outer wrapper | Component identity; data-scope attr reflects page/all |
ui-bulk-action-count | Count message | aria-live region |
ui-bulk-action-select-all-matching | Cross-page select offer | Visible only when scope=page AND totalMatching > selectedCount |
ui-bulk-action-clear-cross-page | Return-to-page selection | Visible only when scope=all |
ui-bulk-action-button | Each action button | data-action-id; data-kind=default|destructive |
ui-bulk-action-dismiss | Dismiss button | Always visible when bar is visible |
Agent test plan
Standalone probes against /admin-test/ui-bulk-action-bar-fixture with variants: scope=page (small), scope=page (page-full, totalMatching available), scope=all, action-in-flight, destructive-action, single-item-selection.
Probe list
- bar-hidden-when-empty: selectedCount=0 → ui-bulk-action-bar not visible
- bar-visible-when-selected: selectedCount=1 → bar visible
- count-pluralization-singular: selectedCount=1, noun="guest" → "1 guest selected"
- count-pluralization-plural: selectedCount=2, noun="guest" → "2 guests selected"
- count-large: selectedCount=10000 → text contains numeric "10000" or "10,000", not "many"
- scope-page-message: scope=page, count=5 → "5 guests selected on this page"
- scope-all-message: scope=all, totalMatching=247 → "All 247 matching guests selected"
- select-all-matching-visible-after-page-select: count===visiblePage AND totalMatching>visiblePage → ui-bulk-action-select-all-matching visible
- select-all-matching-hidden-after-scope-all: scope=all → not visible
- clear-cross-page-visible-when-scope-all: scope=all → ui-bulk-action-clear-cross-page visible
- scope-toggle-fires-handler: click select-all-matching, onScopeChange called with "all"
- destructive-action-confirms-first: click destructive action, ui-destructive-confirmation visible, onAction NOT called
- default-action-fires-immediately: click default action, onAction called once
- disabled-action-no-fire: action disabled by predicate, click, onAction NOT called
- disabled-action-tooltip: focus/hover disabled action, tooltip explains why
- bar-persists-during-action: actionInFlight=true, optimistic count=0 → bar still visible
- dismiss-fires-handler: click dismiss, onDismiss called
- dismiss-returns-focus: focus dismiss + click, active element returns to row checkbox
- aria-live-on-count: count region has aria-live=polite
- prefers-reduced-motion: motion-reduce active, no slide animation
- color-contrast-count: ≥ 4.5:1
- color-contrast-destructive: ≥ 4.5:1
- axe-clean-serious: no serious violations