Skip to content

Commit cf76aff

Browse files
authored
Multi-select combobox (#1351)
1 parent 6d0a8c3 commit cf76aff

File tree

12 files changed

+1758
-134
lines changed

12 files changed

+1758
-134
lines changed

.changeset/clean-goats-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@keystar/ui': patch
3+
---
4+
5+
Add ComboboxMulti component.

design-system/pkg/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,7 @@
15201520
"@react-stately/data": "^3.11.6",
15211521
"@react-stately/datepicker": "^3.10.2",
15221522
"@react-stately/dnd": "^3.4.2",
1523+
"@react-stately/form": "^3.0.6",
15231524
"@react-stately/layout": "^4.0.2",
15241525
"@react-stately/list": "^3.10.8",
15251526
"@react-stately/menu": "^3.8.2",
@@ -1528,6 +1529,7 @@
15281529
"@react-stately/radio": "^3.10.7",
15291530
"@react-stately/searchfield": "^3.5.6",
15301531
"@react-stately/select": "^3.6.7",
1532+
"@react-stately/selection": "^3.17.0",
15311533
"@react-stately/table": "^3.12.1",
15321534
"@react-stately/tabs": "^3.6.9",
15331535
"@react-stately/toast": "3.0.0-beta.5",

design-system/pkg/src/combobox/Combobox.tsx

Lines changed: 59 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@react-aria/utils';
99
import { useComboBoxState } from '@react-stately/combobox';
1010
import { AriaButtonProps } from '@react-types/button';
11+
import { LoadingState } from '@react-types/shared';
1112
import React, {
1213
ForwardedRef,
1314
InputHTMLAttributes,
@@ -21,7 +22,7 @@ import React, {
2122
} from 'react';
2223

2324
import { FieldButton } from '@keystar/ui/button';
24-
import { useProvider, useProviderProps } from '@keystar/ui/core';
25+
import { useProviderProps } from '@keystar/ui/core';
2526
import { FieldPrimitive } from '@keystar/ui/field';
2627
import { Icon } from '@keystar/ui/icon';
2728
import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon';
@@ -74,12 +75,11 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
7475
shouldFlip = true,
7576
direction = 'bottom',
7677
loadingState,
77-
menuWidth: menuWidthProp,
78+
menuWidth,
7879
onLoadMore,
7980
} = props;
8081

8182
let isAsync = loadingState != null;
82-
let stringFormatter = useLocalizedStringFormatter(localizedMessages);
8383
let buttonRef = useRef<HTMLButtonElement>(null);
8484
let inputRef = useRef<HTMLInputElement>(null);
8585
let listBoxRef = useRef<HTMLDivElement>(null);
@@ -114,31 +114,13 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
114114
state
115115
);
116116

117-
// Measure the width of the input and the button to inform the width of the menu (below).
118-
let [menuWidth, setMenuWidth] = useState<number>();
119-
let { scale } = useProvider();
120-
121-
let onResize = useCallback(() => {
122-
if (buttonRef.current && inputRef.current) {
123-
let buttonWidth = buttonRef.current.offsetWidth;
124-
let inputWidth = inputRef.current.offsetWidth;
125-
126-
setMenuWidth(inputWidth + buttonWidth);
127-
}
128-
}, [buttonRef, inputRef, setMenuWidth]);
129-
130-
useResizeObserver({
131-
ref: fieldRef,
132-
onResize: onResize,
117+
let popoverStyle = usePopoverStyles({
118+
menuWidth,
119+
buttonRef,
120+
inputRef,
121+
fieldRef,
133122
});
134123

135-
useLayoutEffect(onResize, [scale, onResize]);
136-
137-
let style = {
138-
width: menuWidth,
139-
minWidth: menuWidthProp ?? menuWidth,
140-
};
141-
142124
return (
143125
<>
144126
<FieldPrimitive
@@ -162,7 +144,7 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
162144
</FieldPrimitive>
163145
<Popover
164146
state={state}
165-
UNSAFE_style={style}
147+
UNSAFE_style={popoverStyle}
166148
ref={popoverRef}
167149
triggerRef={align === 'end' ? buttonRef : inputRef}
168150
scrollRef={listBoxRef}
@@ -185,30 +167,64 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
185167
onLoadMore={onLoadMore}
186168
UNSAFE_className={listStyles}
187169
renderEmptyState={() =>
188-
isAsync && (
189-
<Flex
190-
height="element.regular"
191-
alignItems="center"
192-
paddingX="medium"
193-
>
194-
<Text color="neutralSecondary">
195-
{loadingState === 'loading'
196-
? stringFormatter.format('loading')
197-
: stringFormatter.format('noResults')}
198-
</Text>
199-
</Flex>
200-
)
170+
isAsync && <ComboboxEmptyState loadingState={loadingState} />
201171
}
202172
/>
203173
</Popover>
204174
</>
205175
);
206176
});
207177

178+
export function ComboboxEmptyState(props: { loadingState?: LoadingState }) {
179+
let stringFormatter = useLocalizedStringFormatter(localizedMessages);
180+
return (
181+
<Flex height="element.regular" alignItems="center" paddingX="medium">
182+
<Text color="neutralSecondary">
183+
{props.loadingState === 'loading'
184+
? stringFormatter.format('loading')
185+
: stringFormatter.format('noResults')}
186+
</Text>
187+
</Flex>
188+
);
189+
}
190+
191+
export function usePopoverStyles(props: {
192+
menuWidth?: number;
193+
buttonRef: RefObject<HTMLButtonElement>;
194+
inputRef: RefObject<HTMLInputElement>;
195+
fieldRef: RefObject<HTMLDivElement>;
196+
}) {
197+
const { buttonRef, inputRef, fieldRef, menuWidth: menuWidthProp } = props;
198+
199+
// Measure the width of the input and the button to inform the width of the menu (below).
200+
let [menuWidth, setMenuWidth] = useState<number>();
201+
202+
let onResize = useCallback(() => {
203+
if (buttonRef.current && inputRef.current) {
204+
let buttonWidth = buttonRef.current.offsetWidth;
205+
let inputWidth = inputRef.current.offsetWidth;
206+
207+
setMenuWidth(inputWidth + buttonWidth);
208+
}
209+
}, [buttonRef, inputRef, setMenuWidth]);
210+
211+
useResizeObserver({
212+
ref: fieldRef,
213+
onResize: onResize,
214+
});
215+
216+
useLayoutEffect(onResize, [onResize]);
217+
218+
return {
219+
width: menuWidth,
220+
minWidth: menuWidthProp ?? menuWidth,
221+
};
222+
}
223+
208224
// FIXME: this is a hack to work around a requirement of react-aria. object refs
209225
// never have the value early enough, so we need to use a stateful ref to force
210226
// a re-render.
211-
function useStatefulRef<T extends HTMLElement>() {
227+
export function useStatefulRef<T extends HTMLElement>() {
212228
let [current, statefulRef] = useState<T | null>(null);
213229
return useMemo(() => {
214230
return [{ current }, statefulRef] as const;
@@ -224,7 +240,8 @@ interface ComboboxInputProps extends ComboboxProps<unknown> {
224240
isOpen?: boolean;
225241
}
226242

227-
const ComboboxInput = React.forwardRef(function ComboboxInput(
243+
/** @private Used by multi variant. */
244+
export const ComboboxInput = React.forwardRef(function ComboboxInput(
228245
props: ComboboxInputProps,
229246
forwardedRef: ForwardedRef<HTMLDivElement>
230247
) {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useObjectRef } from '@react-aria/utils';
2+
import React, { ForwardedRef, ReactElement, useRef } from 'react';
3+
4+
import { useProviderProps } from '@keystar/ui/core';
5+
import { FieldPrimitive } from '@keystar/ui/field';
6+
import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox';
7+
import { Popover } from '@keystar/ui/overlays';
8+
import { useIsMobileDevice } from '@keystar/ui/style';
9+
import { validateTextFieldProps } from '@keystar/ui/text-field';
10+
11+
import {
12+
ComboboxEmptyState,
13+
ComboboxInput,
14+
usePopoverStyles,
15+
useStatefulRef,
16+
} from './Combobox';
17+
import { MobileComboboxMulti } from './MobileComboboxMulti';
18+
import { ComboboxMultiProps } from './types';
19+
import { useComboboxMultiState } from './useComboboxMultiState';
20+
import { useComboboxMulti } from './useComboboxMulti';
21+
22+
function ComboboxMulti<T extends object>(
23+
props: ComboboxMultiProps<T>,
24+
forwardedRef: ForwardedRef<HTMLDivElement>
25+
) {
26+
props = useProviderProps(props);
27+
// FIXME
28+
props = validateTextFieldProps(props as any) as typeof props;
29+
30+
let isMobile = useIsMobileDevice();
31+
if (isMobile) {
32+
// menuTrigger=focus/manual don't apply to mobile combobox
33+
return (
34+
<MobileComboboxMulti {...props} menuTrigger="input" ref={forwardedRef} />
35+
);
36+
} else {
37+
// @ts-expect-error FIXME: 'T' could be instantiated with an arbitrary type which could be unrelated to 'unknown'.
38+
return <ComboboxMultiBase {...props} ref={forwardedRef} />;
39+
}
40+
}
41+
42+
const ComboboxMultiBase = React.forwardRef(function ComboboxMultiBase<
43+
T extends object,
44+
>(props: ComboboxMultiProps<T>, forwardedRef: ForwardedRef<HTMLDivElement>) {
45+
let {
46+
align = 'start',
47+
// menuTrigger = 'focus',
48+
shouldFlip = true,
49+
direction = 'bottom',
50+
loadingState,
51+
menuWidth,
52+
onLoadMore,
53+
} = props;
54+
55+
let isAsync = loadingState != null;
56+
let buttonRef = useRef<HTMLButtonElement>(null);
57+
let inputRef = useRef<HTMLInputElement>(null);
58+
let listBoxRef = useRef<HTMLDivElement>(null);
59+
let [popoverRefLikeValue, popoverRef] = useStatefulRef<HTMLDivElement>();
60+
let fieldRef = useObjectRef(forwardedRef);
61+
62+
let layoutDelegate = useListBoxLayout();
63+
let state = useComboboxMultiState(props);
64+
let {
65+
buttonProps,
66+
descriptionProps,
67+
errorMessageProps,
68+
inputProps,
69+
labelProps,
70+
listBoxProps,
71+
} = useComboboxMulti(
72+
{
73+
...props,
74+
buttonRef,
75+
inputRef,
76+
layoutDelegate,
77+
listBoxRef,
78+
popoverRef: popoverRefLikeValue,
79+
},
80+
state
81+
);
82+
83+
let popoverStyle = usePopoverStyles({
84+
menuWidth,
85+
buttonRef,
86+
inputRef,
87+
fieldRef,
88+
});
89+
90+
return (
91+
<>
92+
<FieldPrimitive
93+
width="alias.singleLineWidth"
94+
{...props}
95+
descriptionProps={descriptionProps}
96+
errorMessageProps={errorMessageProps}
97+
labelProps={labelProps}
98+
ref={fieldRef}
99+
>
100+
{/* @ts-expect-error FIXME: not sure how to resolve this type error */}
101+
<ComboboxInput
102+
{...props}
103+
isOpen={state.isOpen}
104+
loadingState={loadingState}
105+
inputProps={inputProps}
106+
inputRef={inputRef}
107+
triggerProps={buttonProps}
108+
triggerRef={buttonRef}
109+
/>
110+
</FieldPrimitive>
111+
<Popover
112+
state={state}
113+
UNSAFE_style={popoverStyle}
114+
ref={popoverRef}
115+
triggerRef={align === 'end' ? buttonRef : inputRef}
116+
scrollRef={listBoxRef}
117+
placement={`${direction} ${align}`}
118+
hideArrow
119+
isNonModal
120+
shouldFlip={shouldFlip}
121+
>
122+
<ListBoxBase
123+
{...listBoxProps}
124+
ref={listBoxRef}
125+
autoFocus={state.focusStrategy}
126+
disallowEmptySelection
127+
focusOnPointerEnter
128+
isLoading={loadingState === 'loadingMore'}
129+
layout={layoutDelegate}
130+
onLoadMore={onLoadMore}
131+
state={state}
132+
UNSAFE_className={listStyles}
133+
renderEmptyState={() =>
134+
isAsync && <ComboboxEmptyState loadingState={loadingState} />
135+
}
136+
/>
137+
</Popover>
138+
</>
139+
);
140+
});
141+
142+
/**
143+
* This component is not accessible, use with caution.
144+
*
145+
* A multi-combobox combines a text input with a listbox, and allows users to filter a
146+
* list of options.
147+
*/
148+
const _ComboboxMulti: <T>(
149+
props: ComboboxMultiProps<T> & { ref?: ForwardedRef<HTMLDivElement> }
150+
) => ReactElement = React.forwardRef(ComboboxMulti as any) as any;
151+
152+
export { _ComboboxMulti as ComboboxMulti };

0 commit comments

Comments
 (0)