Files
Modrinth-plus/packages/ui/src/stories/base/Table.stories.ts
Calum H. 78aca7e5c0 feat: shared components for worlds + p2p instances (#5135)
* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: add ContentModpackCard

* fix: extract types

* feat: selection v-model

* add show icon in selected for combobox with stories

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: fix gap + border issues on last elm

* fix: use TeleportOverflowMenu

* fix: hasUpdate type

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* remove install to play modal from ui package

* pnpm prepr

* feat: reusable table component

* feat: add column width prop for table and fix stories

* feat: add table overflow menu story example

* feat: add surface-1.5 and use in table

* chore: export table in index

* fix: allow more loose typing on columns

* feat: update table component to derive key from column instead of data

* feat: surface 1.5 for oled + refactor story for contentcardtable + yeet sorting funcs

* fix: lint

* feat: add no padding story for new modal

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-01-28 20:09:24 +00:00

456 lines
12 KiB
TypeScript

import { EditIcon, MoreVerticalIcon, TrashIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Badge from '../../components/base/Badge.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import OverflowMenu from '../../components/base/OverflowMenu.vue'
import Table from '../../components/base/Table.vue'
interface User {
id: string
name: string
email: string
status: 'active' | 'inactive' | 'pending'
role: string
}
const sampleUsers: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'active', role: 'Admin' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'inactive', role: 'User' },
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', status: 'pending', role: 'Editor' },
{ id: '4', name: 'Alice Brown', email: 'alice@example.com', status: 'active', role: 'User' },
{
id: '5',
name: 'Charlie Wilson',
email: 'charlie@example.com',
status: 'active',
role: 'Admin',
},
]
const meta = {
title: 'Base/Table',
// @ts-ignore - Generic component
component: Table,
} satisfies Meta<typeof Table>
export default meta
export const Default: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data" />
`,
}),
}
export const WithSelection: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
const selectedIds = ref<string[]>([])
return { columns, data, selectedIds }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
/>
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
</div>
`,
}),
}
export const WithSorting: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
{ key: 'email', label: 'Email', enableSorting: true },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role', enableSorting: true },
]
const data = sampleUsers
const sortColumn = ref<string | undefined>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
function handleSort(column: string, direction: 'asc' | 'desc') {
console.log(`Sorting by ${column} ${direction}`)
}
return { columns, data, sortColumn, sortDirection, handleSort }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
@sort="handleSort"
/>
<p class="text-secondary">Sort: {{ sortColumn }} ({{ sortDirection }})</p>
</div>
`,
}),
}
export const WithColumnAlignment: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name', align: 'left' as const },
{ key: 'email', label: 'Email', align: 'center' as const },
{ key: 'status', label: 'Status', align: 'center' as const },
{ key: 'role', label: 'Role', align: 'right' as const },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data" />
`,
}),
}
export const WithCustomCellSlots: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', align: 'center' as const, width: '20%' },
{ key: 'role', label: 'Role', width: '10%' },
]
const data = sampleUsers
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
return { columns, data, statusColor }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-name="{ value, row }">
<div class="font-semibold">{{ value }}</div>
</template>
<template #cell-email="{ value }">
<a :href="'mailto:' + value" class="text-brand hover:underline">{{ value }}</a>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
</Table>
`,
}),
}
export const WithCustomHeaderSlots: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #header-name="{ column }">
<span class="text-brand font-bold uppercase">{{ column.label }} ✨</span>
</template>
<template #header-status="{ column }">
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green"></span>
{{ column.label }}
</span>
</template>
</Table>
`,
}),
}
export const WithActionsColumn: StoryObj = {
args: {},
render: () => ({
components: { Table, ButtonStyled, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' },
{ key: 'actions', label: 'Actions', align: 'right' as const },
]
const data = sampleUsers
function handleEdit(row: User) {
alert(`Edit user: ${row.name}`)
}
function handleDelete(row: User) {
alert(`Delete user: ${row.name}`)
}
return { columns, data, handleEdit, handleDelete }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<ButtonStyled color="brand" type="transparent" @click="handleEdit(row)">
<button class="flex items-center gap-1">
<EditIcon class="size-4" />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red" type="transparent" @click="handleDelete(row)">
<button class="flex items-center gap-1">
<TrashIcon class="size-4" />
Delete
</button>
</ButtonStyled>
</div>
</template>
</Table>
`,
}),
}
export const FullFeatured: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge, ButtonStyled, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
{ key: 'email', label: 'Email', enableSorting: true },
{ key: 'status', label: 'Status', align: 'center' as const, width: '100px' },
{ key: 'role', label: 'Role', enableSorting: true },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: '200px' },
]
const data = sampleUsers
const selectedIds = ref<string[]>([])
const sortColumn = ref<string | undefined>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
function handleSort(column: string, direction: 'asc' | 'desc') {
console.log(`Sorting by ${column} ${direction}`)
}
function handleEdit(row: User) {
alert(`Edit user: ${row.name}`)
}
function handleDelete(row: User) {
alert(`Delete user: ${row.name}`)
}
return {
columns,
data,
selectedIds,
sortColumn,
sortDirection,
statusColor,
handleSort,
handleEdit,
handleDelete,
}
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
@sort="handleSort"
>
<template #cell-name="{ value }">
<span class="font-semibold">{{ value }}</span>
</template>
<template #cell-email="{ value }">
<a :href="'mailto:' + value" class="text-brand hover:underline">{{ value }}</a>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<ButtonStyled color="brand" type="transparent" @click="handleEdit(row)">
<button class="flex items-center gap-1">
<EditIcon class="size-4" />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red" type="transparent" @click="handleDelete(row)">
<button class="flex items-center gap-1">
<TrashIcon class="size-4" />
Delete
</button>
</ButtonStyled>
</div>
</template>
</Table>
<div class="flex gap-4 text-secondary text-sm">
<span>Selected: {{ selectedIds.length }} items</span>
<span>Sort: {{ sortColumn }} ({{ sortDirection }})</span>
</div>
</div>
`,
}),
}
export const WithOverflowMenu: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge, ButtonStyled, OverflowMenu, MoreVerticalIcon, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', align: 'center' as const, width: '20%' },
{ key: 'role', label: 'Role' },
{ key: 'actions', label: '', width: '48px' },
]
const data = sampleUsers
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
const getMenuOptions = (row: User) => [
{
id: 'edit',
action: () => alert(`Edit user: ${row.name}`),
},
{
id: 'duplicate',
action: () => alert(`Duplicate user: ${row.name}`),
},
{ divider: true },
{
id: 'delete',
color: 'red' as const,
hoverFilled: true,
action: () => alert(`Delete user: ${row.name}`),
},
]
return { columns, data, statusColor, getMenuOptions }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-name="{ value }">
<span class="font-semibold">{{ value }}</span>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="getMenuOptions(row)"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="size-4" aria-hidden="true" />
Edit
</template>
<template #duplicate>
<EditIcon class="size-4" aria-hidden="true" />
Duplicate
</template>
<template #delete>
<TrashIcon class="size-4" aria-hidden="true" />
Delete
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</Table>
`,
}),
}