chore: WIP migrate to React Aria

This commit is contained in:
Corbin Crutchley
2023-07-23 20:03:19 -07:00
parent 647de4e6a9
commit 5abd08ab85
5 changed files with 1720 additions and 152 deletions

View File

@@ -48,7 +48,15 @@ export default defineConfig({
vite: {
ssr: {
external: ["svgo"],
noExternal: ["@floating-ui/react", "@floating-ui/react-dom"],
noExternal: [
"@floating-ui/react",
"@floating-ui/react-dom",
"react-aria",
"react-stately",
/@react-aria/,
/@react-stately/,
/@react-types/,
],
},
plugins: [svgr()],
},

1570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -123,7 +123,9 @@
"@floating-ui/react": "^0.24.8",
"@tanstack/react-query": "^4.29.19",
"medium-zoom": "^1.0.8",
"preact": "^10.16.0"
"preact": "^10.16.0",
"react-aria": "^3.26.0",
"react-stately": "^3.24.0"
},
"overrides": {
"react": "npm:@preact/compat@latest",

View File

@@ -1,193 +1,185 @@
/**
* TODO: Migrate this to be controlled at some point. Right now, it's uncontrolled
*/
import { ListState, useSelectState } from "react-stately";
import {
autoUpdate,
flip,
useFloating,
useInteractions,
useListNavigation,
useTypeahead,
useClick,
useListItem,
useDismiss,
useRole,
FloatingFocusManager,
FloatingList,
} from "@floating-ui/react";
import { createContext } from "preact";
import { PropsWithChildren, useContext } from "preact/compat";
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
AriaSelectProps,
HiddenSelect,
useListBox,
useOption,
useSelect,
} from "react-aria";
import { PropsWithChildren } from "preact/compat";
import down from "src/icons/chevron_down.svg?raw";
import { Button } from "components/button/button";
import styles from "./select.module.scss";
import checkmark from "src/icons/checkmark.svg?raw";
import { useRef } from "preact/hooks";
import { AriaListBoxOptions } from "@react-aria/listbox";
import { Node } from "@react-types/shared";
import {
autoUpdate,
flip,
FloatingFocusManager,
useClick,
useDismiss,
useFloating,
useInteractions,
} from "@floating-ui/react";
interface SelectContextValue {
activeIndex: number | null;
selectedIndex: number | null;
getItemProps: ReturnType<typeof useInteractions>["getItemProps"];
handleSelect: (index: number | null) => void;
}
export { Item, Section } from "react-stately";
const SelectContext = createContext<SelectContextValue>(
{} as SelectContextValue
);
const rightIcon = (
<span
className={styles.downSpan}
dangerouslySetInnerHTML={{ __html: down }}
></span>
);
interface SelectProps {
initial?: {
selectedIndex: number;
selectedLabel: string;
};
interface SelectProps<T extends object> extends AriaSelectProps<T> {
class?: string;
className?: string;
onChangeVal: (val: string) => void;
}
export function Select({
children,
initial,
export function Select<T extends object>({
class: className = "",
className: classNameName = "",
onChangeVal,
}: PropsWithChildren<SelectProps>) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(
initial?.selectedIndex ?? null
);
const [selectedLabel, setSelectedLabel] = useState<string | null>(
initial?.selectedLabel ?? null
...props
}: PropsWithChildren<SelectProps<T>>) {
const state = useSelectState(props);
// Get props for child elements from useSelect
const ref = useRef(null);
const { labelProps, triggerProps, valueProps, menuProps } = useSelect(
props,
state,
ref
);
const setRefs = (el: HTMLButtonElement) => {
ref.current = el;
refs.setReference(el);
};
const { refs, floatingStyles, context } = useFloating({
placement: "bottom",
open: isOpen,
onOpenChange: setIsOpen,
placement: "bottom-start",
open: state.isOpen,
onOpenChange: state.setOpen,
whileElementsMounted: autoUpdate,
middleware: [flip()],
});
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const handleSelect = useCallback((index: number | null) => {
setSelectedIndex(index);
setIsOpen(false);
if (index !== null) {
const newLabel = labelsRef.current[index];
setSelectedLabel(newLabel);
onChangeVal(newLabel);
}
}, []);
function handleTypeaheadMatch(index: number | null) {
if (isOpen) {
setActiveIndex(index);
} else {
handleSelect(index);
}
}
const listNav = useListNavigation(context, {
listRef: elementsRef,
activeIndex,
selectedIndex,
onNavigate: setActiveIndex,
});
const typeahead = useTypeahead(context, {
listRef: labelsRef,
activeIndex,
selectedIndex,
onMatch: handleTypeaheadMatch,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "listbox" });
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
[listNav, typeahead, click, dismiss, role]
);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
const selectContext = useMemo(
() => ({
activeIndex,
selectedIndex,
getItemProps,
handleSelect,
}),
[activeIndex, selectedIndex, getItemProps, handleSelect]
);
const referenceProps = getReferenceProps();
const mergedTriggerProps = { ...triggerProps };
for (const key in referenceProps) {
if (!key.startsWith("on") && triggerProps[key] && referenceProps[key]) {
mergedTriggerProps[key] = (e: any) => {
const one = (triggerProps[key] as (e: unknown) => boolean)(e);
const two = (referenceProps[key] as (e: unknown) => boolean)(e);
return one && two;
};
continue;
}
mergedTriggerProps[key] = referenceProps[key];
}
return (
<>
<HiddenSelect
isDisabled={props.isDisabled}
state={state}
triggerRef={ref}
label={props.label}
name={props.name}
/>
<div {...labelProps} class={"visually-hidden"}>
Post sort order
</div>
<Button
class={`${className} ${classNameName}`}
tag="button"
type="button"
ref={refs.setReference}
{...getReferenceProps()}
rightIcon={rightIcon}
ref={setRefs}
{...mergedTriggerProps}
rightIcon={
<span
className={styles.downSpan}
dangerouslySetInnerHTML={{ __html: down }}
></span>
}
>
{selectedLabel ?? "Select..."}
<span {...valueProps}>
{state.selectedItem
? state.selectedItem.rendered
: "Select an option"}
</span>
</Button>
<SelectContext.Provider value={selectContext}>
{isOpen && (
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={floatingStyles}
class={styles.selectDropdown}
{...getFloatingProps()}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{children}
</FloatingList>
</div>
</FloatingFocusManager>
)}
</SelectContext.Provider>
{state.isOpen && (
<FloatingFocusManager context={context} modal={false}>
{/*<Popover state={state} triggerRef={ref} placement="bottom start">*/}
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
<ListBox {...menuProps} state={state} />
</div>
{/*</Popover>*/}
</FloatingFocusManager>
)}
</>
);
}
export function Option({ label }: { label: string }) {
const { activeIndex, selectedIndex, getItemProps, handleSelect } =
useContext(SelectContext);
interface ListBoxProps extends AriaListBoxOptions<unknown> {
listBoxRef?: React.RefObject<HTMLUListElement>;
state: ListState<unknown>;
}
const { ref, index } = useListItem({ label });
const isActive = activeIndex === index;
const isSelected = selectedIndex === index;
function ListBox(props: ListBoxProps) {
const ref = useRef<HTMLUListElement>(null);
const { listBoxRef = ref, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<button
<ul {...listBoxProps} ref={listBoxRef}>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
export function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
role="option"
aria-selected={isActive && isSelected}
tabIndex={isActive ? 0 : -1}
class={`${styles.option} ${isSelected ? styles.selected : ""} ${
isActive ? styles.active : ""
isSelected ? styles.active : ""
}`}
{...getItemProps({
onClick: () => handleSelect(index),
})}
>
<span class={"text-style-button-regular"}>{label}</span>
<span className={"text-style-button-regular"}>{item.rendered}</span>
{isSelected && (
<span
class={styles.checkmark}
className={styles.checkmark}
dangerouslySetInnerHTML={{ __html: checkmark }}
></span>
)}
</button>
</li>
);
}

View File

@@ -3,7 +3,7 @@ import { SearchInput } from "components/input/input";
import { Button, IconOnlyButton } from "components/button/button";
import filter from "src/icons/filter.svg?raw";
import forward from "src/icons/arrow_right.svg?raw";
import { Option, Select } from "components/select/select";
import { Item, Option, Select } from "components/select/select";
interface SearchTopbarProps {
onSearch: (search: string) => void;
@@ -91,14 +91,12 @@ export const SearchTopbar = ({
</div>
<div class={style.orderSelectContainer}>
<Select
initial={{
selectedIndex: 1,
selectedLabel: "Newest",
}}
onChangeVal={(val) => alert(val)}
label={"Order"}
selectedKey={sort}
onSelectionChange={(v) => setSort(v)}
>
<Option label={"Newest"} />
<Option label={"Oldest"} />
<Item key={"newest"}>Newest</Item>
<Item key={"oldest"}>Oldest</Item>
</Select>
</div>
<div className={style.topBarSmallTabletButtons}>