feat: ai workflow improved, initial structure of alpha components done

This commit is contained in:
Junior Garcia
2025-07-13 12:48:41 -03:00
parent 13af5cb1d6
commit c208be1b09
67 changed files with 6426 additions and 40 deletions
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable no-console */
// .claude/hooks.mjs
import {execSync} from "child_process";
import path from "path";
// Hook that runs before editing files
export async function preEdit({filePath}) {
// Check if editing TypeScript/JavaScript files
if (filePath.match(/\.(ts|tsx|js|jsx)$/)) {
// Ensure file is properly formatted before edit
try {
execSync(`pnpm prettier --check "${filePath}"`, {stdio: "pipe"});
} catch (e) {
console.log("⚠️ File needs formatting - will format after edit");
}
}
// Prevent editing of certain protected files
const protectedFiles = ["yarn.lock", "package-lock.json", ".env.production", "firebase.json"];
const fileName = path.basename(filePath);
if (protectedFiles.includes(fileName)) {
throw new Error(`❌ Cannot edit protected file: ${fileName}`);
}
return {proceed: true};
}
// Hook that runs after editing files
export async function postEdit({filePath, success}) {
if (!success) return;
// Run linting and auto-fix on TypeScript/JavaScript files
if (filePath.match(/\.(ts|tsx|js|jsx)$/)) {
try {
execSync(`pnpm lint --fix "${filePath}"`, {stdio: "pipe"});
console.log("✅ Lint fixes applied");
} catch (e) {
console.log("⚠️ Lint errors detected - please review");
}
}
// Run type checking on TypeScript files
if (filePath.match(/\.(ts|tsx)$/)) {
try {
execSync(`npx tsc --noEmit --skipLibCheck "${filePath}"`, {stdio: "pipe"});
} catch (e) {
console.log("⚠️ TypeScript errors detected - please review");
}
}
}
+240 -17
View File
@@ -113,30 +113,106 @@ Each component in `packages/react/src/components/` follows this structure:
component-name/
├── component-name.tsx # Main component (uses React Aria)
├── component-name.styles.ts # Tailwind Variants styling
├── component-name.stories.tsx # Storybook stories
├── component-name.stories.tsx # Storybook stories (title: "Components/ComponentName")
└── index.ts # Barrel exports
```
### Key Patterns
**IMPORTANT**: All Storybook stories must use the "Components" group in their title. For example: `title: "Components/Card"`, `title: "Components/Button"`, etc.
### Core Component Design Principles
**IMPORTANT**: HeroUI v3 follows a compound component pattern similar to Radix UI, built on top of React Aria Components primitives. This enables maximum flexibility and customization for users.
### React Aria Components Integration
**CRITICAL**: Before implementing any component, you MUST:
1. Visit React Aria Components docs: https://react-spectrum.adobe.com/react-aria/
2. Study the specific component's API and examples
3. Understand its accessibility features and ARIA patterns
4. Plan the transformation from React Aria's prop-based API to Radix UI's composition-based API
React Aria provides the accessibility foundation, but we transform their API to match Radix UI's compound component pattern for better customization.
#### 1. **Compound Component Pattern**:
- Export all internal component pieces (Root, Item, Trigger, Content, etc.)
- Each piece can be styled and composed independently
- Users can customize render logic without accessing internal code
- Examples: Accordion (Root, Item, Heading, Trigger, Panel, Indicator, Body), Alert (Root, Icon, Title, Description, Action, Close)
#### 2. **Export Strategy**:
```typescript
// Named exports for compound components
export * as ComponentName from "./component-name";
// Direct exports for simple components
export {Component, type ComponentProps} from "./component";
// Always export variants
export {componentVariants, type ComponentVariants} from "./component.styles";
```
#### 3. **Component Structure for Compound Components**:
```typescript
// Context for sharing state/styles
const ComponentContext = createContext<{slots?: ReturnType<typeof componentVariants>}>({});
// Root component wraps with context
const ComponentRoot = React.forwardRef<...>(({children, className, ...props}, ref) => {
const slots = React.useMemo(() => componentVariants({...}), [...]);
return (
<ComponentContext.Provider value={{slots}}>
<ReactAriaComponent ref={ref} className={composeTwRenderProps(className, slots.base())}>
{children}
</ReactAriaComponent>
</ComponentContext.Provider>
);
});
// Child components consume context
const ComponentItem = React.forwardRef<...>(({className, ...props}, ref) => {
const {slots} = useContext(ComponentContext);
return (
<ReactAriaComponent ref={ref} className={composeTwRenderProps(className, slots?.item())}>
{props.children}
</ReactAriaComponent>
);
});
// Export pattern
export {ComponentRoot as Root, ComponentItem as Item, ...};
```
#### 4. **Key Implementation Details**:
1. **Styling with Tailwind Variants**:
- Styles defined in `.styles.ts` files using `tv()` function
- Styles defined in `.styles.ts` files using `tv()` function from `tailwind-variants`
- **IMPORTANT**: Always import from `tailwind-variants`, never from `@heroui/standard` (which doesn't exist)
- **CRITICAL**: tailwind-variants already includes `twMerge` functionality, so NEVER manually use `twMerge`
- **RULE**: All component styles MUST be defined in separate `.styles.ts` files, NOT in the component implementation files
- Component implementation files (`.tsx`) should only contain logic and React Aria primitives
- Example imports:
```typescript
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
```
- Support for variants (primary, secondary, etc.)
- Compound variants for conditional styling
- Slot system for complex components
2. **Component Implementation**:
2. **Component Features**:
- Built on React Aria Components for accessibility
- Use `forwardRef` for ref forwarding
- Display names follow: `HeroUI.ComponentName`
- Support `asChild` prop pattern when applicable
- Use `forwardRef` for all components
- Display names follow: `HeroUI.ComponentName` or `HeroUI.Component.SubPart`
- Support `asChild` prop pattern when applicable (using Radix UI's Slot)
- Support render props from React Aria when available
3. **Type Exports**:
```typescript
export type ComponentProps = {...}
// Export props for each component part
export type ComponentRootProps = {...}
export type ComponentItemProps = {...}
export type ComponentVariants = VariantProps<typeof componentVariants>
```
@@ -144,6 +220,88 @@ component-name/
- `composeTwRenderProps`: Merge Tailwind classes with render props
- `focusRingClasses`: Consistent focus styling
- `disabledClasses`: Disabled state styling
- `mapPropsVariants`: Separate variant props from component props
- `objectToDeps`: Convert objects to dependency arrays for memoization
5. **React Aria Components className Patterns**:
**CRITICAL**: React Aria components have different className prop behaviors:
**Components that support render props** (use `composeTwRenderProps`):
- Button, TextField, FieldError, Checkbox, CheckboxGroup
- Switch, RadioGroup, Radio, Slider (and Track, Thumb, Output)
- Popover, Tooltip, Tabs (and Tab, TabList, TabPanel)
- Link, Menu, MenuItem, Accordion (DisclosureGroup)
**Components that ONLY accept string className** (pass className directly):
- Label, Text, Input, TextArea
- Heading, Dialog, OverlayArrow
**Usage examples**:
```typescript
// For render prop components - use composeTwRenderProps
<ButtonPrimitive
className={composeTwRenderProps(className, slots?.button())}
/>
// For string-only components - pass className directly
<LabelPrimitive
className={slots?.label({className})}
/>
// OR
<LabelPrimitive
className={labelVariants({size, variant, className})}
/>
```
**How to check**: If unsure, check the React Aria docs or try both approaches - TypeScript will error if a component doesn't support render props
6. **Composition Pattern with Existing Components**:
**CRITICAL**: HeroUI follows a composition-based approach. Components should reuse existing primitives rather than creating component-specific versions.
**Key Principles**:
- **DO NOT** create component-specific Label, Description, or FieldError components
- **DO** reuse the existing `Label`, `Description`, and `FieldError` components
- **DO** use standard HTML composition patterns with `htmlFor`/`id` attributes
**Example Pattern**:
```typescript
// ❌ WRONG - Component-specific label
export const Checkbox = {
Root: CheckboxRoot,
Label: CheckboxLabel, // Don't create this!
};
// ✅ CORRECT - Compose with existing components
import { Label } from "@/components/label";
import { Description } from "@/components/description";
// Usage:
<div className="flex items-center gap-3">
<Checkbox.Root id="terms">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="terms">Accept terms</Label>
</div>
// With description:
<div className="flex gap-3">
<Checkbox.Root className="mt-0.5" id="notifications">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="notifications">Email notifications</Label>
<Description>Get notified when someone mentions you</Description>
</div>
</div>
```
**Components that follow this pattern**:
- Checkbox - uses external Label/Description
- Radio - uses external Label/Description
- Switch - uses external Label/Description
- TextField - provides slots for Label/Description/FieldError
### Current Components
@@ -151,23 +309,64 @@ component-name/
- `alert`: Alert messages with compound components
- `avatar`: User avatars with Radix UI
- `button`: Button with variants and sizes
- `checkbox`: Checkbox with compound components (uses external Label/Description)
- `chip`: Small informational badges
- `description`: Description text for form fields
- `field-error`: Error messages for form fields
- `fieldset`: Form field grouping components (Fieldset, Legend, FieldGroup, Field, CheckboxField)
- `label`: Label text for form fields
- `link`: Styled anchor links
- `menu`: Dropdown menu system
- `popover`: Popover overlays
- `spinner`: Loading indicators
- `tabs`: Tab navigation
- `text`: Text component for paragraphs and general text
- `text-field`: Text input field with compound components
- `tooltip`: Hover tooltips
## Development Workflow
1. **Creating/Modifying Components**:
1. **Creating New Components**:
- Follow existing component patterns
- Use React Aria Components for accessibility
- Define styles in separate `.styles.ts` file
- Create Storybook stories for testing
- Add "use client" directive for Next.js compatibility
**CRITICAL: Research & Design Phase**:
- **FIRST**: Check the Figma design for the component breakdown (e.g., Menu Container, Menu Item, etc.)
- **SECOND**: Research the React Aria Components documentation at https://react-spectrum.adobe.com/react-aria/
- Find the appropriate React Aria primitive (e.g., CheckboxGroup, Dialog, Select, etc.)
- Understand the React Aria API, props, and accessibility features
- Map Figma component pieces to React Aria components and plan the compound structure
- Plan how to adapt it to follow Radix UI's compound component pattern
**Implementation Steps**:
- Study existing HeroUI components (accordion, alert) to understand the compound pattern
- Use React Aria Components as the foundation for accessibility
- Transform React Aria's API to match Radix UI patterns:
- Single component → Multiple exported parts (Root, Item, Trigger, Content, etc.)
- Props-based API → Composition-based API
- Internal state → Context-based state sharing
- Create Context for sharing styles across component parts
- Export ALL component parts for maximum customization
- Define styles in separate `.styles.ts` file with slot system
- Support `asChild` prop where it makes sense (using Radix UI's Slot)
- Add "use client" directive at the top of component file
- Create comprehensive Storybook stories showing all variants and compositions
- Follow the export pattern: `export * as ComponentName from "./component-name"`
**Example Transformation**:
```typescript
// React Aria: Single component with props
<CheckboxGroup label="Options" value={selected} onChange={setSelected}>
<Checkbox value="1">Option 1</Checkbox>
</CheckboxGroup>
// HeroUI: Compound pattern
<CheckboxGroup.Root value={selected} onValueChange={setSelected}>
<CheckboxGroup.Label>Options</CheckboxGroup.Label>
<CheckboxGroup.Item value="1">
<CheckboxGroup.Indicator />
<CheckboxGroup.Label>Option 1</CheckboxGroup.Label>
</CheckboxGroup.Item>
</CheckboxGroup.Root>
```
2. **Testing**:
@@ -194,3 +393,27 @@ component-name/
- Maintain TypeScript type safety
- Use the commit convention to avoid git hook failures
- Run lint and type checks before committing: `pnpm lint && pnpm typecheck`
## Figma Integration & MCP Server Rules
### Figma Dev Mode MCP Server
**IMPORTANT**: When creating components with Figma designs:
1. **Component Breakdown**: Figma designs are already broken down into component pieces (e.g., Menu Container, Menu Item, etc.). Use these as reference for:
- Component structure and naming (adapt to code conventions)
- Visual styling and spacing
- Component composition patterns
2. **MCP Server Rules**:
- The Figma Dev Mode MCP Server provides an assets endpoint for images and SVG assets
- **CRITICAL**: If the Figma MCP Server returns a localhost source for an image or SVG, use that source directly
- **DO NOT** import or add new icon packages - all assets should come from the Figma payload
- **DO NOT** use or create placeholders if a localhost source is provided
- Always use the actual assets from Figma MCP Server
3. **Workflow**:
- Check Figma for component visual design and breakdown
- Map Figma component names to appropriate React Aria primitives
- Use Figma assets (icons, images) directly from the MCP Server
- Implement styles based on Figma design tokens and specifications
+1
View File
@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
+5 -1
View File
@@ -25,6 +25,10 @@
"lint:docs": "turbo lint --filter=@heroui/docs",
"lint:react": "turbo lint --filter=@heroui/react",
"lint:storybook": "turbo lint --filter=@heroui/storybook",
"typecheck": "turbo typecheck",
"typecheck:docs": "turbo typecheck --filter=@heroui/docs",
"typecheck:react": "turbo typecheck --filter=@heroui/react",
"typecheck:storybook": "turbo typecheck --filter=@heroui/storybook",
"changeset:canary": "changeset pre enter canary",
"changeset:beta": "changeset pre enter beta",
"version": "changeset version",
@@ -78,7 +82,7 @@
"vitest": "3.1.2"
},
"engines": {
"node": ">=22.x",
"node": ">=20.x",
"pnpm": ">=10.x"
},
"packageManager": "pnpm@10.9.0"
+8 -1
View File
@@ -1,6 +1,13 @@
import baseReactConfig from "@heroui/standard/eslint/react.mjs";
import {defineConfig} from "eslint/config";
const config = defineConfig([...baseReactConfig]);
const config = defineConfig([
...baseReactConfig,
{
rules: {
"react-hooks/rules-of-hooks": "off",
},
},
]);
export default config;
+27
View File
@@ -37,6 +37,11 @@
--accent-soft: var(--color-neutral-200);
--accent-soft-foreground: var(--color-neutral-700);
/* Surface Levels */
--surface-1: oklch(100% 0 0);
--surface-2: var(--color-neutral-50);
--surface-3: var(--color-neutral-100);
/* Status Colors */
--success: oklch(0.55 0.1241 153.51);
--success-foreground: oklch(99.11% 0 0);
@@ -94,6 +99,11 @@
--accent-soft: var(--color-neutral-800);
--accent-soft-foreground: var(--color-neutral-200);
/* Surface Levels */
--surface-1: oklch(0.22 0 0);
--surface-2: var(--color-neutral-900);
--surface-3: var(--color-neutral-800);
/* Status Colors */
--success: oklch(0.8 0.1561 154);
--success-foreground: oklch(0.1 0.02 0);
@@ -155,6 +165,11 @@
--color-danger: var(--danger);
--color-danger-foreground: var(--danger-foreground);
/* Surface Levels */
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--shadow-border: var(--shadow-border);
/* Calculated Variables */
@@ -227,3 +242,15 @@
font-size: var(--font-size-base);
}
}
@layer utilities {
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
.animate-blink {
animation: blink 1s ease-in-out infinite;
}
}
+4 -1
View File
@@ -32,11 +32,14 @@
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"lint": "eslint ."
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@internationalized/date": "3.8.2",
"@radix-ui/react-avatar": "1.1.7",
"@radix-ui/react-slot": "1.2.3",
"input-otp": "1.4.2",
"react-aria-components": "1.8.0",
"tailwind-merge": "3.0.2",
"tailwind-variants": "1.0.0",
@@ -18,7 +18,7 @@ export const accordionVariants = tv({
/* Focus State */
focusRingClasses,
/* Base Styles */
"hover:bg-base duration-50 flex flex-1 items-center justify-between px-4 py-4 text-left font-medium transition-[background-color]",
"hover:bg-base duration-50 flex flex-1 items-center justify-between px-4 py-4 text-left font-medium transition-[background-color] cursor-pointer",
/* Expanded State */
"[&[aria-expanded=true]_[data-accordion-indicator]]:-rotate-180",
/* Disabled State */
@@ -12,6 +12,7 @@ export const alertVariants = tv({
action: [
"select-none rounded-lg px-3.5 py-2 text-sm font-medium",
"transition-colors duration-150",
"cursor-pointer",
focusRingClasses,
disabledClasses,
],
@@ -25,6 +26,7 @@ export const alertVariants = tv({
"transition-colors duration-150",
"hover:bg-base active:bg-base data-[pressed]:bg-base",
"hover:text-foreground active:text-foreground data-[pressed]:text-foreground",
"cursor-pointer",
focusRingClasses,
disabledClasses,
],
@@ -1,5 +1,3 @@
/* eslint-disable sort-keys */
/* eslint-disable sort-keys-fix/sort-keys-fix */
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
@@ -16,6 +16,8 @@ export const buttonVariants = tv({
"font-medium",
// sizing
"px-[calc(--spacing(4)-1px)]",
// cursor
"cursor-pointer",
// icon
"[&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:my-0.5 [&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:self-center sm:[&_svg]:my-1 sm:[&_svg]:size-4",
// pending
@@ -0,0 +1,169 @@
import type {Meta, StoryObj} from "@storybook/react";
import {getLocalTimeZone, parseDate, today} from "@internationalized/date";
import React from "react";
import {Calendar} from "./calendar";
const meta: Meta<typeof Calendar.Root> = {
title: "Components/Calendar",
component: Calendar.Root,
parameters: {
layout: "centered",
},
argTypes: {
isDisabled: {
control: "boolean",
},
},
};
export default meta;
type Story = StoryObj<typeof Calendar.Root>;
export const Default: Story = {
render: (args) => (
<Calendar.Root {...args} aria-label="Event date">
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
),
};
export const ControlledValue: Story = {
render: (args) => {
const [value, setValue] = React.useState(today(getLocalTimeZone()));
return (
<div className="flex flex-col gap-4">
<Calendar.Root
{...args}
aria-label="Event date"
value={value}
onChange={(newValue) => setValue(newValue as any)}
>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
<p className="text-muted-foreground text-sm">Selected date: {value.toString()}</p>
</div>
);
},
};
export const MinMaxDates: Story = {
render: (args) => (
<Calendar.Root
{...args}
aria-label="Event date"
maxValue={today(getLocalTimeZone()).add({months: 1})}
minValue={today(getLocalTimeZone())}
>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
),
};
export const UnavailableDates: Story = {
render: (args) => {
const now = today(getLocalTimeZone());
const isWeekend = (date: any) => {
const dayOfWeek = date.toDate(getLocalTimeZone()).getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
};
return (
<Calendar.Root {...args} aria-label="Event date" isDateUnavailable={isWeekend} minValue={now}>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
);
},
};
export const Disabled: Story = {
args: {
isDisabled: true,
},
render: (args) => (
<Calendar.Root {...args} aria-label="Event date" defaultValue={today(getLocalTimeZone())}>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
),
};
export const MultipleMonths: Story = {
render: (args) => (
<div className="flex gap-4">
<Calendar.Root {...args} aria-label="Event date" visibleDuration={{months: 2}}>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<div className="flex gap-4">
<Calendar.Grid />
<Calendar.Grid offset={{months: 1}} />
</div>
</Calendar.Root>
</div>
),
};
export const CustomDayNames: Story = {
render: (args) => (
<Calendar.Root {...args} aria-label="Event date">
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day.slice(0, 1)}</Calendar.HeaderCell>}
</Calendar.GridHeader>
</Calendar.Grid>
</Calendar.Root>
),
};
export const InitialFocus: Story = {
render: (args) => (
<Calendar.Root
{...args}
autoFocus
aria-label="Event date"
defaultValue={parseDate("2025-02-15")}
>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
),
};
@@ -0,0 +1,95 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {disabledClasses, focusRingClasses} from "../../utils";
export const calendarVariants = tv({
slots: {
base: ["flex flex-col", "bg-panel rounded-xl", "shadow-lg", "p-4", "w-[280px]"],
header: ["flex items-center justify-between", "mb-4", "px-1"],
heading: ["text-lg font-semibold", "text-foreground", "tracking-[-0.36px]"],
navButton: [
"rounded-lg",
"p-1.5",
"transition-all duration-200",
"hover:bg-base-hover",
"text-foreground",
"text-xl",
"leading-none",
"cursor-pointer",
focusRingClasses,
],
grid: ["w-full", "border-separate", "border-spacing-0"],
gridHeader: [],
headerCell: ["text-xs font-medium", "text-muted-foreground", "text-center", "pb-2", "h-8 w-9"],
cell: ["relative", "p-0", "text-center", "focus-within:z-10"],
cellButton: [
"group",
"relative",
"h-9 w-9",
"rounded-lg",
"text-sm",
"font-medium",
"transition-all duration-200",
"outline-none",
"cursor-pointer",
"data-[hovered]:bg-base-hover",
"data-[pressed]:scale-95",
focusRingClasses,
],
},
variants: {
isDisabled: {
true: {
base: disabledClasses,
navButton: disabledClasses,
},
},
variant: {
previous: {},
next: {},
},
isSelected: {
true: {
cellButton: [
"bg-accent text-accent-foreground",
"hover:bg-accent/90",
"data-[hovered]:bg-accent/90",
],
},
},
isHovered: {
true: {
cellButton: ["bg-base-hover"],
},
},
isUnavailable: {
true: {
cellButton: [
"text-muted-foreground/50",
"line-through",
"cursor-not-allowed",
"hover:bg-transparent",
],
},
},
isOutsideMonth: {
true: {
cellButton: ["text-muted-foreground/30", "hover:bg-transparent"],
},
},
},
compoundSlots: [
{
slots: ["cellButton"],
isDisabled: true,
class: [disabledClasses, "hover:bg-transparent"],
},
],
defaultVariants: {
isDisabled: false,
},
});
export type CalendarVariants = VariantProps<typeof calendarVariants>;
@@ -0,0 +1,281 @@
"use client";
import type {CalendarVariants} from "./calendar.styles";
import type {
ButtonProps as ButtonPrimitiveProps,
CalendarCellProps as CalendarCellPrimitiveProps,
CalendarGridHeaderProps as CalendarGridHeaderPrimitiveProps,
CalendarGridProps as CalendarGridPrimitiveProps,
CalendarHeaderCellProps as CalendarHeaderCellPrimitiveProps,
CalendarProps as CalendarPrimitiveProps,
DateValue,
HeadingProps as HeadingPrimitiveProps,
} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {
Button as ButtonPrimitive,
CalendarCell as CalendarCellPrimitive,
CalendarGridBody as CalendarGridBodyPrimitive,
CalendarGridHeader as CalendarGridHeaderPrimitive,
CalendarGrid as CalendarGridPrimitive,
CalendarHeaderCell as CalendarHeaderCellPrimitive,
Calendar as CalendarPrimitive,
Heading as HeadingPrimitive,
} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {calendarVariants} from "./calendar.styles";
/* -------------------------------------------------------------------------------------------------
* Calendar Context
* -----------------------------------------------------------------------------------------------*/
interface CalendarContext {
slots?: ReturnType<typeof calendarVariants>;
}
const CalendarContext = createContext<CalendarContext>({});
/* -------------------------------------------------------------------------------------------------
* Calendar
* -----------------------------------------------------------------------------------------------*/
interface CalendarRootProps<T extends DateValue = DateValue>
extends CalendarPrimitiveProps<T>,
CalendarVariants {}
function CalendarRootInner<T extends DateValue = DateValue>(
props: CalendarRootProps<T> & React.RefAttributes<HTMLDivElement>,
) {
const {children, className, isDisabled, ...rest} = props;
const slots = React.useMemo(() => calendarVariants({isDisabled}), [isDisabled]);
return (
<CalendarContext.Provider value={{slots}}>
<CalendarPrimitive
data-calendar
isDisabled={isDisabled}
{...rest}
className={composeTwRenderProps(className, slots.base())}
>
{children}
</CalendarPrimitive>
</CalendarContext.Provider>
);
}
const CalendarRoot = React.forwardRef(CalendarRootInner) as <T extends DateValue = DateValue>(
props: CalendarRootProps<T> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
// @ts-expect-error - displayName on generic component
CalendarRoot.displayName = "HeroUI.Calendar.Root";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarHeaderProps {
children?: React.ReactNode;
className?: string;
}
const CalendarHeader = React.forwardRef<HTMLDivElement, CalendarHeaderProps>(
({children, className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<header ref={ref} data-calendar-header className={slots?.header({className})} {...props}>
{children}
</header>
);
},
);
CalendarHeader.displayName = "HeroUI.Calendar.Header";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarHeadingProps extends HeadingPrimitiveProps {}
const CalendarHeading = React.forwardRef<HTMLHeadingElement, CalendarHeadingProps>(
({className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<HeadingPrimitive
ref={ref}
data-calendar-heading
{...props}
className={slots?.heading({className})}
/>
);
},
);
CalendarHeading.displayName = "HeroUI.Calendar.Heading";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarNavButtonProps extends ButtonPrimitiveProps {
slot?: "previous" | "next";
}
const CalendarNavButton = React.forwardRef<HTMLButtonElement, CalendarNavButtonProps>(
({children, className, slot, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<ButtonPrimitive
ref={ref}
data-calendar-nav-button
slot={slot}
{...props}
className={composeTwRenderProps(className, slots?.navButton())}
>
{children || (slot === "previous" ? "" : "")}
</ButtonPrimitive>
);
},
);
CalendarNavButton.displayName = "HeroUI.Calendar.NavButton";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarGridProps extends CalendarGridPrimitiveProps {}
const CalendarGrid = React.forwardRef<HTMLTableElement, CalendarGridProps>(
({className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<CalendarGridPrimitive
ref={ref}
data-calendar-grid
{...props}
className={slots?.grid({className})}
>
<CalendarGridHeader>
{(day) => <CalendarHeaderCell>{day}</CalendarHeaderCell>}
</CalendarGridHeader>
<CalendarGridBodyPrimitive>
{(date) => <CalendarCell date={date}>{date.day}</CalendarCell>}
</CalendarGridBodyPrimitive>
</CalendarGridPrimitive>
);
},
);
CalendarGrid.displayName = "HeroUI.Calendar.Grid";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarGridHeaderProps extends CalendarGridHeaderPrimitiveProps {}
const CalendarGridHeader = React.forwardRef<HTMLTableSectionElement, CalendarGridHeaderProps>(
({className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<CalendarGridHeaderPrimitive
ref={ref}
data-calendar-grid-header
{...props}
className={slots?.gridHeader({className})}
/>
);
},
);
CalendarGridHeader.displayName = "HeroUI.Calendar.GridHeader";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarHeaderCellProps extends CalendarHeaderCellPrimitiveProps {}
const CalendarHeaderCell = React.forwardRef<HTMLTableCellElement, CalendarHeaderCellProps>(
({className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<CalendarHeaderCellPrimitive
ref={ref}
data-calendar-header-cell
{...props}
className={slots?.headerCell({className})}
/>
);
},
);
CalendarHeaderCell.displayName = "HeroUI.Calendar.HeaderCell";
/* -----------------------------------------------------------------------------------------------*/
interface CalendarCellProps extends CalendarCellPrimitiveProps {}
const CalendarCell = React.forwardRef<HTMLTableCellElement, CalendarCellProps>(
({children, className, ...props}, ref) => {
const {slots} = useContext(CalendarContext);
return (
<CalendarCellPrimitive
ref={ref}
data-calendar-cell
{...props}
className={composeTwRenderProps(className, slots?.cell())}
>
{(values) => {
const {formattedDate, isDisabled, isHovered, isOutsideMonth, isSelected, isUnavailable} =
values;
return (
<div
className={slots?.cellButton({
isSelected,
isHovered,
isDisabled,
isUnavailable,
isOutsideMonth,
})}
>
{typeof children === "function" ? children(values) : children || formattedDate}
</div>
);
}}
</CalendarCellPrimitive>
);
},
);
CalendarCell.displayName = "HeroUI.Calendar.Cell";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Calendar = Object.assign(
{},
{
Root: CalendarRoot,
Header: CalendarHeader,
Heading: CalendarHeading,
NavButton: CalendarNavButton,
Grid: CalendarGrid,
GridHeader: CalendarGridHeader,
HeaderCell: CalendarHeaderCell,
Cell: CalendarCell,
},
);
export type {
CalendarRootProps,
CalendarHeaderProps,
CalendarHeadingProps,
CalendarNavButtonProps,
CalendarGridProps,
CalendarGridHeaderProps,
CalendarHeaderCellProps,
CalendarCellProps,
};
@@ -0,0 +1,12 @@
export * as Calendar from "./calendar";
export type {
CalendarRootProps,
CalendarHeaderProps,
CalendarHeadingProps,
CalendarNavButtonProps,
CalendarGridProps,
CalendarGridHeaderProps,
CalendarHeaderCellProps,
CalendarCellProps,
} from "./calendar";
export {calendarVariants, type CalendarVariants} from "./calendar.styles";
@@ -0,0 +1,212 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Button} from "../button";
import {Link} from "../link";
import {Card} from "./card";
const meta = {
title: "Components/Card",
component: Card.Root,
parameters: {
layout: "centered",
},
argTypes: {
surface: {
control: {type: "select"},
options: ["1", "2", "3"],
},
},
} satisfies Meta<typeof Card.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
className: "w-[400px]",
},
render: (args) => (
<Card.Root {...args}>
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Description>Card description goes here</Card.Description>
</Card.Header>
<Card.Content>
<p>This is the card content. You can add any content here.</p>
</Card.Content>
</Card.Root>
),
};
export const WithFooter: Story = {
args: {
className: "w-[400px]",
},
render: (args) => (
<Card.Root {...args}>
<Card.Header>
<Card.Title>Become an Acme Creator!</Card.Title>
<Card.Description>
Visit heroui.com to sign up today and start earning credits from your fans and followers.
</Card.Description>
</Card.Header>
<Card.Footer>
<Button>Call to action</Button>
</Card.Footer>
</Card.Root>
),
};
export const WithImage: Story = {
args: {
className: "w-[400px]",
},
render: (args) => (
<Card.Root {...args}>
<Card.Image
alt="Mountains"
className="h-[200px]"
src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=200&fit=crop"
/>
<Card.Header>
<Card.Title>Beautiful Mountains</Card.Title>
<Card.Description>
Explore the stunning mountain landscapes and breathtaking views.
</Card.Description>
</Card.Header>
</Card.Root>
),
};
export const LoginForm: Story = {
args: {
className: "w-[400px]",
},
render: (args) => (
<Card.Root {...args}>
<Card.Header>
<Card.Title>Login</Card.Title>
<Card.Description>Enter your credentials to access your account</Card.Description>
</Card.Header>
<Card.Content>
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium" htmlFor="email">
Email
</label>
<input
className="border-border bg-background focus:ring-focus rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2"
id="email"
placeholder="email@example.com"
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium" htmlFor="password">
Password
</label>
<input
className="border-border bg-background focus:ring-focus rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2"
id="password"
placeholder="••••••••"
type="password"
/>
</div>
</form>
</Card.Content>
<Card.Footer className="flex flex-col gap-2">
<Button className="w-full">Sign In</Button>
<Link className="text-center text-sm" href="#">
Forgot password?
</Link>
</Card.Footer>
</Card.Root>
),
};
export const SurfaceVariants: Story = {
render: () => (
<div className="flex flex-col gap-6">
<Card.Root className="w-[400px]" surface="1">
<Card.Header>
<Card.Title>Surface Level 1</Card.Title>
<Card.Description>This card uses surface level 1 (default)</Card.Description>
</Card.Header>
<Card.Content>
<p>Content goes here</p>
</Card.Content>
</Card.Root>
<Card.Root className="w-[400px]" surface="2">
<Card.Header>
<Card.Title>Surface Level 2</Card.Title>
<Card.Description>This card uses surface level 2</Card.Description>
</Card.Header>
<Card.Content>
<p>Content goes here</p>
</Card.Content>
</Card.Root>
<Card.Root className="w-[400px]" surface="3">
<Card.Header>
<Card.Title>Surface Level 3</Card.Title>
<Card.Description>This card uses surface level 3</Card.Description>
</Card.Header>
<Card.Content>
<p>Content goes here</p>
</Card.Content>
</Card.Root>
</div>
),
};
export const NestedCards: Story = {
render: () => (
<Card.Root className="w-[600px]">
<Card.Header>
<Card.Title>Parent Card</Card.Title>
<Card.Description>This card contains nested cards</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-4">
<Card.Root surface="2">
<Card.Header>
<Card.Title>Nested Card 1</Card.Title>
<Card.Description>This is a nested card with surface level 2</Card.Description>
</Card.Header>
</Card.Root>
<Card.Root surface="3">
<Card.Header>
<Card.Title>Nested Card 2</Card.Title>
<Card.Description>This is another nested card with surface level 3</Card.Description>
</Card.Header>
</Card.Root>
</Card.Content>
</Card.Root>
),
};
export const AsChild: Story = {
args: {
className: "w-[400px]",
},
render: (args) => (
<Card.Root {...args} asChild>
<article>
<Card.Header>
<Card.Title asChild>
<h2>Article Card</h2>
</Card.Title>
<Card.Description>
This card uses semantic HTML elements via the asChild prop
</Card.Description>
</Card.Header>
<Card.Content>
<p>The root element is an article tag, and the title is an h2 tag.</p>
</Card.Content>
</article>
</Card.Root>
),
};
@@ -0,0 +1,42 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
const cardVariants = tv({
slots: {
base: [
"relative",
"overflow-hidden",
"rounded-panel",
"border",
"border-border",
"bg-surface-1",
"shadow-border",
],
header: ["flex", "flex-col", "gap-1", "p-6"],
title: ["text-base", "font-medium", "leading-6", "text-foreground"],
description: ["text-sm", "text-muted", "leading-5"],
content: ["p-6", "pt-0"],
footer: ["flex", "items-center", "p-6", "pt-0"],
image: ["w-full", "object-cover"],
},
variants: {
surface: {
"1": {
base: "bg-surface-1",
},
"2": {
base: "bg-surface-2",
},
"3": {
base: "bg-surface-3",
},
},
},
defaultVariants: {
surface: "1",
},
});
export {cardVariants};
export type CardVariants = VariantProps<typeof cardVariants>;
+191
View File
@@ -0,0 +1,191 @@
"use client";
import type {CardVariants} from "./card.styles";
import {Slot} from "@radix-ui/react-slot";
import React, {createContext, useContext} from "react";
import {cardVariants} from "./card.styles";
/* -------------------------------------------------------------------------------------------------
* Card Context
* -----------------------------------------------------------------------------------------------*/
interface CardContext {
slots?: ReturnType<typeof cardVariants>;
}
const CardContext = createContext<CardContext>({});
/* -------------------------------------------------------------------------------------------------
* Card
* -----------------------------------------------------------------------------------------------*/
interface CardRootProps extends React.HTMLAttributes<HTMLDivElement>, CardVariants {
asChild?: boolean;
}
const CardRoot = React.forwardRef<HTMLDivElement, CardRootProps>(
({asChild = false, children, className, surface, ...props}, ref) => {
const slots = React.useMemo(() => cardVariants({surface}), [surface]);
const Comp = asChild ? Slot : "div";
return (
<CardContext.Provider value={{slots}}>
<Comp ref={ref} data-card className={slots.base({className})} {...props}>
{children}
</Comp>
</CardContext.Provider>
);
},
);
CardRoot.displayName = "HeroUI.Card.Root";
/* -------------------------------------------------------------------------------------------------
* CardHeader
* -----------------------------------------------------------------------------------------------*/
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "div";
return <Comp ref={ref} data-card-header className={slots?.header({className})} {...props} />;
},
);
CardHeader.displayName = "HeroUI.Card.Header";
/* -------------------------------------------------------------------------------------------------
* CardTitle
* -----------------------------------------------------------------------------------------------*/
interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
asChild?: boolean;
}
const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "h3";
return <Comp ref={ref} data-card-title className={slots?.title({className})} {...props} />;
},
);
CardTitle.displayName = "HeroUI.Card.Title";
/* -------------------------------------------------------------------------------------------------
* CardDescription
* -----------------------------------------------------------------------------------------------*/
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
asChild?: boolean;
}
const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "p";
return (
<Comp
ref={ref}
data-card-description
className={slots?.description({className})}
{...props}
/>
);
},
);
CardDescription.displayName = "HeroUI.Card.Description";
/* -------------------------------------------------------------------------------------------------
* CardContent
* -----------------------------------------------------------------------------------------------*/
interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "div";
return <Comp ref={ref} data-card-content className={slots?.content({className})} {...props} />;
},
);
CardContent.displayName = "HeroUI.Card.Content";
/* -------------------------------------------------------------------------------------------------
* CardFooter
* -----------------------------------------------------------------------------------------------*/
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "div";
return <Comp ref={ref} data-card-footer className={slots?.footer({className})} {...props} />;
},
);
CardFooter.displayName = "HeroUI.Card.Footer";
/* -------------------------------------------------------------------------------------------------
* CardImage
* -----------------------------------------------------------------------------------------------*/
interface CardImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
asChild?: boolean;
}
const CardImage = React.forwardRef<HTMLImageElement, CardImageProps>(
({asChild = false, className, ...props}, ref) => {
const {slots} = useContext(CardContext);
const Comp = asChild ? Slot : "img";
return <Comp ref={ref} data-card-image className={slots?.image({className})} {...props} />;
},
);
CardImage.displayName = "HeroUI.Card.Image";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Card = Object.assign(
{},
{
Root: CardRoot,
Header: CardHeader,
Title: CardTitle,
Description: CardDescription,
Content: CardContent,
Footer: CardFooter,
Image: CardImage,
},
);
export type {
CardRootProps,
CardHeaderProps,
CardTitleProps,
CardDescriptionProps,
CardContentProps,
CardFooterProps,
CardImageProps,
};
@@ -0,0 +1,11 @@
export * as Card from "./card";
export type {
CardRootProps,
CardHeaderProps,
CardTitleProps,
CardDescriptionProps,
CardContentProps,
CardFooterProps,
CardImageProps,
} from "./card";
export {cardVariants, type CardVariants} from "./card.styles";
@@ -0,0 +1,447 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Description} from "../description";
import {Field, FieldError} from "../fieldset";
import {Label} from "../label";
import {Checkbox, CheckboxGroup} from "./checkbox";
const meta: Meta<typeof CheckboxGroup.Root> = {
title: "Components/CheckboxGroup",
component: CheckboxGroup.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CheckboxGroup.Root>;
export const Default: Story = {
render: () => (
<CheckboxGroup.Root>
<Label>Favorite sports</Label>
<CheckboxGroup.Items>
<Field variant="checkbox">
<Checkbox.Root value="soccer">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Soccer</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="baseball">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Baseball</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="basketball">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Basketball</Label>
</Field>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const WithDescription: Story = {
render: () => (
<CheckboxGroup.Root>
<Label>Notifications</Label>
<Description>Choose how you want to be notified</Description>
<CheckboxGroup.Items>
<Field variant="checkbox">
<Checkbox.Root value="email">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Email</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="sms">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>SMS</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="push">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Push notifications</Label>
</Field>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const DefaultValue: Story = {
render: () => (
<CheckboxGroup.Root defaultValue={["email", "push"]}>
<Label>Communication preferences</Label>
<CheckboxGroup.Items>
<Field variant="checkbox">
<Checkbox.Root value="email">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Email updates</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="sms">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Text messages</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="push">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Push notifications</Label>
</Field>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const Controlled: Story = {
render: () => {
const [selected, setSelected] = React.useState<string[]>(["reading"]);
return (
<div className="flex flex-col gap-4">
<CheckboxGroup.Root value={selected} onChange={setSelected}>
<Label>Hobbies</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="reading" value="reading">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="reading">Reading</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="gaming" value="gaming">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="gaming">Gaming</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="traveling" value="traveling">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="traveling">Traveling</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="cooking" value="cooking">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="cooking">Cooking</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
<p className="text-muted-foreground text-sm">
Selected: {selected.length > 0 ? selected.join(", ") : "none"}
</p>
</div>
);
},
};
export const Horizontal: Story = {
render: () => (
<CheckboxGroup.Root orientation="horizontal">
<Label>Select features</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="feature1" value="feature1">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="feature1">Feature 1</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="feature2" value="feature2">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="feature2">Feature 2</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="feature3" value="feature3">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="feature3">Feature 3</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const Disabled: Story = {
render: () => (
<CheckboxGroup.Root isDisabled defaultValue={["option1"]}>
<Label>Disabled options</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="option1" value="option1">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="option1">Option 1</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="option2" value="option2">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="option2">Option 2</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="option3" value="option3">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="option3">Option 3</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const DisabledIndividual: Story = {
render: () => (
<CheckboxGroup.Root>
<Label>Mixed availability</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="available" value="available">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="available">Available option</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root isDisabled id="unavailable" value="unavailable">
<Checkbox.Indicator />
</Checkbox.Root>
<Label disabled htmlFor="unavailable">
Unavailable option
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root isDisabled id="coming-soon" value="coming-soon">
<Checkbox.Indicator />
</Checkbox.Root>
<Label disabled htmlFor="coming-soon">
Coming soon
</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const ReadOnly: Story = {
render: () => (
<CheckboxGroup.Root isReadOnly defaultValue={["agreed", "understood"]}>
<Label>Review only</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="agreed" value="agreed">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="agreed">Terms agreed</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="understood" value="understood">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="understood">Privacy policy understood</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="newsletter-readonly" value="newsletter">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="newsletter-readonly">Newsletter subscription</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const WithValidation: Story = {
render: () => {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
if (selected.length < 2) {
alert("Please select at least 2 options");
} else {
alert("Form submitted successfully!");
}
}}
>
<CheckboxGroup.Root isInvalid={selected.length < 2} value={selected} onChange={setSelected}>
<Label required>Select your interests (at least 2)</Label>
<Description>Choose topics you'd like to receive updates about</Description>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="technology" value="technology">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="technology">Technology</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="design" value="design">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="design">Design</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="business" value="business">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="business">Business</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="marketing" value="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="marketing">Marketing</Label>
</div>
</CheckboxGroup.Items>
<FieldError>Please select at least 2 interests</FieldError>
</CheckboxGroup.Root>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover rounded-md px-4 py-2"
type="submit"
>
Submit
</button>
</form>
);
},
};
export const ComplexLabels: Story = {
render: () => (
<CheckboxGroup.Root>
<Label>Subscription tiers</Label>
<CheckboxGroup.Items className="gap-4">
<div className="flex gap-3">
<Checkbox.Root className="mt-1" id="basic-tier" value="basic">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label className="font-medium" htmlFor="basic-tier">
Basic Plan
</Label>
<Description>Essential features for individuals</Description>
</div>
</div>
<div className="flex gap-3">
<Checkbox.Root className="mt-1" id="pro-tier" value="pro">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label className="font-medium" htmlFor="pro-tier">
Pro Plan
</Label>
<Description>Advanced features for professionals</Description>
</div>
</div>
<div className="flex gap-3">
<Checkbox.Root className="mt-1" id="enterprise-tier" value="enterprise">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label className="font-medium" htmlFor="enterprise-tier">
Enterprise Plan
</Label>
<Description>Custom solutions for large teams</Description>
</div>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
export const FormIntegration: Story = {
render: () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const values = formData.getAll("preferences");
alert(`Selected preferences: ${values.join(", ")}`);
};
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<CheckboxGroup.Root name="preferences">
<Label>Email preferences</Label>
<CheckboxGroup.Items>
<div className="flex items-center gap-3">
<Checkbox.Root id="updates" value="updates">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="updates">Product updates</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="newsletter-form" value="newsletter">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="newsletter-form">Newsletter</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="offers" value="offers">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="offers">Special offers</Label>
</div>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover rounded-md px-4 py-2"
type="submit"
>
Save preferences
</button>
</form>
);
},
};
export const CleanPattern: Story = {
render: () => (
<CheckboxGroup.Root defaultValue={["product"]}>
<Label>Email Preferences</Label>
<Description>Select the types of emails you'd like to receive</Description>
<CheckboxGroup.Items>
<Field variant="checkbox">
<Checkbox.Root value="product">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Product updates</Label>
<Description>New features and improvements</Description>
</div>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Marketing emails</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root value="security">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Security alerts</Label>
<Description>Important notifications about your account</Description>
</div>
</Field>
</CheckboxGroup.Items>
</CheckboxGroup.Root>
),
};
@@ -0,0 +1,537 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Description} from "../description";
import {Field, FieldGroup, Fieldset, Legend} from "../fieldset";
import {Label} from "../label";
import {Text} from "../text";
import {Checkbox} from "./checkbox";
const meta: Meta<typeof Checkbox.Root> = {
title: "Components/Checkbox",
component: Checkbox.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Checkbox.Root>;
export const Default: Story = {
render: () => (
<div className="flex items-center gap-3">
<Checkbox.Root id="subscribe">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="subscribe">Subscribe to newsletter</Label>
</div>
),
};
export const WithLabelAsChild: Story = {
render: () => (
<Checkbox.Root>
<Checkbox.Indicator />
<Label>Subscribe to newsletter</Label>
</Checkbox.Root>
),
};
export const WithLabelAndDescription: Story = {
render: () => (
<Checkbox.Root>
<Checkbox.Indicator />
<Label>Postal mail</Label>
<Description>Receive notifications via postal mail</Description>
</Checkbox.Root>
),
};
export const DesignStates: Story = {
render: () => (
<div className="flex flex-col gap-6">
<h3 className="text-sm font-medium">Design States Preview</h3>
<div className="grid grid-cols-6 items-center gap-4">
<div className="text-muted-foreground text-xs">Default</div>
<div className="text-muted-foreground text-xs">Hover</div>
<div className="text-muted-foreground text-xs">Pressed</div>
<div className="text-muted-foreground text-xs">Focus</div>
<div className="text-muted-foreground text-xs">Error</div>
<div className="text-muted-foreground text-xs">Disabled</div>
{/* Unselected row */}
<Checkbox.Root>
<Checkbox.Indicator />
</Checkbox.Root>
<div className="[&>*]:data-[hovered=true]:border-accent-hover">
<Checkbox.Root data-hovered>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[pressed=true]:scale-[0.97]">
<Checkbox.Root data-pressed>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[focus-visible=true]:border-2">
<Checkbox.Root data-focus-visible>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<Checkbox.Root isInvalid>
<Checkbox.Indicator />
</Checkbox.Root>
<Checkbox.Root isDisabled>
<Checkbox.Indicator />
</Checkbox.Root>
{/* Selected row */}
<Checkbox.Root defaultSelected>
<Checkbox.Indicator />
</Checkbox.Root>
<div className="[&>*]:data-[hovered=true]:bg-accent-hover">
<Checkbox.Root data-hovered defaultSelected>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[pressed=true]:scale-[0.97]">
<Checkbox.Root data-pressed defaultSelected>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[focus-visible=true]:border-2">
<Checkbox.Root data-focus-visible defaultSelected>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<Checkbox.Root defaultSelected isInvalid>
<Checkbox.Indicator />
</Checkbox.Root>
<Checkbox.Root defaultSelected isDisabled>
<Checkbox.Indicator />
</Checkbox.Root>
{/* Indeterminate row */}
<Checkbox.Root isIndeterminate>
<Checkbox.Indicator />
</Checkbox.Root>
<div className="[&>*]:data-[hovered=true]:bg-accent-hover">
<Checkbox.Root data-hovered isIndeterminate>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[pressed=true]:scale-[0.97]">
<Checkbox.Root data-pressed isIndeterminate>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<div className="[&>*]:data-[focus-visible=true]:border-2">
<Checkbox.Root data-focus-visible isIndeterminate>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
<Checkbox.Root isIndeterminate isInvalid>
<Checkbox.Indicator />
</Checkbox.Root>
<Checkbox.Root isDisabled isIndeterminate>
<Checkbox.Indicator />
</Checkbox.Root>
</div>
</div>
),
};
export const States: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div>
<h3 className="mb-3 text-sm font-medium">Selection States</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Checkbox.Root id="unchecked">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="unchecked">Unchecked</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected id="checked">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="checked">Checked</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root isIndeterminate id="indeterminate">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="indeterminate">Indeterminate</Label>
</div>
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-medium">Interactive States</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Checkbox.Root isDisabled id="disabled">
<Checkbox.Indicator />
</Checkbox.Root>
<Label disabled htmlFor="disabled">
Disabled
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected isDisabled id="disabled-checked">
<Checkbox.Indicator />
</Checkbox.Root>
<Label disabled htmlFor="disabled-checked">
Disabled and checked
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root isDisabled isIndeterminate id="disabled-indeterminate">
<Checkbox.Indicator />
</Checkbox.Root>
<Label disabled htmlFor="disabled-indeterminate">
Disabled and indeterminate
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected isReadOnly id="readonly">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="readonly">Read only</Label>
</div>
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-medium">Validation States</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Checkbox.Root isInvalid id="invalid">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="invalid">Invalid</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected isInvalid id="invalid-checked">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="invalid-checked">Invalid and checked</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root isIndeterminate isInvalid id="invalid-indeterminate">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="invalid-indeterminate">Invalid and indeterminate</Label>
</div>
</div>
</div>
<div>
<p className="text-muted-foreground text-xs">
Note: Hover over checkboxes to see hover state. Tab to focus for keyboard navigation.
</p>
</div>
</div>
),
};
export const Controlled: Story = {
render: () => {
const [isSelected, setIsSelected] = React.useState(false);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Checkbox.Root id="terms-controlled" isSelected={isSelected} onChange={setIsSelected}>
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="terms-controlled">I agree to the terms and conditions</Label>
</div>
<p className="text-muted-foreground text-sm">
You have {isSelected ? "agreed" : "not agreed"} to the terms
</p>
</div>
);
},
};
export const Uncontrolled: Story = {
render: () => (
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected id="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="marketing">Receive marketing emails</Label>
</div>
),
};
export const WithExternalLabel: Story = {
render: () => (
<div className="flex items-center gap-3">
<Checkbox.Root id="terms">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="terms">Accept terms and conditions</Label>
</div>
),
};
export const WithDescription: Story = {
render: () => (
<div className="flex gap-3">
<Checkbox.Root className="mt-0.5" id="notifications">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="notifications">Email notifications</Label>
<Description>Get notified when someone mentions you</Description>
</div>
</div>
),
};
export const WithFieldComponent: Story = {
render: () => (
<Field variant="checkbox">
<Checkbox.Root>
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Email notifications</Label>
<Description>Get notified when someone mentions you</Description>
</div>
</Field>
),
};
export const CleanFieldsetPattern: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Preferences</Legend>
<FieldGroup>
<Field variant="checkbox">
<Checkbox.Root name="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Marketing emails</Label>
</Field>
<Field variant="checkbox">
<Checkbox.Root defaultSelected name="updates">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Product updates</Label>
<Description>New features and improvements</Description>
</div>
</Field>
<Field variant="checkbox">
<Checkbox.Root name="security">
<Checkbox.Indicator />
</Checkbox.Root>
<Label>Security alerts</Label>
</Field>
</FieldGroup>
</Fieldset>
),
};
export const CheckboxOnly: Story = {
render: () => (
<Checkbox.Root aria-label="Select item">
<Checkbox.Indicator />
</Checkbox.Root>
),
};
export const CustomIcon: Story = {
render: () => (
<div className="flex items-center gap-3">
<Checkbox.Root id="custom-icon">
<Checkbox.Indicator>
{(isIndeterminate) =>
isIndeterminate ? (
<svg fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
</svg>
) : (
<svg fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
fillRule="evenodd"
/>
</svg>
)
}
</Checkbox.Indicator>
</Checkbox.Root>
<Label htmlFor="custom-icon">Custom icon checkbox</Label>
</div>
),
};
export const Indeterminate: Story = {
render: () => {
const [checkedItems, setCheckedItems] = React.useState(["option2"]);
const allOptions = ["option1", "option2", "option3"];
const isIndeterminate = checkedItems.length > 0 && checkedItems.length < allOptions.length;
const isAllSelected = checkedItems.length === allOptions.length;
const handleParentChange = (isSelected: boolean) => {
setCheckedItems(isSelected ? allOptions : []);
};
const handleChildChange = (value: string, isSelected: boolean) => {
setCheckedItems((prev) =>
isSelected ? [...prev, value] : prev.filter((item) => item !== value),
);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Checkbox.Root
id="select-all"
isIndeterminate={isIndeterminate}
isSelected={isAllSelected}
onChange={handleParentChange}
>
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="select-all">Select all</Label>
</div>
<div className="ml-6 flex flex-col gap-2">
{allOptions.map((option) => (
<div key={option} className="flex items-center gap-3">
<Checkbox.Root
id={option}
isSelected={checkedItems.includes(option)}
value={option}
onChange={(isSelected) => handleChildChange(option, isSelected)}
>
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor={option}>
{option.charAt(0).toUpperCase() + option.slice(1).replace(/\d+/, " $&")}
</Label>
</div>
))}
</div>
</div>
);
},
};
export const FormIntegration: Story = {
render: () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const subscribe = formData.get("subscribe");
alert(`Newsletter subscription: ${subscribe ? "Yes" : "No"}`);
};
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex items-center gap-3">
<Checkbox.Root id="newsletter" name="subscribe" value="yes">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="newsletter">Subscribe to our newsletter</Label>
</div>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover rounded-md px-4 py-2"
type="submit"
>
Submit
</button>
</form>
);
},
};
export const Required: Story = {
render: () => (
<form className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Checkbox.Root isRequired id="agree">
<Checkbox.Indicator />
</Checkbox.Root>
<Label required htmlFor="agree">
I agree to the terms and conditions
</Label>
</div>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover rounded-md px-4 py-2"
type="submit"
>
Continue
</button>
</form>
),
};
export const RequiredWithChildLabel: Story = {
render: () => (
<form className="flex flex-col gap-4">
<Checkbox.Root isRequired>
<Checkbox.Indicator />
<Label required>I agree to the terms and conditions</Label>
</Checkbox.Root>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover rounded-md px-4 py-2"
type="submit"
>
Continue
</button>
</form>
),
};
export const WithFieldset: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Notification Preferences</Legend>
<Text>Select how you&apos;d like to receive updates from us.</Text>
<FieldGroup>
<Field variant="checkbox">
<Checkbox.Root defaultSelected name="notifications" value="email">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Email notifications</Label>
<Description>Get updates about your account via email</Description>
</div>
</Field>
<Field variant="checkbox">
<Checkbox.Root name="notifications" value="sms">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>SMS notifications</Label>
<Description>Receive text messages for important alerts</Description>
</div>
</Field>
<Field variant="checkbox">
<Checkbox.Root defaultSelected name="notifications" value="push">
<Checkbox.Indicator />
</Checkbox.Root>
<div>
<Label>Push notifications</Label>
<Description>Get instant updates on your device</Description>
</div>
</Field>
</FieldGroup>
</Fieldset>
),
};
@@ -0,0 +1,86 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const checkboxVariants = tv({
slots: {
base: "group flex cursor-pointer items-center gap-3",
wrapper: [
"relative inline-flex h-4 w-4 shrink-0 items-center justify-center",
"rounded-[5px]",
"transition-all duration-200",
"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)]",
// Default unselected state
"bg-base border-border/10 border",
// Hover state - unselected
"group-data-[hovered=true]:group-data-[selected=false]:border-accent-hover",
"group-data-[hovered=true]:group-data-[indeterminate=false]:border-accent-hover",
// Pressed state
"group-data-[pressed=true]:scale-[0.97]",
// Selected/Indeterminate states
"group-data-[selected=true]:bg-accent group-data-[selected=true]:border-accent-hover group-data-[selected=true]:border-[0.5px]",
"group-data-[indeterminate=true]:bg-accent group-data-[indeterminate=true]:border-accent-hover group-data-[indeterminate=true]:border-[0.5px]",
// Hover state - selected
"group-data-[hovered=true]:group-data-[selected=true]:bg-accent-hover",
"group-data-[hovered=true]:group-data-[indeterminate=true]:bg-accent-hover",
// Focus state
"group-data-[focus-visible=true]:border-2",
"group-data-[focus-visible=true]:shadow-[0px_0px_0px_3px_rgba(0,0,0,0.30),0px_0px_0px_0.5px_rgba(0,0,0,0.05)]",
// Error state - unselected
"group-data-[invalid=true]:group-data-[selected=false]:border-danger",
"group-data-[invalid=true]:group-data-[indeterminate=false]:border-danger",
// Error state - selected
"group-data-[invalid=true]:group-data-[selected=true]:bg-danger group-data-[invalid=true]:group-data-[selected=true]:border-danger group-data-[invalid=true]:group-data-[selected=true]:border-[0.5px]",
"group-data-[invalid=true]:group-data-[indeterminate=true]:bg-danger group-data-[invalid=true]:group-data-[indeterminate=true]:border-danger group-data-[invalid=true]:group-data-[indeterminate=true]:border-[0.5px]",
// Disabled state
"group-data-[disabled=true]:cursor-not-allowed group-data-[disabled=true]:opacity-50",
],
icon: [
"h-3 w-3",
"text-accent-foreground",
"scale-0 opacity-0",
"transition-all duration-200",
"group-data-[selected=true]:scale-100 group-data-[selected=true]:opacity-100",
"group-data-[indeterminate=true]:scale-100 group-data-[indeterminate=true]:opacity-100",
],
},
variants: {
isDisabled: {
true: {
base: "cursor-not-allowed",
},
},
},
defaultVariants: {},
});
export const checkboxGroupVariants = tv({
slots: {
base: "flex flex-col gap-2",
items: "flex flex-col gap-2",
},
variants: {
orientation: {
horizontal: {
items: "flex-row gap-4",
},
vertical: {
items: "flex-col gap-2",
},
},
},
defaultVariants: {
orientation: "vertical",
},
});
export type CheckboxGroupVariants = VariantProps<typeof checkboxGroupVariants>;
@@ -0,0 +1,208 @@
"use client";
import type {CheckboxGroupVariants} from "./checkbox.styles";
import type {
CheckboxGroupProps as CheckboxGroupPrimitiveProps,
CheckboxProps as CheckboxPrimitiveProps,
} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {
CheckboxGroup as CheckboxGroupPrimitive,
Checkbox as CheckboxPrimitive,
} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {checkboxGroupVariants, checkboxVariants} from "./checkbox.styles";
/* -------------------------------------------------------------------------------------------------
* CheckboxGroup Context
* -----------------------------------------------------------------------------------------------*/
interface CheckboxGroupContext {
slots?: ReturnType<typeof checkboxGroupVariants>;
}
const CheckboxGroupContext = createContext<CheckboxGroupContext>({});
/* -------------------------------------------------------------------------------------------------
* CheckboxGroup
* -----------------------------------------------------------------------------------------------*/
interface CheckboxGroupRootProps extends CheckboxGroupPrimitiveProps, CheckboxGroupVariants {}
const CheckboxGroupRoot = React.forwardRef<
React.ElementRef<typeof CheckboxGroupPrimitive>,
CheckboxGroupRootProps
>(({children, className, orientation, ...props}, ref) => {
const slots = React.useMemo(() => checkboxGroupVariants({orientation}), [orientation]);
return (
<CheckboxGroupContext.Provider value={{slots}}>
<CheckboxGroupPrimitive
ref={ref}
data-checkbox-group
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</CheckboxGroupPrimitive>
</CheckboxGroupContext.Provider>
);
});
CheckboxGroupRoot.displayName = "HeroUI.CheckboxGroup.Root";
/* -----------------------------------------------------------------------------------------------*/
interface CheckboxGroupItemsProps extends React.HTMLAttributes<HTMLDivElement> {}
const CheckboxGroupItems = React.forwardRef<HTMLDivElement, CheckboxGroupItemsProps>(
({className, ...props}, ref) => {
const {slots} = useContext(CheckboxGroupContext);
return (
<div ref={ref} data-checkbox-group-items className={slots?.items({className})} {...props} />
);
},
);
CheckboxGroupItems.displayName = "HeroUI.CheckboxGroup.Items";
/* -------------------------------------------------------------------------------------------------
* Checkbox
* -----------------------------------------------------------------------------------------------*/
interface CheckboxContext {
slots?: ReturnType<typeof checkboxVariants>;
isSelected?: boolean;
isIndeterminate?: boolean;
}
const CheckboxContext = createContext<CheckboxContext>({});
interface CheckboxRootProps extends CheckboxPrimitiveProps {}
const CheckboxRoot = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive>,
CheckboxRootProps
>(({children, className, ...props}, ref) => {
const slots = React.useMemo(
() => checkboxVariants({isDisabled: props.isDisabled}),
[props.isDisabled],
);
return (
<CheckboxPrimitive
ref={ref}
data-checkbox
data-slot="control"
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => (
<CheckboxContext.Provider
value={{isIndeterminate: values.isIndeterminate, isSelected: values.isSelected, slots}}
>
{typeof children === "function" ? children(values) : children}
</CheckboxContext.Provider>
)}
</CheckboxPrimitive>
);
});
CheckboxRoot.displayName = "HeroUI.Checkbox.Root";
/* -----------------------------------------------------------------------------------------------*/
interface CheckboxIndicatorProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
children?: React.ReactNode | ((isIndeterminate: boolean) => React.ReactNode);
}
const CheckboxIndicator = React.forwardRef<HTMLSpanElement, CheckboxIndicatorProps>(
({children, className, ...props}, ref) => {
const {isIndeterminate, isSelected, slots} = useContext(CheckboxContext);
const renderIcon = () => {
if (!isSelected && !isIndeterminate) {
return null;
}
if (typeof children === "function") {
return children(isIndeterminate || false);
}
if (children) {
return children;
}
if (isIndeterminate) {
return <CheckboxIndeterminateIcon />;
}
return <CheckboxDefaultIcon />;
};
return (
<span ref={ref} data-checkbox-wrapper className={slots?.wrapper({className})} {...props}>
<span data-checkbox-icon className={slots?.icon()}>
{renderIcon()}
</span>
</span>
);
},
);
CheckboxIndicator.displayName = "HeroUI.Checkbox.Indicator";
/* -----------------------------------------------------------------------------------------------*/
const CheckboxDefaultIcon = () => (
<svg fill="none" height="12" viewBox="0 0 16 16" width="12">
<path
clipRule="evenodd"
d="M13.488 3.43a.75.75 0 0 1 .081 1.058l-6 7a.75.75 0 0 1-1.1.042l-3.5-3.5A.75.75 0 0 1 4.03 6.97l2.928 2.927 5.473-6.385a.75.75 0 0 1 1.057-.081"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
const CheckboxIndeterminateIcon = () => (
<svg fill="none" height="12" viewBox="0 0 16 16" width="12">
<path
clipRule="evenodd"
d="M1.75 8a.75.75 0 0 1 .75-.75h11a.75.75 0 0 1 0 1.5h-11A.75.75 0 0 1 1.75 8"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Checkbox = Object.assign(
{},
{
Root: CheckboxRoot,
Indicator: CheckboxIndicator,
Icon: CheckboxDefaultIcon,
IndeterminateIcon: CheckboxIndeterminateIcon,
},
);
export const CheckboxGroup = Object.assign(
{},
{
Root: CheckboxGroupRoot,
Items: CheckboxGroupItems,
},
);
export type {
CheckboxGroupRootProps,
CheckboxGroupItemsProps,
CheckboxRootProps,
CheckboxIndicatorProps,
};
@@ -0,0 +1,3 @@
export {Checkbox, CheckboxGroup} from "./checkbox";
export {checkboxVariants, checkboxGroupVariants} from "./checkbox.styles";
export type {CheckboxGroupVariants} from "./checkbox.styles";
@@ -1,5 +1,3 @@
/* eslint-disable sort-keys */
/* eslint-disable sort-keys-fix/sort-keys-fix */
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
@@ -0,0 +1,23 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const descriptionVariants = tv({
base: ["text-muted-foreground text-sm", "transition-colors duration-200"],
variants: {
size: {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
},
disabled: {
true: "opacity-[var(--disabled-opacity)]",
},
},
defaultVariants: {
size: "md",
disabled: false,
},
});
export type DescriptionVariants = VariantProps<typeof descriptionVariants>;
@@ -0,0 +1,34 @@
"use client";
import type {DescriptionVariants} from "./description.styles";
import type {TextProps} from "react-aria-components";
import React from "react";
import {Text} from "react-aria-components";
import {descriptionVariants} from "./description.styles";
interface DescriptionProps extends TextProps, DescriptionVariants {
ref?: React.Ref<HTMLElement>;
}
const Description = React.forwardRef<React.ElementRef<typeof Text>, DescriptionProps>(
({children, className, disabled, size, ...rest}, ref) => {
return (
<Text
ref={ref}
className={descriptionVariants({size, disabled, className})}
data-slot="description"
slot="description"
{...rest}
>
{children}
</Text>
);
},
);
Description.displayName = "HeroUI.Description";
export type {DescriptionProps};
export {Description};
@@ -0,0 +1,2 @@
export {Description, type DescriptionProps} from "./description";
export {descriptionVariants, type DescriptionVariants} from "./description.styles";
@@ -0,0 +1,327 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Checkbox, CheckboxGroup} from "../checkbox";
import {Description} from "../description";
import {Label} from "../label";
import {Text} from "../text";
import {Field, FieldError, FieldGroup, Fieldset, Legend} from "./fieldset";
const meta: Meta<typeof Fieldset> = {
title: "Components/Fieldset",
component: Fieldset,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Fieldset>;
export const Default: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Notification Settings</Legend>
<Text>Choose how you want to be notified about updates.</Text>
<FieldGroup>
<Field>
<Label htmlFor="email-notifications">Email notifications</Label>
<Description>Receive updates via email</Description>
<div className="flex items-center gap-3">
<Checkbox.Root id="email-notifications">
<Checkbox.Indicator />
</Checkbox.Root>
<Label className="font-normal" htmlFor="email-notifications">
Enable email notifications
</Label>
</div>
</Field>
</FieldGroup>
</Fieldset>
),
};
export const WithCheckboxGroup: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Discoverability</Legend>
<Text>Decide where your events can be found across the web.</Text>
<CheckboxGroup.Root>
<FieldGroup>
<div className="flex items-start gap-3">
<Checkbox.Root
defaultSelected
id="show-events"
name="discoverability"
value="show_on_events_page"
>
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="show-events">Show on events page</Label>
<Description>Make this event visible on your profile.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root id="allow-embed" name="discoverability" value="allow_embedding">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="allow-embed">Allow embedding</Label>
<Description>Allow others to embed your event details on their own site.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
),
};
export const WithFieldError: Story = {
render: () => {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<Fieldset className="w-96">
<Legend>Select Required Options</Legend>
<Text>You must select at least two options to continue.</Text>
<CheckboxGroup.Root isInvalid={selected.length < 2} value={selected} onChange={setSelected}>
<FieldGroup>
<div className="flex items-center gap-3">
<Checkbox.Root id="opt1" name="options" value="option1">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="opt1">Option 1</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="opt2" name="options" value="option2">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="opt2">Option 2</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="opt3" name="options" value="option3">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="opt3">Option 3</Label>
</div>
</FieldGroup>
<FieldError>Please select at least 2 options</FieldError>
</CheckboxGroup.Root>
</Fieldset>
);
},
};
export const MultipleFieldsets: Story = {
render: () => (
<div className="w-96 space-y-8">
<Fieldset>
<Legend>Privacy Settings</Legend>
<Text>Control who can see your content and interact with you.</Text>
<CheckboxGroup.Root>
<FieldGroup>
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="public" name="privacy" value="public_profile">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="public">Public profile</Label>
<Description>Anyone can view your profile and content.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root id="searchable" name="privacy" value="searchable">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="searchable">Searchable</Label>
<Description>Your profile appears in search results.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
<Fieldset>
<Legend>Email Preferences</Legend>
<Text>Choose which emails you&apos;d like to receive from us.</Text>
<CheckboxGroup.Root>
<FieldGroup>
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="marketing" name="emails" value="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="marketing">Marketing emails</Label>
<Description>New features, product updates, and special offers.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root id="newsletter" name="emails" value="newsletter">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="newsletter">Newsletter</Label>
<Description>Weekly digest of the best content.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="notifications" name="emails" value="notifications">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="notifications">Notifications</Label>
<Description>Important account activity and security alerts.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
</div>
),
};
export const SimpleCheckboxGroup: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Select your interests</Legend>
<CheckboxGroup.Root>
<FieldGroup>
<div className="flex items-center gap-3">
<Checkbox.Root id="design" name="interests" value="design">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="design">Design</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="development" name="interests" value="development">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="development">Development</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="marketing-interest" name="interests" value="marketing">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="marketing-interest">Marketing</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="sales" name="interests" value="sales">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="sales">Sales</Label>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
),
};
export const WithDisabledState: Story = {
render: () => (
<Fieldset className="w-96">
<Legend>Feature Access</Legend>
<Text>Some features require a premium subscription.</Text>
<CheckboxGroup.Root>
<FieldGroup>
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="basic-features" name="features" value="basic">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="basic-features">Basic features</Label>
<Description>Available to all users.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root isDisabled id="advanced-features" name="features" value="advanced">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label disabled htmlFor="advanced-features">
Advanced features
</Label>
<Description>Requires premium subscription.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root isDisabled id="beta-features" name="features" value="beta">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label disabled htmlFor="beta-features">
Beta features
</Label>
<Description>Coming soon for premium users.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
),
};
export const NestedFieldsets: Story = {
render: () => (
<Fieldset className="w-[32rem]">
<Legend>Account Settings</Legend>
<div className="space-y-8">
<Fieldset>
<Legend className="text-sm">Security</Legend>
<CheckboxGroup.Root>
<FieldGroup spacing="sm">
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="2fa" name="security" value="2fa">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="2fa">Two-factor authentication</Label>
<Description>Add an extra layer of security to your account.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root id="login-alerts" name="security" value="login_alerts">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="login-alerts">Login alerts</Label>
<Description>Get notified when someone logs into your account.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
<Fieldset>
<Legend className="text-sm">Privacy</Legend>
<CheckboxGroup.Root>
<FieldGroup spacing="sm">
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="activity" name="privacy" value="activity_status">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="activity">Show activity status</Label>
<Description>Let others see when you&apos;re online.</Description>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox.Root defaultSelected id="receipts" name="privacy" value="read_receipts">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="flex flex-col gap-1">
<Label htmlFor="receipts">Read receipts</Label>
<Description>Let people know when you&apos;ve read their messages.</Description>
</div>
</div>
</FieldGroup>
</CheckboxGroup.Root>
</Fieldset>
</div>
</Fieldset>
),
};
@@ -0,0 +1,72 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const fieldsetVariants = tv({
base: ["space-y-6", "[&>*+[data-slot=control]]:mt-6", "*:data-[slot=text]:mt-1"],
});
export const legendVariants = tv({
base: ["text-base font-semibold", "text-foreground", "disabled:opacity-50"],
});
export const fieldGroupVariants = tv({
base: "space-y-8 data-[slot=control]:block",
variants: {
spacing: {
sm: "space-y-4",
md: "space-y-6",
lg: "space-y-8",
},
},
defaultVariants: {
spacing: "lg",
},
});
export const fieldVariants = tv({
base: [
"[&>[data-slot=label]+[data-slot=control]]:mt-3",
"[&>[data-slot=label]+[data-slot=description]]:mt-1",
"[&>[data-slot=description]+[data-slot=control]]:mt-3",
"[&>[data-slot=control]+[data-slot=description]]:mt-3",
"[&>[data-slot=control]+[data-slot=error]]:mt-3",
"*:data-[slot=label]:font-medium",
],
variants: {
variant: {
default: "",
checkbox: [
"flex items-start gap-3",
"[&>[data-slot=label]]:mt-0",
"[&>[data-slot=control]]:mt-0.5",
"[&>[data-slot=control]+[data-slot=label]]:mt-0",
"[&>[data-slot=control]+div]:flex",
"[&>[data-slot=control]+div]:flex-col",
"[&>[data-slot=control]+div]:gap-1",
"[&_[data-slot=label]]:font-normal",
],
},
},
defaultVariants: {
variant: "default",
},
});
export const fieldErrorVariants = tv({
base: [
"text-destructive text-sm",
"mt-1",
"transition-all duration-200",
"opacity-0",
"translate-y-[-4px]",
"data-[visible=true]:opacity-100",
"data-[visible=true]:translate-y-0",
],
});
export type FieldsetVariants = VariantProps<typeof fieldsetVariants>;
export type LegendVariants = VariantProps<typeof legendVariants>;
export type FieldGroupVariants = VariantProps<typeof fieldGroupVariants>;
export type FieldVariants = VariantProps<typeof fieldVariants>;
export type FieldErrorVariants = VariantProps<typeof fieldErrorVariants>;
@@ -0,0 +1,152 @@
"use client";
import type {
FieldErrorVariants,
FieldGroupVariants,
FieldVariants,
FieldsetVariants,
LegendVariants,
} from "./fieldset.styles";
import type {FieldErrorProps as FieldErrorPrimitiveProps} from "react-aria-components";
import {Slot} from "@radix-ui/react-slot";
import React from "react";
import {FieldError as FieldErrorPrimitive} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {
fieldErrorVariants,
fieldGroupVariants,
fieldVariants,
fieldsetVariants,
legendVariants,
} from "./fieldset.styles";
/* -------------------------------------------------------------------------------------------------
* Fieldset
* -----------------------------------------------------------------------------------------------*/
interface FieldsetProps extends React.HTMLAttributes<HTMLFieldSetElement>, FieldsetVariants {
asChild?: boolean;
}
const Fieldset = React.forwardRef<HTMLFieldSetElement, FieldsetProps>(
({asChild = false, className, ...props}, ref) => {
const Comp = asChild ? Slot : "fieldset";
return <Comp ref={ref} className={fieldsetVariants({className})} {...props} />;
},
);
Fieldset.displayName = "HeroUI.Fieldset";
/* -------------------------------------------------------------------------------------------------
* Legend
* -----------------------------------------------------------------------------------------------*/
interface LegendProps extends React.HTMLAttributes<HTMLLegendElement>, LegendVariants {
asChild?: boolean;
}
const Legend = React.forwardRef<HTMLLegendElement, LegendProps>(
({asChild = false, className, ...props}, ref) => {
const Comp = asChild ? Slot : "legend";
return <Comp ref={ref} className={legendVariants({className})} data-slot="legend" {...props} />;
},
);
Legend.displayName = "HeroUI.Legend";
/* -------------------------------------------------------------------------------------------------
* FieldGroup
* -----------------------------------------------------------------------------------------------*/
interface FieldGroupProps extends React.HTMLAttributes<HTMLDivElement>, FieldGroupVariants {
asChild?: boolean;
}
const FieldGroup = React.forwardRef<HTMLDivElement, FieldGroupProps>(
({asChild = false, className, spacing, ...props}, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
className={fieldGroupVariants({spacing, className})}
data-slot="control"
{...props}
/>
);
},
);
FieldGroup.displayName = "HeroUI.FieldGroup";
/* -------------------------------------------------------------------------------------------------
* Field
* -----------------------------------------------------------------------------------------------*/
interface FieldProps extends React.HTMLAttributes<HTMLDivElement>, FieldVariants {
asChild?: boolean;
}
const Field = React.forwardRef<HTMLDivElement, FieldProps>(
({asChild = false, className, variant, ...props}, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
className={fieldVariants({variant, className})}
data-slot="field"
{...props}
/>
);
},
);
Field.displayName = "HeroUI.Field";
/* -------------------------------------------------------------------------------------------------
* FieldError
* -----------------------------------------------------------------------------------------------*/
interface FieldErrorProps extends FieldErrorPrimitiveProps, FieldErrorVariants {
ref?: React.Ref<HTMLElement>;
}
const FieldError = React.forwardRef<React.ElementRef<typeof FieldErrorPrimitive>, FieldErrorProps>(
({children, className, ...rest}, ref) => {
return (
<FieldErrorPrimitive
ref={ref}
data-visible
className={composeTwRenderProps(className, fieldErrorVariants())}
{...rest}
>
{(renderProps) => (typeof children === "function" ? children(renderProps) : children)}
</FieldErrorPrimitive>
);
},
);
FieldError.displayName = "HeroUI.FieldError";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export {
Fieldset,
Legend,
FieldGroup,
Field,
FieldError,
type FieldsetProps,
type LegendProps,
type FieldGroupProps,
type FieldProps,
type FieldErrorProps,
};
@@ -0,0 +1,2 @@
export * from "./fieldset";
export * from "./fieldset.styles";
@@ -0,0 +1,360 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Button} from "../button";
import {Checkbox} from "../checkbox";
import {Description} from "../description";
import {Label} from "../label";
import {Radio, RadioGroup} from "../radio";
import {TextField} from "../text-field/text-field";
import * as Form from "./form";
const meta: Meta<typeof Form.Root> = {
title: "Components/Form",
component: Form.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
validationBehavior: {
control: {type: "select"},
options: ["native", "aria"],
description: "How validation errors are displayed",
defaultValue: "native",
},
isDisabled: {
control: {type: "boolean"},
description: "Whether the form is disabled",
defaultValue: false,
},
},
};
export default meta;
type Story = StoryObj<typeof Form.Root>;
export const Default: Story = {
render: (args) => (
<Form.Root className="w-96 space-y-4" {...args}>
<TextField.Root isRequired name="username" type="text">
<TextField.Label>Username</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root isRequired name="email" type="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input />
<TextField.Description>We&apos;ll never share your email</TextField.Description>
<TextField.Error />
</TextField.Root>
<Form.Actions>
<Button type="submit">Submit</Button>
<Button type="reset" variant="tertiary">
Reset
</Button>
</Form.Actions>
</Form.Root>
),
};
export const WithSections: Story = {
render: (args) => (
<Form.Root className="w-96" {...args}>
<Form.Section>
<h3 className="mb-3 text-lg font-semibold">Account Information</h3>
<div className="space-y-4">
<TextField.Root isRequired name="username" type="text">
<TextField.Label>Username</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root isRequired name="email" type="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
</div>
</Form.Section>
<Form.Section>
<h3 className="mb-3 text-lg font-semibold">Preferences</h3>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox.Root id="newsletter">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="newsletter">Subscribe to newsletter</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="notifications">
<Checkbox.Indicator />
</Checkbox.Root>
<div className="space-y-1">
<Label htmlFor="notifications">Email notifications</Label>
<Description>Get notified when someone mentions you</Description>
</div>
</div>
</div>
</Form.Section>
<Form.Actions>
<Button type="submit">Save Changes</Button>
<Button type="reset" variant="tertiary">
Cancel
</Button>
</Form.Actions>
</Form.Root>
),
};
export const WithValidation: Story = {
render: (args) => {
const [errors, setErrors] = React.useState<Record<string, string>>({});
return (
<Form.Root
className="w-96 space-y-4"
validationBehavior="aria"
validationErrors={errors}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const email = formData.get("email");
const newErrors: Record<string, string> = {};
if (!username) {
newErrors["username"] = "Username is required";
} else if (username.toString().length < 3) {
newErrors["username"] = "Username must be at least 3 characters";
}
if (!email) {
newErrors["email"] = "Email is required";
} else if (!email.toString().includes("@")) {
newErrors["email"] = "Please enter a valid email";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
} else {
setErrors({});
alert("Form submitted successfully!");
}
}}
{...args}
>
<TextField.Root isRequired name="username" type="text">
<TextField.Label>Username</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root isRequired name="email" type="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root isRequired minLength={8} name="password" type="password">
<TextField.Label>Password</TextField.Label>
<TextField.Input />
<TextField.Description>Must be at least 8 characters</TextField.Description>
<TextField.Error />
</TextField.Root>
<Form.Actions>
<Button type="submit">Create Account</Button>
</Form.Actions>
</Form.Root>
);
},
};
export const ComplexForm: Story = {
render: (args) => (
<Form.Root className="w-[600px]" {...args}>
<Form.Section>
<h2 className="mb-6 text-2xl font-bold">User Registration</h2>
<div className="grid grid-cols-2 gap-4">
<TextField.Root isRequired name="firstName" type="text">
<TextField.Label>First Name</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root isRequired name="lastName" type="text">
<TextField.Label>Last Name</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
</div>
<div className="mt-4 space-y-4">
<TextField.Root isRequired name="email" type="email">
<TextField.Label>Email Address</TextField.Label>
<TextField.Input />
<TextField.Error />
</TextField.Root>
<TextField.Root name="phone" type="tel">
<TextField.Label>Phone Number</TextField.Label>
<TextField.Input placeholder="+1 (555) 000-0000" />
<TextField.Description>Optional</TextField.Description>
</TextField.Root>
</div>
</Form.Section>
<Form.Section>
<h3 className="mb-3 text-lg font-semibold">Address Information</h3>
<div className="space-y-4">
<TextField.Root name="street" type="text">
<TextField.Label>Street Address</TextField.Label>
<TextField.Input />
</TextField.Root>
<div className="grid grid-cols-2 gap-4">
<TextField.Root name="city" type="text">
<TextField.Label>City</TextField.Label>
<TextField.Input />
</TextField.Root>
<TextField.Root name="zipCode" type="text">
<TextField.Label>ZIP Code</TextField.Label>
<TextField.Input />
</TextField.Root>
</div>
</div>
</Form.Section>
<Form.Section>
<h3 className="mb-3 text-lg font-semibold">Communication Preferences</h3>
<div className="space-y-3">
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">Contact Method</legend>
<RadioGroup.Root name="contactMethod">
<div className="flex items-center gap-3">
<Radio.Root id="contact-email" value="email">
<Radio.Indicator />
</Radio.Root>
<Label htmlFor="contact-email">Email</Label>
</div>
<div className="flex items-center gap-3">
<Radio.Root id="contact-phone" value="phone">
<Radio.Indicator />
</Radio.Root>
<Label htmlFor="contact-phone">Phone</Label>
</div>
<div className="flex items-center gap-3">
<Radio.Root id="contact-both" value="both">
<Radio.Indicator />
</Radio.Root>
<Label htmlFor="contact-both">Both</Label>
</div>
</RadioGroup.Root>
</fieldset>
<div className="space-y-2 pt-2">
<div className="flex items-center gap-3">
<Checkbox.Root id="terms">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="terms">I agree to the terms and conditions</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox.Root id="privacy">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="privacy">I have read the privacy policy</Label>
</div>
</div>
</div>
</Form.Section>
<Form.Actions className="justify-end">
<Button type="reset" variant="ghost">
Clear Form
</Button>
<Button type="submit">Register</Button>
</Form.Actions>
</Form.Root>
),
};
export const DisabledForm: Story = {
args: {
isDisabled: true,
},
render: (args) => (
<Form.Root className="w-96 space-y-4" {...args}>
<TextField.Root name="username" type="text">
<TextField.Label>Username</TextField.Label>
<TextField.Input defaultValue="johndoe" />
</TextField.Root>
<TextField.Root name="email" type="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input defaultValue="john@example.com" />
</TextField.Root>
<div className="flex items-center gap-3">
<Checkbox.Root defaultSelected id="disabled-checkbox">
<Checkbox.Indicator />
</Checkbox.Root>
<Label htmlFor="disabled-checkbox">Remember me</Label>
</div>
<Form.Actions>
<Button type="submit">Submit</Button>
</Form.Actions>
</Form.Root>
),
};
export const InlineErrors: Story = {
render: (args) => (
<Form.Root
className="w-96 space-y-4"
validationBehavior="aria"
validationErrors={{
username: "This username is already taken",
email: "Please enter a valid email address",
}}
{...args}
>
<TextField.Root isInvalid name="username" type="text">
<TextField.Label>Username</TextField.Label>
<TextField.Input defaultValue="admin" />
<TextField.Error />
</TextField.Root>
<TextField.Root isInvalid name="email" type="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input defaultValue="invalid-email" />
<TextField.Error />
</TextField.Root>
<TextField.Root name="password" type="password">
<TextField.Label>Password</TextField.Label>
<TextField.Input />
<TextField.Description>Choose a strong password</TextField.Description>
</TextField.Root>
<Form.Actions>
<Button type="submit">Continue</Button>
</Form.Actions>
</Form.Root>
),
};
@@ -0,0 +1,23 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const formVariants = tv({
slots: {
base: ["flex", "flex-col"],
section: ["space-y-4"],
actions: ["flex", "items-center", "gap-2", "pt-4"],
},
variants: {
isDisabled: {
true: {
base: ["opacity-50", "pointer-events-none"],
},
},
},
defaultVariants: {
isDisabled: false,
},
});
export type FormVariants = VariantProps<typeof formVariants>;
+106
View File
@@ -0,0 +1,106 @@
"use client";
import type {FormVariants} from "./form.styles";
import type {FormProps as FormPrimitiveProps} from "react-aria-components";
import {Slot as SlotPrimitive} from "@radix-ui/react-slot";
import React, {createContext, useContext} from "react";
import {Form as FormPrimitive} from "react-aria-components";
import {formVariants} from "./form.styles";
/* ------------------------------------------------------------------------------------------------
* Form Context
* --------------------------------------------------------------------------------------------- */
type FormContext = {
slots?: ReturnType<typeof formVariants>;
};
const FormContext = createContext<FormContext>({});
/* ------------------------------------------------------------------------------------------------
* Form Root
* --------------------------------------------------------------------------------------------- */
interface FormRootProps extends FormPrimitiveProps, FormVariants {
asChild?: boolean;
}
const FormRoot = React.forwardRef<React.ElementRef<typeof FormPrimitive>, FormRootProps>(
({asChild, children, className, isDisabled, ...rest}, ref) => {
const slots = React.useMemo(() => formVariants({isDisabled}), [isDisabled]);
if (asChild) {
return (
<FormContext.Provider value={{slots}}>
<SlotPrimitive data-form-root className={slots?.base({className})} {...rest}>
{children}
</SlotPrimitive>
</FormContext.Provider>
);
}
return (
<FormContext.Provider value={{slots}}>
<FormPrimitive ref={ref} data-form-root className={slots.base({className})} {...rest}>
{children}
</FormPrimitive>
</FormContext.Provider>
);
},
);
FormRoot.displayName = "HeroUI.Form.Root";
/* ------------------------------------------------------------------------------------------------
* Form Section
* --------------------------------------------------------------------------------------------- */
type FormSectionProps = React.ComponentProps<"div"> & {
asChild?: boolean;
};
const FormSection = React.forwardRef<HTMLDivElement, FormSectionProps>(
({asChild, children, className, ...rest}, ref) => {
const {slots} = useContext(FormContext);
const Component = asChild ? SlotPrimitive : "div";
return (
<Component ref={ref} data-form-section className={slots?.section({className})} {...rest}>
{children}
</Component>
);
},
);
FormSection.displayName = "HeroUI.Form.Section";
/* ------------------------------------------------------------------------------------------------
* Form Actions
* --------------------------------------------------------------------------------------------- */
type FormActionsProps = React.ComponentProps<"div"> & {
asChild?: boolean;
};
const FormActions = React.forwardRef<HTMLDivElement, FormActionsProps>(
({asChild, children, className, ...rest}, ref) => {
const {slots} = useContext(FormContext);
const Component = asChild ? SlotPrimitive : "div";
return (
<Component ref={ref} data-form-actions className={slots?.actions({className})} {...rest}>
{children}
</Component>
);
},
);
FormActions.displayName = "HeroUI.Form.Actions";
/* ------------------------------------------------------------------------------------------------
* Exports
* --------------------------------------------------------------------------------------------- */
export {FormRoot as Root, FormSection as Section, FormActions as Actions};
export type {FormRootProps, FormSectionProps, FormActionsProps};
@@ -0,0 +1,4 @@
export * as Form from "./form";
export type {FormRootProps, FormSectionProps, FormActionsProps} from "./form";
export {formVariants} from "./form.styles";
export type {FormVariants} from "./form.styles";
+13
View File
@@ -4,11 +4,24 @@
export * from "./button";
export * from "./accordion";
export * from "./avatar";
export * from "./calendar";
export * from "./card";
export * from "./checkbox";
export * from "./chip";
export * from "./description";
export * from "./fieldset";
export * from "./form";
export * from "./label";
export * from "./link";
export * from "./tooltip";
export * from "./spinner";
export * from "./popover";
export * from "./switch";
export * from "./radio";
export * from "./slider";
export * from "./text-field";
export * from "./text";
export * from "./input-otp";
// ===================================
// Icons
@@ -0,0 +1,8 @@
export * as InputOTP from "./input-otp";
export type {
InputOTPRootProps,
InputOTPGroupProps,
InputOTPSlotProps,
InputOTPSeparatorProps,
} from "./input-otp";
export {inputOTPVariants, type InputOTPVariants} from "./input-otp.styles";
@@ -0,0 +1,224 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Description} from "../description";
import {Label} from "../label";
import {Text} from "../text";
import {InputOTP} from "./input-otp";
const meta: Meta<typeof InputOTP.Root> = {
title: "Components/InputOTP",
component: InputOTP.Root,
parameters: {
layout: "centered",
},
argTypes: {
isDisabled: {
control: "boolean",
},
isInvalid: {
control: "boolean",
},
maxLength: {
control: "number",
},
},
};
export default meta;
type Story = StoryObj<typeof InputOTP.Root>;
export const Default: Story = {
render: (args) => (
<InputOTP.Root {...args} maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
),
};
export const WithLabel: Story = {
render: (args) => (
<div className="w-[280px]">
<Label>Verify account</Label>
<Description size="sm">We&apos;ve sent a code to a****@gmail.com</Description>
<InputOTP.Root {...args} maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
<div className="flex items-center gap-[5px] px-1 pt-1">
<Text size="xs" variant="muted">
Didn&apos;t receive a code?
</Text>
<button className="text-foreground text-xs font-medium underline">Resend</button>
</div>
</div>
),
};
export const Required: Story = {
render: (args) => (
<div className="w-[280px]">
<Label required>Verify account</Label>
<InputOTP.Root {...args} maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
</div>
),
};
export const FourDigits: Story = {
render: (args) => (
<InputOTP.Root {...args} maxLength={4}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
),
};
export const Invalid: Story = {
args: {
isInvalid: true,
},
render: (args) => (
<div className="w-[280px]">
<Label>Verify account</Label>
<InputOTP.Root {...args} maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
<Text className="px-1 pt-1" size="xs" variant="danger">
Invalid code, please try again
</Text>
</div>
),
};
export const Disabled: Story = {
args: {
isDisabled: true,
},
render: (args) => (
<div className="w-[280px]">
<Label disabled>Verify account</Label>
<InputOTP.Root {...args} maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
</div>
),
};
export const WithPattern: Story = {
render: (args) => (
<div className="w-[280px]">
<Label>Enter code (numbers only)</Label>
<InputOTP.Root {...args} maxLength={6} pattern="^[0-9]+$">
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
</div>
),
};
export const OnComplete: Story = {
render: (args) => {
const [value, setValue] = React.useState("");
const [isComplete, setIsComplete] = React.useState(false);
return (
<div className="w-[280px]">
<Label>Verify account</Label>
<InputOTP.Root
{...args}
maxLength={6}
value={value}
onChange={setValue}
onComplete={(code) => {
setIsComplete(true);
// eslint-disable-next-line no-console
console.log("Code complete:", code);
}}
>
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot />
<InputOTP.Slot />
<InputOTP.Slot />
</InputOTP.Group>
</InputOTP.Root>
{!!isComplete && (
<Text className="mt-2" size="sm" variant="success">
Code submitted successfully!
</Text>
)}
</div>
);
},
};
@@ -0,0 +1,68 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {disabledClasses, focusRingClasses} from "../../utils";
export const inputOTPVariants = tv({
slots: {
base: "flex w-full flex-col gap-1",
container: "relative flex items-center gap-2",
group: "flex items-center gap-2",
slot: [
"relative flex items-center justify-center",
"rounded-xl",
"bg-neutral-50 backdrop-blur-0",
"min-h-8 min-w-8 flex-1",
"transition-all duration-200",
"border border-[rgba(0,0,0,0.01)]",
"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)]",
"shadow-[0px_0px_0px_0px_inset_rgba(255,255,255,0.1)]",
"text-sm font-semibold",
"text-foreground",
"hover:bg-neutral-100",
focusRingClasses,
],
slotValue: ["text-[13.5px] leading-[18px]", "tracking-[-0.27px]"],
caret: ["absolute", "h-4 w-[2px]", "bg-foreground", "animate-blink"],
separator: ["bg-neutral-200", "h-0.5 w-[5px]", "shrink-0"],
},
variants: {
isDisabled: {
true: {
slot: [disabledClasses, "bg-neutral-50/50"],
},
},
isInvalid: {
true: {
slot: ["border-danger", "hover:border-danger", "focus-within:border-danger"],
},
},
isActive: {
true: {
slot: [
"bg-white",
"shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_0px_rgba(0,0,0,0.06)]",
"ring-2 ring-neutral-900/20 ring-offset-2",
],
},
},
isFilled: {
true: {
slot: ["bg-white"],
},
},
},
compoundVariants: [
{
isDisabled: false,
isInvalid: false,
isActive: false,
class: {
slot: ["hover:bg-neutral-100"],
},
},
],
});
export type InputOTPVariants = VariantProps<typeof inputOTPVariants>;
@@ -0,0 +1,181 @@
"use client";
import type {InputOTPVariants} from "./input-otp.styles";
import type {OTPInputProps} from "input-otp";
import {OTPInput} from "input-otp";
import React, {createContext, useContext} from "react";
import {inputOTPVariants} from "./input-otp.styles";
/* -------------------------------------------------------------------------------------------------
* InputOTP Context
* -----------------------------------------------------------------------------------------------*/
interface InputOTPContext {
slots?: ReturnType<typeof inputOTPVariants>;
}
const InputOTPContext = createContext<InputOTPContext>({});
/* -------------------------------------------------------------------------------------------------
* InputOTP
* -----------------------------------------------------------------------------------------------*/
interface InputOTPRootProps extends Omit<OTPInputProps, "render">, InputOTPVariants {
children?: React.ReactNode;
}
const InputOTPRoot = React.forwardRef<HTMLInputElement, InputOTPRootProps>(
({children, className, isDisabled, isInvalid, ...props}, ref) => {
const slots = React.useMemo(
() => inputOTPVariants({isDisabled, isInvalid}),
[isDisabled, isInvalid],
);
return (
<InputOTPContext.Provider value={{slots}}>
<div data-input-otp className={slots.base({className})}>
<OTPInput
ref={ref}
disabled={isDisabled}
{...props}
containerClassName={slots.container()}
render={({slots: otpSlots}) => (
<>
{React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === InputOTPGroup) {
return React.cloneElement(child as React.ReactElement<InputOTPGroupProps>, {
slots: otpSlots,
});
}
return child;
})}
</>
)}
/>
</div>
</InputOTPContext.Provider>
);
},
);
InputOTPRoot.displayName = "HeroUI.InputOTP.Root";
/* -----------------------------------------------------------------------------------------------*/
interface InputOTPSlotData {
char?: string | null;
isActive?: boolean;
hasFakeCaret?: boolean;
}
interface InputOTPGroupProps {
children?: React.ReactNode;
className?: string;
slots?: InputOTPSlotData[];
}
const InputOTPGroup = React.forwardRef<HTMLDivElement, InputOTPGroupProps>(
({children, className, slots = []}, ref) => {
const {slots: contextSlots} = useContext(InputOTPContext);
return (
<div ref={ref} data-input-otp-group className={contextSlots?.group({className})}>
{React.Children.map(children, (child, index) => {
if (React.isValidElement(child) && child.type === InputOTPSlot) {
const slotData = slots[index];
if (!slotData) return null;
return React.cloneElement(child as React.ReactElement<InputOTPSlotProps>, {
char: slotData.char ?? undefined,
isActive: slotData.isActive,
hasFakeCaret: slotData.hasFakeCaret,
index,
});
}
return child;
})}
</div>
);
},
);
InputOTPGroup.displayName = "HeroUI.InputOTP.Group";
/* -----------------------------------------------------------------------------------------------*/
interface InputOTPSlotProps {
index?: number;
char?: string;
isActive?: boolean;
hasFakeCaret?: boolean;
className?: string;
}
const InputOTPSlot = React.forwardRef<HTMLDivElement, InputOTPSlotProps>(
({char, className, hasFakeCaret, isActive, ...props}, ref) => {
const {slots} = useContext(InputOTPContext);
return (
<div
ref={ref}
{...props}
data-input-otp-slot
className={slots?.slot({className, isActive, isFilled: !!char})}
data-active={isActive || undefined}
>
{char ? (
<div data-input-otp-slot-value className={slots?.slotValue()}>
{char}
</div>
) : null}
{hasFakeCaret && isActive ? <div data-input-otp-caret className={slots?.caret()} /> : null}
</div>
);
},
);
InputOTPSlot.displayName = "HeroUI.InputOTP.Slot";
/* -----------------------------------------------------------------------------------------------*/
interface InputOTPSeparatorProps {
className?: string;
}
const InputOTPSeparator = React.forwardRef<HTMLDivElement, InputOTPSeparatorProps>(
({className, ...props}, ref) => {
const {slots} = useContext(InputOTPContext);
return (
<div
ref={ref}
data-input-otp-separator
className={slots?.separator({className})}
{...props}
/>
);
},
);
InputOTPSeparator.displayName = "HeroUI.InputOTP.Separator";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const InputOTP = Object.assign(
{},
{
Root: InputOTPRoot,
Group: InputOTPGroup,
Slot: InputOTPSlot,
Separator: InputOTPSeparator,
},
);
export type {InputOTPRootProps, InputOTPGroupProps, InputOTPSlotProps, InputOTPSeparatorProps};
@@ -0,0 +1,2 @@
export {Label, type LabelProps} from "./label";
export {labelVariants, type LabelVariants} from "./label.styles";
@@ -0,0 +1,33 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const labelVariants = tv({
base: ["text-base font-medium", "transition-colors duration-200", "select-none"],
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
variant: {
default: "text-foreground",
muted: "text-muted-foreground",
destructive: "text-destructive",
},
required: {
true: "after:text-destructive after:ml-0.5 after:content-['*']",
},
disabled: {
true: "cursor-not-allowed opacity-[var(--disabled-opacity)]",
},
},
defaultVariants: {
size: "md",
variant: "default",
required: false,
disabled: false,
},
});
export type LabelVariants = VariantProps<typeof labelVariants>;
@@ -0,0 +1,33 @@
"use client";
import type {LabelVariants} from "./label.styles";
import type {LabelProps as LabelPrimitiveProps} from "react-aria-components";
import React from "react";
import {Label as LabelPrimitive} from "react-aria-components";
import {labelVariants} from "./label.styles";
interface LabelProps extends LabelPrimitiveProps, LabelVariants {
ref?: React.Ref<HTMLLabelElement>;
}
const Label = React.forwardRef<React.ElementRef<typeof LabelPrimitive>, LabelProps>(
({children, className, disabled, required, size, variant, ...rest}, ref) => {
return (
<LabelPrimitive
ref={ref}
className={labelVariants({size, variant, required, disabled, className})}
data-slot="label"
{...rest}
>
{children}
</LabelPrimitive>
);
},
);
Label.displayName = "HeroUI.Label";
export type {LabelProps};
export {Label};
@@ -10,7 +10,7 @@ export const popoverVariants = tv({
base: [tooltipBase, "p-0"],
dialog: ["p-3", focusRingClasses],
heading: "font-medium",
trigger: [focusRingClasses],
trigger: ["cursor-pointer", focusRingClasses],
},
});
@@ -0,0 +1,9 @@
export {Radio, RadioGroup} from "./radio";
export {radioVariants, radioGroupVariants, type RadioGroupVariants} from "./radio.styles";
export type {
RadioGroupRootProps,
RadioGroupItemsProps,
RadioRootProps,
RadioIndicatorProps,
RadioLabelProps,
} from "./radio";
@@ -0,0 +1,215 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Description} from "../description";
import {FieldError} from "../fieldset";
import {Label} from "../label";
import {Radio, RadioGroup} from "./radio";
const meta = {
title: "Components/RadioGroup",
component: RadioGroup.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof RadioGroup.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<RadioGroup.Root defaultValue="1">
<Label>Select an option</Label>
<RadioGroup.Items>
<Radio.Root value="1">
<Radio.Indicator />
<Radio.Label>Option 1</Radio.Label>
</Radio.Root>
<Radio.Root value="2">
<Radio.Indicator />
<Radio.Label>Option 2</Radio.Label>
</Radio.Root>
<Radio.Root value="3">
<Radio.Indicator />
<Radio.Label>Option 3</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
),
};
export const Horizontal: Story = {
render: () => (
<RadioGroup.Root defaultValue="small" orientation="horizontal">
<Label>Choose size</Label>
<RadioGroup.Items>
<Radio.Root value="small">
<Radio.Indicator />
<Radio.Label>Small</Radio.Label>
</Radio.Root>
<Radio.Root value="medium">
<Radio.Indicator />
<Radio.Label>Medium</Radio.Label>
</Radio.Root>
<Radio.Root value="large">
<Radio.Indicator />
<Radio.Label>Large</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
),
};
export const WithDescription: Story = {
render: () => (
<RadioGroup.Root defaultValue="basic">
<Label>Select your plan</Label>
<Description>Choose the plan that best fits your needs</Description>
<RadioGroup.Items>
<Radio.Root value="basic">
<Radio.Indicator />
<Radio.Label>Basic</Radio.Label>
</Radio.Root>
<Radio.Root value="premium">
<Radio.Indicator />
<Radio.Label>Premium</Radio.Label>
</Radio.Root>
<Radio.Root value="business">
<Radio.Indicator />
<Radio.Label>Business</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
),
};
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-6">
<RadioGroup.Root isDisabled defaultValue="1">
<Label>Disabled group</Label>
<RadioGroup.Items>
<Radio.Root value="1">
<Radio.Indicator />
<Radio.Label>Option 1</Radio.Label>
</Radio.Root>
<Radio.Root value="2">
<Radio.Indicator />
<Radio.Label>Option 2</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
<RadioGroup.Root defaultValue="1">
<Label>Individual disabled</Label>
<RadioGroup.Items>
<Radio.Root value="1">
<Radio.Indicator />
<Radio.Label>Option 1</Radio.Label>
</Radio.Root>
<Radio.Root isDisabled value="2">
<Radio.Indicator />
<Radio.Label>Option 2 (disabled)</Radio.Label>
</Radio.Root>
<Radio.Root value="3">
<Radio.Indicator />
<Radio.Label>Option 3</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
</div>
),
};
export const Invalid: Story = {
render: () => (
<RadioGroup.Root isInvalid isRequired>
<Label>Select your preference</Label>
<RadioGroup.Items>
<Radio.Root value="yes">
<Radio.Indicator />
<Radio.Label>Yes</Radio.Label>
</Radio.Root>
<Radio.Root value="no">
<Radio.Indicator />
<Radio.Label>No</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
<FieldError>Please select an option</FieldError>
</RadioGroup.Root>
),
};
export const Controlled: Story = {
render: () => {
const [value, setValue] = React.useState("red");
return (
<div className="flex flex-col gap-4">
<RadioGroup.Root value={value} onChange={setValue}>
<Label>Select a color</Label>
<RadioGroup.Items>
<Radio.Root value="red">
<Radio.Indicator />
<Radio.Label>Red</Radio.Label>
</Radio.Root>
<Radio.Root value="green">
<Radio.Indicator />
<Radio.Label>Green</Radio.Label>
</Radio.Root>
<Radio.Root value="blue">
<Radio.Indicator />
<Radio.Label>Blue</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
<p className="text-muted-foreground text-sm">Selected: {value}</p>
</div>
);
},
};
export const LongLabels: Story = {
render: () => (
<RadioGroup.Root className="max-w-md" defaultValue="1">
<Label>Terms and Conditions</Label>
<RadioGroup.Items>
<Radio.Root value="1">
<Radio.Indicator />
<Radio.Label>
I agree to receive marketing emails and understand that I can unsubscribe at any time
</Radio.Label>
</Radio.Root>
<Radio.Root value="2">
<Radio.Indicator />
<Radio.Label>
I prefer not to receive marketing emails but would like to stay informed about my
account
</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
),
};
export const CustomStyles: Story = {
render: () => (
<RadioGroup.Root defaultValue="1">
<Label>Custom styled options</Label>
<RadioGroup.Items>
<Radio.Root className="hover:bg-muted/50 rounded-lg border p-4" value="1">
<Radio.Indicator />
<Radio.Label className="font-semibold">Premium Plan</Radio.Label>
</Radio.Root>
<Radio.Root className="hover:bg-muted/50 rounded-lg border p-4" value="2">
<Radio.Indicator />
<Radio.Label className="font-semibold">Basic Plan</Radio.Label>
</Radio.Root>
</RadioGroup.Items>
</RadioGroup.Root>
),
};
@@ -0,0 +1,79 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {focusRingClasses} from "../../utils/compose";
export const radioVariants = tv({
slots: {
base: "group flex cursor-pointer items-center gap-3",
wrapper: [
"relative inline-flex h-4 w-4 shrink-0 items-center justify-center",
"rounded-full border-2",
"transition-all duration-200",
focusRingClasses,
// Default state
"border-muted-foreground/50 bg-transparent",
// Hover state
"group-data-[hovered=true]:border-foreground/70",
// Pressed state
"group-data-[pressed=true]:scale-[0.97]",
// Selected state
"group-data-[selected=true]:border-accent",
// Focus state (both focused and focus-visible)
"group-data-[focused=true]:border-foreground",
"group-data-[focus-visible=true]:border-foreground",
// Invalid/Error state
"group-data-[invalid=true]:border-danger",
"group-data-[invalid=true]:group-data-[selected=true]:border-danger",
// Disabled state
"group-data-[disabled=true]:cursor-not-allowed group-data-[disabled=true]:opacity-[var(--disabled-opacity)]",
"group-data-[disabled=true]:group-data-[hovered=true]:border-muted-foreground/50",
],
indicator: [
"absolute inset-0 m-auto h-1.5 w-1.5",
"bg-accent rounded-full",
"scale-0 opacity-0",
"transition-all duration-200",
// Selected state
"group-data-[selected=true]:scale-100 group-data-[selected=true]:opacity-100",
// Error state indicator color
"group-data-[invalid=true]:bg-danger",
],
label: [
"text-foreground select-none",
"transition-colors duration-200",
"group-data-[disabled=true]:opacity-[var(--disabled-opacity)]",
],
},
variants: {
isDisabled: {
true: {
base: "cursor-not-allowed",
},
},
},
defaultVariants: {},
});
export const radioGroupVariants = tv({
slots: {
base: "flex flex-col gap-2",
items: "flex flex-col gap-2",
},
variants: {
orientation: {
horizontal: {
items: "flex-row gap-4",
},
vertical: {
items: "flex-col gap-2",
},
},
},
defaultVariants: {
orientation: "vertical",
},
});
export type RadioGroupVariants = VariantProps<typeof radioGroupVariants>;
@@ -0,0 +1,168 @@
"use client";
import type {RadioGroupVariants} from "./radio.styles";
import type {
RadioGroupProps as RadioGroupPrimitiveProps,
RadioProps as RadioPrimitiveProps,
} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {RadioGroup as RadioGroupPrimitive, Radio as RadioPrimitive} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {radioGroupVariants, radioVariants} from "./radio.styles";
/* -------------------------------------------------------------------------------------------------
* RadioGroup Context
* -----------------------------------------------------------------------------------------------*/
interface RadioGroupContext {
slots?: ReturnType<typeof radioGroupVariants>;
}
const RadioGroupContext = createContext<RadioGroupContext>({});
/* -------------------------------------------------------------------------------------------------
* RadioGroup
* -----------------------------------------------------------------------------------------------*/
interface RadioGroupRootProps extends RadioGroupPrimitiveProps, RadioGroupVariants {}
const RadioGroupRoot = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive>,
RadioGroupRootProps
>(({children, className, orientation, ...props}, ref) => {
const slots = React.useMemo(() => radioGroupVariants({orientation}), [orientation]);
return (
<RadioGroupContext.Provider value={{slots}}>
<RadioGroupPrimitive
ref={ref}
data-radio-group
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</RadioGroupPrimitive>
</RadioGroupContext.Provider>
);
});
RadioGroupRoot.displayName = "HeroUI.RadioGroup.Root";
/* -----------------------------------------------------------------------------------------------*/
interface RadioGroupItemsProps extends React.HTMLAttributes<HTMLDivElement> {}
const RadioGroupItems = React.forwardRef<HTMLDivElement, RadioGroupItemsProps>(
({className, ...props}, ref) => {
const {slots} = useContext(RadioGroupContext);
return (
<div ref={ref} data-radio-group-items className={slots?.items({className})} {...props} />
);
},
);
RadioGroupItems.displayName = "HeroUI.RadioGroup.Items";
/* -------------------------------------------------------------------------------------------------
* Radio
* -----------------------------------------------------------------------------------------------*/
interface RadioContext {
slots?: ReturnType<typeof radioVariants>;
}
const RadioContext = createContext<RadioContext>({});
interface RadioRootProps extends RadioPrimitiveProps {
/** The name of the radio button, used when submitting an HTML form. */
name?: string;
}
const RadioRoot = React.forwardRef<React.ElementRef<typeof RadioPrimitive>, RadioRootProps>(
({children, className, ...props}, ref) => {
const slots = React.useMemo(
() => radioVariants({isDisabled: props.isDisabled}),
[props.isDisabled],
);
return (
<RadioContext.Provider value={{slots}}>
<RadioPrimitive
ref={ref}
data-radio
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</RadioPrimitive>
</RadioContext.Provider>
);
},
);
RadioRoot.displayName = "HeroUI.Radio.Root";
/* -----------------------------------------------------------------------------------------------*/
interface RadioIndicatorProps extends React.HTMLAttributes<HTMLSpanElement> {}
const RadioIndicator = React.forwardRef<HTMLSpanElement, RadioIndicatorProps>(
({className, ...props}, ref) => {
const {slots} = useContext(RadioContext);
return (
<span ref={ref} data-radio-wrapper className={slots?.wrapper({className})} {...props}>
<span data-radio-indicator className={slots?.indicator()} />
</span>
);
},
);
RadioIndicator.displayName = "HeroUI.Radio.Indicator";
/* -----------------------------------------------------------------------------------------------*/
interface RadioLabelProps extends React.HTMLAttributes<HTMLSpanElement> {}
const RadioLabel = React.forwardRef<HTMLSpanElement, RadioLabelProps>(
({className, ...props}, ref) => {
const {slots} = useContext(RadioContext);
return <span ref={ref} data-radio-label className={slots?.label({className})} {...props} />;
},
);
RadioLabel.displayName = "HeroUI.Radio.Label";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Radio = Object.assign(
{},
{
Root: RadioRoot,
Indicator: RadioIndicator,
Label: RadioLabel,
},
);
export const RadioGroup = Object.assign(
{},
{
Root: RadioGroupRoot,
Items: RadioGroupItems,
},
);
export type {
RadioGroupRootProps,
RadioGroupItemsProps,
RadioRootProps,
RadioIndicatorProps,
RadioLabelProps,
};
@@ -0,0 +1,14 @@
export * as Slider from "./slider";
export {sliderVariants, type SliderVariants} from "./slider.styles";
export type {
SliderRootProps,
SliderHeaderProps,
SliderLabelProps,
SliderOutputProps,
SliderTrackProps,
SliderFillProps,
SliderThumbProps,
SliderMarksProps,
} from "./slider";
@@ -0,0 +1,276 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Slider} from "./slider";
const meta: Meta<typeof Slider.Root> = {
title: "Components/Slider",
component: Slider.Root,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div className="w-96 p-8">
<Story />
</div>
),
],
tags: ["autodocs"],
argTypes: {
orientation: {
control: {type: "select"},
options: ["horizontal", "vertical"],
},
isDisabled: {
control: {type: "boolean"},
},
},
};
export default meta;
type Story = StoryObj<typeof Slider.Root>;
export const Default: Story = {
render: (args) => (
<Slider.Root defaultValue={50} {...args}>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Title</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
</>
)}
</Slider.Root>
),
};
export const Disabled: Story = {
args: {
isDisabled: true,
},
render: (args) => (
<Slider.Root defaultValue={30} {...args}>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Title</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
</>
)}
</Slider.Root>
),
};
export const WithMarks: Story = {
render: () => (
<Slider.Root defaultValue={50} step={25}>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Title</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
<Slider.Marks>
<span>20%</span>
<span>50%</span>
<span>80%</span>
</Slider.Marks>
</>
)}
</Slider.Root>
),
};
export const Range: Story = {
render: () => {
const [value, setValue] = React.useState([20, 80]);
return (
<Slider.Root value={value} onChange={(newValue) => setValue(newValue as number[])}>
<Slider.Header>
<Slider.Label>Title</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill
style={{
left: `${value[0] ?? 0}%`,
width: `${(value[1] ?? 0) - (value[0] ?? 0)}%`,
}}
/>
<Slider.Thumb index={0} />
<Slider.Thumb index={1} />
</Slider.Track>
<Slider.Marks>
<span>20%</span>
<span>50%</span>
<span>80%</span>
</Slider.Marks>
</Slider.Root>
);
},
};
export const Controlled: Story = {
render: () => {
const [value, setValue] = React.useState(50);
return (
<div className="space-y-4">
<Slider.Root value={value} onChange={(newValue) => setValue(newValue as number)}>
<Slider.Header>
<Slider.Label>Price</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={value} />
<Slider.Thumb />
</Slider.Track>
</Slider.Root>
<p className="text-gray-11 text-sm">External value: {value}</p>
</div>
);
},
};
export const Examples: Story = {
render: () => (
<div className="space-y-8">
{/* Light theme example */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<Slider.Root defaultValue={50} maxValue={100000}>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Price</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
</>
)}
</Slider.Root>
</div>
{/* Dark theme example */}
<div className="bg-gray-12 rounded-lg p-6">
<Slider.Root
className="[&_[data-slider-output]]:text-gray-3 [&_[data-slider-track]]:bg-gray-9 [&_[data-slider-fill]]:bg-white [&_[data-slider-label]]:text-white [&_[data-slider-thumb]]:border-white"
defaultValue={50}
maxValue={100000}
>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Price</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
</>
)}
</Slider.Root>
</div>
</div>
),
};
export const SingleWithMarks: Story = {
render: () => {
const [value, setValue] = React.useState(50);
return (
<Slider.Root
maxValue={100}
minValue={0}
step={25}
value={value}
onChange={(newValue) => setValue(newValue as number)}
>
<Slider.Header>
<Slider.Label>Title</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={value} />
<Slider.Thumb />
</Slider.Track>
<Slider.Marks>
<span>20%</span>
<span>50%</span>
<span>80%</span>
</Slider.Marks>
</Slider.Root>
);
},
};
export const Vertical: Story = {
decorators: [
(Story) => (
<div className="h-64 w-96 p-8">
<Story />
</div>
),
],
render: () => (
<Slider.Root className="h-full" defaultValue={60} orientation="vertical">
{({state}) => (
<>
<Slider.Label>Volume</Slider.Label>
<Slider.Output />
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
</>
)}
</Slider.Root>
),
};
export const WithSteps: Story = {
render: () => (
<Slider.Root defaultValue={50} maxValue={100} minValue={0} step={10}>
{({state}) => (
<>
<Slider.Header>
<Slider.Label>Quality</Slider.Label>
<Slider.Output />
</Slider.Header>
<Slider.Track>
<Slider.Fill percentage={state.getThumbPercent(0) * 100} />
<Slider.Thumb />
</Slider.Track>
<div className="text-gray-11 mt-1 flex justify-between text-xs">
{[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((mark) => (
<span key={mark} className="w-0">
{mark}
</span>
))}
</div>
</>
)}
</Slider.Root>
),
};
@@ -0,0 +1,107 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const sliderVariants = tv({
slots: {
base: [
"relative",
"flex",
"flex-col",
"gap-1",
"touch-none",
"select-none",
"w-full",
"data-[orientation=vertical]:h-full",
"data-[orientation=vertical]:w-auto",
"data-[orientation=vertical]:flex-col",
],
header: ["flex", "justify-between", "items-center", "mb-1"],
label: ["text-sm", "font-medium", "text-gray-12"],
output: ["text-sm", "text-gray-11", "tabular-nums"],
track: [
"relative",
"bg-gray-5",
"rounded-full",
"grow",
"cursor-pointer",
"data-[disabled]:cursor-not-allowed",
"data-[disabled]:opacity-50",
// Horizontal
"data-[orientation=horizontal]:w-full",
"data-[orientation=horizontal]:h-1",
// Vertical
"data-[orientation=vertical]:h-full",
"data-[orientation=vertical]:w-1",
],
fill: [
"absolute",
"bg-gray-12",
"rounded-full",
"data-[disabled]:bg-gray-8",
// Horizontal
"data-[orientation=horizontal]:h-full",
"data-[orientation=horizontal]:left-0",
"data-[orientation=horizontal]:top-0",
// Vertical
"data-[orientation=vertical]:w-full",
"data-[orientation=vertical]:bottom-0",
"data-[orientation=vertical]:left-0",
],
thumb: [
"absolute",
"rounded-full",
"bg-white",
"border-2",
"border-gray-12",
"shadow-sm",
"cursor-grab",
"data-[dragging]:cursor-grabbing",
"data-[pressed]:scale-95",
"data-[pressed]:shadow-none",
"outline-none",
"data-[focus-visible]:ring-2",
"data-[focus-visible]:ring-gray-7",
"data-[focus-visible]:ring-offset-2",
"data-[focus-visible]:ring-offset-white",
"data-[disabled]:cursor-not-allowed",
"data-[disabled]:border-gray-8",
"data-[disabled]:opacity-50",
// Size
"h-5",
"w-5",
// Position
"data-[orientation=horizontal]:top-1/2",
"data-[orientation=horizontal]:-translate-y-1/2",
"data-[orientation=vertical]:left-1/2",
"data-[orientation=vertical]:-translate-x-1/2",
// Transition
"transition-all",
"duration-150",
],
marks: ["flex", "justify-between", "mt-1", "text-xs", "text-gray-11"],
},
variants: {
orientation: {
horizontal: {},
vertical: {
base: "items-center",
},
},
isDisabled: {
true: {
base: "cursor-not-allowed opacity-50",
label: "text-gray-8",
output: "text-gray-8",
track: "bg-gray-4 cursor-not-allowed",
fill: "bg-gray-8",
thumb: "border-gray-8 cursor-not-allowed",
},
},
},
defaultVariants: {
orientation: "horizontal",
},
});
export type SliderVariants = VariantProps<typeof sliderVariants>;
@@ -0,0 +1,265 @@
"use client";
import type {SliderVariants} from "./slider.styles";
import type {
SliderProps as SliderPrimitiveProps,
SliderThumbProps as SliderThumbPrimitiveProps,
SliderTrackProps as SliderTrackPrimitiveProps,
} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {
Label as LabelPrimitive,
SliderOutput as SliderOutputPrimitive,
Slider as SliderPrimitive,
SliderThumb as SliderThumbPrimitive,
SliderTrack as SliderTrackPrimitive,
} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {sliderVariants} from "./slider.styles";
/* -------------------------------------------------------------------------------------------------
* Slider Context
* -----------------------------------------------------------------------------------------------*/
interface SliderContext {
slots?: ReturnType<typeof sliderVariants>;
orientation?: "horizontal" | "vertical";
isDisabled?: boolean;
}
const SliderContext = createContext<SliderContext>({});
/* -------------------------------------------------------------------------------------------------
* Slider
* -----------------------------------------------------------------------------------------------*/
interface SliderRootProps extends SliderPrimitiveProps, SliderVariants {}
const SliderRoot = React.forwardRef<React.ElementRef<typeof SliderPrimitive>, SliderRootProps>(
({children, className, orientation = "horizontal", ...props}, ref) => {
const slots = React.useMemo(
() =>
sliderVariants({
orientation,
isDisabled: props.isDisabled,
}),
[orientation, props.isDisabled],
);
return (
<SliderContext.Provider value={{slots, orientation, isDisabled: props.isDisabled}}>
<SliderPrimitive
ref={ref}
data-slider
orientation={orientation}
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</SliderPrimitive>
</SliderContext.Provider>
);
},
);
SliderRoot.displayName = "HeroUI.Slider.Root";
/* -------------------------------------------------------------------------------------------------
* Slider Header
* -----------------------------------------------------------------------------------------------*/
interface SliderHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
const SliderHeader = React.forwardRef<HTMLDivElement, SliderHeaderProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SliderContext);
return <div ref={ref} data-slider-header className={slots?.header({className})} {...props} />;
},
);
SliderHeader.displayName = "HeroUI.Slider.Header";
/* -------------------------------------------------------------------------------------------------
* Slider Label
* -----------------------------------------------------------------------------------------------*/
interface SliderLabelProps extends React.ComponentProps<typeof LabelPrimitive> {}
const SliderLabel = React.forwardRef<React.ElementRef<typeof LabelPrimitive>, SliderLabelProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SliderContext);
return (
<LabelPrimitive
ref={ref}
data-slider-label
className={slots?.label({className})}
{...props}
/>
);
},
);
SliderLabel.displayName = "HeroUI.Slider.Label";
/* -------------------------------------------------------------------------------------------------
* Slider Output
* -----------------------------------------------------------------------------------------------*/
interface SliderOutputProps extends React.ComponentProps<typeof SliderOutputPrimitive> {}
const SliderOutput = React.forwardRef<
React.ElementRef<typeof SliderOutputPrimitive>,
SliderOutputProps
>(({className, ...props}, ref) => {
const {slots} = useContext(SliderContext);
return (
<SliderOutputPrimitive
ref={ref}
data-slider-output
className={composeTwRenderProps(className, slots?.output())}
{...props}
/>
);
});
SliderOutput.displayName = "HeroUI.Slider.Output";
/* -------------------------------------------------------------------------------------------------
* Slider Track
* -----------------------------------------------------------------------------------------------*/
interface SliderTrackProps extends SliderTrackPrimitiveProps {}
const SliderTrack = React.forwardRef<
React.ElementRef<typeof SliderTrackPrimitive>,
SliderTrackProps
>(({children, className, ...props}, ref) => {
const {isDisabled, slots} = useContext(SliderContext);
return (
<SliderTrackPrimitive
ref={ref}
data-slider-track
className={composeTwRenderProps(className, slots?.track())}
data-disabled={isDisabled || undefined}
{...props}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</SliderTrackPrimitive>
);
});
SliderTrack.displayName = "HeroUI.Slider.Track";
/* -------------------------------------------------------------------------------------------------
* Slider Fill
* -----------------------------------------------------------------------------------------------*/
interface SliderFillProps extends React.HTMLAttributes<HTMLDivElement> {
percentage?: number;
}
const SliderFill = React.forwardRef<HTMLDivElement, SliderFillProps>(
({className, percentage, style, ...props}, ref) => {
const {isDisabled, orientation, slots} = useContext(SliderContext);
const fillStyle = React.useMemo(() => {
if (percentage === undefined) return style;
return {
...style,
...(orientation === "horizontal" ? {width: `${percentage}%`} : {height: `${percentage}%`}),
};
}, [percentage, orientation, style]);
return (
<div
ref={ref}
data-slider-fill
className={slots?.fill({className})}
data-disabled={isDisabled || undefined}
style={fillStyle}
{...props}
/>
);
},
);
SliderFill.displayName = "HeroUI.Slider.Fill";
/* -------------------------------------------------------------------------------------------------
* Slider Thumb
* -----------------------------------------------------------------------------------------------*/
interface SliderThumbProps extends SliderThumbPrimitiveProps {}
const SliderThumb = React.forwardRef<
React.ElementRef<typeof SliderThumbPrimitive>,
SliderThumbProps
>(({children, className, ...props}, ref) => {
const {slots} = useContext(SliderContext);
return (
<SliderThumbPrimitive
ref={ref}
data-slider-thumb
className={composeTwRenderProps(className, slots?.thumb())}
{...props}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</SliderThumbPrimitive>
);
});
SliderThumb.displayName = "HeroUI.Slider.Thumb";
/* -------------------------------------------------------------------------------------------------
* Slider Marks
* -----------------------------------------------------------------------------------------------*/
interface SliderMarksProps extends React.HTMLAttributes<HTMLDivElement> {}
const SliderMarks = React.forwardRef<HTMLDivElement, SliderMarksProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SliderContext);
return <div ref={ref} data-slider-marks className={slots?.marks({className})} {...props} />;
},
);
SliderMarks.displayName = "HeroUI.Slider.Marks";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Slider = Object.assign(
{},
{
Root: SliderRoot,
Header: SliderHeader,
Label: SliderLabel,
Output: SliderOutput,
Track: SliderTrack,
Fill: SliderFill,
Thumb: SliderThumb,
Marks: SliderMarks,
},
);
export type {
SliderRootProps,
SliderHeaderProps,
SliderLabelProps,
SliderOutputProps,
SliderTrackProps,
SliderFillProps,
SliderThumbProps,
SliderMarksProps,
};
@@ -0,0 +1,10 @@
export * as Switch from "./switch";
export * as SwitchGroup from "./switch";
export {switchVariants, switchGroupVariants, type SwitchGroupVariants} from "./switch.styles";
export type {
SwitchGroupRootProps,
SwitchGroupItemsProps,
SwitchRootProps,
SwitchControlProps,
SwitchLabelProps,
} from "./switch";
@@ -0,0 +1,201 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {Switch, SwitchGroup} from "./switch";
const meta: Meta<typeof Switch.Root> = {
title: "Components/Switch",
component: Switch.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Switch.Root>;
export const Default: Story = {
render: () => (
<Switch.Root>
<Switch.Control />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Root>
),
};
export const Disabled: Story = {
render: () => (
<Switch.Root isDisabled>
<Switch.Control />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Root>
),
};
export const DefaultSelected: Story = {
render: () => (
<Switch.Root defaultSelected>
<Switch.Control />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Root>
),
};
export const Controlled: Story = {
render: function ControlledSwitch() {
const [isSelected, setIsSelected] = React.useState(false);
return (
<div className="flex flex-col gap-4">
<Switch.Root isSelected={isSelected} onChange={setIsSelected}>
<Switch.Control />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Root>
<p className="text-muted-foreground text-sm">Switch is {isSelected ? "on" : "off"}</p>
</div>
);
},
};
export const WithoutLabel: Story = {
render: () => (
<Switch.Root aria-label="Enable notifications">
<Switch.Control />
</Switch.Root>
),
};
export const LabelBefore: Story = {
render: () => (
<Switch.Root>
<Switch.Label>Enable notifications</Switch.Label>
<Switch.Control />
</Switch.Root>
),
};
export const Group: Story = {
render: () => (
<SwitchGroup.Root>
<SwitchGroup.Items>
<Switch.Root name="notifications">
<Switch.Control />
<Switch.Label>Allow Notifications</Switch.Label>
</Switch.Root>
<Switch.Root name="marketing">
<Switch.Control />
<Switch.Label>Marketing emails</Switch.Label>
</Switch.Root>
<Switch.Root name="social">
<Switch.Control />
<Switch.Label>Social media updates</Switch.Label>
</Switch.Root>
</SwitchGroup.Items>
</SwitchGroup.Root>
),
};
export const GroupHorizontal: Story = {
render: () => (
<SwitchGroup.Root orientation="horizontal">
<SwitchGroup.Items>
<Switch.Root name="notifications">
<Switch.Control />
<Switch.Label>Notifications</Switch.Label>
</Switch.Root>
<Switch.Root name="marketing">
<Switch.Control />
<Switch.Label>Marketing</Switch.Label>
</Switch.Root>
<Switch.Root name="social">
<Switch.Control />
<Switch.Label>Social</Switch.Label>
</Switch.Root>
</SwitchGroup.Items>
</SwitchGroup.Root>
),
};
export const WithDescription: Story = {
render: () => (
<div className="max-w-sm">
<Switch.Root>
<div className="flex gap-3">
<Switch.Control />
<div className="flex flex-col gap-1">
<Switch.Label>Public profile</Switch.Label>
<p className="text-muted-foreground text-sm">
Allow others to see your profile information
</p>
</div>
</div>
</Switch.Root>
</div>
),
};
export const CustomStyling: Story = {
render: () => (
<Switch.Root>
<Switch.Control className="h-7 w-12 data-[selected=true]:bg-green-500" />
<Switch.Label className="text-lg font-medium">Custom styled switch</Switch.Label>
</Switch.Root>
),
};
export const RenderProps: Story = {
render: () => (
<Switch.Root>
{({isSelected}) => (
<>
<Switch.Control />
<Switch.Label>{isSelected ? "Enabled" : "Disabled"}</Switch.Label>
</>
)}
</Switch.Root>
),
};
export const Form: Story = {
render: function FormExample() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
alert(
`Form submitted with:\n${Array.from(formData.entries())
.map(([key, value]) => `${key}: ${value}`)
.join("\n")}`,
);
};
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<SwitchGroup.Root>
<SwitchGroup.Items>
<Switch.Root name="notifications" value="on">
<Switch.Control />
<Switch.Label>Enable notifications</Switch.Label>
</Switch.Root>
<Switch.Root defaultSelected name="newsletter" value="on">
<Switch.Control />
<Switch.Label>Subscribe to newsletter</Switch.Label>
</Switch.Root>
<Switch.Root name="marketing" value="on">
<Switch.Control />
<Switch.Label>Receive marketing updates</Switch.Label>
</Switch.Root>
</SwitchGroup.Items>
</SwitchGroup.Root>
<button
className="bg-accent text-accent-foreground hover:bg-accent-hover mt-4 rounded-md px-4 py-2 text-sm font-medium"
type="submit"
>
Submit
</button>
</form>
);
},
};
@@ -0,0 +1,78 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {focusRingClasses} from "../../utils/compose";
export const switchVariants = tv({
slots: {
base: "group inline-flex cursor-pointer items-center gap-3",
control: [
"relative inline-flex h-6 w-11 shrink-0 items-center",
"rounded-full border-2",
"transition-all duration-200",
focusRingClasses,
// Default (off) state
"border-muted-foreground/30 bg-muted-foreground/20",
// Hover state (off)
"group-data-[hovered=true]:border-muted-foreground/40 group-data-[hovered=true]:bg-muted-foreground/30",
// Pressed state
"group-data-[pressed=true]:scale-[0.97]",
// Selected (on) state
"group-data-[selected=true]:border-accent group-data-[selected=true]:bg-accent",
// Hover state (on)
"group-data-[selected=true]:group-data-[hovered=true]:border-accent-hover group-data-[selected=true]:group-data-[hovered=true]:bg-accent-hover",
// Focus state
"group-data-[focused=true]:border-foreground/60",
"group-data-[focus-visible=true]:border-foreground/60",
// Disabled state
"group-data-[disabled=true]:cursor-not-allowed group-data-[disabled=true]:opacity-[var(--disabled-opacity)]",
"group-data-[disabled=true]:group-data-[hovered=true]:border-muted-foreground/30",
],
thumb: [
"absolute left-0.5 block h-4 w-4",
"rounded-full bg-white",
"shadow-sm",
"transition-transform duration-200",
// Selected (on) state - move thumb to right
"group-data-[selected=true]:translate-x-5",
// Pressed state
"group-data-[pressed=true]:w-5 group-data-[selected=true]:group-data-[pressed=true]:translate-x-4",
],
label: [
"text-foreground select-none",
"transition-colors duration-200",
"group-data-[disabled=true]:opacity-[var(--disabled-opacity)]",
],
},
variants: {
isDisabled: {
true: {
base: "cursor-not-allowed",
},
},
},
defaultVariants: {},
});
export const switchGroupVariants = tv({
slots: {
base: "flex flex-col gap-4",
items: "flex flex-col gap-3",
},
variants: {
orientation: {
horizontal: {
items: "flex-row gap-6",
},
vertical: {
items: "flex-col gap-3",
},
},
},
defaultVariants: {
orientation: "vertical",
},
});
export type SwitchGroupVariants = VariantProps<typeof switchGroupVariants>;
@@ -0,0 +1,156 @@
"use client";
import type {SwitchGroupVariants} from "./switch.styles";
import type {SwitchProps as SwitchPrimitiveProps} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {Switch as SwitchPrimitive} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {switchGroupVariants, switchVariants} from "./switch.styles";
/* -------------------------------------------------------------------------------------------------
* SwitchGroup Context
* -----------------------------------------------------------------------------------------------*/
interface SwitchGroupContext {
slots?: ReturnType<typeof switchGroupVariants>;
}
const SwitchGroupContext = createContext<SwitchGroupContext>({});
/* -------------------------------------------------------------------------------------------------
* SwitchGroup
* -----------------------------------------------------------------------------------------------*/
interface SwitchGroupRootProps extends React.HTMLAttributes<HTMLDivElement>, SwitchGroupVariants {}
const SwitchGroupRoot = React.forwardRef<HTMLDivElement, SwitchGroupRootProps>(
({children, className, orientation, ...props}, ref) => {
const slots = React.useMemo(() => switchGroupVariants({orientation}), [orientation]);
return (
<SwitchGroupContext.Provider value={{slots}}>
<div ref={ref} data-switch-group {...props} className={slots.base({className})}>
{children}
</div>
</SwitchGroupContext.Provider>
);
},
);
SwitchGroupRoot.displayName = "HeroUI.SwitchGroup.Root";
/* -----------------------------------------------------------------------------------------------*/
interface SwitchGroupItemsProps extends React.HTMLAttributes<HTMLDivElement> {}
const SwitchGroupItems = React.forwardRef<HTMLDivElement, SwitchGroupItemsProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SwitchGroupContext);
return (
<div ref={ref} data-switch-group-items className={slots?.items({className})} {...props} />
);
},
);
SwitchGroupItems.displayName = "HeroUI.SwitchGroup.Items";
/* -------------------------------------------------------------------------------------------------
* Switch
* -----------------------------------------------------------------------------------------------*/
interface SwitchContext {
slots?: ReturnType<typeof switchVariants>;
}
const SwitchContext = createContext<SwitchContext>({});
interface SwitchRootProps extends SwitchPrimitiveProps {}
const SwitchRoot = React.forwardRef<React.ElementRef<typeof SwitchPrimitive>, SwitchRootProps>(
({children, className, ...props}, ref) => {
const slots = React.useMemo(
() => switchVariants({isDisabled: props.isDisabled}),
[props.isDisabled],
);
return (
<SwitchContext.Provider value={{slots}}>
<SwitchPrimitive
ref={ref}
data-switch
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</SwitchPrimitive>
</SwitchContext.Provider>
);
},
);
SwitchRoot.displayName = "HeroUI.Switch.Root";
/* -----------------------------------------------------------------------------------------------*/
interface SwitchControlProps extends React.HTMLAttributes<HTMLSpanElement> {}
const SwitchControl = React.forwardRef<HTMLSpanElement, SwitchControlProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SwitchContext);
return (
<span ref={ref} data-switch-control className={slots?.control({className})} {...props}>
<span data-switch-thumb className={slots?.thumb()} />
</span>
);
},
);
SwitchControl.displayName = "HeroUI.Switch.Control";
/* -----------------------------------------------------------------------------------------------*/
interface SwitchLabelProps extends React.HTMLAttributes<HTMLSpanElement> {}
const SwitchLabel = React.forwardRef<HTMLSpanElement, SwitchLabelProps>(
({className, ...props}, ref) => {
const {slots} = useContext(SwitchContext);
return <span ref={ref} data-switch-label className={slots?.label({className})} {...props} />;
},
);
SwitchLabel.displayName = "HeroUI.Switch.Label";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const Switch = Object.assign(
{},
{
Root: SwitchRoot,
Control: SwitchControl,
Label: SwitchLabel,
},
);
export const SwitchGroup = Object.assign(
{},
{
Root: SwitchGroupRoot,
Items: SwitchGroupItems,
},
);
export type {
SwitchGroupRootProps,
SwitchGroupItemsProps,
SwitchRootProps,
SwitchControlProps,
SwitchLabelProps,
};
@@ -17,7 +17,7 @@ export const tabsVariants = tv({
],
tab: [
// Base styles
"relative w-full cursor-default cursor-pointer rounded-md text-center font-medium outline-none",
"relative w-full cursor-pointer rounded-md text-center font-medium outline-none",
// Orientation styles
"group-data-[orientation=horizontal]:px-3 group-data-[orientation=horizontal]:py-1",
"group-data-[orientation=vertical]:px-4 group-data-[orientation=vertical]:py-2",
@@ -0,0 +1,2 @@
export * as TextField from "./text-field";
export {textFieldVariants, type TextFieldVariants} from "./text-field.styles";
@@ -0,0 +1,153 @@
import type {Meta, StoryObj} from "@storybook/react";
import React from "react";
import {TextField} from "./text-field";
const meta: Meta<typeof TextField.Root> = {
title: "Components/TextField",
component: TextField.Root,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<div className="w-80">
<TextField.Root>
<TextField.Label>Your name</TextField.Label>
<TextField.Input placeholder="John" />
</TextField.Root>
</div>
),
};
export const Required: Story = {
render: () => (
<div className="w-80">
<TextField.Root isRequired>
<TextField.Label isRequired>Your name</TextField.Label>
<TextField.Input placeholder="John" />
</TextField.Root>
</div>
),
};
export const WithDescription: Story = {
render: () => (
<div className="w-80">
<TextField.Root>
<TextField.Label isRequired>Your name</TextField.Label>
<TextField.Input placeholder="John" />
<TextField.Description>We'll never share this with anyone else</TextField.Description>
</TextField.Root>
</div>
),
};
export const Invalid: Story = {
render: () => (
<div className="w-80">
<TextField.Root isInvalid>
<TextField.Label isRequired>Your age</TextField.Label>
<TextField.Input placeholder="18" type="number" />
<TextField.Error>Please enter a valid age</TextField.Error>
</TextField.Root>
</div>
),
};
export const Disabled: Story = {
render: () => (
<div className="w-80">
<TextField.Root isDisabled>
<TextField.Label>Your name</TextField.Label>
<TextField.Input placeholder="John" />
<TextField.Description>We'll never share this with anyone else</TextField.Description>
</TextField.Root>
</div>
),
};
export const TextArea: Story = {
render: () => (
<div className="w-80">
<TextField.Root>
<TextField.Label>Your message</TextField.Label>
<TextField.TextArea placeholder="Tell us more about yourself..." />
<TextField.Description>Min 50 characters</TextField.Description>
</TextField.Root>
</div>
),
};
export const InputTypes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<TextField.Root>
<TextField.Label>Your age</TextField.Label>
<TextField.Input placeholder="18" type="number" />
</TextField.Root>
<TextField.Root>
<TextField.Label>Your password</TextField.Label>
<TextField.Input placeholder="••••••••" type="password" />
</TextField.Root>
<TextField.Root>
<TextField.Label>Your email</TextField.Label>
<TextField.Input placeholder="john@example.com" type="email" />
</TextField.Root>
</div>
),
};
export const Controlled: Story = {
render: () => {
const [value, setValue] = React.useState("");
return (
<div className="w-80">
<TextField.Root>
<TextField.Label>Your name</TextField.Label>
<TextField.Input
placeholder="John"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<TextField.Description>Character count: {value.length}</TextField.Description>
</TextField.Root>
</div>
);
},
};
export const WithValidation: Story = {
render: () => {
const [value, setValue] = React.useState("");
const isInvalid = value.length > 0 && value.length < 3;
return (
<div className="w-80">
<TextField.Root isInvalid={isInvalid}>
<TextField.Label isRequired>Username</TextField.Label>
<TextField.Input
placeholder="john_doe"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{isInvalid ? (
<TextField.Error>Username must be at least 3 characters</TextField.Error>
) : (
<TextField.Description>Choose a unique username</TextField.Description>
)}
</TextField.Root>
</div>
);
},
};
@@ -0,0 +1,76 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {disabledClasses, focusRingClasses} from "../../utils";
export const textFieldVariants = tv({
slots: {
base: "flex w-full flex-col gap-1",
labelWrapper: "flex items-center gap-1",
label: ["text-foreground text-sm font-medium", "transition-colors duration-200"],
required: "text-danger text-sm font-medium",
inputWrapper: [
"relative rounded-xl",
"bg-neutral-50 backdrop-blur-0",
"min-h-8",
"transition-all duration-200",
"border border-[rgba(0,0,0,0.01)]",
"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)]",
"shadow-[0px_0px_0px_0px_inset_rgba(255,255,255,0.1)]",
focusRingClasses,
],
input: [
"w-full",
"bg-transparent",
"px-3 py-2",
"text-sm leading-5",
"text-foreground placeholder:text-neutral-500",
"outline-none",
"min-h-inherit",
],
textarea: [
"w-full",
"bg-transparent",
"px-3 py-2",
"text-sm leading-5",
"text-foreground placeholder:text-neutral-500",
"outline-none",
"resize-y",
"min-h-[80px]",
],
description: ["text-xs text-neutral-500", "px-1 pt-1"],
error: ["text-danger text-xs", "px-1 pt-1"],
},
variants: {
isDisabled: {
true: {
label: disabledClasses,
inputWrapper: [disabledClasses, "bg-neutral-50/50"],
input: disabledClasses,
textarea: disabledClasses,
description: disabledClasses,
},
},
isInvalid: {
true: {
inputWrapper: ["border-danger", "hover:border-danger", "focus-within:border-danger"],
},
},
},
compoundVariants: [
{
isDisabled: false,
isInvalid: false,
class: {
inputWrapper: [
"hover:bg-neutral-100",
"focus-within:bg-white",
"focus-within:shadow-[0px_1px_3px_0px_rgba(0,0,0,0.1),0px_1px_2px_0px_rgba(0,0,0,0.06)]",
],
},
},
],
});
export type TextFieldVariants = VariantProps<typeof textFieldVariants>;
@@ -0,0 +1,223 @@
"use client";
import type {TextFieldVariants} from "./text-field.styles";
import type {
FieldErrorProps as FieldErrorPrimitiveProps,
InputProps as InputPrimitiveProps,
LabelProps as LabelPrimitiveProps,
TextAreaProps as TextAreaPrimitiveProps,
TextFieldProps as TextFieldPrimitiveProps,
TextProps as TextPrimitiveProps,
} from "react-aria-components";
import React, {createContext, useContext} from "react";
import {
FieldError as FieldErrorPrimitive,
Input as InputPrimitive,
Label as LabelPrimitive,
TextArea as TextAreaPrimitive,
TextField as TextFieldPrimitive,
Text as TextPrimitive,
} from "react-aria-components";
import {composeTwRenderProps} from "../../utils/compose";
import {textFieldVariants} from "./text-field.styles";
/* -------------------------------------------------------------------------------------------------
* TextField Context
* -----------------------------------------------------------------------------------------------*/
interface TextFieldContext {
slots?: ReturnType<typeof textFieldVariants>;
}
const TextFieldContext = createContext<TextFieldContext>({});
/* -------------------------------------------------------------------------------------------------
* TextField
* -----------------------------------------------------------------------------------------------*/
interface TextFieldRootProps extends TextFieldPrimitiveProps, TextFieldVariants {}
const TextFieldRoot = React.forwardRef<
React.ElementRef<typeof TextFieldPrimitive>,
TextFieldRootProps
>(({children, className, isDisabled, isInvalid, ...props}, ref) => {
const slots = React.useMemo(
() => textFieldVariants({isDisabled, isInvalid}),
[isDisabled, isInvalid],
);
return (
<TextFieldContext.Provider value={{slots}}>
<TextFieldPrimitive
ref={ref}
data-text-field
isDisabled={isDisabled}
isInvalid={isInvalid}
{...props}
className={composeTwRenderProps(className, slots.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</TextFieldPrimitive>
</TextFieldContext.Provider>
);
});
TextFieldRoot.displayName = "HeroUI.TextField.Root";
/* -----------------------------------------------------------------------------------------------*/
interface TextFieldLabelProps extends LabelPrimitiveProps {
isRequired?: boolean;
}
const TextFieldLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive>,
TextFieldLabelProps
>(({children, className, isRequired, ...props}, ref) => {
const {slots} = useContext(TextFieldContext);
return (
<div data-text-field-label-wrapper className={slots?.labelWrapper()}>
<LabelPrimitive
ref={ref}
data-text-field-label
{...props}
className={slots?.label({className})}
>
{children}
</LabelPrimitive>
{!!isRequired && (
<span data-text-field-required className={slots?.required()}>
*
</span>
)}
</div>
);
});
TextFieldLabel.displayName = "HeroUI.TextField.Label";
/* -----------------------------------------------------------------------------------------------*/
interface TextFieldInputProps extends InputPrimitiveProps {}
const TextFieldInput = React.forwardRef<
React.ElementRef<typeof InputPrimitive>,
TextFieldInputProps
>(({className, ...props}, ref) => {
const {slots} = useContext(TextFieldContext);
return (
<div data-text-field-input-wrapper className={slots?.inputWrapper()}>
<InputPrimitive
ref={ref}
data-text-field-input
{...props}
className={composeTwRenderProps(className, slots?.input())}
/>
</div>
);
});
TextFieldInput.displayName = "HeroUI.TextField.Input";
/* -----------------------------------------------------------------------------------------------*/
interface TextFieldTextAreaProps extends TextAreaPrimitiveProps {}
const TextFieldTextArea = React.forwardRef<
React.ElementRef<typeof TextAreaPrimitive>,
TextFieldTextAreaProps
>(({className, ...props}, ref) => {
const {slots} = useContext(TextFieldContext);
return (
<div data-text-field-textarea-wrapper className={slots?.inputWrapper()}>
<TextAreaPrimitive
ref={ref}
data-text-field-textarea
{...props}
className={composeTwRenderProps(className, slots?.textarea())}
/>
</div>
);
});
TextFieldTextArea.displayName = "HeroUI.TextField.TextArea";
/* -----------------------------------------------------------------------------------------------*/
interface TextFieldDescriptionProps extends TextPrimitiveProps {}
const TextFieldDescription = React.forwardRef<
React.ElementRef<typeof TextPrimitive>,
TextFieldDescriptionProps
>(({className, ...props}, ref) => {
const {slots} = useContext(TextFieldContext);
return (
<TextPrimitive
ref={ref}
data-text-field-description
slot="description"
{...props}
className={slots?.description({className}) as string}
/>
);
});
TextFieldDescription.displayName = "HeroUI.TextField.Description";
/* -----------------------------------------------------------------------------------------------*/
interface TextFieldErrorProps extends FieldErrorPrimitiveProps {}
const TextFieldError = React.forwardRef<
React.ElementRef<typeof FieldErrorPrimitive>,
TextFieldErrorProps
>(({className, ...props}, ref) => {
const {slots} = useContext(TextFieldContext);
return (
<FieldErrorPrimitive
ref={ref}
data-text-field-error
{...props}
className={composeTwRenderProps(className, slots?.error())}
>
{(renderProps) =>
typeof props.children === "function" ? props.children(renderProps) : props.children
}
</FieldErrorPrimitive>
);
});
TextFieldError.displayName = "HeroUI.TextField.Error";
/* -------------------------------------------------------------------------------------------------
* Exports
* -----------------------------------------------------------------------------------------------*/
export const TextField = Object.assign(
{},
{
Root: TextFieldRoot,
Label: TextFieldLabel,
Input: TextFieldInput,
TextArea: TextFieldTextArea,
Description: TextFieldDescription,
Error: TextFieldError,
},
);
export type {
TextFieldRootProps,
TextFieldLabelProps,
TextFieldInputProps,
TextFieldTextAreaProps,
TextFieldDescriptionProps,
TextFieldErrorProps,
};
@@ -0,0 +1,2 @@
export {Text, type TextProps} from "./text";
export {textVariants, type TextVariants} from "./text.styles";
@@ -0,0 +1,29 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
export const textVariants = tv({
base: ["transition-colors duration-200"],
variants: {
size: {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
},
variant: {
default: "text-foreground",
muted: "text-muted-foreground",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
},
},
defaultVariants: {
size: "base",
variant: "default",
},
});
export type TextVariants = VariantProps<typeof textVariants>;
@@ -0,0 +1,28 @@
"use client";
import type {TextVariants} from "./text.styles";
import type {TextProps as TextPrimitiveProps} from "react-aria-components";
import React from "react";
import {Text as TextPrimitive} from "react-aria-components";
import {textVariants} from "./text.styles";
interface TextProps extends TextPrimitiveProps, TextVariants {
ref?: React.Ref<HTMLElement>;
}
const Text = React.forwardRef<React.ElementRef<typeof TextPrimitive>, TextProps>(
({children, className, size, variant, ...rest}, ref) => {
return (
<TextPrimitive ref={ref} className={textVariants({size, variant, className})} {...rest}>
{children}
</TextPrimitive>
);
},
);
Text.displayName = "HeroUI.Text";
export type {TextProps};
export {Text};
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "storybook dev --host 127.0.0.1 --port 6006 --no-open",
"build": "storybook build",
"start": "storybook start",
"lint": "eslint ."
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "19.1.0",
+29 -12
View File
@@ -171,12 +171,18 @@ importers:
packages/react:
dependencies:
'@internationalized/date':
specifier: 3.8.2
version: 3.8.2
'@radix-ui/react-avatar':
specifier: 1.1.7
version: 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot':
specifier: 1.2.3
version: 1.2.3(@types/react@19.1.2)(react@19.1.0)
input-otp:
specifier: 1.4.2
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-aria-components:
specifier: 1.8.0
version: 1.8.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1211,8 +1217,8 @@ packages:
cpu: [x64]
os: [win32]
'@internationalized/date@3.8.0':
resolution: {integrity: sha512-J51AJ0fEL68hE4CwGPa6E0PO6JDaVLd8aln48xFCSy7CZkZc96dGEGmLs2OEEbBxcsVZtfrqkXJwI2/MSG8yKw==}
'@internationalized/date@3.8.2':
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
'@internationalized/message@3.1.7':
resolution: {integrity: sha512-gLQlhEW4iO7DEFPf/U7IrIdA3UyLGS0opeqouaFwlMObLUzwexRjbygONHDVbC9G9oFLXsLyGKYkJwqXw/QADg==}
@@ -4952,6 +4958,12 @@ packages:
inline-style-parser@0.2.4:
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -7941,7 +7953,7 @@ snapshots:
'@img/sharp-win32-x64@0.34.1':
optional: true
'@internationalized/date@3.8.0':
'@internationalized/date@3.8.2':
dependencies:
'@swc/helpers': 0.5.17
@@ -8618,7 +8630,7 @@ snapshots:
'@parcel/source-map': 2.1.1
'@parcel/utils': 2.14.4
'@parcel/workers': 2.14.4(@parcel/core@2.14.4(@swc/helpers@0.5.17))
'@swc/helpers': 0.5.15
'@swc/helpers': 0.5.17
browserslist: 4.24.4
nullthrows: 1.1.1
regenerator-runtime: 0.14.1
@@ -9236,7 +9248,7 @@ snapshots:
'@react-aria/calendar@3.8.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@react-aria/i18n': 3.12.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/interactions': 3.25.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/live-announcer': 3.4.2
@@ -9317,7 +9329,7 @@ snapshots:
'@react-aria/datepicker@3.14.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@internationalized/number': 3.6.1
'@internationalized/string': 3.2.6
'@react-aria/focus': 3.20.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -9430,7 +9442,7 @@ snapshots:
'@react-aria/i18n@3.12.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@internationalized/message': 3.1.7
'@internationalized/number': 3.6.1
'@internationalized/string': 3.2.6
@@ -9832,7 +9844,7 @@ snapshots:
'@react-stately/calendar@3.8.0(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@react-stately/utils': 3.10.6(react@19.1.0)
'@react-types/calendar': 3.7.0(react@19.1.0)
'@react-types/shared': 3.29.0(react@19.1.0)
@@ -9888,7 +9900,7 @@ snapshots:
'@react-stately/datepicker@3.14.0(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@internationalized/string': 3.2.6
'@react-stately/form': 3.1.3(react@19.1.0)
'@react-stately/overlays': 3.6.15(react@19.1.0)
@@ -10102,7 +10114,7 @@ snapshots:
'@react-types/calendar@3.7.0(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@react-types/shared': 3.29.0(react@19.1.0)
react: 19.1.0
@@ -10124,7 +10136,7 @@ snapshots:
'@react-types/datepicker@3.12.0(react@19.1.0)':
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@react-types/calendar': 3.7.0(react@19.1.0)
'@react-types/overlays': 3.8.14(react@19.1.0)
'@react-types/shared': 3.29.0(react@19.1.0)
@@ -12711,6 +12723,11 @@ snapshots:
inline-style-parser@0.2.4: {}
input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -14054,7 +14071,7 @@ snapshots:
react-aria-components@1.8.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@internationalized/date': 3.8.0
'@internationalized/date': 3.8.2
'@internationalized/string': 3.2.6
'@react-aria/autocomplete': 3.0.0-beta.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/collections': 3.0.0-rc.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)