Skip to content

Commit d3e6ebc

Browse files
committed
fix(ComboBox): focus and blur handling
1 parent fea4349 commit d3e6ebc

File tree

1 file changed

+151
-62
lines changed

1 file changed

+151
-62
lines changed

src/components/fields/ComboBox/ComboBox.tsx

Lines changed: 151 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import React, {
1515
} from 'react';
1616
import {
1717
useFilter,
18-
useFocusWithin,
1918
useKeyboard,
2019
useOverlay,
2120
useOverlayPosition,
@@ -130,10 +129,10 @@ export interface CubeComboBoxProps<T>
130129
placeholder?: string;
131130
/** Whether the input should have autofocus */
132131
autoFocus?: boolean;
133-
/** Callback fired when the wrapper element receives focus */
134-
onFocus?: (e: React.FocusEvent) => void;
135-
/** Callback fired when the wrapper element loses focus */
136-
onBlur?: (e: React.FocusEvent) => void;
132+
/** Callback fired when focus enters the component (input, trigger, or popover). Does not receive event object. */
133+
onFocus?: () => void;
134+
/** Callback fired when focus leaves the component entirely. Does not receive event object. */
135+
onBlur?: () => void;
137136

138137
/** Popover trigger behavior: 'focus', 'input', or 'manual'. Defaults to 'input' */
139138
popoverTrigger?: PopoverTriggerAction;
@@ -414,6 +413,85 @@ function useComboBoxFiltering({
414413
};
415414
}
416415

416+
// ============================================================================
417+
// Hook: useCompositeFocus
418+
// ============================================================================
419+
interface UseCompositeFocusProps {
420+
wrapperRef: RefObject<HTMLElement>;
421+
popoverRef: RefObject<HTMLElement>;
422+
onFocus?: () => void;
423+
onBlur?: () => void;
424+
isDisabled?: boolean;
425+
}
426+
427+
interface UseCompositeFocusReturn {
428+
compositeFocusProps: {
429+
onFocus: (e: React.FocusEvent) => void;
430+
onBlur: (e: React.FocusEvent) => void;
431+
};
432+
}
433+
434+
function useCompositeFocus({
435+
wrapperRef,
436+
popoverRef,
437+
onFocus,
438+
onBlur,
439+
isDisabled,
440+
}: UseCompositeFocusProps): UseCompositeFocusReturn {
441+
const wasInsideRef = useRef(false);
442+
const rafRef = useRef<number | null>(null);
443+
444+
const checkFocus = useCallback(() => {
445+
if (isDisabled) return;
446+
447+
const activeElement = document.activeElement;
448+
const isInside =
449+
(wrapperRef.current?.contains(activeElement) ?? false) ||
450+
(popoverRef.current?.contains(activeElement) ?? false);
451+
452+
if (isInside !== wasInsideRef.current) {
453+
wasInsideRef.current = isInside;
454+
if (isInside) {
455+
onFocus?.();
456+
} else {
457+
onBlur?.();
458+
}
459+
}
460+
}, [wrapperRef, popoverRef, onFocus, onBlur, isDisabled]);
461+
462+
const handleFocusOrBlur = useCallback(
463+
(e: React.FocusEvent) => {
464+
// Cancel any pending check
465+
if (rafRef.current !== null) {
466+
cancelAnimationFrame(rafRef.current);
467+
}
468+
469+
// Schedule focus check for next frame
470+
rafRef.current = requestAnimationFrame(() => {
471+
rafRef.current = null;
472+
checkFocus();
473+
});
474+
},
475+
[checkFocus],
476+
);
477+
478+
// Cleanup on unmount
479+
useEffect(() => {
480+
return () => {
481+
if (rafRef.current !== null) {
482+
cancelAnimationFrame(rafRef.current);
483+
}
484+
};
485+
}, []);
486+
487+
return {
488+
compositeFocusProps: {
489+
onFocus: handleFocusOrBlur,
490+
onBlur: handleFocusOrBlur,
491+
},
492+
};
493+
}
494+
417495
// ============================================================================
418496
// Hook: useComboBoxKeyboard
419497
// ============================================================================
@@ -718,6 +796,10 @@ interface ComboBoxOverlayProps {
718796
onClose: () => void;
719797
label?: ReactNode;
720798
ariaLabel?: string;
799+
compositeFocusProps: {
800+
onFocus: (e: React.FocusEvent) => void;
801+
onBlur: (e: React.FocusEvent) => void;
802+
};
721803
}
722804

723805
function ComboBoxOverlay({
@@ -745,6 +827,7 @@ function ComboBoxOverlay({
745827
onClose,
746828
label,
747829
ariaLabel,
830+
compositeFocusProps,
748831
}: ComboBoxOverlayProps) {
749832
// Overlay positioning
750833
const {
@@ -796,7 +879,11 @@ function ComboBoxOverlay({
796879
<DisplayTransition exposeUnmounted isShown={isOpen}>
797880
{({ phase, isShown, ref: transitionRef }) => (
798881
<ComboBoxOverlayElement
799-
{...mergeProps(overlayPositionProps, overlayBehaviorProps)}
882+
{...mergeProps(
883+
overlayPositionProps,
884+
overlayBehaviorProps,
885+
compositeFocusProps,
886+
)}
800887
ref={(value) => {
801888
transitionRef(value as HTMLElement | null);
802889
(popoverRef as any).current = value;
@@ -1094,11 +1181,62 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
10941181

10951182
const { isFocused, focusProps } = useFocus({ isDisabled });
10961183

1097-
// Wrapper-level focus/blur handlers
1098-
const { focusWithinProps } = useFocusWithin({
1184+
// Composite blur handler - fires when focus leaves the entire component
1185+
const handleCompositeBlur = useEvent(() => {
1186+
// Always disable filter on blur
1187+
setIsFilterActive(false);
1188+
1189+
// In allowsCustomValue mode with shouldCommitOnBlur, commit the input value
1190+
if (
1191+
allowsCustomValue &&
1192+
shouldCommitOnBlur &&
1193+
effectiveInputValue &&
1194+
effectiveSelectedKey == null
1195+
) {
1196+
externalOnSelectionChange?.(effectiveInputValue as string);
1197+
if (!isControlledKey) {
1198+
setInternalSelectedKey(effectiveInputValue as Key);
1199+
}
1200+
// Call user's onBlur callback
1201+
onBlur?.();
1202+
return;
1203+
}
1204+
1205+
// In clearOnBlur mode (only for non-custom-value mode), clear selection and input
1206+
if (clearOnBlur && !allowsCustomValue) {
1207+
externalOnSelectionChange?.(null);
1208+
if (!isControlledKey) {
1209+
setInternalSelectedKey(null);
1210+
}
1211+
if (!isControlledInput) {
1212+
setInternalInputValue('');
1213+
}
1214+
onInputChange?.('');
1215+
// Call user's onBlur callback
1216+
onBlur?.();
1217+
return;
1218+
}
1219+
1220+
// Reset input to show current selection (or empty if none)
1221+
const nextValue =
1222+
effectiveSelectedKey != null ? getItemLabel(effectiveSelectedKey) : '';
1223+
1224+
if (!isControlledInput) {
1225+
setInternalInputValue(nextValue);
1226+
}
1227+
onInputChange?.(nextValue);
1228+
1229+
// Call user's onBlur callback
1230+
onBlur?.();
1231+
});
1232+
1233+
// Composite focus hook - handles focus tracking across wrapper and portaled popover
1234+
const { compositeFocusProps } = useCompositeFocus({
1235+
wrapperRef,
1236+
popoverRef,
1237+
onFocus,
1238+
onBlur: handleCompositeBlur,
10991239
isDisabled,
1100-
onFocusWithin: onFocus,
1101-
onBlurWithin: onBlur,
11021240
});
11031241

11041242
let isInvalid = validationState === 'invalid';
@@ -1240,59 +1378,9 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
12401378
}
12411379
});
12421380

1243-
// Input blur handler
1381+
// Input blur handler - just handles internal focus props
12441382
const handleInputBlur = useEvent((e: React.FocusEvent<HTMLInputElement>) => {
12451383
focusProps.onBlur?.(e as any);
1246-
1247-
const relatedTarget = e.relatedTarget as Node | null;
1248-
1249-
// Don't do anything if focus is moving within the combobox
1250-
if (
1251-
relatedTarget &&
1252-
(wrapperRef.current?.contains(relatedTarget) ||
1253-
popoverRef.current?.contains(relatedTarget))
1254-
) {
1255-
return;
1256-
}
1257-
1258-
// Always disable filter on blur
1259-
setIsFilterActive(false);
1260-
1261-
// In allowsCustomValue mode with shouldCommitOnBlur, commit the input value
1262-
if (
1263-
allowsCustomValue &&
1264-
shouldCommitOnBlur &&
1265-
effectiveInputValue &&
1266-
effectiveSelectedKey == null
1267-
) {
1268-
externalOnSelectionChange?.(effectiveInputValue as string);
1269-
if (!isControlledKey) {
1270-
setInternalSelectedKey(effectiveInputValue as Key);
1271-
}
1272-
return;
1273-
}
1274-
1275-
// In clearOnBlur mode (only for non-custom-value mode), clear selection and input
1276-
if (clearOnBlur && !allowsCustomValue) {
1277-
externalOnSelectionChange?.(null);
1278-
if (!isControlledKey) {
1279-
setInternalSelectedKey(null);
1280-
}
1281-
if (!isControlledInput) {
1282-
setInternalInputValue('');
1283-
}
1284-
onInputChange?.('');
1285-
return;
1286-
}
1287-
1288-
// Reset input to show current selection (or empty if none)
1289-
const nextValue =
1290-
effectiveSelectedKey != null ? getItemLabel(effectiveSelectedKey) : '';
1291-
1292-
if (!isControlledInput) {
1293-
setInternalInputValue(nextValue);
1294-
}
1295-
onInputChange?.(nextValue);
12961384
});
12971385

12981386
// Clear button logic
@@ -1462,7 +1550,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
14621550
zIndex: isFocused ? 1 : 'initial',
14631551
}}
14641552
data-size={size}
1465-
{...focusWithinProps}
1553+
{...compositeFocusProps}
14661554
>
14671555
{prefix ? <div data-element="Prefix">{prefix}</div> : null}
14681556
<ComboBoxInput
@@ -1558,6 +1646,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
15581646
listStateRef={listStateRef}
15591647
label={label}
15601648
ariaLabel={(props as any)['aria-label']}
1649+
compositeFocusProps={compositeFocusProps}
15611650
onSelectionChange={handleSelectionChange}
15621651
onClose={() => setIsPopoverOpen(false)}
15631652
>

0 commit comments

Comments
 (0)