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:
Calum H.
2026-01-28 20:09:24 +00:00
committed by GitHub
parent 728f8db7b9
commit 78aca7e5c0
52 changed files with 4097 additions and 939 deletions

View File

@@ -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,
},
}

View 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>
`,
}),
}

View File

@@ -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>
`,
}),