docs(web): add Storybook stories for overlay and select primitives (#35334)

This commit is contained in:
yyh
2026-04-17 12:58:16 +08:00
committed by GitHub
parent 13a9359191
commit dc3f992e6e
13 changed files with 1121 additions and 8 deletions

View File

@@ -0,0 +1,179 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '.'
import { Button } from '../button'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/AlertDialog',
component: AlertDialog,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof AlertDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<AlertDialog>
<AlertDialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Delete project
</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Delete project?
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
This action cannot be undone. All workflows, datasets, and API keys will be permanently removed.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">Cancel</AlertDialogCancelButton>
<AlertDialogConfirmButton>Delete</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
),
}
export const NonDestructive: Story = {
render: () => (
<AlertDialog>
<AlertDialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Publish changes
</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Publish the latest draft?
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
Collaborators will immediately see the new workflow. You can still revert from version history.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">Not now</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default">Publish</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
),
}
const ControlledDemo = () => {
const [open, setOpen] = useState(false)
const [count, setCount] = useState(0)
return (
<div className="flex flex-col items-center gap-3">
<Button variant="secondary" onClick={() => setOpen(true)}>
Open controlled dialog
</Button>
<span className="text-xs text-text-tertiary">
Confirmed
{count}
{' '}
times
</span>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Revoke API token?
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
Any integration using this token will immediately stop working. You can issue a new token afterwards.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">Keep token</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
setCount(prev => prev + 1)
setOpen(false)
}}
>
Revoke
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
}
const LoadingConfirmDemo = () => {
const [pending, setPending] = useState(false)
const [open, setOpen] = useState(false)
const handleConfirm = () => {
setPending(true)
window.setTimeout(() => {
setPending(false)
setOpen(false)
}, 1200)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Archive workspace
</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Archive this workspace?
</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-5 text-text-secondary">
Members will lose access until the workspace is restored. This may take a moment to finalize.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={pending}>
Cancel
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="default"
loading={pending}
onClick={handleConfirm}
>
{pending ? 'Archiving…' : 'Archive'}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export const LoadingConfirm: Story = {
render: () => <LoadingConfirmDemo />,
}

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Avatar, AvatarFallback, AvatarRoot } from '.'
const meta = {
title: 'Base/Data Display/Avatar',
title: 'Base/UI/Avatar',
component: Avatar,
parameters: {
docs: {

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Button } from '.'
const meta = {
title: 'Base/General/Button',
title: 'Base/UI/Button',
component: Button,
parameters: {
layout: 'centered',

View File

@@ -29,7 +29,7 @@ const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: strin
)
const meta = {
title: 'Base/Navigation/ContextMenu',
title: 'Base/UI/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',

View File

@@ -0,0 +1,176 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '.'
import { Button } from '../button'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/Dialog',
component: Dialog,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound modal dialog built on Base UI Dialog. Use it for focused flows that interrupt the user, such as editing settings, confirming non-destructive actions, or collecting short-form input. Compose `DialogTitle`, `DialogDescription`, and optional `DialogCloseButton` inside `DialogContent`.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Dialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Dialog>
<DialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Open dialog
</DialogTrigger>
<DialogContent>
<DialogCloseButton />
<div className="flex flex-col gap-2 pr-8">
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Invite collaborators
</DialogTitle>
<DialogDescription className="text-sm leading-5 text-text-secondary">
Add teammates by email to share this workspace. They will receive an invitation link.
</DialogDescription>
</div>
<div className="mt-5 flex flex-col gap-2">
<label className="text-xs font-medium text-text-tertiary" htmlFor="invite-email">
Email address
</label>
<input
id="invite-email"
type="email"
placeholder="teammate@example.com"
className="h-9 w-full rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder focus:border-components-input-border-hover"
/>
</div>
<div className="mt-6 flex items-center justify-end">
<Button variant="primary">Send invite</Button>
</div>
</DialogContent>
</Dialog>
),
}
export const WithoutCloseButton: Story = {
render: () => (
<Dialog>
<DialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Start onboarding
</DialogTrigger>
<DialogContent>
<div className="flex flex-col gap-2">
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Welcome to Dify
</DialogTitle>
<DialogDescription className="text-sm leading-5 text-text-secondary">
Let's get your workspace ready. This takes about a minute and sets up your default models, datasets, and API keys.
</DialogDescription>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="secondary">Skip for now</Button>
<Button variant="primary">Start setup</Button>
</div>
</DialogContent>
</Dialog>
),
}
const ControlledDemo = () => {
const [open, setOpen] = useState(false)
return (
<div className="flex flex-col items-center gap-3">
<Button variant="secondary" onClick={() => setOpen(true)}>
Open controlled dialog
</Button>
<span className="text-xs text-text-tertiary">
State:
{' '}
{open ? 'open' : 'closed'}
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogCloseButton />
<div className="flex flex-col gap-2 pr-8">
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Rename workspace
</DialogTitle>
<DialogDescription className="text-sm leading-5 text-text-secondary">
The workspace URL will stay the same, but the display name updates everywhere.
</DialogDescription>
</div>
<input
type="text"
defaultValue="Acme Workspace"
className="mt-4 h-9 w-full rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-hidden focus:border-components-input-border-hover"
/>
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Save
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
}
export const ScrollingContent: Story = {
render: () => (
<Dialog>
<DialogTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Review release notes
</DialogTrigger>
<DialogContent>
<DialogCloseButton />
<div className="flex flex-col gap-2 pr-8">
<DialogTitle className="text-lg leading-7 font-semibold text-text-primary">
Release notes
</DialogTitle>
<DialogDescription className="text-sm leading-5 text-text-secondary">
Highlights from the latest workspace update.
</DialogDescription>
</div>
<ul className="mt-4 flex flex-col gap-3 text-sm leading-5 text-text-secondary">
{Array.from({ length: 24 }, (_, index) => `improvement-${index + 1}`).map((id, index) => (
<li key={id} className="rounded-lg bg-background-default-subtle px-3 py-2">
<span className="font-medium text-text-primary">
Improvement #
{index + 1}
:
</span>
{' '}
Refined a workflow behavior so long content naturally overflows and scrolls inside the dialog.
</li>
))}
</ul>
</DialogContent>
</Dialog>
),
}

View File

@@ -28,7 +28,7 @@ const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => (
)
const meta = {
title: 'Base/Navigation/DropdownMenu',
title: 'Base/UI/DropdownMenu',
component: DropdownMenu,
parameters: {
layout: 'centered',

View File

@@ -108,7 +108,7 @@ const DemoField = ({
}
const meta = {
title: 'Base/Form/NumberField',
title: 'Base/UI/NumberField',
component: NumberField,
parameters: {
layout: 'centered',

View File

@@ -0,0 +1,199 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Placement } from '../placement'
import { useState } from 'react'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from '.'
import { Button } from '../button'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/Popover',
component: Popover,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Popover>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Popover>
<PopoverTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Open popover
</PopoverTrigger>
<PopoverContent>
<div className="flex w-72 flex-col gap-2 p-4">
<PopoverTitle className="text-sm font-semibold text-text-primary">
Keyboard shortcuts
</PopoverTitle>
<PopoverDescription className="text-xs text-text-secondary">
Press
{' '}
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]"></kbd>
{' '}
+
{' '}
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]">K</kbd>
{' '}
to open the command palette anywhere in the app.
</PopoverDescription>
</div>
</PopoverContent>
</Popover>
),
}
export const WithActions: Story = {
render: () => (
<Popover>
<PopoverTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Share workspace
</PopoverTrigger>
<PopoverContent>
<div className="flex w-80 flex-col gap-3 p-4">
<div className="flex flex-col gap-1">
<PopoverTitle className="text-sm font-semibold text-text-primary">
Share workspace
</PopoverTitle>
<PopoverDescription className="text-xs text-text-secondary">
Invite collaborators by email. They will get a pending invitation in their inbox.
</PopoverDescription>
</div>
<input
type="email"
placeholder="teammate@example.com"
className="h-8 rounded-md border-[0.5px] border-components-input-border-active bg-components-input-bg-normal px-2 text-sm text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder focus:border-components-input-border-hover"
/>
<div className="flex items-center justify-end gap-2">
<PopoverClose
render={<Button variant="secondary" size="small" />}
>
Cancel
</PopoverClose>
<PopoverClose
render={<Button variant="primary" size="small" />}
>
Send invite
</PopoverClose>
</div>
</div>
</PopoverContent>
</Popover>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-start',
'bottom',
'bottom-end',
'left-start',
'left',
'left-end',
]
const PlacementsDemo = () => {
const [placement, setPlacement] = useState<Placement>('bottom')
return (
<div className="flex flex-col items-center gap-4 p-20">
<div className="grid grid-cols-3 gap-2 text-xs">
{PLACEMENTS.map(value => (
<button
key={value}
type="button"
onClick={() => setPlacement(value)}
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
}`}
>
{value}
</button>
))}
</div>
<Popover open>
<PopoverTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Anchored trigger
</PopoverTrigger>
<PopoverContent placement={placement}>
<div className="flex w-56 flex-col gap-1 p-3">
<PopoverTitle className="text-sm font-semibold text-text-primary">
placement="
{placement}
"
</PopoverTitle>
<PopoverDescription className="text-xs text-text-secondary">
Popover positions itself relative to the trigger using the selected placement.
</PopoverDescription>
</div>
</PopoverContent>
</Popover>
</div>
)
}
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
},
render: () => <PlacementsDemo />,
}
const ControlledDemo = () => {
const [open, setOpen] = useState(false)
return (
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={() => setOpen(prev => !prev)}>
{open ? 'Close from outside' : 'Open from outside'}
</Button>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Anchor
</PopoverTrigger>
<PopoverContent>
<div className="flex w-64 flex-col gap-2 p-4">
<PopoverTitle className="text-sm font-semibold text-text-primary">
Controlled popover
</PopoverTitle>
<PopoverDescription className="text-xs text-text-secondary">
Open state is owned by the parent. The trigger and the external button both toggle it.
</PopoverDescription>
</div>
</PopoverContent>
</Popover>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
}

View File

@@ -13,7 +13,7 @@ import {
} from '.'
const meta = {
title: 'Base/Layout/ScrollArea',
title: 'Base/UI/ScrollArea',
component: ScrollAreaRoot,
parameters: {
layout: 'padded',

View File

@@ -0,0 +1,312 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '.'
const triggerWidth = 'w-64'
const meta = {
title: 'Base/UI/Select',
component: Select,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound select built on Base UI Select. Compose `SelectTrigger`, `SelectContent`, and `SelectItem` to build accessible single-value pickers with groups, labels, separators, and keyboard selection.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle">
<SelectTrigger aria-label="City">
<SelectValue placeholder="Select a city" />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="tokyo">
<SelectItemText>Tokyo</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="paris">
<SelectItemText>Paris</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
export const WithPlaceholder: Story = {
render: () => (
<div className={triggerWidth}>
<Select>
<SelectTrigger aria-label="Model">
<SelectValue placeholder="Choose a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-5">
<SelectItemText>GPT-5</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="claude-opus">
<SelectItemText>Claude Opus</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="gemini-25">
<SelectItemText>Gemini 2.5</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex flex-col gap-3">
{(['small', 'medium', 'large'] as const).map(size => (
<div key={size} className={triggerWidth}>
<Select defaultValue="seattle">
<SelectTrigger aria-label={`${size} select`} size={size}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
),
}
export const WithGroupsAndSeparator: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="gpt-5">
<SelectTrigger aria-label="Model">
<SelectValue placeholder="Choose a model" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>OpenAI</SelectLabel>
<SelectItem value="gpt-5">
<SelectItemText>GPT-5</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="gpt-4o">
<SelectItemText>GPT-4o</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Anthropic</SelectLabel>
<SelectItem value="claude-opus">
<SelectItemText>Claude Opus</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="claude-sonnet">
<SelectItemText>Claude Sonnet</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Google</SelectLabel>
<SelectItem value="gemini-25">
<SelectItemText>Gemini 2.5</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="gemini-flash">
<SelectItemText>Gemini Flash</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
),
}
export const WithDisabledItem: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="free">
<SelectTrigger aria-label="Plan">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">
<SelectItemText>Free</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="team">
<SelectItemText>Team</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="enterprise" disabled>
<SelectItemText>Enterprise (contact sales)</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
export const Disabled: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle">
<SelectTrigger aria-label="City" disabled>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
export const ReadOnly: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle" readOnly>
<SelectTrigger aria-label="City">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
),
}
const ControlledDemo = () => {
const [value, setValue] = useState<string | null>('balanced')
return (
<div className="flex flex-col items-start gap-3">
<div className={triggerWidth}>
<Select value={value} onValueChange={setValue}>
<SelectTrigger aria-label="Routing strategy">
<SelectValue placeholder="Choose a strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low-latency">
<SelectItemText>Low latency</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="balanced">
<SelectItemText>Balanced</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="high-quality">
<SelectItemText>High quality</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</div>
<span className="text-xs text-text-tertiary">
Selected:
{value ?? '—'}
</span>
</div>
)
}
export const Controlled: Story = {
render: () => <ControlledDemo />,
}
export const InForm: Story = {
render: () => (
<form
onSubmit={(event) => {
event.preventDefault()
}}
className="flex w-72 flex-col gap-3"
>
<label className="text-xs font-medium text-text-tertiary" htmlFor="timezone">
Timezone
</label>
<Select name="timezone" defaultValue="utc">
<SelectTrigger id="timezone" aria-label="Timezone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="utc">
<SelectItemText>UTC</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="pst">
<SelectItemText>Pacific (PST)</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="jst">
<SelectItemText>Japan (JST)</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
<button
type="submit"
className="h-8 rounded-lg bg-components-button-primary-bg px-3 text-sm text-components-button-primary-text"
>
Submit
</button>
</form>
),
}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { Slider } from '.'
const meta = {
title: 'Base UI/Data Entry/Slider',
title: 'Base/UI/Slider',
component: Slider,
parameters: {
layout: 'centered',

View File

@@ -298,7 +298,7 @@ const ToastDocsDemo = () => {
}
const meta = {
title: 'Base/Feedback/UI Toast',
title: 'Base/UI/Toast',
component: ToastDocsDemo,
parameters: {
layout: 'fullscreen',

View File

@@ -0,0 +1,247 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Placement } from '../placement'
import { useState } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '.'
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover'
const meta = {
title: 'Base/UI/Tooltip',
component: Tooltip,
decorators: [
Story => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Tooltip>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Hover me
</TooltipTrigger>
<TooltipContent>
Tooltips describe interactive elements without a click.
</TooltipContent>
</Tooltip>
),
}
export const Plain: Story = {
parameters: {
docs: {
description: {
story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.',
},
},
},
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Preview details
</TooltipTrigger>
<TooltipContent
variant="plain"
className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg"
>
<div className="flex w-64 flex-col gap-1">
<span className="text-sm font-semibold text-text-primary">Dataset preview</span>
<span className="text-xs text-text-secondary">
32 documents Last indexed 2 minutes ago
</span>
</div>
</TooltipContent>
</Tooltip>
),
}
const PLACEMENTS: Placement[] = [
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-start',
'bottom',
'bottom-end',
'left-start',
'left',
'left-end',
]
const PlacementsDemo = () => {
const [placement, setPlacement] = useState<Placement>('top')
return (
<div className="flex flex-col items-center gap-4 p-24">
<div className="grid grid-cols-3 gap-2 text-xs">
{PLACEMENTS.map(value => (
<button
key={value}
type="button"
onClick={() => setPlacement(value)}
className={`rounded-md border border-divider-subtle px-2 py-1 text-text-secondary ${
placement === value ? 'bg-state-base-hover' : 'bg-components-button-secondary-bg'
}`}
>
{value}
</button>
))}
</div>
<Tooltip open>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
Anchor
</TooltipTrigger>
<TooltipContent placement={placement}>
placement="
{placement}
"
</TooltipContent>
</Tooltip>
</div>
)
}
export const Placements: Story = {
parameters: {
layout: 'fullscreen',
},
render: () => <PlacementsDemo />,
}
export const OnIconButtons: Story = {
parameters: {
docs: {
description: {
story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.',
},
},
},
render: () => (
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Edit" className={iconButtonClassName}>
<span aria-hidden className="i-ri-pencil-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Duplicate" className={iconButtonClassName}>
<span aria-hidden className="i-ri-file-copy-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Archive" className={iconButtonClassName}>
<span aria-hidden className="i-ri-archive-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button type="button" aria-label="Delete" className={iconButtonClassName}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
</button>
)}
/>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
),
}
export const LongContent: Story = {
render: () => (
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
What are tokens?
</TooltipTrigger>
<TooltipContent>
Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota.
</TooltipContent>
</Tooltip>
),
}
const DELAY_PRESETS: Array<{ label: string, delay: number }> = [
{ label: 'Instant (0ms)', delay: 0 },
{ label: 'Fast (150ms)', delay: 150 },
{ label: 'Default (600ms)', delay: 600 },
]
const DelayDemo = () => {
return (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={<button type="button" className={triggerButtonClassName} />}
>
{label}
</TooltipTrigger>
<TooltipContent>
Appeared after
{delay}
ms hover delay.
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
}
export const WithDelay: Story = {
parameters: {
docs: {
description: {
story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.',
},
},
},
render: () => <DelayDemo />,
}