♻️(react) use react aria for select menu

The way the menu of the select was made was causing it to be cropped
inside modals, it was due to the fact the menu was nested inside a
position relative parent. Now we use react aria to position in full
absolute the menu, making it to be correctly displayed inside modals.
This commit is contained in:
Nathan Vasse
2024-05-14 16:28:49 +02:00
committed by NathanVss
parent 3374093225
commit 06c5c9dff3
7 changed files with 428 additions and 219 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": patch
---
♻️(react) use react aria for select menu

View File

@@ -1,4 +1,4 @@
import React, { HTMLAttributes } from "react";
import React, { HTMLAttributes, useRef } from "react";
import { UseSelectReturnValue } from "downshift";
import classNames from "classnames";
import { useCunningham } from ":/components/Provider";
@@ -7,6 +7,7 @@ import { LabelledBox } from ":/components/Forms/LabelledBox";
import { Button } from ":/components/Button";
import { Option, SelectProps } from ":/components/Forms/Select";
import { isOptionWithRender } from ":/components/Forms/Select/utils";
import { SelectMenu } from ":/components/Forms/Select/select-menu";
export function getOptionsFilter(inputValue?: string) {
return (option: Option) => {
@@ -87,131 +88,130 @@ export const SelectMonoAux = ({
}: SelectAuxProps) => {
const { t } = useCunningham();
const labelProps = downshiftReturn.getLabelProps();
const ref = useRef<HTMLDivElement>(null);
return (
<Field state={state} {...props}>
<div
className={classNames(
"c__select",
"c__select--mono",
"c__select--" + state,
{
"c__select--disabled": disabled,
},
)}
onBlur={() =>
onBlur &&
onBlur({ target: { value: downshiftReturn.selectedItem?.value } })
}
>
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
{/* mouse users, so this do not engender any issue for accessibility. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<>
<Field state={state} {...props}>
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
})}
{...downshiftReturn.wrapperProps}
>
{downshiftReturn.selectedItem && (
<input
type="hidden"
name={name}
value={optionToValue(downshiftReturn.selectedItem)}
/>
ref={ref}
className={classNames(
"c__select",
"c__select--mono",
"c__select--" + state,
{
"c__select--disabled": disabled,
},
)}
<LabelledBox
label={label}
hideLabel={hideLabel}
labelAsPlaceholder={labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
disabled={disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__value">{children}</div>
<div className="c__select__inner__actions">
{clearable && !disabled && downshiftReturn.selectedItem && (
<>
<Button
color="tertiary-text"
size="nano"
aria-label={t(
"components.forms.select.clear_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
downshiftReturn.selectItem(null);
e.stopPropagation();
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
color="tertiary-text"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={disabled}
type="button"
{...downshiftReturn.toggleButtonProps}
/>
</div>
</div>
</LabelledBox>
</div>
<div
className={classNames("c__select__menu", {
"c__select__menu--opened": downshiftReturn.isOpen || false,
})}
{...downshiftReturn.getMenuProps()}
onBlur={() =>
onBlur?.({ target: { value: downshiftReturn.selectedItem?.value } })
}
>
<ul>
{(downshiftReturn.isOpen || false) && (
<>
{options.map((item, index) => {
const isActive = index === downshiftReturn.highlightedIndex;
return (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight": isActive,
"c__select__menu__item--selected":
downshiftReturn.selectedItem &&
optionsEqual(downshiftReturn.selectedItem, item),
"c__select__menu__item--disabled": item.disabled,
})}
key={`${optionToValue(item)}${index.toString()}`}
{...downshiftReturn.getItemProps({
item,
index,
})}
>
<span>{renderOption(item)}</span>
</li>
);
})}
{options.length === 0 && (
<li className="c__select__menu__item c__select__menu__empty-placeholder">
{t("components.forms.select.menu_empty_placeholder")}
</li>
)}
</>
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
{/* mouse users, so this do not engender any issue for accessibility. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
})}
{...downshiftReturn.wrapperProps}
>
{downshiftReturn.selectedItem && (
<input
type="hidden"
name={name}
value={optionToValue(downshiftReturn.selectedItem)}
/>
)}
</ul>
<LabelledBox
label={label}
hideLabel={hideLabel}
labelAsPlaceholder={labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
disabled={disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__value">{children}</div>
<div className="c__select__inner__actions">
{clearable && !disabled && downshiftReturn.selectedItem && (
<>
<Button
color="tertiary-text"
size="nano"
aria-label={t(
"components.forms.select.clear_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
downshiftReturn.selectItem(null);
e.stopPropagation();
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
color="tertiary-text"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={disabled}
type="button"
{...downshiftReturn.toggleButtonProps}
/>
</div>
</div>
</LabelledBox>
</div>
</div>
</div>
</Field>
</Field>
<SelectMenu
isOpen={downshiftReturn.isOpen}
selectRef={ref}
downshiftReturn={downshiftReturn}
>
<ul>
{options.map((item, index) => {
const isActive = index === downshiftReturn.highlightedIndex;
return (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight": isActive,
"c__select__menu__item--selected":
downshiftReturn.selectedItem &&
optionsEqual(downshiftReturn.selectedItem, item),
"c__select__menu__item--disabled": item.disabled,
})}
key={`${optionToValue(item)}${index.toString()}`}
{...downshiftReturn.getItemProps({
item,
index,
})}
>
<span>{renderOption(item)}</span>
</li>
);
})}
{options.length === 0 && (
<li className="c__select__menu__item c__select__menu__empty-placeholder">
{t("components.forms.select.menu_empty_placeholder")}
</li>
)}
</ul>
</SelectMenu>
</>
);
};

View File

@@ -1,6 +1,6 @@
import { Meta, StoryFn } from "@storybook/react";
import React, { useRef, useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import * as Yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils";
@@ -10,6 +10,8 @@ import {
getCountryOption,
RhfSelect,
} from ":/components/Forms/Select/stories-utils";
import { Modal, ModalSize, useModal } from ":/components/Modal";
import { Input } from ":/components/Forms/Input";
export default {
title: "Components/Forms/Select/Mono",
@@ -17,7 +19,7 @@ export default {
} as Meta<typeof Select>;
const Template: StoryFn<typeof Select> = (args) => (
<div style={{ paddingBottom: "200px" }}>
<div style={{ paddingBottom: "200px", position: "relative" }}>
<Select {...args} />
</div>
);
@@ -461,6 +463,74 @@ export const FormExample = () => {
);
};
export const SelectInModal = () => {
const modal = useModal({ isOpenDefault: true });
return (
<Modal size={ModalSize.MEDIUM} {...modal} title="Example">
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<Select
label="Context"
options={[
{
label: "Ask a document",
},
{
label: "Download files",
},
{
label: "Ask for help",
},
]}
fullWidth={true}
clearable={true}
/>
<div style={{ display: "flex", gap: "1rem" }}>
<Input label="First name" />
<Input label="Last name" />
</div>
<Input
label="Email address"
fullWidth={true}
text="Only @acme.com domain is authorized"
/>
<div style={{ display: "flex", gap: "1rem" }}>
<div style={{ width: "25%" }}>
<Input label="ZIP" fullWidth={true} />
</div>
<Input label="City" fullWidth={true} />
</div>
<Select
label="Skills"
options={[
{
label: "Communication",
},
{
label: "Teamwork",
},
{
label: "Problem solving",
},
{
label: "Leadership",
},
{
label: "Work ethic",
},
]}
fullWidth={true}
/>
</div>
</Modal>
);
};
export const ReactHookForm = () => {
enum CitiesOptionEnum {
NONE = "",

View File

@@ -1,4 +1,4 @@
import React, { HTMLAttributes } from "react";
import React, { HTMLAttributes, useRef } from "react";
import { useMultipleSelection } from "downshift";
import classNames from "classnames";
import { Field } from ":/components/Forms/Field";
@@ -63,99 +63,103 @@ export interface SelectMultiAuxProps extends SubProps {
export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
const { t } = useCunningham();
const labelProps = props.downshiftReturn.getLabelProps();
const ref = useRef<HTMLDivElement>(null);
// We need to remove onBlur from toggleButtonProps because it triggers a menu closing each time
// we tick a checkbox using the monoline style.
const { onBlur, ...toggleProps } = props.downshiftReturn.toggleButtonProps;
return (
<Field {...props}>
<div
className={classNames(
"c__select",
"c__select--multi",
"c__select--" + props.state,
"c__select--" + props.selectedItemsStyle,
{
"c__select--disabled": props.disabled,
"c__select--populated": props.selectedItems.length > 0,
"c__select--monoline": props.monoline,
"c__select--multiline": !props.monoline,
},
)}
>
<>
<Field {...props}>
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus":
props.downshiftReturn.isOpen && !props.disabled,
})}
{...props.downshiftReturn.wrapperProps}
{...toggleProps}
ref={ref}
className={classNames(
"c__select",
"c__select--multi",
"c__select--" + props.state,
"c__select--" + props.selectedItemsStyle,
{
"c__select--disabled": props.disabled,
"c__select--populated": props.selectedItems.length > 0,
"c__select--monoline": props.monoline,
"c__select--multiline": !props.monoline,
},
)}
>
{props.selectedItems.map((selectedItem, index) => (
<input
key={`${optionToValue(selectedItem)}${index.toString()}`}
type="hidden"
name={props.name}
value={optionToValue(selectedItem)}
/>
))}
<LabelledBox
label={props.label}
labelAsPlaceholder={props.labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
hideLabel={props.hideLabel}
disabled={props.disabled}
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus":
props.downshiftReturn.isOpen && !props.disabled,
})}
{...props.downshiftReturn.wrapperProps}
{...toggleProps}
>
<div className="c__select__inner">
<div className="c__select__inner__actions">
{props.clearable &&
!props.disabled &&
props.selectedItems.length > 0 && (
<>
<Button
color="tertiary-text"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
color="tertiary-text"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: props.downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={props.disabled}
type="button"
/>
{props.selectedItems.map((selectedItem, index) => (
<input
key={`${optionToValue(selectedItem)}${index.toString()}`}
type="hidden"
name={props.name}
value={optionToValue(selectedItem)}
/>
))}
<LabelledBox
label={props.label}
labelAsPlaceholder={props.labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
hideLabel={props.hideLabel}
disabled={props.disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__actions">
{props.clearable &&
!props.disabled &&
props.selectedItems.length > 0 && (
<>
<Button
color="tertiary-text"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
type="button"
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
color="tertiary-text"
size="nano"
className="c__select__inner__actions__open"
icon={
<span
className={classNames("material-icons", {
opened: props.downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={props.disabled}
type="button"
/>
</div>
<div className="c__select__inner__value">
<SelectedItems {...props} />
{children}
</div>
</div>
<div className="c__select__inner__value">
<SelectedItems {...props} />
{children}
</div>
</div>
</LabelledBox>
</LabelledBox>
</div>
</div>
<SelectMultiMenu {...props} />
</div>
</Field>
</Field>
<SelectMultiMenu {...props} selectRef={ref} />
</>
);
};

View File

@@ -8,19 +8,20 @@ import {
import { useCunningham } from ":/components/Provider";
import { Checkbox } from ":/components/Forms/Checkbox";
import { Option } from ":/components/Forms/Select/index";
import { SelectMenu } from ":/components/Forms/Select/select-menu";
export const SelectMultiMenu = (props: SelectMultiAuxProps) => {
export const SelectMultiMenu = (
props: SelectMultiAuxProps & {
selectRef: React.RefObject<HTMLDivElement>;
},
) => {
const { t } = useCunningham();
return (
<div
className={classNames(
"c__select__menu",
"c__select__menu--" + props.menuOptionsStyle,
{
"c__select__menu--opened": props.downshiftReturn.isOpen,
},
)}
{...props.downshiftReturn.getMenuProps()}
<SelectMenu
isOpen={props.downshiftReturn.isOpen}
selectRef={props.selectRef}
downshiftReturn={props.downshiftReturn}
menuOptionsStyle={props.menuOptionsStyle}
>
<ul>
{props.downshiftReturn.isOpen && (
@@ -41,7 +42,7 @@ export const SelectMultiMenu = (props: SelectMultiAuxProps) => {
</>
)}
</ul>
</div>
</SelectMenu>
);
};

View File

@@ -10,6 +10,8 @@ import {
getCountryOption,
RhfSelect,
} from ":/components/Forms/Select/stories-utils";
import { Modal, ModalSize, useModal } from ":/components/Modal";
import { Input } from ":/components/Forms/Input";
export default {
title: "Components/Forms/Select/Multi",
@@ -418,6 +420,76 @@ export const FormExample = () => {
);
};
export const SelectInModal = () => {
const modal = useModal({ isOpenDefault: true });
return (
<Modal size={ModalSize.MEDIUM} {...modal} title="Example">
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<Select
label="Context"
options={[
{
label: "Ask a document",
},
{
label: "Download files",
},
{
label: "Ask for help",
},
]}
multi={true}
fullWidth={true}
clearable={true}
/>
<div style={{ display: "flex", gap: "1rem" }}>
<Input label="First name" />
<Input label="Last name" />
</div>
<Input
label="Email address"
fullWidth={true}
text="Only @acme.com domain is authorized"
/>
<div style={{ display: "flex", gap: "1rem" }}>
<div style={{ width: "25%" }}>
<Input label="ZIP" fullWidth={true} />
</div>
<Input label="City" fullWidth={true} />
</div>
<Select
label="Skills"
options={[
{
label: "Communication",
},
{
label: "Teamwork",
},
{
label: "Problem solving",
},
{
label: "Leadership",
},
{
label: "Work ethic",
},
]}
multi={true}
fullWidth={true}
/>
</div>
</Modal>
);
};
export const ReactHookForm = () => {
enum CitiesOptionEnum {
NONE = "",

View File

@@ -0,0 +1,57 @@
import React, { PropsWithChildren } from "react";
import classNames from "classnames";
import { useOverlayPosition } from "react-aria";
import { SelectProps } from ":/components/Forms/Select/index";
import { SelectAuxProps } from ":/components/Forms/Select/mono-common";
import { SelectMultiAuxProps } from ":/components/Forms/Select/multi-common";
export interface SelectDropdownProps extends PropsWithChildren {
isOpen: boolean;
selectRef: React.RefObject<HTMLDivElement>;
menuOptionsStyle?: SelectProps["menuOptionsStyle"];
downshiftReturn:
| SelectAuxProps["downshiftReturn"]
| SelectMultiAuxProps["downshiftReturn"];
}
export const SelectMenu = ({
isOpen,
selectRef,
downshiftReturn,
menuOptionsStyle,
children,
}: SelectDropdownProps) => {
const menuRef = React.useRef<HTMLElement | null>(null);
const overlayPosition = useOverlayPosition({
targetRef: selectRef,
overlayRef: menuRef,
placement: "bottom",
isOpen,
maxHeight: 160,
shouldUpdatePosition: true,
});
const menuProps = downshiftReturn.getMenuProps({
ref: menuRef,
});
return (
<div
className={classNames(
"c__select__menu",
menuOptionsStyle ? "c__select__menu--" + menuOptionsStyle : "",
{
"c__select__menu--opened": isOpen,
},
)}
{...menuProps}
style={{
marginLeft: "-4px",
width: selectRef.current
? selectRef.current.getBoundingClientRect().width - 4
: 0,
...overlayPosition.overlayProps.style,
}}
>
{isOpen && children}
</div>
);
};