Skip to content

Commit

Permalink
Multi-select combobox (#1351)
Browse files Browse the repository at this point in the history
  • Loading branch information
jossmac authored Nov 18, 2024
1 parent 6d0a8c3 commit cf76aff
Show file tree
Hide file tree
Showing 12 changed files with 1,758 additions and 134 deletions.
5 changes: 5 additions & 0 deletions .changeset/clean-goats-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystar/ui': patch
---

Add ComboboxMulti component.
2 changes: 2 additions & 0 deletions design-system/pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,7 @@
"@react-stately/data": "^3.11.6",
"@react-stately/datepicker": "^3.10.2",
"@react-stately/dnd": "^3.4.2",
"@react-stately/form": "^3.0.6",
"@react-stately/layout": "^4.0.2",
"@react-stately/list": "^3.10.8",
"@react-stately/menu": "^3.8.2",
Expand All @@ -1528,6 +1529,7 @@
"@react-stately/radio": "^3.10.7",
"@react-stately/searchfield": "^3.5.6",
"@react-stately/select": "^3.6.7",
"@react-stately/selection": "^3.17.0",
"@react-stately/table": "^3.12.1",
"@react-stately/tabs": "^3.6.9",
"@react-stately/toast": "3.0.0-beta.5",
Expand Down
101 changes: 59 additions & 42 deletions design-system/pkg/src/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@react-aria/utils';
import { useComboBoxState } from '@react-stately/combobox';
import { AriaButtonProps } from '@react-types/button';
import { LoadingState } from '@react-types/shared';
import React, {
ForwardedRef,
InputHTMLAttributes,
Expand All @@ -21,7 +22,7 @@ import React, {
} from 'react';

import { FieldButton } from '@keystar/ui/button';
import { useProvider, useProviderProps } from '@keystar/ui/core';
import { useProviderProps } from '@keystar/ui/core';
import { FieldPrimitive } from '@keystar/ui/field';
import { Icon } from '@keystar/ui/icon';
import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon';
Expand Down Expand Up @@ -74,12 +75,11 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
shouldFlip = true,
direction = 'bottom',
loadingState,
menuWidth: menuWidthProp,
menuWidth,
onLoadMore,
} = props;

let isAsync = loadingState != null;
let stringFormatter = useLocalizedStringFormatter(localizedMessages);
let buttonRef = useRef<HTMLButtonElement>(null);
let inputRef = useRef<HTMLInputElement>(null);
let listBoxRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -114,31 +114,13 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
state
);

// Measure the width of the input and the button to inform the width of the menu (below).
let [menuWidth, setMenuWidth] = useState<number>();
let { scale } = useProvider();

let onResize = useCallback(() => {
if (buttonRef.current && inputRef.current) {
let buttonWidth = buttonRef.current.offsetWidth;
let inputWidth = inputRef.current.offsetWidth;

setMenuWidth(inputWidth + buttonWidth);
}
}, [buttonRef, inputRef, setMenuWidth]);

useResizeObserver({
ref: fieldRef,
onResize: onResize,
let popoverStyle = usePopoverStyles({
menuWidth,
buttonRef,
inputRef,
fieldRef,
});

useLayoutEffect(onResize, [scale, onResize]);

let style = {
width: menuWidth,
minWidth: menuWidthProp ?? menuWidth,
};

return (
<>
<FieldPrimitive
Expand All @@ -162,7 +144,7 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
</FieldPrimitive>
<Popover
state={state}
UNSAFE_style={style}
UNSAFE_style={popoverStyle}
ref={popoverRef}
triggerRef={align === 'end' ? buttonRef : inputRef}
scrollRef={listBoxRef}
Expand All @@ -185,30 +167,64 @@ const ComboboxBase = React.forwardRef(function ComboboxBase<T extends object>(
onLoadMore={onLoadMore}
UNSAFE_className={listStyles}
renderEmptyState={() =>
isAsync && (
<Flex
height="element.regular"
alignItems="center"
paddingX="medium"
>
<Text color="neutralSecondary">
{loadingState === 'loading'
? stringFormatter.format('loading')
: stringFormatter.format('noResults')}
</Text>
</Flex>
)
isAsync && <ComboboxEmptyState loadingState={loadingState} />
}
/>
</Popover>
</>
);
});

export function ComboboxEmptyState(props: { loadingState?: LoadingState }) {
let stringFormatter = useLocalizedStringFormatter(localizedMessages);
return (
<Flex height="element.regular" alignItems="center" paddingX="medium">
<Text color="neutralSecondary">
{props.loadingState === 'loading'
? stringFormatter.format('loading')
: stringFormatter.format('noResults')}
</Text>
</Flex>
);
}

export function usePopoverStyles(props: {
menuWidth?: number;
buttonRef: RefObject<HTMLButtonElement>;
inputRef: RefObject<HTMLInputElement>;
fieldRef: RefObject<HTMLDivElement>;
}) {
const { buttonRef, inputRef, fieldRef, menuWidth: menuWidthProp } = props;

// Measure the width of the input and the button to inform the width of the menu (below).
let [menuWidth, setMenuWidth] = useState<number>();

let onResize = useCallback(() => {
if (buttonRef.current && inputRef.current) {
let buttonWidth = buttonRef.current.offsetWidth;
let inputWidth = inputRef.current.offsetWidth;

setMenuWidth(inputWidth + buttonWidth);
}
}, [buttonRef, inputRef, setMenuWidth]);

useResizeObserver({
ref: fieldRef,
onResize: onResize,
});

useLayoutEffect(onResize, [onResize]);

return {
width: menuWidth,
minWidth: menuWidthProp ?? menuWidth,
};
}

// FIXME: this is a hack to work around a requirement of react-aria. object refs
// never have the value early enough, so we need to use a stateful ref to force
// a re-render.
function useStatefulRef<T extends HTMLElement>() {
export function useStatefulRef<T extends HTMLElement>() {
let [current, statefulRef] = useState<T | null>(null);
return useMemo(() => {
return [{ current }, statefulRef] as const;
Expand All @@ -224,7 +240,8 @@ interface ComboboxInputProps extends ComboboxProps<unknown> {
isOpen?: boolean;
}

const ComboboxInput = React.forwardRef(function ComboboxInput(
/** @private Used by multi variant. */
export const ComboboxInput = React.forwardRef(function ComboboxInput(
props: ComboboxInputProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
Expand Down
152 changes: 152 additions & 0 deletions design-system/pkg/src/combobox/ComboboxMulti.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useObjectRef } from '@react-aria/utils';
import React, { ForwardedRef, ReactElement, useRef } from 'react';

import { useProviderProps } from '@keystar/ui/core';
import { FieldPrimitive } from '@keystar/ui/field';
import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox';
import { Popover } from '@keystar/ui/overlays';
import { useIsMobileDevice } from '@keystar/ui/style';
import { validateTextFieldProps } from '@keystar/ui/text-field';

import {
ComboboxEmptyState,
ComboboxInput,
usePopoverStyles,
useStatefulRef,
} from './Combobox';
import { MobileComboboxMulti } from './MobileComboboxMulti';
import { ComboboxMultiProps } from './types';
import { useComboboxMultiState } from './useComboboxMultiState';
import { useComboboxMulti } from './useComboboxMulti';

function ComboboxMulti<T extends object>(
props: ComboboxMultiProps<T>,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
props = useProviderProps(props);
// FIXME
props = validateTextFieldProps(props as any) as typeof props;

let isMobile = useIsMobileDevice();
if (isMobile) {
// menuTrigger=focus/manual don't apply to mobile combobox
return (
<MobileComboboxMulti {...props} menuTrigger="input" ref={forwardedRef} />
);
} else {
// @ts-expect-error FIXME: 'T' could be instantiated with an arbitrary type which could be unrelated to 'unknown'.
return <ComboboxMultiBase {...props} ref={forwardedRef} />;
}
}

const ComboboxMultiBase = React.forwardRef(function ComboboxMultiBase<
T extends object,
>(props: ComboboxMultiProps<T>, forwardedRef: ForwardedRef<HTMLDivElement>) {
let {
align = 'start',
// menuTrigger = 'focus',
shouldFlip = true,
direction = 'bottom',
loadingState,
menuWidth,
onLoadMore,
} = props;

let isAsync = loadingState != null;
let buttonRef = useRef<HTMLButtonElement>(null);
let inputRef = useRef<HTMLInputElement>(null);
let listBoxRef = useRef<HTMLDivElement>(null);
let [popoverRefLikeValue, popoverRef] = useStatefulRef<HTMLDivElement>();
let fieldRef = useObjectRef(forwardedRef);

let layoutDelegate = useListBoxLayout();
let state = useComboboxMultiState(props);
let {
buttonProps,
descriptionProps,
errorMessageProps,
inputProps,
labelProps,
listBoxProps,
} = useComboboxMulti(
{
...props,
buttonRef,
inputRef,
layoutDelegate,
listBoxRef,
popoverRef: popoverRefLikeValue,
},
state
);

let popoverStyle = usePopoverStyles({
menuWidth,
buttonRef,
inputRef,
fieldRef,
});

return (
<>
<FieldPrimitive
width="alias.singleLineWidth"
{...props}
descriptionProps={descriptionProps}
errorMessageProps={errorMessageProps}
labelProps={labelProps}
ref={fieldRef}
>
{/* @ts-expect-error FIXME: not sure how to resolve this type error */}
<ComboboxInput
{...props}
isOpen={state.isOpen}
loadingState={loadingState}
inputProps={inputProps}
inputRef={inputRef}
triggerProps={buttonProps}
triggerRef={buttonRef}
/>
</FieldPrimitive>
<Popover
state={state}
UNSAFE_style={popoverStyle}
ref={popoverRef}
triggerRef={align === 'end' ? buttonRef : inputRef}
scrollRef={listBoxRef}
placement={`${direction} ${align}`}
hideArrow
isNonModal
shouldFlip={shouldFlip}
>
<ListBoxBase
{...listBoxProps}
ref={listBoxRef}
autoFocus={state.focusStrategy}
disallowEmptySelection
focusOnPointerEnter
isLoading={loadingState === 'loadingMore'}
layout={layoutDelegate}
onLoadMore={onLoadMore}
state={state}
UNSAFE_className={listStyles}
renderEmptyState={() =>
isAsync && <ComboboxEmptyState loadingState={loadingState} />
}
/>
</Popover>
</>
);
});

/**
* This component is not accessible, use with caution.
*
* A multi-combobox combines a text input with a listbox, and allows users to filter a
* list of options.
*/
const _ComboboxMulti: <T>(
props: ComboboxMultiProps<T> & { ref?: ForwardedRef<HTMLDivElement> }
) => ReactElement = React.forwardRef(ComboboxMulti as any) as any;

export { _ComboboxMulti as ComboboxMulti };
Loading

0 comments on commit cf76aff

Please sign in to comment.