IftaLabel
Preview
Basic
Loading…
Preview
Code
ts
import IftaLabel from '@/components/form/IftaLabel';src/components/form/IftaLabel.tsx
AI prompt
text
Build an "infield top-aligned" label field component in React + TypeScript + Tailwind CSS: a small label and the control share one bordered box with one focus ring.
## Look
- Wrapper box: `flex flex-col rounded-lg border bg-white/80 dark:bg-slate-900/60 transition-shadow`, border `border-slate-300 dark:border-slate-700`; on `focus-within`: `ring-2 ring-indigo-500/40 border-indigo-500`. Disabled control inside → whole box at 60% opacity (`has-[:disabled]:opacity-60`).
- Top row `flex items-center justify-between gap-2 px-3 pt-1.5`: the label in `text-[10px] font-semibold leading-none text-slate-500 dark:text-slate-400`, and an optional `aside` on the right (a unit, a counter) in `text-[10px] leading-none text-slate-400 dark:text-slate-500`.
- The control below, its own chrome stripped: `w-full min-w-0 border-0 bg-transparent px-3 pt-1 pb-1.5 text-xs text-slate-800 dark:text-slate-100 placeholder-slate-400 shadow-none outline-none ring-0 focus:ring-0`.
- `invalid`: border `border-rose-400 dark:border-rose-500`, focus ring `ring-rose-400/40`, label `text-rose-600 dark:text-rose-400`.
## Behaviour
- The border and ring belong to the WRAPPER, not the control: the box the eye reads as "the field" includes the label, so a ring round just the input would draw a second box inside it.
- Clone the single child control, giving it the id (its own or a generated one), `aria-invalid` when invalid, and the stripping classes (as utilities, so they beat an existing text-input class on the child). The label's `htmlFor` focuses the control.
## API
`label: ReactNode`, `children` (one input/textarea/select), `aside?: ReactNode`, `invalid = false`, `className`.
## Demo
Three side by side: "Amount" with aside "USD" and placeholder "0.00"; "Plan" select (Free / Team / Enterprise, Team selected); "Reference" invalid with value "ABC-".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { cloneElement, useId, type ReactElement } from 'react';
import { cn } from '@/lib/cn';
type ControlProps = { id?: string; className?: string; 'aria-invalid'?: boolean };
export interface IftaLabelProps {
label: React.ReactNode;
/** Exactly one `<input>`, `<textarea>` or `<select>` (or a component that forwards `id` and `className` to one). */
children: ReactElement<ControlProps>;
/** Small text on the right of the label row — a unit, a character count. */
aside?: React.ReactNode;
/** Rose border and label, for a field that failed validation. */
invalid?: boolean;
className?: string;
}
/**
* "Infield top-aligned" label: the label sits small at the top INSIDE the
* bordered box, the control below it in the same box.
*
* The border and focus ring belong to the wrapper, not the control — the box
* the eye reads as "the field" includes the label, so a ring around just the
* input would draw a second, smaller box inside it. `focus-within` gives the
* whole box the same ring `.field-input` draws, and clicking the label still
* focuses the control through `htmlFor`.
*/
export default function IftaLabel({ label, children, aside, invalid = false, className }: IftaLabelProps) {
const autoId = useId();
const id = children.props.id ?? autoId;
return (
<div
className={cn(
'flex flex-col rounded-lg border bg-white/80 dark:bg-slate-900/60 transition-shadow',
'focus-within:ring-2 has-[:disabled]:opacity-60',
invalid
? 'border-rose-400 dark:border-rose-500 focus-within:ring-rose-400/40'
: 'border-slate-300 dark:border-slate-700 focus-within:border-indigo-500 focus-within:ring-indigo-500/40',
className,
)}
>
<div className="flex items-center justify-between gap-2 px-3 pt-1.5">
<label
htmlFor={id}
className={cn(
'text-[10px] font-semibold leading-none',
invalid ? 'text-rose-600 dark:text-rose-400' : 'text-slate-500 dark:text-slate-400',
)}
>
{label}
</label>
{aside && <span className="text-[10px] leading-none text-slate-400 dark:text-slate-500">{aside}</span>}
</div>
{cloneElement(children, {
id,
'aria-invalid': invalid || undefined,
// Strip the control's own chrome. These are utilities, so they beat a
// `.field-input` on the child (components layer) — the kit's `<Input>`
// drops in as readily as a bare `<input>`.
className: cn(
children.props.className,
'w-full min-w-0 border-0 bg-transparent px-3 pb-1.5 pt-1 text-xs text-slate-800 dark:text-slate-100',
'placeholder-slate-400 dark:placeholder-slate-500 shadow-none outline-none ring-0 focus:ring-0 focus:outline-none',
'disabled:cursor-not-allowed',
),
})}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label* | React.ReactNode | — | |
children* | ReactElement<ControlProps> | — | Exactly one `<input>`, `<textarea>` or `<select>` (or a component that forwards `id` and `className` to one). |
aside | React.ReactNode | — | Small text on the right of the label row — a unit, a character count. |
invalid | boolean | false | Rose border and label, for a field that failed validation. |
className | string | — |