Files
Rocketmc/packages/ui/src/stories/instances/ContentCardTable.stories.ts
T
Calum H. 7d92e4ec7f feat: content tab rewrite for worlds (#5136)
* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* 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: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* 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

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
2026-03-12 13:24:32 -07:00

850 lines
22 KiB
TypeScript

import { DownloadIcon, EyeIcon, FolderOpenIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentCardTable from '../../layouts/shared/content-tab/components/ContentCardTable.vue'
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
// Sample data
const sodiumItem: ContentCardTableItem = {
id: 'AANobbMI',
project: {
id: 'AANobbMI',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
version: {
id: '59wygFUQ',
version_number: 'mc1.21.11-0.8.2-fabric',
file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar',
},
owner: {
id: 'DzLrfrbK',
name: 'IMS',
avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
type: 'user',
},
enabled: true,
}
const modMenuItem: ContentCardTableItem = {
id: 'mOgUt4GM',
project: {
id: 'mOgUt4GM',
slug: 'modmenu',
title: 'Mod Menu',
icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
},
version: {
id: 'QuU0ciaR',
version_number: '16.0.0',
file_name: 'modmenu-16.0.0.jar',
},
owner: {
id: 'u2',
name: 'Prospector',
type: 'user',
},
enabled: true,
}
const fabricApiItem: ContentCardTableItem = {
id: 'P7dR8mSH',
project: {
id: 'P7dR8mSH',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
version: {
id: 'Lwa1Q6e4',
version_number: '0.141.3+26.1',
file_name: 'fabric-api-0.141.3+26.1.jar',
},
owner: {
id: 'BZoBsPo6',
name: 'FabricMC',
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
type: 'organization',
},
enabled: false,
}
const emfItem: ContentCardTableItem = {
id: 'emf123',
project: {
id: 'emf123',
slug: 'entity-model-features',
title: '[EMF] Entity Model Features',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
version: {
id: 'v1',
version_number: '2.4.1',
file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar',
},
owner: {
id: 'u1',
name: 'Traben',
type: 'user',
},
enabled: true,
}
const etfItem: ContentCardTableItem = {
id: 'etf456',
project: {
id: 'etf456',
slug: 'entity-texture-features',
title: '[ETF] Entity Texture Features',
icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
},
version: {
id: 'v2',
version_number: '6.2.9',
file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
},
owner: {
id: 'u1',
name: 'Traben',
type: 'user',
},
enabled: true,
}
const importedModItem: ContentCardTableItem = {
id: 'imported123',
project: {
id: 'imported123',
slug: 'import-mod',
title: 'Import mod',
icon_url: undefined,
},
version: {
id: 'v3',
version_number: 'Unknown',
file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
},
enabled: false,
}
// Edge case items
const longNameItem: ContentCardTableItem = {
id: 'long-name',
project: {
id: 'long-name',
slug: 'very-long-project-name',
title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod for Minecraft',
icon_url: sodiumItem.project.icon_url,
},
version: {
id: 'v1',
version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0',
file_name: 'Entity_model_features_fabric_1.21.1-2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0.jar',
},
owner: {
id: 'u1',
name: 'Traben',
type: 'user',
},
enabled: true,
}
const noOwnerAvatarItem: ContentCardTableItem = {
id: 'no-avatar',
project: {
id: 'no-avatar',
slug: 'no-avatar-mod',
title: 'Mod Without Owner Avatar',
icon_url: modMenuItem.project.icon_url,
},
version: {
id: 'v1',
version_number: '1.0.0',
file_name: 'no-avatar-mod-1.0.0.jar',
},
owner: {
id: 'u1',
name: 'Anonymous User',
avatar_url: undefined,
type: 'user',
},
enabled: true,
}
const updateAvailableItem: ContentCardTableItem = {
id: 'update-available',
project: {
id: 'update-available',
slug: 'outdated-mod',
title: 'Outdated Mod',
icon_url: fabricApiItem.project.icon_url,
},
version: {
id: 'v1',
version_number: '1.0.0',
file_name: 'outdated-mod-1.0.0.jar',
},
owner: fabricApiItem.owner,
enabled: true,
hasUpdate: true,
}
const sampleItems: ContentCardTableItem[] = [sodiumItem, modMenuItem, fabricApiItem]
const figmaDesignItems: ContentCardTableItem[] = [emfItem, etfItem, importedModItem]
// Comprehensive items showing all possible states
const allStatesItems: ContentCardTableItem[] = [
{ ...sodiumItem, enabled: true, hasUpdate: false },
{ ...modMenuItem, enabled: true, hasUpdate: true },
{ ...fabricApiItem, enabled: false },
longNameItem,
importedModItem,
noOwnerAvatarItem,
updateAvailableItem,
{ ...emfItem, disabled: true, enabled: false },
]
const meta = {
title: 'Instances/ContentCardTable',
component: ContentCardTable,
parameters: {
layout: 'padded',
},
argTypes: {
items: {
control: 'object',
description: 'Array of items to display in the table',
},
showSelection: {
control: 'boolean',
description: 'Show checkboxes for selection',
},
sortable: {
control: 'boolean',
description: 'Enable column sorting',
},
sortBy: {
control: 'select',
options: ['project', 'version', undefined],
description: 'Current sort column',
},
sortDirection: {
control: 'select',
options: ['asc', 'desc'],
description: 'Sort direction',
},
},
} satisfies Meta<typeof ContentCardTable>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// Basic Stories
// ============================================
export const Default: Story = {
args: {
items: sampleItems,
},
}
/**
* Comprehensive story showing all possible item states in one view:
* - Normal enabled item
* - Item with update available
* - Disabled toggle (enabled: false)
* - Long project name and version (truncation)
* - No project icon
* - No owner avatar
* - Item with hasUpdate flag
* - Fully disabled item (disabled: true)
*/
export const AllStates: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
const items = ref<ContentCardTableItem[]>(allStatesItems)
return { items }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<div class="text-sm text-secondary space-y-1">
<p>This story demonstrates all possible item states:</p>
<ul class="list-disc list-inside ml-2">
<li>Sodium - Normal enabled item</li>
<li>Mod Menu - Has update available (green button)</li>
<li>Fabric API - Toggle off (enabled: false)</li>
<li>EMF - Long name/version (truncation)</li>
<li>Import mod - No project icon</li>
<li>No avatar - Owner without avatar</li>
<li>Outdated Mod - hasUpdate flag</li>
<li>ETF - Fully disabled (disabled: true, grayed out)</li>
</ul>
</div>
<ContentCardTable
:items="items"
show-selection
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
@update="(id) => console.log('Update', id)"
/>
</div>
`,
}),
}
/**
* Shows items with update available - displays green download button
*/
export const WithUpdatesAvailable: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
const items: ContentCardTableItem[] = [
{ ...sodiumItem, hasUpdate: true },
{ ...modMenuItem, hasUpdate: true },
{ ...fabricApiItem, hasUpdate: false },
]
return { items }
},
template: /*html*/ `
<ContentCardTable
:items="items"
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
@update="(id) => console.log('Update clicked', id)"
/>
`,
}),
}
/**
* Shows difference between user and organization owners
*/
export const UserVsOrganizationOwners: Story = {
args: {
items: [
{ ...sodiumItem }, // User owner (circular avatar)
{ ...fabricApiItem }, // Organization owner (rounded + icon)
],
},
}
/**
* Edge cases: long names, missing icons, missing avatars
*/
export const EdgeCases: Story = {
args: {
items: [longNameItem, importedModItem, noOwnerAvatarItem],
},
}
export const FigmaDesign: Story = {
args: {
items: figmaDesignItems,
showSelection: true,
},
render: (args) => ({
components: { ContentCardTable },
setup() {
const selectedIds = ref<string[]>([emfItem.id, etfItem.id])
return { args, selectedIds }
},
template: /*html*/ `
<ContentCardTable
v-bind="args"
v-model:selected-ids="selectedIds"
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
/>
`,
}),
}
export const WithSelection: Story = {
args: {
items: sampleItems,
showSelection: true,
},
render: (args) => ({
components: { ContentCardTable },
setup() {
const selectedIds = ref<string[]>([])
return { args, selectedIds }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<ContentCardTable
v-bind="args"
v-model:selected-ids="selectedIds"
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
/>
<div class="text-sm text-secondary">
Selected: <strong>{{ selectedIds.length }}</strong> items
<span v-if="selectedIds.length">({{ selectedIds.join(', ') }})</span>
</div>
</div>
`,
}),
}
export const WithSorting: Story = {
args: {
items: sampleItems,
sortable: true,
sortBy: 'project',
sortDirection: 'asc',
},
render: (args) => ({
components: { ContentCardTable },
setup() {
const sortBy = ref<'project' | 'version' | undefined>(args.sortBy)
const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc')
const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => {
sortBy.value = column
sortDirection.value = direction
console.log('Sort:', column, direction)
}
return { args, sortBy, sortDirection, handleSort }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<ContentCardTable
:items="args.items"
:sortable="args.sortable"
:sort-by="sortBy"
:sort-direction="sortDirection"
@sort="handleSort"
/>
<div class="text-sm text-secondary">
Sorted by: <strong>{{ sortBy || 'none' }}</strong> ({{ sortDirection }})
</div>
</div>
`,
}),
}
export const WithSelectionAndSorting: Story = {
args: {
items: sampleItems,
showSelection: true,
sortable: true,
sortBy: 'project',
sortDirection: 'asc',
},
render: (args) => ({
components: { ContentCardTable },
setup() {
const selectedIds = ref<string[]>([])
const sortBy = ref<'project' | 'version' | undefined>(args.sortBy)
const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc')
const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => {
sortBy.value = column
sortDirection.value = direction
}
return { args, selectedIds, sortBy, sortDirection, handleSort }
},
template: /*html*/ `
<ContentCardTable
:items="args.items"
:show-selection="args.showSelection"
:sortable="args.sortable"
:sort-by="sortBy"
:sort-direction="sortDirection"
v-model:selected-ids="selectedIds"
@sort="handleSort"
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
/>
`,
}),
}
// ============================================
// Action Stories
// ============================================
export const WithActions: Story = {
args: {
items: sampleItems,
showSelection: true,
'onUpdate:enabled': fn(),
onDelete: fn(),
onUpdate: fn(),
},
}
export const InteractiveActions: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
const items = ref<ContentCardTableItem[]>([
{ ...sodiumItem, enabled: true },
{ ...modMenuItem, enabled: true },
{ ...fabricApiItem, enabled: false },
])
const selectedIds = ref<string[]>([])
const handleToggle = (id: string, value: boolean) => {
const item = items.value.find((i) => i.id === id)
if (item) item.enabled = value
}
const handleDelete = (id: string) => {
items.value = items.value.filter((i) => i.id !== id)
selectedIds.value = selectedIds.value.filter((i) => i !== id)
}
const handleUpdate = (id: string) => {
console.log('Update available clicked for:', id)
}
return { items, selectedIds, handleToggle, handleDelete, handleUpdate }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<ContentCardTable
:items="items"
show-selection
v-model:selected-ids="selectedIds"
@update:enabled="handleToggle"
@delete="handleDelete"
@update="handleUpdate"
/>
<div class="flex gap-4 text-sm text-secondary">
<span>Items: <strong>{{ items.length }}</strong></span>
<span>Selected: <strong>{{ selectedIds.length }}</strong></span>
</div>
</div>
`,
}),
}
// ============================================
// Slot Stories
// ============================================
export const WithCustomItemButtons: Story = {
render: () => ({
components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon },
setup() {
return { items: sampleItems }
},
template: /*html*/ `
<ContentCardTable
:items="items"
show-selection
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
>
<template #itemButtonsLeft="{ item }">
<ButtonStyled v-tooltip="'Download'" circular type="transparent" color="green" color-fill="text">
<button @click="console.log('Download', item.id)">
<DownloadIcon class="size-5" />
</button>
</ButtonStyled>
</template>
<template #itemButtonsRight="{ item }">
<ButtonStyled v-tooltip="'View on Modrinth'" circular type="transparent">
<button @click="console.log('View', item.id)">
<EyeIcon class="size-5 text-secondary" />
</button>
</ButtonStyled>
<ButtonStyled v-tooltip="'Open folder'" circular type="transparent">
<button @click="console.log('Open folder', item.id)">
<FolderOpenIcon class="size-5 text-secondary" />
</button>
</ButtonStyled>
</template>
</ContentCardTable>
`,
}),
}
export const WithEmptyState: Story = {
args: {
items: [],
},
}
export const WithCustomEmptyState: Story = {
render: () => ({
components: { ContentCardTable, ButtonStyled },
template: /*html*/ `
<ContentCardTable :items="[]">
<template #empty>
<div class="flex flex-col items-center gap-4 py-8">
<span class="text-lg text-secondary">No mods installed</span>
<ButtonStyled color="green">
<button>Browse mods</button>
</ButtonStyled>
</div>
</template>
</ContentCardTable>
`,
}),
}
// ============================================
// State Stories
// ============================================
export const PerItemDisabled: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
// Simulates items being modified (e.g., toggled, deleted)
const items: ContentCardTableItem[] = [
{ ...sodiumItem, enabled: true },
{ ...modMenuItem, enabled: true, disabled: true }, // Being modified
{ ...fabricApiItem, enabled: false, disabled: true }, // Being modified
]
return { items }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<p class="text-sm text-secondary">
Items with <code>disabled: true</code> have all interactions disabled (simulating items being modified).
</p>
<ContentCardTable
:items="items"
show-selection
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
/>
</div>
`,
}),
}
export const SingleItem: Story = {
args: {
items: [sodiumItem],
showSelection: true,
},
}
export const ManyItems: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
const items = ref<ContentCardTableItem[]>(
Array.from({ length: 2000 }, (_, i) => ({
...sodiumItem,
id: `item-${i}`,
project: {
...sodiumItem.project,
title: `Mod ${i + 1}`,
},
version: {
...sodiumItem.version!,
version_number: `1.0.${i}`,
},
enabled: i % 3 !== 0,
})),
)
const selectedIds = ref<string[]>([])
const virtualized = ref(true)
const tableRef = ref<InstanceType<typeof ContentCardTable> | null>(null)
// Perf monitoring
const domNodes = ref(0)
let animationId: number
const updatePerf = () => {
// Count ContentCardItem elements (they have h-20 class)
if (tableRef.value?.$el) {
const container = tableRef.value.$el as HTMLElement
domNodes.value = container.querySelectorAll('.h-20').length
}
animationId = requestAnimationFrame(updatePerf)
}
onMounted(() => {
animationId = requestAnimationFrame(updatePerf)
})
onUnmounted(() => {
cancelAnimationFrame(animationId)
})
return {
items,
selectedIds,
virtualized,
tableRef,
domNodes,
}
},
template: /*html*/ `
<div>
<!-- Toggle -->
<div class="sticky top-0 z-10 mb-4 flex items-center gap-3 rounded-lg bg-surface-2 p-3">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
v-model="virtualized"
class="h-4 w-4 rounded"
/>
<span class="font-medium text-contrast">Enable Virtualization</span>
</label>
</div>
<!-- Perf Panel -->
<div class="fixed bottom-4 right-4 z-50 rounded-lg bg-surface-1 p-4 shadow-lg border border-surface-3 font-mono text-sm">
<div class="mb-2 font-semibold text-contrast">Performance</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<span class="text-secondary">Total Items:</span>
<span class="text-contrast">{{ items.length }}</span>
<span class="text-secondary">DOM Nodes:</span>
<span :class="domNodes > 100 ? 'text-red-500' : 'text-green-500'">{{ domNodes }}</span>
<span class="text-secondary">Mode:</span>
<span :class="virtualized ? 'text-green-500' : 'text-red-500'">{{ virtualized ? 'Virtual' : 'Full DOM' }}</span>
</div>
</div>
<ContentCardTable
ref="tableRef"
:items="items"
:virtualized="virtualized"
show-selection
v-model:selected-ids="selectedIds"
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
/>
</div>
`,
}),
}
// ============================================
// With Overflow Menu
// ============================================
export const WithOverflowMenu: Story = {
render: () => ({
components: { ContentCardTable },
setup() {
const items: ContentCardTableItem[] = [
{
...sodiumItem,
overflowOptions: [
{ id: 'view', action: () => console.log('View sodium') },
{ id: 'folder', action: () => console.log('Open folder') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
],
},
{
...modMenuItem,
overflowOptions: [
{ id: 'view', action: () => console.log('View modmenu') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
],
},
]
return { items }
},
template: /*html*/ `
<ContentCardTable
:items="items"
show-selection
@update:enabled="(id, val) => console.log('Toggle', id, val)"
@delete="(id) => console.log('Delete', id)"
>
<template #view>View on Modrinth</template>
<template #folder>Open folder</template>
<template #remove>Remove</template>
</ContentCardTable>
`,
}),
}
// ============================================
// Bulk Actions Demo
// ============================================
export const BulkActionsDemo: Story = {
render: () => ({
components: { ContentCardTable, ButtonStyled },
setup() {
const items = ref<ContentCardTableItem[]>([
{ ...sodiumItem, enabled: true },
{ ...modMenuItem, enabled: true },
{ ...fabricApiItem, enabled: false },
{ ...emfItem, enabled: true },
{ ...etfItem, enabled: true },
])
const selectedIds = ref<string[]>([])
const enableSelected = () => {
items.value.forEach((item) => {
if (selectedIds.value.includes(item.id)) {
item.enabled = true
}
})
}
const disableSelected = () => {
items.value.forEach((item) => {
if (selectedIds.value.includes(item.id)) {
item.enabled = false
}
})
}
const deleteSelected = () => {
items.value = items.value.filter((item) => !selectedIds.value.includes(item.id))
selectedIds.value = []
}
const handleToggle = (id: string, value: boolean) => {
const item = items.value.find((i) => i.id === id)
if (item) item.enabled = value
}
return { items, selectedIds, enableSelected, disableSelected, deleteSelected, handleToggle }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-secondary">{{ selectedIds.length }} selected</span>
<template v-if="selectedIds.length > 0">
<ButtonStyled size="small" color="green">
<button @click="enableSelected">Enable</button>
</ButtonStyled>
<ButtonStyled size="small" type="transparent">
<button @click="disableSelected">Disable</button>
</ButtonStyled>
<ButtonStyled size="small" color="red">
<button @click="deleteSelected">Delete</button>
</ButtonStyled>
</template>
</div>
<ContentCardTable
:items="items"
show-selection
v-model:selected-ids="selectedIds"
@update:enabled="handleToggle"
@delete="(id) => console.log('Delete', id)"
/>
</div>
`,
}),
}