Skip to content

ListDetail

WxListDetail is the screen an admin panel keeps coming back to: something to narrow the set on the left, the records in the middle, the open one on the right. Inbox and messages, orders and an order, tickets, products, users, invoices — the furniture is the same every time, and so is the part nobody enjoys writing twice: what happens when the window is 900px wide.

It is layout only. What a row looks like, what the record shows, how the filters work — all of that is yours, in slots.

NB
Nova Build
NewWX-1041 · today, 11:20
€4 180
TW
Tomas Weber
NewWX-1040 · today, 09:05
€620
LG
Lehmann GmbH
PaidWX-1038 · yesterday
€12 900
AR
Anna Roth
ShippedWX-1035 · 2 July
€245
WX-1041New
Nova Build Placed today, 11:20
Hydraulic breaker HB-201 × €3 400
Quick coupler2 × €390
Total€4 180
Drag the corner: the filters fold into a button, then the order becomes a screen of its own

Usage

vue
<script setup lang="ts">
import { computed, ref } from 'vue'

const selectedId = ref<string | null>(orders[0].id)
const open = ref(true)

const selected = computed(() => orders.find((order) => order.id === selectedId.value))
</script>

<template>
  <wx-list-detail v-model:open="open">
    <template #filters>
      <wx-menu v-model="status">…</wx-menu>
    </template>

    <template #list>
      <wx-scrollbar>
        <order-row v-for="order in visible" :key="order.id" :order="order" @click="pick(order)" />
      </wx-scrollbar>
    </template>

    <template #detail="{ inline, back }">
      <order-details :order="selected" :show-back="!inline" @back="back" />
    </template>

    <template #empty>Pick an order</template>
  </wx-list-detail>
</template>

It fills the space it is given, so it belongs in a WxMain with no padding of its own — which is exactly where a <router-view /> sits in an admin shell.

Neither pane is inset by the component, on either side of the breakpoint: on a narrow screen the detail becomes a sheet, and that sheet pads nothing either. What it holds is a screen rather than a note, and a screen knows its own margins — two insets stacked would put the same content further from the edge inside the sheet than it stands anywhere else.

vue
<template>
  <wx-container viewport>
    <wx-header>…</wx-header>

    <wx-container direction="horizontal">
      <wx-aside v-if="showAside" :collapsed="collapsed">…</wx-aside>

      <wx-main padding="none">
        <router-view />
      </wx-main>
    </wx-container>
  </wx-container>
</template>

One model, two jobs

v-model:open is not "the drawer is up" — it is a record is open. Beside the list that picks the detail over the empty state; on a screen too narrow for a third column it is what raises the record as a panel. The back handed to the detail slot closes it, and the same code serves both shapes: what is a column on a desktop is a screen on a phone, written once.

Which record is open stays yours. The component never holds your selection — only whether there is one.

The thresholds are the widths you gave

There are no magic breakpoints here. The component measures itself, and adds up what it was told:

  • the filters column folds away below filtersWidth + listWidth + detailMin;
  • the detail stops standing beside the list below listWidth + detailMin.

With the defaults that is 1040px and 800px — of the component's own width, not the window's. A sidebar that collapses to a rail hands the screen 160px, and the filters column comes back on its own; the same screen inside a 700px drawer behaves like a phone, because there it is one. A viewport media query cannot tell those apart.

Widen the list and the thresholds move with it:

vue
<template>
  <wx-list-detail :list-width="440" :detail-min="520">…</wx-list-detail>
</template>

With no detail, the list is the screen

Leave the detail slot out and this is a list with a chooser in front of it — which form's submissions, which folder's files — rather than a record standing beside one. The list then takes the room the detail would have had, and the only threshold left is the chooser's: filtersWidth + detailMin, because detailMin is the floor of whichever pane is the main one.

That is the shape to reach for when a record opens on a route of its own. What folds on a phone is then the chooser rather than the records: the reader sees the list first and reaches the chooser through the button the list slot is handed.

vue
<template>
  <wx-list-detail :filters-width="270" filters-title="Forms">
    <template #filters="{ inline, close }">
      <form-list :heading="inline" @pick="close" />
    </template>

    <template #list="{ filtersInline, openFilters }">
      <submission-list :picker="!filtersInline" @pick="openFilters" />
    </template>
  </wx-list-detail>
</template>

Where the filters go

When the column does not fit, the filters slot moves into a drawer — the same markup, no second copy. The list slot is handed the button to open it:

vue
<template #list="{ filtersInline, openFilters }">
  <wx-button v-if="!filtersInline" variant="outline" size="sm" @click="openFilters">
    Status
  </wx-button>

</template>

A head that stands outside the pane — the screen's own, above the card — cannot be handed that slot prop, so the component says it as an event instead: filters-inline fires whenever the column appears or folds, and once at the start. Pair it with v-model:filtersOpen to raise the drawer from up there:

vue
<template>
  <wx-screen-head :actions="actions">
    <template v-if="!columnInline" #extra>
      <wx-button variant="outline" @click="filtersOpen = true">Forms</wx-button>
    </template>
  </wx-screen-head>

  <wx-list-detail v-model:filters-open="filtersOpen" @filters-inline="columnInline = $event">

  </wx-list-detail>
</template>

Each slot knows where it is rendered — inline is false inside a drawer — so a heading that only makes sense in the column can stand down:

vue
<template #filters="{ inline, close }">
  <h2 v-if="inline">Orders</h2>
  <wx-menu v-model="status" @select="close">…</wx-menu>
</template>

Props

PropTypeDefaultDescription
filtersWidthnumber | string240Width of the filters column
listWidthnumber | string380Width of the list column
detailMinnumber420Narrowest the main pane may be; sets both thresholds
filtersTitlestring'Filters'Heading of the drawer the filters move into
detailLabelstring'Details'Accessible name of the detail panel

Models: v-model:open (boolean) — whether a record is open; v-model:filtersOpen (boolean) — the filters drawer, if you want to drive it yourself.

Events: filters-inline (boolean) — whether the filters still have a column, said on every change and once at the start.

Slots

SlotPropsDescription
filtersinline, closeViews, folders, filters. Optional — no slot, no column
listfiltersInline, openFiltersThe records
detailinline, backThe open record. Optional — without it the list is the screen
emptyShown in the pane's place while nothing is open

What it does not do

No selection, no data, no keyboard list navigation, and no draggable divider between the columns — that one waits for Splitter. It is three regions and the rule for when there is room for them.

Released under the MIT License.