+
+export const Playground: Story = {}
+
+export const AllVariants: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon },
+ setup() {
+ return { sizeColumns, variantRows }
+ },
+ template: /*html*/ `
+
+
+
+ {{ size.label }}
+
+
+
+ {{ row.label }}
+
+
+
+ `,
+ }),
+}
+
+export const Quiet: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, IconButton, SettingsIcon },
+ template: /*html*/ `
+
+
+
+
+
+ `,
+ }),
+}
+
+export const Sizes: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, IconButton },
+ setup() {
+ return { sizes }
+ },
+ template: /*html*/ `
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const ColoredTones: Story = {
+ render: () => ({
+ components: { Button },
+ setup() {
+ return { tones }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+}
+
+export const Content: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, SettingsIcon },
+ template: /*html*/ `
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const InteractionStates: Story = {
+ render: () => ({
+ components: { Button },
+ template: /*html*/ `
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const LinksAndIconButton: Story = {
+ render: () => ({
+ components: { ButtonLink, ExternalIcon, HeartIcon, IconButton },
+ template: /*html*/ `
+
+ Internal link
+
+ Modrinth
+
+ Disabled link
+
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/buttons/ButtonGroup.stories.ts b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts
new file mode 100644
index 0000000000..3a7b93364a
--- /dev/null
+++ b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts
@@ -0,0 +1,82 @@
+import { PlayIcon, SettingsIcon, StopCircleIcon, TrashIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Button from '../../components/base/buttons/Button.vue'
+import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue'
+import SplitButton from '../../components/base/buttons/SplitButton.vue'
+import type { OverflowMenuOption } from '../../components/base/buttons/types'
+
+const splitOptions: OverflowMenuOption[] = [
+ {
+ id: 'settings',
+ label: 'Server settings',
+ icon: SettingsIcon,
+ action: () => undefined,
+ },
+ { type: 'divider' },
+ {
+ id: 'delete',
+ label: 'Delete server',
+ icon: TrashIcon,
+ tone: 'red',
+ action: () => undefined,
+ },
+]
+
+const meta = {
+ title: 'Buttons/Button Group',
+ component: ButtonGroup,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Joined: Story = {
+ render: () => ({
+ components: { Button, ButtonGroup },
+ template: /*html*/ `
+
+
+
+
+ `,
+ }),
+}
+
+export const Split: Story = {
+ render: () => ({
+ components: { PlayIcon, SplitButton },
+ setup() {
+ return { splitOptions }
+ },
+ template: /*html*/ `
+
+ Start server
+
+ `,
+ }),
+}
+
+export const IndependentDisabledStates: Story = {
+ render: () => ({
+ components: { SplitButton, StopCircleIcon },
+ setup() {
+ return { splitOptions }
+ },
+ template: /*html*/ `
+
+
+ Primary disabled
+
+
+ Menu disabled
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/buttons/FileButton.stories.ts b/packages/ui/src/stories/buttons/FileButton.stories.ts
new file mode 100644
index 0000000000..5a744a0cd8
--- /dev/null
+++ b/packages/ui/src/stories/buttons/FileButton.stories.ts
@@ -0,0 +1,61 @@
+import { UploadIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import FileButton from '../../components/base/buttons/FileButton.vue'
+
+const meta = {
+ title: 'Buttons/File Button',
+ component: FileButton,
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['base', 'colored', 'outlined', 'quiet'],
+ },
+ size: {
+ control: 'select',
+ options: ['sm', 'default', 'md', 'lg'],
+ },
+ tone: {
+ control: 'select',
+ options: ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'promotion'],
+ },
+ },
+ args: {
+ prompt: 'Select file',
+ variant: 'base',
+ size: 'default',
+ multiple: false,
+ disabled: false,
+ },
+ render: (args) => ({
+ components: { FileButton, UploadIcon },
+ setup() {
+ return { args }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const MultipleImages: Story = {
+ args: {
+ prompt: 'Select images',
+ accept: 'image/*',
+ multiple: true,
+ variant: 'colored',
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+}
diff --git a/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts
new file mode 100644
index 0000000000..ff10ea991d
--- /dev/null
+++ b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts
@@ -0,0 +1,97 @@
+import {
+ DownloadIcon,
+ ExternalIcon,
+ MoreVerticalIcon,
+ SettingsIcon,
+ TrashIcon,
+} from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue'
+import type { OverflowMenuOption } from '../../components/base/buttons/types'
+
+const options: OverflowMenuOption[] = [
+ {
+ id: 'download',
+ label: 'Download',
+ icon: DownloadIcon,
+ action: () => undefined,
+ },
+ {
+ id: 'settings',
+ label: 'Project settings',
+ icon: SettingsIcon,
+ type: 'link',
+ to: '/settings',
+ },
+ {
+ id: 'website',
+ label: 'Open website',
+ icon: ExternalIcon,
+ type: 'link',
+ href: 'https://modrinth.com',
+ target: '_blank',
+ },
+ {
+ id: 'unavailable',
+ label: 'Unavailable action',
+ disabled: true,
+ tooltip: 'This action is currently unavailable',
+ action: () => undefined,
+ },
+ { type: 'divider' },
+ {
+ id: 'delete',
+ label: 'Delete project',
+ icon: TrashIcon,
+ tone: 'red',
+ action: () => undefined,
+ },
+]
+
+const meta = {
+ title: 'Buttons/Teleport Overflow Menu',
+ component: TeleportOverflowMenu,
+ args: {
+ label: 'More actions',
+ options,
+ variant: 'base',
+ size: 'default',
+ placement: 'bottom-end',
+ disabled: false,
+ },
+ render: (args) => ({
+ components: { MoreVerticalIcon, TeleportOverflowMenu },
+ setup() {
+ return { args }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const ColoredTrigger: Story = {
+ args: {
+ variant: 'colored',
+ },
+}
+
+export const OutlinedTrigger: Story = {
+ args: {
+ variant: 'outlined',
+ },
+}
+
+export const QuietTrigger: Story = {
+ args: {
+ variant: 'quiet',
+ },
+}
diff --git a/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts
new file mode 100644
index 0000000000..d4910437bc
--- /dev/null
+++ b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts
@@ -0,0 +1,50 @@
+import { SettingsIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Button from '../../components/base/buttons/Button.vue'
+import TeleportPopoutMenu from '../../components/base/buttons/TeleportPopoutMenu.vue'
+
+const meta = {
+ title: 'Buttons/Teleport Popout Menu',
+ component: TeleportPopoutMenu,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const ArbitraryContent: Story = {
+ render: () => ({
+ components: { Button, SettingsIcon, TeleportPopoutMenu },
+ template: /*html*/ `
+
+
+ Configure
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const IconTrigger: Story = {
+ render: () => ({
+ components: { SettingsIcon, TeleportPopoutMenu },
+ template: /*html*/ `
+
+
+
+ Arbitrary teleported content can live here.
+
+
+ `,
+ }),
+}