Add shot table column lock with two-pane horizontal scroll
Add a lock toggle to the shot table toolbar that freezes the checkbox, thumbnail, and shot name columns. When locked, the table splits into a fixed left pane and a horizontally scrollable right pane so the scrollbar spans only the movable columns. Constrain the table to fill the viewport height so the scrollbar stays at the screen bottom, and add min-w-0 to the layout to keep horizontal overflow inside the table instead of the page. Also fix the asset Task Status filter to render task type groups by using the object-aware TaskStatusBadge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,107 @@
|
||||
<template>
|
||||
<div class="space-y-2 px-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<div class="px-4 h-full flex flex-col">
|
||||
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||
<template v-if="lockColumns">
|
||||
<div v-if="hasRows" class="flex h-full">
|
||||
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
|
||||
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
|
||||
<table class="caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in frozenHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:style="frozenWidth(header.column.id)"
|
||||
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@dblclick="emit('row-dblclick', row.original)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in frozenCells(row)"
|
||||
:key="cell.id"
|
||||
:style="frozenWidth(cell.column.id)"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
|
||||
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
|
||||
<table class="min-w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in movableHeaders(headerGroup)"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'actions' ? 'w-12' : '',
|
||||
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@dblclick="emit('row-dblclick', row.original)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in movableCells(row)"
|
||||
:key="cell.id"
|
||||
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No results.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unlocked: standard single table -->
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
@@ -31,7 +131,7 @@
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
:class="{
|
||||
'bg-muted/30': row.getIsSelected(),
|
||||
'table-row-selectable': true,
|
||||
'selecting': isRangeSelecting
|
||||
@@ -69,13 +169,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type HeaderGroup,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
@@ -95,10 +197,51 @@ interface Props {
|
||||
sorting: SortingState
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
lockColumns?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Frozen (locked) columns and their fixed widths (px), in column order.
|
||||
const FROZEN: Record<string, number> = {
|
||||
select: 48,
|
||||
thumbnail: 96,
|
||||
name: 200,
|
||||
}
|
||||
|
||||
const isFrozen = (id: string) => id in FROZEN
|
||||
|
||||
const frozenWidth = (id: string) => {
|
||||
const w = FROZEN[id]
|
||||
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
|
||||
}
|
||||
|
||||
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
|
||||
// respect column visibility; frozen ones are always shown.
|
||||
const frozenHeaders = (group: HeaderGroup<Shot>) =>
|
||||
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const movableHeaders = (group: HeaderGroup<Shot>) =>
|
||||
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
|
||||
const frozenCells = (row: Row<Shot>) =>
|
||||
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
|
||||
const movableCells = (row: Row<Shot>) =>
|
||||
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
|
||||
|
||||
const hasRows = computed(() => table.getRowModel().rows.length > 0)
|
||||
|
||||
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
|
||||
const leftPane = ref<HTMLElement>()
|
||||
const rightPane = ref<HTMLElement>()
|
||||
let syncing = false
|
||||
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
|
||||
if (syncing || !from || !to) return
|
||||
syncing = true
|
||||
to.scrollTop = from.scrollTop
|
||||
requestAnimationFrame(() => { syncing = false })
|
||||
}
|
||||
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
|
||||
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
@@ -280,4 +423,13 @@ watch(
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user