1'use client';
2
3import * as React from 'react';
4import { cn } from '../lib/cn';
5import { Label } from './label';
6
7type FieldOrientation = 'vertical' | 'horizontal' | 'responsive';
8
9interface FieldContextValue {
10 id: string;
11 controlId: string;
12 setControlId: (id: string | undefined) => void;
13 descriptionId: string;
14 errorId: string;
15 invalid: boolean;
16 disabled: boolean;
17 hasDescription: boolean;
18 hasError: boolean;
19 setHasDescription: (present: boolean) => void;
20 setHasError: (present: boolean) => void;
21}
22
23const FieldContext = React.createContext<FieldContextValue | null>(null);
24
25function useFieldContext(): FieldContextValue | null {
26 return React.use(FieldContext);
27}
28
29interface FieldControlProps {
30 id: string;
31 'aria-describedby': string | undefined;
32 'aria-invalid': boolean | undefined;
33 disabled: boolean | undefined;
34}
35
36function useField(): FieldControlProps {
37 const field = React.use(FieldContext);
38 if (!field) throw new Error('useField must be used within Field');
39
40 const describedBy =
41 [
42 field.hasDescription ? field.descriptionId : undefined,
43 field.hasError ? field.errorId : undefined,
44 ]
45 .filter(Boolean)
46 .join(' ') || undefined;
47
48 return {
49 id: field.id,
50 'aria-describedby': describedBy,
51 'aria-invalid': field.invalid || undefined,
52 disabled: field.disabled || undefined,
53 };
54}
55
56interface FieldProps extends React.HTMLAttributes<HTMLDivElement> {
57 orientation?: FieldOrientation;
58 invalid?: boolean;
59 disabled?: boolean;
60 name?: string;
61}
62
63const FieldRoot = ({
64 orientation = 'vertical',
65 invalid = false,
66 disabled = false,
67 name,
68 id,
69 className,
70 ref,
71 ...props
72}: FieldProps & { ref?: React.Ref<HTMLDivElement> }) => {
73 const generatedId = React.useId();
74 const fieldId = id ?? generatedId;
75 const [hasDescription, setHasDescription] = React.useState(false);
76 const [hasError, setHasError] = React.useState(false);
77 const [customControlId, setControlId] = React.useState<string | undefined>(undefined);
78 const labelled = Boolean(props['aria-label'] || props['aria-labelledby']);
79
80 const context = React.useMemo(
81 () => ({
82 id: fieldId,
83 controlId: customControlId ?? fieldId,
84 setControlId,
85 descriptionId: `${fieldId}-description`,
86 errorId: `${fieldId}-error`,
87 invalid,
88 disabled,
89 hasDescription,
90 hasError,
91 setHasDescription,
92 setHasError,
93 }),
94 [fieldId, customControlId, invalid, disabled, hasDescription, hasError],
95 );
96
97 return (
98 <FieldContext value={context}>
99 <div
100 ref={ref}
101 role={labelled ? 'group' : undefined}
102 data-slot="field"
103 data-name={name}
104 data-orientation={orientation}
105 data-invalid={invalid ? '' : undefined}
106 data-disabled={disabled ? '' : undefined}
107 className={cn(
108 'group/field data-[invalid]:text-destructive-text flex w-full gap-2 data-[disabled]:opacity-60',
109 orientation === 'vertical' && 'flex-col [&>*]:w-full',
110 orientation === 'horizontal' &&
111 'flex-row items-center [&>[data-slot=field-content]]:flex-1 [&>[data-slot=field-label]]:flex-auto',
112 orientation === 'responsive' &&
113 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-content]]:flex-1 @md/field-group:[&>[data-slot=field-label]]:flex-auto',
114 className,
115 )}
116 {...props}
117 />
118 </FieldContext>
119 );
120};
121FieldRoot.displayName = 'Field';
122
123interface FieldControlSlotProps {
124 children: React.ReactElement<Record<string, unknown>>;
125}
126
127const FieldControl = ({ children }: FieldControlSlotProps) => {
128 const control = useField();
129 const childProps = children.props;
130 const ownId = childProps.id as string | undefined;
131 const setControlId = React.use(FieldContext)?.setControlId;
132
133 React.useLayoutEffect(() => {
134 setControlId?.(ownId);
135 return () => setControlId?.(undefined);
136 }, [setControlId, ownId]);
137 const describedBy =
138 [control['aria-describedby'], childProps['aria-describedby'] as string | undefined]
139 .filter(Boolean)
140 .join(' ') || undefined;
141
142 const injected: Record<string, unknown> = { id: childProps.id ?? control.id };
143 if (describedBy) injected['aria-describedby'] = describedBy;
144 if (childProps['aria-invalid'] === undefined && control['aria-invalid']) {
145 injected['aria-invalid'] = true;
146 }
147 if (childProps.disabled === undefined && control.disabled) injected.disabled = true;
148
149 return React.cloneElement(children, injected);
150};
151FieldControl.displayName = 'FieldControl';
152
153type FieldLabelProps = React.ComponentProps<typeof Label>;
154
155const FieldLabel = ({ className, htmlFor, ...props }: FieldLabelProps) => {
156 const field = useFieldContext();
157
158 return (
159 <Label
160 data-slot="field-label"
161 htmlFor={htmlFor ?? field?.controlId}
162 className={cn('group-data-[invalid]/field:text-destructive-text', className)}
163 {...props}
164 />
165 );
166};
167FieldLabel.displayName = 'FieldLabel';
168
169const FieldDescription = ({
170 className,
171 id,
172 ref,
173 ...props
174}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) => {
175 const field = useFieldContext();
176 const setHasDescription = field?.setHasDescription;
177
178 React.useEffect(() => {
179 setHasDescription?.(true);
180 return () => setHasDescription?.(false);
181 }, [setHasDescription]);
182
183 return (
184 <p
185 ref={ref}
186 id={id ?? field?.descriptionId}
187 data-slot="field-description"
188 className={cn(
189 'text-muted-foreground [&>a:hover]:text-foreground text-sm leading-normal font-normal [&>a]:underline [&>a]:underline-offset-4',
190 className,
191 )}
192 {...props}
193 />
194 );
195};
196FieldDescription.displayName = 'FieldDescription';
197
198type FieldErrorMessage = string | { message?: string } | undefined | null;
199
200interface FieldErrorProps extends React.HTMLAttributes<HTMLDivElement> {
201 errors?: FieldErrorMessage[];
202}
203
204function errorMessages(errors: FieldErrorMessage[] | undefined): string[] {
205 if (!errors) return [];
206 const messages = errors
207 .map((error) => (typeof error === 'string' ? error : error?.message))
208 .filter((message): message is string => Boolean(message));
209 return [...new Set(messages)];
210}
211
212const FieldError = ({
213 errors,
214 children,
215 className,
216 id,
217 ref,
218 ...props
219}: FieldErrorProps & { ref?: React.Ref<HTMLDivElement> }) => {
220 const field = useFieldContext();
221 const messages = errorMessages(errors);
222 const hasContent = Boolean(children) || messages.length > 0;
223 const setHasError = field?.setHasError;
224
225 React.useEffect(() => {
226 setHasError?.(hasContent);
227 return () => setHasError?.(false);
228 }, [setHasError, hasContent]);
229
230 if (!hasContent) return null;
231
232 let content: React.ReactNode = children;
233 if (!content && messages.length === 1) content = messages[0];
234 if (!content && messages.length > 1) {
235 content = (
236 <ul className="ml-4 flex list-disc flex-col gap-1">
237 {messages.map((message) => (
238 <li key={message}>{message}</li>
239 ))}
240 </ul>
241 );
242 }
243
244 return (
245 <div
246 ref={ref}
247 id={id ?? field?.errorId}
248 role="alert"
249 data-slot="field-error"
250 className={cn('text-destructive-text text-sm font-normal', className)}
251 {...props}
252 >
253 {content}
254 </div>
255 );
256};
257FieldError.displayName = 'FieldError';
258
259const FieldContent = ({
260 className,
261 ref,
262 ...props
263}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => (
264 <div
265 ref={ref}
266 data-slot="field-content"
267 className={cn('group/field-content flex flex-1 flex-col gap-1 leading-snug', className)}
268 {...props}
269 />
270);
271FieldContent.displayName = 'FieldContent';
272
273const FieldTitle = ({
274 className,
275 ref,
276 ...props
277}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => (
278 <div
279 ref={ref}
280 data-slot="field-title"
281 className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
282 {...props}
283 />
284);
285FieldTitle.displayName = 'FieldTitle';
286
287const FieldSet = ({
288 className,
289 ref,
290 ...props
291}: React.FieldsetHTMLAttributes<HTMLFieldSetElement> & {
292 ref?: React.Ref<HTMLFieldSetElement>;
293}) => (
294 <fieldset
295 ref={ref}
296 data-slot="field-set"
297 className={cn(
298 'flex min-w-0 flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
299 className,
300 )}
301 {...props}
302 />
303);
304FieldSet.displayName = 'FieldSet';
305
306interface FieldLegendProps extends React.HTMLAttributes<HTMLLegendElement> {
307 variant?: 'legend' | 'label';
308}
309
310const FieldLegend = ({
311 variant = 'legend',
312 className,
313 ref,
314 ...props
315}: FieldLegendProps & { ref?: React.Ref<HTMLLegendElement> }) => (
316 <legend
317 ref={ref}
318 data-slot="field-legend"
319 data-variant={variant}
320 className={cn('mb-3 font-medium', variant === 'legend' ? 'text-base' : 'text-sm', className)}
321 {...props}
322 />
323);
324FieldLegend.displayName = 'FieldLegend';
325
326const FieldGroup = ({
327 className,
328 ref,
329 ...props
330}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => (
331 <div
332 ref={ref}
333 data-slot="field-group"
334 className={cn(
335 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
336 className,
337 )}
338 {...props}
339 />
340);
341FieldGroup.displayName = 'FieldGroup';
342
343const FieldSeparator = ({
344 children,
345 className,
346 ref,
347 ...props
348}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => (
349 <div
350 ref={ref}
351 data-slot="field-separator"
352 data-content={children ? '' : undefined}
353 className={cn('relative -my-2 h-5 text-sm', className)}
354 {...props}
355 >
356 <div aria-hidden="true" className="bg-border absolute inset-x-0 top-1/2 h-px" />
357 {children && (
358 <span className="bg-background text-muted-foreground relative mx-auto block w-fit px-2">
359 {children}
360 </span>
361 )}
362 </div>
363);
364FieldSeparator.displayName = 'FieldSeparator';
365
366const Field = Object.assign(FieldRoot, {
367 Control: FieldControl,
368 Label: FieldLabel,
369 Description: FieldDescription,
370 Error: FieldError,
371 Content: FieldContent,
372 Title: FieldTitle,
373 Set: FieldSet,
374 Legend: FieldLegend,
375 Group: FieldGroup,
376 Separator: FieldSeparator,
377});
378
379export { Field, useField, useFieldContext };
380export type {
381 FieldProps,
382 FieldOrientation,
383 FieldErrorProps,
384 FieldErrorMessage,
385 FieldLegendProps,
386 FieldControlProps,
387};