- pact.ListRowStates with the fixed RowState set deleted, negative, disabled - list response meta.row_states keyed by row id; unknown values dropped - list messages rowStateDeleted, rowStateNegative, rowStateDisabled - update writes through the scope the load used, so a soft-deleted record a controller includes stays soft-deleted - DataTable row state badges and text styles - roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
339 lines
13 KiB
Vue
339 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { RouterLink, useRouter } from 'vue-router'
|
|
import { ArrowDown, ArrowUp, Check, Minus } from '@lucide/vue'
|
|
import type { AdminRecord, ListColumn } from '../../api/types'
|
|
import { t } from '../../app/i18n'
|
|
import type { RowId } from '../../app/listQuery'
|
|
import CellValue from './CellValue.vue'
|
|
import RowStateBadges from './RowStateBadges.vue'
|
|
|
|
// Schema-driven table (design screen 3, D-12, D-16): exactly the server's
|
|
// columns, each record read through its column keys. Optional checkbox
|
|
// column with a tri-state header over the current page, sortable headers
|
|
// (asc -> desc -> none) with aria-sort, row links to the record route, eight
|
|
// skeleton rows while loading and an `empty` slot for the empty states.
|
|
// The relation variant (design screen 5) has 56px rows, an initials avatar
|
|
// before the first column's text and the remaining columns muted. Phase
|
|
// 12.2: with `openable` its rows open (the first cell's text is a button and
|
|
// a row click outside other controls does the same), and `trailing` adds a
|
|
// 56px last cell filled by the `trailing` slot. Phase 12.1 (UI-SPEC S4): a
|
|
// row may carry states from the fixed set deleted, negative, disabled; each
|
|
// shows as a text badge after the first cell's text plus a text style, and
|
|
// the row background, selection, sorting, links and row click stay as they
|
|
// are. A value outside the set is ignored.
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
columns: ListColumn[]
|
|
rows: AdminRecord[]
|
|
loading?: boolean
|
|
rowLink?: (row: AdminRecord) => string | null
|
|
selectable?: boolean
|
|
selected?: RowId[]
|
|
sortable?: boolean
|
|
sort?: { column: string; dir: 'asc' | 'desc' } | null
|
|
variant?: 'list' | 'relation'
|
|
/** Rows open on click (relation variant): emits `open`. */
|
|
openable?: boolean
|
|
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
|
|
trailing?: boolean
|
|
/** States per row id (list meta row_states). */
|
|
rowStates?: Record<string, string[]>
|
|
/** Badge text per state. */
|
|
stateLabels?: Record<string, string>
|
|
}>(),
|
|
{
|
|
loading: false,
|
|
rowLink: undefined,
|
|
selectable: false,
|
|
selected: () => [],
|
|
sortable: false,
|
|
sort: null,
|
|
variant: 'list',
|
|
openable: false,
|
|
trailing: false,
|
|
rowStates: () => ({}),
|
|
stateLabels: () => ({}),
|
|
},
|
|
)
|
|
const emit = defineEmits<{
|
|
'update:selected': [ids: RowId[]]
|
|
sort: [column: string]
|
|
open: [row: AdminRecord, trigger: HTMLElement | null]
|
|
}>()
|
|
|
|
const router = useRouter()
|
|
|
|
function rowId(row: AdminRecord): RowId | null {
|
|
const id = row.id
|
|
return typeof id === 'number' || typeof id === 'string' ? id : null
|
|
}
|
|
|
|
function rowKey(row: AdminRecord, index: number): string {
|
|
const id = rowId(row)
|
|
return id === null ? `row-${index}` : String(id)
|
|
}
|
|
|
|
function linkOf(row: AdminRecord): string | null {
|
|
return props.rowLink ? props.rowLink(row) : null
|
|
}
|
|
|
|
function openRow(event: MouseEvent, row: AdminRecord): void {
|
|
const target = event.target as HTMLElement | null
|
|
if (target?.closest('a, button, input, label, select')) {
|
|
return
|
|
}
|
|
if (props.openable) {
|
|
const opener = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[data-row-open]') ?? null
|
|
emit('open', row, opener)
|
|
return
|
|
}
|
|
const to = linkOf(row)
|
|
if (to) {
|
|
void router.push(to)
|
|
}
|
|
}
|
|
|
|
const pageIds = computed(() => props.rows.map(rowId).filter((id): id is RowId => id !== null))
|
|
const selectedSet = computed(() => new Set(props.selected.map(String)))
|
|
|
|
const headerState = computed<'true' | 'false' | 'mixed'>(() => {
|
|
const ids = pageIds.value
|
|
const chosen = ids.filter((id) => selectedSet.value.has(String(id))).length
|
|
if (chosen === 0) {
|
|
return 'false'
|
|
}
|
|
return chosen === ids.length ? 'true' : 'mixed'
|
|
})
|
|
|
|
function isSelected(row: AdminRecord): boolean {
|
|
const id = rowId(row)
|
|
return id !== null && selectedSet.value.has(String(id))
|
|
}
|
|
|
|
function toggleAll(): void {
|
|
emit('update:selected', headerState.value === 'true' ? [] : [...pageIds.value])
|
|
}
|
|
|
|
function toggleRow(row: AdminRecord): void {
|
|
const id = rowId(row)
|
|
if (id === null) {
|
|
return
|
|
}
|
|
if (selectedSet.value.has(String(id))) {
|
|
emit(
|
|
'update:selected',
|
|
props.selected.filter((item) => String(item) !== String(id)),
|
|
)
|
|
} else {
|
|
emit('update:selected', [...props.selected, id])
|
|
}
|
|
}
|
|
|
|
function sortState(column: ListColumn): 'ascending' | 'descending' | undefined {
|
|
if (props.sort?.column !== column.key) {
|
|
return undefined
|
|
}
|
|
return props.sort.dir === 'desc' ? 'descending' : 'ascending'
|
|
}
|
|
|
|
function canSort(column: ListColumn): boolean {
|
|
return props.sortable && column.sortable
|
|
}
|
|
|
|
const relation = computed(() => props.variant === 'relation')
|
|
const rowHeight = computed(() => (relation.value ? 'h-[56px]' : 'h-row'))
|
|
const skeletonRows = computed(() => (relation.value ? 3 : 8))
|
|
|
|
/** Initials of a cell value for the relation variant's avatar. */
|
|
function initials(value: unknown): string {
|
|
const text = typeof value === 'string' || typeof value === 'number' ? String(value) : ''
|
|
return text
|
|
.split(/[\s@._-]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part.charAt(0).toUpperCase())
|
|
.join('')
|
|
}
|
|
|
|
const ROW_STATES = ['deleted', 'negative', 'disabled']
|
|
|
|
/** The row's known states in the fixed order; anything else is dropped. */
|
|
function statesOf(row: AdminRecord): string[] {
|
|
const id = rowId(row)
|
|
const states = id === null ? undefined : props.rowStates[String(id)]
|
|
if (!Array.isArray(states)) {
|
|
return []
|
|
}
|
|
return ROW_STATES.filter((state) => states.includes(state))
|
|
}
|
|
|
|
function cellClass(column: ListColumn, columnIndex: number, states: string[] = []): string {
|
|
// A deleted or disabled row mutes every cell; the first keeps weight 600.
|
|
const muted = states.includes('deleted') || states.includes('disabled')
|
|
if (columnIndex === 0) {
|
|
return muted ? 'font-semibold text-muted' : 'font-semibold'
|
|
}
|
|
return muted || relation.value || column.relation ? 'text-muted' : ''
|
|
}
|
|
|
|
/** First-cell text of a row with states: struck through, danger or muted. */
|
|
function stateTextClass(states: string[]): string[] {
|
|
return [
|
|
'min-w-0 truncate',
|
|
states.includes('deleted') ? 'line-through' : 'no-underline',
|
|
states.includes('negative') ? 'text-danger' : states.includes('deleted') || states.includes('disabled') ? 'text-muted' : 'text-text',
|
|
]
|
|
}
|
|
|
|
const span = computed(() =>
|
|
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
|
|
)
|
|
|
|
function openFrom(event: MouseEvent, row: AdminRecord): void {
|
|
emit('open', row, event.currentTarget as HTMLElement | null)
|
|
}
|
|
|
|
// Varied skeleton bar widths, as in the design.
|
|
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
|
|
</script>
|
|
|
|
<template>
|
|
<div class="overflow-x-auto">
|
|
<table :class="relation ? 'min-w-[480px]' : 'min-w-[640px]'" class="w-full border-collapse text-left">
|
|
<thead class="sticky top-0 z-10">
|
|
<tr class="h-row-head border-y border-border bg-subtle">
|
|
<th v-if="selectable" scope="col" data-select class="w-[52px] pl-5">
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
:aria-checked="headerState"
|
|
:aria-label="t('backend::lang.list.select_all')"
|
|
:disabled="loading || pageIds.length === 0"
|
|
:class="headerState === 'false' ? 'border-border-strong bg-surface' : 'border-primary bg-primary text-on-primary'"
|
|
class="flex size-[18px] items-center justify-center rounded-checkbox border disabled:opacity-60"
|
|
@click="toggleAll"
|
|
>
|
|
<Minus v-if="headerState === 'mixed'" :size="14" aria-hidden="true" />
|
|
<Check v-else-if="headerState === 'true'" :size="14" aria-hidden="true" />
|
|
</button>
|
|
</th>
|
|
<th
|
|
v-for="column in columns"
|
|
:key="column.key"
|
|
scope="col"
|
|
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
|
|
:class="sortState(column) ? 'text-text' : 'text-muted'"
|
|
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
|
|
>
|
|
<button
|
|
v-if="canSort(column)"
|
|
type="button"
|
|
:data-sort="column.key"
|
|
class="inline-flex items-center gap-1 font-semibold"
|
|
@click="emit('sort', column.key)"
|
|
>
|
|
{{ column.label }}
|
|
<ArrowUp v-if="sortState(column) === 'ascending'" :size="14" aria-hidden="true" />
|
|
<ArrowDown v-else-if="sortState(column) === 'descending'" :size="14" aria-hidden="true" />
|
|
</button>
|
|
<template v-else>{{ column.label }}</template>
|
|
</th>
|
|
<td v-if="trailing" class="w-[56px] pr-5" />
|
|
</tr>
|
|
</thead>
|
|
<tbody :aria-busy="loading ? 'true' : undefined">
|
|
<template v-if="loading">
|
|
<tr v-for="n in skeletonRows" :key="`skeleton-${n}`" data-skeleton :class="rowHeight" class="border-b border-border">
|
|
<td v-if="selectable" class="pl-5">
|
|
<span class="block size-[18px] rounded-checkbox bg-skel" />
|
|
</td>
|
|
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
|
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
|
</td>
|
|
<td v-if="trailing" class="pr-5" />
|
|
</tr>
|
|
</template>
|
|
<tr v-else-if="rows.length === 0">
|
|
<td :colspan="span" class="px-5 py-20 text-center">
|
|
<slot name="empty">
|
|
<span class="text-muted">{{ t('backend::lang.list.no_records') }}</span>
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
<template v-else>
|
|
<tr
|
|
v-for="(row, index) in rows"
|
|
:key="rowKey(row, index)"
|
|
:class="[
|
|
rowHeight,
|
|
isSelected(row) ? 'bg-sel' : linkOf(row) || openable ? 'hover:bg-hover' : '',
|
|
linkOf(row) || openable ? 'cursor-pointer' : '',
|
|
]"
|
|
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
|
|
:data-row-states="statesOf(row).length > 0 ? statesOf(row).join(' ') : undefined"
|
|
class="border-b border-border transition-colors duration-150 ease-out"
|
|
@click="openRow($event, row)"
|
|
>
|
|
<td v-if="selectable" data-select class="pl-5">
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
:aria-checked="isSelected(row) ? 'true' : 'false'"
|
|
:aria-label="t('backend::lang.list.select_row')"
|
|
:class="isSelected(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
|
|
class="flex size-[18px] items-center justify-center rounded-checkbox border"
|
|
@click="toggleRow(row)"
|
|
>
|
|
<Check v-if="isSelected(row)" :size="14" aria-hidden="true" />
|
|
</button>
|
|
</td>
|
|
<td
|
|
v-for="(column, columnIndex) in columns"
|
|
:key="column.key"
|
|
:class="cellClass(column, columnIndex, statesOf(row))"
|
|
class="px-3.5 first:pl-5 last:pr-5"
|
|
>
|
|
<span v-if="relation && columnIndex === 0" class="flex items-center gap-3">
|
|
<span
|
|
data-avatar
|
|
class="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold text-text"
|
|
aria-hidden="true"
|
|
>{{ initials(row[column.key]) }}</span
|
|
>
|
|
<button
|
|
v-if="openable"
|
|
type="button"
|
|
data-row-open
|
|
class="min-w-0 truncate text-left font-semibold text-text"
|
|
@click="openFrom($event, row)"
|
|
>
|
|
<CellValue :column="column" :value="row[column.key]" />
|
|
</button>
|
|
<CellValue v-else :column="column" :value="row[column.key]" />
|
|
</span>
|
|
<span v-else-if="columnIndex === 0 && statesOf(row).length > 0" class="flex items-center gap-2">
|
|
<RouterLink v-if="linkOf(row)" :to="linkOf(row)!" :class="stateTextClass(statesOf(row))">
|
|
<CellValue :column="column" :value="row[column.key]" />
|
|
</RouterLink>
|
|
<span v-else :class="stateTextClass(statesOf(row))">
|
|
<CellValue :column="column" :value="row[column.key]" />
|
|
</span>
|
|
<RowStateBadges :states="statesOf(row)" :labels="stateLabels" />
|
|
</span>
|
|
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
|
<CellValue :column="column" :value="row[column.key]" />
|
|
</RouterLink>
|
|
<CellValue v-else :column="column" :value="row[column.key]" />
|
|
</td>
|
|
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
|
|
<slot name="trailing" :row="row" />
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|