Skip to content

Commit afb156b

Browse files
feat: integrate shadcn/ui InputGroup component for prefix/suffix functionality
- Add official shadcn/ui InputGroup component with proper accessibility - Refactor TextField to use InputGroup when prefix/suffix props are provided - Maintain backward compatibility with existing TextField API - Deprecate legacy FieldPrefix/FieldSuffix components (kept for compatibility) - Add biome-ignore comments for intentional WAI-ARIA role usage - All existing tests pass with the new implementation Benefits: - Cleaner, more maintainable code structure - Better accessibility with proper semantic markup - Official shadcn/ui patterns and styling - Support for inline-start, inline-end, block-start, block-end alignment - No breaking changes - existing code continues to work Requested by: Jake Ruesink
1 parent 7788aa7 commit afb156b

File tree

5 files changed

+204
-22
lines changed

5 files changed

+204
-22
lines changed

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"@radix-ui/react-select": "^2.2.6",
5959
"@radix-ui/react-separator": "^1.1.6",
6060
"@radix-ui/react-slider": "^1.3.4",
61-
"@radix-ui/react-slot": "^1.2.3",
61+
"@radix-ui/react-slot": "^1.2.4",
6262
"@radix-ui/react-switch": "^1.1.2",
6363
"@radix-ui/react-tabs": "^1.1.11",
6464
"@radix-ui/react-tooltip": "^1.1.6",

packages/components/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './date-picker-field';
1313
export * from './dropdown-menu';
1414
export * from './form';
1515
export * from './form-error-field';
16+
export * from './input-group';
1617
export * from './label';
1718
export * from './otp-input';
1819
export * from './otp-input-field';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
'use client';
2+
3+
import type * as React from 'react';
4+
import { cva, type VariantProps } from 'class-variance-authority';
5+
6+
import { cn } from './utils';
7+
import { Button } from './button';
8+
import { TextInput } from './text-input';
9+
import { Textarea } from './textarea';
10+
11+
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
12+
return (
13+
// biome-ignore lint/a11y/useSemanticElements: role="group" is appropriate for input groups per WAI-ARIA
14+
<div
15+
data-slot="input-group"
16+
role="group"
17+
className={cn(
18+
'group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full min-w-0 items-center rounded-md border outline-none transition-[color,box-shadow]',
19+
'h-9 has-[>textarea]:h-auto',
20+
21+
// Variants based on alignment.
22+
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
23+
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
24+
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
25+
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
26+
27+
// Focus state.
28+
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
29+
30+
// Error state.
31+
'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',
32+
33+
className,
34+
)}
35+
{...props}
36+
/>
37+
);
38+
}
39+
40+
const inputGroupAddonVariants = cva(
41+
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
42+
{
43+
variants: {
44+
align: {
45+
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
46+
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
47+
'block-start':
48+
'[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5',
49+
'block-end': '[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5',
50+
},
51+
},
52+
defaultVariants: {
53+
align: 'inline-start',
54+
},
55+
},
56+
);
57+
58+
function InputGroupAddon({
59+
className,
60+
align = 'inline-start',
61+
...props
62+
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
63+
return (
64+
// biome-ignore lint/a11y/useSemanticElements: role="group" is appropriate for input group addons per WAI-ARIA
65+
// biome-ignore lint/a11y/useKeyWithClickEvents: onClick is for focus management, not interactive action
66+
<div
67+
role="group"
68+
data-slot="input-group-addon"
69+
data-align={align}
70+
className={cn(inputGroupAddonVariants({ align }), className)}
71+
onClick={(e) => {
72+
if ((e.target as HTMLElement).closest('button')) {
73+
return;
74+
}
75+
e.currentTarget.parentElement?.querySelector('input')?.focus();
76+
}}
77+
{...props}
78+
/>
79+
);
80+
}
81+
82+
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
83+
variants: {
84+
size: {
85+
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
86+
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
87+
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
88+
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
89+
},
90+
},
91+
defaultVariants: {
92+
size: 'xs',
93+
},
94+
});
95+
96+
function InputGroupButton({
97+
className,
98+
type = 'button',
99+
variant = 'ghost',
100+
size = 'xs',
101+
...props
102+
}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
103+
return (
104+
<Button
105+
type={type}
106+
data-size={size}
107+
variant={variant}
108+
className={cn(inputGroupButtonVariants({ size }), className)}
109+
{...props}
110+
/>
111+
);
112+
}
113+
114+
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
115+
return (
116+
<span
117+
className={cn(
118+
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
119+
className,
120+
)}
121+
{...props}
122+
/>
123+
);
124+
}
125+
126+
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
127+
return (
128+
<TextInput
129+
data-slot="input-group-control"
130+
className={cn(
131+
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
132+
className,
133+
)}
134+
{...props}
135+
/>
136+
);
137+
}
138+
139+
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
140+
return (
141+
<Textarea
142+
data-slot="input-group-control"
143+
className={cn(
144+
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
145+
className,
146+
)}
147+
{...props}
148+
/>
149+
);
150+
}
151+
152+
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };

packages/components/src/ui/text-field.tsx

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ import {
1010
FormMessage,
1111
} from './form';
1212
import { type InputProps, TextInput } from './text-input';
13+
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from './input-group';
1314
import { cn } from './utils';
1415

16+
/**
17+
* @deprecated Use InputGroupAddon with InputGroupText instead
18+
* These components are kept for backward compatibility but will be removed in a future version.
19+
*/
1520
export const FieldPrefix = ({ children, className }: { children: React.ReactNode; className?: string }) => {
1621
return (
1722
<div
@@ -25,6 +30,10 @@ export const FieldPrefix = ({ children, className }: { children: React.ReactNode
2530
);
2631
};
2732

33+
/**
34+
* @deprecated Use InputGroupAddon with InputGroupText instead
35+
* These components are kept for backward compatibility but will be removed in a future version.
36+
*/
2837
export const FieldSuffix = ({ children, className }: { children: React.ReactNode; className?: string }) => {
2938
return (
3039
<div
@@ -72,30 +81,35 @@ export const TextField = function TextField({
7281
control={control}
7382
name={name}
7483
render={({ field, fieldState }) => {
84+
// Use the new InputGroup pattern when prefix or suffix is provided
85+
const hasAddon = prefix || suffix;
86+
7587
return (
7688
<FormItem className={className}>
7789
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
78-
<div
79-
className={cn('flex group transition-all duration-200 rounded-md', {
80-
'field__input--with-prefix': prefix,
81-
'field__input--with-suffix': suffix,
82-
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background': true,
83-
})}
84-
>
85-
{prefix && <FieldPrefix>{prefix}</FieldPrefix>}
90+
{hasAddon ? (
91+
// New shadcn/ui InputGroup pattern
92+
<FormControl Component={components?.FormControl}>
93+
<InputGroup>
94+
{prefix && (
95+
<InputGroupAddon>
96+
<InputGroupText>{prefix}</InputGroupText>
97+
</InputGroupAddon>
98+
)}
99+
<InputGroupInput {...field} {...props} ref={ref} aria-invalid={fieldState.error ? 'true' : 'false'} />
100+
{suffix && (
101+
<InputGroupAddon align="inline-end">
102+
<InputGroupText>{suffix}</InputGroupText>
103+
</InputGroupAddon>
104+
)}
105+
</InputGroup>
106+
</FormControl>
107+
) : (
108+
// Original pattern without addons
86109
<FormControl Component={components?.FormControl}>
87-
<InputComponent
88-
{...field}
89-
{...props}
90-
ref={ref}
91-
className={cn('focus-visible:ring-0 focus-visible:ring-offset-0 border-input', {
92-
'rounded-l-none border-l-0': prefix,
93-
'rounded-r-none border-r-0': suffix,
94-
})}
95-
/>
110+
<InputComponent {...field} {...props} ref={ref} />
96111
</FormControl>
97-
{suffix && <FieldSuffix>{suffix}</FieldSuffix>}
98-
</div>
112+
)}
99113
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
100114
{fieldState.error && (
101115
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>

yarn.lock

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,7 +1729,7 @@ __metadata:
17291729
"@radix-ui/react-select": "npm:^2.2.6"
17301730
"@radix-ui/react-separator": "npm:^1.1.6"
17311731
"@radix-ui/react-slider": "npm:^1.3.4"
1732-
"@radix-ui/react-slot": "npm:^1.2.3"
1732+
"@radix-ui/react-slot": "npm:^1.2.4"
17331733
"@radix-ui/react-switch": "npm:^1.1.2"
17341734
"@radix-ui/react-tabs": "npm:^1.1.11"
17351735
"@radix-ui/react-tooltip": "npm:^1.1.6"
@@ -2639,7 +2639,7 @@ __metadata:
26392639
languageName: node
26402640
linkType: hard
26412641

2642-
"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.2.3":
2642+
"@radix-ui/react-slot@npm:1.2.3":
26432643
version: 1.2.3
26442644
resolution: "@radix-ui/react-slot@npm:1.2.3"
26452645
dependencies:
@@ -2654,6 +2654,21 @@ __metadata:
26542654
languageName: node
26552655
linkType: hard
26562656

2657+
"@radix-ui/react-slot@npm:^1.2.4":
2658+
version: 1.2.4
2659+
resolution: "@radix-ui/react-slot@npm:1.2.4"
2660+
dependencies:
2661+
"@radix-ui/react-compose-refs": "npm:1.1.2"
2662+
peerDependencies:
2663+
"@types/react": "*"
2664+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
2665+
peerDependenciesMeta:
2666+
"@types/react":
2667+
optional: true
2668+
checksum: 10c0/8b719bb934f1ae5ac0e37214783085c17c2f1080217caf514c1c6cc3d9ca56c7e19d25470b26da79aa6e605ab36589edaade149b76f5fc0666f1063e2fc0a0dc
2669+
languageName: node
2670+
linkType: hard
2671+
26572672
"@radix-ui/react-switch@npm:^1.1.2":
26582673
version: 1.2.6
26592674
resolution: "@radix-ui/react-switch@npm:1.2.6"

0 commit comments

Comments
 (0)