Skip to content
Draft
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
6 changes: 5 additions & 1 deletion apps/docs/src/ui/select-alignment.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const RightAlignedSelectExample = () => {
};

export const RightAligned: Story = {
args: {
options: fruits,
placeholder: 'Select a fruit...',
},
render: () => <RightAlignedSelectExample />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
Expand All @@ -79,7 +83,7 @@ export const RightAligned: Story = {
await waitFor(() => {
expect(document.activeElement).toBe(listbox);
});
await userEvent.keyboard('{ArrowDown}', { focusTrap: false });
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
const activeItem = document.querySelector('[cmdk-item][aria-selected="true"]');
expect(activeItem).not.toBeNull();
Expand Down
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.6",
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './date-picker-field';
export * from './dropdown-menu';
export * from './form';
export * from './form-error-field';
export * from './input-group';
export * from './label';
export * from './otp-input';
export * from './otp-input-field';
Expand Down
117 changes: 117 additions & 0 deletions packages/components/src/ui/input-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use client';

import type * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from './utils';
import { Button } from './button';
import { TextInput } from './text-input';
import { Textarea } from './textarea';

function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
// biome-ignore lint/a11y/useSemanticElements: role="group" is appropriate for input groups per WAI-ARIA
<div
data-slot="input-group"
role="group"
className={cn(
// Simple wrapper with focus-within ring, matching original design
'group flex w-full rounded-md transition-all duration-200',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
className,
)}
{...props}
/>
);
}

function InputGroupAddon({
className,
align = 'start',
...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
const isPrefix = align === 'start';
const isSuffix = align === 'end';

return (
<div
className={cn(
// Base styling matching original FieldPrefix/FieldSuffix
'flex h-10 items-center text-base text-gray-500 group-focus-within:text-gray-700 transition-colors duration-200',
'border border-input bg-background',
// Prefix styling (left side)
isPrefix && 'pl-3 pr-0 rounded-l-md border-r-0',
// Suffix styling (right side)
isSuffix && 'pr-3 pl-0 rounded-r-md border-l-0',
className,
)}
{...props}
/>
);
}

const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 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('whitespace-nowrap', className)} {...props} />;
}

function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<TextInput
data-slot="input-group-control"
className={cn(
// Match original input styling but remove focus ring/offset (handled by wrapper)
// Border removal for prefix/suffix should be handled by the parent component
'flex-1 focus-visible:ring-0 focus-visible:ring-offset-0 border-input',
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 };
79 changes: 34 additions & 45 deletions packages/components/src/ui/text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,9 @@ import {
FormMessage,
} from './form';
import { type InputProps, TextInput } from './text-input';
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from './input-group';
import { cn } from './utils';

export const FieldPrefix = ({ children, className }: { children: React.ReactNode; className?: string }) => {
return (
<div
className={cn(
'flex h-full text-base items-center pl-3 pr-0 text-gray-500 group-focus-within:text-gray-700 transition-colors duration-200 border-y border-l border-input rounded-l-md bg-background',
className,
)}
>
<span className="whitespace-nowrap">{children}</span>
</div>
);
};

export const FieldSuffix = ({ children, className }: { children: React.ReactNode; className?: string }) => {
return (
<div
className={cn(
'flex h-full text-base items-center pr-3 pl-0 text-gray-500 group-focus-within:text-gray-700 transition-colors duration-200 border-y border-r border-input rounded-r-md bg-background',
className,
)}
>
<span className="whitespace-nowrap">{children}</span>
</div>
);
};

// Create a specific interface for the input props that includes className explicitly
export interface TextInputProps extends Omit<InputProps, 'prefix' | 'suffix'> {
control?: Control<FieldValues>;
Expand Down Expand Up @@ -72,30 +47,44 @@ export const TextField = function TextField({
control={control}
name={name}
render={({ field, fieldState }) => {
// Use the new InputGroup pattern when prefix or suffix is provided
const hasAddon = prefix || suffix;

return (
<FormItem className={className}>
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
<div
className={cn('flex group transition-all duration-200 rounded-md', {
'field__input--with-prefix': prefix,
'field__input--with-suffix': suffix,
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background': true,
})}
>
{prefix && <FieldPrefix>{prefix}</FieldPrefix>}
{hasAddon ? (
// New shadcn/ui InputGroup pattern
<FormControl Component={components?.FormControl}>
<InputComponent
{...field}
{...props}
ref={ref}
className={cn('focus-visible:ring-0 focus-visible:ring-offset-0 border-input', {
'rounded-l-none border-l-0': prefix,
'rounded-r-none border-r-0': suffix,
})}
/>
<InputGroup>
{prefix && (
<InputGroupAddon align="start">
<InputGroupText>{prefix}</InputGroupText>
</InputGroupAddon>
)}
<InputGroupInput
{...field}
{...props}
ref={ref}
aria-invalid={fieldState.error ? 'true' : 'false'}
className={cn(className, {
'rounded-l-none border-l-0': prefix,
'rounded-r-none border-r-0': suffix,
})}
/>
{suffix && (
<InputGroupAddon align="end">
<InputGroupText>{suffix}</InputGroupText>
</InputGroupAddon>
)}
</InputGroup>
</FormControl>
{suffix && <FieldSuffix>{suffix}</FieldSuffix>}
</div>
) : (
// Original pattern without addons
<FormControl Component={components?.FormControl}>
<InputComponent {...field} {...props} ref={ref} />
</FormControl>
)}
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
{fieldState.error && (
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>
Expand Down
19 changes: 17 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1729,7 +1729,7 @@ __metadata:
"@radix-ui/react-select": "npm:^2.2.6"
"@radix-ui/react-separator": "npm:^1.1.6"
"@radix-ui/react-slider": "npm:^1.3.4"
"@radix-ui/react-slot": "npm:^1.2.3"
"@radix-ui/react-slot": "npm:^1.2.4"
"@radix-ui/react-switch": "npm:^1.1.2"
"@radix-ui/react-tabs": "npm:^1.1.11"
"@radix-ui/react-tooltip": "npm:^1.1.6"
Expand Down Expand Up @@ -2639,7 +2639,7 @@ __metadata:
languageName: node
linkType: hard

"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.2.3":
"@radix-ui/react-slot@npm:1.2.3":
version: 1.2.3
resolution: "@radix-ui/react-slot@npm:1.2.3"
dependencies:
Expand All @@ -2654,6 +2654,21 @@ __metadata:
languageName: node
linkType: hard

"@radix-ui/react-slot@npm:^1.2.4":
version: 1.2.4
resolution: "@radix-ui/react-slot@npm:1.2.4"
dependencies:
"@radix-ui/react-compose-refs": "npm:1.1.2"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/8b719bb934f1ae5ac0e37214783085c17c2f1080217caf514c1c6cc3d9ca56c7e19d25470b26da79aa6e605ab36589edaade149b76f5fc0666f1063e2fc0a0dc
languageName: node
linkType: hard

"@radix-ui/react-switch@npm:^1.1.2":
version: 1.2.6
resolution: "@radix-ui/react-switch@npm:1.2.6"
Expand Down