diff --git a/src/select/Select.tsx b/src/select/Select.tsx index efda77a..b9f4a0d 100644 --- a/src/select/Select.tsx +++ b/src/select/Select.tsx @@ -47,6 +47,7 @@ function SelectComponent( return (
( ) { const selectState = useSelectContext(); const dispatchSelectStateAction = useSelectDispatchContext(); - const {onSelect, value, focusedOptionIndex, shouldCloseOnSelect, options} = selectState; + const { + onSelect, + value, + focusedOptionIndex, + shouldCloseOnSelect, + options, + isMenuOpen + } = selectState; const optionIndex = options.findIndex((opt) => opt?.id === option?.id); const isSelected = Array.isArray(value) ? Boolean(value.find((currentOption) => currentOption.id === option?.id)) @@ -83,7 +90,7 @@ function SelectItemComponent( payload: optionIndex }); - if (shouldCloseOnSelect) { + if (shouldCloseOnSelect && isMenuOpen) { dispatchSelectStateAction({type: "TOGGLE_MENU_VISIBILITY"}); } } diff --git a/src/select/typeahead/TypeaheadSelect.tsx b/src/select/typeahead/TypeaheadSelect.tsx index 3b1e132..82250ee 100644 --- a/src/select/typeahead/TypeaheadSelect.tsx +++ b/src/select/typeahead/TypeaheadSelect.tsx @@ -8,7 +8,6 @@ import TypeaheadInput, { } from "../../form/input/typeahead/TypeaheadInput"; import {mapOptionsToTagShapes} from "../../tag/util/tagUtils"; import {TagShape} from "../../tag/Tag"; -import {filterOptionsByKeyword} from "./util/typeaheadSelectUtils"; import {filterOutItemsByKey} from "../../core/utils/array/arrayUtils"; import Spinner from "../../spinner/Spinner"; import {KEYBOARD_EVENT_KEY} from "../../core/utils/keyboard/keyboardEventConstants"; @@ -32,15 +31,14 @@ export interface TypeaheadSelectProps< TypeaheadInputProps, "id" | "placeholder" | "name" | "onFocus" | "type" >; + contentRenderer: (option: T) => React.ReactNode; + onKeywordChange: (value: string) => void; + keyword: string; testid?: string; - onKeywordChange?: (value: string) => void; - initialKeyword?: string; - controlledKeyword?: string; onTagRemove?: (option: Option) => void; selectedOptionLimit?: number; customClassName?: string; shouldDisplaySelectedOptions?: boolean; - shouldFilterOptionsByKeyword?: boolean; isDisabled?: boolean; customSpinner?: React.ReactNode; shouldShowEmptyOptions?: boolean; @@ -48,41 +46,39 @@ export interface TypeaheadSelectProps< areOptionsFetching?: boolean; } -/* eslint-disable complexity */ function TypeaheadSelect({ testid, options, selectedOptions, typeaheadProps, onTagRemove, + keyword, onKeywordChange, onSelect, + contentRenderer, customClassName, selectedOptionLimit, shouldDisplaySelectedOptions = true, - shouldFilterOptionsByKeyword = true, isDisabled, shouldShowEmptyOptions = true, canOpenDropdownMenu = true, areOptionsFetching, - customSpinner, - initialKeyword = "", - controlledKeyword + customSpinner }: TypeaheadSelectProps) { const typeaheadInputRef = useRef(null); const [isMenuOpen, setMenuVisibility] = useState(false); const [computedDropdownOptions, setComputedDropdownOptions] = useState(options); const [shouldFocusOnInput, setShouldFocusOnInput] = useState(false); - const [keyword, setKeyword] = useState(initialKeyword); - const inputValue = typeof controlledKeyword === "string" ? controlledKeyword : keyword; - const tags = mapOptionsToTagShapes(selectedOptions); + const tags = mapOptionsToTagShapes(selectedOptions, contentRenderer); const shouldDisplayOnlyTags = Boolean( selectedOptionLimit && selectedOptions.length >= selectedOptionLimit ); - const canSelectMultiple = !selectedOptionLimit || selectedOptionLimit > 1; + const canSelectMultiple = + options.length > 1 && (!selectedOptionLimit || selectedOptionLimit > 1); + const shouldCloseOnSelect = !canSelectMultiple || Boolean(selectedOptionLimit && selectedOptions.length >= selectedOptionLimit - 1); @@ -124,6 +120,7 @@ function TypeaheadSelect @@ -159,9 +157,10 @@ function TypeaheadSelect {computedDropdownOptions.map((option) => ( - {option.title} + {contentRenderer(option)} ))} + {shouldShowEmptyOptions && !computedDropdownOptions.length && (

selectedOptions.indexOf(option) < 0 - ); - - setComputedDropdownOptions(filterOptionsByKeyword(unselectedOptions, value)); - } - - if (onKeywordChange) { - onKeywordChange(value); - } - - if (typeof controlledKeyword === "undefined") { - setKeyword(value); + setMenuVisibility(false); } } @@ -226,7 +209,7 @@ function TypeaheadSelect void; customClassName?: string; input?: React.ReactNode; + onClick?: VoidFunction; } function TypeheadSelectTrigger({ handleTagRemove, tags, customClassName, - input + input, + onClick }: TypeheadSelectTriggerProps) { return ( - + {(tag: TagShape) => ( )} + {input} ); diff --git a/src/select/typeahead/trigger/_typehead-select-trigger.scss b/src/select/typeahead/trigger/_typehead-select-trigger.scss index b755350..c522d95 100644 --- a/src/select/typeahead/trigger/_typehead-select-trigger.scss +++ b/src/select/typeahead/trigger/_typehead-select-trigger.scss @@ -8,8 +8,12 @@ padding: 0; - border: 1px solid var(--default-border-color); - border-radius: var(--small-border-radius); + .typeahead-select__input { + .input { + border: none; + border-radius: 8px; + } + } } .typeahead-select-trigger__tag-list { diff --git a/src/select/typeahead/typeahead-select.test.tsx b/src/select/typeahead/typeahead-select.test.tsx index 4fbe2d1..70c8892 100644 --- a/src/select/typeahead/typeahead-select.test.tsx +++ b/src/select/typeahead/typeahead-select.test.tsx @@ -1,13 +1,16 @@ import React from "react"; -import {fireEvent, render, screen, within} from "@testing-library/react"; +import {fireEvent, render, screen} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import TypeaheadSelect, {TypeaheadSelectProps} from "./TypeaheadSelect"; import {testA11y} from "../../core/utils/test/testUtils"; +import {TypeaheadSelectOption} from "../util/selectTypes"; describe("", () => { - const defaultTypeaheadSelectProps: TypeaheadSelectProps = { + const defaultTypeaheadSelectProps: TypeaheadSelectProps< + TypeaheadSelectOption & {title: string} + > = { testid: "typeahead-select", options: [ {id: "1", title: "first-dropdown-option"}, @@ -21,7 +24,9 @@ describe("", () => { typeaheadProps: { placeholder: "test placeholder", name: "test typeahead" - } + }, + keyword: "", + contentRenderer: (option) => option.title }; it("should render correctly", () => { @@ -60,19 +65,19 @@ describe("", () => { }); it("should set initialValue and remove when set new value", () => { - render( - - ); + render(); - const typeaheadSelect = screen.getByRole("textbox") as HTMLInputElement; + const typeaheadSelectInput = screen.getByTestId( + `${defaultTypeaheadSelectProps.testid}.search` + ).firstElementChild as HTMLInputElement; - expect(typeaheadSelect).toHaveValue("initial"); + expect(typeaheadSelectInput).toHaveValue("initial"); - typeaheadSelect.setSelectionRange(0, typeaheadSelect.value.length); + typeaheadSelectInput.setSelectionRange(0, typeaheadSelectInput.value.length); - userEvent.type(typeaheadSelect, "test"); + userEvent.type(typeaheadSelectInput, "test"); - expect(typeaheadSelect).toHaveValue("test"); + expect(typeaheadSelectInput).toHaveValue("test"); }); it("should render custom spinner correctly", () => { @@ -101,15 +106,14 @@ describe("", () => { const dropdownList = screen.getByTestId("test-dropdown-visibility"); - expect(dropdownList).not.toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).not.toHaveClass("typeahead-select--is-dropdown-menu-open"); - // fireEvent.focus(screen.getByRole("listbox")); - userEvent.click(screen.getByRole("listbox")); + userEvent.click(screen.getByRole("button")); - expect(dropdownList).toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).toHaveClass("typeahead-select--is-dropdown-menu-open"); }); - it("should run click event handle when option is selected", () => { + it("should run click event handler when option is selected", async () => { render( ", () => { /> ); - const selectedOptionList = screen.getByRole("list"); - - const dropdownList = screen.getByTestId("test-dropdown-visibility"); - - const firstOption = within(dropdownList).getByTestId( - "test-dropdown-visibility.item-0" + const firstOption = await screen.findByText( + defaultTypeaheadSelectProps.options[0].title ); userEvent.click(firstOption); expect(defaultTypeaheadSelectProps.onSelect).toHaveBeenCalledTimes(1); - const secondOption = within(dropdownList).getByTestId( - "test-dropdown-visibility.item-1" + const secondOption = await screen.findByText( + defaultTypeaheadSelectProps.options[1].title ); userEvent.click(secondOption); - expect(selectedOptionList).not.toContainElement(secondOption); + expect(defaultTypeaheadSelectProps.onSelect).toHaveBeenCalledTimes(2); }); - it("should not render option menu when selectedOptionLimit is reached", () => { + it("should not render option menu when selectedOptionLimit is reached", async () => { render( ", () => { const selectedOptionList = screen.getByRole("list"); - const dropdownList = screen.getByTestId("test-dropdown-visibility"); - - const secondOption = within(dropdownList).getByTestId( - "test-dropdown-visibility.item-1" + const secondOption = await screen.findByText( + defaultTypeaheadSelectProps.options[1].title ); userEvent.click(secondOption); @@ -161,7 +159,7 @@ describe("", () => { expect(selectedOptionList).not.toContainElement(secondOption); }); - it("should render when select an option flow correctly", () => { + it("should render when select an option flow correctly", async () => { render( ", () => { /> ); - userEvent.click(screen.getByRole("listbox")); + userEvent.click(screen.getByRole("button")); const dropdownList = screen.getByTestId("test-dropdown-visibility"); - expect(dropdownList).toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).toHaveClass("typeahead-select--is-dropdown-menu-open"); const typeaheadInput = screen.getByRole("textbox"); userEvent.type(typeaheadInput, "second-dropdown"); - const searchedOption = screen.getByTestId("test-dropdown-visibility.item-1"); + const searchedOption = await screen.findByText( + defaultTypeaheadSelectProps.options[0].title + ); expect(dropdownList).toContainElement(searchedOption); @@ -189,12 +189,12 @@ describe("", () => { userEvent.click(searchedOption); - expect(dropdownList).not.toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).not.toHaveClass("typeahead-select--is-dropdown-menu-open"); expect(defaultTypeaheadSelectProps.onSelect).toHaveBeenCalledTimes(1); }); - it("should not render selected option on dropdown list", () => { + it("should not render selected option on dropdown list", async () => { const {rerender} = render( ", () => { /> ); - userEvent.click(screen.getByRole("listbox")); + userEvent.click(screen.getByRole("button")); const dropdownList = screen.getByTestId("test-dropdown-visibility"); - expect(dropdownList).toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).toHaveClass("typeahead-select--is-dropdown-menu-open"); const typeaheadInput = screen.getByRole("textbox"); userEvent.type(typeaheadInput, "second-dropdown"); - const searchedOption = screen.getByTestId("test-dropdown-visibility.item-1"); + const searchedOption = await screen.findByText( + defaultTypeaheadSelectProps.options[1].title + ); expect(dropdownList).toContainElement(searchedOption); @@ -223,8 +225,7 @@ describe("", () => { userEvent.click(searchedOption); expect(defaultTypeaheadSelectProps.onSelect).toHaveBeenCalledTimes(1); - - expect(dropdownList).toHaveClass("dropdown-list--is-visible"); + expect(dropdownList).not.toHaveClass("select--is-visible"); rerender( ", () => { expect(dropdownList.children.length).toBe(2); - // One of items is the input another one is selected option - expect(selectedOptionList.children.length).toBe(2); + expect(selectedOptionList.children.length).toBe(1); }); }); /* eslint diff --git a/src/select/typeahead/util/typeaheadSelectUtils.ts b/src/select/typeahead/util/typeaheadSelectUtils.ts deleted file mode 100644 index 129bba9..0000000 --- a/src/select/typeahead/util/typeaheadSelectUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {TypeaheadSelectOption} from "../../util/selectTypes"; - -function filterOptionsByKeyword( - options: T[], - keyword: string -): T[] { - let filteredOptions = options; - - if (keyword) { - filteredOptions = options.filter( - (option) => - !option.title || option.title.toLowerCase().includes(keyword.toLowerCase()) - ); - } - - return filteredOptions; -} - -export {filterOptionsByKeyword}; diff --git a/src/select/util/selectTypes.ts b/src/select/util/selectTypes.ts index 561a75c..4e777c0 100644 --- a/src/select/util/selectTypes.ts +++ b/src/select/util/selectTypes.ts @@ -1,13 +1,11 @@ import React from "react"; -interface Option { - id: string; +interface Option { + id: Id; isDisabled?: boolean; } -interface TypeaheadSelectOption extends Option { - title: string; -} +type TypeaheadSelectOption = Option; type SelectItemElement = HTMLLIElement | HTMLDivElement; @@ -32,6 +30,7 @@ interface SelectProps { isDisabled?: boolean; shouldCloseOnSelect?: boolean; isMenuOpen?: boolean; + testid?: string; } type SelectContextValue = Pick< diff --git a/src/tag/util/tagUtils.ts b/src/tag/util/tagUtils.ts index 124cb7d..8853262 100644 --- a/src/tag/util/tagUtils.ts +++ b/src/tag/util/tagUtils.ts @@ -1,20 +1,24 @@ +import React from "react"; + import {TypeaheadSelectOption} from "../../select/util/selectTypes"; import {TagShape} from "../Tag"; function mapOptionToTagShape( - option: T + option: T, + content: React.ReactNode ): TagShape { return { id: option.id, - content: option.title, + content, context: option }; } function mapOptionsToTagShapes( - options: T[] + options: T[], + contentRenderer: (option: T) => React.ReactNode ) { - return options.map(mapOptionToTagShape); + return options.map((option) => mapOptionToTagShape(option, contentRenderer(option))); } export {mapOptionsToTagShapes}; diff --git a/stories/11-Typeahead.stories.tsx b/stories/11-Typeahead.stories.tsx index 0067c5e..edd191b 100644 --- a/stories/11-Typeahead.stories.tsx +++ b/stories/11-Typeahead.stories.tsx @@ -7,6 +7,8 @@ import StoryFragment from "./utils/StoryFragment"; import FormField from "../src/form/field/FormField"; import TypeaheadSelect from "../src/select/typeahead/TypeaheadSelect"; import {TypeaheadSelectOption} from "../src/select/util/selectTypes"; +import {Language} from "./utils/constants/select/selectStoryConstants"; +import {filterOptionsByKeyword} from "./utils/typeaheadSelectStoryUtils"; const simulateAPICall = (timeout = 1000) => new Promise((resolve) => setTimeout(resolve, timeout)); @@ -26,11 +28,11 @@ storiesOf("Typeahead", module).add("Typeahead", () => { id: "spanish", title: "Spanish" } - ], + ] as (TypeaheadSelectOption & {title: string})[], thirdOptions: [], - selectedOptions: [] as TypeaheadSelectOption[], - secondSelectedOptions: [] as TypeaheadSelectOption[], - thirdSelectedOptions: [] as TypeaheadSelectOption[], + selectedOptions: [] as (TypeaheadSelectOption & {title: string})[], + secondSelectedOptions: [] as (TypeaheadSelectOption & {title: string})[], + thirdSelectedOptions: [] as (TypeaheadSelectOption & {title: string})[], areOptionsFetching: false, keyword: "" }; @@ -38,16 +40,13 @@ storiesOf("Typeahead", module).add("Typeahead", () => { const modelInitialState = { options: [ { - id: "2005", - title: "2005" + id: "2005" }, { - id: "2015", - title: "2015" + id: "2015" }, { - id: "2021", - title: "2021" + id: "2021" } ], thirdOptions: [] as TypeaheadSelectOption[], @@ -65,14 +64,26 @@ storiesOf("Typeahead", module).add("Typeahead", () => { {(state, setState) => ( option.title} onSelect={(option) => setState({ ...state, selectedOptions: [...state.selectedOptions, option] }) } + keyword={state.keyword} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onTagRemove={handleRemoveTag(state, setState)} typeaheadProps={{ placeholder: "Select Languages", @@ -88,7 +99,8 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.secondSelectedOptions} onSelect={(option) => setState({ @@ -96,6 +108,17 @@ storiesOf("Typeahead", module).add("Typeahead", () => { secondSelectedOptions: [...state.secondSelectedOptions, option] }) } + keyword={state.keyword} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onTagRemove={handleRemoveTag(state, setState, "secondSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -112,8 +135,20 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.secondSelectedOptions} + keyword={state.keyword} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onSelect={(option) => setState({ ...state, @@ -137,9 +172,9 @@ storiesOf("Typeahead", module).add("Typeahead", () => { "Select Languages (API Fetch Simulation) - with keyword by TypeaheadSelect" }> option.title} selectedOptions={state.thirdSelectedOptions} onSelect={(option) => setState({ @@ -147,7 +182,8 @@ storiesOf("Typeahead", module).add("Typeahead", () => { thirdSelectedOptions: [...state.thirdSelectedOptions, option] }) } - onKeywordChange={handleKeywordChange(setState)} + keyword={state.keyword} + onKeywordChange={handleAsyncKeywordChange(setState)} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -163,9 +199,9 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.thirdSelectedOptions} onSelect={(option) => setState({ @@ -174,8 +210,8 @@ storiesOf("Typeahead", module).add("Typeahead", () => { keyword: "" }) } - onKeywordChange={handleKeywordChange(setState)} - controlledKeyword={state.keyword} + onKeywordChange={handleAsyncKeywordChange(setState)} + keyword={state.keyword} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -190,7 +226,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { {(state, setState) => ( setState({ @@ -198,6 +234,18 @@ storiesOf("Typeahead", module).add("Typeahead", () => { selectedOptions: [...state.selectedOptions, option] }) } + contentRenderer={(option) => option.id} + keyword={state.keyword} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + modelInitialState.options, + keyword, + "id" + ) + }) + } onTagRemove={handleRemoveTag(state, setState)} typeaheadProps={{ placeholder: "Select Model", @@ -222,7 +270,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { }); } - function handleKeywordChange(setState) { + function handleAsyncKeywordChange(setState) { return async (keyword) => { if (keyword) { setState((prevState) => ({ diff --git a/stories/utils/constants/select/selectStoryConstants.tsx b/stories/utils/constants/select/selectStoryConstants.tsx index a8ed0c5..7daeb05 100644 --- a/stories/utils/constants/select/selectStoryConstants.tsx +++ b/stories/utils/constants/select/selectStoryConstants.tsx @@ -1,4 +1,5 @@ import React from "react"; +import {Option} from "../../../../src/select/util/selectTypes"; function CoinDropdownOptionCustomContent({ id, @@ -21,6 +22,13 @@ function CoinDropdownOptionCustomContent({ ); } +export enum Language { + TURKISH = "turkish", + ENGLISH = "english", + SPANISH = "spanish", + FRENCH = "french" +} + const initialState = { basic: { options: [ @@ -42,7 +50,7 @@ const initialState = { isDisabled: true } ], - selectedOption: null as {id: string; isDisabled?: boolean; title: string} | null + selectedOption: null as (Option & {title: string}) | null }, multiSelect: { options: [ @@ -63,8 +71,8 @@ const initialState = { title: "French - Disabled", isDisabled: true } - ] as {id: string; isDisabled?: boolean; title: string}[], - value: [] as {id: string; isDisabled?: boolean; title: string}[] + ] as (Option & {title: string})[], + value: [] as (Option & {title: string})[] }, withSubtitle: { options: [ @@ -84,7 +92,7 @@ const initialState = { subtitle: "JavaScript" } ], - selectedOption: null as {id: string; isDisabled?: boolean; subtitle: string} | null + selectedOption: null as (Option & {title: string}) | null }, withCustomContent: { options: [ @@ -127,11 +135,7 @@ const initialState = { ) } ], - selectedOption: null as { - id: string; - title?: string; - CustomContent: JSX.Element; - } | null + selectedOption: null as (Option & {title: string}) | null } }; diff --git a/stories/utils/selectStoryUtils.ts b/stories/utils/selectStoryUtils.ts index aadbb1c..25f4bad 100644 --- a/stories/utils/selectStoryUtils.ts +++ b/stories/utils/selectStoryUtils.ts @@ -1,9 +1,10 @@ -import {initialState} from "./constants/select/selectStoryConstants"; +import {Option} from "../../src/select/util/selectTypes"; +import {initialState, Language} from "./constants/select/selectStoryConstants"; function handleMultiSelect( state: typeof initialState.multiSelect, setState: React.Dispatch>, - option: {id: string; isDisabled?: boolean; title: string} + option: Option & {title: string} ) { const isSelected = state.value.findIndex((opt) => opt.id === option.id) > -1; diff --git a/stories/utils/typeaheadSelectStoryUtils.ts b/stories/utils/typeaheadSelectStoryUtils.ts new file mode 100644 index 0000000..0d1fd5e --- /dev/null +++ b/stories/utils/typeaheadSelectStoryUtils.ts @@ -0,0 +1,21 @@ +import {TypeaheadSelectOption} from "../../src/select/util/selectTypes"; + +function filterOptionsByKeyword( + options: T[], + keyword: string, + filterBy: keyof Pick | "title" +): T[] { + let filteredOptions = options; + + if (keyword) { + filteredOptions = options.filter((option) => { + const optionFilterValue = option[filterBy] as string; + + return optionFilterValue.toLowerCase().includes(keyword.toLowerCase()); + }); + } + + return filteredOptions; +} + +export {filterOptionsByKeyword};