Story: support variants

This commit is contained in:
Fuma Nama
2026-01-19 21:41:24 +08:00
parent 635a114de2
commit 79955bb2cd
11 changed files with 269 additions and 122 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@fumadocs/story": patch
---
support variants
@@ -42,10 +42,29 @@ If you are new to the concept of Story, this is a short explanation from **Story
describe the component's behavior and appearance given a set of arguments.
</blockquote>
To create a story in Fumadocs Story:
Create a story factory to store your global settings:
```ts title="lib/story.ts"
import { createFileSystemCache, defineStoryFactory } from '@fumadocs/story';
const { defineStory } = defineStoryFactory({
// for Vercel, this is required: choose a directory for cache.
cache:
process.env.NODE_ENV === 'production'
? createFileSystemCache('.next/fumadocs-story')
: undefined,
tsc: {
// we use tsc to generate controls from types
// you can customise TypeScript options here
},
});
```
Now create your first story:
```tsx title="graph-view.story.tsx"
import { defineStory } from '@fumadocs/story';
import { defineStory } from '@/lib/story';
import { GraphView } from '@/components/graph-view';
export const story = defineStory(import.meta.url, {
@@ -57,7 +76,7 @@ export const story = defineStory(import.meta.url, {
});
```
- Fill the `Component` option to your component, the passed component must be a client component.
- Fill the `Component` option to your component, the passed component must be a **client component**.
Now you can render the story like:
@@ -73,7 +92,34 @@ Preview for component:
<story.WithControl />
## Advanced
## Configurations
### Variants
You can provide multiple variants of the component with `args`:
```ts
import { defineStory } from '@/lib/story';
import { Callout } from '@/components/callout';
export const story = defineStory(import.meta.url, {
Component: Callout,
args: [
{
variant: 'Default',
initial: {
type: 'default',
},
},
{
variant: 'Warning',
initial: {
type: 'warning',
},
},
],
});
```
### `controls`
@@ -94,9 +140,9 @@ export const story = defineStory(import.meta.url, {
},
},
// or options for generator powered by tsc
// or customise the generated controls
controls: {
tsconfigPath: 'tsconfig.json',
transform: (node) => node,
},
},
});
@@ -1,16 +1,30 @@
import { createFileSystemCache, defineStory } from '@fumadocs/story';
import { createFileSystemCache, defineStoryFactory } from '@fumadocs/story';
import { CalloutStory } from './client';
export const story = defineStory(import.meta.url, {
Component: CalloutStory,
const { defineStory } = defineStoryFactory({
// use only on Vercel
cache:
process.env.NODE_ENV === 'production'
? createFileSystemCache('.next/fumadocs-story')
: undefined,
args: {
initial: {
title: 'This is a Callout',
});
export const story = defineStory(import.meta.url, {
displayName: 'Callout',
Component: CalloutStory,
args: [
{
variant: 'Default',
initial: {
title: 'This is a Callout',
},
},
},
{
variant: 'Warning',
initial: {
title: 'This is a Callout',
type: 'warning',
},
},
],
});
+1 -1
View File
@@ -205,8 +205,8 @@ export class DataEngine {
}
reset(data: Record<string, unknown>) {
this.update([], data);
this.attachedDataMap.clear();
this.update([], data);
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@fumari/stf": "workspace:^",
"@fumari/stf": "workspace:*",
"@radix-ui/react-select": "^2.2.6",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.562.0",
+3
View File
@@ -9,6 +9,9 @@ export interface Cache {
export * from './fs';
/**
* Note: the returned value is always re-constructed, direct modifications will not pollute the cached values.
*/
export async function cached<V>(
cache: Cache | false,
key: string,
+109 -75
View File
@@ -1,5 +1,5 @@
import * as fs from 'node:fs/promises';
import { createTypeTreeBuilder, Handler, literalEnumHandler } from './type-tree/builder';
import { createTypeTreeBuilder, literalEnumHandler } from './type-tree/builder';
import { cached, type Cache } from './cache';
import type { TypeNode } from './type-tree/types';
import { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
@@ -11,38 +11,36 @@ type Awaitable<T> = T | Promise<T>;
export interface StoryOptions<C extends FC<any>> {
/**
* the export name of story
* the export name of story, necessary for TypeScript Compiler.
*
* @defaultValue `story`
*/
name?: string;
displayName?: string;
Component: C;
cache?: Cache | false;
/**
* story arguments
* story arguments, you can pass an array of options for multiple presets.
*/
args?: {
/**
* the default values of arguments
*/
initial?: ComponentPropsWithoutRef<C> | (() => Awaitable<ComponentPropsWithoutRef<C>>);
/**
* customise the generated controls, by default generated from component props using TypeScript compiler.
*/
controls?:
| {
node: TypeNode;
}
| {
/** default to `tsconfig.json` under cwd */
tsconfigPath?: string;
/** modify generated node */
transform?: (node: TypeNode) => TypeNode;
/** custom type-to-node handler */
handlers?: Handler[];
};
};
args?: ArgsOptions<C> | (ArgsOptions<C> & VariantInfo)[];
}
export interface ArgsOptions<C extends FC<any> = FC<any>> {
/**
* the default values of arguments
*/
initial?: ComponentPropsWithoutRef<C> | (() => Awaitable<ComponentPropsWithoutRef<C>>);
/**
* customise the generated controls, by default generated from component props using TypeScript compiler.
*/
controls?:
| {
node: TypeNode;
}
| {
/** modify generated node */
transform?: (node: TypeNode) => TypeNode;
};
}
export { type Cache, createFileSystemCache } from './cache';
@@ -50,7 +48,7 @@ export { type Cache, createFileSystemCache } from './cache';
export interface StoryResult<C extends FC<any>> {
WithControl: FC<undefined>;
_static_: {
_private_: {
component: C;
};
}
@@ -68,63 +66,99 @@ type ReplaceReactNode<V> = ReactNode extends V
}
: V;
export function defineStory<C extends FC<any>>(
callerUrl: string,
options: StoryOptions<C>,
): StoryResult<C> {
const { Component, cache = false, name = 'story', args = {} } = options;
const filePath = fileURLToPath(callerUrl);
export interface StoryFactoryOptions {
cache?: Cache | false;
async function generateControls(): Promise<{
propsNode: TypeNode;
}> {
const controls = args.controls ?? {};
if ('node' in controls) return { propsNode: controls.node };
tsc?: {
/** default to `tsconfig.json` under cwd */
tsconfigPath?: string;
};
}
const fileContent = await fs.readFile(filePath, 'utf-8');
export interface StoryFactory {
defineStory: <C extends FC<any>>(callerUrl: string, options: StoryOptions<C>) => StoryResult<C>;
}
return cached(cache, getHash(`extract-types:${filePath}:${name}:${fileContent}`), async () => {
const project = new Project({
tsConfigFilePath: controls.tsconfigPath ?? './tsconfig.json',
skipAddingFilesFromTsConfig: true,
});
export interface VariantInfo {
variant: string;
description?: string;
}
const injection = `export type _StoryProps_ = import('@fumadocs/story').GetProps<typeof ${name}>`;
const sourceFile = project.createSourceFile(filePath, `${fileContent}\n${injection}`, {
overwrite: true,
});
const exportedDeclarations = sourceFile.getExportedDeclarations();
const declaration = exportedDeclarations.get('_StoryProps_')?.[0];
export function defineStoryFactory(factoryOptions: StoryFactoryOptions = {}): StoryFactory {
let _project: Project | undefined;
const { cache = false, tsc: { tsconfigPath } = {} } = factoryOptions;
if (!declaration) {
throw new Error(`Export "${name}" not found in file "${filePath}"`);
}
let propsNode = createTypeTreeBuilder(project, [
literalEnumHandler,
...(controls.handlers ?? []),
]).typeToNode(declaration.getType(), declaration);
if (controls.transform) propsNode = controls.transform(propsNode);
return {
propsNode,
};
});
function initProject() {
return (_project ??= new Project({
tsConfigFilePath: tsconfigPath ?? './tsconfig.json',
skipAddingFilesFromTsConfig: true,
}));
}
return {
_static_: {
component: Component,
},
async WithControl() {
const { Story } = await import('./ui/story');
const { propsNode } = await generateControls();
return (
<Story
Component={Component}
argTypes={propsNode}
defaultValues={typeof args.initial === 'function' ? await args.initial() : args.initial}
/>
);
defineStory(callerUrl, { Component, name = 'story', displayName, args = {} }) {
const filePath = fileURLToPath(callerUrl);
async function generateDefaultControls({ controls = {} }: ArgsOptions): Promise<TypeNode> {
if ('node' in controls) return controls.node;
const fileContent = await fs.readFile(filePath, 'utf-8');
let propsNode = await cached(
cache,
getHash(`extract-types:${filePath}:${name}:${fileContent}`),
async () => {
const project = initProject();
const injection = `export type _StoryProps_ = import('@fumadocs/story').GetProps<typeof ${name}>`;
const sourceFile = project.createSourceFile(filePath, `${fileContent}\n${injection}`, {
overwrite: true,
});
const exportedDeclarations = sourceFile.getExportedDeclarations();
const declaration = exportedDeclarations.get('_StoryProps_')?.[0];
if (!declaration) {
throw new Error(`Export "${name}" not found in file "${filePath}"`);
}
return createTypeTreeBuilder(project, [literalEnumHandler]).typeToNode(
declaration.getType(),
declaration,
);
},
);
if (controls.transform) propsNode = controls.transform(propsNode);
return propsNode;
}
return {
_private_: {
component: Component,
},
async WithControl() {
const { Story } = await import('./ui/story');
const presets: (ArgsOptions & VariantInfo)[] = Array.isArray(args)
? args
: [{ ...args, variant: 'default' }];
return (
<Story
displayName={displayName}
Component={Component}
presets={await Promise.all(
presets.map(async (preset) => ({
variant: preset.variant,
description: preset.description,
controls: await generateDefaultControls(preset),
defaultValues:
typeof preset.initial === 'function' ? await preset.initial() : preset.initial,
})),
)}
/>
);
},
};
},
};
}
export const { defineStory } = defineStoryFactory();
+3 -11
View File
@@ -317,7 +317,7 @@ export function FieldSet(
buttonVariants({
size: 'icon-xs',
color: 'ghost',
className: 'text-fd-muted-foreground -ms-1',
className: 'text-fd-muted-foreground',
}),
)}
>
@@ -335,11 +335,7 @@ export function FieldSet(
<ObjectInput
field={field}
fieldName={fieldName}
{...rest}
className={cn(
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-2 shadow-sm',
rest.className,
)}
className="rounded-lg border border-fd-primary/20 bg-fd-background/50 p-2 shadow-sm"
/>
)}
</fieldset>
@@ -376,11 +372,7 @@ export function FieldSet(
<ArrayInput
fieldName={fieldName}
items={field.elementType}
{...rest}
className={cn(
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-2 shadow-sm',
rest.className,
)}
className="rounded-lg border border-fd-primary/20 bg-fd-background/50 p-2 shadow-sm"
/>
)}
</fieldset>
+22 -7
View File
@@ -2,6 +2,7 @@ import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import React, { forwardRef } from 'react';
import { cn } from '@/utils/cn';
import { cva, type VariantProps } from 'class-variance-authority';
const Select = SelectPrimitive.Root;
@@ -9,16 +10,30 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const triggerVariants = cva(
'flex items-center w-full rounded-md gap-2 text-start text-sm focus:outline-none focus:ring focus:ring-fd-ring disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default:
'border p-2 text-fd-secondary-foreground bg-fd-secondary hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'px-1.5 py-1 hover:bg-fd-accent hover:text-fd-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const SelectTrigger = forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
VariantProps<typeof triggerVariants>
>(({ className, variant, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex items-center w-full rounded-md border p-2 gap-2 text-start text-sm text-fd-secondary-foreground bg-fd-secondary hover:bg-fd-accent focus:outline-none focus:ring focus:ring-fd-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
className={cn(triggerVariants({ variant }), className)}
{...props}
>
{children}
@@ -98,7 +113,7 @@ const SelectItem = forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'flex select-none flex-row items-center rounded-md py-1.5 px-2 text-sm outline-none focus:bg-fd-accent focus:text-fd-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'flex select-none flex-row items-center rounded-md gap-2 py-1.5 px-2 text-sm outline-none focus:bg-fd-accent focus:text-fd-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
+46 -7
View File
@@ -8,26 +8,65 @@ import { ErrorBoundary } from 'react-error-boundary';
import { AlertCircle } from 'lucide-react';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { VariantInfo } from '..';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/select';
export function Story({
argTypes,
presets,
displayName,
Component,
defaultValues = {},
}: {
argTypes: TypeNode;
Component: FC;
defaultValues?: Record<string, unknown>;
displayName?: string;
presets: (VariantInfo & {
controls: TypeNode;
defaultValues?: Record<string, unknown>;
})[];
}) {
const [variant, setVariant] = useState(presets[0].variant);
const preset = presets.find((preset) => preset.variant === variant);
const stf = useStf({
defaultValues,
defaultValues: preset?.defaultValues,
});
if (!preset) return;
return (
<StfProvider value={stf}>
<div className="not-prose p-1 border rounded-md shadow-sm bg-fd-card">
<div className="not-prose flex flex-col gap-1 p-1 border rounded-md shadow-sm bg-fd-card text-fd-card-foreground">
<div className="flex flex-row items-center gap-2 empty:hidden">
{displayName && <p className="text-sm font-medium px-1.5">{displayName}</p>}
{presets.length > 1 && (
<Select
value={variant}
onValueChange={(value) => {
const preset = presets.find((preset) => preset.variant === value);
if (preset) {
setVariant(value);
stf.dataEngine.reset(preset.defaultValues ?? {});
}
}}
>
<SelectTrigger
variant="ghost"
className="w-fit ms-auto text-fd-muted-foreground text-xs font-medium"
>
<SelectValue>{preset.variant}</SelectValue>
</SelectTrigger>
<SelectContent>
{presets.map((item) => (
<SelectItem key={item.variant} value={item.variant}>
<p className="text-xs font-medium">{item.variant}</p>
<p className="text-xs text-fd-muted-foreground">{item.description}</p>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<StoryComponent Component={Component} />
<FieldSet
field={argTypes}
key={variant}
field={preset.controls}
fieldName={[]}
name="Props"
className="max-h-[600px] overflow-auto"
+6 -7
View File
@@ -1006,7 +1006,7 @@ importers:
version: 19.2.3(@types/[email protected])
'@vitejs/plugin-rsc':
specifier: ^0.5.11
version: 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-router-devtools:
specifier: ^6.1.0
version: 6.1.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -2237,7 +2237,7 @@ importers:
version: 5.1.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc':
specifier: ^0.5.11
version: 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
commander:
specifier: ^14.0.2
version: 14.0.2
@@ -2462,7 +2462,7 @@ importers:
packages/story:
dependencies:
'@fumari/stf':
specifier: workspace:^
specifier: workspace:*
version: link:../stf
'@radix-ui/react-select':
specifier: ^2.2.6
@@ -6878,7 +6878,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -14240,7 +14239,7 @@ snapshots:
vite-node: 3.2.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
optionalDependencies:
'@react-router/serve': 7.12.0([email protected]([email protected]([email protected]))([email protected]))([email protected])
'@vitejs/plugin-rsc': 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-server-dom-webpack: 19.2.3([email protected]([email protected]))([email protected])([email protected])
typescript: 5.9.3
transitivePeerDependencies:
@@ -15738,7 +15737,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
es-module-lexer: 2.0.0
estree-walker: 3.0.3
@@ -21905,7 +21904,7 @@ snapshots:
dependencies:
'@hono/node-server': 1.19.7([email protected])
'@vitejs/plugin-react': 5.1.2([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.11([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
dotenv: 17.2.3
hono: 4.11.3
magic-string: 0.30.21