Skip to content

Commit 34bf316

Browse files
authored
feat(combo-box): Make it feel more like a select (#69087)
Use pointer as cursor in unfocused state + add interaction layer. Select all text on focus. Fix bug where the selected item was not the focused on opening the listbox. - closes #69086
1 parent 86c6aa6 commit 34bf316

File tree

1 file changed

+61
-4
lines changed

1 file changed

+61
-4
lines changed

static/app/components/comboBox/index.tsx

+61-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from 'sentry/components/compactSelect/utils';
1818
import {GrowingInput} from 'sentry/components/growingInput';
1919
import Input from 'sentry/components/input';
20+
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
2021
import LoadingIndicator from 'sentry/components/loadingIndicator';
2122
import {Overlay, PositionWrapper} from 'sentry/components/overlay';
2223
import {t} from 'sentry/locale';
@@ -34,7 +35,10 @@ import type {
3435
} from './types';
3536

3637
interface ComboBoxProps<Value extends string>
37-
extends ComboBoxStateOptions<ComboBoxOptionOrSection<Value>> {
38+
extends Omit<
39+
ComboBoxStateOptions<ComboBoxOptionOrSection<Value>>,
40+
'allowsCustomValue'
41+
> {
3842
'aria-label': string;
3943
className?: string;
4044
disabled?: boolean;
@@ -59,6 +63,7 @@ function ComboBox<Value extends string>({
5963
sizeLimitMessage,
6064
menuTrigger = 'focus',
6165
growingInput = false,
66+
onOpenChange,
6267
menuWidth,
6368
...props
6469
}: ComboBoxProps<Value>) {
@@ -70,10 +75,25 @@ function ComboBox<Value extends string>({
7075
const state = useComboBoxState({
7176
// Mapping our disabled prop to react-aria's isDisabled
7277
isDisabled: disabled,
78+
onOpenChange: (isOpen, ...otherArgs) => {
79+
onOpenChange?.(isOpen, ...otherArgs);
80+
if (isOpen) {
81+
// Ensure the selected element is being focused
82+
state.selectionManager.setFocusedKey(state.selectedKey);
83+
}
84+
},
7385
...props,
7486
});
87+
7588
const {inputProps, listBoxProps} = useComboBox(
76-
{listBoxRef, inputRef, popoverRef, isDisabled: disabled, ...props},
89+
{
90+
listBoxRef,
91+
inputRef,
92+
popoverRef,
93+
shouldFocusWrap: true,
94+
isDisabled: disabled,
95+
...props,
96+
},
7797
state
7898
);
7999

@@ -89,6 +109,14 @@ function ComboBox<Value extends string>({
89109
return () => {};
90110
}, [menuWidth, state.isOpen]);
91111

112+
useEffect(() => {
113+
const popoverElement = popoverRef.current;
114+
// Reset scroll state on opening the popover
115+
if (popoverElement) {
116+
popoverElement.scrollTop = 0;
117+
}
118+
}, [state.isOpen]);
119+
92120
const selectContext = useContext(SelectContext);
93121

94122
const {overlayProps, triggerProps} = useOverlay({
@@ -97,22 +125,41 @@ function ComboBox<Value extends string>({
97125
position: 'bottom-start',
98126
offset: [0, 8],
99127
isDismissable: true,
100-
isKeyboardDismissDisabled: true,
101128
onInteractOutside: () => {
102129
state.close();
103130
inputRef.current?.blur();
104131
},
105132
shouldCloseOnBlur: true,
106133
});
107134

108-
// The menu opens after selecting an item but the input stais focused
135+
// The menu opens after selecting an item but the input stays focused
109136
// This ensures the user can open the menu again by clicking on the input
110137
const handleInputClick = useCallback(() => {
111138
if (!state.isOpen && menuTrigger === 'focus') {
112139
state.open();
113140
}
114141
}, [state, menuTrigger]);
115142

143+
const handleInputMouseUp = useCallback((event: React.MouseEvent<HTMLInputElement>) => {
144+
// Prevents the input from being selected when clicking on the trigger
145+
event.preventDefault();
146+
}, []);
147+
148+
const handleInputFocus = useCallback(
149+
(event: React.FocusEvent<HTMLInputElement>) => {
150+
const onFocusProp = inputProps.onFocus;
151+
onFocusProp?.(event);
152+
if (menuTrigger === 'focus') {
153+
state.open();
154+
}
155+
// Need to setTimeout otherwise Chrome might reset the selection on padding click
156+
setTimeout(() => {
157+
event.target.select();
158+
}, 0);
159+
},
160+
[inputProps.onFocus, menuTrigger, state]
161+
);
162+
116163
const InputComponent = growingInput ? StyledGrowingInput : StyledInput;
117164

118165
return (
@@ -123,10 +170,13 @@ function ComboBox<Value extends string>({
123170
}}
124171
>
125172
<ControlWrapper className={className}>
173+
{!state.isFocused && <InteractionStateLayer />}
126174
<InputComponent
127175
{...inputProps}
128176
onClick={handleInputClick}
129177
placeholder={placeholder}
178+
onMouseUp={handleInputMouseUp}
179+
onFocus={handleInputFocus}
130180
ref={mergeRefs([inputRef, triggerProps.ref])}
131181
size={size}
132182
/>
@@ -307,15 +357,22 @@ const ControlWrapper = styled('div')`
307357
width: max-content;
308358
min-width: 150px;
309359
max-width: 100%;
360+
cursor: pointer;
310361
`;
311362

312363
const StyledInput = styled(Input)`
313364
max-width: inherit;
314365
min-width: inherit;
366+
&:not(:focus) {
367+
pointer-events: none;
368+
}
315369
`;
316370
const StyledGrowingInput = styled(GrowingInput)`
317371
max-width: inherit;
318372
min-width: inherit;
373+
&:not(:focus) {
374+
cursor: pointer;
375+
}
319376
`;
320377

321378
const StyledPositionWrapper = styled(PositionWrapper, {

0 commit comments

Comments
 (0)