Add Dialog, Tag, Tooltip, Popover, and Alert components; fix Button icon sizes

New primitives completing the ui/ component tier: Dialog (native <dialog>
with focus trapping), Tag (6 colours × 3 variants), Tooltip and Popover
(using @floating-ui/react for positioning), and Alert (5 status variants
with icons, close, and action slot). Reduced Button icon sizes to better
match label text. Added Tag and Alert token layers to tokens.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:31:52 +10:00
parent 4be996789e
commit d696619e4e
19 changed files with 1729 additions and 3 deletions

View File

@@ -0,0 +1,174 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Alert } from './Alert'
import { Button } from '@/components/ui/Button'
const meta: Meta<typeof Alert> = {
title: 'UI/Alert',
component: Alert,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['info', 'warning', 'error', 'success', 'neutral'],
},
},
decorators: [
(Story) => (
<div className="max-w-xl">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
title: 'Information',
children: 'Your submission has been received and is being reviewed.',
},
}
// --- Variants ---
export const Info: Story = {
args: {
variant: 'info',
title: 'Alert title',
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
},
}
export const Warning: Story = {
args: {
variant: 'warning',
title: 'Alert title',
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
},
}
export const Error: Story = {
args: {
variant: 'error',
title: 'Alert title',
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
},
}
export const Success: Story = {
args: {
variant: 'success',
title: 'Alert title',
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
},
}
export const Neutral: Story = {
args: {
variant: 'neutral',
title: 'Alert title',
children: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus!',
},
}
// --- With close ---
export const Dismissible: Story = {
render: () => {
const [visible, setVisible] = useState(true)
if (!visible) return <Button size="compact" onClick={() => setVisible(true)}>Show alert</Button>
return (
<Alert variant="info" title="New feature available" onClose={() => setVisible(false)}>
You can now export your synthesis results as a PDF report.
</Alert>
)
},
}
// --- With action ---
export const WithAction: Story = {
render: () => (
<Alert
variant="warning"
title="Incomplete submission"
action={
<Button size="compact">Complete now</Button>
}
>
Your ethics application is missing required attachments. Please upload them before the deadline.
</Alert>
),
}
// --- With close and action ---
export const WithCloseAndAction: Story = {
render: () => (
<Alert
variant="error"
title="Upload failed"
onClose={() => {}}
action={
<Button size="compact" intent="danger">Retry upload</Button>
}
>
The file could not be uploaded due to a network error. Please check your connection and try again.
</Alert>
),
}
// --- Title only ---
export const TitleOnly: Story = {
args: {
variant: 'success',
title: 'Changes saved successfully.',
},
}
// --- Content only ---
export const ContentOnly: Story = {
args: {
variant: 'neutral',
children: 'This project is in read-only mode. Contact the project owner to request edit access.',
},
}
// --- All variants ---
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<Alert variant="info" title="Alert title" onClose={() => {}}
action={<Button size="compact">Optional action</Button>}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
</Alert>
<Alert variant="warning" title="Alert title" onClose={() => {}}
action={<Button size="compact">Optional action</Button>}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
</Alert>
<Alert variant="error" title="Alert title" onClose={() => {}}
action={<Button size="compact">Optional action</Button>}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
</Alert>
<Alert variant="success" title="Alert title" onClose={() => {}}
action={<Button size="compact">Optional action</Button>}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
</Alert>
<Alert variant="neutral" title="Alert title" onClose={() => {}}
action={<Button size="compact">Optional action</Button>}
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima, at placeat totam, magni doloremque veniam neque porro libero rerum unde voluptatem!
</Alert>
</div>
),
}

View File

@@ -0,0 +1,110 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type AlertVariant = 'info' | 'warning' | 'error' | 'success' | 'neutral'
export interface AlertProps extends HTMLAttributes<HTMLDivElement> {
variant?: AlertVariant
title?: string
onClose?: () => void
action?: ReactNode
icon?: ReactNode
}
const variantStyles: Record<AlertVariant, string> = {
info: 'bg-alert-info-bg',
warning: 'bg-alert-warning-bg',
error: 'bg-alert-error-bg',
success: 'bg-alert-success-bg',
neutral: 'bg-alert-neutral-bg',
}
const iconColorStyles: Record<AlertVariant, string> = {
info: 'text-alert-info-icon',
warning: 'text-alert-warning-icon',
error: 'text-alert-error-icon',
success: 'text-alert-success-icon',
neutral: 'text-alert-neutral-icon',
}
const InfoIcon = () => (
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<text x="12" y="17" textAnchor="middle" fontSize="14" fontWeight="bold" fill="white">i</text>
</svg>
)
const WarningIcon = () => (
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" fill="currentColor" stroke="none" />
<line x1="12" y1="9" x2="12" y2="13" stroke="white" strokeWidth={2.5} />
<line x1="12" y1="17" x2="12.01" y2="17" stroke="white" strokeWidth={2.5} />
</svg>
)
const ErrorIcon = () => (
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="13" stroke="white" strokeWidth={2.5} strokeLinecap="round" />
<line x1="12" y1="16.5" x2="12.01" y2="16.5" stroke="white" strokeWidth={2.5} strokeLinecap="round" />
</svg>
)
const SuccessIcon = () => (
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<path d="M8 12.5l2.5 2.5 5-5" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)
const defaultIcons: Record<AlertVariant, () => ReactNode> = {
info: InfoIcon,
warning: WarningIcon,
error: ErrorIcon,
success: SuccessIcon,
neutral: InfoIcon,
}
export const Alert = forwardRef<HTMLDivElement, AlertProps>(
({ variant = 'info', title, onClose, action, icon, className, children, ...props }, ref) => {
const DefaultIcon = defaultIcons[variant]
return (
<div
ref={ref}
role="alert"
className={cn(
'rounded-default px-4 py-3',
variantStyles[variant],
className,
)}
{...props}
>
<div className="flex items-start gap-3">
<span className={cn('mt-0.5 shrink-0', iconColorStyles[variant])}>
{icon ?? <DefaultIcon />}
</span>
<div className="min-w-0 flex-1">
{title && <p className="font-bold text-text">{title}</p>}
{children && <div className={cn('text-small text-text', title && 'mt-1')}>{children}</div>}
{action && <div className="mt-3">{action}</div>}
</div>
{onClose && (
<button
type="button"
onClick={onClose}
className="mt-0.5 shrink-0 rounded-full p-0.5 text-text-secondary transition-colors hover:text-text"
aria-label="Dismiss"
>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
)
},
)
Alert.displayName = 'Alert'

View File

@@ -0,0 +1,2 @@
export { Alert } from './Alert'
export type { AlertProps, AlertVariant } from './Alert'

View File

@@ -44,9 +44,9 @@ const sizeStyles: Record<string, string> = {
}
const iconSizeStyles: Record<string, string> = {
default: 'size-6',
comfortable: 'size-5',
compact: 'size-5',
default: 'size-5',
comfortable: 'size-[18px]',
compact: 'size-4',
}
const Spinner = ({ className }: { className?: string }) => (

View File

@@ -0,0 +1,250 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { AlertTriangle } from 'lucide-react'
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogContent, DialogFooter } from './Dialog'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
const meta: Meta<typeof Dialog> = {
title: 'UI/Dialog',
component: Dialog,
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['sm', 'default', 'lg', 'full'],
},
closeOnBackdrop: {
control: 'boolean',
},
},
}
export default meta
type Story = StoryObj<typeof meta>
// --- Default ---
export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open dialog</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogHeader onClose={() => setOpen(false)}>
<DialogTitle>Dialog title</DialogTitle>
<DialogDescription>A short description of the dialog purpose.</DialogDescription>
</DialogHeader>
<DialogContent>
<p className="text-body text-text">
This is the dialog body. It can contain any content text, forms, lists, or other
components.
</p>
</DialogContent>
<DialogFooter>
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setOpen(false)}>Confirm</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- Small ---
export const Small: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open small dialog</Button>
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
<DialogHeader onClose={() => setOpen(false)}>
<DialogTitle>Quick confirmation</DialogTitle>
</DialogHeader>
<DialogContent>
<p className="text-body text-text">Are you sure you want to proceed?</p>
</DialogContent>
<DialogFooter>
<Button variant="tertiary" intent="neutral" onClick={() => setOpen(false)}>
No
</Button>
<Button onClick={() => setOpen(false)}>Yes</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- Large ---
export const Large: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open large dialog</Button>
<Dialog open={open} onClose={() => setOpen(false)} size="lg">
<DialogHeader onClose={() => setOpen(false)}>
<DialogTitle>Review submission details</DialogTitle>
<DialogDescription>
Please review the information below before submitting.
</DialogDescription>
</DialogHeader>
<DialogContent>
<div className="space-y-3">
<div className="rounded-lg bg-bg px-4 py-3">
<p className="text-small font-bold text-text">Participant count</p>
<p className="text-body text-text-secondary">24 participants across 3 schools</p>
</div>
<div className="rounded-lg bg-bg px-4 py-3">
<p className="text-small font-bold text-text">Data collection period</p>
<p className="text-body text-text-secondary">March 2026 June 2026</p>
</div>
<div className="rounded-lg bg-bg px-4 py-3">
<p className="text-small font-bold text-text">Ethics approval</p>
<p className="text-body text-text-secondary">SERAP 2026-0142 (approved)</p>
</div>
</div>
</DialogContent>
<DialogFooter>
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
Go back
</Button>
<Button onClick={() => setOpen(false)}>Submit</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- Danger confirmation ---
export const DangerConfirmation: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button intent="danger" onClick={() => setOpen(true)}>
Delete project
</Button>
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
<DialogHeader onClose={() => setOpen(false)}>
<div className="flex items-center gap-2">
<div className="flex size-10 items-center justify-center rounded-full bg-error/10">
<AlertTriangle className="size-5 text-error" />
</div>
<DialogTitle>Delete project?</DialogTitle>
</div>
</DialogHeader>
<DialogContent>
<p className="text-body text-text">
This action cannot be undone. All data, themes, and participant responses associated
with this project will be permanently deleted.
</p>
</DialogContent>
<DialogFooter>
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button intent="danger" onClick={() => setOpen(false)}>
Delete
</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- With form ---
export const WithForm: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Create new theme</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogHeader onClose={() => setOpen(false)}>
<DialogTitle>New theme</DialogTitle>
<DialogDescription>
Give your theme a name and description to help organise your findings.
</DialogDescription>
</DialogHeader>
<DialogContent>
<div className="space-y-4">
<Input label="Theme name" placeholder="e.g. Student engagement" />
<Input label="Description" placeholder="Brief summary of this theme" hint="Optional — you can add this later" />
</div>
</DialogContent>
<DialogFooter>
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setOpen(false)}>Create theme</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- No close button ---
export const NoCloseButton: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open without close button</Button>
<Dialog open={open} onClose={() => setOpen(false)} closeOnBackdrop={false}>
<DialogHeader>
<DialogTitle>Terms of use</DialogTitle>
</DialogHeader>
<DialogContent>
<p className="text-body text-text">
You must accept the terms of use before continuing. This dialog cannot be dismissed
by clicking the backdrop or pressing Escape only through the action buttons.
</p>
</DialogContent>
<DialogFooter>
<Button variant="secondary" intent="neutral" onClick={() => setOpen(false)}>
Decline
</Button>
<Button onClick={() => setOpen(false)}>Accept</Button>
</DialogFooter>
</Dialog>
</>
)
},
}
// --- Content only ---
export const ContentOnly: Story = {
render: () => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open minimal dialog</Button>
<Dialog open={open} onClose={() => setOpen(false)} size="sm">
<DialogContent className="py-6">
<p className="text-center text-body text-text">
Your changes have been saved.
</p>
<div className="mt-4 flex justify-center">
<Button onClick={() => setOpen(false)}>Done</Button>
</div>
</DialogContent>
</Dialog>
</>
)
},
}

View File

@@ -0,0 +1,195 @@
import {
forwardRef,
useEffect,
useRef,
useCallback,
type HTMLAttributes,
type DialogHTMLAttributes,
type ReactNode,
type MouseEvent,
} from 'react'
import { cn } from '@/lib/utils'
// --- Dialog ---
export interface DialogProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, 'open'> {
open: boolean
onClose: () => void
size?: 'sm' | 'default' | 'lg' | 'full'
closeOnBackdrop?: boolean
}
const sizeStyles: Record<string, string> = {
sm: 'max-w-sm',
default: 'max-w-lg',
lg: 'max-w-2xl',
full: 'max-w-[calc(100vw-3rem)] max-h-[calc(100vh-3rem)]',
}
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
({ open, onClose, size = 'default', closeOnBackdrop = true, className, children, ...props }, ref) => {
const internalRef = useRef<HTMLDialogElement>(null)
const dialogRef = (ref as React.RefObject<HTMLDialogElement>) || internalRef
useEffect(() => {
const el = dialogRef.current
if (!el) return
if (open && !el.open) {
el.showModal()
} else if (!open && el.open) {
el.close()
}
}, [open, dialogRef])
useEffect(() => {
const el = dialogRef.current
if (!el) return
const handleCancel = (e: Event) => {
e.preventDefault()
onClose()
}
el.addEventListener('cancel', handleCancel)
return () => el.removeEventListener('cancel', handleCancel)
}, [onClose, dialogRef])
const handleBackdropClick = useCallback(
(e: MouseEvent<HTMLDialogElement>) => {
if (closeOnBackdrop && e.target === dialogRef.current) {
onClose()
}
},
[closeOnBackdrop, onClose, dialogRef],
)
return (
<dialog
ref={dialogRef}
className={cn(
'w-full rounded-xl bg-surface shadow-md backdrop:bg-black/50',
'p-0 open:flex open:flex-col',
sizeStyles[size],
className,
)}
onClick={handleBackdropClick}
{...props}
>
<div className="flex flex-col">{children}</div>
</dialog>
)
},
)
Dialog.displayName = 'Dialog'
// --- DialogHeader ---
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
onClose?: () => void
}
export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
({ onClose, className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-start gap-3 px-6 pt-6', onClose && 'justify-between', className)}
{...props}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
{onClose && (
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-primary/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
aria-label="Close dialog"
>
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
),
)
DialogHeader.displayName = 'DialogHeader'
// --- DialogTitle ---
export type DialogTitleProps = HTMLAttributes<HTMLHeadingElement>
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-h4 font-bold leading-tight text-text', className)}
{...props}
/>
),
)
DialogTitle.displayName = 'DialogTitle'
// --- DialogDescription ---
export type DialogDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-body text-text-secondary', className)}
{...props}
/>
),
)
DialogDescription.displayName = 'DialogDescription'
// --- DialogContent ---
export type DialogContentProps = HTMLAttributes<HTMLDivElement>
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-6 py-4', className)} {...props} />
),
)
DialogContent.displayName = 'DialogContent'
// --- DialogFooter ---
export type DialogFooterProps = HTMLAttributes<HTMLDivElement>
export const DialogFooter = forwardRef<HTMLDivElement, DialogFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center justify-end gap-3 px-6 pb-6', className)}
{...props}
/>
),
)
DialogFooter.displayName = 'DialogFooter'
// --- DialogClose ---
export interface DialogCloseProps extends HTMLAttributes<HTMLButtonElement> {
onClose: () => void
asChild?: boolean
children: ReactNode
}
export const DialogClose = forwardRef<HTMLButtonElement, DialogCloseProps>(
({ onClose, className, children, ...props }, ref) => (
<button
ref={ref}
type="button"
onClick={onClose}
className={className}
{...props}
>
{children}
</button>
),
)
DialogClose.displayName = 'DialogClose'

View File

@@ -0,0 +1,18 @@
export {
Dialog,
DialogHeader,
DialogTitle,
DialogDescription,
DialogContent,
DialogFooter,
DialogClose,
} from './Dialog'
export type {
DialogProps,
DialogHeaderProps,
DialogTitleProps,
DialogDescriptionProps,
DialogContentProps,
DialogFooterProps,
DialogCloseProps,
} from './Dialog'

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/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { Input } from '@/components/ui/Input'
import { Checkbox } from '@/components/ui/Checkbox'
const meta: Meta<typeof Popover> = {
title: 'UI/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'

View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Hash, User, BookOpen } from 'lucide-react'
import { Tag } from './Tag'
import type { TagColor } from './Tag'
const meta: Meta<typeof Tag> = {
title: 'UI/Tag',
component: Tag,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['outline', 'filled', 'light'],
},
color: {
control: 'select',
options: ['navy', 'blue', 'green', 'red', 'orange', 'grey'],
},
size: {
control: 'select',
options: ['default', 'sm'],
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: 'Student engagement',
},
}
// --- Variants ---
export const Outline: Story = {
args: {
variant: 'outline',
children: 'Outline',
},
}
export const Filled: Story = {
args: {
variant: 'filled',
children: 'Filled',
},
}
export const Light: Story = {
args: {
variant: 'light',
children: 'Light',
},
}
// --- With icon ---
export const WithIcon: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Tag icon={<Hash />}>Theme</Tag>
<Tag icon={<User />} variant="filled">Participant</Tag>
<Tag icon={<BookOpen />} variant="light">Literature</Tag>
</div>
),
}
// --- Removable ---
export const Removable: Story = {
render: () => {
const [tags, setTags] = useState(['Student engagement', 'Teacher wellbeing', 'Curriculum design', 'Assessment'])
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag key={tag} onRemove={() => setTags((t) => t.filter((v) => v !== tag))}>
{tag}
</Tag>
))}
{tags.length === 0 && <p className="text-small text-text-secondary">All tags removed</p>}
</div>
)
},
}
// --- All colours ---
const colors: TagColor[] = ['navy', 'blue', 'green', 'red', 'orange', 'grey']
export const AllColours: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Outline</p>
<div className="flex flex-wrap gap-2">
{colors.map((c) => (
<Tag key={c} color={c} variant="outline">{c}</Tag>
))}
</div>
</div>
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Filled</p>
<div className="flex flex-wrap gap-2">
{colors.map((c) => (
<Tag key={c} color={c} variant="filled">{c}</Tag>
))}
</div>
</div>
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Light</p>
<div className="flex flex-wrap gap-2">
{colors.map((c) => (
<Tag key={c} color={c} variant="light">{c}</Tag>
))}
</div>
</div>
</div>
),
}
// --- All variants (with features) ---
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Outline</p>
<div className="flex flex-wrap gap-2">
<Tag variant="outline">Default</Tag>
<Tag variant="outline" icon={<Hash />}>With icon</Tag>
<Tag variant="outline" onRemove={() => {}}>Removable</Tag>
<Tag variant="outline" size="sm">Small</Tag>
</div>
</div>
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Filled</p>
<div className="flex flex-wrap gap-2">
<Tag variant="filled">Default</Tag>
<Tag variant="filled" icon={<Hash />}>With icon</Tag>
<Tag variant="filled" onRemove={() => {}}>Removable</Tag>
<Tag variant="filled" size="sm">Small</Tag>
</div>
</div>
<div>
<p className="mb-2 text-small font-bold text-text-secondary">Light</p>
<div className="flex flex-wrap gap-2">
<Tag variant="light">Default</Tag>
<Tag variant="light" icon={<Hash />}>With icon</Tag>
<Tag variant="light" onRemove={() => {}}>Removable</Tag>
<Tag variant="light" size="sm">Small</Tag>
</div>
</div>
</div>
),
}
// --- Realistic usage ---
export const ThemeLabels: Story = {
render: () => (
<div className="max-w-md rounded-xl border border-border bg-surface p-4">
<p className="mb-3 text-small font-bold text-text">Assigned themes</p>
<div className="flex flex-wrap gap-2">
<Tag variant="light" color="blue" icon={<Hash />}>Student engagement</Tag>
<Tag variant="light" color="green" icon={<Hash />}>Digital literacy</Tag>
<Tag variant="light" color="orange" icon={<Hash />}>Rural access</Tag>
</div>
</div>
),
}
// --- Removable coloured ---
export const RemovableColoured: Story = {
render: () => {
const initial: { label: string; color: TagColor }[] = [
{ label: 'Qualitative', color: 'blue' },
{ label: 'Approved', color: 'green' },
{ label: 'Urgent', color: 'red' },
{ label: 'Draft', color: 'orange' },
{ label: 'Archived', color: 'grey' },
]
const [tags, setTags] = useState(initial)
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
key={tag.label}
color={tag.color}
onRemove={() => setTags((t) => t.filter((v) => v.label !== tag.label))}
>
{tag.label}
</Tag>
))}
</div>
)
},
}

View File

@@ -0,0 +1,99 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type TagColor = 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'outline' | 'filled' | 'light'
color?: TagColor
size?: 'default' | 'sm'
icon?: ReactNode
onRemove?: () => void
}
const colorVariantStyles: Record<TagColor, Record<string, string>> = {
navy: {
outline: 'border border-tag-navy text-tag-navy',
filled: 'bg-tag-navy text-white',
light: 'bg-tag-navy-light text-tag-navy',
},
blue: {
outline: 'border border-tag-blue text-tag-blue',
filled: 'bg-tag-blue text-white',
light: 'bg-tag-blue-light text-tag-blue',
},
green: {
outline: 'border border-tag-green text-tag-green',
filled: 'bg-tag-green text-white',
light: 'bg-tag-green-light text-tag-green',
},
red: {
outline: 'border border-tag-red text-tag-red',
filled: 'bg-tag-red text-white',
light: 'bg-tag-red-light text-tag-red',
},
orange: {
outline: 'border border-tag-orange text-tag-orange',
filled: 'bg-tag-orange text-white',
light: 'bg-tag-orange-light text-tag-orange',
},
grey: {
outline: 'border border-tag-grey text-tag-grey',
filled: 'bg-tag-grey text-white',
light: 'bg-tag-grey-light text-tag-grey',
},
}
const sizeStyles: Record<string, string> = {
default: 'h-7 px-2.5 text-small gap-1.5',
sm: 'h-5 px-2 text-caption gap-1',
}
const removeHoverStyles: Record<string, string> = {
outline: 'hover:bg-current/10',
filled: 'hover:bg-white/20',
light: 'hover:bg-current/10',
}
const removeSizeStyles: Record<string, string> = {
default: 'size-4',
sm: 'size-3',
}
export const Tag = forwardRef<HTMLSpanElement, TagProps>(
({ variant = 'outline', color = 'navy', size = 'default', icon, onRemove, className, children, ...props }, ref) => (
<span
ref={ref}
className={cn(
'inline-flex items-center rounded-full font-medium leading-none',
colorVariantStyles[color][variant],
sizeStyles[size],
className,
)}
{...props}
>
{icon && <span className="shrink-0 [&>svg]:size-3.5">{icon}</span>}
<span className="truncate">{children}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className={cn(
'-mr-0.5 shrink-0 rounded-full p-px transition-colors',
removeHoverStyles[variant],
)}
aria-label="Remove"
>
<svg className={removeSizeStyles[size]} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</span>
),
)
Tag.displayName = 'Tag'

View File

@@ -0,0 +1,2 @@
export { Tag } from './Tag'
export type { TagProps, TagColor } from './Tag'

View File

@@ -0,0 +1,143 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Info, HelpCircle, Trash2, Copy } from 'lucide-react'
import { Tooltip } from './Tooltip'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
const meta: Meta<typeof Tooltip> = {
title: 'UI/Tooltip',
component: Tooltip,
tags: ['autodocs'],
argTypes: {
placement: {
control: 'select',
options: ['top', 'right', 'bottom', 'left'],
},
},
decorators: [
(Story) => (
<div className="flex min-h-40 items-center justify-center p-16">
<Story />
</div>
),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<Tooltip content="This is a tooltip">
<Button>Hover me</Button>
</Tooltip>
),
}
// --- Placements ---
export const Top: Story = {
render: () => (
<Tooltip content="Placed on top" placement="top">
<Button variant="secondary">Top</Button>
</Tooltip>
),
}
export const Right: Story = {
render: () => (
<Tooltip content="Placed on the right" placement="right">
<Button variant="secondary">Right</Button>
</Tooltip>
),
}
export const Bottom: Story = {
render: () => (
<Tooltip content="Placed on the bottom" placement="bottom">
<Button variant="secondary">Bottom</Button>
</Tooltip>
),
}
export const Left: Story = {
render: () => (
<Tooltip content="Placed on the left" placement="left">
<Button variant="secondary">Left</Button>
</Tooltip>
),
}
// --- With icon buttons ---
export const OnIconButtons: Story = {
render: () => (
<div className="flex items-center gap-2">
<Tooltip content="More information">
<IconButton variant="tertiary" intent="neutral" icon={<Info />} aria-label="Info" />
</Tooltip>
<Tooltip content="Get help">
<IconButton variant="tertiary" intent="neutral" icon={<HelpCircle />} aria-label="Help" />
</Tooltip>
<Tooltip content="Delete item" placement="bottom">
<IconButton variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
</Tooltip>
<Tooltip content="Copy to clipboard" placement="bottom">
<IconButton variant="tertiary" intent="neutral" icon={<Copy />} aria-label="Copy" />
</Tooltip>
</div>
),
}
// --- Long content ---
export const LongContent: Story = {
render: () => (
<Tooltip content="This tooltip contains longer explanatory text that wraps across multiple lines within the max-width constraint.">
<Button variant="secondary" intent="neutral">Hover for details</Button>
</Tooltip>
),
}
// --- All placements ---
export const AllPlacements: Story = {
decorators: [
(Story) => (
<div className="flex min-h-64 items-center justify-center p-24">
<Story />
</div>
),
],
render: () => (
<div className="grid grid-cols-3 gap-4">
<div />
<Tooltip content="Top" placement="top">
<Button variant="secondary" intent="neutral" className="w-full">Top</Button>
</Tooltip>
<div />
<Tooltip content="Left" placement="left">
<Button variant="secondary" intent="neutral" className="w-full">Left</Button>
</Tooltip>
<div />
<Tooltip content="Right" placement="right">
<Button variant="secondary" intent="neutral" className="w-full">Right</Button>
</Tooltip>
<div />
<Tooltip content="Bottom" placement="bottom">
<Button variant="secondary" intent="neutral" className="w-full">Bottom</Button>
</Tooltip>
<div />
</div>
),
}
// --- Instant (no delay) ---
export const NoDelay: Story = {
render: () => (
<Tooltip content="Appears instantly" delay={0}>
<Button variant="secondary">No delay</Button>
</Tooltip>
),
}

View File

@@ -0,0 +1,102 @@
import {
useState,
useRef,
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
} from 'react'
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
offset,
flip,
shift,
arrow,
FloatingArrow,
FloatingPortal,
autoUpdate,
type Placement,
} from '@floating-ui/react'
import { cn } from '@/lib/utils'
export interface TooltipProps {
content: ReactNode
placement?: Placement
delay?: number | { open?: number; close?: number }
children: ReactElement
className?: string
}
export function Tooltip({
content,
placement = 'top',
delay = { open: 400, close: 0 },
children,
className,
}: TooltipProps) {
const [open, setOpen] = useState(false)
const arrowRef = useRef(null)
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
})
const hover = useHover(context, { delay })
const focus = useFocus(context)
const dismiss = useDismiss(context)
const role = useRole(context, { role: 'tooltip' })
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
])
if (!isValidElement(children)) return children
return (
<>
{cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
} as Record<string, unknown>)}
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
className={cn(
'z-50 max-w-xs rounded-default bg-surface px-3 py-1.5 font-sans text-small text-text shadow-md',
className,
)}
{...getFloatingProps()}
>
{content}
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-surface drop-shadow-sm"
width={12}
height={6}
/>
</div>
</FloatingPortal>
)}
</>
)
}

View File

@@ -0,0 +1,2 @@
export { Tooltip } from './Tooltip'
export type { TooltipProps } from './Tooltip'

View File

@@ -111,6 +111,37 @@
--color-chip-selected-bg: var(--color-blue-01);
--color-chip-selected-text: var(--color-white);
/* Tag */
--color-tag-navy: var(--color-blue-01);
--color-tag-navy-light: color-mix(in srgb, var(--color-blue-01) 10%, transparent);
--color-tag-blue: var(--color-blue-02);
--color-tag-blue-light: var(--color-blue-04);
--color-tag-green: var(--color-green-01);
--color-tag-green-light: var(--color-green-04);
--color-tag-red: var(--color-red-02);
--color-tag-red-light: var(--color-red-04);
--color-tag-orange: var(--color-orange-02);
--color-tag-orange-light: var(--color-orange-04);
--color-tag-grey: var(--color-grey-02);
--color-tag-grey-light: var(--color-grey-04);
/* Alert */
--color-alert-info-bg: var(--color-blue-05);
--color-alert-info-border: var(--color-blue-02);
--color-alert-info-icon: var(--color-blue-02);
--color-alert-warning-bg: var(--color-orange-04);
--color-alert-warning-border: var(--color-orange-02);
--color-alert-warning-icon: var(--color-orange-02);
--color-alert-error-bg: var(--color-red-05);
--color-alert-error-border: var(--color-red-02);
--color-alert-error-icon: var(--color-red-02);
--color-alert-success-bg: var(--color-green-04);
--color-alert-success-border: var(--color-green-02);
--color-alert-success-icon: var(--color-green-02);
--color-alert-neutral-bg: var(--color-off-white);
--color-alert-neutral-border: var(--color-grey-03);
--color-alert-neutral-icon: var(--color-blue-01);
/* Radius */
--radius-sm: 4px;
--radius-default: 6px;