All files / src/components/ui select.tsx

100% Statements 6/6
100% Branches 2/2
100% Functions 2/2
100% Lines 6/6

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                                                        116x                                                                                               116x                               7190x   7190x                               12426x                                     116x        
/**
 * Select — the Job Book's ruled dropdown.
 *
 * The mockups are the specification and they print this control as a native
 * `<select class="select">` (see `.context/mockups/onboarding.html`,
 * `leads-board.html`, `project-detail-photos.html`): a ruled field with the
 * chevron set inside its right edge, in three control tiers.
 *
 * Why native and not `@radix-ui/react-select`: that package is NOT a portal
 * dependency (only `@radix-ui/react-slot` is), and adding it means editing
 * `apps/portal/package.json` + the root `bun.lock`. A native `<select>` is
 * keyboard-complete and screen-reader-correct for free — including the
 * platform wheel on iOS/Android — so this wrapper is the accessible floor,
 * not a hand-rolled listbox. The props below are deliberately the shape a
 * Radix/shadcn `Select` would take (`value`/`onChange`, `placeholder`,
 * `disabled`, `invalid`), so swapping the internals later touches this file
 * only.
 *
 * Error *messages* are not this component's job: wrap it in a `Field` and add
 * an `ErrorText`, which sets `aria-invalid` here automatically.
 */
 
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { forwardRef, type SelectHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
import { useFieldControl } from "./field";
 
const selectVariants = cva(
	"block w-full appearance-none rounded-md border bg-background-base pr-8 text-foreground-default transition-colors focus-visible:border-foreground-default focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground-default disabled:cursor-not-allowed disabled:opacity-50",
	{
		variants: {
			// Three control tiers (40 / 32 / 24), with the 44px touch floor kept
			// ONLY under a coarse pointer — a mouse keeps the dense tiers, which is
			// the whole point of having them.
			tier: {
				comfortable: "min-h-10 px-2.5 text-sm pointer-coarse:min-h-11",
				dense: "min-h-8 px-2.5 text-sm pointer-coarse:min-h-11",
				micro: "min-h-6 px-1.5 text-xs pointer-coarse:min-h-11",
			},
			invalid: {
				// Colour never travels alone: the ErrorText under the control is the
				// message, this is only the ink on the rule.
				true: "border-error ring-1 ring-error",
				// The portal's fields resolve their stroke through --border-strong,
				// 3:1 against both grounds (WCAG 1.4.11, UI component boundary).
				// --border-strong is 1.67:1 light / 1.91:1 dark and is NOT a legal
				// field stroke; it was a placeholder while the Earthy override
				// still shadowed the package tokens.
				false: "border-input",
			},
		},
		defaultVariants: {
			tier: "comfortable",
			invalid: false,
		},
	},
);
 
interface SelectOption {
	value: string;
	label: string;
	disabled?: boolean;
}
 
interface SelectProps
	extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">,
		Omit<VariantProps<typeof selectVariants>, "invalid"> {
	/** Options as data. `children` (e.g. `<optgroup>`) renders after them. */
	options?: readonly SelectOption[];
	/** Rendered as a leading empty-value option. */
	placeholder?: string;
	/** Marks the control invalid. A `Field` with an `ErrorText` sets this too. */
	invalid?: boolean;
}
 
const Select = forwardRef<HTMLSelectElement, SelectProps>(
	(
		{
			className,
			tier,
			invalid,
			options,
			placeholder,
			id,
			required,
			children,
			"aria-describedby": describedBy,
			...props
		},
		ref,
	) => {
		const field = useFieldControl({ id, describedBy, invalid, required });
 
		return (
			<div className="relative">
				<select
					ref={ref}
					id={field.id}
					required={field.required}
					aria-describedby={field["aria-describedby"]}
					aria-invalid={field["aria-invalid"]}
					className={cn(
						selectVariants({ tier, invalid: field.invalid }),
						className,
					)}
					{...props}
				>
					{placeholder ? <option value="">{placeholder}</option> : null}
					{options?.map((option) => (
						<option
							key={option.value}
							value={option.value}
							disabled={option.disabled}
						>
							{option.label}
						</option>
					))}
					{children}
				</select>
				<ChevronDown
					aria-hidden="true"
					className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-foreground-subtle"
				/>
			</div>
		);
	},
);
 
Select.displayName = "Select";
 
export type { SelectOption, SelectProps };
export { Select, selectVariants };