From 5b608c9fb00245dec61a587fd2246adc28be0cce Mon Sep 17 00:00:00 2001 From: Haider Alshamma Date: Thu, 9 May 2024 15:21:07 -0400 Subject: [PATCH 1/8] wip --- .eslintrc | 1 + src/Select/Select.tsx | 226 ++++++++++++------------------------------ 2 files changed, 67 insertions(+), 160 deletions(-) diff --git a/.eslintrc b/.eslintrc index a1d9e0450..18adea489 100644 --- a/.eslintrc +++ b/.eslintrc @@ -28,6 +28,7 @@ } ], "react/display-name": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", // rules below are to override nulogy config "react/jsx-filename-extension": [ 1, diff --git a/src/Select/Select.tsx b/src/Select/Select.tsx index db67d1297..6df598995 100644 --- a/src/Select/Select.tsx +++ b/src/Select/Select.tsx @@ -1,104 +1,54 @@ -import React, { ReactNode } from "react"; -import propTypes from "@styled-system/prop-types"; +import React, { forwardRef, ReactNode, MutableRefObject } from "react"; +import Select from "react-select/base"; import WindowedSelect, { GroupBase } from "react-windowed-select"; -import type { MenuPlacement, MenuPosition, Props as SelectProps } from "react-select"; +import type { Props as SelectProps } from "react-select"; import { useTranslation } from "react-i18next"; -import { ThemeContext } from "styled-components"; +import { useTheme } from "styled-components"; +import propTypes from "@styled-system/prop-types"; import { Field } from "../Form"; import { MaybeFieldLabel } from "../FieldLabel"; import { InlineValidation } from "../Validation"; +import customStyles from "../Select/customReactSelectStyles"; import { getSubset } from "../utils/subset"; +import { SelectControl } from "../AsyncSelect/AsyncSelectComponents"; import { ComponentSize, useComponentSize } from "../NDSProvider/ComponentSizeContext"; -import customStyles from "./customReactSelectStyles"; -import { SelectOption } from "./SelectOption"; - import { - SelectControl, SelectMultiValue, SelectClearIndicator, SelectContainer, - SelectMenu, SelectInput, SelectDropdownIndicator, + SelectMenu, } from "./SelectComponents"; +import { SelectOption } from "./SelectOption"; -type ReactSelectStateManager = { - state: { - value: any[]; - }; - setState: (prevState: any) => void; - blur: () => void; -}; - -// NOTE: We recreate these props as upstream doesn't export them. Note also that -// we have a default value for windowThreshold, therefore this param is optional. -interface WindowedSelectProps extends SelectProps { - windowThreshold?: number; -} - -interface NDSOptionType { - label: string; - value: unknown; +interface WindowedSelectProps> + extends SelectProps { + windowThreshold: number; } -interface CustomProps> { - autocomplete?: SelectProps["isSearchable"]; +type CustomProps> = { + autocomplete?: WindowedSelectProps["isSearchable"]; labelText?: string; + size?: ComponentSize; requirementText?: string; helpText?: ReactNode; - disabled?: SelectProps["isDisabled"]; + disabled?: WindowedSelectProps["isDisabled"]; errorMessage?: string; errorList?: string[]; - initialIsOpen?: SelectProps["defaultMenuIsOpen"]; - multiselect?: SelectProps["isMulti"]; + initialIsOpen?: WindowedSelectProps["defaultMenuIsOpen"]; + multiselect?: WindowedSelectProps["isMulti"]; maxHeight?: string; - size?: ComponentSize; - error?: boolean; - options: NDSOptionType[]; - onChange?: (newValue: unknown) => void; - [key: string]: any; -} + defaultValue?: WindowedSelectProps["defaultInputValue"]; +}; export type NDSSelectProps> = Omit< - WindowedSelectProps, - "isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue" | "options" | "onChange" + WindowedSelectProps, + "isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue" > & CustomProps; -export const SelectDefaultProps = { - autocomplete: true, - disabled: undefined, - defaultValue: undefined, - error: undefined, - errorMessage: undefined, - errorList: undefined, - labelText: undefined, - helpText: undefined, - noOptionsMessage: undefined, - requirementText: undefined, - id: undefined, - initialIsOpen: undefined, - maxHeight: "248px", - menuPosition: "absolute" as MenuPosition, - menuPlacement: "bottom" as MenuPlacement, - multiselect: false, - name: undefined, - onBlur: undefined, - onChange: undefined, - placeholder: undefined, - required: false, - value: undefined, - className: undefined, - classNamePrefix: "ndsSelect", // a prefix is required in react-select top put classes on all buttons to apply style overrides - menuIsOpen: undefined, - onMenuOpen: undefined, - onMenuClose: undefined, - onInputChange: undefined, - components: undefined, - closeMenuOnSelect: true, -}; - -const ReactSelect = React.forwardRef( +const NDSSelect = forwardRef( >( { size, @@ -111,74 +61,81 @@ const ReactSelect = React.forwardRef( disabled, errorMessage, errorList, - error = !!(errorMessage || errorList), id, initialIsOpen, maxHeight, - multiselect, + isClearable, onChange, + multiselect, placeholder, value, defaultValue, + noOptionsMessage, + menuPosition, + name, + className, + classNamePrefix, + onBlur, + menuIsOpen, + onMenuOpen, + onMenuClose, + onInputChange, components, "aria-label": ariaLabel, - windowThreshold = 300, + windowThreshold = 100, ...props }: NDSSelectProps, - ref + ref: + | ((instance: Select | null) => void) + | MutableRefObject | null> + | null ) => { const { t } = useTranslation(); - const themeContext = React.useContext(ThemeContext); + const theme = useTheme(); const spaceProps = getSubset(props, propTypes.space); - const reactSelectRef = React.useRef(null); - const optionsRef = React.useRef(options); + const error = !!(errorMessage || errorList); const componentSize = useComponentSize(size); - React.useEffect(() => { - checkOptionsAreValid(options); - optionsRef.current = options; - }, [options]); - - React.useEffect(() => { - if (ref) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - ref.current = reactSelectRef.current; - } - }, [reactSelectRef, ref]); - return ( windowThreshold, })} isDisabled={disabled} isSearchable={autocomplete} aria-required={required} + required={required} aria-invalid={error} defaultMenuIsOpen={initialIsOpen} inputId={id} - onChange={(option) => { - if (!onChange) return; - - const value = extractValue(option as NDSOptionType | NDSOptionType[], multiselect); - onChange(value); - }} - defaultValue={getReactSelectValue(options, defaultValue)} - value={getReactSelectValue(options, value)} + onBlur={onBlur} + onChange={onChange} + name={name} isMulti={multiselect} + menuIsOpen={menuIsOpen} + onMenuOpen={onMenuOpen} + onMenuClose={onMenuClose} + menuPosition={menuPosition} + onInputChange={onInputChange} components={{ - Option: (props) => , + SelectOption: (props) => ( + + {props.children} + + ), Control: SelectControl, MultiValue: SelectMultiValue, ClearIndicator: SelectClearIndicator, @@ -189,8 +146,7 @@ const ReactSelect = React.forwardRef( ...components, }} aria-label={ariaLabel} - options={options} - {...props} + isClearable={isClearable} /> @@ -199,54 +155,4 @@ const ReactSelect = React.forwardRef( } ); -const checkOptionsAreValid = (options: NDSOptionType[]) => { - if (options && process.env.NODE_ENV === "development") { - const uniq = (a: unknown[]) => Array.from(new Set(a)); - - const uniqueValues = uniq(options.map(({ value }) => (value === null ? "_null_" : value))); - - if (uniqueValues.length < options.length) { - console.warn("NDS: The options prop passed to Select must have unique values for each option", options); - } - } -}; - -export const getOption = (options: NDSOptionType[], value: unknown) => { - // allows an option with a null value to be matched - if (options.length > 0 && value !== undefined) { - const optionWithMatchingValue = options.find((o) => o.value === value); - return optionWithMatchingValue || null; - } - return value; -}; - -const getReactSelectValue = (options: NDSOptionType[], input: unknown) => { - if (Array.isArray(input)) { - return input.map((i) => getOption(options, i)); - } - return getOption(options, input); -}; - -function extractValue(options: NDSOptionType[] | NDSOptionType, isMulti: boolean) { - if (Array.isArray(options)) { - if (isMulti) { - return options && options.length ? options.map((o) => o.value) : []; - } else { - throw new Error("UNEXPECTED ERROR: don't forget to enable isMulti"); - } - } - - if (options === null) { - return options; - } else { - return options.value; - } -} - -ReactSelect.defaultProps = { - ...SelectDefaultProps, - windowThreshold: 300, - filterOption: undefined, -}; - -export default ReactSelect; +export default NDSSelect; From 750ca4f863132f5da0962b1a75d5bfb31273bd9a Mon Sep 17 00:00:00 2001 From: Haider Alshamma Date: Thu, 9 May 2024 15:38:09 -0400 Subject: [PATCH 2/8] fix: remove all generics from NDSSelect react-windowed-select does not forward > downstream to the react-select resulting in mismatches between the expected types (defaulting to unknown) and the actual types inferred by react-select. This caused a problem for both the onChange and the custom components. --- src/Select/Select.tsx | 52 ++++++++++++++++++--------------- src/Select/SelectComponents.tsx | 26 +++++++++++------ src/Select/SelectOption.tsx | 8 ++--- 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/Select/Select.tsx b/src/Select/Select.tsx index 6df598995..dd3122103 100644 --- a/src/Select/Select.tsx +++ b/src/Select/Select.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, ReactNode, MutableRefObject } from "react"; import Select from "react-select/base"; -import WindowedSelect, { GroupBase } from "react-windowed-select"; +import WindowedSelect from "react-windowed-select"; import type { Props as SelectProps } from "react-select"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; @@ -10,7 +10,6 @@ import { MaybeFieldLabel } from "../FieldLabel"; import { InlineValidation } from "../Validation"; import customStyles from "../Select/customReactSelectStyles"; import { getSubset } from "../utils/subset"; -import { SelectControl } from "../AsyncSelect/AsyncSelectComponents"; import { ComponentSize, useComponentSize } from "../NDSProvider/ComponentSizeContext"; import { SelectMultiValue, @@ -19,37 +18,39 @@ import { SelectInput, SelectDropdownIndicator, SelectMenu, + SelectControl, } from "./SelectComponents"; import { SelectOption } from "./SelectOption"; +import { checkOptionsAreValid, getReactSelectValue } from "./lib"; -interface WindowedSelectProps> - extends SelectProps { +interface WindowedSelectProps extends SelectProps { windowThreshold: number; } -type CustomProps> = { - autocomplete?: WindowedSelectProps["isSearchable"]; +type CustomProps = { + autocomplete?: WindowedSelectProps["isSearchable"]; labelText?: string; size?: ComponentSize; requirementText?: string; helpText?: ReactNode; - disabled?: WindowedSelectProps["isDisabled"]; + disabled?: WindowedSelectProps["isDisabled"]; errorMessage?: string; errorList?: string[]; - initialIsOpen?: WindowedSelectProps["defaultMenuIsOpen"]; - multiselect?: WindowedSelectProps["isMulti"]; + initialIsOpen?: WindowedSelectProps["defaultMenuIsOpen"]; + multiselect?: WindowedSelectProps["isMulti"]; maxHeight?: string; - defaultValue?: WindowedSelectProps["defaultInputValue"]; + defaultValue?: WindowedSelectProps["defaultInputValue"]; + value?: string; }; -export type NDSSelectProps> = Omit< - WindowedSelectProps, - "isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue" +export type NDSSelectProps = Omit< + WindowedSelectProps, + "isSearchable" | "isDisabled" | "isMulti" | "defaultMenuIsOpen" | "defaultInputValue" | "value" > & - CustomProps; + CustomProps; const NDSSelect = forwardRef( - >( + ( { size, autocomplete, @@ -84,29 +85,33 @@ const NDSSelect = forwardRef( "aria-label": ariaLabel, windowThreshold = 100, ...props - }: NDSSelectProps, - ref: - | ((instance: Select | null) => void) - | MutableRefObject | null> - | null + }: NDSSelectProps, + ref: ((instance: Select | null) => void) | MutableRefObject; }; -function SelectWithState>( - props: NDSSelectProps -) { - const [selectedValue, setSelectedValue] = useState(""); +type SelectWithStateProps = NDSSelectProps & { + selectedValue: string; +}; + +class SelectWithState extends React.Component }> { + constructor(props) { + super(props); - function handleChange(selectedValue) { - setSelectedValue(selectedValue); + this.state = { selectedValue: "" }; + this.handleChange = this.handleChange.bind(this); + this.clearSelection = this.clearSelection.bind(this); } - function clearSelection() { - setSelectedValue(""); + handleChange(selectedValue: PropsValue) { + this.setState({ selectedValue }); } - return ( - - + + + ); + } } export default { @@ -120,7 +128,6 @@ export const _Select = () => ( closeMenuOnSelect={boolean("closeMenuOnSelect", true)} disabled={boolean("disabled", false)} defaultValue={select("defaultValue", [undefined, ...options.map(({ value }) => value)], undefined)} - error={boolean("error", false)} errorMessage={text("errorMessage", "")} labelText={text("labelText", "Inventory Status")} helpText={text("helpText", undefined)} @@ -149,7 +156,7 @@ export const WithDifferentSizes = () => { Standard { /> "No options"} placeholder="Please select inventory status" @@ -192,7 +199,7 @@ export const WithDifferentSizes = () => { /> "No options"} placeholder="Please select inventory status" @@ -263,7 +270,12 @@ WithAnOptionSelected.story = { }; export const WithState = () => ( - + ); WithState.story = { @@ -557,27 +569,27 @@ export const WithFetchedOptions = () => ( ); -export const WithCustomOptionComponent = () => { - const Indicator = styled.span(() => ({ - borderRadius: "25%", - background: "green", - lineHeight: "0", - display: "inline-block", - width: "10px", - height: "10px", - marginRight: "5px", - })); - - const CustomOption = ({ children, ...props }: OptionProps) => { - const newChildren = ( - <> - - {children} - - ); - return {newChildren}; - }; +const Indicator = styled.span(() => ({ + borderRadius: "25%", + background: "green", + lineHeight: "0", + display: "inline-block", + width: "10px", + height: "10px", + marginRight: "5px", +})); + +const CustomOption = ({ children, ...props }: SelectOptionProps) => { + const newChildren = ( + <> + + {children} + + ); + return {newChildren}; +}; +export const WithCustomOptionComponent = () => { return ( <> @@ -636,23 +648,3 @@ export const WithTopMenuPlacement = () => { UsingRefToControlFocus.story = { name: "using ref to control focus", }; - -const CustomOption = (props) => { - return {props.selectProps.myCustomProp}; -}; - -const CustomSingleValue = ({ innerProps, ...props }) => { - return
{props.selectProps.myCustomProp}
; -}; - -export const WithCustomProps = () => { - return ( - <> - | null + }: NDSSelectProps, + ref: + | ((instance: Select | null) => void) + | MutableRefObject | null> + | null ) => { const { t } = useTranslation(); const theme = useTheme(); const spaceProps = getSubset(props, propTypes.space); const error = !!(errorMessage || errorList); const optionsRef = React.useRef(options); - const componentSize = useComponentSize(size); React.useEffect(() => { @@ -104,38 +99,34 @@ const NDSSelect = forwardRef( return ( - windowThreshold, - })} options={options} - isDisabled={disabled} - isSearchable={autocomplete} + onChange={(newValue) => { + if (!onChange) return; + + const value = extractValue(newValue, multiselect); + onChange(value); + }} + // windowThreshold={windowThreshold} + placeholder={placeholder || t("start typing")} aria-required={required} required={required} aria-invalid={error} - defaultMenuIsOpen={initialIsOpen} inputId={id} - onBlur={onBlur} - onChange={onChange} - name={name} - isMulti={multiselect} - menuIsOpen={menuIsOpen} - onMenuOpen={onMenuOpen} - onMenuClose={onMenuClose} - menuPosition={menuPosition} - onInputChange={onInputChange} + styles={customStyles({ + theme: theme, + error, + maxHeight, + size: componentSize, + windowed: options.length > windowThreshold, + })} components={{ Option: (props) => ( @@ -151,8 +142,7 @@ const NDSSelect = forwardRef( Input: SelectInput, ...components, }} - aria-label={ariaLabel} - isClearable={isClearable} + {...props} /> diff --git a/src/Select/SelectComponents.tsx b/src/Select/SelectComponents.tsx index 13fa5ebe3..ef7f1a8ae 100644 --- a/src/Select/SelectComponents.tsx +++ b/src/Select/SelectComponents.tsx @@ -8,9 +8,10 @@ import { MenuProps, MultiValueProps, components as selectComponents, -} from "react-windowed-select"; +} from "react-select"; +import { NDSOption } from "./Select"; -export const SelectControl = (props: ControlProps) => { +export function SelectControl(props: ControlProps) { const { isFocused } = props; return (
@@ -18,55 +19,61 @@ export const SelectControl = (props: ControlProps) => { className={isFocused ? "nds-select--is-focused" : null} isFocused={isFocused} {...props} - /> + > + {props.children} +
); -}; +} -export const SelectMultiValue = (props: MultiValueProps) => { +export function SelectMultiValue(props: MultiValueProps) { return (
- + {props.children}
); -}; +} -export const SelectClearIndicator = (props: ClearIndicatorProps) => { +export function SelectClearIndicator( + props: ClearIndicatorProps +) { return (
); -}; +} -export const SelectDropdownIndicator = (props: DropdownIndicatorProps) => { +export function SelectDropdownIndicator( + props: DropdownIndicatorProps +) { return (
); -}; +} -export const SelectMenu = (props: MenuProps) => { +export function SelectMenu(props: MenuProps) { return (
- + {props.children}
); -}; +} -export const SelectContainer = (props: ContainerProps) => { +export function SelectContainer(props: ContainerProps) { return (
- + {props.children}
); -}; +} -export const SelectInput = (props: InputProps) => { +export function SelectInput(props: InputProps) { return (
- + {props.children}
); -}; +} diff --git a/src/Select/SelectOption.tsx b/src/Select/SelectOption.tsx index 0c8ed238d..b233d34f1 100644 --- a/src/Select/SelectOption.tsx +++ b/src/Select/SelectOption.tsx @@ -1,10 +1,11 @@ import React from "react"; import styled from "styled-components"; -import { components, OptionProps } from "react-windowed-select"; +import { components, OptionProps } from "react-select"; import { typography } from "styled-system"; import { subPx } from "../utils"; import { ComponentSize, useComponentSize } from "../NDSProvider/ComponentSizeContext"; import { stylesForSize } from "./customReactSelectStyles"; +import { NDSOption } from "./Select"; type StyledOptionProps = { isSelected: boolean; @@ -51,7 +52,7 @@ export const StyledOption = styled.div( ) ); -interface SelectOptionProps extends OptionProps { +export interface SelectOptionProps extends OptionProps { size?: ComponentSize; } diff --git a/src/Select/lib.ts b/src/Select/lib.ts new file mode 100644 index 000000000..2c9ed79c5 --- /dev/null +++ b/src/Select/lib.ts @@ -0,0 +1,52 @@ +import { Options, PropsValue } from "react-select"; +import { NDSOption, NDSOptionValue } from "./Select"; + +export function checkOptionsAreValid(options: Options) { + if (options && process.env.NODE_ENV === "development") { + const uniq = (a: unknown[]) => Array.from(new Set(a)); + + const uniqueValues = uniq(options.map(({ value }) => (value === null ? "_null_" : value))); + + if (uniqueValues.length < options.length) { + console.warn("NDS: The options prop passed to Select must have unique values for each option", options); + } + } +} + +export function getOption(options: Options, value: PropsValue) { + if (Array.isArray(value)) { + return value.map((o) => getOption(options, o)); + } + + if (options.length > 0 && value !== undefined) { + return options.find((o) => o.value === value) ?? null; + } + + return value; +} + +export function getReactSelectValue(options: Options, input: PropsValue) { + if (Array.isArray(input)) { + return input.map((i) => getOption(options, i)); + } + return getOption(options, input); +} + +export function extractValue( + options: Options | NDSOption, + isMulti: boolean +): NDSOptionValue[] | NDSOptionValue { + if (options === null) { + return null; + } + + if (!Array.isArray(options)) { + return (options as NDSOption).value; + } + + if (isMulti) { + return options && options.length ? options.map((o) => o.value) : []; + } + + throw new Error("UNEXPECTED ERROR: don't forget to enable isMulti"); +} diff --git a/yarn.lock b/yarn.lock index d744962da..e5414de47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14069,7 +14069,7 @@ memfs@^3.1.2: dependencies: fs-monkey "1.0.3" -"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0: +memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -16987,7 +16987,7 @@ react-select@^3.2.0: react-input-autosize "^3.0.0" react-transition-group "^4.3.0" -react-select@^5.2.2, react-select@^5.8.0: +react-select@^5.8.0: version "5.8.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.0.tgz#bd5c467a4df223f079dd720be9498076a3f085b5" integrity sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA== @@ -17070,22 +17070,6 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" -react-window@^1.8.6: - version "1.8.10" - resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" - integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== - dependencies: - "@babel/runtime" "^7.0.0" - memoize-one ">=3.1.1 <6" - -react-windowed-select@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/react-windowed-select/-/react-windowed-select-5.2.0.tgz#367916ded1ed492ab8319b614681d914ace54388" - integrity sha512-NOdkFj3GKjIdSQicPITjvIMzu2y75JIoCAB1CPiMiRfbLCQRwaf5rH6n4EdP38KpDrv6R2Vt8bVo3PUoB+RspA== - dependencies: - react-select "^5.2.2" - react-window "^1.8.6" - react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" From 115122cb5136fe901f758f74883d99c3df2d4538 Mon Sep 17 00:00:00 2001 From: Haider Alshamma Date: Wed, 15 May 2024 17:11:03 -0400 Subject: [PATCH 4/8] feat: Roll our own windowing Select --- package.json | 2 + src/Select/MenuList.tsx | 171 ++++++++++++++++++++++++++++ src/Select/Select.spec.tsx | 10 +- src/Select/Select.story.fixture.tsx | 76 +++++++++++++ src/Select/Select.story.tsx | 117 ++++--------------- src/Select/Select.tsx | 11 +- src/Select/SelectComponents.tsx | 69 +++++++---- src/Select/SelectOption.tsx | 17 ++- src/Select/lib.ts | 60 ++++++++++ yarn.lock | 17 ++- 10 files changed, 416 insertions(+), 134 deletions(-) create mode 100644 src/Select/MenuList.tsx create mode 100644 src/Select/Select.story.fixture.tsx diff --git a/package.json b/package.json index 31b09f4f5..b56943a67 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@nulogy/tokens": "^5.4.0", "@styled-system/prop-types": "^5.1.4", "@styled-system/theme-get": "^5.1.2", + "@types/react-window": "^1.8.8", "@types/styled-system": "5.1.22", "body-scroll-lock": "^3.1.5", "core-js": "3", @@ -162,6 +163,7 @@ "react-popper-2": "npm:react-popper@2.2.4", "react-resize-detector": "^9.1.0", "react-select": "^5.8.0", + "react-window": "^1.8.10", "smoothscroll-polyfill": "^0.4.4", "styled-system": "^5.1.4" }, diff --git a/src/Select/MenuList.tsx b/src/Select/MenuList.tsx new file mode 100644 index 000000000..b4a36c06b --- /dev/null +++ b/src/Select/MenuList.tsx @@ -0,0 +1,171 @@ +/* + Copied as is from: https://github.com/jacobworrel/react-windowed-select/blob/master/src/MenuList.tsx +*/ + +import * as React from "react"; +import { ListChildComponentProps, VariableSizeList as List } from "react-window"; +import { OptionProps, GroupBase } from "react-select"; +import { createGetHeight, flattenGroupedChildren, getCurrentIndex } from "./lib"; + +interface Style extends React.CSSProperties { + top: number; +} + +interface ListChildProps extends ListChildComponentProps { + style: Style; +} + +interface OptionTypeBase { + [key: string]: any; +} + +function MenuList(props) { + const children = React.useMemo(() => { + const children = React.Children.toArray(props.children); + + const head = children[0] || {}; + + if (React.isValidElement>>(head)) { + const { props: { data: { options = [] } = {} } = {} } = head; + const groupedChildrenLength = options.length; + const isGrouped = groupedChildrenLength > 0; + const flattenedChildren = isGrouped && flattenGroupedChildren(children); + + return isGrouped ? flattenedChildren : children; + } else { + return []; + } + }, [props.children]); + + const { getStyles } = props; + const groupHeadingStyles = getStyles("groupHeading", props); + const loadingMsgStyles = getStyles("loadingMessage", props); + const noOptionsMsgStyles = getStyles("noOptionsMessage", props); + const optionStyles = getStyles("option", props); + const getHeight = createGetHeight({ + groupHeadingStyles, + noOptionsMsgStyles, + optionStyles, + loadingMsgStyles, + }); + + const heights = React.useMemo(() => children.map(getHeight), [children]); + const currentIndex = React.useMemo(() => getCurrentIndex(children), [children]); + + const itemCount = children.length; + + const [measuredHeights, setMeasuredHeights] = React.useState({}); + + // calc menu height + const { maxHeight, paddingBottom = 0, paddingTop = 0, ...menuListStyle } = getStyles("menuList", props); + const totalHeight = React.useMemo(() => { + return heights.reduce((sum, height, idx) => { + if (measuredHeights[idx]) { + return sum + measuredHeights[idx]; + } else { + return sum + height; + } + }, 0); + }, [heights, measuredHeights]); + const totalMenuHeight = totalHeight + paddingBottom + paddingTop; + const menuHeight = Math.min(maxHeight, totalMenuHeight); + const estimatedItemSize = Math.floor(totalHeight / itemCount); + + const { innerRef, selectProps } = props; + + const { classNamePrefix, isMulti } = selectProps || {}; + const list = React.useRef(null); + + React.useEffect(() => { + setMeasuredHeights({}); + }, [props.children]); + + // method to pass to inner item to set this items outer height + const setMeasuredHeight = ({ index, measuredHeight }) => { + if (measuredHeights[index] !== undefined && measuredHeights[index] === measuredHeight) { + return; + } + + setMeasuredHeights((measuredHeights) => ({ + ...measuredHeights, + [index]: measuredHeight, + })); + + // this forces the list to rerender items after the item positions resizing + if (list.current) { + list.current.resetAfterIndex(index); + } + }; + + React.useEffect(() => { + /** + * enables scrolling on key down arrow + */ + if (currentIndex >= 0 && list.current !== null) { + list.current.scrollToItem(currentIndex); + } + }, [currentIndex, children, list]); + + return ( + ( +
+ ))} + height={menuHeight} + width="100%" + itemCount={itemCount} + itemData={children} + itemSize={(index) => measuredHeights[index] || heights[index]} + > + {/*@ts-ignore*/} + {({ data, index, style }: ListChildProps) => { + return ( +
+ +
+ ); + }} + + ); +} + +function MenuItem({ data, index, setMeasuredHeight }) { + const ref = React.useRef(null); + + // using useLayoutEffect prevents bounciness of options of re-renders + React.useLayoutEffect(() => { + if (ref.current) { + const measuredHeight = ref.current.getBoundingClientRect().height; + + setMeasuredHeight({ index, measuredHeight }); + } + }, [ref.current]); + + return ( +
+ {data} +
+ ); +} +export default MenuList; diff --git a/src/Select/Select.spec.tsx b/src/Select/Select.spec.tsx index 74196891d..4f74d04ce 100644 --- a/src/Select/Select.spec.tsx +++ b/src/Select/Select.spec.tsx @@ -2,7 +2,7 @@ import React from "react"; import { fireEvent } from "@testing-library/react"; import { renderWithNDSProvider } from "../NDSProvider/renderWithNDSProvider.spec-utils"; import { selectOption } from "./Select.spec-utils"; -import { UsingRefToControlFocus, WithCustomProps, WithMultiselect, WithState } from "./Select.story"; +import { UsingRefToControlFocus, WithMultiselect, WithState } from "./Select.story"; import { Select } from "."; describe("select", () => { @@ -38,14 +38,6 @@ describe("select", () => { expect(container).toHaveTextContent("Three"); }); - it("passes along the custom props to custom components", () => { - const { container, queryByText } = renderWithNDSProvider(); - - selectOption("custom prop value", container, queryByText); - - expect(container).toHaveTextContent("custom prop value"); - }); - describe("with state", () => { it("clears the selected option", () => { const { container, queryByText } = renderWithNDSProvider(); diff --git a/src/Select/Select.story.fixture.tsx b/src/Select/Select.story.fixture.tsx new file mode 100644 index 000000000..184087666 --- /dev/null +++ b/src/Select/Select.story.fixture.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import styled from "styled-components"; +import { SelectOption, SelectOptionProps } from "./SelectOption"; +import { NDSOption } from "./Select"; + +export const errorList = ["Error message 1", "Error message 2"]; + +export const options: NDSOption[] = [ + { value: "accepted", label: "Accepted" }, + { value: "assigned", label: "Assigned to a line" }, + { value: "hold", label: "On hold" }, + { value: "rejected", label: "Rejected" }, + { value: "open", label: "Open" }, + { value: "progress", label: "In progress" }, + { value: "quarantine", label: "In quarantine" }, +]; + +export const partnerCompanyName = [ + { value: "2", label: "PCN2 12387387484895884957848576867587685780" }, + { value: "4", label: "PCN4 12387387484895884957848576867587685780" }, + { value: "1", label: "PCN1 12387387484895884957848576867587685780" }, + { value: "9", label: "PCN9 12387387484895884957848576867587685780" }, + { value: "7", label: "PCN7 12387387484895884957848576867587685780" }, + { value: "6", label: "PCN6 12387387484895884957848576867587685780" }, + { value: "3", label: "PCN3 12387387484895884957848576867587685780e" }, +]; + +export const wrappingOptions = [ + { + value: "onestring", + label: + "Onelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstring", + }, + { + value: "manywords", + label: + "Many words many words many words many words many words many words many words many words many words many words many words many words many words", + }, +]; + +export const PCNList = [ + { value: "2", label: "PCN2" }, + { value: "4", label: "PCN4" }, + { value: "1", label: "PCN1" }, + { value: "9", label: "PCN9" }, +]; + +export const getPhotos = async () => { + // returns 5000 items + const data = await fetch("https://jsonplaceholder.typicode.com/photos"); + const json = await data.json(); + return json.map(({ title, id }) => ({ + label: title, + value: id, + })); +}; + +const Indicator = styled.span(() => ({ + borderRadius: "25%", + background: "green", + lineHeight: "0", + display: "inline-block", + width: "10px", + height: "10px", + marginRight: "5px", +})); + +export const CustomOption = ({ children, ...props }: SelectOptionProps) => { + const newChildren = ( + <> + + {children} + + ); + return {newChildren}; +}; diff --git a/src/Select/Select.story.tsx b/src/Select/Select.story.tsx index 4920cbc5a..c5c318cfd 100644 --- a/src/Select/Select.story.tsx +++ b/src/Select/Select.story.tsx @@ -1,65 +1,20 @@ import React, { useEffect, useRef, useState } from "react"; import { action } from "@storybook/addon-actions"; -import styled from "styled-components"; import { boolean, select, text } from "@storybook/addon-knobs"; import { PropsValue } from "react-select"; import { Box } from "../Box"; import { Flex } from "../Flex"; -import { Button, Heading2, SelectOption } from "../index"; -import Select, { NDSOption, NDSOptionValue, NDSSelectProps } from "./Select"; -import { SelectOptionProps } from "./SelectOption"; - -const errorList = ["Error message 1", "Error message 2"]; - -const options: NDSOption[] = [ - { value: "accepted", label: "Accepted" }, - { value: "assigned", label: "Assigned to a line" }, - { value: "hold", label: "On hold" }, - { value: "rejected", label: "Rejected" }, - { value: "open", label: "Open" }, - { value: "progress", label: "In progress" }, - { value: "quarantine", label: "In quarantine" }, -]; - -const partnerCompanyName = [ - { value: "2", label: "PCN2 12387387484895884957848576867587685780" }, - { value: "4", label: "PCN4 12387387484895884957848576867587685780" }, - { value: "1", label: "PCN1 12387387484895884957848576867587685780" }, - { value: "9", label: "PCN9 12387387484895884957848576867587685780" }, - { value: "7", label: "PCN7 12387387484895884957848576867587685780" }, - { value: "6", label: "PCN6 12387387484895884957848576867587685780" }, - { value: "3", label: "PCN3 12387387484895884957848576867587685780e" }, -]; - -const wrappingOptions = [ - { - value: "onestring", - label: - "Onelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstringonelongstring", - }, - { - value: "manywords", - label: - "Many words many words many words many words many words many words many words many words many words many words many words many words many words", - }, -]; - -const PCNList = [ - { value: "2", label: "PCN2" }, - { value: "4", label: "PCN4" }, - { value: "1", label: "PCN1" }, - { value: "9", label: "PCN9" }, -]; - -const getPhotos = async () => { - // returns 5000 items - const data = await fetch("https://jsonplaceholder.typicode.com/photos"); - const json = await data.json(); - return json.map(({ title, id }) => ({ - label: title, - value: id, - })); -}; +import { Button, Heading2 } from "../index"; +import Select, { NDSOptionValue, NDSSelectProps } from "./Select"; +import { + CustomOption, + getPhotos, + options, + partnerCompanyName, + wrappingOptions, + PCNList, + errorList, +} from "./Select.story.fixture"; const SelectWithManyOptions = ({ multiselect, labelText, ...props }: Partial) => { const [photoList, setPhotoList] = useState([]); @@ -569,44 +524,22 @@ export const WithFetchedOptions = () => ( ); -const Indicator = styled.span(() => ({ - borderRadius: "25%", - background: "green", - lineHeight: "0", - display: "inline-block", - width: "10px", - height: "10px", - marginRight: "5px", -})); - -const CustomOption = ({ children, ...props }: SelectOptionProps) => { - const newChildren = ( - <> - - {children} - - ); - return {newChildren}; -}; - export const WithCustomOptionComponent = () => { return ( - <> - - "No options"} + placeholder="Please select inventory status" + options={options} + components={{ + Option: CustomOption, + }} + multiselect + labelText="Inventory status" + menuPosition="fixed" + /> + ); }; diff --git a/src/Select/Select.tsx b/src/Select/Select.tsx index c19059eef..cea3afa66 100644 --- a/src/Select/Select.tsx +++ b/src/Select/Select.tsx @@ -19,9 +19,10 @@ import { SelectInput, SelectDropdownIndicator, SelectMenu, - SelectOption, -} from "../AsyncSelect/AsyncSelectComponents"; -import { checkOptionsAreValid, extractValue, getReactSelectValue } from "./lib"; +} from "./SelectComponents"; +import { SelectOption } from "./SelectOption"; +import MenuList from "./MenuList"; +import { calcOptionsLength, checkOptionsAreValid, extractValue, getReactSelectValue } from "./lib"; export type NDSOptionValue = string | number | boolean | null; @@ -90,6 +91,8 @@ const NDSSelect = forwardRef( const error = !!(errorMessage || errorList); const optionsRef = React.useRef(options); const componentSize = useComponentSize(size); + const optionsLength = React.useMemo(() => calcOptionsLength(options), [options]); + const isWindowed = optionsLength >= windowThreshold; React.useEffect(() => { checkOptionsAreValid(options); @@ -114,7 +117,6 @@ const NDSSelect = forwardRef( const value = extractValue(newValue, multiselect); onChange(value); }} - // windowThreshold={windowThreshold} placeholder={placeholder || t("start typing")} aria-required={required} required={required} @@ -140,6 +142,7 @@ const NDSSelect = forwardRef( SelectContainer: SelectContainer, Menu: SelectMenu, Input: SelectInput, + ...(isWindowed ? { MenuList } : {}), ...components, }} {...props} diff --git a/src/Select/SelectComponents.tsx b/src/Select/SelectComponents.tsx index ef7f1a8ae..58da9e1f4 100644 --- a/src/Select/SelectComponents.tsx +++ b/src/Select/SelectComponents.tsx @@ -7,73 +7,94 @@ import { InputProps, MenuProps, MultiValueProps, - components as selectComponents, + GroupBase, + components, } from "react-select"; import { NDSOption } from "./Select"; -export function SelectControl(props: ControlProps) { +export function SelectControl< + Option = NDSOption, + IsMulti extends boolean = boolean, + Group extends GroupBase