From 0dca21a8d79de69c83d3968e34ba0ae6e6c7f11b Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 16 Feb 2021 15:15:14 +0800 Subject: [PATCH] Select: Implement new Select based on Downshift --- package.json | 2 + src/General/Loading/Loading.tsx | 2 +- src/Input/DownshiftSelect/Select.stories.tsx | 521 ++++++++++ src/Input/DownshiftSelect/Select.test.tsx | 402 ++++++++ src/Input/DownshiftSelect/Select.tsx | 357 +++++++ src/Input/DownshiftSelect/SelectStyle.ts | 165 +++ .../__snapshots__/Select.test.tsx.snap | 942 ++++++++++++++++++ yarn.lock | 22 +- 8 files changed, 2411 insertions(+), 2 deletions(-) create mode 100644 src/Input/DownshiftSelect/Select.stories.tsx create mode 100644 src/Input/DownshiftSelect/Select.test.tsx create mode 100644 src/Input/DownshiftSelect/Select.tsx create mode 100644 src/Input/DownshiftSelect/SelectStyle.ts create mode 100644 src/Input/DownshiftSelect/__snapshots__/Select.test.tsx.snap diff --git a/package.json b/package.json index 85d9521ba..1bba9ef1d 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,9 @@ "body-scroll-lock": "^3.1.5", "classnames": "^2.2.6", "create-react-context": "^0.2.3", + "downshift": "^6.1.0", "moment": "^2.24.0", + "react-id-generator": "^3.0.1", "styled-system": "^5.1.5" }, "peerDependencies": { diff --git a/src/General/Loading/Loading.tsx b/src/General/Loading/Loading.tsx index 89ea7caae..474a37ebe 100644 --- a/src/General/Loading/Loading.tsx +++ b/src/General/Loading/Loading.tsx @@ -8,10 +8,10 @@ export const Loading: FC = props => { return ( diff --git a/src/Input/DownshiftSelect/Select.stories.tsx b/src/Input/DownshiftSelect/Select.stories.tsx new file mode 100644 index 000000000..8aecdc7e9 --- /dev/null +++ b/src/Input/DownshiftSelect/Select.stories.tsx @@ -0,0 +1,521 @@ +import React, { useState } from 'react'; +import { Story, Meta } from '@storybook/react'; +import { Select, Props, ItemProps, Item } from './Select'; +import { BaseContainer } from '../../Layout/GlintsContainer/GlintsContainer'; +import styled from 'styled-components'; + +import * as Components from './SelectStyle'; +import { SecondaryColor } from '../../Utils/Colors'; +import { Button } from '../../General/Button/Button'; +import { Box } from '../../Layout/Box'; +import { identity, sample, sampleSize } from 'lodash'; +import { TextField } from '../..'; + +const StoryContainer = styled(BaseContainer)` + min-height: 250px; +`; + +export default { + title: 'Input/NewSelect', + + component: Select, + argTypes: { + components: { + control: null, + }, + transformFunction: { + control: null, + }, + items: { + control: null, + }, + downshift: { + control: null, + }, + selectedItem: { + control: null, + }, + inputValue: { + control: null, + }, + onInputValueChange: { + control: null, + }, + }, + decorators: [Story => {Story()}], +} as Meta; + +const items: Item[] = [ + 'Software Engineer', + 'Software Tester', + 'Back-end Engineer', + 'Front-end Engineer', +].map(label => ({ value: label, label })); + +const componentNames = Object.keys(Components).filter( + name => !name.startsWith('_') +); + +const CustomLabel = styled.label` + color: green; +`; + +const CustomStyledItem = styled(Select.Components.Item)` + display: flex; + justify-content: space-between; + &[aria-selected='true'] { + color: ${SecondaryColor.darkgreen}; + } +`; + +const CustomItem: React.FC = props => { + return ( + + {props.children} + {JSON.stringify(props.item)} + + ); +}; + +const Template: Story = args => ( + setSelectedItem(item)} + /> + + Selected Job: {selectedItem ? selectedItem.label : 'none'} + + + ); +}; +RealisticExample.parameters = { + docs: { + description: { + story: + 'The Select is built with async items in mind. Just update the items at any time.', + }, + }, +}; + +export const TransformFunctionForCustomFilter = Template.bind({}); +TransformFunctionForCustomFilter.args = { + transformFunction: (items: Item[], inputValue: string) => + items.filter(item => + item.label.toLowerCase().includes(inputValue.toLowerCase()) + ), +}; +TransformFunctionForCustomFilter.parameters = { + docs: { + description: { + story: `You can pass a transformFunction to make the combobox filter the provided items according to custom logic. In the example above, the filter function uses String.includes instead of the default String.startsWith.`, + }, + }, +}; + +export const TransformFunctionForNoFilter = Template.bind({}); +TransformFunctionForNoFilter.args = { + transformFunction: (items: Item[]) => items, +}; +TransformFunctionForNoFilter.parameters = { + docs: { + description: { + story: `You can pass an identity function as transformFunction to disable filtering (useful if you are controlling the items in an external state).`, + }, + }, +}; + +export const CustomComponents = Template.bind({}); +CustomComponents.args = { + components: { Label: CustomLabel, Item: CustomItem }, +}; +CustomComponents.parameters = { + docs: { + description: { + story: `You can override the components that comprise the Combobox. These components are available at the moment: ${componentNames.map( + name => `${name}` + )}`, + }, + }, +}; + +export const RemoveToggleButton = Template.bind({}); +RemoveToggleButton.args = { + components: { ToggleButton: () => null as React.FC }, +}; +RemoveToggleButton.parameters = { + docs: { + description: { + story: + 'Remove the toggle button by passing components={{ ToggleButton: () => null }}', + }, + }, +}; + +export const LoadingState = Template.bind({}); +LoadingState.args = { + isLoading: true, +}; + +export const InitiallyOpen = Template.bind({}); +InitiallyOpen.args = { + isOpenInitially: false, +}; +InitiallyOpen.parameters = { + docs: { + description: { + story: + 'Pass isOpenInitially=true to open and focus the Select on render. (Set to false in this story because it would steal focus from the other stories otherwise).', + }, + }, +}; + +export const HelperText = Template.bind({}); +HelperText.args = { + helperText: 'I am helpful text', +}; + +export const Placeholder = Template.bind({}); +Placeholder.args = { + placeholder: 'I am placeholder', +}; + +export const Label = Template.bind({}); +Label.args = { + label: 'I am label', +}; + +export const DownshiftOptions = Template.bind({}); +DownshiftOptions.args = { + downshift: { + defaultHighlightedIndex: 2, + }, +}; +DownshiftOptions.parameters = { + docs: { + description: { + story: + 'If the options afforded by the Select component are not enough, you can also use the downshift prop to pass custom options to the internal useCombobox hook. You can read the documentation here. Use this with caution, future versions of this component might break your custom functionality.', + }, + }, +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, + label: 'Select An Option', + placeholder: 'I am placeholder', + helperText: 'I am helper text', +}; + +export const DisableTyping = Template.bind({}); +DisableTyping.args = { + disableTyping: true, + transformFunction: identity, +}; +DisableTyping.parameters = { + docs: { + description: { + story: + "With disableTyping=true, the internal input will be set to readonly. This is helpful when there's only a small or fixed amount of items. This can be combined with setting transformFunction to the identity function to disable the filtering after an option has been chosen.", + }, + }, +}; + +export const DisabledOptions = Template.bind({}); +DisabledOptions.args = { + items: items.map((item, index) => ({ ...item, disabled: index % 2 === 1 })), +}; +DisabledOptions.parameters = { + docs: { + description: { + story: + "Use the items' disabled prop to disable individual items.", + }, + }, +}; + +export const ControlledSelectedItem: Story = () => { + const [selectedItem, setSelectedItem] = useState(); + return ( + <> + + +    + {selectedItem + ? `Selected item is ${selectedItem.label}` + : 'No item selected'} + + setIsOpen(newState)} + /> + + ); +}; +ControlledIsOpen.parameters = { + docs: { + description: { + story: + "Use isOpen and onIsOpenChange to control the state of the Select's menu. Note that clicking outside of the Select closes the select (calls onIsOpenChange with false) so if you're trying to build a 'toggle' button, clicking that button will first close the menu.", + }, + }, +}; + +export const ControlledInputValue: Story = () => { + const [inputValue, setInputValue] = useState(''); + return ( + <> + + setInputValue(e.target.value)} + /> + + + Input value is {JSON.stringify(inputValue)} + + setOnFocusCalls(onFocusCalls + 1)} + onBlur={() => setOnBlurCalls(onBlurCalls + 1)} + /> + + ); +}; +FocusCallbacks.parameters = { + docs: { + description: { + story: + 'Intrinsic props are usually passed down to the internal input element, so you can simply use onFocus and onBlur to capture those events.', + }, + }, +}; + +export const OnClearCallback: Story = () => { + const [onClearCalls, setOnClearCalls] = useState(0); + return ( + <> + + onClear called {onClearCalls} times. + + + + ); +}; +AsyncItems.parameters = { + docs: { + description: { + story: + 'The Select is built with async items in mind. Just update the items at any time.', + }, + }, +}; diff --git a/src/Input/DownshiftSelect/Select.test.tsx b/src/Input/DownshiftSelect/Select.test.tsx new file mode 100644 index 000000000..fbe53dfed --- /dev/null +++ b/src/Input/DownshiftSelect/Select.test.tsx @@ -0,0 +1,402 @@ +import React, { HTMLAttributes } from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import _userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import { Select, Props, Item, ItemProps, LabelProps } from './Select'; +import { first, identity } from 'lodash'; +import { Item as ItemComponent, Label as LabelComponent } from './SelectStyle'; + +// userEvent is typed incorrectly. The tab function exists, but is not declared +// on the userEvent's type, so we have to add it manually here +const userEvent = _userEvent as typeof _userEvent & { tab: () => void }; + +const arrowDownKey = { key: 'ArrowDown', keyCode: 40 }; +const enterKey = { key: 'Enter', keyCode: 13 }; +const escKey = { key: 'Escape', keyCode: 27 }; + +const items: Item[] = [ + 'Software Engineer', + 'Software Tester', + 'Back-end Engineer', + 'Front-end Engineer', +].map(label => ({ value: label, label })); + +const CustomItem: React.FC = props => { + return ; +}; + +const CustomLabel: React.FC = props => { + return ; +}; + +const renderSelect = ( + props: Omit, 'items'> & { + 'data-test'?: string; // Can't figure out how to get the built-in props here + } = {} +) => { + const { asFragment, queryByTestId, queryAllByRole, rerender } = render( + ); + + return { + getSnapshot, + getSelectContainer, + getLabel, + getCombobox, + getInput, + getMenu, + getLoadingIndicator, + getToggleButton, + getClearButton, + getItems, + getFirstItem, + getHelperText, + rerenderWithProps, + }; +}; + +describe(' + + {isLoading && } + {selectedItem && ( + + + + )} + e.stopPropagation(), + })} + aria-label="toggle menu" + data-testid="toggle-button" + > + {isOpen ? : } + + + + + {isOpen && displayItems.length > 0 ? ( + displayItems.map((item, index) => ( + + {defaultItemToString(item)} + + )) + ) : ( + {emptyListText} + )} + + {helperText && ( + + {helperText} + + )} + + ); +}; + +Select.Components = internalComponents; diff --git a/src/Input/DownshiftSelect/SelectStyle.ts b/src/Input/DownshiftSelect/SelectStyle.ts new file mode 100644 index 000000000..84f5d3479 --- /dev/null +++ b/src/Input/DownshiftSelect/SelectStyle.ts @@ -0,0 +1,165 @@ +import styled from 'styled-components'; +import Loading from '../../General/Loading'; +import { Greyscale, PrimaryColor, SecondaryColor } from '../../Utils/Colors'; +import { Shadow } from '../../Utils/Shadow'; +import { + ContainerProps, + ComboboxProps, + HelperTextProps, + IndicatorsContainerProps, + InputProps, + ItemProps, + EmptyListProps, + LabelProps, + LoadingIndicatorProps, + MenuProps, + ToggleButtonProps, + ClearButtonProps, +} from './Select'; + +export const Container = styled.div` + position: relative; + font-size: 16px; +`; + +export const Label = styled.label` + display: block; + margin-bottom: 8px; + color: ${Greyscale.devilsgrey}; + font-weight: 500; + + &[data-active='true'] { + color: ${SecondaryColor.actionblue}; + } + + &[data-disabled='true'] { + color: ${Greyscale.lightgrey}; + } + + &[data-invalid='true'] { + color: ${PrimaryColor.glintsred}; + } +`; + +export const Combobox = styled.div` + display: grid; + grid-template-columns: 1fr auto; + grid-column-gap: 0.75em; + padding: 0.75em 1em 0.75em 1em; + border-radius: 0.5em; + background-color: #ebf5fa; + + :hover { + background-color: #d6eaf2; + } + + &[aria-expanded='true'], + &[data-active='true'] { + background-color: #c2e0ed; + } + + &[data-disabled='true'] { + background-color: ${Greyscale.softgrey}; + } + + &[data-invalid='true'] { + background-color: #feeeee; + } +`; + +export const Input = styled.input` + border: none; + background-color: transparent; + outline: none; + color: ${Greyscale.devilsgrey}; + font-size: inherit; + text-overflow: ellipsis; + + &:not([value='']) { + color: black; + } + + :disabled::placeholder { + color: ${Greyscale.lightgrey}; + } +`; + +export const IndicatorsContainer = styled.div` + display: flex; + flex-direction: row; + > :not(:last-child) { + margin-right: 0.75em; + } +`; + +export const _IndicatorButton = styled.button` + padding: 0; + border: none; + background: none; + color: ${Greyscale.devilsgrey}; + font-size: inherit; +`; + +export const ClearButton = styled(_IndicatorButton)``; + +export const ToggleButton = styled(_IndicatorButton)``; + +export const LoadingIndicator = styled(Loading)` + align-items: center; + font-size: 0.5625em; +`; + +export const Menu = styled.ul` + margin-top: 4px; + position: absolute; + width: 100%; + padding: 0.5em 0 0.5em 0; + border: 1px solid #eeeeee; + border-radius: 0.5em; + box-shadow: ${Shadow.down3}; + background-color: white; + + [aria-expanded='false'] + & { + visibility: hidden; + } +`; + +export const Item = styled.li` + padding: 0.5em 1em; + list-style-type: none; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &[aria-selected='true'] { + background-color: ${Greyscale.softgrey}; + color: ${SecondaryColor.actionblue}; + } + + &[disabled] { + background-color: transparent; + color: ${Greyscale.lightgrey}; + cursor: not-allowed; + } +`; + +export const EmptyList = styled.li` + padding: 0.5em 1em; + list-style-type: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${Greyscale.lightgrey}; + cursor: not-allowed; +`; + +export const HelperText = styled.span` + margin-top: 4px; + color: ${Greyscale.devilsgrey}; + font-size: 0.875em; + + &[data-invalid='true'] { + color: ${PrimaryColor.glintsred}; + } +`; diff --git a/src/Input/DownshiftSelect/__snapshots__/Select.test.tsx.snap b/src/Input/DownshiftSelect/__snapshots__/Select.test.tsx.snap new file mode 100644 index 000000000..19db6ba04 --- /dev/null +++ b/src/Input/DownshiftSelect/__snapshots__/Select.test.tsx.snap @@ -0,0 +1,942 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` +
+
+
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; + +exports[` +
+ +
+
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + Invalid + + + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + foo + + + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + Software Engineer +
  • +
  • + Software Tester +
  • +
  • + Back-end Engineer +
  • +
  • + Front-end Engineer +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + Software Engineer +
  • +
  • + Software Tester +
  • +
  • + Back-end Engineer +
  • +
  • + Front-end Engineer +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + Software Engineer +
  • +
  • + Software Tester +
  • +
  • + Back-end Engineer +
  • +
  • + Front-end Engineer +
  • +
+ + +`; + +exports[` +
+ +
+ +
    +
  • + No results found. Try another keyword to search for. +
  • +
+ + +`; diff --git a/yarn.lock b/yarn.lock index 2de98c2ff..12ee5c663 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5534,7 +5534,7 @@ component-emitter@^1.2.1: resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== -compute-scroll-into-view@^1.0.14: +compute-scroll-into-view@^1.0.14, compute-scroll-into-view@^1.0.16: version "1.0.16" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== @@ -6324,6 +6324,16 @@ downshift@^6.0.6: prop-types "^15.7.2" react-is "^16.13.1" +downshift@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-6.1.0.tgz#f008063d9b63935910d9db12ead07979ab51ce66" + integrity sha512-MnEJERij+1pTVAsOPsH3q9MJGNIZuu2sT90uxOCEOZYH6sEzkVGtUcTBVDRQkE8y96zpB7uEbRn24aE9VpHnZg== + dependencies: + "@babel/runtime" "^7.12.5" + compute-scroll-into-view "^1.0.16" + prop-types "^15.7.2" + react-is "^17.0.1" + duplexer@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -11730,6 +11740,11 @@ react-hotkeys@2.0.0: dependencies: prop-types "^15.6.1" +react-id-generator@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/react-id-generator/-/react-id-generator-3.0.1.tgz#30ec683954b3c0a5aa0732c4b98ed0f88fd85d00" + integrity sha512-YxorMaYxB8ItXA3Cuadcl/8ZaquU9Tzrrr5ogFL8tNqevF96cmCtx3LZLXYqBEj3BxoV9aBIK5yJjIuX/XHQ1A== + react-inspector@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-5.1.0.tgz#45a325e15f33e595be5356ca2d3ceffb7d6b8c3a" @@ -11744,6 +11759,11 @@ react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1, react- resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" + integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"