Skip to content

Commit

Permalink
Create SelectNext component replacing Select
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 21, 2023
1 parent 759c2af commit c51250f
Show file tree
Hide file tree
Showing 9 changed files with 793 additions and 4 deletions.
1 change: 1 addition & 0 deletions src/components/input/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const arrowImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000

/**
* Style a native `<select>` element.
* @deprecated Use SelectNext instead
*/
export default function Select({
children,
Expand Down
10 changes: 10 additions & 0 deletions src/components/input/SelectContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createContext } from 'preact';

export type SelectContextType<T = unknown> = {
selectValue: (newValue: T) => void;
value: T;
};

const SelectContext = createContext<SelectContextType | null>(null);

export default SelectContext;
195 changes: 195 additions & 0 deletions src/components/input/SelectNext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import {
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useState,
} from 'preact/hooks';

import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation';
import { useClickAway } from '../../hooks/use-click-away';
import { useKeyPress } from '../../hooks/use-key-press';
import { useSyncedRef } from '../../hooks/use-synced-ref';
import type { PresentationalProps } from '../../types';
import { MenuCollapseIcon, MenuExpandIcon } from '../icons';
import Button from './Button';
import SelectContext from './SelectContext';

export type SelectOptionStatus = {
selected: boolean;
disabled: boolean;
};

export type SelectOptionProps<T> = {
value: T;
disabled?: boolean;
children: (status: SelectOptionStatus) => ComponentChildren;
classes?: string | string[];
};

function SelectOption<T>({
value,
children,
disabled = false,
classes,
}: SelectOptionProps<T>) {
const selectContext = useContext(SelectContext);
if (!selectContext) {
throw new Error('Select.Option can only be used as Select child');
}

const { selectValue, value: currentValue } = selectContext;
const selected = !disabled && currentValue === value;

return (
<Button
variant="custom"
classes={classnames(
'w-full ring-inset rounded-none !p-0',
'border-t first:border-t-0 bg-transparent',
{ 'hover:bg-grey-1': !disabled },
classes,
)}
onClick={() => selectValue(value)}
role="option"
disabled={disabled}
aria-selected={selected}
// This is intended to be focused with arrow keys
tabIndex={-1}
>
<div
className={classnames('flex w-full p-1.5 border-l-4', {
'border-l-transparent': !selected,
'border-l-brand font-medium': selected,
})}
>
{children({ selected, disabled })}
</div>
</Button>
);
}

export type SelectProps<T> = PresentationalProps & {
value: T;
onChange: (newValue: T) => void;
label: ComponentChildren;
disabled?: boolean;
};

function SelectMain<T>({
label,
value,
onChange,
children,
disabled,
classes,
elementRef,
}: SelectProps<T>) {
const [isListboxOpen, setIsListboxOpen] = useState(false);
const [shouldListboxDropUp, setShouldListboxDropUp] = useState(false);
const closeListbox = useCallback(() => setIsListboxOpen(false), []);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const listboxRef = useRef<HTMLDivElement | null>(null);
const listboxId = useId();
const buttonRef = useSyncedRef(elementRef);
const buttonId = useId();

const selectValue = useCallback(
(newValue: unknown) => {
onChange(newValue as T);
closeListbox();
},
[closeListbox, onChange],
);

// When clicking away or pressing `Esc`, close the listbox
useClickAway(wrapperRef, closeListbox);
useKeyPress(['Escape'], closeListbox);

// Vertical arrow key for options in the listbox
useArrowKeyNavigation(wrapperRef, { horizontal: false });

useLayoutEffect(() => {
if (!isListboxOpen) {
// Focus button after closing listbox
buttonRef.current!.focus();
// Reset shouldDropUp so that it does not affect calculations next time
// it opens
setShouldListboxDropUp(false);
} else {
const viewportHeight = window.innerHeight;
const { top: buttonDistanceToTop, bottom: buttonBottom } =
buttonRef.current!.getBoundingClientRect();
const buttonDistanceToBottom = viewportHeight - buttonBottom;
const { bottom: listboxBottom } =
listboxRef.current!.getBoundingClientRect();
const listboxDistanceToBottom = viewportHeight - listboxBottom;

// The listbox should drop up only if there's not enough space below it for
// the listbox max height, and there's also more space above
setShouldListboxDropUp(
listboxDistanceToBottom < 0 &&
buttonDistanceToTop > buttonDistanceToBottom,
);
}
}, [buttonRef, isListboxOpen]);

return (
<div className="relative" ref={wrapperRef}>
<Button
id={buttonId}
variant="custom"
classes={classnames(
'w-full flex border',
'bg-grey-0 disabled:bg-grey-1 disabled:text-grey-6',
classes,
)}
expanded={isListboxOpen}
pressed={isListboxOpen}
disabled={disabled}
aria-haspopup="listbox"
aria-controls={listboxId}
elementRef={buttonRef}
onClick={() => setIsListboxOpen(prev => !prev)}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !isListboxOpen) {
setIsListboxOpen(true);
}
}}
data-testid="select-toggle-button"
>
{label}
<div className="grow" />
{isListboxOpen ? <MenuCollapseIcon /> : <MenuExpandIcon />}
</Button>
<SelectContext.Provider value={{ selectValue, value }}>
<div
className={classnames(
'absolute z-5 w-full max-h-80 overflow-y-auto',
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
{
'top-full mt-1': !shouldListboxDropUp,
'bottom-full mb-1': shouldListboxDropUp,
hidden: !isListboxOpen,
},
)}
role="listbox"
ref={listboxRef}
id={listboxId}
aria-labelledby={buttonId}
aria-orientation="vertical"
data-testid="select-listbox"
>
{children}
</div>
</SelectContext.Provider>
</div>
);
}

const SelectNext = Object.assign(SelectMain, { Option: SelectOption });

export default SelectNext;
2 changes: 2 additions & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { default as Input } from './Input';
export { default as InputGroup } from './InputGroup';
export { default as OptionButton } from './OptionButton';
export { default as Select } from './Select';
export { default as SelectNext } from './SelectNext';
export { default as Textarea } from './Textarea';

export type { ButtonProps } from './Button';
Expand All @@ -18,4 +19,5 @@ export type { InputProps } from './Input';
export type { InputGroupProps } from './InputGroup';
export type { OptionButtonProps } from './OptionButton';
export type { SelectProps } from './Select';
export type { SelectProps as SelectNextProps } from './SelectNext';
export type { TextareaProps } from './Textarea';
Loading

0 comments on commit c51250f

Please sign in to comment.