Add Accordion, Textarea, Select, and Card components; fix Input focus shift
New components: - Accordion: compound component with single/multiple mode, grid-rows animation - Textarea: multi-line input with auto-resize, character count, outlined/stacked variants - Select: custom dropdown with keyboard navigation, combobox/listbox ARIA pattern - Card: 4 variants (surface/outlined/elevated/filled) with header action support Changes: - Fix Input/Textarea focus ring layout shift (ring-1 instead of border-2) - Add small/xsmall sizes to IconButton for card action contexts - Add --radius-xl token (16px) for larger container corners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
src/components/ui/Accordion/Accordion.stories.tsx
Normal file
224
src/components/ui/Accordion/Accordion.stories.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { FileText, Settings, Users, Shield, Bell, HelpCircle } from 'lucide-react'
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion'
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: 'UI/Accordion',
|
||||
component: Accordion,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['single', 'multiple'],
|
||||
},
|
||||
collapsible: { control: 'boolean' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=100-1366',
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-xl">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// --- Default ---
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>What is this design system?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
A React component library built for the Research Synthesiser, following NSW Design System
|
||||
patterns with custom tokens and Tailwind CSS v4.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>How are tokens structured?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Tokens are organised in layers: palette (raw values), semantic (purpose-based aliases), and
|
||||
domain-specific tokens for components like buttons, badges, and chips.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Can I customise the components?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. All components accept className overrides and forward refs. Style customisation is done
|
||||
through Tailwind utilities and the design token layer.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Multiple ---
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<Accordion type="multiple" defaultValue={['item-1', 'item-3']}>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>First section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Multiple items can be open simultaneously when type is set to "multiple".
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Second section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Click any header to toggle it independently of the others.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Third section</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
This item is also open by default, demonstrating multiple default values.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With Icons ---
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="documents">
|
||||
<AccordionTrigger icon={<FileText />}>Documents</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Manage your uploaded documents, PDFs, and research papers.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="team">
|
||||
<AccordionTrigger icon={<Users />}>Team members</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
View and manage team members who have access to this project.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="settings">
|
||||
<AccordionTrigger icon={<Settings />}>Settings</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Configure project settings, notifications, and integrations.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="security">
|
||||
<AccordionTrigger icon={<Shield />}>Security</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Manage access controls, permissions, and audit logs.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Disabled ---
|
||||
|
||||
export const DisabledItem: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Available section</AccordionTrigger>
|
||||
<AccordionContent>This section is interactive and can be toggled.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2" disabled>
|
||||
<AccordionTrigger>Disabled section</AccordionTrigger>
|
||||
<AccordionContent>This content cannot be revealed.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Another available section</AccordionTrigger>
|
||||
<AccordionContent>This section is also interactive.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Single non-collapsible ---
|
||||
|
||||
export const SingleNonCollapsible: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Always one open</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
In single non-collapsible mode, one item is always open. Clicking the active header
|
||||
does nothing — you must click a different header to switch.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Click me to switch</AccordionTrigger>
|
||||
<AccordionContent>Now this item is open and the previous one closed.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Or click me</AccordionTrigger>
|
||||
<AccordionContent>Each click opens one and closes the other.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Rich content ---
|
||||
|
||||
export const RichContent: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="notifications">
|
||||
<AccordionTrigger icon={<Bell />}>Notification preferences</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>Choose how you would like to be notified about project updates:</p>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
<li>Email digests (daily or weekly)</li>
|
||||
<li>In-app notifications</li>
|
||||
<li>Browser push notifications</li>
|
||||
</ul>
|
||||
<p className="text-small text-text-secondary">
|
||||
You can change these settings at any time from your profile.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="faq">
|
||||
<AccordionTrigger icon={<HelpCircle />}>Frequently asked questions</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="font-bold text-text">How do I export my data?</p>
|
||||
<p>Navigate to Settings → Export and choose your preferred format (CSV, JSON, or PDF).</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-text">Is my data encrypted?</p>
|
||||
<p>Yes. All data is encrypted at rest and in transit using AES-256.</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Many items ---
|
||||
|
||||
export const ManyItems: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<AccordionItem key={i} value={`item-${i}`}>
|
||||
<AccordionTrigger>Section {i + 1}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Content for section {i + 1}. Each item expands independently in single collapsible mode.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
243
src/components/ui/Accordion/Accordion.tsx
Normal file
243
src/components/ui/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'
|
||||
7
src/components/ui/Accordion/index.ts
Normal file
7
src/components/ui/Accordion/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion'
|
||||
export type {
|
||||
AccordionProps,
|
||||
AccordionItemProps,
|
||||
AccordionTriggerProps,
|
||||
AccordionContentProps,
|
||||
} from './Accordion'
|
||||
257
src/components/ui/Card/Card.stories.tsx
Normal file
257
src/components/ui/Card/Card.stories.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { ClipboardList, BookOpen, Info, ExternalLink } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'UI/Card',
|
||||
component: Card,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['surface', 'outlined', 'elevated', 'filled'],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-lg">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card title</CardTitle>
|
||||
<CardDescription>A short description of the card content.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">
|
||||
This is the card body. It can contain any content — text, lists, forms, or other
|
||||
components.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Surface: Story = {
|
||||
render: () => (
|
||||
<Card variant="surface">
|
||||
<CardHeader>
|
||||
<CardTitle>Surface</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Default variant with border and subtle shadow.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Outlined: Story = {
|
||||
render: () => (
|
||||
<Card variant="outlined">
|
||||
<CardHeader>
|
||||
<CardTitle>Outlined</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Border only, no shadow. Good for less prominent cards.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Elevated: Story = {
|
||||
render: () => (
|
||||
<Card variant="elevated">
|
||||
<CardHeader>
|
||||
<CardTitle>Elevated</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">Shadow only, no border. Creates a floating effect.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
render: () => (
|
||||
<Card variant="filled">
|
||||
<CardHeader>
|
||||
<CardTitle>Professional pathway</CardTitle>
|
||||
<CardDescription className="text-white/70">
|
||||
Track your progress through each stage.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-body text-white/90">
|
||||
Dark filled variant for featured or highlighted content sections.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With header action ---
|
||||
|
||||
export const WithHeaderAction: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
|
||||
<Info className="size-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="size-5 text-primary-dark" />
|
||||
<CardTitle>Steps to be taken</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Apply and verify WWCC
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Fill a Registration Form
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Complete compliance modules
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- With footer ---
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mandatory Training Reminders</CardTitle>
|
||||
<CardDescription>
|
||||
Please consult the training hub for role-specific training requirements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-lg bg-bg px-3 py-2 text-small">
|
||||
<span className="text-text">Aboriginal Cultural Education</span>
|
||||
<span className="font-bold text-success">Certified</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-lg border border-primary-dark px-3 py-1.5 text-small font-bold text-primary-dark hover:bg-primary/5">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Go to myPL
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Minimal ---
|
||||
|
||||
export const ContentOnly: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-body text-text">
|
||||
A card with just content — no header or footer. Useful as a simple container.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Related information ---
|
||||
|
||||
export const RelatedInformation: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<button className="rounded-full p-1 text-text-secondary hover:bg-primary/5">
|
||||
<Info className="size-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-5 text-primary-dark" />
|
||||
<CardTitle>Related information</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Visit the Beginning Teacher Information Hub
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Apply for a role with DoE
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-3 text-body text-text">
|
||||
Start your accreditation journey
|
||||
<span className="text-text-secondary">›</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
// --- All variants ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card variant="surface">
|
||||
<CardHeader>
|
||||
<CardTitle>Surface</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Border + subtle shadow</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="outlined">
|
||||
<CardHeader>
|
||||
<CardTitle>Outlined</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Border only</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="elevated">
|
||||
<CardHeader>
|
||||
<CardTitle>Elevated</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-text-secondary">Shadow only</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="filled">
|
||||
<CardHeader>
|
||||
<CardTitle>Filled</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-small text-white/70">Dark background, white text</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
102
src/components/ui/Card/Card.tsx
Normal file
102
src/components/ui/Card/Card.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Card ---
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'surface' | 'outlined' | 'elevated' | 'filled'
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ variant = 'surface', className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-xl', variantStyles[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
// --- CardHeader ---
|
||||
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ action, className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-start gap-3 px-6 pt-6', action && 'justify-between', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
// --- CardTitle ---
|
||||
|
||||
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>
|
||||
|
||||
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-h5 font-bold leading-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
// --- CardDescription ---
|
||||
|
||||
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-small text-text-secondary', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
// --- CardContent ---
|
||||
|
||||
export type CardContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('px-6 py-4', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
// --- CardFooter ---
|
||||
|
||||
export type CardFooterProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-3 px-6 pb-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
9
src/components/ui/Card/index.ts
Normal file
9
src/components/ui/Card/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
||||
export type {
|
||||
CardProps,
|
||||
CardHeaderProps,
|
||||
CardTitleProps,
|
||||
CardDescriptionProps,
|
||||
CardContentProps,
|
||||
CardFooterProps,
|
||||
} from './Card'
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight } from 'lucide-react'
|
||||
import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight, MoreVertical, Copy, Maximize2 } from 'lucide-react'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
@@ -17,7 +17,7 @@ const meta: Meta<typeof IconButton> = {
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['large', 'default', 'compact'],
|
||||
options: ['large', 'default', 'compact', 'small', 'xsmall'],
|
||||
},
|
||||
shape: {
|
||||
control: 'select',
|
||||
@@ -76,6 +76,19 @@ export const AllSizes: Story = {
|
||||
<IconButton size="large" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="default" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="compact" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="small" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="xsmall" icon={<X />} aria-label="Close" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CardActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-surface p-4">
|
||||
<span className="mr-auto text-small font-bold text-text">Card title</span>
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Copy />} aria-label="Copy" />
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<Maximize2 />} aria-label="Expand" />
|
||||
<IconButton size="small" variant="tertiary" intent="neutral" icon={<MoreVertical />} aria-label="More options" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary'
|
||||
intent?: 'default' | 'danger' | 'neutral'
|
||||
size?: 'default' | 'large' | 'compact'
|
||||
size?: 'default' | 'large' | 'compact' | 'small' | 'xsmall'
|
||||
shape?: 'circle' | 'square'
|
||||
icon: ReactNode
|
||||
'aria-label': string
|
||||
@@ -35,12 +35,16 @@ const sizeStyles: Record<string, string> = {
|
||||
large: 'size-14',
|
||||
default: 'size-12',
|
||||
compact: 'size-10',
|
||||
small: 'size-8',
|
||||
xsmall: 'size-6',
|
||||
}
|
||||
|
||||
const iconSizeStyles: Record<string, string> = {
|
||||
large: 'size-6',
|
||||
default: 'size-6',
|
||||
compact: 'size-[18px]',
|
||||
small: 'size-4',
|
||||
xsmall: 'size-3.5',
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
|
||||
@@ -96,8 +96,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
'relative flex items-center rounded-[4px] border bg-control-bg px-3 transition-colors',
|
||||
styles.container,
|
||||
hasError
|
||||
? 'border-control-error focus-within:border-2 focus-within:border-control-error focus-within:px-[11px]'
|
||||
: 'border-control-border hover:border-control-border-hover focus-within:border-2 focus-within:border-control-checked focus-within:px-[11px]',
|
||||
? 'border-control-error focus-within:ring-1 focus-within:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked',
|
||||
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
||||
readOnly && 'border-transparent bg-control-bg-readonly',
|
||||
)}
|
||||
|
||||
209
src/components/ui/Select/Select.stories.tsx
Normal file
209
src/components/ui/Select/Select.stories.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Select } from './Select'
|
||||
|
||||
const yearLevels = [
|
||||
{ value: 'kindergarten', label: 'Kindergarten' },
|
||||
{ value: 'year-1', label: 'Year 1' },
|
||||
{ value: 'year-2', label: 'Year 2' },
|
||||
{ value: 'year-3', label: 'Year 3' },
|
||||
{ value: 'year-4', label: 'Year 4' },
|
||||
{ value: 'year-5', label: 'Year 5' },
|
||||
{ value: 'year-6', label: 'Year 6' },
|
||||
]
|
||||
|
||||
const categories = [
|
||||
{ value: 'wellbeing', label: 'Student wellbeing' },
|
||||
{ value: 'engagement', label: 'Engagement' },
|
||||
{ value: 'curriculum', label: 'Curriculum design' },
|
||||
{ value: 'assessment', label: 'Assessment practices' },
|
||||
{ value: 'inclusion', label: 'Inclusion & diversity' },
|
||||
]
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'UI/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['outlined', 'stacked'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
error: { control: 'text' },
|
||||
hint: { control: 'text' },
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-sm">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
placeholder: 'Choose a year level...',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Research category',
|
||||
hint: 'Choose the primary focus area for this project',
|
||||
options: categories,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
error: 'Please select a year level',
|
||||
options: yearLevels,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDisabledOptions: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: [
|
||||
{ value: 'kindergarten', label: 'Kindergarten' },
|
||||
{ value: 'year-1', label: 'Year 1' },
|
||||
{ value: 'year-2', label: 'Year 2', disabled: true },
|
||||
{ value: 'year-3', label: 'Year 3' },
|
||||
{ value: 'year-4', label: 'Year 4', disabled: true },
|
||||
{ value: 'year-5', label: 'Year 5' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
options: yearLevels,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PreselectedExample = () => {
|
||||
const [value, setValue] = useState('year-2')
|
||||
return (
|
||||
<Select
|
||||
label="Year level"
|
||||
options={yearLevels}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Preselected: Story = {
|
||||
render: () => <PreselectedExample />,
|
||||
}
|
||||
|
||||
const ControlledExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="Year level"
|
||||
options={yearLevels}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<p className="text-small text-text-secondary">
|
||||
Selected: {value || '(none)'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => <ControlledExample />,
|
||||
}
|
||||
|
||||
// --- Stacked variant ---
|
||||
|
||||
export const Stacked: Story = {
|
||||
args: {
|
||||
label: 'Research category',
|
||||
placeholder: 'Choose a category...',
|
||||
options: categories,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithDescription: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
description: 'Select the year level this research applies to.',
|
||||
options: yearLevels,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithError: Story = {
|
||||
args: {
|
||||
label: 'Year level',
|
||||
description: 'Select the year level this research applies to.',
|
||||
error: 'A year level is required',
|
||||
options: yearLevels,
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
// --- All states ---
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Select label="Default" options={yearLevels} />
|
||||
<Select label="With hint" options={yearLevels} hint="Helpful hint text" />
|
||||
<Select label="With error" options={yearLevels} error="This field is required" />
|
||||
<Select label="Disabled" options={yearLevels} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StackedAllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Select variant="stacked" label="Default" options={yearLevels} />
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="With description"
|
||||
description="A short description of the field."
|
||||
options={yearLevels}
|
||||
/>
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="With hint"
|
||||
description="Description text here."
|
||||
options={yearLevels}
|
||||
hint="Helpful hint text"
|
||||
/>
|
||||
<Select
|
||||
variant="stacked"
|
||||
label="Error"
|
||||
description="Description text here."
|
||||
options={yearLevels}
|
||||
error="This field is required"
|
||||
/>
|
||||
<Select variant="stacked" label="Disabled" options={yearLevels} disabled />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
360
src/components/ui/Select/Select.tsx
Normal file
360
src/components/ui/Select/Select.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
variant?: 'outlined' | 'stacked'
|
||||
placeholder?: string
|
||||
options: SelectOption[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
variant = 'outlined',
|
||||
placeholder = 'Select',
|
||||
options,
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
id: idProp,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const autoId = useId()
|
||||
const id = idProp ?? autoId
|
||||
const triggerId = `${id}-trigger`
|
||||
const listboxId = `${id}-listbox`
|
||||
const descriptionId = `${id}-description`
|
||||
const hintId = `${id}-hint`
|
||||
const hasError = !!error
|
||||
const supportiveText = error || hint
|
||||
const isStacked = variant === 'stacked'
|
||||
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
|
||||
const selectedValue = isControlled ? controlledValue : internalValue
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const listboxRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const selectedOption = options.find((o) => o.value === selectedValue)
|
||||
|
||||
const selectableOptions = options.filter((o) => !o.disabled)
|
||||
|
||||
const describedBy =
|
||||
[
|
||||
description && isStacked ? descriptionId : undefined,
|
||||
supportiveText ? hintId : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
|
||||
const selectOption = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (!isControlled) setInternalValue(optionValue)
|
||||
onChange?.(optionValue)
|
||||
setIsOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
},
|
||||
[isControlled, onChange],
|
||||
)
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (disabled) return
|
||||
setIsOpen(true)
|
||||
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
|
||||
setActiveIndex(idx >= 0 ? idx : selectableOptions.length > 0 ? options.indexOf(selectableOptions[0]) : -1)
|
||||
}, [disabled, options, selectedValue, selectableOptions])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setActiveIndex(-1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (!triggerRef.current?.contains(target) && !listboxRef.current?.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, close])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeIndex < 0) return
|
||||
const activeEl = listboxRef.current?.children[activeIndex] as HTMLElement | undefined
|
||||
activeEl?.scrollIntoView({ block: 'nearest' })
|
||||
}, [isOpen, activeIndex])
|
||||
|
||||
const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
open()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
open()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const findNextIndex = (from: number, direction: 1 | -1): number => {
|
||||
let idx = from + direction
|
||||
while (idx >= 0 && idx < options.length) {
|
||||
if (!options[idx].disabled) return idx
|
||||
idx += direction
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
const handleListboxKeyDown = (e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => findNextIndex(prev, 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActiveIndex((prev) => findNextIndex(prev, -1))
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setActiveIndex(options.findIndex((o) => !o.disabled))
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
for (let i = options.length - 1; i >= 0; i--) {
|
||||
if (!options[i].disabled) {
|
||||
setActiveIndex(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (activeIndex >= 0 && !options[activeIndex].disabled) {
|
||||
selectOption(options[activeIndex].value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
e.preventDefault()
|
||||
close()
|
||||
triggerRef.current?.focus()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{isStacked && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label
|
||||
id={`${id}-label`}
|
||||
htmlFor={triggerId}
|
||||
className={cn(
|
||||
'text-small font-bold',
|
||||
hasError ? 'text-control-error' : 'text-control-label',
|
||||
disabled && 'text-control-description',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={listboxId}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={hasError || undefined}
|
||||
aria-labelledby={isStacked ? `${id}-label` : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => (isOpen ? close() : open())}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
className={cn(
|
||||
'flex h-12 w-full items-center rounded-[4px] border bg-control-bg px-3 text-left transition-colors',
|
||||
hasError
|
||||
? 'border-control-error focus:ring-1 focus:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus:border-control-checked focus:ring-1 focus:ring-control-checked',
|
||||
isOpen &&
|
||||
(hasError
|
||||
? 'ring-1 ring-control-error'
|
||||
: 'border-control-checked ring-1 ring-control-checked'),
|
||||
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
||||
)}
|
||||
>
|
||||
{!isStacked && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
|
||||
hasError ? 'text-control-error' : 'text-control-label',
|
||||
disabled && 'text-control-description',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('flex-1 truncate text-body', selectedOption ? 'text-text' : 'text-text/50')}>
|
||||
{selectedOption?.label ?? placeholder}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={isStacked ? `${id}-label` : triggerId}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListboxKeyDown}
|
||||
onFocus={() => {
|
||||
if (activeIndex < 0) {
|
||||
const idx = options.findIndex((o) => o.value === selectedValue && !o.disabled)
|
||||
setActiveIndex(idx >= 0 ? idx : options.findIndex((o) => !o.disabled))
|
||||
}
|
||||
}}
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-border bg-surface py-1 shadow-md"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`${id}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={option.value === selectedValue}
|
||||
aria-disabled={option.disabled || undefined}
|
||||
onMouseEnter={() => !option.disabled && setActiveIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (!option.disabled) selectOption(option.value)
|
||||
}}
|
||||
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.disabled && 'pointer-events-none text-text/30',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{supportiveText && (
|
||||
<div
|
||||
id={hintId}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-small',
|
||||
hasError ? 'text-control-error' : 'text-control-description',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{hasError && (
|
||||
<svg
|
||||
className="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)}
|
||||
<p className="flex-1">{supportiveText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
2
src/components/ui/Select/index.ts
Normal file
2
src/components/ui/Select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Select } from './Select'
|
||||
export type { SelectProps, SelectOption } from './Select'
|
||||
218
src/components/ui/Textarea/Textarea.stories.tsx
Normal file
218
src/components/ui/Textarea/Textarea.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: 'UI/Textarea',
|
||||
component: Textarea,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
hint: { control: 'text' },
|
||||
error: { control: 'text' },
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['outlined', 'stacked'],
|
||||
},
|
||||
resize: {
|
||||
control: 'select',
|
||||
options: ['vertical', 'horizontal', 'both', 'none'],
|
||||
},
|
||||
autoResize: { control: 'boolean' },
|
||||
rows: { control: 'number' },
|
||||
disabled: { control: 'boolean' },
|
||||
readOnly: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Description',
|
||||
placeholder: 'Enter a description...',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Research notes',
|
||||
placeholder: 'Record your observations...',
|
||||
hint: 'Include participant quotes where possible',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
label: 'Summary',
|
||||
defaultValue:
|
||||
'Participants expressed a strong preference for visual feedback during task completion. Several noted that the absence of confirmation messages led to uncertainty about whether their input had been saved.',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Findings',
|
||||
defaultValue: 'Too short.',
|
||||
error: 'Please provide at least 50 characters',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Description',
|
||||
placeholder: 'Enter a description...',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledWithValue: Story = {
|
||||
args: {
|
||||
label: 'Previous notes',
|
||||
defaultValue: 'This field has been locked after review submission.',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
label: 'Submitted feedback',
|
||||
defaultValue:
|
||||
'The onboarding flow was intuitive and participants completed all tasks without assistance. Average completion time was 4 minutes.',
|
||||
readOnly: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const NoResize: Story = {
|
||||
args: {
|
||||
label: 'Comment',
|
||||
placeholder: 'Add a comment...',
|
||||
resize: 'none',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
const CharacterCountExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<Textarea
|
||||
label="Theme description"
|
||||
placeholder="Describe the theme in detail..."
|
||||
hint="Be specific about participant sentiment"
|
||||
maxLength={500}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCharacterCount: Story = {
|
||||
render: () => <CharacterCountExample />,
|
||||
}
|
||||
|
||||
const AutoResizeExample = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<Textarea
|
||||
label="Auto-resizing notes"
|
||||
placeholder="Start typing — the field grows as you type..."
|
||||
autoResize
|
||||
rows={2}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AutoResize: Story = {
|
||||
render: () => <AutoResizeExample />,
|
||||
}
|
||||
|
||||
// --- Stacked variant ---
|
||||
|
||||
export const Stacked: Story = {
|
||||
args: {
|
||||
label: 'Research notes',
|
||||
placeholder: 'Record your observations...',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithDescription: Story = {
|
||||
args: {
|
||||
label: 'Theme summary',
|
||||
description: 'Summarise the key findings for this theme.',
|
||||
placeholder: 'e.g. Participants consistently reported...',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
export const StackedWithError: Story = {
|
||||
args: {
|
||||
label: 'Analysis notes',
|
||||
description: 'Provide your interpretation of the data.',
|
||||
defaultValue: 'N/A',
|
||||
error: 'Analysis notes are required for theme completion',
|
||||
variant: 'stacked',
|
||||
},
|
||||
}
|
||||
|
||||
// --- All states ---
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex max-w-md flex-col gap-6">
|
||||
<Textarea label="Default" placeholder="Placeholder" />
|
||||
<Textarea label="With hint" placeholder="Placeholder" hint="Helpful hint text" />
|
||||
<Textarea
|
||||
label="With value"
|
||||
defaultValue="Participants noted that the notification system was effective but could benefit from customisable frequency settings."
|
||||
/>
|
||||
<Textarea label="Error" defaultValue="Bad value" error="This field is required" />
|
||||
<Textarea label="Disabled" placeholder="Placeholder" disabled />
|
||||
<Textarea label="Disabled with value" defaultValue="Locked content" disabled />
|
||||
<Textarea label="Read only" defaultValue="Submitted content" readOnly />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StackedAllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex max-w-md flex-col gap-6">
|
||||
<Textarea variant="stacked" label="Default" placeholder="Placeholder" />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="With description"
|
||||
description="A short description of the field."
|
||||
placeholder="Placeholder"
|
||||
/>
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="With hint"
|
||||
description="Description text here."
|
||||
placeholder="Placeholder"
|
||||
hint="Helpful hint text"
|
||||
/>
|
||||
<Textarea variant="stacked" label="With value" defaultValue="Some content entered by the user" />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="Error"
|
||||
description="Description text here."
|
||||
defaultValue="Bad value"
|
||||
error="This field is required"
|
||||
/>
|
||||
<Textarea variant="stacked" label="Disabled" placeholder="Placeholder" disabled />
|
||||
<Textarea
|
||||
variant="stacked"
|
||||
label="Disabled with description"
|
||||
description="This field cannot be edited."
|
||||
defaultValue="Locked content"
|
||||
disabled
|
||||
/>
|
||||
<Textarea variant="stacked" label="Read only" defaultValue="Submitted content" readOnly />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
197
src/components/ui/Textarea/Textarea.tsx
Normal file
197
src/components/ui/Textarea/Textarea.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { forwardRef, useId, useCallback, useRef, useEffect, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string
|
||||
description?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
variant?: 'outlined' | 'stacked'
|
||||
resize?: 'vertical' | 'horizontal' | 'both' | 'none'
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
hint,
|
||||
error,
|
||||
variant = 'outlined',
|
||||
resize = 'vertical',
|
||||
autoResize = false,
|
||||
disabled,
|
||||
readOnly,
|
||||
maxLength,
|
||||
value,
|
||||
defaultValue,
|
||||
rows = 3,
|
||||
className,
|
||||
id: idProp,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const autoId = useId()
|
||||
const id = idProp ?? autoId
|
||||
const descriptionId = `${id}-description`
|
||||
const hintId = `${id}-hint`
|
||||
const hasError = !!error
|
||||
const supportiveText = error || hint
|
||||
const isStacked = variant === 'stacked'
|
||||
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const currentLength =
|
||||
maxLength != null && typeof value === 'string' ? value.length : undefined
|
||||
|
||||
const describedBy =
|
||||
[
|
||||
description && isStacked ? descriptionId : undefined,
|
||||
supportiveText ? hintId : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
|
||||
const resizeClass: Record<string, string> = {
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize',
|
||||
none: 'resize-none',
|
||||
}
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = internalRef.current
|
||||
if (!el || !autoResize) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [adjustHeight, value])
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node
|
||||
if (typeof ref === 'function') ref(node)
|
||||
else if (ref) ref.current = node
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
|
||||
{isStacked && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-small font-bold',
|
||||
hasError ? 'text-control-error' : 'text-control-label',
|
||||
disabled && 'text-control-description',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn('text-small text-text', disabled && 'opacity-50')}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex rounded-[4px] border bg-control-bg transition-colors',
|
||||
hasError
|
||||
? 'border-control-error focus-within:ring-1 focus-within:ring-control-error'
|
||||
: 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked',
|
||||
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
||||
readOnly && 'border-transparent bg-control-bg-readonly',
|
||||
)}
|
||||
>
|
||||
{!isStacked && (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
|
||||
hasError ? 'text-control-error' : 'text-control-label',
|
||||
disabled && 'text-control-description',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={setRefs}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
rows={rows}
|
||||
aria-invalid={hasError || undefined}
|
||||
aria-describedby={describedBy}
|
||||
onChange={(e) => {
|
||||
onChange?.(e)
|
||||
adjustHeight()
|
||||
}}
|
||||
className={cn(
|
||||
'w-full bg-transparent px-3 py-3 text-body font-normal text-text outline-none',
|
||||
'placeholder:text-text/50',
|
||||
autoResize ? 'resize-none overflow-hidden' : resizeClass[resize],
|
||||
disabled && 'text-text/50',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(supportiveText || currentLength != null) && (
|
||||
<div
|
||||
id={hintId}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-small',
|
||||
hasError ? 'text-control-error' : 'text-control-description',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{hasError && (
|
||||
<svg
|
||||
className="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)}
|
||||
{supportiveText && <p className="flex-1">{supportiveText}</p>}
|
||||
{currentLength != null && (
|
||||
<p className="shrink-0 text-right">
|
||||
{currentLength}/{maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
2
src/components/ui/Textarea/index.ts
Normal file
2
src/components/ui/Textarea/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Textarea } from './Textarea'
|
||||
export type { TextareaProps } from './Textarea'
|
||||
@@ -115,6 +115,7 @@
|
||||
--radius-sm: 4px;
|
||||
--radius-default: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
|
||||
Reference in New Issue
Block a user