Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toggle": "^1.1.6",
Expand Down
1 change: 1 addition & 0 deletions src/components/input-group/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../ui/input-otp'
148 changes: 148 additions & 0 deletions src/components/ui/input-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '../../lib/utils'
import { Button } from './button'
import { Input } from './input'
import { Textarea } from './textarea'

function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',

// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',

// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',

// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',

className
)}
{...props}
/>
)
}

const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
}
)

function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={e => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}

const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
})

function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}

function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}

function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}

function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}

export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }
71 changes: 71 additions & 0 deletions src/stories/InputGroup/InputGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
} from '../../components/ui/input-group'
import { Label } from '../../components'
import { expect, within, userEvent } from 'storybook/test'
import { Search, AtSign, X } from 'lucide-react'

const meta: Meta<typeof InputGroup> = {
title: 'Components/InputGroup',
component: InputGroup,
parameters: {
docs: {
description: {
component:
'A flexible input group component that allows combining inputs with addons, buttons, text, and icons in various configurations.',
},
},
layout: 'centered',
},
tags: ['autodocs'],
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
render: () => (
<div className="grid w-full max-w-md items-center gap-4">
<div className="grid gap-1.5">
<Label htmlFor="searchAction">Search</Label>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<Search />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput id="searchAction" placeholder="Search..." />
<InputGroupAddon align="inline-end">
<InputGroupButton variant="ghost" size="icon-xs" aria-label="Clear search">
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>

<div className="grid gap-1.5">
<Label htmlFor="email">Email Address</Label>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<AtSign />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput id="email" type="email" placeholder="you@example.com" />
</InputGroup>
</div>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const searchInput = canvas.getByPlaceholderText('Search...')
await expect(searchInput).toBeInTheDocument()
await userEvent.type(searchInput, 'Test query')
await expect(searchInput).toHaveValue('Test query')
},
}
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1952,13 +1952,20 @@
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"

"@radix-ui/react-slot@1.2.0", "@radix-ui/react-slot@^1.2.0":
"@radix-ui/react-slot@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba"
integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"

"@radix-ui/react-slot@^1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"

"@radix-ui/react-switch@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.2.tgz#aee51a72b93b49d625e201e32c43deb7957e4641"
Expand Down