Files
ADS3-Design-System/src/components/molecules/Accordion/Accordion.tsx
Richie d915443b8c 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>
2026-06-03 14:24:23 +10:00

244 lines
6.8 KiB
TypeScript

import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useMemo,
useState,
type HTMLAttributes,
type ReactNode,
} from 'react'
import { cn } from '@/lib/utils'
const ChevronIcon = () => (
<svg
xmlns="http://www.w3.org/2000/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>
)
// --- Context ---
interface AccordionContextValue {
openItems: Set<string>
toggle: (value: string) => void
}
const AccordionContext = createContext<AccordionContextValue | null>(null)
function useAccordionContext() {
const ctx = useContext(AccordionContext)
if (!ctx) throw new Error('Accordion components must be used within an Accordion')
return ctx
}
interface AccordionItemContextValue {
value: string
isOpen: boolean
triggerId: string
contentId: string
disabled: boolean
}
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null)
function useAccordionItemContext() {
const ctx = useContext(AccordionItemContext)
if (!ctx) throw new Error('AccordionItem sub-components must be used within an AccordionItem')
return ctx
}
// --- Accordion ---
export interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
type?: 'single' | 'multiple'
collapsible?: boolean
defaultValue?: string | string[]
value?: string | string[]
onValueChange?: (value: string | string[]) => void
}
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(
(
{ type = 'single', collapsible = false, defaultValue, value, onValueChange, className, children, ...props },
ref,
) => {
const [internalOpen, setInternalOpen] = useState<Set<string>>(() => {
if (defaultValue) return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue])
return new Set()
})
const isControlled = value !== undefined
const openItems = isControlled
? new Set(Array.isArray(value) ? value : value ? [value] : [])
: internalOpen
const toggle = useCallback(
(itemValue: string) => {
const compute = (prev: Set<string>): Set<string> => {
const next = new Set(prev)
if (next.has(itemValue)) {
if (type === 'single' && !collapsible) return prev
next.delete(itemValue)
} else {
if (type === 'single') next.clear()
next.add(itemValue)
}
return next
}
if (isControlled) {
const next = compute(openItems)
if (next !== openItems) {
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
}
} else {
setInternalOpen((prev) => {
const next = compute(prev)
if (next !== prev) {
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
}
return next
})
}
},
[type, collapsible, isControlled, openItems, onValueChange],
)
const contextValue = useMemo(() => ({ openItems, toggle }), [openItems, toggle])
return (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
className={cn('overflow-hidden rounded-xl bg-surface', className)}
{...props}
>
{children}
</div>
</AccordionContext.Provider>
)
},
)
Accordion.displayName = 'Accordion'
// --- AccordionItem ---
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
value: string
disabled?: boolean
}
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, disabled = false, className, children, ...props }, ref) => {
const { openItems } = useAccordionContext()
const isOpen = openItems.has(value)
const id = useId()
const triggerId = `${id}-trigger`
const contentId = `${id}-content`
const itemContext = useMemo(
() => ({ value, isOpen, triggerId, contentId, disabled }),
[value, isOpen, triggerId, contentId, disabled],
)
return (
<AccordionItemContext.Provider value={itemContext}>
<div
ref={ref}
data-state={isOpen ? 'open' : 'closed'}
className={cn('border-b border-border last:border-b-0', className)}
{...props}
>
{children}
</div>
</AccordionItemContext.Provider>
)
},
)
AccordionItem.displayName = 'AccordionItem'
// --- AccordionTrigger ---
export interface AccordionTriggerProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'children'> {
icon?: ReactNode
children: ReactNode
}
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ icon, className, children, ...props }, ref) => {
const { toggle } = useAccordionContext()
const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext()
return (
<button
ref={ref}
id={triggerId}
type="button"
aria-expanded={isOpen}
aria-controls={contentId}
disabled={disabled}
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-info',
isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5',
disabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
>
{icon && <span className="size-6 shrink-0 [&>svg]:size-full">{icon}</span>}
<span className="flex-1">{children}</span>
<span
className={cn(
'size-6 shrink-0 transition-transform duration-200 [&>svg]:size-full',
isOpen && 'rotate-180',
)}
>
<ChevronIcon />
</span>
</button>
)
},
)
AccordionTrigger.displayName = 'AccordionTrigger'
// --- AccordionContent ---
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {}
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
({ className, children, ...props }, ref) => {
const { isOpen, triggerId, contentId } = useAccordionItemContext()
return (
<div
ref={ref}
id={contentId}
role="region"
aria-labelledby={triggerId}
aria-hidden={!isOpen}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
{...props}
>
<div className="min-h-0 overflow-hidden">
<div className={cn('px-6 pb-4 pt-2 text-text-secondary', className)}>{children}</div>
</div>
</div>
)
},
)
AccordionContent.displayName = 'AccordionContent'