-
Notifications
You must be signed in to change notification settings - Fork 10
list layout
Default rule: when a page renders a <List> (or any container whose body slot iterates over rows / panels) and each row exposes actions tied to that row's data, render those actions as a dedicated component inside the body slot. Do not lift the actions into a top-level form gated by a selectedItem ref.
The body slot's mount lifecycle is the guard. While the panel is open, the component exists with a non-null item prop; when the panel collapses or the row leaves the list, the component unmounts and its state is discarded. No v-if="selectedItem", no nullable-item branches in computeds, no provide/inject across slot scopes.
Applies to every list-shaped page in the app — wherever the action set depends on a single selected entity.
Companion to command-input-validation.md (commands take validated input) and query-shape-list-vs-get.md (project at the boundary).
PageShell.vue
├─ data loads (Promise.all of suspense queries — query results outlive every panel)
├─ subscriptions hoisted to parent (SSE, websockets — see below)
├─ <GlobalListener @event="..."> ← if there's one global event source per page (scanner, hotkey)
└─ <List>
├─ #Top-Action-Menu
│ ├─ <div id="top-action-menu" /> ← portal target for per-item Teleport
│ └─ shell-level commands (release-all, mark-finished, pause)
├─ #Title ← per-item header chips, slot-scoped
├─ #Item-Body
│ └─ <ItemActions :item :resources @… />
│ ├─ <Teleport to="#top-action-menu" defer>
│ │ └─ per-item form, action buttons, confirm dialogs
│ └─ inline body table + per-row inline actions
└─ #Item-ReadOnly-Body
└─ <ItemReadOnly :item /> ← separate, smaller component for terminal-state rows
<List> renders one body-slot instance per panel and passes the row as the slot scope ({ item }). Mounting <ItemActions> inside that slot gives the component a non-null item prop for its entire lifetime.
That removes a whole class of plumbing:
- No
selectedItemref to keep in sync with the List's internal selection. - No
v-if="selectedItem"wrappers; noitem?.fieldchains in computeds. - No
useSelectable(itemRef, …)that takes a nullable root. - No template refs / provide-inject to bridge "the form in the top slot" with "the body table in the body slot" — they're the same component now, with the form Teleported out.
The implicit guard is the panel's mount lifecycle. State (selection, draft form values, in-flight optimistic updates) is per-item by construction.
- Route middleware / claim guards / page-level redirects.
- All
Promise.alldata fetches whose results several screens read. - Page-stable refs / subscriptions that must not re-mount when a panel switches (see "Hoist subscriptions" below).
-
<List>lifecycle plumbing: anylistKeyremount after a confirm flow, the active-tab state, watchers that re-select an item after a server round-trip. - Commands with no item context: bulk-release, page-level pause, dashboard-wide refresh.
- Global event listeners (the keyboard buffer for a barcode scanner, a hotkey listener) — these own the subscription and delegate per-event to the active item's handler.
-
defineProps<{ item; …shared resources }>()—itemis non-null. - Selection composables scoped to the item (
useSelectableSingle(computed(() => props.item), …)). - Per-item commands: edit, submit, undo, the per-row half of any global event.
-
defineExpose({ … })exposing any handler the page-level listener needs to route into (e.g.scan). -
<Teleport to="#top-action-menu" defer>projects the form into the page-level action area; the rest of the body renders inline inside the slot.
- Terminal-state rendering for rows in the "done" tab.
- No selection, no form, no Teleport — just the row's read-only details.
- Kept separate from
ItemActionsso the action-heavy code doesn't pay the import cost on rows that can't be acted on.
Anything whose subscription / identity must survive a panel re-mount lives in the parent:
- Event-source connections (hardware event sources, SSE feeds, websocket subscriptions). Opening / closing the connection every time the user expands a panel is wasteful and racy. Owned by the shell, passed down as a prop.
-
Suspense query roots. TanStack already caches, but the suspense boundary has to live above
<List>. Shell owns the queries;ItemActionsreads from props. -
One-time projections. Shape transforms applied via
selecton the parent's query (see query-shape-list-vs-get.md). EachItemActionsinstance receives the already-projected shape and never re-derives.
Commands stay in the child even when they read parent-stable refs, because their handler identity matters per item: toast keys, optimistic state, scan-delegate hooks all want to be torn down when the item unmounts.
The pattern for a barcode scanner, hotkey, or any single-listener input that should act on the currently-active item:
// shell
const actionsRef = useTemplateRef<InstanceType<typeof ItemActions>>("actionsRef")
const onEvent = Command.fn("…")(function*(payload: string) {
const handler = actionsRef.value
if (!handler) return yield* new InvalidStateError("Please select an item")
yield* handler.handleEvent(payload)
}, Command.withDefaultToast({ onSuccess: null, onWaiting: null }))<!-- shell template -->
<GlobalListener @event="onEvent.handle" />
<!-- … -->
<ItemActions ref="actionsRef" :item … />The shell owns the subscription (one buffer, one listener, one set of teardown hooks); the child owns the handler (knows the active item, the selection, the next action). The InvalidStateError covers the "fired with nothing expanded" race.
Some actions are intrinsically per-row (an unpack button on each completed sub-package, a remove button on each line item). Render them inside ItemActions rather than the shell, because they need to mutate the same selection / form state the rest of the component owns. The body slot only renders for the expanded panel, so the row data is always paired with the active props.item.
After a server round-trip, the item may flicker — leave one tab, briefly re-enter the other before settling. A pendingItemId ref + a watcher on the relevant list in the shell handles re-selection once the cache settles:
// shell
const pendingItemId = ref<string | null>(null)
watch(visibleItems, (current) => {
const id = pendingItemId.value
if (!id) return
const found = current.find((_) => _.id === id)
if (found) {
selectedItem.value = found
pendingItemId.value = null
}
}, { flush: "post" })The per-row command emits unpackPending (or similar) when it finishes; the shell stores the id and lets the watcher re-select.
-
selectedItemplumbed into a top-of-page form. Requiredv-ifguards everywhere, an explicitnullbranch in every computed, and auseSelectableinstance with a nullable root. Touching the form for one item could leak state into the next. -
Bodies in the shell, commands in a sibling child. Forced provide/inject or template-ref drilling to bridge them — and provide/inject does not work for this, because slot content renders in the parent scope where the child's
provideis not reachable. - Resubscribing to a long-lived feed on every panel switch. Drops events during the gap between unmount and remount, exhausts upstream connection limits, makes "is the feed connected?" a per-panel UI question.
-
A page-level scan handler that walks the DOM for the "currently expanded" panel. Use a template ref into
ItemActionsinstead — explicit, typed, no stale-DOM races.
-
frontend/workflows/<workflow-a>/pages/package/— split shell +_components/Actions.vue+_components/ActionsPacked.vue. -
frontend/workflows/<workflow-b>/pages/package/— same shape, with workflow-specific extras (pallet dialog, building-block flow) entirely insideActions.vue. -
frontend/workflows/<workflow-c>/pages/package/— same shape; the most recent convergence. Before the refactor, it ran aselectedOrderref with a top-of-page form gated byv-if; the migration to this layout shrank the shell from ~750 lines to ~270.
When adding a new list-shaped page, start from this shape; if you find yourself adding a selectedItem ref to gate a top-level form, stop and move the form into a body-slot child instead.
- Index
- Import Rules
- Resource & Controller Layout
- Command Pattern
- Command Input Validation
- Query Shape: List vs Get
- Database Query Guidelines
- List Layout
- Streams & Progress
- Vue Conventions
- E2E State Pattern
- E2E
- E2E Toast Wait Audit
- Flow Documentation
- (project-local — create
flows/when first workflow lands)