Skip to content

Commit

Permalink
feat: new form input component (#2910)
Browse files Browse the repository at this point in the history
  • Loading branch information
ogzhanolguncu authored Feb 24, 2025
1 parent 9395fb7 commit 5f0994d
Show file tree
Hide file tree
Showing 7 changed files with 337 additions and 8 deletions.
116 changes: 116 additions & 0 deletions apps/engineering/content/design/components/form-input.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: FormInput
description: A form input component with built-in label, description, and error handling capabilities.
---
import {
DefaultFormInputVariant,
RequiredFormInputVariant,
SuccessFormInputVariant,
WarningFormInputVariant,
ErrorFormInputVariant,
DisabledFormInputVariant,
DefaultValueFormInputVariant,
ReadonlyFormInputVariant,
ComplexFormInputVariant
} from "./form/form-input.variants"

# FormInput
A comprehensive form input component that combines labels, descriptions, and validation states. Perfect for creating accessible, user-friendly forms with proper labeling and helpful context.

## Default
The default FormInput includes a label and optional description text, providing clear context for users.

<DefaultFormInputVariant />

## Input States

### Required Field
Use the required prop to indicate mandatory fields. This automatically adds an asterisk (*) to the label.

<RequiredFormInputVariant />

### Success State
Indicates successful validation or acceptance of input value. The success icon and text provide positive feedback.

<SuccessFormInputVariant />

### Warning State
Used for potentially problematic inputs that don't prevent form submission. Includes a warning icon and explanatory text.

<WarningFormInputVariant />

### Error State
Shows validation errors or other issues that need user attention. Features prominent error styling and message.

<ErrorFormInputVariant />

### Disabled State
Apply when the field should be non-interactive, such as during form submission or based on other field values.

<DisabledFormInputVariant />

### With Default Value
Pre-populated input with an initial value that users can modify.

<DefaultValueFormInputVariant />

### Read-only State
For displaying non-editable information while maintaining form layout consistency.

<ReadonlyFormInputVariant />

## Complex Usage
Example of a FormInput with multiple props configured for a specific use case.

<ComplexFormInputVariant />

## Props
The FormInput component extends the standard Input component props with additional form-specific properties:

<AutoTypeTable
name="FormInputProps"
type={`import { InputProps } from "../input"
import { ReactNode } from "react"
export interface FormInputProps extends InputProps {
/** Text label for the input field */
label?: string;
/** Helper text providing additional context */
description?: string;
/** Whether the field is required */
required?: boolean;
/** Error message to display when validation fails */
error?: string;
/** ID for the input element, auto-generated if not provided */
id?: string;
/** Additional class names to apply to the fieldset wrapper */
className?: string;
/** Visual state variant passed to the underlying Input component */
variant?: 'default' | 'success' | 'warning' | 'error';
}`}
/>

## Accessibility
FormInput is built with accessibility in mind:
- Labels are properly associated with inputs using htmlFor/id
- Error messages are announced to screen readers using role="alert"
- Required fields are marked both visually and via aria-required
- Helper text is linked to inputs using aria-describedby
- Error states are indicated using aria-invalid

## Best Practices
When using the FormInput component:
- Always provide clear, concise labels
- Use description text to provide additional context when needed
- Keep error messages specific and actionable
- Use required fields sparingly and logically
- Group related FormInputs using fieldset and legend when appropriate
- Consider the mobile experience when writing labels and descriptions
- Maintain consistent validation patterns across your form
- Use appropriate input types (email, tel, etc.) for better mobile keyboards
- Consider character/word limits in descriptions and error messages
- Test with screen readers to ensure accessibility

## Layout Guidelines
- Labels should be clear and concise
- Error messages should appear immediately below the input
- Description text should be helpful but not too lengthy
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { RenderComponentWithSnippet } from "@/app/components/render";
import { FormInput } from "@unkey/ui";

export const DefaultFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Username"
description="Choose a unique username for your account"
placeholder="e.g. gandalf_grey"
/>
</RenderComponentWithSnippet>
);
};

// Required field variant
export const RequiredFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Email Address"
description="We'll send your confirmation email here"
required
placeholder="[email protected]"
/>
</RenderComponentWithSnippet>
);
};

// Success variant
export const SuccessFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="API Key"
description="Your API key has been verified"
variant="success"
defaultValue="sk_live_middleearth123"
placeholder="Enter your API key"
/>
</RenderComponentWithSnippet>
);
};

// Warning variant
export const WarningFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Password"
description="Your password is about to expire"
variant="warning"
type="password"
placeholder="Enter your password"
/>
</RenderComponentWithSnippet>
);
};

// Error variant
export const ErrorFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Repository Name"
error="A repository with this name already exists"
placeholder="my-awesome-project"
/>
</RenderComponentWithSnippet>
);
};

// Disabled variant
export const DisabledFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Organization ID"
description="Contact admin to change organization ID"
disabled
defaultValue="org_fellowship123"
placeholder="Organization ID"
/>
</RenderComponentWithSnippet>
);
};

// With default value
export const DefaultValueFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Project Name"
description="Name of your new project"
defaultValue="The Fellowship Project"
placeholder="Enter project name"
/>
</RenderComponentWithSnippet>
);
};

// Readonly variant
export const ReadonlyFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Generated Token"
description="Copy this token for your records"
readOnly
defaultValue="tkn_1ring2rulethemall"
placeholder="Your token will appear here"
/>
</RenderComponentWithSnippet>
);
};

// Complex example with multiple props
export const ComplexFormInputVariant = () => {
return (
<RenderComponentWithSnippet>
<FormInput
label="Webhook URL"
description="Enter the URL where we'll send event notifications"
required
placeholder="https://api.yourdomain.com/webhooks"
className="max-w-lg"
id="webhook-url-input"
/>
</RenderComponentWithSnippet>
);
};
2 changes: 0 additions & 2 deletions apps/engineering/content/design/components/input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
title: Input
description: A text input field component with different states, validations, and icon support.
---

import { Input } from "@unkey/ui"
import { RenderComponentWithSnippet } from "@/app/components/render"
import {
InputDefaultVariant,
Expand Down
82 changes: 82 additions & 0 deletions internal/ui/src/components/form/form-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { CircleInfo, TriangleWarning2 } from "@unkey/icons";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Input, type InputProps } from "../input";

export interface FormInputProps extends InputProps {
label?: string;
description?: string;
required?: boolean;
error?: string;
}

export const FormInput = React.forwardRef<HTMLInputElement, FormInputProps>(
({ label, description, error, required, id, className, variant, ...props }, ref) => {
const inputVariant = error ? "error" : variant;

const inputId = id || React.useId();
const descriptionId = `${inputId}-helper`;
const errorId = `${inputId}-error`;

return (
<fieldset className={cn("flex flex-col gap-1.5 border-0 m-0 p-0", className)}>
{label && (
<label
id={`${inputId}-label`}
htmlFor={inputId}
className="text-gray-11 text-[13px] flex items-center"
>
{label}
{required && (
<span className="text-error-9 ml-1" aria-label="required field">
*
</span>
)}
</label>
)}

<Input
ref={ref}
id={inputId}
variant={inputVariant}
aria-describedby={error ? errorId : description ? descriptionId : undefined}
aria-invalid={!!error}
aria-required={required}
{...props}
/>

{(description || error) && (
<div className="text-[13px] leading-5">
{error ? (
<div id={errorId} role="alert" className="text-error-11 flex gap-2 items-center">
<TriangleWarning2 aria-hidden="true" />
{error}
</div>
) : description ? (
<output
id={descriptionId}
className={cn(
"text-gray-9 flex gap-2 items-center",
variant === "success"
? "text-success-11"
: variant === "warning"
? "text-warning-11"
: "",
)}
>
{variant === "warning" ? (
<TriangleWarning2 size="md-regular" aria-hidden="true" />
) : (
<CircleInfo size="md-regular" aria-hidden="true" />
)}
<span>{description}</span>
</output>
) : null}
</div>
)}
</fieldset>
);
},
);

FormInput.displayName = "FormInput";
1 change: 1 addition & 0 deletions internal/ui/src/components/form/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./form-input";
12 changes: 6 additions & 6 deletions internal/ui/src/components/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,19 @@ const inputVariants = cva(
"[&:not(:placeholder-shown)]:focus:ring-0",
],
success: [
"border border-success-6 hover:border-success-7 bg-gray-2",
"border border-success-9 hover:border-success-10 bg-gray-2",
"focus:border-success-8 focus:ring-2 focus:ring-success-2 focus-visible:outline-none",
"[&:not(:placeholder-shown)]:focus:ring-success-3",
"[&:not(:placeholder-shown)]:focus:ring-success-0",
],
warning: [
"border border-warning-6 hover:border-warning-7 bg-gray-2",
"border border-warning-9 hover:border-warning-10 bg-gray-2",
"focus:border-warning-8 focus:ring-2 focus:ring-warning-2 focus-visible:outline-none",
"[&:not(:placeholder-shown)]:focus:ring-warning-3",
"[&:not(:placeholder-shown)]:focus:ring-warning-0",
],
error: [
"border border-error-6 hover:border-error-7 bg-gray-2",
"border border-error-9 hover:border-error-10 bg-gray-2",
"focus:border-error-8 focus:ring-2 focus:ring-error-2 focus-visible:outline-none",
"[&:not(:placeholder-shown)]:focus:ring-error-3",
"[&:not(:placeholder-shown)]:focus:ring-error-0",
],
},
},
Expand Down
1 change: 1 addition & 0 deletions internal/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./components/tooltip";
export * from "./components/date-time/date-time";
export * from "./components/input";
export * from "./components/empty";
export * from "./components/form";

0 comments on commit 5f0994d

Please sign in to comment.