Reorganise components into atoms/molecules/organisms and fix Input icon colours
Moved all 17 components from ui/ into atomic design tiers: atoms (Button, IconButton, Input, Textarea, Select, Checkbox, Radio, Switch, Badge, Tag, Chip, Tooltip) and molecules (Alert, Accordion, Card, Dialog, Popover). Updated all Storybook titles and cross-component imports. Changed Input icons to primary-dark and replaced palette token references with semantic tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
243
src/components/molecules/Accordion/Accordion.tsx
Normal file
243
src/components/molecules/Accordion/Accordion.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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-primary',
|
||||
isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/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'
|
||||
Reference in New Issue
Block a user