mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 10:55:57 +00:00
Story: support variants
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -205,8 +205,8 @@ export class DataEngine {
|
||||
}
|
||||
|
||||
reset(data: Record<string, unknown>) {
|
||||
this.update([], data);
|
||||
this.attachedDataMap.clear();
|
||||
this.update([], data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+3
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+6
-7
Reference in New Issue
Block a user