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:
2026-05-22 09:10:12 +10:00
parent d696619e4e
commit 722475215d
51 changed files with 32 additions and 32 deletions

View 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>
),
}

View 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'

View File

@@ -0,0 +1,7 @@
export { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover'
export type {
PopoverProps,
PopoverTriggerProps,
PopoverContentProps,
PopoverCloseProps,
} from './Popover'