From 4aba58a2f696ce95caed738deb1596698786e122 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:50:16 +0800 Subject: [PATCH 01/12] feat(filters): implement unified clinical filter overhaul --- .design-sync/config.json | 2 +- docs/design-system/COMPONENTS.md | 6 +- docs/design-system/adoption-manifest.json | 21 +- docs/filter-contract.md | 324 ++++----- docs/search-results-bar-decisions.md | 68 +- src/app/(search-app)/dsm/search/page.tsx | 9 +- src/components/ClinicalDashboard.tsx | 16 +- src/components/applications-launcher-page.tsx | 49 +- src/components/calculators/search-page.tsx | 11 +- .../clinical-dashboard/differentials-home.tsx | 36 +- .../document-search-results.tsx | 683 ++++++++++++------ .../favourites-command-library-page.tsx | 465 ++++++------ .../medication-prescribing-workspace.tsx | 355 ++++++--- .../result-filter-control.tsx | 406 ++++++++--- .../search-results-header-band.tsx | 34 +- .../differential-stream-workspace.tsx | 386 +++++----- src/components/dsm/dsm-search-page.tsx | 406 ++++------- .../factsheets/factsheets-search-page.tsx | 9 +- .../forms/forms-search-results-page.tsx | 363 ++++------ .../formulation/formulation-home-page.tsx | 72 +- .../services/services-navigator-page.tsx | 157 ++-- .../specifiers/specifiers-home-page.tsx | 298 ++++++-- .../therapy-compass/screens/search-screen.tsx | 17 +- .../tools/tools-search-results-page.tsx | 2 +- src/components/ui/sheet.tsx | 81 ++- src/lib/document-filter-model.ts | 177 +++++ src/lib/form-filters.ts | 94 +++ src/lib/medication-filters.ts | 58 ++ src/lib/result-filter-url.ts | 73 ++ src/lib/search-scope-filter-chips.ts | 9 +- tests/differential-stream-page.dom.test.tsx | 72 +- ...fferentials-compare-selection.dom.test.tsx | 6 + tests/document-filter-model.test.ts | 57 ++ tests/document-filter-panel.dom.test.tsx | 208 ++++-- ...ent-search-scope-zero-results.dom.test.tsx | 6 +- tests/dsm-search-empty-state.dom.test.tsx | 62 +- tests/factsheets-search-page.dom.test.tsx | 26 +- tests/favourites-auth-gate.dom.test.tsx | 2 +- tests/favourites-empty-state.dom.test.tsx | 30 +- tests/form-filters.test.ts | 56 ++ tests/forms-search-filters.dom.test.tsx | 100 +++ tests/formulation-search-filters.dom.test.tsx | 55 ++ tests/medication-filters.test.ts | 55 ++ ...ication-prescribing-workspace.dom.test.tsx | 79 +- tests/mobile-interaction-regressions.test.ts | 3 +- tests/result-filter-url.test.ts | 59 ++ tests/search-results-header-band.dom.test.tsx | 126 +++- ...s-navigator-scope-empty-state.dom.test.tsx | 8 +- tests/sheet.dom.test.tsx | 18 + tests/specifiers-search-filters.dom.test.tsx | 89 +++ tests/ui-route-coverage.spec.ts | 38 +- tests/ui-smoke.spec.ts | 70 +- tests/ui-specifiers.spec.ts | 12 +- tests/ui-stress.spec.ts | 12 +- tests/ui-tools.spec.ts | 21 +- 55 files changed, 3924 insertions(+), 2033 deletions(-) create mode 100644 src/lib/document-filter-model.ts create mode 100644 src/lib/form-filters.ts create mode 100644 src/lib/medication-filters.ts create mode 100644 src/lib/result-filter-url.ts create mode 100644 tests/document-filter-model.test.ts create mode 100644 tests/form-filters.test.ts create mode 100644 tests/forms-search-filters.dom.test.tsx create mode 100644 tests/formulation-search-filters.dom.test.tsx create mode 100644 tests/medication-filters.test.ts create mode 100644 tests/result-filter-url.test.ts create mode 100644 tests/specifiers-search-filters.dom.test.tsx diff --git a/.design-sync/config.json b/.design-sync/config.json index e706ae1491..48d9a6d3e6 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -59,7 +59,7 @@ "SearchField": "about?: string; accept?: string; accessKey?: string; alt?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoComplete?: import(\"react\").HTMLInputAutoCompleteAttribute; autoCorrect?: string; autoFocus?: boolean; autoSave?: string; capture?: boolean | \"user\" | \"environment\"; checked?: boolean; children?: import(\"react\").ReactNode; className?: string; clearLabel?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; error?: string; exportparts?: string; fieldClassName?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; height?: string | number; hidden?: boolean; hideLabel?: boolean; hint?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; label: string; lang?: string; list?: string; max?: string | number; maxLength?: number; min?: string | number; minLength?: number; multiple?: boolean; name?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClear?: (() => void); onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; pattern?: string; placeholder?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; readOnly?: boolean; ref?: Ref; rel?: string; required?: boolean; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; size?: number; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); src?: string; step?: string | number; style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string; width?: string | number;", "SegmentedControl": "ariaControls?: string; ariaDescribedBy?: string; ariaLabelledBy?: string; className?: string; label?: string; layout?: \"fit\" | \"equal\"; onChange: (value: string) => void; options: readonly SegmentedControlOption[]; value: string;", "Select": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoComplete?: string; autoCorrect?: string; autoFocus?: boolean; autoSave?: string; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; error?: string; exportparts?: string; fieldClassName?: string; form?: string; hidden?: boolean; hideLabel?: boolean; hint?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; label: string; lang?: string; multiple?: boolean; name?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; options: SelectOption[]; part?: string; placeholder?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; ref?: Ref; rel?: string; required?: boolean; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; size?: number; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", - "Sheet": "ariaLabel?: string; bodyClassName?: string; bodyRef?: RefObject; children: ReactNode; closeButtonClassName?: string; closeLabel?: string; contentClassName?: string; contentStyle?: CSSProperties; description?: string; descriptionContent?: ReactNode; desktopBackdropClassName?: string; footer?: ReactNode; footerClassName?: string; headerActions?: ReactNode; headerClassName?: string; headerLeading?: ReactNode; id?: string; initialFocusRef?: RefObject; labelledBy?: string; mobilePlacement?: \"bottom\" | \"top\" | \"fullscreen\"; mobileSize?: SheetMobileSize; onBodyScroll?: UIEventHandler; onClose: () => void; open: boolean; placement?: \"default\" | \"left\"; portal?: boolean; resolveReturnFocusTarget?: (() => HTMLElement | null); returnFocusRef?: RefObject; testId?: string; title?: string; titleAccessory?: ReactNode; titleClassName?: string;", + "Sheet": "ariaLabel?: string; bodyClassName?: string; bodyRef?: RefObject; children: ReactNode; closeButtonClassName?: string; closeLabel?: string; contentClassName?: string; contentStyle?: CSSProperties; description?: string; descriptionContent?: ReactNode; desktopBackdropClassName?: string; footer?: ReactNode; footerClassName?: string; headerActions?: ReactNode; headerClassName?: string; headerLeading?: ReactNode; id?: string; initialFocusRef?: RefObject; labelledBy?: string; mobilePlacement?: \"bottom\" | \"top\" | \"fullscreen\"; mobileSize?: SheetMobileSize; onBodyScroll?: UIEventHandler; onClose: () => void; open: boolean; placement?: \"default\" | \"left\" | \"right\" | \"responsive-right\"; portal?: boolean; resolveReturnFocusTarget?: (() => HTMLElement | null); returnFocusRef?: RefObject; testId?: string; title?: string; titleAccessory?: ReactNode; titleClassName?: string;", "Skeleton": "about?: string; accessKey?: string; animationDelay?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; children?: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; hidden?: boolean; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; typeof?: string; unselectable?: \"off\" | \"on\"; vocab?: string;", "SourceDesignationBadge": "className?: string; metadata?: SourceMetadataInput;", "SourceProvenance": "metadata?: SourceMetadataInput;", diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e2eea76857..ef90f84310 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -999,9 +999,9 @@ This generated snapshot is a local source-derived inventory. It does not assert | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | | `SearchField` | controls | yes | yes | no | yes | no | 0 | -| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 10 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 6 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | @@ -1012,7 +1012,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `TextField` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `TextLink` | controls | yes | yes | no | yes | no | 0 | | `ToastRegion` | feedback | yes | yes | no | yes | no | 0 | -| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 | +| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 1 | | `Tooltip` | feedback | yes | yes | no | yes | no | 0 | | `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 4b369cea4e..f18b37952b 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1396,26 +1396,18 @@ "entryExported": true, "directImportFiles": [ "src/components/applications-launcher-page.tsx", - "src/components/clinical-dashboard/differentials-home.tsx", - "src/components/clinical-dashboard/medication-prescribing-workspace.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", - "src/components/services/services-navigator-page.tsx", - "src/components/specifiers/specifiers-home-page.tsx", "src/components/tools/tools-search-results-page.tsx" ], "productImportFiles": [ "src/components/applications-launcher-page.tsx", - "src/components/clinical-dashboard/differentials-home.tsx", - "src/components/clinical-dashboard/medication-prescribing-workspace.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", - "src/components/services/services-navigator-page.tsx", - "src/components/specifiers/specifiers-home-page.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { @@ -1467,6 +1459,7 @@ "tests/answer-progress-ui-smoke.spec.ts", "tests/design-sync-visual-exports.test.ts", "tests/eval-process-safety.test.ts", + "tests/search-results-header-band.dom.test.tsx", "tests/ui-formulation.spec.ts", "tests/ui-smoke.spec.ts", "tests/ui-v2-components.dom.test.tsx" @@ -1512,7 +1505,6 @@ "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", "src/components/forms/form-detail-page.tsx", - "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/services/service-group-nav.tsx", @@ -1541,7 +1533,6 @@ "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", "src/components/forms/form-detail-page.tsx", - "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/services/service-group-nav.tsx", @@ -1901,14 +1892,8 @@ "source": "src/components/ui-primitives.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [ - "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" - ], - "productImportFiles": [ - "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" - ], + "directImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"], + "productImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 0469e77946..16eb264000 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -1,192 +1,148 @@ # The filter contract -One filter surface, shared by every mode. This is the rule set; the component is -`src/components/clinical-dashboard/result-filter-control.tsx`. +Every searchable catalogue uses the typed filter system in +`src/components/clinical-dashboard/result-filter-control.tsx`. A mode declares the meaning of +its refinements and supplies one predicate; the shared system owns responsive presentation, +selection semantics, applied-state visibility and the result action. -It exists because an audit of all ten filter surfaces in 2026-08 found the same dimension -rendered three different ways, two modes whose "filter" discarded the search instead of -narrowing it, and a mode whose footer counted items its filters did not govern. The fix is not a -restyle — it is agreeing on what a filter group _means_ before deciding how it looks. - -## 1. A mode declares semantics, never a layout - -`ResultFilterGroup` is discriminated by `kind`. The mode says what the dimension is; the -component decides the renderer. No call site picks chips, rows or a segmented control. +## Responsive anatomy -| Kind | Meaning | Selection | Built with | -| ---------------- | --------------------------------------------------------------- | --------------------------------- | -------------------------- | -| `lens` (default) | The options **partition** the result set; exactly one is active | one-of-N | `resultFilterGroup()` | -| `facet` | Independent constraints that accumulate | many-of-N, OR within / AND across | `resultFilterFacetGroup()` | +- `ResultFilterTrigger` is pinned in `SearchResultsHeaderBand`, carries the active-refinement + count and is available at every supported width. +- `ResultFilterSheet` is a bottom sheet on phones and a restrained right drawer from `sm` up. + It owns the dialog label, focus trap and restoration, sticky header/footer, safe-area inset, + reduced-motion behavior and 48 px phone / 40 px desktop action targets. +- The visible order is coverage, optional `Search in` scope, optional dense-filter search, + groups, polite result summary, one primary result action, then an optional secondary reach + action. +- More than three facet groups or more than twenty facet options enables dense mode: a + find-a-filter field and collapse-by-default groups. Selected options remain reachable while + searching. +- Callers use typed `summary`, `coverage`, `scope`, `secondaryAction` and + `applicationMode`. Arbitrary caller-owned footer, meter and scope markup is not supported. -`kind` is optional and defaults to `lens`, because that is what all seven existing call sites -are. Adding the facet kind changed no rendered output. +## Semantics -**Which is which is a question about the data, not the UI.** Differentials' -All / Presentations / Diagnoses is a lens: a result cannot be both. Formulation's twelve domains -are facets: a mechanism routinely carries four. Rendering facets as radios — which formulation -does today — tells the reader they cannot hold two domains at once, which is false. +| Kind | Meaning | Selection | Component builder | +| ----- | ------------------------------------------------ | --------------------------------- | ------------------------------------- | +| Lens | A one-of-N partition of the same result universe | exactly one | `resultFilterGroup()` | +| Facet | Values that may accumulate | many-of-N | `resultFilterFacetGroup()` | +| Scope | Which result universe is searched | exactly one, separate from groups | `ResultFilterScopeSelector` / `scope` | -### There is no `navigate` kind, and that is the point - -Services' quick filters do not filter. They call `router.push` and **replace the query**, so -choosing one discards the search and its results with no warning and no undo. A control labelled -"Filter" must not do that. Query-replacing presets belong beside the composer as suggested -searches (`AnswerSuggestionChips`), not inside the filter sheet. +Facets use OR within a group and AND across groups. Visual `optionSections` may organise a long +facet but never create additional AND groups. Formulation domains are the reference case: four +visual sections, one OR predicate. + +Sort, display density, grouping, comparison state, suggested searches and Recently used views +are not filters. They do not increment the badge or appear on the applied-filter shelf. A +query-replacing suggestion is never placed in the filter panel. Answer has no artificial filter. + +Lens groups expose radio semantics with roving tab focus and Arrow/Home/End selection. Facets +expose individually reachable pressed controls. Selected state always includes an icon and text; +colour or opacity is never the only channel. + +## Scope -Factsheets' category dimension is not this pattern, despite an earlier draft of this section -grouping it with services' quick filters: `filterFactsheets(query, category)` ANDs the two, so -selecting a category narrows within the current search and preserves `q` — it is a real `lens` -(one-of-N, exactly the shape this section describes above), not a query-replacing preset. Its -actual defect was the one section 2 names next: the desktop rail used raw `` chips while -the phone sheet correctly used `resultFilterGroup`, so the two breakpoints disagreed on -component even though they agreed on values. Converged, not evicted — see the Rollout section. - -Until a mode has real facets, it is better for its filter trigger to be absent than to open a -sheet that throws the query away. - -## 2. Accessibility follows from the kind - -| | `lens` | `facet` | -| ---------- | ------------------------------- | -------------- | -| Container | `role="radiogroup"` | `role="group"` | -| Option | `role="radio"` + `aria-checked` | `aria-pressed` | -| Tab stops | one per group, roving tabindex | one per option | -| Arrow keys | move **and select** | not bound | - -The single tab stop is correct for a lens precisely because arrowing _replaces_ the selection. -It would be wrong for a facet, where arrowing would silently accumulate constraints the reader -never asked for, and where every toggle must be individually reachable. - -Both breakpoints use the same contract. The per-mode desktop chip rails that use `aria-pressed` -for one-of-N dimensions are wrong and are replaced as each mode adopts. - -## 3. Counts, and the rule that makes them safe - -A count goes in `option.hint` and answers **"how many would I have if I ticked this as well?"** — -the same predicate as the filter. Under OR-within-group, adding an option to an already-selected -group _widens_, so a count derived by narrowing the current subset would disagree with what the -click actually does. `projectSmartTagFacetGroups` in `src/lib/document-tags.ts` is the reference -implementation. - -That contract has one failure mode: an option that matches nothing reports the unchanged total -rather than zero, so an empty option looks identical to a full one. The companion rule removes -the failure rather than patching it: - -> **Derive the option list from the data. Never declare it.** - -Formulation declares twelve domains; three of them — Biological, Social and Cultural — match -none of the twelve mechanisms, so the sheet offers three controls that can never return anything. -Documents derives its facets from the current match set, so a zero-member facet cannot exist. -Derive, and the union count is always safe. - -A zero **as a consequence of the current selection** is legitimate and must stay visible: dashed -border, muted pair, `aria-disabled`, click guarded, still focusable, with an `sr-only` reason. -Never `opacity` — it multiplies against an already-muted foreground and does not survive -forced-colors, where border-style is preserved. A reader who has narrowed to nothing needs to see -which choice did it. - -## 4. Scope is conditional, and most modes do not get it - -Where a mode has a catalogue meaningfully larger than the current result set, the sheet offers a -scope segment — `These results N | All items N`, counts on both — built from the shared -`SegmentedControl` (`src/components/ui/segmented-control.tsx`), which already has the roving -tabindex and radio semantics. `ResultFilterSheet` reserves the slot (`scopeControl`) but does not -build the segment itself: "meaningfully larger" and what the two counts mean are per-mode -judgements the shared renderer cannot make. Services is the first mode to use it — see -`services-navigator-page.tsx`: the segment is gated on the catalogue exceeding the query/group -scoped result set (not the facet-narrowed one, so the segment does not flicker away as facets are -applied), and both counts reflect the current facet/lens selection. - -It earns its place because it is the only escape from a filtered-to-zero state that does not -discard the query: the commit becomes "Show N in all items" instead of a dead end. - -**Render it only when the catalogue is meaningfully larger than the result set.** Otherwise the -two segments show the same number and the row is noise. - -| Gets scope | Does not | -| ----------------------------------------------------------------------- | --------------------------------------------------- | -| services (219), medication (328), differentials (232), specifiers (585) | factsheets (8), applications (13), formulation (12) | - -Documents is deliberately excluded: it already answers this with a `N of M documents shown` meter -and a "Browse all sources" action framed as _reach, not refinement_. That is a better fit for a -corpus of that size, and it stays. - -## 5. Density is a function of option count - -Facet groups only. Two states, not three: the shared renderer uses the same threshold for its -find-a-filter field and collapse-by-default disclosures. - -| Options | Renderer | -| --------------------------- | ---------------------------------------------------------------------------------------- | -| ≤ 3 groups and ≤ 20 options | chips, single row where they fit (unchanged from before this section) | -| > 3 groups, or > 20 options | chips plus find-a-filter and collapse-by-default, every group behind a disclosure header | - -`ResultFilterSheet` computes the threshold once across all facet groups. The option-count limb -catches a small number of very large groups, while the group-count limb covers services' five -facet groups. Below the threshold every group renders as before. - -Collapse rules, when they apply: groups start collapsed; a group holding a selection opens -itself; an explicit user collapse beats that; an active needle forces every matched group open -and owns openness. A selected option always survives the needle, so an active constraint can -never become unreachable. A group whose options are all filtered out by the needle disappears -rather than showing an empty heading. - -## 6. Invariants - -- **`footerNote` counts what the filters actually govern.** Specifiers currently reports - `results.length + catalogueMatches.length` while the groups narrow only `results` — the sheet - claims to scope a list it half controls. A mode must not report a total its filters cannot move. -- **`onClearAll` never touches the query.** Clearing filters and clearing a search are different - intentions. Therapy-compass now uses the shared sheet's filter-only clear; the composer's - explicit "Clear search" action remains responsible for deleting the query. -- **One trigger component.** `ResultFilterTrigger`. Therapy-compass now uses the shared trigger - at the phone breakpoint and the shared facet chips on desktop. -- **Tap targets are `min-h-tap` (48px) on phone.** Do not relax to 44px for generic WCAG - guidance; it reintroduces a known `ui-smoke` flake. - -## 7. Not yet reconciled - -`SearchScopeFilters` (`src/lib/search-scope.ts`) is a second filter surface: 20 keys applied -server-side at retrieval, not editable from any panel, visible only as removable chips in the -documents zero-results state. Anything here that claims to be "the" filter contract is currently -telling half the story. Reconciling the two is tracked separately and is not a prerequisite for -adoption. - -## Rollout - -Contract first, then one PR per mode: - -1. **Contract** — add the kinds, the facet renderer and the builders, changing no rendered output. -2. **Per mode** — adopt the right kind, derive the option list, add counts, and retire that mode's - desktop rail so the breakpoints stop disagreeing. Done for differentials, medication, - applications and specifiers (all `lens`), formulation (`facet`), and factsheets, whose real - category lens now shares one counted option array between desktop and phone. -3. **Services** — move its query-replacing quick filters to the composer. Done for services: - five facets (catchments, age_groups, setting_flags, acuity_flags, housing_flags), - substance_flags as a lens (an exact partition, not an accumulating constraint — see - `src/lib/service-facets.ts`), a URL round-trip alongside `q`/`group`, and the scope segment - (section 4). Services is also the first mode dense enough (5 facet groups) to exercise the - `> 3 groups` chrome added to the shared sheet for this — see section 5. -4. **Therapy-compass** — converge runtime use of the bespoke phone-only filter sheet and trigger - onto `ResultFilterSheet`, `ResultFilterTrigger`, and `ResultFilterFacetChips`. Topics are OR - within their group. Review status and handout availability are independent one-option groups - that AND with Topics and with each other. Option counts and filtering share - `matchesTopics`/`matchesAvailability`, and Clear filters preserves the query. -5. **Documents last** — converged onto the shared component. Its needle and collapse-by-default - mechanics moved up into `ResultFilterSheet` first, as part of services (§5); documents itself - deleted its ~500-line bespoke `DocumentFilterPanel` and rebuilt on `ResultFilterSheet` with - three small additive extensions the other six modes never needed: - - `meterContent` — the `N of M documents shown` progress bar, rendered first in the body. - - `footerOverride` — replaces the default `footerNote` + Done button entirely, for a mode whose - commit needs its own label (`Show N documents`) and a second action beside it - (`Browse all sources`). - - `note` on `resultFilterGroup()` — a short label-adjacent annotation (`"one only"`) that - distinguishes a `lens` group from the `facet` groups sitting beside it in the same sheet. - Source type is the first lens to share a sheet with facets; services' `substance` lens beside - five facet groups (PR C) has the identical shape without this annotation — a follow-up worth - tracking, not a contradiction this PR resolves. The annotation is scoped to documents' call - site for now, not a general convention. - - All three are optional and default to inert — the six modes that adopted earlier render - byte-identical output. This closes the rollout: every filter surface in the audit now shares - one component. +`Search in` is used only when a mode can meaningfully widen beyond the current query result set. +It is rendered as quiet, labelled radio cards with truthful counts and descriptions, not as a +generic segmented toggle. A non-default scope is an active refinement. + +Current scoped modes are Services, Differential stream workspaces, Specifiers and Medication. +Documents has retrieval scope in the same panel but keeps **Browse all sources** as a separate, +query-preserving reach action rather than presenting corpus browsing as a refinement. + +An explicit scope is respected even when it yields zero results. Specifiers may choose its +default from available guide matches only when the URL does not specify a scope. + +## Counts and dead ends + +Options and counts must come from the same predicate as the visible list. A projected facet count +answers “how many would be visible if I selected this too?”; adding another value to a facet group +therefore widens that group while the other groups remain applied. + +Derive stable options from catalogue data. If another active refinement makes an option a dead +end, retain it as a focusable, dashed, muted control with `aria-disabled` and an explanation. +Selected values always remain removable, including at zero results. Do not hide a selected value +or disable it natively. + +The footer summary counts only the rows governed by the panel. Documents additionally reports +`visible of retrieved matches`; it never compares local refinements with the whole corpus. +Faulted or untrusted catalogue reads must not assert a trustworthy count. + +## Applied state and actions + +Every hidden or multi-dimensional refinement is represented in the labelled `Filtered by` shelf. +`AppliedFilterChip` separates: + +- `valueLabel`, compact enough for phones; +- `groupLabel`, restored on larger screens (for example `Risk: High`); +- `accessibleLabel`, the complete assistive label when the visible forms are not sufficient. + +The shelf remains visible during loading and zero-result recovery. The actions are deliberately +distinct: + +- **Remove** clears one value. +- **Reset filters** clears filter-owned state only. +- **Clear search** clears the query. +- **Browse all** widens reach while preserving the query. + +Reset never clears `q`, comparison `ids`, group navigation or focus intent unless a mode-specific +contract explicitly owns that parameter. + +## Live and staged application + +Local catalogues apply filters live. Their footer action closes the panel while announcing the +current result count. + +Documents uses `applicationMode="staged"`: + +1. Opening the panel snapshots committed retrieval and local facet state into a draft. +2. Dismissing the sheet discards the draft. +3. **Update search** commits all draft changes together. +4. Retrieval/source changes schedule at most one retrieval with the committed scope. +5. Result type and local smart-tag-only changes commit without refetching. + +Internal import batch IDs and mode-default label-type constraints are preserved through the merge +but are not clinician-facing options. + +## URL contract + +Stable catalogue filters use `src/lib/result-filter-url.ts`: + +- multi-value parameters are comma-separated, deduplicated and sorted; +- defaults and empty selections are omitted; +- invalid values are ignored; +- unrelated parameters such as `q`, `ids`, `focus` and document `scope.*` state are preserved; +- repeated filter changes use replacement navigation so Back history is not polluted; +- Back/Forward hydration reads from the URL rather than from a second local copy. + +Calculator progress and private/session-derived favourites state remain local. A mode must not +persist patient-profile or private clinical state in catalogue filter URLs. + +## Mode matrix + +| Mode | Filter contract | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Documents | Staged selected-source and retrieval governance/locality controls, advanced clinical labels, result type and smart-tag facets; `resultType`/`facet` plus existing `scope.*`; query-preserving Browse all sources. | +| Services | Search in Current results / All services; Program type, catchment, age group, setting, acuity and housing; existing URL keys; no query-replacing suggestion rail. | +| Forms | Category, clinical risk and availability facets; `category`, `risk`, `availability`. Exact code matching, grouping and access behavior stay outside the filter predicate. | +| Differentials | Combined All / Presentations / Diagnoses lens; stream scope, urgency, presentation/chapter and related-only refinements; grouping is a view control. | +| DSM Diagnosis | Category facets plus independent Has specifiers / Has differential guidance; comma-separated legacy-compatible `category` and `support`; comparison IDs preserved. | +| Specifiers | Clinical guides / Full catalogue scope; guide Family/Diagnosis lenses; catalogue Category and Source-verified facets; filter before the progressive display limit. | +| Formulation | One Domain OR facet organised into four visual sections; `domain`; pattern suggestions remain outside filters. | +| Medication | Best matches / All medications scope, Match quality lens, Drug class facet and one Safety/Monitoring OR signal facet; patient safety presentation and ranking remain independent. | +| Calculators / Therapy | Existing predicates with typed summaries and the shared adaptive panel. | +| Factsheets / Tools / Applications | Compact category lenses driven by one counted option source. | +| Favourites | Local Set and Type facets plus independent Pinned and Source-backed refinements; Recently used is a view/sort choice. | +| Answer | No narrowing dimension, therefore no filter control. | + +## Verification obligations + +Predicate tests cover OR/AND composition and projected counts. URL tests cover invalid values, +stable serialization, query preservation, reset and history hydration. Shared DOM tests cover +scope radios, visual sections, live/staged footer behavior, draft dismissal, focus restoration, +arrow navigation, dead ends, dense search and applied-chip labels. + +Browser evidence covers phone, tablet and desktop opening, selection, removal, reset, zero-result +recovery, keyboard-only use, reduced motion and forced colours. Chromium emulation does not close +physical Safari or installed-PWA acceptance. diff --git a/docs/search-results-bar-decisions.md b/docs/search-results-bar-decisions.md index 3ba51e9dcf..e04386b4bd 100644 --- a/docs/search-results-bar-decisions.md +++ b/docs/search-results-bar-decisions.md @@ -60,28 +60,24 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md). one control sitting flush against the sort group rendered as a different component. It now uses the band's own control recipe — the same string `Save search` and `Retry` use — with the active/resting colours as mutually exclusive branches. -- **Every mode's phone filter is now the badged trigger, not a select.** The `w-full` - native select is gone from all seven surfaces that shipped one — differentials, services, - factsheets, prescribing, the tools launcher, and formulation and specifiers, which each - passed _two_ in a two-column grid. Each now passes one `ResultFilterTrigger` - (`result-filter-control.tsx`) with `mobileControlsPlacement="inline"`, so the one-line - band is universal rather than a documents/therapy-compass exception. What the select cost: - a whole second band row; no way to say how many filters were active without spending - label width on it; and, because the iOS anti-zoom rule pins every native select to 16 px - below `sm`, a value rendered at the same size as the query heading above it. Single-choice - dimensions moved into `ResultFilterSheet`, one `role="radiogroup"` per dimension — real - radio semantics, because these are one-of-N and an `aria-pressed` bank asserts otherwise. - Documents keeps its own panel; multi-select facet groups with counts, a find-a-filter - field and collapse-by-default are not radios. +- **Every mode uses one adaptive filter system, not a phone-only substitute.** Each result + surface passes `ResultFilterTrigger` at phone and desktop breakpoints. The trigger opens + `ResultFilterSheet`, which is a bottom sheet on phones and a restrained right drawer from + `sm` up. The old native selects, bespoke desktop dropdowns and competing rails are gone. + Lenses render as roving radio groups; facets render as individually reachable multi-select + rows; `Search in` is a labelled, count-bearing scope selector. Dense catalogues gain search + and collapsible groups from the shared component rather than from route-local panels. + Documents now uses the same panel with staged application and a typed secondary Browse all + sources action. The filter contract, including mode ownership, is in + [filter-contract.md](filter-contract.md). - **`mobileControlsPlacement` still defaults to `row`.** Nothing relies on that fallback now that every caller passes `inline`, and it stays anyway: a new mode that forgets the prop, or one with a genuine reason to hand over something full-width, should degrade to a second row rather than to an unreadable 58 px line at 320 px. Do not flip the default. -- **Filter at the right edge, Sort inboard.** Sort is set about once a session. Filter is - the only control carrying state and the one returned to repeatedly, and on a phone the - right edge is where the thumb already is. The page filter is therefore the utility - rail's **last** child; `tests/ui-tools.spec.ts` asserts that placement and a matched - phone tap height, rather than the Sort/Filter adjacency it asserted before. +- **Filter is pinned at the right edge; Sort stays inboard.** Sort is set about once a + session. Filter carries narrowing state and is returned to repeatedly, so it is a pinned + sibling after the optional utility rail rather than a child that can scroll away. + `tests/ui-tools.spec.ts` asserts the placement and matched phone tap height. - **The shelf.** A labelled `Filtered by` row under the bar, one tap to remove each filter, trailing `Clear` once more than one is applied. The chips scroll in an inner track; the label and `Clear` are pinned outside it, because the shelf was one @@ -98,31 +94,23 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md). on every keystroke. Only a fault removes it, because filtering a result set that never loaded is meaningless. -## The shelf is scoped to two modes, on purpose +## The shelf is shared state, not a mode exception -`documents` and `therapy-compass` only. Both have multi-valued filters hidden behind a -panel, so what is applied is not otherwise visible. Of the other ten results-band modes: +Any mode with hidden, multi-dimensional or non-default scope state passes `appliedFilters` +and `onClearFilters`. The shelf is prop-driven; the shared bar never infers refinement state +from context. `AppliedFilterChip` carries a compact `valueLabel`, an optional desktop +`groupLabel`, a complete `accessibleLabel` when necessary, and `onRemove`. -- Differentials, prescribing, specifiers, formulation, services, and factsheets keep a - single-choice dimension whose desktop control is already on screen, and whose phone - trigger carries a count badge, so a shelf would restate what is visible either way. -- Forms still ships a Filter trigger whose panel is a coming-soon placeholder, not applied - filter state, so there is nothing for a shelf to show. -- Favourites renders its own active-filter chips inside `filterControls` rather than the - shared shelf props. -- DSM filters by category through navigation links, and tools through a single category - dimension — neither passes `appliedFilters`. +The shelf is required for Documents, Services, Forms, Differential workspaces, DSM, +Specifiers, Formulation, Medication, Therapy, Calculators when narrowed, and Favourites. A +compact category lens may omit it only when the active value remains directly visible at that +breakpoint. Selected retrieval and result facets remain visible at zero results so recovery is +always possible. -Two traps met while drawing that line: - -- **Count what a control does, not how many there are.** Formulation's "Pattern" and - factsheets' "Category" look like filters and are navigation (`router.push`). Services' - "quick filter" rewrites the query. None of them belong behind a filter surface. -- **A shared component must not read filter state from context.** The original shelf - pulled `commandScopes` from a context that no page populated. It passed a DOM test which - constructed that context by hand, and rendered for nobody in production. The replacement - is prop-driven — the page supplies `appliedFilters` (`id`, `label`, `onRemove`) and an - optional `onClearFilters`. Keep it that way. +Count what narrows results, not how many controls happen to be visible. Scope is counted when +non-default. Sort, display density, grouping, comparison state, suggested searches and +Recently used views are excluded. Query-replacing presets belong near the composer, never in +the shelf or filter badge. ## Deliberately not done diff --git a/src/app/(search-app)/dsm/search/page.tsx b/src/app/(search-app)/dsm/search/page.tsx index 72c6873d37..e19b6912fa 100644 --- a/src/app/(search-app)/dsm/search/page.tsx +++ b/src/app/(search-app)/dsm/search/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { DsmSearchPage } from "@/components/dsm/dsm-search-page"; -import { dsmCategories, dsmDiagnoses, listDsmDiagnosisSummaries } from "@/lib/dsm"; +import { dsmCategories, listDsmDiagnosisSummaries } from "@/lib/dsm"; export const metadata: Metadata = { title: "Search DSM diagnoses | Clinical KB", @@ -13,7 +13,6 @@ type DsmSearchRouteProps = { searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; - category?: string | string[]; ids?: string | string[]; }>; }; @@ -25,8 +24,6 @@ function firstValue(value?: string | string[]) { export default async function DsmSearchRoute({ searchParams }: DsmSearchRouteProps) { const params = searchParams ? await searchParams : {}; const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); - const requestedCategory = firstValue(params.category)?.trim(); - const category = dsmCategories.some((item) => item.key === requestedCategory) ? requestedCategory : undefined; const rawIds = firstValue(params.ids) ?? ""; const initialIds = rawIds ? rawIds @@ -38,10 +35,8 @@ export default async function DsmSearchRoute({ searchParams }: DsmSearchRoutePro return ( ); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index cd423461cc..aee3c8d8f8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2197,7 +2197,7 @@ export function ClinicalDashboard({ if (searchMode === "documents" && trimmedQuery) { rememberRecentQuery(trimmedQuery); autoRunSearchSignatureRef.current = searchSubmissionSignature(searchMode, trimmedQuery, navigationContext); - window.history.pushState( + window.history[replaceExistingAnswer ? "replaceState" : "pushState"]( null, "", documentsSearchHref({ @@ -3228,6 +3228,18 @@ export function ClinicalDashboard({ const handleCrossModeSearch = useEventCallback(crossModeSearch); const handleDocumentTagSearch = useEventCallback(handleTagSearch); const handleScopeFiltersChange = useScopeFilterRelax(query, queryMode, setScopeFilters, ask); + const handleDocumentFiltersApply = useEventCallback((filters: SearchScopeFilters, documentIds: string[]) => { + setScopeFilters(filters); + setSelectedDocumentIds(documentIds); + const trimmedQuery = query.trim(); + if (!trimmedQuery) return; + // Let the selected-source state commit before `ask` builds the private + // scope reference. Calling the live ref prevents a stale render closure + // from submitting the previous source selection. + window.requestAnimationFrame(() => { + void askRef.current(trimmedQuery, { queryMode, scopeFilters: filters }, true); + }); + }); const handleOpenRecentDocuments = useEventCallback(openRecentDocuments); const handleOpenSourceLibrary = useEventCallback(openSourceLibrary); const handleDocumentsDrawerOpenChange = useEventCallback((nextOpen: boolean) => { @@ -3769,6 +3781,8 @@ export function ClinicalDashboard({ onTagSearch={handleDocumentTagSearch} scopeFilters={searchMode === "documents" ? scopeFilters : null} onScopeFiltersChange={searchMode === "documents" ? handleScopeFiltersChange : undefined} + selectedDocumentIds={searchMode === "documents" ? selectedDocumentIds : []} + onDocumentFiltersApply={searchMode === "documents" ? handleDocumentFiltersApply : undefined} showHome={searchMode === "documents" && !modeSearchSubmitted} desktopComposerSlotId={desktopHomeComposerSlotId} /> diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index f00c828988..0bbb3d589a 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -54,6 +54,12 @@ type LauncherFilter = "all" | LauncherArea | "more"; type LauncherApp = ToolCatalogRecord & { icon: LucideIcon }; +function launcherAppMatchesFilter(app: LauncherApp, filter: LauncherFilter): boolean { + if (filter === "all") return true; + if (filter === "more") return app.area === "coordination" || app.area === "saved"; + return app.area === filter; +} + const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -381,10 +387,12 @@ function FilterTabs({ activeFilter, onFilterChange, canAccessFavourites, + filterCounts, }: { activeFilter: LauncherFilter; onFilterChange: (filter: LauncherFilter) => void; canAccessFavourites: boolean; + filterCounts: Readonly>; }) { const desktopFilters = desktopFiltersForSession(canAccessFavourites); const filterPanelId = useId(); @@ -394,7 +402,11 @@ function FilterTabs({ // One array for the desktop rail and the phone sheet. The categories partition // the tool list, so this is a lens at both breakpoints — the rail used to say // many-of-N with `aria-pressed` while the sheet said one-of-N. - const launcherFilterOptions = desktopFilters.map((filter) => ({ value: filter.id, label: filter.label })); + const launcherFilterOptions = desktopFilters.map((filter) => ({ + value: filter.id, + label: filter.label, + hint: String(filterCounts[filter.id] ?? 0), + })); return ( <>
@@ -445,6 +457,10 @@ function FilterTabs({ onFilterChange("all"); } } + summary={{ + count: filterCounts[resolvedFilter] ?? 0, + noun: (filterCounts[resolvedFilter] ?? 0) === 1 ? "tool" : "tools", + }} />
@@ -756,16 +772,29 @@ export function ApplicationsLauncherWorkspace({ const selectedId = detailOpen || selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const effectiveFilter: LauncherFilter = activeFilter === "saved" && !canAccessFavourites ? "all" : activeFilter; + const queryMatchedApps = useMemo( + () => + launcherApps.filter( + (app) => + !normalizedQuery || + [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ), + [launcherApps, normalizedQuery], + ); + const filterCounts = Object.fromEntries( + desktopFilters.map((filter) => [ + filter.id, + queryMatchedApps.filter((app) => launcherAppMatchesFilter(app, filter.id)).length, + ]), + ); + const filteredApps = useMemo(() => { return launcherApps.filter((app) => { - const matchesFilter = - effectiveFilter === "all" - ? true - : effectiveFilter === "more" - ? app.area === "coordination" || app.area === "saved" - : effectiveFilter === "saved" - ? app.area === "saved" - : app.area === effectiveFilter; + const matchesFilter = launcherAppMatchesFilter(app, effectiveFilter); const matchesQuery = !normalizedQuery || [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] @@ -868,6 +897,7 @@ export function ApplicationsLauncherWorkspace({ activeFilter={effectiveFilter} onFilterChange={setActiveFilter} canAccessFavourites={canAccessFavourites} + filterCounts={filterCounts} /> } /> @@ -881,6 +911,7 @@ export function ApplicationsLauncherWorkspace({ activeFilter={effectiveFilter} onFilterChange={setActiveFilter} canAccessFavourites={canAccessFavourites} + filterCounts={filterCounts} />

Sorted A to Z diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 6599ba091c..94f5877f86 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -374,7 +374,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st .filter((domain) => selectedDomains.has(domain)) .map((domain) => ({ id: `domain-${domain}`, - label: `Clinical domain: ${domainLabels[domain]}`, + groupLabel: "Clinical domain", + valueLabel: domainLabels[domain], onRemove: () => toggleDomain(domain), })), ...(progress === "all" @@ -382,7 +383,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st : [ { id: `progress-${progress}`, - label: `Session progress: ${optionLabel(progressOptions, progress)}`, + groupLabel: "Session progress", + valueLabel: optionLabel(progressOptions, progress), onRemove: () => setProgress("all"), }, ]), @@ -391,7 +393,8 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st : [ { id: `time-${time}`, - label: `Completion time: ${optionLabel(timeOptions, time)}`, + groupLabel: "Completion time", + valueLabel: optionLabel(timeOptions, time), onRemove: () => setTime("all"), }, ]), @@ -446,7 +449,7 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st description="Choose any clinical domains, then narrow by session progress and completion time." groups={[domainGroup, progressGroup, timeGroup]} onClearAll={activeFilterCount > 0 ? clearFilters : undefined} - footerNote={`${results.length} showing`} + summary={{ count: results.length, noun: results.length === 1 ? "calculator" : "calculators" }} chromeResetKey={query} /> diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index cd6bea2a9c..b10cd3ffe6 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -23,7 +23,10 @@ import { } from "lucide-react"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, @@ -34,7 +37,6 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog"; import { useResultSort } from "@/components/use-result-sort"; import { Chip as DesignChip } from "@/components/ui/chip"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { @@ -831,6 +833,17 @@ function SearchResultsView({ () => sortResultItems(relevanceResults, sortValue, (result) => result.title), [relevanceResults, sortValue], ); + const appliedFilters: AppliedFilterChip[] = + kindFilter === "all" + ? [] + : [ + { + id: "result-type", + groupLabel: "Show", + valueLabel: kindFilter === "presentation" ? "Presentations" : "Diagnoses", + onRemove: () => setKindFilter("all"), + }, + ]; // Keep the feature card inside the active result type while preserving the // relevance winner when the visible list is presented alphabetically. const best = relevanceResults[0] ?? null; @@ -949,6 +962,8 @@ function SearchResultsView({ } sortValue={sortValue} onSortChange={setSortValue} + appliedFilters={appliedFilters} + onClearFilters={kindFilter === "all" ? undefined : () => setKindFilter("all")} filterLabel="Filter differential result type" // A compact badged trigger, so it shares the count line. mobileControlsPlacement="inline" @@ -963,11 +978,13 @@ function SearchResultsView({ /> } filterControls={ - setFilterOpen((current) => !current)} /> } /> @@ -989,7 +1006,10 @@ function SearchResultsView({ }), ]} onClearAll={kindFilter === "all" ? undefined : () => setKindFilter("all")} - footerNote={`${visibleResults.length} showing`} + summary={{ + count: visibleResults.length, + noun: visibleResults.length === 1 ? "result" : "results", + }} /> {catalogLoading ? (

diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 2d869298f1..944b5e6877 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -12,6 +12,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { BookOpen, Clock3, @@ -35,6 +36,7 @@ import { ResultFilterTrigger, resultFilterFacetGroup, resultFilterGroup, + type ResultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; @@ -73,8 +75,34 @@ import { } from "@/lib/document-tags"; import type { ServiceSearchMatch } from "@/lib/services"; import type { FormSearchMatch } from "@/lib/forms"; -import type { SearchScopeFilters } from "@/lib/search-scope"; +import { + extractionQualityValues, + sourceStatusValues, + validationStatusValues, + type SearchScopeFilters, +} from "@/lib/search-scope"; import { removeScopeFilterValue, scopeFilterChips } from "@/lib/search-scope-filter-chips"; +import { + documentLabelFilterFields, + documentRetrievalFilterValueCount, + deriveDocumentLabelOptions, + extractionQualityLabels, + filterDocumentsByRetrievalScope, + mergePublicDocumentScopeFilters, + projectedDocumentScopeCount, + publicDocumentScopeFilters, + sameDocumentScope, + sourceStatusLabels, + validationStatusLabels, + type DocumentLabelFilterKey, +} from "@/lib/document-filter-model"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; import type { ClinicalDocument, DocumentMatch, SearchScopeSummary } from "@/lib/types"; import type { RegistryRequestStatus } from "@/lib/use-registry-records"; import { sortResultItems } from "@/lib/result-sort"; @@ -82,6 +110,14 @@ import { documentRelevancePercent } from "./relevance-score"; type SearchFacet = { value: string; count: number }; type ResultTypeFilter = "all" | "tables" | "images" | "pdfs"; +const resultTypeFilterValues = new Set(["all", "tables", "images", "pdfs"]); +type DocumentFilterDraft = { + query: string; + facetKeys: string[]; + resultType: ResultTypeFilter; + scopeFilters: SearchScopeFilters; + selectedDocumentIds: string[]; +}; /** Initial DOM budget for document result cards; further rows reveal on demand. */ const DOCUMENT_RESULTS_INITIAL_WINDOW = 25; @@ -108,8 +144,6 @@ export type SearchFacets = { type SearchRecordMode = "services" | "forms"; type SearchRecordMatch = ServiceSearchMatch | FormSearchMatch; -const EMPTY_APPLIED_FILTERS: AppliedFilterChip[] = []; - const searchRecordConfig: Record< SearchRecordMode, { @@ -142,9 +176,8 @@ const searchRecordConfig: Record< // The filter sheet itself (source type as a lens, smart-tag facets as facet groups) is // built in DocumentSearchResultsPanelImpl below and rendered through the shared // ResultFilterSheet/ResultFilterTrigger (src/components/clinical-dashboard/result-filter-control.tsx), -// which grew documents' own dense tier (find-a-filter, collapse-by-default) and a -// meterContent/footerOverride slot for its progress meter and "Show N documents" / -// "Browse all sources" footer during the filter-contract rollout. Source type is +// which grew documents' own dense tier (find-a-filter, collapse-by-default) and +// typed coverage, result-action and secondary-action anatomy. Result type is // single-select and the tag facets are multi-select (OR within a group, AND across // groups, per filterDocumentsBySmartTagFacetIndex), so the two still carry different // affordances: role="radio"+aria-checked for source type, aria-pressed for facets — @@ -758,8 +791,11 @@ function DocumentSearchResultsPanelImpl({ onOpenLibrary, onOpenSourcePdf, onTagSearch, + recentDocuments = [], + selectedDocumentIds = [], scopeFilters, onScopeFiltersChange, + onDocumentFiltersApply, showHome = false, desktopComposerSlotId, }: { @@ -784,6 +820,7 @@ function DocumentSearchResultsPanelImpl({ onOpenLibrary: () => void; onOpenSourcePdf: () => void; onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void; + selectedDocumentIds?: string[]; /** * The scope filters the current results were requested with. Paired with * `searchScope.activeFilterCount` (the server's count of what it actually @@ -797,14 +834,15 @@ function DocumentSearchResultsPanelImpl({ * retrieval, so no client-side control can undo them. Omit to hide that route. */ onScopeFiltersChange?: (filters: SearchScopeFilters) => void; + /** Commits the staged retrieval scope and performs the one resulting search. */ + onDocumentFiltersApply?: (filters: SearchScopeFilters, selectedDocumentIds: string[]) => void; showHome?: boolean; desktopComposerSlotId?: string; }) { void _facets; const [sortValue, setSortValue] = useResultSort(); + const searchParams = useSearchParams(); const trimmedQuery = query.trim(); - const [activeFacetState, setActiveFacetState] = useState<{ query: string; keys: string[] }>({ query: "", keys: [] }); - const [activeResultType, setActiveResultType] = useState("all"); const filterPanelId = useId(); // Query-scope the open flag the same way facets are scoped: a new search must // not leave the panel covering a different result set (especially on phones). @@ -814,11 +852,24 @@ function DocumentSearchResultsPanelImpl({ open: false, }); const filterPanelOpen = filterPanelState.query === query && filterPanelState.open; + const tagFacetIndex = useMemo(() => buildSmartDocumentTagFacetIndex(matches, { query }), [matches, query]); + const availableFacetKeys = useMemo( + () => new Set(tagFacetIndex.groups.flatMap((group) => group.facets.map((facet) => facet.key))), + [tagFacetIndex], + ); const activeFacetKeys = useMemo( - () => (activeFacetState.query === query ? activeFacetState.keys : []), - [activeFacetState, query], + () => readResultFilterValues(searchParams, "facet", availableFacetKeys), + [availableFacetKeys, searchParams], ); - const tagFacetIndex = useMemo(() => buildSmartDocumentTagFacetIndex(matches, { query }), [matches, query]); + const activeResultType = readResultFilterValue(searchParams, "resultType", resultTypeFilterValues, "all"); + const committedScopeFilters = scopeFilters ?? {}; + const [filterDraft, setFilterDraft] = useState({ + query: "", + facetKeys: [], + resultType: "all", + scopeFilters: {}, + selectedDocumentIds: [], + }); // Counts must describe the set the reader is looking at. `tagFacetIndex.groups` // counts against the whole match set, so once a facet is selected the rest of // the panel reports numbers for a set that no longer exists — several of them @@ -867,17 +918,21 @@ function DocumentSearchResultsPanelImpl({ // rather than suppressing the dependency check. const toggleTagFacet = useCallback( (key: string) => { - setActiveFacetState((current) => { - const keys = current.query === query ? current.keys : []; - return { - query, - keys: keys.includes(key) ? keys.filter((existing) => existing !== key) : [...keys, key], - }; + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "facet", availableFacetKeys)); + if (!next.delete(key)) next.add(key); + writeResultFilterValues(params, "facet", next, availableFacetKeys); }); }, - [query], + [availableFacetKeys], ); + const setResultType = useCallback((value: ResultTypeFilter) => { + replaceResultFilterUrl((params) => + writeResultFilterValue(params, "resultType", value, "all", resultTypeFilterValues), + ); + }, []); + const unavailable = deriveDocumentSearchUnavailable({ apiUnavailable, authUnavailable, @@ -893,12 +948,19 @@ function DocumentSearchResultsPanelImpl({ const recordBandOwnsFault = showRecordMatches && (recordStatus === "error" || recordStatus === "not_found" || recordStatus === "unauthorized"); const showResultsControls = matches.length > 0 && !loading; - const activeFilterCount = activeFacetKeys.length + (effectiveResultType === "all" ? 0 : 1); + const activeFilterCount = + activeFacetKeys.length + + (effectiveResultType === "all" ? 0 : 1) + + documentRetrievalFilterValueCount(committedScopeFilters, selectedDocumentIds.length); // Both the source-type tabs and the tag facets are derived from the current // match set, so a query that yields one uniform kind of document has nothing // to offer. Advertising Filter there would open an empty panel. const hasFilters = resultTabs.length > 1 || tagFacetGroups.length > 0; - const showFilterControl = showResultsControls && hasFilters; + const hasRetrievalFilters = + recentDocuments.length > 0 || + documentRetrievalFilterValueCount(committedScopeFilters, selectedDocumentIds.length) > 0; + const showFilterControl = + !showRecordMatches && !loading && Boolean(trimmedQuery) && (hasFilters || hasRetrievalFilters); /* The registry is still answering. `loading` covers only the document search, so on the services and forms paths the zero-result body used to render "No matches for …" directly beneath a spine reading "Searching…" — the band @@ -927,6 +989,20 @@ function DocumentSearchResultsPanelImpl({ ) : null} ); + const openOrCloseFilters = () => { + if (filterPanelOpen) { + setFilterPanelState({ query, open: false }); + return; + } + setFilterDraft({ + query, + facetKeys: activeFacetKeys, + resultType: effectiveResultType, + scopeFilters: publicDocumentScopeFilters(committedScopeFilters), + selectedDocumentIds, + }); + setFilterPanelState({ query, open: true }); + }; const renderFilterTrigger = (testId: string) => showFilterControl ? ( - setFilterPanelState((current) => ({ - query, - open: current.query === query ? !current.open : true, - })) - } + onToggle={openOrCloseFilters} /> ) : null; - // The shelf's contents. Facet chips carry their group's own label so a bare - // "Policy" is not ambiguous across ten groups, and the source-type chip joins - // them because it narrows the same list by the same act. - const appliedFilters = useMemo(() => { - const selected = new Set(activeFacetKeys); - const chips = tagFacetGroups.flatMap((group) => + + const activeDraft: DocumentFilterDraft = + filterPanelOpen && filterDraft.query === query + ? filterDraft + : { + query, + facetKeys: activeFacetKeys, + resultType: effectiveResultType, + scopeFilters: publicDocumentScopeFilters(committedScopeFilters), + selectedDocumentIds, + }; + const draftSelectedDocumentIds = new Set(activeDraft.selectedDocumentIds); + const draftTagFacetGroups = projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys); + const draftVisibleMatches = filterDocumentsBySmartTagFacetIndex(tagFacetIndex, activeDraft.facetKeys); + const draftResultTabs = resultTypeTabs(draftVisibleMatches); + const draftResultType = draftResultTabs.some((tab) => tab.key === activeDraft.resultType) + ? activeDraft.resultType + : "all"; + const draftDisplayedMatches = filterMatchesByResultType(draftVisibleMatches, draftResultType); + + function toggleDraftListFilter( + key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities", + value: string, + ) { + setFilterDraft((current) => { + const selected = new Set((current.scopeFilters[key] as string[] | undefined) ?? []); + if (!selected.delete(value)) selected.add(value); + return { ...current, scopeFilters: { ...current.scopeFilters, [key]: [...selected] } }; + }); + } + + function commitRetrievalFilters(filters: SearchScopeFilters, documentIds: string[]) { + if (onDocumentFiltersApply) onDocumentFiltersApply(filters, documentIds); + else if (onScopeFiltersChange) onScopeFiltersChange(filters); + } + + function applyDocumentFilters() { + replaceResultFilterUrl((params) => { + writeResultFilterValues(params, "facet", activeDraft.facetKeys, availableFacetKeys); + writeResultFilterValue(params, "resultType", draftResultType, "all", resultTypeFilterValues); + }); + const nextScope = mergePublicDocumentScopeFilters(committedScopeFilters, activeDraft.scopeFilters); + const selectedChanged = + [...activeDraft.selectedDocumentIds].sort().join("\0") !== [...selectedDocumentIds].sort().join("\0"); + if (!sameDocumentScope(nextScope, committedScopeFilters) || selectedChanged) { + commitRetrievalFilters(nextScope, activeDraft.selectedDocumentIds); + } + setFilterPanelState({ query, open: false }); + } + + function clearAllFilters() { + replaceResultFilterUrl((params) => { + params.delete("facet"); + params.delete("resultType"); + }); + const nextScope = mergePublicDocumentScopeFilters(committedScopeFilters, {}); + if (!sameDocumentScope(nextScope, committedScopeFilters) || selectedDocumentIds.length > 0) { + commitRetrievalFilters(nextScope, []); + } + } + + const selectedFacetKeys = new Set(activeFacetKeys); + const appliedFilters: AppliedFilterChip[] = [ + ...selectedDocumentIds.map((documentId) => { + const document = recentDocuments.find((item) => item.id === documentId); + return { + id: `source-${documentId}`, + groupLabel: "Source", + valueLabel: document ? documentDisplayTitle(document) : "Selected source", + onRemove: () => + commitRetrievalFilters( + committedScopeFilters, + selectedDocumentIds.filter((id) => id !== documentId), + ), + }; + }), + ...scopeFilterChips(publicDocumentScopeFilters(committedScopeFilters)).map((chip) => ({ + ...chip, + onRemove: () => + commitRetrievalFilters( + mergePublicDocumentScopeFilters( + committedScopeFilters, + removeScopeFilterValue(publicDocumentScopeFilters(committedScopeFilters), chip.id), + ), + selectedDocumentIds, + ), + })), + ...tagFacetGroups.flatMap((group) => group.facets - .filter((facet) => selected.has(facet.key)) + .filter((facet) => selectedFacetKeys.has(facet.key)) .map((facet) => ({ id: facet.key, - label: facet.label, + groupLabel: group.group, + valueLabel: facet.label, onRemove: () => toggleTagFacet(facet.key), })), - ); - if (effectiveResultType !== "all") { - const tab = resultTabs.find((entry) => entry.key === effectiveResultType); - if (tab) { - chips.push({ - id: `result-type-${tab.key}`, - label: tab.label, - onRemove: () => setActiveResultType("all"), - }); - } + ), + ]; + if (effectiveResultType !== "all") { + const tab = resultTabs.find((entry) => entry.key === effectiveResultType); + if (tab) { + appliedFilters.push({ + id: `result-type-${tab.key}`, + groupLabel: "Result type", + valueLabel: tab.label, + onRemove: () => setResultType("all"), + }); } - return chips; - }, [tagFacetGroups, activeFacetKeys, effectiveResultType, resultTabs, toggleTagFacet]); - const clearAllFilters = () => { - setActiveFacetState({ query, keys: [] }); - setActiveResultType("all"); - }; - /* The scope filters the API applied BEFORE retrieval, as removable chips. - Only used on the zero-result path: while matches exist the facet chips above - describe what is narrowing the visible list, and stacking both would show a - reader two filter shelves doing different jobs. At zero there is no match - set to derive facets from, so without these the constraint that emptied the - search is invisible — and unclearable, since `showResultsControls` gates the - Filter trigger on `matches.length > 0`. */ + } + + const documentFilterGroups: ResultFilterGroup[] = []; + if (recentDocuments.length > 0) { + documentFilterGroups.push( + resultFilterFacetGroup({ + id: "selected-sources", + label: "Selected sources", + description: "Choose specific indexed sources. Leave empty to search across the filtered source set.", + selected: draftSelectedDocumentIds, + options: [...recentDocuments] + .sort((left, right) => documentDisplayTitle(left).localeCompare(documentDisplayTitle(right))) + .map((document) => { + const withCandidate = new Set(draftSelectedDocumentIds); + if (!withCandidate.has(document.id)) withCandidate.add(document.id); + const count = filterDocumentsByRetrievalScope( + recentDocuments, + activeDraft.scopeFilters, + withCandidate, + ).length; + return { + value: document.id, + label: documentDisplayTitle(document), + searchText: `${document.title} ${document.file_name}`, + hint: String(count), + disabled: count === 0 && !draftSelectedDocumentIds.has(document.id), + }; + }), + onToggle: (documentId) => + setFilterDraft((current) => { + const next = new Set(current.selectedDocumentIds); + if (!next.delete(documentId)) next.add(documentId); + return { ...current, selectedDocumentIds: [...next] }; + }), + }), + ); + } + + const governanceGroups = [ + { + key: "sourceStatuses" as const, + label: "Source status", + values: sourceStatusValues, + labels: sourceStatusLabels, + }, + { + key: "validationStatuses" as const, + label: "Clinical validation", + values: validationStatusValues, + labels: validationStatusLabels, + }, + { + key: "extractionQualities" as const, + label: "Extraction quality", + values: extractionQualityValues, + labels: extractionQualityLabels, + }, + ]; + for (const group of governanceGroups) { + const selected = new Set((activeDraft.scopeFilters[group.key] as string[] | undefined) ?? []); + documentFilterGroups.push( + resultFilterFacetGroup({ + id: group.key, + label: group.label, + description: "Source governance is applied before document retrieval.", + selected, + options: group.values.map((value) => { + const count = projectedDocumentScopeCount({ + documents: recentDocuments, + filters: activeDraft.scopeFilters, + selectedDocumentIds: draftSelectedDocumentIds, + key: group.key, + value, + }); + return { + value, + label: group.labels[value] ?? value, + hint: String(count), + disabled: count === 0 && !selected.has(value), + }; + }), + onToggle: (value) => toggleDraftListFilter(group.key, value), + }), + ); + } + + documentFilterGroups.push( + resultFilterGroup({ + id: "locality", + label: "Source locality", + description: "Separate WA and health-service sources from non-local guidance.", + value: activeDraft.scopeFilters.locality ?? "all", + options: [ + { + value: "all", + label: "Any locality", + hint: String( + filterDocumentsByRetrievalScope( + recentDocuments, + { ...activeDraft.scopeFilters, locality: undefined }, + draftSelectedDocumentIds, + ).length, + ), + }, + ...(["local", "non_local"] as const).map((value) => { + const count = filterDocumentsByRetrievalScope( + recentDocuments, + { ...activeDraft.scopeFilters, locality: value }, + draftSelectedDocumentIds, + ).length; + return { + value, + label: value === "local" ? "Local" : "Non-local", + hint: String(count), + disabled: count === 0 && activeDraft.scopeFilters.locality !== value, + }; + }), + ], + onChange: (value) => + setFilterDraft((current) => ({ + ...current, + scopeFilters: { + ...current.scopeFilters, + locality: value === "all" ? undefined : value, + }, + })), + }), + ); + + for (const field of documentLabelFilterFields) { + const selected = new Set(activeDraft.scopeFilters[field.key] ?? []); + const values = [...new Set([...deriveDocumentLabelOptions(recentDocuments, field.labelType), ...selected])]; + if (values.length === 0) continue; + documentFilterGroups.push( + resultFilterFacetGroup({ + id: field.key, + label: field.label, + description: "Advanced clinical label. Values are OR alternatives within this group.", + selected, + options: values.map((value) => { + const count = projectedDocumentScopeCount({ + documents: recentDocuments, + filters: activeDraft.scopeFilters, + selectedDocumentIds: draftSelectedDocumentIds, + key: field.key, + value, + }); + return { + value, + label: value, + hint: String(count), + disabled: count === 0 && !selected.has(value), + }; + }), + onToggle: (value) => toggleDraftListFilter(field.key, value), + }), + ); + } + + if (draftResultTabs.length > 1) { + documentFilterGroups.push( + resultFilterGroup({ + id: "result-type", + label: "Result type", + description: "Refine the retrieved matches without running retrieval again.", + note: "one only", + value: draftResultType, + options: draftResultTabs.map((tab) => ({ value: tab.key, label: tab.label, hint: String(tab.count) })), + onChange: (value) => setFilterDraft((current) => ({ ...current, resultType: value })), + }), + ); + } + for (const group of draftTagFacetGroups) { + const selected = new Set( + group.facets.filter((facet) => activeDraft.facetKeys.includes(facet.key)).map((facet) => facet.key), + ); + documentFilterGroups.push( + resultFilterFacetGroup({ + id: `smart-${group.group}`, + label: group.group, + description: "Smart tags refine the matches already retrieved.", + selected, + options: group.facets.map((facet) => ({ + value: facet.key, + label: facet.label, + hint: String(facet.count), + searchText: facet.searchText, + disabled: facet.count === 0 && !selected.has(facet.key), + })), + onToggle: (facetKey) => + setFilterDraft((current) => { + const next = new Set(current.facetKeys); + if (!next.delete(facetKey)) next.add(facetKey); + return { ...current, facetKeys: [...next] }; + }), + }), + ); + } + const draftActiveFilterCount = + activeDraft.facetKeys.length + + Number(draftResultType !== "all") + + documentRetrievalFilterValueCount(activeDraft.scopeFilters, activeDraft.selectedDocumentIds.length); + /* A retrieval layer errored, so no count from this search is trustworthy — including a non-zero one. The band owns that claim: it renders `matchCount` inside the only `role="status"` region on the page, and the zero-result @@ -991,15 +1336,50 @@ function DocumentSearchResultsPanelImpl({ above zero it is the only thing that says the list is a floor rather than the answer. (Raised by Devin review on PR #1640.) */ const retrievalDegraded = Boolean(searchScope?.retrieval?.degraded); - const activeScopeFilters = scopeFilters ?? null; - const scopeEmptiedResults = matches.length === 0 && (searchScope?.activeFilterCount ?? 0) > 0; - const scopeAppliedFilters = useMemo(() => { - if (!scopeEmptiedResults || !activeScopeFilters || !onScopeFiltersChange) return EMPTY_APPLIED_FILTERS; - return scopeFilterChips(activeScopeFilters).map((chip) => ({ - ...chip, - onRemove: () => onScopeFiltersChange(removeScopeFilterValue(activeScopeFilters, chip.id)), - })); - }, [scopeEmptiedResults, activeScopeFilters, onScopeFiltersChange]); + const documentFilterSheet = showFilterControl ? ( + setFilterPanelState({ query, open: false })} + panelId={filterPanelId} + testId="document-filter-panel" + title="Filter documents" + description="Set retrieval scope and refine the matches already returned. Changes run together." + chromeResetKey={query} + groups={documentFilterGroups} + applicationMode="staged" + primaryActionLabel="Update search" + onApply={applyDocumentFilters} + onClearAll={ + draftActiveFilterCount > 0 + ? () => + setFilterDraft((current) => ({ + ...current, + facetKeys: [], + resultType: "all", + scopeFilters: {}, + selectedDocumentIds: [], + })) + : undefined + } + summary={{ + count: draftDisplayedMatches.length, + noun: draftDisplayedMatches.length === 1 ? "match" : "matches", + }} + coverage={{ + visibleCount: draftDisplayedMatches.length, + totalCount: matches.length, + label: "Visible retrieved matches", + }} + secondaryAction={{ + label: "Browse all sources", + count: documentCount > 0 ? documentCount : undefined, + onClick: () => { + setFilterPanelState({ query, open: false }); + onOpenLibrary(); + }, + }} + /> + ) : null; const showIdentityHeader = recordMatchCount > 0 || matches.length > 0 || @@ -1053,7 +1433,7 @@ function DocumentSearchResultsPanelImpl({ // footer, and the zero-result state, which are the two moments // browsing is actually the next step. appliedFilters={appliedFilters} - onClearFilters={clearAllFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} filterLabel="Filter documents" // The same trigger goes in both slots: the ribbon shows `mobileControls` // below `sm` and `filterControls` from `sm` up, never both at once. @@ -1065,6 +1445,8 @@ function DocumentSearchResultsPanelImpl({ /> ) : null} + {documentFilterSheet} + {/* When the ribbon is shown it owns this message in its fault panel. This standalone alert remains for the routes that render no ribbon, so the message is never lost. */} @@ -1116,10 +1498,8 @@ function DocumentSearchResultsPanelImpl({ // shared state already carries for filtered-to-zero, which the // documents path could not reach because it only ever passed // client-derived facet chips (always empty at zero matches). - appliedFilters={scopeAppliedFilters} - onClearFilters={ - scopeAppliedFilters.length > 0 && onScopeFiltersChange ? () => onScopeFiltersChange({}) : undefined - } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} // A retrieval layer errored, so this zero is not evidence of absence. degraded={retrievalDegraded} onBrowseAll={onOpenLibrary} @@ -1138,166 +1518,7 @@ function DocumentSearchResultsPanelImpl({ ) ) : ( <> - {/* Opened by the ribbon's Filter trigger. Previously this was gated on - `activeFacetKeys.length > 0`, which nothing else could satisfy — - the only writers of that state lived inside the gated subtree — so - the facets were unreachable. The trigger is now the way in. - Mounted unconditionally: `Sheet` returns null while closed and owns - its own open/close transition, so gating the mount here would cut - the dismiss animation off mid-flight. */} - {showFilterControl ? ( - setFilterPanelState({ query, open: false })} - panelId={filterPanelId} - testId="document-filter-panel" - title="Filter documents" - chromeResetKey={query} - groups={[ - ...(resultTabs.length > 1 - ? [ - // Radio semantics, not toggles: picking one source type replaces the - // last, so `aria-pressed` on four buttons would describe a state the - // filter cannot be in. `note` states the shape once, because facet - // groups directly below render as near-identical chips. - resultFilterGroup({ - id: "source-type", - label: "Source type", - note: "one only", - value: effectiveResultType, - options: resultTabs.map((tab) => ({ - value: tab.key, - label: tab.label, - hint: String(tab.count), - })), - onChange: setActiveResultType, - }), - ] - : []), - ...tagFacetGroups.map((group) => { - const selected = new Set( - group.facets.filter((facet) => activeFacetKeys.includes(facet.key)).map((facet) => facet.key), - ); - return resultFilterFacetGroup({ - id: group.group, - label: group.group, - selected, - options: group.facets.map((facet) => ({ - value: facet.key, - label: facet.label, - hint: String(facet.count), - searchText: facet.searchText, - // Zero-count unselected facets stay visible so the list does not jump, - // but they are disabled: selecting them would empty the set. - disabled: facet.count === 0 && !selected.has(facet.key), - })), - onToggle: toggleTagFacet, - }); - }), - ]} - onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined} - meterContent={ - // The proportion, once, at the top. A meter rather than a second number: - // "12 of 2,014" is a ratio the reader is judging, not a figure they are - // reading off. It goes to `--warning` at zero so the state that needs - // explaining is the one that looks different. - <> - -

- - {sortedMatches.length} - {" "} - - of {documentCount > 0 ? documentCount.toLocaleString() : "—"} documents shown - -

- - } - footerOverride={ -
-
- {/* The count is the point of the panel: it tells the reader whether the - combination they have built still returns anything before they - dismiss it. `aria-live` is deliberate — the number changes under - them as they toggle, and the sheet covers the results it - describes. The bare repeat of the number beside the button is gone; - the button carries it, and the readout at the top carries the - proportion. */} - - {sortedMatches.length} document{sortedMatches.length === 1 ? "" : "s"} match the current filters - - -
- {/* Below a rule, and phrased as reach rather than refinement. Library - spent the utility rail competing with Filter for the same edge while - answering a different question — Filter narrows what this query - returned, Library opens the whole corpus. Here it is the actual next - step, and it keeps the in-context route that stopped it being - deleted: the documents action menu clears the query. */} - -
- } - /> - ) : null} - {showResultsControls && !hasFilters ? browseLibraryControl : null} + {showResultsControls && !showFilterControl ? browseLibraryControl : null} {/* With the panel closed the active filters are otherwise invisible apart from the trigger's badge, so the reader needs the count to explain why the list is shorter than the ribbon's total. */} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index bb7c10199c..a323765441 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -19,10 +19,9 @@ import { Search, ShieldCheck, Trash2, - X, type LucideIcon, } from "lucide-react"; -import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useId, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; @@ -40,6 +39,11 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; @@ -51,7 +55,7 @@ type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; // Previously imported from `favourites-library-nav`, which this redesign // retired along with the sidebar and the two phone rails it exported. -type ViewMode = "all" | "source-backed" | "pinned" | "recent"; +type ViewMode = "all" | "recent"; type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { @@ -230,14 +234,18 @@ function filterAndSortItems( items: FavouriteItem[], { searchTerm, - selectedTypeId, - selectedSet, + selectedTypeIds, + selectedSetTitles, + pinnedOnly, + sourceBackedOnly, viewMode, sortMode, }: { searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; + selectedTypeIds: ReadonlySet; + selectedSetTitles: ReadonlySet; + pinnedOnly: boolean; + sourceBackedOnly: boolean; viewMode: ViewMode; sortMode: SortMode; }, @@ -246,13 +254,10 @@ function filterAndSortItems( const effectiveSort: SortMode = viewMode === "recent" ? "last-used" : sortMode; return items - .filter((item) => selectedTypeId === "all" || item.tabId === selectedTypeId) - .filter((item) => !selectedSet || item.set === selectedSet.title) - .filter((item) => { - if (viewMode === "source-backed") return isSourceBacked(item); - if (viewMode === "pinned") return item.pinned === true; - return true; - }) + .filter((item) => selectedTypeIds.size === 0 || selectedTypeIds.has(item.tabId)) + .filter((item) => selectedSetTitles.size === 0 || selectedSetTitles.has(item.set)) + .filter((item) => !pinnedOnly || item.pinned === true) + .filter((item) => !sourceBackedOnly || isSourceBacked(item)) .filter((item) => normalizedSearch ? [item.title, item.description, item.type, item.set, item.evidence].some((field) => @@ -300,62 +305,6 @@ function SmallChip({ children, appearance }: { children: React.ReactNode; appear ); } -function ActiveFilterChips({ - searchTerm, - selectedTypeId, - selectedSet, - viewMode, - onClearSearch, - onClearType, - onClearSet, - onClearViewMode, - includeSearch = true, -}: { - searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; - viewMode: ViewMode; - onClearSearch: () => void; - onClearType: () => void; - onClearSet: () => void; - onClearViewMode: () => void; - includeSearch?: boolean; -}) { - const typeLabel = favouriteTabs.find((tab) => tab.id === selectedTypeId)?.label; - const chips: { key: string; label: string; onClear: () => void }[] = []; - - if (includeSearch && searchTerm.trim()) { - chips.push({ key: "search", label: `Search: ${searchTerm.trim()}`, onClear: onClearSearch }); - } - if (selectedSet) chips.push({ key: "set", label: selectedSet.title, onClear: onClearSet }); - if (selectedTypeId !== "all" && typeLabel) chips.push({ key: "type", label: typeLabel, onClear: onClearType }); - if (viewMode === "source-backed") chips.push({ key: "view", label: "Source-backed", onClear: onClearViewMode }); - if (viewMode === "pinned") chips.push({ key: "view", label: "Pinned", onClear: onClearViewMode }); - if (viewMode === "recent") chips.push({ key: "view", label: "Recently used", onClear: onClearViewMode }); - - if (chips.length === 0) return null; - - return ( -
- {chips.map((chip) => ( - - ))} -
- ); -} - function ContinueStrip({ item }: { item: FavouriteItem }) { const Icon = item.icon; return ( @@ -608,133 +557,6 @@ function FavouritesEmptyMatches() { ); } -/** - * One horizontal rail replacing three separate navigation surfaces: the - * desktop `FavouritesSidebar`, the phone `FavouritesMobileQuickViews`, and the - * phone `FavouritesMobileBrowseRail`. Direction B (ledger #164) collapses sets, - * quick views and types into a single row of chips, so the same control reads - * identically at every width instead of three components disagreeing about - * what "browse" means. - * - * Each chip toggles its own dimension, so a set and a type still compose the - * way the sidebar allowed. "All" is the only chip that clears every dimension, - * and it reads as pressed exactly when nothing else is. - */ -function FavouritesFilterRail({ - items, - sets, - selectedSetId, - selectedTypeId, - viewMode, - onSelectSet, - onSelectType, - onSelectViewMode, - onClearAll, -}: { - items: FavouriteItem[]; - sets: FavouriteSet[]; - selectedSetId: string | null; - selectedTypeId: string; - viewMode: ViewMode; - onSelectSet: (id: string | null) => void; - onSelectType: (id: string) => void; - onSelectViewMode: (mode: ViewMode) => void; - onClearAll: () => void; -}) { - const typeChips = favouriteTabs - .filter((tab) => tab.id !== "all" && tab.id !== "sets") - .map((tab) => ({ id: tab.id, label: tab.label, count: items.filter((item) => item.tabId === tab.id).length })) - .filter((tab) => tab.count > 0); - const pinnedCount = items.filter((item) => item.pinned === true).length; - const sourceBackedCount = items.filter((item) => isSourceBacked(item)).length; - const nothingActive = selectedSetId === null && selectedTypeId === "all" && viewMode === "all"; - - return ( - - ); -} - -function FilterRailChip({ - label, - count, - pressed, - onClick, -}: { - label: string; - count: number | null; - pressed: boolean; - onClick: () => void; -}) { - return ( - - ); -} - /** * The empty-query dashboard band. Both cards read from the same derived data * the table does — no separate store — so they cannot drift from it. @@ -1296,14 +1118,24 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: ? "ready" : favouritesHookStatus; const sets = useMemo(() => buildFavouriteSets(items), [items]); - const [selectedTypeId, setSelectedTypeId] = useState("all"); - const [selectedSetId, setSelectedSetId] = useState(null); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); + const [selectedTypeIds, setSelectedTypeIds] = useState>(() => new Set()); + const [selectedSetIds, setSelectedSetIds] = useState>(() => new Set()); + const [pinnedOnly, setPinnedOnly] = useState(false); + const [sourceBackedOnly, setSourceBackedOnly] = useState(false); const [viewMode, setViewMode] = useState("all"); const [sortMode, setSortMode] = useState("last-used"); const [selectedItemId, setSelectedItemId] = useState(null); - const effectiveSelectedSetId = selectedSetId && sets.some((set) => set.id === selectedSetId) ? selectedSetId : null; - const selectedSet = effectiveSelectedSetId ? (sets.find((set) => set.id === effectiveSelectedSetId) ?? null) : null; + const effectiveSelectedSetIds = useMemo( + () => new Set([...selectedSetIds].filter((id) => sets.some((set) => set.id === id))), + [selectedSetIds, sets], + ); + const selectedSetTitles = useMemo( + () => new Set(sets.filter((set) => effectiveSelectedSetIds.has(set.id)).map((set) => set.title)), + [effectiveSelectedSetIds, sets], + ); // Single source of truth for "what is in the list right now". The band's // match count, the Continue gate, the empty-state branch and the table all @@ -1312,12 +1144,14 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: () => filterAndSortItems(items, { searchTerm: activeQuery, - selectedTypeId, - selectedSet, + selectedTypeIds, + selectedSetTitles, + pinnedOnly, + sourceBackedOnly, viewMode, sortMode, }), - [items, activeQuery, selectedTypeId, selectedSet, viewMode, sortMode], + [items, activeQuery, selectedTypeIds, selectedSetTitles, pinnedOnly, sourceBackedOnly, viewMode, sortMode], ); const continueItem = useMemo(() => getMostRecentlyUsedItem(items), [items]); const showContinueStrip = @@ -1338,11 +1172,147 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: } function clearAllFilters() { - setSelectedSetId(null); - setSelectedTypeId("all"); - setViewMode("all"); + setSelectedSetIds(new Set()); + setSelectedTypeIds(new Set()); + setPinnedOnly(false); + setSourceBackedOnly(false); } + const toggleSet = (id: string) => { + const next = new Set(effectiveSelectedSetIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedSetIds(next); + }; + const toggleType = (id: string) => { + const next = new Set(selectedTypeIds); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelectedTypeIds(next); + }; + const countWith = ({ + typeIds = selectedTypeIds, + setIds = effectiveSelectedSetIds, + pinned = pinnedOnly, + sourceBacked = sourceBackedOnly, + }: { + typeIds?: ReadonlySet; + setIds?: ReadonlySet; + pinned?: boolean; + sourceBacked?: boolean; + }) => { + const setTitles = new Set(sets.filter((set) => setIds.has(set.id)).map((set) => set.title)); + return filterAndSortItems(items, { + searchTerm: activeQuery, + selectedTypeIds: typeIds, + selectedSetTitles: setTitles, + pinnedOnly: pinned, + sourceBackedOnly: sourceBacked, + viewMode, + sortMode, + }).length; + }; + const setOptions = sets.map((set) => { + const projected = effectiveSelectedSetIds.has(set.id) + ? effectiveSelectedSetIds + : new Set([...effectiveSelectedSetIds, set.id]); + const count = countWith({ setIds: projected }); + return { + value: set.id, + label: set.title, + hint: String(count), + disabled: !effectiveSelectedSetIds.has(set.id) && count === 0, + }; + }); + const typeOptions = favouriteTabs + .filter((tab) => tab.id !== "all" && tab.id !== "sets") + .map((tab) => { + const projected = selectedTypeIds.has(tab.id) ? selectedTypeIds : new Set([...selectedTypeIds, tab.id]); + const count = countWith({ typeIds: projected }); + return { + value: tab.id, + label: tab.label, + hint: String(count), + disabled: !selectedTypeIds.has(tab.id) && count === 0, + }; + }) + .filter((option) => items.some((item) => item.tabId === option.value) || selectedTypeIds.has(option.value)); + const pinnedCount = countWith({ pinned: true }); + const sourceBackedCount = countWith({ sourceBacked: true }); + const activeFilterCount = + effectiveSelectedSetIds.size + selectedTypeIds.size + Number(pinnedOnly) + Number(sourceBackedOnly); + const filterGroups = [ + resultFilterFacetGroup({ + id: "set", + label: "Set", + selected: effectiveSelectedSetIds, + options: setOptions, + onToggle: toggleSet, + }), + resultFilterFacetGroup({ + id: "type", + label: "Type", + selected: selectedTypeIds, + options: typeOptions, + onToggle: toggleType, + }), + resultFilterFacetGroup({ + id: "pinned", + label: "Pinned", + selected: new Set(pinnedOnly ? ["pinned"] : []), + options: [ + { + value: "pinned", + label: "Pinned only", + hint: String(pinnedCount), + disabled: !pinnedOnly && pinnedCount === 0, + }, + ], + onToggle: () => setPinnedOnly((current) => !current), + }), + resultFilterFacetGroup({ + id: "source", + label: "Source support", + selected: new Set(sourceBackedOnly ? ["source-backed"] : []), + options: [ + { + value: "source-backed", + label: "Source-backed only", + hint: String(sourceBackedCount), + disabled: !sourceBackedOnly && sourceBackedCount === 0, + }, + ], + onToggle: () => setSourceBackedOnly((current) => !current), + }), + ]; + const appliedFilters = [ + ...[...effectiveSelectedSetIds].map((id) => ({ + id: `set-${id}`, + groupLabel: "Set", + valueLabel: sets.find((set) => set.id === id)?.title ?? id, + onRemove: () => toggleSet(id), + })), + ...[...selectedTypeIds].map((id) => ({ + id: `type-${id}`, + groupLabel: "Type", + valueLabel: favouriteTabs.find((tab) => tab.id === id)?.label ?? id, + onRemove: () => toggleType(id), + })), + ...(pinnedOnly + ? [{ id: "pinned", groupLabel: "Status", valueLabel: "Pinned", onRemove: () => setPinnedOnly(false) }] + : []), + ...(sourceBackedOnly + ? [ + { + id: "source-backed", + groupLabel: "Support", + valueLabel: "Source-backed", + onRemove: () => setSourceBackedOnly(false), + }, + ] + : []), + ]; + if (!favouritesAccessible) { return (
setFilterOpen((current) => !current)} + /> + } filterControls={ - selectedTypeId !== "all" || selectedSet || viewMode !== "all" ? ( - setSelectedTypeId("all")} - onClearSet={() => setSelectedSetId(null)} - onClearViewMode={() => setViewMode("all")} - includeSearch={false} - /> - ) : null + setFilterOpen((current) => !current)} + /> + } + utilityControls={ + } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearAllFilters : undefined} /> - setFilterOpen(false)} + panelId={filterPanelId} + testId="favourites-filter-panel" + title="Filter favourites" + description="Combine sets, item types, pinned status and source support. Recently used remains a view choice." + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined} + summary={{ count: filteredItems.length, noun: filteredItems.length === 1 ? "favourite" : "favourites" }} /> {showContinueStrip && continueItem ? : null} @@ -1487,7 +1482,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: setSelectedSetIds(new Set([id]))} onShowRecent={() => setViewMode("recent")} /> ) : null} @@ -1496,7 +1491,13 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: faulted an empty list means we could not look, not that the library is empty; the band's fault panel reports that. */} {searching && filteredItems.length === 0 && favouritesRegistryStatus === "ready" ? ( - + 0 ? clearAllFilters : undefined} + onClearSearch={clearSearch} + /> ) : ( = [ + { value: "all", label: "All qualities" }, + { value: "exact", label: "Exact clinical fit" }, + { value: "good", label: "Good clinical fit" }, + { value: "related", label: "Related match" }, +]; -const medicationResultFilters: Array<{ id: MedicationResultFilter; label: string; icon: LucideIcon }> = [ - { id: "best", label: "Best", icon: Sparkles }, - { id: "indication", label: "Indication", icon: Target }, - { id: "safety", label: "Safety", icon: ShieldAlert }, - { id: "monitoring", label: "Monitor", icon: Activity }, +const medicationSignalOptions: ReadonlyArray<{ value: MedicationClinicalSignal; label: string }> = [ + { value: "safety", label: "Safety" }, + { value: "monitoring", label: "Monitoring" }, ]; const medicationCapabilities: Capability[] = [ @@ -297,22 +317,6 @@ function MedicationHome({ ); } -function resultMatchesFilter(result: MedicationResult, filter: MedicationResultFilter) { - if (filter === "best") return true; - if (filter === "indication") return result.match !== "Related match"; - // actionTone is source-derived (contraindication vs caution vs monitoring content), - // so it is a stronger signal than the text heuristics — any row whose action shows - // a safety icon (danger or warning) must be reachable through the Safety chip. The - // chips are lenses, not partitions, so warning rows may also appear under Monitor. - if (filter === "safety") { - return result.actionTone !== "neutral" || /check|avoid|caution|ceiling|max/i.test(result.action); - } - return ( - result.actionTone === "warning" || - /monitor|level|review|renal|hepatic/i.test(`${result.action} ${result.dose} ${result.ceiling}`) - ); -} - function ResultToneIcon({ result, accent }: { result: MedicationResult; accent?: string }) { const tone = result.tone === "teal" ? "teal" : result.tone === "blue" ? "blue" : "slate"; if (accent) { @@ -412,14 +416,15 @@ function MedicationResults({ // identity consumers still use `fields=index`. const catalog = useMedicationCatalog(query); const { profile, isEmpty: profileEmpty } = usePatientProfile(); - const [activeFilter, setActiveFilter] = useState("best"); + const searchParams = useSearchParams(); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); - const { rows, counts, totalAvailable } = useMemo(() => { + const { bestRows, allRows } = useMemo(() => { const governance = catalog.data?.governance; const toRow = (result: MedicationResult, medication?: MedicationRecord): MedicationRow => { const badges = medication ? medicationIdentityBadges(medication, governance?.[medication.slug]) : []; const accent = medication?.accent; + const drugClass = medication?.class || medication?.category || "Other"; // Prepend a per-patient verdict badge so the highest-severity signal // surfaces first in the row's badge cluster (priority-sorted by tone). // The verdict folds BOTH engines together — physiology considerations and @@ -438,59 +443,206 @@ function MedicationResults({ unreachableCounterpartyCount: interactions.unreachableCounterparties.length, }); const verdictBadge = verdictSummaryBadge(verdict); - return { result, badges: verdictBadge ? [verdictBadge, ...badges] : badges, accent, verdict }; + return { + result, + medication, + drugClass, + badges: verdictBadge ? [verdictBadge, ...badges] : badges, + accent, + verdict, + }; } - return { result, badges, accent }; + return { result, medication, drugClass, badges, accent }; }; - const sourceRows = - catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? - (catalog.data?.records ?? []).slice(0, 12).map((record) => - toRow( - { - id: record.slug, - name: record.name, - indication: record.subclass || record.category, - match: "Catalogue match", - dose: "See reference", - ceiling: "See reference", - action: "Open full prescribing reference.", - actionTone: "neutral" as const, - tone: "slate" as const, - href: `/medications/${record.slug}`, - }, - record, + const ranked = catalog.data?.matches?.map((match) => toRow(match.result, match.medication)) ?? []; + const rankedSlugs = new Set(ranked.map((row) => row.medication?.slug ?? row.result.id)); + // Widening to the full catalogue must not reshuffle the ranked query + // matches. Keep them in provider rank order, then append catalogue-only + // records in catalogue order. + const complete = [ + ...ranked, + ...(catalog.data?.records ?? []) + .filter((record) => !rankedSlugs.has(record.slug)) + .map((record) => + toRow( + { + id: record.slug, + name: record.name, + indication: record.subclass || record.category, + match: "Catalogue match", + dose: "See reference", + ceiling: "See reference", + action: "Open full prescribing reference.", + actionTone: "neutral" as const, + tone: "slate" as const, + href: `/medications/${record.slug}`, + }, + record, + ), ), - ); - const scoped = sourceRows; - const filterCounts: Record = { best: 0, indication: 0, safety: 0, monitoring: 0 }; - for (const row of scoped) { - for (const filter of medicationResultFilters) { - if (resultMatchesFilter(row.result, filter.id)) filterCounts[filter.id] += 1; - } - } + ]; + return { bestRows: ranked, allRows: complete }; + }, [catalog.data, profile, profileEmpty]); + + const scope = readResultFilterValue(searchParams, "scope", medicationScopeValues, "best"); + const matchFilter = readResultFilterValue(searchParams, "match", medicationMatchValues, "all"); + const classValues = useMemo( + () => + [...new Set(allRows.map((row) => row.drugClass).filter(Boolean))].sort((left, right) => + left.localeCompare(right), + ), + [allRows], + ); + const classValueSet = useMemo(() => new Set(classValues), [classValues]); + const classFilters = useMemo( + () => new Set(readResultFilterValues(searchParams, "class", classValueSet)), + [classValueSet, searchParams], + ); + const signalFilters = useMemo( + () => new Set(readResultFilterValues(searchParams, "signal", medicationSignalValues)), + [searchParams], + ); + + const { rows, scopeCounts, matchCounts, classCounts, signalCounts, totalAvailable } = useMemo(() => { + const filters = { match: matchFilter, classes: classFilters, signals: signalFilters }; + const rowsForScope = (nextScope: MedicationScope) => (nextScope === "best" ? bestRows : allRows); + const matches = (candidateRows: MedicationRow[], candidateFilters = filters) => + candidateRows.filter((row) => medicationRowMatchesFilters(row, candidateFilters)); + const baseRows = rowsForScope(scope); + const visible = matches(baseRows); + return { - rows: scoped.filter((row) => resultMatchesFilter(row.result, activeFilter)), - counts: filterCounts, - totalAvailable: scoped.length, + rows: visible, + totalAvailable: baseRows.length, + scopeCounts: { + best: matches(bestRows).length, + all: matches(allRows).length, + } satisfies Record, + matchCounts: Object.fromEntries( + medicationMatchOptions.map((option) => [ + option.value, + matches(baseRows, { ...filters, match: option.value }).length, + ]), + ) as Record, + classCounts: Object.fromEntries( + classValues.map((drugClass) => { + const projected = classFilters.has(drugClass) ? classFilters : new Set([...classFilters, drugClass]); + return [drugClass, matches(baseRows, { ...filters, classes: projected }).length]; + }), + ) as Record, + signalCounts: Object.fromEntries( + medicationSignalOptions.map((option) => { + const projected = signalFilters.has(option.value) ? signalFilters : new Set([...signalFilters, option.value]); + return [option.value, matches(baseRows, { ...filters, signals: projected }).length]; + }), + ) as Record, }; - }, [activeFilter, catalog.data, profile, profileEmpty]); + }, [allRows, bestRows, classFilters, classValues, matchFilter, scope, signalFilters]); + + const setScope = (value: MedicationScope) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "scope", value, "best", medicationScopeValues)); + const setMatchFilter = (value: MedicationMatchQuality) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "match", value, "all", medicationMatchValues)); + const toggleClass = (value: string) => { + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "class", classValueSet)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "class", next, classValueSet); + }); + }; + const toggleSignal = (value: MedicationClinicalSignal) => { + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "signal", medicationSignalValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "signal", next, medicationSignalValues); + }); + }; + const clearFilters = () => + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("match"); + params.delete("class"); + params.delete("signal"); + }); + const resultCount = rows.length; - // One array feeds the desktop rail and the phone sheet, so the counts cannot - // drift between them and the dimension states one-of-N at both breakpoints. - const filterOptions = useMemo>>( - () => - medicationResultFilters.map((filter) => ({ - value: filter.id, - label: filter.label, - icon: filter.icon, - hint: String(counts[filter.id]), + const activeFilterCount = + (scope === "best" ? 0 : 1) + (matchFilter === "all" ? 0 : 1) + classFilters.size + signalFilters.size; + const filterGroups = [ + resultFilterGroup({ + id: "match-quality", + label: "Match quality", + value: matchFilter, + options: medicationMatchOptions.map((option) => ({ + ...option, + hint: String(matchCounts[option.value]), + disabled: option.value !== matchFilter && matchCounts[option.value] === 0, })), - [counts], - ); + onChange: setMatchFilter, + }), + resultFilterFacetGroup({ + id: "drug-class", + label: "Drug class", + selected: classFilters, + options: classValues.map((value) => ({ + value, + label: value, + hint: String(classCounts[value] ?? 0), + disabled: !classFilters.has(value) && (classCounts[value] ?? 0) === 0, + })), + onToggle: toggleClass, + }), + resultFilterFacetGroup({ + id: "clinical-signal", + label: "Clinical signal", + description: "Safety and monitoring are one OR group; choosing either keeps records with that signal.", + selected: signalFilters, + options: medicationSignalOptions.map((option) => ({ + ...option, + hint: String(signalCounts[option.value]), + disabled: !signalFilters.has(option.value) && signalCounts[option.value] === 0, + })), + onToggle: toggleSignal, + }), + ]; + const appliedFilters = [ + ...(scope === "all" + ? [ + { + id: "scope", + groupLabel: "Search in", + valueLabel: "All medications", + accessibleLabel: "Search in all medications", + onRemove: () => setScope("best"), + }, + ] + : []), + ...(matchFilter === "all" + ? [] + : [ + { + id: "match", + groupLabel: "Match", + valueLabel: medicationMatchOptions.find((option) => option.value === matchFilter)?.label ?? matchFilter, + onRemove: () => setMatchFilter("all"), + }, + ]), + ...[...classFilters].map((value) => ({ + id: `class-${value}`, + groupLabel: "Class", + valueLabel: value, + onRemove: () => toggleClass(value), + })), + ...[...signalFilters].map((value) => ({ + id: `signal-${value}`, + groupLabel: "Signal", + valueLabel: medicationSignalOptions.find((option) => option.value === value)?.label ?? value, + onRemove: () => toggleSignal(value), + })), + ]; // The match-quality badge only earns its slot when it differentiates: hide it on // "Exact clinical fit" rows when every visible row says the same thing. const showMatchBadge = useMemo(() => new Set(rows.map((row) => row.result.match)).size > 1, [rows]); - const activeFilterLabel = medicationResultFilters.find((filter) => filter.id === activeFilter)?.label ?? "filtered"; const initialCatalogLoading = catalog.loading && !catalog.data; const catalogRefetching = catalog.loading && Boolean(catalog.data); @@ -513,44 +665,55 @@ function MedicationResults({ testId="medication-filter-trigger-phone" title="Filter medication results" open={filterOpen} - activeCount={activeFilter === "best" ? 0 : 1} + activeCount={activeFilterCount} onToggle={() => setFilterOpen((current) => !current)} /> } filterControls={ - setFilterOpen((current) => !current)} /> } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} /> - {/* Phone-only by construction: the trigger that opens it lives in the - ribbon's `mobileControls` slot, which the band hides from `sm` up. */} setFilterOpen(false)} panelId={filterPanelId} testId="medication-filter-panel" title="Filter medication results" - groups={[ - resultFilterGroup({ - id: "result-filter", - label: "Show", - value: activeFilter, - options: filterOptions, - onChange: setActiveFilter, - }), - ]} - onClearAll={activeFilter === "best" ? undefined : () => setActiveFilter("best")} - footerNote={`${resultCount} showing`} + description="Narrow the ranked matches without changing prescribing content or patient safety notices." + scope={{ + label: "Search in", + value: scope, + options: [ + { + value: "best", + label: "Best matches", + count: scopeCounts.best, + description: "The ranked results for this query.", + }, + { + value: "all", + label: "All medications", + count: scopeCounts.all, + description: "The complete medication catalogue.", + }, + ], + onChange: (value) => setScope(value as MedicationScope), + }} + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: resultCount, noun: resultCount === 1 ? "medication" : "medications" }} /> {/* Phone gets the docked pill + sheet instead of the in-flow strip: two @@ -576,16 +739,16 @@ function MedicationResults({
) : ( diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx index b53b6b2a50..0bcb8756c3 100644 --- a/src/components/clinical-dashboard/result-filter-control.tsx +++ b/src/components/clinical-dashboard/result-filter-control.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ChevronDown, Funnel, Search, X } from "lucide-react"; -import { type ReactNode, useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Sheet } from "@/components/ui/sheet"; import { cn } from "@/components/ui-primitives"; @@ -25,11 +25,9 @@ import { cn } from "@/components/ui-primitives"; * - `ResultFilterSheet` — the shared sheet for every mode's filters, lens and * facet alike. Below the density threshold (`docs/filter-contract.md` §5) a * facet group is a plain chip row; above it, the sheet grows a find-a-filter - * field and collapse-by-default per group. Documents (the largest surface — - * up to 11 facet groups) converged onto this in PR F; `meterContent` and - * `footerOverride` exist because its progress meter and "Show N documents" / - * "Browse all sources" footer are richer than every other mode's plain - * `footerNote` + "Done". + * field and collapse-by-default per group. Typed scope, coverage, summary and + * secondary-action props describe the non-group anatomy without arbitrary + * caller-owned markup. * * Desktop is untouched. The ribbon renders `filterControls` from `sm` up and * `mobileControls` below it, never both, so each mode keeps the chip row or tab @@ -47,14 +45,21 @@ export type ResultFilterOption = { disabled?: boolean; }; +export type ResultFilterOptionSection = { + id: string; + label: string; + description?: string; + optionValues: ReadonlyArray; +}; + /** * A dimension the sheet can render, discriminated by what it MEANS rather than * by how it should look. * * `lens` — one-of-N. The options partition the result set and exactly one is - * active: differentials' All/Presentations/Diagnoses, medication's - * Best/Indication/Safety/Monitor. This is the shape every call site uses today, - * so it is the default and `kind` may be omitted. + * active: differentials' All/Presentations/Diagnoses or medication match + * quality. This is the shape every call site uses today, so it is the default + * and `kind` may be omitted. * * `facet` — many-of-N, OR within the group and AND across groups. Formulation's * domains and the documents tag groups are facets; rendering them as radios (as @@ -71,7 +76,11 @@ type ResultFilterGroupBase = { /** Stable within one sheet; used for the group's own labelling ids. */ id: string; label: string; + /** Optional clinical context rendered below the group heading. */ + description?: string; options: ReadonlyArray>; + /** Visual grouping only. It never changes the group's lens/facet predicate. */ + optionSections?: ReadonlyArray; }; export type ResultFilterLensGroup = ResultFilterGroupBase & { @@ -113,18 +122,22 @@ export function isFacetGroup(group: ResultFilterGroup): group is ResultFilterFac export function resultFilterGroup(group: { id: string; label: string; + description?: string; value: Value; options: ReadonlyArray>; onChange: (value: Value) => void; note?: string; + optionSections?: ReadonlyArray; }): ResultFilterGroup { return { kind: "lens", id: group.id, label: group.label, + description: group.description, value: group.value, options: group.options, note: group.note, + optionSections: group.optionSections, // The one narrowing, isolated here rather than repeated at seven call sites. onChange: (value) => group.onChange(value as Value), }; @@ -148,9 +161,11 @@ export function resultFilterGroup(group: { export function resultFilterFacetGroup(group: { id: string; label: string; + description?: string; selected: ReadonlySet; options: ReadonlyArray>; onToggle: (value: Value) => void; + optionSections?: ReadonlyArray; // Returns the narrow facet type, not the union: a mode hands the same group // to `ResultFilterSheet` (which takes the union) and to // `ResultFilterFacetChips` for its desktop rail (which does not). @@ -159,8 +174,10 @@ export function resultFilterFacetGroup(group: { kind: "facet", id: group.id, label: group.label, + description: group.description, selected: group.selected as ReadonlySet, options: group.options, + optionSections: group.optionSections, onToggle: (value) => group.onToggle(value as Value), }; } @@ -255,10 +272,9 @@ export function ResultFilterTrigger({ * the interaction. * * Arrow keys select as they move, which is the ARIA default and also what the - * native `` this replaced already did on desktop. Live catalogue + * lenses therefore commit as focus moves, while staged panels keep their + * changes in draft state until the primary action runs. * * A dead-end option stays on the arrow path but is never selected by it. That is * the ARIA guidance for a disabled radio, and it is the only arrangement that @@ -268,11 +284,9 @@ export function ResultFilterTrigger({ * would make them reachable — and would add a second, third and fourth tab stop * to a control whose whole point is having one. * - * No call site produces a dead end today: `deadEnd` is `disabled && !selected`, - * and every placeholder this component ships with (services' "Current search" / - * "All services", formulation's "Current search") is rendered only while it is - * the *selected* option. This path is therefore defensive, and is asserted in the - * DOM tests so it cannot rot before the first mode needs it. + * `deadEnd` is `disabled && !selected`. These options remain reachable for an + * explanation while the selected zero-result value stays removable through + * the applied shelf. DOM coverage keeps that distinction explicit. */ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; panelId: string }) { const refs = useRef(new Map()); @@ -334,6 +348,9 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa {group.label} {group.note ? {group.note} : null} + {group.description ? ( +

{group.description}

+ ) : null}
>) => + items.map((option) => { + const selected = group.selected.has(option.value); + const deadEnd = Boolean(option.disabled) && !selected; + const deadEndDescId = `${panelId}-${group.id}-${option.value.replace(/[^A-Za-z0-9_-]/g, "-")}-note`; + return ( + + ); + }); return (
@@ -512,71 +575,150 @@ export function ResultFilterFacetChips({ )} + {group.description ? ( +

{group.description}

+ ) : null} +
+ ); +} + +export type ResultFilterScopeOption = { + value: Value; + label: string; + count: number; + description?: string; +}; + +export type ResultFilterScopeConfig = { + label?: string; + value: string; + options: ReadonlyArray; + onChange: (value: string) => void; +}; + +/** + * A compact, count-bearing scope choice for a catalogue that can widen beyond + * the current query. It is deliberately not a generic toggle: the legend names + * the dimension, native radios provide keyboard behaviour, and each choice + * explains the result universe it will use. + */ +export function ResultFilterScopeSelector({ + id, + label = "Search in", + value, + options, + onChange, +}: { + id: string; + label?: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + return ( +
+ + {label} + +
+ {options.map((option) => { + const descriptionId = option.description + ? `${id}-scope-${option.value.replace(/[^A-Za-z0-9_-]/g, "-")}-description` + : undefined; return ( - + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + {option.count.toLocaleString()} + + ); })}
- +
); } +export type ResultFilterSummary = { + count: number; + noun: string; + totalCount?: number; +}; + +export type ResultFilterSecondaryAction = { + label: string; + count?: number; + onClick: () => void; +}; + /** * A single-choice filter sheet: one radio group per dimension, plus * many-of-N facet groups. @@ -612,23 +754,19 @@ export function ResultFilterSheet({ description, groups, onClearAll, - footerNote, + summary, + scope, + coverage, + primaryActionLabel, + onApply, + secondaryAction, + applicationMode = "live", /** Result-set identity for the dense-mode chrome below (the find-a-filter text and per-group collapse state). Changing it clears that chrome, so a new search or scope change cannot leave a stale needle filtering a list it no longer describes. Only meaningful once a sheet goes dense (more than three facet groups); a sheet that never does can omit it. */ chromeResetKey = "", - /** The scope segment (docs/filter-contract.md section 4) — "These results - N | All items N" — rendered above the groups when the calling mode has - a catalogue meaningfully larger than its current result set. Built by - the caller from `SegmentedControl` rather than owned here: the sheet - only reserves the slot, since "meaningfully larger" and what the two - counts mean are per-mode judgements, not something a shared filter - renderer can decide. */ - scopeControl, - meterContent, - footerOverride, }: { open: boolean; onClose: () => void; @@ -640,18 +778,14 @@ export function ResultFilterSheet({ /** Omit to hide the header's Clear. A control that advertises an action must perform one, so pass this only when something is actually clearable. */ onClearAll?: () => void; - footerNote?: ReactNode; + summary?: ResultFilterSummary; + scope?: ResultFilterScopeConfig; + coverage?: { visibleCount: number; totalCount: number; label?: string }; + primaryActionLabel?: string; + onApply?: () => void; + secondaryAction?: ResultFilterSecondaryAction; + applicationMode?: "live" | "staged"; chromeResetKey?: string; - scopeControl?: ReactNode; - /** Content rendered first in the body, above `scopeControl` and the find-a-filter field — - documents' `N of M documents shown` progress meter. No other mode needs this; omit - otherwise. */ - meterContent?: ReactNode; - /** Replaces the entire default footer (the `footerNote` span plus the "Done" button) rather - than composing with it. For a mode whose commit action needs its own label/count (documents' - "Show N documents") or a secondary action beside it (its "Browse all sources"), supplying - those loses nothing the default footer offered. `footerNote` is ignored when this is set. */ - footerOverride?: ReactNode; }) { // Hooks run unconditionally — the empty-groups early return happens below, // after every hook the render needs has already been declared. @@ -705,7 +839,7 @@ export function ResultFilterSheet({ }); }; - if (groups.length === 0) return null; + if (groups.length === 0 && !scope) return null; const facetGroups = groups.filter(isFacetGroup); const totalFacetOptions = facetGroups.reduce((total, group) => total + group.options.length, 0); @@ -746,6 +880,7 @@ export function ResultFilterSheet({ title={title} description={description} portal + placement="responsive-right" id={panelId} testId={testId} headerActions={ @@ -755,7 +890,7 @@ export function ResultFilterSheet({ onClick={onClearAll} data-testid={`${testId}-clear`} className={cn( - "search-band-ghost inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-2xs font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-8", + "search-band-ghost inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-2xs font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", )} > @@ -765,28 +900,89 @@ export function ResultFilterSheet({ ) : null } footer={ - footerOverride ?? ( +
- {footerNote} + + {summary + ? `${summary.count.toLocaleString()} ${summary.noun}${ + summary.totalCount == null ? "" : ` of ${summary.totalCount.toLocaleString()}` + }` + : null} + {applicationMode === "staged" ? . Changes apply together. : null} +
- ) + {secondaryAction ? ( + + ) : null} +
} >
- {meterContent ?
{meterContent}
: null} - {scopeControl ? ( -
{scopeControl}
+ {coverage ? ( +
+
+ 0 + ? `${Math.min(100, Math.max(0, (coverage.visibleCount / coverage.totalCount) * 100))}%` + : "0%", + }} + /> +
+

+ {coverage.visibleCount.toLocaleString()} of{" "} + {coverage.totalCount.toLocaleString()} retrieved matches visible +

+
+ ) : null} + {scope ? ( +
+ +
) : null} {dense ? (
@@ -802,14 +998,14 @@ export function ResultFilterSheet({ onChange={(event) => setNeedle(event.target.value)} placeholder="Find a filter…" data-testid={`${testId}-find`} - className="min-h-tap min-w-0 flex-1 bg-transparent text-xs font-semibold text-[color:var(--text)] outline-none placeholder:font-medium placeholder:text-[color:var(--text-placeholder)] sm:min-h-9" + className="min-h-tap min-w-0 flex-1 bg-transparent text-xs font-semibold text-[color:var(--text)] outline-none placeholder:font-medium placeholder:text-[color:var(--text-placeholder)] sm:min-h-10" /> {needle ? ( diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index dd3ebcebe3..ae70d088bb 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -112,7 +112,12 @@ function useRailOverflow() { export type AppliedFilterChip = { id: string; - label: string; + /** Compact value shown on phones, for example `High`. */ + valueLabel: string; + /** Context restored on larger screens, for example `Risk: High`. */ + groupLabel?: string; + /** Complete label announced by assistive technology. */ + accessibleLabel?: string; onRemove: () => void; }; @@ -591,9 +596,10 @@ export function SearchResultsHeaderBand({ toolbar. It deliberately survives `loading` and a zero result: nothing matching is exactly when you need to relax a filter, and dropping it mid-search would flicker the chips out and back on every keystroke. - Only a fault removes it, because filtering a result set that never - loaded is meaningless. */} - {appliedFilters.length > 0 && !faulted ? ( + A fault still keeps it: retrieval and result constraints are user + state, not a claim that the faulted result count is trustworthy, and + the controls remain the shortest recovery path. */} + {appliedFilters.length > 0 ? (
- {filter.label} + {filter.groupLabel ? ( + + {filter.groupLabel}: + {filter.valueLabel} + + ) : null} + {filter.valueLabel} ))} @@ -786,11 +798,11 @@ export function ResultSortControl({ * escape, and on therapy-compass `Clear search` is the only one for a query-only * zero result. Shipping the escape hatch below the floor the same change raised * the facets, the find field and the disclosure headings to would contradict the - * rule this component's own redesign argues for. Relaxes to 36px from `sm`, - * exactly like the facets. + * rule this component's own redesign argues for. It keeps the 40px pointer + * floor from `sm`, exactly like the filter controls. */ const emptyStateAction = - "inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:text-[color:var(--text)] sm:min-h-9"; + "inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:text-[color:var(--text)] sm:min-h-10"; export function SearchResultsEmptyState({ modeId, @@ -992,7 +1004,7 @@ export function SearchResultsEmptyState({ )} > - Remove “{lastFilter.label}” + Remove “{lastFilter.accessibleLabel ?? lastFilter.valueLabel}” ) : null} {onClearFilters && appliedFilters.length > 1 ? ( diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index 4a4aea03be..e9615cb34d 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { ArrowLeft, ArrowRight, FileText, GitCompareArrows, ShieldAlert } from "lucide-react"; @@ -9,6 +9,7 @@ import { ArrowLeft, ArrowRight, FileText, GitCompareArrows, ShieldAlert } from " import { ResultFilterSheet, ResultFilterTrigger, + resultFilterFacetGroup, resultFilterGroup, type ResultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; @@ -19,6 +20,13 @@ import { import { ContextualBackLink } from "@/components/contextual-back-link"; import { appModeHomeHref } from "@/lib/app-modes"; import { normalizeSearchText } from "@/lib/catalog-search"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; import type { @@ -30,6 +38,10 @@ import type { DifferentialLikelihood } from "@/lib/differential-snapshot"; type BrowseGrouping = "urgency" | "presentation"; type PresentationPriority = "all" | DifferentialStreamItem["status"]; +type DifferentialResultScope = "matches" | "all"; + +const priorityValues = new Set(["all", "emergent", "urgent", "routine"]); +const scopeValues = new Set(["matches", "all"]); type DifferentialStreamWorkspaceProps = { model: DifferentialStreamModel; @@ -293,32 +305,13 @@ function StreamCard({ ); } -function StreamFilterControls({ - stream, - showGrouping, +function StreamGroupingControl({ browseGrouping, onBrowseGroupingChange, - presentationPriority, - onPresentationPriorityChange, - focusedTitle, - showConnectionFilter, - focusMode, - onFocusModeChange, }: { - stream: DifferentialStreamType; - showGrouping: boolean; browseGrouping: BrowseGrouping; onBrowseGroupingChange: (grouping: BrowseGrouping) => void; - presentationPriority: PresentationPriority; - onPresentationPriorityChange: (priority: PresentationPriority) => void; - focusedTitle: string | null; - showConnectionFilter: boolean; - focusMode: boolean; - onFocusModeChange: (enabled: boolean) => void; }) { - const isPresentation = stream === "presentations"; - if (!showGrouping && !isPresentation && !showConnectionFilter) return null; - const optionClass = (selected: boolean) => [ "inline-flex min-h-10 items-center rounded-lg border px-3 text-xs font-bold transition-colors motion-reduce:transition-none", @@ -329,80 +322,30 @@ function StreamFilterControls({ ].join(" "); return ( -
- {showGrouping ? ( -
- - -
- ) : null} - {isPresentation ? ( -
- {( - [ - ["all", "All priorities"], - ["emergent", "Emergent"], - ["urgent", "Urgent"], - ["routine", "Routine"], - ] as const - ).map(([value, label]) => ( - - ))} -
- ) : null} - {showConnectionFilter && focusedTitle ? ( -
- - -
- ) : null} +
+ +
); } export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: DifferentialStreamWorkspaceProps) { const router = useRouter(); + const searchParams = useSearchParams(); const copy = streamCopy[model.stream]; const isPresentation = model.stream === "presentations"; // Match buildDifferentialStreamModel: punctuation-only queries are browse mode. @@ -410,20 +353,55 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: const matchItems = useMemo(() => model.items.filter((item) => item.isMatch), [model.items]); const itemBySlug = useMemo(() => new Map(model.items.map((item) => [item.slug, item])), [model.items]); const itemById = useMemo(() => new Map(model.items.map((item) => [item.id, item])), [model.items]); + const chapterFilterOptions = useMemo( + () => [ + ...model.chapters.map((chapter) => ({ + value: `chapter:${chapter.id}`, + label: chapter.title, + section: "chapters" as const, + itemIds: new Set(chapter.itemIds), + })), + ...(model.stream === "diagnoses" + ? model.presentationChapters.map((chapter) => ({ + value: `presentation:${chapter.id}`, + label: chapter.title, + section: "presentations" as const, + itemIds: new Set(chapter.itemIds), + })) + : []), + ], + [model.chapters, model.presentationChapters, model.stream], + ); + const chapterFilterValues = useMemo( + () => new Set(chapterFilterOptions.map((option) => option.value)), + [chapterFilterOptions], + ); + const defaultScope: DifferentialResultScope = hasQuery ? "matches" : "all"; + const resultScope = readResultFilterValue(searchParams, "scope", scopeValues, defaultScope); + const presentationPriority = readResultFilterValue(searchParams, "priority", priorityValues, "all"); + const selectedChapterFilters = new Set(readResultFilterValues(searchParams, "chapter", chapterFilterValues)); const [focusedSlug, setFocusedSlug] = useState(() => { const requested = initialFocus.trim().toLowerCase(); if (requested && itemBySlug.has(requested)) return requested; return matchItems[0]?.slug ?? null; }); - const [familyMode, setFamilyMode] = useState(false); const [browseGrouping, setBrowseGrouping] = useState("urgency"); - const [presentationPriority, setPresentationPriority] = useState("all"); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); const didAutoJumpForFocus = useRef(""); + const setResultScope = (value: DifferentialResultScope) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "scope", value, defaultScope, scopeValues)); + const setPresentationPriority = (value: PresentationPriority) => + replaceResultFilterUrl((params) => writeResultFilterValue(params, "priority", value, "all", priorityValues)); + const setFamilyMode = (enabled: boolean) => + replaceResultFilterUrl((params) => { + if (enabled) params.set("related", "1"); + else params.delete("related"); + }); + const resultSignature = matchItems.map((item) => item.slug).join("|"); const [lastResultSignature, setLastResultSignature] = useState(""); if (lastResultSignature !== resultSignature) { @@ -436,10 +414,10 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: if (!initialFocus.trim() && matchItems[0]) { setFocusedSlug(matchItems[0].slug); } - setFamilyMode(false); } const focusedItem = focusedSlug ? (itemBySlug.get(focusedSlug) ?? null) : null; + const familyMode = Boolean(focusedItem && searchParams.get("related") === "1"); const relatedSlugSet = useMemo(() => { if (!focusedItem) return new Set(); return new Set( @@ -449,14 +427,24 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: ); }, [focusedItem, isPresentation]); - const visibleItems = useMemo(() => { - const connectedItems = - familyMode && focusedItem - ? model.items.filter((item) => item.slug === focusedItem.slug || relatedSlugSet.has(item.slug)) - : model.items; - if (!isPresentation || presentationPriority === "all") return connectedItems; - return connectedItems.filter((item) => item.status === presentationPriority); - }, [familyMode, focusedItem, isPresentation, model.items, presentationPriority, relatedSlugSet]); + const filterStreamItems = ( + scope: DifferentialResultScope, + priority: PresentationPriority, + chapters: ReadonlySet, + related: boolean, + ) => + model.items.filter((item) => { + if (scope === "matches" && !item.isMatch) return false; + if (related && focusedItem && item.slug !== focusedItem.slug && !relatedSlugSet.has(item.slug)) return false; + if (priority !== "all" && item.status !== priority) return false; + if ( + chapters.size > 0 && + !chapterFilterOptions.some((option) => chapters.has(option.value) && option.itemIds.has(item.id)) + ) + return false; + return true; + }); + const visibleItems = filterStreamItems(resultScope, presentationPriority, selectedChapterFilters, familyMode); const activeChapters = !hasQuery && model.stream === "diagnoses" @@ -528,33 +516,59 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: const showBrowseGrouping = !hasQuery && model.stream === "diagnoses"; const showConnectionFilter = Boolean(focusedItem && (!isPresentation || focusedItem.relatedPathways.length > 0)); const mobileFilterGroups: ResultFilterGroup[] = []; - if (showBrowseGrouping) { - mobileFilterGroups.push( - resultFilterGroup({ - id: "grouping", - label: "Group diagnoses", - value: browseGrouping, - options: [ - { value: "urgency", label: "By urgency" }, - { value: "presentation", label: "By presentation" }, - ], - onChange: setBrowseGrouping, - }), - ); - } - if (isPresentation) { + mobileFilterGroups.push( + resultFilterGroup({ + id: "priority", + label: "Clinical urgency", + value: presentationPriority, + options: (["all", "emergent", "urgent", "routine"] as const).map((value) => ({ + value, + label: value === "all" ? "All priorities" : statusLabel(value), + hint: String(filterStreamItems(resultScope, value, selectedChapterFilters, familyMode).length), + })), + onChange: setPresentationPriority, + }), + ); + if (model.stream === "diagnoses" && chapterFilterOptions.length > 0) { mobileFilterGroups.push( - resultFilterGroup({ - id: "priority", - label: "Presentation priority", - value: presentationPriority, - options: [ - { value: "all", label: "All priorities" }, - { value: "emergent", label: "Emergent" }, - { value: "urgent", label: "Urgent" }, - { value: "routine", label: "Routine" }, + resultFilterFacetGroup({ + id: "chapter", + label: "Chapter or presentation", + description: "Alternatives in this group combine with OR.", + selected: selectedChapterFilters, + optionSections: [ + { + id: "chapters", + label: "Diagnostic chapters", + optionValues: chapterFilterOptions + .filter((option) => option.section === "chapters") + .map((option) => option.value), + }, + { + id: "presentations", + label: "Presentation families", + optionValues: chapterFilterOptions + .filter((option) => option.section === "presentations") + .map((option) => option.value), + }, ], - onChange: setPresentationPriority, + options: chapterFilterOptions.map((option) => { + const withCandidate = new Set(selectedChapterFilters); + if (!withCandidate.has(option.value)) withCandidate.add(option.value); + const count = filterStreamItems(resultScope, presentationPriority, withCandidate, familyMode).length; + return { + value: option.value, + label: option.label, + hint: String(count), + disabled: count === 0 && !selectedChapterFilters.has(option.value), + }; + }), + onToggle: (value) => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "chapter", next, chapterFilterValues); + }), }), ); } @@ -578,28 +592,57 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: } const activeFilterCount = Number(familyMode) + - Number(showBrowseGrouping && browseGrouping !== "urgency") + - Number(isPresentation && presentationPriority !== "all"); + Number(resultScope !== defaultScope) + + Number(presentationPriority !== "all") + + selectedChapterFilters.size; const appliedFilters: AppliedFilterChip[] = []; - if (isPresentation && presentationPriority !== "all") { + if (resultScope !== defaultScope) { + appliedFilters.push({ + id: "scope", + groupLabel: "Search in", + valueLabel: resultScope === "all" ? "All entries" : "Matches", + onRemove: () => setResultScope(defaultScope), + }); + } + if (presentationPriority !== "all") { appliedFilters.push({ id: "priority", - label: `${statusLabel(presentationPriority)} priority`, + groupLabel: "Priority", + valueLabel: statusLabel(presentationPriority), onRemove: () => setPresentationPriority("all"), }); } + for (const token of selectedChapterFilters) { + const option = chapterFilterOptions.find((item) => item.value === token); + if (!option) continue; + appliedFilters.push({ + id: token, + groupLabel: option.section === "presentations" ? "Presentation" : "Chapter", + valueLabel: option.label, + onRemove: () => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); + next.delete(token); + writeResultFilterValues(params, "chapter", next, chapterFilterValues); + }), + }); + } if (familyMode && focusedItem) { appliedFilters.push({ id: isPresentation ? "pathways" : "family", - label: isPresentation ? `${focusedItem.title} related pathways` : `${focusedItem.title} family`, + groupLabel: isPresentation ? "Related pathways" : "Family", + valueLabel: focusedItem.title, onRemove: () => setFamilyMode(false), }); } function clearStreamFilters() { - setBrowseGrouping("urgency"); - setFamilyMode(false); - setPresentationPriority("all"); + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("priority"); + params.delete("chapter"); + params.delete("related"); + }); } function renderCardGrid(items: DifferentialStreamItem[]): ReactNode { @@ -643,37 +686,34 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: 0 ? ( - setFilterOpen((current) => !current)} - title={`Filter ${copy.heading.toLowerCase()}`} - /> + setFilterOpen((current) => !current)} + title={`Filter ${copy.heading.toLowerCase()}`} + /> + } + utilityControls={ + showBrowseGrouping ? ( + ) : undefined } filterControls={ - mobileFilterGroups.length > 0 ? ( - - ) : undefined + setFilterOpen((current) => !current)} + title={`Filter ${copy.heading.toLowerCase()}`} + /> } appliedFilters={appliedFilters} onClearFilters={activeFilterCount > 0 ? clearStreamFilters : undefined} @@ -686,20 +726,40 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: title={`Filter ${copy.heading.toLowerCase()}`} description={ isPresentation - ? "Narrow pathways by clinical priority or shared diagnostic candidates." - : "Choose how catalogue entries are grouped or narrowed." + ? "Choose query scope, urgency, or pathways related to the focused presentation." + : "Choose query scope, urgency, diagnostic chapter, presentation family, or related entries." } groups={mobileFilterGroups} + scope={{ + label: "Search in", + value: resultScope, + onChange: (value) => setResultScope(value as DifferentialResultScope), + options: [ + { + value: "matches", + label: "Matches", + count: filterStreamItems("matches", presentationPriority, selectedChapterFilters, familyMode).length, + description: "Keep entries matched by the current query.", + }, + { + value: "all", + label: "All entries", + count: filterStreamItems("all", presentationPriority, selectedChapterFilters, familyMode).length, + description: "Apply refinements across this whole stream.", + }, + ], + }} onClearAll={activeFilterCount > 0 ? clearStreamFilters : undefined} - footerNote={`${visibleItems.length} ${ - isPresentation + summary={{ + count: visibleItems.length, + noun: isPresentation ? visibleItems.length === 1 ? "pathway" : "pathways" : visibleItems.length === 1 ? "entry" - : "entries" - } showing`} + : "entries", + }} /> {!hasQuery && model.presets.length > 0 ? ( diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 1aa44f4baf..2e681262e1 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -1,262 +1,149 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - useId, - useMemo, - useRef, - useState, - type FocusEvent as ReactFocusEvent, - type KeyboardEvent as ReactKeyboardEvent, -} from "react"; -import { - BookOpenCheck, - Check, - ChevronDown, - ChevronRight, - CircleAlert, - GitCompareArrows, - ListFilter, - SearchX, - X, -} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useId, useMemo, useState } from "react"; +import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, SearchX } from "lucide-react"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, - resultFilterGroup, + resultFilterFacetGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn, codeText, EmptyState, metadataPill, pageContainer, searchFocusRing } from "@/components/ui-primitives"; import type { DsmCategory, DsmDiagnosisSummary } from "@/lib/dsm"; - -function categoryHref(query: string, category?: string, ids: string[] = []) { - const params = new URLSearchParams(); - if (query) params.set("q", query); - if (category) params.set("category", category); - if (ids.length) params.set("ids", ids.join(",")); - const suffix = params.toString(); - return suffix ? `/dsm/search?${suffix}` : "/dsm/search"; -} +import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues } from "@/lib/result-filter-url"; function compareHref(slugs: string[]) { const params = new URLSearchParams({ ids: slugs.join(",") }); return `/dsm/compare?${params.toString()}`; } -// Compact category filter: a single trigger that opens an anchored menu of -// category links, replacing the multi-row pill wall so results sit higher on the -// page. Each option is a real navigation link (server-driven filtering), styled as -// a menuitemradio so the active category reads as the checked option. -function CategoryFilterDropdown({ - query, - categories, - activeCategory, - totalCount, - selected, -}: { - query: string; - categories: DsmCategory[]; - activeCategory?: DsmCategory; - totalCount: number; - selected: string[]; -}) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - const triggerRef = useRef(null); - const optionRefs = useRef>([]); - const menuId = useId(); - - const options = useMemo( - () => [ - { key: undefined as string | undefined, label: "All categories", count: totalCount }, - ...categories.map((item) => ({ key: item.key, label: item.label, count: item.diagnosis_count })), - ], - [categories, totalCount], - ); - const activeIndex = activeCategory ? options.findIndex((option) => option.key === activeCategory.key) : 0; - - useDismissableLayer({ - enabled: open, - refs: [rootRef], - restoreFocusRef: triggerRef, - onDismiss: () => setOpen(false), - }); - - function focusOption(index: number) { - const total = options.length; - const next = ((index % total) + total) % total; - optionRefs.current[next]?.focus(); - } - - // Single source of truth for initial focus: whoever opens the menu picks the - // option to land on and schedules the one focus call. A parallel open-effect - // that also focused the active item would race this and clobber ArrowUp's - // reverse-entry onto the last option. - function openMenu(focusIndex: number) { - setOpen(true); - window.requestAnimationFrame(() => focusOption(focusIndex)); - } - - function handleTriggerKeyDown(event: ReactKeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - openMenu(Math.max(0, activeIndex)); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - openMenu(options.length - 1); - } - } - - function handleOptionKeyDown(event: ReactKeyboardEvent, index: number) { - if (event.key === "ArrowDown") { - event.preventDefault(); - focusOption(index + 1); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - focusOption(index - 1); - } else if (event.key === "Home") { - event.preventDefault(); - focusOption(0); - } else if (event.key === "End") { - event.preventDefault(); - focusOption(options.length - 1); - } else if (event.key === " ") { - // A menuitemradio announces Space as an activation key, but the option is - // an anchor (Space would otherwise scroll), so activate it like click/Enter. - event.preventDefault(); - event.currentTarget.click(); - } - // Escape (dismiss + restore focus to the trigger) is owned by - // useDismissableLayer's document-level handler, so it isn't duplicated here. - } - - // Close when focus leaves the widget entirely (e.g. Tab off the last option), - // so the menu never lingers open over the results. Keep it open while focus - // moves between the trigger and its options, and don't prevent the focus move. - function handleRootBlur(event: ReactFocusEvent) { - if (!open) return; - const nextTarget = event.relatedTarget as Node | null; - if (nextTarget && rootRef.current?.contains(nextTarget)) return; - setOpen(false); - } - - const activeLabel = activeCategory ? activeCategory.label : "All categories"; - const activeCount = activeCategory ? activeCategory.diagnosis_count : totalCount; - - return ( -
- - - {open ? ( - - ) : null} -
- ); -} - export function DsmSearchPage({ query, - category, categories, - results, - totalCount, + results: queryResults, initialIds = [], }: { query: string; - category?: string; categories: DsmCategory[]; results: DsmDiagnosisSummary[]; - totalCount: number; initialIds?: string[]; + /** Retained for older server/test callers; filtering now uses the query-matched summaries. */ + totalCount?: number; }) { - const router = useRouter(); + const searchParams = useSearchParams(); const [selected, setSelected] = useState(initialIds.slice(0, 3)); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); - const activeCategory = categories.find((item) => item.key === category); + const categoryKeys = useMemo(() => new Set(categories.map((item) => item.key)), [categories]); + const selectedCategories = new Set(readResultFilterValues(searchParams, "category", categoryKeys)); + const supportValues = new Set(["specifiers", "differentials"] as const); + const selectedSupport = new Set(readResultFilterValues(searchParams, "support", supportValues)); + const filterDiagnoses = ( + categorySelection: ReadonlySet, + supportSelection: ReadonlySet<"specifiers" | "differentials">, + ) => + queryResults.filter((result) => { + if (categorySelection.size > 0 && !categorySelection.has(result.category.key)) return false; + if (supportSelection.has("specifiers") && result.specifierCount === 0) return false; + if (supportSelection.has("differentials") && result.differentialCount === 0) return false; + return true; + }); + const results = filterDiagnoses(selectedCategories, selectedSupport); + const activeFilterCount = selectedCategories.size + selectedSupport.size; const selectedSet = useMemo(() => new Set(selected), [selected]); const canCompare = selected.length >= 2; + function toggleCategory(value: string) { + replaceResultFilterUrl((params) => { + const current = new Set(readResultFilterValues(params, "category", categoryKeys)); + if (!current.delete(value)) current.add(value); + writeResultFilterValues(params, "category", current, categoryKeys); + }); + } + + function toggleSupport(value: "specifiers" | "differentials") { + replaceResultFilterUrl((params) => { + const current = new Set(readResultFilterValues(params, "support", supportValues)); + if (!current.delete(value)) current.add(value); + writeResultFilterValues(params, "support", current, supportValues); + }); + } + + function clearFilters() { + replaceResultFilterUrl((params) => { + params.delete("category"); + params.delete("support"); + }); + } + + const appliedFilters: AppliedFilterChip[] = [ + ...[...selectedCategories].map((key) => ({ + id: `category-${key}`, + groupLabel: "Category", + valueLabel: categories.find((item) => item.key === key)?.label ?? key, + onRemove: () => toggleCategory(key), + })), + ...[...selectedSupport].map((support) => ({ + id: `support-${support}`, + groupLabel: "Support", + valueLabel: support === "specifiers" ? "Has specifiers" : "Has differential guidance", + onRemove: () => toggleSupport(support), + })), + ]; + + const filterGroups = [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + description: "Select one or more query-matched diagnostic categories.", + selected: selectedCategories, + options: categories.map((category) => { + const next = new Set(selectedCategories); + if (!next.has(category.key)) next.add(category.key); + const count = filterDiagnoses(next, selectedSupport).length; + return { + value: category.key, + label: category.label, + hint: String(count), + disabled: count === 0 && !selectedCategories.has(category.key), + }; + }), + onToggle: toggleCategory, + }), + resultFilterFacetGroup({ + id: "has-specifiers", + label: "Specifier support", + selected: new Set(selectedSupport.has("specifiers") ? ["specifiers"] : []), + options: [ + { + value: "specifiers", + label: "Has specifiers", + hint: String(filterDiagnoses(selectedCategories, new Set([...selectedSupport, "specifiers"])).length), + }, + ], + onToggle: () => toggleSupport("specifiers"), + }), + resultFilterFacetGroup({ + id: "has-differentials", + label: "Differential guidance", + selected: new Set(selectedSupport.has("differentials") ? ["differentials"] : []), + options: [ + { + value: "differentials", + label: "Has differential guidance", + hint: String(filterDiagnoses(selectedCategories, new Set([...selectedSupport, "differentials"])).length), + }, + ], + onToggle: () => toggleSupport("differentials"), + }), + ]; + function toggleDiagnosis(slug: string) { setSelected((current) => { if (current.includes(slug)) return current.filter((item) => item !== slug); @@ -274,6 +161,8 @@ export function DsmSearchPage({ matchCount={results.length} headingLevel={1} filterLabel="Filter diagnoses by category" + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} mobileControlsPlacement="inline" mobileControls={ setFilterOpen((current) => !current)} /> } @@ -298,27 +187,14 @@ export function DsmSearchPage({ ) : null } filterControls={ -
- - {activeCategory ? ( - - - Clear category - - ) : null} -
+ setFilterOpen((current) => !current)} + /> } /> @@ -328,34 +204,10 @@ export function DsmSearchPage({ panelId={filterPanelId} testId="dsm-category-filter-panel" title="Filter DSM diagnoses" - groups={[ - resultFilterGroup({ - id: "category", - label: "Category", - value: activeCategory?.key ?? "all", - options: [ - { value: "all", label: "All categories", hint: String(totalCount) }, - ...categories.map((item) => ({ - value: item.key, - label: item.label, - hint: String(item.diagnosis_count), - })), - ], - onChange: (value) => { - setFilterOpen(false); - router.push(categoryHref(query, value === "all" ? undefined : value, selected)); - }, - }), - ]} - onClearAll={ - activeCategory - ? () => { - setFilterOpen(false); - router.push(categoryHref(query, undefined, selected)); - } - : undefined - } - footerNote={`${results.length} ${results.length === 1 ? "diagnosis" : "diagnoses"} showing`} + description="Narrow the query-matched summary set without changing diagnosis or comparison content." + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: results.length, noun: results.length === 1 ? "diagnosis" : "diagnoses" }} /> {results.length ? ( diff --git a/src/components/factsheets/factsheets-search-page.tsx b/src/components/factsheets/factsheets-search-page.tsx index 119ec91ca5..de8d6297d1 100644 --- a/src/components/factsheets/factsheets-search-page.tsx +++ b/src/components/factsheets/factsheets-search-page.tsx @@ -88,7 +88,7 @@ export function FactsheetsSearchPage({ const option = categoryOptions.find((entry) => entry.value === value); if (!option || option.disabled) return; setFilterOpen(false); - router.push(searchHref(query, value === "all" ? undefined : value)); + router.replace(searchHref(query, value === "all" ? undefined : value), { scroll: false }); }; return ( @@ -182,8 +182,8 @@ export function FactsheetsSearchPage({ {/* Phone-only by construction: the trigger that opens it lives in the ribbon's `mobileControls` slot, which the band hides from `sm` up. - Selecting a category is a navigation here, so the sheet closes with the - push — leaving it open would float over a page it no longer describes. */} + Selecting a category is replacement navigation here, so the sheet closes + before the route updates and repeated filtering does not pollute Back. */} setFilterOpen(false)} @@ -194,13 +194,14 @@ export function FactsheetsSearchPage({ resultFilterGroup({ id: "category", label: "Category", + description: activeCategoryDeadEndMessage, value: activeCategory ?? "all", options: categoryOptions, onChange: applyCategory, }), ]} onClearAll={activeCategory ? () => applyCategory("all") : undefined} - footerNote={activeCategoryDeadEndMessage ?? `${results.length} showing`} + summary={{ count: results.length, noun: results.length === 1 ? "factsheet" : "factsheets" }} /> {results.length === 0 ? ( diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index c37b896e7b..f3efeff52b 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -1,24 +1,27 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { - Check, - ChevronDown, - ChevronRight, - ExternalLink, - FileText, - Search, - Shield, - ShieldCheck, - SlidersHorizontal, - Workflow, - type LucideIcon, -} from "lucide-react"; -import { useId, useMemo, useState, useDeferredValue } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Check, ChevronRight, ExternalLink, ShieldCheck, Workflow } from "lucide-react"; +import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; import { appModeHomeHref } from "@/lib/app-modes"; import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker"; +import { + deriveFormCategories, + filterFormMatches, + formAvailabilityFilterLabels, + formAvailabilityFilterValues, + formFilterCandidateCount, + formFilterSelectionFromParams, + formFilterSelectionSize, + formMatchRisk, + formRiskFilterLabels, + formRiskFilterValues, + writeFormFilterSelection, + type FormFilterSelection, + type FormRiskFilter, +} from "@/lib/form-filters"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { cn, @@ -30,16 +33,20 @@ import { searchFocusRing, searchPageCanvas, searchResultsSection, - ToggleSwitch, } from "@/components/ui-primitives"; import { SearchResultsEmptyState, SearchResultsHeaderBand, + type AppliedFilterChip, } from "@/components/clinical-dashboard/search-results-header-band"; -import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { FormCodeBadge } from "@/components/forms/form-code-badge"; -import { Sheet } from "@/components/ui/sheet"; import { sortResultItems, type ResultSortValue } from "@/lib/result-sort"; +import { replaceResultFilterUrl } from "@/lib/result-filter-url"; import { useResultSort } from "@/components/use-result-sort"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; @@ -49,21 +56,6 @@ type FormsSearchResultsPageProps = { const supportsPathwayClaims = false; -const refineFilters: { - icon: LucideIcon; - title: string; - subtitle: string; - enabled: boolean; - danger?: boolean; -}[] = [ - { icon: Shield, title: "High risk only", subtitle: "Show high risk forms", enabled: false, danger: true }, - { icon: FileText, title: "Official forms", subtitle: "Limit to official forms", enabled: true }, - ...(supportsPathwayClaims - ? [{ icon: Workflow, title: "Pathway linked", subtitle: "Show pathway-linked", enabled: true }] - : []), - { icon: Search, title: "Source matches", subtitle: "Require source match", enabled: false }, -]; - function resultCode(match: FormSearchMatch, index: number) { return formCatalogDetails(match.service)?.form ?? String(index + 1); } @@ -91,34 +83,14 @@ function compactMatchReason(match: FormSearchMatch, query: string) { return "Content match in the forms catalogue"; } -type FormRiskLevel = "high" | "medium" | "low"; - -const formRiskLevels: readonly FormRiskLevel[] = ["high", "medium", "low"]; - // Risk is the only badge a phone result card carries, so it must never out-rank // itself: high is solid danger, medium is a bordered wash, low stays neutral. -const riskBadgeToneClass: Record = { +const riskBadgeToneClass: Record = { high: "bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]", medium: "border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", low: "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", }; -/** - * Prefer the catalogue's typed `riskLevel`. Records that reach the registry - * without a catalogue payload still carry the level as their first status chip - * (`"high risk"`), so fall back to that rather than dropping the safety signal. - */ -function formRiskLevel(match: FormSearchMatch): FormRiskLevel | null { - const level = formCatalogDetails(match.service)?.riskLevel; - if (level && formRiskLevels.includes(level)) return level; - for (const chip of match.service.statusChips ?? []) { - const label = chip.label?.trim().toLowerCase() ?? ""; - const matched = formRiskLevels.find((candidate) => label === `${candidate} risk`); - if (matched) return matched; - } - return null; -} - // The catalogue generates a `purpose` for every form, but most are boilerplate // that only restates the title ("Official form source: Transfer Order. Review // the source snippets and approved form before use."). Rendering one of those @@ -191,102 +163,6 @@ function findExactFormCodeMatch(items: CodedFormMatch[], query: string): CodedFo ); } -function RefineFilterItem({ - icon: Icon, - title, - subtitle, - enabled, - danger, -}: { - icon: LucideIcon; - title: string; - subtitle: string; - enabled: boolean; - danger?: boolean; -}) { - return ( -
- - - -
-

{title}

-

{subtitle}

-
- -
- ); -} - -function RefineBar({ open, onToggle, panelId }: { open: boolean; onToggle: () => void; panelId: string }) { - return ( - - ); -} - -function RefinePanel({ open, panelId }: { open: boolean; panelId: string }) { - if (!open) return null; - return ( -
-
-
-

Refine results

-

Filter controls are coming soon.

-
- -
-
- {refineFilters.map((filter) => ( - - ))} -
-
- ); -} - const resultsGridColumns = "md:grid-cols-[72px_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1.35fr)_minmax(88px,auto)]"; function ResultsTable({ @@ -505,7 +381,7 @@ const metaChipClass = "rounded-md px-2 py-1 text-3xs font-extrabold uppercase le function MobileExactMatchHero({ match, code }: CodedFormMatch) { const form = match.service; const details = formCatalogDetails(form); - const risk = formRiskLevel(match); + const risk = formMatchRisk(match); const purpose = editorialPurpose(form); // Derive availability label from the actual availability field rather than // positional statusChips indexing to avoid fragility. @@ -578,7 +454,7 @@ function MobileExactMatchHero({ match, code }: CodedFormMatch) { function MobileResultCard({ match, code }: CodedFormMatch) { const form = match.service; - const risk = formRiskLevel(match); + const risk = formMatchRisk(match); // `subtitle` is the catalogue's `purpose` — what the form is actually for. // It replaces the old "Content match in record details" line, which said the // same thing on every card. Boilerplate purposes are dropped entirely rather @@ -737,11 +613,10 @@ export function FormsSearchResultsPage(props: FormsSearchResultsPageProps) { function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { const router = useRouter(); + const searchParams = useSearchParams(); const [sortValue, setSortValue] = useResultSort(); const registry = useRegistryRecords("form"); const registryReady = registry.status === "ready" || registry.status === "refetching"; - const [refineOpen, setRefineOpen] = useState(false); - const refinePanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const filterPanelId = useId(); const deferredQuery = useDeferredValue(query); @@ -753,9 +628,114 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { if (!deferredQuery.trim()) return []; return rankFormRecords(registry.records, deferredQuery); }, [registryReady, registry.records, deferredQuery, query]); + const categoryOptions = useMemo(() => deriveFormCategories(matches), [matches]); + const filterSelection = useMemo( + () => formFilterSelectionFromParams(searchParams, categoryOptions), + [categoryOptions, searchParams], + ); + const filteredMatches = useMemo(() => filterFormMatches(matches, filterSelection), [filterSelection, matches]); const displayedMatches = useMemo( - () => sortResultItems(matches, sortValue, (match) => match.service.title), - [matches, sortValue], + () => sortResultItems(filteredMatches, sortValue, (match) => match.service.title), + [filteredMatches, sortValue], + ); + const activeFilterCount = formFilterSelectionSize(filterSelection); + + const updateFilterSelection = useCallback((next: FormFilterSelection) => { + replaceResultFilterUrl((params) => writeFormFilterSelection(params, next)); + }, []); + + const toggleFilter = useCallback( + (dimension: keyof FormFilterSelection, value: string) => { + replaceResultFilterUrl((params) => { + const current = formFilterSelectionFromParams(params, categoryOptions); + const nextValues = new Set(current[dimension] as ReadonlySet); + if (!nextValues.delete(value)) nextValues.add(value); + writeFormFilterSelection(params, { ...current, [dimension]: nextValues }); + }); + }, + [categoryOptions], + ); + + const clearFilters = useCallback(() => { + updateFilterSelection({ categories: new Set(), risks: new Set(), availability: new Set() }); + }, [updateFilterSelection]); + + const filterGroups = useMemo( + () => [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + description: "Statutory and clinical form families.", + selected: filterSelection.categories, + options: categoryOptions.map((category) => { + const count = formFilterCandidateCount(matches, filterSelection, "categories", category); + return { + value: category, + label: category, + hint: String(count), + disabled: count === 0 && !filterSelection.categories.has(category), + }; + }), + onToggle: (value) => toggleFilter("categories", value), + }), + resultFilterFacetGroup({ + id: "risk", + label: "Clinical risk", + description: "Risk classification recorded in the form catalogue.", + selected: filterSelection.risks, + options: formRiskFilterValues.map((risk) => { + const count = formFilterCandidateCount(matches, filterSelection, "risks", risk); + return { + value: risk, + label: formRiskFilterLabels[risk], + hint: String(count), + disabled: count === 0 && !filterSelection.risks.has(risk), + }; + }), + onToggle: (value) => toggleFilter("risks", value), + }), + resultFilterFacetGroup({ + id: "availability", + label: "Availability", + description: "How the catalogue record can be obtained.", + selected: filterSelection.availability, + options: formAvailabilityFilterValues.map((availability) => { + const count = formFilterCandidateCount(matches, filterSelection, "availability", availability); + return { + value: availability, + label: formAvailabilityFilterLabels[availability], + hint: String(count), + disabled: count === 0 && !filterSelection.availability.has(availability), + }; + }), + onToggle: (value) => toggleFilter("availability", value), + }), + ], + [categoryOptions, filterSelection, matches, toggleFilter], + ); + + const appliedFilters = useMemo( + () => [ + ...[...filterSelection.categories].map((category) => ({ + id: `category-${category}`, + groupLabel: "Category", + valueLabel: category, + onRemove: () => toggleFilter("categories", category), + })), + ...[...filterSelection.risks].map((risk) => ({ + id: `risk-${risk}`, + groupLabel: "Risk", + valueLabel: formRiskFilterLabels[risk], + onRemove: () => toggleFilter("risks", risk), + })), + ...[...filterSelection.availability].map((availability) => ({ + id: `availability-${availability}`, + groupLabel: "Availability", + valueLabel: formAvailabilityFilterLabels[availability], + onRemove: () => toggleFilter("availability", availability), + })), + ], + [filterSelection, toggleFilter], ); const renderFilterTrigger = (testId: string) => ( @@ -764,7 +744,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { testId={testId} title="Filter form results" open={filterOpen} - activeCount={0} + activeCount={activeFilterCount} onToggle={() => setFilterOpen((current) => !current)} /> ); @@ -809,80 +789,39 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { } sortValue={sortValue} onSortChange={setSortValue} + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} filterLabel="Filter form results" - // Same Documents idiom: compact funnel trigger in both slots so the - // one-line band stays universal. The panel is a coming-soon placeholder - // until form facets ship (see docs/search-results-bar-decisions.md). mobileControlsPlacement="inline" mobileControls={renderFilterTrigger("form-filter-trigger-phone")} - filterControls={ - supportsPathwayClaims ? ( -
- {renderFilterTrigger("form-filter-trigger-wide")} - setRefineOpen((open) => !open)} panelId={refinePanelId} /> -
- ) : ( - renderFilterTrigger("form-filter-trigger-wide") - ) - } + filterControls={renderFilterTrigger("form-filter-trigger-wide")} /> - setFilterOpen(false)} - title="Filter form results" - description="Form filters are not available yet." - portal - id={filterPanelId} + panelId={filterPanelId} testId="form-filter-panel" - footer={ -
- -
- } - > -
-

- Form filters are coming soon. Sorting still works from the results bar. -

- - - Form filters are coming soon. - -
-
+ title="Filter form results" + description="Narrow by form family, recorded clinical risk, and access route." + groups={filterGroups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: displayedMatches.length, noun: displayedMatches.length === 1 ? "form" : "forms" }} + chromeResetKey={query} + /> {registryReady ? ( <> {query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( 0 ? clearFilters : undefined} onTryExample={(example) => router.push(appModeHomeHref("forms", { query: example, focus: true, run: true })) } /> ) : ( <> - {supportsPathwayClaims ? : null}
diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 9fd31b8774..63a28f68aa 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -1,8 +1,8 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useCallback, useId, useMemo, useState, useDeferredValue } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; import { ArrowRight, CheckCircle2, @@ -24,9 +24,11 @@ import { } from "@/components/formulation/formulation-ui"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { - ResultFilterFacetChips, + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; +import { ResultFilterSheet, ResultFilterTrigger, resultFilterFacetGroup, @@ -36,12 +38,14 @@ import { cn, eyebrowText } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { formulationDomainsInUse, + formulationDomainGroups, formulationSearchPresets, formulationTemplates, searchFormulationMechanisms, } from "@/lib/formulation"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; +import { readResultFilterValues, replaceResultFilterUrl, writeResultFilterValues } from "@/lib/result-filter-url"; function presetHref(query: string) { return appModeHomeHref("formulation", { query, run: true, focus: true }); @@ -152,9 +156,14 @@ function EmptySearchResults({ query }: { query: string }) { function FormulationResults({ query }: { query: string }) { const router = useRouter(); + const searchParams = useSearchParams(); // Many-of-N. A mechanism carries 3.92 domains on average, so a radio set // claimed the reader could not hold Affect and Risk at once, which is false. - const [domains, setDomains] = useState>(() => new Set()); + const domainValues = useMemo(() => new Set(formulationDomainsInUse), []); + const domains = useMemo( + () => new Set(readResultFilterValues(searchParams, "domain", domainValues)), + [domainValues, searchParams], + ); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const deferredQuery = useDeferredValue(query); @@ -177,13 +186,16 @@ function FormulationResults({ query }: { query: string }) { }, [domains, deferredQuery, query]); const hasUniqueTopMatch = results.length > 0 && (results.length < 2 || results[0].score !== results[1].score); - const toggleDomain = useCallback((value: string) => { - setDomains((current) => { - const next = new Set(current); - if (!next.delete(value)) next.add(value); - return next; - }); - }, []); + const toggleDomain = useCallback( + (value: string) => { + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "domain", domainValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "domain", next, domainValues); + }); + }, + [domainValues], + ); // "How many would I have if I ticked this as well" — the same predicate as the // filter, run with the candidate added. Under OR-within-group adding an option @@ -194,7 +206,14 @@ function FormulationResults({ query }: { query: string }) { resultFilterFacetGroup({ id: "domain", label: "Domain", + description: "Domains combine with OR. Sections organise the taxonomy without changing that predicate.", selected: domains, + optionSections: formulationDomainGroups.map((section) => ({ + id: section.id, + label: section.label, + description: section.description, + optionValues: section.domains.filter((domain) => domainValues.has(domain)), + })), options: formulationDomainsInUse.map((item) => { const withCandidate = pendingRanking ? 0 @@ -217,8 +236,18 @@ function FormulationResults({ query }: { query: string }) { }), onToggle: toggleDomain, }), - [domains, pendingRanking, searchQuery, toggleDomain], + [domains, domainValues, pendingRanking, searchQuery, toggleDomain], ); + const appliedFilters: AppliedFilterChip[] = [...domains].map((domain) => ({ + id: `domain-${domain}`, + groupLabel: "Domain", + valueLabel: domain, + onRemove: () => toggleDomain(domain), + })); + const clearDomains = () => + replaceResultFilterUrl((params) => { + params.delete("domain"); + }); return ( @@ -233,6 +262,8 @@ function FormulationResults({ query }: { query: string }) { status={rankingReady ? "ready" : "refetching"} headingLevel={1} filterLabel="Filter formulation mechanisms" + appliedFilters={appliedFilters} + onClearFilters={domains.size > 0 ? clearDomains : undefined} // One compact badged trigger replaces the two-column grid of selects, so // the band collapses to one line here too. mobileControlsPlacement="inline" @@ -250,7 +281,16 @@ function FormulationResults({ query }: { query: string }) { // drift. The preset row that used to sit here has moved below the band: // it replaced the query rather than narrowing it, which a control // labelled "Filter" must not do. - filterControls={} + filterControls={ + setFilterOpen((current) => !current)} + /> + } /> {/* Phone-only by construction: the trigger that opens it lives in the @@ -266,8 +306,8 @@ function FormulationResults({ query }: { query: string }) { testId="formulation-filter-panel" title="Filter formulation mechanisms" groups={[domainGroup]} - onClearAll={domains.size === 0 ? undefined : () => setDomains(new Set())} - footerNote={`${results.length} showing`} + onClearAll={domains.size === 0 ? undefined : clearDomains} + summary={{ count: results.length, noun: results.length === 1 ? "mechanism" : "mechanisms" }} /> {/* Evicted from the filter sheet, and all five rather than the first four: diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 4e1212de64..3e24327226 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -21,16 +21,15 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, SearchResultsSkeleton, + type AppliedFilterChip, } from "@/components/clinical-dashboard/search-results-header-band"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; -import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { ResultFilterSheet, ResultFilterTrigger, resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { ServiceGroupNav } from "@/components/services/service-group-nav"; import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; import { cn } from "@/components/ui-primitives"; @@ -60,21 +59,12 @@ import { type ServiceFacetDimension, } from "@/lib/service-facets"; import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; +import { replaceResultFilterUrl } from "@/lib/result-filter-url"; import { sortResultItems } from "@/lib/result-sort"; import { useRegistryRecords } from "@/lib/use-registry-records"; type ServiceResultScope = "results" | "all"; -const bestFitQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; -const serviceQuickFilters = [ - { label: "Best fit", query: bestFitQuery }, - { label: "Crisis", query: "crisis" }, - { label: "Culturally safe", query: "Aboriginal Torres Strait Islander" }, - { label: "Phone referral", query: "phone referral" }, - { label: "Free", query: "free" }, - { label: "WA", query: "WA" }, -] as const; - function displayText(value: string | null | undefined, fallback = "Confirm locally") { return value?.trim() ? value.trim() : fallback; } @@ -442,7 +432,7 @@ export function ServicesNavigatorPage() { // Facet selection lives in the URL, alongside `q`/`group`, so a filtered // services search stays shareable and survives navigating away and back — - // see docs/filter-contract.md section 1 and PR C's own scope note. + // see docs/filter-contract.md. const facetSelection = useMemo(() => serviceFacetSelectionFromParams(searchParams), [searchParams]); const substanceLens = searchParams.get("substance") ?? "all"; const resultScope: ServiceResultScope = searchParams.get("scope") === "all" ? "all" : "results"; @@ -471,7 +461,8 @@ export function ServicesNavigatorPage() { () => filterServicesByFacets(searchableRecords, facetSelection, substanceLens).length, [searchableRecords, facetSelection, substanceLens], ); - const activeFilterCount = serviceFacetSelectionSize(facetSelection) + (substanceLens === "all" ? 0 : 1); + const activeFilterCount = + serviceFacetSelectionSize(facetSelection) + (substanceLens === "all" ? 0 : 1) + (resultScope === "all" ? 1 : 0); const relevanceRankMap = useMemo(() => { const map = new Map(); rankedMatches.forEach((service, index) => map.set(service.slug, index + 1)); @@ -540,70 +531,76 @@ export function ServicesNavigatorPage() { }); } - const updateParams = useCallback( - (mutator: (params: URLSearchParams) => void, replace = false) => { + const updateNavigationParams = useCallback( + (mutator: (params: URLSearchParams) => void) => { const params = new URLSearchParams(searchParams.toString()); mutator(params); params.set("run", "1"); const href = `/services?${params.toString()}`; - if (replace) router.replace(href, { scroll: false }); - else router.push(href, { scroll: false }); + router.push(href, { scroll: false }); }, [router, searchParams], ); + const updateFilterParams = useCallback((mutator: (params: URLSearchParams) => void) => { + replaceResultFilterUrl((params) => { + mutator(params); + params.set("run", "1"); + }); + }, []); + // Facet/lens/scope toggles replace (not push) — a reader ticking several // chips should not spam the back-button history the way submitting a new // search does. const toggleFacetValue = useCallback( (dimension: ServiceFacetDimension, value: string) => { - updateParams((params) => { + updateFilterParams((params) => { const current = serviceFacetSelectionFromParams(params); const next = new Set(current[dimension]); if (!next.delete(value)) next.add(value); writeServiceFacetSelectionToParams(params, { ...current, [dimension]: next }); - }, true); + }); }, - [updateParams], + [updateFilterParams], ); const setSubstanceLensValue = useCallback( (value: string) => { - updateParams((params) => { + updateFilterParams((params) => { if (value === "all") params.delete("substance"); else params.set("substance", value); - }, true); + }); }, - [updateParams], + [updateFilterParams], ); const setResultScopeValue = useCallback( (value: ServiceResultScope) => { - updateParams((params) => { + updateFilterParams((params) => { if (value === "all") params.set("scope", "all"); else params.delete("scope"); - }, true); + }); }, - [updateParams], + [updateFilterParams], ); // Never touches `q`/`query`/`group` — docs/filter-contract.md section 6: // clearing filters and clearing a search are different intentions. The - // quick filters this sheet used to hold DID conflate the two (clearing - // them wiped the search box); moving them to suggestion chips below the - // band removes that conflation rather than papering over it here. + // old query-replacing suggestion rail conflated the two (clearing its + // choices also replaced the search). It is gone; this reset now changes + // only scope and narrowing dimensions. const clearAllFilters = useCallback(() => { - updateParams((params) => { + updateFilterParams((params) => { for (const dimension of serviceFacetDimensions) params.delete(dimension); params.delete("substance"); params.delete("scope"); - }, true); - }, [updateParams]); + }); + }, [updateFilterParams]); function applyServiceQuery(nextQuery: string) { const trimmedQuery = nextQuery.trim(); setLocalQuery({ urlQuery, value: trimmedQuery }); - updateParams((params) => { + updateNavigationParams((params) => { params.delete("query"); if (trimmedQuery) params.set("q", trimmedQuery); else params.delete("q"); @@ -682,6 +679,37 @@ export function ServicesNavigatorPage() { [facetBaseMatches, facetSelection, searchableRecords, substanceLens, toggleFacetValue], ); + const appliedFilters = useMemo(() => { + const chips: AppliedFilterChip[] = []; + if (resultScope === "all") { + chips.push({ + id: "scope-all", + groupLabel: "Search in", + valueLabel: "All services", + onRemove: () => setResultScopeValue("results"), + }); + } + if (substanceLens !== "all") { + chips.push({ + id: `substance-${substanceLens}`, + groupLabel: "Program type", + valueLabel: serviceSubstanceLensValueLabel(substanceLens), + onRemove: () => setSubstanceLensValue("all"), + }); + } + for (const dimension of serviceFacetDimensions) { + for (const value of facetSelection[dimension]) { + chips.push({ + id: `${dimension}-${value}`, + groupLabel: serviceFacetDimensionLabels[dimension], + valueLabel: serviceFacetValueLabel(dimension, value), + onRemove: () => toggleFacetValue(dimension, value), + }); + } + } + return chips; + }, [facetSelection, resultScope, setResultScopeValue, setSubstanceLensValue, substanceLens, toggleFacetValue]); + return ( 0 ? clearAllFilters : undefined} mobileControlsPlacement="inline" mobileControls={ 0 ? clearAllFilters : undefined} - footerNote={`${displayedMatches.length} showing`} + summary={{ count: displayedMatches.length, noun: displayedMatches.length === 1 ? "service" : "services" }} chromeResetKey={`${deferredQuery}|${activeGroup ?? ""}|${resultScope}`} - scopeControl={ - showResultScope ? ( - - ) : undefined + scope={ + showResultScope + ? { + label: "Search in", + value: resultScope, + onChange: (value) => setResultScopeValue(value as ServiceResultScope), + options: [ + { + value: "results", + label: "Current results", + count: resultsScopedCount, + description: "Keep the current search and service group.", + }, + { + value: "all", + label: "All services", + count: allScopedCount, + description: "Apply filters across the full catalogue.", + }, + ], + } + : undefined } /> - - {/* Evicted from the filter sheet: every quick filter called - `router.push` and replaced the query outright, discarding the - search and its results with no warning and no undo — exactly - what docs/filter-contract.md section 1 forbids inside a control - labelled "Filter". Framed as a new search, which is what - picking one does. */} - filter.label)} - onPick={(label) => { - const filter = serviceQuickFilters.find((item) => item.label === label); - if (filter) applyServiceQuery(filter.query); - }} - /> } > @@ -881,9 +903,8 @@ export function ServicesNavigatorPage() { /> ) : deferredQuery === query && displayedMatches.length === 0 ? ( // `displayedMatches` is now group- AND facet/lens-narrowed, so a zero - // here can come from either — the old copy named only the group tab - // and a "quick filter" concept that no longer filters anything (it - // navigates). Offer both escapes, and only the one that applies. + // here can come from either. Offer both escapes, and only the one that + // applies, without reintroducing the removed query-suggestion rail.

No services match

diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index 0d2637a840..04dd907b25 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -1,38 +1,51 @@ "use client"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { useId, useMemo, useState } from "react"; import { ArrowRight, ChevronRight, GitCompareArrows, ListChecks, Search, Tags } from "lucide-react"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; -import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, ResultFilterTrigger, + resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { CategoryTag, ReviewStatusBadge, - SpecifierDiagnosisFilter, SpecifierMatchCard, SpecifierPageShell, SpecifierSafetyNote, specifierCard, } from "@/components/specifiers/specifier-ui"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { searchSpecifiers, specifierFamilies, specifierSearchPresets, type SpecifierFamily } from "@/lib/specifiers"; import { searchSpecifierCatalog, type SpecifierCatalogMatch } from "@/lib/specifiers-search-index"; +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; // The curated set covers a small number of high-signal mood-episode specifiers. // The full DSM-5-TR catalogue (~585 items) is surfaced additively beneath the // curated matches so a search still reaches the broader taxonomy without displacing // the richer curated cards. const CATALOGUE_RESULT_LIMIT = 24; +const CATALOGUE_RESULT_INCREMENT = 24; +type SpecifierResultScope = "guides" | "catalogue"; +const specifierScopeValues = new Set(["guides", "catalogue"]); const diagnosisOptions = [ { value: "", label: "All diagnoses" }, @@ -184,34 +197,186 @@ function SpecifierCatalogueMatches({ matches }: { matches: SpecifierCatalogMatch } function SpecifierResults({ query }: { query: string }) { - const [family, setFamily] = useState<"all" | SpecifierFamily>("all"); - const [diagnosis, setDiagnosis] = useState(""); + const searchParams = useSearchParams(); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); - const results = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); - // The full-catalogue section is additive and diagnosis-specific, so it is NOT - // de-duped against the curated cards: those are generic mood-only specifiers, and - // a label-only match would wrongly hide the disorder-specific catalogue rows (e.g. - // a curated "With catatonia" card must not remove schizophrenia/autism catatonia). - // It still drives the shared count and empty-state so a catalog-only query never - // shows "0 matches" with an empty-state banner above real results. - const catalogueMatches = useMemo(() => searchSpecifierCatalog(query).slice(0, CATALOGUE_RESULT_LIMIT), [query]); - const totalMatches = results.length + catalogueMatches.length; - // One array for the desktop rail and the phone sheet. The full label is safe - // at both: the band hides `filterControls` below `sm` whenever a page also - // supplies `mobileControls`, so the chips' phone-only short label could never - // render and the two breakpoints never showed different text. + const allGuideMatches = useMemo(() => searchSpecifiers(query, { family: "all", diagnosis: "" }), [query]); + const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(query), [query]); + const defaultScope: SpecifierResultScope = allGuideMatches.length > 0 ? "guides" : "catalogue"; + const scope = readResultFilterValue(searchParams, "scope", specifierScopeValues, defaultScope); + const familyValues = useMemo(() => new Set(specifierFamilies.map((option) => option.id)), []); + const diagnosisValues = useMemo(() => new Set(diagnosisOptions.map((option) => option.value)), []); + const family = readResultFilterValue(searchParams, "family", familyValues, "all") as "all" | SpecifierFamily; + const diagnosis = readResultFilterValue(searchParams, "diagnosis", diagnosisValues, ""); + const categoryOptions = useMemo( + () => + [...new Map(allCatalogueMatches.map(({ item }) => [item.categoryId, item.category])).entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)), + [allCatalogueMatches], + ); + const categoryValues = useMemo(() => new Set(categoryOptions.map((option) => option.value)), [categoryOptions]); + const selectedCategories = new Set(readResultFilterValues(searchParams, "category", categoryValues)); + const reviewedOnly = searchParams.get("reviewed") === "1"; + const guideMatches = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); + const catalogueMatches = allCatalogueMatches.filter( + ({ item }) => + (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)) && + (!reviewedOnly || item.src === "source-verified"), + ); + const matchingTotal = scope === "guides" ? guideMatches.length : catalogueMatches.length; + const resultSignature = `${query}|${scope}|${[...selectedCategories].sort().join(",")}|${reviewedOnly}`; + const [visibleState, setVisibleState] = useState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT }); + if (visibleState.signature !== resultSignature) { + setVisibleState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT }); + } + const visibleCatalogueMatches = catalogueMatches.slice(0, visibleState.count); + const familyOptions = useMemo( () => specifierFamilies.map((option) => ({ value: option.id, label: option.label })), [], ); + const updateLens = ( + key: "scope" | "family" | "diagnosis", + value: Value, + fallback: Value, + allowed: ReadonlySet, + ) => replaceResultFilterUrl((params) => writeResultFilterValue(params, key, value, fallback, allowed)); + const toggleCategory = (value: string) => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "category", categoryValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "category", next, categoryValues); + }); + const setReviewedOnly = (enabled: boolean) => + replaceResultFilterUrl((params) => { + if (enabled) params.set("reviewed", "1"); + else params.delete("reviewed"); + }); + const clearFilters = () => + replaceResultFilterUrl((params) => { + params.delete("scope"); + params.delete("family"); + params.delete("diagnosis"); + params.delete("category"); + params.delete("reviewed"); + }); + const activeFilterCount = + Number(scope !== defaultScope) + + (scope === "guides" + ? Number(family !== "all") + Number(Boolean(diagnosis)) + : selectedCategories.size + Number(reviewedOnly)); + const appliedFilters: AppliedFilterChip[] = []; + if (scope !== defaultScope) { + appliedFilters.push({ + id: "scope", + groupLabel: "Search in", + valueLabel: scope === "guides" ? "Clinical guides" : "Full catalogue", + onRemove: () => updateLens("scope", defaultScope, defaultScope, specifierScopeValues), + }); + } + if (scope === "guides") { + if (family !== "all") { + appliedFilters.push({ + id: "family", + groupLabel: "Family", + valueLabel: familyOptions.find((option) => option.value === family)?.label ?? family, + onRemove: () => updateLens("family", "all", "all", familyValues), + }); + } + if (diagnosis) { + appliedFilters.push({ + id: "diagnosis", + groupLabel: "Diagnosis", + valueLabel: diagnosisOptions.find((option) => option.value === diagnosis)?.label ?? diagnosis, + onRemove: () => updateLens("diagnosis", "", "", diagnosisValues), + }); + } + } else { + for (const category of selectedCategories) { + appliedFilters.push({ + id: `category-${category}`, + groupLabel: "Category", + valueLabel: categoryOptions.find((option) => option.value === category)?.label ?? category, + onRemove: () => toggleCategory(category), + }); + } + if (reviewedOnly) { + appliedFilters.push({ + id: "reviewed", + groupLabel: "Source", + valueLabel: "Source verified", + onRemove: () => setReviewedOnly(false), + }); + } + } + + const groups = + scope === "guides" + ? [ + resultFilterGroup({ + id: "family", + label: "Family", + value: family, + options: familyOptions.map((option) => ({ + ...option, + hint: String(searchSpecifiers(query, { family: option.value, diagnosis }).length), + })), + onChange: (value) => updateLens("family", value, "all", familyValues), + }), + resultFilterGroup({ + id: "diagnosis", + label: "Diagnosis", + value: diagnosis, + options: diagnosisOptions.map((option) => ({ + ...option, + hint: String(searchSpecifiers(query, { family, diagnosis: option.value }).length), + })), + onChange: (value) => updateLens("diagnosis", value, "", diagnosisValues), + }), + ] + : [ + resultFilterFacetGroup({ + id: "category", + label: "Category", + selected: selectedCategories, + options: categoryOptions.map((option) => { + const next = new Set(selectedCategories); + if (!next.has(option.value)) next.add(option.value); + const count = allCatalogueMatches.filter( + ({ item }) => next.has(item.categoryId) && (!reviewedOnly || item.src === "source-verified"), + ).length; + return { ...option, hint: String(count), disabled: count === 0 && !selectedCategories.has(option.value) }; + }), + onToggle: toggleCategory, + }), + resultFilterFacetGroup({ + id: "reviewed", + label: "Source verification", + selected: new Set(reviewedOnly ? ["reviewed"] : []), + options: [ + { + value: "reviewed", + label: "Source verified", + hint: String( + allCatalogueMatches.filter( + ({ item }) => + item.src === "source-verified" && + (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)), + ).length, + ), + }, + ], + onToggle: () => setReviewedOnly(!reviewedOnly), + }), + ]; return ( setFilterOpen((current) => !current)} /> } filterControls={ -

- - -
+ setFilterOpen((current) => !current)} + /> } + appliedFilters={appliedFilters} + onClearFilters={activeFilterCount > 0 ? clearFilters : undefined} /> {/* Phone-only by construction: the trigger that opens it lives in the ribbon's `mobileControls` slot, which the band hides from `sm` up. Both @@ -250,48 +415,55 @@ function SpecifierResults({ query }: { query: string }) { panelId={filterPanelId} testId="specifier-filter-panel" title="Filter specifiers" - groups={[ - resultFilterGroup({ - id: "family", - label: "Family", - value: family, - options: familyOptions, - onChange: setFamily, - }), - resultFilterGroup({ - id: "diagnosis", - label: "Diagnosis", - value: diagnosis, - options: diagnosisOptions, - onChange: setDiagnosis, - }), - ]} - onClearAll={ - family === "all" && diagnosis === "" - ? undefined - : () => { - setFamily("all"); - setDiagnosis(""); - } - } - // `results`, not `totalMatches`. Both groups narrow only the curated - // list; `catalogueMatches` keys on the query alone, so reporting the sum - // claimed the sheet scoped a total its filters could not move — the - // count sat still while a filter visibly changed the list. - footerNote={`${results.length} showing`} + scope={{ + label: "Search in", + value: scope, + onChange: (value) => updateLens("scope", value as SpecifierResultScope, defaultScope, specifierScopeValues), + options: [ + { + value: "guides", + label: "Clinical guides", + count: guideMatches.length, + description: "Curated decision-support guides.", + }, + { + value: "catalogue", + label: "Full catalogue", + count: catalogueMatches.length, + description: "The complete disorder-specific catalogue.", + }, + ], + }} + groups={groups} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + summary={{ count: matchingTotal, noun: matchingTotal === 1 ? "specifier" : "specifiers" }} /> - {totalMatches === 0 ? ( + {matchingTotal === 0 ? ( - ) : results.length > 0 ? ( + ) : scope === "guides" ? (
- {results.map(({ record }, index) => ( + {guideMatches.map(({ record }, index) => ( ))}
) : null} - + {scope === "catalogue" ? : null} + {scope === "catalogue" && visibleCatalogueMatches.length < catalogueMatches.length ? ( + + ) : null} diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index 894554c24f..7dca4908e1 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -45,9 +45,18 @@ export function SearchScreen() { // shelf. The query is deliberately absent: it is stated in the composer and // removing it is not a filter operation. const appliedFilters = [ - ...b.search.tags.map((tag) => ({ id: `topic-${tag}`, label: tag, onRemove: () => b.toggleTag(tag) })), - ...(b.search.reviewedOnly ? [{ id: "reviewed", label: "Reviewed only", onRemove: b.toggleReviewedOnly }] : []), - ...(b.search.briefOnly ? [{ id: "brief", label: "Brief available", onRemove: b.toggleBriefOnly }] : []), + ...b.search.tags.map((tag) => ({ + id: `topic-${tag}`, + groupLabel: "Topic", + valueLabel: tag, + onRemove: () => b.toggleTag(tag), + })), + ...(b.search.reviewedOnly + ? [{ id: "reviewed", groupLabel: "Evidence", valueLabel: "Reviewed", onRemove: b.toggleReviewedOnly }] + : []), + ...(b.search.briefOnly + ? [{ id: "brief", groupLabel: "Availability", valueLabel: "Brief available", onRemove: b.toggleBriefOnly }] + : []), ]; const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); @@ -204,7 +213,7 @@ export function SearchScreen() { // "Clear search" control (every breakpoint) already covers wiping the // query; this sheet only ever clears what it itself narrows. onClearAll={activeFilterCount > 0 ? b.clearSearchFilters : undefined} - footerNote={`${results.length} therap${results.length === 1 ? "y" : "ies"}`} + summary={{ count: results.length, noun: results.length === 1 ? "therapy" : "therapies" }} /> {/* The band's fault panel owns the failure. Without this guard an error diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx index 43f5e5ef5e..dd74cbafc3 100644 --- a/src/components/tools/tools-search-results-page.tsx +++ b/src/components/tools/tools-search-results-page.tsx @@ -418,7 +418,7 @@ export function ToolsSearchResultsPage({ }), ]} onClearAll={effectiveActiveFilter === "all" ? undefined : () => setActiveFilter("all")} - footerNote={`${filteredTools.length} showing`} + summary={{ count: filteredTools.length, noun: filteredTools.length === 1 ? "tool" : "tools" }} />
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 3cf633024f..607aebbd43 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -60,7 +60,8 @@ type SheetBaseProps = { bodyRef?: RefObject; onBodyScroll?: UIEventHandler; footerClassName?: string; - placement?: "default" | "left"; + /** Side placement is opt-in so existing dialogs keep their centred layout. */ + placement?: "default" | "left" | "right" | "responsive-right"; mobilePlacement?: "bottom" | "top" | "fullscreen"; mobileSize?: SheetMobileSize; portal?: boolean; @@ -348,9 +349,11 @@ export function Sheet({ } const resolvedAriaLabel = ariaLabel || (!title && !labelledBy ? "Dialog" : undefined); const resolvedLabelledBy = labelledBy ?? (title ? titleId : undefined); - const defaultSheetIsFullscreen = placement !== "left" && mobilePlacement === "fullscreen"; - const defaultSheetIsTopAligned = placement !== "left" && mobilePlacement === "top"; - const defaultSheetUsesViewportSize = placement !== "left" && mobileSize === "viewport"; + const sideSheet = placement === "left" || placement === "right"; + const responsiveSideSheet = placement === "responsive-right"; + const defaultSheetIsFullscreen = !sideSheet && mobilePlacement === "fullscreen"; + const defaultSheetIsTopAligned = !sideSheet && mobilePlacement === "top"; + const defaultSheetUsesViewportSize = !sideSheet && mobileSize === "viewport"; const contentClassTokens = contentClassName?.split(/\s+/) ?? []; const hasMobileMaxHeight = contentClassTokens.some((token) => /^!?max-h-/.test(token)); const hasSmallScreenMaxHeight = contentClassTokens.some((token) => /^sm:!?max-h-/.test(token)); @@ -363,14 +366,18 @@ export function Sheet({ // branch still stacks above sibling chrome. "pointer-events-auto fixed inset-0 z-[var(--z-modal)] flex bg-[color:var(--overlay-backdrop)] backdrop-blur-[2px] motion-reduce:animate-none motion-reduce:transition-none", desktopBackdropClassName, - placement !== "left" && "motion-safe:animate-overlay-in", + !sideSheet && !responsiveSideSheet && "motion-safe:animate-overlay-in", placement === "left" ? "items-stretch justify-start" - : defaultSheetIsFullscreen - ? "items-stretch justify-center p-0 lg:items-center lg:p-6" - : defaultSheetIsTopAligned - ? "items-start justify-center px-3 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:items-center sm:p-6" - : "items-end justify-center sm:items-center sm:p-6", + : placement === "right" + ? "items-stretch justify-end" + : placement === "responsive-right" + ? "items-end justify-center sm:items-stretch sm:justify-end sm:p-0" + : defaultSheetIsFullscreen + ? "items-stretch justify-center p-0 lg:items-center lg:p-6" + : defaultSheetIsTopAligned + ? "items-start justify-center px-3 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:items-center sm:p-6" + : "items-end justify-center sm:items-center sm:p-6", )} // Dismiss on click (not pointerdown) so the sheet stays mounted through // pointerup and the same gesture cannot click-through into content below. @@ -403,30 +410,34 @@ export function Sheet({ "transition duration-[var(--duration-moderate)] motion-reduce:transition-none sm:duration-[var(--duration-quick)]", placement === "left" ? "h-full max-h-full max-w-[min(22rem,calc(100vw-1rem))] rounded-r-2xl border-y-0 border-l-0 pt-safe sm:max-h-dvh sm:max-w-[22rem] sm:rounded-l-none sm:rounded-r-2xl sm:pb-0" - : cn( - defaultSheetIsFullscreen - ? // Fullscreen panels size from the inset-0 backdrop (h-full), not - // 100dvh: iOS Safari resolves dvh stale across toolbar - // collapse, which strands a dead band under the sheet. - "h-full max-h-full rounded-none border-0 motion-safe:animate-pop-in sm:max-w-none sm:rounded-none lg:h-auto lg:max-h-[calc(100dvh-3rem)] lg:rounded-2xl lg:border lg:border-[color:var(--border-lux)] lg:pb-0 lg:motion-safe:animate-dialog-rise" - : cn( - "sm:max-w-lg sm:rounded-2xl sm:pb-0 sm:motion-safe:animate-dialog-rise", - defaultSheetIsTopAligned - ? cn( - "max-h-[calc(100dvh-1.5rem)] rounded-2xl motion-safe:animate-pop-in", - defaultSheetUsesViewportSize && "min-h-[calc(100dvh-2rem)] sm:min-h-0", - ) - : cn( - "rounded-t-2xl motion-safe:animate-sheet-up", - defaultSheetUsesViewportSize - ? "min-h-[calc(100dvh-2rem)] max-h-[calc(100dvh-1rem)] sm:min-h-0" - : cn( - !hasMobileMaxHeight && "max-h-[calc(100dvh-2rem)]", - !hasSmallScreenMaxHeight && "sm:max-h-[88dvh]", - ), - ), - ), - ), + : placement === "right" + ? "h-full max-h-full max-w-[min(32rem,calc(100vw-1rem))] rounded-l-2xl border-y-0 border-r-0 pt-safe sm:max-h-dvh sm:max-w-[32rem] sm:rounded-l-2xl sm:rounded-r-none sm:pb-0" + : placement === "responsive-right" + ? "max-h-[calc(100dvh-2rem)] rounded-t-2xl motion-safe:animate-sheet-up sm:h-full sm:max-h-full sm:max-w-[32rem] sm:rounded-l-2xl sm:rounded-r-none sm:border-y-0 sm:border-r-0 sm:pb-0 sm:motion-safe:animate-dialog-rise" + : cn( + defaultSheetIsFullscreen + ? // Fullscreen panels size from the inset-0 backdrop (h-full), not + // 100dvh: iOS Safari resolves dvh stale across toolbar + // collapse, which strands a dead band under the sheet. + "h-full max-h-full rounded-none border-0 motion-safe:animate-pop-in sm:max-w-none sm:rounded-none lg:h-auto lg:max-h-[calc(100dvh-3rem)] lg:rounded-2xl lg:border lg:border-[color:var(--border-lux)] lg:pb-0 lg:motion-safe:animate-dialog-rise" + : cn( + "sm:max-w-lg sm:rounded-2xl sm:pb-0 sm:motion-safe:animate-dialog-rise", + defaultSheetIsTopAligned + ? cn( + "max-h-[calc(100dvh-1.5rem)] rounded-2xl motion-safe:animate-pop-in", + defaultSheetUsesViewportSize && "min-h-[calc(100dvh-2rem)] sm:min-h-0", + ) + : cn( + "rounded-t-2xl motion-safe:animate-sheet-up", + defaultSheetUsesViewportSize + ? "min-h-[calc(100dvh-2rem)] max-h-[calc(100dvh-1rem)] sm:min-h-0" + : cn( + !hasMobileMaxHeight && "max-h-[calc(100dvh-2rem)]", + !hasSmallScreenMaxHeight && "sm:max-h-[88dvh]", + ), + ), + ), + ), "motion-reduce:animate-none", contentClassName, )} @@ -434,7 +445,7 @@ export function Sheet({
= [ + { key: "medications", label: "Medication", labelType: "medication" }, + { key: "topics", label: "Topic", labelType: "topic" }, + { key: "documentTypes", label: "Document type", labelType: "document_type" }, + { key: "sites", label: "Site", labelType: "site" }, + { key: "services", label: "Service", labelType: "service" }, + { key: "settings", label: "Setting", labelType: "setting" }, + { key: "populations", label: "Population", labelType: "population" }, + { key: "risks", label: "Risk", labelType: "risk" }, + { key: "workflows", label: "Workflow", labelType: "workflow" }, + { key: "clinicalActions", label: "Clinical action", labelType: "clinical_action" }, + { key: "carePhases", label: "Care phase", labelType: "care_phase" }, + { key: "documentIntents", label: "Document intent", labelType: "document_intent" }, + { key: "contentFeatures", label: "Content feature", labelType: "content_feature" }, +]; + +export const sourceStatusLabels: Record = { + current: "Current", + review_due: "Review due", + outdated: "Outdated", + unknown: "Unknown", +}; + +export const validationStatusLabels: Record = { + approved: "Approved", + locally_reviewed: "Locally reviewed", + unverified: "Unverified", + unknown: "Unknown", +}; + +export const extractionQualityLabels: Record = { + good: "Good", + partial: "Partial", + poor: "Poor", + unknown: "Unknown", +}; + +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +function isLocalDocument(document: ClinicalDocument): boolean { + const metadata = normalizeSourceMetadata(document.metadata); + const locality = `${metadata.jurisdiction ?? ""} ${metadata.publisher ?? ""}`.toLowerCase(); + return /\b(?:wa|western australia|north metropolitan|east metropolitan|south metropolitan|perth|health service)\b/.test( + locality, + ); +} + +function documentHasAnyLabel(document: ClinicalDocument, type: DocumentLabelType, values: readonly string[]): boolean { + if (values.length === 0) return true; + const wanted = new Set(values.map(normalize)); + return (document.labels ?? []).some((label) => label.label_type === type && wanted.has(normalize(label.label))); +} + +export function deriveDocumentLabelOptions( + documents: ReadonlyArray, + labelType: DocumentLabelType, +): string[] { + return [ + ...new Set( + documents.flatMap((document) => + (document.labels ?? []) + .filter((label) => label.label_type === labelType && label.label.trim()) + .map((label) => label.label.trim()), + ), + ), + ].sort((left, right) => left.localeCompare(right)); +} + +export function filterDocumentsByRetrievalScope( + documents: ReadonlyArray, + filters: SearchScopeFilters, + selectedDocumentIds: ReadonlySet = new Set(), +): ClinicalDocument[] { + return documents.filter((document) => { + if (selectedDocumentIds.size > 0 && !selectedDocumentIds.has(document.id)) return false; + const metadata = normalizeSourceMetadata(document.metadata); + if (filters.sourceStatuses?.length && !filters.sourceStatuses.includes(metadata.document_status)) return false; + if ( + filters.validationStatuses?.length && + !filters.validationStatuses.includes(metadata.clinical_validation_status as never) + ) + return false; + if (filters.extractionQualities?.length && !filters.extractionQualities.includes(metadata.extraction_quality)) + return false; + if (filters.locality === "local" && !isLocalDocument(document)) return false; + if (filters.locality === "non_local" && isLocalDocument(document)) return false; + for (const field of documentLabelFilterFields) { + if (!documentHasAnyLabel(document, field.labelType, filters[field.key] ?? [])) return false; + } + return true; + }); +} + +export function projectedDocumentScopeCount(args: { + documents: ReadonlyArray; + filters: SearchScopeFilters; + selectedDocumentIds?: ReadonlySet; + key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities"; + value: string; +}): number { + const current = new Set((args.filters[args.key] as string[] | undefined) ?? []); + if (!current.has(args.value)) current.add(args.value); + return filterDocumentsByRetrievalScope( + args.documents, + { ...args.filters, [args.key]: [...current] }, + args.selectedDocumentIds, + ).length; +} + +export function documentRetrievalFilterValueCount(filters: SearchScopeFilters, selectedDocumentCount = 0): number { + return ( + selectedDocumentCount + + documentLabelFilterFields.reduce((total, field) => total + (filters[field.key]?.length ?? 0), 0) + + (filters.sourceStatuses?.length ?? 0) + + (filters.validationStatuses?.length ?? 0) + + (filters.extractionQualities?.length ?? 0) + + Number(Boolean(filters.locality)) + ); +} + +export function publicDocumentScopeFilters(filters: SearchScopeFilters): SearchScopeFilters { + const next = { ...filters }; + // Internal request constraints remain preserved by the caller but are not + // represented as clinician-facing controls. + delete next.importBatchIds; + delete next.labelTypesAny; + return next; +} + +export function mergePublicDocumentScopeFilters( + existing: SearchScopeFilters, + publicFilters: SearchScopeFilters, +): SearchScopeFilters { + return { + ...publicFilters, + ...(existing.importBatchIds?.length ? { importBatchIds: existing.importBatchIds } : {}), + ...(existing.labelTypesAny?.length ? { labelTypesAny: existing.labelTypesAny } : {}), + }; +} + +export function sameDocumentScope(left: SearchScopeFilters, right: SearchScopeFilters): boolean { + const canonical = (filters: SearchScopeFilters) => + JSON.stringify( + Object.fromEntries( + Object.entries(filters) + .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : value != null)) + .map(([key, value]) => [key, Array.isArray(value) ? [...value].sort() : value] as const) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), + ), + ); + return canonical(left) === canonical(right); +} diff --git a/src/lib/form-filters.ts b/src/lib/form-filters.ts new file mode 100644 index 0000000000..f5312a88ae --- /dev/null +++ b/src/lib/form-filters.ts @@ -0,0 +1,94 @@ +import { formCatalogDetails, type FormAvailability, type FormSearchMatch } from "@/lib/form-ranker"; +import { readResultFilterValues, writeResultFilterValues } from "@/lib/result-filter-url"; + +export type FormRiskFilter = "high" | "medium" | "low"; + +export type FormFilterSelection = { + categories: ReadonlySet; + risks: ReadonlySet; + availability: ReadonlySet; +}; + +export const formRiskFilterValues = ["high", "medium", "low"] as const; +export const formAvailabilityFilterValues = ["downloadable", "contact_ocp", "unavailable"] as const; + +export const formRiskFilterLabels: Record = { + high: "High", + medium: "Medium", + low: "Low", +}; + +export const formAvailabilityFilterLabels: Record = { + downloadable: "Downloadable", + contact_ocp: "Contact required", + unavailable: "Unavailable", +}; + +export function formMatchCategory(match: FormSearchMatch): string { + return formCatalogDetails(match.service)?.category?.trim() || "Other forms"; +} + +export function formMatchRisk(match: FormSearchMatch): FormRiskFilter | null { + const level = formCatalogDetails(match.service)?.riskLevel; + if (level && formRiskFilterValues.includes(level)) return level; + for (const chip of match.service.statusChips ?? []) { + const label = chip.label?.trim().toLowerCase(); + const matched = formRiskFilterValues.find((candidate) => label === `${candidate} risk`); + if (matched) return matched; + } + return null; +} + +export function formMatchAvailability(match: FormSearchMatch): FormAvailability { + return formCatalogDetails(match.service)?.availability ?? "unavailable"; +} + +export function deriveFormCategories(matches: ReadonlyArray): string[] { + return [...new Set(matches.map(formMatchCategory))].sort((left, right) => left.localeCompare(right)); +} + +export function formFilterSelectionFromParams( + params: Pick, + categories: ReadonlyArray, +): FormFilterSelection { + return { + categories: new Set(readResultFilterValues(params, "category", new Set(categories))), + risks: new Set(readResultFilterValues(params, "risk", new Set(formRiskFilterValues))), + availability: new Set(readResultFilterValues(params, "availability", new Set(formAvailabilityFilterValues))), + }; +} + +export function writeFormFilterSelection(params: URLSearchParams, selection: FormFilterSelection): void { + const categories = [...selection.categories]; + writeResultFilterValues(params, "category", categories, new Set(categories)); + writeResultFilterValues(params, "risk", selection.risks, new Set(formRiskFilterValues)); + writeResultFilterValues(params, "availability", selection.availability, new Set(formAvailabilityFilterValues)); +} + +export function formFilterSelectionSize(selection: FormFilterSelection): number { + return selection.categories.size + selection.risks.size + selection.availability.size; +} + +export function filterFormMatches( + matches: ReadonlyArray, + selection: FormFilterSelection, +): FormSearchMatch[] { + return matches.filter((match) => { + if (selection.categories.size > 0 && !selection.categories.has(formMatchCategory(match))) return false; + const risk = formMatchRisk(match); + if (selection.risks.size > 0 && (!risk || !selection.risks.has(risk))) return false; + if (selection.availability.size > 0 && !selection.availability.has(formMatchAvailability(match))) return false; + return true; + }); +} + +export function formFilterCandidateCount( + matches: ReadonlyArray, + selection: FormFilterSelection, + dimension: keyof FormFilterSelection, + value: string, +): number { + const next = new Set(selection[dimension] as ReadonlySet); + if (!next.has(value)) next.add(value); + return filterFormMatches(matches, { ...selection, [dimension]: next }).length; +} diff --git a/src/lib/medication-filters.ts b/src/lib/medication-filters.ts new file mode 100644 index 0000000000..bffb8d1bfe --- /dev/null +++ b/src/lib/medication-filters.ts @@ -0,0 +1,58 @@ +export const medicationScopeValues = new Set(["best", "all"] as const); +export type MedicationScope = "best" | "all"; + +export const medicationMatchValues = new Set(["all", "exact", "good", "related"] as const); +export type MedicationMatchQuality = "all" | "exact" | "good" | "related"; + +export const medicationSignalValues = new Set(["safety", "monitoring"] as const); +export type MedicationClinicalSignal = "safety" | "monitoring"; + +export type MedicationFilterRow = { + drugClass: string; + result: { + match: string; + action: string; + actionTone: "danger" | "warning" | "neutral"; + dose: string; + ceiling: string; + }; +}; + +export type MedicationFilters = { + match: MedicationMatchQuality; + classes: ReadonlySet; + signals: ReadonlySet; +}; + +export function medicationMatchQuality(match: string): Exclude | null { + if (match === "Exact clinical fit") return "exact"; + if (match === "Good clinical fit") return "good"; + if (match === "Related match") return "related"; + return null; +} + +export function medicationResultHasSignal( + result: MedicationFilterRow["result"], + signal: MedicationClinicalSignal, +): boolean { + if (signal === "safety") { + return result.actionTone !== "neutral" || /check|avoid|caution|ceiling|max/i.test(result.action); + } + return ( + result.actionTone === "warning" || + /monitor|level|review|renal|hepatic/i.test(`${result.action} ${result.dose} ${result.ceiling}`) + ); +} + +/** OR within class/signal groups, AND across groups. */ +export function medicationRowMatchesFilters(row: MedicationFilterRow, filters: MedicationFilters): boolean { + if (filters.match !== "all" && medicationMatchQuality(row.result.match) !== filters.match) return false; + if (filters.classes.size > 0 && !filters.classes.has(row.drugClass)) return false; + if ( + filters.signals.size > 0 && + ![...filters.signals].some((signal) => medicationResultHasSignal(row.result, signal)) + ) { + return false; + } + return true; +} diff --git a/src/lib/result-filter-url.ts b/src/lib/result-filter-url.ts new file mode 100644 index 0000000000..233c3ae2f4 --- /dev/null +++ b/src/lib/result-filter-url.ts @@ -0,0 +1,73 @@ +type ReadableSearchParams = Pick; + +function normalizeValues(values: Iterable, allowedValues: ReadonlySet): string[] { + return [...new Set(values)] + .map((value) => value.trim()) + .filter((value) => value.length > 0 && allowedValues.has(value)) + .sort((left, right) => left.localeCompare(right)); +} + +/** + * Read a deterministic comma-separated catalogue facet. Unknown values are + * ignored so stale links cannot create invisible filters. + */ +export function readResultFilterValues( + params: ReadableSearchParams, + key: string, + allowedValues: ReadonlySet, +): Value[] { + const raw = params.get(key); + if (!raw) return []; + return normalizeValues(raw.split(","), allowedValues) as Value[]; +} + +/** Write sorted values, omitting the parameter when no refinement remains. */ +export function writeResultFilterValues( + params: URLSearchParams, + key: string, + values: Iterable, + allowedValues: ReadonlySet, +): void { + const normalized = normalizeValues(values, allowedValues); + if (normalized.length === 0) params.delete(key); + else params.set(key, normalized.join(",")); +} + +/** Read a one-of-N value, falling back when the URL is absent or invalid. */ +export function readResultFilterValue( + params: ReadableSearchParams, + key: string, + allowedValues: ReadonlySet, + fallback: Value, +): Value { + const value = params.get(key); + return value && allowedValues.has(value as Value) ? (value as Value) : fallback; +} + +/** Write a one-of-N value and omit its default. */ +export function writeResultFilterValue( + params: URLSearchParams, + key: string, + value: Value, + fallback: Value, + allowedValues: ReadonlySet, +): void { + if (value === fallback || !allowedValues.has(value)) params.delete(key); + else params.set(key, value); +} + +/** + * Replace only filter-owned parameters while preserving the route, hash, query, + * comparison ids, focus intent and every unrelated navigation parameter. + */ +export function replaceResultFilterUrl(mutator: (params: URLSearchParams) => void): void { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + mutator(params); + const query = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`, + ); +} diff --git a/src/lib/search-scope-filter-chips.ts b/src/lib/search-scope-filter-chips.ts index 7f8e6e4987..19dd77ee1f 100644 --- a/src/lib/search-scope-filter-chips.ts +++ b/src/lib/search-scope-filter-chips.ts @@ -47,8 +47,8 @@ const listFilterKeys = Object.keys(listFilterLabels) as ListFilterKey[]; export type ScopeFilterChip = { /** Stable identity, also the token `removeScopeFilterValue` resolves. */ id: string; - /** "Topic: agitation" — the group is named because a bare value is ambiguous across 19 keys. */ - label: string; + groupLabel: string; + valueLabel: string; }; function chipId(key: string, value: string) { @@ -66,13 +66,14 @@ export function scopeFilterChips(filters: SearchScopeFilters | null | undefined) const chips: ScopeFilterChip[] = []; for (const key of listFilterKeys) { for (const value of filters[key] ?? []) { - chips.push({ id: chipId(key, value), label: `${listFilterLabels[key]}: ${value}` }); + chips.push({ id: chipId(key, value), groupLabel: listFilterLabels[key], valueLabel: value }); } } if (filters.locality) { chips.push({ id: chipId("locality", filters.locality), - label: `Locality: ${filters.locality === "local" ? "local" : "non-local"}`, + groupLabel: "Locality", + valueLabel: filters.locality === "local" ? "local" : "non-local", }); } return chips; diff --git a/tests/differential-stream-page.dom.test.tsx b/tests/differential-stream-page.dom.test.tsx index 038e1ae135..dd950a20a0 100644 --- a/tests/differential-stream-page.dom.test.tsx +++ b/tests/differential-stream-page.dom.test.tsx @@ -1,7 +1,7 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DifferentialStreamPage } from "@/components/differentials/differential-stream-page"; import { buildDifferentialStreamModel } from "@/lib/differential-stream"; @@ -17,7 +17,7 @@ vi.mock("next/navigation", () => ({ refresh: vi.fn(), }), usePathname: () => "/differentials/presentations", - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => new URLSearchParams(window.location.search), })); vi.mock("next/link", () => ({ @@ -28,8 +28,14 @@ vi.mock("next/link", () => ({ ), })); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + describe("DifferentialStreamPage", () => { - it("renders the presentations catalogue as a compact symptom-led pathway workspace", () => { + it("renders the presentations catalogue as a compact symptom-led pathway workspace", async () => { + const user = userEvent.setup(); render(); expect(screen.getByRole("heading", { level: 1, name: "Presentations" })).toBeInTheDocument(); @@ -40,7 +46,8 @@ describe("DifferentialStreamPage", () => { expect(screen.getByRole("heading", { level: 2, name: "All" })).toBeInTheDocument(); expect(screen.getByText("Symptom-led pathways with priority and safety cues")).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Presentation pathways" })).toBeInTheDocument(); - expect(screen.getByRole("group", { name: "Presentation priority" })).toBeInTheDocument(); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + expect(screen.getByRole("radiogroup", { name: "Clinical urgency" })).toBeInTheDocument(); expect(screen.getByText("High-priority presentation pathways")).toBeInTheDocument(); expect(screen.queryByText("Differentials: Presentations")).not.toBeInTheDocument(); @@ -75,10 +82,13 @@ describe("DifferentialStreamPage", () => { .find((item) => item?.status === "emergent"); expect(safetyItem).toBeTruthy(); - render(); + const view = render(); - const priorityGroup = screen.getByRole("group", { name: "Presentation priority" }); - await user.click(within(priorityGroup).getByRole("button", { name: "Urgent" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + const priorityGroup = screen.getByRole("radiogroup", { name: "Clinical urgency" }); + await user.click(within(priorityGroup).getByRole("radio", { name: /^Urgent/ })); + expect(new URL(window.location.href).searchParams.get("priority")).toBe("urgent"); + view.rerender(); expect(screen.queryByTestId(`differential-stream-card-${safetyItem!.slug}`)).not.toBeInTheDocument(); scrollIntoView.mockClear(); @@ -88,36 +98,41 @@ describe("DifferentialStreamPage", () => { }), ); + expect(new URL(window.location.href).searchParams.has("priority")).toBe(false); + view.rerender(); await waitFor(() => expect(screen.getByTestId(`differential-stream-card-${safetyItem!.slug}`)).toBeInTheDocument()); - expect(within(priorityGroup).getByRole("button", { name: "All priorities" })).toHaveAttribute( - "aria-pressed", - "true", - ); expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "center" })); }); it("filters presentations by priority and shared-candidate pathways", async () => { const user = userEvent.setup(); - render(); + const view = render(); const initialCards = screen.getAllByTestId(/^differential-stream-card-/).length; - const priorityGroup = screen.getByRole("group", { name: "Presentation priority" }); - await user.click(within(priorityGroup).getByRole("button", { name: "Emergent" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + const priorityGroup = screen.getByRole("radiogroup", { name: "Clinical urgency" }); + await user.click(within(priorityGroup).getByRole("radio", { name: /^Emergent/ })); + expect(new URL(window.location.href).searchParams.get("priority")).toBe("emergent"); + view.rerender(); const emergentCards = screen.getAllByTestId(/^differential-stream-card-/); expect(emergentCards.length).toBeLessThan(initialCards); expect(emergentCards.every((card) => card.dataset.status === "emergent")).toBe(true); - expect(screen.getByRole("button", { name: "Remove Emergent priority filter" })).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Remove Emergent priority filter" })); - const pathwayGroup = screen.getByRole("group", { name: "Related pathway filter" }); - await user.click(within(pathwayGroup).getByRole("button", { name: "Related pathways" })); + expect(screen.getByRole("button", { name: "Remove Priority: Emergent filter" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Remove Priority: Emergent filter" })); + expect(new URL(window.location.href).searchParams.has("priority")).toBe(false); + view.rerender(); + const pathwayGroup = screen.getByRole("radiogroup", { name: "Related pathways" }); + await user.click(within(pathwayGroup).getByRole("radio", { name: /^Related pathways/ })); + expect(new URL(window.location.href).searchParams.get("related")).toBe("1"); + view.rerender(); expect(screen.getAllByTestId(/^differential-stream-card-/).length).toBeLessThan(initialCards); - expect(screen.getByRole("button", { name: /Remove .+ related pathways filter/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Remove Related pathways: .+ filter/ })).toBeInTheDocument(); }); it("replaces match navigation with a removable focused-family filter", async () => { const user = userEvent.setup(); - render(); + const view = render(); expect(screen.getByRole("heading", { level: 1, name: "Diagnoses" })).toBeInTheDocument(); expect(screen.getByText("Compare likely causes and exclusion clues.")).toBeInTheDocument(); @@ -129,13 +144,18 @@ describe("DifferentialStreamPage", () => { expect(screen.queryByText(/Related cluster lit for/)).not.toBeInTheDocument(); const initialCards = screen.getAllByTestId(/^differential-stream-card-/).length; - await user.click(screen.getByRole("button", { name: "Focused family" })); + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + await user.click(screen.getByRole("radio", { name: /^Focused family/ })); + expect(new URL(window.location.href).searchParams.get("related")).toBe("1"); + view.rerender(); - const removeFamily = screen.getByRole("button", { name: /Remove .+ family filter/ }); + const removeFamily = screen.getByRole("button", { name: /Remove Family: .+ filter/ }); expect(removeFamily).toBeInTheDocument(); expect(screen.getAllByTestId(/^differential-stream-card-/).length).toBeLessThan(initialCards); await user.click(removeFamily); + expect(new URL(window.location.href).searchParams.has("related")).toBe(false); + view.rerender(); expect(screen.getAllByTestId(/^differential-stream-card-/)).toHaveLength(initialCards); }); @@ -151,6 +171,7 @@ describe("DifferentialStreamPage", () => { await user.click(byPresentation); expect(byUrgency).toHaveAttribute("aria-pressed", "false"); expect(byPresentation).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByTestId("differentials-stream-filter-trigger")).toHaveAccessibleName(/No filters active/); }); it("scrolls only for an explicit focus parameter", async () => { @@ -160,7 +181,8 @@ describe("DifferentialStreamPage", () => { initialRender.unmount(); scrollIntoView.mockClear(); - const focused = differentialDiagnosesCards[0]; + const focusedMatch = buildDifferentialStreamModel("diagnoses", "pain").items.find((item) => item.isMatch); + const focused = differentialDiagnosesCards.find((item) => item.href.endsWith(`/${focusedMatch?.slug ?? ""}`)); expect(focused).toBeTruthy(); const focusedSlug = focused!.href.split("/").at(-1); expect(focusedSlug).toBeTruthy(); diff --git a/tests/differentials-compare-selection.dom.test.tsx b/tests/differentials-compare-selection.dom.test.tsx index a021834579..75ea0f2dcb 100644 --- a/tests/differentials-compare-selection.dom.test.tsx +++ b/tests/differentials-compare-selection.dom.test.tsx @@ -143,9 +143,15 @@ describe("DifferentialsHome compare selection URL handoff", () => { expect(screen.getAllByText("Anorexia nervosa").length).toBeGreaterThan(0); }); + await act(async () => { + screen.getByTestId("differential-filter-trigger-phone").click(); + }); await act(async () => { screen.getByRole("radio", { name: "Presentations (0)" }).click(); }); + await act(async () => { + screen.getByTestId("differential-filter-panel-done").click(); + }); expect(screen.getByTestId("differentials-filter-empty-results")).toBeVisible(); expect(screen.getByRole("heading", { name: "No presentations in this result set" })).toBeVisible(); diff --git a/tests/document-filter-model.test.ts b/tests/document-filter-model.test.ts new file mode 100644 index 0000000000..6a1cce8236 --- /dev/null +++ b/tests/document-filter-model.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + filterDocumentsByRetrievalScope, + mergePublicDocumentScopeFilters, + sameDocumentScope, +} from "@/lib/document-filter-model"; +import type { ClinicalDocument } from "@/lib/types"; + +function document(id: string, site: string, risk: string, status = "current"): ClinicalDocument { + return { + id, + title: id, + description: null, + file_name: `${id}.pdf`, + file_type: "application/pdf", + file_size: 1, + storage_path: id, + status: "indexed", + page_count: 1, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { document_status: status }, + labels: [ + { id: `${id}-site`, document_id: id, label: site, label_type: "site", metadata: null, created_at: "" }, + { id: `${id}-risk`, document_id: id, label: risk, label_type: "risk", metadata: null, created_at: "" }, + ], + created_at: "", + updated_at: "", + } as ClinicalDocument; +} + +const documents = [document("a", "FSH", "High"), document("b", "RPH", "High"), document("c", "FSH", "Low")]; + +describe("document retrieval filter model", () => { + it("uses OR within a group and AND across groups", () => { + expect(filterDocumentsByRetrievalScope(documents, { sites: ["FSH", "RPH"], risks: ["High"] })).toHaveLength(2); + expect( + filterDocumentsByRetrievalScope(documents, { sites: ["FSH"], risks: ["High"] }).map((item) => item.id), + ).toEqual(["a"]); + }); + + it("preserves internal request constraints while public filters change", () => { + const merged = mergePublicDocumentScopeFilters( + { importBatchIds: ["00000000-0000-0000-0000-000000000001"], labelTypesAny: ["service"] }, + { risks: ["High"] }, + ); + expect(merged.risks).toEqual(["High"]); + expect(merged.importBatchIds).toHaveLength(1); + expect(merged.labelTypesAny).toEqual(["service"]); + }); + + it("compares order-independent scope state", () => { + expect(sameDocumentScope({ risks: ["High", "Low"] }, { risks: ["Low", "High"] })).toBe(true); + }); +}); diff --git a/tests/document-filter-panel.dom.test.tsx b/tests/document-filter-panel.dom.test.tsx index 6bc8a4a51d..97f21fa890 100644 --- a/tests/document-filter-panel.dom.test.tsx +++ b/tests/document-filter-panel.dom.test.tsx @@ -1,9 +1,9 @@ -import { render, screen, within } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; -import type { DocumentLabel, DocumentMatch } from "@/lib/types"; +import type { ClinicalDocument, DocumentLabel, DocumentMatch } from "@/lib/types"; vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -14,10 +14,16 @@ vi.mock("next/navigation", () => ({ forward: vi.fn(), prefetch: vi.fn(), }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => new URLSearchParams(window.location.search), usePathname: () => "/documents/search", })); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + vi.clearAllMocks(); +}); + vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => ({ status: "signed_out", @@ -69,6 +75,32 @@ const lithiumDoc = match({ labels: [label("22222222-2222-4222-8222-222222222222", "lithium", "medication")], }); +function sourceDocument(id: string, title: string, jurisdiction: string): ClinicalDocument { + return { + id, + title, + description: null, + file_name: `${id}.pdf`, + file_type: "application/pdf", + file_size: 1024, + storage_path: `documents/${id}.pdf`, + status: "indexed", + page_count: 2, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + jurisdiction, + }, + labels: [], + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-01T00:00:00.000Z", + }; +} + const baseProps = { matches: [clozapineDoc, lithiumDoc], recordMatches: [], @@ -96,9 +128,9 @@ function resultTitles() { async function openPanel(props = baseProps) { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); - return { user, panel: screen.getByTestId("document-filter-panel") }; + return { user, view, panel: screen.getByTestId("document-filter-panel") }; } describe("document filter panel", () => { @@ -119,64 +151,96 @@ describe("document filter panel", () => { expect(trigger).toHaveAttribute("aria-expanded", "true"); }); - it("carries source type and tag facets in one panel", async () => { - // Source type used to be a separate chip row in the ribbon on desktop and a - // native select on phones. One panel means one place to see and undo - // everything narrowing the list. + it("carries result type and smart-tag facets in one panel", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = screen.getByTestId("document-filter-panel"); - expect(within(panel).getByRole("radiogroup", { name: "Source type" })).toBeInTheDocument(); - // Mutually exclusive, so radio semantics rather than four independent toggles. + expect(within(panel).getByRole("radiogroup", { name: "Result type" })).toBeInTheDocument(); expect(within(panel).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); expect(within(panel).getByRole("button", { name: /Clozapine/ })).toBeInTheDocument(); }); - it("filters the result list when a facet is selected", async () => { + it("stages a local facet and commits it without running document retrieval", async () => { const user = userEvent.setup(); - render(); + const onDocumentFiltersApply = vi.fn(); + const props = { ...baseProps, onDocumentFiltersApply }; + const view = render(); expect(resultTitles()).toHaveLength(2); await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); + expect(resultTitles()).toHaveLength(2); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toEqual([expect.stringContaining("Clozapine Monitoring Protocol")]); expect(screen.getByTestId("document-filter-trigger-phone")).toHaveTextContent("1"); + expect(onDocumentFiltersApply).not.toHaveBeenCalled(); }); - it("filters by source type from the same panel", async () => { + it("stages result type from the same panel", async () => { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("radio", { name: /Tables/ })); - // Only the clozapine document carries tables. + expect(resultTitles()).toHaveLength(2); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toEqual([expect.stringContaining("Clozapine Monitoring Protocol")]); }); + it("performs one retrieval when staged source scope changes", async () => { + const user = userEvent.setup(); + const onDocumentFiltersApply = vi.fn(); + const recentDocuments = [ + sourceDocument(clozapineDoc.document_id, clozapineDoc.title, "WA"), + sourceDocument(lithiumDoc.document_id, lithiumDoc.title, "National"), + ]; + render( + , + ); + + await user.click(screen.getByTestId("document-filter-trigger-phone")); + const panel = screen.getByTestId("document-filter-panel"); + await user.click(within(panel).getByRole("radio", { name: /^Local \(1\)$/ })); + expect(onDocumentFiltersApply).not.toHaveBeenCalled(); + await user.click(within(panel).getByTestId("document-filter-panel-done")); + + expect(onDocumentFiltersApply).toHaveBeenCalledTimes(1); + expect(onDocumentFiltersApply).toHaveBeenCalledWith(expect.objectContaining({ locality: "local" }), []); + }); + it("hides Clear filters until a document filter is active", async () => { const { panel } = await openPanel(); expect(within(panel).queryByTestId("document-filter-panel-clear")).toBeNull(); }); - it("clears both filter kinds at once", async () => { + it("clears staged result and smart-tag refinements at once", async () => { const user = userEvent.setup(); - render(); + window.history.replaceState(null, "", "/documents/search?resultType=tables&facet=medication%3Aclozapine"); + const view = render(); + expect(resultTitles()).toHaveLength(1); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = () => screen.getByTestId("document-filter-panel"); - await user.click(within(panel()).getByRole("radio", { name: /Tables/ })); - await user.click(within(panel()).getByRole("button", { name: /Clozapine/ })); - expect(resultTitles()).toHaveLength(1); - await user.click(within(panel()).getByTestId("document-filter-panel-clear")); + await user.click(within(panel()).getByTestId("document-filter-panel-done")); + view.rerender(); expect(resultTitles()).toHaveLength(2); - expect(within(panel()).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + expect(new URLSearchParams(window.location.search).has("resultType")).toBe(false); + expect(new URLSearchParams(window.location.search).has("facet")).toBe(false); }); it("is a dialog, so it overlays the results rather than pushing them down", async () => { @@ -234,13 +298,13 @@ describe("document filter panel", () => { expect(document.body.style.overflow).not.toBe("hidden"); }); - it("closes on Show N documents", async () => { + it("closes on Update search", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); - const done = within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done"); - expect(done).toHaveTextContent("Show 2 documents"); + const done = within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done"); + expect(done).toHaveTextContent("Update search"); await user.click(done); expect(screen.queryByTestId("document-filter-panel")).toBeNull(); @@ -262,45 +326,55 @@ describe("document filter panel", () => { }); describe("applied-filter shelf", () => { - async function selectClozapine(user: ReturnType) { + async function selectClozapine( + user: ReturnType, + view: ReturnType, + props = baseProps, + ) { await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); - await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + view.rerender(); } it("shows an applied facet as a chip and removes it in one tap", async () => { const user = userEvent.setup(); - render(); + const view = render(); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); - await selectClozapine(user); + await selectClozapine(user, view); const shelf = screen.getByTestId("search-query-ribbon-shelf"); expect(shelf).toHaveTextContent("Filtered by"); expect(resultTitles()).toHaveLength(1); // One tap, and the count follows immediately. - await user.click(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })); + await user.click(within(shelf).getByRole("button", { name: /Remove Medication: Clozapine filter/ })); + view.rerender(); expect(resultTitles()).toHaveLength(2); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); }); it("carries the source type alongside facets, and Clear tears both down at once", async () => { const user = userEvent.setup(); - render(); + const view = render(); await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = screen.getByTestId("document-filter-panel"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); await user.click(within(panel).getByRole("button", { name: /Clozapine/ })); await user.click(within(panel).getByRole("radio", { name: /Tables/ })); - await user.click(within(panel).getByTestId("document-filter-done")); + await user.click(within(panel).getByTestId("document-filter-panel-done")); + view.rerender(); const shelf = screen.getByTestId("search-query-ribbon-shelf"); - expect(within(shelf).getByRole("button", { name: /Remove Clozapine filter/ })).toBeInTheDocument(); - expect(within(shelf).getByRole("button", { name: /Remove Tables filter/ })).toBeInTheDocument(); + expect(within(shelf).getByRole("button", { name: /Remove Medication: Clozapine filter/ })).toBeInTheDocument(); + expect(within(shelf).getByRole("button", { name: /Remove Result type: Tables filter/ })).toBeInTheDocument(); // Clear appears only past one chip — teardown that used to be one tap each. await user.click(within(shelf).getByTestId("search-query-ribbon-shelf-clear")); + view.rerender(); expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); expect(resultTitles()).toHaveLength(2); }); @@ -308,7 +382,11 @@ describe("applied-filter shelf", () => { it("survives a pending search, because chips must not flicker on every keystroke", async () => { const user = userEvent.setup(); const { rerender } = render(); - await selectClozapine(user); + await user.click(screen.getByTestId("document-filter-trigger-phone")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /^Medication/ })); + await user.click(within(screen.getByTestId("document-filter-panel")).getByRole("button", { name: /Clozapine/ })); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); + rerender(); expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); rerender(); @@ -316,14 +394,14 @@ describe("applied-filter shelf", () => { expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); }); - it("drops only on a fault, where filtering a set that never loaded is meaningless", async () => { + it("keeps applied recovery visible when retrieval faults", async () => { const user = userEvent.setup(); - const { rerender } = render(); - await selectClozapine(user); + const view = render(); + await selectClozapine(user, view); - rerender(); + view.rerender(); - expect(screen.queryByTestId("search-query-ribbon-shelf")).toBeNull(); + expect(screen.getByTestId("search-query-ribbon-shelf")).toBeInTheDocument(); }); }); @@ -389,29 +467,32 @@ describe("filter sheet — density, exclusivity and reach", () => { const denseProps = { ...baseProps, matches: [denseDoc, clozapineDoc, lithiumDoc], documentCount: 2014 }; it("puts every facet on the tap floor, not the 28px it shipped with", async () => { - const { panel } = await openPanel(); + const { user, panel } = await openPanel(); // The sheet is the primary phone filtering surface and these are its only // interactive elements. `min-h-7` was 28px, packed at `gap-1.5`, so a // neighbouring mis-tap was likely. + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); const facet = within(panel).getByRole("button", { name: /Clozapine/ }); expect(facet.className).toContain("min-h-tap"); expect(facet.className).not.toContain("min-h-7"); - // Compact density returns from `sm`, where a pointer is likely. - expect(facet.className).toContain("sm:min-h-9"); - expect(facet.className).toContain("lg:min-h-8"); + // Pointer layouts remain compact without dropping below the filter + // system's 40px desktop target floor. + expect(facet.className).toContain("sm:min-h-10"); + expect(facet.className).not.toContain("lg:min-h-8"); }); it("says which group replaces and which accumulate", async () => { - const { panel } = await openPanel(); + const { user, panel } = await openPanel(); - // Source type is a radiogroup and the facets are aria-pressed toggles, but + // Result type is a radiogroup and the facets are aria-pressed toggles, but // both rendered as chips of near-identical size, colour and radius, directly // adjacent — so the OR-within-group, AND-across-groups model had to be // discovered by experiment. The joined control is the shape cue; this is the // words. expect(within(panel).getByText("one only")).toBeVisible(); expect(within(panel).getByRole("radio", { name: /All/ })).toHaveAttribute("aria-checked", "true"); + await user.click(within(panel).getByRole("button", { name: /^Medication/ })); expect(within(panel).getByRole("button", { name: /Clozapine/ })).toHaveAttribute("aria-pressed", "false"); }); @@ -502,7 +583,7 @@ describe("filter sheet — density, exclusivity and reach", () => { expect(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-find")).toHaveValue( "clozapine", ); - await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-done")); + await user.click(within(screen.getByTestId("document-filter-panel")).getByTestId("document-filter-panel-done")); rerender(); await user.click(screen.getByTestId("document-filter-trigger-phone")); @@ -524,14 +605,12 @@ describe("filter sheet — density, exclusivity and reach", () => { expect(within(panel).getByRole("button", { name: /Community/ })).toBeVisible(); }); - it("shows neither the field nor a collapse control for a handful of groups", async () => { + it("keeps dense search for the unified retrieval and result sections", async () => { const { panel } = await openPanel(); - // Below the threshold every group is open permanently, so a heading that - // advertised a collapse would be a control that does nothing. - expect(within(panel).queryByTestId("document-filter-panel-find")).toBeNull(); - expect(within(panel).queryByRole("button", { name: /^Medication/ })).toBeNull(); - expect(within(panel).getByRole("button", { name: /Clozapine/ })).toBeVisible(); + expect(within(panel).getByTestId("document-filter-panel-find")).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Medication/ })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: /^Source status/ })).toBeVisible(); }); it("moves Library off the rail into the sheet footer", async () => { @@ -541,7 +620,7 @@ describe("filter sheet — density, exclusivity and reach", () => { // questions, and Library occupied the space the pinned Filter needs. expect(screen.queryByRole("button", { name: "Open source library" })).toBeNull(); - const browse = within(panel).getByTestId("document-filter-browse-library"); + const browse = within(panel).getByRole("button", { name: /Browse all sources/ }); expect(browse).toHaveTextContent("Browse all sources"); // The corpus count, beside it — reach, stated as a size. expect(browse).toHaveTextContent("2,014"); @@ -552,9 +631,10 @@ describe("filter sheet — density, exclusivity and reach", () => { it("states the proportion once, and warns when the combination returns nothing", async () => { const { user, panel } = await openPanel(denseProps); - expect(within(panel).getByText(/of 2,014 documents shown/)).toBeVisible(); - // The footer no longer prints the number twice: the button carries it. - expect(within(panel).getByTestId("document-filter-done")).toHaveTextContent("Show 3 documents"); + expect( + within(panel).getByRole("progressbar", { name: "Visible retrieved matches" }).parentElement, + ).toHaveTextContent("3 of 3 retrieved matches visible"); + expect(within(panel).getByTestId("document-filter-panel-done")).toHaveTextContent("Update search"); // Clozapine and Suicide sit on different fixture documents, so the pair // would return nothing. The panel does not let you build that: once @@ -579,13 +659,9 @@ describe("filter sheet — density, exclusivity and reach", () => { await user.click(suicide); // Unchanged: the guarded click did not empty the list behind the reader. - expect(within(panel).getByTestId("document-filter-done")).toHaveTextContent("Show 1 document"); - // The number is its own span so it can carry the emphasis, so assert the - // containing paragraph rather than the matched node. + expect(within(panel).getByTestId("document-filter-panel-done")).toHaveTextContent("Update search"); expect( - within(panel) - .getByText(/of 2,014 documents shown/) - .closest("p"), - ).toHaveTextContent("1 of 2,014 documents shown"); + within(panel).getByRole("progressbar", { name: "Visible retrieved matches" }).parentElement, + ).toHaveTextContent("1 of 3 retrieved matches visible"); }); }); diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx index 9f5e2a80e8..1f96b9a602 100644 --- a/tests/document-search-scope-zero-results.dom.test.tsx +++ b/tests/document-search-scope-zero-results.dom.test.tsx @@ -236,9 +236,9 @@ describe("documents zero-result state when retrieval degraded", () => { describe("scope filter chips", () => { it("names the group so a bare value is not ambiguous", () => { expect(scopeFilterChips({ topics: ["agitation"], sites: ["FSH"], locality: "local" })).toEqual([ - { id: "scope:topics:agitation", label: "Topic: agitation" }, - { id: "scope:sites:FSH", label: "Site: FSH" }, - { id: "scope:locality:local", label: "Locality: local" }, + { id: "scope:topics:agitation", groupLabel: "Topic", valueLabel: "agitation" }, + { id: "scope:sites:FSH", groupLabel: "Site", valueLabel: "FSH" }, + { id: "scope:locality:local", groupLabel: "Locality", valueLabel: "local" }, ]); }); diff --git a/tests/dsm-search-empty-state.dom.test.tsx b/tests/dsm-search-empty-state.dom.test.tsx index 15922fc60d..3a001c687b 100644 --- a/tests/dsm-search-empty-state.dom.test.tsx +++ b/tests/dsm-search-empty-state.dom.test.tsx @@ -3,10 +3,11 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { DsmSearchPage } from "@/components/dsm/dsm-search-page"; -import type { DsmCategory } from "@/lib/dsm"; +import type { DsmCategory, DsmDiagnosisSummary } from "@/lib/dsm"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(window.location.search), })); // `/dsm/search` rendered "No diagnosis matches" as an `

` until it adopted @@ -35,14 +36,65 @@ describe("DsmSearchPage empty state", () => { expect(screen.getByTestId("search-query-ribbon")).toBeVisible(); expect(screen.getByRole("heading", { level: 1, name: "Delirium" })).toBeVisible(); - expect(screen.getByTestId("dsm-category-filter")).toBeVisible(); - expect(screen.getByTestId("dsm-category-filter")).toHaveTextContent("12"); + expect(screen.getByTestId("dsm-category-filter-desktop")).toBeVisible(); const phoneFilter = screen.getByTestId("dsm-category-filter-phone"); expect(phoneFilter).toHaveAccessibleName(/Filter\s*No filters active/); await user.click(phoneFilter); expect(screen.getByRole("dialog", { name: "Filter DSM diagnoses" })).toBeVisible(); - expect(screen.getByRole("radiogroup", { name: "Category" })).toBeVisible(); - expect(screen.getByRole("radio", { name: "All categories (12)" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("group", { name: "Category" })).toBeVisible(); + expect(screen.getByRole("button", { name: /Mood disorders \(0\)/ })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("group", { name: "Specifier support" })).toBeVisible(); + expect(screen.getByRole("group", { name: "Differential guidance" })).toBeVisible(); + }); + + it("accepts comma-separated categories and preserves comparison ids while refining", async () => { + const user = userEvent.setup(); + const anxiety = { + key: "anxiety", + label: "Anxiety disorders", + css_class: "anxiety", + color: "#654321", + diagnosis_count: 8, + }; + const results: DsmDiagnosisSummary[] = [ + { + slug: "mood-1", + title: "Mood one", + icd_code: "F30", + category: { key: "mood", label: "Mood disorders" }, + summary: "Mood summary", + criteriaCount: 2, + differentialCount: 1, + specifierCount: 1, + }, + { + slug: "anxiety-1", + title: "Anxiety one", + icd_code: "F40", + category: { key: "anxiety", label: "Anxiety disorders" }, + summary: "Anxiety summary", + criteriaCount: 2, + differentialCount: 1, + specifierCount: 0, + }, + ]; + window.history.replaceState(null, "", "/dsm/search?q=review&ids=mood-1,anxiety-1&category=mood"); + + render( + , + ); + await user.click(screen.getByTestId("dsm-category-filter-phone")); + await user.click(screen.getByRole("button", { name: /Anxiety disorders \(2\)/ })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("category")).toBe("anxiety,mood"); + expect(params.get("ids")).toBe("mood-1,anxiety-1"); + expect(params.get("q")).toBe("review"); }); }); diff --git a/tests/factsheets-search-page.dom.test.tsx b/tests/factsheets-search-page.dom.test.tsx index 09410628b8..3ef35691a1 100644 --- a/tests/factsheets-search-page.dom.test.tsx +++ b/tests/factsheets-search-page.dom.test.tsx @@ -4,9 +4,9 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -const push = vi.fn(); +const replace = vi.fn(); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push }), + useRouter: () => ({ replace }), })); import { FactsheetsSearchPage } from "@/components/factsheets/factsheets-search-page"; @@ -45,14 +45,14 @@ describe("FactsheetsSearchPage category filter", () => { it("selecting a category narrows within the query rather than replacing it", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render(); const desktopGroup = screen.getByRole("radiogroup", { name: "Category" }); await user.click(within(desktopGroup).getByRole("radio", { name: "Medications (1)" })); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; // Both params survive — category narrows, it does not replace the search. expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).toContain("category=Medications"); @@ -60,21 +60,21 @@ describe("FactsheetsSearchPage category filter", () => { it("clearing the category preserves the query", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render(); await user.click(screen.getByTestId("factsheet-filter-trigger-phone")); await user.click(screen.getByTestId("factsheet-filter-panel-clear")); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).not.toContain("category="); }); it("keeps a selected zero-count category as an explained, inert dead end", async () => { const user = userEvent.setup(); - push.mockClear(); + replace.mockClear(); render( , ); @@ -90,7 +90,7 @@ describe("FactsheetsSearchPage category filter", () => { expect(within(desktopGroup).queryByRole("radio", { name: "Tests & procedures (0)" })).not.toBeInTheDocument(); await user.click(selectedZero); - expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); await user.click(screen.getByTestId("factsheet-filter-trigger-phone")); const sheet = screen.getByTestId("factsheet-filter-panel"); @@ -100,11 +100,11 @@ describe("FactsheetsSearchPage category filter", () => { expect(within(sheet).getByText(selectedZeroMessage)).toBeInTheDocument(); await user.click(sheetSelectedZero); - expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); await user.click(within(sheetGroup).getByRole("radio", { name: "Medications (1)" })); - expect(push).toHaveBeenCalledTimes(1); - const href = push.mock.calls[0][0] as string; + expect(replace).toHaveBeenCalledTimes(1); + const href = replace.mock.calls[0][0] as string; expect(href).toContain(`q=${encodeURIComponent(query)}`); expect(href).toContain("category=Medications"); }); diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index 5d98170a2d..ff22f5a508 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -252,7 +252,7 @@ describe("favourites auth gate DOM", () => { it("keeps Tools Saved workflows available when Favourites access is granted", () => { render(); - expect(screen.getByRole("radio", { name: "Saved" })).toBeVisible(); + expect(screen.getByRole("radio", { name: "Saved (1)" })).toBeVisible(); expect(screen.getByTestId("tool-shortcut-favourites")).toBeVisible(); expect( toolCatalogRecordsForSession({ authenticated: true, demoMode: false }).some((t) => t.id === "favourites"), diff --git a/tests/favourites-empty-state.dom.test.tsx b/tests/favourites-empty-state.dom.test.tsx index 77dd9c9c12..22c2acf5a5 100644 --- a/tests/favourites-empty-state.dom.test.tsx +++ b/tests/favourites-empty-state.dom.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; @@ -43,4 +44,31 @@ describe("favourites empty rendering", () => { screen.getAllByRole("status").filter((status) => status.textContent?.includes("No favourites match")), ).toHaveLength(1); }); + + it("uses Set and Type facets while keeping Recently used outside the filter count", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByTestId("favourites-filter-rail")).toBeNull(); + const trigger = screen.getByTestId("favourites-filter-trigger-phone"); + expect(trigger).toHaveAccessibleName(/No filters active/); + + await user.click(trigger); + const panel = screen.getByTestId("favourites-filter-panel"); + expect(within(panel).getByRole("button", { name: "Set" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Type" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Pinned" })).toHaveAttribute("aria-expanded", "false"); + expect(within(panel).getByRole("button", { name: "Source support" })).toHaveAttribute("aria-expanded", "false"); + + await user.click(within(panel).getByRole("button", { name: "Pinned" })); + await user.click(within(panel).getByRole("button", { name: /^Pinned only \(/ })); + expect(trigger).toHaveAccessibleName(/1 filter active/); + await user.click(within(panel).getByTestId("favourites-filter-panel-done")); + expect(screen.getByRole("button", { name: "Remove Status: Pinned filter" })).toBeVisible(); + + const recent = screen.getByRole("button", { name: "Recently used" }); + await user.click(recent); + expect(recent).toHaveAttribute("aria-pressed", "true"); + expect(trigger).toHaveAccessibleName(/1 filter active/); + }); }); diff --git a/tests/form-filters.test.ts b/tests/form-filters.test.ts new file mode 100644 index 0000000000..15b8ef9ec9 --- /dev/null +++ b/tests/form-filters.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + filterFormMatches, + formFilterSelectionFromParams, + writeFormFilterSelection, + type FormFilterSelection, +} from "@/lib/form-filters"; +import type { FormSearchMatch } from "@/lib/form-ranker"; + +function match( + id: string, + category: string, + riskLevel: "high" | "medium" | "low", + availability: "downloadable" | "contact_ocp" | "unavailable", +): FormSearchMatch { + return { + service: { + id, + slug: id, + title: id, + catalogPayload: { form: id, name: id, category, riskLevel, availability }, + }, + score: 1, + reasons: [], + } as FormSearchMatch; +} + +const matches = [ + match("a", "Orders", "high", "downloadable"), + match("b", "Orders", "medium", "contact_ocp"), + match("c", "Notices", "low", "unavailable"), +]; + +describe("form filters", () => { + it("uses OR within a facet and AND across facets", () => { + const selection: FormFilterSelection = { + categories: new Set(["Orders"]), + risks: new Set(["high", "medium"]), + availability: new Set(["downloadable"]), + }; + expect(filterFormMatches(matches, selection).map((item) => item.service.slug)).toEqual(["a"]); + }); + + it("ignores invalid URL values and writes stable query-preserving state", () => { + const params = new URLSearchParams("q=transfer&focus=1&category=Unknown,Orders&risk=bogus,high"); + const selection = formFilterSelectionFromParams(params, ["Orders", "Notices"]); + expect([...selection.categories]).toEqual(["Orders"]); + expect([...selection.risks]).toEqual(["high"]); + writeFormFilterSelection(params, selection); + expect(params.get("q")).toBe("transfer"); + expect(params.get("focus")).toBe("1"); + expect(params.get("category")).toBe("Orders"); + expect(params.get("risk")).toBe("high"); + }); +}); diff --git a/tests/forms-search-filters.dom.test.tsx b/tests/forms-search-filters.dom.test.tsx new file mode 100644 index 0000000000..8c30b130e2 --- /dev/null +++ b/tests/forms-search-filters.dom.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { deriveFormCategories, filterFormMatches } from "@/lib/form-filters"; +import { formRecords } from "@/lib/forms"; +import { rankFormRecords } from "@/lib/form-ranker"; + +const navigation = vi.hoisted(() => ({ push: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: navigation.push }), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ + status: "ready", + records: formRecords, + total: formRecords.length, + demoMode: true, + governance: {}, + refetch: vi.fn(), + }), +})); + +vi.mock("@/components/use-result-sort", () => ({ + useResultSort: () => ["relevance", vi.fn()] as const, +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + navigation.push.mockReset(); +}); + +function escapePattern(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +describe("FormsSearchResultsPage filters", () => { + it("renders functional category, clinical-risk, and availability facets", async () => { + const user = userEvent.setup(); + const query = "form"; + window.history.replaceState(null, "", "/forms?q=form"); + const matches = rankFormRecords(formRecords, query); + const category = deriveFormCategories(matches).find((candidate) => { + const filtered = filterFormMatches(matches, { + categories: new Set([candidate]), + risks: new Set(), + availability: new Set(), + }); + return filtered.length > 0 && filtered.length < matches.length; + }); + expect(category).toBeTruthy(); + + const view = render(); + await user.click(screen.getByTestId("form-filter-trigger-phone")); + const panel = screen.getByTestId("form-filter-panel"); + expect(within(panel).getByRole("group", { name: "Category" })).toBeVisible(); + expect(within(panel).getByRole("group", { name: "Clinical risk" })).toBeVisible(); + expect(within(panel).getByRole("group", { name: "Availability" })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Downloadable \(/ })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Contact required \(/ })).toBeVisible(); + expect(within(panel).getByRole("button", { name: /^Unavailable \(/ })).toBeVisible(); + + const categoryOption = within(within(panel).getByRole("group", { name: "Category" })).getByRole("button", { + name: new RegExp(`^${escapePattern(category!)} \\(`), + }); + await user.click(categoryOption); + + expect(window.location.search).toContain("q=form"); + expect(window.location.search).toContain("category="); + view.rerender(); + + const expected = filterFormMatches(matches, { + categories: new Set([category!]), + risks: new Set(), + availability: new Set(), + }); + expect(screen.getByRole("status")).toHaveTextContent( + `${expected.length} ${expected.length === 1 ? "form" : "forms"}`, + ); + expect(screen.getByRole("button", { name: `Remove Category: ${category} filter` })).toBeVisible(); + }); +}); diff --git a/tests/formulation-search-filters.dom.test.tsx b/tests/formulation-search-filters.dom.test.tsx new file mode 100644 index 0000000000..6d17f3caaf --- /dev/null +++ b/tests/formulation-search-filters.dom.test.tsx @@ -0,0 +1,55 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; +import { searchFormulationMechanisms } from "@/lib/formulation"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("FormulationHomePage filters", () => { + it("keeps one OR facet while round-tripping domain state in the URL", async () => { + const user = userEvent.setup(); + const query = "coping"; + const domain = searchFormulationMechanisms(query)[0]?.mechanism.domains[0]; + expect(domain).toBeTruthy(); + window.history.replaceState(null, "", "/formulation?q=coping&focus=1"); + + const view = render(); + await user.click(screen.getByTestId("formulation-filter-trigger-phone")); + const panel = screen.getByTestId("formulation-filter-panel"); + expect(within(panel).getAllByRole("group", { name: "Domain" })).toHaveLength(1); + expect(within(panel).getByText(/Domains combine with OR/)).toBeVisible(); + await user.click(within(panel).getByRole("button", { name: new RegExp(`^${domain} \\(`) })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("domain")).toBe(domain); + expect(params.get("q")).toBe("coping"); + expect(params.get("focus")).toBe("1"); + view.rerender(); + expect(screen.getByRole("button", { name: `Remove Domain: ${domain} filter` })).toBeVisible(); + }); +}); diff --git a/tests/medication-filters.test.ts b/tests/medication-filters.test.ts new file mode 100644 index 0000000000..eb79d44afd --- /dev/null +++ b/tests/medication-filters.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { + medicationResultHasSignal, + medicationRowMatchesFilters, + type MedicationFilterRow, +} from "@/lib/medication-filters"; + +function row( + drugClass: string, + match: string, + action: string, + actionTone: "danger" | "warning" | "neutral" = "neutral", +): MedicationFilterRow { + return { + drugClass, + result: { match, action, actionTone, dose: "10 mg", ceiling: "20 mg" }, + }; +} + +describe("medication filters", () => { + it("uses OR within class and signal groups and AND across groups", () => { + const antipsychoticSafety = row("Antipsychotic", "Exact clinical fit", "Avoid when ANC is low", "danger"); + const antidepressantMonitoring = row("Antidepressant", "Good clinical fit", "Monitor sodium", "warning"); + const moodStabiliser = row("Mood stabiliser", "Related match", "Open reference"); + const filters = { + match: "all" as const, + classes: new Set(["Antipsychotic", "Antidepressant"]), + signals: new Set(["safety", "monitoring"] as const), + }; + + expect(medicationRowMatchesFilters(antipsychoticSafety, filters)).toBe(true); + expect(medicationRowMatchesFilters(antidepressantMonitoring, filters)).toBe(true); + expect(medicationRowMatchesFilters(moodStabiliser, filters)).toBe(false); + }); + + it("separates match quality without changing row order", () => { + const rows = [ + row("A", "Exact clinical fit", "Open reference"), + row("A", "Good clinical fit", "Open reference"), + row("A", "Related match", "Open reference"), + ]; + const visible = rows.filter((item) => + medicationRowMatchesFilters(item, { match: "good", classes: new Set(), signals: new Set() }), + ); + + expect(visible).toEqual([rows[1]]); + }); + + it("keeps safety and monitoring as overlapping clinical signals", () => { + const warning = row("A", "Exact clinical fit", "Review renal function", "warning"); + expect(medicationResultHasSignal(warning.result, "safety")).toBe(true); + expect(medicationResultHasSignal(warning.result, "monitoring")).toBe(true); + }); +}); diff --git a/tests/medication-prescribing-workspace.dom.test.tsx b/tests/medication-prescribing-workspace.dom.test.tsx index d9037e53e0..65ad55f4e5 100644 --- a/tests/medication-prescribing-workspace.dom.test.tsx +++ b/tests/medication-prescribing-workspace.dom.test.tsx @@ -1,15 +1,19 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; -// The prescribing results view filters a medication catalogue through a -// best/indication/safety/monitoring lens strip. The catalogue hook fetches +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +// The prescribing results view filters a medication catalogue through scope, +// match-quality, class and clinical-signal controls. The catalogue hook fetches // `/api/medications` (and reads the auth session), so it is mocked with a fixed -// set of results chosen to land in different filter buckets; the filter strip is -// the unit under test. Only usePatientProfile needs a real provider (the profile -// stays empty here, so no per-patient alert badges are computed). +// set of results chosen to land in different filter buckets. Only +// usePatientProfile needs a real provider (the profile stays empty here, so no +// per-patient alert badges are computed). type Result = { id: string; @@ -23,7 +27,7 @@ type Result = { tone: "teal" | "blue" | "slate"; }; -// Clozapine: danger + exact fit → best, indication, safety (not monitoring). +// Clozapine: danger + exact fit → Safety (not Monitoring). const clozapine: Result = { id: "clozapine", name: "Clozapine", @@ -35,7 +39,7 @@ const clozapine: Result = { actionTone: "danger", tone: "teal", }; -// Lithium: warning + monitor language → every filter. +// Lithium: warning + monitor language → both clinical signals. const lithium: Result = { id: "lithium", name: "Lithium", @@ -47,7 +51,7 @@ const lithium: Result = { actionTone: "warning", tone: "blue", }; -// Sertraline: neutral + related match → best only. +// Sertraline: neutral + related match → neither clinical signal. const sertraline: Result = { id: "sertraline", name: "Sertraline", @@ -118,14 +122,15 @@ function rowVisible(name: string): boolean { return screen.queryAllByText(name).length > 0; } -// The rail is a one-of-N lens, so its options are radios in a radiogroup, not -// pressed toggles. The accessible name carries the count as "Best (3)". +// Match quality is a one-of-N lens, so its options are radios in a radiogroup. function filterButton(label: string): HTMLElement { return screen.getByRole("radio", { name: new RegExp(`^${label}`, "i") }); } afterEach(() => { catalogInterpretation.current = undefined; + window.history.replaceState(null, "", "/"); + cleanup(); vi.restoreAllMocks(); }); @@ -190,44 +195,58 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { }); }); -describe("MedicationPrescribingWorkspace — result filter strip", () => { - it("labels each lens with the count of matching results", () => { +describe("MedicationPrescribingWorkspace — refined filters", () => { + function openFilters() { + fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); + } + + it("separates match quality from overlapping clinical signals with projected counts", () => { renderWorkspace(); - // best = 3 (all), indication = 2 (exact-fit), safety = 2 (non-neutral), monitor = 1. - expect(filterButton("Best").textContent).toContain("3"); - expect(filterButton("Indication").textContent).toContain("2"); - expect(filterButton("Safety").textContent).toContain("2"); - expect(filterButton("Monitor").textContent).toContain("1"); + openFilters(); + + expect(filterButton("All qualities").textContent).toContain("3"); + expect(filterButton("Exact clinical fit").textContent).toContain("2"); + expect(filterButton("Related match").textContent).toContain("1"); + expect(screen.getByRole("button", { name: /^Safety \(2\)$/ })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: /^Monitoring \(1\)$/ })).toHaveAttribute("aria-pressed", "false"); }); - it("defaults to the Best lens with every result shown", () => { + it("defaults to Best matches and all match qualities with ranked rows unchanged", () => { renderWorkspace(); - expect(filterButton("Best")).toHaveAttribute("aria-checked", "true"); - expect(filterButton("Safety")).toHaveAttribute("aria-checked", "false"); + openFilters(); + + expect(screen.getByRole("radio", { name: /Best matches/ })).toBeChecked(); + expect(filterButton("All qualities")).toHaveAttribute("aria-checked", "true"); expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Lithium")).toBe(true); expect(rowVisible("Sertraline")).toBe(true); }); - it("narrows to indication-relevant results and drops related-only matches", () => { + it("round-trips a match-quality URL refinement without changing patient safety chrome", () => { renderWorkspace(); - fireEvent.click(filterButton("Indication")); + openFilters(); + fireEvent.click(filterButton("Exact clinical fit")); + expect(new URLSearchParams(window.location.search).get("match")).toBe("exact"); - expect(filterButton("Indication")).toHaveAttribute("aria-checked", "true"); - expect(filterButton("Best")).toHaveAttribute("aria-checked", "false"); + cleanup(); + renderWorkspace(); expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Lithium")).toBe(true); - // Sertraline is a "Related match", so it leaves the Indication lens. expect(rowVisible("Sertraline")).toBe(false); + expect(screen.getByText(/Patient details/i)).toBeInTheDocument(); }); - it("narrows the Monitor lens to results with monitoring signals only", () => { + it("keeps Safety and Monitoring as OR values in one clinical-signal facet", () => { renderWorkspace(); - fireEvent.click(filterButton("Monitor")); + openFilters(); + fireEvent.click(screen.getByRole("button", { name: /^Safety \(2\)$/ })); + fireEvent.click(screen.getByRole("button", { name: /^Monitoring \(1\)$/ })); + expect(new URLSearchParams(window.location.search).get("signal")).toBe("monitoring,safety"); - expect(filterButton("Monitor")).toHaveAttribute("aria-checked", "true"); + cleanup(); + renderWorkspace(); expect(rowVisible("Lithium")).toBe(true); - expect(rowVisible("Clozapine")).toBe(false); + expect(rowVisible("Clozapine")).toBe(true); expect(rowVisible("Sertraline")).toBe(false); }); }); diff --git a/tests/mobile-interaction-regressions.test.ts b/tests/mobile-interaction-regressions.test.ts index 80ca857271..50250bbe09 100644 --- a/tests/mobile-interaction-regressions.test.ts +++ b/tests/mobile-interaction-regressions.test.ts @@ -91,7 +91,8 @@ describe("mobile interaction regressions", () => { expect(tools).not.toContain("Sort by"); expect(tools).not.toContain("hasMenu"); expect(tools).toContain('label: "Saved", desktopLabel: "Favourites"'); - expect(tools).toMatch(/effectiveFilter === "more"\s*\?\s*app\.area === "coordination" \|\| app\.area === "saved"/); + expect(tools).toContain('if (filter === "more") return app.area === "coordination" || app.area === "saved";'); + expect(tools).toContain("launcherAppMatchesFilter(app, effectiveFilter)"); // Tools local search submit is an interactive control: both end tracks and // the submit face must read the tap knob (not a leftover h-10 / 2.75rem). expect(tools).toContain("grid-cols-[var(--spacing-tap)_minmax(0,1fr)_var(--spacing-tap)]"); diff --git a/tests/result-filter-url.test.ts b/tests/result-filter-url.test.ts new file mode 100644 index 0000000000..692560df3d --- /dev/null +++ b/tests/result-filter-url.test.ts @@ -0,0 +1,59 @@ +/** @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; + +import { + readResultFilterValue, + readResultFilterValues, + replaceResultFilterUrl, + writeResultFilterValue, + writeResultFilterValues, +} from "@/lib/result-filter-url"; + +const allowed = new Set(["all", "high", "low"] as const); + +describe("result filter URL state", () => { + it("reads stable values, de-duplicates them and ignores invalid values", () => { + const params = new URLSearchParams("risk=low,unknown,high,low&q=mania&ids=a,b"); + expect(readResultFilterValues(params, "risk", allowed)).toEqual(["high", "low"]); + }); + + it("writes sorted facets while preserving unrelated parameters", () => { + const params = new URLSearchParams("q=mania&ids=a,b&focus=1"); + writeResultFilterValues(params, "risk", ["low", "high", "low"], allowed); + expect(params.toString()).toBe("q=mania&ids=a%2Cb&focus=1&risk=high%2Clow"); + }); + + it("omits empty facets and default lenses", () => { + const params = new URLSearchParams("q=mania&risk=high&scope=high"); + writeResultFilterValues(params, "risk", [], allowed); + writeResultFilterValue(params, "scope", "all", "all", allowed); + expect(params.toString()).toBe("q=mania"); + }); + + it("falls back for an invalid lens", () => { + expect(readResultFilterValue(new URLSearchParams("scope=unknown"), "scope", allowed, "all")).toBe("all"); + }); + + it("replaces the current URL without adding history or dropping unrelated state", () => { + window.history.replaceState(null, "", "/forms?q=mania&ids=a,b&focus=1#results"); + const pushState = vi.spyOn(window.history, "pushState"); + const replaceState = vi.spyOn(window.history, "replaceState"); + + replaceResultFilterUrl((params) => writeResultFilterValues(params, "risk", ["low", "high"], allowed)); + + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).toHaveBeenCalledWith(null, "", "/forms?q=mania&ids=a%2Cb&focus=1&risk=high%2Clow#results"); + expect(window.location.pathname).toBe("/forms"); + expect(window.location.hash).toBe("#results"); + }); + + it("composes rapid refinements from the current URL instead of a stale render snapshot", () => { + window.history.replaceState(null, "", "/services?q=crisis&group=community"); + + replaceResultFilterUrl((params) => writeResultFilterValues(params, "risk", ["high"], allowed)); + replaceResultFilterUrl((params) => writeResultFilterValues(params, "signal", ["low"], allowed)); + + expect(window.location.search).toBe("?q=crisis&group=community&risk=high&signal=low"); + }); +}); diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 0d46996618..821d803000 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -88,6 +88,35 @@ describe("SearchResultsHeaderBand", () => { expect(within(region).queryByText(/\d/)).toBeNull(); }); + it("keeps removable filter state visible while a fault suppresses untrusted counts", () => { + const onRemove = vi.fn(); + + render( + Tables 0} + appliedFilters={[ + { + id: "risk-high", + valueLabel: "High", + groupLabel: "Risk", + accessibleLabel: "high-risk sources", + onRemove, + }, + ]} + />, + ); + + const region = screen.getByRole("region", { name: "Search results for lithium monitoring" }); + expect(screen.queryByText("Tables 0")).toBeNull(); + expect(within(region).queryByText(/\d/)).toBeNull(); + expect(screen.getByRole("group", { name: "Applied filters" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Remove high-risk sources filter" })).toBeVisible(); + }); + // Initial loading is the same untrue-zero risk: forms forces matches to [] until // the registry is ready, so a count-bearing page control would assert "0" under Searching…. it("drops count-bearing page controls while loading", () => { @@ -737,6 +766,42 @@ describe("ResultFilterSheet facet groups", () => { expect(screen.getByRole("radiogroup", { name: "Show" })).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Domain" })).toBeInTheDocument(); }); + + it("organises one facet into visual sections without changing its OR group", () => { + render( + (), + options: [ + { value: "belief", label: "Belief" }, + { value: "threat", label: "Threat" }, + ], + optionSections: [ + { id: "meaning", label: "Meaning and belief", optionValues: ["belief"] }, + { id: "emotion", label: "Emotion and threat", optionValues: ["threat"] }, + ], + onToggle: vi.fn(), + }), + ]} + />, + ); + + expect(screen.getAllByRole("group", { name: "Domain" })).toHaveLength(1); + expect(screen.getByText("Select any relevant domains; selections combine with OR.")).toBeVisible(); + expect(screen.getByRole("heading", { name: "Meaning and belief" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Emotion and threat" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Belief" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Threat" })).toBeVisible(); + }); }); // docs/filter-contract.md section 5: more than three facet groups adds a @@ -896,7 +961,8 @@ describe("ResultFilterSheet dense facet groups", () => { expect(screen.getByText("No filter matches “zzz-no-match”.")).toBeInTheDocument(); }); - it("renders the scope segment slot above the groups when a mode supplies one", () => { + it("renders the typed scope selector above the groups when a mode supplies one", () => { + const onChange = vi.fn(); render( { testId="scope-filter-panel" title="Filter services" groups={[facetGroup("catchments", "Catchment", [{ value: "Peel", label: "Peel" }])]} - scopeControl={
These results 12 · All items 219
} + scope={{ + value: "results", + onChange, + options: [ + { value: "results", label: "Current results", count: 12 }, + { value: "all", label: "All items", count: 219 }, + ], + }} />, ); - expect(screen.getByTestId("scope-segment")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /current results/i })).toBeChecked(); + expect(screen.getByText("219")).toBeInTheDocument(); }); }); describe("ResultFilterSheet", () => { + it("uses typed staged actions, coverage, and one result action", async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + const onBrowseAll = vi.fn(); + + render( + , + ); + + const coverage = screen.getByRole("progressbar", { name: "Visible results" }); + expect(coverage).toHaveAttribute("aria-valuenow", "7"); + expect(coverage.parentElement).toHaveTextContent("7 of 12 retrieved matches visible"); + expect(screen.getByText(/Changes apply together/)).toHaveClass("sr-only"); + expect(screen.getAllByRole("button", { name: "Update search" })).toHaveLength(1); + + await user.click(screen.getByRole("button", { name: "Update search" })); + await user.click(screen.getByRole("button", { name: /Browse all sources/ })); + expect(onApply).toHaveBeenCalledTimes(1); + expect(onBrowseAll).toHaveBeenCalledTimes(1); + }); + it("exposes each dimension as a radio group and reports the selection back typed", async () => { const user = userEvent.setup(); const onFamilyChange = vi.fn(); @@ -1214,8 +1330,8 @@ describe("ResultFilterSheet", () => { describe("SearchResultsEmptyState", () => { const filters = [ - { id: "medication", label: "Lithium", onRemove: vi.fn() }, - { id: "action", label: "Discharge", onRemove: vi.fn() }, + { id: "medication", valueLabel: "Lithium", onRemove: vi.fn() }, + { id: "action", valueLabel: "Discharge", onRemove: vi.fn() }, ]; it("leads with relaxing a filter when the set is empty because of them", async () => { diff --git a/tests/services-navigator-scope-empty-state.dom.test.tsx b/tests/services-navigator-scope-empty-state.dom.test.tsx index 6036ff522b..e878c3ef62 100644 --- a/tests/services-navigator-scope-empty-state.dom.test.tsx +++ b/tests/services-navigator-scope-empty-state.dom.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -93,6 +93,12 @@ describe("services scope segment vs the query-empty state", () => { const results = screen.getByTestId("service-search-results"); expect(results).toBeInTheDocument(); expect(results.children.length).toBe(registryRecords.length); + + fireEvent.click(screen.getByTestId("service-filter-trigger-desktop")); + const scope = screen.getByRole("group", { name: "Search in" }); + expect(within(scope).getByRole("radio", { name: /^Current results/ })).not.toBeChecked(); + expect(within(scope).getByRole("radio", { name: /^All services/ })).toBeChecked(); + expect(screen.getByRole("button", { name: "Remove Search in: All services filter" })).toBeVisible(); }); it("marks a zero-count care lens as unavailable from the active facet set", () => { diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index 803d15eada..197805101e 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -236,6 +236,24 @@ describe("Sheet stacked-overlay coordination", () => { expect(classes).not.toContain("sm:max-h-[88dvh]"); }); + it("uses a bottom sheet on phones and a restrained right drawer from small screens", () => { + render( + +

Filter body

+
, + ); + + const dialog = screen.getByRole("dialog", { name: "Filters" }); + expect(dialog).toHaveClass( + "rounded-t-2xl", + "sm:h-full", + "sm:max-w-[32rem]", + "sm:rounded-l-2xl", + "sm:rounded-r-none", + ); + expect(dialog.parentElement).toHaveClass("items-end", "justify-center", "sm:items-stretch", "sm:justify-end"); + }); + it("keeps the dialog mounted in production when the title resolves empty", () => { vi.stubEnv("NODE_ENV", "production"); try { diff --git a/tests/specifiers-search-filters.dom.test.tsx b/tests/specifiers-search-filters.dom.test.tsx new file mode 100644 index 0000000000..48fe521227 --- /dev/null +++ b/tests/specifiers-search-filters.dom.test.tsx @@ -0,0 +1,89 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; +import { searchSpecifierCatalog } from "@/lib/specifiers-search-index"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("SpecifiersHomePage filters", () => { + it("filters the catalogue before applying the 24-item display limit", () => { + const matches = searchSpecifierCatalog("disorder").filter(({ item }) => item.categoryId === "per"); + expect(matches.length).toBeGreaterThan(0); + expect(matches.length).toBeLessThan(24); + window.history.replaceState(null, "", "/specifiers?q=disorder&scope=catalogue&category=per"); + + render(); + + const catalogue = screen.getByRole("region", { name: "Full specifier catalogue matches" }); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(matches.length); + expect(screen.getByRole("status")).toHaveTextContent(`${matches.length} specifiers`); + expect(screen.queryByRole("button", { name: /show more/i })).toBeNull(); + }); + + it("reports the full total and progressively reveals catalogue matches", async () => { + const user = userEvent.setup(); + const matches = searchSpecifierCatalog("disorder"); + expect(matches.length).toBeGreaterThan(48); + window.history.replaceState(null, "", "/specifiers?q=disorder&scope=catalogue"); + + render(); + + const catalogue = screen.getByRole("region", { name: "Full specifier catalogue matches" }); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(24); + expect(screen.getByRole("status")).toHaveTextContent(`${matches.length} specifiers`); + + await user.click(screen.getByRole("button", { name: /show more/i })); + expect(within(catalogue).getAllByRole("listitem")).toHaveLength(48); + }); + + it("respects an explicit guide scope even when that scope is empty", () => { + expect(searchSpecifierCatalog("type").length).toBeGreaterThan(0); + window.history.replaceState(null, "", "/specifiers?q=type&scope=guides"); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent("0 specifiers"); + expect(screen.getByRole("button", { name: "Remove Search in: Clinical guides filter" })).toBeVisible(); + expect(screen.queryByRole("region", { name: "Full specifier catalogue matches" })).toBeNull(); + }); + + it("writes a stable category URL and hydrates its contextual applied chip", async () => { + const user = userEvent.setup(); + const category = searchSpecifierCatalog("disorder").find(({ item }) => item.categoryId === "per")?.item.category; + expect(category).toBeTruthy(); + window.history.replaceState(null, "", "/specifiers?q=disorder&focus=1&scope=catalogue"); + + const view = render(); + await user.click(screen.getByTestId("specifier-filter-trigger-phone")); + const panel = screen.getByTestId("specifier-filter-panel"); + await user.click(within(panel).getByRole("button", { name: "Category" })); + await user.click(within(panel).getByRole("button", { name: new RegExp(`^${category} \\(`) })); + + const params = new URLSearchParams(window.location.search); + expect(params.get("category")).toBe("per"); + expect(params.get("q")).toBe("disorder"); + expect(params.get("focus")).toBe("1"); + view.rerender(); + expect(screen.getByRole("button", { name: `Remove Category: ${category} filter` })).toBeVisible(); + }); +}); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 509c2e0edb..891e659d42 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -3,11 +3,7 @@ import type { Route } from "playwright-core"; import { expect, test, type Page } from "playwright/test"; import { demoDocuments, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; -import { - differentialDiagnosesCards, - getDifferentialDetailContext, - getDifferentialRecord, -} from "../src/lib/differentials"; +import { getDifferentialDetailContext, getDifferentialRecord } from "../src/lib/differentials"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; import { visibleByTestId } from "./playwright-settlement"; @@ -482,8 +478,6 @@ test.describe("previously uncovered production routes", () => { browserName === "webkit" && text === 'Viewport argument key "interactive-widget" not recognized and ignored.'; if (message.type() === "error" && !isWebKitViewportDiagnostic) consoleErrors.push(text); }); - const action = - differentialDiagnosesCards.find((card) => !card.href.endsWith("/delirium")) ?? differentialDiagnosesCards[0]; await proveRenderedRoute( page, "/differentials/diagnoses?q=delirium", @@ -526,31 +520,37 @@ test.describe("previously uncovered production routes", () => { const filterPanel = visibleByTestId(currentPage, "differentials-stream-filter-panel"); await expect(filterPanel).toBeVisible(); - const allEntries = filterPanel.getByRole("radio", { name: "All entries" }); + const familyView = filterPanel.getByRole("radiogroup", { name: "Family view" }); + const allEntries = familyView.getByRole("radio", { name: "All entries", exact: true }); await allEntries.focus(); await currentPage.keyboard.press("ArrowRight"); - await expect(filterPanel.getByRole("radio", { name: /Focused family/ })).toHaveAttribute( - "aria-checked", - "true", - ); - await filterPanel.getByRole("button", { name: "Done" }).click(); + await expect(familyView.getByRole("radio", { name: /Focused family/ })).toHaveAttribute("aria-checked", "true"); + await filterPanel.getByTestId("differentials-stream-filter-panel-done").click(); await expect(filterTrigger).toBeFocused(); - const removeFamily = currentPage.getByRole("button", { name: /Remove .+ family filter/ }); + const removeFamily = currentPage.getByRole("button", { name: /Remove Family: .+ filter/ }); await expect(removeFamily).toBeVisible(); await removeFamily.click(); await expect(removeFamily).toHaveCount(0); - const entry = currentPage.locator(`a[href="${action.href}"]`).first(); + const entry = currentPage.locator('main a[href^="/differentials/diagnoses/"]:visible').first(); await expect(entry).toBeVisible(); - await Promise.all([currentPage.waitForURL(new RegExp(`${action.href}$`), { timeout: 30_000 }), entry.click()]); + const entryHref = await entry.getAttribute("href"); + expect(entryHref).toMatch(/^\/differentials\/diagnoses\/[a-z0-9-]+$/); + await Promise.all([ + currentPage.waitForURL((url) => url.pathname === entryHref, { timeout: 30_000 }), + entry.click(), + ]); }, ); await gotoApp(page, "/differentials/presentations"); await page.setViewportSize({ width: 1280, height: 900 }); await expect(page.getByRole("heading", { name: "Presentation pathways", level: 2 })).toBeVisible(); - await expect(page.getByRole("group", { name: "Presentation priority" })).toBeVisible(); + const presentationBrowseTrigger = visibleByTestId(page, "differentials-stream-filter-trigger-desktop"); + await presentationBrowseTrigger.click(); + await expect(page.getByRole("radiogroup", { name: "Clinical urgency" })).toBeVisible(); + await page.getByTestId("differentials-stream-filter-panel-done").click(); await expect(page.getByText("High-priority presentation pathways")).toBeVisible(); await expect(page.getByRole("link", { name: /Open pathway/ }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Show family" })).toHaveCount(0); @@ -580,9 +580,9 @@ test.describe("previously uncovered production routes", () => { "aria-checked", "true", ); - await presentationFilterPanel.getByRole("button", { name: "Done" }).click(); + await presentationFilterPanel.getByTestId("differentials-stream-filter-panel-done").click(); await expect(presentationFilterTrigger).toBeFocused(); - await expect(page.getByRole("button", { name: "Remove Emergent priority filter" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove Priority: Emergent filter" })).toBeVisible(); const visiblePresentationCards = page.locator('[data-testid^="differential-stream-card-"]:visible'); await expect(visiblePresentationCards.first()).toHaveAttribute("data-status", "emergent"); await expect.poll(async () => visiblePresentationCards.count()).toBeGreaterThan(0); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index df2e4cb2c5..e1d1e52fcf 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3287,49 +3287,29 @@ test.describe("Clinical KB UI smoke coverage", () => { } }); - test("DSM category filter dropdown opens to the correct option by keyboard", async ({ page }) => { + test("DSM category facets support keyboard selection and restore focus", async ({ page }) => { await page.setViewportSize({ width: 1100, height: 850 }); await mockDemoApi(page); await gotoApp(page, "/dsm/search?q=depression"); await expect(page.getByTestId("dsm-search-page")).toBeVisible(); - const trigger = page.getByTestId("dsm-category-filter"); - const options = page.getByRole("menuitemradio"); - - // ArrowUp opens the menu with focus on the LAST option (reverse entry). This - // guards against a regression where a competing focus-on-open effect raced - // the key handler and stole focus back to the active item. + const trigger = page.getByTestId("dsm-category-filter-desktop"); await trigger.focus(); - await page.keyboard.press("ArrowUp"); - await expect(options.last()).toBeFocused(); - - // Escape closes the menu and restores focus to the trigger. - await page.keyboard.press("Escape"); - await expect(options.first()).toBeHidden(); - await expect(trigger).toBeFocused(); - - // ArrowDown opens the menu with focus on the active option ("All categories"). - await page.keyboard.press("ArrowDown"); - await expect(options.first()).toBeFocused(); - await expect(options.first()).toHaveAttribute("aria-checked", "true"); - - // Options sit outside the Tab sequence (tabIndex=-1), so one Tab press from a - // non-final option leaves the whole widget in a single step and closes the - // menu instead of stepping through every category link. - await page.keyboard.press("Tab"); - await expect(options.first()).toBeHidden(); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(options).toHaveCount(0); + await page.keyboard.press("Enter"); + const panel = page.getByTestId("dsm-category-filter-panel"); + await expect(panel).toBeVisible(); - // Space activates the focused option (announced as a menuitemradio) even - // though the underlying element is an anchor, applying the category filter. - await trigger.focus(); - await page.keyboard.press("ArrowDown"); - await expect(options.first()).toBeFocused(); - await page.keyboard.press("ArrowDown"); - await expect(options.nth(1)).toBeFocused(); + const categoryGroup = panel.getByRole("group", { name: "Category" }); + const category = categoryGroup.locator('button:not([aria-disabled="true"])').first(); + await expect(category).toHaveAttribute("aria-pressed", "false"); + await category.focus(); await page.keyboard.press("Space"); await expect(page).toHaveURL(/[?&]category=/); + await expect(category).toHaveAttribute("aria-pressed", "true"); + + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); }); test("dashboard specifiers mode param redirects to the standalone specifiers route", async ({ page }) => { @@ -3977,7 +3957,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await mobileFilterTrigger.click(); const filterPanel = page.getByTestId("document-filter-panel"); await expect(filterPanel).toBeVisible(); - await expect(filterPanel.getByRole("radiogroup", { name: "Source type" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Source locality" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Result type" })).toBeVisible(); // Library lives in the sheet footer now, under a rule and below the commit // action. It was first renamed from "Open source filters" — it browses, it // does not refine, and the old name made it read as a duplicate of Filter — @@ -3987,11 +3968,11 @@ test.describe("Clinical KB UI smoke coverage", () => { // overflow at all"; that was measured wrong. With Library gone the rail still // overflowed at every common phone width, because the inline utilities group // was `shrink` — see the rail-fit assertion above. - const libraryButton = filterPanel.getByTestId("document-filter-browse-library"); + const libraryButton = filterPanel.getByRole("button", { name: /Browse all sources/ }); await expect(libraryButton).toBeVisible(); await expect(libraryButton).toHaveText(/Browse all sources/); await expectMinTouchTarget(libraryButton); - await filterPanel.getByTestId("document-filter-done").click(); + await filterPanel.getByTestId("document-filter-panel-done").click(); await expect(filterPanel).toHaveCount(0); await expect(mobileFilterTrigger).toHaveAttribute("aria-expanded", "false"); // Asserted as an absence, not merely tolerated: putting Library back on the @@ -4094,17 +4075,20 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.keyboard.press("Escape"); await expect(moreActions).toBeFocused(); - // Source type now lives inside the filter panel rather than in a native - // select in the ribbon, so reaching it goes through the trigger. + // Retrieval scope and result refinements now live in one staged panel + // rather than competing with a native select in the ribbon. await mobileFilterTrigger.click(); const phoneFilterPanel = page.getByTestId("document-filter-panel"); const phoneTablesFilter = phoneFilterPanel.getByRole("radio", { name: /Tables/ }); if ((await phoneTablesFilter.count()) > 0) { await phoneTablesFilter.click(); await expect(phoneTablesFilter).toHaveAttribute("aria-checked", "true"); - await phoneFilterPanel.getByRole("radio", { name: /^All/ }).click(); + await phoneFilterPanel + .getByRole("radiogroup", { name: "Result type" }) + .getByRole("radio", { name: /^All/ }) + .click(); } - await phoneFilterPanel.getByTestId("document-filter-done").click(); + await phoneFilterPanel.getByTestId("document-filter-panel-done").click(); await expect(phoneFilterPanel).toHaveCount(0); await expect(documentResults).toBeVisible(); @@ -4145,14 +4129,14 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(queryRibbon.getByTestId("document-filter-trigger-phone")).toBeHidden(); await wideFilterTrigger.click(); const wideFilterPanel = page.getByTestId("document-filter-panel"); - await expect(wideFilterPanel.getByRole("radiogroup", { name: "Source type" })).toBeVisible(); + await expect(wideFilterPanel.getByRole("radiogroup", { name: "Source locality" })).toBeVisible(); const dashboardMain = page.locator("main#main-content"); const scrollTopBeforeSources = await dashboardMain.evaluate((element) => element.scrollTop); // The corpus is now reached from the sheet's footer rather than the rail, // and reaching it dismisses the sheet: browsing is leaving this surface, so // the Sources drawer must not open underneath a filter panel still covering // the results both of them describe. - await wideFilterPanel.getByTestId("document-filter-browse-library").click(); + await wideFilterPanel.getByRole("button", { name: /Browse all sources/ }).click(); await expect(wideFilterPanel).toHaveCount(0); const resultsLibraryDialog = page.getByRole("dialog", { name: "Sources" }); await expect(resultsLibraryDialog).toBeVisible(); diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index c69abaf6de..aa4f75e72b 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -79,10 +79,14 @@ test("searches clinical language without provenance fields and carries a result await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible(); await expect(page.getByText(/Results ranked by text relevance/i)).toHaveCount(0); await expect(page.getByText("Top match", { exact: true })).toBeVisible(); - // A lens, so the rail is a radiogroup rather than a toggle group — the sheet - // already said one-of-N and the desktop rail now agrees with it. - await expect(page.getByRole("radiogroup", { name: "Filter by specifier family" })).toBeVisible(); - await expect(page.getByRole("combobox", { name: "Filter by diagnosis" })).toBeVisible(); + const filterTrigger = page.getByTestId("specifier-filter-trigger-desktop"); + await filterTrigger.click(); + const filterPanel = page.getByTestId("specifier-filter-panel"); + await expect(filterPanel).toBeVisible(); + await expect(filterPanel.getByRole("group", { name: "Search in" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Family" })).toBeVisible(); + await expect(filterPanel.getByRole("radiogroup", { name: "Diagnosis" })).toBeVisible(); + await filterPanel.getByTestId("specifier-filter-panel-done").click(); await expect(page.getByText("Best fit", { exact: true })).toHaveCount(0); await expect(page.getByText(/clinical fit/i)).toHaveCount(0); const topMatch = page.getByTestId("specifier-top-match"); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 3326c926d8..843386feeb 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -474,13 +474,13 @@ test.describe("Medication responsive stress coverage", () => { const metrics = await page.evaluate((viewportWidth) => { const workspace = document.querySelector(".medication-results-workspace"); const patient = document.querySelector(".medication-patient-strip"); - const filters = document.querySelector(".medication-filter-strip"); const card = document.querySelector('[data-testid="medication-result-acamprosate-phone"]'); - const firstFilter = + const firstFilter = document.querySelector( viewportWidth < 640 - ? document.querySelector('[data-testid="medication-filter-trigger-phone"]') - : filters?.querySelector("button"); - if (!workspace || !patient || !filters || !card || !firstFilter) return null; + ? '[data-testid="medication-filter-trigger-phone"]' + : '[data-testid="medication-filter-trigger-desktop"]', + ); + if (!workspace || !patient || !card || !firstFilter) return null; const workspaceRect = workspace.getBoundingClientRect(); const patientRect = patient.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); @@ -501,7 +501,7 @@ test.describe("Medication responsive stress coverage", () => { }; }, viewport.width); expect(metrics).not.toBeNull(); - expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(42); + expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(viewport.width < 640 ? 48 : 40); if (viewport.width <= 639) { expect(metrics?.workspaceLeft ?? 0).toBeGreaterThanOrEqual(12); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 877af366be..a83279613e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -576,7 +576,8 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(filterSheet).toBeVisible(); await expect(filterSheet.getByRole("radio", { name: /Assess/ })).toHaveAttribute("aria-checked", "false"); await expect(filterSheet.getByRole("radio", { name: /Treat/ })).toHaveAttribute("aria-disabled", "true"); - await filterSheet.getByRole("button", { name: "Done" }).click(); + await expect(filterSheet.getByTestId("tools-search-filter-sheet-done")).toHaveText(/View 1 tool/); + await filterSheet.getByTestId("tools-search-filter-sheet-done").click(); const details = results.getByRole("button", { name: "View details for Differentials" }); await details.click(); @@ -1158,7 +1159,9 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { const quickFilter = page.getByTestId("service-filter-trigger-phone"); await expect(quickFilter).toBeVisible(); await expect(quickFilter).toHaveAccessibleName(/No filters active/); - await page.getByTestId("service-quick-search-suggestions").getByRole("button", { name: "Crisis" }).click(); + await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); + await input.fill("crisis"); + await input.press("Enter"); await expect(page).toHaveURL(/\/services\?.*q=crisis/); // Phones keep the full search results in the page instead of opening a @@ -1386,7 +1389,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("services results keep browse navigation without a walkthrough", async ({ page }) => { + test("services results keep browse navigation and filters without a suggestion rail", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); @@ -1409,16 +1412,8 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(firstResult.getByText("Catchment", { exact: true })).toHaveCount(0); await expect(firstResult.getByRole("button", { name: "Save 13YARN to favourites" })).toBeVisible(); - const culturallySafe = page - .getByTestId("service-quick-search-suggestions") - .getByRole("button", { name: "Culturally safe" }); - await expect(culturallySafe).toBeVisible(); - await waitForReactEventHandler(culturallySafe); - await culturallySafe.click(); - await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); - await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); + await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); - // Quick search suggestions and facet clearing are separate contracts. // Exercise a real facet, then clear only that facet while preserving q. await page.getByTestId("service-filter-trigger-desktop").click(); const filterPanel = page.getByTestId("service-filter-panel"); @@ -1428,7 +1423,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await crisisFacet.click(); await expect(page).toHaveURL(/acuity_flags=crisis_high/); await filterPanel.getByTestId("service-filter-panel-clear").click(); - await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); + await expect(page).toHaveURL(/q=13YARN/); await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); await filterPanel.getByRole("button", { name: "Close", exact: true }).click(); From a64f415bbceb2d6b082e158d247e48fca0da0f5f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:06:45 +0800 Subject: [PATCH 02/12] test(filters): cover review regressions --- tests/filter-review-regressions.dom.test.tsx | 284 +++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 tests/filter-review-regressions.dom.test.tsx diff --git a/tests/filter-review-regressions.dom.test.tsx b/tests/filter-review-regressions.dom.test.tsx new file mode 100644 index 0000000000..9423590c5a --- /dev/null +++ b/tests/filter-review-regressions.dom.test.tsx @@ -0,0 +1,284 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; +import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; +import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; +import { DifferentialStreamWorkspace } from "@/components/differentials/differential-stream-workspace"; +import { buildDifferentialStreamModel } from "@/lib/differential-stream"; +import type { ClinicalDocument, DocumentMatch } from "@/lib/types"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(window.location.search), + usePathname: () => window.location.pathname, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "signed_out", + session: null, + isConfigured: true, + authorizationHeader: () => null, + registerAuthRequest: vi.fn(), + isAuthEpochCurrent: () => true, + markSessionExpired: vi.fn(), + }), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +type MockMedicationResult = { + id: string; + name: string; + indication: string; + match: string; + dose: string; + ceiling: string; + action: string; + actionTone: "danger" | "warning" | "neutral"; + tone: "teal" | "blue" | "slate"; +}; + +type MockMedicationCatalogState = { + data: null | { + records: []; + matches: Array<{ + medication: undefined; + result: MockMedicationResult; + score: number; + reasons: string[]; + }>; + total: number; + governance: Record; + }; + loading: boolean; + error: string | null; +}; + +const medicationCatalogState = vi.hoisted(() => ({ + current: { data: null, loading: true, error: null } as MockMedicationCatalogState, +})); + +vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ + useMedicationCatalog: () => medicationCatalogState.current, +})); + +const documentId = "11111111-1111-4111-8111-111111111111"; + +function documentMatch(): DocumentMatch { + return { + document_id: documentId, + title: "Clozapine Monitoring Protocol", + file_name: "clozapine-monitoring.pdf", + labels: [], + summarySnippet: "Synthetic summary.", + bestPages: [1], + bestChunkIds: ["clozapine-chunk"], + imageCount: 0, + tableCount: 0, + matchReason: "Matched indexed passage", + score: 0.9, + }; +} + +function sourceDocument(): ClinicalDocument { + return { + id: documentId, + title: "Clozapine Monitoring Protocol", + description: null, + file_name: "clozapine-monitoring.pdf", + file_type: "application/pdf", + file_size: 1024, + storage_path: `documents/${documentId}.pdf`, + status: "indexed", + page_count: 2, + chunk_count: 1, + image_count: 0, + error_message: null, + metadata: { + document_status: "current", + clinical_validation_status: "approved", + extraction_quality: "good", + jurisdiction: "WA", + }, + labels: [], + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-01T00:00:00.000Z", + }; +} + +const documentProps = { + matches: [documentMatch()], + recordMatches: [], + showRecordMatches: false, + query: "clozapine", + loading: false, + documentCount: 42, + realDataReady: true, + authUnavailable: false, + apiUnavailable: false, + setupWarning: null, + onScopeDocument: vi.fn(), + onAnswerFromDocument: vi.fn(), + onOpenRecentDocuments: vi.fn(), + onOpenLibrary: vi.fn(), + onOpenSourcePdf: vi.fn(), + onTagSearch: vi.fn(), +}; + +function renderMedicationWorkspace() { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + medicationCatalogState.current = { data: null, loading: true, error: null }; + vi.clearAllMocks(); +}); + +describe("filter review regressions", () => { + it("does not derive paginated document retrieval facets before the panel opens", async () => { + const user = userEvent.setup(); + const source = sourceDocument(); + const metadata = source.metadata; + let metadataReads = 0; + Object.defineProperty(source, "metadata", { + configurable: true, + get: () => { + metadataReads += 1; + return metadata; + }, + }); + + render(); + + expect(metadataReads).toBe(0); + await user.click(screen.getByTestId("document-filter-trigger-phone")); + expect(metadataReads).toBeGreaterThan(0); + }); + + it("labels document retrieval projections as loaded-source counts", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("document-filter-trigger-phone")); + + const panel = screen.getByTestId("document-filter-panel"); + expect(within(panel).getByRole("radio", { name: /^Local \(1 loaded source\)$/ })).toBeInTheDocument(); + }); + + it("defers differential projections until open and avoids repeated chapter-option scans", async () => { + const user = userEvent.setup(); + const model = buildDifferentialStreamModel("diagnoses", ""); + const optionValues = [ + ...model.chapters.map((chapter) => `chapter:${chapter.id}`), + ...model.presentationChapters.map((chapter) => `presentation:${chapter.id}`), + ]; + const originalHas = Set.prototype.has; + let optionMembershipChecks = 0; + const hasSpy = vi.spyOn(Set.prototype, "has").mockImplementation(function (this: Set, value: unknown) { + if (typeof value === "string" && optionValues.includes(value)) optionMembershipChecks += 1; + return originalHas.call(this, value); + }); + + try { + render(); + expect(optionMembershipChecks).toBe(0); + + await user.click(screen.getByTestId("differentials-stream-filter-trigger")); + + expect(optionMembershipChecks).toBeLessThan(optionValues.length * 8); + } finally { + hasSpy.mockRestore(); + } + }); + + it("retains a medication class deep link through initial loading and unrelated filter changes", () => { + window.history.replaceState(null, "", "/medications/search?class=Other"); + const view = renderMedicationWorkspace(); + + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); + fireEvent.click(screen.getByRole("radio", { name: "All medications, 0 matches" })); + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + + medicationCatalogState.current = { + data: { + records: [], + matches: [ + { + medication: undefined, + result: { + id: "clozapine", + name: "Clozapine", + indication: "Treatment-resistant schizophrenia", + match: "Exact clinical fit", + dose: "12.5 mg", + ceiling: "900 mg", + action: "Avoid abrupt cessation", + actionTone: "danger", + tone: "teal", + }, + score: 1, + reasons: [], + }, + ], + total: 1, + governance: {}, + }, + loading: false, + error: null, + }; + view.rerender( + + + , + ); + + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); + expect(screen.getByRole("button", { name: "Remove Class: Other filter" })).toBeInTheDocument(); + }); +}); From 667f8cceab67d93acd76d209f216165dc0546600 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:23:17 +0800 Subject: [PATCH 03/12] fix(filters): normalize URL values after trimming --- src/lib/result-filter-url.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/result-filter-url.ts b/src/lib/result-filter-url.ts index 233c3ae2f4..18dda35a1e 100644 --- a/src/lib/result-filter-url.ts +++ b/src/lib/result-filter-url.ts @@ -1,8 +1,7 @@ type ReadableSearchParams = Pick; function normalizeValues(values: Iterable, allowedValues: ReadonlySet): string[] { - return [...new Set(values)] - .map((value) => value.trim()) + return [...new Set([...values].map((value) => value.trim()))] .filter((value) => value.length > 0 && allowedValues.has(value)) .sort((left, right) => left.localeCompare(right)); } From 324ef626507ed1839129c4e130fcb5111bb72235 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:23:33 +0800 Subject: [PATCH 04/12] fix(filters): align locality chip labels --- src/lib/search-scope-filter-chips.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/search-scope-filter-chips.ts b/src/lib/search-scope-filter-chips.ts index 19dd77ee1f..3ecf11f3ce 100644 --- a/src/lib/search-scope-filter-chips.ts +++ b/src/lib/search-scope-filter-chips.ts @@ -73,7 +73,7 @@ export function scopeFilterChips(filters: SearchScopeFilters | null | undefined) chips.push({ id: chipId("locality", filters.locality), groupLabel: "Locality", - valueLabel: filters.locality === "local" ? "local" : "non-local", + valueLabel: filters.locality === "local" ? "Local" : "Non-local", }); } return chips; From 4d31dffe0278d61655a9c5d5834d7b46b37ff560 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:26:33 +0800 Subject: [PATCH 05/12] fix(filters): defer and streamline differential projections --- .../differential-stream-workspace.tsx | 161 ++++++++++-------- 1 file changed, 86 insertions(+), 75 deletions(-) diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index e9615cb34d..d0de5fe203 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -372,6 +372,10 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: ], [model.chapters, model.presentationChapters, model.stream], ); + const chapterItemIdsByValue = useMemo( + () => new Map(chapterFilterOptions.map((option) => [option.value, option.itemIds])), + [chapterFilterOptions], + ); const chapterFilterValues = useMemo( () => new Set(chapterFilterOptions.map((option) => option.value)), [chapterFilterOptions], @@ -437,11 +441,16 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: if (scope === "matches" && !item.isMatch) return false; if (related && focusedItem && item.slug !== focusedItem.slug && !relatedSlugSet.has(item.slug)) return false; if (priority !== "all" && item.status !== priority) return false; - if ( - chapters.size > 0 && - !chapterFilterOptions.some((option) => chapters.has(option.value) && option.itemIds.has(item.id)) - ) - return false; + if (chapters.size > 0) { + let inSelectedChapter = false; + for (const token of chapters) { + if (chapterItemIdsByValue.get(token)?.has(item.id)) { + inSelectedChapter = true; + break; + } + } + if (!inSelectedChapter) return false; + } return true; }); const visibleItems = filterStreamItems(resultScope, presentationPriority, selectedChapterFilters, familyMode); @@ -516,79 +525,81 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: const showBrowseGrouping = !hasQuery && model.stream === "diagnoses"; const showConnectionFilter = Boolean(focusedItem && (!isPresentation || focusedItem.relatedPathways.length > 0)); const mobileFilterGroups: ResultFilterGroup[] = []; - mobileFilterGroups.push( - resultFilterGroup({ - id: "priority", - label: "Clinical urgency", - value: presentationPriority, - options: (["all", "emergent", "urgent", "routine"] as const).map((value) => ({ - value, - label: value === "all" ? "All priorities" : statusLabel(value), - hint: String(filterStreamItems(resultScope, value, selectedChapterFilters, familyMode).length), - })), - onChange: setPresentationPriority, - }), - ); - if (model.stream === "diagnoses" && chapterFilterOptions.length > 0) { + if (filterOpen) { mobileFilterGroups.push( - resultFilterFacetGroup({ - id: "chapter", - label: "Chapter or presentation", - description: "Alternatives in this group combine with OR.", - selected: selectedChapterFilters, - optionSections: [ - { - id: "chapters", - label: "Diagnostic chapters", - optionValues: chapterFilterOptions - .filter((option) => option.section === "chapters") - .map((option) => option.value), - }, - { - id: "presentations", - label: "Presentation families", - optionValues: chapterFilterOptions - .filter((option) => option.section === "presentations") - .map((option) => option.value), - }, - ], - options: chapterFilterOptions.map((option) => { - const withCandidate = new Set(selectedChapterFilters); - if (!withCandidate.has(option.value)) withCandidate.add(option.value); - const count = filterStreamItems(resultScope, presentationPriority, withCandidate, familyMode).length; - return { - value: option.value, - label: option.label, - hint: String(count), - disabled: count === 0 && !selectedChapterFilters.has(option.value), - }; - }), - onToggle: (value) => - replaceResultFilterUrl((params) => { - const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); - if (!next.delete(value)) next.add(value); - writeResultFilterValues(params, "chapter", next, chapterFilterValues); - }), - }), - ); - } - if (focusedItem && showConnectionFilter) { - mobileFilterGroups.push( - resultFilterGroup<"all" | "family">({ - id: isPresentation ? "pathways" : "family", - label: isPresentation ? "Related pathways" : "Family view", - value: familyMode ? "family" : "all", - options: [ - { value: "all", label: isPresentation ? "All pathways" : "All entries" }, - { - value: "family", - label: isPresentation ? "Related pathways" : "Focused family", - hint: focusedItem.title, - }, - ], - onChange: (value) => setFamilyMode(value === "family"), + resultFilterGroup({ + id: "priority", + label: "Clinical urgency", + value: presentationPriority, + options: (["all", "emergent", "urgent", "routine"] as const).map((value) => ({ + value, + label: value === "all" ? "All priorities" : statusLabel(value), + hint: String(filterStreamItems(resultScope, value, selectedChapterFilters, familyMode).length), + })), + onChange: setPresentationPriority, }), ); + if (model.stream === "diagnoses" && chapterFilterOptions.length > 0) { + mobileFilterGroups.push( + resultFilterFacetGroup({ + id: "chapter", + label: "Chapter or presentation", + description: "Alternatives in this group combine with OR.", + selected: selectedChapterFilters, + optionSections: [ + { + id: "chapters", + label: "Diagnostic chapters", + optionValues: chapterFilterOptions + .filter((option) => option.section === "chapters") + .map((option) => option.value), + }, + { + id: "presentations", + label: "Presentation families", + optionValues: chapterFilterOptions + .filter((option) => option.section === "presentations") + .map((option) => option.value), + }, + ], + options: chapterFilterOptions.map((option) => { + const withCandidate = new Set(selectedChapterFilters); + if (!withCandidate.has(option.value)) withCandidate.add(option.value); + const count = filterStreamItems(resultScope, presentationPriority, withCandidate, familyMode).length; + return { + value: option.value, + label: option.label, + hint: String(count), + disabled: count === 0 && !selectedChapterFilters.has(option.value), + }; + }), + onToggle: (value) => + replaceResultFilterUrl((params) => { + const next = new Set(readResultFilterValues(params, "chapter", chapterFilterValues)); + if (!next.delete(value)) next.add(value); + writeResultFilterValues(params, "chapter", next, chapterFilterValues); + }), + }), + ); + } + if (focusedItem && showConnectionFilter) { + mobileFilterGroups.push( + resultFilterGroup<"all" | "family">({ + id: isPresentation ? "pathways" : "family", + label: isPresentation ? "Related pathways" : "Family view", + value: familyMode ? "family" : "all", + options: [ + { value: "all", label: isPresentation ? "All pathways" : "All entries" }, + { + value: "family", + label: isPresentation ? "Related pathways" : "Focused family", + hint: focusedItem.title, + }, + ], + onChange: (value) => setFamilyMode(value === "family"), + }), + ); + } } const activeFilterCount = Number(familyMode) + From 374ba3c68812a7a53a2df7b80fb5ed0c09318c5b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:30:46 +0800 Subject: [PATCH 06/12] test(filters): cover locality chip casing --- tests/document-search-scope-zero-results.dom.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx index 1f96b9a602..91541987af 100644 --- a/tests/document-search-scope-zero-results.dom.test.tsx +++ b/tests/document-search-scope-zero-results.dom.test.tsx @@ -238,7 +238,7 @@ describe("scope filter chips", () => { expect(scopeFilterChips({ topics: ["agitation"], sites: ["FSH"], locality: "local" })).toEqual([ { id: "scope:topics:agitation", groupLabel: "Topic", valueLabel: "agitation" }, { id: "scope:sites:FSH", groupLabel: "Site", valueLabel: "FSH" }, - { id: "scope:locality:local", groupLabel: "Locality", valueLabel: "local" }, + { id: "scope:locality:local", groupLabel: "Locality", valueLabel: "Local" }, ]); }); From d6da003e98a60c3bec5c6a6f6c2d187506cb2ad8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:31:03 +0800 Subject: [PATCH 07/12] test(filters): cover trimmed URL deduplication --- tests/result-filter-url.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/result-filter-url.test.ts b/tests/result-filter-url.test.ts index 692560df3d..0283f72813 100644 --- a/tests/result-filter-url.test.ts +++ b/tests/result-filter-url.test.ts @@ -18,12 +18,23 @@ describe("result filter URL state", () => { expect(readResultFilterValues(params, "risk", allowed)).toEqual(["high", "low"]); }); + it("de-duplicates values after trimming whitespace", () => { + const params = new URLSearchParams("risk=high,%20high,low"); + expect(readResultFilterValues(params, "risk", allowed)).toEqual(["high", "low"]); + }); + it("writes sorted facets while preserving unrelated parameters", () => { const params = new URLSearchParams("q=mania&ids=a,b&focus=1"); writeResultFilterValues(params, "risk", ["low", "high", "low"], allowed); expect(params.toString()).toBe("q=mania&ids=a%2Cb&focus=1&risk=high%2Clow"); }); + it("de-duplicates written values after trimming whitespace", () => { + const params = new URLSearchParams(); + writeResultFilterValues(params, "risk", ["high", " high" as "high", "low"], allowed); + expect(params.get("risk")).toBe("high,low"); + }); + it("omits empty facets and default lenses", () => { const params = new URLSearchParams("q=mania&risk=high&scope=high"); writeResultFilterValues(params, "risk", [], allowed); From 3c9a2cb56cb45a1297dc39e51dc4aa3113ec56c9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:39:43 +0800 Subject: [PATCH 08/12] fix(filters): defer document scope projections --- .../document-search-results.tsx | 193 ++++++++++-------- 1 file changed, 107 insertions(+), 86 deletions(-) diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 944b5e6877..b711421c25 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -215,6 +215,10 @@ function filterMatchesByResultType(matches: DocumentMatch[], filter: ResultTypeF return matches; } +function loadedSourceCountHint(count: number) { + return `${count.toLocaleString()} loaded ${count === 1 ? "source" : "sources"}`; +} + function relevanceTone(document: DocumentMatch) { const verdict = document.relevance?.verdict as string | undefined; const percent = documentRelevancePercent(document); @@ -1025,14 +1029,25 @@ function DocumentSearchResultsPanelImpl({ scopeFilters: publicDocumentScopeFilters(committedScopeFilters), selectedDocumentIds, }; - const draftSelectedDocumentIds = new Set(activeDraft.selectedDocumentIds); - const draftTagFacetGroups = projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys); - const draftVisibleMatches = filterDocumentsBySmartTagFacetIndex(tagFacetIndex, activeDraft.facetKeys); - const draftResultTabs = resultTypeTabs(draftVisibleMatches); - const draftResultType = draftResultTabs.some((tab) => tab.key === activeDraft.resultType) + // These projections exist solely to render the open filter sheet. Keep the + // library-sized scans dormant while it is closed so composer keystrokes do + // not rebuild every projected count. + const draftSourceDocuments = filterPanelOpen ? recentDocuments : []; + const loadedSourceCountsAreComplete = documentCount > 0 && draftSourceDocuments.length >= documentCount; + const draftSelectedDocumentIds = new Set(filterPanelOpen ? activeDraft.selectedDocumentIds : []); + const draftTagFacetGroups = filterPanelOpen + ? projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys) + : []; + const draftVisibleMatches = filterPanelOpen + ? filterDocumentsBySmartTagFacetIndex(tagFacetIndex, activeDraft.facetKeys) + : []; + const draftResultTabs = filterPanelOpen ? resultTypeTabs(draftVisibleMatches) : []; + const draftResultType = filterPanelOpen && draftResultTabs.some((tab) => tab.key === activeDraft.resultType) ? activeDraft.resultType : "all"; - const draftDisplayedMatches = filterMatchesByResultType(draftVisibleMatches, draftResultType); + const draftDisplayedMatches = filterPanelOpen + ? filterMatchesByResultType(draftVisibleMatches, draftResultType) + : []; function toggleDraftListFilter( key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities", @@ -1125,20 +1140,20 @@ function DocumentSearchResultsPanelImpl({ } const documentFilterGroups: ResultFilterGroup[] = []; - if (recentDocuments.length > 0) { + if (draftSourceDocuments.length > 0) { documentFilterGroups.push( resultFilterFacetGroup({ id: "selected-sources", label: "Selected sources", description: "Choose specific indexed sources. Leave empty to search across the filtered source set.", selected: draftSelectedDocumentIds, - options: [...recentDocuments] + options: [...draftSourceDocuments] .sort((left, right) => documentDisplayTitle(left).localeCompare(documentDisplayTitle(right))) .map((document) => { const withCandidate = new Set(draftSelectedDocumentIds); if (!withCandidate.has(document.id)) withCandidate.add(document.id); const count = filterDocumentsByRetrievalScope( - recentDocuments, + draftSourceDocuments, activeDraft.scopeFilters, withCandidate, ).length; @@ -1146,7 +1161,7 @@ function DocumentSearchResultsPanelImpl({ value: document.id, label: documentDisplayTitle(document), searchText: `${document.title} ${document.file_name}`, - hint: String(count), + hint: loadedSourceCountHint(count), disabled: count === 0 && !draftSelectedDocumentIds.has(document.id), }; }), @@ -1160,26 +1175,28 @@ function DocumentSearchResultsPanelImpl({ ); } - const governanceGroups = [ - { - key: "sourceStatuses" as const, - label: "Source status", - values: sourceStatusValues, - labels: sourceStatusLabels, - }, - { - key: "validationStatuses" as const, - label: "Clinical validation", - values: validationStatusValues, - labels: validationStatusLabels, - }, - { - key: "extractionQualities" as const, - label: "Extraction quality", - values: extractionQualityValues, - labels: extractionQualityLabels, - }, - ]; + const governanceGroups = filterPanelOpen + ? [ + { + key: "sourceStatuses" as const, + label: "Source status", + values: sourceStatusValues, + labels: sourceStatusLabels, + }, + { + key: "validationStatuses" as const, + label: "Clinical validation", + values: validationStatusValues, + labels: validationStatusLabels, + }, + { + key: "extractionQualities" as const, + label: "Extraction quality", + values: extractionQualityValues, + labels: extractionQualityLabels, + }, + ] + : []; for (const group of governanceGroups) { const selected = new Set((activeDraft.scopeFilters[group.key] as string[] | undefined) ?? []); documentFilterGroups.push( @@ -1190,7 +1207,7 @@ function DocumentSearchResultsPanelImpl({ selected, options: group.values.map((value) => { const count = projectedDocumentScopeCount({ - documents: recentDocuments, + documents: draftSourceDocuments, filters: activeDraft.scopeFilters, selectedDocumentIds: draftSelectedDocumentIds, key: group.key, @@ -1199,8 +1216,8 @@ function DocumentSearchResultsPanelImpl({ return { value, label: group.labels[value] ?? value, - hint: String(count), - disabled: count === 0 && !selected.has(value), + hint: loadedSourceCountHint(count), + disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value), }; }), onToggle: (value) => toggleDraftListFilter(group.key, value), @@ -1208,52 +1225,55 @@ function DocumentSearchResultsPanelImpl({ ); } - documentFilterGroups.push( - resultFilterGroup({ - id: "locality", - label: "Source locality", - description: "Separate WA and health-service sources from non-local guidance.", - value: activeDraft.scopeFilters.locality ?? "all", - options: [ - { - value: "all", - label: "Any locality", - hint: String( - filterDocumentsByRetrievalScope( - recentDocuments, - { ...activeDraft.scopeFilters, locality: undefined }, - draftSelectedDocumentIds, - ).length, - ), - }, - ...(["local", "non_local"] as const).map((value) => { - const count = filterDocumentsByRetrievalScope( - recentDocuments, - { ...activeDraft.scopeFilters, locality: value }, - draftSelectedDocumentIds, - ).length; - return { - value, - label: value === "local" ? "Local" : "Non-local", - hint: String(count), - disabled: count === 0 && activeDraft.scopeFilters.locality !== value, - }; - }), - ], - onChange: (value) => - setFilterDraft((current) => ({ - ...current, - scopeFilters: { - ...current.scopeFilters, - locality: value === "all" ? undefined : value, + if (filterPanelOpen) { + documentFilterGroups.push( + resultFilterGroup({ + id: "locality", + label: "Source locality", + description: "Separate WA and health-service sources from non-local guidance.", + value: activeDraft.scopeFilters.locality ?? "all", + options: [ + { + value: "all", + label: "Any locality", + hint: loadedSourceCountHint( + filterDocumentsByRetrievalScope( + draftSourceDocuments, + { ...activeDraft.scopeFilters, locality: undefined }, + draftSelectedDocumentIds, + ).length, + ), }, - })), - }), - ); + ...(["local", "non_local"] as const).map((value) => { + const count = filterDocumentsByRetrievalScope( + draftSourceDocuments, + { ...activeDraft.scopeFilters, locality: value }, + draftSelectedDocumentIds, + ).length; + return { + value, + label: value === "local" ? "Local" : "Non-local", + hint: loadedSourceCountHint(count), + disabled: + loadedSourceCountsAreComplete && count === 0 && activeDraft.scopeFilters.locality !== value, + }; + }), + ], + onChange: (value) => + setFilterDraft((current) => ({ + ...current, + scopeFilters: { + ...current.scopeFilters, + locality: value === "all" ? undefined : value, + }, + })), + }), + ); + } - for (const field of documentLabelFilterFields) { + for (const field of filterPanelOpen ? documentLabelFilterFields : []) { const selected = new Set(activeDraft.scopeFilters[field.key] ?? []); - const values = [...new Set([...deriveDocumentLabelOptions(recentDocuments, field.labelType), ...selected])]; + const values = [...new Set([...deriveDocumentLabelOptions(draftSourceDocuments, field.labelType), ...selected])]; if (values.length === 0) continue; documentFilterGroups.push( resultFilterFacetGroup({ @@ -1263,7 +1283,7 @@ function DocumentSearchResultsPanelImpl({ selected, options: values.map((value) => { const count = projectedDocumentScopeCount({ - documents: recentDocuments, + documents: draftSourceDocuments, filters: activeDraft.scopeFilters, selectedDocumentIds: draftSelectedDocumentIds, key: field.key, @@ -1272,8 +1292,8 @@ function DocumentSearchResultsPanelImpl({ return { value, label: value, - hint: String(count), - disabled: count === 0 && !selected.has(value), + hint: loadedSourceCountHint(count), + disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value), }; }), onToggle: (value) => toggleDraftListFilter(field.key, value), @@ -1281,7 +1301,7 @@ function DocumentSearchResultsPanelImpl({ ); } - if (draftResultTabs.length > 1) { + if (filterPanelOpen && draftResultTabs.length > 1) { documentFilterGroups.push( resultFilterGroup({ id: "result-type", @@ -1320,10 +1340,11 @@ function DocumentSearchResultsPanelImpl({ }), ); } - const draftActiveFilterCount = - activeDraft.facetKeys.length + - Number(draftResultType !== "all") + - documentRetrievalFilterValueCount(activeDraft.scopeFilters, activeDraft.selectedDocumentIds.length); + const draftActiveFilterCount = filterPanelOpen + ? activeDraft.facetKeys.length + + Number(draftResultType !== "all") + + documentRetrievalFilterValueCount(activeDraft.scopeFilters, activeDraft.selectedDocumentIds.length) + : 0; /* A retrieval layer errored, so no count from this search is trustworthy — including a non-zero one. The band owns that claim: it renders `matchCount` @@ -1336,14 +1357,14 @@ function DocumentSearchResultsPanelImpl({ above zero it is the only thing that says the list is a floor rather than the answer. (Raised by Devin review on PR #1640.) */ const retrievalDegraded = Boolean(searchScope?.retrieval?.degraded); - const documentFilterSheet = showFilterControl ? ( + const documentFilterSheet = showFilterControl && filterPanelOpen ? ( setFilterPanelState({ query, open: false })} panelId={filterPanelId} testId="document-filter-panel" title="Filter documents" - description="Set retrieval scope and refine the matches already returned. Changes run together." + description="Set retrieval scope and refine the matches already returned. Source-scope counts cover loaded sources; changes run together across the full indexed library." chromeResetKey={query} groups={documentFilterGroups} applicationMode="staged" From def4df1dae6c3ff5e7b138e782d33003a2828d39 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:52:34 +0800 Subject: [PATCH 09/12] fix(filters): restore missing service nav and sheet safe-area types --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 + src/components/services/service-group-nav.tsx | 113 ++++++++++++++++++ src/components/ui/sheet.tsx | 13 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/components/services/service-group-nav.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 5d3a2921c1..ef90f84310 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1001,7 +1001,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 6 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 24 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index be124b8b6e..f79c22334f 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1507,6 +1507,7 @@ "src/components/forms/form-detail-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx" ], @@ -1534,6 +1535,7 @@ "src/components/forms/form-detail-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { diff --git a/src/components/services/service-group-nav.tsx b/src/components/services/service-group-nav.tsx new file mode 100644 index 0000000000..b0866e99f4 --- /dev/null +++ b/src/components/services/service-group-nav.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown } from "lucide-react"; +import { useId, useState } from "react"; + +import { Sheet } from "@/components/ui/sheet"; +import { cn } from "@/components/ui-primitives"; +import { serviceCoreGroups, type ServiceCoreGroupId } from "@/lib/service-core-groups"; + +type GroupValue = ServiceCoreGroupId | null; + +export function ServiceGroupNav({ + activeGroup, + hrefForGroup, + counts, + className, +}: { + activeGroup: GroupValue; + hrefForGroup: (group: GroupValue) => string; + counts?: Partial> & { all?: number }; + className?: string; +}) { + const [moreOpen, setMoreOpen] = useState(false); + const panelId = useId(); + const phonePrimary = serviceCoreGroups.slice(0, 2); + const phoneMore = serviceCoreGroups.slice(2); + const moreActive = phoneMore.some((group) => group.id === activeGroup); + + const groupLink = (group: (typeof serviceCoreGroups)[number], phone = false) => ( + setMoreOpen(false)} + className={cn( + "inline-flex min-h-tap min-w-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10", + activeGroup === group.id + ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", + )} + > + {phone ? group.shortLabel : group.label} + {typeof counts?.[group.id] === "number" ? ( + {counts[group.id]} + ) : null} + + ); + + return ( + + ); +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 607aebbd43..7072c0690e 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/sheet-focus"; export type SheetMobileSize = "content" | "viewport"; +export type SheetMobileHeaderSafeArea = "none" | "padding" | "offset"; type SheetAccessibleName = | { title: string; labelledBy?: string; ariaLabel?: string } @@ -64,6 +65,13 @@ type SheetBaseProps = { placement?: "default" | "left" | "right" | "responsive-right"; mobilePlacement?: "bottom" | "top" | "fullscreen"; mobileSize?: SheetMobileSize; + /** + * Keeps the Sheet-owned header controls below the phone top safe area. + * Fullscreen sheets default to `padding`; near-full bottom sheets must opt in + * because short bottom sheets should not inherit a notch-sized empty band. + * Use `offset` only for an absolutely positioned header. + */ + mobileHeaderSafeArea?: SheetMobileHeaderSafeArea; portal?: boolean; desktopBackdropClassName?: string; testId?: string; @@ -113,6 +121,7 @@ export function Sheet({ placement = "default", mobilePlacement = "bottom", mobileSize = "content", + mobileHeaderSafeArea, portal = true, desktopBackdropClassName, testId, @@ -354,6 +363,7 @@ export function Sheet({ const defaultSheetIsFullscreen = !sideSheet && mobilePlacement === "fullscreen"; const defaultSheetIsTopAligned = !sideSheet && mobilePlacement === "top"; const defaultSheetUsesViewportSize = !sideSheet && mobileSize === "viewport"; + const resolvedMobileHeaderSafeArea = mobileHeaderSafeArea ?? (defaultSheetIsFullscreen ? "padding" : "none"); const contentClassTokens = contentClassName?.split(/\s+/) ?? []; const hasMobileMaxHeight = contentClassTokens.some((token) => /^!?max-h-/.test(token)); const hasSmallScreenMaxHeight = contentClassTokens.some((token) => /^sm:!?max-h-/.test(token)); @@ -395,6 +405,7 @@ export function Sheet({ ref={panelRef} id={id} data-testid={testId} + data-mobile-header-safe-area={resolvedMobileHeaderSafeArea} role="dialog" aria-modal="true" aria-labelledby={resolvedLabelledBy} @@ -462,6 +473,8 @@ export function Sheet({ className={cn( "flex items-center justify-between gap-3 border-b border-[color:var(--border)] p-4 sm:p-5", headerClassName, + resolvedMobileHeaderSafeArea === "padding" && "pt-[max(1rem,var(--safe-area-top))] sm:pt-5", + resolvedMobileHeaderSafeArea === "offset" && "top-[max(0.75rem,var(--safe-area-top))] sm:top-4", )} >
From df3c4ffa2c413c020eaa0c851cddb3fe1959d282 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:58:18 +0800 Subject: [PATCH 10/12] chore(ledger): record PR #1998 review outcome --- ...9528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md diff --git a/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md b/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md new file mode 100644 index 0000000000..fc3972403c --- /dev/null +++ b/docs/branch-review-records/3cb7ccda715349528ce874a104d8f7086772b310145b8ef3f028b82ae6b4f63c.record.md @@ -0,0 +1 @@ +| 2026-08-17 | codex/pr-1998-fix | 89d764ec9df835c3cb477d4e71859e62b55311cb | pr | PARTIAL-FIX | typecheck, tests(sheets+ui-tools), merge main, docs format | From 7e55c066e20be34beca8441b2e0f9c40be5fcb95 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:23:27 +0800 Subject: [PATCH 11/12] fix(filters): address CodeRabbit review findings and test assertions --- .../document-search-results.tsx | 107 +++++++++--------- .../medication-prescribing-workspace.tsx | 3 +- src/components/dsm/dsm-search-page.tsx | 2 +- src/lib/document-filter-model.ts | 3 +- tests/document-filter-panel.dom.test.tsx | 2 +- tests/filter-review-regressions.dom.test.tsx | 7 +- 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index b711421c25..d400def4a6 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -1035,19 +1035,16 @@ function DocumentSearchResultsPanelImpl({ const draftSourceDocuments = filterPanelOpen ? recentDocuments : []; const loadedSourceCountsAreComplete = documentCount > 0 && draftSourceDocuments.length >= documentCount; const draftSelectedDocumentIds = new Set(filterPanelOpen ? activeDraft.selectedDocumentIds : []); - const draftTagFacetGroups = filterPanelOpen - ? projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys) - : []; + const draftTagFacetGroups = filterPanelOpen ? projectSmartTagFacetGroups(tagFacetIndex, activeDraft.facetKeys) : []; const draftVisibleMatches = filterPanelOpen ? filterDocumentsBySmartTagFacetIndex(tagFacetIndex, activeDraft.facetKeys) : []; const draftResultTabs = filterPanelOpen ? resultTypeTabs(draftVisibleMatches) : []; - const draftResultType = filterPanelOpen && draftResultTabs.some((tab) => tab.key === activeDraft.resultType) - ? activeDraft.resultType - : "all"; - const draftDisplayedMatches = filterPanelOpen - ? filterMatchesByResultType(draftVisibleMatches, draftResultType) - : []; + const draftResultType = + filterPanelOpen && draftResultTabs.some((tab) => tab.key === activeDraft.resultType) + ? activeDraft.resultType + : "all"; + const draftDisplayedMatches = filterPanelOpen ? filterMatchesByResultType(draftVisibleMatches, draftResultType) : []; function toggleDraftListFilter( key: DocumentLabelFilterKey | "sourceStatuses" | "validationStatuses" | "extractionQualities", @@ -1254,8 +1251,7 @@ function DocumentSearchResultsPanelImpl({ value, label: value === "local" ? "Local" : "Non-local", hint: loadedSourceCountHint(count), - disabled: - loadedSourceCountsAreComplete && count === 0 && activeDraft.scopeFilters.locality !== value, + disabled: loadedSourceCountsAreComplete && count === 0 && activeDraft.scopeFilters.locality !== value, }; }), ], @@ -1357,50 +1353,51 @@ function DocumentSearchResultsPanelImpl({ above zero it is the only thing that says the list is a floor rather than the answer. (Raised by Devin review on PR #1640.) */ const retrievalDegraded = Boolean(searchScope?.retrieval?.degraded); - const documentFilterSheet = showFilterControl && filterPanelOpen ? ( - setFilterPanelState({ query, open: false })} - panelId={filterPanelId} - testId="document-filter-panel" - title="Filter documents" - description="Set retrieval scope and refine the matches already returned. Source-scope counts cover loaded sources; changes run together across the full indexed library." - chromeResetKey={query} - groups={documentFilterGroups} - applicationMode="staged" - primaryActionLabel="Update search" - onApply={applyDocumentFilters} - onClearAll={ - draftActiveFilterCount > 0 - ? () => - setFilterDraft((current) => ({ - ...current, - facetKeys: [], - resultType: "all", - scopeFilters: {}, - selectedDocumentIds: [], - })) - : undefined - } - summary={{ - count: draftDisplayedMatches.length, - noun: draftDisplayedMatches.length === 1 ? "match" : "matches", - }} - coverage={{ - visibleCount: draftDisplayedMatches.length, - totalCount: matches.length, - label: "Visible retrieved matches", - }} - secondaryAction={{ - label: "Browse all sources", - count: documentCount > 0 ? documentCount : undefined, - onClick: () => { - setFilterPanelState({ query, open: false }); - onOpenLibrary(); - }, - }} - /> - ) : null; + const documentFilterSheet = + showFilterControl && filterPanelOpen ? ( + setFilterPanelState({ query, open: false })} + panelId={filterPanelId} + testId="document-filter-panel" + title="Filter documents" + description="Set retrieval scope and refine the matches already returned. Source-scope counts cover loaded sources; changes run together across the full indexed library." + chromeResetKey={query} + groups={documentFilterGroups} + applicationMode="staged" + primaryActionLabel="Update search" + onApply={applyDocumentFilters} + onClearAll={ + draftActiveFilterCount > 0 + ? () => + setFilterDraft((current) => ({ + ...current, + facetKeys: [], + resultType: "all", + scopeFilters: {}, + selectedDocumentIds: [], + })) + : undefined + } + summary={{ + count: draftDisplayedMatches.length, + noun: draftDisplayedMatches.length === 1 ? "match" : "matches", + }} + coverage={{ + visibleCount: draftDisplayedMatches.length, + totalCount: matches.length, + label: "Visible retrieved matches", + }} + secondaryAction={{ + label: "Browse all sources", + count: documentCount > 0 ? documentCount : undefined, + onClick: () => { + setFilterPanelState({ query, open: false }); + onOpenLibrary(); + }, + }} + /> + ) : null; const showIdentityHeader = recordMatchCount > 0 || matches.length > 0 || diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index d1da7304b9..daa70f2be1 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { Activity, @@ -544,6 +544,7 @@ function MedicationResults({ const setMatchFilter = (value: MedicationMatchQuality) => replaceResultFilterUrl((params) => writeResultFilterValue(params, "match", value, "all", medicationMatchValues)); const toggleClass = (value: string) => { + if (classValueSet.size === 0) return; replaceResultFilterUrl((params) => { const next = new Set(readResultFilterValues(params, "class", classValueSet)); if (!next.delete(value)) next.add(value); diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 2e681262e1..af945f553a 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -316,7 +316,7 @@ export function DsmSearchPage({ {selected.length ? (
    {selected.map((slug, index) => { - const item = results.find((result) => result.slug === slug); + const item = queryResults.find((result) => result.slug === slug); return (
  1. { await user.click(screen.getByTestId("document-filter-trigger-phone")); const panel = screen.getByTestId("document-filter-panel"); - await user.click(within(panel).getByRole("radio", { name: /^Local \(1\)$/ })); + await user.click(within(panel).getByRole("radio", { name: /^Local \(1 loaded source\)$/ })); expect(onDocumentFiltersApply).not.toHaveBeenCalled(); await user.click(within(panel).getByTestId("document-filter-panel-done")); diff --git a/tests/filter-review-regressions.dom.test.tsx b/tests/filter-review-regressions.dom.test.tsx index 9423590c5a..ec965b9d55 100644 --- a/tests/filter-review-regressions.dom.test.tsx +++ b/tests/filter-review-regressions.dom.test.tsx @@ -231,9 +231,6 @@ describe("filter review regressions", () => { window.history.replaceState(null, "", "/medications/search?class=Other"); const view = renderMedicationWorkspace(); - expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); - fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); - fireEvent.click(screen.getByRole("radio", { name: "All medications, 0 matches" })); expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); medicationCatalogState.current = { @@ -280,5 +277,9 @@ describe("filter review regressions", () => { expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); expect(screen.getByRole("button", { name: "Remove Class: Other filter" })).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("medication-filter-trigger-desktop")); + fireEvent.click(screen.getByRole("radio", { name: /^All medications/ })); + expect(new URLSearchParams(window.location.search).get("class")).toBe("Other"); }); }); From c9ac046d9d947351e08b1470292e62649ac9610e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:25:24 +0800 Subject: [PATCH 12/12] chore(ledger): record PR #1998 verified review outcome --- ...cfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md diff --git a/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md b/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md new file mode 100644 index 0000000000..1e59a4924e --- /dev/null +++ b/docs/branch-review-records/81782f186cfefcfe52006b705b15b5018bd6bc01d0998839c9544151db434263.record.md @@ -0,0 +1 @@ +| 2026-08-17 | codex/filter-system-overhaul | 7e55c066e20be34beca8441b2e0f9c40be5fcb95 | pr #1998 clinical filter overhaul | PASS | typecheck, vitest (filters, sheets, search band, dom panels), prettier |