diff --git a/.design-sync/config.json b/.design-sync/config.json index f63a82a5f7..d022658458 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -58,7 +58,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": "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; children: ReactNode; closeButtonClassName?: string; closeLabel?: string; contentClassName?: string; contentStyle?: CSSProperties; description?: string; descriptionContent?: ReactNode; desktopBackdropClassName?: string; footer?: ReactNode; headerActions?: ReactNode; headerClassName?: string; headerLeading?: ReactNode; id?: string; initialFocusRef?: RefObject; labelledBy?: string; mobilePlacement?: \"bottom\" | \"top\" | \"fullscreen\"; mobileSize?: SheetMobileSize; onClose: () => void; open: boolean; placement?: \"default\" | \"left\"; portal?: boolean; returnFocusRef?: RefObject; testId?: string; title?: string; titleAccessory?: ReactNode; titleClassName?: string;", + "Sheet": "ariaLabel?: string; bodyClassName?: string; children: ReactNode; closeButtonClassName?: string; closeLabel?: string; contentClassName?: string; contentStyle?: CSSProperties; description?: string; descriptionContent?: ReactNode; desktopBackdropClassName?: string; footer?: ReactNode; headerActions?: ReactNode; headerClassName?: string; headerLeading?: ReactNode; id?: string; initialFocusRef?: RefObject; labelledBy?: string; mobilePlacement?: \"bottom\" | \"top\" | \"fullscreen\"; mobileSize?: SheetMobileSize; onClose: () => void; open: boolean; placement?: \"default\" | \"left\"; 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/branch-review-ledger.md b/docs/branch-review-ledger.md index c071bc679a..40658febc5 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -641,5 +641,9 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-05 | codex/v2-design-system-completion | 8863cea53bf4df59e8795dcaab1fa420b5109516 | PR #1616 v2 design system CI+reviews | fixed typecheck + review defects; baselines remain not-committed by design | tsc; vitest ui-v2/accessible-table/ui-primitives/design-system-adoption | | 2026-08-05 | codex/v2-design-system-completion | 8b49bfa2b66ed78577b08e1a50414db55898f2df | PR #1616 v2 design system CI+reviews | fixed typecheck + review defects; baselines remain not-committed by design | tsc; vitest ui-v2/accessible-table/ui-primitives/design-system-adoption | | 2026-08-05 | codex/v2-design-system-completion | 1ea30c19c48b3791519659abf85b20c9e8c7faab | PR #1616 review feedback disposition | COMPLETED: fixed mode-home empty composer reserve + chip category v2 tokens; dispositioned remaining Devin analysis findings to outstanding-issues; unresolved threads 0 | vitest search-route-ownership 12/12; ckb-v2-token-contract+related green; CI in progress on tip | +| 2026-08-05 | codex/editable-search-pins | 18c3069c7ad19b7e92e3ec9e0b65b5d845afe9c1 | editable search pins menu production integration and mockups | no findings; ready for draft review | verify:pr-local; focused pin tests; typecheck; responsive browser QA; phone-chrome contracts and journey | +| 2026-08-05 | codex/editable-search-pins | 193afe637481c0ed2fa82dbe9bb1f1d1c8bcc4d8 | editable search pins menu review follow-up | fixed remaining review defects; unresolved threads cleared; auto-merge armed | vitest:search-pins 18/18; tsc clean on touched surfaces; review threads 0 unresolved | +| 2026-08-05 | codex/editable-search-pins | a0e2801fb291a672274c9a723108637d3cdb43f9 | editable search pins menu review follow-up | fixed remaining review defects; lint setState-in-effect; unresolved threads cleared; auto-merge armed | vitest:search-pins 18/18; eslint touched surfaces; review threads 0 unresolved | | 2026-08-05 | cursor/phone-mode-sheet-yes-05c0 | b579d68491980388ff3e4ce8aba85530e87a9d84 | Run PR sweep | threads already resolved; synced main via update-branch; CI was green pre-sync | CI: PR required SUCCESS pre-sync | | 2026-08-05 | cursor/phone-mode-sheet-yes-05c0 | 9152e239076ff823ad3bf812d282ffc87c3295b7 | Run PR sweep | threads already resolved; merged origin/main; CI was green pre-sync | CI: PR required SUCCESS pre-sync; merge-tree clean | +| 2026-08-05 | codex/editable-search-pins | 67f2c71e8376fbc44615f765a73cb6a37c4ddb40 | editable search pins menu review follow-up | merged main; review threads cleared; auto-merge armed | vitest search-pins+mode-action+command-surface: Test Files 4 passed (4); Tests 33 passed (33); eslint max-warnings 0 on touched surfaces | diff --git a/docs/site-map.md b/docs/site-map.md index 0b17c4ab6a..65089687af 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1017,9 +1017,11 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/phone-inpage-navigation` - Route discovered from app directory Source: `src/app/mockups/phone-inpage-navigation/page.tsx`. - `/mockups/phone-mode-sheet-yes` - Route discovered from app directory Source: `src/app/mockups/phone-mode-sheet-yes/page.tsx`. +- `/mockups/pinned-plus-menu` - Route discovered from app directory Source: `src/app/mockups/pinned-plus-menu/page.tsx`. - `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. - `/mockups/search-band-directions` - Route discovered from app directory Source: `src/app/mockups/search-band-directions/page.tsx`. - `/mockups/search-heading` - Route discovered from app directory Source: `src/app/mockups/search-heading/page.tsx`. +- `/mockups/search-lens-menu` - Route discovered from app directory Source: `src/app/mockups/search-lens-menu/page.tsx`. - `/mockups/search-refine-adaptive` - Route discovered from app directory Source: `src/app/mockups/search-refine-adaptive/page.tsx`. - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. diff --git a/docs/superpowers/plans/2026-08-05-editable-pin-menu-mockups.md b/docs/superpowers/plans/2026-08-05-editable-pin-menu-mockups.md new file mode 100644 index 0000000000..03bf585999 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-editable-pin-menu-mockups.md @@ -0,0 +1,26 @@ +# Search + Editable Pins Menu — Implementation Record + +## Goal + +Perfect `/mockups/search-lens-menu` around editable app-destination pins while preserving the original visual direction. + +## Completed + +- [x] Preserve the light attached fold-out and paired desktop/phone frames. +- [x] Replace editable search lenses with editable app-destination pins. +- [x] Add `Your pins` and a separate `Search in` section. +- [x] Keep Documents, global, and alternate-area search choices explicit. +- [x] Retain overlapping phone icon constellations with accessible labels. +- [x] Add a functional local `New pin` flow with editable name and destinations. +- [x] Add a functional Ward essentials edit flow. +- [x] Adapt the command deck to distinguish pins from search commands. +- [x] Adapt the inline builder into an inline pin bar. +- [x] Add focused DOM interaction coverage. +- [x] Verify 18 width/direction combinations, dialog containment, keyboard closing and focus restoration, reduced motion, forced colors, and console output. + +## Remaining production work + +- [ ] Decide persistence, synchronization, permissions, and pin limits. +- [ ] Map mockup destinations to canonical production routes. +- [ ] Complete physical iPhone Safari and installed-PWA acceptance. +- [ ] Conduct clinical governance review before production search integration. diff --git a/docs/superpowers/specs/2026-08-05-editable-pin-menu-mockups-design.md b/docs/superpowers/specs/2026-08-05-editable-pin-menu-mockups-design.md new file mode 100644 index 0000000000..134953157c --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-editable-pin-menu-mockups-design.md @@ -0,0 +1,63 @@ +# Search + Editable Pins Menu — Final Mockup Direction + +## Preferred artifact + +Route: `/mockups/search-lens-menu` + +The preferred visual direction is the original light clinical search-menu study, refined around the corrected pin model. It preserves its attached fold-out, deliberate page scrim, compact overlapping icons, and paired desktop/phone treatments. + +## Product model + +A **pin** is a user-named collection of useful destinations from anywhere in the app. It is not a search scope, filter, or saved query. + +Example pins: + +- Ward essentials: Documents, Medications, Forms. +- My quick tools: Clinical tools, Services, Differentials. + +Users can create a pin, edit its name, add or remove destinations, and recognise its contents through the first three overlapping destination icons. + +Search remains explicit and separate: + +- Search Documents — current mode. +- Search all clinical areas — global search. +- Choose another search area — explicit alternate scope. + +Opening or selecting a pin never silently changes the composer’s search scope. + +## Direction 1 — Attached pin fold-out + +Recommended. + +- Opens from the composer `+` button. +- Shows `Your pins` first. +- Shows `Search in` as a separate section below. +- Includes useful actions without confusing them with either pins or search scope. +- Desktop/tablet use an attached panel with a subdued scrim. +- Phone uses a contained bottom sheet above the visible composer. + +## Direction 2 — Pins and search command deck + +- Presents pins and search commands in one power-user workspace. +- The navigation rail clearly labels both object types. +- Selecting a pin previews its app destinations. +- Selecting a search command runs the current query in that area. + +## Direction 3 — Inline pin bar + +- Pin destinations become removable chips directly above the composer. +- The Documents search scope remains visible and stable beneath the pin. +- Editing uses a compact destination picker on desktop and phone. + +## Accessibility and responsive rules + +- Minimum 48 px primary touch targets. +- Icon constellations expose full destination labels to assistive technology. +- Escape closes panels and restores focus to the composer trigger. +- Panels own internal scrolling and remain within device frames. +- Reduced-motion and forced-colors modes remain usable. +- Validate 320, 390, 639, 768, 1440, and 1920 px. + +## Boundaries + +This remains a local design-scratch artifact. It does not persist pins, call APIs or providers, alter production navigation/search, change retrieval ranking, or deploy anything. diff --git a/src/app/globals.css b/src/app/globals.css index bce509ddff..df02de4bce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1327,7 +1327,10 @@ summary::-webkit-details-marker { outline-offset: 2px; } -.mode-action-body { +/* Cap scrolling only inside the anchored popover surface, which defines + --mode-action-body-max-height. Sheet/dialog hosts leave the variable unset; + applying max-height there would create a nested non-capping scroll region. */ +.mode-action-surface .mode-action-body { max-height: var(--mode-action-body-max-height); overflow-y: auto; overscroll-behavior: contain; diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index c6e3c5bb6c..94df07b142 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -36,6 +36,10 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isWarningConsolidationMockup = pathname === "/mockups/warning-consolidation"; const isWarningLineMockup = pathname === "/mockups/warning-line"; const isAnswerHomeProposalMockup = pathname === "/mockups/answer-home-proposal"; + // These studies draw complete app shells and their own search composers, so + // shared chrome would make the interaction studies ambiguous. + const isSearchLensMenuMockup = pathname === "/mockups/search-lens-menu"; + const isPinnedPlusMenuMockup = pathname === "/mockups/pinned-plus-menu"; // Draws its own phone frames with an in-frame Choose mode sheet; shared chrome // would read as a second real header over the study. const isPhoneModeSheetYesMockup = pathname === "/mockups/phone-mode-sheet-yes"; @@ -80,6 +84,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isSearchLensMenuMockup && + !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup } chromeVisible={ @@ -92,6 +98,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isSearchLensMenuMockup && + !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup } > diff --git a/src/app/mockups/pinned-plus-menu/page.tsx b/src/app/mockups/pinned-plus-menu/page.tsx new file mode 100644 index 0000000000..e4ff4b6576 --- /dev/null +++ b/src/app/mockups/pinned-plus-menu/page.tsx @@ -0,0 +1,5 @@ +import { PinnedPlusMenuMockupsPage } from "@/components/pinned-plus-menu-mockups"; + +export default function Page() { + return ; +} diff --git a/src/app/mockups/search-lens-menu/page.tsx b/src/app/mockups/search-lens-menu/page.tsx new file mode 100644 index 0000000000..0b4a071cda --- /dev/null +++ b/src/app/mockups/search-lens-menu/page.tsx @@ -0,0 +1,5 @@ +import { SearchLensMenuMockupsPage } from "@/components/search-lens-menu-mockups"; + +export default function SearchLensMenuMockupRoute() { + return ; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 70d95b41aa..4c981917a8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3234,7 +3234,7 @@ export function ClinicalDashboard({ const returnTarget = documentsDrawerReturnFocusRef.current; window.requestAnimationFrame(() => { const fallbackTarget = Array.from( - document.querySelectorAll('button[aria-haspopup="menu"][aria-label$=" options"]'), + document.querySelectorAll('button[aria-haspopup][aria-label$=" options"]'), ).find((element) => element.isConnected && element.getClientRects().length > 0); const focusTarget = returnTarget?.isConnected ? returnTarget : fallbackTarget; focusTarget?.focus({ preventScroll: true }); diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index c7fa818bc9..4d82d827ff 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -22,6 +22,7 @@ import { Filter, Globe2, Loader2, + Layers3, Menu, MessageSquarePlus, Plus, @@ -36,8 +37,10 @@ import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer"; import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; +import { SearchPinsMenu } from "@/components/clinical-dashboard/search-pins-menu"; import { ModeActionPopup, modeActionItemsFor, @@ -76,6 +79,8 @@ import { setModeHomeComposerReservePending, } from "@/lib/mode-home-composer"; import { resolveScrollBehavior } from "@/lib/scroll-behavior"; +import type { CommandSurfacePlacement } from "@/lib/search-command-surface"; +import { useCommandDropdownDisplayableByPlacement } from "@/components/clinical-dashboard/use-command-dropdown-displayable"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; import { tagSearchText } from "@/lib/document-tags"; @@ -353,6 +358,7 @@ export function MasterSearchHeader({ const [commandDropdownOpen, setCommandDropdownOpen] = useState(false); const [commandListboxId, setCommandListboxId] = useState(); const [commandActiveItemId, setCommandActiveItemId] = useState(null); + const commandDropdownDisplayableByPlacement = useCommandDropdownDisplayableByPlacement(); const [modeMenuOpen, setModeMenuOpen] = useState(false); // Which menuitemradio should receive initial focus when the mode menu opens // (keyboard ArrowOpen or the active mode on tap). Shared by the desktop @@ -485,6 +491,7 @@ export function MasterSearchHeader({ const prefetchedModeHrefsRef = useRef(new Set()); const scopePopoverRef = useRef(null); const actionMenuTriggerRef = useRef(null); + const actionMenuSheetReturnFocusRef = useRef(null); const scopeFilterInputRef = useRef(null); const touchStartY = useRef(null); const selectedDocumentIdSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]); @@ -594,8 +601,6 @@ export function MasterSearchHeader({ ? "factsheets" : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); - const actionMenuTitle = selectedAppMode.label; - const actionMenuSubtitle = searchMode === "answer" ? "Source-backed mode" : selectedAppMode.description; const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; const useMobileBackControl = mobileLeadingAction === "back"; @@ -1033,6 +1038,27 @@ export function MasterSearchHeader({ restoreFocusUnlessMoved(actionMenuTriggerRef.current); }); }, []); + const handleFocusSearchInput = useEventCallback(() => { + queryInputRef?.current?.focus(); + }); + const retargetActionMenuSheetFocusToSearchInput = useEventCallback(() => { + const target = queryInputRef?.current ?? null; + actionMenuSheetReturnFocusRef.current = target; + if (!currentUsesScopeSheet()) { + window.requestAnimationFrame(() => { + restoreFocusUnlessMoved(target); + }); + } + }); + const handleActionMenuCurrentSearch = useEventCallback(() => { + retargetActionMenuSheetFocusToSearchInput(); + setActionMenuOpen(false); + }); + const handleActionMenuGlobalSearch = useEventCallback(() => { + retargetActionMenuSheetFocusToSearchInput(); + setActionMenuOpen(false); + setCommandDropdownOpen(true); + }); const phoneLayoutGateRef = useRef(null); useEffect(() => { @@ -1580,7 +1606,8 @@ export function MasterSearchHeader({ // Tablet/desktop composers keep the site-wide notice everywhere. const showsComposerPrivacyNotice = usesPhoneSearchLayout ? isDesktopHomeComposer : true; - const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; + const commandSurfacePlacement: CommandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; + const commandDropdownDisplayable = commandDropdownDisplayableByPlacement[commandSurfacePlacement]; // Search sits outside the collapsing top-bar row. Sticky hosts pin an outer // top-bar stack; result composers portal into page flow at sm+, so this // relative fallback only covers the brief pre-portal default placement (a @@ -1623,7 +1650,7 @@ export function MasterSearchHeader({ data-command-open={ // Phones never show the command dropdown, so the dock scrim must not // grow for it — gate the open attribute to widths that can display it. - usesBottomComposerPlacement && !usesPhoneSearchLayout && commandDropdownOpen ? "true" : undefined + usesBottomComposerPlacement && commandDropdownDisplayable && commandDropdownOpen ? "true" : undefined } data-scroll-hidden={shouldHideBottomOnScroll && bottomComposerHidden ? "true" : undefined} {...(shouldHideBottomOnScroll ? composerFocusProps : undefined)} @@ -1719,7 +1746,7 @@ export function MasterSearchHeader({ onRunModeAction={runModeAction} onListboxIdReady={setCommandListboxId} onActiveItemIdChange={setCommandActiveItemId} - onFocusSearchInput={() => queryInputRef?.current?.focus()} + onFocusSearchInput={handleFocusSearchInput} >
{ + actionMenuSheetReturnFocusRef.current = null; setUsesScopeSheet(currentUsesScopeSheet()); + setCommandDropdownOpen(false); setModeMenuOpen(false); setScopeOpen(false); setScopeSheetOpen(false); }} onAction={runModeAction} - onModeSelect={selectAppModeById} onPlacementChange={setActionMenuPlacement} triggerClassName="answer-footer-search-action" triggerRef={actionMenuTriggerRef} integrated={usesFooterChipLayout} integratedChipRow={showFooterSearchChips} useSheet={usesScopeSheet} + sheetReturnFocusRef={actionMenuSheetReturnFocusRef} dismissIgnoreRefs={[modeMenuRef]} + customBody={ + setActionMenuOpen(false)} + onCurrentSearch={handleActionMenuCurrentSearch} + onGlobalSearch={handleActionMenuGlobalSearch} + onModeSelect={(modeId) => { + setActionMenuOpen(false); + selectAppModeById(modeId); + }} + onAction={(actionId) => { + setActionMenuOpen(false); + runModeAction(actionId); + }} + /> + } /> {/* The clear button is a flex sibling (not absolutely positioned): the diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 73a532c3bb..7577f4f10c 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -9,6 +9,7 @@ import { useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, type Ref, type RefObject, } from "react"; @@ -64,6 +65,9 @@ export type ModeActionSetId = | "factsheets"; export type ModeActionPlacement = "up" | "down"; +const actionSurfaceId = "daily-actions-sheet"; +const customBodyFocusScopeId = "daily-actions-sheet-body"; + type IntegratedSurfaceLayout = { placement: ModeActionPlacement; left: number; @@ -382,7 +386,9 @@ export function ModeActionPopup({ integratedChipRow = true, useSheet = false, triggerRef, + sheetReturnFocusRef, dismissIgnoreRefs, + customBody, }: { open: boolean; title: string; @@ -405,8 +411,13 @@ export function ModeActionPopup({ /** Render the actions in a bottom sheet / centred dialog (phones + tablets ≤1023px) * instead of the anchored desktop popover. */ useSheet?: boolean; + /** Optional dynamic focus target for Sheet closes that intentionally leave the menu. */ + sheetReturnFocusRef?: RefObject; /** Header-owned controls (e.g. app mode trigger) that must stay clickable above the portaled menu. */ dismissIgnoreRefs?: readonly RefObject[]; + /** Optional richer body for the shared + surface. The popup continues to own + * positioning, dismissal, focus restoration, and responsive Sheet behavior. */ + customBody?: ReactNode; }) { const buttonRef = useRef(null); const rootRef = useRef(null); @@ -422,12 +433,19 @@ export function ModeActionPopup({ const [modeSelectorOpen, setModeSelectorOpen] = useState(false); const canSwitchMode = Boolean(modeOptions?.length && onModeSelect); const selectedModeOption = modeOptions?.find((mode) => mode.id === selectedModeId); + // Truthiness only — callers pass inline JSX for customBody, so the element + // identity must not invalidate placement measurement every parent render. + const hasCustomBody = Boolean(customBody); const closeAndRestoreFocus = useCallback(() => { setModeSelectorOpen(false); onOpenChange(false); window.requestAnimationFrame(() => buttonRef.current?.focus()); }, [onOpenChange, setModeSelectorOpen]); + const resolveSheetReturnFocusTarget = useCallback( + () => sheetReturnFocusRef?.current ?? buttonRef.current, + [sheetReturnFocusRef], + ); // The sheet owns its own focus trap, Escape, and backdrop dismissal; only the // anchored popover needs the outside-click dismissable layer. @@ -442,6 +460,15 @@ export function ModeActionPopup({ }); function focusActionItem(index: number) { + if (customBody) { + const body = document.getElementById(customBodyFocusScopeId); + const focusable = body?.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (!focusable?.length) return; + focusable[index < 0 ? focusable.length - 1 : Math.min(index, focusable.length - 1)]?.focus(); + return; + } const nextIndex = (index + items.length) % items.length; itemRefs.current[nextIndex]?.focus(); } @@ -459,7 +486,11 @@ export function ModeActionPopup({ const edgePadding = 12; const availableAbove = Math.max(0, rect.top - viewportTop - edgePadding); const availableBelow = Math.max(0, viewportBottom - rect.bottom - edgePadding); - const { minSurfaceHeight, headerHeight } = estimateActionListHeights(items.length, integrated); + // Custom bodies (pins + search) are substantially taller than one row per + // mode action; size placement against a pin-panel floor so "fits above" + // is not chosen from an unrelated action-count estimate. + const estimatedItemCount = hasCustomBody ? Math.max(items.length, 10) : items.length; + const { minSurfaceHeight, headerHeight } = estimateActionListHeights(estimatedItemCount, integrated); const detachedUpOffset = 16; const integratedDownOffset = integratedChipRow ? 58 : 14; const detachedDownOffset = integrated ? integratedDownOffset : 14; @@ -513,7 +544,7 @@ export function ModeActionPopup({ } else { setIntegratedSurfaceLayout(null); } - }, [integrated, integratedChipRow, items.length]); + }, [hasCustomBody, integrated, integratedChipRow, items.length]); function openWithFocus(index: number) { onBeforeOpen?.(); @@ -526,7 +557,7 @@ export function ModeActionPopup({ function handleTriggerKeyDown(event: ReactKeyboardEvent) { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; event.preventDefault(); - openWithFocus(event.key === "ArrowUp" ? items.length - 1 : 0); + openWithFocus(event.key === "ArrowUp" ? -1 : 0); } function handleItemKeyDown(event: ReactKeyboardEvent, index: number) { @@ -687,7 +718,7 @@ export function ModeActionPopup({ function renderActionRows() { return ( : null; + const popoverBody = customBodyWithFocusScope ?? defaultPopoverBody; + // Desktop popover header — same anatomy as the Sheet header the ≤1023px surface // uses (accent icon tile + stacked title/subtitle + close), so the menu reads as // one design across breakpoints. The title doubles as the mode switcher; the @@ -874,6 +911,10 @@ export function ModeActionPopup({ ref={surfaceRef} data-placement={placement} style={surfaceStyle} + id={customBody ? actionSurfaceId : undefined} + data-testid={customBody ? "daily-actions-menu" : undefined} + role={customBody ? "dialog" : undefined} + aria-label={customBody ? title : undefined} className={cn( "mode-action-surface z-[60] text-[color:var(--text)]", integrated && integratedSurfaceLayout ? "fixed" : "absolute", @@ -900,14 +941,14 @@ export function ModeActionPopup({ )} > {renderPopoverHeader()} - {renderPopoverBody()} + {popoverBody}
) : null; return ( <> - {useSheet && open ? ( + {useSheet ? ( ) : integrated && open && typeof document !== "undefined" ? ( createPortal(actionSurface, document.body) @@ -949,9 +993,9 @@ export function ModeActionPopup({ open && "bg-[color:var(--surface-subtle)] text-[color:var(--text)] rotate-45", )} aria-label={buttonLabel} - aria-controls={open ? "daily-actions-sheet" : undefined} + aria-controls={open ? actionSurfaceId : undefined} aria-expanded={open} - aria-haspopup="menu" + aria-haspopup={customBody ? "dialog" : "menu"} title={buttonLabel} onKeyDown={handleTriggerKeyDown} onClick={() => { diff --git a/src/components/clinical-dashboard/search-pins-menu.tsx b/src/components/clinical-dashboard/search-pins-menu.tsx new file mode 100644 index 0000000000..f1e286219a --- /dev/null +++ b/src/components/clinical-dashboard/search-pins-menu.tsx @@ -0,0 +1,652 @@ +"use client"; + +import { useEffect, useId, useState, type ReactNode } from "react"; +import Link from "next/link"; +import { + Check, + ChevronDown, + ChevronRight, + Globe2, + Pencil, + Plus, + Search, + Trash2, + X, + type LucideIcon, +} from "lucide-react"; + +import type { + ModeActionId, + ModeActionItem, + ModeActionModeOption, +} from "@/components/clinical-dashboard/mode-action-popup"; +import { cn } from "@/components/ui-primitives"; +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { appModeIcons } from "@/lib/app-mode-icons"; +import { + maximumSearchPins, + normalizeSearchPins, + readSearchPins, + searchPinDestinationIds, + searchPinsChangeEvent, + searchPinsStorageKey, + writeSearchPins, + type SearchPin, + type SearchPinDestinationId, +} from "@/lib/search-pins"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const destinationLabels: Record = { + documents: "Documents", + prescribing: "Medications", + forms: "Forms", + services: "Services", + differentials: "Differentials", + tools: "Clinical tools", +}; + +const destinationTone: Record = { + documents: + "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", + prescribing: + "border-[color:var(--tone-slate-border)] bg-[color:var(--tone-slate-soft)] text-[color:var(--tone-slate)]", + forms: "border-[color:var(--tone-purple-border)] bg-[color:var(--tone-purple-soft)] text-[color:var(--tone-purple)]", + services: + "border-[color:var(--tone-indigo-border)] bg-[color:var(--tone-indigo-soft)] text-[color:var(--tone-indigo)]", + differentials: + "border-[color:var(--tone-rose-border)] bg-[color:var(--tone-rose-soft)] text-[color:var(--tone-rose)]", + tools: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", +}; + +function PinIcons({ pin }: { pin: SearchPin }) { + const shown = pin.destinationIds.slice(0, 3); + const labels = pin.destinationIds.map((id) => destinationLabels[id]).join(", "); + return ( + + {shown.map((id, index) => { + const Icon = appModeIcons[id]; + return ( + + ); + })} + {pin.destinationIds.length > shown.length ? ( + + ) : null} + + ); +} + +function SectionHeading({ children, action }: { children: string; action?: ReactNode }) { + return ( +
+

{children}

+ {action} +
+ ); +} + +type EditorState = { pinId: string | null; name: string; destinationIds: SearchPinDestinationId[] }; + +let pinIdFallbackCounter = 0; + +function createPinId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + pinIdFallbackCounter += 1; + return `pin-${Date.now().toString(36)}-${pinIdFallbackCounter.toString(36)}`; +} + +export function SearchPinsMenu({ + currentModeId, + modeOptions, + actions, + globalSearchAvailable = true, + onClose, + onCurrentSearch, + onGlobalSearch, + onModeSelect, + onAction, +}: { + currentModeId: AppModeId; + modeOptions: readonly ModeActionModeOption[]; + actions: readonly ModeActionItem[]; + globalSearchAvailable?: boolean; + onClose: () => void; + onCurrentSearch: () => void; + onGlobalSearch: () => void; + onModeSelect: (modeId: string) => void; + onAction: (actionId: ModeActionId) => void; +}) { + const [pins, setPins] = useState(() => readSearchPins()); + const [expandedPinId, setExpandedPinId] = useState(null); + const [showModePicker, setShowModePicker] = useState(false); + const [editor, setEditor] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [editorNotice, setEditorNotice] = useState(null); + const editorDescriptionId = useId(); + const editorNoticeId = useId(); + const currentMode = appModeDefinition(currentModeId); + const CurrentModeIcon = appModeIcons[currentModeId]; + + useEffect(() => { + const syncStoredPins = (event: StorageEvent) => { + if (event.key === null || event.key === searchPinsStorageKey) setPins(readSearchPins()); + }; + const syncPins = (event: Event) => { + // CustomEvent without an init object exposes detail === null, not undefined. + // Treat both as "re-read storage" so a bare change signal cannot resurrect defaults. + const detail = event instanceof CustomEvent ? event.detail : undefined; + setPins(detail == null ? readSearchPins() : normalizeSearchPins(detail)); + }; + window.addEventListener("storage", syncStoredPins); + window.addEventListener(searchPinsChangeEvent, syncPins); + return () => { + window.removeEventListener("storage", syncStoredPins); + window.removeEventListener(searchPinsChangeEvent, syncPins); + }; + }, []); + + useEffect(() => { + if (!editor) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + setConfirmDelete(false); + setEditorNotice(null); + setEditor(null); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [editor]); + + function persistPins(next: SearchPin[]) { + const normalized = writeSearchPins(next); + setPins(normalized); + window.dispatchEvent(new CustomEvent(searchPinsChangeEvent, { detail: normalized })); + } + + function beginNewPin() { + if (pins.length >= maximumSearchPins) return; + setExpandedPinId(null); + setShowModePicker(false); + setConfirmDelete(false); + setEditorNotice(null); + setEditor({ pinId: null, name: "", destinationIds: ["documents"] }); + } + + function beginEditPin(pin: SearchPin) { + setExpandedPinId(null); + setShowModePicker(false); + setConfirmDelete(false); + setEditorNotice(null); + setEditor({ pinId: pin.id, name: pin.name, destinationIds: [...pin.destinationIds] }); + } + + function toggleEditorDestination(destinationId: SearchPinDestinationId) { + if (!editor) return; + const selected = editor.destinationIds.includes(destinationId); + if (selected && editor.destinationIds.length === 1) return; + setEditor({ + ...editor, + destinationIds: selected + ? editor.destinationIds.filter((id) => id !== destinationId) + : [...editor.destinationIds, destinationId], + }); + } + + function saveEditor() { + if (!editor) return; + const name = editor.name.trim(); + if (!name) return; + if (editor.pinId) { + if (!pins.some((pin) => pin.id === editor.pinId)) { + setEditorNotice("This pin was removed elsewhere. Close the editor and create it again if needed."); + return; + } + persistPins( + pins.map((pin) => (pin.id === editor.pinId ? { ...pin, name, destinationIds: editor.destinationIds } : pin)), + ); + setExpandedPinId(editor.pinId); + } else { + if (pins.length >= maximumSearchPins) { + setEditorNotice(`Maximum ${maximumSearchPins} pins reached. Delete a pin before creating another.`); + return; + } + const pin = { id: createPinId(), name, destinationIds: editor.destinationIds }; + persistPins([...pins, pin]); + setExpandedPinId(pin.id); + } + setConfirmDelete(false); + setEditorNotice(null); + setEditor(null); + } + + function deleteEditorPin() { + if (!editor?.pinId) return; + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + persistPins(pins.filter((pin) => pin.id !== editor.pinId)); + setConfirmDelete(false); + setEditorNotice(null); + setEditor(null); + } + + if (editor) { + return ( +
+
+
+

+ {editor.pinId ? "Edit pin" : "Create a new pin"} +

+

+ Name this shortcut and choose the app destinations it should contain. +

+
+ +
+ + + +
+ App destinations +
+ {searchPinDestinationIds.map((destinationId) => { + const Icon = appModeIcons[destinationId]; + const selected = editor.destinationIds.includes(destinationId); + return ( + + ); + })} +
+
+ + {editorNotice ? ( +

+ {editorNotice} +

+ ) : null} + +
+ {editor.pinId ? ( + + ) : null} + + + +
+
+ ); + } + + if (showModePicker) { + return ( +
+
+ +
+

Choose a search area

+

Your query stays in the composer.

+
+
+
+ {modeOptions.map((mode) => { + const Icon = mode.icon; + const active = mode.id === currentModeId; + return ( + + ); + })} +
+
+ ); + } + + return ( +
+ = maximumSearchPins} + title={pins.length >= maximumSearchPins ? `Maximum ${maximumSearchPins} pins reached` : undefined} + className={cn( + "inline-flex min-h-12 items-center gap-1 rounded-lg px-2 text-xs font-extrabold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)] disabled:cursor-not-allowed disabled:opacity-45", + focusRing, + )} + > + + +
+ {pins.length === 0 ? ( +

+ No pins yet. Create one for the app destinations you use together. +

+ ) : null} + {pins.map((pin) => { + const expanded = expandedPinId === pin.id; + return ( +
+
+ + +
+ {expanded ? ( +
+ {pin.destinationIds.map((destinationId) => { + const Icon = appModeIcons[destinationId]; + return ( + +
+ ) : null} +
+ ); + })} +
+ +
+ Search in + + {globalSearchAvailable ? ( + + ) : null} + +
+ +
+ Useful actions +
+ {actions.map((action) => { + const Icon: LucideIcon = action.icon; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 2b7cfb7241..d3f8e2ff5d 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -30,6 +30,8 @@ import { searchCommandSurfaceConfig, type CommandSurfacePlacement, } from "@/lib/search-command-surface"; +import { useCommandDropdownDisplayable } from "@/components/clinical-dashboard/use-command-dropdown-displayable"; +import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; import type { UniversalSearchDomain } from "@/lib/universal-search"; import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context"; @@ -405,31 +407,17 @@ export function UniversalSearchCommandSurface({ // The dropdown is a fine-pointer desktop enhancement. Width-only checks let // wide, zoomed, or desktop-mode phones open it over the page. const dropdownMinimumWidthQuery = commandDropdownMinimumWidthMediaQuery(placement); - const [dropdownDisplayable, setDropdownDisplayable] = useState(false); - useEffect(() => { - if (typeof window.matchMedia !== "function") return; - const minimumWidthMedia = window.matchMedia(dropdownMinimumWidthQuery); - const pointerMedia = window.matchMedia(commandDropdownPointerMediaQuery); - const sync = () => { - const displayable = commandDropdownCanDisplay({ - minimumWidthMatches: minimumWidthMedia.matches, - pointerMatches: pointerMedia.matches, - maxTouchPoints: navigator.maxTouchPoints, - }); - setDropdownDisplayable(displayable); - if (!displayable) { - onDropdownOpenChange(false); - setActiveIndex(-1); - } - }; - sync(); - minimumWidthMedia.addEventListener("change", sync); - pointerMedia.addEventListener("change", sync); - return () => { - minimumWidthMedia.removeEventListener("change", sync); - pointerMedia.removeEventListener("change", sync); - }; - }, [dropdownMinimumWidthQuery, onDropdownOpenChange]); + const [eagerDropdownDisplayable, setEagerDropdownDisplayable] = useState(null); + const closeDropdownWhenUndisplayable = useEventCallback(() => { + setEagerDropdownDisplayable(null); + onDropdownOpenChange(false); + setActiveIndex(-1); + }); + const mediaDropdownDisplayable = useCommandDropdownDisplayable(placement, closeDropdownWhenUndisplayable); + // Focus can arrive before the shared hook's post-hydration effect has + // synchronized the conservative false initial state. Keep only an event-driven + // eager override instead of mirroring hook state from an effect. + const dropdownDisplayable = eagerDropdownDisplayable ?? mediaDropdownDisplayable; // A true "everything" view: the active mode's own domain is included (no excludeDomain) so // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ @@ -1022,11 +1010,12 @@ export function UniversalSearchCommandSurface({ pointerMatches: window.matchMedia(commandDropdownPointerMediaQuery).matches, maxTouchPoints: navigator.maxTouchPoints, }); - setDropdownDisplayable(displayable); + setEagerDropdownDisplayable(displayable ? true : null); if (displayable) onDropdownOpenChange(true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setEagerDropdownDisplayable(null); onDropdownOpenChange(false); setActiveIndex(-1); } diff --git a/src/components/clinical-dashboard/use-command-dropdown-displayable.ts b/src/components/clinical-dashboard/use-command-dropdown-displayable.ts new file mode 100644 index 0000000000..3bc4c41f73 --- /dev/null +++ b/src/components/clinical-dashboard/use-command-dropdown-displayable.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + commandDropdownCanDisplay, + commandDropdownMinimumWidthMediaQuery, + commandDropdownPointerMediaQuery, + type CommandSurfacePlacement, +} from "@/lib/search-command-surface"; + +const commandSurfacePlacements: readonly CommandSurfacePlacement[] = ["bottom-dock", "inline"]; + +const undisplayableByPlacement: Record = { + "bottom-dock": false, + inline: false, +}; + +/** + * Shared displayability for the universal command dropdown. Both the header + * (scrim / pins-menu gate) and the command surface must agree on when the + * panel can render — width + fine pointer / zero-touch. + */ +export function useCommandDropdownDisplayableByPlacement() { + const [displayableByPlacement, setDisplayableByPlacement] = + useState>(undisplayableByPlacement); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + + const minimumWidthMediaByPlacement = Object.fromEntries( + commandSurfacePlacements.map((placement) => [ + placement, + window.matchMedia(commandDropdownMinimumWidthMediaQuery(placement)), + ]), + ) as Record; + const pointerMedia = window.matchMedia(commandDropdownPointerMediaQuery); + const sync = () => { + setDisplayableByPlacement({ + "bottom-dock": commandDropdownCanDisplay({ + minimumWidthMatches: minimumWidthMediaByPlacement["bottom-dock"].matches, + pointerMatches: pointerMedia.matches, + maxTouchPoints: navigator.maxTouchPoints, + }), + inline: commandDropdownCanDisplay({ + minimumWidthMatches: minimumWidthMediaByPlacement.inline.matches, + pointerMatches: pointerMedia.matches, + maxTouchPoints: navigator.maxTouchPoints, + }), + }); + }; + sync(); + for (const media of Object.values(minimumWidthMediaByPlacement)) media.addEventListener("change", sync); + pointerMedia.addEventListener("change", sync); + return () => { + for (const media of Object.values(minimumWidthMediaByPlacement)) media.removeEventListener("change", sync); + pointerMedia.removeEventListener("change", sync); + }; + }, []); + + return displayableByPlacement; +} + +export function useCommandDropdownDisplayable(placement: CommandSurfacePlacement, onUndisplayable?: () => void) { + const [displayable, setDisplayable] = useState(false); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const minimumWidthMedia = window.matchMedia(commandDropdownMinimumWidthMediaQuery(placement)); + const pointerMedia = window.matchMedia(commandDropdownPointerMediaQuery); + const sync = () => { + const next = commandDropdownCanDisplay({ + minimumWidthMatches: minimumWidthMedia.matches, + pointerMatches: pointerMedia.matches, + maxTouchPoints: navigator.maxTouchPoints, + }); + setDisplayable(next); + if (!next) onUndisplayable?.(); + }; + sync(); + minimumWidthMedia.addEventListener("change", sync); + pointerMedia.addEventListener("change", sync); + return () => { + minimumWidthMedia.removeEventListener("change", sync); + pointerMedia.removeEventListener("change", sync); + }; + }, [placement, onUndisplayable]); + + return displayable; +} diff --git a/src/components/pinned-plus-menu-mockups.tsx b/src/components/pinned-plus-menu-mockups.tsx new file mode 100644 index 0000000000..f4a6e3c12b --- /dev/null +++ b/src/components/pinned-plus-menu-mockups.tsx @@ -0,0 +1,792 @@ +"use client"; + +import { + BookOpen, + Calculator, + Check, + ChevronDown, + Clock3, + FileText, + Heart, + Menu, + Pencil, + Pill, + Plus, + Search, + Sparkles, + UserRound, + X, + type LucideIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +type Direction = "unified" | "rail" | "palette"; +type Device = "desktop" | "tablet" | "phone"; +type PreviewState = "closed" | "open" | "edit"; +type DestinationId = "documents" | "medications" | "forms" | "monitoring" | "calculators" | "favourites" | "recent"; +type Pin = { id: string; name: string; destinations: DestinationId[] }; + +const destinations: Record = { + documents: { + label: "Documents", + detail: "Guidelines and local policies", + icon: FileText, + tone: "bg-sky-50 text-sky-700", + }, + medications: { + label: "Medications", + detail: "Doses and interactions", + icon: Pill, + tone: "bg-emerald-50 text-emerald-700", + }, + forms: { + label: "Pathology forms", + detail: "Frequently used request forms", + icon: BookOpen, + tone: "bg-amber-50 text-amber-700", + }, + monitoring: { + label: "Monitoring", + detail: "Physical health checks", + icon: Sparkles, + tone: "bg-violet-50 text-violet-700", + }, + calculators: { + label: "Calculators", + detail: "Clinical scoring tools", + icon: Calculator, + tone: "bg-indigo-50 text-indigo-700", + }, + favourites: { label: "Favourites", detail: "Saved pages and sources", icon: Heart, tone: "bg-rose-50 text-rose-700" }, + recent: { label: "Recent items", detail: "Return to recent work", icon: Clock3, tone: "bg-slate-100 text-slate-700" }, +}; + +const startingPins: Pin[] = [ + { id: "ward", name: "Ward essentials", destinations: ["documents", "medications", "forms", "monitoring"] }, + { id: "tools", name: "My quick tools", destinations: ["calculators", "favourites", "recent"] }, +]; + +const directionMeta: Array<{ id: Direction; label: string; note: string; verdict: string }> = [ + { + id: "unified", + label: "Unified plus menu", + note: "Pins and search choices in one calm, attached surface.", + verdict: "Recommended", + }, + { + id: "rail", + label: "Pinned icon rail", + note: "Keeps your small pin icons one tap from the composer.", + verdict: "Fastest", + }, + { + id: "palette", + label: "Search command palette", + note: "A keyboard-friendly launcher for pins and search areas.", + verdict: "Power users", + }, +]; + +function Constellation({ pin, size = "md" }: { pin: Pin; size?: "sm" | "md" }) { + const shown = pin.destinations.slice(0, 3); + const extra = pin.destinations.length - shown.length; + return ( + destinations[id].label).join(", ")}`} + className="inline-flex shrink-0 items-center" + > + {shown.map((id, index) => { + const item = destinations[id]; + const Icon = item.icon; + return ( + 0 && "-ml-2", + item.tone, + )} + > + + ); + })} + {extra > 0 && ( + + +{extra} + + )} + + ); +} + +function SearchChoices({ compact = false }: { compact?: boolean }) { + const choices = [ + { label: "Search Documents", detail: "Current area", icon: FileText, active: true }, + { label: "Search all clinical areas", detail: "Global search", icon: Search, active: false }, + { label: "Choose another area", detail: "Medications, services, tools…", icon: Plus, active: false }, + ]; + return ( +
+ {choices.map((choice) => { + const Icon = choice.icon; + return ( + + ); + })} +
+ ); +} + +function PinCard({ + pin, + selected, + onSelect, + onEdit, + expanded = false, +}: { + pin: Pin; + selected?: boolean; + onSelect: () => void; + onEdit: () => void; + expanded?: boolean; +}) { + return ( +
+
+ + +
+ {expanded && ( +
+ {pin.destinations.map((id) => { + const item = destinations[id]; + const Icon = item.icon; + return ( + + ); + })} +
+ )} +
+ ); +} + +function UnifiedMenu({ + pins, + selected, + setSelected, + edit, + phone, +}: { + pins: Pin[]; + selected: string; + setSelected: (id: string) => void; + edit: (pin: Pin) => void; + phone: boolean; +}) { + return ( +
+
+
+

Quick access

+

Your pins

+

Shortcuts you chose from anywhere in the app.

+
+ +
+
+ {pins.map((pin) => ( + setSelected(pin.id)} + onEdit={() => edit(pin)} + /> + ))} +
+
+

Search in

+

Choose where this query should run.

+
+ +
+
+ ); +} + +function RailMenu({ + pins, + selected, + setSelected, + edit, + phone, +}: { + pins: Pin[]; + selected: string; + setSelected: (id: string) => void; + edit: (pin: Pin) => void; + phone: boolean; +}) { + const pin = pins.find((item) => item.id === selected) ?? pins[0]; + return ( +
+
+ {pins.map((item) => ( + + ))} + +
+
+
+
+
+

Selected pin

+

{pin.name}

+
+ +
+
+ {pin.destinations.map((id) => { + const item = destinations[id]; + const Icon = item.icon; + return ( + + ); + })} +
+
+
+

Search in

+
+ +
+
+
+
+ ); +} + +function PaletteMenu({ + pins, + selected, + setSelected, + edit, + phone, +}: { + pins: Pin[]; + selected: string; + setSelected: (id: string) => void; + edit: (pin: Pin) => void; + phone: boolean; +}) { + return ( +
+
+ + Open a pin or choose where to search… + + ⌘ K + +
+
+

Your pins

+
+ {pins.map((pin) => ( +
+ + +
+ ))} +
+

Search in

+
+ +
+
+
+ ); +} + +function PinEditor({ + pin, + save, + cancel, + phone, +}: { + pin: Pin; + save: (pin: Pin) => void; + cancel: () => void; + phone: boolean; +}) { + const [draft, setDraft] = useState(pin); + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") cancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [cancel]); + function toggle(id: DestinationId) { + setDraft((current) => ({ + ...current, + destinations: current.destinations.includes(id) + ? current.destinations.length === 1 + ? current.destinations + : current.destinations.filter((entry) => entry !== id) + : [...current.destinations, id], + })); + } + return ( +
+
+
+

Edit pin

+

{pin.name}

+
+ +
+
+ +
+

App destinations

+ + {draft.destinations.length} destinations selected + +
+
+ {(Object.keys(destinations) as DestinationId[]).map((id) => { + const item = destinations[id]; + const Icon = item.icon; + const active = draft.destinations.includes(id); + return ( + + ); + })} +
+
+
+ + +
+
+ ); +} + +function AppPreview({ + direction, + device, + state, + pins, + setPins, + setState, +}: { + direction: Direction; + device: Device; + state: PreviewState; + pins: Pin[]; + setPins: (pins: Pin[]) => void; + setState: (state: PreviewState) => void; +}) { + const phone = device === "phone"; + const [selected, setSelected] = useState(pins[0].id); + const trigger = useRef(null); + const previousState = useRef(state); + const selectedPin = pins.find((pin) => pin.id === selected) ?? pins[0]; + useEffect(() => { + if (previousState.current !== "closed" && state === "closed") trigger.current?.focus(); + previousState.current = state; + }, [state]); + const edit = (pin: Pin) => { + setSelected(pin.id); + setState("edit"); + }; + const shared = { pins, selected, setSelected, edit, phone }; + return ( +
+
+ + + +
+
+ + + +

+ Find the right clinical source +

+

+ Search Documents first, then widen the same query when another clinical area will help. +

+ {state === "closed" && ( +
+ + + Pinned by you + {pins.length} pins ready + +
+ )} +
+
+
+ +
+ + Search Documents +
+ +
+
+ {state !== "closed" && ( +
+ ); +} + +export function PinnedPlusMenuMockupsPage() { + const [direction, setDirection] = useState("unified"); + const [device, setDevice] = useState("desktop"); + const [state, setState] = useState("closed"); + const [pins, setPins] = useState(startingPins); + return ( +
+
+
+
+

Search + menu · revised study

+

+ Editable pins, without changing search by accident +

+

+ Pins open useful app destinations. Search choices control where the query runs. Both live in one coherent + + menu. +

+
+
+ Recommendation: Unified plus menu +
+
+ +
+
+ {(["desktop", "tablet", "phone"] as Device[]).map((item) => ( + + ))} +
+
+ {( + [ + { id: "closed", label: "Closed" }, + { id: "open", label: "Menu open" }, + { id: "edit", label: "Edit pin" }, + ] as Array<{ id: PreviewState; label: string }> + ).map((item) => ( + + ))} +
+
+
+ +
+
+
+

Pins mean

+

+ A named collection of destinations chosen from anywhere in the app—not a search filter. +

+
+
+

Small icons mean

+

+ The first three destinations inside a pin. +N communicates the remainder without visual clutter. +

+
+
+

Users edit

+

+ Pin name and included destinations. Search area remains an explicit choice every time. +

+
+
+
+
+ ); +} diff --git a/src/components/search-lens-menu-mockups.tsx b/src/components/search-lens-menu-mockups.tsx new file mode 100644 index 0000000000..4e51fc8584 --- /dev/null +++ b/src/components/search-lens-menu-mockups.tsx @@ -0,0 +1,1375 @@ +"use client"; + +import { + ArrowRight, + BookOpen, + Check, + ChevronDown, + CirclePlus, + Clock3, + Command, + FileText, + Globe2, + Heart, + Layers3, + MessageSquarePlus, + Pill, + Plus, + Search, + Settings2, + ShieldCheck, + SlidersHorizontal, + Sparkles, + UploadCloud, + Wrench, + X, + type LucideIcon, +} from "lucide-react"; +import { useEffect, useRef, useState, type FormEvent, type ReactNode, type RefObject } from "react"; + +import { cn } from "@/components/ui-primitives"; + +type DirectionId = "lens-fold" | "command-deck" | "inline-builder"; +type DomainId = "documents" | "medications" | "services" | "forms" | "differentials" | "tools"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const directionMeta: Record< + DirectionId, + { label: string; shortLabel: string; strapline: string; verdict: string; bestFor: string; icon: LucideIcon } +> = { + "lens-fold": { + label: "Attached pin fold-out", + shortLabel: "Pin fold-out", + strapline: "A calm layer for user pins and deliberate search-area choices.", + verdict: "Recommended", + bestFor: "Clarity and gradual adoption", + icon: Layers3, + }, + "command-deck": { + label: "Pins and search command deck", + shortLabel: "Pins + search deck", + strapline: "One powerful workspace for opening pins, choosing search areas, and acting on results.", + verdict: "Powerful", + bestFor: "Frequent expert use", + icon: Command, + }, + "inline-builder": { + label: "Inline pin bar", + shortLabel: "Inline pin bar", + strapline: "A personal pin stays visible above the composer while search scope remains stable.", + verdict: "Expressive", + bestFor: "Fast custom combinations", + icon: SlidersHorizontal, + }, +}; + +const domainMeta: Record = { + documents: { + label: "Documents", + shortLabel: "Documents", + icon: FileText, + tone: "bg-[#eaf3fb] text-[#145b94] border-[#bfd8ec]", + }, + medications: { + label: "Medications", + shortLabel: "Medications", + icon: Pill, + tone: "bg-[#eef7f3] text-[#21694f] border-[#c7e3d6]", + }, + services: { + label: "Services", + shortLabel: "Services", + icon: ShieldCheck, + tone: "bg-[#f4f0fb] text-[#65479a] border-[#dbcfef]", + }, + forms: { + label: "Forms", + shortLabel: "Forms", + icon: BookOpen, + tone: "bg-[#fff5e8] text-[#865819] border-[#ead6b7]", + }, + differentials: { + label: "Differentials", + shortLabel: "Differentials", + icon: Sparkles, + tone: "bg-[#fdf0f3] text-[#98445c] border-[#ecc9d3]", + }, + tools: { + label: "Clinical tools", + shortLabel: "Tools", + icon: Wrench, + tone: "bg-[#f0f2f5] text-[#4a5663] border-[#d5dbe2]", + }, +}; + +const domainOrder = Object.keys(domainMeta) as DomainId[]; + +const lenses = [ + { + id: "documents", + kind: "search", + label: "Search Documents", + detail: "Current mode", + domains: ["documents"] as DomainId[], + icon: FileText, + }, + { + id: "everywhere", + kind: "search", + label: "Search all clinical areas", + detail: "All 6 search areas", + domains: domainOrder, + icon: Globe2, + }, + { + id: "choose-area", + kind: "search", + label: "Choose another search area", + detail: "Medications, services, tools…", + domains: ["medications", "services", "tools"] as DomainId[], + icon: Plus, + }, + { + id: "ward-round", + kind: "pin", + label: "Ward essentials", + detail: "Your editable pin", + domains: ["documents", "medications", "forms"] as DomainId[], + icon: Heart, + }, + { + id: "quick-tools", + kind: "pin", + label: "My quick tools", + detail: "Calculators, services, differentials", + domains: ["tools", "services", "differentials"] as DomainId[], + icon: Wrench, + }, +]; + +const actions = [ + { label: "Upload PDF", detail: "Add an indexed source", icon: UploadCloud }, + { label: "Scope sources", detail: "Limit answer evidence", icon: SlidersHorizontal }, + { label: "Recent documents", detail: "Continue reading", icon: Clock3 }, + { label: "Browse library", detail: "Open all sources", icon: BookOpen }, +]; + +function DomainIconRail({ + domains, + compact = false, + ariaLabel, +}: { + domains: DomainId[]; + compact?: boolean; + ariaLabel?: string; +}) { + const shown = domains.slice(0, compact ? 3 : 4); + const domainLabels = domains.map((domain) => domainMeta[domain].label).join(", "); + return ( + + {shown.map((domain, index) => { + const Icon = domainMeta[domain].icon; + return ( + + ); + })} + {domains.length > shown.length ? ( + + ) : null} + + ); +} + +function DomainCountBadge({ domains, noun = "areas" }: { domains: DomainId[]; noun?: string }) { + const domainLabels = domains.map((domain) => domainMeta[domain].label).join(", "); + return ( + + {domains.length} {domains.length === 1 ? noun.replace(/s$/, "") : noun} + + ); +} + +function AppFrame({ phone, children }: { phone: boolean; children: ReactNode }) { + return ( +
+
+ {phone ? ( + + ) : ( + + + )} + + {phone ? ( + + ) : ( + + )} +
+
{children}
+
+ ); +} + +function Composer({ + phone, + open, + label, + domains, + onToggle, + onInputIntent, + toggleButtonRef, +}: { + phone: boolean; + open: boolean; + label: string; + domains: DomainId[]; + onToggle: () => void; + onInputIntent?: () => void; + toggleButtonRef?: RefObject; +}) { + const [query, setQuery] = useState(""); + + function submit(event: FormEvent) { + event.preventDefault(); + } + + return ( +
+ {!phone || !open ? ( +
+ + {!phone ? Enter searches this lens : null} +
+ ) : null} +
+ + + +
+
+ ); +} + +function LensFoldPanel({ + phone, + selectedLens, + onSelectLens, + wardRoundDomains, + onWardRoundDomainsChange, + onClose, + focusOnMount, +}: { + phone: boolean; + selectedLens: string; + onSelectLens: (lensId: string) => void; + wardRoundDomains: DomainId[]; + onWardRoundDomainsChange: (domains: DomainId[]) => void; + onClose: () => void; + focusOnMount: boolean; +}) { + const [editing, setEditing] = useState(false); + const [creating, setCreating] = useState(false); + const [selectedPinId, setSelectedPinId] = useState("ward-round"); + const [wardPinName, setWardPinName] = useState("Ward essentials"); + const [newPinName, setNewPinName] = useState("My new pin"); + const [newPinDomains, setNewPinDomains] = useState(["documents"]); + const [createdPin, setCreatedPin] = useState<{ + id: string; + kind: string; + label: string; + detail: string; + domains: DomainId[]; + icon: LucideIcon; + } | null>(null); + + const editorDomains = creating ? newPinDomains : wardRoundDomains; + const pinOptions = [ + ...lenses + .filter((item) => item.kind === "pin") + .map((item) => (item.id === "ward-round" ? { ...item, label: wardPinName, domains: wardRoundDomains } : item)), + ...(createdPin ? [createdPin] : []), + ]; + + function toggleDomain(domain: DomainId) { + const current = creating ? newPinDomains : wardRoundDomains; + const next = current.includes(domain) + ? current.length === 1 + ? current + : current.filter((item) => item !== domain) + : [...current, domain]; + if (creating) setNewPinDomains(next); + else onWardRoundDomainsChange(next); + } + + function closeEditor() { + setEditing(false); + setCreating(false); + } + + function saveEditor() { + if (creating) { + const pin = { + id: "created-pin", + kind: "pin", + label: newPinName.trim() || "My new pin", + detail: "Your new pin", + domains: newPinDomains, + icon: Heart, + }; + setCreatedPin(pin); + setSelectedPinId(pin.id); + } + closeEditor(); + } + + const body = ( +
+
+
+
+ + +
+

+ {editing ? (creating ? "Create a new pin" : `Edit ${wardPinName}`) : "Pins and search"} +

+

+ {editing + ? "Choose useful destinations from across the app." + : "Open a pin or choose where this search runs."} +

+
+
+
+ +
+ + {editing ? ( +
+ +
+ App destinations + + {editorDomains.length} destinations selected + +
+
+ {domainOrder.map((domain) => { + const meta = domainMeta[domain]; + const Icon = meta.icon; + const active = editorDomains.includes(domain); + return ( + + ); + })} +
+
+ + +
+
+ ) : ( + <> +
+
+

Your pins

+ +
+ {pinOptions.map((lens) => { + const Icon = lens.icon; + const active = lens.id === selectedPinId; + const pinDomains = lens.domains; + return ( +
+ + {lens.id === "ward-round" ? ( + + ) : null} +
+ ); + })} +
+

Search in

+ {lenses + .filter((item) => item.kind === "search") + .map((lens) => { + const Icon = lens.icon; + const active = lens.id === selectedLens; + return ( + + ); + })} +
+
+
+

Useful actions

+
+ {actions.map((action) => ( + + ))} +
+
+ + )} +
+ ); + + if (phone) { + return ( +
+
{body}
+
+ ); + } + + return
{body}
; +} + +function CommandDeckPanel({ + phone, + onClose, + focusOnMount, +}: { + phone: boolean; + onClose: () => void; + focusOnMount: boolean; +}) { + const [query, setQuery] = useState(""); + const [activeSection, setActiveSection] = useState("Ward essentials"); + const sections = ["Ward essentials", "My quick tools", "Search Documents", "Search all clinical areas"]; + + return ( +
+
+ {!phone ? ( + + + ) : null} + + +
+ +
+
+

+ Pins and search +

+
+ {sections.map((section, index) => { + const icons = [Heart, Wrench, FileText, Globe2]; + const Icon = icons[index]; + const active = activeSection === section; + return ( + + ); + })} +
+ {!phone ? ( +
+ +
+ ) : null} +
+ +
+
+
+

+ Selected command · {activeSection} +

+

+ Pins open destinations; search commands run the current query. +

+
+ {phone ? ( + + ) : ( + + )} +
+
+ {[ + { + title: "Documents", + meta: "Open guidelines and local policies", + icon: FileText, + tag: "Destination", + }, + { title: "Medications", meta: "Open doses and interactions", icon: Pill, tag: "Destination" }, + { + title: "Pathology forms", + meta: "Open frequently used request forms", + icon: BookOpen, + tag: "Destination", + }, + ].map((result, index) => ( + + ))} +
+
+ {["Search all results", "Upload a source", "Scope answer evidence"].map((label, index) => { + const icons = [Search, UploadCloud, SlidersHorizontal]; + const Icon = icons[index]; + return ( + + ); + })} +
+
+
+
+ {phone ? "3 grouped matches" : "↑↓ navigate · Enter open"} + {query ? `Searching “${query}”` : "Type to search"} +
+
+ ); +} + +function InlineBuilderPanel({ + phone, + selectedDomains, + onToggleDomain, + onClose, + focusOnMount, +}: { + phone: boolean; + selectedDomains: DomainId[]; + onToggleDomain: (domain: DomainId) => void; + onClose: () => void; + focusOnMount: boolean; +}) { + const panel = ( +
+
+
+

Edit Quick pin

+

Choose useful destinations from across the app.

+
+ +
+
+ {domainOrder.map((domain) => { + const meta = domainMeta[domain]; + const Icon = meta.icon; + const active = selectedDomains.includes(domain); + return ( + + ); + })} +
+
+ + {selectedDomains.length} destinations selected + + +
+
+ ); + + if (phone) { + return ( +
+ {panel} +
+ ); + } + + return panel; +} + +function Prototype({ direction, phone }: { direction: DirectionId; phone: boolean }) { + const [open, setOpen] = useState(true); + const [focusPanelOnOpen, setFocusPanelOnOpen] = useState(false); + const [selectedLensId, setSelectedLensId] = useState("documents"); + const [wardRoundDomains, setWardRoundDomains] = useState(["documents", "medications", "forms"]); + const [selectedDomains, setSelectedDomains] = useState(["documents", "medications", "forms"]); + const toggleButtonRef = useRef(null); + const selectedLens = lenses.find((lens) => lens.id === selectedLensId) ?? lenses[0]; + const activeDomains = selectedLens.domains; + const activeLabel = selectedLens.label; + + function closePanel(restoreFocus = true) { + setOpen(false); + setFocusPanelOnOpen(false); + if (restoreFocus) window.setTimeout(() => toggleButtonRef.current?.focus(), 0); + } + + function togglePanel() { + setOpen((current) => { + const next = !current; + setFocusPanelOnOpen(next); + return next; + }); + } + + useEffect(() => { + if (!open) return; + + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + setFocusPanelOnOpen(false); + window.setTimeout(() => toggleButtonRef.current?.focus(), 0); + } + } + + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [open]); + + function toggleDomain(domain: DomainId) { + setSelectedDomains((current) => { + if (current.includes(domain)) { + return current.length === 1 ? current : current.filter((item) => item !== domain); + } + return [...current, domain]; + }); + } + + return ( + +
+ {phone && open && direction === "lens-fold" ? ( + { + setSelectedLensId(lensId); + closePanel(); + }} + onClose={() => closePanel()} + focusOnMount={focusPanelOnOpen} + /> + ) : null} + {phone && open && direction === "command-deck" ? ( + closePanel()} focusOnMount={focusPanelOnOpen} /> + ) : null} + {phone && open && direction === "inline-builder" ? ( + closePanel()} + focusOnMount={focusPanelOnOpen} + /> + ) : null} + {open && !phone ? ( + + + ); +} + +function DesignNote({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: ReactNode }) { + return ( +
+
+ + + {title} +
+

{children}

+
+ ); +} + +export function SearchLensMenuMockupsPage() { + const [direction, setDirection] = useState("lens-fold"); + const meta = directionMeta[direction]; + const DirectionIcon = meta.icon; + + return ( +
+
+
+
+ + +

+ Three ways to make “+” useful without turning pins into search filters. +

+

+ Each direction keeps Documents search explicit while adding editable pins that open useful destinations + from across the app. Compare the interaction model—not a cosmetic reskin. +

+
+ +
+ {(Object.keys(directionMeta) as DirectionId[]).map((id) => { + const option = directionMeta[id]; + const Icon = option.icon; + const active = id === direction; + return ( + + ); + })} +
+
+ +
+
+
+

+ Selected direction +

+

+

+

{meta.strapline}

+
+

+ Try closing the panel, editing Ward essentials, choosing global search, or changing the Quick pin. Every + visible device frame is interactive. +

+
+ +
+
+

+ Desktop / tablet · fluid 768–1440px treatment +

+ +
+
+

+ Phone · up to 390px treatment +

+ +
+
+
+ +
+ + {direction === "lens-fold" + ? "The overlapping pin icons reveal what each pin contains without turning the composer into a settings bar." + : direction === "command-deck" + ? "A structured two-pane workspace makes pins and search commands feel related without making them interchangeable." + : "Pin destinations become graspable objects: visible and removable immediately above the composer."} + + + {direction === "lens-fold" + ? "Pins come first, the current mode stays obvious, and widening the search remains a deliberate choice." + : direction === "command-deck" + ? "The user can open pinned destinations or launch a scoped search without repeatedly opening different surfaces." + : "The relationship between pin contents and the stable Documents search scope remains explicit."} + + + {direction === "lens-fold" + ? "One drill-in is needed to edit a pin, but that keeps the everyday surface calm." + : direction === "command-deck" + ? "It is the densest option and risks feeling oversized for users who only need one focused search." + : "Several pin destinations can consume valuable phone width and need careful horizontal overflow behaviour."} + +
+
+
+ ); +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 28fc253161..a9f26b36e6 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -38,7 +38,14 @@ type SheetBaseProps = { footer?: ReactNode; closeLabel?: string; initialFocusRef?: RefObject; + /** Read at close time (not capture-on-open). Mutating `.current` while open retargets restore. */ returnFocusRef?: RefObject; + /** + * Optional late resolver consulted before `returnFocusRef` / prior active element. + * Must be referentially stable (e.g. `useCallback`); an inline arrow re-runs the + * open-effect and fights the sheet for focus on every parent render. + */ + resolveReturnFocusTarget?: () => HTMLElement | null; headerLeading?: ReactNode; titleAccessory?: ReactNode; descriptionContent?: ReactNode; @@ -67,8 +74,10 @@ export type SheetProps = SheetBaseProps & SheetAccessibleName; * Portals into `OverlayRoot` (`layer="modal"`) by default so stacking and * inerting stay consistent across product overlays; pass `portal={false}` to * keep the sheet in-tree when an ancestor-scoped style must still apply. - * Focus is trapped while open and returned to the opener on close; Escape and - * backdrop click both dismiss. + * Focus is trapped while open and returned on close; Escape and backdrop click + * both dismiss. Return focus is resolved late from `resolveReturnFocusTarget` + * (stable callback), then `returnFocusRef.current`, then the previously focused + * element — so callers can retarget restore while the sheet is still open. */ export function Sheet({ open, @@ -82,6 +91,7 @@ export function Sheet({ ariaLabel, initialFocusRef, returnFocusRef, + resolveReturnFocusTarget, headerLeading, titleAccessory, descriptionContent, @@ -129,6 +139,7 @@ export function Sheet({ }, [sheetId], ); + const resolveReturnFocusRefTarget = useCallback(() => returnFocusRef?.current ?? null, [returnFocusRef]); useEffect(() => { onCloseRef.current = onClose; @@ -189,7 +200,6 @@ export function Sheet({ useEffect(() => { if (!open) return; - const explicitReturnElement = returnFocusRef?.current ?? null; const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; const restoreTimers = restoreTimersRef.current; // A close-then-reopen inside one frame leaves this instance's own restore @@ -271,7 +281,10 @@ export function Sheet({ openFocusRef.current?.cancel(); openFocusRef.current = null; popSheet(sheetId); - const restoreTarget = explicitReturnElement ?? previousActiveElement; + const resolveConnectedRestoreTarget = () => + [resolveReturnFocusTarget?.() ?? null, resolveReturnFocusRefTarget(), previousActiveElement].find( + (target): target is HTMLElement => Boolean(target?.isConnected), + ) ?? null; if (restoreTimers.frame != null) { window.cancelAnimationFrame(restoreTimers.frame); restoreTimers.frame = null; @@ -287,6 +300,7 @@ export function Sheet({ // the whole suite even when every test assertion passed. restoreTimers.frame = window.requestAnimationFrame(() => { restoreTimers.frame = null; + const restoreTarget = resolveConnectedRestoreTarget(); if (typeof document === "undefined" || !restoreTarget?.isConnected) return; // A sheet opened while this one was closing (switching between two // sheets in one tick) now owns focus. Instances cannot cancel each @@ -298,22 +312,23 @@ export function Sheet({ restoreTimers.timeout = window.setTimeout(() => { restoreTimers.timeout = null; if (typeof document === "undefined") return; + const retryTarget = resolveConnectedRestoreTarget(); // Only retry when focus fell through to the document body. If another // surface (e.g. a Guide dialog opened from a phone menu sheet) already // took focus, do not steal it back. if ( - !restoreTarget.isConnected || - !canRestoreFocusTo(restoreTarget) || - document.activeElement === restoreTarget || + !retryTarget || + !canRestoreFocusTo(retryTarget) || + document.activeElement === retryTarget || (document.activeElement !== document.body && document.activeElement != null) ) { return; } - restoreTarget.focus({ preventScroll: true }); + retryTarget.focus({ preventScroll: true }); }, 50); }); }; - }, [open, initialFocusRef, returnFocusRef, sheetId]); + }, [open, initialFocusRef, resolveReturnFocusRefTarget, resolveReturnFocusTarget, sheetId]); if (!open) return null; diff --git a/src/lib/search-pins.ts b/src/lib/search-pins.ts new file mode 100644 index 0000000000..477fd475d6 --- /dev/null +++ b/src/lib/search-pins.ts @@ -0,0 +1,135 @@ +export const searchPinDestinationIds = [ + "documents", + "prescribing", + "forms", + "services", + "differentials", + "tools", +] as const; + +export type SearchPinDestinationId = (typeof searchPinDestinationIds)[number]; + +export type SearchPin = { + id: string; + name: string; + destinationIds: SearchPinDestinationId[]; +}; + +export const defaultSearchPins: SearchPin[] = [ + { + id: "ward-essentials", + name: "Ward essentials", + destinationIds: ["documents", "prescribing", "forms"], + }, + { + id: "quick-tools", + name: "My quick tools", + destinationIds: ["tools", "services", "differentials"], + }, +]; + +export const searchPinsStorageKey = "clinical-kb-search-pins-v1"; +export const searchPinsChangeEvent = "clinical-kb-search-pins-change"; + +const destinationIdSet = new Set(searchPinDestinationIds); +export const maximumSearchPins = 8; +const maximumDestinations = 6; +const maximumNameLength = 48; +let inMemorySearchPins: SearchPin[] | null = null; +let inMemorySearchPinsFallbackActive = false; + +function normalizePin(value: unknown): SearchPin | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + const id = typeof candidate.id === "string" ? candidate.id.trim().slice(0, 80) : ""; + const name = typeof candidate.name === "string" ? candidate.name.trim().slice(0, maximumNameLength) : ""; + const destinationIds = Array.isArray(candidate.destinationIds) + ? ([...new Set(candidate.destinationIds)] + .filter( + (destination): destination is SearchPinDestinationId => + typeof destination === "string" && destinationIdSet.has(destination), + ) + .slice(0, maximumDestinations) as SearchPinDestinationId[]) + : []; + + if (!id || !name || destinationIds.length === 0) return null; + return { id, name, destinationIds }; +} + +function cloneSearchPins(pins: readonly SearchPin[]): SearchPin[] { + return pins.map((pin) => ({ + id: pin.id, + name: pin.name, + destinationIds: [...pin.destinationIds], + })); +} + +function cloneDefaultSearchPins(): SearchPin[] { + return cloneSearchPins(defaultSearchPins); +} + +function cloneStoredOrDefaultSearchPins(): SearchPin[] { + return inMemorySearchPinsFallbackActive && inMemorySearchPins + ? cloneSearchPins(inMemorySearchPins) + : cloneDefaultSearchPins(); +} + +export function normalizeSearchPins(value: unknown): SearchPin[] { + if (!Array.isArray(value)) return cloneDefaultSearchPins(); + const seenIds = new Set(); + return value + .map(normalizePin) + .filter((pin): pin is SearchPin => { + if (!pin || seenIds.has(pin.id)) return false; + seenIds.add(pin.id); + return true; + }) + .slice(0, maximumSearchPins); +} + +export function readSearchPins(storage?: Pick): SearchPin[] { + let raw: string | null; + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) return cloneStoredOrDefaultSearchPins(); + raw = target.getItem(searchPinsStorageKey); + } catch { + return cloneStoredOrDefaultSearchPins(); + } + + if (!raw) return cloneStoredOrDefaultSearchPins(); + try { + const normalized = normalizeSearchPins(JSON.parse(raw)); + inMemorySearchPinsFallbackActive = false; + return normalized; + } catch { + inMemorySearchPinsFallbackActive = false; + return cloneDefaultSearchPins(); + } +} + +export function writeSearchPins(pins: SearchPin[], storage?: Pick): SearchPin[] { + const normalized = normalizeSearchPins(pins); + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) { + inMemorySearchPins = cloneSearchPins(normalized); + inMemorySearchPinsFallbackActive = true; + return normalized; + } + target.setItem(searchPinsStorageKey, JSON.stringify(normalized)); + inMemorySearchPinsFallbackActive = false; + } catch { + // Pins are a progressive enhancement. Keep this session's normalized value + // when browser storage is unavailable or full. + inMemorySearchPins = cloneSearchPins(normalized); + inMemorySearchPinsFallbackActive = true; + } + return cloneSearchPins(normalized); +} + +/** Test-only: drop tab session memory so cases do not leak pin lists. */ +export function resetSearchPinsSessionForTests() { + inMemorySearchPins = null; + inMemorySearchPinsFallbackActive = false; +} diff --git a/tests/mode-action-popup.dom.test.tsx b/tests/mode-action-popup.dom.test.tsx index d9dcad1193..9f751e8a2f 100644 --- a/tests/mode-action-popup.dom.test.tsx +++ b/tests/mode-action-popup.dom.test.tsx @@ -1,4 +1,7 @@ -import { useState } from "react"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { useRef, useState } from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -36,6 +39,66 @@ function Harness({ onAction = vi.fn(), onModeSelect = vi.fn() }) { ); } +function CustomBodyHarness() { + const [open, setOpen] = useState(false); + + return ( + + + + +
+ } + /> + ); +} + +function CustomBodySheetReturnFocusHarness() { + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const returnFocusRef = useRef(null); + + return ( + <> + + { + returnFocusRef.current = null; + }} + customBody={ + + } + /> + + ); +} + describe("ModeActionPopup state transitions", () => { it("starts closed, opens its action menu, and closes after an action", async () => { const user = userEvent.setup(); @@ -73,6 +136,44 @@ describe("ModeActionPopup state transitions", () => { await waitFor(() => expect(trigger).toHaveFocus()); }); + it("gives custom bodies dialog semantics and focuses the last custom control from ArrowUp", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole("button", { name: "Open pins and search" }); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + + trigger.focus(); + await user.keyboard("{ArrowUp}"); + + const dialog = screen.getByRole("dialog", { name: "Pins and search" }); + expect(screen.getByTestId("daily-actions-menu")).toBe(dialog); + await waitFor(() => expect(screen.getByRole("button", { name: "Last custom action" })).toHaveFocus()); + }); + + it("keeps custom body placement measurement stable across parent renders", () => { + // jsdom does not support `new URL(..., import.meta.url)` file resolution. + const source = readFileSync( + path.join(process.cwd(), "src/components/clinical-dashboard/mode-action-popup.tsx"), + "utf8", + ); + + expect(source).toContain("const hasCustomBody = Boolean(customBody);"); + expect(source).toContain("[hasCustomBody, integrated, integratedChipRow, items.length]"); + expect(source).not.toContain("[customBody, integrated, integratedChipRow, items.length]"); + }); + + it("lets custom sheet actions retarget return focus to the composer", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Open pins and search" })); + await waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + await user.click(screen.getByRole("button", { name: "Search current area" })); + + await waitFor(() => expect(screen.getByRole("textbox", { name: "Search composer" })).toHaveFocus()); + }); + it("selects enabled modes and exposes disabled modes without selecting them", async () => { const user = userEvent.setup(); const onModeSelect = vi.fn(); diff --git a/tests/pinned-plus-menu-mockups.dom.test.tsx b/tests/pinned-plus-menu-mockups.dom.test.tsx new file mode 100644 index 0000000000..308f2eb411 --- /dev/null +++ b/tests/pinned-plus-menu-mockups.dom.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { PinnedPlusMenuMockupsPage } from "@/components/pinned-plus-menu-mockups"; + +describe("editable pin menu mockups", () => { + it("shows three distinct directions, device treatments, and realistic states", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("button", { name: /Unified plus menu/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Closed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("dialog", { name: "Search and pinned shortcuts" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Menu open" })); + expect(screen.getByRole("dialog", { name: "Search and pinned shortcuts" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Your pins" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Search in" })).toBeInTheDocument(); + expect(screen.getByText("Ward essentials")).toBeInTheDocument(); + expect( + screen.getByRole("img", { + name: /Ward essentials contains Documents, Medications, Pathology forms, Monitoring/, + }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Edit Ward essentials" })); + expect(screen.getByRole("dialog", { name: "Edit Ward essentials pin" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove Monitoring" })); + expect(screen.getByText("3 destinations selected")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await user.click(screen.getByRole("button", { name: "Phone preview" })); + expect(screen.getByRole("region", { name: "Phone mockup preview" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Pinned icon rail/ })); + expect(screen.getByRole("dialog", { name: "Pinned icon rail menu" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Search command palette/ })); + expect(screen.getByRole("dialog", { name: "Search command palette" })).toBeInTheDocument(); + }); +}); diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index 7680d6774e..b2d351ba3f 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -78,4 +78,14 @@ describe("search command surface", () => { expect(source).not.toContain("sm:max-h-[min(55dvh,26rem)]"); expect(source).toContain('opensUpward ? "sm:max-h-[min(38dvh,20rem)]" : "sm:max-h-[min(42dvh,24rem)]"'); }); + + it("does not let a stale narrow-window focus decision shadow later displayability", () => { + const source = readFileSync( + new URL("../src/components/clinical-dashboard/universal-search-command-surface.tsx", import.meta.url), + "utf8", + ); + + expect(source).not.toContain("setEagerDropdownDisplayable(displayable);"); + expect(source).toContain("setEagerDropdownDisplayable(displayable ? true : null);"); + }); }); diff --git a/tests/search-lens-menu-mockups.dom.test.tsx b/tests/search-lens-menu-mockups.dom.test.tsx new file mode 100644 index 0000000000..fc5f3088e2 --- /dev/null +++ b/tests/search-lens-menu-mockups.dom.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { SearchLensMenuMockupsPage } from "@/components/search-lens-menu-mockups"; + +describe("search plus menu mockups", () => { + it("keeps editable pins separate from explicit search choices across all directions", async () => { + const user = userEvent.setup(); + render(); + + const foldOut = screen.getByRole("button", { name: /Attached pin fold-out/ }); + const commandDeck = screen.getByRole("button", { name: /Pins and search command deck/ }); + const inlineBuilder = screen.getByRole("button", { name: /Inline pin bar/ }); + + expect(foldOut).toHaveAttribute("aria-pressed", "true"); + expect(screen.getAllByRole("dialog", { name: "Pins and search" })).toHaveLength(2); + expect(screen.getAllByRole("heading", { name: "Your pins" })).toHaveLength(2); + expect(screen.getAllByRole("heading", { name: "Search in" })).toHaveLength(2); + expect(screen.getAllByText("Search Documents").length).toBeGreaterThan(0); + expect(screen.getAllByText("Search all clinical areas").length).toBeGreaterThan(0); + expect( + screen.getAllByRole("img", { + name: /Ward essentials contains app destinations: Documents, Medications, Forms/, + }).length, + ).toBeGreaterThan(0); + + await user.click(screen.getAllByRole("button", { name: "Edit Ward essentials" })[0]); + expect(screen.getByRole("dialog", { name: "Edit Ward essentials pin" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove Forms" })); + expect(screen.getByText("2 destinations selected")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Save pin" })); + + await user.click(screen.getAllByRole("button", { name: "New pin" })[0]); + const pinName = screen.getByRole("textbox", { name: "Pin name" }); + await user.clear(pinName); + await user.type(pinName, "Night shift"); + await user.click(screen.getByRole("button", { name: "Create pin" })); + expect(screen.getByText("Night shift")).toBeInTheDocument(); + + await user.click(commandDeck); + expect(commandDeck).toHaveAttribute("aria-pressed", "true"); + expect(screen.getAllByRole("dialog", { name: "Pins and search command deck" })).toHaveLength(2); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "Pins and search command deck" })).not.toBeInTheDocument(); + + await user.click(inlineBuilder); + expect(inlineBuilder).toHaveAttribute("aria-pressed", "true"); + expect(screen.getAllByRole("dialog", { name: "Edit pin destinations" })).toHaveLength(2); + expect(screen.getByTestId("phone-inline-builder-layer")).toHaveClass("bottom-[108px]"); + }); +}); diff --git a/tests/search-pins-menu.dom.test.tsx b/tests/search-pins-menu.dom.test.tsx new file mode 100644 index 0000000000..6c6dd7528e --- /dev/null +++ b/tests/search-pins-menu.dom.test.tsx @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FileText, Search } from "lucide-react"; + +import { SearchPinsMenu } from "@/components/clinical-dashboard/search-pins-menu"; +import type { ModeActionItem, ModeActionModeOption } from "@/components/clinical-dashboard/mode-action-popup"; +import { + maximumSearchPins, + resetSearchPinsSessionForTests, + searchPinsChangeEvent, + searchPinsStorageKey, +} from "@/lib/search-pins"; + +const modeOptions: ModeActionModeOption[] = [ + { id: "documents", label: "Documents", description: "Source documents", icon: FileText }, + { id: "answer", label: "Answer", description: "Source-backed answer", icon: Search }, +]; + +const actions: ModeActionItem[] = [ + { id: "documents-search", label: "Search sources", icon: Search }, + { id: "documents-recent", label: "Recent documents", icon: FileText }, +]; + +const longActionList: ModeActionItem[] = [ + { id: "documents-search", label: "Search sources", icon: Search }, + { id: "documents-upload", label: "Upload PDF", icon: FileText }, + { id: "documents-scope", label: "Scope sources", icon: FileText }, + { id: "documents-tables", label: "Tables", icon: FileText }, + { id: "documents-viewer", label: "Open source PDF", icon: FileText }, +]; + +function renderMenu(overrides: Partial> = {}) { + const props: ComponentProps = { + currentModeId: "documents", + modeOptions, + actions, + onClose: vi.fn(), + onCurrentSearch: vi.fn(), + onGlobalSearch: vi.fn(), + onModeSelect: vi.fn(), + onAction: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("SearchPinsMenu", () => { + beforeEach(() => { + resetSearchPinsSessionForTests(); + window.localStorage.removeItem(searchPinsStorageKey); + }); + + it("keeps pins, search choices, and actions as separate sections", () => { + renderMenu(); + expect(screen.getByRole("heading", { name: "Your pins" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Search in" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Useful actions" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open Ward essentials pin, 3 destinations" })).toHaveAttribute( + "data-sheet-autofocus", + "true", + ); + }); + + it("expands a pin into explicit destination links without changing search mode", async () => { + const user = userEvent.setup(); + const onModeSelect = vi.fn(); + renderMenu({ onModeSelect }); + + await user.click(screen.getByRole("button", { name: "Open Ward essentials pin, 3 destinations" })); + expect(screen.getByRole("link", { name: "Documents" })).toHaveAttribute("href", "/?mode=documents"); + expect(screen.getByRole("link", { name: "Medications" })).toHaveAttribute("href", "/?mode=prescribing"); + expect(onModeSelect).not.toHaveBeenCalled(); + }); + + it("creates and persists an editable pin", async () => { + const user = userEvent.setup(); + renderMenu(); + + await user.click(screen.getByRole("button", { name: "New pin" })); + const name = screen.getByRole("textbox", { name: "Pin name" }); + await user.type(name, "My clinic"); + await user.click(screen.getByRole("button", { name: "Medications" })); + await user.click(screen.getByRole("button", { name: "Create pin" })); + + expect(screen.getByText("My clinic")).toBeInTheDocument(); + expect(JSON.parse(window.localStorage.getItem(searchPinsStorageKey) ?? "[]")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "My clinic", destinationIds: ["documents", "prescribing"] }), + ]), + ); + }); + + it("keeps same-window pin changes when browser storage is unavailable", () => { + renderMenu(); + + act(() => { + window.dispatchEvent( + new CustomEvent(searchPinsChangeEvent, { + detail: [{ id: "clinic", name: "My clinic", destinationIds: ["documents", "forms"] }], + }), + ); + }); + + expect(screen.getByText("My clinic")).toBeInTheDocument(); + }); + + it("re-reads storage when a pin-change event carries no payload", () => { + window.localStorage.setItem( + searchPinsStorageKey, + JSON.stringify([{ id: "clinic", name: "Stored clinic", destinationIds: ["documents"] }]), + ); + renderMenu(); + + act(() => { + window.dispatchEvent(new CustomEvent(searchPinsChangeEvent)); + }); + + expect(screen.getByText("Stored clinic")).toBeInTheDocument(); + expect(screen.queryByText("Ward essentials")).not.toBeInTheDocument(); + }); + + it("marks the first pin for sheet autofocus", () => { + renderMenu(); + expect(screen.getByRole("button", { name: "Open Ward essentials pin, 3 destinations" })).toHaveAttribute( + "data-sheet-autofocus", + "true", + ); + }); + + it("closes the pin editor on Escape without dismissing the surrounding menu", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderMenu({ onClose }); + + await user.click(screen.getByRole("button", { name: "New pin" })); + expect(screen.getByRole("heading", { name: "Create a new pin" })).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(screen.getByRole("heading", { name: "Your pins" })).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("requires a second click before deleting a pin", async () => { + const user = userEvent.setup(); + renderMenu(); + + await user.click(screen.getByRole("button", { name: "Edit Ward essentials" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.getByRole("button", { name: "Confirm delete" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Edit pin" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + expect(screen.queryByText("Ward essentials")).not.toBeInTheDocument(); + }); + + it("disables pin creation at the persisted limit instead of silently truncating", () => { + window.localStorage.setItem( + searchPinsStorageKey, + JSON.stringify( + Array.from({ length: maximumSearchPins }, (_, index) => ({ + id: `pin-${index}`, + name: `Pin ${index + 1}`, + destinationIds: ["documents"], + })), + ), + ); + + renderMenu(); + + expect(screen.getByRole("button", { name: "New pin" })).toBeDisabled(); + }); + + it("opens universal search and changes area only through explicit search controls", async () => { + const user = userEvent.setup(); + const onGlobalSearch = vi.fn(); + const onModeSelect = vi.fn(); + renderMenu({ onGlobalSearch, onModeSelect }); + + await user.click(screen.getByRole("button", { name: /Search all clinical areas/ })); + expect(onGlobalSearch).toHaveBeenCalledOnce(); + expect(onModeSelect).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /Choose another search area/ })); + await user.click(screen.getByRole("button", { name: /Answer/ })); + expect(onModeSelect).toHaveBeenCalledWith("answer"); + }); + + it("omits the universal search action when the command dropdown is unavailable", () => { + renderMenu({ globalSearchAvailable: false }); + + expect(screen.queryByRole("button", { name: /Search all clinical areas/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Choose another search area/ })).toBeInTheDocument(); + }); + + it("renders every supplied mode action", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderMenu({ actions: longActionList, onAction }); + + expect(screen.getByRole("menuitem", { name: "Search sources" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Tables" })).toBeInTheDocument(); + await user.click(screen.getByRole("menuitem", { name: "Open source PDF" })); + expect(onAction).toHaveBeenCalledWith("documents-viewer"); + }); +}); diff --git a/tests/search-pins.test.ts b/tests/search-pins.test.ts new file mode 100644 index 0000000000..90f3ff0533 --- /dev/null +++ b/tests/search-pins.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + defaultSearchPins, + normalizeSearchPins, + readSearchPins, + resetSearchPinsSessionForTests, + searchPinsStorageKey, + writeSearchPins, +} from "@/lib/search-pins"; + +describe("search pin storage", () => { + beforeEach(() => { + resetSearchPinsSessionForTests(); + }); + it("normalizes user-owned pin names and destination ids", () => { + expect( + normalizeSearchPins([ + { + id: "rounds", + name: " Ward round ", + destinationIds: ["documents", "unknown", "documents", "forms"], + }, + ]), + ).toEqual([{ id: "rounds", name: "Ward round", destinationIds: ["documents", "forms"] }]); + }); + + it("keeps the first valid pin when duplicate ids are stored", () => { + expect( + normalizeSearchPins([ + { id: "rounds", name: "Ward round", destinationIds: ["documents"] }, + { id: "rounds", name: "Duplicate round", destinationIds: ["forms"] }, + { id: "forms", name: "Forms", destinationIds: ["forms"] }, + ]), + ).toEqual([ + { id: "rounds", name: "Ward round", destinationIds: ["documents"] }, + { id: "forms", name: "Forms", destinationIds: ["forms"] }, + ]); + }); + + it("falls back safely for malformed browser data", () => { + const storage = { getItem: () => "not-json" }; + expect(readSearchPins(storage)).toEqual(defaultSearchPins); + }); + + it("returns a copy of the default pins so callers cannot mutate the shared seed", () => { + const first = readSearchPins({ getItem: () => null }); + first[0].name = "Mutated"; + first[0].destinationIds.push("tools"); + expect(readSearchPins({ getItem: () => null })).toEqual(defaultSearchPins); + expect(defaultSearchPins[0].name).toBe("Ward essentials"); + }); + + it("keeps the last written pins in session memory when storage writes fail", () => { + const failingStorage = { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + writeSearchPins([{ id: "clinic", name: "Clinic", destinationIds: ["documents"] }], failingStorage); + expect(readSearchPins({ getItem: () => null })).toEqual([ + { id: "clinic", name: "Clinic", destinationIds: ["documents"] }, + ]); + }); + + it("writes only the normalized preference payload", () => { + const values = new Map(); + const storage = { setItem: (key: string, value: string) => values.set(key, value) }; + writeSearchPins([{ id: "tools", name: " Tools ", destinationIds: ["tools", "tools"] }], storage); + expect(JSON.parse(values.get(searchPinsStorageKey) ?? "null")).toEqual([ + { id: "tools", name: "Tools", destinationIds: ["tools"] }, + ]); + }); + + it("preserves an intentionally empty pin collection", () => { + expect(normalizeSearchPins([])).toEqual([]); + }); + + it("keeps the last normalized pins in memory when storage writes and reads fail", () => { + const unavailableStorage = { + getItem: () => { + throw new Error("storage disabled"); + }, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + + expect(writeSearchPins([], unavailableStorage)).toEqual([]); + expect(readSearchPins(unavailableStorage)).toEqual([]); + + writeSearchPins(defaultSearchPins); + }); +}); diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index e69a2f3c3c..40dae22938 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -31,8 +31,11 @@ function rendersBand(source: string) { return source.includes(BAND_ELEMENT); } -/** Mockups are design scratch and exempt from production wiring gates. */ -function isMockupPath(relativePath: string) { +/** Mockups are design scratch and exempt from production wiring gates. + * Absolute paths must first be scoped to the repository: a worktree parent can + * legitimately contain "mockup" without making every production module one. */ +function isMockupPath(candidatePath: string) { + const relativePath = path.isAbsolute(candidatePath) ? path.relative(REPO_ROOT, candidatePath) : candidatePath; return /mockup/i.test(relativePath); } @@ -573,6 +576,11 @@ describe("search results band adoption", () => { .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) })) .filter(({ rel }) => !isMockupPath(rel)); + it("scopes mockup detection to the repository-relative path", () => { + expect(isMockupPath(path.join(REPO_ROOT, "src", "components", "production-result.tsx"))).toBe(false); + expect(isMockupPath(path.join(REPO_ROOT, "src", "app", "mockups", "study", "page.tsx"))).toBe(true); + }); + it("mounts the shared band on every mode that presents a result list", () => { const mounted = new Set(); for (const { abs } of productionComponents) { diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index e3b0d156cf..216e306fc6 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -1,4 +1,7 @@ -import { render, waitFor } from "@testing-library/react"; +import { useRef, useState } from "react"; + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sheet } from "@/components/ui/sheet"; @@ -49,6 +52,91 @@ function pressEscape() { window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); } +function DetachedResolverFallbackHarness() { + const [open, setOpen] = useState(false); + const [showResolverTarget, setShowResolverTarget] = useState(false); + const openerRef = useRef(null); + const staleResolverTargetRef = useRef(null); + + return ( + <> + + {showResolverTarget ? ( + + ) : null} + setOpen(false)} + title="Return focus" + returnFocusRef={openerRef} + resolveReturnFocusTarget={() => staleResolverTargetRef.current} + > + + + + ); +} + +function DynamicReturnFocusHarness() { + const [open, setOpen] = useState(false); + const openerRef = useRef(null); + const updatedTargetRef = useRef(null); + const returnFocusRef = useRef(null); + + return ( + <> + + + setOpen(false)} title="Dynamic return" returnFocusRef={returnFocusRef}> + + + + ); +} + describe("Sheet stacked-overlay coordination", () => { it("routes Escape to only the top-most open Sheet", () => { const onCloseA = vi.fn(); @@ -179,4 +267,28 @@ describe("Sheet stacked-overlay coordination", () => { expect(document.activeElement).toBe(findField); }); }); + + it("falls back to returnFocusRef when a resolver target has detached", async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole("button", { name: "Open sheet" }); + await user.click(opener); + await waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + + await user.click(screen.getByRole("button", { name: "Close sheet" })); + + await waitFor(() => expect(opener).toHaveFocus()); + }); + + it("reads returnFocusRef at close time so callers can retarget focus", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + await user.click(screen.getByRole("button", { name: "Close to updated target" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Updated return target" })).toHaveFocus()); + }); });