feat: ai workflow improved, initial structure of alpha components done
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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'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'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'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'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'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>;
|
||||
@@ -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";
|
||||
@@ -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'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'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};
|
||||
@@ -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",
|
||||
|
||||
Generated
+29
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user