From d696619e4e0f36c635435330c5aa5058e5818be1 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 21 May 2026 22:31:52 +1000 Subject: [PATCH] Add Dialog, Tag, Tooltip, Popover, and Alert components; fix Button icon sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New primitives completing the ui/ component tier: Dialog (native 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 --- package-lock.json | 60 +++++ package.json | 1 + src/components/ui/Alert/Alert.stories.tsx | 174 ++++++++++++ src/components/ui/Alert/Alert.tsx | 110 ++++++++ src/components/ui/Alert/index.ts | 2 + src/components/ui/Button/Button.tsx | 6 +- src/components/ui/Dialog/Dialog.stories.tsx | 250 ++++++++++++++++++ src/components/ui/Dialog/Dialog.tsx | 195 ++++++++++++++ src/components/ui/Dialog/index.ts | 18 ++ src/components/ui/Popover/Popover.stories.tsx | 155 +++++++++++ src/components/ui/Popover/Popover.tsx | 174 ++++++++++++ src/components/ui/Popover/index.ts | 7 + src/components/ui/Tag/Tag.stories.tsx | 201 ++++++++++++++ src/components/ui/Tag/Tag.tsx | 99 +++++++ src/components/ui/Tag/index.ts | 2 + src/components/ui/Tooltip/Tooltip.stories.tsx | 143 ++++++++++ src/components/ui/Tooltip/Tooltip.tsx | 102 +++++++ src/components/ui/Tooltip/index.ts | 2 + src/tokens/tokens.css | 31 +++ 19 files changed, 1729 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/Alert/Alert.stories.tsx create mode 100644 src/components/ui/Alert/Alert.tsx create mode 100644 src/components/ui/Alert/index.ts create mode 100644 src/components/ui/Dialog/Dialog.stories.tsx create mode 100644 src/components/ui/Dialog/Dialog.tsx create mode 100644 src/components/ui/Dialog/index.ts create mode 100644 src/components/ui/Popover/Popover.stories.tsx create mode 100644 src/components/ui/Popover/Popover.tsx create mode 100644 src/components/ui/Popover/index.ts create mode 100644 src/components/ui/Tag/Tag.stories.tsx create mode 100644 src/components/ui/Tag/Tag.tsx create mode 100644 src/components/ui/Tag/index.ts create mode 100644 src/components/ui/Tooltip/Tooltip.stories.tsx create mode 100644 src/components/ui/Tooltip/Tooltip.tsx create mode 100644 src/components/ui/Tooltip/index.ts diff --git a/package-lock.json b/package-lock.json index 16544fb..d9d686f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sdc-frontend", "version": "0.0.0", "dependencies": { + "@floating-ui/react": "^0.27.19", "@fontsource-variable/public-sans": "^5.2.7", "@tailwindcss/vite": "^4.3.0", "clsx": "^2.1.1", @@ -939,6 +940,59 @@ "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@fontsource-variable/public-sans": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.2.7.tgz", @@ -6231,6 +6285,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", diff --git a/package.json b/package.json index d563d01..17ae80a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@floating-ui/react": "^0.27.19", "@fontsource-variable/public-sans": "^5.2.7", "@tailwindcss/vite": "^4.3.0", "clsx": "^2.1.1", diff --git a/src/components/ui/Alert/Alert.stories.tsx b/src/components/ui/Alert/Alert.stories.tsx new file mode 100644 index 0000000..888ab0f --- /dev/null +++ b/src/components/ui/Alert/Alert.stories.tsx @@ -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 = { + title: 'UI/Alert', + component: Alert, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['info', 'warning', 'error', 'success', 'neutral'], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +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 + return ( + setVisible(false)}> + You can now export your synthesis results as a PDF report. + + ) + }, +} + +// --- With action --- + +export const WithAction: Story = { + render: () => ( + Complete now + } + > + Your ethics application is missing required attachments. Please upload them before the deadline. + + ), +} + +// --- With close and action --- + +export const WithCloseAndAction: Story = { + render: () => ( + {}} + action={ + + } + > + The file could not be uploaded due to a network error. Please check your connection and try again. + + ), +} + +// --- 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: () => ( +
+ {}} + action={} + > + 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! + + {}} + action={} + > + 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! + + {}} + action={} + > + 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! + + {}} + action={} + > + 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! + + {}} + action={} + > + 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! + +
+ ), +} diff --git a/src/components/ui/Alert/Alert.tsx b/src/components/ui/Alert/Alert.tsx new file mode 100644 index 0000000..c3076df --- /dev/null +++ b/src/components/ui/Alert/Alert.tsx @@ -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 { + variant?: AlertVariant + title?: string + onClose?: () => void + action?: ReactNode + icon?: ReactNode +} + +const variantStyles: Record = { + 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 = { + 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 = () => ( + + + i + +) + +const WarningIcon = () => ( + + + + + +) + +const ErrorIcon = () => ( + + + + + +) + +const SuccessIcon = () => ( + + + + +) + +const defaultIcons: Record ReactNode> = { + info: InfoIcon, + warning: WarningIcon, + error: ErrorIcon, + success: SuccessIcon, + neutral: InfoIcon, +} + +export const Alert = forwardRef( + ({ variant = 'info', title, onClose, action, icon, className, children, ...props }, ref) => { + const DefaultIcon = defaultIcons[variant] + + return ( +
+
+ + {icon ?? } + +
+ {title &&

{title}

} + {children &&
{children}
} + {action &&
{action}
} +
+ {onClose && ( + + )} +
+
+ ) + }, +) +Alert.displayName = 'Alert' diff --git a/src/components/ui/Alert/index.ts b/src/components/ui/Alert/index.ts new file mode 100644 index 0000000..2665bfe --- /dev/null +++ b/src/components/ui/Alert/index.ts @@ -0,0 +1,2 @@ +export { Alert } from './Alert' +export type { AlertProps, AlertVariant } from './Alert' diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx index d68eed1..a48c9ac 100644 --- a/src/components/ui/Button/Button.tsx +++ b/src/components/ui/Button/Button.tsx @@ -44,9 +44,9 @@ const sizeStyles: Record = { } const iconSizeStyles: Record = { - default: 'size-6', - comfortable: 'size-5', - compact: 'size-5', + default: 'size-5', + comfortable: 'size-[18px]', + compact: 'size-4', } const Spinner = ({ className }: { className?: string }) => ( diff --git a/src/components/ui/Dialog/Dialog.stories.tsx b/src/components/ui/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000..7016d67 --- /dev/null +++ b/src/components/ui/Dialog/Dialog.stories.tsx @@ -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 = { + 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 + +// --- Default --- + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)}> + setOpen(false)}> + Dialog title + A short description of the dialog purpose. + + +

+ This is the dialog body. It can contain any content — text, forms, lists, or other + components. +

+
+ + + + +
+ + ) + }, +} + +// --- Small --- + +export const Small: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} size="sm"> + setOpen(false)}> + Quick confirmation + + +

Are you sure you want to proceed?

+
+ + + + +
+ + ) + }, +} + +// --- Large --- + +export const Large: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} size="lg"> + setOpen(false)}> + Review submission details + + Please review the information below before submitting. + + + +
+
+

Participant count

+

24 participants across 3 schools

+
+
+

Data collection period

+

March 2026 — June 2026

+
+
+

Ethics approval

+

SERAP 2026-0142 (approved)

+
+
+
+ + + + +
+ + ) + }, +} + +// --- Danger confirmation --- + +export const DangerConfirmation: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} size="sm"> + setOpen(false)}> +
+
+ +
+ Delete project? +
+
+ +

+ This action cannot be undone. All data, themes, and participant responses associated + with this project will be permanently deleted. +

+
+ + + + +
+ + ) + }, +} + +// --- With form --- + +export const WithForm: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)}> + setOpen(false)}> + New theme + + Give your theme a name and description to help organise your findings. + + + +
+ + +
+
+ + + + +
+ + ) + }, +} + +// --- No close button --- + +export const NoCloseButton: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} closeOnBackdrop={false}> + + Terms of use + + +

+ 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. +

+
+ + + + +
+ + ) + }, +} + +// --- Content only --- + +export const ContentOnly: Story = { + render: () => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} size="sm"> + +

+ Your changes have been saved. +

+
+ +
+
+
+ + ) + }, +} diff --git a/src/components/ui/Dialog/Dialog.tsx b/src/components/ui/Dialog/Dialog.tsx new file mode 100644 index 0000000..a5db6e8 --- /dev/null +++ b/src/components/ui/Dialog/Dialog.tsx @@ -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, 'open'> { + open: boolean + onClose: () => void + size?: 'sm' | 'default' | 'lg' | 'full' + closeOnBackdrop?: boolean +} + +const sizeStyles: Record = { + 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( + ({ open, onClose, size = 'default', closeOnBackdrop = true, className, children, ...props }, ref) => { + const internalRef = useRef(null) + const dialogRef = (ref as React.RefObject) || 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) => { + if (closeOnBackdrop && e.target === dialogRef.current) { + onClose() + } + }, + [closeOnBackdrop, onClose, dialogRef], + ) + + return ( + +
{children}
+
+ ) + }, +) +Dialog.displayName = 'Dialog' + +// --- DialogHeader --- + +export interface DialogHeaderProps extends HTMLAttributes { + onClose?: () => void +} + +export const DialogHeader = forwardRef( + ({ onClose, className, children, ...props }, ref) => ( +
+
{children}
+ {onClose && ( + + )} +
+ ), +) +DialogHeader.displayName = 'DialogHeader' + +// --- DialogTitle --- + +export type DialogTitleProps = HTMLAttributes + +export const DialogTitle = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +DialogTitle.displayName = 'DialogTitle' + +// --- DialogDescription --- + +export type DialogDescriptionProps = HTMLAttributes + +export const DialogDescription = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +DialogDescription.displayName = 'DialogDescription' + +// --- DialogContent --- + +export type DialogContentProps = HTMLAttributes + +export const DialogContent = forwardRef( + ({ className, ...props }, ref) => ( +

+ ), +) +DialogContent.displayName = 'DialogContent' + +// --- DialogFooter --- + +export type DialogFooterProps = HTMLAttributes + +export const DialogFooter = forwardRef( + ({ className, ...props }, ref) => ( +
+ ), +) +DialogFooter.displayName = 'DialogFooter' + +// --- DialogClose --- + +export interface DialogCloseProps extends HTMLAttributes { + onClose: () => void + asChild?: boolean + children: ReactNode +} + +export const DialogClose = forwardRef( + ({ onClose, className, children, ...props }, ref) => ( + + ), +) +DialogClose.displayName = 'DialogClose' diff --git a/src/components/ui/Dialog/index.ts b/src/components/ui/Dialog/index.ts new file mode 100644 index 0000000..c69ea96 --- /dev/null +++ b/src/components/ui/Dialog/index.ts @@ -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' diff --git a/src/components/ui/Popover/Popover.stories.tsx b/src/components/ui/Popover/Popover.stories.tsx new file mode 100644 index 0000000..a5bb79c --- /dev/null +++ b/src/components/ui/Popover/Popover.stories.tsx @@ -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 = { + title: 'UI/Popover', + component: Popover, + tags: ['autodocs'], + argTypes: { + placement: { + control: 'select', + options: ['top', 'right', 'bottom', 'left', 'bottom-start', 'bottom-end'], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + + +

Popover title

+

+ This is a popover with rich content. It can contain text, forms, or any components. +

+
+
+ ), +} + +// --- With form --- + +export const WithForm: Story = { + render: () => ( + + + + + +

Display settings

+
+ + + +
+
+ + Cancel + + + Apply + +
+
+
+ ), +} + +// --- Filter popover --- + +export const FilterPopover: Story = { + render: () => ( + + + + + +

Filter by status

+
+ + + + +
+
+ + Clear all filters + +
+
+
+ ), +} + +// --- Context menu style --- + +export const ActionMenu: Story = { + render: () => ( + + + } aria-label="More actions" /> + + + {['Edit', 'Duplicate', 'Move to folder'].map((item) => ( + + {item} + + ))} +
+ + Delete + + + + ), +} + +// --- Placements --- + +export const BottomStart: Story = { + render: () => ( + + + + + +

Aligned to the start of the trigger.

+
+
+ ), +} + +export const TopEnd: Story = { + render: () => ( + + + + + +

Aligned to the end of the trigger, above.

+
+
+ ), +} diff --git a/src/components/ui/Popover/Popover.tsx b/src/components/ui/Popover/Popover.tsx new file mode 100644 index 0000000..93b77a2 --- /dev/null +++ b/src/components/ui/Popover/Popover.tsx @@ -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['refs'] + floatingStyles: ReturnType['floatingStyles'] + context: ReturnType['context'] + getReferenceProps: ReturnType['getReferenceProps'] + getFloatingProps: ReturnType['getFloatingProps'] +} + +const PopoverContext = createContext(null) + +function usePopoverContext() { + const ctx = useContext(PopoverContext) + if (!ctx) throw new Error('Popover compound components must be used within ') + 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 {children} +} + +// --- 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) +} + +// --- PopoverContent --- + +export type PopoverContentProps = HTMLAttributes + +export const PopoverContent = forwardRef( + ({ className, children, ...props }, _ref) => { + const { open, refs, floatingStyles, context, getFloatingProps } = usePopoverContext() + + if (!open) return null + + return ( + + +
+ {children} +
+
+
+ ) + }, +) +PopoverContent.displayName = 'PopoverContent' + +// --- PopoverClose --- + +export interface PopoverCloseProps extends HTMLAttributes { + children: ReactNode +} + +export const PopoverClose = forwardRef( + ({ className, onClick, children, ...props }, ref) => { + const { setOpen } = usePopoverContext() + + return ( + + ) + }, +) +PopoverClose.displayName = 'PopoverClose' diff --git a/src/components/ui/Popover/index.ts b/src/components/ui/Popover/index.ts new file mode 100644 index 0000000..7797385 --- /dev/null +++ b/src/components/ui/Popover/index.ts @@ -0,0 +1,7 @@ +export { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './Popover' +export type { + PopoverProps, + PopoverTriggerProps, + PopoverContentProps, + PopoverCloseProps, +} from './Popover' diff --git a/src/components/ui/Tag/Tag.stories.tsx b/src/components/ui/Tag/Tag.stories.tsx new file mode 100644 index 0000000..ea13248 --- /dev/null +++ b/src/components/ui/Tag/Tag.stories.tsx @@ -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 = { + 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 + +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: () => ( +
+ }>Theme + } variant="filled">Participant + } variant="light">Literature +
+ ), +} + +// --- Removable --- + +export const Removable: Story = { + render: () => { + const [tags, setTags] = useState(['Student engagement', 'Teacher wellbeing', 'Curriculum design', 'Assessment']) + return ( +
+ {tags.map((tag) => ( + setTags((t) => t.filter((v) => v !== tag))}> + {tag} + + ))} + {tags.length === 0 &&

All tags removed

} +
+ ) + }, +} + +// --- All colours --- + +const colors: TagColor[] = ['navy', 'blue', 'green', 'red', 'orange', 'grey'] + +export const AllColours: Story = { + render: () => ( +
+
+

Outline

+
+ {colors.map((c) => ( + {c} + ))} +
+
+
+

Filled

+
+ {colors.map((c) => ( + {c} + ))} +
+
+
+

Light

+
+ {colors.map((c) => ( + {c} + ))} +
+
+
+ ), +} + +// --- All variants (with features) --- + +export const AllVariants: Story = { + render: () => ( +
+
+

Outline

+
+ Default + }>With icon + {}}>Removable + Small +
+
+
+

Filled

+
+ Default + }>With icon + {}}>Removable + Small +
+
+
+

Light

+
+ Default + }>With icon + {}}>Removable + Small +
+
+
+ ), +} + +// --- Realistic usage --- + +export const ThemeLabels: Story = { + render: () => ( +
+

Assigned themes

+
+ }>Student engagement + }>Digital literacy + }>Rural access +
+
+ ), +} + +// --- 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 ( +
+ {tags.map((tag) => ( + setTags((t) => t.filter((v) => v.label !== tag.label))} + > + {tag.label} + + ))} +
+ ) + }, +} diff --git a/src/components/ui/Tag/Tag.tsx b/src/components/ui/Tag/Tag.tsx new file mode 100644 index 0000000..ac4d1e7 --- /dev/null +++ b/src/components/ui/Tag/Tag.tsx @@ -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 { + variant?: 'outline' | 'filled' | 'light' + color?: TagColor + size?: 'default' | 'sm' + icon?: ReactNode + onRemove?: () => void +} + +const colorVariantStyles: Record> = { + 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 = { + default: 'h-7 px-2.5 text-small gap-1.5', + sm: 'h-5 px-2 text-caption gap-1', +} + +const removeHoverStyles: Record = { + outline: 'hover:bg-current/10', + filled: 'hover:bg-white/20', + light: 'hover:bg-current/10', +} + +const removeSizeStyles: Record = { + default: 'size-4', + sm: 'size-3', +} + +export const Tag = forwardRef( + ({ variant = 'outline', color = 'navy', size = 'default', icon, onRemove, className, children, ...props }, ref) => ( + + {icon && {icon}} + {children} + {onRemove && ( + + )} + + ), +) +Tag.displayName = 'Tag' diff --git a/src/components/ui/Tag/index.ts b/src/components/ui/Tag/index.ts new file mode 100644 index 0000000..3cdfdc6 --- /dev/null +++ b/src/components/ui/Tag/index.ts @@ -0,0 +1,2 @@ +export { Tag } from './Tag' +export type { TagProps, TagColor } from './Tag' diff --git a/src/components/ui/Tooltip/Tooltip.stories.tsx b/src/components/ui/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000..bebc375 --- /dev/null +++ b/src/components/ui/Tooltip/Tooltip.stories.tsx @@ -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 = { + title: 'UI/Tooltip', + component: Tooltip, + tags: ['autodocs'], + argTypes: { + placement: { + control: 'select', + options: ['top', 'right', 'bottom', 'left'], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + ), +} + +// --- Placements --- + +export const Top: Story = { + render: () => ( + + + + ), +} + +export const Right: Story = { + render: () => ( + + + + ), +} + +export const Bottom: Story = { + render: () => ( + + + + ), +} + +export const Left: Story = { + render: () => ( + + + + ), +} + +// --- With icon buttons --- + +export const OnIconButtons: Story = { + render: () => ( +
+ + } aria-label="Info" /> + + + } aria-label="Help" /> + + + } aria-label="Delete" /> + + + } aria-label="Copy" /> + +
+ ), +} + +// --- Long content --- + +export const LongContent: Story = { + render: () => ( + + + + ), +} + +// --- All placements --- + +export const AllPlacements: Story = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: () => ( +
+
+ + + +
+ + + +
+ + + +
+ + + +
+
+ ), +} + +// --- Instant (no delay) --- + +export const NoDelay: Story = { + render: () => ( + + + + ), +} diff --git a/src/components/ui/Tooltip/Tooltip.tsx b/src/components/ui/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..4758953 --- /dev/null +++ b/src/components/ui/Tooltip/Tooltip.tsx @@ -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)} + {open && ( + +
+ {content} + +
+
+ )} + + ) +} diff --git a/src/components/ui/Tooltip/index.ts b/src/components/ui/Tooltip/index.ts new file mode 100644 index 0000000..eaca424 --- /dev/null +++ b/src/components/ui/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { Tooltip } from './Tooltip' +export type { TooltipProps } from './Tooltip' diff --git a/src/tokens/tokens.css b/src/tokens/tokens.css index edd97d8..af6fb17 100644 --- a/src/tokens/tokens.css +++ b/src/tokens/tokens.css @@ -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;