devex: storybook for UI Package (#4984)

* add storybook

* clean up stories

* small fix

* add stories for all components

* add vintl

* default to dark mode

* fix  teleport

* add theme addon

* add new modal story

* delete broken stories

* move all stories to central stories folder

* fix paths

* add pnpm run storybook

* remove chromatic

* add add-stories.md

* fix types

* fix unncessary args field

* cover more addordion states

* pt2

* remove old vintl

* fix: missing style + ctx

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-01-01 16:32:58 -08:00
committed by GitHub
parent 477d77cdc1
commit daf804947c
65 changed files with 5526 additions and 78 deletions

3
.gitignore vendored
View File

@@ -68,3 +68,6 @@ app-playground-data/*
# labrinth demo fixtures
apps/labrinth/fixtures/demo
*storybook.log
storybook-static

View File

@@ -21,6 +21,7 @@
"prepr:frontend:lib": "turbo run prepr --filter=@modrinth/ui --filter=@modrinth/moderation --filter=@modrinth/assets --filter=@modrinth/blog --filter=@modrinth/api-client --filter=@modrinth/utils --filter=@modrinth/tooling-config",
"prepr:frontend:web": "turbo run prepr --filter=@modrinth/frontend",
"prepr:frontend:app": "turbo run prepr --filter=@modrinth/app-frontend",
"storybook": "pnpm --filter @modrinth/ui storybook",
"icons:add": "pnpm --filter @modrinth/assets icons:add",
"scripts": "node scripts/run.mjs"
},

View File

@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
framework: '@storybook/vue3-vite',
core: {
builder: '@storybook/builder-vite',
},
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-themes',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-onboarding',
],
}
export default config

View File

@@ -0,0 +1,80 @@
import '@modrinth/assets/omorphia.scss'
import 'floating-vue/dist/style.css'
import '../src/styles/tailwind.css'
import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import FloatingVue from 'floating-vue'
import { createI18n } from 'vue-i18n'
import {
buildLocaleMessages,
createMessageCompiler,
type CrowdinMessages,
} from '../src/composables/i18n'
// Load locale messages from the UI package's locales
// @ts-ignore
const localeModules = import.meta.glob('../src/locales/*/index.json', {
eager: true,
}) as Record<string, { default: CrowdinMessages }>
// Set up vue-i18n for Storybook - provides useVIntl() context for components
const i18n = createI18n({
legacy: false,
locale: 'en-US',
fallbackLocale: 'en-US',
messageCompiler: createMessageCompiler(),
missingWarn: false,
fallbackWarn: false,
messages: buildLocaleMessages(localeModules),
})
setup((app) => {
app.use(i18n)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
$extend: 'dropdown',
placement: 'bottom-end',
instantMove: true,
distance: 8,
},
'dismissable-prompt': {
$extend: 'dropdown',
placement: 'bottom-start',
},
},
})
// Create teleport target for components that use <Teleport to="#teleports">
if (typeof document !== 'undefined' && !document.getElementById('teleports')) {
const teleportTarget = document.createElement('div')
teleportTarget.id = 'teleports'
document.body.appendChild(teleportTarget)
}
})
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
withThemeByClassName({
themes: {
light: 'light-mode',
dark: 'dark-mode',
oled: 'oled-mode',
},
defaultTheme: 'dark',
}),
],
}
export default preview

View File

@@ -1,2 +1,15 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config
import baseConfig from '@modrinth/tooling-config/eslint/nuxt.mjs'
import storybook from 'eslint-plugin-storybook'
export default baseConfig.append([
{
name: 'storybook',
files: ['**/*.stories.@(js|jsx|ts|tsx)', '**/.storybook/**/*.@(js|ts)'],
plugins: {
storybook,
},
rules: {
...storybook.configs.recommended.rules,
},
},
])

View File

@@ -1,6 +1,7 @@
{
"name": "@modrinth/ui",
"version": "0.0.0",
"type": "module",
"private": true,
"main": "./index.ts",
"types": "./index.ts",
@@ -18,14 +19,34 @@
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"src/**/*.d.ts\" --out-file src/locales/en-US/index.json --preserve-whitespace"
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"src/**/*.d.ts\" --out-file src/locales/en-US/index.json --preserve-whitespace",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@storybook/addon-a11y": "^10.1.10",
"@storybook/addon-docs": "^10.1.10",
"@storybook/addon-onboarding": "^10.1.10",
"@storybook/addon-themes": "^10.1.10",
"@storybook/addon-vitest": "^10.1.10",
"@storybook/builder-vite": "^10.1.10",
"@storybook/vue3-vite": "^10.1.10",
"@stripe/stripe-js": "^7.3.1",
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"eslint-plugin-storybook": "^10.1.10",
"playwright": "^1.57.0",
"storybook": "^10.1.10",
"stripe": "^18.1.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.4.6",
"vite-svg-loader": "^5.1.0",
"vitest": "^4.0.16",
"vue": "^3.5.13",
"vue-component-type-helpers": "^3.1.8",
"vue-router": "^4.6.0"
@@ -47,6 +68,7 @@
"@tresjs/post-processing": "^2.4.0",
"@types/markdown-it": "^14.1.1",
"@types/three": "^0.172.0",
"@vintl/how-ago": "^3.0.1",
"@vueuse/core": "^11.1.0",
"apexcharts": "^4.0.0",
"dayjs": "^1.11.10",

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,292 @@
# Storybook Story Creation Guide
This document provides instructions for AI assistants when creating Storybook stories for Vue components in the `@modrinth/ui` package.
## File Location
Stories should be placed in a `stories` subdirectory next to the component's directory:
- Component: `src/components/base/MyComponent.vue`
- Story: `src/stories/base/MyComponent.stories.ts`
Example with modal components:
- Component: `src/components/modal/MyModal.vue`
- Story: `src/stories/modal/MyModal.stories.ts`
## Basic Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MyComponent from '../../components/base/MyComponent.vue'
const meta = {
title: 'Base/MyComponent', // Category/ComponentName
component: MyComponent,
} satisfies Meta<typeof MyComponent>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
// Default prop values
},
}
```
## Key Principles
### 1. Let Storybook Auto-Infer Props
**DO NOT** manually define `argTypes`.
```typescript
// ❌ BAD - Don't include prop types
const meta = {
argTypes: {
size: { control: 'select', options: ['small', 'medium', 'large'] },
},
}
// ✅ GOOD - Let Storybook infer from component
const meta = {
component: MyComponent,
}
```
### 2. Use Render Functions for Slot Content
When a component uses slots, provide a render function:
```typescript
const meta = {
component: Accordion,
render: (args) => ({
components: { Accordion },
setup() {
return { args }
},
template: /* html */ `
<Accordion v-bind="args">
<template #title>Click to expand</template>
<p>Accordion content here.</p>
</Accordion>
`,
}),
} satisfies Meta<typeof Accordion>
```
### 3. Keep Stories Concise
Instead of creating individual stories for every prop variant, use showcase stories:
Make sure to type it as `StoryObj` when using render functions.
```typescript
// ❌ BAD - Too many individual stories
export const Small: Story = { args: { size: 'small' } }
export const Medium: Story = { args: { size: 'medium' } }
export const Large: Story = { args: { size: 'large' } }
// ✅ GOOD - One showcase story
export const AllSizes: StoryObj = {
render: () => ({
components: { MyComponent },
template: /* html */ `
<div class="flex gap-4">
<MyComponent size="small">Small</MyComponent>
<MyComponent size="medium">Medium</MyComponent>
<MyComponent size="large">Large</MyComponent>
</div>
`,
}),
}
```
### 4. Required Stories
Each component should have:
- `Default` - Basic usage with controls
- `All[Variants]` - Showcase stories for major prop variations (e.g., `AllColors`, `AllSizes`, `AllTypes`)
### 5. Handling Generic Vue Components
For components with generics like `MyComponent<T>`, add // @ts-ignore
```typescript
const meta = {
title: 'Base/Combobox',
// @ts-ignore
component: Combobox,
} satisfies Meta<typeof Combobox>
```
## Common Patterns
### Components with Icons
Import icons from `@modrinth/assets`:
```typescript
import { SearchIcon, ChevronDownIcon } from '@modrinth/assets'
export const WithIcon: Story = {
render: () => ({
components: { MyComponent, SearchIcon },
template: /* html */ `
<MyComponent>
<SearchIcon />
Search
</MyComponent>
`,
}),
args: {},
}
```
### Interactive Components (Modals, Dropdowns)
For components that need user interaction to show:
```typescript
export const Default: Story = {
render: () => ({
components: { Modal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof Modal> | null>(null)
return { modalRef }
},
template: /* html */ `
<div>
<ButtonStyled @click="modalRef?.show()">
<button>Open Modal</button>
</ButtonStyled>
<Modal ref="modalRef" header="Example Modal">
<p>Modal content</p>
</Modal>
</div>
`,
}),
args: {},
}
```
### Components with v-model
```typescript
export const Default: Story = {
render: () => ({
components: { Toggle },
setup() {
const value = ref(false)
return { value }
},
template: /* html */ `
<Toggle v-model="value" />
`,
}),
args: {},
}
```
## Things to Avoid
### 1. Don't Import from `@modrinth/ui` in Components
Components should use relative imports, not the package alias:
```typescript
// ❌ BAD - Causes circular dependency in Storybook
import { ButtonStyled } from '@modrinth/ui'
// ✅ GOOD - Use relative imports
import ButtonStyled from '../base/ButtonStyled.vue'
```
### 2. Object/Array Prop Defaults Must Be Factory Functions
This is a Vue requirement that `vue-docgen-plugin` enforces:
```typescript
// ❌ BAD - Will cause Storybook build error
defineProps<{ icon: Component }>()
withDefaults(defineProps<{ icon: Component }>(), {
icon: TrashIcon,
})
// ✅ GOOD - Use factory function
withDefaults(defineProps<{ icon: Component }>(), {
icon: () => TrashIcon,
})
```
## Dependencies Available in Storybook
The following are configured in `.storybook/preview.ts`:
- **VIntl**: `useVIntl()` and `formatMessage()` work automatically
- **Teleports**: `<Teleport to="#teleports">` has a target element
- **Tailwind CSS**: All Tailwind classes are available
- **Dark Mode**: Use `@storybook/addon-themes` for theme switching
## Example: Complete Story File
```typescript
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Badge from '../../components/base/Badge.vue'
const meta = {
title: 'Base/Badge',
component: Badge,
render: (args) => ({
components: { Badge },
setup() {
return { args }
},
template: /* html */ `
<Badge v-bind="args">Badge Text</Badge>
`,
}),
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
color: 'green',
},
}
export const AllColors: StoryObj = {
render: () => ({
components: { Badge },
template: /* html */ `
<div class="flex flex-wrap gap-2">
<Badge color="green">Green</Badge>
<Badge color="red">Red</Badge>
<Badge color="orange">Orange</Badge>
<Badge color="blue">Blue</Badge>
<Badge color="purple">Purple</Badge>
<Badge color="gray">Gray</Badge>
</div>
`,
}),
}
export const AllTypes: StoryObj = {
render: () => ({
components: { Badge },
template: /* html */ `
<div class="flex flex-wrap gap-2">
<Badge type="default">Default</Badge>
<Badge type="outlined">Outlined</Badge>
<Badge type="highlight">Highlight</Badge>
</div>
`,
}),
}
```

View File

@@ -0,0 +1,188 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Accordion from '../../components/base/Accordion.vue'
const meta = {
title: 'Base/Accordion',
component: Accordion,
render: (args) => ({
components: { Accordion },
setup() {
return { args }
},
template: /*html*/ `
<Accordion v-bind="args">
<template #title>Click to expand</template>
<p>This is the accordion content that shows when expanded.</p>
</Accordion>
`,
}),
} satisfies Meta<typeof Accordion>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const OpenByDefault: Story = {
args: {
openByDefault: true,
},
}
export const ForceOpen: Story = {
args: {
forceOpen: true,
},
}
export const WithCustomButtonClass: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<Accordion
open-by-default
button-class="text-primary m-0 p-0 border-none bg-transparent active:scale-95"
>
<template #title>3 servers</template>
<div class="mt-2 flex flex-wrap gap-2">
<span class="px-2 py-1 bg-bg-raised rounded text-sm">server-001</span>
<span class="px-2 py-1 bg-bg-raised rounded text-sm">server-002</span>
<span class="px-2 py-1 bg-bg-raised rounded text-sm">server-003</span>
</div>
</Accordion>
`,
}),
}
export const WithSummarySlot: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<Accordion>
<template #title>
<span class="text-lg font-bold text-contrast">Advanced Settings</span>
</template>
<template #summary>
<span class="text-sm text-secondary">Configure advanced options for your project</span>
</template>
<div class="mt-4 flex flex-col gap-2">
<label class="flex items-center gap-2">
<input type="checkbox" />
<span>Enable debug mode</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" />
<span>Show verbose output</span>
</label>
</div>
</Accordion>
`,
}),
}
export const WithContentClass: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<Accordion open-by-default content-class="p-4 bg-bg-raised rounded-lg mt-2">
<template #title>
<span class="text-primary font-semibold">Styled Content Area</span>
</template>
<p>This content has custom padding and background styling applied via contentClass.</p>
</Accordion>
`,
}),
}
export const MultipleAccordions: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<div class="flex flex-col gap-4">
<Accordion>
<template #title>
<span class="text-primary font-semibold">Section 1: Getting Started</span>
</template>
<div class="mt-2 text-secondary">
<p>Welcome! This section covers the basics of getting started with the application.</p>
</div>
</Accordion>
<Accordion>
<template #title>
<span class="text-primary font-semibold">Section 2: Configuration</span>
</template>
<div class="mt-2 text-secondary">
<p>Learn how to configure your settings and customize the experience.</p>
</div>
</Accordion>
<Accordion open-by-default>
<template #title>
<span class="text-primary font-semibold">Section 3: FAQ (Open by default)</span>
</template>
<div class="mt-2 text-secondary">
<p>Frequently asked questions and their answers.</p>
</div>
</Accordion>
</div>
`,
}),
}
export const NestedContent: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<Accordion open-by-default>
<template #title>
<span class="text-primary font-semibold">Project Dependencies</span>
</template>
<div class="mt-2 flex flex-col gap-3">
<div class="flex items-center justify-between p-2 bg-bg-raised rounded">
<span>vue</span>
<span class="text-sm text-secondary">^3.5.0</span>
</div>
<div class="flex items-center justify-between p-2 bg-bg-raised rounded">
<span>typescript</span>
<span class="text-sm text-secondary">^5.0.0</span>
</div>
<div class="flex items-center justify-between p-2 bg-bg-raised rounded">
<span>vite</span>
<span class="text-sm text-secondary">^6.0.0</span>
</div>
</div>
</Accordion>
`,
}),
}
export const AllStates: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<div class="flex flex-col gap-6">
<div>
<h3 class="text-sm font-semibold text-secondary mb-2">Default (collapsed)</h3>
<Accordion>
<template #title><span class="text-primary">Click to expand</span></template>
<p class="mt-2">Hidden content revealed on click.</p>
</Accordion>
</div>
<div>
<h3 class="text-sm font-semibold text-secondary mb-2">Open by default</h3>
<Accordion open-by-default>
<template #title><span class="text-primary">Already expanded</span></template>
<p class="mt-2">This content is visible by default.</p>
</Accordion>
</div>
<div>
<h3 class="text-sm font-semibold text-secondary mb-2">Force open (no toggle)</h3>
<Accordion force-open>
<template #title><span class="text-primary">Cannot be collapsed</span></template>
<p class="mt-2">This accordion is always open and cannot be toggled.</p>
</Accordion>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Admonition from '../../components/base/Admonition.vue'
const meta = {
title: 'Base/Admonition',
component: Admonition,
} satisfies Meta<typeof Admonition>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
body: 'This is an informational message.',
},
}
export const AllTypes: Story = {
render: () => ({
components: { Admonition },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<Admonition type="info" header="Info" body="This is an informational message." />
<Admonition type="warning" header="Warning" body="This is a warning message." />
<Admonition type="critical" header="Critical" body="This is a critical message." />
</div>
`,
}),
}
export const WithHeader: Story = {
args: {
type: 'warning',
header: 'Important Notice',
body: 'Please read this carefully before proceeding.',
},
}

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AppearingProgressBar from '../../components/base/AppearingProgressBar.vue'
const meta = {
title: 'Base/AppearingProgressBar',
component: AppearingProgressBar,
} satisfies Meta<typeof AppearingProgressBar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
maxValue: 100,
currentValue: 45,
},
}
export const AllProgress: Story = {
...Default,
render: () => ({
components: { AppearingProgressBar },
template: `
<div class="flex flex-col gap-8">
<div>
<p class="text-secondary mb-2">0%</p>
<AppearingProgressBar :maxValue="100" :currentValue="0" />
</div>
<div>
<p class="text-secondary mb-2">25%</p>
<AppearingProgressBar :maxValue="100" :currentValue="25" />
</div>
<div>
<p class="text-secondary mb-2">50%</p>
<AppearingProgressBar :maxValue="100" :currentValue="50" />
</div>
<div>
<p class="text-secondary mb-2">75%</p>
<AppearingProgressBar :maxValue="100" :currentValue="75" />
</div>
<div>
<p class="text-secondary mb-2">100%</p>
<AppearingProgressBar :maxValue="100" :currentValue="100" />
</div>
</div>
`,
}),
}
export const CustomTips: Story = {
args: {
maxValue: 1000000,
currentValue: 450000,
tips: ['Loading assets...', 'Processing data...', 'Almost there...'],
},
}

View File

@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AutoBrandIcon from '../../components/base/AutoBrandIcon.vue'
const meta = {
title: 'Base/AutoBrandIcon',
component: AutoBrandIcon,
} satisfies Meta<typeof AutoBrandIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
keyword: 'discord',
},
}
export const AllBrands: StoryObj = {
render: () => ({
components: { AutoBrandIcon },
template: `
<div class="grid grid-cols-4 gap-4">
<div v-for="brand in brands" :key="brand" class="flex flex-col items-center gap-2 p-4 bg-bg-raised rounded-lg">
<AutoBrandIcon :keyword="brand" class="h-8 w-8" />
<span class="text-sm text-secondary">{{ brand }}</span>
</div>
</div>
`,
setup() {
const brands = [
'discord',
'github',
'twitter',
'youtube',
'twitch',
'reddit',
'mastodon',
'bluesky',
'patreon',
'kofi',
'paypal',
'curseforge',
'modrinth',
'instagram',
'facebook',
'tiktok',
]
return { brands }
},
}),
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AutoLink from '../../components/base/AutoLink.vue'
const meta = {
title: 'Base/AutoLink',
component: AutoLink,
render: (args) => ({
components: { AutoLink },
setup() {
return { args }
},
template: /*html*/ `
<AutoLink v-bind="args">Link Text</AutoLink>
`,
}),
} satisfies Meta<typeof AutoLink>
export default meta
type Story = StoryObj<typeof meta>
export const ExternalLink: Story = {
args: {
to: 'https://modrinth.com',
},
}
export const InternalPath: Story = {
args: {
to: '/projects',
},
}

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Avatar from '../../components/base/Avatar.vue'
const meta = {
title: 'Base/Avatar',
component: Avatar,
} satisfies Meta<typeof Avatar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithImage: Story = {
args: {
src: 'https://cdn.modrinth.com/data/AANobbMI/icon.png',
alt: 'Project icon',
},
}
export const Circle: Story = {
args: {
src: 'https://cdn.modrinth.com/data/AANobbMI/icon.png',
circle: true,
},
}
export const AllSizes: Story = {
render: () => ({
components: { Avatar },
template: /*html*/ `
<div style="display: flex; gap: 1rem; align-items: center;">
<Avatar src="https://cdn.modrinth.com/data/AANobbMI/icon.png" size="1.5rem" />
<Avatar src="https://cdn.modrinth.com/data/AANobbMI/icon.png" size="2rem" />
<Avatar src="https://cdn.modrinth.com/data/AANobbMI/icon.png" size="3rem" />
<Avatar src="https://cdn.modrinth.com/data/AANobbMI/icon.png" size="4rem" />
</div>
`,
}),
}
export const Placeholder: Story = {
args: {
size: '3rem',
},
}

View File

@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from '../../components/base/Badge.vue'
const meta = {
title: 'Base/Badge',
component: Badge,
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
type: 'approved',
},
}
export const ProjectStatuses: StoryObj = {
render: () => ({
components: { Badge },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<Badge type="approved" />
<Badge type="unlisted" />
<Badge type="private" />
<Badge type="draft" />
<Badge type="archived" />
<Badge type="rejected" />
<Badge type="processing" />
</div>
`,
}),
}
export const UserRoles: StoryObj = {
render: () => ({
components: { Badge },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<Badge type="admin" />
<Badge type="moderator" />
<Badge type="creator" />
</div>
`,
}),
}
export const WithColor: Story = {
args: {
type: 'release',
color: 'green',
},
}

View File

@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BulletDivider from '../../components/base/BulletDivider.vue'
const meta = {
title: 'Base/BulletDivider',
component: BulletDivider,
} satisfies Meta<typeof BulletDivider>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const InContext: Story = {
render: () => ({
components: { BulletDivider },
template: /*html*/ `
<div style="display: flex; align-items: center;">
<span>Item 1</span>
<BulletDivider />
<span>Item 2</span>
<BulletDivider />
<span>Item 3</span>
</div>
`,
}),
}

View File

@@ -0,0 +1,83 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '../../components/base/Button.vue'
const meta = {
title: 'Base/Button',
component: Button,
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: /*html*/ `
<Button v-bind="args">Click me</Button>
`,
}),
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Primary: Story = {
args: {
color: 'primary',
},
}
export const Danger: Story = {
args: {
color: 'danger',
},
}
export const AllColors: Story = {
render: () => ({
components: { Button },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 1rem;">
<Button color="default">Default</Button>
<Button color="primary">Primary</Button>
<Button color="danger">Danger</Button>
<Button color="red">Red</Button>
<Button color="orange">Orange</Button>
<Button color="green">Green</Button>
<Button color="blue">Blue</Button>
<Button color="purple">Purple</Button>
</div>
`,
}),
}
export const Large: Story = {
args: {
large: true,
},
}
export const Outline: Story = {
args: {
outline: true,
},
}
export const Transparent: Story = {
args: {
transparent: true,
},
}
export const Disabled: Story = {
args: {
disabled: true,
},
}
export const AsLink: Story = {
args: {
link: 'https://modrinth.com',
external: true,
},
}

View File

@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
const meta = {
title: 'Base/ButtonStyled',
component: ButtonStyled,
render: (args) => ({
components: { ButtonStyled },
setup() {
return { args }
},
template: /*html*/ `
<ButtonStyled v-bind="args">
<button @click="()=>{}">Button</button>
</ButtonStyled>
`,
}),
} satisfies Meta<typeof ButtonStyled>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
// All colors showcase
export const AllColors: Story = {
render: () => ({
components: { ButtonStyled },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: center;">
<ButtonStyled color="standard"><button>Standard</button></ButtonStyled>
<ButtonStyled color="brand"><button>Brand</button></ButtonStyled>
<ButtonStyled color="red"><button>Red</button></ButtonStyled>
<ButtonStyled color="orange"><button>Orange</button></ButtonStyled>
<ButtonStyled color="green"><button>Green</button></ButtonStyled>
<ButtonStyled color="blue"><button>Blue</button></ButtonStyled>
<ButtonStyled color="purple"><button>Purple</button></ButtonStyled>
</div>
`,
}),
}
// All sizes showcase
export const AllSizes: Story = {
render: () => ({
components: { ButtonStyled },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: center;">
<ButtonStyled size="small" color="standard"><button>Small</button></ButtonStyled>
<ButtonStyled size="standard" color="standard"><button>Standard</button></ButtonStyled>
<ButtonStyled size="large" color="standard"><button>Large</button></ButtonStyled>
</div>
`,
}),
}
// All types showcase
export const AllTypes: Story = {
render: () => ({
components: { ButtonStyled },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: center;">
<ButtonStyled type="standard" color="standard"><button>Standard</button></ButtonStyled>
<ButtonStyled type="outlined" color="standard"><button>Outlined</button></ButtonStyled>
<ButtonStyled type="transparent" color="standard"><button>Transparent</button></ButtonStyled>
<ButtonStyled type="highlight" color="standard"><button>Highlight</button></ButtonStyled>
<ButtonStyled type="highlight-colored-text" color="standard"><button>Highlight Colored</button></ButtonStyled>
<ButtonStyled type="chip" color="standard"><button>Chip</button></ButtonStyled>
<ButtonStyled color="standard"><button disabled>Disabled</button></ButtonStyled>
</div>
`,
}),
}

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Card from '../../components/base/Card.vue'
const meta = {
title: 'Base/Card',
component: Card,
render: (args) => ({
components: { Card },
setup() {
return { args }
},
template: /*html*/ `
<Card v-bind="args">
<template #header><h3>Card Title</h3></template>
<p>This is the card content.</p>
</Card>
`,
}),
} satisfies Meta<typeof Card>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Collapsible: Story = {
args: {
collapsible: true,
},
}
export const CollapsedByDefault: Story = {
args: {
collapsible: true,
defaultCollapsed: true,
},
}

View File

@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Checkbox from '../../components/base/Checkbox.vue'
const meta = {
title: 'Base/Checkbox',
component: Checkbox,
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Accept terms and conditions',
modelValue: false,
},
}
export const Checked: Story = {
args: {
label: 'Accept terms and conditions',
modelValue: true,
},
}
export const Disabled: Story = {
args: {
label: 'Disabled checkbox',
modelValue: false,
disabled: true,
},
}
export const Indeterminate: Story = {
args: {
label: 'Indeterminate state',
modelValue: false,
indeterminate: true,
},
}
export const AllStates: StoryObj = {
render: () => ({
components: { Checkbox },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<Checkbox label="Unchecked" :model-value="false" />
<Checkbox label="Checked" :model-value="true" />
<Checkbox label="Indeterminate" :model-value="false" :indeterminate="true" />
<Checkbox label="Disabled unchecked" :model-value="false" :disabled="true" />
<Checkbox label="Disabled checked" :model-value="true" :disabled="true" />
</div>
`,
}),
}

View File

@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Chips from '../../components/base/Chips.vue'
const meta = {
title: 'Base/Chips',
// @ts-ignore - error comes from generically typed component
component: Chips,
} satisfies Meta<typeof Chips>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
items: ['Option 1', 'Option 2', 'Option 3'],
},
}
export const Small: Story = {
args: {
items: ['Option 1', 'Option 2', 'Option 3'],
size: 'small',
},
}
export const AllowEmpty: Story = {
args: {
items: ['Option 1', 'Option 2', 'Option 3'],
neverEmpty: false,
},
}
export const NoCapitalize: Story = {
args: {
items: ['Option 1', 'Option 2', 'Option 3'],
capitalize: false,
},
}
export const ManyItems: Story = {
args: {
items: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta'],
},
}

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Collapsible from '../../components/base/Collapsible.vue'
const meta = {
title: 'Base/Collapsible',
component: Collapsible,
render: (args) => ({
components: { Collapsible },
setup() {
return { args }
},
template: /*html*/ `
<Collapsible v-bind="args">
<p>This content can be collapsed or expanded.</p>
</Collapsible>
`,
}),
} satisfies Meta<typeof Collapsible>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
collapsed: false,
},
}
export const Collapsed: Story = {
args: {
collapsed: true,
},
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CollapsibleRegion from '../../components/base/CollapsibleRegion.vue'
const meta = {
title: 'Base/CollapsibleRegion',
component: CollapsibleRegion,
} satisfies Meta<typeof CollapsibleRegion>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { CollapsibleRegion },
template: `
<CollapsibleRegion>
<div class="space-y-4">
<p>This is some content that can be collapsed or expanded.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.</p>
<p>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.</p>
</div>
</CollapsibleRegion>
`,
}),
}
export const CustomLabels: Story = {
render: () => ({
components: { CollapsibleRegion },
template: `
<CollapsibleRegion expandText="Show more" collapseText="Show less" collapsedHeight="6rem">
<div class="space-y-4">
<p>Custom expand and collapse labels.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.</p>
</div>
</CollapsibleRegion>
`,
}),
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Combobox from '../../components/base/Combobox.vue'
const meta = {
title: 'Base/Combobox',
// @ts-ignore - generic component
component: Combobox,
} satisfies Meta<typeof Combobox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
options: [
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
],
triggerText: 'Select an option',
},
}
export const Searchable: Story = {
args: {
options: [
{ value: '1', label: 'Minecraft' },
{ value: '2', label: 'Fabric' },
{ value: '3', label: 'Forge' },
{ value: '4', label: 'NeoForge' },
{ value: '5', label: 'Quilt' },
],
triggerText: 'Select a loader',
searchable: true,
searchPlaceholder: 'Search loaders...',
},
}
export const Disabled: Story = {
args: {
options: [{ value: '1', label: 'Option 1' }],
triggerText: 'Disabled',
disabled: true,
},
}

View File

@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Avatar from '../../components/base/Avatar.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentPageHeader from '../../components/base/ContentPageHeader.vue'
const meta = {
title: 'Base/ContentPageHeader',
component: ContentPageHeader,
} satisfies Meta<typeof ContentPageHeader>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ContentPageHeader, Avatar, ButtonStyled },
template: `
<ContentPageHeader>
<template #icon>
<Avatar size="64px" />
</template>
<template #title>Project Name</template>
<template #summary>A brief description of the project goes here.</template>
<template #stats>
<span>1.2M downloads</span>
<span>50K followers</span>
</template>
<template #actions>
<ButtonStyled color="brand">
<button>Follow</button>
</ButtonStyled>
</template>
</ContentPageHeader>
`,
}),
}
export const WithTitleSuffix: Story = {
render: () => ({
components: { ContentPageHeader, Avatar, ButtonStyled },
template: `
<ContentPageHeader>
<template #icon>
<Avatar size="64px" />
</template>
<template #title>Featured Project</template>
<template #title-suffix>
<span class="px-2 py-1 bg-brand-highlight text-brand rounded-full text-sm">Featured</span>
</template>
<template #summary>This project has been featured by the Modrinth team.</template>
<template #actions>
<ButtonStyled color="brand">
<button>Download</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button>Share</button>
</ButtonStyled>
</template>
</ContentPageHeader>
`,
}),
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CopyCode from '../../components/base/CopyCode.vue'
const meta = {
title: 'Base/CopyCode',
component: CopyCode,
} satisfies Meta<typeof CopyCode>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
text: 'npm install @modrinth/ui',
},
}
export const LongText: Story = {
args: {
text: 'curl -X GET "https://api.modrinth.com/v2/project/sodium" -H "Accept: application/json"',
},
}

View File

@@ -0,0 +1,26 @@
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DoubleIcon from '../../components/base/DoubleIcon.vue'
const meta = {
title: 'Base/DoubleIcon',
component: DoubleIcon,
render: (args) => ({
components: { DoubleIcon, DownloadIcon, HeartIcon },
setup() {
return { args }
},
template: /*html*/ `
<DoubleIcon v-bind="args">
<template #primary><DownloadIcon style="width: 2rem; height: 2rem;" /></template>
<template #secondary><HeartIcon /></template>
</DoubleIcon>
`,
}),
} satisfies Meta<typeof DoubleIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DropArea from '../../components/base/DropArea.vue'
const meta = {
title: 'Base/DropArea',
component: DropArea,
} satisfies Meta<typeof DropArea>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { DropArea },
template: `
<DropArea accept="*" @change="(files) => console.log('Files dropped:', files)">
<div class="p-8 border-2 border-dashed border-divider rounded-lg text-center">
<p class="text-secondary">Drag and drop files anywhere on the page</p>
<p class="text-sm text-secondary mt-2">The drop overlay will appear when you drag files over</p>
</div>
</DropArea>
`,
}),
}
export const ImagesOnly: StoryObj = {
render: () => ({
components: { DropArea },
template: `
<DropArea accept="image/*" @change="(files) => console.log('Images dropped:', files)">
<div class="p-8 border-2 border-dashed border-divider rounded-lg text-center">
<p class="text-secondary">Drop images here</p>
<p class="text-sm text-secondary mt-2">Only accepts image files</p>
</div>
</DropArea>
`,
}),
}

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DropdownSelect from '../../components/base/DropdownSelect.vue'
const meta = {
title: 'Base/DropdownSelect',
component: DropdownSelect,
} satisfies Meta<typeof DropdownSelect>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
name: 'dropdown',
options: ['Option 1', 'Option 2', 'Option 3'],
modelValue: 'Option 1',
},
}
export const ManyOptions: Story = {
args: {
name: 'sort',
options: ['Relevance', 'Downloads', 'Follows', 'Newest', 'Updated'],
modelValue: 'Relevance',
},
}
export const Disabled: Story = {
args: {
name: 'disabled',
options: ['Option 1', 'Option 2', 'Option 3'],
modelValue: 'Option 1',
disabled: true,
},
}

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DropzoneFileInput from '../../components/base/DropzoneFileInput.vue'
const meta = {
title: 'Base/DropzoneFileInput',
component: DropzoneFileInput,
} satisfies Meta<typeof DropzoneFileInput>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: {
size: 'small',
},
}
export const MultipleFiles: Story = {
args: {
multiple: true,
primaryPrompt: 'Drag and drop multiple files',
secondaryPrompt: 'Select multiple files at once',
},
}
export const Disabled: Story = {
args: {
disabled: true,
},
}
export const CustomPrompts: Story = {
args: {
primaryPrompt: 'Drop your mod files here',
secondaryPrompt: 'Supports .jar and .zip files up to 25MB',
accept: '.jar,.zip',
},
}

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import EnvironmentIndicator from '../../components/base/EnvironmentIndicator.vue'
const meta = {
title: 'Base/EnvironmentIndicator',
component: EnvironmentIndicator,
} satisfies Meta<typeof EnvironmentIndicator>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
type: 'mod',
clientSide: 'required',
serverSide: 'optional',
},
}
export const AllEnvironments: StoryObj = {
render: () => ({
components: { EnvironmentIndicator },
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" clientSide="required" serverSide="optional" />
<span class="text-secondary text-sm">Client (required client, optional server)</span>
</div>
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" clientSide="optional" serverSide="required" />
<span class="text-secondary text-sm">Server (optional client, required server)</span>
</div>
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" clientSide="required" serverSide="required" />
<span class="text-secondary text-sm">Client and server (both required)</span>
</div>
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" clientSide="optional" serverSide="optional" />
<span class="text-secondary text-sm">Client or server (both optional)</span>
</div>
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" clientSide="unsupported" serverSide="unsupported" />
<span class="text-secondary text-sm">Unsupported</span>
</div>
<div class="flex items-center gap-2">
<EnvironmentIndicator type="mod" typeOnly />
<span class="text-secondary text-sm">Type only</span>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,56 @@
import { IssuesIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorInformationCard from '../../components/base/ErrorInformationCard.vue'
const meta = {
title: 'Base/ErrorInformationCard',
component: ErrorInformationCard,
decorators: [
(story) => ({
components: { story },
template: '<div class="flex min-h-[400px] items-center justify-center bg-bg"><story /></div>',
}),
],
} satisfies Meta<typeof ErrorInformationCard>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
title: 'Something went wrong',
description: 'An unexpected error occurred while processing your request.',
icon: IssuesIcon,
},
}
export const WithErrorDetails: Story = {
args: {
title: 'Connection Failed',
description: 'Unable to connect to the server.',
icon: IssuesIcon,
errorDetails: [
{ label: 'Error Code', value: 'ERR_CONNECTION_REFUSED', type: 'inline' },
{ label: 'Timestamp', value: '2024-01-15T10:30:00Z', type: 'inline' },
{
label: 'Stack Trace',
value: 'Error: Connection refused\n at Socket.connect\n at Client.connect',
type: 'block',
},
],
},
}
export const WithAction: Story = {
args: {
title: 'Download Failed',
description: 'The file could not be downloaded. Please try again.',
icon: IssuesIcon,
action: {
label: 'Retry Download',
onClick: () => console.log('Retry clicked'),
color: 'brand',
},
},
}

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FileInput from '../../components/base/FileInput.vue'
const meta = {
title: 'Base/FileInput',
component: FileInput,
} satisfies Meta<typeof FileInput>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
prompt: 'Select file',
},
}
export const Multiple: Story = {
args: {
prompt: 'Select files',
multiple: true,
},
}
export const ImagesOnly: Story = {
args: {
prompt: 'Select image',
accept: 'image/*',
},
}
export const Disabled: Story = {
args: {
prompt: 'Select file',
disabled: true,
},
}

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import FilterBar from '../../components/base/FilterBar.vue'
const meta = {
title: 'Base/FilterBar',
component: FilterBar,
} satisfies Meta<typeof FilterBar>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { FilterBar },
setup() {
const selected = ref<string[]>([])
const options = [
{ id: 'active', message: { id: 'filter.active', defaultMessage: 'Active' } },
{ id: 'archived', message: { id: 'filter.archived', defaultMessage: 'Archived' } },
{ id: 'draft', message: { id: 'filter.draft', defaultMessage: 'Draft' } },
]
return { selected, options }
},
template: `
<FilterBar v-model="selected" :options="options" showAllOptions />
`,
}),
}
export const WithSelection: StoryObj = {
render: () => ({
components: { FilterBar },
setup() {
const selected = ref<string[]>(['mods', 'plugins'])
const options = [
{ id: 'mods', message: { id: 'filter.mods', defaultMessage: 'Mods' } },
{ id: 'plugins', message: { id: 'filter.plugins', defaultMessage: 'Plugins' } },
{
id: 'resourcepacks',
message: { id: 'filter.resourcepacks', defaultMessage: 'Resource Packs' },
},
{ id: 'modpacks', message: { id: 'filter.modpacks', defaultMessage: 'Modpacks' } },
]
return { selected, options }
},
template: `
<FilterBar v-model="selected" :options="options" showAllOptions />
`,
}),
}

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeadingLink from '../../components/base/HeadingLink.vue'
const meta = {
title: 'Base/HeadingLink',
component: HeadingLink,
render: (args) => ({
components: { HeadingLink },
setup() {
return { args }
},
template: /*html*/ `
<HeadingLink v-bind="args">View All Projects</HeadingLink>
`,
}),
} satisfies Meta<typeof HeadingLink>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
to: '/projects',
},
}

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HorizontalRule from '../../components/base/HorizontalRule.vue'
const meta = {
title: 'Base/HorizontalRule',
component: HorizontalRule,
} satisfies Meta<typeof HorizontalRule>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LargeRadioButton from '../../components/base/LargeRadioButton.vue'
const meta = {
title: 'Base/LargeRadioButton',
// @ts-ignore
component: LargeRadioButton,
} satisfies Meta<typeof LargeRadioButton>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { LargeRadioButton },
template: `
<LargeRadioButton :selected="false">
Unselected option
</LargeRadioButton>
`,
}),
}
export const AllStates: StoryObj = {
render: () => ({
components: { LargeRadioButton },
template: `
<div class="flex flex-col gap-4 max-w-md">
<LargeRadioButton :selected="false">
Unselected option
</LargeRadioButton>
<LargeRadioButton :selected="true">
Selected option
</LargeRadioButton>
<LargeRadioButton :selected="false" :disabled="true">
Disabled unselected
</LargeRadioButton>
<LargeRadioButton :selected="true" :disabled="true">
Disabled selected
</LargeRadioButton>
</div>
`,
}),
}

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LoadingIndicator from '../../components/base/LoadingIndicator.vue'
const meta = {
title: 'Base/LoadingIndicator',
component: LoadingIndicator,
} satisfies Meta<typeof LoadingIndicator>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MarkdownEditor from '../../components/base/MarkdownEditor.vue'
const meta = {
title: 'Base/MarkdownEditor',
component: MarkdownEditor,
} satisfies Meta<typeof MarkdownEditor>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { MarkdownEditor },
setup() {
const content = ref('# Hello World\n\nThis is some **markdown** content.')
return { content }
},
template: `
<div class="h-96">
<MarkdownEditor v-model="content" />
</div>
`,
}),
}
export const WithPlaceholder: StoryObj = {
render: () => ({
components: { MarkdownEditor },
setup() {
const content = ref('')
return { content }
},
template: `
<div class="h-96">
<MarkdownEditor v-model="content" placeholder="Write your description here..." />
</div>
`,
}),
}

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import OptionGroup from '../../components/base/OptionGroup.vue'
const meta = {
title: 'Base/OptionGroup',
// @ts-ignore - error comes from generically typed component
component: OptionGroup,
render: (args) => ({
components: { OptionGroup },
setup() {
return { args }
},
template: /*html*/ `
<OptionGroup v-bind="args" v-model="args.modelValue">
<template #default="{ option }">{{ option }}</template>
</OptionGroup>
`,
}),
} satisfies Meta<typeof OptionGroup>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
options: ['Option 1', 'Option 2', 'Option 3'],
modelValue: 'Option 1',
},
}
export const ManyOptions: Story = {
args: {
options: ['All', 'Mods', 'Plugins', 'Resource Packs', 'Modpacks', 'Shaders'],
modelValue: 'All',
},
}

View File

@@ -0,0 +1,81 @@
import { MoreHorizontalIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import OverflowMenu from '../../components/base/OverflowMenu.vue'
const meta = {
title: 'Base/OverflowMenu',
component: OverflowMenu,
render: (args) => ({
components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled },
setup() {
return { args }
},
template: /*html*/ `
<ButtonStyled circular type="transparent">
<OverflowMenu v-bind="args">
<MoreHorizontalIcon class="h-5 w-5" />
<template #edit>Edit</template>
<template #delete>Delete</template>
<template #share>Share</template>
</OverflowMenu>
</ButtonStyled>
`,
}),
} satisfies Meta<typeof OverflowMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
options: [
{ id: 'edit', action: () => console.log('Edit clicked') },
{ id: 'share', action: () => console.log('Share clicked') },
{ divider: true },
{ id: 'delete', action: () => console.log('Delete clicked'), color: 'danger' },
],
},
}
export const WithDifferentPlacements: StoryObj = {
render: () => ({
components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled },
template: /*html*/ `
<div class="flex gap-8 items-start">
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-secondary">bottom-end (default)</span>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'edit', action: () => {} },
{ id: 'delete', action: () => {}, color: 'danger' },
]"
>
<MoreHorizontalIcon class="h-5 w-5" />
<template #edit>Edit</template>
<template #delete>Delete</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-secondary">bottom-start</span>
<ButtonStyled circular type="transparent">
<OverflowMenu
direction="left"
:options="[
{ id: 'edit', action: () => {} },
{ id: 'delete', action: () => {}, color: 'danger' },
]"
>
<MoreHorizontalIcon class="h-5 w-5" />
<template #edit>Edit</template>
<template #delete>Delete</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Page from '../../components/base/Page.vue'
const meta = {
title: 'Base/Page',
component: Page,
} satisfies Meta<typeof Page>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { Page },
template: `
<Page>
<div class="p-4 bg-bg-raised rounded-lg">
<h1 class="text-2xl font-bold mb-4">Page Content</h1>
<p>This is the main content area of the page.</p>
</div>
</Page>
`,
}),
}
export const WithSidebar: Story = {
render: () => ({
components: { Page },
template: `
<Page>
<template #sidebar>
<div class="p-4 bg-bg-raised rounded-lg">
<h2 class="text-xl font-bold mb-2">Sidebar</h2>
<ul class="space-y-2">
<li>Link 1</li>
<li>Link 2</li>
<li>Link 3</li>
</ul>
</div>
</template>
<div class="p-4 bg-bg-raised rounded-lg">
<h1 class="text-2xl font-bold mb-4">Page Content</h1>
<p>This is the main content area with a sidebar.</p>
</div>
</Page>
`,
}),
}
export const WithHeaderAndFooter: Story = {
render: () => ({
components: { Page },
template: `
<Page>
<template #header>
<div class="p-4 bg-brand-highlight rounded-lg mb-4">
<h1 class="text-2xl font-bold">Page Header</h1>
</div>
</template>
<template #sidebar>
<div class="p-4 bg-bg-raised rounded-lg">
<h2 class="text-xl font-bold mb-2">Sidebar</h2>
</div>
</template>
<div class="p-4 bg-bg-raised rounded-lg">
<h2 class="text-xl font-bold mb-4">Main Content</h2>
<p>Content with header, sidebar, and footer.</p>
</div>
<template #footer>
<div class="p-4 bg-bg-raised rounded-lg mt-4">
<p class="text-secondary">Footer content</p>
</div>
</template>
</Page>
`,
}),
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Pagination from '../../components/base/Pagination.vue'
const meta = {
title: 'Base/Pagination',
component: Pagination,
} satisfies Meta<typeof Pagination>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
page: 1,
count: 10,
},
}
export const MiddlePage: Story = {
args: {
page: 5,
count: 10,
},
}
export const LastPage: Story = {
args: {
page: 10,
count: 10,
},
}
export const FewPages: Story = {
args: {
page: 1,
count: 3,
},
}
export const ManyPages: Story = {
args: {
page: 50,
count: 100,
},
}

View File

@@ -0,0 +1,94 @@
import { SettingsIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '../../components/base/Button.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import PopoutMenu from '../../components/base/PopoutMenu.vue'
const meta = {
title: 'Base/PopoutMenu',
component: PopoutMenu,
render: (args) => ({
components: { PopoutMenu, Button, ButtonStyled, SettingsIcon },
setup() {
return { args }
},
template: /*html*/ `
<ButtonStyled circular type="transparent">
<PopoutMenu v-bind="args">
<SettingsIcon class="h-5 w-5" />
<template #menu>
<div class="flex flex-col gap-1 p-1">
<Button transparent>Option 1</Button>
<Button transparent>Option 2</Button>
<Button transparent>Option 3</Button>
</div>
</template>
</PopoutMenu>
</ButtonStyled>
`,
}),
} satisfies Meta<typeof PopoutMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithTooltip: Story = {
args: {
tooltip: 'Click for more options',
},
}
export const DifferentPlacements: StoryObj = {
render: () => ({
components: { PopoutMenu, Button, ButtonStyled, SettingsIcon },
template: /*html*/ `
<div class="flex gap-8 items-start p-8">
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-secondary">bottom-end (default)</span>
<ButtonStyled circular type="transparent">
<PopoutMenu placement="bottom-end">
<SettingsIcon class="h-5 w-5" />
<template #menu>
<div class="flex flex-col gap-1 p-1">
<Button transparent>Option 1</Button>
<Button transparent>Option 2</Button>
</div>
</template>
</PopoutMenu>
</ButtonStyled>
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-secondary">bottom-start</span>
<ButtonStyled circular type="transparent">
<PopoutMenu placement="bottom-start">
<SettingsIcon class="h-5 w-5" />
<template #menu>
<div class="flex flex-col gap-1 p-1">
<Button transparent>Option 1</Button>
<Button transparent>Option 2</Button>
</div>
</template>
</PopoutMenu>
</ButtonStyled>
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-secondary">top-end</span>
<ButtonStyled circular type="transparent">
<PopoutMenu placement="top-end">
<SettingsIcon class="h-5 w-5" />
<template #menu>
<div class="flex flex-col gap-1 p-1">
<Button transparent>Option 1</Button>
<Button transparent>Option 2</Button>
</div>
</template>
</PopoutMenu>
</ButtonStyled>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,133 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import PreviewSelectButton from '../../components/base/PreviewSelectButton.vue'
const meta = {
title: 'Base/PreviewSelectButton',
component: PreviewSelectButton,
} satisfies Meta<typeof PreviewSelectButton>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { PreviewSelectButton },
template: `
<PreviewSelectButton :checked="false">
<template #preview>
<div class="w-16 h-16 bg-brand-highlight rounded-lg" />
</template>
Option Label
</PreviewSelectButton>
`,
}),
}
export const AllStates: StoryObj = {
render: () => ({
components: { PreviewSelectButton },
template: `
<div class="flex gap-4">
<PreviewSelectButton :checked="false">
<template #preview>
<div class="w-16 h-16 bg-bg-raised rounded-lg flex items-center justify-center border border-divider">
<span class="text-secondary">A</span>
</div>
</template>
Unchecked
</PreviewSelectButton>
<PreviewSelectButton :checked="true">
<template #preview>
<div class="w-16 h-16 bg-brand-highlight rounded-lg flex items-center justify-center border border-brand">
<span class="text-brand">B</span>
</div>
</template>
Checked
</PreviewSelectButton>
</div>
`,
}),
}
export const InteractiveSelection: StoryObj = {
render: () => ({
components: { PreviewSelectButton },
setup() {
const selected = ref('dark')
return { selected }
},
template: `
<div>
<p class="text-sm text-secondary mb-4">Selected: {{ selected }}</p>
<div class="grid grid-cols-3 gap-4">
<PreviewSelectButton
:checked="selected === 'light'"
@click="selected = 'light'"
>
<template #preview>
<div class="w-16 h-16 bg-white rounded-lg border border-divider" />
</template>
Light
</PreviewSelectButton>
<PreviewSelectButton
:checked="selected === 'dark'"
@click="selected = 'dark'"
>
<template #preview>
<div class="w-16 h-16 bg-gray-900 rounded-lg border border-divider" />
</template>
Dark
</PreviewSelectButton>
<PreviewSelectButton
:checked="selected === 'oled'"
@click="selected = 'oled'"
>
<template #preview>
<div class="w-16 h-16 bg-black rounded-lg border border-divider" />
</template>
OLED
</PreviewSelectButton>
</div>
</div>
`,
}),
}
export const ColorSelection: StoryObj = {
render: () => ({
components: { PreviewSelectButton },
setup() {
const selected = ref('brand')
return { selected }
},
template: `
<div>
<p class="text-sm text-secondary mb-4">Accent color: {{ selected }}</p>
<div class="grid grid-cols-4 gap-3">
<PreviewSelectButton
v-for="color in ['brand', 'red', 'orange', 'green', 'blue', 'purple']"
:key="color"
:checked="selected === color"
@click="selected = color"
>
<template #preview>
<div
class="w-12 h-12 rounded-lg"
:class="{
'bg-brand': color === 'brand',
'bg-red': color === 'red',
'bg-orange': color === 'orange',
'bg-green': color === 'green',
'bg-blue': color === 'blue',
'bg-purple': color === 'purple',
}"
/>
</template>
{{ color.charAt(0).toUpperCase() + color.slice(1) }}
</PreviewSelectButton>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ProgressBar from '../../components/base/ProgressBar.vue'
const meta = {
title: 'Base/ProgressBar',
component: ProgressBar,
} satisfies Meta<typeof ProgressBar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
progress: 0.5,
},
}
export const WithLabel: Story = {
args: {
progress: 0.75,
label: 'Uploading...',
showProgress: true,
},
}
export const AllColors: StoryObj = {
render: () => ({
components: { ProgressBar },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<ProgressBar :progress="0.6" color="brand" label="Brand" />
<ProgressBar :progress="0.6" color="green" label="Green" />
<ProgressBar :progress="0.6" color="red" label="Red" />
<ProgressBar :progress="0.6" color="orange" label="Orange" />
<ProgressBar :progress="0.6" color="blue" label="Blue" />
<ProgressBar :progress="0.6" color="purple" label="Purple" />
<ProgressBar :progress="0.6" color="gray" label="Gray" />
</div>
`,
}),
}
export const Striped: Story = {
args: {
progress: 0.5,
striped: true,
},
}
export const AllStripedColors: StoryObj = {
render: () => ({
components: { ProgressBar },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<ProgressBar :progress="0.6" color="brand" striped label="Brand Striped" />
<ProgressBar :progress="0.6" color="green" striped label="Green Striped" />
<ProgressBar :progress="0.6" color="red" striped label="Red Striped" />
<ProgressBar :progress="0.6" color="orange" striped label="Orange Striped" />
<ProgressBar :progress="0.6" color="blue" striped label="Blue Striped" />
<ProgressBar :progress="0.6" color="purple" striped label="Purple Striped" />
<ProgressBar :progress="0.6" color="gray" striped label="Gray Striped" />
</div>
`,
}),
}
export const FullWidth: Story = {
args: {
progress: 0.5,
fullWidth: true,
},
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ProgressSpinner from '../../components/base/ProgressSpinner.vue'
const meta = {
title: 'Base/ProgressSpinner',
component: ProgressSpinner,
} satisfies Meta<typeof ProgressSpinner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
progress: 0.5,
},
}
export const AllProgress: StoryObj = {
render: () => ({
components: { ProgressSpinner },
template: /*html*/ `
<div style="display: flex; gap: 1rem; align-items: center;">
<ProgressSpinner :progress="0" />
<ProgressSpinner :progress="0.25" />
<ProgressSpinner :progress="0.5" />
<ProgressSpinner :progress="0.75" />
<ProgressSpinner :progress="1" />
</div>
`,
}),
}

View File

@@ -0,0 +1,213 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ProjectCard from '../../components/base/ProjectCard.vue'
const meta = {
title: 'Base/ProjectCard',
component: ProjectCard,
decorators: [
(story) => ({
components: { story },
template: '<div class="display-mode--grid"><story /></div>',
}),
],
} satisfies Meta<typeof ProjectCard>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
id: 'example-mod',
type: 'mod',
name: 'Example Mod',
author: 'ModAuthor',
description:
'An example mod that demonstrates the ProjectCard component with a detailed description.',
iconUrl: 'https://cdn.modrinth.com/data/AANobbMI/icon.png',
downloads: '1234567',
follows: '12345',
createdAt: '2023-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
categories: ['adventure', 'decoration'],
projectTypeDisplay: 'Mod',
projectTypeUrl: 'mod',
clientSide: 'required',
serverSide: 'optional',
},
}
export const AllTypes: Story = {
render: () => ({
components: { ProjectCard },
template: `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<ProjectCard
id="example-mod"
type="mod"
name="Example Mod"
author="ModAuthor"
description="A wonderful mod that adds new features to the game."
downloads="1000000"
follows="50000"
createdAt="2023-01-15T00:00:00Z"
:categories="['technology', 'magic']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
clientSide="required"
serverSide="optional"
/>
<ProjectCard
id="example-plugin"
type="plugin"
name="Example Plugin"
author="PluginDev"
description="A server plugin for managing permissions."
downloads="500000"
follows="25000"
createdAt="2023-06-01T00:00:00Z"
:categories="['utility']"
projectTypeDisplay="Plugin"
projectTypeUrl="plugin"
serverSide="required"
/>
<ProjectCard
id="example-modpack"
type="modpack"
name="Example Modpack"
author="PackCreator"
description="A curated collection of mods for the best experience."
downloads="250000"
follows="10000"
createdAt="2023-03-20T00:00:00Z"
:categories="['adventure']"
projectTypeDisplay="Modpack"
projectTypeUrl="modpack"
/>
<ProjectCard
id="example-resourcepack"
type="resourcepack"
name="HD Textures"
author="ArtistName"
description="High definition textures for a better visual experience."
downloads="750000"
follows="30000"
createdAt="2022-12-01T00:00:00Z"
:categories="['realistic']"
projectTypeDisplay="Resource Pack"
projectTypeUrl="resourcepack"
/>
</div>
`,
}),
}
export const WithStatus: Story = {
render: () => ({
components: { ProjectCard },
template: `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<ProjectCard
id="draft-mod"
type="mod"
name="Draft Project"
author="Developer"
description="This project is still in draft mode."
downloads="0"
follows="0"
createdAt="2024-01-01T00:00:00Z"
:categories="['utility']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
status="draft"
/>
<ProjectCard
id="pending-mod"
type="mod"
name="Pending Review"
author="Developer"
description="This project is pending review."
downloads="0"
follows="0"
createdAt="2024-01-01T00:00:00Z"
:categories="['utility']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
status="processing"
/>
</div>
`,
}),
}
export const DisplayModes: StoryObj = {
decorators: [], // Remove default decorator for this story
render: () => ({
components: { ProjectCard },
template: `
<div class="flex flex-col gap-8">
<div>
<h3 class="text-lg font-bold mb-4">Grid Mode</h3>
<div class="display-mode--grid">
<ProjectCard
id="grid-mod"
type="mod"
name="Example Mod"
author="ModAuthor"
description="A wonderful mod that adds new features to the game."
downloads="1000000"
follows="50000"
createdAt="2023-01-15T00:00:00Z"
:categories="['technology', 'magic']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
clientSide="required"
serverSide="optional"
/>
</div>
</div>
<div>
<h3 class="text-lg font-bold mb-4">List Mode</h3>
<div class="display-mode--list">
<ProjectCard
id="list-mod"
type="mod"
name="Example Mod"
author="ModAuthor"
description="A wonderful mod that adds new features to the game."
downloads="1000000"
follows="50000"
createdAt="2023-01-15T00:00:00Z"
:categories="['technology', 'magic']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
clientSide="required"
serverSide="optional"
/>
</div>
</div>
<div>
<h3 class="text-lg font-bold mb-4">Gallery Mode</h3>
<div class="display-mode--gallery">
<ProjectCard
id="gallery-mod"
type="mod"
name="Example Mod"
author="ModAuthor"
description="A wonderful mod that adds new features to the game."
downloads="1000000"
follows="50000"
createdAt="2023-01-15T00:00:00Z"
:categories="['technology', 'magic']"
projectTypeDisplay="Mod"
projectTypeUrl="mod"
clientSide="required"
serverSide="optional"
featuredImage="https://cdn.modrinth.com/data/AANobbMI/images/be1cc1abc9cd9c2f52bb6a39be0b4b05af24d813.png"
/>
</div>
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import RadialHeader from '../../components/base/RadialHeader.vue'
const meta = {
title: 'Base/RadialHeader',
component: RadialHeader,
} satisfies Meta<typeof RadialHeader>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { RadialHeader },
template: `
<RadialHeader class="p-8">
<h1 class="text-2xl font-bold text-center">Radial Header Content</h1>
</RadialHeader>
`,
}),
}
export const AllColors: Story = {
render: () => ({
components: { RadialHeader },
template: `
<div class="flex flex-col gap-4">
<RadialHeader color="brand" class="p-8">
<p class="text-center font-semibold">Brand</p>
</RadialHeader>
<RadialHeader color="red" class="p-8">
<p class="text-center font-semibold">Red</p>
</RadialHeader>
<RadialHeader color="orange" class="p-8">
<p class="text-center font-semibold">Orange</p>
</RadialHeader>
<RadialHeader color="green" class="p-8">
<p class="text-center font-semibold">Green</p>
</RadialHeader>
<RadialHeader color="blue" class="p-8">
<p class="text-center font-semibold">Blue</p>
</RadialHeader>
<RadialHeader color="purple" class="p-8">
<p class="text-center font-semibold">Purple</p>
</RadialHeader>
<RadialHeader color="gray" class="p-8">
<p class="text-center font-semibold">Gray</p>
</RadialHeader>
</div>
`,
}),
}

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import RadioButtons from '../../components/base/RadioButtons.vue'
const meta = {
title: 'Base/RadioButtons',
// @ts-ignore - error comes from generically typed component
component: RadioButtons,
render: (args) => ({
components: { RadioButtons },
setup() {
return { args }
},
template: /*html*/ `
<RadioButtons v-bind="args" v-model="args.modelValue">
<template #default="{ item }">{{ item }}</template>
</RadioButtons>
`,
}),
} satisfies Meta<typeof RadioButtons>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
items: ['Option 1', 'Option 2', 'Option 3'],
modelValue: 'Option 1',
},
}
export const ManyOptions: Story = {
args: {
items: ['Daily', 'Weekly', 'Monthly', 'Yearly'],
modelValue: 'Weekly',
},
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ScrollablePanel from '../../components/base/ScrollablePanel.vue'
const meta = {
title: 'Base/ScrollablePanel',
component: ScrollablePanel,
render: (args) => ({
components: { ScrollablePanel },
setup() {
return { args }
},
template: /*html*/ `
<ScrollablePanel v-bind="args">
<div style="display: flex; flex-direction: column; gap: 1rem;">
<p v-for="i in 20" :key="i">Item {{ i }}</p>
</div>
</ScrollablePanel>
`,
}),
} satisfies Meta<typeof ScrollablePanel>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const DisabledScrolling: Story = {
args: {
disableScrolling: true,
},
}

View File

@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ServerNotice from '../../components/base/ServerNotice.vue'
const meta = {
title: 'Base/ServerNotice',
component: ServerNotice,
} satisfies Meta<typeof ServerNotice>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
level: 'info',
message: 'This is an informational server notice.',
dismissable: true,
},
}
export const AllLevels: StoryObj = {
render: () => ({
components: { ServerNotice },
template: `
<div class="flex flex-col gap-4">
<ServerNotice
level="info"
message="This is an informational notice for users."
:dismissable="true"
@dismiss="() => console.log('dismissed')"
/>
<ServerNotice
level="warn"
message="This is a warning notice that requires attention."
:dismissable="true"
@dismiss="() => console.log('dismissed')"
/>
<ServerNotice
level="critical"
message="This is a critical notice about an important issue."
:dismissable="true"
@dismiss="() => console.log('dismissed')"
/>
</div>
`,
}),
}
export const WithTitle: Story = {
args: {
level: 'warn',
message: 'Server maintenance is scheduled for tonight at midnight.',
dismissable: true,
title: 'Scheduled Maintenance',
},
}
export const NonDismissable: Story = {
args: {
level: 'critical',
message: 'Your account requires verification before you can upload projects.',
dismissable: false,
},
}

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SettingsLabel from '../../components/base/SettingsLabel.vue'
const meta = {
title: 'Base/SettingsLabel',
component: SettingsLabel,
} satisfies Meta<typeof SettingsLabel>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
title: 'Setting Name',
},
}
export const WithDescription: Story = {
args: {
title: 'Enable Notifications',
description: 'Receive email notifications when someone follows your project.',
},
}
export const WithId: Story = {
args: {
id: 'setting-input',
title: 'Username',
description: 'Your unique username on the platform.',
},
}

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SimpleBadge from '../../components/base/SimpleBadge.vue'
const meta = {
title: 'Base/SimpleBadge',
component: SimpleBadge,
} satisfies Meta<typeof SimpleBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
formattedName: 'Badge Text',
},
}

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Slider from '../../components/base/Slider.vue'
const meta = {
title: 'Base/Slider',
component: Slider,
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: 50,
min: 0,
max: 100,
},
}
export const WithUnit: Story = {
args: {
modelValue: 50,
min: 0,
max: 100,
unit: '%',
},
}
export const WithSnapPoints: Story = {
args: {
modelValue: 25,
min: 0,
max: 100,
step: 25,
snapPoints: [0, 25, 50, 75, 100],
},
}
export const Disabled: Story = {
args: {
modelValue: 50,
min: 0,
max: 100,
disabled: true,
},
}

View File

@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SmartClickable from '../../components/base/SmartClickable.vue'
const meta = {
title: 'Base/SmartClickable',
component: SmartClickable,
render: (args) => ({
components: { SmartClickable },
setup() {
return { args }
},
template: /*html*/ `
<SmartClickable v-bind="args">
<template #clickable>
<a href="#" style="display: block; width: 100%; height: 100%;"></a>
</template>
<div style="padding: 1rem; background: var(--color-button-bg); border-radius: 0.5rem;">
<h3>Clickable Card</h3>
<p>The entire card is clickable</p>
</div>
</SmartClickable>
`,
}),
} satisfies Meta<typeof SmartClickable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TagItem from '../../components/base/TagItem.vue'
const meta = {
title: 'Base/TagItem',
component: TagItem,
render: (args) => ({
components: { TagItem },
setup() {
return { args }
},
template: /*html*/ `
<TagItem v-bind="args">Tag Name</TagItem>
`,
}),
} satisfies Meta<typeof TagItem>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithAction: Story = {
args: {
action: () => alert('Tag clicked!'),
},
}
export const MultipleTags: Story = {
render: () => ({
components: { TagItem },
template: /*html*/ `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<TagItem>Minecraft</TagItem>
<TagItem>Fabric</TagItem>
<TagItem>Adventure</TagItem>
<TagItem>Technology</TagItem>
</div>
`,
}),
}

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Timeline from '../../components/base/Timeline.vue'
const meta = {
title: 'Base/Timeline',
component: Timeline,
render: (args) => ({
components: { Timeline },
setup() {
return { args }
},
template: /*html*/ `
<Timeline v-bind="args">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="width: 1rem; height: 1rem; border-radius: 50%; background: var(--color-brand);"></div>
<span>Event 1</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="width: 1rem; height: 1rem; border-radius: 50%; background: var(--color-brand);"></div>
<span>Event 2</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="width: 1rem; height: 1rem; border-radius: 50%; background: var(--color-brand);"></div>
<span>Event 3</span>
</div>
</Timeline>
`,
}),
} satisfies Meta<typeof Timeline>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const FadeOutStart: Story = {
args: {
fadeOutStart: true,
},
}
export const FadeOutEnd: Story = {
args: {
fadeOutEnd: true,
},
}

View File

@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Toggle from '../../components/base/Toggle.vue'
const meta = {
title: 'Base/Toggle',
component: Toggle,
} satisfies Meta<typeof Toggle>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: false,
},
}
export const Checked: Story = {
args: {
modelValue: true,
},
}
export const Disabled: Story = {
args: {
modelValue: false,
disabled: true,
},
}
export const AllStates: Story = {
render: () => ({
components: { Toggle },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" /> Off
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="true" /> On
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :disabled="true" /> Disabled
</div>
</div>
`,
}),
}

View File

@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UnsavedChangesPopup from '../../components/base/UnsavedChangesPopup.vue'
const meta = {
title: 'Base/UnsavedChangesPopup',
// @ts-ignore
component: UnsavedChangesPopup,
} satisfies Meta<typeof UnsavedChangesPopup>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { UnsavedChangesPopup },
template: `
<div class="relative h-32">
<UnsavedChangesPopup
:original="{ name: 'Original Name' }"
:modified="{ name: 'Modified Name' }"
@save="() => console.log('Save clicked')"
@reset="() => console.log('Reset clicked')"
/>
</div>
`,
}),
}
export const Saving: StoryObj = {
render: () => ({
components: { UnsavedChangesPopup },
template: `
<div class="relative h-32">
<UnsavedChangesPopup
:original="{ name: 'Original' }"
:modified="{ name: 'Changed' }"
:saving="true"
@save="() => console.log('Save clicked')"
@reset="() => console.log('Reset clicked')"
/>
</div>
`,
}),
}
export const NoResetButton: StoryObj = {
render: () => ({
components: { UnsavedChangesPopup },
template: `
<div class="relative h-32">
<UnsavedChangesPopup
:original="{ name: 'Original' }"
:modified="{ name: 'Changed' }"
:canReset="false"
@save="() => console.log('Save clicked')"
/>
</div>
`,
}),
}
export const Hidden: StoryObj = {
render: () => ({
components: { UnsavedChangesPopup },
template: `
<div class="relative h-32">
<p class="text-secondary mb-4">No changes detected - popup is hidden</p>
<UnsavedChangesPopup
:original="{ name: 'Same Value' }"
:modified="{ name: 'Same Value' }"
@save="() => console.log('Save clicked')"
@reset="() => console.log('Reset clicked')"
/>
</div>
`,
}),
}

View File

@@ -0,0 +1,213 @@
import type { StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import NewModal from '../../components/modal/NewModal.vue'
const meta = {
title: 'Modal/NewModal',
component: NewModal,
}
export default meta
type Story = StoryObj<typeof NewModal>
export const Default: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Modal</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Example Modal">
<p>This is the modal content.</p>
<p class="text-secondary mt-2">You can put any content here.</p>
</NewModal>
</div>
`,
}),
}
export const WithActions: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Modal with Actions</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Confirm Action">
<p>Are you sure you want to proceed with this action?</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modalRef?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="modalRef?.hide()">Confirm</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}
export const DangerFade: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="red">
<button @click="openModal">Open Danger Modal</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Delete Item" fade="danger">
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modalRef?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="modalRef?.hide()">Delete</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}
export const WarningFade: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="orange">
<button @click="openModal">Open Warning Modal</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Warning" fade="warning">
<p>This action may have unintended consequences. Please review before proceeding.</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modalRef?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="modalRef?.hide()">Proceed</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}
export const Scrollable: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Scrollable Modal</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Scrollable Content" scrollable max-content-height="300px">
<div class="space-y-4">
<p v-for="i in 20" :key="i">
This is paragraph {{ i }} of scrollable content. The modal will show fade indicators when scrolled.
</p>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled color="brand">
<button @click="modalRef?.hide()">Close</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}
export const MergedHeader: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Modal (Merged Header)</button>
</ButtonStyled>
<NewModal ref="modalRef" hide-header merge-header>
<div class="text-center py-8">
<h2 class="text-xl font-bold mb-4">Custom Header Area</h2>
<p>This modal has the header merged with content and only shows a close button.</p>
</div>
</NewModal>
</div>
`,
}),
}
export const NotClosable: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Non-Closable Modal</button>
</ButtonStyled>
<NewModal ref="modalRef" header="Processing..." :closable="false" :close-on-esc="false" :close-on-click-outside="false">
<p>This modal cannot be closed by clicking outside or pressing escape.</p>
<p class="text-secondary mt-2">Only the action button can close it.</p>
<template #actions>
<div class="flex justify-end">
<ButtonStyled color="brand">
<button @click="modalRef?.hide()">I understand, close</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -2,7 +2,12 @@ import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/components/**/*.{js,vue,ts}', './src/pages/**/*.{js,vue,ts}'],
content: [
'./src/components/**/*.{js,vue,ts}',
'./src/pages/**/*.{js,vue,ts}',
'./src/stories/**/*.{js,vue,ts,mdx}',
'./.storybook/**/*.{ts,js}',
],
presets: [preset],
}

View File

@@ -0,0 +1,44 @@
import path from 'node:path'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import svgLoader from 'vite-svg-loader'
export default defineConfig({
plugins: [
vue(),
svgLoader({
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
}),
],
cacheDir: '.vite',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
lib: {
entry: path.resolve(__dirname, 'index.ts'),
name: 'ModrinthUI',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['vue'],
},
},
})

2103
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff