"use client" import * as React from "react" import { cn } from "../../lib/utils" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select" /** * Replaces `@wordpress/components`' `CustomSelectControl` (19 sites) and the `CustomSelectOption` * type imported from `@wordpress/components/build-types/custom-select-control/types`. * * An adapter, not a rename. Two parts of WP's contract must survive or call sites fail silently: * * 1. `onChange` receives `{ selectedItem }`, not a value. Every site destructures it. * 2. `selectedItem` is the caller's own option object, handed back by reference. Sites read extra * fields off it (`selectedItem.value` in RoleSwitcher, AppointmentsPage, RelatedPatientSelector), * so a rebuilt object would lose them. * * Radix keys items by a non-empty string and several sites use `{ key: null, name: '' }` or * `{ key: '' }` as a blank first row. Items are therefore keyed by their index in `options`, and * the index maps back to the option. Duplicate keys are safe for the same reason. * * `value` accepts an option object, which is what WP documents, or a bare key string. Six sites * pass `field.value` from react-hook-form, which is a key. * * `label` and `help` are rendered here, as WP did and a bare Radix select does not. * `__next40pxDefaultSize` is not accepted; call sites drop it. */ export type CustomSelectOption = { key: string name: string style?: React.CSSProperties className?: string // `any`, not `unknown`, and copied from WP's own type on purpose. Sites hang their own fields off // an option and compare them (`option.value === selectedWorkspace` in DashboardPage); `unknown` // makes every one of those a type error. // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any } /** WP's onChange payload. Only `selectedItem` is read anywhere in this app. */ export type CustomSelectChangeObject< T extends CustomSelectOption = CustomSelectOption, > = { selectedItem: T } export interface CustomSelectControlProps< T extends CustomSelectOption = CustomSelectOption, > { label?: React.ReactNode help?: React.ReactNode /** Keep the label for screen readers only. */ hideLabelFromVision?: boolean options: ReadonlyArray /** An option object, or the key of one. */ value?: T | string | null /** WP's contract: `{ selectedItem }`, never a bare value. */ onChange?: (newValue: CustomSelectChangeObject) => void disabled?: boolean placeholder?: string /** Lands on the trigger, which is the element worth styling. */ className?: string id?: string "aria-label"?: string } function CustomSelectControl({ label, help, hideLabelFromVision = false, options, value, onChange, disabled, placeholder, className, id, "aria-label": ariaLabel, }: CustomSelectControlProps) { // useId emits colons, which are invalid in a CSS selector, so they are stripped. Same reasoning // as ui/input-control. const generated = React.useId().replace(/:/g, "") const triggerId = id ?? `custom-select-${generated}` const labelId = `${triggerId}-label` const helpId = help ? `${triggerId}-help` : undefined const selectedIndex = React.useMemo(() => { if (value === undefined || value === null) return -1 // Identity first, so two options sharing a key still resolve to the one passed in. if (typeof value === "object") { const exact = options.indexOf(value) if (exact !== -1) return exact } const key = typeof value === "object" ? value.key : value return options.findIndex((option) => option.key === key) }, [options, value]) /** * AN EMPTY TOKEN IS NOT A SELECTION, AND `Number("")` IS 0. * * MEASURED 2026-09-07, reproduced in isolation: a form reset with a stored code, whose value set * arrived a moment later, lost the code. The field held `current` after the reset and `undefined` * once the options list grew, with nobody touching the control. The callback fired with * `token = ""` while index 1 was selected and three options were present - Radix reporting a * cleared selection of its own as the item set changed underneath it. * * `Number("")` is **0**, so that cleared signal was read as *"the person chose the FIRST * option"*. Here that row is the placeholder and `FhirCode` maps it back to a clear, so the stored * value was destroyed by the arrival of the list that was supposed to display it. **On a caller * with no placeholder row it is worse and quieter: the field is silently set to whatever option * happens to be first.** * * WHY IT HAD NO WITNESSES. A create form starts empty, so a field that arrives empty looks correct, * and until this week nothing in the product could open one of these editors on a resource that * already had values. On a REQUIRED field the save is then refused for an answer nobody touched; on * an OPTIONAL one there is no refusal at all and the value is simply gone from the record. */ const handleValueChange = (token: string) => { if (!/^\d+$/.test(token)) return const selectedItem = options[Number(token)] if (!selectedItem) return onChange?.({ selectedItem }) } return (
{label && ( )} {help && (

{help}

)}
) } export { CustomSelectControl }