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;