import { DismissibleAlert } from '../ui/dismissible-alert'; import { CustomSelectControl } from "../ui/custom-select-control"; import { ComboboxControl } from "../ui/combobox-control"; import { FormTokenField } from "../ui/form-token-field"; import { SelectControl } from "../ui/select-control"; import { ProgressBar } from "../ui/progress-bar"; import type { CustomSelectOption } from "../ui/custom-select-control"; import type { TokenItem } from "../ui/form-token-field"; import type { ComboboxControlOption } from "../ui/combobox-control"; import { Controller, useFormContext } from "react-hook-form"; import { IValueSet } from "../../services"; import { getNestedError } from "../../utils/formUtils.ts"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "../ui/select"; import { RelationValueSetList } from "../FhirRelatedPerson/FhirRelations"; import { Field, FieldLabel } from "../ui/field"; const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', ' ', 'Escape', 'Tab', 'Home', 'End']; const preventTypeahead = (e: React.KeyboardEvent) => { if (!NAVIGATION_KEYS.includes(e.key)) { e.preventDefault(); e.stopPropagation(); } }; interface FhirCodeableConceptProps { label: string; context: string; options?: IValueSet[]; groupedOptions?: RelationValueSetList[]; multiple?: boolean; combobox?: boolean; required?: boolean; disabled?: boolean; disableSelected?: boolean; loading?: boolean; useNativeSelect?: boolean; useShadcn?: boolean; maxSuggestions?: number; protectedCodes?: string[]; onProtectedRemoveAttempt?: () => void; // Optional veto hook. Called just before a chip is removed; return false to cancel the immediate // removal (the caller then owns applying it, e.g. after a confirmation). Absent => unchanged behaviour. onBeforeRemove?: (code: string) => boolean; // Optional per-item secondary text (e.g. a caller-supplied note), rendered under an option's // label. Only consumed by the useShadcn + multiple + groupedOptions render path; every other // path/caller is unaffected. Returning undefined for an option renders no extra markup for it. getSecondaryText?: (option: IValueSet) => string | undefined; } const FhirCodeableConcept = ({ label, context, options, groupedOptions, multiple = false, combobox = false, required = false, disabled = false, loading = false, useNativeSelect = false, useShadcn = false, maxSuggestions = 500, protectedCodes, onProtectedRemoveAttempt, onBeforeRemove, getSecondaryText, }: FhirCodeableConceptProps) => { const { control, formState: { errors }, } = useFormContext(); options = groupedOptions ? groupedOptions.flatMap((g) => g.items) : options ?? []; /** * THE MARKER FOR THE BRANCHES THAT CANNOT RENDER JSX BESIDE THEIR LABEL. * * This component enforces `required` once, in the Controller below, and then renders through four * different controls. The two shadcn branches put an asterisk next to the label themselves; the two * WordPress ones take `label` as a STRING, so they had no way to and simply did not - which is how a * field could refuse a save while showing nothing to say it was needed. * * Measured on Condition: Category is `required={true}` and multi-valued, so it took the FormTokenField * branch, and a clinician who filled the one asterisked field was told about a second afterwards. * * Keyed off the same `required` that does the enforcing, so a field only ever gains the marker when it * would actually refuse - marking everything required is the same lie pointing the other way. */ const markedLabel = required && label ? `${label} *` : label; return ( <> { // THE REF RHF WAS ALREADY REACHING FOR, and could never find. `shouldFocusError` defaults to // true and no modal turns it off, so on every refused submit react-hook-form tries to focus the // first invalid field - and this component forwarded no ref, so there was nothing to focus. A // clinician who clicked Create from below the fold saw NOTHING happen, while the required-field // message sat on the page beside a field they could not see. // It goes on a wrapper because every branch below renders a WordPress or shadcn control that // takes no ref, and `tabIndex={-1}` makes the wrapper focusable programmatically without putting // a div into the tab order. Focusing it scrolls the field into view, which is the behaviour that // draws the eye to whatever actually stopped the submit. let customSelectValue: CustomSelectOption | undefined; let comboBoxValue: string | undefined; if (!multiple && field.value) { const selectedOption = options.find( (option) => option.code === field.value.code, ); customSelectValue = selectedOption ? { key: selectedOption.code, name: selectedOption.display, } : undefined; } if (combobox) { const selectedOption = options.find( (option) => option.code === field.value?.code, ); comboBoxValue = selectedOption ? selectedOption.code : undefined; } const tokenValues: (string | TokenItem)[] = multiple && !combobox && Array.isArray(field.value) ? field.value.map((item: any) => { if (typeof item === "string") { const option = options.find((o) => o.code === item); return option ? option.display : item; } return item.display || ""; }) : []; // Native select for constrained layouts where a portaled custom menu can be clipped. if (useNativeSelect && !multiple && !combobox) { return (
({ label: option.display, value: option.code, })), ]} value={field.value?.code ?? ""} onChange={(value) => { const selectedOption = options.find( (option) => option.code === value, ); field.onChange(selectedOption ?? null); }} disabled={disabled} required={required} /> {loading && }
); } // Shadcn Select for single selection if (useShadcn && !multiple && !combobox) { return (
{label} {required && *} {loading && }
); } // Shadcn Select for multiple selection if (useShadcn && multiple) { const selectedValues = Array.isArray(field.value) ? field.value.map((item: any) => item.code).filter(Boolean) : []; const renderGroupedItems = () => { if (groupedOptions) { return groupedOptions.map((group) => { const visibleItems = group.items.filter( (item) => !selectedValues.includes(item.code), ); if (visibleItems.length === 0) return null; return ( {group.label} {visibleItems.map((option) => { const secondaryText = getSecondaryText?.(option); // SelectItem lays its LAST direct span (the ItemText wrapper) out as a flex // ROW, so sibling spans sit side by side no matter what `block` says. When a // note exists, wrap both in one flex-col span so it lands under the label. // With no note the markup is unchanged from the no-secondaryText original - // ~145 files render this component and must not shift. if (!secondaryText) { return ( {option.display} ); } return ( {option.display} {secondaryText} ); })} ); }); } return options .filter((option) => !selectedValues.includes(option.code)) .map((option) => ( {option.display} )); }; return (
{label} {required && *} {/* Display selected items */} {Array.isArray(field.value) && field.value.length > 0 && (
{field.value.map((item: any, index: number) => { const isProtected = protectedCodes?.includes(item.code) ?? false; return (
{item.display}
); })}
)}
{loading && }
); } return ( <> {/* Standard mode - WordPress CustomSelectControl */} {!combobox && !multiple && !useShadcn && ( ({ key: option.code, name: option.display, })), ] as CustomSelectOption[] } value={customSelectValue} disabled={disabled} onChange={(newValue: { selectedItem: CustomSelectOption; }) => { const selectedValueSet = options.find( (option) => option.code === newValue.selectedItem.key, ); field.onChange(selectedValueSet); }} /> )} {/* Multiple selection with FormTokenField */} {multiple && !useShadcn && ( { const valueSetObjects = tokens.map((token) => { const tokenStr = typeof token === "string" ? token : token.value; const valueSet = options.find( (option) => option.display === tokenStr, ); return valueSet || { code: tokenStr, display: tokenStr }; }); field.onChange(valueSetObjects); }} suggestions={options.map( (option: IValueSet) => option.display, )} /> )} {/* Combobox */} {combobox && !useShadcn && ( ({ value: option.code, label: option.display, })), ] as ComboboxControlOption[] } value={comboBoxValue} onChange={(value) => { const selectedOption = options.find( (option) => option.code === value, ); field.onChange(selectedOption || null); }} /> )} {loading && !useShadcn && ( )} ); }} /> {getNestedError(errors, context) && ( {label} is required )} ); }; export default FhirCodeableConcept;