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>
244 lines
6.8 KiB
TypeScript
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'
|