diff --git a/vue/stories/.gitignore b/vue/stories/.gitignore new file mode 100644 index 0000000..4fee689 --- /dev/null +++ b/vue/stories/.gitignore @@ -0,0 +1,3 @@ +node_modules +storybook-static +*.log diff --git a/vue/stories/.storybook/main.ts b/vue/stories/.storybook/main.ts new file mode 100644 index 0000000..438a142 --- /dev/null +++ b/vue/stories/.storybook/main.ts @@ -0,0 +1,21 @@ +import type { StorybookConfig } from '@storybook/vue3-vite'; + +const config: StorybookConfig = { + stories: ['../stories/**/*.stories.ts'], + addons: [ + '@storybook/addon-docs', + '@storybook/addon-a11y', + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: { + defaultName: 'Docs', + }, + typescript: { + check: false, + }, +}; + +export default config; diff --git a/vue/stories/.storybook/preview-styles.css b/vue/stories/.storybook/preview-styles.css new file mode 100644 index 0000000..84c5a98 --- /dev/null +++ b/vue/stories/.storybook/preview-styles.css @@ -0,0 +1,110 @@ +.sb-dialog-trigger, +.sb-dialog-close, +.sb-toggle { + font: inherit; + padding: 0.5rem 1rem; + border-radius: 6px; + border: 1px solid #888; + background: #fff; + cursor: pointer; +} + +.sb-toggle[aria-pressed='true'] { background: #222; color: #fff; } +.sb-toggle[data-disabled] { opacity: 0.5; cursor: not-allowed; } + +.sb-dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); } + +.sb-dialog-content { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + padding: 1.5rem; + border-radius: 8px; + min-width: 320px; + max-width: 90vw; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + display: grid; + gap: 0.75rem; +} + +.sb-dialog-title { margin: 0; font-size: 1.125rem; font-weight: 600; } +.sb-dialog-desc { margin: 0; color: #555; } + +.sb-switch { + position: relative; + width: 44px; + height: 24px; + padding: 0; + border-radius: 999px; + border: 1px solid #888; + background: #ddd; + cursor: pointer; + transition: background 0.15s; +} +.sb-switch[aria-checked='true'] { background: #10b981; border-color: #10b981; } +.sb-switch-thumb { + display: block; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transform: translateX(2px); + transition: transform 0.15s; +} +.sb-switch[aria-checked='true'] .sb-switch-thumb { transform: translateX(22px); } + +.sb-progress { + position: relative; + width: 240px; + height: 8px; + background: #eee; + border-radius: 999px; + overflow: hidden; +} +.sb-progress-ind { height: 100%; background: #3b82f6; transition: width 0.2s; } +.sb-progress[data-state='indeterminate'] .sb-progress-ind { + animation: sb-progress-indeterminate 1.2s ease-in-out infinite; + background: linear-gradient(90deg, transparent, #3b82f6, transparent); +} +@keyframes sb-progress-indeterminate { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +.sb-collapsible { font-family: system-ui; max-width: 320px; } +.sb-collapsible-trigger { + font: inherit; + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid #888; + background: #fff; + cursor: pointer; + width: 100%; + text-align: left; +} +.sb-collapsible-content { + padding: 0.5rem 0.75rem; + border: 1px dashed #bbb; + border-top: none; + border-radius: 0 0 6px 6px; +} + +.sb-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + background: #e5e7eb; + font-family: system-ui; + font-weight: 600; + color: #374151; + vertical-align: middle; +} +.sb-avatar-img { width: 100%; height: 100%; object-fit: cover; } +.sb-avatar-fallback { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; } diff --git a/vue/stories/.storybook/preview.ts b/vue/stories/.storybook/preview.ts new file mode 100644 index 0000000..97a79cd --- /dev/null +++ b/vue/stories/.storybook/preview.ts @@ -0,0 +1,19 @@ +import type { Preview } from '@storybook/vue3-vite'; +import './preview-styles.css'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: 'todo', + }, + }, + tags: ['autodocs'], +}; + +export default preview; diff --git a/vue/stories/eslint.config.ts b/vue/stories/eslint.config.ts new file mode 100644 index 0000000..940a7b6 --- /dev/null +++ b/vue/stories/eslint.config.ts @@ -0,0 +1,9 @@ +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic, { + name: 'stories/overrides', + files: ['**/*.vue', '**/*.stories.ts'], + rules: { + '@stylistic/no-multiple-empty-lines': 'off', + }, +}); diff --git a/vue/stories/package.json b/vue/stories/package.json new file mode 100644 index 0000000..798a816 --- /dev/null +++ b/vue/stories/package.json @@ -0,0 +1,30 @@ +{ + "name": "@robonen/stories", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "description": "Storybook for @robonen/primitives", + "type": "module", + "scripts": { + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", + "dev": "storybook dev -p 6006 --no-open", + "build": "storybook build -o storybook-static" + }, + "dependencies": { + "@robonen/primitives": "workspace:*", + "@robonen/vue": "workspace:*", + "vue": "catalog:" + }, + "devDependencies": { + "@robonen/eslint": "workspace:*", + "@robonen/tsconfig": "workspace:*", + "@storybook/addon-a11y": "^10.2.1", + "@storybook/addon-docs": "^10.2.1", + "@storybook/vue3-vite": "^10.2.1", + "@vitejs/plugin-vue": "^6.0.6", + "eslint": "catalog:", + "storybook": "^10.2.1", + "vite": "^7.1.9" + } +} diff --git a/vue/stories/stories/AspectRatio.stories.ts b/vue/stories/stories/AspectRatio.stories.ts new file mode 100644 index 0000000..27bf9d7 --- /dev/null +++ b/vue/stories/stories/AspectRatio.stories.ts @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { AspectRatio } from '@robonen/primitives'; + +const meta = { + title: 'Layout/AspectRatio', + component: AspectRatio, + tags: ['autodocs'], + argTypes: { + ratio: { control: { type: 'number', min: 0.1, step: 0.1 } }, + }, + args: { ratio: 16 / 9 }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Widescreen: Story = { + render: args => ({ + components: { AspectRatio }, + setup: () => ({ args }), + template: ` +
+ + landscape + +
+ `, + }), +}; + +export const Square: Story = { + args: { ratio: 1 }, + render: args => ({ + components: { AspectRatio }, + setup: () => ({ args }), + template: ` +
+ +
1 : 1
+
+
+ `, + }), +}; diff --git a/vue/stories/stories/Avatar.stories.ts b/vue/stories/stories/Avatar.stories.ts new file mode 100644 index 0000000..8d40e49 --- /dev/null +++ b/vue/stories/stories/Avatar.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { AvatarFallback, AvatarImage, AvatarRoot } from '@robonen/primitives'; + +const meta = { + title: 'Media/Avatar', + component: AvatarRoot, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + render: () => ({ + components: { AvatarRoot, AvatarImage, AvatarFallback }, + template: ` + + + CT + + `, + }), +}; + +export const Fallback: Story = { + render: () => ({ + components: { AvatarRoot, AvatarImage, AvatarFallback }, + template: ` + + + AB + + `, + }), +}; diff --git a/vue/stories/stories/Collapsible.stories.ts b/vue/stories/stories/Collapsible.stories.ts new file mode 100644 index 0000000..1e67913 --- /dev/null +++ b/vue/stories/stories/Collapsible.stories.ts @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from '@robonen/primitives'; + +const meta = { + title: 'Disclosure/Collapsible', + component: CollapsibleRoot, + tags: ['autodocs'], + args: { defaultOpen: false, disabled: false }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: args => ({ + components: { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent }, + setup: () => ({ args }), + template: ` + + + + + +

Hidden content revealed when the trigger is activated.

+
+
+ `, + }), +}; + +export const OpenByDefault: Story = { args: { defaultOpen: true }, render: Default.render }; +export const Disabled: Story = { args: { disabled: true }, render: Default.render }; diff --git a/vue/stories/stories/Dialog.stories.ts b/vue/stories/stories/Dialog.stories.ts new file mode 100644 index 0000000..02c86fd --- /dev/null +++ b/vue/stories/stories/Dialog.stories.ts @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogRoot, + DialogTitle, + DialogTrigger, +} from '@robonen/primitives'; + +const meta = { + title: 'Overlays/Dialog', + component: DialogRoot, + tags: ['autodocs'], + argTypes: { + modal: { control: 'boolean' }, + defaultOpen: { control: 'boolean' }, + }, + args: { + modal: true, + defaultOpen: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const render = (args: Record) => ({ + components: { + DialogRoot, + DialogTrigger, + DialogPortal, + DialogOverlay, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, + }, + setup: () => ({ args }), + template: ` + + Open Dialog + + + + Dialog Title + + Traps focus, locks scroll, and dismisses on Escape or outside click. + + Close + + + + `, +}); + +export const Default: Story = { render }; + +export const OpenByDefault: Story = { + args: { defaultOpen: true }, + render, +}; + +export const NonModal: Story = { + args: { modal: false }, + render: args => ({ + components: { + DialogRoot, + DialogTrigger, + DialogPortal, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, + }, + setup: () => ({ args }), + template: ` + + Open non-modal + + + Non-modal dialog + + No overlay, no scroll lock, no focus trap. + + Close + + + + `, + }), +}; diff --git a/vue/stories/stories/Label.stories.ts b/vue/stories/stories/Label.stories.ts new file mode 100644 index 0000000..412191e --- /dev/null +++ b/vue/stories/stories/Label.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { Label } from '@robonen/primitives'; + +const meta = { + title: 'Forms/Label', + component: Label, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ({ + components: { Label }, + template: ` +
+ + +
+ `, + }), +}; + +export const WithCheckbox: Story = { + render: () => ({ + components: { Label }, + template: ` +
+ +
+ `, + }), +}; diff --git a/vue/stories/stories/Progress.stories.ts b/vue/stories/stories/Progress.stories.ts new file mode 100644 index 0000000..fa501a6 --- /dev/null +++ b/vue/stories/stories/Progress.stories.ts @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { ProgressIndicator, ProgressRoot } from '@robonen/primitives'; + +const meta = { + title: 'Feedback/Progress', + component: ProgressRoot, + tags: ['autodocs'], + argTypes: { + modelValue: { control: { type: 'number', min: 0, max: 100, step: 1 } }, + max: { control: { type: 'number', min: 1 } }, + }, + args: { modelValue: 40, max: 100 }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Determinate: Story = { + render: args => ({ + components: { ProgressRoot, ProgressIndicator }, + setup: () => ({ args }), + template: ` + + + + `, + }), +}; + +export const Indeterminate: Story = { + args: { modelValue: null }, + render: Determinate.render, +}; + +export const Complete: Story = { + args: { modelValue: 100 }, + render: Determinate.render, +}; diff --git a/vue/stories/stories/Separator.stories.ts b/vue/stories/stories/Separator.stories.ts new file mode 100644 index 0000000..a1c4c22 --- /dev/null +++ b/vue/stories/stories/Separator.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { Separator } from '@robonen/primitives'; + +const meta = { + title: 'Layout/Separator', + component: Separator, + tags: ['autodocs'], + argTypes: { + orientation: { control: 'radio', options: ['horizontal', 'vertical'] }, + decorative: { control: 'boolean' }, + }, + args: { orientation: 'horizontal', decorative: false }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseStyle = ` + background: #888; + display: block; +`; +const horizontal = `${baseStyle} height: 1px; width: 100%;`; +const vertical = `${baseStyle} height: 24px; width: 1px; display: inline-block; margin: 0 0.75rem;`; + +export const Horizontal: Story = { + render: args => ({ + components: { Separator }, + setup: () => ({ args, horizontal }), + template: ` +
+

Section one

+ +

Section two

+
+ `, + }), +}; + +export const Vertical: Story = { + args: { orientation: 'vertical' }, + render: args => ({ + components: { Separator }, + setup: () => ({ args, vertical }), + template: ` + + `, + }), +}; diff --git a/vue/stories/stories/Switch.stories.ts b/vue/stories/stories/Switch.stories.ts new file mode 100644 index 0000000..0cba12b --- /dev/null +++ b/vue/stories/stories/Switch.stories.ts @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { Label, Switch } from '@robonen/primitives'; +import { ref } from 'vue'; + +const meta = { + title: 'Forms/Switch', + component: Switch, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BooleanDefault: Story = { + name: 'Boolean (default)', + render: () => ({ + components: { Switch, Label }, + template: ` +
+ + + + +
+ `, + }), +}; + +export const StringPair: Story = { + name: 'String pair ("on" / "off")', + render: () => ({ + components: { Switch, Label }, + setup() { + const value = ref<'on' | 'off'>('off'); + return { value }; + }, + template: ` +
+ + + + +
+ `, + }), +}; + +export const ObjectPair: Story = { + name: 'Object pair (generic)', + render: () => ({ + components: { Switch, Label }, + setup() { + const LIGHT = { theme: 'light' as const }; + const DARK = { theme: 'dark' as const }; + const value = ref(LIGHT); + return { value, LIGHT, DARK }; + }, + template: ` +
+ + + + +
+ `, + }), +}; + +export const Disabled: Story = { + name: 'Disabled', + render: () => ({ + components: { Switch, Label }, + template: ` +
+ + + + +
+ `, + }), +}; diff --git a/vue/stories/stories/Toggle.stories.ts b/vue/stories/stories/Toggle.stories.ts new file mode 100644 index 0000000..9dd8415 --- /dev/null +++ b/vue/stories/stories/Toggle.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import { Toggle } from '@robonen/primitives'; + +const meta = { + title: 'Forms/Toggle', + component: Toggle, + tags: ['autodocs'], + argTypes: { disabled: { control: 'boolean' }, defaultPressed: { control: 'boolean' } }, + args: { disabled: false, defaultPressed: false }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const template = ` + + + +`; + +export const Default: Story = { + render: args => ({ components: { Toggle }, setup: () => ({ args }), template }), +}; + +export const Pressed: Story = { + args: { defaultPressed: true }, + render: args => ({ components: { Toggle }, setup: () => ({ args }), template }), +}; + +export const Disabled: Story = { + args: { disabled: true }, + render: args => ({ components: { Toggle }, setup: () => ({ args }), template }), +}; diff --git a/vue/stories/tsconfig.json b/vue/stories/tsconfig.json new file mode 100644 index 0000000..2781e66 --- /dev/null +++ b/vue/stories/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/vue/stories/tsconfig.node.json b/vue/stories/tsconfig.node.json new file mode 100644 index 0000000..43827e8 --- /dev/null +++ b/vue/stories/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts", ".storybook/**/*.ts"] +} diff --git a/vue/stories/tsconfig.src.json b/vue/stories/tsconfig.src.json new file mode 100644 index 0000000..11f772b --- /dev/null +++ b/vue/stories/tsconfig.src.json @@ -0,0 +1,10 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.vue.json", + "compilerOptions": { + "composite": true, + "types": ["vite/client"], + "allowImportingTsExtensions": false, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo" + }, + "include": ["stories/**/*.ts", "stories/**/*.vue"] +}