Align design system with ADS 3.0 and add new components
Token foundation: fix 16 palette colours to match official ADS_COLORS, add 5 new palettes (teal, brown, purple, fuchsia, yellow), realign semantic tokens (primary=navy, info=bright blue), fix border radii to 8px base, add responsive heading typography. Component migration: swap primary/info references across all existing components, update Button (44px/semibold), Switch (green/compact), Chip (30px/8px radius + colour variants), SideNav (80px rail), Tag (11 colours). New components: SideNav, TopBar, Avatar, Tabs, PageHeader, Slider, RangeSlider, FileInput, DataTable, List, Autocomplete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Autocomplete } from './Autocomplete'
|
||||
|
||||
const states = [
|
||||
{ value: 'nsw', label: 'New South Wales' },
|
||||
{ value: 'vic', label: 'Victoria' },
|
||||
{ value: 'qld', label: 'Queensland' },
|
||||
{ value: 'wa', label: 'Western Australia' },
|
||||
{ value: 'sa', label: 'South Australia' },
|
||||
{ value: 'tas', label: 'Tasmania' },
|
||||
{ value: 'act', label: 'Australian Capital Territory' },
|
||||
{ value: 'nt', label: 'Northern Territory' },
|
||||
]
|
||||
|
||||
const meta: Meta<typeof Autocomplete> = {
|
||||
title: 'Atoms/Autocomplete',
|
||||
component: Autocomplete,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Autocomplete>
|
||||
|
||||
const BasicTemplate = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
placeholder="Search states…"
|
||||
options={states}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <BasicTemplate />,
|
||||
}
|
||||
|
||||
const FreeSoloTemplate = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="School name"
|
||||
placeholder="Type to search or enter a new name…"
|
||||
options={states}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
freeSolo
|
||||
hint="Select from the list or type a custom value"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FreeSolo: Story = {
|
||||
name: 'Free solo (Combobox)',
|
||||
render: () => <FreeSoloTemplate />,
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
name: 'With error',
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
options={states}
|
||||
error="Please select a valid state"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Autocomplete
|
||||
label="State"
|
||||
options={states}
|
||||
value="nsw"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface AutocompleteProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
placeholder?: string
|
||||
options: AutocompleteOption[]
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
freeSolo?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
noResultsText?: string
|
||||
}
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
placeholder,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
freeSolo = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
noResultsText = 'No results found',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const id = useId()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options
|
||||
const q = query.toLowerCase()
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||
}, [options, query])
|
||||
|
||||
const selectOption = useCallback(
|
||||
(opt: AutocompleteOption) => {
|
||||
onChange?.(opt.value)
|
||||
setQuery(opt.label)
|
||||
setOpen(false)
|
||||
setActiveIndex(-1)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setActiveIndex(-1)
|
||||
if (freeSolo) {
|
||||
onChange?.(e.target.value)
|
||||
}
|
||||
},
|
||||
[freeSolo, onChange],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i + 1) % filtered.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault()
|
||||
const opt = filtered[activeIndex]
|
||||
if (opt && !opt.disabled) selectOption(opt)
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[open, filtered, activeIndex, selectOption],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOption && !open) {
|
||||
setQuery(selectedOption.label)
|
||||
}
|
||||
}, [selectedOption, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const el = (ref as React.RefObject<HTMLDivElement>)?.current
|
||||
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open, ref])
|
||||
|
||||
const listboxId = `${id}-listbox`
|
||||
const hasError = !!error
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative flex flex-col gap-1.5', className)} {...props}>
|
||||
<label htmlFor={id} className="text-small font-semibold text-control-label">
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className="text-small text-control-description">{description}</p>}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
|
||||
aria-invalid={hasError || undefined}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'h-14 w-full rounded-default border bg-control-bg px-4 pr-10 text-body text-text outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-2 focus:ring-control-focus-ring',
|
||||
hasError ? 'border-control-error' : 'border-control-border hover:border-primary',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-primary [&>svg]:size-full">
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{open && !disabled && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-default border border-border bg-surface py-1 shadow-md"
|
||||
>
|
||||
{loading ? (
|
||||
<li className="px-4 py-3 text-body text-text-secondary">Loading…</li>
|
||||
) : filtered.length === 0 ? (
|
||||
<li className="px-4 py-3 text-body text-text-secondary">{noResultsText}</li>
|
||||
) : (
|
||||
filtered.map((opt, i) => (
|
||||
<li
|
||||
key={opt.value}
|
||||
id={`${id}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
aria-disabled={opt.disabled || undefined}
|
||||
onClick={() => !opt.disabled && selectOption(opt)}
|
||||
className={cn(
|
||||
'cursor-pointer px-4 py-3 text-body transition-colors',
|
||||
opt.value === value && 'bg-info/12 font-bold',
|
||||
i === activeIndex && opt.value !== value && 'bg-info/5',
|
||||
opt.disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hint && !error && <p className="text-small text-control-description">{hint}</p>}
|
||||
{error && <p className="text-small text-control-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Autocomplete.displayName = 'Autocomplete'
|
||||
2
src/components/atoms/Autocomplete/index.ts
Normal file
2
src/components/atoms/Autocomplete/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Autocomplete } from './Autocomplete'
|
||||
export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'
|
||||
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Avatar } from './Avatar'
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Atoms/Avatar',
|
||||
component: Avatar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Avatar>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { initials: 'DW' },
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { initials: 'SR', size: 'sm' },
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { initials: 'JB', size: 'lg' },
|
||||
}
|
||||
|
||||
export const SingleInitial: Story = {
|
||||
name: 'Single initial',
|
||||
args: { initials: 'R', size: 'default' },
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
name: 'All sizes',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar initials="SM" size="sm" />
|
||||
<Avatar initials="MD" size="default" />
|
||||
<Avatar initials="LG" size="lg" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const OnDarkBackground: Story = {
|
||||
name: 'On dark background',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4 rounded-lg bg-primary-dark p-4">
|
||||
<Avatar initials="DW" size="lg" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
initials: string
|
||||
src?: string
|
||||
alt?: string
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'size-8 text-caption',
|
||||
default: 'size-10 text-body',
|
||||
lg: 'size-12 text-[18px]',
|
||||
}
|
||||
|
||||
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ initials, src, alt, size = 'default', className, ...props }, ref) => {
|
||||
const label = alt || initials
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full bg-avatar text-avatar-text',
|
||||
sizeStyles[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className="size-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials.slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Avatar.displayName = 'Avatar'
|
||||
2
src/components/atoms/Avatar/index.ts
Normal file
2
src/components/atoms/Avatar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Avatar } from './Avatar'
|
||||
export type { AvatarProps } from './Avatar'
|
||||
@@ -21,7 +21,7 @@ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
const variantStyles: Record<string, string> = {
|
||||
navy: 'bg-badge-navy text-white',
|
||||
info: 'bg-badge-info text-white',
|
||||
'info-light': 'bg-badge-info-light text-primary-dark',
|
||||
'info-light': 'bg-badge-info-light text-primary',
|
||||
success: 'bg-badge-success text-white',
|
||||
'success-light': 'bg-badge-success-light text-badge-on-success-light',
|
||||
error: 'bg-badge-error text-white',
|
||||
@@ -29,7 +29,7 @@ const variantStyles: Record<string, string> = {
|
||||
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',
|
||||
white: 'bg-surface text-primary border border-primary',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
|
||||
@@ -38,7 +38,7 @@ const variantIntentStyles: Record<string, Record<string, string>> = {
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
default: 'h-12 px-6 text-body gap-2',
|
||||
default: 'h-11 px-6 text-body gap-2',
|
||||
comfortable: 'h-10 px-5 text-body gap-2',
|
||||
compact: 'h-9 px-4 text-small gap-1.5',
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full font-bold transition-colors',
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
||||
sizeStyles[size],
|
||||
variantIntentStyles[variant][intent],
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
export type ChipColor = 'default' | 'info' | 'error' | 'warning' | 'success'
|
||||
|
||||
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'color'> {
|
||||
children: ReactNode
|
||||
selected?: boolean
|
||||
color?: ChipColor
|
||||
onDismiss?: () => void
|
||||
rightIcon?: ReactNode
|
||||
}
|
||||
@@ -24,8 +27,16 @@ const DismissIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const colorStyles: Record<ChipColor, string> = {
|
||||
default: 'bg-grey-04/40 text-text-secondary',
|
||||
info: 'bg-blue-04/60 text-info',
|
||||
error: 'bg-red-04/60 text-error',
|
||||
warning: 'bg-orange-04/60 text-warning',
|
||||
success: 'bg-green-04/60 text-success',
|
||||
}
|
||||
|
||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||
({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
||||
({ children, selected = false, color, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
||||
const isInteractive = !!(onClick || onDismiss)
|
||||
const Component = isInteractive ? 'button' : 'span'
|
||||
|
||||
@@ -49,10 +60,12 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]',
|
||||
'inline-flex h-[30px] items-center gap-2 rounded-default px-3 py-1.5 text-small font-bold leading-[19px]',
|
||||
selected
|
||||
? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
||||
: 'border-chip-border bg-chip-bg text-chip-text',
|
||||
? 'border border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
||||
: color && color !== 'default'
|
||||
? cn('border-transparent', colorStyles[color])
|
||||
: 'border 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',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Chip } from './Chip'
|
||||
export type { ChipProps } from './Chip'
|
||||
export type { ChipProps, ChipColor } from './Chip'
|
||||
|
||||
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { FileInput } from './FileInput'
|
||||
|
||||
const meta: Meta<typeof FileInput> = {
|
||||
title: 'Atoms/FileInput',
|
||||
component: FileInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof FileInput>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Upload document',
|
||||
description: 'Supported formats: PDF, DOCX, PNG',
|
||||
},
|
||||
}
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
label: 'Upload files',
|
||||
multiple: true,
|
||||
accept: '.pdf,.docx,.png,.jpg',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
name: 'With error',
|
||||
args: {
|
||||
label: 'Upload evidence',
|
||||
error: 'File size must be under 10MB',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Upload document',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface FileInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
error?: string
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
onChange?: (files: File[]) => void
|
||||
}
|
||||
|
||||
const PaperclipIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 015 0v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6h-1.5v9.5a2.5 2.5 0 005 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6H16.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
||||
({ label, description, error, accept, multiple = false, disabled = false, onChange, className, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList) return
|
||||
const next = Array.from(fileList)
|
||||
setFiles(next)
|
||||
onChange?.(next)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const removeFile = useCallback(
|
||||
(index: number) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index)
|
||||
onChange?.(next)
|
||||
return next
|
||||
})
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const displayText = files.length > 0
|
||||
? files.map((f) => f.name).join(', ')
|
||||
: `Select file${multiple ? 's' : ''}…`
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-1.5', className)} {...props}>
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
{description && <p className="text-small text-control-description">{description}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex h-14 items-center gap-3 rounded-default border px-4 text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
error
|
||||
? 'border-control-error'
|
||||
: 'border-control-border hover:border-primary',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-6 shrink-0 [&>svg]:size-full', error ? 'text-control-error' : 'text-primary')}>
|
||||
<PaperclipIcon />
|
||||
</span>
|
||||
<span className={cn(
|
||||
'flex-1 truncate text-body',
|
||||
files.length > 0 ? 'text-text' : 'text-text-secondary',
|
||||
)}>
|
||||
{displayText}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{files.map((file, i) => (
|
||||
<span
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1 rounded-default border border-control-border px-2.5 py-1 text-small text-text"
|
||||
>
|
||||
<span className="max-w-48 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-text/[0.04]"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<svg className="size-3.5" 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-small text-control-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
FileInput.displayName = 'FileInput'
|
||||
2
src/components/atoms/FileInput/index.ts
Normal file
2
src/components/atoms/FileInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FileInput } from './FileInput'
|
||||
export type { FileInputProps } from './FileInput'
|
||||
@@ -117,7 +117,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
{leftIcon && (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)}
|
||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
|
||||
>
|
||||
{leftIcon}
|
||||
</span>
|
||||
@@ -144,7 +144,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
{rightIcon && (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)}
|
||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
|
||||
>
|
||||
{rightIcon}
|
||||
</span>
|
||||
|
||||
57
src/components/atoms/List/List.stories.tsx
Normal file
57
src/components/atoms/List/List.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||
|
||||
const HomeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /></svg>
|
||||
)
|
||||
const StarIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></svg>
|
||||
)
|
||||
const SettingsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z" /></svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof List> = {
|
||||
title: 'Atoms/List',
|
||||
component: List,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof List>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithActive: Story = {
|
||||
name: 'With active item',
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListItem icon={<HomeIcon />} active>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSubheaders: Story = {
|
||||
name: 'With subheaders',
|
||||
render: () => (
|
||||
<List className="w-80 rounded-default shadow-default">
|
||||
<ListSubheader>Reports</ListSubheader>
|
||||
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||
<ListDivider />
|
||||
<ListSubheader>Settings</ListSubheader>
|
||||
<ListItem icon={<SettingsIcon />}>Preferences</ListItem>
|
||||
</List>
|
||||
),
|
||||
}
|
||||
96
src/components/atoms/List/List.tsx
Normal file
96
src/components/atoms/List/List.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- List ---
|
||||
|
||||
export interface ListProps extends HTMLAttributes<HTMLUListElement> {}
|
||||
|
||||
export const List = forwardRef<HTMLUListElement, ListProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
role="list"
|
||||
className={cn('flex flex-col bg-surface', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
)
|
||||
List.displayName = 'List'
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
export interface ListItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
href?: string
|
||||
}
|
||||
|
||||
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||
({ icon, active = false, disabled = false, href, className, children, ...props }, ref) => {
|
||||
const styles = cn(
|
||||
'flex min-h-12 items-center gap-4 px-4 py-2 transition-colors',
|
||||
active
|
||||
? 'bg-info/12 text-info'
|
||||
: 'text-text hover:bg-text/[0.04]',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
className,
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && (
|
||||
<span className={cn('size-6 shrink-0 [&>svg]:size-full', active ? 'text-info' : 'text-text-secondary')}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 text-body">{children}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<li ref={ref} {...props}>
|
||||
<a href={href} className={styles}>{content}</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li ref={ref} role="listitem" className={styles} {...props}>
|
||||
{content}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem.displayName = 'ListItem'
|
||||
|
||||
// --- ListSubheader ---
|
||||
|
||||
export interface ListSubheaderProps extends HTMLAttributes<HTMLLIElement> {}
|
||||
|
||||
export const ListSubheader = forwardRef<HTMLLIElement, ListSubheaderProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn('flex min-h-10 items-center px-4 text-small font-semibold text-text-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
)
|
||||
ListSubheader.displayName = 'ListSubheader'
|
||||
|
||||
// --- ListDivider ---
|
||||
|
||||
export interface ListDividerProps extends HTMLAttributes<HTMLLIElement> {}
|
||||
|
||||
export const ListDivider = forwardRef<HTMLLIElement, ListDividerProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} role="separator" className={cn('border-t border-border', className)} {...props} />
|
||||
),
|
||||
)
|
||||
ListDivider.displayName = 'ListDivider'
|
||||
2
src/components/atoms/List/index.ts
Normal file
2
src/components/atoms/List/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List'
|
||||
@@ -311,8 +311,8 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
|
||||
option.value === selectedValue && 'bg-primary/12 font-bold',
|
||||
index === activeIndex && option.value !== selectedValue && 'bg-primary/5',
|
||||
option.value === selectedValue && 'bg-info/12 font-bold',
|
||||
index === activeIndex && option.value !== selectedValue && 'bg-info/5',
|
||||
option.disabled && 'pointer-events-none text-text/30',
|
||||
)}
|
||||
>
|
||||
|
||||
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Slider, RangeSlider } from './Slider'
|
||||
|
||||
const meta: Meta<typeof Slider> = {
|
||||
title: 'Atoms/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Slider>
|
||||
|
||||
const SliderTemplate = () => {
|
||||
const [value, setValue] = useState(40)
|
||||
return (
|
||||
<div className="w-80">
|
||||
<Slider label="Volume" value={value} onChange={setValue} />
|
||||
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <SliderTemplate />,
|
||||
}
|
||||
|
||||
const SteppedTemplate = () => {
|
||||
const [value, setValue] = useState(50)
|
||||
return (
|
||||
<div className="w-80">
|
||||
<Slider label="Brightness" value={value} onChange={setValue} min={0} max={100} step={10} />
|
||||
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Stepped: Story = {
|
||||
render: () => <SteppedTemplate />,
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<Slider label="Disabled" value={30} onChange={() => {}} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const RangeTemplate = () => {
|
||||
const [value, setValue] = useState<[number, number]>([20, 80])
|
||||
return (
|
||||
<div className="w-80">
|
||||
<RangeSlider label="Price range" value={value} onChange={setValue} />
|
||||
<p className="mt-2 text-small text-text-secondary">Range: {value[0]} – {value[1]}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Range: Story = {
|
||||
render: () => <RangeTemplate />,
|
||||
}
|
||||
201
src/components/atoms/Slider/Slider.tsx
Normal file
201
src/components/atoms/Slider/Slider.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { forwardRef, useCallback, useRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label?: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
||||
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const percent = ((value - min) / (max - min)) * 100
|
||||
|
||||
const handlePointer = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track || disabled) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const raw = min + ratio * (max - min)
|
||||
const stepped = Math.round(raw / step) * step
|
||||
onChange(Math.max(min, Math.min(max, stepped)))
|
||||
},
|
||||
[min, max, step, disabled, onChange],
|
||||
)
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
handlePointer(e.clientX)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
handlePointer(e.clientX)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||
{label && (
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-label={label}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
onChange(Math.min(max, value + step))
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
onChange(Math.max(min, value - step))
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
onChange(min)
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
onChange(max)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md transition-shadow focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||
style={{ left: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Slider.displayName = 'Slider'
|
||||
|
||||
// --- RangeSlider ---
|
||||
|
||||
export interface RangeSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label?: string
|
||||
value: [number, number]
|
||||
onChange: (value: [number, number]) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
|
||||
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const activeThumb = useRef<0 | 1>(0)
|
||||
|
||||
const toPercent = (v: number) => ((v - min) / (max - min)) * 100
|
||||
const lowPct = toPercent(value[0])
|
||||
const highPct = toPercent(value[1])
|
||||
|
||||
const snap = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track || disabled) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const raw = min + ratio * (max - min)
|
||||
const stepped = Math.round(raw / step) * step
|
||||
const clamped = Math.max(min, Math.min(max, stepped))
|
||||
|
||||
const next: [number, number] = [...value]
|
||||
if (activeThumb.current === 0) {
|
||||
next[0] = Math.min(clamped, value[1])
|
||||
} else {
|
||||
next[1] = Math.max(clamped, value[0])
|
||||
}
|
||||
onChange(next)
|
||||
},
|
||||
[min, max, step, value, disabled, onChange],
|
||||
)
|
||||
|
||||
const pickThumb = (clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = (clientX - rect.left) / rect.width
|
||||
const pos = min + ratio * (max - min)
|
||||
activeThumb.current = Math.abs(pos - value[0]) <= Math.abs(pos - value[1]) ? 0 : 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||
{label && (
|
||||
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
aria-label={label}
|
||||
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
pickThumb(e.clientX)
|
||||
snap(e.clientX)
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) snap(e.clientX)
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||
<div
|
||||
className="absolute h-1 rounded-full bg-primary"
|
||||
style={{ left: `${lowPct}%`, width: `${highPct - lowPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{[lowPct, highPct].map((pct, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="slider"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value[i]}
|
||||
aria-label={`${label || 'Range'} ${i === 0 ? 'minimum' : 'maximum'}`}
|
||||
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||
style={{ left: `${pct}%` }}
|
||||
onFocus={() => { activeThumb.current = i as 0 | 1 }}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return
|
||||
const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step
|
||||
: e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step
|
||||
: e.key === 'Home' ? min - value[i]
|
||||
: e.key === 'End' ? max - value[i]
|
||||
: 0
|
||||
if (!delta) return
|
||||
e.preventDefault()
|
||||
const next: [number, number] = [...value]
|
||||
next[i] = Math.max(min, Math.min(max, value[i] + delta))
|
||||
if (next[0] > next[1]) return
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
RangeSlider.displayName = 'RangeSlider'
|
||||
2
src/components/atoms/Slider/index.ts
Normal file
2
src/components/atoms/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Slider, RangeSlider } from './Slider'
|
||||
export type { SliderProps, RangeSliderProps } from './Slider'
|
||||
@@ -40,9 +40,9 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
||||
checked ? 'bg-control-checked' : 'bg-control-border',
|
||||
!disabled && checked && 'hover:bg-control-checked-hover',
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
||||
checked ? 'bg-switch-on' : 'bg-control-border',
|
||||
!disabled && checked && 'hover:bg-switch-on-hover',
|
||||
!disabled && !checked && 'hover:bg-control-border-hover',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
|
||||
'active:scale-[0.97]',
|
||||
@@ -52,8 +52,8 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
|
||||
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
|
||||
'pointer-events-none inline-block size-3.5 rounded-full bg-white shadow-default transition-transform duration-150',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
title: 'Atoms/Tabs',
|
||||
component: Tabs,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Tabs>
|
||||
|
||||
const BasicTemplate = () => {
|
||||
const [value, setValue] = useState('tab1')
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
<Tab value="tab1">Overview</Tab>
|
||||
<Tab value="tab2">Details</Tab>
|
||||
<Tab value="tab3">History</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="tab1">Overview content goes here.</TabPanel>
|
||||
<TabPanel value="tab2">Details content goes here.</TabPanel>
|
||||
<TabPanel value="tab3">History content goes here.</TabPanel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <BasicTemplate />,
|
||||
}
|
||||
|
||||
const WithIconsTemplate = () => {
|
||||
const [value, setValue] = useState('status')
|
||||
|
||||
const StatusIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg>
|
||||
)
|
||||
const DetailsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" /></svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
<Tab value="status" icon={<StatusIcon />}>Status</Tab>
|
||||
<Tab value="details" icon={<DetailsIcon />}>Details</Tab>
|
||||
<Tab value="disabled" disabled>Disabled</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="status">Status panel content.</TabPanel>
|
||||
<TabPanel value="details">Details panel content.</TabPanel>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithIcons: Story = {
|
||||
name: 'With icons',
|
||||
render: () => <WithIconsTemplate />,
|
||||
}
|
||||
|
||||
const ManyTabsTemplate = () => {
|
||||
const [value, setValue] = useState('tab1')
|
||||
return (
|
||||
<Tabs value={value} onChange={setValue}>
|
||||
<TabList>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Tab key={i} value={`tab${i + 1}`}>Tab {i + 1}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<TabPanel key={i} value={`tab${i + 1}`}>Content for tab {i + 1}</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const ManyTabs: Story = {
|
||||
name: 'Many tabs',
|
||||
render: () => <ManyTabsTemplate />,
|
||||
}
|
||||
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
baseId: string
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextValue | null>(null)
|
||||
|
||||
function useTabsContext() {
|
||||
const ctx = useContext(TabsContext)
|
||||
if (!ctx) throw new Error('Tab components must be used within Tabs')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
|
||||
export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
({ value, onChange, className, children, ...props }, ref) => {
|
||||
const baseId = useId()
|
||||
const ctx = useMemo(() => ({ value, onChange, baseId }), [value, onChange, baseId])
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={ctx}>
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tabs.displayName = 'Tabs'
|
||||
|
||||
// --- TabList ---
|
||||
|
||||
export interface TabListProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tablist"
|
||||
className={cn('flex border-b border-border', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
TabList.displayName = 'TabList'
|
||||
|
||||
// --- Tab ---
|
||||
|
||||
export interface TabProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
value: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
|
||||
({ value, icon, disabled = false, className, children, ...props }, ref) => {
|
||||
const { value: selected, onChange, baseId } = useTabsContext()
|
||||
const isSelected = value === selected
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
role="tab"
|
||||
type="button"
|
||||
id={`${baseId}-tab-${value}`}
|
||||
aria-selected={isSelected}
|
||||
aria-controls={`${baseId}-panel-${value}`}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-4 py-3 text-body font-semibold transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-info',
|
||||
isSelected
|
||||
? 'text-primary'
|
||||
: 'text-text/80 hover:text-text',
|
||||
isSelected && 'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1 after:bg-error',
|
||||
disabled && 'pointer-events-none opacity-55',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="size-5 shrink-0 [&>svg]:size-full">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tab.displayName = 'Tab'
|
||||
|
||||
// --- TabPanel ---
|
||||
|
||||
export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ value, className, children, ...props }, ref) => {
|
||||
const { value: selected, baseId } = useTabsContext()
|
||||
const isSelected = value === selected
|
||||
|
||||
if (!isSelected) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
id={`${baseId}-panel-${value}`}
|
||||
aria-labelledby={`${baseId}-tab-${value}`}
|
||||
tabIndex={0}
|
||||
className={cn('pt-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
TabPanel.displayName = 'TabPanel'
|
||||
2
src/components/atoms/Tabs/index.ts
Normal file
2
src/components/atoms/Tabs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs'
|
||||
@@ -1,7 +1,9 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TagColor = 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
|
||||
export type TagColor =
|
||||
| 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
|
||||
| 'teal' | 'brown' | 'purple' | 'fuchsia' | 'yellow'
|
||||
|
||||
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'outline' | 'filled' | 'light'
|
||||
@@ -42,6 +44,31 @@ const colorVariantStyles: Record<TagColor, Record<string, string>> = {
|
||||
filled: 'bg-tag-grey text-white',
|
||||
light: 'bg-tag-grey-light text-tag-grey',
|
||||
},
|
||||
teal: {
|
||||
outline: 'border border-tag-teal text-tag-teal',
|
||||
filled: 'bg-tag-teal text-white',
|
||||
light: 'bg-tag-teal-light text-tag-teal',
|
||||
},
|
||||
brown: {
|
||||
outline: 'border border-tag-brown text-tag-brown',
|
||||
filled: 'bg-tag-brown text-white',
|
||||
light: 'bg-tag-brown-light text-tag-brown',
|
||||
},
|
||||
purple: {
|
||||
outline: 'border border-tag-purple text-tag-purple',
|
||||
filled: 'bg-tag-purple text-white',
|
||||
light: 'bg-tag-purple-light text-tag-purple',
|
||||
},
|
||||
fuchsia: {
|
||||
outline: 'border border-tag-fuchsia text-tag-fuchsia',
|
||||
filled: 'bg-tag-fuchsia text-white',
|
||||
light: 'bg-tag-fuchsia-light text-tag-fuchsia',
|
||||
},
|
||||
yellow: {
|
||||
outline: 'border border-tag-yellow text-tag-yellow',
|
||||
filled: 'bg-tag-yellow text-white',
|
||||
light: 'bg-tag-yellow-light text-tag-yellow',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
|
||||
@@ -189,8 +189,8 @@ export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerPr
|
||||
onClick={() => toggle(value)}
|
||||
className={cn(
|
||||
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
|
||||
isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/5',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ const variantStyles: Record<string, string> = {
|
||||
surface: 'bg-surface border border-border shadow-default',
|
||||
outlined: 'bg-surface border border-border',
|
||||
elevated: 'bg-surface shadow-md',
|
||||
filled: 'bg-primary-dark text-white',
|
||||
filled: 'bg-primary text-white',
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
|
||||
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { DataTable, type DataTableColumn } from './DataTable'
|
||||
|
||||
type Dessert = {
|
||||
name: string
|
||||
calories: number
|
||||
fat: number
|
||||
carbs: number
|
||||
protein: number
|
||||
iron: number
|
||||
}
|
||||
|
||||
const desserts: Dessert[] = [
|
||||
{ name: 'Frozen Yogurt', calories: 159, fat: 6, carbs: 24, protein: 4, iron: 1 },
|
||||
{ name: 'Ice cream sandwich', calories: 237, fat: 9, carbs: 37, protein: 4.3, iron: 1 },
|
||||
{ name: 'Eclair', calories: 262, fat: 16, carbs: 23, protein: 6, iron: 7 },
|
||||
{ name: 'Cupcake', calories: 305, fat: 3.7, carbs: 67, protein: 4.3, iron: 8 },
|
||||
{ name: 'Gingerbread', calories: 356, fat: 16, carbs: 49, protein: 3.9, iron: 16 },
|
||||
{ name: 'Jelly bean', calories: 375, fat: 0, carbs: 94, protein: 0, iron: 0 },
|
||||
{ name: 'Lollipop', calories: 392, fat: 0.2, carbs: 98, protein: 0, iron: 2 },
|
||||
{ name: 'Honeycomb', calories: 408, fat: 3.2, carbs: 87, protein: 6.5, iron: 45 },
|
||||
{ name: 'Donut', calories: 452, fat: 25, carbs: 51, protein: 4.9, iron: 22 },
|
||||
{ name: 'KitKat', calories: 518, fat: 26, carbs: 65, protein: 7, iron: 6 },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<Dessert>[] = [
|
||||
{ key: 'name', header: 'Dessert (100g serving)', sortable: true },
|
||||
{ key: 'calories', header: 'Calories', sortable: true, align: 'right' },
|
||||
{ key: 'fat', header: 'Fat (g)', sortable: true, align: 'right' },
|
||||
{ key: 'carbs', header: 'Carbs (g)', align: 'right' },
|
||||
{ key: 'protein', header: 'Protein (g)', align: 'right' },
|
||||
{ key: 'iron', header: 'Iron (%)', align: 'right' },
|
||||
]
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Molecules/DataTable',
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={desserts} />
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSelection: Story = {
|
||||
name: 'With selection',
|
||||
render: () => (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={desserts}
|
||||
selectable
|
||||
onSelectionChange={(sel) => console.log('Selected:', sel)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomPageSize: Story = {
|
||||
name: 'Custom page size',
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={desserts} pageSize={3} pageSizeOptions={[3, 5, 10]} />
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={[]} emptyMessage="No desserts found" />
|
||||
),
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<DataTable columns={columns} data={[]} loading />
|
||||
),
|
||||
}
|
||||
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
render?: (value: unknown, row: T, index: number) => ReactNode
|
||||
}
|
||||
|
||||
export interface DataTableProps<T = Record<string, unknown>> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
selectable?: boolean
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
pageSizeOptions?: number[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
onSelectionChange?: (selected: T[]) => void
|
||||
}
|
||||
|
||||
type SortState = { key: string; dir: 'asc' | 'desc' } | null
|
||||
|
||||
const ChevronUpIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// --- DataTable ---
|
||||
|
||||
function DataTableInner<T extends Record<string, unknown>>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
selectable = false,
|
||||
pagination = true,
|
||||
pageSize: initialPageSize = 5,
|
||||
pageSizeOptions = [5, 10, 25],
|
||||
loading = false,
|
||||
emptyMessage = 'No data available',
|
||||
onSelectionChange,
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [sort, setSort] = useState<SortState>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sort) return data
|
||||
const { key, dir } = sort
|
||||
return [...data].sort((a, b) => {
|
||||
const va = a[key]
|
||||
const vb = b[key]
|
||||
if (va == null && vb == null) return 0
|
||||
if (va == null) return 1
|
||||
if (vb == null) return -1
|
||||
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true })
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [data, sort])
|
||||
|
||||
const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1
|
||||
const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData
|
||||
const rangeStart = page * pageSize + 1
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length)
|
||||
|
||||
const toggleSort = useCallback((key: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.key === key) {
|
||||
return prev.dir === 'asc' ? { key, dir: 'desc' } : null
|
||||
}
|
||||
return { key, dir: 'asc' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleRow = useCallback(
|
||||
(index: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
onSelectionChange?.(
|
||||
[...next].map((i) => sortedData[i]).filter(Boolean),
|
||||
)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[sortedData, onSelectionChange],
|
||||
)
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === sortedData.length) {
|
||||
onSelectionChange?.([])
|
||||
return new Set()
|
||||
}
|
||||
const all = new Set(sortedData.map((_, i) => i))
|
||||
onSelectionChange?.([...sortedData])
|
||||
return all
|
||||
})
|
||||
}, [sortedData, onSelectionChange])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('overflow-hidden rounded-default bg-surface', className)} {...props}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{selectable && (
|
||||
<th className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === sortedData.length && sortedData.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 font-normal text-primary',
|
||||
col.sortable && 'cursor-pointer select-none hover:bg-text/[0.04]',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && sort?.key === col.key && (
|
||||
sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : pageData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageData.map((row, rowIdx) => {
|
||||
const globalIdx = page * pageSize + rowIdx
|
||||
return (
|
||||
<tr
|
||||
key={globalIdx}
|
||||
className={cn(
|
||||
'border-b border-border last:border-b-0 transition-colors',
|
||||
selected.has(globalIdx) ? 'bg-info/5' : 'hover:bg-text/[0.02]',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(globalIdx)}
|
||||
onChange={() => toggleRow(globalIdx)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, globalIdx)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && sortedData.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-4 border-t border-border px-4 py-2 text-small text-text-secondary">
|
||||
<label className="flex items-center gap-2">
|
||||
Rows per page:
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
className="rounded-sm border border-border bg-surface px-2 py-1 text-small text-text"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{rangeStart}-{rangeEnd} of {sortedData.length}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="First page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage(pageCount - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Last page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTable = forwardRef(DataTableInner) as <T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
||||
) => React.ReactElement | null
|
||||
2
src/components/molecules/DataTable/index.ts
Normal file
2
src/components/molecules/DataTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DataTable } from './DataTable'
|
||||
export type { DataTableProps, DataTableColumn } from './DataTable'
|
||||
@@ -101,7 +101,7 @@ export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
|
||||
<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"
|
||||
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-info/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { PageHeader } from './PageHeader'
|
||||
|
||||
const GridIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof PageHeader> = {
|
||||
title: 'Organisms/PageHeader',
|
||||
component: PageHeader,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof PageHeader>
|
||||
|
||||
export const Light: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const Dark: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
theme="dark"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoIcon: Story = {
|
||||
name: 'No icon',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="My Documents"
|
||||
subtitle="View and manage your uploaded documents"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const Centered: Story = {
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Welcome to your PDP"
|
||||
subtitle="Performance and Development Plan portal"
|
||||
icon={<BookIcon />}
|
||||
centered
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoBackground: Story = {
|
||||
name: 'No background',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
subtitle="Manage your account preferences"
|
||||
noBackground
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithContent: Story = {
|
||||
name: 'With content slot',
|
||||
render: () => (
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
subtitle="Essential resources for my work"
|
||||
icon={<GridIcon />}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<button className="rounded-full bg-primary px-5 py-2 text-small font-semibold text-white">
|
||||
Browse all
|
||||
</button>
|
||||
<button className="rounded-full border-2 border-primary px-5 py-2 text-small font-semibold text-primary">
|
||||
My favourites
|
||||
</button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
),
|
||||
}
|
||||
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
iconSize?: string
|
||||
theme?: 'light' | 'dark'
|
||||
centered?: boolean
|
||||
noBackground?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DecoArcs({ isDark }: { isDark: boolean }) {
|
||||
const stroke = isDark ? 'rgba(20, 108, 253, 0.25)' : 'rgba(0, 38, 100, 0.12)'
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute right-0 top-0 h-full w-1/2"
|
||||
viewBox="0 0 400 200"
|
||||
preserveAspectRatio="xMaxYMid slice"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="350" cy="100" r="160" stroke={stroke} strokeWidth="30" />
|
||||
<circle cx="350" cy="100" r="100" stroke={stroke} strokeWidth="20" />
|
||||
<circle cx="350" cy="100" r="50" stroke={stroke} strokeWidth="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
iconSize = '50px',
|
||||
theme = 'light',
|
||||
centered = false,
|
||||
noBackground = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative overflow-hidden px-9 py-11',
|
||||
!noBackground && (isDark ? 'bg-primary text-white' : 'bg-secondary text-primary'),
|
||||
noBackground && 'text-text',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!noBackground && <DecoArcs isDark={isDark} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex gap-5',
|
||||
centered ? 'flex-col items-center text-center' : 'items-start',
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className="shrink-0 [&>svg]:size-full"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-h2-responsive">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className={cn('mt-1 text-body', isDark ? 'text-white/80' : 'text-text-secondary')}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
PageHeader.displayName = 'PageHeader'
|
||||
2
src/components/organisms/PageHeader/index.ts
Normal file
2
src/components/organisms/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './PageHeader'
|
||||
export type { PageHeaderProps } from './PageHeader'
|
||||
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||
|
||||
const HomeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PersonIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GridIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FolderIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SchoolIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3 1 9l11 6 9-4.91V17h2V9L12 3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SupportIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const meta: Meta<typeof SideNav> = {
|
||||
title: 'Organisms/SideNav',
|
||||
component: SideNav,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="h-[600px] border border-border rounded-lg overflow-hidden">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SideNav>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#">My PDP</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithActiveNested: Story = {
|
||||
name: 'Active nested item',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#">
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const Collapsed: Story = {
|
||||
render: () => (
|
||||
<SideNav collapsed>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" active>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithBadges: Story = {
|
||||
name: 'With badges',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" badge={12}>
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3}>
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" badge={5} defaultOpen>
|
||||
<SideNavItem href="#" badge={2}>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#" badge={3}>
|
||||
PDP guide
|
||||
</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
const ToggleTemplate = () => {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<SideNav collapsed={collapsed}>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||
<SideNavItem href="#">My PDP</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
<SideNavItem href="#">Management</SideNavItem>
|
||||
<SideNavItem href="#">Useful links</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="self-start rounded-lg border border-border px-4 py-2 text-small hover:bg-bg"
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
name: 'Toggle collapsed',
|
||||
render: () => <ToggleTemplate />,
|
||||
}
|
||||
|
||||
export const WithAlerts: Story = {
|
||||
name: 'With alerts',
|
||||
render: () => (
|
||||
<SideNav>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success" defaultOpen>
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
|
||||
export const CollapsedWithAlerts: Story = {
|
||||
name: 'Collapsed with alerts',
|
||||
render: () => (
|
||||
<SideNav collapsed>
|
||||
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||
My status
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||
My details
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<GridIcon />} href="#">
|
||||
Workspace
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||
Resources
|
||||
</SideNavItem>
|
||||
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||
My documents & links
|
||||
</SideNavItem>
|
||||
<SideNavDivider />
|
||||
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success">
|
||||
<SideNavItem href="#" active>
|
||||
My PDP
|
||||
</SideNavItem>
|
||||
</SideNavGroup>
|
||||
<SideNavDivider />
|
||||
<SideNavItem icon={<SupportIcon />} href="#">
|
||||
Support
|
||||
</SideNavItem>
|
||||
</SideNav>
|
||||
),
|
||||
}
|
||||
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip } from '@/components/atoms/Tooltip/Tooltip'
|
||||
|
||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
const alertStyles: Record<AlertVariant, string> = {
|
||||
info: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
}
|
||||
|
||||
function resolveAlertColor(alert: boolean | AlertVariant | undefined): string | null {
|
||||
if (!alert) return null
|
||||
return alertStyles[alert === true ? 'error' : alert]
|
||||
}
|
||||
|
||||
const ChevronIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// --- Contexts ---
|
||||
|
||||
interface SideNavContextValue {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
const SideNavContext = createContext<SideNavContextValue>({ collapsed: false })
|
||||
|
||||
const NestedContext = createContext(false)
|
||||
|
||||
// --- SideNav ---
|
||||
|
||||
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
export const SideNav = forwardRef<HTMLElement, SideNavProps>(
|
||||
({ collapsed = false, className, children, ...props }, ref) => {
|
||||
const contextValue = useMemo(() => ({ collapsed }), [collapsed])
|
||||
|
||||
return (
|
||||
<SideNavContext.Provider value={contextValue}>
|
||||
<nav
|
||||
ref={ref}
|
||||
aria-label="Side navigation"
|
||||
className={cn(
|
||||
'flex flex-col bg-nav-bg px-2 py-2 transition-[width] duration-200',
|
||||
collapsed ? 'w-20 items-center' : 'w-[360px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
</SideNavContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNav.displayName = 'SideNav'
|
||||
|
||||
// --- SideNavDivider ---
|
||||
|
||||
export interface SideNavDividerProps extends HTMLAttributes<HTMLHRElement> {}
|
||||
|
||||
export const SideNavDivider = forwardRef<HTMLHRElement, SideNavDividerProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
|
||||
return (
|
||||
<hr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'my-1 border-t border-nav-divider',
|
||||
collapsed ? 'mx-1' : 'mx-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNavDivider.displayName = 'SideNavDivider'
|
||||
|
||||
// --- Badge (internal) ---
|
||||
|
||||
function NavBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-caption font-semibold leading-none text-white tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Alert dot (internal) ---
|
||||
|
||||
function AlertDot({ alert }: { alert: boolean | AlertVariant | undefined }) {
|
||||
const color = resolveAlertColor(alert)
|
||||
if (!color) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={cn('size-2 rounded-full', color)} aria-hidden="true" />
|
||||
<span className="sr-only">Has notifications</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Icon with optional alert overlay (collapsed mode) ---
|
||||
|
||||
function NavIcon({
|
||||
icon,
|
||||
alert,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
alert?: boolean | AlertVariant
|
||||
}) {
|
||||
const color = resolveAlertColor(alert)
|
||||
|
||||
return (
|
||||
<span className="relative size-6 shrink-0 text-nav-icon [&>svg]:size-full">
|
||||
{icon}
|
||||
{color && (
|
||||
<>
|
||||
<span
|
||||
className={cn('absolute -right-1 -top-1 size-2.5 rounded-full ring-2 ring-nav-bg', color)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">Has notifications</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- SideNavItem ---
|
||||
|
||||
type SideNavItemBase = {
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
badge?: number
|
||||
alert?: boolean | AlertVariant
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SideNavItemAsLink = SideNavItemBase & {
|
||||
href: string
|
||||
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase | 'href'>
|
||||
|
||||
type SideNavItemAsButton = SideNavItemBase & {
|
||||
href?: undefined
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>
|
||||
|
||||
export type SideNavItemProps = SideNavItemAsLink | SideNavItemAsButton
|
||||
|
||||
export const SideNavItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, SideNavItemProps>(
|
||||
({ icon, active = false, badge, alert, className, children, ...props }, ref) => {
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
const isNested = useContext(NestedContext)
|
||||
const isLink = 'href' in props && props.href !== undefined
|
||||
|
||||
const styles = cn(
|
||||
'relative flex items-center rounded-full transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
isNested
|
||||
? 'h-14 pl-14 pr-6'
|
||||
: cn('h-14', collapsed ? 'size-14 justify-center' : 'pl-4 pr-6'),
|
||||
active
|
||||
? 'bg-nav-active/12 text-nav-text'
|
||||
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||
className,
|
||||
)
|
||||
|
||||
const hasExtras = !collapsed && (alert || badge !== undefined)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon && !isNested && (
|
||||
collapsed ? (
|
||||
<NavIcon icon={icon} alert={alert} />
|
||||
) : (
|
||||
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||
)
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate text-body leading-[1.5]">{children}</span>
|
||||
{hasExtras && (
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||
<AlertDot alert={alert} />
|
||||
{badge !== undefined && <NavBadge count={badge} />}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const element = isLink ? (
|
||||
<a
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
className={styles}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{...(props as Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase>)}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
className={styles}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{...(props as Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (collapsed && !isNested) {
|
||||
return (
|
||||
<Tooltip content={children} placement="right" delay={{ open: 200, close: 0 }}>
|
||||
{element}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return element
|
||||
},
|
||||
)
|
||||
SideNavItem.displayName = 'SideNavItem'
|
||||
|
||||
// --- SideNavGroup ---
|
||||
|
||||
export interface SideNavGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
defaultOpen?: boolean
|
||||
badge?: number
|
||||
alert?: boolean | AlertVariant
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export const SideNavGroup = forwardRef<HTMLDivElement, SideNavGroupProps>(
|
||||
({ icon, label, defaultOpen = false, badge, alert, active = false, className, children, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const { collapsed } = useContext(SideNavContext)
|
||||
|
||||
const toggle = useCallback(() => setOpen((prev) => !prev), [])
|
||||
|
||||
const triggerStyles = cn(
|
||||
'relative flex h-14 w-full items-center rounded-full transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
|
||||
active && collapsed
|
||||
? 'bg-nav-active/12 text-nav-text'
|
||||
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
<button type="button" onClick={toggle} aria-expanded={open} className={triggerStyles}>
|
||||
{collapsed ? (
|
||||
<NavIcon icon={icon} alert={alert} />
|
||||
) : (
|
||||
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 truncate text-body leading-[1.5]">{label}</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||
<AlertDot alert={alert} />
|
||||
{badge !== undefined && <NavBadge count={badge} />}
|
||||
<span
|
||||
className={cn(
|
||||
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{collapsed ? (
|
||||
<Tooltip content={label} placement="right" delay={{ open: 200, close: 0 }}>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
) : (
|
||||
trigger
|
||||
)}
|
||||
{open && !collapsed && (
|
||||
<NestedContext.Provider value={true}>
|
||||
<div role="group" aria-label={label} className="flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</NestedContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
SideNavGroup.displayName = 'SideNavGroup'
|
||||
7
src/components/organisms/SideNav/index.ts
Normal file
7
src/components/organisms/SideNav/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||
export type {
|
||||
SideNavProps,
|
||||
SideNavItemProps,
|
||||
SideNavGroupProps,
|
||||
SideNavDividerProps,
|
||||
} from './SideNav'
|
||||
205
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
205
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||
|
||||
// --- Story icons ---
|
||||
|
||||
const MenuIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HelpIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||
<circle cx="12" cy="17" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BellIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DotsIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const NswLogo = () => (
|
||||
<svg viewBox="0 0 36 35" className="size-9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M20.234 29.091c-.204-.333-.512-.636-.916-.899a4.932 4.932 0 0 0-1.629-.597l-1.886-.391c-.57-.124-.963-.285-1.168-.476-.2-.187-.302-.435-.302-.738 0-.193.044-.369.13-.523.086-.154.211-.291.371-.406.161-.115.361-.206.594-.27a2.92 2.92 0 0 1 .788-.098c.378 0 .719.05 1.013.15.291.1.532.262.717.484.184.223.297.525.336.899l.005.052h2.341l-.001-.06a3.474 3.474 0 0 0-.531-1.734 3.453 3.453 0 0 0-1.5-1.23c-.653-.302-1.466-.454-2.419-.454-.8 0-1.533.14-2.18.418-.648.278-1.169.68-1.546 1.194-.38.516-.568 1.135-.563 1.838.016.859.275 1.555.77 2.072.493.514 1.169.856 2.01 1.016l1.902.39c.35.07.666.161.942.273.27.109.486.25.642.421.151.167.228.385.228.65 0 .293-.096.542-.284.736-.193.2-.456.35-.778.449a3.527 3.527 0 0 1-1.095.15c-.387 0-.744-.06-1.06-.18a1.956 1.956 0 0 1-.784-.514 1.714 1.714 0 0 1-.42-.822l-.01-.046h-2.386l.006.064c.057.562.23 1.06.516 1.482.286.418.651.771 1.086 1.05.434.277.921.488 1.448.627a6.44 6.44 0 0 0 1.613.209c.891 0 1.682-.142 2.351-.422.671-.281 1.202-.668 1.579-1.151.38-.485.572-1.04.572-1.647 0-.287-.032-.606-.096-.949a3.178 3.178 0 0 0-.405-.948ZM3.577 26.662l5.092 7.412h2.071V22.99H8.424v7.13L3.552 23.014l-.017-.025H1.268v11.085h2.31v-7.412Zm27.123 3.417 1.984-7.09h2.27l-3.253 11.085h-1.844l-2.007-7.017-2.022 7.017H24.014L20.77 22.99h2.276l1.984 7.088 1.985-7.088h1.678l2.007 7.089Z" fill="white" />
|
||||
<path d="M16.868 20.24c-2.724-.338-5.37.542-10.027-.733-.475-.13-.65.375-.393.798 1.25 2.058 7.57.445 10.432.148.125-.013.113-.198-.012-.213Z" fill="#D7153A" />
|
||||
<path d="M28.791 19.508c-4.656 1.274-7.302.395-10.026.731-.125.016-.138.201-.013.214 2.863.297 9.182 1.91 10.432-.148.258-.423.083-.928-.393-1.06v.263Z" fill="#D7153A" />
|
||||
<path d="M8.26 16.564c-.947-1.399-1.72-2.963-2.32-4.684-1.807.531-3.657 1.3-5.54 2.303a.629.629 0 0 0-.4.65c-.007.276.13.527.366.669 3.646 2.197 7.21 3.454 10.611 3.744a7.133 7.133 0 0 1-2.718-2.682Z" fill="#D7153A" />
|
||||
<path d="M3.18 11.89c.835-.337 1.661-.625 2.48-.867a31.64 31.64 0 0 1-.724-3.011 24.71 24.71 0 0 0-2.924-.228h-.026c-.264 0-.502.133-.641.362a.637.637 0 0 0-.017.762c.586 1.065 1.204 2.059 1.852 2.982Z" fill="#D7153A" />
|
||||
<path d="M12.139 18.849c.565.251 1.143.392 1.704.421-1.373-.99-2.451-2.668-3.073-4.808-.8-2.754-1.068-5.793-.804-9.071a28.32 28.32 0 0 0-3.511-1.926.622.622 0 0 0-.746.06.636.636 0 0 0-.33.673c.294 4.757 1.511 8.747 3.617 11.86.884 1.305 1.97 2.27 3.143 2.791Z" fill="#D7153A" />
|
||||
<path d="M14.826 3.278a28.02 28.02 0 0 0-2.104-2.342.605.605 0 0 0-.531-.229.588.588 0 0 0-.515.573c-.148.718-.43 2.181-.554 3.63a27.77 27.77 0 0 1 2.68 2.033c.317-1.217.73-2.451 1.024-3.665Z" fill="#D7153A" />
|
||||
<path d="M35.23 14.183c-1.882-1.005-3.732-1.772-5.539-2.304a24.63 24.63 0 0 1-2.32 4.684 7.127 7.127 0 0 1-2.718 2.682c3.401-.29 6.966-1.548 10.611-3.744a.635.635 0 0 0 .366-.67.629.629 0 0 0-.4-.648Z" fill="#D7153A" />
|
||||
<path d="M32.451 11.889a28.137 28.137 0 0 0 1.853-2.983.637.637 0 0 0-.017-.762.618.618 0 0 0-.641-.362h-.026c-1.008.034-1.983.11-2.924.228-.19 1.048-.431 2.053-.724 3.011.819.243 1.646.531 2.48.867Z" fill="#D7153A" />
|
||||
<path d="M24.862 14.463c-.622-2.139-1.7-3.818-3.073-4.808.56.029 1.14.17 1.705.421 1.171.521 2.258 1.486 3.141 2.791 2.107 3.112 3.324 7.103 3.617 11.859a.636.636 0 0 0-.33-.673.622.622 0 0 0-.745-.06 28.382 28.382 0 0 0-3.511 1.926c.264-3.279-.003-6.318-.804-9.071v-.385Z" fill="#D7153A" />
|
||||
<path d="M24.719 4.939c-.125-1.447-.406-2.911-.554-3.63a.588.588 0 0 0-.515-.572.605.605 0 0 0-.53.229 28.145 28.145 0 0 0-2.105 2.342c.504 1.243.917 2.476 1.234 3.694a27.58 27.58 0 0 1 2.47-2.063Z" fill="#D7153A" />
|
||||
<path d="M17.813 12.128c.916-1.82 2.379-3.452 3.477-4.483a26.56 26.56 0 0 0-2.814-7.243.631.631 0 0 0-1.326 0 26.634 26.634 0 0 0-3.474 7.243c1.147 1.069 2.66 2.812 3.474 4.483h.663Z" fill="#D7153A" />
|
||||
<path d="M21.178 18.587c.95-.672 1.9-1.843 2.502-3.472 1.167-3.153 1.287-6.666 1.12-9.152-2.114 1.42-5.471 4.387-6.617 7.558-.54 1.492-.791 3.525-.375 4.66.174.474.469.833.875 1.004.616.26 1.56.063 2.495-.598Z" fill="#D7153A" />
|
||||
<path d="M17.34 13.245a11.07 11.07 0 0 0-1.066-1.973 22.052 22.052 0 0 0-5.445-5.309c-.023.369-.354 4.351.808 8.245.883 2.962 2.393 4.168 3.221 4.634.833.469 1.595.579 2.488.356-.906-1.242-.793-3.792-.006-5.954Z" fill="#D7153A" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function IconBtn({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex size-12 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<span className="size-6 [&>svg]:size-full">{icon}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Stories ---
|
||||
|
||||
const meta: Meta<typeof TopBar> = {
|
||||
title: 'Organisms/TopBar',
|
||||
component: TopBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof TopBar>
|
||||
|
||||
const trailingActions = (
|
||||
<>
|
||||
<IconBtn icon={<HelpIcon />} label="Help" />
|
||||
<IconBtn icon={<BellIcon />} label="Notifications" />
|
||||
<IconBtn icon={<DotsIcon />} label="More options" />
|
||||
<Avatar initials="DW" size="lg" />
|
||||
</>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'Top level (no menu)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={
|
||||
<div className="flex size-14 items-center justify-center">
|
||||
<NswLogo />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const MenuClosed: Story = {
|
||||
name: 'Top level (menu closed)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={<IconBtn icon={<MenuIcon />} label="Open menu" />}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const MenuOpen: Story = {
|
||||
name: 'Top level (menu open)',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={<IconBtn icon={<CloseIcon />} label="Close menu" />}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const ChildLevel: Story = {
|
||||
name: 'Child level',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="PDP Guide"
|
||||
leading={<IconBtn icon={<BackIcon />} label="Go back" />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
export const FullscreenDialog: Story = {
|
||||
name: 'Fullscreen dialog',
|
||||
render: () => (
|
||||
<TopBar
|
||||
title="Edit PDP"
|
||||
leading={<IconBtn icon={<CloseIcon />} label="Close" />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 rounded-full bg-blue-04 px-6 py-2.5 text-body font-bold text-primary-dark transition-colors hover:bg-blue-04/80"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</TopBar>
|
||||
),
|
||||
}
|
||||
|
||||
const InteractiveTemplate = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TopBar
|
||||
title="Performance and development plan"
|
||||
leading={
|
||||
<IconBtn
|
||||
icon={menuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
/>
|
||||
}
|
||||
logo={<NswLogo />}
|
||||
>
|
||||
{trailingActions}
|
||||
</TopBar>
|
||||
<div className="p-4 text-small text-text-secondary">
|
||||
Menu is {menuOpen ? 'open' : 'closed'} — click the hamburger/close icon to toggle
|
||||
</div>
|
||||
{/* Invisible click handler since IconBtn doesn't take onClick */}
|
||||
<button
|
||||
className="fixed left-3.5 top-0 z-10 size-12 opacity-0"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
name: 'Interactive menu toggle',
|
||||
render: () => <InteractiveTemplate />,
|
||||
}
|
||||
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TopBarProps extends HTMLAttributes<HTMLElement> {
|
||||
title: string
|
||||
leading?: ReactNode
|
||||
logo?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const TopBar = forwardRef<HTMLElement, TopBarProps>(
|
||||
({ title, leading, logo, className, children, ...props }, ref) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-16 w-full items-center bg-topbar',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leading && (
|
||||
<div className="flex shrink-0 items-center pl-3.5">
|
||||
{leading}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 pl-5">
|
||||
{logo && <div className="shrink-0">{logo}</div>}
|
||||
<h1 className="truncate text-h4 font-bold leading-7 text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="flex shrink-0 items-center pr-2.5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
},
|
||||
)
|
||||
TopBar.displayName = 'TopBar'
|
||||
2
src/components/organisms/TopBar/index.ts
Normal file
2
src/components/organisms/TopBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TopBar } from './TopBar'
|
||||
export type { TopBarProps } from './TopBar'
|
||||
@@ -12,3 +12,19 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-h1-responsive: 2.25rem;
|
||||
--text-h2-responsive: 1.75rem;
|
||||
--text-h3-responsive: 1.375rem;
|
||||
--text-h4-responsive: 1.125rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--text-h1-responsive: 3rem;
|
||||
--text-h2-responsive: 2rem;
|
||||
--text-h3-responsive: 1.5rem;
|
||||
--text-h4-responsive: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
line-height: var(--text-body--line-height);
|
||||
font-weight: 700;
|
||||
text-decoration-line: underline;
|
||||
color: var(--color-blue-02);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
@utility text-small-link {
|
||||
@@ -37,5 +37,35 @@
|
||||
line-height: var(--text-small--line-height);
|
||||
font-weight: 700;
|
||||
text-decoration-line: underline;
|
||||
color: var(--color-blue-02);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
@utility text-button {
|
||||
font-size: var(--text-body);
|
||||
line-height: calc(19 / 16);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@utility text-h1-responsive {
|
||||
font-size: var(--text-h1-responsive);
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@utility text-h2-responsive {
|
||||
font-size: var(--text-h2-responsive);
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@utility text-h3-responsive {
|
||||
font-size: var(--text-h3-responsive);
|
||||
line-height: 1.333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@utility text-h4-responsive {
|
||||
font-size: var(--text-h4-responsive);
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -27,47 +27,76 @@
|
||||
/* Blues */
|
||||
--color-blue-01: #002664;
|
||||
--color-blue-02: #146CFD;
|
||||
--color-blue-03: #69B3E7;
|
||||
--color-blue-03: #8CE0FF;
|
||||
--color-blue-04: #CBEDFD;
|
||||
--color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */
|
||||
|
||||
/* Reds */
|
||||
--color-red-01: #3E0014;
|
||||
--color-red-01: #630019;
|
||||
--color-red-02: #D7153A;
|
||||
--color-red-03: #F5C5D0;
|
||||
--color-red-04: #FDDDE5;
|
||||
--color-red-05: #FFF5F8; /* extrapolated: ultra-light background */
|
||||
--color-red-03: #FFB8C1;
|
||||
--color-red-04: #FFE6EA;
|
||||
|
||||
/* Oranges */
|
||||
--color-orange-01: #7A3300; /* extrapolated: dark */
|
||||
--color-orange-02: #EC6608;
|
||||
--color-orange-03: #F5B98A;
|
||||
--color-orange-04: #FEF0E4; /* extrapolated: light background */
|
||||
--color-orange-01: #941B00;
|
||||
--color-orange-02: #F3631B;
|
||||
--color-orange-03: #FFCE99;
|
||||
--color-orange-04: #FDEDDF;
|
||||
|
||||
/* Greens */
|
||||
--color-green-01: #005C35; /* extrapolated: dark */
|
||||
--color-green-02: #00A651;
|
||||
--color-green-03: #89E5B3;
|
||||
--color-green-04: #E0F8EA; /* extrapolated: light background */
|
||||
--color-green-01: #004000;
|
||||
--color-green-02: #00AA45;
|
||||
--color-green-03: #A8EDB3;
|
||||
--color-green-04: #DBFADF;
|
||||
|
||||
/* Teals */
|
||||
--color-teal-01: #0B3F47;
|
||||
--color-teal-02: #2E808E;
|
||||
--color-teal-03: #8CDBE5;
|
||||
--color-teal-04: #D1EEEA;
|
||||
|
||||
/* Browns */
|
||||
--color-brown-01: #523719;
|
||||
--color-brown-02: #B68D5D;
|
||||
--color-brown-03: #E8D0B5;
|
||||
--color-brown-04: #EDE3D7;
|
||||
|
||||
/* Purples */
|
||||
--color-purple-01: #441170;
|
||||
--color-purple-02: #8055F1;
|
||||
--color-purple-03: #CEBFFF;
|
||||
--color-purple-04: #E6E1FD;
|
||||
|
||||
/* Fuchsias */
|
||||
--color-fuchsia-01: #65004D;
|
||||
--color-fuchsia-02: #D912AE;
|
||||
--color-fuchsia-03: #F4B5E6;
|
||||
--color-fuchsia-04: #FDDEF2;
|
||||
|
||||
/* Yellows */
|
||||
--color-yellow-01: #694800;
|
||||
--color-yellow-02: #FAAF05;
|
||||
--color-yellow-03: #FDE79A;
|
||||
--color-yellow-04: #FFF4CF;
|
||||
|
||||
/* Greys */
|
||||
--color-grey-01: #22272B;
|
||||
--color-grey-02: #6D7278;
|
||||
--color-grey-03: #C0C0C0;
|
||||
--color-grey-04: #E0E0E0;
|
||||
--color-off-white: #F4F4F4;
|
||||
--color-grey-02: #495054;
|
||||
--color-grey-03: #CDD3D6;
|
||||
--color-grey-04: #EBEBEB;
|
||||
--color-grey-05: #F2F2F2;
|
||||
--color-white: #FFFFFF;
|
||||
|
||||
/* Semantic Aliases */
|
||||
--color-primary: var(--color-blue-02);
|
||||
--color-primary-dark: var(--color-blue-01);
|
||||
--color-primary: var(--color-blue-01);
|
||||
--color-info: var(--color-blue-02);
|
||||
--color-secondary: var(--color-blue-04);
|
||||
--color-error: var(--color-red-02);
|
||||
--color-success: var(--color-green-02);
|
||||
--color-warning: var(--color-orange-02);
|
||||
--color-text: var(--color-grey-01);
|
||||
--color-text-secondary: var(--color-grey-02);
|
||||
--color-border: var(--color-grey-04);
|
||||
--color-bg: var(--color-off-white);
|
||||
--color-bg: var(--color-grey-05);
|
||||
--color-surface: var(--color-white);
|
||||
|
||||
/* Form Controls */
|
||||
@@ -80,7 +109,7 @@
|
||||
--color-control-description: var(--color-grey-02);
|
||||
--color-control-error: var(--color-red-02);
|
||||
--color-control-bg: var(--color-white);
|
||||
--color-control-bg-readonly: var(--color-off-white);
|
||||
--color-control-bg-readonly: var(--color-grey-05);
|
||||
|
||||
/* Button */
|
||||
--color-button-default: var(--color-blue-01);
|
||||
@@ -89,6 +118,10 @@
|
||||
--color-button-subtle-bg: var(--color-blue-04);
|
||||
--color-button-subtle-text: var(--color-blue-01);
|
||||
|
||||
/* Switch */
|
||||
--color-switch-on: var(--color-success);
|
||||
--color-switch-on-hover: var(--color-green-01);
|
||||
|
||||
/* Badge */
|
||||
--color-badge-navy: var(--color-blue-01);
|
||||
--color-badge-info: var(--color-blue-02);
|
||||
@@ -124,29 +157,53 @@
|
||||
--color-tag-orange-light: var(--color-orange-04);
|
||||
--color-tag-grey: var(--color-grey-02);
|
||||
--color-tag-grey-light: var(--color-grey-04);
|
||||
--color-tag-teal: var(--color-teal-01);
|
||||
--color-tag-teal-light: var(--color-teal-04);
|
||||
--color-tag-brown: var(--color-brown-01);
|
||||
--color-tag-brown-light: var(--color-brown-04);
|
||||
--color-tag-purple: var(--color-purple-01);
|
||||
--color-tag-purple-light: var(--color-purple-04);
|
||||
--color-tag-fuchsia: var(--color-fuchsia-01);
|
||||
--color-tag-fuchsia-light: var(--color-fuchsia-04);
|
||||
--color-tag-yellow: var(--color-yellow-01);
|
||||
--color-tag-yellow-light: var(--color-yellow-04);
|
||||
|
||||
/* Avatar */
|
||||
--color-avatar: var(--color-blue-04);
|
||||
--color-avatar-text: var(--color-grey-01);
|
||||
|
||||
/* TopBar */
|
||||
--color-topbar: var(--color-blue-01);
|
||||
|
||||
/* SideNav */
|
||||
--color-nav-bg: var(--color-white);
|
||||
--color-nav-text: var(--color-grey-01);
|
||||
--color-nav-icon: var(--color-blue-01);
|
||||
--color-nav-active: var(--color-info);
|
||||
--color-nav-divider: var(--color-grey-03);
|
||||
|
||||
/* Alert */
|
||||
--color-alert-info-bg: var(--color-blue-05);
|
||||
--color-alert-info-bg: var(--color-blue-04);
|
||||
--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-bg: var(--color-red-04);
|
||||
--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-bg: var(--color-grey-05);
|
||||
--color-alert-neutral-border: var(--color-grey-03);
|
||||
--color-alert-neutral-icon: var(--color-blue-01);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-default: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 16px;
|
||||
--radius-default: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
|
||||
Reference in New Issue
Block a user