You've already forked AstralRinth
dfe12d4ecb
* fix: clicking users in table in app takes you to blank page instead of website * fix: wrong loader icon on server panel * fix: surface var misalignment * fix: password managers still detecting username field as something to autofill * feat: show users on backupitem components * feat: seperators for filter sections * fix: lint + change remove -> revoke * fix: copy * feat: align copy
390 lines
12 KiB
Vue
390 lines
12 KiB
Vue
<template>
|
|
<div class="overflow-hidden rounded-2xl border border-solid border-surface-4">
|
|
<div
|
|
v-if="hasHeaderSlot"
|
|
class="border-solid border-0 border-b border-surface-4 bg-surface-3 p-4"
|
|
>
|
|
<slot name="header" />
|
|
</div>
|
|
<div class="overflow-x-auto overflow-y-hidden">
|
|
<table
|
|
class="w-full table-fixed border-separate border-spacing-0 border-surface-4"
|
|
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
|
>
|
|
<colgroup>
|
|
<col v-if="showSelection" class="w-12" />
|
|
<col
|
|
v-for="column in columns"
|
|
:key="column.key"
|
|
:style="column.width ? { width: column.width } : undefined"
|
|
/>
|
|
</colgroup>
|
|
<thead class="">
|
|
<tr class="bg-surface-3">
|
|
<th v-if="showSelection" class="w-12">
|
|
<Checkbox
|
|
:model-value="allSelected"
|
|
:indeterminate="someSelected"
|
|
class="shrink-0 p-4 focus-visible:!outline-none"
|
|
@update:model-value="toggleSelectAll"
|
|
/>
|
|
</th>
|
|
<th
|
|
v-for="column in columns"
|
|
:key="column.key"
|
|
class="h-14 first:pl-4 last:pr-4"
|
|
:class="[
|
|
`text-${column.align ?? 'left'}`,
|
|
column.enableSorting ? 'cursor-pointer select-none' : '',
|
|
]"
|
|
:style="column.width ? { width: column.width } : undefined"
|
|
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
|
>
|
|
<slot :name="`header-${column.key}`" :column="column">
|
|
<span
|
|
v-if="column.label || column.enableSorting"
|
|
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
|
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
|
|
>
|
|
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
|
<template v-if="column.enableSorting">
|
|
<ChevronUpIcon
|
|
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
|
class="size-4 shrink-0"
|
|
/>
|
|
<ChevronDownIcon
|
|
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
|
class="size-4 shrink-0"
|
|
/>
|
|
</template>
|
|
</span>
|
|
</slot>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<TransitionGroup
|
|
v-if="rowTransitionName && !virtualized"
|
|
:name="rowTransitionName"
|
|
tag="tbody"
|
|
>
|
|
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
|
|
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
|
<slot name="empty-state">
|
|
<div class="text-secondary flex h-64 items-center justify-center">
|
|
No data available.
|
|
</div>
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
<template v-else>
|
|
<tr
|
|
v-for="(row, rowIndex) in renderedRows"
|
|
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
|
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
|
|
>
|
|
<td
|
|
v-if="showSelection"
|
|
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
|
>
|
|
<Checkbox
|
|
:model-value="isSelected(row)"
|
|
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
|
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
|
|
/>
|
|
</td>
|
|
<td
|
|
v-for="column in columns"
|
|
:key="column.key"
|
|
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
|
:class="`text-${column.align ?? 'left'}`"
|
|
>
|
|
<slot
|
|
:name="`cell-${column.key}`"
|
|
:row="row"
|
|
:value="row[column.key]"
|
|
:column="column"
|
|
:index="getAbsoluteRowIndex(rowIndex)"
|
|
>
|
|
{{ row[column.key] ?? '' }}
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</TransitionGroup>
|
|
<tbody v-else :ref="setListContainer">
|
|
<tr v-if="data.length === 0" class="bg-surface-2">
|
|
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-4 p-0">
|
|
<slot name="empty-state">
|
|
<div class="text-secondary flex h-64 items-center justify-center">
|
|
No data available.
|
|
</div>
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
<template v-else>
|
|
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
|
<td
|
|
:colspan="columnSpan"
|
|
class="border-0 p-0"
|
|
:style="{ height: `${topSpacerHeight}px` }"
|
|
></td>
|
|
</tr>
|
|
<tr
|
|
v-for="(row, rowIndex) in renderedRows"
|
|
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
|
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
|
|
>
|
|
<td
|
|
v-if="showSelection"
|
|
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
|
|
>
|
|
<Checkbox
|
|
:model-value="isSelected(row)"
|
|
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
|
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
|
|
/>
|
|
</td>
|
|
<td
|
|
v-for="column in columns"
|
|
:key="column.key"
|
|
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
|
|
:class="`text-${column.align ?? 'left'}`"
|
|
>
|
|
<slot
|
|
:name="`cell-${column.key}`"
|
|
:row="row"
|
|
:value="row[column.key]"
|
|
:column="column"
|
|
:index="getAbsoluteRowIndex(rowIndex)"
|
|
>
|
|
{{ row[column.key] ?? '' }}
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
|
<td
|
|
:colspan="columnSpan"
|
|
class="border-0 p-0"
|
|
:style="{ height: `${bottomSpacerHeight}px` }"
|
|
></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script
|
|
setup
|
|
lang="ts"
|
|
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
|
|
>
|
|
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
|
import { computed, ref, toRef, useSlots } from 'vue'
|
|
|
|
import { useVirtualScroll } from '../../composables/virtual-scroll'
|
|
import Checkbox from './Checkbox.vue'
|
|
|
|
export type TableColumnAlign = 'left' | 'center' | 'right'
|
|
export type SortDirection = 'asc' | 'desc'
|
|
|
|
/**
|
|
* Defines a table column configuration.
|
|
* @template K - The column key is used to get cell data of row
|
|
*/
|
|
export interface TableColumn<K extends string = string> {
|
|
key: K
|
|
label?: string
|
|
align?: TableColumnAlign
|
|
enableSorting?: boolean
|
|
defaultSortDirection?: SortDirection
|
|
/**
|
|
* CSS width value for the column.
|
|
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
|
|
*/
|
|
width?: string
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
columns: TableColumn<K>[]
|
|
data: T[] /* Row data for table */
|
|
showSelection?: boolean
|
|
rowKey?: keyof T /* The key used to uniquely identify each row */
|
|
selectionKey?: keyof T /* The key used to identify selectable rows */
|
|
selectionData?: T[] /* The complete selectable data set when data is paginated */
|
|
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
|
|
virtualized?: boolean
|
|
virtualRowHeight?: number
|
|
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
|
|
rowTransitionName?: string
|
|
/**
|
|
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
|
|
*/
|
|
tableMinWidth?: string
|
|
}>(),
|
|
{
|
|
showSelection: false,
|
|
rowKey: 'id' as keyof T,
|
|
virtualized: false,
|
|
virtualRowHeight: 56,
|
|
virtualBufferSize: 5,
|
|
},
|
|
)
|
|
|
|
const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
|
|
const sortColumn = defineModel<string | undefined>('sortColumn')
|
|
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
|
|
const slots = useSlots()
|
|
const selectionAnchorId = ref<unknown>()
|
|
const hasHeaderSlot = computed(() => Boolean(slots.header))
|
|
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
|
|
|
|
const {
|
|
listContainer,
|
|
totalHeight,
|
|
visibleRange,
|
|
visibleTop: topSpacerHeight,
|
|
visibleItems,
|
|
} = useVirtualScroll(toRef(props, 'data'), {
|
|
itemHeight: props.virtualRowHeight,
|
|
bufferSize: props.virtualBufferSize,
|
|
enabled: toRef(props, 'virtualized'),
|
|
})
|
|
|
|
const renderedRows = computed(() => (props.virtualized ? visibleItems.value : props.data))
|
|
const bottomSpacerHeight = computed(() => {
|
|
if (!props.virtualized) {
|
|
return 0
|
|
}
|
|
|
|
return Math.max(
|
|
0,
|
|
totalHeight.value - topSpacerHeight.value - renderedRows.value.length * props.virtualRowHeight,
|
|
)
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
sort: [column: string, direction: SortDirection]
|
|
}>()
|
|
|
|
const selectableRows = computed(() => props.selectionData ?? props.data)
|
|
const selectableRowIds = computed(
|
|
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
|
|
)
|
|
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
|
const selectedSelectableIdCount = computed(() => {
|
|
let count = 0
|
|
for (const id of selectableRowIds.value) {
|
|
if (selectedIdSet.value.has(id)) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
})
|
|
const allSelected = computed(
|
|
() =>
|
|
selectableRowIds.value.length > 0 &&
|
|
selectedSelectableIdCount.value === selectableRowIds.value.length,
|
|
)
|
|
const someSelected = computed(
|
|
() =>
|
|
selectedSelectableIdCount.value > 0 &&
|
|
selectedSelectableIdCount.value < selectableRowIds.value.length,
|
|
)
|
|
|
|
function getRowId(row: T): unknown {
|
|
return row[props.rowKey as keyof T]
|
|
}
|
|
|
|
function getSelectionId(row: T): unknown {
|
|
return row[(props.selectionKey ?? props.rowKey) as keyof T]
|
|
}
|
|
|
|
function setListContainer(element: unknown) {
|
|
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
|
|
}
|
|
|
|
function getAbsoluteRowIndex(rowIndex: number): number {
|
|
return props.virtualized ? visibleRange.value.start + rowIndex : rowIndex
|
|
}
|
|
|
|
function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
|
|
const rowId = getRowId(row)
|
|
if (typeof rowId === 'string' || typeof rowId === 'number' || typeof rowId === 'symbol') {
|
|
return rowId
|
|
}
|
|
|
|
return rowIndex
|
|
}
|
|
|
|
function getRowClass(rowIndex: number): string {
|
|
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
|
|
}
|
|
|
|
function isSelected(row: T): boolean {
|
|
return selectedIdSet.value.has(getSelectionId(row))
|
|
}
|
|
|
|
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
|
|
const id = getSelectionId(row)
|
|
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
|
|
const anchorIndex = selectableRowIds.value.findIndex(
|
|
(selectableId) => selectableId === selectionAnchorId.value,
|
|
)
|
|
|
|
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
|
|
const startIndex = Math.min(rowIndex, anchorIndex)
|
|
const endIndex = Math.max(rowIndex, anchorIndex)
|
|
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
|
|
|
|
if (selectRow) {
|
|
const nextSelectedIds = [...selectedIds.value]
|
|
const nextSelectedIdSet = new Set(nextSelectedIds)
|
|
for (const rangeId of rangeIds) {
|
|
if (!nextSelectedIdSet.has(rangeId)) {
|
|
nextSelectedIds.push(rangeId)
|
|
nextSelectedIdSet.add(rangeId)
|
|
}
|
|
}
|
|
selectedIds.value = nextSelectedIds
|
|
} else {
|
|
const rangeIdSet = new Set(rangeIds)
|
|
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
|
|
}
|
|
} else {
|
|
selectedIds.value = selectRow
|
|
? [...selectedIds.value, id]
|
|
: selectedIds.value.filter((selectedId) => selectedId !== id)
|
|
}
|
|
|
|
selectionAnchorId.value = id
|
|
}
|
|
|
|
function toggleSelectAll(selectAll: boolean) {
|
|
selectionAnchorId.value = undefined
|
|
if (selectAll) {
|
|
selectedIds.value = [...selectableRowIds.value]
|
|
} else {
|
|
selectedIds.value = []
|
|
}
|
|
}
|
|
|
|
function handleSort(columnKey: string) {
|
|
const column = props.columns.find((column) => column.key === columnKey)
|
|
const defaultDirection = column?.defaultSortDirection ?? 'asc'
|
|
const newDirection: SortDirection =
|
|
sortColumn.value === columnKey && sortDirection.value === defaultDirection
|
|
? getOppositeSortDirection(defaultDirection)
|
|
: defaultDirection
|
|
sortColumn.value = columnKey
|
|
sortDirection.value = newDirection
|
|
emit('sort', columnKey, newDirection)
|
|
}
|
|
|
|
function getOppositeSortDirection(direction: SortDirection): SortDirection {
|
|
return direction === 'asc' ? 'desc' : 'asc'
|
|
}
|
|
</script>
|