Skip to content

Commit 711a0b7

Browse files
authored
Merge pull request #102 from oasisprotocol/mz/input-groups
Generate Shadcn input group components
2 parents 06a75b1 + 12dd196 commit 711a0b7

5 files changed

Lines changed: 229 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"@radix-ui/react-select": "^2.2.2",
7474
"@radix-ui/react-separator": "^1.1.4",
7575
"@radix-ui/react-slider": "^1.3.2",
76-
"@radix-ui/react-slot": "^1.2.0",
76+
"@radix-ui/react-slot": "^1.2.4",
7777
"@radix-ui/react-switch": "^1.2.2",
7878
"@radix-ui/react-tabs": "^1.1.9",
7979
"@radix-ui/react-toggle": "^1.1.6",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from '../ui/input-otp'

src/components/ui/input-group.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as React from 'react'
2+
import { cva, type VariantProps } from 'class-variance-authority'
3+
4+
import { cn } from '../../lib/utils'
5+
import { Button } from './button'
6+
import { Input } from './input'
7+
import { Textarea } from './textarea'
8+
9+
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
10+
return (
11+
<div
12+
data-slot="input-group"
13+
role="group"
14+
className={cn(
15+
'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',
16+
'h-9 min-w-0 has-[>textarea]:h-auto',
17+
18+
// Variants based on alignment.
19+
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
20+
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
21+
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
22+
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
23+
24+
// Focus state.
25+
'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]',
26+
27+
// Error state.
28+
'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',
29+
30+
className
31+
)}
32+
{...props}
33+
/>
34+
)
35+
}
36+
37+
const inputGroupAddonVariants = cva(
38+
"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",
39+
{
40+
variants: {
41+
align: {
42+
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
43+
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
44+
'block-start':
45+
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
46+
'block-end':
47+
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
48+
},
49+
},
50+
defaultVariants: {
51+
align: 'inline-start',
52+
},
53+
}
54+
)
55+
56+
function InputGroupAddon({
57+
className,
58+
align = 'inline-start',
59+
...props
60+
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
61+
return (
62+
<div
63+
role="group"
64+
data-slot="input-group-addon"
65+
data-align={align}
66+
className={cn(inputGroupAddonVariants({ align }), className)}
67+
onClick={e => {
68+
if ((e.target as HTMLElement).closest('button')) {
69+
return
70+
}
71+
e.currentTarget.parentElement?.querySelector('input')?.focus()
72+
}}
73+
{...props}
74+
/>
75+
)
76+
}
77+
78+
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
79+
variants: {
80+
size: {
81+
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
82+
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
83+
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
84+
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
85+
},
86+
},
87+
defaultVariants: {
88+
size: 'xs',
89+
},
90+
})
91+
92+
function InputGroupButton({
93+
className,
94+
type = 'button',
95+
variant = 'ghost',
96+
size = 'xs',
97+
...props
98+
}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
99+
return (
100+
<Button
101+
type={type}
102+
data-size={size}
103+
variant={variant}
104+
className={cn(inputGroupButtonVariants({ size }), className)}
105+
{...props}
106+
/>
107+
)
108+
}
109+
110+
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
111+
return (
112+
<span
113+
className={cn(
114+
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
115+
className
116+
)}
117+
{...props}
118+
/>
119+
)
120+
}
121+
122+
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
123+
return (
124+
<Input
125+
data-slot="input-group-control"
126+
className={cn(
127+
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
128+
className
129+
)}
130+
{...props}
131+
/>
132+
)
133+
}
134+
135+
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
136+
return (
137+
<Textarea
138+
data-slot="input-group-control"
139+
className={cn(
140+
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
141+
className
142+
)}
143+
{...props}
144+
/>
145+
)
146+
}
147+
148+
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import {
3+
InputGroup,
4+
InputGroupAddon,
5+
InputGroupButton,
6+
InputGroupText,
7+
InputGroupInput,
8+
} from '../../components/ui/input-group'
9+
import { Label } from '../../components'
10+
import { expect, within, userEvent } from 'storybook/test'
11+
import { Search, AtSign, X } from 'lucide-react'
12+
13+
const meta: Meta<typeof InputGroup> = {
14+
title: 'Components/InputGroup',
15+
component: InputGroup,
16+
parameters: {
17+
docs: {
18+
description: {
19+
component:
20+
'A flexible input group component that allows combining inputs with addons, buttons, text, and icons in various configurations.',
21+
},
22+
},
23+
layout: 'centered',
24+
},
25+
tags: ['autodocs'],
26+
}
27+
28+
export default meta
29+
type Story = StoryObj<typeof meta>
30+
31+
export const Default: Story = {
32+
render: () => (
33+
<div className="grid w-full max-w-md items-center gap-4">
34+
<div className="grid gap-1.5">
35+
<Label htmlFor="searchAction">Search</Label>
36+
<InputGroup>
37+
<InputGroupAddon align="inline-start">
38+
<InputGroupText>
39+
<Search />
40+
</InputGroupText>
41+
</InputGroupAddon>
42+
<InputGroupInput id="searchAction" placeholder="Search..." />
43+
<InputGroupAddon align="inline-end">
44+
<InputGroupButton variant="ghost" size="icon-xs" aria-label="Clear search">
45+
<X />
46+
</InputGroupButton>
47+
</InputGroupAddon>
48+
</InputGroup>
49+
</div>
50+
51+
<div className="grid gap-1.5">
52+
<Label htmlFor="email">Email Address</Label>
53+
<InputGroup>
54+
<InputGroupAddon align="inline-start">
55+
<InputGroupText>
56+
<AtSign />
57+
</InputGroupText>
58+
</InputGroupAddon>
59+
<InputGroupInput id="email" type="email" placeholder="you@example.com" />
60+
</InputGroup>
61+
</div>
62+
</div>
63+
),
64+
play: async ({ canvasElement }) => {
65+
const canvas = within(canvasElement)
66+
const searchInput = canvas.getByPlaceholderText('Search...')
67+
await expect(searchInput).toBeInTheDocument()
68+
await userEvent.type(searchInput, 'Test query')
69+
await expect(searchInput).toHaveValue('Test query')
70+
},
71+
}

yarn.lock

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1952,13 +1952,20 @@
19521952
"@radix-ui/react-use-previous" "1.1.1"
19531953
"@radix-ui/react-use-size" "1.1.1"
19541954

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

1962+
"@radix-ui/react-slot@^1.2.4":
1963+
version "1.2.4"
1964+
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
1965+
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
1966+
dependencies:
1967+
"@radix-ui/react-compose-refs" "1.1.2"
1968+
19621969
"@radix-ui/react-switch@^1.2.2":
19631970
version "1.2.2"
19641971
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.2.tgz#aee51a72b93b49d625e201e32c43deb7957e4641"

0 commit comments

Comments
 (0)