From 7cda5a49de6698010ffad57119a929a1e5be91ad Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 6 Jan 2025 19:16:26 +0200 Subject: [PATCH] feat: upstream CustomSelect component Signed-off-by: Mason Hu --- src/components/CustomSelect/CustomSelect.scss | 82 ++++ .../CustomSelect/CustomSelect.stories.tsx | 143 +++++++ .../CustomSelect/CustomSelect.test.tsx | 72 ++++ src/components/CustomSelect/CustomSelect.tsx | 203 ++++++++++ .../CustomSelectDropdown.test.tsx | 154 ++++++++ .../CustomSelectDropdown.tsx | 374 ++++++++++++++++++ .../CustomSelectDropdown/index.ts | 5 + .../__snapshots__/CustomSelect.test.tsx.snap | 46 +++ src/components/CustomSelect/index.ts | 6 + src/index.ts | 6 + 10 files changed, 1091 insertions(+) create mode 100644 src/components/CustomSelect/CustomSelect.scss create mode 100644 src/components/CustomSelect/CustomSelect.stories.tsx create mode 100644 src/components/CustomSelect/CustomSelect.test.tsx create mode 100644 src/components/CustomSelect/CustomSelect.tsx create mode 100644 src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.test.tsx create mode 100644 src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.tsx create mode 100644 src/components/CustomSelect/CustomSelectDropdown/index.ts create mode 100644 src/components/CustomSelect/__snapshots__/CustomSelect.test.tsx.snap create mode 100644 src/components/CustomSelect/index.ts diff --git a/src/components/CustomSelect/CustomSelect.scss b/src/components/CustomSelect/CustomSelect.scss new file mode 100644 index 00000000..15a7eaa3 --- /dev/null +++ b/src/components/CustomSelect/CustomSelect.scss @@ -0,0 +1,82 @@ +@use "sass:map"; +@import "vanilla-framework"; +@include vf-b-placeholders; // Vanilla base placeholders to extend from + +.p-custom-select { + @include vf-b-forms; + + // style copied directly from vanilla-framework for the select element + .p-custom-select__toggle { + @include vf-icon-chevron-themed; + @extend %vf-input-elements; + + // stylelint-disable property-no-vendor-prefix + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + // stylelint-enable property-no-vendor-prefix + background-position: right calc(map-get($grid-margin-widths, default) / 2) + center; + background-repeat: no-repeat; + background-size: map-get($icon-sizes, default); + border-top: none; + box-shadow: none; + min-height: map-get($line-heights, default-text); + padding-right: calc($default-icon-size + 2 * $sph--small); + text-indent: 0.01px; + + &:hover { + cursor: pointer; + } + + // this emulates the highlight effect when the select is focused + // without crowding the content with a border + &.active, + &:focus { + box-shadow: inset 0 0 0 3px $color-focus; + } + + .toggle-label { + display: flow-root; + text-align: left; + width: 100%; + } + } +} + +.p-custom-select__dropdown { + background-color: $colors--theme--background-alt; + box-shadow: $box-shadow--deep; + outline: none; + position: relative; + + .p-custom-select__option { + background-color: $colors--theme--background-alt; + font-weight: $font-weight-regular-text; + padding: $sph--x-small $sph--small; + + &.highlight { + // browser default styling for options when hovered + background-color: #06c; + cursor: pointer; + + // make sure that if an option is highlighted, its text is white for good contrast + * { + color: white; + } + } + } + + .p-custom-select__search { + background-color: $colors--theme--background-alt; + padding: $sph--x-small; + padding-bottom: $sph--small; + position: sticky; + top: 0; + } + + .p-list { + max-height: 30rem; + overflow: auto; + } +} diff --git a/src/components/CustomSelect/CustomSelect.stories.tsx b/src/components/CustomSelect/CustomSelect.stories.tsx new file mode 100644 index 00000000..ae51c877 --- /dev/null +++ b/src/components/CustomSelect/CustomSelect.stories.tsx @@ -0,0 +1,143 @@ +import { Meta, StoryObj } from "@storybook/react/*"; +import CustomSelect from "./CustomSelect"; +import React, { ComponentProps, useState } from "react"; +import { CustomSelectOption } from "."; + +type StoryProps = ComponentProps; + +const generateStandardOptions = (num: number): CustomSelectOption[] => + Array(num) + .fill(null) + .map((_, i) => ({ + value: `option-${i + 1}`, + label: `Option ${i + 1}`, + text: `Option ${i + 1}`, + disabled: false, + })); + +const generateCustomOptions = (): CustomSelectOption[] => { + return [ + { + value: "smile", + label:
😀
, + text: "Smile", + disabled: false, + }, + { + value: "grin", + label:
😁
, + text: "Grin", + disabled: false, + }, + { + value: "cry", + label:
😭
, + text: "Cry", + disabled: false, + }, + { + value: "angry", + label:
😡
, + text: "Angry", + disabled: false, + }, + { + value: "sad", + label:
😢
, + text: "Sad", + disabled: false, + }, + ]; +}; + +const Template = ({ ...props }: StoryProps) => { + const [selected, setSelected] = useState(props.value || ""); + return ( + setSelected(value)} + /> + ); +}; + +const meta: Meta = { + component: CustomSelect, + render: Template, + tags: ["autodocs"], + args: { + name: "customSelect", + label: "Custom Select", + searchable: "auto", + initialPosition: "left", + }, + argTypes: { + searchable: { + options: ["auto", "always", "never"], + control: { + type: "select", + }, + }, + initialPosition: { + options: ["left", "right"], + control: { + type: "select", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * If `label` is of `string` type. You do not have to do anything extra to render it. + */ +export const StandardOptions: Story = { + args: { + options: generateStandardOptions(10), + }, +}; + +/** + * If `label` is of `ReactNode` type. You can render custom content. + * In this case, the `text` property for each option is required and is used for display in the toggle, search and sort functionalities. + */ +export const CustomOptions: Story = { + args: { + options: generateCustomOptions(), + }, +}; + +/** + * For each option, if `disable` is set to `true`, the option will be disabled. + */ +export const DisabledOptions: Story = { + args: { + options: generateStandardOptions(5).map((option, i) => ({ + ...option, + disabled: i % 2 === 0, + })), + }, +}; + +/** + * Search is enabled by default when there are 5 or more options. + */ +export const AutoSearchable: Story = { + args: { + options: generateStandardOptions(5), + searchable: "auto", + }, +}; + +/** + * Search can be enabled manually by setting `searchable` to `always`. + */ +export const ManualSearchable: Story = { + args: { + options: generateStandardOptions(4), + searchable: "always", + }, +}; diff --git a/src/components/CustomSelect/CustomSelect.test.tsx b/src/components/CustomSelect/CustomSelect.test.tsx new file mode 100644 index 00000000..8ddac4bb --- /dev/null +++ b/src/components/CustomSelect/CustomSelect.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import CustomSelect from "./CustomSelect"; +import { CustomSelectOption } from "./CustomSelectDropdown"; +import React from "react"; + +// Global mocks +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +describe("CustomSelect", () => { + const mockOnChange = jest.fn(); + const options: CustomSelectOption[] = [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + ]; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("renders", () => { + render( + , + ); + expect(screen.getByTestId("test-select")).toMatchSnapshot(); + }); + + it("renders the CustomSelectDropdown when clicked", () => { + render( + , + ); + + const toggleButton = screen.getByRole("button"); + fireEvent.click(toggleButton); + + options.forEach((option) => { + expect( + screen.getByRole("option", { name: option.label as string }), + ).toBeInTheDocument(); + }); + }); + + it("calls onChange when an option is selected and closes the dropdown", () => { + render( + , + ); + + const toggleButton = screen.getByRole("button"); + fireEvent.click(toggleButton); + fireEvent.click(screen.getByRole("option", { name: "Option 2" })); + + expect(mockOnChange).toHaveBeenCalledWith("2"); + const option = screen.queryByRole("option", { name: "Option 2" }); + expect(option).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/CustomSelect/CustomSelect.tsx b/src/components/CustomSelect/CustomSelect.tsx new file mode 100644 index 00000000..af32cc36 --- /dev/null +++ b/src/components/CustomSelect/CustomSelect.tsx @@ -0,0 +1,203 @@ +import classNames from "classnames"; +import React, { useEffect, useId, useImperativeHandle, useState } from "react"; +import type { MutableRefObject, ReactNode } from "react"; +import { ClassName, PropsWithSpread } from "types"; +import Field, { FieldProps } from "components/Field"; +import ContextualMenu, { Position } from "components/ContextualMenu"; +import { useListener } from "hooks"; +import CustomSelectDropdown, { + CustomSelectOption, + getOptionText, +} from "./CustomSelectDropdown"; +import "./CustomSelect.scss"; + +export type SelectRef = MutableRefObject< + | { + open: () => void; + close: () => void; + isOpen: boolean; + focus: () => void; + } + | undefined +>; + +export type Props = PropsWithSpread< + FieldProps, + { + // Selected option value + value: string; + // Array of options that the select can choose from. + options: CustomSelectOption[]; + // Function to run when select value changes. + onChange: (value: string) => void; + // id for the select component + id?: string | null; + // Name for the select element + name?: string; + // Whether if the select is disabled + disabled?: boolean; + // Styling for the wrapping Field component + wrapperClassName?: ClassName; + // The styling for the select toggle button + toggleClassName?: ClassName; + // The styling for the select dropdown + dropdownClassName?: string; + // Whether the select is searchable. Option "auto" is the default, the select will be searchable if it has 5 or more options. + searchable?: "auto" | "always" | "never"; + // Whether to focus on the element on initial render. + takeFocus?: boolean; + // Additional component to display above the dropdwon list. + header?: ReactNode; + // Ref for the select component which exposes internal methods and state for programatic control at the parent level. + selectRef?: SelectRef; + // initial position of the dropdown + initialPosition?: Position; + } +>; + +/** + * This is a [React](https://reactjs.org/) component that extends from the Vanilla [Select](https://vanillaframework.io/docs/base/forms#select) element. + * + * The aim of this component is to provide a select component with customisable options and a dropdown menu, whilst maintaining accessibility and usability. + */ +const CustomSelect = ({ + value, + options, + onChange, + id, + name, + disabled, + success, + error, + help, + wrapperClassName, + toggleClassName, + dropdownClassName, + searchable = "auto", + takeFocus, + header, + selectRef, + initialPosition = "left", + ...fieldProps +}: Props): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const validationId = useId(); + const defaultSelectId = useId(); + const selectId = id || defaultSelectId; + const helpId = useId(); + const hasError = !!error; + + // Close the dropdown when the browser tab is hidden + const onBrowserTabHidden = () => { + if (document.visibilityState === "hidden") { + setIsOpen(false); + } + }; + useListener(window, onBrowserTabHidden, "visibilitychange"); + + // Close the dropdown when the browser window loses focus + useListener(window, () => setIsOpen(false), "blur"); + + useImperativeHandle( + selectRef, + () => ({ + open: () => { + setIsOpen(true); + document.getElementById(selectId)?.focus(); + }, + focus: () => document.getElementById(selectId)?.focus(), + close: setIsOpen.bind(null, false), + isOpen: isOpen, + }), + [isOpen, selectId], + ); + + useEffect(() => { + if (takeFocus) { + const toggleButton = document.getElementById(selectId); + toggleButton?.focus(); + } + }, [takeFocus, selectId]); + + const selectedOption = options.find((option) => option.value === value); + + const toggleLabel = ( + + {selectedOption ? getOptionText(selectedOption) : "Select an option"} + + ); + + const handleSelect = (value: string) => { + document.getElementById(selectId)?.focus(); + setIsOpen(false); + onChange(value); + }; + + return ( + + { + // Handle syncing the state when toggling the menu from within the + // contextual menu component e.g. when clicking outside. + if (open !== isOpen) { + setIsOpen(open); + } + }} + toggleProps={{ + id: selectId, + disabled: disabled, + // tabIndex is set to -1 when disabled to prevent keyboard navigation to the select toggle + tabIndex: disabled ? -1 : 0, + }} + className="p-custom-select__wrapper" + dropdownClassName={dropdownClassName} + style={{ width: "100%" }} + autoAdjust + position={initialPosition} + > + {(close: () => void) => ( + { + // When pressing ESC to close the dropdown, we keep focus on the toggle button + close(); + document.getElementById(selectId)?.focus(); + }} + header={header} + toggleId={selectId} + /> + )} + + + ); +}; + +export default CustomSelect; diff --git a/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.test.tsx b/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.test.tsx new file mode 100644 index 00000000..17e514d7 --- /dev/null +++ b/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.test.tsx @@ -0,0 +1,154 @@ +import React, { HTMLProps } from "react"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; +import * as CustomSelectDropdown from "./CustomSelectDropdown"; +import { CustomSelectOption } from "./CustomSelectDropdown"; +import { CustomSelectDropdownProps } from "."; + +// Global mocks +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +jest.mock("hooks", () => ({ + useListener: jest.fn(), +})); + +jest.mock("components/SearchBox", () => { + return React.forwardRef( + ( + props: HTMLProps, + ref: React.ForwardedRef, + ) => , + ); +}); + +describe("CustomSelectDropdown", () => { + const defaultProps: CustomSelectDropdownProps = { + searchable: "always", + name: "test-select", + options: [ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3", disabled: true }, + ] as CustomSelectOption[], + onSelect: jest.fn(), + onClose: jest.fn(), + toggleId: "toggle-id", + }; + + beforeEach(() => { + jest.clearAllMocks(); + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => { + return { + width: 100, + height: 20, + top: 10, + bottom: 30, + left: 0, + right: 100, + x: 0, + y: 10, + toJSON: () => {}, + } as DOMRect; + }); + }); + + it("renders options correctly", () => { + render(); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent("Option 1"); + expect(options[1]).toHaveTextContent("Option 2"); + expect(options[2]).toHaveTextContent("Option 3"); + expect(options[2]).toHaveClass("disabled"); + }); + + it("handles search input correctly", () => { + render(); + + const searchBox = screen.getByTestId("search-box"); + fireEvent.change(searchBox, { target: { value: "Option 2" } }); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent("Option 2"); + }); + + it("calls onSelect when an option is clicked", () => { + render(); + + const option = screen.getByText("Option 1"); + fireEvent.click(option); + + expect(defaultProps.onSelect).toHaveBeenCalledWith("option1"); + }); + + it("handles keyboard navigation and selection", () => { + render(); + + const dropdown = screen.getByRole("combobox"); + + fireEvent.keyDown(dropdown, { key: "ArrowDown" }); + fireEvent.keyDown(dropdown, { key: "ArrowDown" }); + fireEvent.keyDown(dropdown, { key: "Enter" }); + + expect(defaultProps.onSelect).toHaveBeenCalledWith("option2"); + }); + + it("closes on Escape key press", () => { + render(); + + const dropdown = screen.getByRole("combobox"); + + fireEvent.keyDown(dropdown, { key: "Escape" }); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("adjusts dropdown height correctly", async () => { + const mockAdjustHeight = jest.spyOn( + CustomSelectDropdown, + "adjustDropdownHeight", + ); + + render(); + + // Trigger a resize event + fireEvent(window, new Event("resize")); + + await waitFor(() => { + expect(mockAdjustHeight).toHaveBeenCalled(); + }); + }); + + it("renders search box when searchable is true", () => { + render( + , + ); + + const searchBox = screen.getByTestId("search-box"); + expect(searchBox).toBeInTheDocument(); + }); + + it("does not render search box when searchable is never", () => { + render( + , + ); + + const searchBox = screen.queryByTestId("search-box"); + expect(searchBox).not.toBeInTheDocument(); + }); + + it("scrolls to selected option on keyboard navigation", () => { + render(); + + const dropdown = screen.getByRole("combobox"); + + const option = screen.getByText("Option 2"); + const spyScrollIntoView = jest.spyOn(option, "scrollIntoView"); + + fireEvent.keyDown(dropdown, { key: "ArrowDown" }); + fireEvent.keyDown(dropdown, { key: "ArrowDown" }); + + expect(spyScrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.tsx b/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.tsx new file mode 100644 index 00000000..17c7bcbc --- /dev/null +++ b/src/components/CustomSelect/CustomSelectDropdown/CustomSelectDropdown.tsx @@ -0,0 +1,374 @@ +import React, { + FC, + KeyboardEvent, + LiHTMLAttributes, + ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import classnames from "classnames"; +import { useListener } from "hooks"; +import SearchBox from "components/SearchBox"; + +export type CustomSelectOption = LiHTMLAttributes & { + value: string; + label: ReactNode; + // text used for search, sort and display in toggle button + // text must be provided if label is not a string + text?: string; + disabled?: boolean; +}; + +export type Props = { + searchable?: "auto" | "always" | "never"; + name: string; + options: CustomSelectOption[]; + onSelect: (value: string) => void; + onClose: () => void; + header?: ReactNode; + toggleId: string; +}; + +const DROPDOWN_MAX_HEIGHT = 16 * 30; // 30rem with base 16px +const DROPDOWN_MARGIN = 20; + +export const adjustDropdownHeightBelow = (dropdown: HTMLUListElement) => { + const dropdownRect = dropdown.getBoundingClientRect(); + const dropdownHeight = dropdown.offsetHeight; + const viewportHeight = window.visualViewport?.height || window.innerHeight; + + // If the dropdown is cut off at the bottom of the viewport + // adjust the height to fit within the viewport minus fixed margin. + // This usually becomes an issue when the dropdown is at the bottom of the viewport or screen getting smaller. + if (dropdownRect.bottom >= viewportHeight) { + const adjustedHeight = + dropdownHeight - dropdownRect.bottom + viewportHeight - DROPDOWN_MARGIN; + dropdown.style.height = `${adjustedHeight}px`; + dropdown.style.maxHeight = `${adjustedHeight}px`; + return; + } + + // If the dropdown does not have overflow, the dropdown should fit its content. + const hasOverflow = dropdown.scrollHeight > dropdown.clientHeight; + if (!hasOverflow) { + dropdown.style.height = "auto"; + dropdown.style.maxHeight = ""; + return; + } + + // If the dropdown is not cut off at the bottom of the viewport + // adjust the height of the dropdown so that its bottom edge is 20px from the bottom of the viewport + // until the dropdown max height is reached. + const adjustedHeight = Math.min( + viewportHeight - dropdownRect.top - DROPDOWN_MARGIN, + DROPDOWN_MAX_HEIGHT, + ); + dropdown.style.height = `${adjustedHeight}px`; + dropdown.style.maxHeight = `${adjustedHeight}px`; +}; + +export const adjustDropdownHeightAbove = ( + dropdown: HTMLUListElement, + search: HTMLInputElement | null, +) => { + // The search height is subtracted (if necessary) so that no options will be hidden behind the search input. + const searchRect = search?.getBoundingClientRect(); + const searchHeight = searchRect?.height || 0; + const dropdownRect = dropdown.getBoundingClientRect(); + + // If the dropdown does not have overflow, do not adjust. + const hasOverflow = dropdown.scrollHeight > dropdown.clientHeight; + if (!hasOverflow) { + dropdown.style.height = "auto"; + dropdown.style.maxHeight = ""; + return; + } + + // adjust the height of the dropdown so that its top edge is 20px from the top of the viewport. + // until the dropdown max height is reached. + // unlike the case where the dropdown is bellow the toggle, dropdown.bottom represents the available space above the toggle always. + // this makes the calculation simpler since we only need to work with dropdown.bottom regardless if the element is cut off or not. + const adjustedHeight = Math.min( + dropdownRect.bottom - searchHeight - DROPDOWN_MARGIN, + DROPDOWN_MAX_HEIGHT, + ); + dropdown.style.height = `${adjustedHeight}px`; + dropdown.style.maxHeight = `${adjustedHeight}px`; +}; + +export const dropdownIsAbove = (dropdown: HTMLUListElement) => { + const toggle = document.querySelector( + ".p-custom-select__toggle", + ) as HTMLElement; + const dropdownRect = dropdown.getBoundingClientRect(); + const toggleRect = toggle.getBoundingClientRect(); + return toggleRect.top >= dropdownRect.bottom; +}; + +export const adjustDropdownHeight = ( + dropdown: HTMLUListElement | null, + search: HTMLInputElement | null, +) => { + if (!dropdown) { + return; + } + + if (dropdownIsAbove(dropdown)) { + adjustDropdownHeightAbove(dropdown, search); + return; + } + + adjustDropdownHeightBelow(dropdown); +}; + +export const getNearestParentsZIndex = ( + element: HTMLElement | null, +): string => { + if (!document.defaultView || !element) { + return "0"; + } + const zIndex = document.defaultView + .getComputedStyle(element, null) + .getPropertyValue("z-index"); + if (!element.parentElement) { + return zIndex; + } + if (zIndex === "auto" || zIndex === "0" || zIndex === "") { + return getNearestParentsZIndex(element.parentElement); + } + return zIndex; +}; + +export const getOptionText = (option: CustomSelectOption): string => { + if (option.text) { + return option.text; + } + + if (typeof option.label === "string") { + return option.label; + } + + throw new Error( + "CustomSelect: options must have a string label or a text property", + ); +}; + +export const sortOptions = ( + a: CustomSelectOption, + b: CustomSelectOption, +): number => { + // sort options alphabetically + const textA = getOptionText(a) || a.value; + const textB = getOptionText(b) || b.value; + return textA.localeCompare(textB); +}; + +const CustomSelectDropdown: FC = ({ + searchable, + name, + options, + onSelect, + onClose, + header, + toggleId, +}) => { + const [search, setSearch] = useState(""); + // track selected option index for keyboard actions + const [selectedIndex, setSelectedIndex] = useState(0); + // use ref to keep a reference to all option HTML elements so we do not need to make DOM calls later for scrolling + const optionsRef = useRef([]); + const dropdownRef = useRef(null); + const searchRef = useRef(null); + const dropdownListRef = useRef(null); + const isSearchable = + searchable !== "never" && + options.length > 1 && + (searchable === "always" || (searchable === "auto" && options.length >= 5)); + + useEffect(() => { + if (dropdownRef.current) { + const toggle = document.getElementById(toggleId); + + // align width with wrapper toggle width + const toggleWidth = toggle?.getBoundingClientRect()?.width ?? 0; + dropdownRef.current.style.setProperty("min-width", `${toggleWidth}px`); + + // align z-index: when we are in a modal context, we want the dropdown to be above the modal + // apply the nearest parents z-index + 1 + const zIndex = getNearestParentsZIndex(toggle); + if (parseInt(zIndex) > 0) { + dropdownRef.current.parentElement?.style.setProperty( + "z-index", + zIndex + 1, + ); + } + } + + setTimeout(() => { + if (isSearchable) { + searchRef.current?.focus(); + return; + } + + dropdownRef.current?.focus(); + }, 100); + }, [isSearchable, toggleId]); + + const handleResize = () => { + adjustDropdownHeight(dropdownListRef.current, searchRef.current); + }; + + useLayoutEffect(handleResize, []); + useListener(window, handleResize, "resize"); + + // track selected index from key board action and scroll into view if needed + useEffect(() => { + optionsRef.current[selectedIndex]?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [selectedIndex]); + + const filteredOptions = options?.filter((option) => { + if (!search || option.disabled) return true; + const searchText = getOptionText(option) || option.value; + return searchText.toLowerCase().includes(search); + }); + + const getNextOptionIndex = (goingUp: boolean, prevIndex: number) => { + const increment = goingUp ? -1 : 1; + let currIndex = prevIndex + increment; + // skip disabled options for key board action + while (filteredOptions[currIndex] && filteredOptions[currIndex]?.disabled) { + currIndex += increment; + } + + // consider upper bound for navigating down the list + if (increment > 0) { + return currIndex < filteredOptions.length ? currIndex : prevIndex; + } + + // consider lower bound for navigating up the list + return currIndex >= 0 ? currIndex : prevIndex; + }; + + // handle keyboard actions for navigating the select dropdown + const handleKeyDown = (event: KeyboardEvent) => { + const upDownKeys = ["ArrowUp", "ArrowDown"]; + + // prevent default browser actions for up, down, enter and escape keys + // also prevent any other event listeners from being called up the DOM tree + if ([...upDownKeys, "Enter", "Escape", "Tab"].includes(event.key)) { + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + } + + if (upDownKeys.includes(event.key)) { + setSelectedIndex((prevIndex) => { + const goingUp = event.key === "ArrowUp"; + return getNextOptionIndex(goingUp, prevIndex); + }); + } + + if (event.key === "Enter" && filteredOptions[selectedIndex]) { + onSelect(filteredOptions[selectedIndex].value); + } + + if (event.key === "Escape" || event.key === "Tab") { + onClose(); + } + }; + + const handleSearch = (value: string) => { + setSearch(value.toLowerCase()); + // reset selected index when search text changes + setSelectedIndex(0); + optionsRef.current = []; + }; + + const handleSelect = (option: CustomSelectOption) => { + if (option.disabled) { + return; + } + + onSelect(option.value); + }; + + const optionItems = filteredOptions.map((option, idx) => { + return ( +
  • handleSelect(option)} + className={classnames( + "p-list__item", + "p-custom-select__option", + "u-truncate", + { + disabled: option.disabled, + highlight: idx === selectedIndex && !option.disabled, + }, + )} + // adding option elements to a ref array makes it easier to scroll the element later + // else we'd have to make a DOM call to find the element based on some identifier + ref={(el) => { + if (!el) return; + optionsRef.current[idx] = el; + }} + role="option" + onMouseMove={() => setSelectedIndex(idx)} + > + + {option.label} + +
  • + ); + }); + + return ( +
    { + // when custom select is used in a modal, which is a portal, a dropdown click + // should not close the modal itself, so we stop the event right here. + e.stopPropagation(); + }} + > + {isSearchable && ( +
    + +
    + )} + {header} +
      + {optionItems} +
    +
    + ); +}; + +export default CustomSelectDropdown; diff --git a/src/components/CustomSelect/CustomSelectDropdown/index.ts b/src/components/CustomSelect/CustomSelectDropdown/index.ts new file mode 100644 index 00000000..59349b15 --- /dev/null +++ b/src/components/CustomSelect/CustomSelectDropdown/index.ts @@ -0,0 +1,5 @@ +export { default, getOptionText } from "./CustomSelectDropdown"; +export type { + CustomSelectOption, + Props as CustomSelectDropdownProps, +} from "./CustomSelectDropdown"; diff --git a/src/components/CustomSelect/__snapshots__/CustomSelect.test.tsx.snap b/src/components/CustomSelect/__snapshots__/CustomSelect.test.tsx.snap new file mode 100644 index 00000000..0ad1c57a --- /dev/null +++ b/src/components/CustomSelect/__snapshots__/CustomSelect.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomSelect renders 1`] = ` +
    + +
    +
    + + + +
    +
    +
    +`; diff --git a/src/components/CustomSelect/index.ts b/src/components/CustomSelect/index.ts new file mode 100644 index 00000000..ca2fe9a2 --- /dev/null +++ b/src/components/CustomSelect/index.ts @@ -0,0 +1,6 @@ +export { default } from "./CustomSelect"; +export type { Props as CustomSelectProps } from "./CustomSelect"; +export type { + CustomSelectDropdownProps, + CustomSelectOption, +} from "./CustomSelectDropdown"; diff --git a/src/index.ts b/src/index.ts index 7dffdbc9..bc944456 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,7 @@ export { default as Textarea } from "./components/Textarea"; export { default as Tooltip } from "./components/Tooltip"; export { default as TablePagination } from "./components/TablePagination"; export { default as TablePaginationControls } from "./components/TablePagination/TablePaginationControls"; +export { default as CustomSelect } from "./components/CustomSelect"; export type { AccordionProps } from "./components/Accordion"; export type { ActionButtonProps } from "./components/ActionButton"; @@ -172,6 +173,11 @@ export type { TabsProps } from "./components/Tabs"; export type { TextareaProps } from "./components/Textarea"; export type { TooltipProps } from "./components/Tooltip"; export type { TablePaginationProps } from "./components/TablePagination"; +export type { + CustomSelectProps, + CustomSelectDropdownProps, + CustomSelectOption, +} from "./components/CustomSelect"; export { useOnClickOutside,