diff --git a/.changeset/yellow-rockets-cry.md b/.changeset/yellow-rockets-cry.md new file mode 100644 index 0000000000..fb5722bfeb --- /dev/null +++ b/.changeset/yellow-rockets-cry.md @@ -0,0 +1,5 @@ +--- +"@kvib/react": minor +--- + +Legger til SearchAsync-komponent - dette gjør at det er mulig med en dropdown i et søkefelt. diff --git a/apps/storybook/stories/components/search/search-async/SearchAsync.mdx b/apps/storybook/stories/components/search/search-async/SearchAsync.mdx new file mode 100644 index 0000000000..455a2c43c1 --- /dev/null +++ b/apps/storybook/stories/components/search/search-async/SearchAsync.mdx @@ -0,0 +1,39 @@ +import { Meta, Canvas, Story, Controls } from "@storybook/blocks"; +import * as SearchAsyncStories from "./SearchAsync.stories"; + + + +# SearchAsync + +SearchAsync er et søkefelt som laster inn søkeresultater fra en liste med objekter. Resultatene blir vist i en dropdown der brukeren kan velge hvilket resultat som skal bli valgt. + +## Ta i bruk + +```jsx +import { SearchAsync } from "@kvib/react"; +``` + +## Props + + + + +## Async søkeresultater + + + +## Søkeresultater med delay + + + +## Standardalternativer + + + +## Størrelser + + + +## Varianter + + diff --git a/apps/storybook/stories/components/search/search-async/SearchAsync.stories.tsx b/apps/storybook/stories/components/search/search-async/SearchAsync.stories.tsx new file mode 100644 index 0000000000..b23aa6c7e7 --- /dev/null +++ b/apps/storybook/stories/components/search/search-async/SearchAsync.stories.tsx @@ -0,0 +1,193 @@ +import { SearchAsync as KvibSearchAsync, Stack as KvibStack, Box, Icon } from "@kvib/react/src"; +import { Meta, StoryObj } from "@storybook/react"; + +const meta: Meta = { + title: "Komponenter/Search/SearchAsync", + component: KvibSearchAsync, + parameters: { + docs: { + story: { inline: true }, + canvas: { sourceState: "shown" }, + }, + a11y: { + // Label warnings + contrast ratio because of chakra wrapper. + disable: true, + }, + }, + argTypes: { + loadOptions: { + control: "text", + }, + handleFromChange: { + control: "text", + }, + placeholder: { + table: { + type: { summary: "string" }, + }, + control: "text", + }, + autoFocus: { + table: { + type: { summary: "boolean" }, + }, + control: "boolean", + }, + debounceTime: { + table: { + type: { summary: "number" }, + }, + control: "number", + }, + className: { + table: { + type: { summary: "string" }, + }, + control: "text", + }, + isClearable: { + table: { + type: { summary: "boolean" }, + }, + control: "boolean", + }, + dropdownIndicator: { + table: { + type: { summary: "Element" }, + }, + control: "text", + }, + size: { + table: { + type: { summary: "sm | md | lg" }, + defaultValue: { summary: "md" }, + }, + options: ["sm", "md", "lg"], + control: { type: "radio" }, + }, + defaultOptions: { + table: { + type: { summary: "OptionsOrGroups>" }, + }, + control: "array", + }, + variant: { + table: { + type: { summary: "outline | filled | flushed | unstyled" }, + defaultValue: { summary: "outline" }, + }, + options: ["outline", "filled", "flushed", "unstyled"], + control: { type: "radio" }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const SearchAsync: Story = { + args: {}, + render: (args) => ( + + + + ), +}; + +const fruits = [ + { label: "Eple", value: "eple" }, + { label: "Banan", value: "banan" }, + { label: "Kirsebær", value: "kirsebær" }, + { label: "Pære", value: "pære" }, + { label: "Svarthyll", value: "svarthyll" }, +]; + +const mockLoadOptions = (inputValue: string, callback: (options: typeof fruits) => void) => { + setTimeout(() => { + const filteredFruits = fruits.filter((fruit) => fruit.label.toLowerCase().includes(inputValue.toLowerCase())); + callback(filteredFruits); + }, 500); +}; + +export const SearchAsyncResults: Story = { + args: { + loadOptions: mockLoadOptions, + handleFromChange: (selectedOption) => { + console.log("Selected Option:", selectedOption); + }, + placeholder: "Søk etter frukt...", + }, + render: (args) => ( + + + + ), +}; + +export const SearchAsyncResultsDebounce: Story = { + args: { + loadOptions: mockLoadOptions, + handleFromChange: (selectedOption) => { + console.log("Selected Option:", selectedOption); + }, + debounceTime: 3000, + placeholder: "Søk etter frukt...", + }, + render: (args) => ( + + + + ), +}; + +export const SearchAsyncDropdownIndicator: Story = { + args: { + loadOptions: mockLoadOptions, + handleFromChange: (selectedOption) => { + console.log("Selected Option:", selectedOption); + }, + dropdownIndicator: , + defaultOptions: fruits, + placeholder: "Søk etter frukt...", + }, + render: (args) => ( + + + + ), +}; + +export const SearchAsyncSizes: Story = { + args: { + loadOptions: mockLoadOptions, + handleFromChange: (selectedOption) => { + console.log("Selected Option:", selectedOption); + }, + placeholder: "Søk etter frukt...", + }, + render: (args) => ( + + + + + + ), +}; + +export const SearchAsyncVariants: Story = { + args: { + loadOptions: mockLoadOptions, + handleFromChange: (selectedOption) => { + console.log("Selected Option:", selectedOption); + }, + placeholder: "Søk etter frukt...", + }, + render: (args) => ( + + + + + + + ), +}; diff --git a/apps/storybook/stories/components/search/Search.mdx b/apps/storybook/stories/components/search/search/Search.mdx similarity index 100% rename from apps/storybook/stories/components/search/Search.mdx rename to apps/storybook/stories/components/search/search/Search.mdx diff --git a/apps/storybook/stories/components/search/Search.stories.tsx b/apps/storybook/stories/components/search/search/Search.stories.tsx similarity index 98% rename from apps/storybook/stories/components/search/Search.stories.tsx rename to apps/storybook/stories/components/search/search/Search.stories.tsx index 2e7094a3f1..bf8ecd6380 100644 --- a/apps/storybook/stories/components/search/Search.stories.tsx +++ b/apps/storybook/stories/components/search/search/Search.stories.tsx @@ -2,7 +2,7 @@ import { Search as KvibSearch } from "@kvib/react/src/search"; import { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { - title: "Komponenter/Search", + title: "Komponenter/Search/Search", component: KvibSearch, parameters: { docs: { diff --git a/package-lock.json b/package-lock.json index 53940d664e..6c05a953b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ }, "apps/storybook": { "name": "@kvib/storybook", - "version": "1.0.0", + "version": "1.0.1", "license": "ISC", "dependencies": { "@storybook/addon-a11y": "^7.0.23" @@ -4310,6 +4310,28 @@ "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", "dev": true }, + "node_modules/@floating-ui/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", + "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "dependencies": { + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.1.tgz", + "integrity": "sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==", + "dependencies": { + "@floating-ui/core": "^1.4.1", + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz", + "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==" + }, "node_modules/@fontsource/mulish": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/mulish/-/mulish-4.5.14.tgz", @@ -10739,8 +10761,7 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -10756,7 +10777,6 @@ "version": "18.2.4", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.4.tgz", "integrity": "sha512-IvAIhJTmKAAJmCIcaa6+5uagjyh+9GvcJ/thPZcw+i+vx+22eHlTy2Q1bJg/prES57jehjebq9DnIhOTtIhmLw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -10772,11 +10792,18 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", + "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "devOptional": true + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "node_modules/@types/semver": { "version": "6.2.3", @@ -12859,6 +12886,26 @@ "node": ">=4" } }, + "node_modules/chakra-react-select": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/chakra-react-select/-/chakra-react-select-4.7.0.tgz", + "integrity": "sha512-BHo4OnLhsfmxMr7ntIL7Sp55zhZKgeU2XOQK/9a0bnnRH6qyd0GMxwII+XnF3TsRHdtrI/2HxdQuX3KKQPFuDg==", + "dependencies": { + "react-select": "5.7.4" + }, + "peerDependencies": { + "@chakra-ui/form-control": "^2.0.0", + "@chakra-ui/icon": "^3.0.0", + "@chakra-ui/layout": "^2.0.0", + "@chakra-ui/media-query": "^3.0.0", + "@chakra-ui/menu": "^2.0.0", + "@chakra-ui/spinner": "^2.0.0", + "@chakra-ui/system": "^2.0.0", + "@emotion/react": "^11.8.1", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -14031,6 +14078,15 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -22635,6 +22691,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/memoizerific": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", @@ -24715,6 +24776,26 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-select": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.7.4.tgz", + "integrity": "sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -24742,6 +24823,21 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -27270,6 +27366,19 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-resize-observer": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", @@ -27997,7 +28106,7 @@ }, "packages/react": { "name": "@kvib/react", - "version": "1.21.1", + "version": "1.21.3", "license": "MIT", "dependencies": { "@chakra-ui/react": "^2.5.1", @@ -28005,6 +28114,7 @@ "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@fontsource/mulish": "^4.5.14", + "chakra-react-select": "^4.7.0", "framer-motion": "^10.5.0", "material-symbols": "^0.5.4" }, diff --git a/packages/react/package.json b/packages/react/package.json index 0ac449b84a..fc44b09c0f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -39,6 +39,7 @@ "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@fontsource/mulish": "^4.5.14", + "chakra-react-select": "^4.7.0", "framer-motion": "^10.5.0", "material-symbols": "^0.5.4" } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8ca4c8b3fc..3f7aa8dd0e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -56,3 +56,4 @@ export * from "./typography"; export * from "./visually-hidden"; export * from "./datepicker"; export * from "./file-upload"; +export * from "./search-async"; diff --git a/packages/react/src/search-async/SearchAsync.tsx b/packages/react/src/search-async/SearchAsync.tsx new file mode 100644 index 0000000000..afcd87d121 --- /dev/null +++ b/packages/react/src/search-async/SearchAsync.tsx @@ -0,0 +1,115 @@ +import { ReactNode, useEffect, useRef } from "react"; +import { Text } from "@kvib/react/src"; +import { AsyncSelect as ReactSearch } from "chakra-react-select"; +import { GroupBase, OptionsOrGroups, SingleValue } from "chakra-react-select"; +import { SizeProp, Variant } from "chakra-react-select/dist/types/types"; + +// Props interface is defined with a generic T to allow flexibility in the data type of options. +export interface Props { + /** Function to fetch/select options based on input. */ + loadOptions: (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void; + + /** Callback for when the selection changes. */ + handleFromChange: (newValue: SingleValue) => void; + + /** Placeholder text for the input field. */ + placeholder?: string; + + /** Determines if the input is focused on mount. */ + autoFocus?: boolean; + + /** Time delay (ms) before invoking `loadOptions`. */ + debounceTime?: number; + + /** Additional CSS class for the component. */ + className?: string; + + /** Allows a clear button in the input. */ + isClearable?: boolean; + + /** Custom JSX for the dropdown indicator. */ + dropdownIndicator?: JSX.Element; + + /** Size of the input (e.g., "small", "medium"). */ + size?: SizeProp; + + /** Default options shown when no input is given. */ + defaultOptions?: OptionsOrGroups>; + + /** Visual style variant of the input. */ + variant?: Variant; +} + +// SearchAsync uses the async version of react-select to fetch and display options. +export const SearchAsync = ({ + loadOptions, + handleFromChange, + placeholder, + debounceTime, + autoFocus, + className, + isClearable = true, + dropdownIndicator, + size, + defaultOptions, + variant, +}: Props) => { + const noOptionsMessage = ({ inputValue }: { inputValue: string }): ReactNode => { + if (inputValue.replaceAll(/\s/g, "").length < 1) { + return null; + } + return Fant ingen resultater; + }; + + // We use our custom useDebounce hook to debounce the loadOptions function, + // ensuring it doesn't get called too frequently. + const loadOptionsDebounce = useDebounce(loadOptions, debounceTime); + + return ( + dropdownIndicator ?? null, + // Only use separator when there is a dropdownindicator + ...(!dropdownIndicator ? { IndicatorSeparator: () => null } : {}), + }} + isClearable={isClearable} + autoFocus={autoFocus} + className={className ? className : ""} + onChange={handleFromChange} + noOptionsMessage={noOptionsMessage} + loadingMessage={() => Laster...} + loadOptions={debounceTime ? loadOptionsDebounce : loadOptions} + blurInputOnSelect={false} + placeholder={placeholder ? placeholder : "Søk her..."} + size={size} + defaultOptions={defaultOptions} + variant={variant} + /> + ); +}; + +type Timer = ReturnType; +type SomeFunction = (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void; + +const useDebounce = (func: SomeFunction, delay = 300) => { + // useRef is used to hold a mutable reference to the timer which doesn't cause re-renders. + const timer = useRef(); + + // useEffect is used to handle cleanup: clearing the timer when the component unmounts. + useEffect(() => { + return () => { + if (!timer.current) return; + clearTimeout(timer.current); + }; + }, []); + + // The debounced function. It resets the timer every time it's called, delaying the execution of the + // original function until the user stops calling the debounced function for a specified duration. + return (inputValue: string, callback: (options: OptionsOrGroups>) => void) => { + const newTimer = setTimeout(() => { + return func(inputValue, callback); + }, delay); + clearTimeout(timer.current); + timer.current = newTimer; + }; +}; diff --git a/packages/react/src/search-async/index.tsx b/packages/react/src/search-async/index.tsx new file mode 100644 index 0000000000..dd82b3b100 --- /dev/null +++ b/packages/react/src/search-async/index.tsx @@ -0,0 +1 @@ +export * from "./SearchAsync";