From e81110bf58700d31b03c9393afb6cee813732297 Mon Sep 17 00:00:00 2001 From: Daniel Sil Date: Thu, 25 May 2023 11:05:44 +0100 Subject: [PATCH] feat(InputSelect): new component --- docs/src/__examples__/InputSelect/DEFAULT.tsx | 57 +++ .../05-input/inputselect/01-guidelines.mdx | 7 + .../05-input/inputselect/02-react.mdx | 9 + .../05-input/inputselect/meta.yml | 3 + .../src/InputSelect/InputSelect.stories.tsx | 247 ++++++++++ .../src/InputSelect/InputSelect.styled.ts | 104 +++++ .../InputSelect/InputSelectOption/index.tsx | 50 +++ .../src/InputSelect/README.md | 108 +++++ .../src/InputSelect/__tests__/index.test.tsx | 193 ++++++++ .../src/InputSelect/__tests__/utils.test.ts | 113 +++++ .../src/InputSelect/helpers.ts | 52 +++ .../src/InputSelect/index.js.flow | 54 +++ .../src/InputSelect/index.tsx | 420 ++++++++++++++++++ .../src/InputSelect/types.d.ts | 53 +++ packages/orbit-components/src/index.ts | 1 + 15 files changed, 1471 insertions(+) create mode 100644 docs/src/__examples__/InputSelect/DEFAULT.tsx create mode 100644 docs/src/documentation/03-components/05-input/inputselect/01-guidelines.mdx create mode 100644 docs/src/documentation/03-components/05-input/inputselect/02-react.mdx create mode 100644 docs/src/documentation/03-components/05-input/inputselect/meta.yml create mode 100644 packages/orbit-components/src/InputSelect/InputSelect.stories.tsx create mode 100644 packages/orbit-components/src/InputSelect/InputSelect.styled.ts create mode 100644 packages/orbit-components/src/InputSelect/InputSelectOption/index.tsx create mode 100644 packages/orbit-components/src/InputSelect/README.md create mode 100644 packages/orbit-components/src/InputSelect/__tests__/index.test.tsx create mode 100644 packages/orbit-components/src/InputSelect/__tests__/utils.test.ts create mode 100644 packages/orbit-components/src/InputSelect/helpers.ts create mode 100644 packages/orbit-components/src/InputSelect/index.js.flow create mode 100644 packages/orbit-components/src/InputSelect/index.tsx create mode 100644 packages/orbit-components/src/InputSelect/types.d.ts diff --git a/docs/src/__examples__/InputSelect/DEFAULT.tsx b/docs/src/__examples__/InputSelect/DEFAULT.tsx new file mode 100644 index 0000000000..3a84f81ac9 --- /dev/null +++ b/docs/src/__examples__/InputSelect/DEFAULT.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { InputSelect } from "@kiwicom/orbit-components"; + +export default { + Example: () => { + const currencyOptions = [ + { + title: "Euro", + value: "EUR", + group: "Popular", + }, + { + title: "US Dollar", + value: "USD", + group: "Popular", + }, + { + title: "Pound Sterling", + value: "GBP", + group: "Popular", + }, + { + title: "Australian Dollar", + value: "AUD", + }, + { + title: "Brazilian Real", + value: "BRL", + }, + { + title: "Czech Koruna", + value: "CZK", + }, + ]; + + return ( + + ); + }, + exampleKnobs: [ + { + component: "InputSelect", + knobs: [ + { name: "showAll", type: "boolean", defaultValue: true }, + { name: "showAllLabel", type: "text", defaultValue: "" }, + { name: "error", type: "text", defaultValue: "" }, + { name: "help", type: "text", defaultValue: "" }, + { name: "label", type: "text", defaultValue: "Select a currency" }, + { name: "disabled", type: "boolean", defaultValue: false }, + ], + }, + ], +}; diff --git a/docs/src/documentation/03-components/05-input/inputselect/01-guidelines.mdx b/docs/src/documentation/03-components/05-input/inputselect/01-guidelines.mdx new file mode 100644 index 0000000000..f79aff4e1b --- /dev/null +++ b/docs/src/documentation/03-components/05-input/inputselect/01-guidelines.mdx @@ -0,0 +1,7 @@ +--- +title: Guidelines +redirect_from: + - /components/inputselect/ +--- + + diff --git a/docs/src/documentation/03-components/05-input/inputselect/02-react.mdx b/docs/src/documentation/03-components/05-input/inputselect/02-react.mdx new file mode 100644 index 0000000000..46031fadc1 --- /dev/null +++ b/docs/src/documentation/03-components/05-input/inputselect/02-react.mdx @@ -0,0 +1,9 @@ +--- +title: React +redirect_from: + - /components/inputselect/react/ +--- + +import InputSelectReadme from "@kiwicom/orbit-components/src/InputSelect/README.md"; + + diff --git a/docs/src/documentation/03-components/05-input/inputselect/meta.yml b/docs/src/documentation/03-components/05-input/inputselect/meta.yml new file mode 100644 index 0000000000..566372f5ce --- /dev/null +++ b/docs/src/documentation/03-components/05-input/inputselect/meta.yml @@ -0,0 +1,3 @@ +title: InputSelect +description: Displays a set of options that can be grouped. Allows searching for an option +type: tabs diff --git a/packages/orbit-components/src/InputSelect/InputSelect.stories.tsx b/packages/orbit-components/src/InputSelect/InputSelect.stories.tsx new file mode 100644 index 0000000000..6e2c5cc0a3 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/InputSelect.stories.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { css } from "styled-components"; +import { object, text, boolean } from "@storybook/addon-knobs"; +import { action } from "@storybook/addon-actions"; + +import InputSelect from "."; + +export default { + title: "InputSelect", +}; + +export const Grouped = () => { + const currencyOptions = [ + { + title: "Euro", + value: "EUR", + group: "Popular", + }, + { + title: "US Dollar", + value: "USD", + group: "Popular", + }, + { + title: "Pound Sterling", + value: "GBP", + group: "Popular", + }, + { + title: "Australian Dollar", + value: "AUD", + }, + { + title: "Brazilian Real", + value: "BRL", + }, + { + title: "Czech Koruna", + value: "CZK", + }, + ]; + + const showAll = boolean("Show all", true); + + return ( +
+ +
+ ); +}; + +Grouped.story = { + name: "Grouped", + parameters: { + info: "By default, grouped options are displayed first and then all options are displayed below. Groups are no longer considered after a search is made. If showAll is false, only the options with no group are displayed after the grouped ones.", + }, +}; + +export const PreviouslySelected = () => { + const currencyOptions = [ + { + title: "Euro", + value: "EUR", + group: "Popular", + }, + { + title: "US Dollar", + value: "USD", + group: "Popular", + }, + { + title: "Pound Sterling", + value: "GBP", + group: "Popular", + }, + { + title: "Australian Dollar", + value: "AUD", + }, + { + title: "Brazilian Real", + value: "BRL", + }, + { + title: "Czech Koruna", + value: "CZK", + }, + ]; + + const showAll = boolean("Show all", true); + const prevSelectedLabel = text("prevSelectedLabel", "Previously selected"); + + return ( +
+ +
+ ); +}; + +PreviouslySelected.story = { + name: "Previously Selected", + parameters: { + info: "If prevSelected is defined, the option is presented on top of every other options.", + }, +}; + +export const Playground = () => { + const pokemonOptions = [ + { + title: "Pikachu", + value: "Pikachu", + group: "Starters", + description: + "This Pokémon has electricity-storing pouches on its cheeks. These appear to become electrically charged during the night while Pikachu sleeps. It occasionally discharges electricity when it is dozy after waking up.", + }, + { + title: "Charizard", + value: "Charizard", + group: "Evolutions", + description: + "Charizard flies around the sky in search of powerful opponents. It breathes fire of such great heat that it melts anything. However, it never turns its fiery breath on any opponent weaker than itself.", + }, + { + title: "Bulbasaur", + value: "Bulbasaur", + group: "Starters", + description: + "There is a plant seed on its back right from the day this Pokémon is born. The seed slowly grows larger.", + }, + { + title: "Squirtle", + value: "Squirtle", + group: "Starters", + description: + "Squirtle's shell is not merely used for protection. The shell's rounded shape and the grooves on its surface help minimize resistance in water, enabling this Pokémon to swim at high speeds.", + }, + { + title: "Jigglypuff", + value: "Jigglypuff", + group: "Others", + description: + "Jigglypuff's vocal cords can freely adjust the wavelength of its voice. This Pokémon uses this ability to sing at precisely the right wavelength to make its foes most drowsy.", + }, + { + title: "Gengar", + value: "Gengar", + group: "Evolutions", + description: + "Gengar is a shadow-like Pokémon that lurks in the darkness. It is said to emerge from darkness to steal the lives of those who become lost in mountains.", + }, + { + title: "Dragonite", + value: "Dragonite", + group: "Evolutions", + description: + "Dragonite is capable of circling the globe in just 16 hours. It is a kindhearted Pokémon that leads lost and foundering ships in a storm to the safety of land.", + }, + { + title: "Mewtwo", + value: "Mewtwo", + description: + "Mewtwo is a Pokémon that was created by genetic manipulation. However, even though the scientific power of humans created this Pokémon's body, they failed to endow Mewtwo with a compassionate heart.", + }, + { + title: "Gyarados", + value: "Gyarados", + group: "Evolutions", + description: + "Gyarados is a Pokémon that has been known to cause major disasters. A vicious Pokémon from the sea, it appears wherever there is conflict to incite rage and cause destruction.", + }, + { + title: "Eevee", + value: "Eevee", + group: "Starters", + description: + "Eevee has an unstable genetic makeup that suddenly mutates due to the environment in which it lives. Radiation from various stones causes this Pokémon to evolve.", + }, + ]; + + const label = text("Label", "Choose your pokemon"); + const placeholder = text("Placeholder", "Search for pokemon"); + const disabled = boolean("Disabled", false); + const options = object("Options", pokemonOptions); + const emptyStateMessage = text("Empty state message", "No results found."); + const showAll = boolean("Show all", true); + const showAllLabel = text("Show all label", "All options"); + const required = boolean("Required", false); + const help = text("Help", "Help message"); + const error = text("Error", "Error message"); + const width = text("width", ""); + const maxWidth = text("maxWidth", ""); + const maxHeight = text("maxHeight", "400px"); + const hasError = boolean("hasError", false); + const hasHelp = boolean("hasHelp", false); + + return ( +
+ +
+ ); +}; diff --git a/packages/orbit-components/src/InputSelect/InputSelect.styled.ts b/packages/orbit-components/src/InputSelect/InputSelect.styled.ts new file mode 100644 index 0000000000..02bd6a1629 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/InputSelect.styled.ts @@ -0,0 +1,104 @@ +import styled, { css } from "styled-components"; + +import type { Props } from "./types"; +import mq from "../utils/mediaQuery"; +import { right, left } from "../utils/rtl"; +import defaultTheme from "../defaultTheme"; +import { ModalWrapperContent } from "../Modal"; +import { StyledModalHeader } from "../Modal/ModalHeader"; +import { StyledModalSection } from "../Modal/ModalSection"; +import { StyledModalFooter } from "../Modal/ModalFooter"; +import { Field } from "../InputField"; + +export const StyledLabel = styled.label` + position: relative; +`; + +export const StyledModalWrapper = styled.div<{ + $maxHeight?: Props["maxHeight"]; + isScrolled?: boolean; +}>` + ${({ theme, isScrolled }) => css` + ${StyledModalSection} { + padding-left: 0; + padding-right: 0; + } + + ${Field} { + margin-top: ${theme.orbit.spaceXSmall}; + } + + ${StyledModalFooter} { + box-shadow: none; + } + + ${StyledModalHeader} { + position: sticky; + padding-bottom: ${isScrolled && theme.orbit.spaceMedium}; + box-shadow: ${isScrolled && theme.orbit.boxShadowFixed}; + top: 0px; + } + + ${ModalWrapperContent} { + height: 100%; + } + `}; +`; + +StyledModalWrapper.defaultProps = { + theme: defaultTheme, +}; + +export const StyledDropdown = styled.ul<{ + $maxHeight?: Props["maxHeight"]; + $maxWidth?: Props["maxWidth"]; + $hasLabel?: boolean; +}>` + ${({ theme, $maxHeight, $maxWidth, $hasLabel }) => css` + display: flex; + flex-direction: column; + list-style-type: none; + margin: 0; + padding: 0; + font-family: ${theme.orbit.fontFamily}; + box-sizing: border-box; + width: 100%; + background: ${theme.orbit.paletteWhite}; + + ${mq.largeMobile(css` + position: absolute; + ${left}: 0; + overflow-y: scroll; + max-height: ${$maxHeight}; + max-width: ${$maxWidth}; + box-shadow: ${theme.orbit.boxShadowAction}; + border-radius: ${theme.orbit.borderRadiusNormal}; + top: calc( + ${parseInt(theme.orbit.heightInputNormal, 10)}px + + ${$hasLabel + ? parseInt(theme.orbit.spaceXLarge, 10) + : parseInt(theme.orbit.spaceXSmall, 10)}px + ); + `)} + `}; +`; + +StyledDropdown.defaultProps = { + theme: defaultTheme, +}; + +export const StyledCloseButton = styled.button<{ $disabled: Props["disabled"] }>` + ${({ theme, $disabled }) => css` + border: 0; + background: transparent; + cursor: ${$disabled ? "not-allowed" : "pointer"}; + pointer-events: ${$disabled ? "none" : "auto"}; + appearance: none; + padding: 0; + margin-${right}: ${theme.orbit.spaceXSmall}; +`}; +`; + +StyledCloseButton.defaultProps = { + theme: defaultTheme, +}; diff --git a/packages/orbit-components/src/InputSelect/InputSelectOption/index.tsx b/packages/orbit-components/src/InputSelect/InputSelectOption/index.tsx new file mode 100644 index 0000000000..7b9d9b6106 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/InputSelectOption/index.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import styled, { css } from "styled-components"; + +import type { Props, Option } from "../types"; +import ListChoice, { StyledListChoice } from "../../ListChoice"; +import defaultTheme from "../../defaultTheme"; +import CheckCircle from "../../icons/CheckCircle"; + +const StyledListChoiceWrapper = styled.li<{ $active: boolean }>` + ${({ theme, $active }) => css` + ${StyledListChoice} { + background: ${$active && theme.orbit.paletteCloudLight}; + } + `}; +`; + +StyledListChoiceWrapper.defaultProps = { + theme: defaultTheme, +}; + +type InputSelectOptionProps = { + id: Props["id"]; + active: boolean; + isSelected: boolean; + title: Option["title"]; + description: Option["description"]; + onClick: (ev: React.SyntheticEvent) => void; +}; + +const InputSelectOption = React.forwardRef( + ({ active, id, onClick, isSelected, title, description }, ref) => { + return ( + + } + role="option" + title={title} + description={description} + /> + + ); + }, +); + +export default InputSelectOption; diff --git a/packages/orbit-components/src/InputSelect/README.md b/packages/orbit-components/src/InputSelect/README.md new file mode 100644 index 0000000000..024b97a18b --- /dev/null +++ b/packages/orbit-components/src/InputSelect/README.md @@ -0,0 +1,108 @@ +# InputSelect + +To implement the InputSelect component into your project you'll need to add the import: + +```jsx +import InputSelect from "@kiwicom/orbit-components/lib/InputSelect"; +``` + +After adding import to your project you can use it simply like: + +```jsx +const options = [ + { + title: "Option 1", + value: 1, + description: "Description for option 1", + }, + { + title: "Option 2", + value: 2, + description: "Description for option 2", + }, + ... +]; + +; +``` + +By using the `onOptionSelect` prop you can have access to the selected option to update your app state based on that. This is called with `null` when the input value is cleared or there is no selected option. +Do not rely on the input's `value` attribute to get the selected value. + +## Groups + +Optionally, each option can have a `group` property. If defined, options are displayed grouped with the name of the group as a label separator. +All groups are displayed first by default. After that, if `showAll` is set to `true` (default), all options are displayed (the ones with a group and the ones without a group, following the order of the array of options). If `showAll` is set to `false`, only the options without a defined group are displayed on that bottom list. +The `showAllLabel` allows to customize the label displayed before the bottom part. If `showAll` is set to `true`, the default value is `"All options"`. If it's set to `false`, the default value is `Other options`. + +```jsx +const options = [ + { + title: "Option 1", + value: 1, + description: "Description for option 1", + }, + { + title: "Option 2", + value: 2, + description: "Description for option 2", + group: "Group name" + }, + ... +]; +``` + +If `prevSelected` is defined, a special group is displayed on top of every options, with the options passed to that prop. This prop is optional and its state is **not** controlled by the component. + +### Props + +The table below contains all types of props available in the InputSelect component. + +| Name | Type | Default | Description | +| :---------------- | :------------------------------ | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **options** | [`Option[]`](#option) | | **Required.** The content of the InputSelect, passed as array of objects. | +| defaultSelected | `Option` | | Default selected option. Must be one of the options passed on the `options` prop. | +| prevSelected | `Option` | | This displays the previously selected option on top of every other options. | +| prevSelectedLabel | `string` | `"Previously selected"` | The label to be displayed before the previously selected option. | +| dataAttrs | `Object` | | Optional prop for passing `data-*` attributes to the `input` DOM element. | +| dataTest | `string` | | Optional prop for testing purposes. | +| disabled | `boolean` | `false` | If `true`, the InputSelect will be disabled. | +| error | `React.Node` | | The error message for the InputSelect. | +| help | `React.Node` | | The help message for the InputSelect. | +| id | `string` | | Adds `id` HTML attribute to an element. | +| label | `Translation` | | The label for the InputSelect. | +| name | `string` | | The name for the InputSelect. | +| onBlur | `event => void \| Promise` | | Function for handling onBlur event. | +| onChange | `event => void \| Promise` | | Function for handling onChange event. | +| onFocus | `event => void \| Promise` | | Function for handling onFocus event. | +| onKeyDown | `event => void \| Promise` | | Function for handling onKeyDown event. | +| onKeyUp | `event => void \| Promise` | | Function for handling onKeyUp event. | +| onMouseUp | `event => void \| Promise` | | Function for handling onMouseUp event. | +| onMouseDown | `event => void \| Promise` | | Function for handling onMouseDown event. | +| placeholder | `TranslationString` | | The placeholder for the InputSelect. | +| ref | `func` | | Prop for forwarded ref of the InputSelect. | +| required | `boolean` | `false` | If true, the label is displayed as required. | +| readOnly | `boolean` | | If true, InputSelect will be readonly. | +| tabIndex | `string \| number` | | Specifies the tab order of an element. | +| width | `string` | `100%` | Specifies width of the InputSelect. | +| maxWidth | `string` | | Specifies max-width of the InputSelect. | +| maxHeight | `string` | `400px` | Specifies max height of the dropdown with results for InputSelect. | +| onOptionSelect | `(opt: Option \| null) => void` | | Callback that fires when an option is selected. | +| onClose | `(opt: Option \| null) => void` | | Callback that fires when the list of options is closed by other means than selecting an option. It is called with the value of the selected or null, if nothing is selected. | +| helpClosable | `boolean` | `true` | Whether to display help as a closable tooltip, or have it open only while the field is focused, same as error. | +| emptyStateMessage | `string` | `"No results found."` | Message to display when no options are available. | +| labelClose | `string` | `Close` | The label for the close button in the dropdown. | +| showAll | `boolean` | `true` | If set to true, it will display all options at the end of the list. If set to false, it will display only the options without a group at the end of the list. | +| showAllLabel | `string` | `"All options" \| "Other options"` | The label displayed before showing the last group of options. If `showAll` is true, the default value is `"All options"`. If it is false, the default value is `"Other options"`. | +| insideInputGroup | `boolean` | `false` | If true, the InputSelect will be rendered inside InputGroup. | + +## Option + +The table below contains all types of props available for the object in the `Option` array. + +| Name | Type | Description | +| :---------- | :------------------- | :---------------------------------------------------------------------------------------------------------------------- | +| **title** | `string` | **Required.** The title of the Option. | +| **value** | `string \| number` | **Required.** The value of the Option. Should be unique in each option on the array of options passed to `InputSelect`. | +| description | `string` | The description of the Option. | +| group | `string` | The group of the Option. | diff --git a/packages/orbit-components/src/InputSelect/__tests__/index.test.tsx b/packages/orbit-components/src/InputSelect/__tests__/index.test.tsx new file mode 100644 index 0000000000..4c5e478260 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/__tests__/index.test.tsx @@ -0,0 +1,193 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; + +import InputSelect from ".."; + +const jetLiOption = { + title: "Jet Li", + value: "Li", + description: "Jet Li is a Chinese actor.", + group: "Asian", +}; + +const options = [ + { + title: "Chuck Norris", + value: "Norris", + description: + "Chuck Norris is an American martial artist, actor, film producer and screenwriter.", + }, + { + title: "Bruce Lee", + value: "Lee", + description: "Bruce Lee was a Hong Kong American martial artist.", + group: "American", + }, + { + title: "Jackie Chan", + value: "Chan", + description: "Jackie Chan is a Hong Kongese actor.", + group: "Asian", + }, + { + ...jetLiOption, + }, +]; + +describe("InputSelect", () => { + const label = "Choose your actor"; + const name = "actors"; + const id = "kek"; + const emptyMessage = "D'oh! No results found."; + + it("should render expected DOM output", async () => { + const onChange = jest.fn(); + const onOptionSelect = jest.fn(); + const onKeyDown = jest.fn(); + const onClose = jest.fn(); + + render( + , + ); + + userEvent.tab(); + + const input = screen.getByRole("combobox"); + const dropdown = screen.getByRole("listbox"); + + // after focus dropdown should have all options grouped and then show all of them + const totalOptions = 2 + 1 + 4; // (2 asian, 1 american, 4 all) + expect(screen.getAllByRole("option")).toHaveLength(totalOptions); + expect(screen.queryByText("All options")).toBeInTheDocument(); + expect(screen.queryByText("Other options")).not.toBeInTheDocument(); + + // should have expected aria attributes + expect(input).toHaveAttribute("aria-expanded", "true"); + expect(input).toHaveAttribute("aria-autocomplete", "list"); + expect(input).toHaveAttribute("aria-haspopup", "true"); + expect(input).toHaveAttribute("aria-controls", dropdown.id); + + expect(input).toBeInTheDocument(); + expect(dropdown).toBeInTheDocument(); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + expect(input).toHaveAttribute("name", name); + expect(input).toHaveAttribute("id", id); + + // clear current value + userEvent.clear(input); + + // test empty message + userEvent.type(input, "Arnold"); + expect(screen.getByText(emptyMessage)).toBeInTheDocument(); + + // test dropdown result filtering + userEvent.clear(input); + userEvent.type(input, "J"); + expect(onChange).toHaveBeenCalled(); + + expect(screen.getAllByRole("option")).toHaveLength(2); + expect(screen.getByText("Jet Li")).toBeInTheDocument(); + expect(screen.getByText("Jackie Chan")).toBeInTheDocument(); + + // test navigating by arrow keys + fireEvent.keyDown(input, { key: "ArrowDown" }); + + expect(onKeyDown).toHaveBeenCalled(); + + // should select by click + userEvent.click(screen.getByText("Jet Li")); + + expect(onOptionSelect).toHaveBeenCalledWith(jetLiOption); + + // test selecting by enter and space + fireEvent.keyDown(input, { key: "Enter" }); + expect(onOptionSelect).toHaveBeenCalledWith(jetLiOption); + + fireEvent.keyDown(input, { key: "Space" }); + expect(onOptionSelect).toHaveBeenCalledWith(jetLiOption); + + // test closing dropdown by ESC + fireEvent.keyDown(input, { key: "Escape" }); + expect(dropdown).not.toBeInTheDocument(); + expect(onClose).toHaveBeenCalledWith(jetLiOption); + + // test clear of the input by button and reset of filtered options + userEvent.tab(); + expect(input).toHaveValue("Jet Li"); + userEvent.click(screen.getByLabelText("Clear")); + expect(onOptionSelect).toBeCalledWith(null); + expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.getAllByRole("option")).toHaveLength(totalOptions); + }); + + it("can have a default selected value", () => { + const onClose = jest.fn(); + + render( + , + ); + + userEvent.tab(); + + const input = screen.getByRole("combobox"); + + expect(input).toHaveValue(jetLiOption.title); + + // Simulate closing to assert the selected value is the default + fireEvent.keyDown(input, { key: "Escape" }); + expect(onClose).toHaveBeenCalledWith(jetLiOption); + }); + + it("can have prevSelected defined", () => { + const prevSelectedLabel = "Formerly selected"; + + render( + , + ); + + userEvent.tab(); + + expect(screen.queryByText("Previously selected")).not.toBeInTheDocument(); + expect(screen.queryByText(prevSelectedLabel)).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(1 + 2 + 1 + 4); // (1 previously selected, 2 asian, 1 american, 4 all) + }); + + describe("when showAll is false", () => { + it("should not render repeated options", () => { + const showAllLabel = "Those without a group"; + render(); + userEvent.tab(); + + // after focus dropdown should have all options grouped and then show only the ones without a group + expect(screen.getAllByRole("option")).toHaveLength(2 + 1 + 1); // (2 asian, 1 american, 1 ungrouped) + expect(screen.queryByText("All options")).not.toBeInTheDocument(); + expect(screen.queryByText("Other options")).not.toBeInTheDocument(); + expect(screen.queryByText(showAllLabel)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/orbit-components/src/InputSelect/__tests__/utils.test.ts b/packages/orbit-components/src/InputSelect/__tests__/utils.test.ts new file mode 100644 index 0000000000..cc8dbb7ecc --- /dev/null +++ b/packages/orbit-components/src/InputSelect/__tests__/utils.test.ts @@ -0,0 +1,113 @@ +import { groupOptions } from "../helpers"; + +describe("groupOptions", () => { + const options = [ + { + title: "option 1", + value: 1, + group: "group 1", + }, + { + title: "option 2", + value: 2, + group: "group 2", + }, + { + title: "option 3", + value: 3, + group: "group 1", + }, + { + title: "option 4", + value: 4, + }, + { + title: "option 5", + value: 5, + group: "group 2", + }, + ]; + + it("groups options with an option in `groups` and includes all options in `all`", () => { + const { groups, all } = groupOptions(options, false); + const group1 = groups[0]; + const group2 = groups[1]; + + expect(groups).toHaveLength(2); + expect(all).toHaveLength(options.length); + + // Assert group 1 + expect(group1).toHaveLength(2); + expect(group1[0].value).toBe(1); + expect(group1[1].value).toBe(3); + + // Assert group 2 + expect(group2).toHaveLength(2); + expect(group2[0].value).toBe(2); + expect(group2[1].value).toBe(5); + + // Assert all + expect(all[0].value).toBe(1); + expect(all[1].value).toBe(2); + expect(all[2].value).toBe(3); + expect(all[3].value).toBe(4); + expect(all[4].value).toBe(5); + }); + + describe("when showAll is false", () => { + const { groups, flattened } = groupOptions(options, false); + const group1 = groups[0]; + const group2 = groups[1]; + + it("flattens correctly, with groups first and ungrouped after", () => { + expect(flattened).toHaveLength(options.length); + + expect([flattened[0], flattened[1]]).toEqual(group1); + expect([flattened[2], flattened[3]]).toEqual(group2); + expect(flattened[4].value).toBe(4); + }); + }); + + describe("when showAll is true", () => { + const { groups, flattened } = groupOptions(options, true); + const group1 = groups[0]; + const group2 = groups[1]; + + it("flattens correctly, with groups first and a copy of all options after", () => { + expect(flattened).toHaveLength(group1.length + group2.length + options.length); + + expect([flattened[0], flattened[1]]).toEqual(group1); + expect([flattened[2], flattened[3]]).toEqual(group2); + expect(flattened.slice(4)).toEqual(options); + }); + }); + + describe("when prevSelected is passed", () => { + it("includes prevSelected as the single item of the first group and the first element of flattened", () => { + const { groups, flattened } = groupOptions(options, false, options[2]); + expect(groups).toHaveLength(3); + + const group1 = groups[0]; + const group2 = groups[1]; + const group3 = groups[2]; + + // Assert group1 - the prevSelected + expect(group1).toHaveLength(1); + expect(group1[0]).toBe(options[2]); + + // Assert group 2 + expect(group2).toHaveLength(2); + expect(group2[0].value).toBe(1); + expect(group2[1].value).toBe(3); + + // Assert group 3 + expect(group3).toHaveLength(2); + expect(group3[0].value).toBe(2); + expect(group3[1].value).toBe(5); + + // Assert flattened starts with prevSelected + expect(flattened).toHaveLength(1 + options.length); + expect(flattened[0]).toBe(options[2]); + }); + }); +}); diff --git a/packages/orbit-components/src/InputSelect/helpers.ts b/packages/orbit-components/src/InputSelect/helpers.ts new file mode 100644 index 0000000000..62c649af7a --- /dev/null +++ b/packages/orbit-components/src/InputSelect/helpers.ts @@ -0,0 +1,52 @@ +import type { Option } from "./types"; + +function separateGroupedAndUngrouped(options: Option[]): { + grouped: Option[][]; + ungrouped: Option[]; +} { + const { groups, ungrouped } = options.reduce<{ + groups: { [group: string]: Option[] }; + ungrouped: Option[]; + }>( + (acc, option) => { + const { group } = option; + + if (group) { + if (!acc.groups[group]) { + acc.groups[group] = []; + } + acc.groups[group].push(option); + } else { + acc.ungrouped.push(option); + } + + return acc; + }, + { groups: {}, ungrouped: [] }, + ); + + const grouped: Option[][] = Object.values(groups); + + return { grouped, ungrouped }; +} + +export function groupOptions( + options: Option[], + showAll: boolean, + prevSelected?: Option, +): { groups: Option[][]; all: Option[]; flattened: Option[] } { + const { grouped, ungrouped } = separateGroupedAndUngrouped(options); + + if (prevSelected) { + grouped.unshift([prevSelected]); + } + + const flattenedGroups = grouped.reduce((acc, group) => [...acc, ...group], []); + const flattened = showAll ? [...flattenedGroups, ...options] : [...flattenedGroups, ...ungrouped]; + + return { + groups: grouped, + all: options, + flattened, + }; +} diff --git a/packages/orbit-components/src/InputSelect/index.js.flow b/packages/orbit-components/src/InputSelect/index.js.flow new file mode 100644 index 0000000000..b28fa84735 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/index.js.flow @@ -0,0 +1,54 @@ +// @flow +// Type definitions for @kiwicom/orbit-components +// Project: http://github.com/kiwicom/orbit + +import * as React from "react"; + +import * as Common from "../common/common.js.flow"; + +type Option = {| + +group?: string, + +title: string, + +value: string | number, + +description?: string, +|}; + +export type Props = {| + +name?: string, + +label?: Common.Translation, + +placeholder?: string, + +help?: React.Node, + +error?: React.Node, + +showAll?: boolean, + +showAllLabel?: string, + +disabled?: boolean, + +maxHeight?: string, + +maxWidth?: string, + +width?: string, + +options: Option[], + +defaultValue?: Option["value"], + +prevSelected?: Option, + +prevSelectedLabel?: string, + +required?: boolean, + +tabIndex?: string | number, + +readOnly: boolean, + +id?: string, + +insideInputGroup?: boolean, + +helpClosable?: boolean, + +emptyStateMessage?: string, + +labelClose?: string, + +onChange?: (ev: SyntheticInputEvent) => void | Promise, + +onFocus?: (ev: SyntheticInputEvent) => void | Promise, + +onBlur?: (ev: SyntheticInputEvent) => void | Promise, + +onSelect?: (ev: SyntheticInputEvent) => void | Promise, + +onMouseUp?: (ev: SyntheticEvent) => void | Promise, + +onMouseDown?: (ev: SyntheticEvent) => void | Promise, + +onKeyDown?: (ev: SyntheticKeyboardEvent) => void | Promise, + +onKeyUp?: (ev: SyntheticKeyboardEvent) => void | Promise, + +onOptionSelect?: (opt: Option | null) => void, + +onClose?: (opt: Option | null) => void, + ...Common.Globals, + ...Common.DataAttrs, +|}; + +declare export default React.AbstractComponent; diff --git a/packages/orbit-components/src/InputSelect/index.tsx b/packages/orbit-components/src/InputSelect/index.tsx new file mode 100644 index 0000000000..b4ecbcca8e --- /dev/null +++ b/packages/orbit-components/src/InputSelect/index.tsx @@ -0,0 +1,420 @@ +import React from "react"; + +import type { Props, Option } from "./types"; +import { groupOptions } from "./helpers"; +import InputSelectOption from "./InputSelectOption"; +import { + StyledCloseButton, + StyledDropdown, + StyledLabel, + StyledModalWrapper, +} from "./InputSelect.styled"; +import CloseCircle from "../icons/CloseCircle"; +import InputField from "../InputField"; +import { useRandomIdSeed } from "../hooks/useRandomId"; +import useClickOutside from "../hooks/useClickOutside"; +import KEY_CODE from "../common/keyMaps"; +import Box from "../Box"; +import Text from "../Text"; +import Stack from "../Stack"; +import useMediaQuery from "../hooks/useMediaQuery"; +import Modal, { ModalSection, ModalHeader, ModalFooter } from "../Modal"; +import ModalCloseButton from "../Modal/ModalCloseButton"; +import Button from "../Button"; +import Heading from "../Heading"; + +const InputSelect = React.forwardRef( + ( + { + onChange, + options, + defaultSelected, + prevSelected, + prevSelectedLabel = "Previously selected", + id, + onFocus, + label, + showAll = true, + showAllLabel = showAll ? "All options" : "Other options", + help, + error, + onBlur, + placeholder, + labelClose = "Close", + emptyStateMessage = "No results found.", + onOptionSelect, + onClose, + disabled, + maxHeight = "400px", + maxWidth, + onKeyDown, + ...props + }, + ref, + ) => { + const randomId = useRandomIdSeed(); + const labelRef = React.useRef(null); + const inputId = id || randomId("input"); + const dropdownId = randomId("dropdown"); + const dropdownRef = React.useRef(null); + + const [isOpened, setIsOpened] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + defaultSelected ? options.find(opt => opt.value === defaultSelected.value)?.title : "", + ); + const [selectedOption, setSelectedOption] = React.useState( + defaultSelected || null, + ); + const [activeIdx, setActiveIdx] = React.useState(0); + const [activeDescendant, setActiveDescendant] = React.useState(""); + const [isScrolled, setIsScrolled] = React.useState(false); + const [topOffset, setTopOffset] = React.useState(0); + + const refs = {}; + + const { isLargeMobile } = useMediaQuery(); + + const handleClose = () => { + if (onClose && isOpened) onClose(selectedOption); + setIsOpened(false); + }; + + useClickOutside(labelRef, handleClose); + + const groupedOptions = React.useMemo( + () => groupOptions(options, showAll, prevSelected), + [options, prevSelected, showAll], + ); + + const [results, setResults] = React.useState<{ + groups: Option[][]; + all: Option[]; + flattened: Option[]; + }>(groupedOptions); + + const handleFocus = (ev: React.SyntheticEvent) => { + if (onFocus) onFocus(ev); + setIsOpened(true); + setResults(results || groupedOptions); + }; + + const handleBlur = (ev: React.SyntheticEvent) => { + if (onBlur) onBlur(ev); + }; + + const handleInputChange = (ev: React.SyntheticEvent) => { + const { value } = ev.currentTarget; + if (onChange) onChange(ev); + + if (value.length === 0) { + setResults(groupedOptions); + } else { + const filtered = options.filter(({ title }) => { + return title.toLowerCase().includes(value.toLowerCase()); + }); + setResults({ + groups: [], + all: filtered, + flattened: filtered, + }); + } + + if (!isOpened) setIsOpened(true); + setInputValue(value); + setActiveIdx(0); + }; + + const handleDropdownKey = (ev: React.KeyboardEvent) => { + if ( + !isOpened && + (ev.keyCode === KEY_CODE.ENTER || + ev.keyCode === KEY_CODE.ARROW_DOWN || + ev.keyCode === KEY_CODE.ARROW_UP) + ) { + setIsOpened(true); + return; + } + + if (isOpened && ev.keyCode === KEY_CODE.ESC) setIsOpened(false); + + if (isOpened && ev.keyCode === KEY_CODE.ENTER) { + ev.preventDefault(); + + if (results.all.length !== 0) { + setSelectedOption(results.flattened[activeIdx]); + setIsOpened(false); + setInputValue(results.flattened[activeIdx].title); + } + } + + if (ev.keyCode === KEY_CODE.ARROW_DOWN) { + if (results.flattened.length - 1 > activeIdx) { + const nextIdx = activeIdx + 1; + setActiveIdx(nextIdx); + setActiveDescendant(refs[nextIdx].current?.id); + + if (dropdownRef && dropdownRef.current) { + dropdownRef.current.scrollTop = refs[nextIdx].current?.offsetTop; + } + } + } + + if (ev.keyCode === KEY_CODE.ARROW_UP) { + if (activeIdx > 0) { + const prevIdx = activeIdx - 1; + + setActiveIdx(prevIdx); + setActiveDescendant(refs[prevIdx].current?.id); + + if (dropdownRef && dropdownRef.current) { + dropdownRef.current.scrollTop = refs[prevIdx].current?.offsetTop; + } + } + } + }; + + const input = ( + { + if (onKeyDown) onKeyDown(ev); + handleDropdownKey(ev); + }} + ariaHasPopup={isOpened} + ariaExpanded={isOpened} + ariaAutocomplete="list" + ariaActiveDescendant={activeDescendant} + ariaControls={isOpened ? dropdownId : undefined} + autoComplete="off" + ref={ref} + suffix={ + String(inputValue).length > 1 && ( + { + if (onOptionSelect) onOptionSelect(null); + setInputValue(""); + setResults(groupedOptions); + setSelectedOption(null); + setActiveIdx(0); + }} + $disabled={disabled} + > + + + ) + } + {...props} + /> + ); + + const renderOptions = () => { + if (results.groups.length === 0) { + return results.all.map((option, idx) => { + const { title, description, value: optValue } = option; + const optionId = randomId(title); + const isSelected = optValue === selectedOption?.value; + const optionRef = React.createRef() as React.RefObject; + refs[idx] = optionRef; + + return ( + { + ev.preventDefault(); + if (onOptionSelect) onOptionSelect(option); + setInputValue(isSelected ? "" : title); + setSelectedOption(isSelected ? null : option); + setActiveIdx(idx); + setResults(groupedOptions); + if (isLargeMobile) setIsOpened(false); + }} + /> + ); + }); + } + + let idx = -1; + return ( + <> + {results.groups.map((group, groupIdx) => { + const prevSelectedOption = prevSelected && groupIdx === 0; + + const { group: groupTitle } = group[0]; + const groupId = randomId(prevSelectedOption ? "prevSelected" : `${groupTitle}`); + + return ( + + + + {prevSelectedOption ? prevSelectedLabel : groupTitle} + + + {group.map(option => { + idx += 1; + const optionIdx = idx; + const optionRef = React.createRef() as React.RefObject; + refs[optionIdx] = optionRef; + + const { title, description, value: optValue } = option; + const optionId = randomId(title); + const isSelected = optValue === selectedOption?.value; + + return ( + { + ev.preventDefault(); + if (onOptionSelect) onOptionSelect(option); + setInputValue(isSelected ? "" : title); + setSelectedOption(isSelected ? null : option); + setActiveIdx(optionIdx); + setResults(groupedOptions); + if (isLargeMobile) setIsOpened(false); + }} + /> + ); + })} + + ); + })} + + {showAllLabel} + + {results.all.map(option => { + const { title, description, value: optValue, group } = option; + if (group && !showAll) return null; + idx += 1; + const optionRef = React.createRef() as React.RefObject; + const optionIdx = idx; + refs[optionIdx] = optionRef; + + const optionId = randomId(`all_${title}`); + const isSelected = optValue === selectedOption?.value; + + return ( + { + ev.preventDefault(); + if (onOptionSelect) onOptionSelect(option); + setInputValue(isSelected ? "" : title); + setSelectedOption(isSelected ? null : option); + setActiveIdx(optionIdx); + setResults(groupedOptions); + if (isLargeMobile) setIsOpened(false); + }} + /> + ); + })} + + ); + }; + + const dropdown = isOpened && ( + + {results.all.length === 0 ? ( + + {emptyStateMessage} + + ) : ( + renderOptions() + )} + + ); + + return isLargeMobile ? ( + + {input} + {dropdown} + + ) : ( + + setIsOpened(true)} + readOnly + role="textbox" + placeholder={placeholder} + value={inputValue} + /> + {isOpened && ( + 50}> + { + if (!isLargeMobile) { + ev.preventDefault(); + setIsScrolled(true); + setTopOffset(ev.currentTarget.scrollTop); + } + }} + mobileHeader={false} + autoFocus + > + + {label && ( + + + {label} + + + + )} + {input} + + {dropdown} + + + + + + )} + + ); + }, +); + +InputSelect.displayName = "InputSelect"; + +export default InputSelect; diff --git a/packages/orbit-components/src/InputSelect/types.d.ts b/packages/orbit-components/src/InputSelect/types.d.ts new file mode 100644 index 0000000000..9116760f68 --- /dev/null +++ b/packages/orbit-components/src/InputSelect/types.d.ts @@ -0,0 +1,53 @@ +// Type definitions for @kiwicom/orbit-components +// Project: http://github.com/kiwicom/orbit + +import type * as React from "react"; + +import type * as Common from "../common/types"; + +export interface Option { + readonly group?: string; + readonly title: string; + readonly value: string | number; + readonly description?: string; +} + +// InputEvent +type InputEvent = Common.Event>; +type KeyboardEvent = Common.Event>; + +export interface Props extends Common.Globals, Common.SpaceAfter, Common.DataAttrs { + readonly name?: string; + readonly label?: Common.Translation; + readonly placeholder?: string; + readonly help?: React.ReactNode; + readonly error?: React.ReactNode; + readonly showAll?: boolean; + readonly showAllLabel?: string; + readonly disabled?: boolean; + readonly maxHeight?: string; + readonly maxWidth?: string; + readonly width?: string; + readonly options: Option[]; + readonly defaultSelected?: Option; + readonly prevSelected?: Option; + readonly prevSelectedLabel?: string; + readonly required?: boolean; + readonly tabIndex?: string | number; + readonly readOnly?: boolean; + readonly id?: string; + readonly insideInputGroup?: boolean; + readonly helpClosable?: boolean; + readonly emptyStateMessage?: string; + readonly labelClose?: string; + readonly onChange?: InputEvent; + readonly onFocus?: InputEvent; + readonly onBlur?: InputEvent; + readonly onSelect?: InputEvent; + readonly onMouseUp?: InputEvent; + readonly onMouseDown?: InputEvent; + readonly onKeyDown?: KeyboardEvent; + readonly onKeyUp?: KeyboardEvent; + readonly onOptionSelect?: (opt: Option | null) => void; + readonly onClose?: (opt: Option | null) => void; +} diff --git a/packages/orbit-components/src/index.ts b/packages/orbit-components/src/index.ts index ba9c78658d..1474733106 100644 --- a/packages/orbit-components/src/index.ts +++ b/packages/orbit-components/src/index.ts @@ -62,6 +62,7 @@ export { default as Inline } from "./Inline"; export { default as InputField } from "./InputField"; export { default as InputFile } from "./InputFile"; export { default as InputGroup } from "./InputGroup"; +export { default as InputSelect } from "./InputSelect"; export { default as Itinerary, ItinerarySegment,