From 50386f1978ce42af081c2e8934cd3c2d7fe5ff27 Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Mon, 28 Nov 2022 13:28:00 +0300 Subject: [PATCH 1/6] fix(select): Create a generic Id type for Option interface - Add title as an interface prop and set type as ReactNode - Create an enum for Language options and use it in Select and TypeaheadSelect components --- .../typeahead/util/typeaheadSelectUtils.ts | 3 ++- src/select/util/selectTypes.ts | 16 +++++++++----- stories/11-Typeahead.stories.tsx | 9 ++++---- .../constants/select/selectStoryConstants.tsx | 22 +++++++++++-------- stories/utils/selectStoryUtils.ts | 5 +++-- 5 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/select/typeahead/util/typeaheadSelectUtils.ts b/src/select/typeahead/util/typeaheadSelectUtils.ts index 129bba9..de5813d 100644 --- a/src/select/typeahead/util/typeaheadSelectUtils.ts +++ b/src/select/typeahead/util/typeaheadSelectUtils.ts @@ -9,7 +9,8 @@ function filterOptionsByKeyword - !option.title || option.title.toLowerCase().includes(keyword.toLowerCase()) + typeof option.title === "string" && + option.title.toLowerCase().includes(keyword.toLowerCase()) ); } diff --git a/src/select/util/selectTypes.ts b/src/select/util/selectTypes.ts index 561a75c..26ff452 100644 --- a/src/select/util/selectTypes.ts +++ b/src/select/util/selectTypes.ts @@ -1,13 +1,19 @@ import React from "react"; -interface Option { - id: string; +interface Option { + id: Id; + title: React.ReactNode; isDisabled?: boolean; } -interface TypeaheadSelectOption extends Option { - title: string; -} +// TypeaheadSelectOption is intentionally empty. It happens not +// to have more properties than Option, but this may +// change in the future, and it helps to have a TypeaheadSelectOption +// interface that people can use. Therefore the no-empty-interface is disabled +// rule for this declaration: + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface TypeaheadSelectOption extends Option {} type SelectItemElement = HTMLLIElement | HTMLDivElement; diff --git a/stories/11-Typeahead.stories.tsx b/stories/11-Typeahead.stories.tsx index 0067c5e..619cc23 100644 --- a/stories/11-Typeahead.stories.tsx +++ b/stories/11-Typeahead.stories.tsx @@ -7,6 +7,7 @@ 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"; const simulateAPICall = (timeout = 1000) => new Promise((resolve) => setTimeout(resolve, timeout)); @@ -26,11 +27,11 @@ storiesOf("Typeahead", module).add("Typeahead", () => { id: "spanish", title: "Spanish" } - ], + ] as TypeaheadSelectOption[], thirdOptions: [], - selectedOptions: [] as TypeaheadSelectOption[], - secondSelectedOptions: [] as TypeaheadSelectOption[], - thirdSelectedOptions: [] as TypeaheadSelectOption[], + selectedOptions: [] as TypeaheadSelectOption[], + secondSelectedOptions: [] as TypeaheadSelectOption[], + thirdSelectedOptions: [] as TypeaheadSelectOption[], areOptionsFetching: false, keyword: "" }; diff --git a/stories/utils/constants/select/selectStoryConstants.tsx b/stories/utils/constants/select/selectStoryConstants.tsx index a8ed0c5..5e15760 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 | 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[], + value: [] as Option[] }, withSubtitle: { options: [ @@ -84,7 +92,7 @@ const initialState = { subtitle: "JavaScript" } ], - selectedOption: null as {id: string; isDisabled?: boolean; subtitle: string} | null + selectedOption: null as Option | null }, withCustomContent: { options: [ @@ -127,11 +135,7 @@ const initialState = { ) } ], - selectedOption: null as { - id: string; - title?: string; - CustomContent: JSX.Element; - } | null + selectedOption: null as Option | null } }; diff --git a/stories/utils/selectStoryUtils.ts b/stories/utils/selectStoryUtils.ts index 7d36a32..81bb726 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 ) { const isSelected = state.value.findIndex((opt) => opt.id === option.id) > -1; From c622ed96e90ba8a5c5f23d551899318709dddaf0 Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Thu, 1 Dec 2022 13:15:23 +0300 Subject: [PATCH 2/6] fix(select-types): make TypeaheadSelectOption generic --- src/select/util/selectTypes.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/select/util/selectTypes.ts b/src/select/util/selectTypes.ts index 26ff452..7e28a2a 100644 --- a/src/select/util/selectTypes.ts +++ b/src/select/util/selectTypes.ts @@ -6,14 +6,9 @@ interface Option { isDisabled?: boolean; } -// TypeaheadSelectOption is intentionally empty. It happens not -// to have more properties than Option, but this may -// change in the future, and it helps to have a TypeaheadSelectOption -// interface that people can use. Therefore the no-empty-interface is disabled -// rule for this declaration: - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface TypeaheadSelectOption extends Option {} +type TypeaheadSelectOption = Omit & { + id: Id; +}; type SelectItemElement = HTMLLIElement | HTMLDivElement; From cb18789ddd80f33dfe94bcad0ced737b93c07884 Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Tue, 10 Jan 2023 20:57:45 +0300 Subject: [PATCH 3/6] fix(typeahead-select): Remove title from TypeaheadSelectOption props - Use contentRenderer as Select.Item content - Use contentRenderer for tag content as we removed the title prop - Instead of handling the keyword change inside TypeaheadSelect by filtering titles, handle it outside of the component --- src/select/typeahead/TypeaheadSelect.tsx | 32 ++------ .../typeahead/typeahead-select.test.tsx | 8 +- .../typeahead/util/typeaheadSelectUtils.ts | 20 ----- src/select/util/selectTypes.ts | 5 +- src/tag/util/tagUtils.ts | 12 ++- stories/11-Typeahead.stories.tsx | 80 ++++++++++++++----- .../constants/select/selectStoryConstants.tsx | 10 +-- stories/utils/selectStoryUtils.ts | 2 +- stories/utils/typeaheadSelectStoryUtils.ts | 21 +++++ tsconfig.json | 2 +- 10 files changed, 111 insertions(+), 81 deletions(-) delete mode 100644 src/select/typeahead/util/typeaheadSelectUtils.ts create mode 100644 stories/utils/typeaheadSelectStoryUtils.ts diff --git a/src/select/typeahead/TypeaheadSelect.tsx b/src/select/typeahead/TypeaheadSelect.tsx index 3b1e132..2e68e37 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,15 @@ export interface TypeaheadSelectProps< TypeaheadInputProps, "id" | "placeholder" | "name" | "onFocus" | "type" >; + contentRenderer: (option: T) => React.ReactNode; + onKeywordChange: (value: string) => void; 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; @@ -57,10 +56,10 @@ function TypeaheadSelect= selectedOptionLimit ); @@ -144,7 +143,7 @@ function TypeaheadSelect @@ -159,9 +158,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); - } - } - function handleKeyDown(event: React.KeyboardEvent) { const {key} = event; diff --git a/src/select/typeahead/typeahead-select.test.tsx b/src/select/typeahead/typeahead-select.test.tsx index 4fbe2d1..18e30bb 100644 --- a/src/select/typeahead/typeahead-select.test.tsx +++ b/src/select/typeahead/typeahead-select.test.tsx @@ -5,9 +5,12 @@ 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,8 @@ describe("", () => { typeaheadProps: { placeholder: "test placeholder", name: "test typeahead" - } + }, + contentRenderer: (option) => option.title }; it("should render correctly", () => { diff --git a/src/select/typeahead/util/typeaheadSelectUtils.ts b/src/select/typeahead/util/typeaheadSelectUtils.ts deleted file mode 100644 index de5813d..0000000 --- a/src/select/typeahead/util/typeaheadSelectUtils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {TypeaheadSelectOption} from "../../util/selectTypes"; - -function filterOptionsByKeyword( - options: T[], - keyword: string -): T[] { - let filteredOptions = options; - - if (keyword) { - filteredOptions = options.filter( - (option) => - typeof option.title === "string" && - 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 7e28a2a..290808a 100644 --- a/src/select/util/selectTypes.ts +++ b/src/select/util/selectTypes.ts @@ -2,13 +2,10 @@ import React from "react"; interface Option { id: Id; - title: React.ReactNode; isDisabled?: boolean; } -type TypeaheadSelectOption = Omit & { - id: Id; -}; +type TypeaheadSelectOption = Option; type SelectItemElement = HTMLLIElement | HTMLDivElement; 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 619cc23..c388575 100644 --- a/stories/11-Typeahead.stories.tsx +++ b/stories/11-Typeahead.stories.tsx @@ -8,6 +8,7 @@ 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)); @@ -27,11 +28,11 @@ storiesOf("Typeahead", module).add("Typeahead", () => { id: "spanish", title: "Spanish" } - ] as TypeaheadSelectOption[], + ] 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: "" }; @@ -39,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[], @@ -66,14 +64,25 @@ storiesOf("Typeahead", module).add("Typeahead", () => { {(state, setState) => ( option.title} onSelect={(option) => setState({ ...state, selectedOptions: [...state.selectedOptions, option] }) } + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onTagRemove={handleRemoveTag(state, setState)} typeaheadProps={{ placeholder: "Select Languages", @@ -89,7 +98,8 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.secondSelectedOptions} onSelect={(option) => setState({ @@ -97,6 +107,16 @@ storiesOf("Typeahead", module).add("Typeahead", () => { secondSelectedOptions: [...state.secondSelectedOptions, option] }) } + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onTagRemove={handleRemoveTag(state, setState, "secondSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -113,8 +133,19 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.secondSelectedOptions} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + initialState.options, + keyword, + "title" + ) + }) + } onSelect={(option) => setState({ ...state, @@ -138,9 +169,9 @@ storiesOf("Typeahead", module).add("Typeahead", () => { "Select Languages (API Fetch Simulation) - with keyword by TypeaheadSelect" }> option.title} selectedOptions={state.thirdSelectedOptions} onSelect={(option) => setState({ @@ -148,7 +179,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { thirdSelectedOptions: [...state.thirdSelectedOptions, option] }) } - onKeywordChange={handleKeywordChange(setState)} + onKeywordChange={handleAsyncKeywordChange(setState)} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -164,9 +195,9 @@ storiesOf("Typeahead", module).add("Typeahead", () => { option.title} selectedOptions={state.thirdSelectedOptions} onSelect={(option) => setState({ @@ -175,7 +206,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { keyword: "" }) } - onKeywordChange={handleKeywordChange(setState)} + onKeywordChange={handleAsyncKeywordChange(setState)} controlledKeyword={state.keyword} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ @@ -191,7 +222,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { {(state, setState) => ( setState({ @@ -199,6 +230,17 @@ storiesOf("Typeahead", module).add("Typeahead", () => { selectedOptions: [...state.selectedOptions, option] }) } + contentRenderer={(option) => option.id} + onKeywordChange={(keyword) => + setState({ + ...state, + options: filterOptionsByKeyword( + modelInitialState.options, + keyword, + "id" + ) + }) + } onTagRemove={handleRemoveTag(state, setState)} typeaheadProps={{ placeholder: "Select Model", @@ -223,7 +265,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 5e15760..7daeb05 100644 --- a/stories/utils/constants/select/selectStoryConstants.tsx +++ b/stories/utils/constants/select/selectStoryConstants.tsx @@ -50,7 +50,7 @@ const initialState = { isDisabled: true } ], - selectedOption: null as Option | null + selectedOption: null as (Option & {title: string}) | null }, multiSelect: { options: [ @@ -71,8 +71,8 @@ const initialState = { title: "French - Disabled", isDisabled: true } - ] as Option[], - value: [] as Option[] + ] as (Option & {title: string})[], + value: [] as (Option & {title: string})[] }, withSubtitle: { options: [ @@ -92,7 +92,7 @@ const initialState = { subtitle: "JavaScript" } ], - selectedOption: null as Option | null + selectedOption: null as (Option & {title: string}) | null }, withCustomContent: { options: [ @@ -135,7 +135,7 @@ const initialState = { ) } ], - selectedOption: null as Option | null + selectedOption: null as (Option & {title: string}) | null } }; diff --git a/stories/utils/selectStoryUtils.ts b/stories/utils/selectStoryUtils.ts index 81bb726..a7c20d7 100644 --- a/stories/utils/selectStoryUtils.ts +++ b/stories/utils/selectStoryUtils.ts @@ -4,7 +4,7 @@ import {initialState, Language} from "./constants/select/selectStoryConstants"; function handleMultiSelect( state: typeof initialState.multiSelect, setState: React.Dispatch>, - option: Option + 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}; diff --git a/tsconfig.json b/tsconfig.json index 83d746a..38b2479 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "noUnusedParameters": true, "downlevelIteration": true }, - "include": ["src"], + "include": ["src", "stories/utils/typeaheadSelectStoryUtils.ts"], "exclude": ["node_modules", "build"] } From a07ff689a7fc43dd2f19dd0f7784ba2937951957 Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Tue, 10 Jan 2023 21:05:15 +0300 Subject: [PATCH 4/6] Remove typeaheadSelectStoryUtils from tsconfig --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 38b2479..83d746a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "noUnusedParameters": true, "downlevelIteration": true }, - "include": ["src", "stories/utils/typeaheadSelectStoryUtils.ts"], + "include": ["src"], "exclude": ["node_modules", "build"] } From 80df9c71b6a01eae0697e9b2997dae625d991ece Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Wed, 25 Jan 2023 00:31:15 +0300 Subject: [PATCH 5/6] fix(typeahead-select): Remove initialKeyword and use controlledKeyword to set initial and changeable value - Set canSelectMultiple to false if options.length is not bigger than 1 - Add testid to use in typeahead-select.test - Add onClick optional prop to TypeheadSelectTrigger to set the correct classname when the menu is open - setMenuVisibility to false if shouldCloseOnSelect is true, so that the classnames are correct - Remove input styles when TypeaheadSelectInput is used inside of the trigger - Add testid to TypeheadSelectTrigger to test it easily - Fix most of the test cases for typeahead-select - Fix classnames - I used screen.findByText function to find the option but I can update it Note:- In my opinion, having two classnames for typeahead-select and select is a bit unnecessary. We can just add typeahead-select as main classname and override select classnames if necessary. - I couldn't fix a11y test case for the typeahead-select. --- src/select/Select.tsx | 1 + src/select/item/SelectItem.tsx | 11 ++- src/select/typeahead/TypeaheadSelect.tsx | 36 ++++++--- src/select/typeahead/_typeahead-select.scss | 10 --- .../trigger/TypeheadSelectTrigger.tsx | 10 ++- .../trigger/_typehead-select-trigger.scss | 8 +- .../typeahead/typeahead-select.test.tsx | 77 +++++++++---------- src/select/util/selectTypes.ts | 1 + 8 files changed, 86 insertions(+), 68 deletions(-) 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 2e68e37..d3e61e8 100644 --- a/src/select/typeahead/TypeaheadSelect.tsx +++ b/src/select/typeahead/TypeaheadSelect.tsx @@ -34,7 +34,6 @@ export interface TypeaheadSelectProps< contentRenderer: (option: T) => React.ReactNode; onKeywordChange: (value: string) => void; testid?: string; - initialKeyword?: string; controlledKeyword?: string; onTagRemove?: (option: Option) => void; selectedOptionLimit?: number; @@ -47,7 +46,6 @@ export interface TypeaheadSelectProps< areOptionsFetching?: boolean; } -/* eslint-disable complexity */ function TypeaheadSelect({ testid, options, @@ -65,23 +63,23 @@ function TypeaheadSelect) { 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 [keyword, setKeyword] = useState(controlledKeyword); 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); @@ -123,6 +121,7 @@ function TypeaheadSelect @@ -192,7 +192,12 @@ function TypeaheadSelect void; customClassName?: string; input?: React.ReactNode; + onClick?: VoidFunction; } function TypeheadSelectTrigger({ handleTagRemove, tags, customClassName, - input + input, + onClick }: TypeheadSelectTriggerProps) { return ( - + )} + {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 18e30bb..37af5f9 100644 --- a/src/select/typeahead/typeahead-select.test.tsx +++ b/src/select/typeahead/typeahead-select.test.tsx @@ -1,5 +1,5 @@ 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"; @@ -65,18 +65,20 @@ describe("", () => { it("should set initialValue and remove when set new value", () => { 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"); + + typeaheadSelectInput.setSelectionRange(0, typeaheadSelectInput.value.length); - typeaheadSelect.setSelectionRange(0, typeaheadSelect.value.length); + userEvent.type(typeaheadSelectInput, "test"); - userEvent.type(typeaheadSelect, "test"); - - expect(typeaheadSelect).toHaveValue("test"); + expect(typeaheadSelectInput).toHaveValue("test"); }); it("should render custom spinner correctly", () => { @@ -105,15 +107,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.getByTestId("TypeaheadSelectTrigger")); - 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); @@ -165,7 +160,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.getByTestId("TypeaheadSelectTrigger")); 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); @@ -193,12 +190,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.getByTestId("TypeaheadSelectTrigger")); 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); @@ -227,8 +226,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/util/selectTypes.ts b/src/select/util/selectTypes.ts index 290808a..4e777c0 100644 --- a/src/select/util/selectTypes.ts +++ b/src/select/util/selectTypes.ts @@ -30,6 +30,7 @@ interface SelectProps { isDisabled?: boolean; shouldCloseOnSelect?: boolean; isMenuOpen?: boolean; + testid?: string; } type SelectContextValue = Pick< From 18781d5433c198f1b3ed4d7d02962bfa9e5f0033 Mon Sep 17 00:00:00 2001 From: gulcinuras Date: Fri, 19 Jan 2024 20:02:07 +0300 Subject: [PATCH 6/6] fix(typeahead-select): remove testids and remove the state for keyword as we usually handle the state of the keyword outside of the component --- src/select/typeahead/TypeaheadSelect.tsx | 26 +++++-------------- .../trigger/TypeheadSelectTrigger.tsx | 6 +---- .../typeahead/typeahead-select.test.tsx | 13 +++++----- stories/11-Typeahead.stories.tsx | 7 ++++- 4 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/select/typeahead/TypeaheadSelect.tsx b/src/select/typeahead/TypeaheadSelect.tsx index d3e61e8..82250ee 100644 --- a/src/select/typeahead/TypeaheadSelect.tsx +++ b/src/select/typeahead/TypeaheadSelect.tsx @@ -33,8 +33,8 @@ export interface TypeaheadSelectProps< >; contentRenderer: (option: T) => React.ReactNode; onKeywordChange: (value: string) => void; + keyword: string; testid?: string; - controlledKeyword?: string; onTagRemove?: (option: Option) => void; selectedOptionLimit?: number; customClassName?: string; @@ -52,6 +52,7 @@ function TypeaheadSelect) { const typeaheadInputRef = useRef(null); const [isMenuOpen, setMenuVisibility] = useState(false); const [computedDropdownOptions, setComputedDropdownOptions] = useState(options); const [shouldFocusOnInput, setShouldFocusOnInput] = useState(false); - const [keyword, setKeyword] = useState(controlledKeyword); const tags = mapOptionsToTagShapes(selectedOptions, contentRenderer); const shouldDisplayOnlyTags = Boolean( @@ -143,7 +142,7 @@ function TypeaheadSelect @@ -191,13 +190,9 @@ function TypeaheadSelect + {(tag: TagShape) => ( ", () => { placeholder: "test placeholder", name: "test typeahead" }, + keyword: "", contentRenderer: (option) => option.title }; @@ -64,16 +65,14 @@ describe("", () => { }); it("should set initialValue and remove when set new value", () => { - render( - - ); + render(); const typeaheadSelectInput = screen.getByTestId( `${defaultTypeaheadSelectProps.testid}.search` ).firstElementChild as HTMLInputElement; expect(typeaheadSelectInput).toHaveValue("initial"); - + typeaheadSelectInput.setSelectionRange(0, typeaheadSelectInput.value.length); userEvent.type(typeaheadSelectInput, "test"); @@ -109,7 +108,7 @@ describe("", () => { expect(dropdownList).not.toHaveClass("typeahead-select--is-dropdown-menu-open"); - userEvent.click(screen.getByTestId("TypeaheadSelectTrigger")); + userEvent.click(screen.getByRole("button")); expect(dropdownList).toHaveClass("typeahead-select--is-dropdown-menu-open"); }); @@ -170,7 +169,7 @@ describe("", () => { /> ); - userEvent.click(screen.getByTestId("TypeaheadSelectTrigger")); + userEvent.click(screen.getByRole("button")); const dropdownList = screen.getByTestId("test-dropdown-visibility"); @@ -205,7 +204,7 @@ describe("", () => { /> ); - userEvent.click(screen.getByTestId("TypeaheadSelectTrigger")); + userEvent.click(screen.getByRole("button")); const dropdownList = screen.getByTestId("test-dropdown-visibility"); diff --git a/stories/11-Typeahead.stories.tsx b/stories/11-Typeahead.stories.tsx index c388575..edd191b 100644 --- a/stories/11-Typeahead.stories.tsx +++ b/stories/11-Typeahead.stories.tsx @@ -73,6 +73,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { selectedOptions: [...state.selectedOptions, option] }) } + keyword={state.keyword} onKeywordChange={(keyword) => setState({ ...state, @@ -107,6 +108,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { secondSelectedOptions: [...state.secondSelectedOptions, option] }) } + keyword={state.keyword} onKeywordChange={(keyword) => setState({ ...state, @@ -136,6 +138,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { options={state.options} contentRenderer={(option) => option.title} selectedOptions={state.secondSelectedOptions} + keyword={state.keyword} onKeywordChange={(keyword) => setState({ ...state, @@ -179,6 +182,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { thirdSelectedOptions: [...state.thirdSelectedOptions, option] }) } + keyword={state.keyword} onKeywordChange={handleAsyncKeywordChange(setState)} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ @@ -207,7 +211,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { }) } onKeywordChange={handleAsyncKeywordChange(setState)} - controlledKeyword={state.keyword} + keyword={state.keyword} onTagRemove={handleRemoveTag(state, setState, "thirdSelectedOptions")} typeaheadProps={{ placeholder: "Select Languages", @@ -231,6 +235,7 @@ storiesOf("Typeahead", module).add("Typeahead", () => { }) } contentRenderer={(option) => option.id} + keyword={state.keyword} onKeywordChange={(keyword) => setState({ ...state,