From e0011a109830876ef2672efb52710d9fa2fab29a Mon Sep 17 00:00:00 2001 From: onesine Date: Thu, 6 Oct 2022 16:06:08 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=98=83=20Group=20option=20feature=20(reso?= =?UTF-8?q?lve=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 +++++++++++++++------ src/components/DisabledItem.tsx | 6 +-- src/components/GroupItem.tsx | 30 ++++++++++++ src/components/Item.tsx | 6 +-- src/components/Options.tsx | 83 ++++++++++++++++++++++++++++----- src/components/SearchInput.tsx | 4 +- src/components/Select.tsx | 24 +++++++--- src/components/type.ts | 9 +++- 8 files changed, 175 insertions(+), 41 deletions(-) create mode 100644 src/components/GroupItem.tsx diff --git a/README.md b/README.md index 6432dda..9d64ea9 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,20 @@ [![](https://img.shields.io/npm/l/react-tailwindcss-select.svg)]() [![](https://img.shields.io/badge/developed%20with-Yarn%202-blue)](https://github.com/yarnpkg/berry) -# React tailwindcss select +# 📦 React tailwindcss select React-tailwindcss-select is a simple component ready to be inserted into your project. This component inspired by [React-select](https://react-select.com) is a select input made with [Tailwindcss](https://tailwindcss.com/) and [React](https://reactjs.com). -## Features -- [x] Select field for a single item -- [x] Selection field for multiple items -- [x] Optional button to clear the field -- [x] Optional search for an item -- [x] Optional deactivation of an option -- [X] TypeScript support -- [ ] Group items -- [ ] Customization of the select field style -- [ ] Fixed Options (multiple items select) -## Why +## Features +- ✅ Select field for a single item +- ✅ Selection field for multiple items +- ✅ Optional button to clear the field +- ✅ Optional search for an item +- ✅ Optional deactivation of an option +- ✅ TypeScript support +- ✅ Group options +- ⬜ Customization of the select field style +- ⬜ Fixed Options (multiple items select) +## Why ❔ A select with the above features is above all indispensable in many projects. On a project using tailwindcss, when I install [react-select](https://react-select.com) or other such packages, the style of the latter is affected by that of [tailwind](https://tailwindcss.com/). Rather than looking for a component that uses [tailwind](https://tailwindcss.com/), I preferred to make my own based on react-select which I like (and also because I generally like to reinvent the wheel 😅). @@ -62,6 +62,7 @@ module.exports = { On a project that does not use tailwind, you need to import the component's CSS as well. To do this use these two codes: `import Select from 'react-tailwindcss-select'` and `import 'react-tailwindcss-select/dist/index.css'` > **Warning** +> > In this case when you don't use tailwind on your project, think about isolating the component and its style so that tailwind doesn't affect the style of the elements in your project. For this, you can use the [shadow dom](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM). Then use react-tailwindcss-select in your app: @@ -147,7 +148,7 @@ This table shows all the options available in react-tailwindcss-select. | `menuIsOpen` | `Boolean` | `false` | Indicates if you want the options menu to be displayed by default. | | `noOptionsMessage` | `String` | `No results found` | Default message when there is no option in the select field. | | [`onChange`](#onChange) | `Function` | | This callback, if present, is triggered when the select field value is modified. | -| [`options`](#options) | `Array` | `[]` | All options available in the select field. | +| [`options`](#options) | `Array` | `[]` | All options or options groups available in the selection field. | | `placeholder` | `String` | `Select...` | The placeholder shown for the select field. | | `searchInputPlaceholder` | `String` | `Search...` | The placeholder shown for the search input field. | | [`value`](#value) | `Object` | `null` | Current value of select field. | @@ -170,6 +171,33 @@ const options = [ {value: "fox", label: "🦊 Fox"} ]; const options = [ {value: "fox", label: "🦊 Fox", disabled: true} ]; ``` +#### Group item +If you want to group options you can use the following code. +```js +const options = [ + { + label: "Mammal", + options: [ + {value: "Dolphin", labe: "🐬 Dolphin"}, + {value: "Giraffe", labe: "🦒 Giraffe"}, + ], + }, + { + label: "Carnivore", + options: [ + {value: "Tiger", labe: "🐅 Tiger"}, + {value: "Lion", labe: "🦁 Lion"}, + ] + }, + // 👉 You can put the grouped and ungrouped options together + {value: "Zombie", labe: "🧟 Zombie"}, +] +``` + +> **Info** +> +> 👉 You can put the grouped and ungrouped options together. + ### value The current value of the select field. These objects must follow the same structure as an `options` element. Thus, the following would work: ```js diff --git a/src/components/DisabledItem.tsx b/src/components/DisabledItem.tsx index f96eca7..bac8e2a 100644 --- a/src/components/DisabledItem.tsx +++ b/src/components/DisabledItem.tsx @@ -1,12 +1,12 @@ import React from 'react' -interface Props { +interface DisabledItemProps { children: JSX.Element | string } -const DisabledItem: React.FC = ({children}) => { +const DisabledItem: React.FC = ({children}) => { return ( -
+
{children}
); diff --git a/src/components/GroupItem.tsx b/src/components/GroupItem.tsx new file mode 100644 index 0000000..0e5140f --- /dev/null +++ b/src/components/GroupItem.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import {GroupOption} from "./type"; +import Item from "./Item"; + +interface GroupItemProps { + item: GroupOption +} + +const GroupItem: React.FC = ({item}) => { + return ( + <> + {item.options.length > 0 && ( + <> +
+ {item.label} +
+ + {item.options.map((item, index) => ( + + ))} + + )} + + ); +}; + +export default GroupItem; \ No newline at end of file diff --git a/src/components/Item.tsx b/src/components/Item.tsx index a277eb8..4aa7c10 100755 --- a/src/components/Item.tsx +++ b/src/components/Item.tsx @@ -3,11 +3,11 @@ import DisabledItem from "./DisabledItem"; import {Option} from "./type"; import {useSelectContext} from "./SelectProvider"; -interface Props { +interface ItemProps { item: Option } -const Item: React.FC = ({item}) => { +const Item: React.FC = ({item}) => { const {value, handleValueChange} = useSelectContext(); const isSelected = useMemo(() => { @@ -25,7 +25,7 @@ const Item: React.FC = ({item}) => { aria-selected={isSelected} role={"option"} onClick={() => handleValueChange(item)} - className={`block transition duration-200 px-2 py-2 cursor-pointer truncate rounded ${isSelected ? 'text-white bg-blue-500' : 'text-gray-500 hover:bg-blue-100 hover:text-blue-500' }`} + className={`block transition duration-200 px-2 py-2 cursor-pointer select-none truncate rounded ${isSelected ? 'text-white bg-blue-500' : 'text-gray-500 hover:bg-blue-100 hover:text-blue-500' }`} > {item.label} diff --git a/src/components/Options.tsx b/src/components/Options.tsx index 57a8bcd..92439de 100755 --- a/src/components/Options.tsx +++ b/src/components/Options.tsx @@ -1,30 +1,72 @@ import React, {useCallback, useMemo} from "react"; import Item from "./Item"; import DisabledItem from "./DisabledItem"; -import {Option} from "./type"; +import {Option, Options as ListOption} from "./type"; +import GroupItem from "./GroupItem"; -interface Props { - list: Option[], +interface OptionsProps { + list: ListOption, noOptionsMessage: string, text: string, isMultiple: boolean, value: Option | Option[] | null, } -const Options: React.FC = ({list, noOptionsMessage, text, isMultiple, value}) => { +const Options: React.FC = ({list, noOptionsMessage, text, isMultiple, value}) => { const filterByText = useCallback(() => { - return list.filter(item => { + const filterItem = (item: Option) => { return item.label.toLowerCase().indexOf(text.toLowerCase()) > -1; + }; + + let result = list.map(item => { + if ("options" in item) { + return { + label: item.label, + options: item.options.filter(filterItem) + } + } + return item; + }); + + result = result.filter(item => { + if ("options" in item) { + return item.options.length > 0; + } + return filterItem(item); }); + + return result; }, [text, list]); - const removeValues = useCallback((array: Option[]) => { + const removeValues = useCallback((array: ListOption) => { if (!isMultiple) { return array; } + if (Array.isArray(value)) { const valueId = value.map(item => item.value); - return array.filter(item => !valueId.includes(item.value)); + + const filterItem = (item: Option) => !valueId.includes(item.value); + + let newArray = array.map(item => { + if ("options" in item) { + return { + label: item.label, + options: item.options.filter(filterItem) + } + } + return item; + }); + + newArray = newArray.filter(item => { + if ("options" in item) { + return item.options.length > 0; + } else { + return filterItem(item); + } + }); + + return newArray; } return array; }, [isMultiple, value]); @@ -34,12 +76,29 @@ const Options: React.FC = ({list, noOptionsMessage, text, isMultiple, val }, [filterByText, removeValues]); return ( -
+
{filterResult.map((item, index) => ( - + + {"options" in item ? ( + <> +
+ +
+ + {index + 1 < filterResult.length && ( +
+ )} + + ) : ( +
+ +
+ )} +
))} {filterResult.length === 0 && ( diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx index 2580d59..58a1c01 100755 --- a/src/components/SearchInput.tsx +++ b/src/components/SearchInput.tsx @@ -1,14 +1,14 @@ import React from 'react' import {SearchIcon} from "./Icons"; -interface Props { +interface SearchInputProps { placeholder?: string, value: string, onChange: (e: React.ChangeEvent) => void, name?: string } -const SearchInput: React.FC = ({placeholder = "", value = "", onChange, name = ""}) => { +const SearchInput: React.FC = ({placeholder = "", value = "", onChange, name = ""}) => { return (
diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 23134f6..aab8ce2 100755 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -4,11 +4,11 @@ import {ChevronIcon, CloseIcon} from "./Icons"; import useOnClickOutside from "../hooks/use-onclick-outside"; import SearchInput from "./SearchInput"; import Options from "./Options"; -import {Option} from "./type"; +import {Option, Options as ListOption} from "./type"; import SelectProvider from "./SelectProvider"; -interface Props { - options: Option[], +interface SelectProps { + options: ListOption, value: Option | Option[] | null, onChange: (value?: Option | Option[] | null) => void, placeholder?: string, @@ -22,21 +22,31 @@ interface Props { noOptionsMessage?: string } - -const Select: React.FC = ({options = [], value = null, onChange, placeholder="Select...", searchInputPlaceholder = "Search...", isMultiple = false, isClearable = false, isSearchable = false, isDisabled = false, loading = false, menuIsOpen = false, noOptionsMessage = "No options found"}) => { +const Select: React.FC = ({options = [], value = null, onChange, placeholder="Select...", searchInputPlaceholder = "Search...", isMultiple = false, isClearable = false, isSearchable = false, isDisabled = false, loading = false, menuIsOpen = false, noOptionsMessage = "No options found"}) => { const [open, setOpen] = useState(menuIsOpen); - const [list, setList] = useState(options); + const [list, setList] = useState(options); const [inputValue, setInputValue] = useState(""); const ref = useRef(null); useEffect(() => { - setList(options.map(item => { + const formatItem = (item: Option) => { if ('disabled' in item) return item; return { ...item, disabled: false } + } + + setList(options.map(item => { + if ("options" in item) { + return { + label: item.label, + options: item.options.map(formatItem) + } + } else { + return formatItem(item); + } })); }, [options]); diff --git a/src/components/type.ts b/src/components/type.ts index 9b947b7..3c110f9 100644 --- a/src/components/type.ts +++ b/src/components/type.ts @@ -2,4 +2,11 @@ export interface Option { value: string, label: string, disabled?: boolean -} \ No newline at end of file +} + +export interface GroupOption { + label: string, + options: Option[] +} + +export type Options = Array