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>
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
SettingsIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Combobox from '../../components/base/Combobox.vue'
|
||||
@@ -44,3 +52,61 @@ export const Disabled: Story = {
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const IconSlot: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
listbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const IconSlotSearchable: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search actions...',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOption: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: '1', label: 'Option 1' },
|
||||
{ value: '2', label: 'Option 2' },
|
||||
{ value: '3', label: 'Option 3' },
|
||||
],
|
||||
modelValue: '2',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOptionAndIcon: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
],
|
||||
modelValue: 'favorite',
|
||||
showIconInSelected: true,
|
||||
},
|
||||
}
|
||||
|
||||
455
packages/ui/src/stories/base/Table.stories.ts
Normal file
455
packages/ui/src/stories/base/Table.stories.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Story = StoryObj<typeof meta>
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
modelValue: false,
|
||||
small: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,11 +30,26 @@ export const Disabled: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
modelValue: false,
|
||||
small: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const SmallChecked: Story = {
|
||||
args: {
|
||||
modelValue: true,
|
||||
small: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { Toggle },
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<span style="font-weight: 600;">Default Size</span>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<Toggle :model-value="false" /> Off
|
||||
</div>
|
||||
@@ -43,6 +59,16 @@ export const AllStates: Story = {
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<Toggle :model-value="false" :disabled="true" /> Disabled
|
||||
</div>
|
||||
<span style="font-weight: 600; margin-top: 1rem;">Small Size</span>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<Toggle :model-value="false" :small="true" /> Off
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<Toggle :model-value="true" :small="true" /> On
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<Toggle :model-value="false" :small="true" :disabled="true" /> Disabled
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user