diff --git a/package.json b/package.json
index f50a6005..1a615e7b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/input-group/index.tsx b/src/components/input-group/index.tsx
new file mode 100644
index 00000000..48b22c3a
--- /dev/null
+++ b/src/components/input-group/index.tsx
@@ -0,0 +1 @@
+export * from '../ui/input-otp'
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 00000000..927ecaf5
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -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 (
+
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
) {
+ return (
+ {
+ 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
, 'size'> & VariantProps) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+ return (
+
+ )
+}
+
+export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }
diff --git a/src/stories/InputGroup/InputGroup.stories.tsx b/src/stories/InputGroup/InputGroup.stories.tsx
new file mode 100644
index 00000000..14969616
--- /dev/null
+++ b/src/stories/InputGroup/InputGroup.stories.tsx
@@ -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 = {
+ 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
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ 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')
+ },
+}
diff --git a/yarn.lock b/yarn.lock
index 0be62e22..8f4af204 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"