From 9b7d39e72bd245cbf5000dd544dc9a19c5a2a3ae Mon Sep 17 00:00:00 2001 From: Taras Mariukhnich <47363177+MarikTar@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:16:42 +0300 Subject: [PATCH] react-components: Add Autocomplete component (#161) * chore(react-components): rename Select * chore(react-components): rename Autocomplete * chore(react-components): add new Autocomplete * chore(react-components): fix storybook * chore(react-components): update snapshot * chore(react-components): removed extra changes * chore(react-components): added showing chip in multiple mode * chore(react-components): fix label * chore(react-components): replace handleDeleteAllValues * chore(react-components): add disabled state * chore(react-components): add disabled state * chore(react-components): fix focus trap * chore(react-components): fix focus trap * chore(react-components): fix console error * chore(react-components): add arrow up/down control * chore(react-components): fix jumping in medium size * chore(react-components): add focus trap * chore(react-components): add simple unit test * chore(react-components): update test * chore(react-components): fix clear in read only mode * chore(react-components): remove max-width in tags --------- Co-authored-by: donskov --- .../__snapshots__/autocomplete.test.tsx.snap | 1026 +++++++++++++++++ .../src/Autocomplete/autocomplete.stories.tsx | 131 +++ .../src/Autocomplete/autocomplete.test.tsx | 313 +++++ .../src/Autocomplete/autocomplete.tsx | 735 ++++++++++++ .../src/Autocomplete/index.ts | 1 + packages/react-components/src/hooks/index.ts | 1 + .../src/hooks/use_autocomplete.ts | 55 +- .../src/hooks/use_outside_click.ts | 21 + 8 files changed, 2268 insertions(+), 15 deletions(-) create mode 100644 packages/react-components/src/Autocomplete/__snapshots__/autocomplete.test.tsx.snap create mode 100644 packages/react-components/src/Autocomplete/autocomplete.stories.tsx create mode 100644 packages/react-components/src/Autocomplete/autocomplete.test.tsx create mode 100644 packages/react-components/src/Autocomplete/autocomplete.tsx create mode 100644 packages/react-components/src/Autocomplete/index.ts create mode 100644 packages/react-components/src/hooks/use_outside_click.ts diff --git a/packages/react-components/src/Autocomplete/__snapshots__/autocomplete.test.tsx.snap b/packages/react-components/src/Autocomplete/__snapshots__/autocomplete.test.tsx.snap new file mode 100644 index 00000000..c152e120 --- /dev/null +++ b/packages/react-components/src/Autocomplete/__snapshots__/autocomplete.test.tsx.snap @@ -0,0 +1,1026 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render with default styles 1`] = ` + + .emotion-0 { + outline: none; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + background-color: var(--pv-color-gray-1); + border-style: solid; + border-width: 1px; + -webkit-transition: background-color 200ms,color 200ms,border-color 200ms; + transition: background-color 200ms,color 200ms,border-color 200ms; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-align: left; + font-family: inherit; + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + gap: var(--pv-size-base); + min-height: var(--pv-size-base-8); + min-height: var(--pv-size-base-7); + padding: 1px calc(var(--pv-size-base-2) + 24px) 1px var(--pv-size-base-2); + border-color: var(--pv-color-gray-8); + color: var(--pv-color-black); + cursor: text; +} + +.emotion-0:hover { + background-color: var(--pv-color-gray-3); + border-color: var(--pv-color-gray-7); +} + +.emotion-0[aria-placeholder] { + color: var(--pv-color-gray-9); +} + +.emotion-0[aria-invalid] { + background-color: var(--pv-color-wrong-tint-5); + border-color: var(--pv-color-wrong-tint-3); +} + +.emotion-0:focus-visible { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-0:focus-within { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-1 { + margin: 0; + color: var(--pv-color-black); + font-weight: var(--pv-text-b3-weight); + font-size: var(--pv-text-b3-size); + line-height: var(--pv-text-b3-height); + letter-spacing: var(--pv-text-b3-spacing); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: inherit; + outline: none; + box-sizing: border-box; + min-width: 30px; + width: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + background-color: transparent; + border-style: none; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + color: var(--pv-color-black); +} + +.emotion-1::-webkit-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::-moz-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:-ms-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:disabled { + cursor: not-allowed; + color: var(--pv-color-gray-7); +} + +.emotion-2 { + position: absolute; + right: 0px; + top: calc(50% - 12px); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin: 0px var(--pv-size-base); +} + +.emotion-3 { + color: var(--pv-color-gray-10); +} + +.emotion-3[aria-disabled="true"] { + color: inherit; +} + +.emotion-4 { + bottom: 0; + left: 0; + height: 100%; + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; +} + +
+ +
+
+`; + +exports[` should render with multiple selection enabled 1`] = ` + + .emotion-0 { + outline: none; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + background-color: var(--pv-color-gray-1); + border-style: solid; + border-width: 1px; + -webkit-transition: background-color 200ms,color 200ms,border-color 200ms; + transition: background-color 200ms,color 200ms,border-color 200ms; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-align: left; + font-family: inherit; + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + gap: var(--pv-size-base); + min-height: var(--pv-size-base-8); + min-height: var(--pv-size-base-7); + padding: 1px calc(var(--pv-size-base-2) + 24px) 1px var(--pv-size-base-2); + border-color: var(--pv-color-gray-8); + color: var(--pv-color-black); + cursor: text; +} + +.emotion-0:hover { + background-color: var(--pv-color-gray-3); + border-color: var(--pv-color-gray-7); +} + +.emotion-0[aria-placeholder] { + color: var(--pv-color-gray-9); +} + +.emotion-0[aria-invalid] { + background-color: var(--pv-color-wrong-tint-5); + border-color: var(--pv-color-wrong-tint-3); +} + +.emotion-0:focus-visible { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-0:focus-within { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-1 { + margin: 0; + color: var(--pv-color-black); + font-weight: var(--pv-text-b3-weight); + font-size: var(--pv-text-b3-size); + line-height: var(--pv-text-b3-height); + letter-spacing: var(--pv-text-b3-spacing); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: inherit; + outline: none; + box-sizing: border-box; + min-width: 30px; + width: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + background-color: transparent; + border-style: none; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + color: var(--pv-color-black); +} + +.emotion-1::-webkit-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::-moz-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:-ms-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:disabled { + cursor: not-allowed; + color: var(--pv-color-gray-7); +} + +.emotion-2 { + position: absolute; + right: 0px; + top: calc(50% - 12px); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin: 0px var(--pv-size-base); +} + +.emotion-3 { + color: var(--pv-color-gray-10); +} + +.emotion-3[aria-disabled="true"] { + color: inherit; +} + +.emotion-4 { + bottom: 0; + left: 0; + height: 100%; + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; +} + +
+ +
+
+`; + +exports[` sizes renders with size "large" 1`] = ` + + .emotion-0 { + outline: none; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + background-color: var(--pv-color-gray-1); + border-style: solid; + border-width: 1px; + -webkit-transition: background-color 200ms,color 200ms,border-color 200ms; + transition: background-color 200ms,color 200ms,border-color 200ms; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-align: left; + font-family: inherit; + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + gap: var(--pv-size-base); + min-height: var(--pv-size-base-8); + min-height: var(--pv-size-base-8); + padding: 3px calc(var(--pv-size-base-2) + 24px) 3px var(--pv-size-base-2); + border-color: var(--pv-color-gray-8); + color: var(--pv-color-black); + cursor: text; +} + +.emotion-0:hover { + background-color: var(--pv-color-gray-3); + border-color: var(--pv-color-gray-7); +} + +.emotion-0[aria-placeholder] { + color: var(--pv-color-gray-9); +} + +.emotion-0[aria-invalid] { + background-color: var(--pv-color-wrong-tint-5); + border-color: var(--pv-color-wrong-tint-3); +} + +.emotion-0:focus-visible { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-0:focus-within { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-1 { + margin: 0; + color: var(--pv-color-black); + font-weight: var(--pv-text-b3-weight); + font-size: var(--pv-text-b3-size); + line-height: var(--pv-text-b3-height); + letter-spacing: var(--pv-text-b3-spacing); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: inherit; + outline: none; + box-sizing: border-box; + min-width: 30px; + width: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + background-color: transparent; + border-style: none; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + color: var(--pv-color-black); +} + +.emotion-1::-webkit-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::-moz-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:-ms-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:disabled { + cursor: not-allowed; + color: var(--pv-color-gray-7); +} + +.emotion-2 { + position: absolute; + right: 0px; + top: calc(50% - 12px); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin: 0px var(--pv-size-base); +} + +.emotion-3 { + color: var(--pv-color-gray-10); +} + +.emotion-3[aria-disabled="true"] { + color: inherit; +} + +.emotion-4 { + bottom: 0; + left: 0; + height: 100%; + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; +} + +
+ +
+
+`; + +exports[` sizes renders with size "medium" 1`] = ` + + .emotion-0 { + outline: none; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + background-color: var(--pv-color-gray-1); + border-style: solid; + border-width: 1px; + -webkit-transition: background-color 200ms,color 200ms,border-color 200ms; + transition: background-color 200ms,color 200ms,border-color 200ms; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-align: left; + font-family: inherit; + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + gap: var(--pv-size-base); + min-height: var(--pv-size-base-8); + min-height: var(--pv-size-base-7); + padding: 1px calc(var(--pv-size-base-2) + 24px) 1px var(--pv-size-base-2); + border-color: var(--pv-color-gray-8); + color: var(--pv-color-black); + cursor: text; +} + +.emotion-0:hover { + background-color: var(--pv-color-gray-3); + border-color: var(--pv-color-gray-7); +} + +.emotion-0[aria-placeholder] { + color: var(--pv-color-gray-9); +} + +.emotion-0[aria-invalid] { + background-color: var(--pv-color-wrong-tint-5); + border-color: var(--pv-color-wrong-tint-3); +} + +.emotion-0:focus-visible { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-0:focus-within { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-1 { + margin: 0; + color: var(--pv-color-black); + font-weight: var(--pv-text-b3-weight); + font-size: var(--pv-text-b3-size); + line-height: var(--pv-text-b3-height); + letter-spacing: var(--pv-text-b3-spacing); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: inherit; + outline: none; + box-sizing: border-box; + min-width: 30px; + width: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + background-color: transparent; + border-style: none; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + color: var(--pv-color-black); +} + +.emotion-1::-webkit-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::-moz-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:-ms-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:disabled { + cursor: not-allowed; + color: var(--pv-color-gray-7); +} + +.emotion-2 { + position: absolute; + right: 0px; + top: calc(50% - 12px); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin: 0px var(--pv-size-base); +} + +.emotion-3 { + color: var(--pv-color-gray-10); +} + +.emotion-3[aria-disabled="true"] { + color: inherit; +} + +.emotion-4 { + bottom: 0; + left: 0; + height: 100%; + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; +} + +
+ +
+
+`; + +exports[` sizes renders with size "small" 1`] = ` + + .emotion-0 { + outline: none; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + background-color: var(--pv-color-gray-1); + border-style: solid; + border-width: 1px; + -webkit-transition: background-color 200ms,color 200ms,border-color 200ms; + transition: background-color 200ms,color 200ms,border-color 200ms; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-align: left; + font-family: inherit; + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + gap: var(--pv-size-base); + min-height: var(--pv-size-base-8); + min-height: var(--pv-size-base-6); + padding: 1px calc(var(--pv-size-base-2) + 24px) 1px var(--pv-size-base-2); + border-color: var(--pv-color-gray-8); + color: var(--pv-color-black); + cursor: text; +} + +.emotion-0:hover { + background-color: var(--pv-color-gray-3); + border-color: var(--pv-color-gray-7); +} + +.emotion-0[aria-placeholder] { + color: var(--pv-color-gray-9); +} + +.emotion-0[aria-invalid] { + background-color: var(--pv-color-wrong-tint-5); + border-color: var(--pv-color-wrong-tint-3); +} + +.emotion-0:focus-visible { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-0:focus-within { + background-color: var(--pv-color-secondary-tint-5); + border-color: var(--pv-color-secondary-tint-3); +} + +.emotion-1 { + margin: 0; + color: var(--pv-color-black); + font-weight: var(--pv-text-c1-weight); + font-size: var(--pv-text-c1-size); + line-height: var(--pv-text-c1-height); + letter-spacing: var(--pv-text-c1-spacing); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: inherit; + outline: none; + box-sizing: border-box; + min-width: 30px; + width: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + background-color: transparent; + border-style: none; + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + color: var(--pv-color-black); +} + +.emotion-1::-webkit-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::-moz-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:-ms-input-placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1::placeholder { + color: var(--pv-color-gray-9); +} + +.emotion-1:disabled { + cursor: not-allowed; + color: var(--pv-color-gray-7); +} + +.emotion-2 { + position: absolute; + right: 0px; + top: calc(50% - 12px); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin: 0px var(--pv-size-base); +} + +.emotion-3 { + color: var(--pv-color-gray-10); +} + +.emotion-3[aria-disabled="true"] { + color: inherit; +} + +.emotion-4 { + bottom: 0; + left: 0; + height: 100%; + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + box-sizing: border-box; +} + +
+ +
+
+`; diff --git a/packages/react-components/src/Autocomplete/autocomplete.stories.tsx b/packages/react-components/src/Autocomplete/autocomplete.stories.tsx new file mode 100644 index 00000000..1a7f233f --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Autocomplete } from './index'; + +const top100Films = [ + { title: 'The Shawshank Redemption', year: 1994 }, + { title: 'The Godfather', year: 1972 }, + { title: 'The Godfather: Part II', year: 1974 }, + { title: 'The Dark Knight', year: 2008 }, + { title: '12 Angry Men', year: 1957 }, + { title: "Schindler's List", year: 1993 }, + { title: 'Pulp Fiction', year: 1994 }, + { title: 'The Lord of the Rings: The Return of the King', year: 2003 }, + { title: 'The Good, the Bad and the Ugly', year: 1966 }, + { title: 'Fight Club', year: 1999 }, + { title: 'The Lord of the Rings: The Fellowship of the Ring', year: 2001 }, + { title: 'Star Wars: Episode V - The Empire Strikes Back', year: 1980 }, + { title: 'Forrest Gump', year: 1994 }, + { title: 'Inception', year: 2010 }, + { title: 'The Lord of the Rings: The Two Towers', year: 2002 }, + { title: "One Flew Over the Cuckoo's Nest", year: 1975 }, + { title: 'Goodfellas', year: 1990 }, + { title: 'The Matrix', year: 1999 }, + { title: 'Seven Samurai', year: 1954 }, + { title: 'Star Wars: Episode IV - A New Hope', year: 1977 }, + { title: 'City of God', year: 2002 }, + { title: 'Se7en', year: 1995 }, + { title: 'The Silence of the Lambs', year: 1991 }, + { title: "It's a Wonderful Life", year: 1946 }, + { title: 'Life Is Beautiful', year: 1997 }, + { title: 'The Usual Suspects', year: 1995 }, + { title: 'Léon: The Professional', year: 1994 }, + { title: 'Spirited Away', year: 2001 }, + { title: 'Saving Private Ryan', year: 1998 }, + { title: 'Once Upon a Time in the West', year: 1968 }, + { title: 'American History X', year: 1998 }, + { title: 'Interstellar', year: 2014 }, + { title: 'Casablanca', year: 1942 }, + { title: 'City Lights', year: 1931 }, + { title: 'Psycho', year: 1960 }, + { title: 'The Green Mile', year: 1999 }, + { title: 'The Intouchables', year: 2011 }, + { title: 'Modern Times', year: 1936 }, + { title: 'Raiders of the Lost Ark', year: 1981 }, + { title: 'Rear Window', year: 1954 }, + { title: 'The Pianist', year: 2002 }, + { title: 'The Departed', year: 2006 }, + { title: 'Terminator 2: Judgment Day', year: 1991 }, + { title: 'Back to the Future', year: 1985 }, + { title: 'Whiplash', year: 2014 }, + { title: 'Gladiator', year: 2000 }, + { title: 'Memento', year: 2000 }, + { title: 'The Prestige', year: 2006 }, + { title: 'The Lion King', year: 1994 }, + { title: 'Apocalypse Now', year: 1979 }, + { title: 'Alien', year: 1979 }, + { title: 'Sunset Boulevard', year: 1950 }, + { title: 'Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb', year: 1964 }, + { title: 'The Great Dictator', year: 1940 }, + { title: 'Cinema Paradiso', year: 1988 }, + { title: 'The Lives of Others', year: 2006 }, + { title: 'Grave of the Fireflies', year: 1988 }, + { title: 'Paths of Glory', year: 1957 }, + { title: 'Django Unchained', year: 2012 }, + { title: 'The Shining', year: 1980 }, + { title: 'WALL·E', year: 2008 }, + { title: 'American Beauty', year: 1999 }, + { title: 'The Dark Knight Rises', year: 2012 }, + { title: 'Princess Mononoke', year: 1997 }, + { title: 'Aliens', year: 1986 }, + { title: 'Oldboy', year: 2003 }, + { title: 'Once Upon a Time in America', year: 1984 }, + { title: 'Witness for the Prosecution', year: 1957 }, + { title: 'Das Boot', year: 1981 }, + { title: 'Citizen Kane', year: 1941 }, + { title: 'North by Northwest', year: 1959 }, + { title: 'Vertigo', year: 1958 }, + { title: 'Star Wars: Episode VI - Return of the Jedi', year: 1983 }, + { title: 'Reservoir Dogs', year: 1992 }, + { title: 'Braveheart', year: 1995 }, + { title: 'M', year: 1931 }, + { title: 'Requiem for a Dream', year: 2000 }, + { title: 'Amélie', year: 2001 }, + { title: 'A Clockwork Orange', year: 1971 }, + { title: 'Like Stars on Earth', year: 2007 }, + { title: 'Taxi Driver', year: 1976 }, + { title: 'Lawrence of Arabia', year: 1962 }, + { title: 'Double Indemnity', year: 1944 }, + { title: 'Eternal Sunshine of the Spotless Mind', year: 2004 }, + { title: 'Amadeus', year: 1984 }, + { title: 'To Kill a Mockingbird', year: 1962 }, + { title: 'Toy Story 3', year: 2010 }, + { title: 'Logan', year: 2017 }, + { title: 'Full Metal Jacket', year: 1987 }, + { title: 'Dangal', year: 2016 }, + { title: 'The Sting', year: 1973 }, + { title: '2001: A Space Odyssey', year: 1968 }, + { title: "Singin' in the Rain", year: 1952 }, + { title: 'Toy Story', year: 1995 }, + { title: 'Bicycle Thieves', year: 1948 }, + { title: 'The Kid', year: 1921 }, + { title: 'Inglourious Basterds', year: 2009 }, + { title: 'Snatch', year: 2000 }, + { title: '3 Idiots', year: 2009 }, + { title: 'Monty Python and the Holy Grail', year: 1975 }, +]; + +const meta: Meta = { + title: 'Components/Autocomplete', + // @ts-ignore + component: Autocomplete, + args: { + options: top100Films, + placeholder: 'Select a movie', + getOptionLabel: (option: any) => option.title, + }, + tags: ['autodocs'], + argTypes: { + options: { control: false }, + getOptionLabel: { control: false }, + defaultValue: { control: false }, + value: { control: false }, + filterOptions: { control: false }, + popoverProps: { control: false }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/packages/react-components/src/Autocomplete/autocomplete.test.tsx b/packages/react-components/src/Autocomplete/autocomplete.test.tsx new file mode 100644 index 00000000..a8f671ad --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.test.tsx @@ -0,0 +1,313 @@ +import React from 'react'; +import { + renderWithWrapper as render, + screen, + fireEvent, + act, + waitFor, +} from '../test-utils'; +import { Autocomplete } from './index'; + +describe('', () => { + const options = ['test-1', 'test-2', 'apple', 'banana', 'grape']; + + it('should render with default styles', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render with multiple selection enabled', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('should apply className', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.my-class-name')).toBeTruthy(); + }); + + describe('sizes', () => { + const sizes: Array['size']> = [ + 'small', + 'medium', + 'large', + ]; + + sizes.forEach((size) => { + it(`renders with size "${size}"`, () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + }); + }); + + it('should display loading state', async () => { + const { getByText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + expect(getByText('Loading...')).toBeInTheDocument(); + }); + + it('should show error message when in error state', () => { + const { getByText } = render( + , + ); + + expect(getByText('Error occurred')).toBeInTheDocument(); + }); + + it('should open the dropdown when clicked', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('should handle user typing and show filtered options', async () => { + render( + , + ); + + const input = screen.getByRole('combobox'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'ap' } }); + }); + + expect(screen.getByText('apple')).toBeInTheDocument(); + expect(screen.queryByText('banana')).not.toBeInTheDocument(); + }); + + it('should select option and display it', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('option-1')); + + expect(screen.getByDisplayValue('option-1')).toBeInTheDocument(); + }); + + it('should allow clearing selected option', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('clearable-option')); + + expect(screen.getByDisplayValue('clearable-option')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + + fireEvent.click(screen.getByRole('button', { name: /clear/i })); + expect(screen.queryByDisplayValue('clearable-option')).not.toBeInTheDocument(); + }); + + it('should allow creating new options', async () => { + const handleCreate = jest.fn(); + + render( + , + ); + + await act(async () => { + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'New Option' } }); + }); + + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', code: 'Enter' }); + expect(handleCreate).toHaveBeenCalledWith(expect.any(Object), 'New Option'); + }); + + it('should close the dropdown on Escape key press', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape', code: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + }); + + describe('multiple false', () => { + it('should select only one option', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('option-1')); + + expect(screen.getByDisplayValue('option-1')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('option-2')); + + expect(screen.getByDisplayValue('option-2')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('option-1')).not.toBeInTheDocument(); + }); + + it('should clear the selected option', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('option-1')); + + expect(screen.getByDisplayValue('option-1')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /clear/i })); + }); + + expect(screen.queryByDisplayValue('option-1')).not.toBeInTheDocument(); + }); + }); + + describe('multiple true', () => { + it('should allow selecting multiple options', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + fireEvent.click(screen.getByText('option-1')); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + fireEvent.click(screen.getByText('option-2')); + + expect(screen.getByText('option-1')).toBeInTheDocument(); + expect(screen.getByText('option-2')).toBeInTheDocument(); + }); + + it('should display limitTags when multiple options are selected', () => { + render( + , + ); + + expect(screen.getByText('option-1')).toBeInTheDocument(); + expect(screen.getByText('option-2')).toBeInTheDocument(); + expect(screen.getByText('1 more')).toBeInTheDocument(); + }); + + it('should allow clearing selected options', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(screen.getByText('option-1')); + fireEvent.click(screen.getByText('option-2')); + + await act(async () => { + fireEvent.click(screen.getByRole('combobox')); + }); + + fireEvent.click(screen.getByRole('button', { name: /clear/i })); + expect(screen.queryByText('option-1')).not.toBeInTheDocument(); + expect(screen.queryByText('option-2')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react-components/src/Autocomplete/autocomplete.tsx b/packages/react-components/src/Autocomplete/autocomplete.tsx new file mode 100644 index 00000000..f18a7eee --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.tsx @@ -0,0 +1,735 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { + useAutocomplete, + UseAutocompleteProps, + UseAutocompleteReturnType, + AutocompleteValue, + useOutsideClick, +} from '../hooks'; +import { Popper } from '../Popper'; +import { Typography, TypographyOwnProps } from '../Typography'; +import { Box } from '../Box'; +import { Chip } from '../Chip'; +import { ArrowDropDownIcon, CloseSmallIcon } from '../icons'; +import { MenuItem } from '../MenuList'; + +/** + * Types. + */ +export type AutocompleteRenderGroupParams = { + key: string | number; + group: string; + children?: React.ReactNode; +}; + +export type AutocompleteOwnProps< + T, + Multiple extends boolean | undefined = undefined, +> = UseAutocompleteProps & { + /** + * The className of the component. + */ + className?: string; + /** + * The size of the root component. + */ + size?: ( + 'small' | + 'medium' | + 'large' + ); + /** + * The short hint displayed in the `input` before the user enters a value. + */ + placeholder?: string; + /** + * The label content. + */ + label?: string; + /** + * Text to display when there are no options. + */ + noOptionsText?: React.ReactNode; + /** + * If `true`, the component is in a loading state. + * This shows the `loadingText` in place of suggestions (only if there are no + * suggestions to show, e.g. `options` are empty). + */ + loading?: boolean; + /** + * Text to display when in a loading state. + */ + loadingText?: React.ReactNode; + /** + * The maximum number of tags that will be visible when not focused. + */ + limitTags?: number; + /** + * If `true`, the autocomplete will be disabled. + */ + disabled?: boolean; + /** + * Name attribute of the `input` element. + */ + name?: string; + /** + * If `true`, the `input` element is required. + */ + required?: boolean; + /** + * If `true`, the create button element will be shown. + */ + allowCreateOption?: boolean; + /** + * If `true`, the `input` will indicate an error. + */ + error?: boolean; + errorText?: string; + /** + * Render the root element. + */ + renderRoot?: ( + props: object, + value: AutocompleteValue, + getTagProps: UseAutocompleteReturnType['getTagProps'], + ) => React.ReactNode; + /** + * Render the option, use `getOptionLabel` by default. + */ + renderOption?: (props: object, option: T) => React.ReactNode; + /** + * The label to display when the tags are truncated (`limitTags`). + */ + getLimitTagsText?: (more: number) => string; + /** + * Callback fired when the create button clicked. + */ + onCreate?: (event: React.SyntheticEvent, value: string) => void; +}; +/** + * + */ + +const reactPropsRegex = /^(as|size|disabled|isHasClearIcon)$/; + +/** + * Styles. + */ +const AutocompleteField = styled(Box, { shouldForwardProp: (prop) => !reactPropsRegex.test(prop) })< +TypographyOwnProps +& Required, 'size' | 'disabled'>> +& { isHasClearIcon: boolean } +>( + { + outline: 'none', + boxSizing: 'border-box', + width: '100%', + borderRadius: '4px', + backgroundColor: 'var(--pv-color-gray-1)', + borderStyle: 'solid', + borderWidth: '1px', + transition: 'background-color 200ms, color 200ms, border-color 200ms', + appearance: 'none', + userSelect: 'none', + textAlign: 'left', + fontFamily: 'inherit', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: 'var(--pv-size-base)', + minHeight: 'var(--pv-size-base-8)', + }, + (props) => { + const actions = props.isHasClearIcon ? '48px' : '24px'; + + switch (props.size) { + case 'small': + return { + minHeight: 'var(--pv-size-base-6)', + padding: `1px calc(var(--pv-size-base-2) + ${actions}) 1px var(--pv-size-base-2)`, + }; + + case 'medium': + return { + minHeight: 'var(--pv-size-base-7)', + padding: `1px calc(var(--pv-size-base-2) + ${actions}) 1px var(--pv-size-base-2)`, + }; + + default: + return { + minHeight: 'var(--pv-size-base-8)', + padding: `3px calc(var(--pv-size-base-2) + ${actions}) 3px var(--pv-size-base-2)`, + }; + } + }, + (props) => { + const isDark = props.theme.mode === 'dark'; + const color = isDark + ? 'var(--pv-color-white)' + : 'var(--pv-color-black)'; + let borderColor = 'var(--pv-color-gray-8)'; + let colorPlaceholder = 'var(--pv-color-gray-9)'; + let borderColorHover = 'var(--pv-color-gray-7)'; + let borderColorDisabled = 'var(--pv-color-gray-5)'; + let colorDisabled = 'var(--pv-color-gray-7)'; + let invalidBackgroundColor = 'var(--pv-color-wrong-tint-5)'; + let invalidBorderColor = 'var(--pv-color-wrong-tint-3)'; + let backgroundColorFocus = 'var(--pv-color-secondary-tint-5)'; + let borderColorFocus = 'var(--pv-color-secondary-tint-3)'; + + if (isDark) { + borderColor = 'var(--pv-color-gray-5)'; + colorPlaceholder = 'var(--pv-color-gray-6)'; + borderColorHover = 'var(--pv-color-gray-4)'; + borderColorDisabled = 'var(--pv-color-gray-4)'; + colorDisabled = 'var(--pv-color-gray-4)'; + invalidBackgroundColor = 'var(--pv-color-wrong-shade-4)'; + invalidBorderColor = 'var(--pv-color-wrong-shade-1)'; + backgroundColorFocus = 'var(--pv-color-secondary-shade-4)'; + borderColorFocus = 'var(--pv-color-secondary-shade-1)'; + } + + return ({ + borderColor, + ...(props.disabled && { + cursor: 'not-allowed', + backgroundColor: 'var(--pv-color-gray-1)', + borderColor: borderColorDisabled, + color: colorDisabled, + }), + ...(!props.disabled && { + color, + cursor: 'text', + '&:hover': { + backgroundColor: 'var(--pv-color-gray-3)', + borderColor: borderColorHover, + }, + '&[aria-placeholder]': { + color: colorPlaceholder, + }, + '&[aria-invalid]': { + backgroundColor: invalidBackgroundColor, + borderColor: invalidBorderColor, + }, + '&:focus-visible': { + backgroundColor: backgroundColorFocus, + borderColor: borderColorFocus, + }, + '&:focus-within': { + backgroundColor: backgroundColorFocus, + borderColor: borderColorFocus, + }, + }), + }); + }, +); + +const AutocompleteActions = styled('div')({ + position: 'absolute', + right: '0px', + top: 'calc(50% - 12px)', + display: 'flex', + alignItems: 'center', + margin: '0px var(--pv-size-base)', +}); + +const AutocompleteRemoveIcon = styled(CloseSmallIcon)({ + color: 'var(--pv-color-gray-10)', + cursor: 'pointer', + '&[aria-disabled="true"]': { + color: 'inherit', + pointerEvents: 'none', + }, +}); + +const AutocompleteArrowIcon = styled(ArrowDropDownIcon)<{ open: boolean }>({ + color: 'var(--pv-color-gray-10)', + '&[aria-disabled="true"]': { + color: 'inherit', + }, +}, (props) => ({ + ...(props.open && { + transform: 'rotate(180deg)', + }), +})); + +const AutocompleteNativeInput = styled('input')({ + bottom: 0, + left: 0, + height: '100%', + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + width: '100%', + boxSizing: 'border-box', +}); + +const AutocompleteDropdownStateItem = styled('div')({ + padding: 'var(--pv-size-base-3) var(--pv-size-base-2)', +}); + +const AutocompleteDropdownList = styled('ul')({ + maxHeight: '36vh', + overflowY: 'auto', + margin: 0, + listStyleType: 'none', + position: 'relative', + padding: '10px 0', +}); + +const AutocompleteDropdownGroupName = styled(Typography)( + (props) => ({ + padding: 'var(--pv-size-base-2)', + color: props.theme.mode === 'dark' + ? 'var(--pv-color-gray-6)' + : 'var(--pv-color-gray-9)', + }), +); + +const AutocompleteDropdownGroupList = styled('ul')({ + padding: 0, + listStyleType: 'none', +}); + +const AutocompleteDropdownGroupListItem = styled(MenuItem)>( + (props) => ({ + ...(props.inGroup && { + padding: '0px var(--pv-size-base-2) 0 var(--pv-size-base-3)', + }), + }), +); + +const AutocompletePopover = styled(Popper)( + { + minWidth: 240, + outline: 0, + borderRadius: '4px', + minHeight: '16px', + maxHeight: 'calc(100% - 32px)', + zIndex: 1300, + }, + (props) => { + const isDark = props.theme.mode === 'dark'; + + let backgroundColor = 'var(--pv-color-white)'; + let boxShadow = 'var(--pv-shadow-light-low)'; + + if (isDark) { + backgroundColor = 'var(--pv-color-gray-3)'; + boxShadow = 'var(--pv-shadow-dark-medium)'; + } + + return ({ + backgroundColor, + boxShadow, + }); + }, +); + +const AutocompleteTag = styled(Chip)<{ + size: AutocompleteOwnProps['size'], +}>((props) => ({ + label: 'Autocomplete-tag', + borderRadius: '3px', + margin: 0, + ...(props.size === 'small' && { + height: 'var(--pv-size-base-5)', + }), +})); + +const AutocompleteTagSize = styled(Typography)({ + margin: '0 var(--pv-size-base-2)', +}); + +const AutocompleteInputField = styled(Typography)( + () => ({ + fontFamily: 'inherit', + outline: 'none', + boxSizing: 'border-box', + minWidth: '30px', + width: 0, + flexGrow: 1, + backgroundColor: 'transparent', + borderStyle: 'none', + appearance: 'none', + }), + (props) => { + const isDark = props.theme.mode === 'dark'; + const color = isDark + ? 'var(--pv-color-white)' + : 'var(--pv-color-black)'; + + let colorPlaceholder = 'var(--pv-color-gray-9)'; + let colorDisabled = 'var(--pv-color-gray-7)'; + + if (isDark) { + colorPlaceholder = 'var(--pv-color-gray-6)'; + colorDisabled = 'var(--pv-color-gray-4)'; + } + + return ({ + color, + '&::placeholder': { + color: colorPlaceholder, + }, + '&:disabled': { + cursor: 'not-allowed', + color: colorDisabled, + }, + }); + }, +); + +const AutocompleteError = styled(Typography)({ + marginTop: '2px', +}); + +const AutocompleteLabel = styled('label')({ + label: 'TextField-label', + marginBottom: '2px', + display: 'inline-block', +}); +/** + * + */ + +export const Autocomplete = < + T, + Multiple extends boolean | undefined = false, +>(props: AutocompleteOwnProps): JSX.Element => { + const { + className, + size, + placeholder, + label, + disabled = false, + noOptionsText, + loading, + loadingText, + limitTags = -1, + name, + required, + multiple = false, + readOnly, + error, + errorText, + renderRoot: renderRootProp, + renderOption: renderOptionProp, + getLimitTagsText = (more) => `${more} more`, + groupBy, + onCreate, + } = props; + const { + id, + value, + searchValue, + groupedOptions, + getRootProps, + getInputLabelProps, + getInputProps, + getListboxProps, + getOptionProps, + getPopoverProps, + getTagProps, + getOptionLabel, + getClearProps, + } = useAutocomplete(props); + const { + onChange, + ...otherInputProps + } = getInputProps(); + const { + onClick, + } = getClearProps(); + + const rootProps = getRootProps(); + const popoverProps = getPopoverProps(); + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Wait until IME is settled. + if (event.which !== 229) { + switch (event.key) { + case 'Tab': { + if (popoverProps.open) { + popoverProps.onClose(event); + } + break; + } + case 'Escape': + // Prevent cursor move + event.preventDefault(); + + popoverProps.onClose(event); + break; + case 'Enter': + // Prevent cursor move + event.preventDefault(); + + if (onCreate && !groupedOptions.length) { + onCreate(event, searchValue); + } + + popoverProps.onKeyDown(event); + break; + + default: + popoverProps.onKeyDown(event); + } + } + }; + + const defaultRenderOption: AutocompleteOwnProps['renderOption'] = (propsOption, option) => ( + + {getOptionLabel(option)} + + ); + + const renderGroup = (params: AutocompleteRenderGroupParams) => ( +
  • + + {params.group} + + + {params.children} + +
  • + ); + + const renderValue = () => { + if (!value || (Array.isArray(value) && value.length === 0)) { + return null; + } + + if (Array.isArray(value)) { + const more = (value.length > limitTags && limitTags !== -1) + && !popoverProps.open ? (value.length - limitTags) : 0; + const valueLimits = more > 0 ? value.slice(0, limitTags) : value; + + return ( + <> + {valueLimits.map((v, index) => ( + + {getOptionLabel(v)} + + ))} + {!!more && ( + + {getLimitTagsText(more)} + + )} + + ); + } + + return getOptionLabel(value as T); + }; + + const renderedValue = renderValue(); + const isValueEmpty = renderedValue === null; + const popoverRef = useOutsideClick(popoverProps.onClose); + + const defaultRenderRoot: AutocompleteOwnProps['renderRoot'] = ({ + // @ts-ignore + ref, + ...propsRoot + }, valueRoot) => ( + + {multiple ? ( + <> + {isValueEmpty ? null : renderedValue} + + + ) : ( + + )} + + {!isValueEmpty && !readOnly ? ( + + ) : null} + + + + ); + + const renderOption = renderOptionProp || defaultRenderOption; + const renderRoot = renderRootProp || defaultRenderRoot; + const renderListOption = (option: T, index: number) => { + const optionProps = getOptionProps(option, index); + + return renderOption(optionProps, option); + }; + + return ( +
    + {label && ( + + + {label} + + + )} + {renderRoot( + { + ...rootProps, + disabled, + }, + value, + getTagProps, + )} + {error && errorText && ( + + {errorText} + + )} + +
    + {loading && groupedOptions.length === 0 && ( + + {typeof loadingText === 'string' ? ( + + {loadingText} + + ) : loadingText} + + )} + {groupedOptions.length === 0 && !loading && ( + + {typeof noOptionsText === 'string' ? ( + + {noOptionsText} + + ) : noOptionsText} + + )} + {groupedOptions.length > 0 && ( + + {groupedOptions + // @ts-ignore + .map((option, index) => { + // @ts-ignore + if (groupBy && 'options' in option) { + return renderGroup({ + key: option.key, + group: option.group, + // @ts-ignore + children: option.options.map((option2, index2) => ( + renderListOption(option2, option.index + index2) + )), + }); + } + + return renderListOption(option as T, index); + })} + + )} +
    +
    +
    + ); +}; + +// @ts-ignore +Autocomplete.defaultProps = { + noOptionsText: 'No options', + loading: false, + loadingText: 'Loading...', + required: false, + allowCreateOption: false, + size: 'medium', +}; diff --git a/packages/react-components/src/Autocomplete/index.ts b/packages/react-components/src/Autocomplete/index.ts new file mode 100644 index 00000000..7020b66f --- /dev/null +++ b/packages/react-components/src/Autocomplete/index.ts @@ -0,0 +1 @@ +export { Autocomplete } from './autocomplete'; diff --git a/packages/react-components/src/hooks/index.ts b/packages/react-components/src/hooks/index.ts index 5be5ca6a..c33abf1d 100644 --- a/packages/react-components/src/hooks/index.ts +++ b/packages/react-components/src/hooks/index.ts @@ -21,3 +21,4 @@ export type { export { useEventCallback } from './use_event_callback'; export { useEnhancedEffect } from './use_enhanced_effect'; export { useDebounceCallback } from './use_debounce_callback'; +export { useOutsideClick } from './use_outside_click'; diff --git a/packages/react-components/src/hooks/use_autocomplete.ts b/packages/react-components/src/hooks/use_autocomplete.ts index 8ada1741..14b06734 100644 --- a/packages/react-components/src/hooks/use_autocomplete.ts +++ b/packages/react-components/src/hooks/use_autocomplete.ts @@ -34,8 +34,8 @@ export type AutocompleteGroupedOption = { }; export type UseAutocompleteProps< -T, -Multiple extends boolean | undefined = undefined, + T, + Multiple extends boolean | undefined = undefined, > = { /** * This prop is used to help implement the accessibility logic. @@ -114,8 +114,8 @@ Multiple extends boolean | undefined = undefined, }; export type UseAutocompleteReturnType< -T, -Multiple extends boolean | undefined = undefined, + T, + Multiple extends boolean | undefined = undefined, > = { groupedOptions: ReadonlyArray | ReadonlyArray>; value: AutocompleteValue; @@ -127,7 +127,10 @@ Multiple extends boolean | undefined = undefined, getInputLabelProps: () => React.HTMLAttributes; getRootProps: () => React.HTMLAttributes; getInputProps: () => React.HTMLAttributes; - getClearProps: () => React.HTMLAttributes; + getClearProps: () => { + tabIndex: -1; + onClick: (event: React.SyntheticEvent) => void; + }; getPopoverProps: () => Pick, 'open' | 'anchorEl' | 'onClose' | 'onKeyDown'>; getTagProps: (option: T, index: number) => { key: number; @@ -159,8 +162,8 @@ const defaultFilterOptions: AutocompleteFilterOptionsType = ( }; export function useAutocomplete< -T, -Multiple extends boolean | undefined = false, + T, + Multiple extends boolean | undefined = false, >( props: UseAutocompleteProps, ): UseAutocompleteReturnType { @@ -462,21 +465,40 @@ Multiple extends boolean | undefined = false, } }; - const handleInputChange = (event: React.ChangeEvent) => { - const { value: valueInput } = event.target; + const handleClear = (event: React.SyntheticEvent) => { + event.preventDefault(); - setSearchValue(valueInput); + setSearchValue(''); + const newValue = (multiple ? [] : null) as AutocompleteValue; if (onInputChange) { - onInputChange(event, valueInput); + onInputChange(event, ''); + } + + setValue(newValue); + + if (onChange) { + onChange(event, newValue, { option: null, index: 0 }, 'removeOption'); } }; - const handleClear = (event: React.MouseEvent) => { - setSearchValue(''); + const handleInputChange = (event: React.ChangeEvent) => { + const { value: valueInput } = event.target; - if (onInputChange) { - onInputChange(event, ''); + if (searchValue !== valueInput) { + setSearchValue(valueInput); + + if (onInputChange) { + onInputChange(event, valueInput); + } + } + + if (valueInput === '') { + if (!multiple) { + handleClear(event); + } + } else { + handleOpen(event); } }; @@ -489,6 +511,7 @@ Multiple extends boolean | undefined = false, event.preventDefault(); changeHighlightedIndex(1, 'next', 'keyboard'); + handleOpen(event); break; case 'ArrowUp': @@ -496,6 +519,8 @@ Multiple extends boolean | undefined = false, event.preventDefault(); changeHighlightedIndex(-1, 'previous', 'keyboard'); + handleOpen(event); + break; case 'Enter': diff --git a/packages/react-components/src/hooks/use_outside_click.ts b/packages/react-components/src/hooks/use_outside_click.ts new file mode 100644 index 00000000..ccea36cf --- /dev/null +++ b/packages/react-components/src/hooks/use_outside_click.ts @@ -0,0 +1,21 @@ +import React from 'react'; + +export const useOutsideClick = (callback: Function) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target)) { + callback(); + } + }; + + document.addEventListener('click', handleClick, true); + + return () => { + document.removeEventListener('click', handleClick, true); + }; + }, [ref, callback]); + + return ref; +};