All files / src/components/ui field.tsx

100% Statements 33/33
100% Branches 32/32
100% Functions 11/11
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235                                                                              149x                                 3430x 3430x 3430x 3430x   3430x 1363x                           3430x                                                 4336x 4336x                                               513x 513x   513x 173x 171x 171x     513x                                 24x 24x   24x 12x 10x 10x     24x                                                                                       9517x   9517x       28551x   9517x   9517x                                      
/**
 * Field assembly — the Job Book's ruled form field.
 *
 * `.field` in `.context/mockups/_base.css`: a `.label` printed code above the
 * control, a `.field-hint` under it, a `.field-error` in error ink. The point
 * of the assembly (over hand-wiring each form) is that it wires the ARIA for
 * you: `Label` gets `htmlFor`, `HelpText` and `ErrorText` register their ids
 * so the control gets `aria-describedby`, and the presence of an `ErrorText`
 * is what sets `aria-invalid` on the control. No call site has to remember.
 *
 * Controls opt in by calling `useFieldControl()` (Select, Textarea, Checkbox
 * and RadioGroup all do). They stay usable outside a `Field` — the hook just
 * returns the props they were given.
 */
import {
	createContext,
	type HTMLAttributes,
	type LabelHTMLAttributes,
	type ReactNode,
	useContext,
	useEffect,
	useId,
	useMemo,
	useState,
} from "react";
import { cn } from "../../lib/utils";
 
type FieldContextValue = {
	controlId: string;
	labelId: string;
	helpId: string;
	errorId: string;
	hasHelp: boolean;
	hasError: boolean;
	required: boolean;
	setHasHelp: (present: boolean) => void;
	setHasError: (present: boolean) => void;
};
 
const FieldContext = createContext<FieldContextValue | null>(null);
 
interface FieldProps extends HTMLAttributes<HTMLDivElement> {
	/** Id for the control inside. Generated when omitted. */
	controlId?: string;
	/** Marks the control inside as required. */
	required?: boolean;
	children: ReactNode;
}
 
function Field({
	controlId,
	required = false,
	className,
	children,
	...props
}: FieldProps) {
	const generatedId = useId();
	const id = controlId ?? generatedId;
	const [hasHelp, setHasHelp] = useState(false);
	const [hasError, setHasError] = useState(false);
 
	const value = useMemo<FieldContextValue>(
		() => ({
			controlId: id,
			labelId: `${id}-label`,
			helpId: `${id}-help`,
			errorId: `${id}-error`,
			hasHelp,
			hasError,
			required,
			setHasHelp,
			setHasError,
		}),
		[id, hasHelp, hasError, required],
	);
 
	return (
		<FieldContext.Provider value={value}>
			<div className={cn("block", className)} {...props}>
				{children}
			</div>
		</FieldContext.Provider>
	);
}
 
interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
	/**
	 * Prints the `.field-optional` code on the right of the label strip. The
	 * Job Book marks what is optional, never what is required.
	 */
	optional?: boolean;
}
 
function Label({
	className,
	children,
	htmlFor,
	id,
	optional,
	...props
}: LabelProps) {
	const field = useContext(FieldContext);
	return (
		<label
			id={id ?? field?.labelId}
			htmlFor={htmlFor ?? field?.controlId}
			className={cn(
				"mb-1.5 flex items-baseline justify-between gap-4 text-xs font-semibold uppercase tracking-wider text-foreground-muted",
				className,
			)}
			{...props}
		>
			<span>{children}</span>
			{optional ? (
				<span className="text-foreground-subtle">Optional</span>
			) : null}
		</label>
	);
}
 
function HelpText({
	className,
	id,
	children,
	...props
}: HTMLAttributes<HTMLParagraphElement>) {
	const field = useContext(FieldContext);
	const setHasHelp = field?.setHasHelp;
 
	useEffect(() => {
		if (!setHasHelp) return;
		setHasHelp(true);
		return () => setHasHelp(false);
	}, [setHasHelp]);
 
	return (
		<p
			id={id ?? field?.helpId}
			className={cn("mt-1.5 text-sm text-foreground-muted", className)}
			{...props}
		>
			{children}
		</p>
	);
}
 
function ErrorText({
	className,
	id,
	children,
	...props
}: HTMLAttributes<HTMLParagraphElement>) {
	const field = useContext(FieldContext);
	const setHasError = field?.setHasError;
 
	useEffect(() => {
		if (!setHasError) return;
		setHasError(true);
		return () => setHasError(false);
	}, [setHasError]);
 
	return (
		<p
			id={id ?? field?.errorId}
			role="alert"
			className={cn("mt-1.5 text-sm font-medium text-error", className)}
			{...props}
		>
			{children}
		</p>
	);
}
 
interface UseFieldControlOptions {
	/** Explicit id. Wins over the Field's generated one. */
	id?: string;
	/** Extra ids to describe the control with, on top of help/error. */
	describedBy?: string;
	/** Explicit invalid state. ORed with "the Field has an ErrorText". */
	invalid?: boolean;
	/** Explicit required state. Wins over the Field's. */
	required?: boolean;
}
 
interface FieldControlAria {
	id: string | undefined;
	/** The `Label`'s id, for controls that are labelled by reference. */
	labelId: string | undefined;
	"aria-describedby": string | undefined;
	"aria-invalid": true | undefined;
	required: boolean | undefined;
	/** Same as `aria-invalid === true`, for styling. */
	invalid: boolean;
}
 
/**
 * Reads the enclosing `Field` and returns the ARIA a control must spread.
 * Safe outside a `Field`: the passed-in props come straight back.
 */
function useFieldControl({
	id,
	describedBy,
	invalid,
	required,
}: UseFieldControlOptions = {}): FieldControlAria {
	const field = useContext(FieldContext);
 
	const describedByIds = [
		field?.hasHelp ? field.helpId : undefined,
		field?.hasError ? field.errorId : undefined,
		describedBy,
	].filter((value): value is string => Boolean(value));
 
	const isInvalid = Boolean(invalid) || Boolean(field?.hasError);
 
	return {
		id: id ?? field?.controlId,
		labelId: field?.labelId,
		"aria-describedby": describedByIds.length
			? describedByIds.join(" ")
			: undefined,
		"aria-invalid": isInvalid ? true : undefined,
		required: required ?? field?.required,
		invalid: isInvalid,
	};
}
 
export type {
	FieldControlAria,
	FieldProps,
	LabelProps,
	UseFieldControlOptions,
};
export { ErrorText, Field, HelpText, Label, useFieldControl };