From e025c0eb3480fff7493e75ec7aa4bd14ad44faab Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 21 May 2026 16:01:06 +1000 Subject: [PATCH] Add Badge, Chip, and IconButton components with token layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Badge: 11 solid-colour variants (navy, info, success, error, warning + light variants, neutral, white) with icon slots - Chip: bordered interactive element with selected, dismissible, and disabled states - IconButton: circle/square shapes, 3 variants × 3 intents × 3 sizes, uses lucide-react icons - Add badge and chip domain token layers to tokens.css - Configure extendTailwindMerge in cn() to recognise custom font-size utilities (text-small, text-body, etc.) Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 11 ++ package.json | 1 + src/components/ui/Badge/Badge.stories.tsx | 173 ++++++++++++++++++ src/components/ui/Badge/Badge.tsx | 61 ++++++ src/components/ui/Badge/index.ts | 2 + src/components/ui/Chip/Chip.stories.tsx | 118 ++++++++++++ src/components/ui/Chip/Chip.tsx | 85 +++++++++ src/components/ui/Chip/index.ts | 2 + .../ui/IconButton/IconButton.stories.tsx | 139 ++++++++++++++ src/components/ui/IconButton/IconButton.tsx | 83 +++++++++ src/components/ui/IconButton/index.ts | 2 + src/lib/utils.ts | 12 +- src/tokens/tokens.css | 22 +++ 13 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/Badge/Badge.stories.tsx create mode 100644 src/components/ui/Badge/Badge.tsx create mode 100644 src/components/ui/Badge/index.ts create mode 100644 src/components/ui/Chip/Chip.stories.tsx create mode 100644 src/components/ui/Chip/Chip.tsx create mode 100644 src/components/ui/Chip/index.ts create mode 100644 src/components/ui/IconButton/IconButton.stories.tsx create mode 100644 src/components/ui/IconButton/IconButton.tsx create mode 100644 src/components/ui/IconButton/index.ts diff --git a/package-lock.json b/package-lock.json index 1aeddf8..16544fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.4.0", "globals": "^17.6.0", + "lucide-react": "^1.16.0", "playwright": "^1.60.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", @@ -5167,6 +5168,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index adde099..d563d01 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.4.0", "globals": "^17.6.0", + "lucide-react": "^1.16.0", "playwright": "^1.60.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", diff --git a/src/components/ui/Badge/Badge.stories.tsx b/src/components/ui/Badge/Badge.stories.tsx new file mode 100644 index 0000000..f70995b --- /dev/null +++ b/src/components/ui/Badge/Badge.stories.tsx @@ -0,0 +1,173 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Badge } from './Badge' + +const meta: Meta = { + title: 'UI/Badge', + component: Badge, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: [ + 'navy', + 'info', + 'info-light', + 'success', + 'success-light', + 'error', + 'error-light', + 'warning', + 'warning-light', + 'neutral', + 'white', + ], + }, + children: { control: 'text' }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=78-1035', + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { children: 'Status here' }, +} + +// --- Variants --- + +export const Navy: Story = { + args: { children: 'Primary', variant: 'navy' }, +} + +export const Info: Story = { + args: { children: 'Information', variant: 'info' }, +} + +export const InfoLight: Story = { + args: { children: 'Information', variant: 'info-light' }, +} + +export const Success: Story = { + args: { children: 'Complete', variant: 'success' }, +} + +export const SuccessLight: Story = { + args: { children: 'Complete', variant: 'success-light' }, +} + +export const Error: Story = { + args: { children: 'Failed', variant: 'error' }, +} + +export const ErrorLight: Story = { + args: { children: 'Failed', variant: 'error-light' }, +} + +export const Warning: Story = { + args: { children: 'Pending', variant: 'warning' }, +} + +export const WarningLight: Story = { + args: { children: 'Pending', variant: 'warning-light' }, +} + +export const Neutral: Story = { + args: { children: 'Draft', variant: 'neutral' }, +} + +export const White: Story = { + args: { children: 'Default', variant: 'white' }, +} + +// --- Icons --- + +const CheckIcon = () => ( + + + +) + +const CalendarIcon = () => ( + + + + + + +) + +export const WithIcons: Story = { + args: { + children: 'Status here', + variant: 'info', + leftIcon: , + rightIcon: , + }, +} + +// --- All variants --- + +export const AllVariants: Story = { + render: () => ( +
+ Navy + Info + Info light + Success + Success light + Error + Error light + Warning + Warning light + Neutral + White +
+ ), +} + +export const StrongVariants: Story = { + render: () => ( +
+ Navy + Info + Success + Error + Warning +
+ ), +} + +export const LightVariants: Story = { + render: () => ( +
+ Info + Success + Error + Warning + Neutral + White +
+ ), +} diff --git a/src/components/ui/Badge/Badge.tsx b/src/components/ui/Badge/Badge.tsx new file mode 100644 index 0000000..b570305 --- /dev/null +++ b/src/components/ui/Badge/Badge.tsx @@ -0,0 +1,61 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface BadgeProps extends HTMLAttributes { + variant?: + | 'navy' + | 'info' + | 'info-light' + | 'success' + | 'success-light' + | 'error' + | 'error-light' + | 'warning' + | 'warning-light' + | 'neutral' + | 'white' + leftIcon?: ReactNode + rightIcon?: ReactNode +} + +const variantStyles: Record = { + navy: 'bg-badge-navy text-white', + info: 'bg-badge-info text-white', + 'info-light': 'bg-badge-info-light text-primary-dark', + success: 'bg-badge-success text-white', + 'success-light': 'bg-badge-success-light text-badge-on-success-light', + error: 'bg-badge-error text-white', + 'error-light': 'bg-badge-error-light text-badge-on-error-light', + warning: 'bg-badge-warning text-white', + 'warning-light': 'bg-badge-warning-light text-badge-on-warning-light', + neutral: 'bg-badge-neutral text-text-secondary', + white: 'bg-surface text-primary-dark border border-primary-dark', +} + +export function Badge({ + variant = 'info', + leftIcon, + rightIcon, + className, + children, + ...props +}: BadgeProps) { + return ( + + {leftIcon && ( + {leftIcon} + )} + {children} + {rightIcon && ( + {rightIcon} + )} + + ) +} diff --git a/src/components/ui/Badge/index.ts b/src/components/ui/Badge/index.ts new file mode 100644 index 0000000..292a59d --- /dev/null +++ b/src/components/ui/Badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from './Badge' +export type { BadgeProps } from './Badge' diff --git a/src/components/ui/Chip/Chip.stories.tsx b/src/components/ui/Chip/Chip.stories.tsx new file mode 100644 index 0000000..9cd38f2 --- /dev/null +++ b/src/components/ui/Chip/Chip.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Chip } from './Chip' + +const meta: Meta = { + title: 'UI/Chip', + component: Chip, + tags: ['autodocs'], + argTypes: { + selected: { control: 'boolean' }, + disabled: { control: 'boolean' }, + children: { control: 'text' }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=63-6084', + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { children: 'Label' }, +} + +// --- Interactive --- + +export const Clickable: Story = { + args: { children: 'Filter', onClick: () => {} }, +} + +export const Selected: Story = { + args: { children: 'Active filter', selected: true, onClick: () => {} }, +} + +export const Disabled: Story = { + args: { children: 'Unavailable', disabled: true, onClick: () => {} }, +} + +export const DisabledSelected: Story = { + args: { children: 'Locked', disabled: true, selected: true, onClick: () => {} }, +} + +// --- Dismissible --- + +export const Dismissible: Story = { + args: { children: 'Remove me', onDismiss: () => {} }, +} + +// --- Custom icon --- + +const DropdownIcon = () => ( + + + +) + +export const WithDropdownIcon: Story = { + args: { children: 'Category', rightIcon: , onClick: () => {} }, +} + +// --- Toggle example --- + +export const ToggleGroup: Story = { + render: () => { + const labels = ['Qualitative', 'Quantitative', 'Mixed methods'] + const [selected, setSelected] = useState>(new Set()) + + const toggle = (label: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(label)) next.delete(label) + else next.add(label) + return next + }) + } + + return ( +
+ {labels.map((label) => ( + toggle(label)}> + {label} + + ))} +
+ ) + }, +} + +// --- Dismissible list --- + +export const DismissibleList: Story = { + render: () => { + const [items, setItems] = useState(['Education', 'Psychology', 'Sociology', 'Linguistics']) + + return ( +
+ {items.map((item) => ( + setItems((prev) => prev.filter((i) => i !== item))}> + {item} + + ))} + {items.length === 0 && All removed} +
+ ) + }, +} diff --git a/src/components/ui/Chip/Chip.tsx b/src/components/ui/Chip/Chip.tsx new file mode 100644 index 0000000..5fff2c4 --- /dev/null +++ b/src/components/ui/Chip/Chip.tsx @@ -0,0 +1,85 @@ +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface ChipProps extends Omit, 'children'> { + children: ReactNode + selected?: boolean + onDismiss?: () => void + rightIcon?: ReactNode +} + +const DismissIcon = () => ( + +) + +export const Chip = forwardRef( + ({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => { + const isInteractive = !!(onClick || onDismiss) + const Component = isInteractive ? 'button' : 'span' + + const trailing = rightIcon ?? (onDismiss ? : null) + + return ( + ) => { + if (onDismiss && !rightIcon) { + onDismiss() + } else { + onClick?.(e) + } + } + : undefined + } + className={cn( + 'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]', + selected + ? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text' + : 'border-chip-border bg-chip-bg text-chip-text', + isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10', + isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80', + isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border', + disabled && 'pointer-events-none opacity-50', + className, + )} + {...(props as Record)} + > + {children} + {trailing && ( + { + e.stopPropagation() + onDismiss() + } + : undefined + } + > + {trailing} + + )} + + ) + }, +) + +Chip.displayName = 'Chip' diff --git a/src/components/ui/Chip/index.ts b/src/components/ui/Chip/index.ts new file mode 100644 index 0000000..d806682 --- /dev/null +++ b/src/components/ui/Chip/index.ts @@ -0,0 +1,2 @@ +export { Chip } from './Chip' +export type { ChipProps } from './Chip' diff --git a/src/components/ui/IconButton/IconButton.stories.tsx b/src/components/ui/IconButton/IconButton.stories.tsx new file mode 100644 index 0000000..00e5b56 --- /dev/null +++ b/src/components/ui/IconButton/IconButton.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight } from 'lucide-react' +import { IconButton } from './IconButton' + +const meta: Meta = { + title: 'UI/IconButton', + component: IconButton, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary', 'tertiary'], + }, + intent: { + control: 'select', + options: ['default', 'danger', 'neutral'], + }, + size: { + control: 'select', + options: ['large', 'default', 'compact'], + }, + shape: { + control: 'select', + options: ['circle', 'square'], + }, + disabled: { control: 'boolean' }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=81-1137', + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { icon: , 'aria-label': 'Close' }, +} + +// --- Intents --- + +export const IntentDefault: Story = { + args: { icon: , 'aria-label': 'Add', intent: 'default' }, +} + +export const IntentDanger: Story = { + args: { icon: , 'aria-label': 'Delete', intent: 'danger' }, +} + +export const IntentNeutral: Story = { + args: { icon: , 'aria-label': 'Menu', intent: 'neutral' }, +} + +// --- Shapes --- + +export const Circle: Story = { + args: { icon: , 'aria-label': 'Add', shape: 'circle' }, +} + +export const Square: Story = { + args: { icon: , 'aria-label': 'Add', shape: 'square' }, +} + +export const SquareSecondary: Story = { + args: { icon: , 'aria-label': 'Expand', shape: 'square', variant: 'secondary' }, +} + +// --- Sizes --- + +export const AllSizes: Story = { + render: () => ( +
+ } aria-label="Close" /> + } aria-label="Close" /> + } aria-label="Close" /> +
+ ), +} + +// --- Disabled --- + +export const Disabled: Story = { + render: () => ( +
+ } aria-label="Close" /> + } aria-label="Close" /> + } aria-label="Close" /> +
+ ), +} + +// --- Full matrix --- + +export const AllVariantsAndIntents: Story = { + render: () => ( +
+
+ } aria-label="Add" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ } aria-label="Search" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ } aria-label="Next" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ ), +} + +export const SquareMatrix: Story = { + render: () => ( +
+
+ } aria-label="Add" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ } aria-label="Search" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ } aria-label="Next" /> + } aria-label="Delete" /> + } aria-label="Menu" /> +
+
+ ), +} diff --git a/src/components/ui/IconButton/IconButton.tsx b/src/components/ui/IconButton/IconButton.tsx new file mode 100644 index 0000000..a839edf --- /dev/null +++ b/src/components/ui/IconButton/IconButton.tsx @@ -0,0 +1,83 @@ +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export interface IconButtonProps extends Omit, 'children'> { + variant?: 'primary' | 'secondary' | 'tertiary' + intent?: 'default' | 'danger' | 'neutral' + size?: 'default' | 'large' | 'compact' + shape?: 'circle' | 'square' + icon: ReactNode + 'aria-label': string +} + +const variantIntentStyles: Record> = { + primary: { + default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80', + danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80', + neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/80', + }, + secondary: { + default: + 'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10', + danger: + 'border-2 border-button-danger text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10', + neutral: + 'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10', + }, + tertiary: { + default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10', + danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10', + neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10', + }, +} + +const sizeStyles: Record = { + large: 'size-14', + default: 'size-12', + compact: 'size-10', +} + +const iconSizeStyles: Record = { + large: 'size-6', + default: 'size-6', + compact: 'size-[18px]', +} + +export const IconButton = forwardRef( + ( + { + variant = 'primary', + intent = 'default', + size = 'default', + shape = 'circle', + icon, + disabled, + className, + ...props + }, + ref, + ) => { + return ( + + ) + }, +) + +IconButton.displayName = 'IconButton' diff --git a/src/components/ui/IconButton/index.ts b/src/components/ui/IconButton/index.ts new file mode 100644 index 0000000..ecee823 --- /dev/null +++ b/src/components/ui/IconButton/index.ts @@ -0,0 +1,2 @@ +export { IconButton } from './IconButton' +export type { IconButtonProps } from './IconButton' diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d32b0fe..d121661 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,15 @@ import { type ClassValue, clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { extendTailwindMerge } from 'tailwind-merge' + +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + 'font-size': [ + { text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'intro', 'body', 'small', 'caption'] }, + ], + }, + }, +}) export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/src/tokens/tokens.css b/src/tokens/tokens.css index 1e2edd7..dd8e33f 100644 --- a/src/tokens/tokens.css +++ b/src/tokens/tokens.css @@ -89,6 +89,28 @@ --color-button-subtle-bg: var(--color-blue-04); --color-button-subtle-text: var(--color-blue-01); + /* Badge */ + --color-badge-navy: var(--color-blue-01); + --color-badge-info: var(--color-blue-02); + --color-badge-info-light: var(--color-blue-04); + --color-badge-success: var(--color-green-02); + --color-badge-success-light: var(--color-green-04); + --color-badge-error: var(--color-red-02); + --color-badge-error-light: var(--color-red-04); + --color-badge-warning: var(--color-orange-02); + --color-badge-warning-light: var(--color-orange-04); + --color-badge-neutral: var(--color-grey-04); + --color-badge-on-success-light: var(--color-green-01); + --color-badge-on-error-light: var(--color-red-01); + --color-badge-on-warning-light: var(--color-orange-01); + + /* Chip */ + --color-chip-border: var(--color-blue-01); + --color-chip-text: var(--color-blue-01); + --color-chip-bg: var(--color-white); + --color-chip-selected-bg: var(--color-blue-01); + --color-chip-selected-text: var(--color-white); + /* Radius */ --radius-sm: 4px; --radius-default: 6px;