Component
Search Input With Actions
Search input with clear and submit controls.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/inputs/search-input-with-actions/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Inputs/search-input-with-actions React/Components/Inputs/search-input-with-actions import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react";
import { useId, useState } from "react";
export type InputSize = "sm" | "md" | "lg";
export type InputTone = "default" | "error" | "success";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const INPUT_SIZES: Record<InputSize, string> = {
sm: "h-8 px-2.5 text-[13px]",
md: "h-9 px-3 text-sm",
lg: "h-11 px-3.5 text-sm",
};
const ICON_SIZES: Record<InputSize, string> = {
sm: "[&_svg]:size-[14px]",
md: "[&_svg]:size-4",
lg: "[&_svg]:size-[18px]",
};
function inputClasses(size: InputSize, tone: InputTone, hasLeading = false, hasTrailing = false): string {
return cx(
"w-full rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none",
tone === "error" ? "border-[var(--ds-color-destructive)]" : tone === "success" ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]",
INPUT_SIZES[size],
hasLeading && "pl-9",
hasTrailing && "pr-9",
);
}
function Spinner() {
return (
<svg className="size-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
export interface SearchInputWithActionsProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> {
label?: string;
helperText?: string;
error?: string;
success?: string;
size?: InputSize;
tone?: InputTone;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
prefix?: ReactNode;
suffix?: ReactNode;
clearLabel?: string;
onClear?: () => void;
onSearch?: (value: string) => void;
searchLabel?: string;
}
export function SearchInputWithActions({ label = "Search with Actions", helperText, error, success, size = "md", tone = "default", id, className, value, defaultValue = "", onChange, clearLabel = "Clear input", onClear, onSearch, searchLabel = "Search", ...props }: SearchInputWithActionsProps) {
const generatedId = useId();
const inputId = id ?? `input-${generatedId}`;
const messageId = `${inputId}-message`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value);
const message = error ?? success ?? helperText;
const resolvedTone: InputTone = error ? "error" : success ? "success" : tone;
function handleChange(event: ChangeEvent<HTMLInputElement>) { if (value === undefined) setInternalValue(event.target.value); onChange?.(event); }
function clearInput() { if (value === undefined) setInternalValue(""); onClear?.(); document.getElementById(inputId)?.focus(); }
return <div className="w-full"><label htmlFor={inputId} className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">{label}</label><div className="relative flex items-center"><input id={inputId} type="search" className={cx(inputClasses(size, resolvedTone, false, Boolean(currentValue)), className)} value={currentValue} onChange={handleChange} aria-invalid={error ? true : undefined} aria-describedby={message ? messageId : undefined} {...props} />{currentValue ? <button type="button" aria-label={clearLabel} onClick={clearInput} className="absolute right-2 inline-flex size-6 items-center justify-center rounded-[var(--ds-radius-xs)] text-[var(--ds-color-muted-foreground)] transition-colors hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none">×</button> : null}</div>{message ? <p id={messageId} className="mt-2 text-xs text-[var(--ds-color-muted-foreground)]">{message}</p> : null}
<button type="button" onClick={() => onSearch?.(currentValue)} className="mt-2 h-9 rounded-[var(--ds-radius-sm)] bg-[var(--ds-color-primary)] px-3 text-[13px] font-medium text-[var(--ds-color-primary-foreground)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none">{searchLabel}</button></div>;
}
export default SearchInputWithActions; /* DevSnips React — JavaScript parity build. Same API and behavior as code.tsx; TypeScript types removed. */
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const INPUT_SIZES = {
sm: "h-8 px-2.5 text-[13px]",
md: "h-9 px-3 text-sm",
lg: "h-11 px-3.5 text-sm",
};
const ICON_SIZES = {
sm: "[&_svg]:size-[14px]",
md: "[&_svg]:size-4",
lg: "[&_svg]:size-[18px]",
};
function inputClasses(size, tone, hasLeading = false, hasTrailing = false) {
return cx(
"w-full rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none",
tone === "error" ? "border-[var(--ds-color-destructive)]" : tone === "success" ? "border-[var(--ds-color-success)]" : "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]",
INPUT_SIZES[size],
hasLeading && "pl-9",
hasTrailing && "pr-9",
);
}
function Spinner() {
return (
<svg className="size-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
export function SearchInputWithActions({ label = "Search with Actions", helperText, error, success, size = "md", tone = "default", id, className, value, defaultValue = "", onChange, clearLabel = "Clear input", onClear, onSearch, searchLabel = "Search", ...props }) {
const generatedId = useId();
const inputId = id ?? `input-${generatedId}`;
const messageId = `${inputId}-message`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value);
const message = error ?? success ?? helperText;
const resolvedTone = error ? "error" : success ? "success" : tone;
function handleChange(event) { if (value === undefined) setInternalValue(event.target.value); onChange?.(event); }
function clearInput() { if (value === undefined) setInternalValue(""); onClear?.(); document.getElementById(inputId)?.focus(); }
return <div className="w-full"><label htmlFor={inputId} className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">{label}</label><div className="relative flex items-center"><input id={inputId} type="search" className={cx(inputClasses(size, resolvedTone, false, Boolean(currentValue)), className)} value={currentValue} onChange={handleChange} aria-invalid={error ? true : undefined} aria-describedby={message ? messageId : undefined} {...props} />{currentValue ? <button type="button" aria-label={clearLabel} onClick={clearInput} className="absolute right-2 inline-flex size-6 items-center justify-center rounded-[var(--ds-radius-xs)] text-[var(--ds-color-muted-foreground)] transition-colors hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none">×</button> : null}</div>{message ? <p id={messageId} className="mt-2 text-xs text-[var(--ds-color-muted-foreground)]">{message}</p> : null}
<button type="button" onClick={() => onSearch?.(currentValue)} className="mt-2 h-9 rounded-[var(--ds-radius-sm)] bg-[var(--ds-color-primary)] px-3 text-[13px] font-medium text-[var(--ds-color-primary-foreground)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none">{searchLabel}</button></div>;
}
export default SearchInputWithActions; # Search Input With Actions
Search input with clear and submit controls.
## Usage
```tsx
import { SearchInputWithActions } from './code';
<SearchInputWithActions label="Workspace name" placeholder="Acme design" />
```
## JavaScript
```jsx
import { SearchInputWithActions } from './code';
<SearchInputWithActions label="Billing email" placeholder="billing@example.com" />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `string` | component label | Visible label associated with the input. |
| `helperText` | `string` | `undefined` | Supporting text announced through `aria-describedby`. |
| `error` | `string` | `undefined` | Error message; sets `aria-invalid`. |
| `success` | `string` | `undefined` | Success message associated with the control. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control height and text sizing. |
| `tone` | `"default" \| "error" \| "success"` | `"default"` | Visual state when no message prop overrides it. |
| `className` | `string` | `undefined` | Adds layout classes to the input element. |
| native input props | `InputHTMLAttributes<HTMLInputElement>` | — | Supports `value`, `defaultValue`, `onChange`, `name`, `id`, `placeholder`, `disabled`, `readOnly`, `required`, and `aria-*`. |
## States
Supports default, hover, focus-visible, disabled, readonly, error, success, and loading where applicable.
## Accessibility
Uses a native `input`, a visible `label`, associated helper/error text, `aria-invalid` for errors, and keyboard-accessible buttons for interactive controls.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. No component-specific CSS file is required.
## Design Tokens
Visual decisions reference `React/DESIGN_TOKENS.md` rather than duplicating the token system. 73 lines UTF-8 · LF · Spaces: 2
Continue browsing