chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@sim/emcn",
"version": "0.1.0",
"private": true,
"sideEffects": [
"**/*.css",
"**/components/code/prism.ts"
],
"type": "module",
"license": "Apache-2.0",
"engines": {
"bun": ">=1.2.13",
"node": ">=20.0.0"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./icons": {
"types": "./src/icons/index.ts",
"default": "./src/icons/index.ts"
},
"./*": {
"types": "./src/*",
"default": "./src/*"
}
},
"scripts": {
"type-check": "tsc --noEmit",
"test": "vitest run",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format ."
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
"lucide-react": ">=0.479.0",
"next": ">=15",
"prismjs": "^1.30.0",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"@types/prismjs": "^1.26.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.479.0",
"next": "16.2.6",
"prismjs": "^1.30.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
# EMCN Components Scope
These rules apply to `apps/sim/components/emcn/**`.
- Import from `@sim/emcn`, never from subpaths except CSS files.
- Use Radix UI primitives for accessibility where applicable.
- Use CVA when a component has 2+ variants; use direct `className` composition for single-style components.
- Export both the component and its variants helper when using CVA.
- Keep tokens consistent with the chip-pill canonical look: normal font-weight, `--text-body` value text, `--text-icon` icons at `size-[14px]`, `rounded-lg`. Components own their exact tokens (e.g. `Button` uses `rounded-[5px]`+`font-medium`). See `.claude/rules/emcn-components.md` for the full chip-chrome reference.
- Prefer `transition-colors` for interactive hover and active states.
- Use TSDoc when documenting public components or APIs.
@@ -0,0 +1,152 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
/**
* Variant styles for the Avatar component.
* Supports multiple sizes for different use cases.
*/
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
xs: 'h-3.5 w-3.5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
},
},
defaultVariants: {
size: 'md',
},
})
/**
* Variant styles for the status indicator.
*/
const avatarStatusVariants = cva(
'absolute bottom-0 right-0 rounded-full border-2 border-[var(--bg)]',
{
variants: {
status: {
online: 'bg-[var(--success)]',
offline: 'bg-[var(--text-muted)]',
busy: 'bg-[var(--error)]',
away: 'bg-[var(--caution)]',
},
size: {
xs: 'h-1.5 w-1.5 border',
sm: 'h-2 w-2',
md: 'h-2.5 w-2.5',
lg: 'h-3 w-3',
},
},
defaultVariants: {
status: 'online',
size: 'md',
},
}
)
type AvatarStatus = 'online' | 'offline' | 'busy' | 'away'
interface AvatarProps
extends React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>,
VariantProps<typeof avatarVariants> {
/** Shows a status indicator badge on the avatar */
status?: AvatarStatus
}
/**
* Avatar component for displaying user profile images with fallback support.
*
* @example
* ```tsx
* import { Avatar, AvatarImage, AvatarFallback } from '../../index'
*
* // Basic usage
* <Avatar>
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // With size variant
* <Avatar size="lg">
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // With status indicator
* <Avatar status="online">
* <AvatarImage src="/avatar.jpg" alt="User" />
* <AvatarFallback>JD</AvatarFallback>
* </Avatar>
*
* // All status types
* <Avatar status="online" /> // Green
* <Avatar status="offline" /> // Gray
* <Avatar status="busy" /> // Red
* <Avatar status="away" /> // Yellow/Amber
* ```
*/
const Avatar = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Root>, AvatarProps>(
({ className, size, status, children, ...props }, ref) => (
<div className='relative inline-flex'>
<AvatarPrimitive.Root
ref={ref}
className={cn(avatarVariants({ size }), className)}
{...props}
>
{children}
</AvatarPrimitive.Root>
{status && (
<span
data-slot='avatar-status'
className={cn(avatarStatusVariants({ status, size }))}
aria-label={`Status: ${status}`}
/>
)}
</div>
)
)
Avatar.displayName = 'Avatar'
/**
* Image component for Avatar. Renders the user's profile picture.
*/
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn(
'-outline-offset-1 aspect-square h-full w-full object-cover object-center outline outline-1 outline-black/5',
className
)}
{...props}
/>
))
AvatarImage.displayName = 'AvatarImage'
/**
* Fallback component for Avatar. Displays initials or icon when image is unavailable.
*/
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-[var(--border-1)] bg-[var(--surface-4)] font-medium text-[var(--text-secondary)] text-xs',
className
)}
{...props}
/>
))
AvatarFallback.displayName = 'AvatarFallback'
export { Avatar, AvatarImage, AvatarFallback, avatarVariants, avatarStatusVariants }
@@ -0,0 +1,114 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
/** Shared base styles for status color badge variants */
const STATUS_BASE = 'gap-1.5 rounded-md'
const badgeVariants = cva(
'inline-flex items-center font-medium focus:outline-none transition-colors',
{
variants: {
variant: {
default:
'gap-1 rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)] hover-hover:border-[var(--border-1)] hover-hover:bg-[var(--surface-6)] dark:hover-hover:bg-[var(--surface-5)]',
outline:
'gap-1 rounded-[40px] border border-[var(--border-1)] bg-transparent text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:bg-transparent dark:hover-hover:border-[var(--surface-6)]',
type: 'gap-1 rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] dark:bg-[var(--surface-6)]',
green: `${STATUS_BASE} bg-[var(--badge-success-bg)] text-[var(--badge-success-text)]`,
red: `${STATUS_BASE} bg-[var(--badge-error-bg)] text-[var(--badge-error-text)]`,
gray: `${STATUS_BASE} bg-[var(--badge-gray-bg)] text-[var(--badge-gray-text)]`,
blue: `${STATUS_BASE} bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)]`,
'blue-secondary': `${STATUS_BASE} bg-[var(--badge-blue-secondary-bg)] text-[var(--badge-blue-secondary-text)]`,
purple: `${STATUS_BASE} bg-[var(--badge-purple-bg)] text-[var(--badge-purple-text)]`,
orange: `${STATUS_BASE} bg-[var(--badge-orange-bg)] text-[var(--badge-orange-text)]`,
amber: `${STATUS_BASE} bg-[var(--badge-amber-bg)] text-[var(--badge-amber-text)]`,
teal: `${STATUS_BASE} bg-[var(--badge-teal-bg)] text-[var(--badge-teal-text)]`,
cyan: `${STATUS_BASE} bg-[var(--badge-cyan-bg)] text-[var(--badge-cyan-text)]`,
pink: `${STATUS_BASE} bg-[var(--badge-pink-bg)] text-[var(--badge-pink-text)]`,
'gray-secondary': `${STATUS_BASE} bg-[var(--surface-4)] text-[var(--text-secondary)]`,
},
size: {
sm: 'px-[7px] py-[1px] text-xs',
md: 'px-[9px] py-0.5 text-caption',
lg: 'px-[9px] py-[2.25px] text-caption',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
/** Color variants that support dot indicators */
const STATUS_VARIANTS = [
'green',
'red',
'gray',
'blue',
'blue-secondary',
'purple',
'orange',
'amber',
'teal',
'cyan',
'pink',
'gray-secondary',
] as const
/** Dot sizes corresponding to badge size variants */
const DOT_SIZES: Record<string, string> = {
sm: 'size-[5px]',
md: 'size-1.5',
lg: 'size-1.5',
}
/** Icon sizes corresponding to badge size variants */
const ICON_SIZES: Record<string, string> = {
sm: 'size-2.5',
md: 'size-3',
lg: 'size-3',
}
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
/** Displays a dot indicator before content (only for color variants) */
dot?: boolean
/** Icon component to render before content */
icon?: React.ComponentType<{ className?: string }>
}
/**
* Displays a badge with configurable variant, size, and optional indicators.
*
* @remarks
* Supports two categories of variants:
* - **Bordered**: `default`, `outline`, `type` - traditional badges with borders
* - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`,
* `orange`, `amber`, `teal`, `cyan`, `pink`, `gray-secondary` - borderless colored badges
*
* Status color variants can display a dot indicator via the `dot` prop.
* All variants support an optional `icon` prop for leading icons.
*/
const Badge = forwardRef<HTMLDivElement, BadgeProps>(function Badge(
{ className, variant, size, dot = false, icon: Icon, children, ...props },
ref
) {
const isStatusVariant = STATUS_VARIANTS.includes(variant as (typeof STATUS_VARIANTS)[number])
const effectiveSize = size ?? 'md'
return (
<div ref={ref} className={cn(badgeVariants({ variant, size }), className)} {...props}>
{isStatusVariant && dot && (
<div className={cn('rounded-xs bg-current', DOT_SIZES[effectiveSize])} />
)}
{Icon && <Icon className={ICON_SIZES[effectiveSize]} />}
{children}
</div>
)
})
Badge.displayName = 'Badge'
export { Badge, badgeVariants }
@@ -0,0 +1,75 @@
'use client'
import type { HTMLAttributes, ReactNode } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
import { Button, type ButtonProps } from '../button/button'
const bannerVariants = cva('shrink-0 px-6 py-2.5', {
variants: {
variant: {
default: 'bg-[var(--surface-active)]',
destructive: 'bg-red-50 dark:bg-red-950/30',
},
},
defaultVariants: {
variant: 'default',
},
})
export interface BannerProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof bannerVariants> {
actionClassName?: string
actionDisabled?: boolean
actionLabel?: ReactNode
actionProps?: Omit<ButtonProps, 'children' | 'className' | 'disabled' | 'onClick' | 'variant'>
actionVariant?: ButtonProps['variant']
children?: ReactNode
contentClassName?: string
onAction?: () => void
text?: ReactNode
textClassName?: string
}
export function Banner({
actionClassName,
actionDisabled,
actionLabel,
actionProps,
actionVariant = 'default',
children,
className,
contentClassName,
onAction,
text,
textClassName,
variant,
...props
}: BannerProps) {
return (
<div className={cn(bannerVariants({ variant }), className)} {...props}>
{children ?? (
<div
className={cn(
'mx-auto flex max-w-[1400px] items-center justify-between gap-3',
contentClassName
)}
>
<p className={cn('text-[13px]', textClassName)}>{text}</p>
{actionLabel ? (
<Button
variant={actionVariant}
className={cn('h-[28px] shrink-0 px-2 text-[12px]', actionClassName)}
onClick={onAction}
disabled={actionDisabled}
{...actionProps}
>
{actionLabel}
</Button>
) : null}
</div>
)}
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import {
Children,
cloneElement,
createContext,
type HTMLAttributes,
isValidElement,
type ReactElement,
type ReactNode,
useContext,
} from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const buttonGroupVariants = cva('inline-flex', {
variants: {
gap: {
none: 'gap-0',
sm: 'gap-0.5',
},
},
defaultVariants: {
gap: 'sm',
},
})
interface ButtonGroupContextValue {
value: string | undefined
onValueChange: ((value: string) => void) | undefined
disabled: boolean
}
const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null)
function useButtonGroupContext() {
const context = useContext(ButtonGroupContext)
if (!context) {
throw new Error('ButtonGroupItem must be used within a ButtonGroup')
}
return context
}
interface ButtonGroupProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof buttonGroupVariants> {
/** Currently selected value */
value?: string
/** Callback fired when selection changes */
onValueChange?: (value: string) => void
/** Disables all items in the group */
disabled?: boolean
children: ReactNode
}
/**
* A group of connected toggle buttons where only one can be selected.
*
* @example
* ```tsx
* <ButtonGroup value={language} onValueChange={setLanguage}>
* <ButtonGroupItem value="curl">cURL</ButtonGroupItem>
* <ButtonGroupItem value="python">Python</ButtonGroupItem>
* <ButtonGroupItem value="javascript">JavaScript</ButtonGroupItem>
* </ButtonGroup>
* ```
*/
function ButtonGroup({
className,
gap,
value,
onValueChange,
disabled = false,
children,
...props
}: ButtonGroupProps) {
const validChildren = Children.toArray(children).filter(isValidElement)
const childCount = validChildren.length
return (
<ButtonGroupContext.Provider value={{ value, onValueChange, disabled }}>
<div role='radiogroup' className={cn(buttonGroupVariants({ gap }), className)} {...props}>
{validChildren.map((child, index) => {
const position: 'first' | 'middle' | 'last' | 'only' =
childCount === 1
? 'only'
: index === 0
? 'first'
: index === childCount - 1
? 'last'
: 'middle'
return cloneElement(child as ReactElement<ButtonGroupItemProps>, {
_position: position,
})
})}
</div>
</ButtonGroupContext.Provider>
)
}
const buttonGroupItemVariants = cva(
'inline-flex items-center justify-center font-medium transition-colors outline-none focus:outline-none focus-visible:outline-none disabled:pointer-events-none disabled:opacity-70 px-2 py-1 text-caption border',
{
variants: {
active: {
true: 'bg-[var(--text-primary)] text-[var(--text-inverse)] border-[var(--text-primary)] hover-hover:bg-[var(--text-primary)] hover-hover:text-[var(--text-inverse)] hover-hover:border-[var(--text-primary)] dark:bg-white dark:text-[var(--bg)] dark:border-white dark:hover-hover:bg-white dark:hover-hover:text-[var(--bg)] dark:hover-hover:border-white',
false:
'bg-[var(--surface-4)] text-[var(--text-secondary)] border-[var(--border)] hover-hover:text-[var(--text-primary)] hover-hover:bg-[var(--surface-6)] hover-hover:border-[var(--border-1)]',
},
position: {
only: 'rounded-[5px]',
first: 'rounded-l-[5px] rounded-r-none',
middle: 'rounded-none',
last: 'rounded-r-[5px] rounded-l-none',
},
},
defaultVariants: {
active: false,
position: 'only',
},
}
)
interface ButtonGroupItemProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onClick'> {
/** Value associated with this item */
value: string
/** Disables this specific item */
disabled?: boolean
/** @internal Position within the group, set automatically */
_position?: 'first' | 'middle' | 'last' | 'only'
}
/**
* An individual item within a ButtonGroup.
*/
function ButtonGroupItem({
className,
value,
disabled: itemDisabled,
_position = 'only',
children,
...props
}: ButtonGroupItemProps) {
const context = useButtonGroupContext()
const isActive = context.value === value
const isDisabled = context.disabled || itemDisabled
const handleClick = () => {
if (!isDisabled && context.onValueChange) {
context.onValueChange(value)
}
}
return (
<button
type='button'
role='radio'
aria-checked={isActive}
disabled={isDisabled}
onClick={handleClick}
className={cn(buttonGroupItemVariants({ active: isActive, position: _position }), className)}
{...props}
>
{children}
</button>
)
}
ButtonGroup.displayName = 'ButtonGroup'
ButtonGroupItem.displayName = 'ButtonGroupItem'
export { ButtonGroup, ButtonGroupItem, buttonGroupVariants, buttonGroupItemVariants }
@@ -0,0 +1,56 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center font-medium transition-colors disabled:pointer-events-none disabled:opacity-70 outline-none focus:outline-none focus-visible:outline-none rounded-[5px]',
{
variants: {
variant: {
default:
'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)] bg-[var(--surface-4)] hover-hover:bg-[var(--surface-6)] border border-[var(--border)] hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]',
active:
'bg-[var(--surface-5)] hover-hover:bg-[var(--surface-6)] text-[var(--text-primary)] hover-hover:text-[var(--text-primary)] border border-[var(--border-1)] hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--border-1)]',
'3d': 'text-[var(--text-tertiary)] border-t border-l border-r border-[var(--border-1)] shadow-[0_2px_0_0_var(--border-1)] hover-hover:shadow-[0_4px_0_0_var(--border-1)] transition-[transform,box-shadow,color] hover-hover:-translate-y-0.5 hover-hover:text-[var(--text-primary)]',
outline:
'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)] border border-[var(--text-muted)] bg-transparent hover-hover:border-[var(--text-secondary)]',
primary:
'bg-[var(--text-primary)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--text-body)] dark:bg-white dark:text-[var(--bg)] dark:hover-hover:bg-[var(--text-secondary)] dark:hover-hover:text-[var(--bg)]',
destructive:
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
tertiary:
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--brand-accent-hover)] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[var(--brand-accent-hover)] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
ghost: 'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]',
subtle:
'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]',
'ghost-secondary': 'text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]',
quiet: 'text-[var(--text-secondary)] hover-hover:bg-[var(--surface-active)]',
},
size: {
sm: 'px-1.5 py-1 text-[length:11px]',
md: 'px-2 py-1.5 text-[length:12px]',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
@@ -0,0 +1,61 @@
'use client'
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react'
import { cn } from '../../lib/cn'
import { chipVariants } from '../chip/chip'
export interface CalendarDayCellProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
/** Strong `primary` fill — the selected calendar day or an active weekday toggle. */
selected?: boolean
/** The `border` shadow-ring marking today. Ignored while `selected`. */
today?: boolean
/**
* Fills the container width (the weekday-toggle row) instead of the fixed
* 30px square used by the calendar's month grid.
*/
fullWidth?: boolean
children: ReactNode
}
/**
* The single day pill shared by the {@link Calendar} month grid and any
* chip-aligned day toggle (e.g. the scheduled-task weekly "Repeat on" row).
* Built from `chipVariants` so the chrome — height, radius, centered glyph,
* `primary` selected fill, `border` today ring — lives in one place and the
* row of weekday toggles reads as a sibling of the date picker rather than a
* separate control.
*
* @example
* <CalendarDayCell selected={isSelected} today={isToday} onClick={pick}>{day}</CalendarDayCell>
*
* @example
* // Weekday toggle: fill the column, drive selection with `aria-pressed`.
* <CalendarDayCell selected={on} fullWidth aria-pressed={on} aria-label='Monday' onClick={toggle}>M</CalendarDayCell>
*/
export const CalendarDayCell = forwardRef<HTMLButtonElement, CalendarDayCellProps>(
function CalendarDayCell(
{ selected = false, today = false, fullWidth = false, className, children, type, ...props },
ref
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(
chipVariants({
variant: selected ? 'primary' : today ? 'border' : undefined,
flush: true,
}),
'justify-center p-0',
fullWidth ? 'h-[30px] w-full' : 'size-[30px]',
!selected && 'text-[var(--text-body)]',
className
)}
{...props}
>
{children}
</button>
)
}
)
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildRangeBounds,
formatDateRangeLabel,
parseDateTimeValue,
parseDateValue,
} from './calendar'
describe('parseDateTimeValue', () => {
it('reads bare dates as pure days with no time', () => {
expect(parseDateTimeValue('2026-07-06').time).toBeNull()
})
it('keeps the time of T datetime strings, even at exactly midnight', () => {
expect(parseDateTimeValue('2026-07-07T00:00:00').time).toBe('00:00')
expect(parseDateTimeValue('2026-07-06T16:04:55').time).toBe('16:04:55')
expect(parseDateTimeValue('2026-07-06T16:04').time).toBe('16:04')
})
it('treats a coincidental local midnight as no time for Date instances', () => {
expect(parseDateTimeValue(new Date(2026, 6, 6)).time).toBeNull()
expect(parseDateTimeValue(new Date(2026, 6, 6, 16, 4, 55)).time).toBe('16:04:55')
})
it('returns nulls for unparseable input', () => {
expect(parseDateTimeValue('garbage')).toEqual({ date: null, time: null })
})
})
describe('parseDateValue', () => {
it('parses a YYYY-MM-DD string as a local day', () => {
const date = parseDateValue('2026-04-21')
expect(date?.getFullYear()).toBe(2026)
expect(date?.getMonth()).toBe(3)
expect(date?.getDate()).toBe(21)
})
it('parses the day from a YYYY-MM-DDTHH:mm string', () => {
const date = parseDateValue('2026-04-21T09:30')
expect(date?.getMonth()).toBe(3)
expect(date?.getDate()).toBe(21)
})
it('returns null for empty or invalid input', () => {
expect(parseDateValue(undefined)).toBeNull()
expect(parseDateValue('not-a-date')).toBeNull()
})
})
describe('buildRangeBounds', () => {
it('serializes bare days when time is off', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 30), {
showTime: false,
startTime: '00:00',
endTime: '23:59',
})
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
})
it('orders inverted bounds', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 30), new Date(2026, 3, 1), {
showTime: false,
startTime: '00:00',
endTime: '23:59',
})
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
})
it('appends start time and closes the end at :59 when time is on', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 2), {
showTime: true,
startTime: '09:00',
endTime: '17:30',
})
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-02T17:30:59' })
})
it('swaps inverted times on a single day', () => {
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 1), {
showTime: true,
startTime: '18:00',
endTime: '09:00',
})
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-01T18:00:59' })
})
})
describe('formatDateRangeLabel', () => {
it('renders a same-year range compactly', () => {
expect(formatDateRangeLabel('2026-04-08', '2026-04-12')).toBe('Apr 8 - Apr 12, 2026')
})
it('shows both years when they differ', () => {
expect(formatDateRangeLabel('2025-12-30', '2026-01-02')).toBe('Dec 30, 2025 - Jan 2, 2026')
})
it('falls back to a single label when only one bound is set', () => {
expect(formatDateRangeLabel('2026-04-08', undefined)).toBe('Apr 8, 2026')
expect(formatDateRangeLabel(undefined, undefined)).toBe('')
})
})
@@ -0,0 +1,571 @@
'use client'
import { useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '../../lib/cn'
import { Chip, chipVariants } from '../chip/chip'
import { chipContentLabelClass } from '../chip/chip-chrome'
import { ChipTimePicker } from '../chip-time-picker/chip-time-picker'
import { CalendarDayCell } from './calendar-day-cell'
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const
const DEFAULT_RANGE_START_TIME = '00:00'
const DEFAULT_RANGE_END_TIME = '23:59'
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay()
}
function pad2(value: number): string {
return value.toString().padStart(2, '0')
}
/**
* Serializes a calendar cell to the `YYYY-MM-DD` wire format used across the
* date filters. Built from local Y/M/D parts so there is no UTC offset shift.
*/
function toDateString(year: number, month: number, day: number): string {
return `${year}-${pad2(month + 1)}-${pad2(day)}`
}
/**
* Parses a `YYYY-MM-DD` string (or `Date`) into a local `Date`. `YYYY-MM-DD`
* is parsed as local time to avoid the off-by-one day that `new Date('2026-05-08')`
* (UTC midnight) produces in negative-offset timezones.
*/
export function parseDateValue(value: string | Date | undefined): Date | null {
if (!value) return null
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
return new Date(year, month - 1, day)
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
/**
* Human-readable label for a date value (e.g. `May 8, 2026`). Returns an empty
* string when there is no parseable date — callers fall back to a placeholder.
*/
export function formatDateLabel(value: string | Date | undefined): string {
const date = parseDateValue(value)
if (!date) return ''
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* Compact label for a date range (e.g. `May 8 - May 12, 2026`). Mirrors
* {@link formatDateLabel} for the empty/partial cases so triggers can fall back
* to a placeholder.
*/
export function formatDateRangeLabel(
start: string | Date | undefined,
end: string | Date | undefined
): string {
const startDate = parseDateValue(start)
const endDate = parseDateValue(end)
if (!startDate && !endDate) return ''
if (startDate && !endDate) return formatDateLabel(startDate)
if (!startDate && endDate) return formatDateLabel(endDate)
if (!startDate || !endDate) return ''
const sameYear = startDate.getFullYear() === endDate.getFullYear()
const startLabel = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
})
const endLabel = endDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
return `${startLabel} - ${endLabel}`
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function isStrictlyWithin(date: Date, start: Date, end: Date): boolean {
const time = date.getTime()
const lo = Math.min(start.getTime(), end.getTime())
const hi = Math.max(start.getTime(), end.getTime())
return time > lo && time < hi
}
/** Reads the `HH:mm` slice from a `YYYY-MM-DDTHH:mm` value, or a fallback. */
function extractTime(value: string | Date | undefined, fallback: string): string {
return typeof value === 'string' && value.includes('T') ? value.slice(11, 16) : fallback
}
/** Local `HH:mm` (plus `:ss` when non-zero) time-of-day of a `Date`. */
function timeOfDayFrom(date: Date): string {
const base = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
return date.getSeconds() > 0 ? `${base}:${pad2(date.getSeconds())}` : base
}
/**
* Parses a date value into its local day plus an optional time-of-day. Bare
* `YYYY-MM-DD` strings are pure days (no time). Datetime strings parse through
* `Date` so an explicit offset (`Z`, `-07:00`) resolves to the **local** day —
* unlike {@link parseDateValue}'s date-slice fast path, which would read the
* UTC day.
*
* A `T` datetime string keeps its time even at exactly midnight — it was
* deliberately supplied with a time component, and dropping it would let a
* day-pick silently convert a midnight instant into a bare calendar date.
* The midnight-means-no-time reading applies only to `Date` instances and
* non-`T` strings, where a coincidental local midnight usually denotes a
* pure day.
*/
export function parseDateTimeValue(value: string | Date | undefined): {
date: Date | null
time: string | null
} {
if (!value) return { date: null, time: null }
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { date: parseDateValue(value), time: null }
}
const parsed = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
if (typeof value === 'string' && value.includes('T')) {
return { date: parsed, time: timeOfDayFrom(parsed) }
}
const isMidnight =
parsed.getHours() === 0 && parsed.getMinutes() === 0 && parsed.getSeconds() === 0
return { date: parsed, time: isMidnight ? null : timeOfDayFrom(parsed) }
}
/**
* Orders a start/end pair and serializes the range bounds to the wire format.
* Without `showTime` the bounds are bare `YYYY-MM-DD` days; with it, the start
* gains its `HH:mm` and the end is closed at `:59` seconds. On a single day the
* times are swapped when inverted so the range never ends before it starts.
*/
export function buildRangeBounds(
start: Date,
end: Date,
options: { showTime: boolean; startTime: string; endTime: string }
): { start: string; end: string } {
const ordered = end < start ? { start: end, end: start } : { start, end }
const startStr = toDateString(
ordered.start.getFullYear(),
ordered.start.getMonth(),
ordered.start.getDate()
)
const endStr = toDateString(
ordered.end.getFullYear(),
ordered.end.getMonth(),
ordered.end.getDate()
)
if (!options.showTime) return { start: startStr, end: endStr }
let startTime = options.startTime
let endTime = options.endTime
if (startStr === endStr && startTime > endTime) {
startTime = options.endTime
endTime = options.startTime
}
return { start: `${startStr}T${startTime}`, end: `${endStr}T${endTime}:59` }
}
interface CalendarBaseProps {
/** Forwarded to the root grid container. */
className?: string
}
interface CalendarSingleProps extends CalendarBaseProps {
mode?: 'single'
/** Selected date as a `YYYY-MM-DD` (or datetime) string or `Date`. */
value?: string | Date
/**
* Called with the picked date in `YYYY-MM-DD` format — or, with `showTime`
* and a set time, the local wall time `YYYY-MM-DDTHH:mm[:ss]`.
*/
onChange?: (value: string) => void
/**
* Adds a time-of-day input under the grid. Day picks keep the current time
* (seconds included when the seeded value had them); time edits re-emit on
* the selected (or today's) day. Without a time set, day picks emit bare
* `YYYY-MM-DD` days.
*/
showTime?: boolean
/**
* Today's calendar day (`YYYY-MM-DD`) in the caller's effective timezone;
* drives the Today button and today ring. Defaults to the runtime's local
* day — pass this when the effective zone can differ from the browser's.
*/
today?: string
}
interface CalendarRangeProps extends CalendarBaseProps {
mode: 'range'
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
startDate?: string | Date
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
endDate?: string | Date
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
showTime?: boolean
/** Called on Apply with the ordered range bounds. */
onRangeChange: (start: string, end: string) => void
/** Called when the Cancel action is pressed. */
onCancel?: () => void
/** Called when the Clear action is pressed. */
onClear?: () => void
}
export type CalendarProps = CalendarSingleProps | CalendarRangeProps
/**
* Date grid composed from the chip family — icon-only `Chip` chevrons for month
* navigation and day cells built on `chipVariants` (`primary` fill when
* selected, the `border` shadow-ring on today). Pair it with
* {@link ChipDatePicker} for a chip trigger, or embed it directly.
*
* `mode='single'` (default) commits on day click via `onChange`. `mode='range'`
* stages a start/end selection behind Clear/Cancel/Apply actions (with optional
* time-of-day inputs) and commits via `onRangeChange`. The range view is keyed
* on its committed bounds so a newly applied range remounts it with fresh draft
* state (React's "reset all state with a key") — the staged start/end never
* lingers when the source range changes.
*
* @example
* <Calendar value={value} onChange={setValue} />
*
* @example
* <Calendar value={datetime} onChange={setDatetime} showTime />
*
* @example
* <Calendar mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
*/
export function Calendar(props: CalendarProps) {
if (props.mode === 'range') {
return (
<RangeCalendarView
key={`${String(props.startDate ?? '')}|${String(props.endDate ?? '')}`}
{...props}
/>
)
}
return <SingleCalendarView {...props} />
}
function useCalendarView(seed: Date | null) {
const today = useMemo(() => new Date(), [])
const [view, setView] = useState(() => {
const base = seed ?? today
return { month: base.getMonth(), year: base.getFullYear() }
})
const goToPrevMonth = () =>
setView((prev) =>
prev.month === 0
? { month: 11, year: prev.year - 1 }
: { month: prev.month - 1, year: prev.year }
)
const goToNextMonth = () =>
setView((prev) =>
prev.month === 11
? { month: 0, year: prev.year + 1 }
: { month: prev.month + 1, year: prev.year }
)
const cells = useMemo<(number | null)[]>(() => {
const leading = getFirstDayOfMonth(view.year, view.month)
const total = getDaysInMonth(view.year, view.month)
const result: (number | null)[] = []
for (let i = 0; i < leading; i++) result.push(null)
for (let day = 1; day <= total; day++) result.push(day)
return result
}, [view])
return { today, view, setView, goToPrevMonth, goToNextMonth, cells }
}
function CalendarHeader({
month,
year,
onPrev,
onNext,
}: {
month: number
year: number
onPrev: () => void
onNext: () => void
}) {
return (
<div className='flex items-center justify-between pb-1'>
<Chip leftIcon={ChevronLeft} aria-label='Previous month' onClick={onPrev} />
<span className={chipContentLabelClass}>
{MONTHS[month]} {year}
</span>
<Chip rightIcon={ChevronRight} aria-label='Next month' onClick={onNext} />
</div>
)
}
function WeekdayRow() {
return (
<div className='grid grid-cols-7'>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-caption'
>
{weekday}
</div>
))}
</div>
)
}
function SingleCalendarView({
value,
onChange,
showTime = false,
today: todayValue,
className,
}: CalendarSingleProps) {
const parsed = useMemo(() => parseDateTimeValue(value), [value])
const selected = parsed.date
const {
today: runtimeToday,
view,
setView,
goToPrevMonth,
goToNextMonth,
cells,
} = useCalendarView(selected)
const today = useMemo(
() => (todayValue ? (parseDateValue(todayValue) ?? runtimeToday) : runtimeToday),
[todayValue, runtimeToday]
)
const [timeOfDay, setTimeOfDay] = useState<string | null>(parsed.time)
const [prevValue, setPrevValue] = useState(value)
if (value !== prevValue) {
setPrevValue(value)
setTimeOfDay(parsed.time)
if (selected) setView({ month: selected.getMonth(), year: selected.getFullYear() })
}
const emit = (year: number, month: number, day: number, time: string | null) => {
const dateStr = toDateString(year, month, day)
onChange?.(showTime && time ? `${dateStr}T${time}` : dateStr)
}
const selectDay = (day: number) => emit(view.year, view.month, day, timeOfDay)
const goToToday = () => {
setView({ month: today.getMonth(), year: today.getFullYear() })
emit(today.getFullYear(), today.getMonth(), today.getDate(), timeOfDay)
}
const handleTimeChange = (time: string) => {
setTimeOfDay(time)
const base = selected ?? today
emit(base.getFullYear(), base.getMonth(), base.getDate(), time)
}
return (
<div className={cn('flex w-[256px] flex-col p-2', className)}>
<CalendarHeader
month={view.month}
year={view.year}
onPrev={goToPrevMonth}
onNext={goToNextMonth}
/>
<WeekdayRow />
<div className='grid grid-cols-7'>
{cells.map((day, index) => {
if (day === null) return <div key={`empty-${index}`} className='h-[34px]' />
const cellDate = new Date(view.year, view.month, day)
const isSelected = selected ? isSameDay(cellDate, selected) : false
const isToday = isSameDay(cellDate, today)
return (
<div key={day} className='flex h-[34px] items-center justify-center'>
<CalendarDayCell selected={isSelected} today={isToday} onClick={() => selectDay(day)}>
{day}
</CalendarDayCell>
</div>
)
})}
</div>
{showTime && (
<div className='mt-1 flex items-center gap-2'>
<span className='shrink-0 text-[var(--text-muted)] text-caption'>Time</span>
<ChipTimePicker
value={timeOfDay?.slice(0, 5)}
onChange={handleTimeChange}
fullWidth
flush
/>
</div>
)}
<button
type='button'
onClick={goToToday}
className={cn(
chipVariants({ variant: 'filled', fullWidth: true, flush: true }),
'mt-1 justify-center'
)}
>
<span className={chipContentLabelClass}>Today</span>
</button>
</div>
)
}
function RangeCalendarView({
startDate,
endDate,
showTime = false,
onRangeChange,
onCancel,
onClear,
className,
}: CalendarRangeProps) {
const seededStart = useMemo(() => parseDateValue(startDate), [startDate])
const { today, view, goToPrevMonth, goToNextMonth, cells } = useCalendarView(seededStart)
const [rangeStart, setRangeStart] = useState<Date | null>(seededStart)
const [rangeEnd, setRangeEnd] = useState<Date | null>(() => parseDateValue(endDate))
const [selectingEnd, setSelectingEnd] = useState(false)
const [startTime, setStartTime] = useState(() => extractTime(startDate, DEFAULT_RANGE_START_TIME))
const [endTime, setEndTime] = useState(() => extractTime(endDate, DEFAULT_RANGE_END_TIME))
const pickDay = (day: number) => {
const date = new Date(view.year, view.month, day)
if (!selectingEnd || !rangeStart) {
setRangeStart(date)
setRangeEnd(null)
setSelectingEnd(true)
return
}
if (date < rangeStart) {
setRangeEnd(rangeStart)
setRangeStart(date)
} else {
setRangeEnd(date)
}
setSelectingEnd(false)
}
const clear = () => {
setRangeStart(null)
setRangeEnd(null)
setSelectingEnd(false)
onClear?.()
}
const apply = () => {
if (!rangeStart) return
const bounds = buildRangeBounds(rangeStart, rangeEnd ?? rangeStart, {
showTime,
startTime,
endTime,
})
onRangeChange(bounds.start, bounds.end)
}
return (
<div className={cn('flex w-[256px] flex-col p-2', className)}>
<CalendarHeader
month={view.month}
year={view.year}
onPrev={goToPrevMonth}
onNext={goToNextMonth}
/>
<WeekdayRow />
<div className='grid grid-cols-7'>
{cells.map((day, index) => {
if (day === null) return <div key={`empty-${index}`} className='h-[34px]' />
const cellDate = new Date(view.year, view.month, day)
const isStart = rangeStart ? isSameDay(cellDate, rangeStart) : false
const isEnd = rangeEnd ? isSameDay(cellDate, rangeEnd) : false
const within =
rangeStart && rangeEnd ? isStrictlyWithin(cellDate, rangeStart, rangeEnd) : false
const hasBand = (within || isStart || isEnd) && Boolean(rangeStart && rangeEnd)
return (
<div
key={day}
className={cn(
'relative flex h-[34px] items-center justify-center',
hasBand &&
'before:absolute before:inset-y-[2px] before:right-0 before:left-0 before:bg-[var(--surface-active)]',
hasBand && isStart && 'before:left-[3px] before:rounded-l-[8px]',
hasBand && isEnd && 'before:right-[3px] before:rounded-r-[8px]'
)}
>
<CalendarDayCell
selected={isStart || isEnd}
today={isSameDay(cellDate, today)}
className='relative z-[1]'
onClick={() => pickDay(day)}
>
{day}
</CalendarDayCell>
</div>
)
})}
</div>
{showTime && (
<div className='mt-1 flex items-center gap-2'>
<ChipTimePicker value={startTime} onChange={setStartTime} fullWidth flush />
<span className='shrink-0 text-[var(--text-muted)] text-caption'>to</span>
<ChipTimePicker value={endTime} onChange={setEndTime} fullWidth flush />
</div>
)}
<div className='mt-1 flex items-center justify-between gap-2'>
<Chip onClick={clear} disabled={!rangeStart && !rangeEnd}>
Clear
</Chip>
<div className='flex items-center gap-2'>
<Chip variant='border' onClick={() => onCancel?.()}>
Cancel
</Chip>
<Chip variant='primary' onClick={apply} disabled={!rangeStart}>
Apply
</Chip>
</div>
</div>
</div>
)
}
@@ -0,0 +1,108 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, Minus } from 'lucide-react'
import { cn } from '../../lib/cn'
/**
* Variant styles for the Checkbox component.
* Controls size and visual style.
*
* @example
* ```tsx
* // Default checkbox
* <Checkbox />
*
* // Small checkbox (for tables)
* <Checkbox size="sm" />
*
* // Large checkbox
* <Checkbox size="lg" />
* ```
*/
const checkboxVariants = cva(
[
'relative peer flex shrink-0 cursor-pointer items-center justify-center rounded-sm border transition-colors',
'border-[var(--border-1)] bg-transparent',
'before:absolute before:inset-[-12px] before:content-[""]',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
'data-[state=indeterminate]:border-[var(--text-primary)] data-[state=indeterminate]:bg-[var(--text-primary)]',
].join(' '),
{
variants: {
size: {
sm: 'h-[14px] w-[14px]',
md: 'h-4 w-4',
lg: 'h-5 w-5',
},
},
defaultVariants: {
size: 'md',
},
}
)
/**
* Variant styles for the Checkbox indicator icon.
*/
const checkboxIconVariants = cva('stroke-[3]', {
variants: {
size: {
sm: 'h-[10px] w-[10px]',
md: 'h-3.5 w-3.5',
lg: 'h-4 w-4',
},
},
defaultVariants: {
size: 'md',
},
})
interface CheckboxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
VariantProps<typeof checkboxVariants> {}
/**
* A checkbox component with size variants.
*
* @example
* ```tsx
* // Basic usage
* <Checkbox checked={checked} onCheckedChange={setChecked} />
*
* // Small checkbox for tables
* <Checkbox size="sm" checked={isSelected} onCheckedChange={handleSelect} />
*
* // With label
* <div className="flex items-center gap-2">
* <Checkbox id="terms" />
* <Label htmlFor="terms">Accept terms</Label>
* </div>
* ```
*/
const Checkbox = React.memo(
React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
({ className, size, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(checkboxVariants({ size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--surface-2)]'>
{props.checked === 'indeterminate' ? (
<Minus className={cn(checkboxIconVariants({ size }))} />
) : (
<Check className={cn(checkboxIconVariants({ size }))} />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
)
)
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox, checkboxVariants, checkboxIconVariants }
@@ -0,0 +1,35 @@
'use client'
import { cn } from '../../lib/cn'
import { Combobox, type ComboboxProps } from '../combobox/combobox'
/**
* Chip-styled {@link Combobox}. A thin wrapper that skins the trigger to match
* the 30px chip pill (`rounded-lg`, chip surface tokens) shared by
* `ChipDropdown`, `ChipModal`, and `ChipInput`.
*
* Reuses 100% of `Combobox` — search, editable entry, multi-select, groups,
* async loading, per-option icons, and `overlayContent` all work unchanged.
* Only the trigger chrome is overridden (the `className` merges last in
* `Combobox`, so `rounded-lg` / height / dark surface and the chip typography
* — normal weight, `--text-body` — win over the heavier combobox defaults).
* The muted placeholder still applies because the combobox tints the inner
* label span with `--text-muted` independently of the trigger className.
*
* Use this in chip-styled surfaces (settings pages, chip forms). For the
* lightweight no-search case, prefer `ChipDropdown`.
*
* @example
* <ChipCombobox options={SOURCE_OPTIONS} value={source} onChange={setSource} />
*/
export function ChipCombobox({ className, ...props }: ComboboxProps) {
return (
<Combobox
{...props}
className={cn(
'h-[30px] rounded-lg font-normal text-[var(--text-body)] dark:bg-[var(--surface-4)]',
className
)}
/>
)
}
@@ -0,0 +1,71 @@
'use client'
/**
* The canonical "view only" chip field: a read-only {@link ChipInput} at full
* opacity with a trailing copy-to-clipboard button. View-only is a display
* mode, not a disabled state — the value stays fully legible and selectable;
* only the copy adornment marks it as non-editable. Reach for this (or
* `ChipModalField type='copy'`, which renders it) instead of a `disabled`
* input whenever a field shows a value the user cannot change — identifiers,
* derived values, record details.
*
* @example
* ```tsx
* import { ChipCopyInput } from '../../index'
*
* <ChipCopyInput value={credential.id} copyLabel='Copy credential ID' />
* ```
*/
import * as React from 'react'
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
import { Check, Duplicate } from '../../icons'
import { cn } from '../../lib/cn'
import { Button } from '../button/button'
import { ChipInput, type ChipInputProps } from '../chip-input/chip-input'
import { Tooltip } from '../tooltip/tooltip'
export interface ChipCopyInputProps
extends Omit<ChipInputProps, 'value' | 'onChange' | 'readOnly' | 'endAdornment' | 'type'> {
/** The value displayed and copied. */
value: string
/**
* Accessible label and tooltip for the copy button.
* @default 'Copy'
*/
copyLabel?: string
}
/** Forwards its ref to the inner `<input>`, exactly like {@link ChipInput}. */
export const ChipCopyInput = React.forwardRef<HTMLInputElement, ChipCopyInputProps>(
({ value, copyLabel = 'Copy', inputClassName, ...props }, ref) => {
const { copied, copy } = useCopyToClipboard()
return (
<ChipInput
ref={ref}
readOnly
value={value}
inputClassName={cn('cursor-default', inputClassName)}
endAdornment={
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='quiet'
className='size-[18px] rounded-sm p-0'
onClick={() => copy(value)}
aria-label={copyLabel}
>
{copied ? <Check className='size-[13px]' /> : <Duplicate className='size-[13px]' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{copied ? 'Copied!' : copyLabel}</Tooltip.Content>
</Tooltip.Root>
}
{...props}
/>
)
}
)
ChipCopyInput.displayName = 'ChipCopyInput'
@@ -0,0 +1,176 @@
'use client'
import { forwardRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { ChevronDown } from '../../icons'
import { cn } from '../../lib/cn'
import { Calendar, formatDateLabel, formatDateRangeLabel } from '../calendar/calendar'
import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip'
import { chipContentLabelClass } from '../chip/chip-chrome'
import { POPOVER_ANIMATION_CLASSES } from '../popover/popover-animation'
interface ChipDatePickerBaseProps {
/**
* Trigger chrome. `filled` (default) is the bordered field chip with the owned
* chevron; `ghost` is the bare toolbar pill — no border, no chevron — for
* visual parity with neighboring `Chip` buttons.
*/
variant?: 'filled' | 'ghost'
/**
* Overrides the trigger text, e.g. a calendar period label ("June 2026") whose
* granularity differs from the selected value.
*/
label?: string
/** Shown in the trigger when nothing is selected. */
placeholder?: string
/** Aligns the calendar popover relative to the trigger. */
align?: 'start' | 'center' | 'end'
/** Disables the trigger. */
disabled?: boolean
/** Stretch the trigger to fill its container (mirrors `Chip`'s `fullWidth`). */
fullWidth?: boolean
/** Removes the default `mx-0.5` cluster margin (mirrors `Chip`'s `flush`). */
flush?: boolean
/** Forwarded class for the trigger button. */
className?: string
}
interface ChipDatePickerSingleProps extends ChipDatePickerBaseProps {
mode?: 'single'
/** Selected date as a `YYYY-MM-DD` string. */
value?: string
/** Called with the picked date in `YYYY-MM-DD` format. */
onChange?: (value: string) => void
/**
* Today's calendar day (`YYYY-MM-DD`) in the caller's effective timezone;
* defaults to the runtime's local day (mirrors `Calendar`'s `today`).
*/
today?: string
}
interface ChipDatePickerRangeProps extends ChipDatePickerBaseProps {
mode: 'range'
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
startDate?: string
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
endDate?: string
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
showTime?: boolean
/** Called on Apply with the ordered range bounds. */
onRangeChange: (start: string, end: string) => void
}
export type ChipDatePickerProps = ChipDatePickerSingleProps | ChipDatePickerRangeProps
/**
* Date counterpart to {@link ChipDropdown} — a chip-styled trigger that opens a
* {@link Calendar} in a popover. The default `filled` trigger reuses
* `chipVariants` (filled + border) and the owned chevron for visual parity with
* the other chip field controls; `ghost` renders the bare toolbar pill instead.
*
* `mode='single'` (default) commits on day click. `mode='range'` opens the
* range calendar — start/end staged behind Clear/Cancel/Apply, with optional
* time-of-day inputs — and commits via `onRangeChange`.
*
* @example
* <ChipDatePicker value={value} onChange={setValue} placeholder='Select date' fullWidth />
*
* @example
* <ChipDatePicker mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
*/
const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(
function ChipDatePicker(props, ref) {
const {
variant = 'filled',
label,
placeholder = props.mode === 'range' ? 'Select date range' : 'Select date',
align = 'start',
disabled,
fullWidth,
flush,
className,
} = props
const [open, setOpen] = useState(false)
const triggerText =
label ??
(props.mode === 'range'
? formatDateRangeLabel(props.startDate, props.endDate)
: formatDateLabel(props.value))
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
variant === 'ghost'
? chipVariants({ fullWidth, flush })
: cn(chipVariants({ variant: 'filled', fullWidth, flush }), TRIGGER_BORDER_CLASS),
className
)}
>
<span
className={cn(
chipContentLabelClass,
'flex-1',
!triggerText && 'text-[var(--text-muted)]'
)}
>
{triggerText || placeholder}
</span>
{variant === 'filled' && (
<span
aria-hidden
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
>
<ChevronDown className='h-[6px] w-[10px]' />
</span>
)}
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={6}
collisionPadding={8}
className={cn(
POPOVER_ANIMATION_CLASSES,
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm'
)}
>
{props.mode === 'range' ? (
<Calendar
mode='range'
startDate={props.startDate}
endDate={props.endDate}
showTime={props.showTime}
onRangeChange={(start, end) => {
props.onRangeChange(start, end)
setOpen(false)
}}
onCancel={() => setOpen(false)}
/>
) : (
<Calendar
value={props.value}
today={props.today}
onChange={(next) => {
props.onChange?.(next)
setOpen(false)
}}
/>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
}
)
ChipDatePicker.displayName = 'ChipDatePicker'
export { ChipDatePicker }
@@ -0,0 +1,346 @@
'use client'
import {
type ComponentType,
forwardRef,
type ReactNode,
useContext,
useMemo,
useState,
} from 'react'
import type { VariantProps } from 'class-variance-authority'
import { Check, ChevronDown } from '../../icons'
import { cn } from '../../lib/cn'
import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSearchInput,
DropdownMenuTrigger,
} from '../dropdown-menu/dropdown-menu'
import { InsideModalContext } from '../modal/modal'
type ChipIcon = ComponentType<{ className?: string }>
/**
* Single option rendered inside a {@link ChipDropdown}.
*/
interface ChipDropdownOption {
/** Stable identifier returned via `onChange`. */
value: string
/** Visual label rendered both inside the trigger (when selected) and in the menu. */
label: ReactNode
/**
* Optional leading icon rendered inside the menu item. Auto-sized to
* `size-[14px]` and tinted with `--text-icon` via the item's base classes
* (see `DROPDOWN_MENU_ITEM_BASE_CLASSES`).
*/
icon?: ChipIcon
/** Pre-rendered leading element (e.g. an avatar) — takes precedence over `icon`. */
iconElement?: ReactNode
}
/**
* Trigger + menu chrome props shared by both selection modes.
*/
interface ChipDropdownBaseProps extends VariantProps<typeof chipVariants> {
/** Options to render in the menu. */
options: ReadonlyArray<ChipDropdownOption>
/** Shown in the trigger when nothing is selected. */
placeholder?: string
/** Aligns the dropdown popover relative to the trigger. */
align?: 'start' | 'center' | 'end'
/**
* Whether the menu width should match the trigger's. When `true` (default),
* the menu pins to `--radix-dropdown-menu-trigger-width`. Set `false` to let
* the menu size to its widest item (avoids truncating long option labels on
* narrow triggers). For anything finer-grained, pass `contentClassName`.
*/
matchTriggerWidth?: boolean
/**
* Forwarded to the inner `DropdownMenuContent`. Use this as the shadcn-style
* escape hatch when neither `matchTriggerWidth` value fits (e.g. a fixed
* pixel width, a different `max-width`, a wider `min-width` floor than the
* `min-w-[8rem]` baked into `DropdownMenuContent`).
*/
contentClassName?: string
/** Disables the trigger. */
disabled?: boolean
/** Optional icon rendered before the label (mirrors `Chip`'s `leftIcon`). */
leftIcon?: ChipIcon
/** Forwarded class for the trigger button. */
className?: string
}
/**
* Single-select props (the default). Picking an option closes the menu and
* reports the new value via `onChange`.
*/
interface ChipDropdownSingleProps extends ChipDropdownBaseProps {
multiple?: false
/** Currently selected value. */
value?: string
/** Called when the user picks a different option from the menu. */
onChange?: (value: string) => void
/**
* Whether to render a trailing check icon on the currently selected item
* (default `true`). When `false`, items render without the check affordance.
*/
showSelectedCheck?: boolean
}
/**
* Multi-select props. The menu stays open across toggles; each active option is
* marked with a trailing check, and `onChange` reports the next selected set.
*/
interface ChipDropdownMultiProps extends ChipDropdownBaseProps {
multiple: true
/** Currently selected values. Empty array reads as "all" / no filter. */
value?: string[]
/** Called with the next selected values when an option is toggled. */
onChange?: (values: string[]) => void
/** Label shown in the trigger and as the reset row when nothing is selected. */
allLabel?: string
/**
* Whether to render the leading "all" reset row inside the menu. Defaults to
* `true` for filter-style use (empty selection reads as "all"). Set `false`
* for selection-style pickers where an empty list is a real "nothing
* selected" state rather than an "all" shortcut.
*/
showAllOption?: boolean
/** Renders a search field at the top of the menu that filters options by label. */
searchable?: boolean
/** Placeholder for the search field. */
searchPlaceholder?: string
}
type ChipDropdownProps = ChipDropdownSingleProps | ChipDropdownMultiProps
/**
* Dropdown counterpart to {@link Chip} — a 30px pill that opens a menu of
* options. In single mode (the default) it reports the picked value; in
* `multiple` mode it toggles values, keeps the menu open across selections,
* and optionally renders an "all" reset row and a search field.
*
* The trigger reuses `chipVariants` for visual parity with `Chip`. The label
* is `flex-1`, so the trailing chevron is pushed flush right. The chevron is
* owned by the component and rendered at `h-[6px] w-[10px]` (matching the
* workspace-header chevron) — there is intentionally no `rightIcon` prop. The
* trigger and menu shell are identical across modes; only the selection
* semantics (label, item handlers, open state, search) branch on `multiple`.
*
* @example
* // Single-select
* <ChipDropdown
* value={member.role}
* onChange={(role) => updateRole(role)}
* options={ROLE_OPTIONS}
* placeholder='Select role'
* />
*
* @example
* // Multi-select
* <ChipDropdown
* multiple
* value={ownerFilter}
* onChange={setOwnerFilter}
* options={memberOptions}
* allLabel='All'
* searchable
* searchPlaceholder='Search members...'
* fullWidth
* flush
* />
*/
const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
function ChipDropdown(props, ref) {
const {
options,
placeholder,
align = 'end',
matchTriggerWidth = true,
contentClassName,
disabled,
leftIcon: LeftIcon,
className,
variant = 'filled',
active,
fullWidth,
flush,
} = props
const isMultiple = props.multiple === true
const selectedValues = useMemo<string[]>(
() => (isMultiple ? (props.value ?? []) : props.value != null ? [props.value] : []),
[isMultiple, props.value]
)
/**
* Inside a modal dialog the menu must be modal too: a non-modal menu
* portaled to `body` inherits the dialog's `pointer-events: none` body
* lock and outside-scroll lock (unclickable, unscrollable), and the
* dialog's still-active focus trap fights item focus. Outside dialogs we
* stay non-modal so filter chips don't lock page scroll while open.
*/
const insideModal = useContext(InsideModalContext)
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const searchable = isMultiple && props.searchable === true
const searchPlaceholder = isMultiple ? (props.searchPlaceholder ?? 'Search...') : 'Search...'
const allLabel = isMultiple ? (props.allLabel ?? 'All') : ''
const showAllOption = isMultiple ? props.showAllOption !== false : false
const showSelectedCheck = isMultiple || props.showSelectedCheck !== false
const filteredOptions = useMemo(() => {
const query = search.trim().toLowerCase()
if (!searchable || !query) return options
return options.filter(
(option) => typeof option.label === 'string' && option.label.toLowerCase().includes(query)
)
}, [options, searchable, search])
const isInverse = variant === 'primary' || variant === 'destructive'
const hasTriggerBorder = variant !== 'primary' && variant !== 'destructive'
let displayLabel: ReactNode
if (isMultiple) {
displayLabel =
selectedValues.length === 0
? allLabel
: selectedValues.length === 1
? (options.find((option) => option.value === selectedValues[0])?.label ?? allLabel)
: `${selectedValues.length} selected`
} else {
const selected = options.find((option) => option.value === selectedValues[0])
displayLabel = selected?.label ?? placeholder ?? 'Select...'
}
const isPlaceholder = !isMultiple && selectedValues.length === 0
const iconClass = cn('size-[16px] flex-shrink-0', !isInverse && 'text-[var(--text-icon)]')
/**
* The chevron glyph stays at its conventional subtle size, but is rendered
* inside a `size-[16px]` slot so its bounding box matches `leftIcon`'s. The
* chip's `gap-2` then produces visually equal spacing on both sides of the
* label — without this, the smaller glyph's bounding box would let the
* chevron read as glued to the text relative to the leading icon.
*/
const chevronSlotClass = cn(
'inline-flex size-[16px] flex-shrink-0 items-center justify-center',
!isInverse && 'text-[var(--text-icon)]'
)
/**
* `flex-1` is always applied so the chevron is pushed flush against the
* trailing edge whenever the trigger gets stretched — by `fullWidth`, by a
* flex parent with `flex-grow`, or by a CSS grid cell with a fixed track.
* On intrinsic-width triggers (`inline-flex` with no parent constraint) the
* container is sized to max-content, so `flex-grow` has no leftover space to
* consume and the layout collapses to the natural `gap-2` between items.
*/
const labelClass = cn(
'min-w-0 flex-1 truncate text-sm',
!isInverse && 'text-[var(--text-body)]'
)
const renderItem = (option: ChipDropdownOption) => {
const isSelected = selectedValues.includes(option.value)
const OptionIcon = option.icon
return (
<DropdownMenuItem
key={option.value}
onSelect={(event) => {
if (isMultiple) {
event.preventDefault()
props.onChange?.(
isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value]
)
} else {
props.onChange?.(option.value)
}
}}
>
{option.iconElement ?? (OptionIcon ? <OptionIcon /> : null)}
<span>{option.label}</span>
{showSelectedCheck && isSelected ? <Check className='!ml-auto !size-[16px]' /> : null}
</DropdownMenuItem>
)
}
return (
<DropdownMenu
modal={insideModal}
{...(isMultiple
? {
open,
onOpenChange: (next: boolean) => {
setOpen(next)
if (!next) setSearch('')
},
}
: {})}
>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
ref={ref}
type='button'
disabled={disabled}
className={cn(
chipVariants({ variant, active, fullWidth, flush }),
hasTriggerBorder && TRIGGER_BORDER_CLASS,
className
)}
>
{LeftIcon ? <LeftIcon className={iconClass} /> : null}
<span
className={cn(labelClass, isPlaceholder && !isInverse && 'text-[var(--text-muted)]')}
>
{displayLabel}
</span>
<span aria-hidden className={chevronSlotClass}>
<ChevronDown className='h-[6px] w-[10px]' />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
onOpenAutoFocus={searchable ? (event) => event.preventDefault() : undefined}
className={cn(
matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none',
contentClassName
)}
>
{searchable && (
<DropdownMenuSearchInput
placeholder={searchPlaceholder}
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
/>
)}
{showAllOption && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
if (isMultiple) props.onChange?.([])
}}
>
<span>{allLabel}</span>
{selectedValues.length === 0 ? <Check className='!ml-auto !size-[16px]' /> : null}
</DropdownMenuItem>
)}
{filteredOptions.map(renderItem)}
</DropdownMenuContent>
</DropdownMenu>
)
}
)
ChipDropdown.displayName = 'ChipDropdown'
export { ChipDropdown }
export type { ChipDropdownOption, ChipDropdownProps }
@@ -0,0 +1,91 @@
'use client'
/**
* The canonical single-line text input of the chip family — a 30px `rounded-lg`
* filled surface that matches the {@link ChipModal} text fields and the
* {@link Chip} pill exactly. Use it for search boxes (settings, integrations),
* secret/credential value fields, and any standalone labeled input that would
* otherwise hand-roll the chip chrome with custom classNames.
*
* The chrome lives on the wrapper so a leading `icon` and a trailing
* `endAdornment` (reveal / copy buttons) sit flush next to a transparent inner
* `<input>`. The leading icon uses the same 1.5 gap as `Chip`. It shares the
* chip-field chrome with {@link ChipTextarea}, shows no focus ring — keep the
* surface calm and rely on the caret for focus. Pass `error` to swap the border
* to the error token.
*
* @example
* ```tsx
* import { ChipInput, Search } from '../../index'
*
* // Search box
* <ChipInput icon={Search} placeholder='Search...' value={q} onChange={(e) => setQ(e.target.value)} />
*
* // Field with a trailing action and an error state
* <ChipInput value={value} onChange={onChange} error={hasError} endAdornment={<CopyButton />} />
* ```
*/
import * as React from 'react'
import { cn } from '../../lib/cn'
import { chipFieldSurfaceClass, chipFieldTextClass } from '../chip/chip-chrome'
type ChipInputIcon = React.ComponentType<{ className?: string }>
export interface ChipInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
/** Leading icon component (e.g. lucide `Search`). Rendered at 14px in `--text-icon`, with the chip's 1.5 gap. */
icon?: ChipInputIcon
/** Trailing content rendered after the input (e.g. reveal / copy buttons). */
endAdornment?: React.ReactNode
/** Marks the field invalid; swaps the border to the error token. */
error?: boolean
/** Class applied to the outer container (the chrome), not the inner input. */
className?: string
/** Class applied to the inner `<input>` (e.g. `font-mono`). */
inputClassName?: string
}
/**
* Forwards its ref to the inner `<input>` so callers can focus or measure the
* field directly, exactly like a native input.
*/
export const ChipInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
(
{
className,
inputClassName,
icon: Icon,
endAdornment,
error,
disabled,
type = 'text',
...props
},
ref
) => (
<div
className={cn(
'flex h-[30px] w-full items-center gap-1.5 px-2',
chipFieldSurfaceClass,
error && 'border-[var(--text-error)]',
disabled && 'opacity-50',
className
)}
>
{Icon ? <Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' /> : null}
<input
ref={ref}
type={type}
disabled={disabled}
className={cn(
'h-full w-full bg-transparent disabled:cursor-not-allowed',
chipFieldTextClass,
inputClassName
)}
{...props}
/>
{endAdornment}
</div>
)
)
ChipInput.displayName = 'ChipInput'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
'use client'
import * as React from 'react'
import { ChevronDown } from '../../icons'
import { cn } from '../../lib/cn'
import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSearchInput,
DropdownMenuTrigger,
} from '../dropdown-menu/dropdown-menu'
/** A selectable option in a {@link ChipSelect}. */
export interface ChipSelectOption {
label: string
value: string
/** Optional leading icon. */
icon?: React.ComponentType<{ className?: string }>
/** Whether this option is non-selectable. */
disabled?: boolean
}
/** A labeled group of options. When `groups` is set, `options` is ignored. */
export interface ChipSelectOptionGroup {
/** Optional section header rendered above the group. */
section?: string
items: ChipSelectOption[]
}
export interface ChipSelectProps {
/** Options in display order. Ignored when `groups` is provided. */
options?: ChipSelectOption[]
/** Grouped options with optional section headers. */
groups?: ChipSelectOptionGroup[]
/** Selected value (single-select mode). */
value?: string
/** Called with the next value when an option is chosen (single-select). */
onChange?: (value: string) => void
/** Enable multi-select: options render as checkbox rows and the menu stays open. */
multiSelect?: boolean
/** Selected values (multi-select mode). */
multiSelectValues?: string[]
/** Called with the next values when a checkbox toggles (multi-select). */
onMultiSelectChange?: (values: string[]) => void
/** Trigger text when nothing is selected. */
placeholder?: string
/** Overrides the computed trigger label (e.g. a custom "N selected" string). */
displayLabel?: React.ReactNode
/** Disable the trigger. */
disabled?: boolean
/** Render an in-menu search box (for long option lists). */
searchable?: boolean
/** Placeholder for the in-menu search box. */
searchPlaceholder?: string
/** Multi-select only: render an "All" row at the top that clears the selection. */
showAllOption?: boolean
/** Label for the "All" row (default "All"). Also the trigger label when nothing is selected. */
allOptionLabel?: string
/** Menu alignment relative to the trigger. */
align?: 'start' | 'center' | 'end'
/**
* Stretch the trigger to fill its container and right-align the chevron —
* use inside form fields. Defaults to a content-width chip (toolbar filters).
*/
fullWidth?: boolean
/** Menu width — 'trigger' matches the trigger, a number is px; defaults to a 160px min. */
dropdownWidth?: 'trigger' | number
/** Max height of the menu in px (defaults to the menu's 240px). */
maxHeight?: number
/** Forwarded to the trigger button. */
className?: string
/** Forwarded to the menu content. */
contentClassName?: string
/** Accessible label for the trigger. */
'aria-label'?: string
/**
* Forwarded to the underlying `DropdownMenu`'s Radix `modal` prop
* (default `true`, matching Radix). Set `false` when an `onChange` handler
* opens a second overlay (e.g. a `Popover`) in the same tick a selection is
* made — a modal menu's focus-lock teardown can trap that overlay
* non-interactive.
*/
modal?: boolean
}
/**
* The platform filter dropdown: a `filled` chip trigger with a trailing
* chevron that opens a `DropdownMenu`. This is the same pattern the
* integrations page uses for its category filter — use it for every settings
* filter so they read identically.
*
* Supports single-select (plain rows), multi-select (`multiSelect` →
* checkbox rows, menu stays open, optional "All" clear row via
* `showAllOption`), grouped options (`groups` → section headers), and an
* optional in-menu search (`searchable`) for long lists.
*
* @example
* ```tsx
* <ChipSelect
* value={range}
* onChange={setRange}
* options={[{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' }]}
* />
* ```
*/
export function ChipSelect({
options,
groups,
value,
onChange,
multiSelect = false,
multiSelectValues,
onMultiSelectChange,
placeholder = 'Select...',
displayLabel,
disabled = false,
searchable = false,
searchPlaceholder = 'Search...',
showAllOption = false,
allOptionLabel = 'All',
align = 'end',
fullWidth = false,
dropdownWidth,
maxHeight,
className,
contentClassName,
'aria-label': ariaLabel,
modal,
}: ChipSelectProps) {
const [query, setQuery] = React.useState('')
const selectedValues = multiSelectValues ?? []
/** Normalized sections — either the provided groups or a single anonymous group. */
const sections = React.useMemo<ChipSelectOptionGroup[]>(
() => groups ?? [{ items: options ?? [] }],
[groups, options]
)
const allOptions = React.useMemo(() => sections.flatMap((g) => g.items), [sections])
const triggerLabel = React.useMemo(() => {
if (multiSelect) {
if (selectedValues.length === 0) return showAllOption ? allOptionLabel : placeholder
if (selectedValues.length === 1) {
return allOptions.find((o) => o.value === selectedValues[0])?.label ?? placeholder
}
return `${selectedValues.length} selected`
}
if (value == null || value === '') return placeholder
return allOptions.find((o) => o.value === value)?.label ?? placeholder
}, [multiSelect, selectedValues, showAllOption, allOptionLabel, placeholder, value, allOptions])
const filteredSections = React.useMemo(() => {
if (!searchable || !query.trim()) return sections
const q = query.toLowerCase()
return sections
.map((g) => ({ ...g, items: g.items.filter((o) => o.label.toLowerCase().includes(q)) }))
.filter((g) => g.items.length > 0)
}, [searchable, query, sections])
const hasResults = filteredSections.some((g) => g.items.length > 0)
const toggleValue = (val: string) => {
if (selectedValues.includes(val)) {
onMultiSelectChange?.(selectedValues.filter((v) => v !== val))
} else {
onMultiSelectChange?.([...selectedValues, val])
}
}
/**
* Inline size constraints for the menu surface. When an explicit
* `dropdownWidth` is set it must also lift the menu's generic
* `max-w-[220px]` cap, otherwise the requested width would be clamped.
*/
const contentStyle: React.CSSProperties = {}
if (dropdownWidth === 'trigger') contentStyle.width = 'var(--radix-dropdown-menu-trigger-width)'
else if (typeof dropdownWidth === 'number') contentStyle.width = dropdownWidth
if (dropdownWidth != null) contentStyle.maxWidth = 'none'
if (typeof maxHeight === 'number') contentStyle.maxHeight = maxHeight
const renderOption = (opt: ChipSelectOption) => {
const Icon = opt.icon
if (multiSelect) {
return (
<DropdownMenuCheckboxItem
key={opt.value}
checked={selectedValues.includes(opt.value)}
disabled={opt.disabled}
onSelect={(event) => {
event.preventDefault()
toggleValue(opt.value)
}}
>
{Icon ? <Icon className='mr-2 size-[14px] text-[var(--text-icon)]' /> : null}
{opt.label}
</DropdownMenuCheckboxItem>
)
}
return (
<DropdownMenuItem
key={opt.value}
disabled={opt.disabled}
onSelect={() => onChange?.(opt.value)}
>
{Icon ? <Icon /> : null}
<span>{opt.label}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenu
modal={modal}
onOpenChange={(open) => {
if (!open) setQuery('')
}}
>
<DropdownMenuTrigger asChild>
<button
type='button'
disabled={disabled}
aria-label={ariaLabel}
className={cn(
chipVariants({ variant: 'filled', flush: true, fullWidth }),
TRIGGER_BORDER_CLASS,
fullWidth ? 'w-full justify-between' : 'w-fit max-w-[240px]',
className
)}
>
<span className='min-w-0 truncate text-[var(--text-body)]'>
{displayLabel ?? triggerLabel}
</span>
<span
aria-hidden
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
>
<ChevronDown className='h-[6px] w-[10px]' />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
onOpenAutoFocus={searchable ? (e) => e.preventDefault() : undefined}
style={contentStyle}
className={cn('min-w-[160px]', contentClassName)}
>
{searchable ? (
<DropdownMenuSearchInput
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
) : null}
{multiSelect && showAllOption ? (
<DropdownMenuCheckboxItem
checked={selectedValues.length === 0}
onSelect={(event) => {
event.preventDefault()
onMultiSelectChange?.([])
}}
>
{allOptionLabel}
</DropdownMenuCheckboxItem>
) : null}
{hasResults ? (
filteredSections.map((group, index) => (
<React.Fragment key={group.section ?? `group-${index}`}>
{group.section ? <DropdownMenuLabel>{group.section}</DropdownMenuLabel> : null}
{group.items.map(renderOption)}
</React.Fragment>
))
) : (
<div className='px-2 py-4 text-center text-[var(--text-muted)] text-small'>
No results
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,98 @@
'use client'
import type { ComponentType, ReactNode } from 'react'
import { cn } from '../../lib/cn'
import { chipVariants } from '../chip/chip'
/**
* One segment in a {@link ChipSwitch}. `label` accepts a `ReactNode` so callers
* can render colored accents (e.g. a discount badge) inline.
*/
export interface ChipSwitchOption<T extends string = string> {
/** The value associated with this option — passed to `onChange` on select. */
value: T
/** Visible label content; `ReactNode` allows inline badges or colored spans. */
label: ReactNode
/** Optional leading icon rendered before the label. */
icon?: ComponentType<{ className?: string }>
}
/**
* Props for {@link ChipSwitch}.
*/
export interface ChipSwitchProps<T extends string = string> {
/** Ordered list of options to render as segments. */
options: ChipSwitchOption<T>[]
/** Currently selected value. */
value: T
/** Invoked with the next selection when a segment is clicked. */
onChange: (value: T) => void
/** Optional accessible label for the radio group. */
'aria-label'?: string
/** Extra classes merged onto the outer container. */
className?: string
}
/**
* A pill-shaped segmented switch built from the chip language: each segment is
* a {@link chipVariants}-styled button — `border-shadow` when active, `ghost`
* when not — so text size, padding, height, and rounding match {@link Chip}
* exactly. The active segment is a flat lifted surface against the trough
* (`--surface-2` light / `--surface-6` dark, no shadow) for a clean, even pill.
*
* @example
* <ChipSwitch
* value={view}
* onChange={setView}
* options={[
* { value: 'annual', label: <>Annual<Badge>-20%</Badge></> },
* { value: 'monthly', label: 'Monthly' },
* ]}
* />
*/
export function ChipSwitch<T extends string>({
options,
value,
onChange,
'aria-label': ariaLabel,
className,
}: ChipSwitchProps<T>) {
return (
<div
role='radiogroup'
aria-label={ariaLabel}
className={cn(
'inline-flex items-center rounded-[10px] bg-[var(--surface-5)] p-[2px] dark:bg-[var(--surface-4)]',
className
)}
>
{options.map((option) => {
const Icon = option.icon
const isActive = option.value === value
return (
<button
key={option.value}
type='button'
role='radio'
aria-checked={isActive}
data-state={isActive ? 'on' : 'off'}
onClick={() => onChange(option.value)}
className={cn(
chipVariants({
variant: isActive ? 'border-shadow' : 'default',
flush: true,
}),
'justify-center',
isActive
? 'text-[var(--text-primary)] shadow-none hover-hover:bg-[var(--surface-2)] dark:bg-[var(--surface-6)] dark:shadow-none dark:hover-hover:bg-[var(--surface-6)]'
: 'text-[var(--text-muted)] hover-hover:bg-transparent hover-hover:text-[var(--text-primary)]'
)}
>
{Icon ? <Icon className='size-[14px] flex-shrink-0' /> : null}
{option.label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,5 @@
export {
ChipSwitch,
type ChipSwitchOption,
type ChipSwitchProps,
} from './chip-switch'
@@ -0,0 +1,132 @@
'use client'
import type { ComponentType, HTMLAttributes, MouseEventHandler, ReactNode } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
/**
* Small inline tag/badge — 20px tall neutral surface for compact in-line accents
* (discount pills, status counters, sub-labels next to titles, invite chips).
*
* @remarks
* Variants, theme-aware via workspace tokens:
* - `mono` — borderless, sharing the {@link ChipSwitch} trough surface
* (`--surface-5` light / `--surface-4` dark) with strong `--text-primary` text
* for emphasis (e.g. a discount next to a primary CTA).
* - `gray` — a light surface over a slightly darker inset ring with muted
* `--text-secondary` text for low-emphasis status labels.
* - `solid` — a filled inverse tag: a dark neutral surface (`--text-secondary`)
* with inverse text (`--text-inverse`), mirroring {@link Chip}'s `primary`
* inverse-surface convention one step softer than near-black. For eyebrow
* kickers and emphasis labels that should read as a solid chip rather than a
* bordered one.
* - `invite` — recipient pill used in invite/sharing flows. Borrows the chip
* family's icon gap (`gap-1.5`), `--text-body` label, and `--text-icon`
* leading/trailing icons; pairs with the `invalid` boolean to flip to an
* error surface (e.g. for invalid email entries) without layout shift.
*/
const chipTagVariants = cva(
'inline-flex items-center rounded-md text-sm leading-5 transition-colors',
{
variants: {
variant: {
mono: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-primary)] dark:bg-[var(--surface-4)]',
gray: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-secondary)] shadow-[inset_0_0_0_1px_var(--border-1)]',
solid: 'h-5 gap-[3px] px-1 bg-[var(--text-secondary)] text-[var(--text-inverse)]',
invite:
'h-5 gap-1.5 px-1 bg-[var(--surface-5)] text-[var(--text-body)] shadow-[inset_0_0_0_1px_var(--border-1)] dark:bg-[var(--surface-4)]',
},
invalid: { true: '', false: '' },
},
compoundVariants: [
{
variant: 'invite',
invalid: true,
className: 'bg-[var(--badge-error-bg)] text-[var(--text-error)] shadow-none',
},
],
defaultVariants: { variant: 'mono', invalid: false },
}
)
type ChipTagIcon = ComponentType<{ className?: string }>
/**
* Props for {@link ChipTag}.
*/
export interface ChipTagProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,
VariantProps<typeof chipTagVariants> {
/** Tag content — typically a short label, number, percentage, or recipient. */
children: ReactNode
/** Icon component rendered before the label. Non-interactive. */
leftIcon?: ChipTagIcon
/**
* Icon component rendered after the label. Becomes a `<button>` with an
* extended hit area when `onRightIconClick` is set (e.g. removable chip).
*/
rightIcon?: ChipTagIcon
/** Click handler that upgrades `rightIcon` into an interactive button. */
onRightIconClick?: MouseEventHandler<HTMLButtonElement>
/** Accessible label for the right-icon button. Required when interactive. */
rightIconLabel?: string
/** Disables the interactive right-icon button. */
rightIconDisabled?: boolean
}
/**
* A compact neutral tag in the chip language.
*
* @example
* <ChipTag variant='mono'>-20%</ChipTag>
* <ChipTag variant='gray'>Your plan</ChipTag>
* <ChipTag
* variant='invite'
* invalid={!isValidEmail}
* leftIcon={isValidEmail ? undefined : AlertTriangle}
* rightIcon={X}
* rightIconLabel={`Remove ${email}`}
* onRightIconClick={handleRemove}
* >
* {email}
* </ChipTag>
*/
export function ChipTag({
variant,
invalid,
className,
children,
leftIcon: LeftIcon,
rightIcon: RightIcon,
onRightIconClick,
rightIconLabel,
rightIconDisabled,
...props
}: ChipTagProps) {
const iconClass = cn('size-[14px] flex-shrink-0', !invalid && 'text-[var(--text-icon)]')
const interactive = RightIcon != null && onRightIconClick != null
return (
<span className={cn(chipTagVariants({ variant, invalid }), className)} {...props}>
{LeftIcon ? <LeftIcon className={iconClass} /> : null}
{children}
{RightIcon ? (
interactive ? (
<button
type='button'
onClick={onRightIconClick}
disabled={rightIconDisabled}
aria-label={rightIconLabel}
className='relative flex flex-shrink-0 items-center opacity-80 transition-opacity before:absolute before:inset-[-8px] before:content-[""] hover-hover:opacity-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
>
<RightIcon className={iconClass} />
</button>
) : (
<RightIcon className={iconClass} />
)
) : null}
</span>
)
}
export { chipTagVariants }
@@ -0,0 +1 @@
export { ChipTag, type ChipTagProps, chipTagVariants } from './chip-tag'
@@ -0,0 +1,62 @@
'use client'
/**
* The multi-line sibling of {@link ChipInput} — a `rounded-lg` filled surface
* that matches the {@link ChipModal} text fields exactly. Use it for any
* multi-line field that would otherwise hand-roll the chip chrome (invite
* messages, credential descriptions, JSON/key blobs) so every textarea on a
* chip surface reads identically.
*
* Like the chip modal fields it shows no focus ring — the surface stays calm.
* Pass `error` to swap the border to the error token, and `resizable` to allow
* vertical user resizing (off by default). Control height with `rows` and/or a
* `min-h-[...]` class.
*
* @example
* ```tsx
* import { ChipTextarea } from '../../index'
*
* <ChipTextarea rows={6} placeholder='Message' value={value} onChange={onChange} error={hasError} />
* ```
*/
import * as React from 'react'
import { cn } from '../../lib/cn'
import { chipFieldSurfaceClass, chipFieldTextClass } from '../chip/chip-chrome'
export interface ChipTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'> {
/** Marks the field invalid; swaps the border to the error token. */
error?: boolean
/** Allows vertical user resizing. Off by default. */
resizable?: boolean
/**
* Renders the textarea as a view-only record: read-only at full opacity with
* the default cursor instead of the text I-beam. The multi-line counterpart
* of `ChipCopyInput` — reach for it over `disabled`, which greys the control
* out.
* @default false
*/
viewOnly?: boolean
}
/** Forwards its ref to the underlying `<textarea>`, exactly like a native textarea. */
export const ChipTextarea = React.forwardRef<HTMLTextAreaElement, ChipTextareaProps>(
({ className, error, resizable = false, viewOnly = false, readOnly, ...props }, ref) => (
<textarea
ref={ref}
readOnly={viewOnly || readOnly}
className={cn(
'w-full px-2 py-1.5 disabled:cursor-not-allowed disabled:opacity-50',
chipFieldSurfaceClass,
chipFieldTextClass,
error ? 'border-[var(--text-error)]' : undefined,
resizable ? 'resize-y' : 'resize-none',
viewOnly && 'cursor-default',
className
)}
{...props}
/>
)
)
ChipTextarea.displayName = 'ChipTextarea'
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { cn } from '../../lib/cn'
import { ChipInput } from '../chip-input/chip-input'
/**
* Formats an `HH:mm` (24h) value as the 12h display label (`9:30 AM`).
* Returns `''` for empty or malformed input.
*/
function formatTimeLabel(value?: string): string {
if (!value) return ''
const [rawHour, rawMinute] = value.split(':')
const hour = Number.parseInt(rawHour, 10)
const minute = Number.parseInt(rawMinute ?? '', 10)
if (Number.isNaN(hour) || Number.isNaN(minute)) return ''
const period = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
}
/**
* Leniently parses typed input into an `HH:mm` value, or `null` when it isn't
* a time. Accepts `9`, `9:47`, `947`, `1430`, `2pm`, `2:05 pm`, `2.05pm` — a
* bare hour above 12 reads as 24h; an explicit am/pm requires a 1-12 hour.
*/
function parseTimeInput(raw: string): string | null {
const text = raw.trim().toLowerCase().replace(/\./g, ':').replace(/\s+/g, ' ')
const match = /^(\d{1,2}):?([0-5]\d)? ?(am?|pm?)?$/.exec(text)
if (!match) return null
let hour = Number.parseInt(match[1], 10)
const minute = match[2] ? Number.parseInt(match[2], 10) : 0
const period = match[3]
if (period) {
if (hour < 1 || hour > 12) return null
if (period.startsWith('p')) hour = hour === 12 ? 12 : hour + 12
else if (hour === 12) hour = 0
} else if (hour > 23) {
return null
}
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
export interface ChipTimePickerProps {
/** Selected time as an `HH:mm` (24h) string. */
value?: string
/** Called with the committed time in `HH:mm` format. */
onChange?: (value: string) => void
/** Shown while empty. */
placeholder?: string
/** Disables the field. */
disabled?: boolean
/** Stretch the field to fill its container (mirrors `Chip`'s `fullWidth`). */
fullWidth?: boolean
/** Removes the default `mx-0.5` cluster margin (mirrors `Chip`'s `flush`). */
flush?: boolean
/** Layout/sizing only — width overrides, margins. The chrome is owned by the chip field. */
className?: string
}
/**
* Minute-granular time field on the chip text-field chrome — the time sibling
* of {@link ChipDatePicker} for footer/toolbar clusters. A plain inline input:
* type anything time-shaped (`9:47`, `947`, `2:05pm`, `14:30`) and it commits
* on Enter or blur, re-rendering as the canonical `9:47 AM` label; input that
* doesn't parse reverts to the last committed time.
*
* @example
* <ChipTimePicker value={time} onChange={setTime} flush />
*/
const ChipTimePicker = React.forwardRef<HTMLInputElement, ChipTimePickerProps>(
function ChipTimePicker(
{ value, onChange, placeholder = '10:00 AM', disabled, fullWidth, flush, className },
ref
) {
const [text, setText] = React.useState(() => formatTimeLabel(value))
const [prevValue, setPrevValue] = React.useState(value)
if (value !== prevValue) {
setPrevValue(value)
setText(formatTimeLabel(value))
}
/**
* Commits the typed text: a parseable time normalizes to its canonical
* label and lifts up via `onChange`; anything else reverts to the last
* committed value.
*/
const commit = React.useCallback(() => {
const parsed = parseTimeInput(text)
if (parsed) {
setText(formatTimeLabel(parsed))
if (parsed !== value) onChange?.(parsed)
return
}
setText(formatTimeLabel(value))
}, [text, value, onChange])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
return
}
if (event.key === 'Escape') {
setText(formatTimeLabel(value))
event.currentTarget.blur()
}
},
[value]
)
return (
<ChipInput
ref={ref}
value={text}
onChange={(event) => setText(event.target.value)}
onFocus={(event) => event.target.select()}
onBlur={commit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
aria-label='Time'
autoComplete='off'
spellCheck={false}
className={cn(fullWidth ? 'w-full' : 'w-[88px]', flush ? 'mx-0' : 'mx-0.5', className)}
/>
)
}
)
ChipTimePicker.displayName = 'ChipTimePicker'
export { ChipTimePicker }
@@ -0,0 +1,34 @@
import { ChevronDown } from '../../icons'
import { cn } from '../../lib/cn'
interface ChipChevronDownProps {
/** Layout-only extras (e.g. `ml-auto` to push the chevron flush right). Never chrome. */
className?: string
}
/**
* Canonical trailing chevron adornment for chip-style dropdown triggers — a
* 16px hidden-from-AT slot centering the 10×6 {@link ChevronDown} glyph in
* `--text-icon`, matching the chevron `ChipDropdown` owns internally. Use it
* inside hand-built `chipVariants` triggers (breadcrumb dropdowns, header
* "New column"-style buttons) instead of re-deriving the span + icon markup.
*
* @example
* <button type='button' className={chipVariants()}>
* <span className={chipContentLabelClass}>New column</span>
* <ChipChevronDown />
* </button>
*/
export function ChipChevronDown({ className }: ChipChevronDownProps) {
return (
<span
aria-hidden
className={cn(
'inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]',
className
)}
>
<ChevronDown className='h-[6px] w-[10px]' />
</span>
)
}
@@ -0,0 +1,67 @@
/** The filled FILL (surface only, no border) — used by the borderless `filled` chip variant. */
export const chipFilledFillTokens = 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]'
/**
* The filled surface WITH a `--border-1` border, for chip FIELDS ({@link ChipInput},
* {@link ChipTextarea}). The `filled` chip variant itself is borderless
* ({@link chipFilledFillTokens}); pill triggers (`ChipDropdown`/`ChipSelect`/
* `ChipDatePicker`) opt into the border via `TRIGGER_BORDER_CLASS`.
*/
export const chipFilledSurfaceTokens = `border border-[var(--border-1)] ${chipFilledFillTokens}`
/**
* The primary (inverse) chip fill at rest — dark fill, inverse text, mirrored in
* dark mode. `chipVariants`' `primary` variant composes this with its hover
* states; static chip-aligned highlights (e.g. calendar day-number pills) use it
* directly. Like every token in this module, never re-derive the literal.
*/
export const chipPrimaryFillTokens =
'bg-[var(--text-primary)] text-[var(--text-inverse)] dark:bg-white dark:text-[var(--bg)]'
/** Filled surface shared by the chip text fields ({@link ChipInput}, {@link ChipTextarea}) — aligned with `Chip` / `ChipDropdown`. */
export const chipFieldSurfaceClass = `rounded-lg ${chipFilledSurfaceTokens} transition-colors`
/**
* The raised "border + drop shadow" ring of the `border-shadow` chip variant: a
* 1px hairline ring plus a soft drop shadow, in both light and dark. Single
* source for the variant ({@link chipVariants}) and for any non-chip surface
* that must read as the same raised card (e.g. a landing media panel) — compose
* it with `rounded-lg` + a fill rather than re-deriving the shadow literal.
*/
export const chipBorderShadowRing =
'shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]'
/** Typography shared by the chip text fields — normal weight, `--text-body`, muted placeholder, no focus outline. */
export const chipFieldTextClass =
'text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
/**
* Icon↔label gap of the canonical chip-content row — the icon↔label pair inside
* a chip pill. Single source for `chipVariants`' base gap and for any non-chip
* surface that must visually match chip content (e.g. resource table cells).
* Like every token in this module, never re-derive the literal; import it.
*/
export const chipContentGap = 'gap-1.5'
/**
* Chip pill geometry — height, centering, gap, radius, padding, text size — with
* NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this
* for its base; static, chip-aligned surfaces (e.g. the resource header's
* current-location label or a non-navigable breadcrumb) reuse it directly to
* match a chip's shape without inheriting its hover.
*/
export const chipGeometryClass = `h-[30px] items-center ${chipContentGap} rounded-lg px-2 text-left text-sm`
/** Chip-content icon (non-inverse): 16px, non-shrinking, `--text-icon`. Inverse chip variants override the color to `currentColor`. */
export const chipContentIconClass = 'size-[16px] flex-shrink-0 text-[var(--text-icon)]'
/** Chip-content label (non-inverse): truncating `--text-body` at `text-sm`. Inverse chip variants override the color to `currentColor`. */
export const chipContentLabelClass = 'min-w-0 truncate text-[var(--text-body)] text-sm'
/**
* Force-sizes a PRE-RENDERED icon node (`<svg>`/`<img>`/`<span>` avatar) to the
* 14px resource-row standard + `--text-icon` color — regardless of the size the
* consumer passed — so every table-row icon across every consumer matches (the
* resource rows run the app's default 14px icons, not the 16px chip-pill icon).
* Element-type child selectors out-specify the node's own `size-*`, so it wins
* without editing any consumer cell builder.
*
* This token serves resource-table ROW CELLS, not chip content itself. It lives
* in this module deliberately: the same cell builders compose it with
* {@link chipContentGap} to keep table cells visually aligned with chip
* content, and chip-chrome is the single home for that shared icon/label
* chrome — do not relocate it to a table-specific module.
*/
export const cellIconNodeClass =
'inline-flex flex-shrink-0 items-center text-[var(--text-icon)] [&>svg]:size-[14px] [&>img]:size-[14px] [&>span]:size-[14px]'
+196
View File
@@ -0,0 +1,196 @@
'use client'
import {
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ComponentType,
forwardRef,
type ReactNode,
} from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import Link, { type LinkProps } from 'next/link'
import { cn } from '../../lib/cn'
import {
chipContentIconClass,
chipContentLabelClass,
chipFilledFillTokens,
chipGeometryClass,
chipPrimaryFillTokens,
} from './chip-chrome'
/**
* 30px pill — the platform's most common chrome pattern.
*
* Render targets:
* - {@link Chip} → `<button>`
* - {@link ChipLink} → Next.js `<Link>`
* - `chipVariants({...})` → any other element (`<div role='button'>`, `<DropdownMenuTrigger asChild>` inner, etc.)
*
* @remarks
* The implicit **default** variant is the bare pill — transparent, `--surface-active` on hover. Omit `variant`
* to get it (shadcn-style); never write `variant='default'`. Named variants:
* `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-active` hover) — a borderless surface reserved for
* chip FIELDS/TRIGGERS ({@link ChipInput}/{@link ChipDropdown}/{@link ChipSelect}/{@link ChipDatePicker}), **never `Chip`
* itself**; those triggers add the `--border-1` outline themselves via `TRIGGER_BORDER_CLASS`;
* `primary` (inverse surface), `destructive` (error-token surface), `border-shadow` (raised card-like surface),
* `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow,
* no CSS border, no fill).
* `active` renders the default/filled chip in its selected state — `--surface-active` at rest, one surface darker
* (`--surface-6`) on hover. `fullWidth` swaps `inline-flex` for block-level `flex`. `flush` removes the default
* `mx-0.5` cluster margin — use when a single chip sits in its own layout slot (grid/table cell).
*
* The default/filled hover lives in `active`-keyed compound variants (not the base variant string) so the
* rest/hover classes are mutually exclusive — a chip renders exactly ONE `hover-hover:bg-*`. This keeps raw
* `chipVariants({...})` consumers identical to `cn(chipVariants({...}))` ones; folding the non-active hover back
* into the variant string would emit two conflicting hover classes that only `cn`'s tailwind-merge resolves,
* silently diverging raw consumers (e.g. an active row that darkens with `Chip` but not with raw `chipVariants`).
*/
const chipVariants = cva(
`group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
{
variants: {
variant: {
default: '',
filled: chipFilledFillTokens,
primary: `${chipPrimaryFillTokens} hover-hover:bg-[var(--text-body)] hover-hover:text-[var(--text-inverse)] dark:hover-hover:bg-[var(--text-secondary)] dark:hover-hover:text-[var(--bg)]`,
destructive:
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
'border-shadow':
'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)] dark:hover-hover:bg-[var(--surface-4)]',
border:
'shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-active)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]',
},
active: { true: '', false: '' },
fullWidth: { true: 'flex', false: 'inline-flex' },
flush: { true: 'mx-0', false: 'mx-0.5' },
},
compoundVariants: [
{
variant: 'default',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'default',
active: true,
className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
},
{
variant: 'filled',
active: false,
className: 'hover-hover:bg-[var(--surface-active)]',
},
{
variant: 'filled',
active: true,
className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
},
],
defaultVariants: { variant: 'default', active: false, fullWidth: false, flush: false },
}
)
type ChipIcon = ComponentType<{ className?: string }>
/**
* Variants a `Chip`/`ChipLink` may render. The `default` (bare) chip is implicit
* — omit `variant` to get it — and `filled` is excluded by design: it is reserved
* for chip fields/triggers, never `Chip` itself. For a selected/toggle chip use
* the `active` prop, not a variant.
*/
type ChipVariant = 'primary' | 'destructive' | 'border-shadow' | 'border'
interface ChipBaseProps extends Omit<VariantProps<typeof chipVariants>, 'variant'> {
variant?: ChipVariant
/** Icon component rendered before the label. */
leftIcon?: ChipIcon
/** Icon component rendered after the label. */
rightIcon?: ChipIcon
children?: ReactNode
}
/**
* `primary` and `destructive` set text color on the chip itself — their icon
* and label inherit via `currentColor`. The default and `filled` chips need
* explicit icon (`--text-icon`) and label (`--text-body`) colors.
*/
function ChipContent({
variant,
leftIcon: LeftIcon,
rightIcon: RightIcon,
children,
}: ChipBaseProps) {
const isInverse = variant === 'primary' || variant === 'destructive'
const iconClass = cn(chipContentIconClass, isInverse && 'text-current')
const labelClass = cn(chipContentLabelClass, 'flex-1', isInverse && 'text-current')
return (
<>
{LeftIcon ? <LeftIcon className={iconClass} /> : null}
{children != null && children !== false ? (
<span className={labelClass}>{children}</span>
) : null}
{RightIcon ? <RightIcon className={iconClass} /> : null}
</>
)
}
interface ChipProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,
ChipBaseProps {}
/**
* @example <Chip leftIcon={Credit} onClick={openBilling}>{balance}</Chip>
*/
const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
{ className, variant, active, fullWidth, flush, leftIcon, rightIcon, children, type, ...props },
ref
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(chipVariants({ variant, active, fullWidth, flush }), className)}
{...props}
>
<ChipContent variant={variant} leftIcon={leftIcon} rightIcon={rightIcon}>
{children}
</ChipContent>
</button>
)
})
interface ChipLinkProps
extends Omit<LinkProps, 'children'>,
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps | 'children'>,
ChipBaseProps {}
/**
* @example <ChipLink href='/integrations' active={isCurrent} leftIcon={ArrowLeft}>Integrations</ChipLink>
*/
const ChipLink = forwardRef<HTMLAnchorElement, ChipLinkProps>(function ChipLink(
{ className, variant, active, fullWidth, flush, leftIcon, rightIcon, children, ...props },
ref
) {
return (
<Link
ref={ref}
className={cn(chipVariants({ variant, active, fullWidth, flush }), className)}
{...props}
>
<ChipContent variant={variant} leftIcon={leftIcon} rightIcon={rightIcon}>
{children}
</ChipContent>
</Link>
)
})
/**
* 1px border applied to `filled` and default chip triggers to read as
* interactive form controls rather than static pills. Omitted on `primary`,
* `destructive`, and `border-shadow` variants which carry their own surface
* treatment.
*/
export const TRIGGER_BORDER_CLASS = 'border border-[var(--border-1)]'
export { Chip, ChipLink, chipVariants }
export type { ChipLinkProps, ChipProps }
+154
View File
@@ -0,0 +1,154 @@
/**
* Code editor syntax token theme.
* Cursor/VS Code base colors with Sim's vibrant saturation.
* Colors aligned to Sim brand where applicable.
* Applied to elements with .code-editor-theme class.
*
* Uses double-class specificity (.code-editor-theme.code-editor-theme)
* to override PrismJS defaults without !important.
*/
/**
* Light mode token colors - Cursor style with Sim vibrancy
*/
.code-editor-theme.code-editor-theme .token.comment,
.code-editor-theme.code-editor-theme .token.block-comment,
.code-editor-theme.code-editor-theme .token.prolog,
.code-editor-theme.code-editor-theme .token.doctype,
.code-editor-theme.code-editor-theme .token.cdata {
color: #16a34a;
}
.code-editor-theme.code-editor-theme .token.punctuation {
color: #383838;
}
.code-editor-theme.code-editor-theme .token.property,
.code-editor-theme.code-editor-theme .token.attr-name,
.code-editor-theme.code-editor-theme .token.variable {
color: #0891b2;
}
.code-editor-theme.code-editor-theme .token.tag,
.code-editor-theme.code-editor-theme .token.boolean,
.code-editor-theme.code-editor-theme .token.number,
.code-editor-theme.code-editor-theme .token.constant {
color: #16a34a;
}
.code-editor-theme.code-editor-theme .token.string,
.code-editor-theme.code-editor-theme .token.char,
.code-editor-theme.code-editor-theme .token.builtin,
.code-editor-theme.code-editor-theme .token.inserted {
color: #b45309;
}
.code-editor-theme.code-editor-theme .token.operator,
.code-editor-theme.code-editor-theme .token.entity,
.code-editor-theme.code-editor-theme .token.url {
color: #383838;
}
.code-editor-theme.code-editor-theme .token.atrule,
.code-editor-theme.code-editor-theme .token.attr-value,
.code-editor-theme.code-editor-theme .token.keyword {
color: #2f55ff;
}
.code-editor-theme.code-editor-theme .token.function,
.code-editor-theme.code-editor-theme .token.class-name {
color: #ca8a04;
}
.code-editor-theme.code-editor-theme .token.regex,
.code-editor-theme.code-editor-theme .token.important {
color: #e11d48;
}
.code-editor-theme.code-editor-theme .token.symbol {
color: #383838;
}
.code-editor-theme.code-editor-theme .token.deleted {
color: #dc2626;
}
/* Blue accents for <var> and {{ENV}} placeholders - light mode */
.code-editor-theme.code-editor-theme .text-blue-500 {
color: #1d4ed8;
}
/**
* Dark mode token colors - Cursor style with Sim vibrancy
*/
.dark .code-editor-theme.code-editor-theme .token.comment,
.dark .code-editor-theme.code-editor-theme .token.block-comment,
.dark .code-editor-theme.code-editor-theme .token.prolog,
.dark .code-editor-theme.code-editor-theme .token.doctype,
.dark .code-editor-theme.code-editor-theme .token.cdata {
color: #6ec97d;
}
.dark .code-editor-theme.code-editor-theme .token.punctuation {
color: #d4d4d4;
}
.dark .code-editor-theme.code-editor-theme .token.property,
.dark .code-editor-theme.code-editor-theme .token.attr-name,
.dark .code-editor-theme.code-editor-theme .token.variable {
color: #4fc3f7;
}
.dark .code-editor-theme.code-editor-theme .token.tag,
.dark .code-editor-theme.code-editor-theme .token.boolean,
.dark .code-editor-theme.code-editor-theme .token.number,
.dark .code-editor-theme.code-editor-theme .token.constant {
color: #a5d6a7;
}
.dark .code-editor-theme.code-editor-theme .token.string,
.dark .code-editor-theme.code-editor-theme .token.char,
.dark .code-editor-theme.code-editor-theme .token.builtin,
.dark .code-editor-theme.code-editor-theme .token.inserted {
color: #f39c6b;
}
.dark .code-editor-theme.code-editor-theme .token.operator,
.dark .code-editor-theme.code-editor-theme .token.entity,
.dark .code-editor-theme.code-editor-theme .token.url {
color: #d4d4d4;
}
.dark .code-editor-theme.code-editor-theme .token.atrule,
.dark .code-editor-theme.code-editor-theme .token.attr-value,
.dark .code-editor-theme.code-editor-theme .token.keyword {
color: #2fa1ff;
}
.dark .code-editor-theme.code-editor-theme .token.function,
.dark .code-editor-theme.code-editor-theme .token.class-name {
color: #fbbf24;
}
.dark .code-editor-theme.code-editor-theme .token.regex,
.dark .code-editor-theme.code-editor-theme .token.important {
color: #f87171;
}
.dark .code-editor-theme.code-editor-theme .token.symbol {
color: #d4d4d4;
}
.dark .code-editor-theme.code-editor-theme .token.deleted {
color: #f87171;
}
/* Blue accents for <var> and {{ENV}} placeholders - dark mode */
.dark .code-editor-theme.code-editor-theme .text-blue-500 {
color: #35b6ff;
}
/* Ensure transparent backgrounds */
.code-editor-theme.code-editor-theme .token {
background: transparent;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
'use client'
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
import { Button, Check, Duplicate } from '../../index'
import { cn } from '../../lib/cn'
interface CopyCodeButtonProps {
code: string
className?: string
}
export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
const { copied, copy } = useCopyToClipboard()
return (
<Button
type='button'
variant='ghost'
onClick={() => copy(code)}
className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', className)}
>
{copied ? <Check className='size-3.5' /> : <Duplicate className='size-3.5' />}
</Button>
)
}
@@ -0,0 +1,41 @@
import { type Grammar, languages, highlight as prismHighlight } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
/**
* Prism.js highlighting utilities isolated in a dedicated module.
*
* The grammar imports above are side-effectful (they register languages on the
* shared `Prism.languages` registry), which marks any module that statically
* imports them as having side effects and therefore non-tree-shakeable. Keeping
* them here — rather than in `code.tsx` — ensures Prism only enters bundles that
* actually import these utilities, instead of every consumer of the shared
* `@sim/emcn` barrel (which re-exports `Code`).
*
* `code.tsx` itself never imports this module statically; it loads it lazily via
* dynamic `import()` on first highlight.
*
* `highlight` is a local wrapper rather than a re-export of Prism's `highlight`.
* A bare re-export lets bundlers resolve the binding straight from `prismjs` and
* skip this module's body, dropping the grammar registrations above so
* `languages.json` (etc.) become `undefined` at runtime. Owning the function
* keeps the registrations in the dependency graph and lets us degrade to escaped
* plaintext when a grammar is missing instead of throwing.
*/
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Highlights `code` with the given Prism `grammar`, returning HTML markup.
* Falls back to escaped plaintext when `grammar` is undefined so a missing or
* unregistered language never throws `The language "<x>" has no grammar.`.
*/
function highlight(code: string, grammar: Grammar | undefined, language: string): string {
if (!grammar) return escapeHtml(code)
return prismHighlight(code, grammar, language)
}
export { highlight, languages }
@@ -0,0 +1,61 @@
'use client'
import type * as React from 'react'
import { cn } from '../../lib/cn'
import { handleKeyboardActivation } from '../../lib/keyboard'
export interface CollapsibleCardProps {
/** Header label (rendered in the standard truncated field-title style). */
title: React.ReactNode
/** Optional trailing header content, e.g. a type `Badge`. */
badge?: React.ReactNode
collapsed: boolean
onToggleCollapse: () => void
/** Body content, shown when expanded. */
children: React.ReactNode
className?: string
}
/**
* A collapsible field card: a `--surface-4` header (click / keyboard to toggle)
* with a truncated title + optional badge, over a `--surface-2` body. Shared by
* the workflow input-mapping rows and the enrichment output-column config.
*/
export function CollapsibleCard({
title,
badge,
collapsed,
onToggleCollapse,
children,
className,
}: CollapsibleCardProps) {
return (
<div
className={cn(
'rounded-sm border border-[var(--border-1)]',
collapsed ? 'overflow-hidden' : 'overflow-visible',
className
)}
>
<div
role='button'
tabIndex={0}
className='flex cursor-pointer items-center justify-between rounded-t-[4px] bg-[var(--surface-4)] px-2.5 py-[5px]'
onClick={onToggleCollapse}
onKeyDown={(event) => handleKeyboardActivation(event, onToggleCollapse)}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<span className='block truncate font-medium text-[var(--text-tertiary)] text-sm'>
{title}
</span>
{badge}
</div>
</div>
{!collapsed && (
<div className='flex flex-col gap-2 rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 pt-1.5 pb-2.5'>
{children}
</div>
)}
</div>
)
}
@@ -0,0 +1,873 @@
'use client'
import {
type ChangeEvent,
forwardRef,
type HTMLAttributes,
type KeyboardEvent,
memo,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, Search } from 'lucide-react'
import { Loader } from '../../icons'
import { cn } from '../../lib/cn'
import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default: '',
},
size: {
sm: 'py-1.5 text-caption',
md: 'py-1.5 text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
/**
* Represents a selectable option in the combobox
*/
export type ComboboxOption = {
label: string
value: string
/** When true, hidden from the picker list but still resolves for display */
hidden?: boolean
/** Icon component to render */
icon?: React.ComponentType<{ className?: string }>
/** Pre-rendered icon element (alternative to icon component) */
iconElement?: ReactNode
/** Custom select handler - when provided, this is called instead of onChange */
onSelect?: () => void
/** Whether this option is disabled */
disabled?: boolean
/** When true, keep the dropdown open after selecting this option */
keepOpen?: boolean
/** Optional element rendered at the trailing end of the option (e.g. chevron for folders) */
suffixElement?: ReactNode
}
/**
* Represents a group of options with an optional section header
*/
export type ComboboxOptionGroup = {
/** Optional section header label */
section?: string
/** Optional custom section header element (overrides section label) */
sectionElement?: ReactNode
/** Options in this group */
items: ComboboxOption[]
}
export interface ComboboxProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof comboboxVariants> {
/** Available options for selection */
options: ComboboxOption[]
/** Current selected value */
value?: string
/** Current selected values for multi-select mode */
multiSelectValues?: string[]
/** Callback when value changes */
onChange?: (value: string) => void
/** Callback when multi-select values change */
onMultiSelectChange?: (values: string[]) => void
/** Placeholder text when no value is selected */
placeholder?: string
/** Whether the combobox is disabled */
disabled?: boolean
/** Enable free-text input mode (default: false) */
editable?: boolean
/** Custom overlay content for editable mode */
overlayContent?: ReactNode
/** Additional input props for editable mode */
inputProps?: Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'disabled' | 'placeholder'
>
/** Ref for the input element in editable mode */
inputRef?: React.RefObject<HTMLInputElement | null>
/** Whether to filter options based on input value (default: true for editable mode) */
filterOptions?: boolean
/** Explicitly control which option is marked as selected (defaults to `value`) */
selectedValue?: string
/** Enable multi-select mode */
multiSelect?: boolean
/** Loading state */
isLoading?: boolean
/** Error message to display */
error?: string | null
/** Callback when popover open state changes */
onOpenChange?: (open: boolean) => void
/** Callback when ArrowLeft is pressed while dropdown is open (for folder back-navigation) */
onArrowLeft?: () => void
/** Enable search input in dropdown (useful for multiselect) */
searchable?: boolean
/** Placeholder for search input */
searchPlaceholder?: string
/** Size variant */
size?: 'sm' | 'md'
/** Dropdown alignment */
align?: 'start' | 'center' | 'end'
/** Dropdown width - 'trigger' matches trigger width, or provide a pixel value */
dropdownWidth?: 'trigger' | number
/** Show an "All" option at the top that clears selection (multi-select only) */
showAllOption?: boolean
/** Custom label for the "All" option (default: "All") */
allOptionLabel?: string
/** Grouped options with section headers - when provided, options prop is ignored */
groups?: ComboboxOptionGroup[]
/** Maximum height for the dropdown */
maxHeight?: number
/** Empty state message when no options match the search */
emptyMessage?: string
}
/**
* Minimal combobox component matching the input and textarea styling.
* Provides a dropdown selection interface with keyboard navigation support.
* Supports both select-only and editable (free-text) modes.
*/
const Combobox = memo(
forwardRef<HTMLDivElement, ComboboxProps>(
(
{
className,
variant,
size,
options,
value,
multiSelectValues,
onChange,
onMultiSelectChange,
placeholder = 'Select...',
disabled,
editable = false,
overlayContent,
inputProps = {},
inputRef: externalInputRef,
filterOptions = editable,
selectedValue,
multiSelect = false,
isLoading = false,
error = null,
onOpenChange,
onArrowLeft,
searchable = false,
searchPlaceholder = 'Search...',
align = 'start',
dropdownWidth = 'trigger',
showAllOption = false,
allOptionLabel = 'All',
groups,
maxHeight = 192,
emptyMessage,
...props
},
ref
) => {
const listboxId = useId()
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const inputRef = externalInputRef || internalInputRef
const effectiveSelectedValue = selectedValue ?? value
// Cleanup blur timeout on unmount
useEffect(() => {
return () => {
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
}
}, [])
// Flatten groups into options if groups are provided
const allOptions = useMemo(() => {
if (groups) {
return groups.flatMap((group) => group.items)
}
return options
}, [groups, options])
const selectedOption = useMemo(
() => allOptions.find((opt) => opt.value === effectiveSelectedValue),
[allOptions, effectiveSelectedValue]
)
/**
* Label rendered in the collapsed trigger for multi-select mode.
* Shows the single label when one value is picked, comma-joined labels
* for two, or "first, second +N" when more are selected. Falls back to
* the raw value if an option for it hasn't loaded yet.
*/
const multiSelectLabel = useMemo(() => {
if (!multiSelect || !multiSelectValues || multiSelectValues.length === 0) return null
const labelFor = (v: string) => allOptions.find((opt) => opt.value === v)?.label ?? v
if (multiSelectValues.length === 1) return labelFor(multiSelectValues[0])
if (multiSelectValues.length === 2) {
return `${labelFor(multiSelectValues[0])}, ${labelFor(multiSelectValues[1])}`
}
return `${labelFor(multiSelectValues[0])}, ${labelFor(multiSelectValues[1])} +${multiSelectValues.length - 2}`
}, [multiSelect, multiSelectValues, allOptions])
/**
* Filter options based on current value or search query
*/
const filteredOptions = useMemo(() => {
let result = allOptions.filter((opt) => !opt.hidden)
if (filterOptions && value && open) {
const currentValue = value.toString().toLowerCase()
const exactMatch = result.find(
(opt) => opt.value === value || opt.label.toLowerCase() === currentValue
)
if (!exactMatch) {
result = result.filter((option) => {
const label = option.label.toLowerCase()
const optionValue = option.value.toLowerCase()
return label.includes(currentValue) || optionValue.includes(currentValue)
})
}
}
if (searchable && searchQuery) {
const query = searchQuery.toLowerCase()
result = result.filter((option) => {
const label = option.label.toLowerCase()
const optionValue = option.value.toLowerCase()
return label.includes(query) || optionValue.includes(query)
})
}
return result
}, [allOptions, value, open, filterOptions, searchable, searchQuery])
/**
* Filter groups based on search query (preserves group structure)
*/
const filteredGroups = useMemo(() => {
if (!groups) return null
const baseGroups = groups
.map((group) => ({
...group,
items: group.items.filter((opt) => !opt.hidden),
}))
.filter((group) => group.items.length > 0)
if (!searchable || !searchQuery) return baseGroups
const query = searchQuery.toLowerCase()
return baseGroups
.map((group) => ({
...group,
items: group.items.filter((option) => {
const label = option.label.toLowerCase()
const optionValue = option.value.toLowerCase()
return label.includes(query) || optionValue.includes(query)
}),
}))
.filter((group) => group.items.length > 0)
}, [groups, searchable, searchQuery])
/**
* Handles selection of an option
*/
const handleSelect = useCallback(
(selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
// If option has custom onSelect, use it instead
if (customOnSelect) {
customOnSelect()
// Always reset search/highlight so stale queries don't filter new options
setSearchQuery('')
setHighlightedIndex(-1)
if (!keepOpen) {
setOpen(false)
}
return
}
if (multiSelect && onMultiSelectChange) {
const currentValues = multiSelectValues || []
const newValues = currentValues.includes(selectedValue)
? currentValues.filter((v) => v !== selectedValue)
: [...currentValues, selectedValue]
onMultiSelectChange(newValues)
} else {
onChange?.(selectedValue)
if (!keepOpen) {
setOpen(false)
setHighlightedIndex(-1)
setSearchQuery('')
if (editable && inputRef.current) {
inputRef.current.blur()
}
}
}
},
[onChange, multiSelect, onMultiSelectChange, multiSelectValues, editable, inputRef]
)
/**
* Handles input change for editable mode
*/
const handleInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled || !editable) return
onChange?.(e.target.value)
},
[disabled, editable, onChange]
)
/**
* Handles focus for editable mode
*/
const handleFocus = useCallback(() => {
if (!disabled) {
setOpen(true)
setHighlightedIndex(-1)
}
}, [disabled])
/**
* Handles blur for editable mode
*/
const handleBlur = useCallback(() => {
// Clear any pending blur timeout
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
// Delay to allow dropdown clicks
blurTimeoutRef.current = setTimeout(() => {
const activeElement = document.activeElement
// Check if focus is in the container, dropdown, or search input
const isInContainer = containerRef.current?.contains(activeElement)
const isInDropdown = dropdownRef.current?.contains(activeElement)
const isSearchInput = activeElement === searchInputRef.current
if (!activeElement || (!isInContainer && !isInDropdown && !isSearchInput)) {
setOpen(false)
setHighlightedIndex(-1)
setSearchQuery('')
}
}, 150)
}, [])
/**
* Handles keyboard navigation
*/
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement | HTMLInputElement>) => {
if (disabled) return
if (e.key === 'Escape') {
setOpen(false)
setHighlightedIndex(-1)
setSearchQuery('')
if (editable && inputRef.current) {
inputRef.current.blur()
}
return
}
if (e.key === 'Enter') {
if (open && highlightedIndex >= 0) {
e.preventDefault()
const selectedOption = filteredOptions[highlightedIndex]
if (selectedOption && !selectedOption.disabled) {
handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen)
}
} else if (!editable) {
e.preventDefault()
setOpen(true)
setHighlightedIndex(0)
}
return
}
if (e.key === ' ' && !editable) {
e.preventDefault()
if (!open) {
setOpen(true)
setHighlightedIndex(0)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!open) {
setOpen(true)
setHighlightedIndex(0)
} else {
setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : 0))
}
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (open) {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1))
}
}
if (e.key === 'ArrowRight') {
if (open && highlightedIndex >= 0) {
const highlightedOption = filteredOptions[highlightedIndex]
if (highlightedOption?.keepOpen && highlightedOption?.onSelect) {
e.preventDefault()
handleSelect(highlightedOption.value, highlightedOption.onSelect, true)
}
}
}
if (e.key === 'ArrowLeft') {
if (open && onArrowLeft) {
e.preventDefault()
onArrowLeft()
setSearchQuery('')
setHighlightedIndex(-1)
}
}
},
[
disabled,
open,
highlightedIndex,
filteredOptions,
handleSelect,
editable,
inputRef,
onArrowLeft,
]
)
/**
* Handles toggle of dropdown (for select mode only)
*/
const handleToggle = useCallback(() => {
if (!disabled && !editable) {
setOpen((prev) => !prev)
setHighlightedIndex(-1)
}
}, [disabled, editable])
/**
* Handles chevron click for editable mode
*/
const handleChevronClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setOpen((prev) => {
const newOpen = !prev
if (newOpen && editable && inputRef.current) {
inputRef.current.focus()
}
return newOpen
})
}
},
[disabled, editable, inputRef]
)
const effectiveHighlightedIndex =
highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1
/**
* Reset highlighted index when filtered options change and index is out of bounds
*/
useEffect(() => {
if (highlightedIndex >= 0 && highlightedIndex >= filteredOptions.length) {
setHighlightedIndex(-1)
}
}, [filteredOptions, highlightedIndex])
/**
* Scroll highlighted option into view
*/
useEffect(() => {
if (effectiveHighlightedIndex >= 0 && dropdownRef.current) {
const highlightedElement = dropdownRef.current.querySelector(
`[data-option-index="${effectiveHighlightedIndex}"]`
)
if (highlightedElement) {
highlightedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}
}, [effectiveHighlightedIndex])
const SelectedIcon = selectedOption?.icon
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setSearchQuery('')
onOpenChange?.(next)
}}
>
<div ref={containerRef} className='relative w-full' {...props}>
<PopoverAnchor asChild>
<div className='w-full'>
{editable ? (
<div className='group relative'>
<Input
ref={inputRef}
className={cn(
'w-full pr-10 font-medium transition-colors',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-7',
open && 'focus-visible:border-[var(--border-1)]',
className
)}
placeholder={placeholder}
value={value ?? ''}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
disabled={disabled}
{...inputProps}
/>
{(overlayContent || SelectedIcon) && (
<div
className={cn(
'pointer-events-none absolute top-0 right-[42px] bottom-0 left-0 flex items-center bg-transparent px-2 py-1.5 font-medium font-sans text-sm',
disabled && 'opacity-50'
)}
>
{overlayContent ? (
overlayContent
) : (
<>
{SelectedIcon && <SelectedIcon className='mr-2 size-3 flex-shrink-0' />}
<span className='truncate text-[var(--text-primary)]'>
{selectedOption?.label}
</span>
</>
)}
</div>
)}
<button
type='button'
aria-label={open ? 'Close options' : 'Open options'}
className='-translate-y-1/2 absolute top-1/2 right-[4px] z-10 flex size-6 cursor-pointer items-center justify-center border-0 bg-transparent p-0'
onMouseDown={handleChevronClick}
>
<ChevronDown
className={cn(
'size-4 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</button>
</div>
) : (
<div
ref={ref}
role='combobox'
aria-expanded={open}
aria-haspopup='listbox'
aria-controls={listboxId}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
comboboxVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleToggle}
onKeyDown={handleKeyDown}
>
<span
className={cn(
'flex-1 truncate',
!selectedOption && !multiSelectLabel && 'text-[var(--text-muted)]',
overlayContent && 'text-transparent'
)}
>
{multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)}
</span>
<ChevronDown
className={cn(
'ml-2 size-4 flex-shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
{overlayContent && (
<div className='pointer-events-none absolute inset-y-0 right-[24px] left-0 flex items-center px-2'>
<div className='w-full truncate'>{overlayContent}</div>
</div>
)}
</div>
)}
</div>
</PopoverAnchor>
<PopoverContent
side='bottom'
align={align}
sideOffset={4}
className={cn(
'rounded-md border border-[var(--border-1)] p-0',
dropdownWidth === 'trigger' && 'w-[var(--radix-popover-trigger-width)]'
)}
style={
typeof dropdownWidth === 'number' ? { width: `${dropdownWidth}px` } : undefined
}
onOpenAutoFocus={(e) => {
e.preventDefault()
// Only auto-focus search input when not in editable mode
if (searchable && !editable) {
setTimeout(() => searchInputRef.current?.focus(), 0)
}
}}
onInteractOutside={(e) => {
// If the user clicks the anchor/trigger while the popover is open,
// prevent Radix from auto-closing on mousedown. Our own toggle handler
// on the anchor will close it explicitly, avoiding close→reopen races.
const target = e.target as Node
if (containerRef.current?.contains(target)) {
e.preventDefault()
}
}}
>
{searchable && (
<div className='flex items-center px-2.5 pt-2 pb-1'>
<Search className='mr-[7px] ml-[1px] size-[13px] shrink-0 text-[var(--text-muted)]' />
<input
ref={searchInputRef}
className='w-full bg-transparent text-[var(--text-primary)] text-small placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
// Forward navigation keys to main handler
// Only forward ArrowLeft/ArrowRight when cursor is at the boundary
// so normal text cursor movement still works in the search input
const input = e.currentTarget
const forwardArrowLeft = e.key === 'ArrowLeft' && input.selectionStart === 0
const forwardArrowRight =
e.key === 'ArrowRight' && input.selectionStart === input.value.length
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
forwardArrowRight ||
forwardArrowLeft ||
e.key === 'Enter' ||
e.key === 'Escape'
) {
handleKeyDown(e)
}
}}
/>
</div>
)}
<PopoverScrollArea
className='!flex-none p-1'
style={{ maxHeight: `${maxHeight}px` }}
onWheelCapture={(e) => {
const target = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = target
const delta = e.deltaY
const isScrollingDown = delta > 0
const isScrollingUp = delta < 0
const isAtTop = scrollTop === 0
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
if ((isScrollingDown && !isAtBottom) || (isScrollingUp && !isAtTop)) {
e.stopPropagation()
}
}}
>
<div ref={dropdownRef} role='listbox' id={listboxId}>
{isLoading ? (
<div className='flex items-center justify-center py-3.5'>
<Loader className='size-[16px] text-[var(--text-muted)]' animate />
<span className='ml-2 text-[var(--text-muted)] text-caption'>
Loading options...
</span>
</div>
) : error ? (
<div className='px-1.5 py-3.5 text-center text-caption text-red-500'>
{error}
</div>
) : filteredOptions.length === 0 ? (
<div className='py-3.5 text-center text-[var(--text-muted)] text-caption'>
{emptyMessage ||
(searchQuery || (editable && value)
? 'No matching options found'
: 'No options available')}
</div>
) : filteredGroups ? (
// Render grouped options with section headers
<div className='space-y-0.5'>
{filteredGroups.map((group, groupIndex) => (
<div key={group.section || `group-${groupIndex}`}>
{group.sectionElement
? group.sectionElement
: group.section && (
<div className='px-1.5 py-1 text-[var(--text-tertiary)] text-xs first:pt-1'>
{group.section}
</div>
)}
{group.items.map((option) => {
const isSelected = multiSelect
? multiSelectValues?.includes(option.value)
: effectiveSelectedValue === option.value
const globalIndex = filteredOptions.findIndex(
(o) => o.value === option.value
)
const isHighlighted = globalIndex === effectiveHighlightedIndex
const OptionIcon = option.icon
return (
<div
key={option.value}
role='option'
aria-selected={isSelected}
aria-disabled={option.disabled}
data-option-index={globalIndex}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
if (!option.disabled) {
handleSelect(option.value, option.onSelect, option.keepOpen)
}
}}
onMouseEnter={() =>
!option.disabled && setHighlightedIndex(globalIndex)
}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
{option.iconElement
? option.iconElement
: OptionIcon && (
<OptionIcon className='size-[14px] flex-shrink-0' />
)}
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
)}
</div>
)
})}
</div>
))}
</div>
) : (
// Render flat options (no groups)
<div className='space-y-0.5'>
{showAllOption && multiSelect && (
<div
role='option'
aria-selected={!multiSelectValues?.length}
data-option-index={-1}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
onMultiSelectChange?.([])
}}
onMouseEnter={() => setHighlightedIndex(-1)}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--surface-active)]',
!multiSelectValues?.length && 'bg-[var(--surface-active)]'
)}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{allOptionLabel}
</span>
</div>
)}
{filteredOptions.map((option, index) => {
const isSelected = multiSelect
? multiSelectValues?.includes(option.value)
: effectiveSelectedValue === option.value
const isHighlighted = index === effectiveHighlightedIndex
const OptionIcon = option.icon
return (
<div
key={option.value}
role='option'
aria-selected={isSelected}
aria-disabled={option.disabled}
data-option-index={index}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
if (!option.disabled) {
handleSelect(option.value, option.onSelect, option.keepOpen)
}
}}
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
{option.iconElement
? option.iconElement
: OptionIcon && <OptionIcon className='size-[14px] flex-shrink-0' />}
<span className='flex-1 truncate text-[var(--text-primary)]'>
{option.label}
</span>
{option.suffixElement}
{multiSelect && isSelected && (
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
)}
</div>
)
})}
</div>
)}
</div>
</PopoverScrollArea>
</PopoverContent>
</div>
</Popover>
)
}
)
)
Combobox.displayName = 'Combobox'
export { Combobox, comboboxVariants }
@@ -0,0 +1,372 @@
/**
* Dropdown menu component built on Radix UI primitives with EMCN styling.
* Provides accessible, animated dropdown menus with consistent design tokens.
*
* @example
* ```tsx
* <DropdownMenu>
* <DropdownMenuTrigger asChild>
* <Button>Open</Button>
* </DropdownMenuTrigger>
* <DropdownMenuContent>
* <DropdownMenuLabel>Actions</DropdownMenuLabel>
* <DropdownMenuSeparator />
* <DropdownMenuItem>Edit</DropdownMenuItem>
* <DropdownMenuItem>Delete</DropdownMenuItem>
* </DropdownMenuContent>
* </DropdownMenu>
* ```
*/
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle, Search } from 'lucide-react'
import { cn } from '../../lib/cn'
import { chipFieldSurfaceClass } from '../chip/chip-chrome'
import { InsideModalContext } from '../modal/modal'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
const CONTENT_BASE_CLASSES =
'z-[var(--z-popover)] max-h-[240px] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden overscroll-none border border-[var(--border)] bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
/**
* Menu root. Inside a `ModalContent` (Radix modal dialog) the menu is forced
* modal regardless of the `modal` prop: a non-modal menu portals outside the
* dialog's `react-remove-scroll` subtree, so its content cannot be
* wheel-scrolled, and it cannot coordinate focus with the dialog's trap. A
* modal menu mounts its own scroll lock and focus scope, which layer correctly
* over the dialog's. Outside dialogs the prop passes through untouched, so
* page-level menus keep their consumer-chosen (or Radix-default) modality.
*/
function DropdownMenu({
modal,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
const insideModal = React.useContext(InsideModalContext)
return <DropdownMenuPrimitive.Root modal={insideModal ? true : modal} {...props} />
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Group
ref={ref}
className={cn('flex flex-col gap-0.5', className)}
{...props}
/>
))
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
asChild?: boolean
}
>(({ className, inset, children, asChild, ...props }, ref) => {
if (asChild) {
return (
<DropdownMenuPrimitive.SubTrigger ref={ref} asChild className={className} {...props}>
{children}
</DropdownMenuPrimitive.SubTrigger>
)
}
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex h-[30px] min-w-0 cursor-default select-none items-center gap-2 rounded-lg px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
inset && 'pl-7',
className
)}
{...props}
>
{children}
<ChevronRight className='ml-auto shrink-0' />
</DropdownMenuPrimitive.SubTrigger>
)
})
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[280px] rounded-lg', className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
/**
* Props for {@link DropdownMenuContent}.
*
* Extends Radix's `DropdownMenu.Content` props with `onOpenAutoFocus`. Radix
* forwards this prop to the internal `FocusScope` (`onMountAutoFocus`) at
* runtime, but its public `DropdownMenuContentProps` type omits it. We surface
* it here so consumers can prevent the default open-focus behavior — useful
* when a sibling input must retain focus while the menu mounts.
*/
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
/**
* Fires when the content mounts and focus is about to move into it. Call
* `event.preventDefault()` to skip Radix's auto-focus.
*/
onOpenAutoFocus?: (event: Event) => void
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
DropdownMenuContentProps
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(ANIMATION_CLASSES, CONTENT_BASE_CLASSES, 'max-w-[220px] rounded-xl', className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DROPDOWN_MENU_ITEM_BASE_CLASSES =
'relative flex h-[30px] min-w-0 cursor-pointer select-none items-center gap-2 rounded-lg px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]'
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
/**
* Optional inline action rendered on the right edge of the item — e.g. a
* "more" icon button. Reveals on hover/focus of the row, and the row stays
* highlighted while the cursor is over the action.
*/
action?: React.ReactNode
}
>(({ className, inset, action, ...props }, ref) => {
if (action) {
return (
<div className='group/dropdownitem relative'>
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
DROPDOWN_MENU_ITEM_BASE_CLASSES,
'pr-[28px] group-focus-within/dropdownitem:bg-[var(--surface-active)] group-hover/dropdownitem:bg-[var(--surface-active)]',
inset && 'pl-7',
className
)}
{...props}
/>
<div className='-translate-y-1/2 absolute top-1/2 right-1 flex items-center opacity-0 transition-opacity group-focus-within/dropdownitem:opacity-100 group-hover/dropdownitem:opacity-100'>
{action}
</div>
</div>
)
}
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(DROPDOWN_MENU_ITEM_BASE_CLASSES, inset && 'pl-7', className)}
{...props}
/>
)
})
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
/**
* Compact icon button intended to be used as the `action` slot on a
* `DropdownMenuItem`. Click events are stopped from bubbling so they don't
* trigger the parent item's selection.
*/
const DropdownMenuItemAction = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, onClick, onPointerDown, ...props }, ref) => (
<button
ref={ref}
type='button'
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onClick?.(e)
}}
onPointerDown={(e) => {
e.stopPropagation()
onPointerDown?.(e)
}}
className={cn(
'flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-[16px] [&_svg]:text-[var(--text-icon)]',
className
)}
{...props}
/>
))
DropdownMenuItemAction.displayName = 'DropdownMenuItemAction'
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex h-[30px] cursor-default select-none items-center rounded-lg pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex size-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='size-[14px]' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex h-[30px] cursor-default select-none items-center rounded-lg pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className='absolute left-2 flex size-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='size-[6px] fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 font-medium text-[var(--text-tertiary)] text-xs',
inset && 'pl-7',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('my-1.5 h-px bg-[var(--border-1)]', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuSearchInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, onKeyDown, ...props }, ref) => {
const internalRef = React.useRef<HTMLInputElement | null>(null)
React.useEffect(() => {
internalRef.current?.focus()
}, [])
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
internalRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref]
)
return (
<div
className={cn(
'mx-0.5 mt-0.5 mb-0.5 flex h-[30px] shrink-0 items-center gap-2 px-2',
chipFieldSurfaceClass
)}
>
<Search className='size-[14px] shrink-0 text-[var(--text-muted)]' />
<input
ref={setRefs}
onKeyDown={(e) => {
e.stopPropagation()
onKeyDown?.(e)
}}
className={cn(
'h-full w-full bg-transparent text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-muted)] focus:outline-none',
className
)}
{...props}
/>
</div>
)
})
DropdownMenuSearchInput.displayName = 'DropdownMenuSearchInput'
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-[var(--text-muted)] text-xs tracking-widest', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemAction,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSearchInput,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
@@ -0,0 +1,63 @@
'use client'
import * as React from 'react'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
import { cn } from '../../lib/cn'
/**
* Expandable root that controls the open/closed state.
* Wraps Radix Collapsible.Root with the `open` prop mapped from `expanded`.
*
* @example
* ```tsx
* const [open, setOpen] = useState(false)
* <button onClick={() => setOpen(!open)}>Toggle</button>
* <Expandable expanded={open}>
* <ExpandableContent>
* <p>This content animates in/out smoothly.</p>
* </ExpandableContent>
* </Expandable>
* ```
*/
interface ExpandableProps
extends Omit<React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Root>, 'open'> {
/** Whether the content is expanded */
expanded: boolean
}
const Expandable = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Root>,
ExpandableProps
>(({ expanded, className, ...props }, ref) => (
<CollapsiblePrimitive.Root
ref={ref}
open={expanded}
className={cn('w-full', className)}
{...props}
/>
))
Expandable.displayName = 'Expandable'
/**
* Animated content container for the Expandable.
* Uses Radix Collapsible.Content with CSS height animation via
* `--radix-collapsible-content-height` custom property.
*/
const ExpandableContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Content>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.Content
ref={ref}
className={cn(
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
className
)}
{...props}
>
{children}
</CollapsiblePrimitive.Content>
))
ExpandableContent.displayName = 'ExpandableContent'
export { Expandable, ExpandableContent }
@@ -0,0 +1,57 @@
import { cn } from '../../lib/cn'
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/**
* The bare dashed hairline used by `FieldDivider` and by inline divider rows
* (e.g. the "Show additional fields" disclosure flanks in the workflow editor
* and table sidebars). Single source of truth for the dash pattern and line
* thickness — consumers pass layout-only classes such as `flex-1`.
*
* @example
* ```tsx
* <DashedDividerLine className='flex-1' />
* ```
*/
function DashedDividerLine({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('h-[1.25px]', className)} style={DASHED_DIVIDER_STYLE} {...props} />
}
interface FieldDividerProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Adds the `subblock-divider` marker class so the workflow editor's CSS
* (`globals.css` `:has()` rule) can hide the divider when adjacent subblocks
* render empty content. Default `false` — only the workflow editor needs it.
*/
subblockMarker?: boolean
}
/**
* Dashed horizontal divider used between fields in form-style panels (the
* workflow editor's subblock list, the table column/workflow sidebars). Same
* visual as the existing `subblock-divider` pattern in `editor.tsx`,
* promoted here so consumers don't keep redefining the gradient style.
*
* @example
* ```tsx
* <Field>...</Field>
* <FieldDivider />
* <Field>...</Field>
* ```
*/
function FieldDivider({ className, subblockMarker = false, ...props }: FieldDividerProps) {
return (
<div
role='separator'
className={cn('px-0.5 pt-4 pb-[13px]', subblockMarker && 'subblock-divider', className)}
{...props}
>
<DashedDividerLine />
</div>
)
}
export { DashedDividerLine, FieldDivider }
+195
View File
@@ -0,0 +1,195 @@
export { Avatar, AvatarFallback, AvatarImage } from './avatar/avatar'
export { Badge, type BadgeProps } from './badge/badge'
export { Banner } from './banner/banner'
export { Button, buttonVariants } from './button/button'
export { ButtonGroup, ButtonGroupItem } from './button-group/button-group'
export {
CalendarDayCell,
type CalendarDayCellProps,
} from './calendar/calendar-day-cell'
export { Checkbox } from './checkbox/checkbox'
export {
Chip,
ChipLink,
type ChipLinkProps,
type ChipProps,
chipVariants,
TRIGGER_BORDER_CLASS,
} from './chip/chip'
export { ChipChevronDown } from './chip/chip-chevron'
export {
cellIconNodeClass,
chipBorderShadowRing,
chipContentGap,
chipContentIconClass,
chipContentLabelClass,
chipFieldSurfaceClass,
chipFieldTextClass,
chipFilledFillTokens,
chipFilledSurfaceTokens,
chipGeometryClass,
chipPrimaryFillTokens,
} from './chip/chip-chrome'
export { ChipCombobox } from './chip-combobox/chip-combobox'
export {
ChipCopyInput,
type ChipCopyInputProps,
} from './chip-copy-input/chip-copy-input'
export { ChipDatePicker } from './chip-date-picker/chip-date-picker'
export {
ChipDropdown,
type ChipDropdownOption,
type ChipDropdownProps,
} from './chip-dropdown/chip-dropdown'
export { ChipInput, type ChipInputProps } from './chip-input/chip-input'
export {
type ChipConfirmAction,
ChipConfirmModal,
type ChipConfirmModalProps,
type ChipConfirmText,
type ChipConfirmTextSegment,
ChipModal,
ChipModalBody,
type ChipModalDropdownOption,
type ChipModalEmailsFieldProps,
ChipModalError,
type ChipModalErrorProps,
ChipModalField,
type ChipModalFieldProps,
ChipModalFooter,
type ChipModalFooterAction,
type ChipModalFooterCustomAction,
type ChipModalFooterProps,
type ChipModalFooterSlotAction,
ChipModalHeader,
type ChipModalHeaderProps,
ChipModalPromptBody,
type ChipModalPromptBodyProps,
type ChipModalProps,
ChipModalSeparator,
type ChipModalTab,
ChipModalTabs,
type ChipModalTabsProps,
} from './chip-modal/chip-modal'
export { ChipSelect, type ChipSelectOption, type ChipSelectProps } from './chip-select/chip-select'
export {
ChipSwitch,
type ChipSwitchOption,
type ChipSwitchProps,
} from './chip-switch/chip-switch'
export { ChipTag, type ChipTagProps, chipTagVariants } from './chip-tag/chip-tag'
export { ChipTextarea, type ChipTextareaProps } from './chip-textarea/chip-textarea'
export { ChipTimePicker, type ChipTimePickerProps } from './chip-time-picker/chip-time-picker'
export {
CODE_LINE_HEIGHT_PX,
Code,
calculateGutterWidth,
getCodeEditorProps,
} from './code/code'
export { CopyCodeButton } from './code/copy-code-button'
export { highlight, languages } from './code/prism'
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
export {
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
} from './combobox/combobox'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuItemAction,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSearchInput,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './dropdown-menu/dropdown-menu'
export { Expandable, ExpandableContent } from './expandable/expandable'
export { DashedDividerLine, FieldDivider } from './field-divider/field-divider'
export { Info } from './info/info'
export {
InfoCard,
InfoCardItem,
type InfoCardItemProps,
InfoCardList,
type InfoCardListProps,
type InfoCardProps,
} from './info-card/info-card'
export { Input, type InputProps } from './input/input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
export { Label } from './label/label'
export { focusFirstTextInput, focusFirstTextInputIn } from './modal/auto-focus'
export {
MODAL_SIZES,
Modal,
ModalBody,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalPortal,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
ModalTitle,
ModalTrigger,
} from './modal/modal'
export {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
PopoverSearch,
PopoverSection,
PopoverTrigger,
usePopoverContext,
} from './popover/popover'
export { POPOVER_ANIMATION_CLASSES } from './popover/popover-animation'
export { ProgressItem } from './progress-item/progress-item'
export { SecretInput } from './secret-input/secret-input'
export { SecretReveal } from './secret-reveal/secret-reveal'
export { Skeleton } from './skeleton/skeleton'
export { Slider } from './slider/slider'
export { Switch } from './switch/switch'
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from './table/table'
export { type FileInputOptions, TagInput, type TagItem } from './tag-input/tag-input'
export { Textarea } from './textarea/textarea'
export { TimePicker, timePickerVariants } from './time-picker/time-picker'
export { ToastProvider, toast, useToast } from './toast/toast'
export {
clamp,
FloatingTooltip,
type FloatingTooltipHandlers,
type FloatingTooltipState,
isFocusVisible,
isTextClipped,
Tooltip,
useFloatingTooltip,
useIsOverflowing,
} from './tooltip/tooltip'
export { Wizard } from './wizard/wizard'
@@ -0,0 +1,96 @@
/**
* Chip-input-styled bordered surface containing a scrollable bulleted list.
* Shares the same outer chrome tokens as `Input` / `Textarea` / `TagInput`
* (default variant) so any `ChipModalField` child reads as a uniform item.
*
* @example
* ```tsx
* <ChipModalField type='custom' title='Permissions requested'>
* <InfoCard>
* <InfoCardList>
* {scopes.map((scope) => (
* <InfoCardItem key={scope}>{getScopeDescription(scope)}</InfoCardItem>
* ))}
* </InfoCardList>
* </InfoCard>
* </ChipModalField>
* ```
*/
'use client'
import * as React from 'react'
import { Check } from 'lucide-react'
import { cn } from '../../lib/cn'
export interface InfoCardProps extends React.HTMLAttributes<HTMLDivElement> {}
/**
* Root container. Owns the chip-input chrome — same tokens as `Input` and
* `Textarea` so the surface visually matches sibling controls in a
* `ChipModalField`.
*/
const InfoCard = React.forwardRef<HTMLDivElement, InfoCardProps>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-4)]',
className
)}
{...props}
/>
))
InfoCard.displayName = 'InfoCard'
export interface InfoCardListProps extends React.HTMLAttributes<HTMLUListElement> {
/**
* Tailwind class controlling the scrollable region's max height.
* @default 'max-h-[200px]'
*/
maxHeightClassName?: string
}
/**
* Scrollable list container. Children should be `<InfoCardItem>`s.
* Padding (`p-2`) matches the modal's text-control chrome so the card aligns
* with sibling `Input` / `Textarea` rhythm. Items are spaced `gap-2`
* vertically to mirror sidebar-item rhythm.
*/
const InfoCardList = React.forwardRef<HTMLUListElement, InfoCardListProps>(
({ className, maxHeightClassName = 'max-h-[200px]', ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-col gap-2 overflow-y-auto p-2', maxHeightClassName, className)}
{...props}
/>
)
)
InfoCardList.displayName = 'InfoCardList'
export interface InfoCardItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
/**
* Leading glyph. Defaults to lucide `Check`. Pass `null` to omit.
*/
icon?: React.ComponentType<{ className?: string }> | null
}
/**
* Single list row. Mirrors sidebar-item tokens: `gap-2`, `text-icon` glyph,
* and `text-body` label at `text-sm`. Icon renders at `size-[12px]` —
* smaller than the default `14px` so it reads as a supporting bullet
* rather than competing with the label.
*/
const InfoCardItem = React.forwardRef<HTMLLIElement, InfoCardItemProps>(
({ className, children, icon: Icon = Check, ...props }, ref) => (
<li ref={ref} className={cn('flex items-center gap-2', className)} {...props}>
{Icon ? <Icon className='size-[12px] flex-shrink-0 text-[var(--text-icon)]' /> : null}
<span className='text-[var(--text-body)] text-sm'>{children}</span>
</li>
)
)
InfoCardItem.displayName = 'InfoCardItem'
export { InfoCard, InfoCardList, InfoCardItem }
@@ -0,0 +1,72 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn'
import { Tooltip } from '../tooltip/tooltip'
/**
* Tooltip placement side.
*/
type InfoSide = 'top' | 'right' | 'bottom' | 'left'
/**
* Tooltip alignment along the chosen side.
*/
type InfoAlign = 'start' | 'center' | 'end'
interface InfoProps {
/** Tooltip content rendered on hover. */
children: ReactNode
/** Optional class names applied to the badge. */
className?: string
/** Tooltip side. Defaults to `'top'`. */
side?: InfoSide
/** Tooltip alignment. Defaults to `'start'`. */
align?: InfoAlign
}
/**
* Inline info badge — an outlined rounded square with a slightly
* slanted `i` glyph that reveals a tooltip on hover. Drawn as an SVG
* so the stroke matches the rest of the EMCN icon set.
*
* @example
* ```tsx
* <Info>Events that start a workflow</Info>
* ```
*/
export function Info({ children, className, side = 'top', align = 'start' }: InfoProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label='More information'
className={cn(
'inline-flex size-[14px] items-center justify-center text-[var(--text-icon)] focus-visible:outline-none',
className
)}
>
<svg
width='100%'
height='100%'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<rect x='3' y='3' width='18' height='18' rx='4' />
<line x1='12.7' y1='10.5' x2='11.3' y2='16.5' />
<line x1='13.2' y1='7.5' x2='13.21' y2='7.5' />
</svg>
</button>
</Tooltip.Trigger>
<Tooltip.Content side={side} align={align} className='max-w-xs'>
<p>{children}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
@@ -0,0 +1,110 @@
/**
* An OTP input component matching the emcn design system.
*
* Wraps the `input-otp` library with emcn design tokens for consistent styling.
*
* @example
* ```tsx
* import { InputOTP, InputOTPGroup, InputOTPSlot } from '../../index'
*
* <InputOTP maxLength={6} value={otp} onChange={setOtp}>
* <InputOTPGroup>
* <InputOTPSlot index={0} />
* <InputOTPSlot index={1} />
* <InputOTPSlot index={2} />
* <InputOTPSlot index={3} />
* <InputOTPSlot index={4} />
* <InputOTPSlot index={5} />
* </InputOTPGroup>
* </InputOTP>
* ```
*
* @see InputOTP - Root component wrapping OTPInput
* @see InputOTPGroup - Groups slots together
* @see InputOTPSlot - Individual digit slot
* @see InputOTPSeparator - Visual separator between groups
*/
'use client'
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { Minus } from 'lucide-react'
import { cn } from '../../lib/cn'
/**
* Root OTP input component. Manages the overall input state and layout.
*/
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
))
InputOTP.displayName = 'InputOTP'
/**
* Groups OTP slots together with consistent spacing.
*/
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center gap-2', className)} {...props} />
))
InputOTPGroup.displayName = 'InputOTPGroup'
/**
* Individual OTP digit slot. Displays the entered character and a fake caret when active.
*
* Uses emcn design tokens for consistent styling with the Input component.
*/
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
'relative flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] font-medium text-[var(--text-primary)] text-lg transition-colors',
isActive && 'z-10 border-[var(--text-muted)] ring-1 ring-[var(--text-muted)]',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='h-6 w-px animate-caret-blink bg-[var(--text-primary)] duration-1000 motion-reduce:animate-none' />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = 'InputOTPSlot'
/**
* Visual separator between OTP slot groups.
*/
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role='separator' className='text-[var(--text-muted)]' {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = 'InputOTPSeparator'
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
@@ -0,0 +1,37 @@
/**
* A minimal input component matching the emcn design system.
*
* @example
* ```tsx
* import { Input } from '../../index'
*
* // Basic usage
* <Input placeholder="Enter text..." />
*
* // Controlled input
* <Input value={value} onChange={(e) => setValue(e.target.value)} />
*
* // Disabled state
* <Input disabled placeholder="Cannot edit" />
* ```
*
* For chip-styled surfaces use {@link ChipInput} instead.
*/
import * as React from 'react'
import { cn } from '../../lib/cn'
const INPUT_CLASS =
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50 scroll-pr-1'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
/** Minimal input component matching the textarea styling. */
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', ...props }, ref) => {
return <input type={type} className={cn(INPUT_CLASS, className)} ref={ref} {...props} />
}
)
Input.displayName = 'Input'
export { Input }
@@ -0,0 +1,38 @@
'use client'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '../../lib/cn'
export interface LabelProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {}
/**
* EMCN Label component built on Radix UI Label primitive.
*
* @remarks
* Provides consistent typography and styling for form labels.
* Automatically handles disabled states through peer-disabled CSS.
*
* @param className - Additional CSS classes to apply
* @param props - Additional props passed to the Radix Label primitive
* @returns The styled label element
*
* @example
* ```tsx
* <Label htmlFor="email">Email Address</Label>
* ```
*/
function Label({ className, ...props }: LabelProps) {
return (
<LabelPrimitive.Root
className={cn(
'inline-flex items-center font-medium text-[var(--text-primary)] text-small leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,58 @@
/**
* Default `onOpenAutoFocus` handler for emcn modals.
*
* Radix's native behavior focuses the first focusable descendant — usually the close
* button in `ModalHeader`. We instead focus the first visible text-entry control
* (input/textarea/contenteditable) inside the dialog, with the caret at the end.
*
* If no such control exists, we let Radix's default behavior run by not calling
* `preventDefault()`.
*/
const TEXT_INPUT_SELECTOR = [
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])' +
':not([type="button"]):not([type="submit"]):not([type="reset"])' +
':not([disabled]):not([readonly]):not([tabindex="-1"])',
'textarea:not([disabled]):not([readonly]):not([tabindex="-1"])',
'[contenteditable="true"]:not([tabindex="-1"])',
'[contenteditable=""]:not([tabindex="-1"])',
].join(',')
function isVisible(el: HTMLElement): boolean {
return el.offsetParent !== null || el.getClientRects().length > 0
}
/**
* Focus the first visible text-entry input within `root`, placing caret at end.
* Returns true if an element was focused.
*/
export function focusFirstTextInputIn(root: HTMLElement | null): boolean {
if (!root) return false
const target = Array.from(root.querySelectorAll<HTMLElement>(TEXT_INPUT_SELECTOR)).find(isVisible)
if (!target) return false
target.focus({ preventScroll: false })
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const end = target.value.length
try {
target.setSelectionRange(end, end)
} catch {
// Some input types (number, email, etc.) reject setSelectionRange — ignore.
}
} else if (target.isContentEditable) {
const range = document.createRange()
range.selectNodeContents(target)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
return true
}
export function focusFirstTextInput(event: Event): void {
if (focusFirstTextInputIn(event.currentTarget as HTMLElement | null)) {
event.preventDefault()
}
}
@@ -0,0 +1,535 @@
/**
* Compositional modal component with optional tabs.
* Uses Radix UI Dialog and Tabs primitives for accessibility.
* For sidebar modals, use `sidebar-modal.tsx` instead.
*
* @example
* ```tsx
* // Base modal
* <Modal>
* <ModalTrigger>Open</ModalTrigger>
* <ModalContent>
* <ModalHeader>Title</ModalHeader>
* <ModalBody>Content here</ModalBody>
* <ModalFooter>
* <Button>Save</Button>
* </ModalFooter>
* </ModalContent>
* </Modal>
*
* // Modal with tabs
* <Modal>
* <ModalContent>
* <ModalHeader>Title</ModalHeader>
* <ModalTabs defaultValue="tab1">
* <ModalTabsList>
* <ModalTabsTrigger value="tab1">Tab 1</ModalTabsTrigger>
* <ModalTabsTrigger value="tab2">Tab 2</ModalTabsTrigger>
* </ModalTabsList>
* <ModalTabsContent value="tab1">Content 1</ModalTabsContent>
* <ModalTabsContent value="tab2">Content 2</ModalTabsContent>
* </ModalTabs>
* </ModalContent>
* </Modal>
* ```
*/
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { cn } from '../../lib/cn'
import { Button } from '../button/button'
import { focusFirstTextInput, focusFirstTextInputIn } from './auto-focus'
/**
* Shared animation classes for modal transitions.
* Mirrors the legacy `Modal` component to ensure consistent behavior.
*/
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
function hasOpenFloatingLayer() {
return Boolean(document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]'))
}
/**
* Clears a stale `pointer-events: none` lock Radix can leave on `<body>` when
* this dialog closes while a nested modal popper (an open `ChipDropdown` /
* `Select`) is still open: both layers' body locks tear down in the same tick
* and the release is lost, freezing the page so nothing is clickable.
*
* Rendered INSIDE `DialogPrimitive.Content` so it unmounts exactly when the
* dialog closes (Radix `Presence`), unlike `ModalContent` which consumers keep
* mounted across open/close. The check is deferred a frame so it runs after
* Radix's own teardown, and only clears the lock when no other dialog remains
* open (nested modals keep theirs). Outside a dialog, EMCN poppers are
* non-modal and never lock the body, so a surviving lock is always stale.
*/
function ModalBodyLockReleaser() {
React.useEffect(() => {
return () => {
requestAnimationFrame(() => {
if (document.body.style.pointerEvents !== 'none') return
const anotherDialogOpen = document.querySelector(
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
)
if (!anotherDialogOpen) {
document.body.style.pointerEvents = ''
}
})
}
}, [])
return null
}
/**
* Whether the current subtree renders inside a `ModalContent`.
*
* Floating EMCN controls (e.g. `ChipDropdown`) read this to switch their
* Radix popper to modal behavior. A non-modal popper portaled to `body`
* underneath a modal dialog inherits the dialog's `pointer-events: none`
* body lock and its outside-scroll lock, leaving the popper unclickable and
* unscrollable; a modal popper pauses the dialog's focus trap and carries
* its own scroll allowance.
*/
const InsideModalContext = React.createContext(false)
/**
* Root modal component. Manages open state.
*/
const Modal = DialogPrimitive.Root
/**
* Trigger element that opens the modal when clicked.
*/
const ModalTrigger = DialogPrimitive.Trigger
/**
* Portal component for rendering modal outside DOM hierarchy.
*/
const ModalPortal = DialogPrimitive.Portal
/**
* Close element that closes the modal when clicked.
*/
const ModalClose = DialogPrimitive.Close
/**
* Modal overlay component with fade transition.
* Outside interactions are handled by the dialog content so nested poppers can
* close without also dismissing the modal.
*/
const ModalOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, style, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-[var(--z-modal)] bg-black/10 backdrop-blur-[2px]',
ANIMATION_CLASSES,
className
)}
style={style}
{...props}
/>
)
})
ModalOverlay.displayName = 'ModalOverlay'
/**
* Modal size variants with responsive viewport-based sizing.
* Each size uses viewport units with sensible min/max constraints.
*/
const MODAL_SIZES = {
sm: 'w-[90vw] max-w-[440px]',
md: 'w-[90vw] max-w-[500px]',
lg: 'w-[90vw] max-w-[600px]',
xl: 'w-[90vw] max-w-[800px]',
full: 'w-[95vw] max-w-[1200px]',
} as const
export type ModalSize = keyof typeof MODAL_SIZES
export interface ModalContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/**
* Whether to show the close button
* @default true
*/
showClose?: boolean
/**
* Modal size variant with responsive viewport-based sizing.
* - sm: max 440px (dialogs, confirmations)
* - md: max 500px (default, forms)
* - lg: max 600px (content-heavy modals)
* - xl: max 800px (complex editors)
* - full: max 1200px (dashboards, large content)
*
* Sizes up to `xl` center within the content area (offset for the sidebar,
* and the panel on workflow pages). `full` modals span most of the viewport,
* so they center against the full viewport instead.
* @default 'md'
*/
size?: ModalSize
/**
* Strips the modal's default visual chrome (background, ring, rounded
* corners, overflow clip) so a custom surface nested inside can fully own
* its appearance. Useful when wrapping a self-styled panel like
* `ChipModal`. Modal mechanics (overlay, focus trap, ESC, animations)
* remain intact.
*
* When `bare` is `true`, pass `srTitle` to keep the dialog accessible —
* there's no visible `ModalHeader` providing a title.
* @default false
*/
bare?: boolean
/**
* Screen-reader-only title rendered as a hidden `DialogPrimitive.Title`.
* Pair with `bare` to satisfy Radix's accessibility contract when no
* visible `ModalHeader` is rendered. Without it, Radix's focus management
* can fall into states where the dialog can't be re-opened cleanly.
*/
srTitle?: string
}
/**
* Modal content component with overlay and styled container.
* Main container that can hold sidebar, header, tabs, and footer.
*/
const ModalContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ModalContentProps
>(
(
{
className,
children,
showClose = true,
size = 'md',
bare = false,
srTitle,
style,
onOpenAutoFocus,
'aria-describedby': ariaDescribedBy,
...props
},
ref
) => {
const pathname = usePathname()
const isWorkflowPage = pathname?.includes('/w/') ?? false
return (
<ModalPortal>
<ModalOverlay />
<div
className='pointer-events-none fixed inset-0 z-[var(--z-modal)] flex items-center justify-center'
style={
size === 'full'
? undefined
: {
paddingLeft: isWorkflowPage
? 'calc(var(--sidebar-width) - var(--panel-width))'
: 'var(--sidebar-width)',
}
}
>
<DialogPrimitive.Content
ref={ref}
className={cn(
'pointer-events-auto flex max-h-[84vh] flex-col text-small',
!bare && 'overflow-hidden rounded-xl bg-[var(--bg)] ring-1 ring-foreground/10',
ANIMATION_CLASSES,
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 duration-200',
MODAL_SIZES[size],
className
)}
style={style}
onEscapeKeyDown={(e) => {
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onPointerUp={(e) => {
e.stopPropagation()
}}
onInteractOutside={(e) => {
/**
* Radix dispatches outside-interaction events to every open
* layer at once, so a click that should only dismiss an open
* dropdown / select / combobox (portaled into a popper wrapper
* above this modal) would also close the modal — both via the
* pointer event and via the transient focus shift when the
* popper's focus scope unwinds (`focusOutside`). Worse, the
* modal and the popper tearing down their body pointer-events
* locks in the same tick can leave the page frozen. Keep the
* modal open and let the interaction dismiss just the popper
* layer. The `data-state="open"` filter ignores poppers that
* are merely animating closed, so a follow-up click during the
* exit animation still dismisses the modal.
*/
if (hasOpenFloatingLayer()) {
e.preventDefault()
}
}}
onOpenAutoFocus={onOpenAutoFocus ?? focusFirstTextInput}
aria-describedby={ariaDescribedBy}
{...props}
>
<ModalBodyLockReleaser />
{srTitle ? (
<DialogPrimitive.Title className='sr-only'>{srTitle}</DialogPrimitive.Title>
) : null}
<InsideModalContext.Provider value={true}>{children}</InsideModalContext.Provider>
</DialogPrimitive.Content>
</div>
</ModalPortal>
)
}
)
ModalContent.displayName = 'ModalContent'
/**
* Modal header component for title and description.
*/
const ModalHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex min-w-0 items-center justify-between gap-2 px-4 pt-4 pb-2', className)}
{...props}
>
<DialogPrimitive.Title className='min-w-0 font-medium text-[var(--text-primary)] text-base leading-none'>
{children}
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<Button
variant='ghost'
className='relative size-[16px] flex-shrink-0 p-0 before:absolute before:inset-[-14px] before:content-[""]'
>
<X className='size-[16px]' />
<span className='sr-only'>Close</span>
</Button>
</DialogPrimitive.Close>
</div>
)
)
ModalHeader.displayName = 'ModalHeader'
/**
* Modal title component.
*/
const ModalTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={className} {...props} />
))
ModalTitle.displayName = 'ModalTitle'
/**
* Modal description component.
*/
const ModalDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={className} {...props} />
))
ModalDescription.displayName = 'ModalDescription'
/**
* Modal tabs root component. Wraps tab list and content panels.
*/
const ModalTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ onValueChange, ...props }, ref) => {
const rootRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, [])
const handleValueChange = (value: string) => {
onValueChange?.(value)
window.requestAnimationFrame(() => {
const root = rootRef.current
if (!root) return
const panel = root.querySelector<HTMLElement>('[role="tabpanel"][data-state="active"]')
focusFirstTextInputIn(panel)
})
}
return <TabsPrimitive.Root ref={rootRef} onValueChange={handleValueChange} {...props} />
})
ModalTabs.displayName = 'ModalTabs'
interface ModalTabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
/** Currently active tab value for indicator positioning */
activeValue?: string
/**
* Whether the tabs are disabled (non-interactive with reduced opacity)
* @default false
*/
disabled?: boolean
}
/**
* Modal tabs list component with animated sliding indicator.
*/
const ModalTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
ModalTabsListProps
>(({ className, children, activeValue, disabled = false, ...props }, ref) => {
const listRef = React.useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = React.useState({ left: 0, width: 0 })
const [ready, setReady] = React.useState(false)
React.useEffect(() => {
const list = listRef.current
if (!list) return
const updateIndicator = () => {
const activeTab = list.querySelector('[data-state="active"]') as HTMLElement | null
if (!activeTab) return
setIndicator({
left: activeTab.offsetLeft,
width: activeTab.offsetWidth,
})
setReady(true)
}
updateIndicator()
const observer = new MutationObserver(updateIndicator)
observer.observe(list, { attributes: true, subtree: true, attributeFilter: ['data-state'] })
window.addEventListener('resize', updateIndicator)
return () => {
observer.disconnect()
window.removeEventListener('resize', updateIndicator)
}
}, [activeValue])
return (
<TabsPrimitive.List
ref={ref}
className={cn(
'relative flex gap-4 px-4 pt-1',
disabled && 'pointer-events-none opacity-50',
className
)}
{...props}
>
<div ref={listRef} className='flex gap-4'>
{children}
</div>
<span
className={cn(
'pointer-events-none absolute bottom-0 h-[1px] rounded-full bg-[var(--text-primary)]',
ready ? 'opacity-100 transition-[left,width,opacity] duration-200 ease-out' : 'opacity-0'
)}
style={{ left: indicator.left, width: indicator.width }}
/>
</TabsPrimitive.List>
)
})
ModalTabsList.displayName = 'ModalTabsList'
/**
* Modal tab trigger component. Individual tab button.
*/
const ModalTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'px-1 pb-2 font-medium text-[var(--text-secondary)] text-small transition-colors',
'hover-hover:text-[var(--text-primary)] data-[state=active]:text-[var(--text-primary)]',
className
)}
{...props}
/>
))
ModalTabsTrigger.displayName = 'ModalTabsTrigger'
/**
* Modal tab content component. Content panel for each tab.
* Includes bottom padding for consistent spacing across all tabbed modals.
*
* When this panel mounts (i.e. its tab becomes active), focus moves to the first
* visible text-entry input inside it so typing works immediately. Tabs with no
* text input are untouched.
*/
const ModalTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('pb-2.5', className)} {...props} />
))
ModalTabsContent.displayName = 'ModalTabsContent'
/**
* Modal body/content area with background and padding.
*/
const ModalBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex-1 overflow-y-auto px-4 pt-3 pb-4', className)} {...props} />
)
)
ModalBody.displayName = 'ModalBody'
/**
* Modal footer component for action buttons.
*/
const ModalFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex justify-end gap-2 rounded-b-xl border-[var(--border)] border-t bg-[color-mix(in_srgb,var(--surface-3)_50%,transparent)] px-4 py-3',
className
)}
{...props}
/>
)
)
ModalFooter.displayName = 'ModalFooter'
export {
InsideModalContext,
Modal,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalTabs,
ModalTabsList,
ModalTabsTrigger,
ModalTabsContent,
ModalFooter,
ModalPortal,
ModalOverlay,
ModalClose,
MODAL_SIZES,
}
@@ -0,0 +1,8 @@
/**
* Radix popover open/close animation classes — fade + zoom + directional slide,
* with `motion-reduce` opt-out. Shared across emcn popover-style surfaces
* (`Popover`, `ChipDatePicker`, and consumers that build their own popover
* content). Apply alongside a surface's own layout/background classes.
*/
export const POPOVER_ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { AlertTriangle } from 'lucide-react'
import { Check, Loader, Square, X } from '../../icons'
import { cn } from '../../lib/cn'
const progressItemVariants = cva('flex items-start gap-2.5 px-3 py-3 text-[12px]', {
variants: {
status: {
pending: '',
success: '',
error: '',
},
},
defaultVariants: { status: 'pending' },
})
type ProgressStatus = NonNullable<VariantProps<typeof progressItemVariants>['status']>
const ICON_CLASS = 'mt-px size-[14px] shrink-0'
function StatusIcon({ status }: { status: ProgressStatus }) {
if (status === 'success')
return <Check className={cn(ICON_CLASS, 'text-[var(--badge-success-text)]')} />
if (status === 'error')
return <AlertTriangle className={cn(ICON_CLASS, 'text-[var(--text-error)]')} />
return <Loader animate className={cn(ICON_CLASS, 'text-[var(--text-icon)]')} />
}
export interface ProgressItemProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,
VariantProps<typeof progressItemVariants> {
status: ProgressStatus
/** Primary line (truncated). */
title: React.ReactNode
/** Right-aligned status on the title row, e.g. `Processing · 45%`. */
meta?: React.ReactNode
/** Secondary line under the title. */
detail?: React.ReactNode
/** Renders a dismiss button when provided (terminal rows). */
onDismiss?: () => void
/** Accessible label for the dismiss button. */
dismissLabel?: string
/** Renders a cancel button when provided (active rows); takes precedence over `onDismiss`. */
onCancel?: () => void
}
/**
* A single status/progress row: a leading status icon (spinner / check / alert), a primary
* title, an optional right-aligned `meta` (status + percent), an optional secondary `detail`
* line, and an optional dismiss button. Every status renders through the same fixed layout —
* only the values change — so rows stay visually consistent across stages.
*
* @example
* ```tsx
* <ProgressItem status='pending' title='data.csv' meta='Processing · 45%' detail='450,000 / 1,000,000 rows' />
* <ProgressItem status='success' title='data.csv' meta='Done' detail='1,000,000 rows imported' onDismiss={close} />
* <ProgressItem status='error' title='data.csv' meta='Failed' detail='Row 12: invalid number' onDismiss={close} />
* ```
*/
const ProgressItem = forwardRef<HTMLDivElement, ProgressItemProps>(function ProgressItem(
{ className, status, title, meta, detail, onDismiss, dismissLabel, onCancel, ...props },
ref
) {
const trailingAction = onCancel ?? onDismiss
const trailingLabel = onCancel ? 'Cancel' : (dismissLabel ?? 'Dismiss')
return (
<div ref={ref} className={cn(progressItemVariants({ status }), className)} {...props}>
<StatusIcon status={status} />
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<div className='flex items-center gap-2'>
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-primary)]'>
{title}
</span>
{meta != null && (
<span className='shrink-0 text-[var(--text-secondary)] tabular-nums'>{meta}</span>
)}
</div>
{detail != null && (
<span
className={cn(
'truncate',
status === 'error' ? 'text-[var(--text-error)]' : 'text-[var(--text-tertiary)]'
)}
>
{detail}
</span>
)}
</div>
{trailingAction && (
<button
type='button'
onClick={trailingAction}
aria-label={trailingLabel}
title={trailingLabel}
className='-mr-1 shrink-0 rounded-[4px] p-1 text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-primary)]'
>
{onCancel ? <Square className='size-[12px]' /> : <X className='size-[14px]' />}
</button>
)}
</div>
)
})
ProgressItem.displayName = 'ProgressItem'
export { ProgressItem, progressItemVariants }
@@ -0,0 +1,69 @@
/**
* A password-style chip input that masks its value with bullets only while the
* field is unfocused.
*
* @remarks
* Unlike a standard `<input type="password">`, this keeps the real text
* visible while the user is actively editing — so they can verify pasted
* secrets like signing tokens or API keys — and only swaps to bullets on
* blur. Uses plain `type="text"` so password managers don't auto-fill.
* Built on {@link ChipInput}, so it shares the canonical chip-field chrome.
*
* @example
* ```tsx
* import { SecretInput } from '../../index'
*
* <SecretInput
* id='signing-secret'
* value={secret}
* onChange={setSecret}
* placeholder='Paste your signing secret'
* />
* ```
*/
'use client'
import * as React from 'react'
import { ChipInput, type ChipInputProps } from '../chip-input/chip-input'
interface SecretInputProps
extends Omit<ChipInputProps, 'type' | 'value' | 'onChange' | 'defaultValue'> {
/** Current value. Rendered as bullets when the input is not focused. */
value: string
/** Called with the new value on every real edit (focused-only). */
onChange: (next: string) => void
}
const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
({ value, onChange, onFocus, onBlur, ...props }, ref) => {
const [isFocused, setIsFocused] = React.useState(false)
const displayValue = isFocused ? value : '•'.repeat(value.length)
return (
<ChipInput
ref={ref}
type='text'
value={displayValue}
onChange={(e) => {
// Guard against synthetic change events (autofill, form reset) firing
// while blurred, which would overwrite the real value with bullets.
if (!isFocused) return
onChange(e.target.value)
}}
onFocus={(e) => {
setIsFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setIsFocused(false)
onBlur?.(e)
}}
autoComplete='off'
{...props}
/>
)
}
)
SecretInput.displayName = 'SecretInput'
export { SecretInput }
@@ -0,0 +1,74 @@
/**
* A read-only display for a one-time secret reveal: the value renders inside
* a bordered code box with a copy button, or as masked dots when redacted.
*
* @remarks
* Use for surfaces that show a freshly-generated credential (API key, signing
* secret, etc.) once and then need to fall back to a redacted state on
* subsequent renders. Pair with `redacted` (or simply omit `value`) to render
* the masked state without a copy affordance.
*
* @example
* ```tsx
* import { SecretReveal } from '../../index'
*
* <SecretReveal value={apiKey} />
* <SecretReveal redacted />
* ```
*/
'use client'
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
import { Button, Check, Duplicate } from '../../index'
import { cn } from '../../lib/cn'
import { chipFieldSurfaceClass, chipFieldTextClass } from '../chip/chip-chrome'
const REDACTED_DOTS = '••••••••••••••••••••••••••••••••'
export interface SecretRevealProps {
/** Secret value to display. When absent or `redacted` is true, renders masked dots. */
value?: string
/** Force the masked state even when `value` is provided. */
redacted?: boolean
className?: string
}
export function SecretReveal({ value, className, redacted = false }: SecretRevealProps) {
const { copied, copy } = useCopyToClipboard()
const isHidden = redacted || !value
const handleCopy = () => {
if (isHidden || !value) return
copy(value)
}
return (
<div
className={cn(
'flex h-[30px] w-full items-center gap-1.5 px-2',
chipFieldSurfaceClass,
className
)}
>
<code
className={cn(
chipFieldTextClass,
'flex-1 truncate font-mono',
isHidden && 'text-[var(--text-muted)]'
)}
>
{isHidden ? REDACTED_DOTS : value}
</code>
{!isHidden && (
<Button
variant='ghost'
className='size-[18px] flex-shrink-0 rounded-sm p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-primary)]'
onClick={handleCopy}
>
{copied ? <Check className='size-[14px]' /> : <Duplicate className='size-[14px]' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
)}
</div>
)
}
@@ -0,0 +1,19 @@
import { cn } from '../../lib/cn'
/**
* Placeholder loading skeleton with a subtle pulse animation.
* @param props - Standard div attributes including className for sizing.
*/
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-[var(--surface-active)] motion-reduce:animate-none',
className
)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,40 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '../../lib/cn'
interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {}
/**
* EMCN Slider component built on Radix UI Slider primitive.
* Styled to match the Switch component with thin track design.
*
* @example
* ```tsx
* <Slider value={[50]} onValueChange={setValue} min={0} max={100} step={10} />
* ```
*/
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, disabled, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
disabled={disabled}
className={cn(
'relative flex w-full touch-none select-none items-center',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-[6px] w-full grow overflow-hidden rounded-[20px] bg-[var(--border-1)] transition-colors'>
<SliderPrimitive.Range className='absolute h-full bg-[var(--text-primary)]' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='relative block size-[14px] cursor-pointer rounded-full bg-[var(--text-primary)] shadow-sm transition-colors before:absolute before:inset-[-15px] before:content-[""] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]' />
</SliderPrimitive.Root>
)
)
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '../../lib/cn'
/**
* Switch component styled to match Sim's design system.
* Uses brand color for checked state, neutral border for unchecked.
*/
const Switch = React.memo(
React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, disabled, ...props }, ref) => (
<SwitchPrimitives.Root
disabled={disabled}
className={cn(
'peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors before:absolute before:inset-[-12px] before:content-[""] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)] data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--text-primary)] data-[disabled]:opacity-50',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb className='pointer-events-none block size-4 rounded-full bg-[var(--surface-2)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
</SwitchPrimitives.Root>
))
)
Switch.displayName = 'Switch'
export { Switch }
@@ -0,0 +1,118 @@
import * as React from 'react'
import { cn } from '../../lib/cn'
/**
* A simple Table component for displaying data.
*
* @example
* ```tsx
* <Table>
* <TableHeader>
* <TableRow>
* <TableHead>Name</TableHead>
* <TableHead>Status</TableHead>
* </TableRow>
* </TableHeader>
* <TableBody>
* <TableRow>
* <TableCell>Document.pdf</TableCell>
* <TableCell>Active</TableCell>
* </TableRow>
* </TableBody>
* </Table>
* ```
*/
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<table ref={ref} className={cn('w-full caption-bottom text-small', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-[color-mix(in_srgb,var(--surface-3)_50%,transparent)] font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-[var(--border)] border-b transition-colors', className)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-3 py-2 text-left align-middle font-medium text-[var(--text-secondary)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-[var(--text-muted)] text-sm', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
@@ -0,0 +1,508 @@
/**
* A tag input component for managing a list of tags with validation support.
*
* @example
* ```tsx
* import { TagInput, type TagItem } from '../../index'
*
* const [items, setItems] = useState<TagItem[]>([])
*
* <TagInput
* items={items}
* onAdd={(value) => {
* const isValid = isValidEmail(value)
* setItems(prev => [...prev, { value, isValid }])
* return isValid
* }}
* onRemove={(value, index) => {
* setItems(prev => prev.filter((_, i) => i !== index))
* }}
* placeholder="Enter emails"
* />
* ```
*
* @example With file input enabled
* ```tsx
* <TagInput
* items={items}
* onAdd={handleAdd}
* onRemove={handleRemove}
* fileInputOptions={{
* enabled: true,
* accept: '.csv,.txt',
* extractValues: (text) => text.match(/[\w.-]+@[\w.-]+\.\w+/g) || [],
* }}
* />
* ```
*/
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Paperclip, Plus, X } from 'lucide-react'
import { cn } from '../../lib/cn'
import { handleKeyboardActivation } from '../../lib/keyboard'
import { ChipTag, chipTagVariants } from '../chip-tag/chip-tag'
import { Tooltip } from '../tooltip/tooltip'
/**
* Variant styles for the TagInput container.
*
* @remarks
* - `default` matches the standard Input component styling for consistent height.
* - `block` matches the multi-row "Description" textarea pattern: larger radius,
* top-aligned items, taller min-height, and no focus ring — for use inside
* form sections where the tag input visually pairs with textarea fields.
* Uses `content-start` so wrapped flex lines pack tightly at `h-5` (20px) row
* pitch instead of being stretched by the `min-h-[112px]` floor; unused
* vertical space stays at the bottom of the container, and content beyond
* `max-h` scrolls vertically.
*/
const tagInputVariants = cva(
'flex w-full cursor-text flex-wrap gap-2 overflow-y-auto border border-[var(--border-1)] bg-[var(--surface-5)] px-2 transition-colors',
{
variants: {
variant: {
default:
'items-center rounded-sm py-1.5 focus-within:outline-none focus-within:ring-1 focus-within:ring-[var(--brand-accent)] dark:bg-[var(--surface-5)]',
block:
'min-h-[112px] content-start items-start rounded-lg py-2 focus-within:outline-none dark:bg-[var(--surface-4)]',
},
},
defaultVariants: {
variant: 'default',
},
}
)
/**
* Represents a tag item with its value and validity status.
*/
export interface TagItem {
value: string
isValid: boolean
/**
* Why the item is invalid. Shown in a tooltip on the invalid chip (and as
* screen-reader-only text inside it). Ignored when `isValid` is true.
*/
error?: string
}
/**
* Options for enabling file input functionality.
*/
export interface FileInputOptions {
/** Whether file input is enabled */
enabled: boolean
/** Accepted file types (default: '.csv,.txt,text/csv,text/plain') */
accept?: string
/** Icon component to render (default: Paperclip) */
icon?: React.ComponentType<{ className?: string; strokeWidth?: number }>
/** Extract values from file content. Each extracted value will be passed to onAdd. */
extractValues?: (text: string) => string[]
/** Tooltip text for the file input button */
tooltip?: string
}
/**
* Props for the TagInput component.
*/
interface TagInputProps extends VariantProps<typeof tagInputVariants> {
/** Array of tag items with value and validity status */
items: TagItem[]
/**
* Callback when a new tag is added.
* Return true if the value was valid and added, false if invalid.
*/
onAdd: (value: string) => boolean
/** Callback when a tag is removed (receives value, index, and isValid) */
onRemove: (value: string, index: number, isValid: boolean) => void
/** Callback when the input value changes (useful for clearing errors) */
onInputChange?: (value: string) => void
/** Placeholder text for the input */
placeholder?: string
/** Placeholder text when there are existing tags */
placeholderWithTags?: string
/** Whether the input is disabled */
disabled?: boolean
/** Additional class names for the container */
className?: string
/** Additional class names for the input */
inputClassName?: string
/**
* Maximum height for the container. Defaults to `max-h-48` (192px) for the
* `block` variant, `max-h-32` (128px) otherwise.
*/
maxHeight?: string
/** HTML id for the input element */
id?: string
/** HTML name for the input element */
name?: string
/** Whether to auto-focus the input */
autoFocus?: boolean
/** Custom keys that trigger tag addition (defaults to Enter, comma, space) */
triggerKeys?: string[]
/** Optional render function for tag suffix content */
renderTagSuffix?: (value: string, index: number) => React.ReactNode
/** Options for enabling file input (drag/drop and file picker) */
fileInputOptions?: FileInputOptions
}
interface TagInputTagProps {
item: TagItem
index: number
onRemove: TagInputProps['onRemove']
disabled: boolean
suffix?: React.ReactNode
}
const TagInputTag = React.memo(function TagInputTag({
item,
index,
onRemove,
disabled,
suffix,
}: TagInputTagProps) {
const handleRemove = React.useCallback(() => {
onRemove(item.value, index, item.isValid)
}, [item.value, item.isValid, index, onRemove])
const showError = !item.isValid && !!item.error
const tag = (
<ChipTag
variant='invite'
invalid={!item.isValid}
className='min-w-0 max-w-full bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-3)]'
rightIcon={disabled ? undefined : X}
onRightIconClick={disabled ? undefined : handleRemove}
rightIconLabel={`Remove ${item.value}`}
>
<span className='min-w-0 flex-1 translate-y-[0.5px] truncate font-medium font-sans text-sm leading-5'>
{item.value}
</span>
{showError && <span className='sr-only'>{item.error}</span>}
{suffix}
</ChipTag>
)
if (!showError) return tag
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{tag}</Tooltip.Trigger>
<Tooltip.Content>{item.error}</Tooltip.Content>
</Tooltip.Root>
)
})
/**
* An input component for managing a list of tags.
*
* @remarks
* - Maintains consistent height with standard Input component
* - Supports keyboard navigation (Enter/comma/space to add, Backspace to remove)
* - Handles paste with multiple values separated by whitespace, commas, or semicolons
* - Displays invalid values with error styling
*/
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(
{
items,
onAdd,
onRemove,
onInputChange,
placeholder = 'Enter values',
placeholderWithTags = 'Add another',
disabled = false,
className,
inputClassName,
maxHeight,
id,
name,
autoFocus = false,
triggerKeys = ['Enter', ',', ' '],
renderTagSuffix,
fileInputOptions,
variant,
},
ref
) => {
const effectiveMaxHeight = maxHeight ?? (variant === 'block' ? 'max-h-48' : 'max-h-32')
const [inputValue, setInputValue] = React.useState('')
const [isDragging, setIsDragging] = React.useState(false)
const internalRef = React.useRef<HTMLInputElement>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
const hasItems = items.length > 0
const fileInputEnabled = fileInputOptions?.enabled ?? false
const FileIcon = fileInputOptions?.icon ?? Paperclip
const fileAccept = fileInputOptions?.accept ?? '.csv,.txt,text/csv,text/plain'
React.useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus()
}
}, [autoFocus, inputRef])
const handleFileContent = React.useCallback(
async (file: File) => {
try {
const text = await file.text()
const extractValues = fileInputOptions?.extractValues
if (extractValues) {
const values = extractValues(text)
values.forEach((value) => onAdd(value))
}
} catch {}
},
[fileInputOptions?.extractValues, onAdd]
)
const handleDragOver = React.useCallback(
(e: React.DragEvent) => {
if (!fileInputEnabled) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
setIsDragging(true)
},
[fileInputEnabled]
)
const handleDragLeave = React.useCallback(
(e: React.DragEvent) => {
if (!fileInputEnabled) return
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
},
[fileInputEnabled]
)
const handleDrop = React.useCallback(
async (e: React.DragEvent) => {
if (!fileInputEnabled) return
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
const acceptPatterns = fileAccept.split(',').map((p) => p.trim().toLowerCase())
const validFiles = files.filter((f) => {
const ext = `.${f.name.split('.').pop()?.toLowerCase()}`
const type = f.type.toLowerCase()
return acceptPatterns.some((pattern) => pattern === ext || pattern === type)
})
for (const file of validFiles) {
await handleFileContent(file)
}
},
[fileInputEnabled, fileAccept, handleFileContent]
)
const handleFileInputChange = React.useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
await handleFileContent(file)
}
e.target.value = ''
},
[handleFileContent]
)
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (triggerKeys.includes(e.key) && inputValue.trim()) {
e.preventDefault()
onAdd(inputValue.trim())
setInputValue('')
}
if (e.key === 'Backspace' && !inputValue && items.length > 0) {
const lastItem = items[items.length - 1]
onRemove(lastItem.value, items.length - 1, lastItem.isValid)
}
},
[inputValue, triggerKeys, onAdd, items, onRemove]
)
/**
* Pasted values are committed through `onAdd` exactly like typing + Enter:
* consumers render rejected values as flagged invalid chips, so nothing is
* re-staged into the input afterwards — doing so would display the same
* value twice (the invalid chip plus the raw text in the typing buffer).
*/
const handlePaste = React.useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedValues = pastedText.split(/[\s,;]+/).filter(Boolean)
pastedValues.forEach((value) => {
onAdd(value.trim())
})
},
[onAdd]
)
const handleBlur = React.useCallback(() => {
if (inputValue.trim()) {
onAdd(inputValue.trim())
setInputValue('')
}
}, [inputValue, onAdd])
const handleContainerClick = React.useCallback(() => {
inputRef.current?.focus()
}, [inputRef])
return (
<div
role='group'
aria-label='Tag input'
className={cn(
tagInputVariants({ variant }),
effectiveMaxHeight,
'relative',
fileInputEnabled && 'pr-7',
isDragging && 'border-[var(--border)] border-dashed bg-[var(--surface-5)]',
className
)}
onClick={handleContainerClick}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
handleKeyboardActivation(event, handleContainerClick)
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{fileInputEnabled && (
<input
ref={fileInputRef}
type='file'
accept={fileAccept}
onChange={handleFileInputChange}
className='hidden'
/>
)}
{isDragging && (
<div className='absolute inset-0 flex items-center justify-center rounded-sm bg-[color-mix(in_srgb,var(--surface-5)_90%,transparent)]'>
<span className='text-[var(--text-tertiary)] text-small'>Drop file here</span>
</div>
)}
{items.map((item, index) => (
<TagInputTag
key={`item-${index}`}
item={item}
index={index}
onRemove={onRemove}
disabled={disabled}
suffix={item.isValid ? renderTagSuffix?.(item.value, index) : undefined}
/>
))}
<div
className={cn(
'flex h-5 min-w-0 max-w-full items-center',
inputValue.trim() &&
cn(
chipTagVariants({ variant: 'invite' }),
'min-w-0 max-w-full bg-[var(--surface-6)] shadow-none dark:bg-[var(--surface-3)]'
)
)}
>
<div className='relative inline-flex h-5 min-w-0 max-w-full items-center overflow-hidden'>
{inputValue.trim() && (
<span
className='invisible whitespace-pre font-medium font-sans text-sm leading-5'
aria-hidden='true'
>
{inputValue}
</span>
)}
<input
ref={inputRef}
id={id}
name={name}
type='text'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
onInputChange?.(e.target.value)
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
placeholder={hasItems ? placeholderWithTags : placeholder}
size={hasItems ? placeholderWithTags?.length || 10 : placeholder?.length || 12}
className={cn(
'appearance-none border-none bg-transparent align-middle font-sans outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
inputValue.trim()
? 'absolute top-0 left-0 h-full w-full p-0 font-medium text-inherit text-sm leading-5'
: 'h-5 w-auto min-w-0 p-0 font-medium text-[var(--text-body)] text-sm leading-5',
inputClassName
)}
disabled={disabled}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
{inputValue.trim() && (
<button
type='button'
onMouseDown={(e) => {
e.preventDefault()
if (inputValue.trim()) {
onAdd(inputValue.trim())
setInputValue('')
inputRef.current?.focus()
}
}}
className='relative flex flex-shrink-0 items-center opacity-80 transition-opacity before:absolute before:inset-[-10px] before:content-[""] hover-hover:opacity-100 focus:outline-none'
disabled={disabled}
aria-label='Add tag'
>
<Plus className='size-[14px]' />
</button>
)}
</div>
{fileInputEnabled && !disabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className='-m-1.5 absolute right-2 bottom-[9px] p-1.5 text-[var(--text-tertiary)] transition-colors hover-hover:text-[var(--text-secondary)]'
aria-label={fileInputOptions?.tooltip ?? 'Upload file'}
>
<FileIcon className='size-3.5' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{fileInputOptions?.tooltip ?? 'Upload file'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)
}
)
TagInput.displayName = 'TagInput'
export { TagInput, tagInputVariants }
@@ -0,0 +1,37 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const textareaVariants = cva(
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-2 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default: '',
},
},
defaultVariants: {
variant: 'default',
},
}
)
interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
VariantProps<typeof textareaVariants> {}
/**
* Minimal textarea component matching the user-input styling.
* Features a resize handle in the bottom right corner.
*/
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, variant, ...props }, ref) => {
return (
<textarea className={cn(textareaVariants({ variant }), className)} ref={ref} {...props} />
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }
@@ -0,0 +1,311 @@
/**
* TimePicker component with popover dropdown for time selection.
* Uses Radix UI Popover primitives for positioning and accessibility.
*
* @example
* ```tsx
* // Basic time picker
* <TimePicker
* value={time}
* onChange={(timeString) => setTime(timeString)}
* placeholder="Select time"
* />
*
* // Small size variant
* <TimePicker
* value={time}
* onChange={setTime}
* size="sm"
* />
*
* // Disabled state
* <TimePicker value="09:00" disabled />
* ```
*/
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { ChevronDown } from 'lucide-react'
import { cn } from '../../lib/cn'
import { Popover, PopoverAnchor, PopoverContent } from '../popover/popover'
/**
* Variant styles for the time picker trigger.
* Matches the input and combobox styling patterns.
*/
const timePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
{
variants: {
variant: {
default: '',
},
size: {
default: 'py-1.5 text-sm',
sm: 'py-[5px] text-caption',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
/**
* Props for the TimePicker component.
*/
interface TimePickerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof timePickerVariants> {
/** Current time value in 24h format (HH:mm) */
value?: string
/** Callback when time changes, returns HH:mm format */
onChange?: (value: string) => void
/** Placeholder text when no value is selected */
placeholder?: string
/** Whether the picker is disabled */
disabled?: boolean
/** Size variant */
size?: 'default' | 'sm'
/** Optional rendered trigger label. */
overlayContent?: React.ReactNode
}
/**
* Converts a 24h time string to 12h display format with AM/PM.
*/
function formatDisplayTime(time: string): string {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
/**
* Converts 12h time components to 24h format string.
*/
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
/**
* Parses a 24h time string into 12h components.
*/
function parseTime(time: string): { hour: string; minute: string; ampm: 'AM' | 'PM' } {
if (!time) return { hour: '12', minute: '00', ampm: 'AM' }
const [hours, minutes] = time.split(':')
const hour24 = Number.parseInt(hours, 10)
const isAM = hour24 < 12
return {
hour: (hour24 % 12 || 12).toString(),
minute: minutes || '00',
ampm: isAM ? 'AM' : 'PM',
}
}
/**
* TimePicker component matching emcn design patterns.
* Provides a popover dropdown for time selection.
*/
const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
(
{
className,
variant,
size,
value,
onChange,
placeholder = 'Select time',
disabled = false,
overlayContent,
...props
},
ref
) => {
const [open, setOpen] = React.useState(false)
const hourInputRef = React.useRef<HTMLInputElement>(null)
const parsed = React.useMemo(() => parseTime(value || ''), [value])
const [hour, setHour] = React.useState(parsed.hour)
const [minute, setMinute] = React.useState(parsed.minute)
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm)
const [prevValue, setPrevValue] = React.useState(value)
if (value !== prevValue) {
setPrevValue(value)
const newParsed = parseTime(value || '')
setHour(newParsed.hour)
setMinute(newParsed.minute)
setAmpm(newParsed.ampm)
}
React.useEffect(() => {
if (open) {
const timeoutId = setTimeout(() => {
hourInputRef.current?.focus()
hourInputRef.current?.select()
}, 0)
return () => clearTimeout(timeoutId)
}
}, [open])
const updateTime = React.useCallback(
(newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => {
if (disabled) return
const h = Number.parseInt(newHour ?? hour) || 12
const m = Number.parseInt(newMinute ?? minute) || 0
const p = newAmpm ?? ampm
onChange?.(formatStorageTime(h, m, p))
},
[disabled, hour, minute, ampm, onChange]
)
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setHour(val)
}, [])
const handleHourBlur = React.useCallback(() => {
const numVal = Number.parseInt(hour) || 12
const clamped = Math.min(12, Math.max(1, numVal))
setHour(clamped.toString())
updateTime(clamped.toString())
}, [hour, updateTime])
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
setMinute(val)
}, [])
const handleMinuteBlur = React.useCallback(() => {
const numVal = Number.parseInt(minute) || 0
const clamped = Math.min(59, Math.max(0, numVal))
setMinute(clamped.toString().padStart(2, '0'))
updateTime(undefined, clamped.toString())
}, [minute, updateTime])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setOpen(!open)
}
},
[disabled, open]
)
/**
* Handles Enter key in inputs to close picker.
*/
const handleInputKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
setOpen(false)
}
}, [])
const handleTriggerClick = React.useCallback(() => {
if (!disabled) {
setOpen(!open)
}
}, [disabled, open])
const displayValue = value ? formatDisplayTime(value) : ''
return (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} className='relative w-full' {...props}>
<PopoverAnchor asChild>
<div
role='button'
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
className={cn(
timePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
>
<span className={cn('flex-1 truncate', !displayValue && 'text-[var(--text-muted)]')}>
{overlayContent ?? (displayValue || placeholder)}
</span>
<ChevronDown
className={cn(
'ml-2 h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</div>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
className='w-auto rounded-md border border-[var(--border-1)] p-2'
>
<div className='flex items-center gap-1.5'>
<input
ref={hourInputRef}
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={hour}
onChange={handleHourChange}
onBlur={handleHourBlur}
onKeyDown={handleInputKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<span className='font-medium text-[var(--text-muted)] text-small'>:</span>
<input
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={minute}
onChange={handleMinuteChange}
onBlur={handleMinuteBlur}
onKeyDown={handleInputKeyDown}
type='text'
inputMode='numeric'
maxLength={2}
autoComplete='off'
/>
<div className='ml-0.5 flex overflow-hidden rounded-sm border border-[var(--border-1)]'>
{(['AM', 'PM'] as const).map((period) => (
<button
key={period}
type='button'
onClick={() => {
setAmpm(period)
updateTime(undefined, undefined, period)
}}
className={cn(
'px-2 py-[5px] font-medium font-sans text-caption transition-colors',
ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
)}
>
{period}
</button>
))}
</div>
</div>
</PopoverContent>
</div>
</Popover>
)
}
)
TimePicker.displayName = 'TimePicker'
export { TimePicker, timePickerVariants }
@@ -0,0 +1,640 @@
'use client'
import {
type ComponentType,
createContext,
type ReactNode,
type SVGProps,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { generateId } from '@sim/utils/id'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Bell } from '../../icons/bell'
import { CircleAlert } from '../../icons/circle-alert'
import { CircleCheck } from '../../icons/circle-check'
import { CircleInfo } from '../../icons/circle-info'
import { TriangleAlert } from '../../icons/triangle-alert'
import { X } from '../../icons/x'
import { cn } from '../../lib/cn'
import { Button } from '../button/button'
import { Chip } from '../chip/chip'
import { chipContentIconClass, chipFilledFillTokens } from '../chip/chip-chrome'
const AUTO_DISMISS_MS = 5000
/** Card width; tracks the workflow-panel inset on narrow viewports. */
const TOAST_WIDTH = 'min(100vw - 2rem, 280px)'
/** Most toasts kept alive at once; older arrivals are evicted. */
const STACK_LIMIT = 3
/** Per-depth lift and shrink that make collapsed cards peek above the front one. */
const COLLAPSED_OFFSET_PX = 13
const COLLAPSED_SCALE_STEP = 0.05
/** Vertical gap between cards once the stack is expanded. */
const EXPAND_GAP_PX = 8
/** Fallback card height used for the expanded fan-out before a toast is measured. */
const ESTIMATED_TOAST_HEIGHT = 56
/** Card border box, added to the measured content so the clamped `<li>` height matches. */
const CARD_BORDER_PX = 2
/** Shared expo-out easing so every card in the stack reshuffles with identical timing. */
const TOAST_EASE = [0.22, 1, 0.36, 1] as const
const STACK_DURATION = 0.4
const RESIZE_DURATION = 0.3
/** Single-line cards use a tighter corner radius; taller cards keep the concentric 16px. */
const COMPACT_CARD_HEIGHT_PX = 46
const COMPACT_RADIUS_PX = 12
const CONCENTRIC_RADIUS_PX = 16
type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
/** Leading icon per variant; the shape alone signals intent, tinted with the canonical chip icon color. */
const VARIANT_ICON: Record<ToastVariant, ComponentType<SVGProps<SVGSVGElement>>> = {
default: Bell,
info: CircleInfo,
success: CircleCheck,
warning: TriangleAlert,
error: CircleAlert,
}
interface ToastAction {
label: string
onClick: () => void
}
interface ToastData {
id: string
message: string
description?: string
variant: ToastVariant
action?: ToastAction
duration: number
persistAcrossRoutes: boolean
}
type ToastInput = {
message: string
description?: string
variant?: ToastVariant
action?: ToastAction
duration?: number
/**
* Keep the toast across navigation. The stack is otherwise cleared on every
* route change (route-scoped notifications shouldn't trail the user); set
* this for global, ongoing-state toasts like a connection/reconnect status.
* @default false
*/
persistAcrossRoutes?: boolean
}
type ToastFn = {
(input: ToastInput): string
success: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
error: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
warning: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
info: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
/** Dismisses a single toast by id. */
dismiss: (id: string) => void
/** Dismisses every visible toast. */
dismissAll: () => void
}
interface ToastContextValue {
toast: ToastFn
dismiss: (id: string) => void
dismissAll: () => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
let globalToast: ToastFn | null = null
let globalDismiss: ((id: string) => void) | null = null
let globalDismissAll: (() => void) | null = null
function createToastFn(add: (input: ToastInput) => string): ToastFn {
const fn = ((input: ToastInput) => add(input)) as ToastFn
fn.success = (message, options) => add({ ...options, message, variant: 'success' })
fn.error = (message, options) => add({ ...options, message, variant: 'error' })
fn.warning = (message, options) => add({ ...options, message, variant: 'warning' })
fn.info = (message, options) => add({ ...options, message, variant: 'info' })
fn.dismiss = (id) => globalDismiss?.(id)
fn.dismissAll = () => globalDismissAll?.()
return fn
}
/**
* Imperative toast. Requires a mounted `<ToastProvider>`. A toast carrying an
* `action` persists until dismissed unless an explicit `duration` is passed.
*
* @example
* ```tsx
* toast.error('Upload failed', { description: 'Network timed out' })
* toast.success('Saved', { action: { label: 'View', onClick: () => router.push('/x') } })
* ```
*/
export const toast: ToastFn = createToastFn((input) => {
if (!globalToast) {
throw new Error('toast() called before <ToastProvider> mounted')
}
return globalToast(input)
})
/** Hook to access the toast function and dismiss helpers from context. */
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}
interface ToastGeometry {
/** Vertical lift from the bottom anchor (negative = upward). */
y: number
/** Depth shrink; `1` when expanded or for the front card. */
scale: number
/** Rendered height; collapsed back cards are clamped to the front card's height. */
height: number
/** Paint order — the front card sits on top. */
zIndex: number
}
interface ToastItemProps {
toast: ToastData
geometry: ToastGeometry
reduceMotion: boolean
onDismiss: (id: string) => void
onMeasure: (id: string, height: number) => void
}
interface RevealTextProps {
text: string
/** Whether the parent card is hovered — the trigger for revealing hidden lines. */
expanded: boolean
/** Lines kept visible before truncation. */
clampLines: number
/** Line height in px; must match the text leading so the head/tail split lands on a line boundary. */
lineHeightPx: number
/** Optional inline icon; included in head and tail so wrapping stays identical. */
leadingIcon?: ReactNode
className?: string
reduceMotion: boolean
}
/**
* Truncated text that reveals its hidden lines on hover: the head shows the
* first `clampLines` lines, and when hovered (and actually overflowing) the
* remaining lines mount below and blur in as one continuous block. Text that
* fits within the clamp is never truncated and the card never grows for it.
*/
function RevealText({
text,
expanded,
clampLines,
lineHeightPx,
leadingIcon,
className,
reduceMotion,
}: RevealTextProps) {
const headRef = useRef<HTMLDivElement>(null)
const [truncated, setTruncated] = useState(false)
const clampHeight = clampLines * lineHeightPx
useLayoutEffect(() => {
const el = headRef.current
if (!el) return
const check = () => setTruncated(el.scrollHeight - el.clientHeight > 1)
check()
const observer = new ResizeObserver(check)
observer.observe(el)
return () => observer.disconnect()
}, [text])
const open = expanded && truncated
const fadeStart = Math.max(0, clampHeight - lineHeightPx * 1.5)
const collapsedMask = `linear-gradient(to bottom, #000 ${fadeStart}px, transparent ${clampHeight}px)`
return (
<div>
<div
ref={headRef}
className={cn('overflow-hidden', className)}
style={{
maxHeight: clampHeight,
maskImage: truncated && !open ? collapsedMask : undefined,
WebkitMaskImage: truncated && !open ? collapsedMask : undefined,
}}
>
{leadingIcon}
{text}
</div>
<AnimatePresence initial={false}>
{open ? (
<motion.div
className='overflow-hidden'
aria-hidden
initial={reduceMotion ? false : { height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: TOAST_EASE }
}
>
<motion.div
className={className}
style={{ marginTop: -clampHeight }}
initial={reduceMotion ? false : { opacity: 0, filter: 'blur(5px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
transition={
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: [0.2, 0, 0, 1] }
}
>
{leadingIcon}
{text}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: ToastItemProps) {
const contentRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const Icon = VARIANT_ICON[t.variant]
/**
* Report the natural height — measured on the unconstrained inner content so
* the outer `<li>` clamp can't feed back — so the provider lays the fan and
* collapsed clamp against real heights. `useLayoutEffect` avoids a paint jump.
*/
useLayoutEffect(() => {
const el = contentRef.current
if (!el) return
const report = () => onMeasure(t.id, el.offsetHeight + CARD_BORDER_PX)
report()
const observer = new ResizeObserver(report)
observer.observe(el)
return () => observer.disconnect()
}, [t.id, onMeasure])
const dismiss = useCallback(() => onDismiss(t.id), [onDismiss, t.id])
const { y, scale, height, zIndex } = geometry
const cornerRadius = height <= COMPACT_CARD_HEIGHT_PX ? COMPACT_RADIUS_PX : CONCENTRIC_RADIUS_PX
/** One fixed-duration tween so all cards reshuffle in unison; height tracks content instantly. */
const transition = reduceMotion
? { duration: 0 }
: {
duration: STACK_DURATION,
ease: TOAST_EASE,
height: { duration: 0 },
}
return (
<motion.li
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
initial={reduceMotion ? false : { opacity: 0, y: height }}
animate={{ opacity: 1, y, scale, height }}
exit={{
opacity: 0,
scale: 0.95,
transition: reduceMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeIn' },
}}
transition={transition}
style={{ zIndex, transformOrigin: 'bottom', width: TOAST_WIDTH, borderRadius: cornerRadius }}
className='pointer-events-auto absolute right-0 bottom-0 m-0 overflow-hidden border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]'
>
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
<div className='flex items-start gap-2'>
<div className='min-w-0 flex-1'>
<RevealText
text={t.message}
expanded={hovered}
clampLines={2}
lineHeightPx={20}
leadingIcon={
<span className='mr-1.5 inline-flex h-5 items-center align-top'>
<Icon className={chipContentIconClass} />
</span>
}
className='text-[var(--text-body)] text-sm leading-5'
reduceMotion={reduceMotion}
/>
</div>
<div className='flex h-5 flex-shrink-0 items-center'>
<Button
variant='quiet'
onClick={dismiss}
aria-label='Dismiss notification'
title='Dismiss'
className='size-[18px] rounded-sm p-0'
>
<X className='size-[16px]' />
</Button>
</div>
</div>
{t.description ? (
<RevealText
text={t.description}
expanded={hovered}
clampLines={3}
lineHeightPx={18}
className='text-[var(--text-muted)] text-small leading-[18px]'
reduceMotion={reduceMotion}
/>
) : null}
{t.action ? (
<Chip
fullWidth
flush
onClick={() => {
t.action!.onClick()
dismiss()
}}
className={cn('justify-center', chipFilledFillTokens)}
>
{t.action.label}
</Chip>
) : null}
</div>
</motion.li>
)
}
/**
* Toast container, mounted once in the root layout. Toasts pile bottom-right as
* a collapsed stack that fans open on hover or keyboard focus, mirroring the
* Sonner / Base-UI interaction; hovering pauses auto-dismiss. On workflow pages
* the stack is inset by the panel and terminal, and it clears on navigation.
*
* @example
* ```tsx
* <ToastProvider />
* ```
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const pathname = usePathname()
const reduceMotion = useReducedMotion() ?? false
/** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */
const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false
const [toasts, setToasts] = useState<ToastData[]>([])
const [heights, setHeights] = useState<Record<string, number>>({})
const [expanded, setExpanded] = useState(false)
const [mounted, setMounted] = useState(false)
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
useEffect(() => {
setMounted(true)
}, [])
/**
* Reset the hover-expanded flag whenever the stack empties. The hover wrapper
* unmounts without firing mouse-leave when the last toast goes (dismiss / clear
* / navigation), so without this `expanded` could stay `true` and stop the next
* toasts from auto-dismissing.
*/
useEffect(() => {
if (toasts.length === 0) setExpanded(false)
}, [toasts.length])
/**
* Adds a toast. Actionable toasts persist (`duration: 0`) unless an explicit
* `duration` is given. When the stack exceeds `STACK_LIMIT` the oldest
* auto-dismissable toast is evicted first, so a persistent (actionable) toast
* isn't silently dropped — only an all-persistent overflow evicts the oldest.
*/
const addToast = useCallback((input: ToastInput): string => {
const id = generateId()
const data: ToastData = {
id,
message: input.message,
description: input.description,
variant: input.variant ?? 'default',
action: input.action,
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
}
setToasts((prev) => {
const next = [...prev, data]
if (next.length <= STACK_LIMIT) return next
const evictIndex = next.findIndex((t) => t.duration > 0)
next.splice(evictIndex === -1 ? 0 : evictIndex, 1)
return next
})
return id
}, [])
const dismissToast = useCallback((id: string) => {
const timer = timersRef.current.get(id)
if (timer) {
clearTimeout(timer)
timersRef.current.delete(id)
}
setToasts((prev) => prev.filter((t) => t.id !== id))
setHeights((prev) => {
if (!(id in prev)) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
const dismissAllToasts = useCallback(() => {
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
setToasts([])
setHeights({})
}, [])
/**
* Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` —
* global, ongoing-state notifications like the connection status — survive,
* everything else (page-scoped notifications) is cleared on navigation.
*/
const dismissRouteScopedToasts = useCallback(() => {
setToasts((prev) => {
const kept = prev.filter((t) => t.persistAcrossRoutes)
if (kept.length === prev.length) return prev
for (const t of prev) {
if (t.persistAcrossRoutes) continue
const timer = timersRef.current.get(t.id)
if (timer) {
clearTimeout(timer)
timersRef.current.delete(t.id)
}
}
return kept
})
}, [])
const measureToast = useCallback((id: string, height: number) => {
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }))
}, [])
/** Drop measured heights for toasts evicted by `slice(-STACK_LIMIT)` (single dismissal prunes its own entry). */
useEffect(() => {
setHeights((prev) => {
const live = new Set(toasts.map((t) => t.id))
const stale = Object.keys(prev).filter((id) => !live.has(id))
if (stale.length === 0) return prev
const next = { ...prev }
for (const id of stale) delete next[id]
return next
})
}, [toasts])
/**
* Per-toast auto-dismiss timers. Each timed toast runs its own timer so it
* expires independently regardless of how many toasts are stacked; persistent
* toasts (`duration <= 0`) never get a timer, and hovering (`expanded`) holds
* every timer so a toast can't be cleared mid-read.
*/
useEffect(() => {
const timers = timersRef.current
if (toasts.length === 0 || expanded) {
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
return
}
for (const t of toasts) {
if (t.duration <= 0 || timers.has(t.id)) continue
timers.set(
t.id,
setTimeout(() => {
timers.delete(t.id)
dismissToast(t.id)
}, t.duration)
)
}
for (const [id, timer] of timers) {
if (!toasts.some((t) => t.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [toasts, expanded, dismissToast])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
}, [])
/** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */
useEffect(() => {
dismissRouteScopedToasts()
}, [pathname, dismissRouteScopedToasts])
/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
const toastFn = useRef<ToastFn>(createToastFn(addToast))
useEffect(() => {
const fn = toastFn.current
globalToast = fn
globalDismiss = dismissToast
globalDismissAll = dismissAllToasts
return () => {
if (globalToast === fn) globalToast = null
if (globalDismiss === dismissToast) globalDismiss = null
if (globalDismissAll === dismissAllToasts) globalDismissAll = null
}
}, [dismissToast, dismissAllToasts])
const ctx = useMemo<ToastContextValue>(
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll: dismissAllToasts }),
[dismissToast, dismissAllToasts]
)
const ordered = [...toasts].reverse()
const frontHeight = heights[ordered[0]?.id ?? ''] ?? ESTIMATED_TOAST_HEIGHT
let cumulative = 0
const layout = ordered.map((toast, index) => {
const naturalHeight = heights[toast.id] ?? ESTIMATED_TOAST_HEIGHT
const offsetBefore = cumulative
cumulative += naturalHeight + EXPAND_GAP_PX
const geometry: ToastGeometry = {
y: expanded ? -offsetBefore : -index * COLLAPSED_OFFSET_PX,
scale: expanded ? 1 : 1 - index * COLLAPSED_SCALE_STEP,
height: expanded || index === 0 ? naturalHeight : frontHeight,
zIndex: ordered.length - index,
}
return { toast, geometry }
})
const collapsedHeight =
frontHeight + Math.max(0, Math.min(ordered.length, STACK_LIMIT) - 1) * COLLAPSED_OFFSET_PX
const expandedHeight = cumulative > 0 ? cumulative - EXPAND_GAP_PX : frontHeight
const containerHeight = expanded ? expandedHeight : collapsedHeight
return (
<ToastContext.Provider value={ctx}>
{children}
{mounted
? createPortal(
<AnimatePresence>
{toasts.length > 0 ? (
<motion.ol
key='toast-stack'
aria-live='polite'
aria-label='Notifications'
className='fixed z-[var(--z-toast)] m-0 list-none p-0'
exit={{
opacity: 0,
transition: reduceMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeIn' },
}}
style={{
right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px',
bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px',
width: TOAST_WIDTH,
height: containerHeight,
}}
>
<div
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
onFocusCapture={() => setExpanded(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setExpanded(false)
}
}}
className='absolute inset-0'
>
<AnimatePresence>
{layout.map(({ toast, geometry }) => (
<ToastItem
key={toast.id}
toast={toast}
geometry={geometry}
reduceMotion={reduceMotion}
onDismiss={dismissToast}
onMeasure={measureToast}
/>
))}
</AnimatePresence>
</div>
</motion.ol>
) : null}
</AnimatePresence>,
document.body
)
: null}
</ToastContext.Provider>
)
}
@@ -0,0 +1,558 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { createPortal } from 'react-dom'
import { cn } from '../../lib/cn'
const TOOLTIP_OFFSET = 16
const EDGE_GUTTER = 16
const EDGE_THRESHOLD = 360
const MIN_FRAME_MS = 16
/**
* Resolved position and motion of a floating tooltip. `x`/`y` are viewport
* coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away
* from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive
* flourish while the pointer is moving.
*/
export interface FloatingTooltipState {
visible: boolean
x: number
y: number
skew: number
scaleX: number
scaleY: number
alignX: 'left' | 'right'
alignY: 'above' | 'below'
}
interface PointerSnapshot {
x: number
y: number
time: number
}
/**
* Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread
* onto the element that should reveal the tooltip on hover or focus.
*/
export interface FloatingTooltipHandlers {
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => void
onPointerMove: (event: React.PointerEvent<HTMLElement>) => void
onPointerLeave: () => void
onPointerDown: () => void
onFocus: (event: React.FocusEvent<HTMLElement>) => void
onBlur: () => void
}
const HIDDEN_STATE: FloatingTooltipState = {
visible: false,
x: 0,
y: 0,
skew: 0,
scaleX: 1,
scaleY: 1,
alignX: 'left',
alignY: 'below',
}
/**
* Drives a pointer-reactive floating tooltip. `canShow` is queried on every
* gesture with the event target, letting the caller gate the tooltip on its own
* overflow measurement. Returns the current {@link FloatingTooltipState} to feed
* a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
* to spread onto the trigger element.
*/
export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
state: FloatingTooltipState
handlers: FloatingTooltipHandlers
} {
const canShowRef = React.useRef(canShow)
canShowRef.current = canShow
const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
const hide = () => {
lastPointerRef.current = null
setState((current) => (current.visible ? HIDDEN_STATE : current))
}
const showStatic = (clientX: number, clientY: number) => {
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
setState({
visible: true,
...getTooltipPosition(clientX, clientY),
skew: 0,
scaleX: 1,
scaleY: 1,
})
}
return {
onPointerEnter: (event) => {
if (!canShowRef.current(event.currentTarget)) return
showStatic(event.clientX, event.clientY)
},
onPointerMove: (event) => {
if (!canShowRef.current(event.currentTarget)) return
const now = performance.now()
const previous = lastPointerRef.current
const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS
const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0
const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0
const velocity = Math.hypot(velocityX, velocityY)
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
setState({
visible: true,
...getTooltipPosition(event.clientX, event.clientY),
skew: clamp(velocityX * 0.11, -6, 6),
scaleX: 1 + Math.min(0.035, velocity / 1100),
scaleY: 1 - Math.min(0.02, velocity / 1500),
})
},
onPointerLeave: hide,
onPointerDown: hide,
onFocus: (event) => {
const target = event.currentTarget
if (!canShowRef.current(target)) return
if (!isFocusVisible(target)) return
const rect = target.getBoundingClientRect()
lastPointerRef.current = null
setState({
visible: true,
...getTooltipPosition(rect.left + rect.width / 2, rect.bottom),
skew: 0,
scaleX: 1,
scaleY: 1,
})
},
onBlur: hide,
}
}, [])
return { state, handlers }
}
/**
* Tracks whether an element's text is horizontally clipped, re-measuring via a
* `ResizeObserver` and window resizes.
*
* Returns a callback `ref` to attach to the element — the observer follows the
* element across mount, unmount, and reassignment, so it is safe to use on
* conditionally rendered children. `node` is a stable ref for reading the
* current element (e.g. for live measurements in event handlers).
*/
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
ref: (node: T | null) => void
node: React.RefObject<T | null>
isOverflowing: boolean
} {
const [isOverflowing, setIsOverflowing] = React.useState(false)
const nodeRef = React.useRef<T | null>(null)
const observerRef = React.useRef<ResizeObserver | null>(null)
const measure = React.useCallback(() => {
const element = nodeRef.current
if (element) setIsOverflowing(isTextClipped(element))
}, [])
const ref = React.useCallback(
(node: T | null) => {
observerRef.current?.disconnect()
observerRef.current = null
nodeRef.current = node
if (!node) return
measure()
const observer = new ResizeObserver(measure)
observer.observe(node)
observerRef.current = observer
},
[measure]
)
React.useEffect(() => {
window.addEventListener('resize', measure)
return () => {
window.removeEventListener('resize', measure)
observerRef.current?.disconnect()
}
}, [measure])
return { ref, node: nodeRef, isOverflowing }
}
/** Whether an element's content is wider than its visible box. */
export function isTextClipped(element: HTMLElement): boolean {
return element.scrollWidth > element.clientWidth + 1
}
/** Clamps `value` to the inclusive `[min, max]` range. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
/**
* Whether an element currently matches `:focus-visible` (keyboard focus, not focus produced by a
* mouse click). Used to keep the tooltip from re-appearing/repositioning when the trigger is
* clicked. Falls back to `true` where the selector can't be queried.
*/
export function isFocusVisible(element: Element): boolean {
try {
return element.matches(':focus-visible')
} catch {
return true
}
}
/**
* Portaled tooltip body positioned from a {@link FloatingTooltipState}. Renders
* nothing while hidden or during SSR.
*/
export const FloatingTooltip = React.memo(function FloatingTooltip({
label,
children,
state,
className,
role,
id,
offset = TOOLTIP_OFFSET,
}: {
/** Text shown when no `children` are provided (the overflow-tooltip case). */
label?: string
/** Arbitrary tooltip content; overrides `label` when provided (general tooltips). */
children?: React.ReactNode
state: FloatingTooltipState
className?: string
/** Set to `"tooltip"` for described/general tooltips; omit for decorative overflow tooltips. */
role?: 'tooltip'
/** Element id, used to wire `aria-describedby` on the trigger for general tooltips. */
id?: string
/**
* Cursor-to-bubble gap in px. Defaults to the standard 16; pass a smaller
* value where the tooltip floats over miniaturized UI (e.g. a scaled product
* preview) so the gap stays proportionate.
*/
offset?: number
}) {
if (typeof document === 'undefined' || !state.visible) return null
return createPortal(
<div
id={id}
role={role}
aria-hidden={role ? undefined : 'true'}
className={cn(
'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-caption opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out',
'motion-reduce:transition-none',
className
)}
style={{
transform: `${getTooltipTranslate(state, offset)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
}}
>
{children ?? <span className='block whitespace-normal break-words text-left'>{label}</span>}
</div>,
document.body
)
})
function getTooltipPosition(
clientX: number,
clientY: number
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
if (typeof window === 'undefined') {
return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' }
}
const alignX = window.innerWidth - clientX < EDGE_THRESHOLD ? 'right' : 'left'
const alignY = window.innerHeight - clientY < EDGE_THRESHOLD / 2 ? 'above' : 'below'
return {
x: clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER),
y: clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER),
alignX,
alignY,
}
}
function getTooltipTranslate(state: FloatingTooltipState, offset: number): string {
const xOffset = state.alignX === 'left' ? `${offset}px` : `calc(-100% - ${offset}px)`
const yOffset = state.alignY === 'below' ? `${offset}px` : `calc(-100% - ${offset}px)`
return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})`
}
/**
* Kept for API compatibility with the previous tooltip. The floating tooltip has no shared hover
* delay, so this is a passthrough — props are accepted but unused.
*/
const Provider = ({
children,
}: {
children: React.ReactNode
delayDuration?: number
skipDelayDuration?: number
disableHoverableContent?: boolean
}) => <>{children}</>
Provider.displayName = 'Tooltip.Provider'
const ALWAYS_SHOW = () => true
interface TooltipContextValue {
state: FloatingTooltipState
handlers: FloatingTooltipHandlers
contentId: string
}
const TooltipContext = React.createContext<TooltipContextValue | null>(null)
function useTooltipContext(component: string): TooltipContextValue {
const context = React.useContext(TooltipContext)
if (!context) {
throw new Error(`Tooltip.${component} must be rendered within a Tooltip.Root`)
}
return context
}
interface RootProps {
children: React.ReactNode
/** Accepted for API compatibility; the floating tooltip has no hover delay. */
delayDuration?: number
}
/**
* Root of a single tooltip. Coordinates a cursor-following floating bubble between its `Trigger`
* and `Content`.
*
* @example
* ```tsx
* <Tooltip.Root>
* <Tooltip.Trigger asChild>
* <Button>Hover me</Button>
* </Tooltip.Trigger>
* <Tooltip.Content>Tooltip text</Tooltip.Content>
* </Tooltip.Root>
* ```
*/
function Root({ children }: RootProps) {
const contentId = React.useId()
const { state, handlers } = useFloatingTooltip(ALWAYS_SHOW)
const value = React.useMemo<TooltipContextValue>(
() => ({ state, handlers, contentId }),
[state, handlers, contentId]
)
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>
}
Root.displayName = 'Tooltip.Root'
function composeHandlers<E extends React.SyntheticEvent>(
theirHandler: ((event: E) => void) | undefined,
ourHandler: (event: E) => void
) {
return (event: E) => {
theirHandler?.(event)
if (!event.defaultPrevented) ourHandler(event)
}
}
interface TriggerProps extends React.ComponentPropsWithoutRef<'button'> {
/** Merge tooltip behavior onto the single child element instead of rendering a button. */
asChild?: boolean
}
/**
* Element that activates the tooltip on hover/focus. Use `asChild` to project onto your own element.
*/
const Trigger = React.forwardRef<HTMLButtonElement, TriggerProps>(
({ asChild = false, ...props }, ref) => {
const ctx = useTooltipContext('Trigger')
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref as React.Ref<HTMLButtonElement>}
aria-describedby={ctx.state.visible ? ctx.contentId : undefined}
{...props}
onPointerEnter={composeHandlers(props.onPointerEnter, (event) =>
ctx.handlers.onPointerEnter(event)
)}
onPointerMove={composeHandlers(props.onPointerMove, (event) =>
ctx.handlers.onPointerMove(event)
)}
onPointerLeave={composeHandlers(props.onPointerLeave, () => ctx.handlers.onPointerLeave())}
onPointerDown={composeHandlers(props.onPointerDown, () => ctx.handlers.onPointerDown())}
onFocus={composeHandlers(props.onFocus, (event) => ctx.handlers.onFocus(event))}
onBlur={composeHandlers(props.onBlur, () => ctx.handlers.onBlur())}
/>
)
}
)
Trigger.displayName = 'Tooltip.Trigger'
interface ContentProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Cursor-to-bubble gap in px. Defaults to the standard 16; pass a smaller
* value where the tooltip floats over miniaturized UI (e.g. a scaled product
* preview) so the gap stays proportionate.
*/
offset?: number
/**
* Legacy positioning props from the previous Radix tooltip. Accepted for drop-in compatibility
* but ignored — the tooltip now follows the cursor.
*/
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
avoidCollisions?: boolean
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>
collisionBoundary?: unknown
arrowPadding?: number
sticky?: 'partial' | 'always'
hideWhenDetached?: boolean
asChild?: boolean
forceMount?: boolean
}
/**
* Tooltip content, rendered in a cursor-following floating bubble.
*
* @example
* ```tsx
* <Tooltip.Content>
* <p>Tooltip text</p>
* </Tooltip.Content>
* ```
*/
function Content({ className, children, offset }: ContentProps) {
const ctx = useTooltipContext('Content')
return (
<FloatingTooltip
state={ctx.state}
role='tooltip'
id={ctx.contentId}
className={className}
offset={offset}
>
{children}
</FloatingTooltip>
)
}
Content.displayName = 'Tooltip.Content'
interface ShortcutProps {
/** The keyboard shortcut keys to display (e.g., "⌘D", "⌘K") */
keys: string
/** Optional additional class names */
className?: string
/** Optional children to display before the shortcut */
children?: React.ReactNode
}
/**
* Displays a keyboard shortcut within tooltip content.
*
* @example
* ```tsx
* <Tooltip.Content>
* <Tooltip.Shortcut keys="⌘D">Clear console</Tooltip.Shortcut>
* </Tooltip.Content>
* ```
*/
const Shortcut = ({ keys, className, children }: ShortcutProps) => (
<span className={cn('flex items-center gap-2', className)}>
{children && <span>{children}</span>}
<span className='opacity-70'>{keys}</span>
</span>
)
Shortcut.displayName = 'Tooltip.Shortcut'
interface PreviewProps {
/** The URL of the image, GIF, or video to display */
src: string
/** Alt text for the media */
alt?: string
/** Width of the preview in pixels */
width?: number
/** Height of the preview in pixels */
height?: number
/** Whether video should loop */
loop?: boolean
/** Optional additional class names */
className?: string
}
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
/**
* Displays a preview image, GIF, or video within tooltip content.
*
* @example
* ```tsx
* <Tooltip.Content>
* <p>Canvas error notifications</p>
* <Tooltip.Preview src="/tooltips/canvas-error-notification.mp4" alt="Error notification example" />
* </Tooltip.Content>
* ```
*/
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
const [isReady, setIsReady] = React.useState(!isVideo)
return (
<div className={cn('-mx-[6px] -mb-[1.5px] mt-1.5 overflow-hidden rounded-[4px]', className)}>
{isVideo ? (
<div className='relative'>
{!isReady && (
<div
className='animate-pulse bg-white/5'
style={{ aspectRatio: height ? `${width}/${height}` : '16/9' }}
/>
)}
<video
src={src}
width={width}
height={height}
className={cn(
'block w-full transition-opacity duration-200',
isReady ? 'opacity-100' : 'absolute inset-0 opacity-0'
)}
autoPlay
loop={loop}
muted
playsInline
preload='auto'
aria-label={alt}
onCanPlay={() => setIsReady(true)}
/>
</div>
) : (
<img
src={src}
alt={alt}
width={width}
height={height}
className='block w-full'
loading='lazy'
/>
)}
</div>
)
}
Preview.displayName = 'Tooltip.Preview'
export const Tooltip = {
Root,
Trigger,
Content,
Provider,
Shortcut,
Preview,
}
@@ -0,0 +1,199 @@
'use client'
import * as React from 'react'
import { cn } from '../../lib/cn'
import {
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
} from '../chip-modal/chip-modal'
import { ModalDescription, type ModalSize } from '../modal/modal'
/**
* A multi-step modal wizard primitive.
*
* @remarks
* Wraps the emcn ChipModal with a shared Back / Next / Done footer and
* declarative `Wizard.Step` children. Step state is controlled
* from the outside so the consumer can hydrate from persisted state, reset
* on close, or jump around imperatively.
*
* @example
* ```tsx
* const [open, setOpen] = useState(false)
* const [step, setStep] = useState(0)
*
* <Wizard
* open={open}
* onOpenChange={(next) => { if (!next) setStep(0); setOpen(next) }}
* currentStep={step}
* onStepChange={setStep}
* size='lg'
* height='h-[580px]'
* >
* <Wizard.Step title='Configure'>
* <ConfigureForm />
* </Wizard.Step>
* <Wizard.Step title='Review' canAdvance={isValid}>
* <Review />
* </Wizard.Step>
* <Wizard.Step title='Done'>
* <DoneSummary />
* </Wizard.Step>
* </Wizard>
* ```
*/
interface WizardStepProps {
/** Title shown in the modal header when this step is active. */
title: string
/** Step body. Rendered inside the modal body when this step is active. */
children: React.ReactNode
/**
* When false, the Next button on this step is disabled. Lets the consumer
* gate progression on validation or async state.
* @default true
*/
canAdvance?: boolean
}
/**
* Declares one step in the wizard. Carries metadata via props; the actual
* children are extracted and rendered by the `Wizard` root.
*/
const Step: React.FC<WizardStepProps> = ({ children }) => <>{children}</>
Step.displayName = 'Wizard.Step'
const STEP_DISPLAY_NAME = 'Wizard.Step'
function isStepElement(node: React.ReactNode): node is React.ReactElement<WizardStepProps> {
if (!React.isValidElement(node)) return false
const type = node.type as { displayName?: string } | string
return typeof type !== 'string' && type?.displayName === STEP_DISPLAY_NAME
}
interface WizardProps {
/** Whether the wizard modal is open. */
open: boolean
/** Called when the modal's open state changes. */
onOpenChange: (next: boolean) => void
/** Zero-indexed current step. */
currentStep: number
/** Called with the new step index when the user clicks Back / Next. */
onStepChange: (next: number) => void
/**
* Modal size variant. Matches `ModalContent` sizes.
* @default 'lg'
*/
size?: ModalSize
/**
* Optional fixed height for the modal content. Pass a Tailwind class
* (e.g. `h-[580px]`) to keep the modal a stable size across steps.
*/
height?: string
/**
* Called when the user clicks Done on the final step. Fires before the
* modal closes; the wizard will close the modal itself after.
*/
onComplete?: () => void
/** One or more `<Wizard.Step>` elements. Non-step children are ignored. */
children: React.ReactNode
/** Label for the Back button. @default 'Back' */
backLabel?: string
/** Label for the Next button. @default 'Next' */
nextLabel?: string
/** Label for the Done button on the final step. @default 'Done' */
doneLabel?: string
/**
* Accessible description for the wizard dialog, surfaced to screen readers.
* If omitted, a generic sr-only description is rendered automatically.
*/
description?: string
/**
* Optional persistent header title shown (with `icon`) instead of the active
* step's title, for a stable branded header across steps.
*/
title?: string
/** Optional leading icon rendered in the header. */
icon?: React.ComponentType<{ className?: string }>
}
const WizardRoot: React.FC<WizardProps> = ({
open,
onOpenChange,
currentStep,
onStepChange,
size = 'lg',
height,
onComplete,
children,
backLabel = 'Back',
nextLabel = 'Next',
doneLabel = 'Done',
description,
title,
icon: Icon,
}) => {
const steps = React.Children.toArray(children).filter(isStepElement)
const total = steps.length
const clamped = Math.min(Math.max(0, currentStep), Math.max(0, total - 1))
const activeStep = steps[clamped]
const canAdvance = activeStep?.props.canAdvance ?? true
const isLast = total > 0 && clamped === total - 1
const handleBack = React.useCallback(() => {
onStepChange(Math.max(0, clamped - 1))
}, [clamped, onStepChange])
const handleNext = React.useCallback(() => {
onStepChange(Math.min(total - 1, clamped + 1))
}, [clamped, total, onStepChange])
const handleDone = React.useCallback(() => {
onComplete?.()
onOpenChange(false)
}, [onComplete, onOpenChange])
if (total === 0) return null
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
size={size}
srTitle={title ?? activeStep?.props.title ?? description ?? 'Multi-step wizard'}
// A fixed `height` sizes the whole dialog (matching the legacy Modal). The
// scoped `[&>div]` variants stretch ChipModal's inner content column so the
// body fills the fixed height instead of leaving a gap; only applied when a
// height is set, so other ChipModal consumers are untouched.
className={height ? cn(height, '[&>div]:min-h-0 [&>div]:flex-1') : undefined}
>
<ChipModalHeader icon={title ? (Icon ?? null) : null} onClose={() => onOpenChange(false)}>
{title ?? activeStep?.props.title}
</ChipModalHeader>
<ChipModalBody>
<ModalDescription className='sr-only'>
{description ?? 'Multi-step wizard'}
</ModalDescription>
{activeStep}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
hideCancel
primaryAdjacentAction={{ label: backLabel, onClick: handleBack, disabled: clamped === 0 }}
primaryAction={
isLast
? { label: doneLabel, onClick: handleDone }
: { label: nextLabel, onClick: handleNext, disabled: !canAdvance }
}
/>
</ChipModal>
)
}
export const Wizard = Object.assign(WizardRoot, {
Step,
})
+10
View File
@@ -0,0 +1,10 @@
/**
* Ambient declaration for CSS Modules so the package type-checks standalone.
* Consuming apps (Next.js) provide their own equivalent at build time.
*/
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.css'
@@ -0,0 +1,59 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseCopyToClipboardOptions {
/** How long the `copied` flag stays true before resetting. Defaults to 2000ms. */
resetMs?: number
}
interface UseCopyToClipboardReturn {
copied: boolean
copy: (text: string) => Promise<boolean>
}
/**
* Copy text to the clipboard with a transient `copied` flag for swap-icon
* feedback (e.g. Copy → Check for ~2s).
*
* Replaces the `[copied, setCopied] + setTimeout` boilerplate that's been
* duplicated across ~30 callsites. Each `copy()` call resets the timer so
* back-to-back copies don't stack timeouts; the timer is cleared on unmount.
*
* @example
* const { copied, copy } = useCopyToClipboard()
* <button onClick={() => copy(value)}>
* {copied ? <Check /> : <Copy />}
* </button>
*/
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {}
): UseCopyToClipboardReturn {
const { resetMs = 2000 } = options
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copy = useCallback(
async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), resetMs)
return true
} catch {
return false
}
},
[resetMs]
)
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
return { copied, copy }
}
@@ -0,0 +1,28 @@
/**
* Download icon animation
* Subtle continuous animation for import/download states
* Arrow gently pulses down to suggest downloading motion
*/
@keyframes arrow-pulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(1.5px);
opacity: 0.7;
}
}
.animated-download-svg {
animation: arrow-pulse 1.5s ease-in-out infinite;
transform-origin: center center;
}
@media (prefers-reduced-motion: reduce) {
.animated-download-svg {
animation: none;
}
}
@@ -0,0 +1,188 @@
/* Counterclockwise animations */
@keyframes move-top-right-ccw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(-11px, 0);
}
50% {
transform: translate(-11px, 13px);
}
75% {
transform: translate(0, 13px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-top-left-ccw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(0, 9px);
}
50% {
transform: translate(11px, 9px);
}
75% {
transform: translate(11px, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-bottom-left-ccw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(11px, 0);
}
50% {
transform: translate(11px, -13px);
}
75% {
transform: translate(0, -13px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-bottom-right-ccw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(0, -9px);
}
50% {
transform: translate(-11px, -9px);
}
75% {
transform: translate(-11px, 0);
}
100% {
transform: translate(0, 0);
}
}
/* Clockwise animations */
@keyframes move-top-right-cw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(0, 13px);
}
50% {
transform: translate(-11px, 13px);
}
75% {
transform: translate(-11px, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-top-left-cw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(11px, 0);
}
50% {
transform: translate(11px, 9px);
}
75% {
transform: translate(0, 9px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-bottom-left-cw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(0, -13px);
}
50% {
transform: translate(11px, -13px);
}
75% {
transform: translate(11px, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes move-bottom-right-cw {
0% {
transform: translate(0, 0);
}
25% {
transform: translate(-11px, 0);
}
50% {
transform: translate(-11px, -9px);
}
75% {
transform: translate(0, -9px);
}
100% {
transform: translate(0, 0);
}
}
/* Counterclockwise variant */
.animated-layout-svg .block-top-left {
animation: move-top-left-ccw 2.5s ease-in-out infinite;
}
.animated-layout-svg .block-top-right {
animation: move-top-right-ccw 2.5s ease-in-out infinite;
}
.animated-layout-svg .block-bottom-right {
animation: move-bottom-right-ccw 2.5s ease-in-out infinite;
}
.animated-layout-svg .block-bottom-left {
animation: move-bottom-left-ccw 2.5s ease-in-out infinite;
}
/* Clockwise variant overrides */
.animated-layout-svg.clockwise .block-top-left {
animation: move-top-left-cw 2.5s ease-in-out infinite;
}
.animated-layout-svg.clockwise .block-top-right {
animation: move-top-right-cw 2.5s ease-in-out infinite;
}
.animated-layout-svg.clockwise .block-bottom-right {
animation: move-bottom-right-cw 2.5s ease-in-out infinite;
}
.animated-layout-svg.clockwise .block-bottom-left {
animation: move-bottom-left-cw 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.animated-layout-svg .block-top-left,
.animated-layout-svg .block-top-right,
.animated-layout-svg .block-bottom-right,
.animated-layout-svg .block-bottom-left {
animation: none;
}
}
@@ -0,0 +1,26 @@
/**
* Loader icon animation
* Continuous spinning animation for loading states
* Uses GPU acceleration for smooth performance
*/
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animated-loader-svg {
animation: spin 1s linear infinite;
transform-origin: center center;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.animated-loader-svg {
animation: none;
}
}
@@ -0,0 +1,28 @@
/**
* PillsRing icon animation
* Pills arranged in a ring fade in/out sequentially,
* creating a chasing spinner effect.
* Individual pill delays are set via inline style.
*/
@keyframes pill-fade {
0%,
50%,
100% {
opacity: 0.15;
}
25% {
opacity: 1;
}
}
.animated-pills-ring-svg .pill {
animation: pill-fade 1.2s ease-in-out infinite;
will-change: opacity;
}
@media (prefers-reduced-motion: reduce) {
.animated-pills-ring-svg .pill {
animation: none;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ArrowDown icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M4 11.25L10.25 17.5L16.5 11.25' />
<path d='M10.25 3V17.5' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ArrowLeft icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowLeft(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M9.25 4L3 10.25L9.25 16.5' />
<path d='M3 10.25H17.5' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ArrowRight icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowRight(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M11.25 4L17.5 10.25L11.25 16.5' />
<path d='M17.5 10.25H3' />
</svg>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* ArrowUpDown icon component for sort toggles
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowUpDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M1.5 8L5.5 4L9.5 8' />
<path d='M5.5 4V16.5' />
<path d='M11 12.5L15 16.5L19 12.5' />
<path d='M15 4V16.5' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ArrowUp icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowUp(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M4 9.25L10.25 3L16.5 9.25' />
<path d='M10.25 3V17.5' />
</svg>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* Asterisk icon component - required field indicator
* @param props - SVG properties including className, fill, etc.
*/
export function Asterisk(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M10.25 3V17.5' />
<path d='M4 6.625L16.5 13.875' />
<path d='M4 13.875L16.5 6.625' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Bell icon component - notification bell
* @param props - SVG properties including className, fill, etc.
*/
export function Bell(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<path d='M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9' />
<path d='M10.3 21a1.94 1.94 0 0 0 3.4 0' />
</svg>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* Blimp icon component
* @param props - SVG properties including className, fill, etc.
*/
export function Blimp(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='1.25 1.25 18 18'
fill='currentColor'
stroke='currentColor'
strokeWidth='0.75'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
transform='translate(20.5, 0) scale(-1, 1)'
d='M18.24 9.18C18.16 8.94 18 8.74 17.83 8.56L17.83 8.56C17.67 8.4 17.49 8.25 17.3 8.11V5.48C17.3 5.32 17.24 5.17 17.14 5.06C17.06 4.95 16.93 4.89 16.79 4.89H15.93C15.61 4.89 15.32 5.11 15.19 5.44L14.68 6.77C14.05 6.51 13.23 6.22 12.15 6C11.04 5.77 9.66 5.61 7.9 5.61C5.97 5.61 4.56 6.13 3.61 6.89C3.14 7.28 2.78 7.72 2.54 8.19C2.29 8.66 2.18 9.15 2.18 9.63C2.18 10.1 2.29 10.59 2.52 11.06C2.87 11.76 3.48 12.41 4.34 12.89C4.91 13.2 5.61 13.44 6.43 13.56L6.8 14.78C6.94 15.27 7.33 15.59 7.78 15.59H10.56C11.06 15.59 11.48 15.18 11.58 14.61L11.81 13.29C12.31 13.2 12.75 13.09 13.14 12.99C13.74 12.82 14.24 12.64 14.67 12.48L15.19 13.82C15.32 14.16 15.61 14.38 15.93 14.38H16.79C16.93 14.38 17.06 14.31 17.14 14.2C17.24 14.1 17.29 13.95 17.3 13.79V11.15C17.33 11.12 17.37 11.09 17.42 11.07L17.4 11.07L17.42 11.07C17.65 10.89 17.87 10.69 18.04 10.46C18.12 10.35 18.19 10.22 18.24 10.08C18.29 9.94 18.32 9.79 18.32 9.63C18.32 9.47 18.29 9.32 18.24 9.18ZM15.69 5.71C15.73 5.6 15.83 5.53 15.93 5.53H16.74V7.89C16.41 7.7 16.06 7.53 15.71 7.37C15.55 7.29 15.37 7.2 15.15 7.1L15.69 5.71ZM11.05 14.48C11 14.76 10.79 14.95 10.56 14.95H7.78C7.56 14.95 7.38 14.79 7.31 14.56L6.99 13.52C7.22 13.54 7.47 13.55 7.73 13.55C7.79 13.55 7.84 13.55 7.9 13.55C9.05 13.53 10.05 13.45 10.9 13.33C11.02 13.31 11.14 13.29 11.26 13.27L11.05 14.48ZM16.74 13.74H15.93C15.83 13.74 15.73 13.66 15.69 13.56L15.15 12.16C15.36 12.06 15.55 11.97 15.71 11.9C16.06 11.73 16.41 11.56 16.74 11.37V13.74ZM17.75 9.83C17.7 9.95 17.61 10.08 17.48 10.22C17.4 10.3 17.3 10.38 17.2 10.46C17.07 10.57 16.91 10.67 16.74 10.77C16.71 10.8 16.67 10.82 16.63 10.84C16.29 11.04 15.91 11.23 15.55 11.4C15.38 11.48 15.18 11.57 14.96 11.67C14.82 11.73 14.68 11.79 14.53 11.85C14.12 12.02 13.62 12.2 13.02 12.36C12.65 12.46 12.24 12.56 11.79 12.64C11.65 12.67 11.51 12.7 11.36 12.72C10.4 12.88 9.26 12.99 7.9 13.01C7.84 13.02 7.79 13.02 7.73 13.02C7.41 13.02 7.11 13 6.82 12.97C6.65 12.95 6.48 12.93 6.32 12.9C5.26 12.71 4.45 12.32 3.88 11.84C3.48 11.5 3.19 11.12 2.99 10.74C2.8 10.36 2.72 9.98 2.72 9.63C2.72 9.28 2.81 8.9 3 8.52C3.3 7.95 3.82 7.38 4.63 6.95C5.44 6.53 6.52 6.25 7.9 6.25C10.2 6.25 11.84 6.53 13.05 6.87C13.64 7.04 14.13 7.22 14.53 7.39L14.54 7.4C14.69 7.46 14.83 7.52 14.96 7.59C15.18 7.69 15.37 7.78 15.55 7.86C15.95 8.06 16.38 8.27 16.74 8.49C16.85 8.56 16.96 8.62 17.06 8.69C17.08 8.71 17.1 8.72 17.12 8.74C17.34 8.9 17.51 9.06 17.62 9.22C17.68 9.29 17.72 9.37 17.75 9.44C17.77 9.5 17.78 9.57 17.78 9.63C17.78 9.7 17.77 9.76 17.75 9.83Z'
/>
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* BookOpen icon component - open book
* @param props - SVG properties including className, fill, etc.
*/
export function BookOpen(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M0.75 2.75C0.75 2.75 3.25 0.75 6.25 0.75C9.25 0.75 10.25 2.75 10.25 2.75V18.75C10.25 18.75 9.25 17.25 6.25 17.25C3.25 17.25 0.75 18.75 0.75 18.75V2.75Z' />
<path d='M10.25 2.75C10.25 2.75 11.25 0.75 14.25 0.75C17.25 0.75 19.75 2.75 19.75 2.75V18.75C19.75 18.75 17.25 17.25 14.25 17.25C11.25 17.25 10.25 18.75 10.25 18.75' />
</svg>
)
}
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
/**
* BubbleChatClose icon component - chat bubble with solid eye to indicate closing
* @param props - SVG properties including className, fill, etc.
*/
export function BubbleChatClose(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='14'
height='14'
viewBox='0 0 14 14'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M10.208 0.73C9.47 0.73 8.84 1.04 8.37 1.39C7.89 1.74 7.55 2.16 7.35 2.42C7.29 2.5 7.15 2.68 7.15 2.92C7.15 3.149 7.29 3.34 7.35 3.42C7.55 3.68 7.89 4.09 8.37 4.44C8.84 4.79 9.47 5.1 10.208 5.1C10.95 5.1 11.58 4.79 12.05 4.44C12.52 4.09 12.87 3.68 13.06 3.42C13.126 3.34 13.27 3.149 13.27 2.92C13.27 2.68 13.126 2.5 13.06 2.42C12.87 2.16 12.52 1.74 12.05 1.39C11.58 1.04 10.95 0.73 10.208 0.73Z'
fill='currentColor'
/>
<circle cx='10.208' cy='2.92' r='0.875' fill='currentColor' />
<path
d='M7 0.73C7.25 0.73 7.38 0.73 7.43 0.79C7.45 0.82 7.47 0.85 7.47 0.89C7.48 0.97 7.38 1.07 7.19 1.27C6.96 1.5 6.78 1.72 6.66 1.88L6.65 1.89C6.58 1.98 6.27 2.38 6.27 2.92C6.27 3.46 6.58 3.85 6.65 3.94L6.66 3.95C6.88 4.24 7.28 4.73 7.85 5.14C8.41 5.56 9.22 5.98 10.208 5.98C11.2 5.98 12.01 5.56 12.57 5.14C12.74 5.02 12.83 4.96 12.9 4.97C12.92 4.97 12.93 4.98 12.95 4.99C13.02 5.02 13.04 5.12 13.09 5.3C13.21 5.76 13.27 6.25 13.27 6.75C13.27 10.09 10.44 12.77 7 12.77C6.59 12.766 6.19 12.73 5.79 12.65C5.65 12.63 5.56 12.61 5.5 12.6C5.45 12.595 5.4 12.613 5.38 12.623C5.32 12.65 5.23 12.7 5.09 12.77C4.26 13.22 3.28 13.38 2.34 13.2C2.19 13.17 2.06 13.07 2.01 12.92C1.96 12.777 1.98 12.61 2.08 12.5C2.35 12.16 2.543 11.75 2.63 11.32C2.65 11.2 2.6 11.04 2.44 10.88C1.38 9.8 0.73 8.35 0.73 6.75C0.73 3.41 3.56 0.73 7 0.73ZM4.67 6.42C4.34 6.42 4.08 6.68 4.08 7C4.08 7.32 4.34 7.58 4.67 7.58H4.67C4.99 7.58 5.25 7.32 5.25 7C5.25 6.68 4.99 6.42 4.67 6.42H4.67ZM7 6.42C6.68 6.42 6.41 6.68 6.41 7C6.41 7.322 6.68 7.58 7 7.58H7C7.32 7.58 7.59 7.322 7.59 7C7.59 6.68 7.32 6.42 7 6.42H7ZM9.33 6.42C9.01 6.42 8.75 6.68 8.75 7C8.75 7.32 9.01 7.58 9.33 7.58H9.34C9.66 7.58 9.92 7.32 9.92 7C9.92 6.68 9.66 6.42 9.34 6.42H9.33Z'
fill='currentColor'
/>
</svg>
)
}
@@ -0,0 +1,39 @@
import type { SVGProps } from 'react'
interface IconProps extends SVGProps<SVGSVGElement> {
/** Square size in px applied to width and height; overridden by explicit width/height or a className size. */
size?: number | string
}
/**
* BubbleChatDelay icon (Hugeicons stroke-rounded: BubbleChatDelayIcon)
* @param props - SVG properties including className, size, fill, etc.
*/
export function BubbleChatDelay({ size = 24, width, height, ...props }: IconProps) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width={width ?? size}
height={height ?? size}
viewBox='0 0 24 24'
fill='none'
aria-hidden='true'
{...props}
>
<path
d='M21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C10.3719 21.5 8.8394 21.0904 7.5 20.3687C5.63177 19.362 4.37462 20.2979 3.26592 20.4658C3.09774 20.4913 2.93024 20.4302 2.80997 20.31C2.62741 20.1274 2.59266 19.8451 2.6935 19.6074C3.12865 18.5818 3.5282 16.6382 2.98341 15C2.6698 14.057 2.5 13.0483 2.5 12C2.5 6.75329 6.75329 2.5 12 2.5C17.2467 2.5 21.5 6.75329 21.5 12Z'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='1.5'
/>
<path
d='M12 7V12L15 14'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='1.5'
/>
</svg>
)
}
@@ -0,0 +1,30 @@
import type { SVGProps } from 'react'
/**
* BubbleChatPreview icon component
* @param props - SVG properties including className, fill, etc.
*/
export function BubbleChatPreview(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='14'
height='14'
viewBox='0 0 14 14'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M10.208 0.73C9.47 0.73 8.84 1.04 8.37 1.39C7.89 1.74 7.55 2.16 7.35 2.42C7.29 2.5 7.15 2.68 7.15 2.92C7.15 3.149 7.29 3.34 7.35 3.42C7.55 3.68 7.89 4.09 8.37 4.44C8.84 4.79 9.47 5.1 10.208 5.1C10.95 5.1 11.58 4.79 12.05 4.44C12.52 4.09 12.87 3.68 13.06 3.42C13.126 3.34 13.27 3.149 13.27 2.92C13.27 2.68 13.126 2.5 13.06 2.42C12.87 2.16 12.52 1.74 12.05 1.39C11.58 1.04 10.95 0.73 10.208 0.73ZM10.2 2.04C9.72 2.04 9.33 2.43 9.33 2.92C9.33 3.4 9.72 3.79 10.2 3.79H10.21C10.693 3.79 11.083 3.4 11.083 2.92C11.083 2.43 10.693 2.04 10.21 2.04H10.2Z'
fill='currentColor'
/>
<path
d='M7 0.73C7.25 0.73 7.38 0.73 7.43 0.79C7.45 0.82 7.47 0.85 7.47 0.89C7.48 0.97 7.38 1.07 7.19 1.27C6.96 1.5 6.78 1.72 6.66 1.88L6.65 1.89C6.58 1.98 6.27 2.38 6.27 2.92C6.27 3.46 6.58 3.85 6.65 3.94L6.66 3.95C6.88 4.24 7.28 4.73 7.85 5.14C8.41 5.56 9.22 5.98 10.208 5.98C11.2 5.98 12.01 5.56 12.57 5.14C12.74 5.02 12.83 4.96 12.9 4.97C12.92 4.97 12.93 4.98 12.95 4.99C13.02 5.02 13.04 5.12 13.09 5.3C13.21 5.76 13.27 6.25 13.27 6.75C13.27 10.09 10.44 12.77 7 12.77C6.59 12.766 6.19 12.73 5.79 12.65C5.65 12.63 5.56 12.61 5.5 12.6C5.45 12.595 5.4 12.613 5.38 12.623C5.32 12.65 5.23 12.7 5.09 12.77C4.26 13.22 3.28 13.38 2.34 13.2C2.19 13.17 2.06 13.07 2.01 12.92C1.96 12.777 1.98 12.61 2.08 12.5C2.35 12.16 2.543 11.75 2.63 11.32C2.65 11.2 2.6 11.04 2.44 10.88C1.38 9.8 0.73 8.35 0.73 6.75C0.73 3.41 3.56 0.73 7 0.73ZM4.67 6.42C4.34 6.42 4.08 6.68 4.08 7C4.08 7.32 4.34 7.58 4.67 7.58H4.67C4.99 7.58 5.25 7.32 5.25 7C5.25 6.68 4.99 6.42 4.67 6.42H4.67ZM7 6.42C6.68 6.42 6.41 6.68 6.41 7C6.41 7.322 6.68 7.58 7 7.58H7C7.32 7.58 7.59 7.322 7.59 7C7.59 6.68 7.32 6.42 7 6.42H7ZM9.33 6.42C9.01 6.42 8.75 6.68 8.75 7C8.75 7.32 9.01 7.58 9.33 7.58H9.34C9.66 7.58 9.92 7.32 9.92 7C9.92 6.68 9.66 6.42 9.34 6.42H9.33Z'
fill='currentColor'
/>
</svg>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { SVGProps } from 'react'
/**
* Bug icon component - debug beetle
* @param props - SVG properties including className, fill, etc.
*/
export function Bug(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M6.25 4.75L4.25 2.75' />
<path d='M14.25 4.75L16.25 2.75' />
<path d='M6.25 14.75L4.25 16.75' />
<path d='M14.25 14.75L16.25 16.75' />
<path d='M0.75 9.75H4.75' />
<path d='M15.75 9.75H19.75' />
<rect x='4.75' y='4.75' width='11' height='13' rx='5.5' />
<path d='M4.75 9.75H15.75' />
<line x1='10.25' y1='9.75' x2='10.25' y2='17.75' />
</svg>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* Calendar icon component - calendar with binding posts and header divider
* @param props - SVG properties including className, fill, etc.
*/
export function Calendar(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<rect x='0.75' y='2.75' width='19' height='16' rx='2.5' />
<path d='M0.75 7.75H19.75' />
<path d='M6.25 0.75V4.75' />
<path d='M14.25 0.75V4.75' />
</svg>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* Check icon component - checkmark
* @param props - SVG properties including className, fill, etc.
*/
export function Check(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M18.25 2.75L7.25 15.75L1.75 10.25' />
</svg>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* ChevronDown icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ChevronDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='10'
height='6'
viewBox='0 0 10 6'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M1 1L5 5L9 1'
stroke='currentColor'
strokeWidth='1.2'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* CircleAlert icon component - circular alert used for error intent.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleAlert(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='M12 8v4' />
<path d='M12 16h.01' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* CircleCheck icon component - circular checkmark used for success intent.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleCheck(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='m9 12 2 2 4-4' />
</svg>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
/**
* CircleInfo icon — a circled "i", used for the info intent. Named for its
* shape to match its siblings (`CircleAlert`, `CircleCheck`) and to avoid
* colliding with the `Info` component re-exported from the emcn barrel.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleInfo(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='M12 16v-4' />
<path d='M12 8h.01' />
</svg>
)
}
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
/**
* ClipboardList icon component - clipboard with checklist lines
* @param props - SVG properties including className, fill, etc.
*/
export function ClipboardList(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M3.75 4.25C3.75 2.87 4.87 1.75 6.25 1.75H14.25C15.63 1.75 16.75 2.87 16.75 4.25V17.25C16.75 18.63 15.63 19.75 14.25 19.75H6.25C4.87 19.75 3.75 18.63 3.75 17.25V4.25Z' />
<path d='M7.75 0.75H12.75V3.25C12.75 3.8 12.3 4.25 11.75 4.25H8.75C8.2 4.25 7.75 3.8 7.75 3.25V0.75Z' />
<path d='M7.75 8.75H12.75' />
<path d='M7.75 11.75H12.75' />
<path d='M7.75 14.75H10.75' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Clipboard icon component
* @param props - SVG properties including className, fill, etc.
*/
export function Clipboard(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M3.75 4.25C3.75 2.87 4.87 1.75 6.25 1.75H14.25C15.63 1.75 16.75 2.87 16.75 4.25V17.25C16.75 18.63 15.63 19.75 14.25 19.75H6.25C4.87 19.75 3.75 18.63 3.75 17.25V4.25Z' />
<path d='M7.75 0.75H12.75V3.25C12.75 3.8 12.3 4.25 11.75 4.25H8.75C8.2 4.25 7.75 3.8 7.75 3.25V0.75Z' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Clock icon component - circular clock face with hour and minute hands
* @param props - SVG properties including className, fill, etc.
*/
export function Clock(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<circle cx='10.25' cy='9.75' r='9' />
<path d='M10.25 4.75V9.75L13.75 12.25' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Columns2 icon component - displays two vertical columns in a rounded container
* @param props - SVG properties including className, fill, etc.
*/
export function Columns2(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M0.75 3.25C0.75 1.87 1.87 0.75 3.25 0.75H17.25C18.63 0.75 19.75 1.87 19.75 3.25V16.25C19.75 17.63 18.63 18.75 17.25 18.75H3.25C1.87 18.75 0.75 17.63 0.75 16.25V3.25Z' />
<path d='M10.25 0.75V18.75' />
</svg>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* Columns3 icon component - displays three vertical columns in a rounded container
* @param props - SVG properties including className, fill, etc.
*/
export function Columns3(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M0.75 3.25C0.75 1.87 1.87 0.75 3.25 0.75H17.25C18.63 0.75 19.75 1.87 19.75 3.25V16.25C19.75 17.63 18.63 18.75 17.25 18.75H3.25C1.87 18.75 0.75 17.63 0.75 16.25V3.25Z' />
<path d='M7.25 0.75V18.75' />
<path d='M13.25 0.75V18.75' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Connections icon component
* @param props - SVG properties including className, fill, etc.
*/
export function Connections(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='13'
height='13'
viewBox='0 0 13 13'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M10.167 0C11.73 0 13 1.27 13 2.83C13 4.23 11.993 5.38 10.667 5.62V7.34C10.99 7.34 11.27 7.35 11.51 7.38C11.89 7.43 12.23 7.54 12.51 7.82C12.79 8.1 12.9 8.45 12.95 8.82C13 9.18 13 9.63 13 10.167C13 10.7 13 11.155 12.95 11.51C12.9 11.89 12.79 12.23 12.51 12.51C12.23 12.79 11.89 12.9 11.51 12.95C11.155 13 10.7 13 10.167 13C9.63 13 9.18 13 8.82 12.95C8.45 12.9 8.1 12.79 7.82 12.51C7.54 12.23 7.43 11.89 7.38 11.51C7.35 11.27 7.34 10.99 7.34 10.667H5.66C5.66 10.99 5.65 11.27 5.62 11.51C5.57 11.89 5.46 12.23 5.18 12.51C4.9 12.79 4.55 12.9 4.18 12.95C3.82 13 3.37 13 2.83 13C2.3 13 1.85 13 1.49 12.95C1.11 12.9 0.77 12.79 0.49 12.51C0.21 12.23 0.1 11.89 0.05 11.51C0 11.155 -5.3e-07 10.7 0 10.167C0 9.63 0 9.18 0.05 8.82C0.1 8.45 0.21 8.1 0.49 7.82C0.77 7.54 1.11 7.43 1.49 7.38C1.85 7.33 2.3 7.33 2.83 7.33C3.37 7.33 3.82 7.33 4.18 7.38C4.55 7.43 4.9 7.54 5.18 7.82C5.46 8.1 5.57 8.45 5.62 8.82C5.65 9.06 5.66 9.34 5.66 9.67H7.34C7.34 9.34 7.35 9.06 7.38 8.82C7.43 8.45 7.54 8.1 7.82 7.82C8.1 7.54 8.45 7.43 8.82 7.38C9.06 7.35 9.34 7.34 9.67 7.34V5.62C8.5 5.41 7.59 4.5 7.38 3.33H5.66C5.66 3.66 5.65 3.94 5.62 4.18C5.57 4.55 5.46 4.9 5.18 5.18C4.9 5.46 4.55 5.57 4.18 5.62C3.82 5.67 3.37 5.67 2.83 5.67C2.3 5.67 1.85 5.67 1.49 5.62C1.11 5.57 0.77 5.46 0.49 5.18C0.21 4.9 0.1 4.55 0.05 4.18C0 3.82 0 3.37 0 2.83C-5.67e-07 2.3 0 1.85 0.05 1.49C0.1 1.11 0.21 0.77 0.49 0.49C0.77 0.21 1.11 0.1 1.49 0.05C1.85 0 2.3 -5.67e-07 2.83 0C3.37 0 3.82 0 4.18 0.05C4.55 0.1 4.9 0.21 5.18 0.49C5.46 0.77 5.57 1.11 5.62 1.49C5.65 1.73 5.66 2.01 5.66 2.33H7.38C7.62 1.01 8.77 0 10.167 0ZM2.83 8.33C2.27 8.33 1.9 8.33 1.62 8.37C1.36 8.41 1.26 8.47 1.2 8.53C1.13 8.59 1.07 8.69 1.04 8.95C1 9.23 1 9.6 1 10.167C1 10.73 1 11.1 1.04 11.38C1.07 11.64 1.13 11.74 1.2 11.8C1.26 11.87 1.36 11.93 1.62 11.96C1.9 12 2.27 12 2.83 12C3.4 12 3.77 12 4.05 11.96C4.31 11.93 4.41 11.87 4.47 11.8C4.53 11.74 4.59 11.64 4.63 11.38C4.67 11.1 4.67 10.73 4.67 10.167C4.67 9.6 4.67 9.23 4.63 8.95C4.59 8.69 4.53 8.59 4.47 8.53C4.41 8.47 4.31 8.41 4.05 8.37C3.77 8.33 3.4 8.33 2.83 8.33ZM10.167 8.33C9.6 8.33 9.23 8.33 8.95 8.37C8.69 8.41 8.59 8.47 8.53 8.53C8.47 8.59 8.41 8.69 8.37 8.95C8.33 9.23 8.33 9.6 8.33 10.167C8.33 10.73 8.33 11.1 8.37 11.38C8.41 11.64 8.47 11.74 8.53 11.8C8.59 11.87 8.69 11.93 8.95 11.96C9.23 12 9.6 12 10.167 12C10.73 12 11.1 12 11.38 11.96C11.64 11.93 11.74 11.87 11.8 11.8C11.87 11.74 11.93 11.64 11.96 11.38C12 11.1 12 10.73 12 10.167C12 9.6 12 9.23 11.96 8.95C11.93 8.69 11.87 8.59 11.8 8.53C11.74 8.47 11.64 8.41 11.38 8.37C11.1 8.33 10.73 8.33 10.167 8.33ZM2.83 1C2.27 1 1.9 1 1.62 1.04C1.36 1.07 1.26 1.13 1.2 1.2C1.13 1.26 1.07 1.36 1.04 1.62C1 1.9 1 2.27 1 2.83C1 3.4 1 3.77 1.04 4.05C1.07 4.31 1.13 4.41 1.2 4.47C1.26 4.53 1.36 4.59 1.62 4.63C1.9 4.67 2.27 4.67 2.83 4.67C3.4 4.67 3.77 4.67 4.05 4.63C4.31 4.59 4.41 4.53 4.47 4.47C4.53 4.41 4.59 4.31 4.63 4.05C4.67 3.77 4.67 3.4 4.67 2.83C4.67 2.27 4.67 1.9 4.63 1.62C4.59 1.36 4.53 1.26 4.47 1.2C4.41 1.13 4.31 1.07 4.05 1.04C3.77 1 3.4 1 2.83 1ZM10.167 1C9.15 1 8.33 1.82 8.33 2.83C8.33 3.85 9.15 4.67 10.167 4.67C11.18 4.67 12 3.85 12 2.83C12 1.82 11.18 1 10.167 1Z'
fill='currentColor'
stroke='currentColor'
strokeWidth='0.2'
/>
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Credit icon component - circular token with inner ring
* @param props - SVG properties including className, fill, etc.
*/
export function Credit(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<circle cx='10.25' cy='9.75' r='8.5' />
<circle cx='10.25' cy='9.75' r='3.5' />
</svg>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { SVGProps } from 'react'
export function Cursor(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M20.51 10.78C21.12 10.54 21.431 10.42 21.52 10.25C21.59 10.099 21.59 9.92 21.51 9.78C21.42 9.61 21.109 9.5 20.486 9.28L4.6 3.57C4.09 3.39 3.83 3.3 3.67 3.36C3.52 3.41 3.41 3.52 3.36 3.66C3.3 3.83 3.39 4.09 3.57 4.6L9.277 20.49C9.5 21.11 9.61 21.42 9.78 21.51C9.92 21.59 10.1 21.59 10.25 21.52C10.42 21.43 10.54 21.12 10.78 20.51L13.37 13.83C13.42 13.707 13.44 13.65 13.48 13.6C13.51 13.55 13.55 13.51 13.6 13.479C13.65 13.44 13.71 13.42 13.828 13.37L20.51 10.78Z'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { SVGProps } from 'react'
/**
* Database-X icon component - cylinder database with an X mark indicating a missing or deleted knowledge base
* @param props - SVG properties including className, fill, etc.
*/
export function DatabaseX(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<ellipse cx='10.25' cy='3.75' rx='8.5' ry='3' />
<path d='M1.75 3.75V9.75C1.75 11.41 5.55 12.75 10.25 12.75C14.95 12.75 18.75 11.41 18.75 9.75V3.75' />
<path d='M1.75 9.75V12.5' />
<path d='M18.75 9.75V15.75C18.75 17.41 14.95 18.75 10.25 18.75C9 18.75 7.75 18.6 6.75 18.3' />
<path d='M1 16L5 20' />
<path d='M5 16L1 20' />
</svg>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* Database icon component - cylinder with horizontal dividers
* @param props - SVG properties including className, fill, etc.
*/
export function Database(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<ellipse cx='10.25' cy='3.75' rx='8.5' ry='3' />
<path d='M1.75 3.75V9.75C1.75 11.41 5.55 12.75 10.25 12.75C14.95 12.75 18.75 11.41 18.75 9.75V3.75' />
<path d='M1.75 9.75V15.75C1.75 17.41 5.55 18.75 10.25 18.75C14.95 18.75 18.75 17.41 18.75 15.75V9.75' />
</svg>
)
}
@@ -0,0 +1,36 @@
import type { SVGProps } from 'react'
/**
* Document attachment icon component
* @param props - SVG properties including className, fill, etc.
*/
export function DocumentAttachment(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='12'
height='13'
viewBox='0 0 12 13'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M10.5 5.69V5.1C10.5 3.99 10.499 3.2 10.414 2.61C10.33 2.02 10.18 1.69 9.91 1.44C9.65 1.19 9.28 1.04 8.65 0.96C8.01 0.88 7.17 0.875 6 0.875H5.38C4.2 0.875 3.36 0.88 2.72 0.96C2.09 1.04 1.73 1.19 1.46 1.44C1.2 1.69 1.04 2.02 0.96 2.61C0.88 3.2 0.88 3.99 0.88 5.1V7.44C0.88 8.55 0.88 9.34 0.96 9.94C1.04 10.52 1.2 10.85 1.46 11.1C1.73 11.354 2.09 11.5 2.72 11.58C3.36 11.67 4.2 11.67 5.38 11.67H5.69C5.93 11.67 6.125 11.86 6.125 12.1C6.125 12.35 5.93 12.54 5.69 12.54H5.38C4.23 12.54 3.32 12.54 2.61 12.45C1.9 12.36 1.32 12.17 0.86 11.739C0.4 11.3 0.19 10.75 0.09 10.06C0 9.39 8.49e-07 8.52 8.99e-07 7.44V5.1C9.49e-07 4.02 0 3.16 0.09 2.48C0.19 1.79 0.4 1.24 0.86 0.8C1.32 0.37 1.9 0.18 2.61 0.09C3.32 0 4.23 2.75e-07 5.38 3.23e-07H6C7.15 3.71e-07 8.06 0 8.76 0.09C9.48 0.18 10.06 0.37 10.51 0.8C10.98 1.24 11.18 1.79 11.28 2.48C11.38 3.16 11.375 4.02 11.375 5.1V5.69C11.375 5.93 11.18 6.125 10.94 6.125C10.7 6.125 10.5 5.93 10.5 5.69Z'
fill='currentColor'
/>
<path
d='M8.02 2.92C8.26 2.92 8.46 3.11 8.46 3.35C8.46 3.6 8.26 3.79 8.02 3.79H3.354C3.11 3.79 2.92 3.6 2.92 3.35C2.92 3.11 3.11 2.92 3.354 2.92H8.02Z'
fill='currentColor'
/>
<path
d='M6.27 5.83C6.51 5.83 6.71 6.03 6.71 6.27C6.71 6.51 6.51 6.71 6.27 6.71H3.354C3.11 6.71 2.92 6.51 2.92 6.27C2.92 6.03 3.11 5.83 3.354 5.83H6.27Z'
fill='currentColor'
/>
<path
d='M9.78 11.23L9.78 9.63C9.78 9.39 9.59 9.2 9.35 9.2C9.1 9.2 8.91 9.39 8.91 9.63L8.91 11.23C8.91 11.416 8.7 11.67 8.39 11.67C8.08 11.67 7.875 11.42 7.875 11.23L7.875 9.19C7.875 8.9 8.01 8.57 8.27 8.3C8.52 8.04 8.846 7.875 9.19 7.875C9.529 7.875 9.86 8.04 10.11 8.3C10.36 8.57 10.5 8.9 10.5 9.19L10.5 11.32C10.5 11.56 10.696 11.75 10.94 11.75C11.179 11.75 11.37 11.56 11.375 11.32L11.375 9.19C11.375 8.64 11.12 8.1 10.74 7.7C10.36 7.3 9.81 7 9.19 7C8.56 7 8.02 7.3 7.63 7.7C7.25 8.1 7 8.64 7 9.19L7 11.23C7 11.95 7.65 12.54 8.39 12.54C9.13 12.54 9.78 11.95 9.78 11.23Z'
fill='currentColor'
/>
</svg>
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { SVGProps } from 'react'
import { cn } from '../lib/cn'
import styles from './animate/download.module.css'
export interface DownloadProps extends SVGProps<SVGSVGElement> {
/**
* Enable animation on the download icon
* @default false
*/
animate?: boolean
}
/**
* Download icon — arrow pointing down into a tray, mirroring the Upload icon.
* Uses the same viewBox, stroke weight, and path style as Upload for visual consistency.
*/
export function Download({ animate = false, className, ...props }: DownloadProps) {
const svgClassName = cn(animate && styles['animated-download-svg'], className)
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
className={svgClassName}
aria-hidden='true'
{...props}
>
{/* tray — same as Upload */}
<path d='M0.75 12.75V16.75C0.75 17.8546 1.64543 18.75 2.75 18.75H17.75C18.8546 18.75 19.75 17.8546 19.75 16.75V12.75' />
{/* stem — top to tray */}
<path d='M10.25 1.75V12.75' />
{/* arrowhead pointing down */}
<path d='M5.25 7.75L10.25 12.75L15.25 7.75' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Duplicate icon component - two overlapping rounded rectangles
* @param props - SVG properties including className, fill, etc.
*/
export function Duplicate(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25' />
<rect x='5.25' y='5.25' width='14' height='14' rx='2' />
</svg>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { SVGProps } from 'react'
export function Expand(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path
d='M15 3H21V9'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9 21H3V15'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M21 3L14 10'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3 21L10 14'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { SVGProps } from 'react'
/**
* Eye-off icon. Companion to {@link Eye}; used for "hide" affordances where
* something is being concealed without being destroyed (e.g. hide a workflow
* output column from the table while keeping it re-addable from the sidebar).
*
* Uses currentColor so it inherits the surrounding text color.
*
* @param props - SVG properties including className, fill, etc.
*/
export function EyeOff(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M9.88 9.88a3 3 0 1 0 4.24 4.24' />
<path d='M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68' />
<path d='M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61' />
<line x1='2' y1='2' x2='22' y2='22' />
</svg>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Eye icon component - almond outline with circular pupil
* @param props - SVG properties including className, fill, etc.
*/
export function Eye(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M0.75 9.75C3 4.25 6.75 1.75 10.25 1.75C13.75 1.75 17.5 4.25 19.75 9.75C17.5 15.25 13.75 17.75 10.25 17.75C6.75 17.75 3 15.25 0.75 9.75Z' />
<circle cx='10.25' cy='9.75' r='3.25' />
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show More