Reorganise components into atoms/molecules/organisms and fix Input icon colours
Moved all 17 components from ui/ into atomic design tiers: atoms (Button, IconButton, Input, Textarea, Select, Checkbox, Radio, Switch, Badge, Tag, Chip, Tooltip) and molecules (Alert, Accordion, Card, Dialog, Popover). Updated all Storybook titles and cross-component imports. Changed Input icons to primary-dark and replaced palette token references with semantic tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
155
src/components/molecules/Popover/Popover.stories.tsx
Normal file
155
src/components/molecules/Popover/Popover.stories.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Settings, Filter, MoreVertical } from 'lucide-react'
|
||||
import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { IconButton } from '@/components/atoms/IconButton'
|
||||
import { Input } from '@/components/atoms/Input'
|
||||
import { Checkbox } from '@/components/atoms/Checkbox'
|
||||
|
||||
const meta: Meta<typeof Popover> = {
|
||||
title: 'Molecules/Popover',
|
||||
component: Popover,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['top', 'right', 'bottom', 'left', 'bottom-start', 'bottom-end'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex min-h-80 items-start justify-center p-16">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary">Open popover</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-body font-bold text-text">Popover title</p>
|
||||
<p className="mt-1 text-small text-text-secondary">
|
||||
This is a popover with rich content. It can contain text, forms, or any components.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With form ---
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: () => (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button leftIcon={<Settings className="size-4" />}>Settings</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="mb-3 text-body font-bold text-text">Display settings</p>
|
||||
<div className="space-y-3">
|
||||
<Input label="Items per page" type="number" defaultValue="25" />
|
||||
<Checkbox label="Show archived items" />
|
||||
<Checkbox label="Compact view" />
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<PopoverClose className="rounded-full px-3 py-1.5 text-small font-bold text-text-secondary hover:bg-bg">
|
||||
Cancel
|
||||
</PopoverClose>
|
||||
<PopoverClose className="rounded-full bg-primary-dark px-3 py-1.5 text-small font-bold text-white hover:bg-primary-dark/90">
|
||||
Apply
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Filter popover ---
|
||||
|
||||
export const FilterPopover: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral" leftIcon={<Filter className="size-4" />}>
|
||||
Filters
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64">
|
||||
<p className="mb-3 text-small font-bold text-text">Filter by status</p>
|
||||
<div className="space-y-2">
|
||||
<Checkbox label="Active" defaultChecked />
|
||||
<Checkbox label="Completed" defaultChecked />
|
||||
<Checkbox label="Archived" />
|
||||
<Checkbox label="Draft" />
|
||||
</div>
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<PopoverClose className="text-small font-bold text-primary-dark hover:underline">
|
||||
Clear all filters
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Context menu style ---
|
||||
|
||||
export const ActionMenu: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-end">
|
||||
<PopoverTrigger>
|
||||
<IconButton variant="tertiary" intent="neutral" icon={<MoreVertical />} aria-label="More actions" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1">
|
||||
{['Edit', 'Duplicate', 'Move to folder'].map((item) => (
|
||||
<PopoverClose
|
||||
key={item}
|
||||
className="flex w-full rounded-md px-3 py-2 text-left text-small text-text hover:bg-bg"
|
||||
>
|
||||
{item}
|
||||
</PopoverClose>
|
||||
))}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<PopoverClose className="flex w-full rounded-md px-3 py-2 text-left text-small text-error hover:bg-error/5">
|
||||
Delete
|
||||
</PopoverClose>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Placements ---
|
||||
|
||||
export const BottomStart: Story = {
|
||||
render: () => (
|
||||
<Popover placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral">Bottom start</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-small text-text">Aligned to the start of the trigger.</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
|
||||
export const TopEnd: Story = {
|
||||
render: () => (
|
||||
<Popover placement="top-end">
|
||||
<PopoverTrigger>
|
||||
<Button variant="secondary" intent="neutral">Top end</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-small text-text">Aligned to the end of the trigger, above.</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
}
|
||||
174
src/components/molecules/Popover/Popover.tsx
Normal file
174
src/components/molecules/Popover/Popover.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
type ReactElement,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import {
|
||||
useFloating,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
autoUpdate,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
} from '@floating-ui/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface PopoverContextValue {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
refs: ReturnType<typeof useFloating>['refs']
|
||||
floatingStyles: ReturnType<typeof useFloating>['floatingStyles']
|
||||
context: ReturnType<typeof useFloating>['context']
|
||||
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps']
|
||||
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps']
|
||||
}
|
||||
|
||||
const PopoverContext = createContext<PopoverContextValue | null>(null)
|
||||
|
||||
function usePopoverContext() {
|
||||
const ctx = useContext(PopoverContext)
|
||||
if (!ctx) throw new Error('Popover compound components must be used within <Popover>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Popover root ---
|
||||
|
||||
export interface PopoverProps {
|
||||
placement?: Placement
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Popover({
|
||||
placement = 'bottom',
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: PopoverProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setUncontrolledOpen
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackAxisSideDirection: 'start' }),
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
})
|
||||
|
||||
const click = useClick(context)
|
||||
const dismiss = useDismiss(context)
|
||||
const role = useRole(context)
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
role,
|
||||
])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ open, setOpen, refs, floatingStyles, context, getReferenceProps, getFloatingProps }),
|
||||
[open, setOpen, refs, floatingStyles, context, getReferenceProps, getFloatingProps],
|
||||
)
|
||||
|
||||
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>
|
||||
}
|
||||
|
||||
// --- PopoverTrigger ---
|
||||
|
||||
export interface PopoverTriggerProps {
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
export function PopoverTrigger({ children }: PopoverTriggerProps) {
|
||||
const { refs, getReferenceProps } = usePopoverContext()
|
||||
|
||||
if (!isValidElement(children)) return children
|
||||
|
||||
return cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// --- PopoverContent ---
|
||||
|
||||
export type PopoverContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
({ className, children, ...props }, _ref) => {
|
||||
const { open, refs, floatingStyles, context, getFloatingProps } = usePopoverContext()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<FloatingPortal>
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-lg border border-border bg-surface p-4 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...getFloatingProps()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
</FloatingPortal>
|
||||
)
|
||||
},
|
||||
)
|
||||
PopoverContent.displayName = 'PopoverContent'
|
||||
|
||||
// --- PopoverClose ---
|
||||
|
||||
export interface PopoverCloseProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const PopoverClose = forwardRef<HTMLButtonElement, PopoverCloseProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const { setOpen } = usePopoverContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
setOpen(false)
|
||||
onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
PopoverClose.displayName = 'PopoverClose'
|
||||
7
src/components/molecules/Popover/index.ts
Normal file
7
src/components/molecules/Popover/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover'
|
||||
export type {
|
||||
PopoverProps,
|
||||
PopoverTriggerProps,
|
||||
PopoverContentProps,
|
||||
PopoverCloseProps,
|
||||
} from './Popover'
|
||||
Reference in New Issue
Block a user