Skip to content

Commit

Permalink
😃 Group option feature (resolve #4)
Browse files Browse the repository at this point in the history
  • Loading branch information
onesine committed Oct 6, 2022
1 parent d84e6a7 commit e0011a1
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 41 deletions.
54 changes: 41 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 😅).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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. |
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/components/DisabledItem.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React from 'react'

interface Props {
interface DisabledItemProps {
children: JSX.Element | string
}

const DisabledItem: React.FC<Props> = ({children}) => {
const DisabledItem: React.FC<DisabledItemProps> = ({children}) => {
return (
<div className={`px-2 py-2 cursor-not-allowed truncate text-gray-400`}>
<div className={`px-2 py-2 cursor-not-allowed truncate text-gray-400 select-none`}>
{children}
</div>
);
Expand Down
30 changes: 30 additions & 0 deletions src/components/GroupItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";
import {GroupOption} from "./type";
import Item from "./Item";

interface GroupItemProps {
item: GroupOption
}

const GroupItem: React.FC<GroupItemProps> = ({item}) => {
return (
<>
{item.options.length > 0 && (
<>
<div className={`pr-2 py-2 cursor-default select-none truncate text-gray-400 font-bold text-gray-700`}>
{item.label}
</div>

{item.options.map((item, index) => (
<Item
key={index}
item={item}
/>
))}
</>
)}
</>
);
};

export default GroupItem;
6 changes: 3 additions & 3 deletions src/components/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({item}) => {
const Item: React.FC<ItemProps> = ({item}) => {
const {value, handleValueChange} = useSelectContext();

const isSelected = useMemo(() => {
Expand All @@ -25,7 +25,7 @@ const Item: React.FC<Props> = ({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}
</li>
Expand Down
83 changes: 71 additions & 12 deletions src/components/Options.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({list, noOptionsMessage, text, isMultiple, value}) => {
const Options: React.FC<OptionsProps> = ({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]);
Expand All @@ -34,12 +76,29 @@ const Options: React.FC<Props> = ({list, noOptionsMessage, text, isMultiple, val
}, [filterByText, removeValues]);

return (
<div role="options" className="max-h-72 px-2.5 overflow-y-auto overflow-y-scroll">
<div role="options" className="max-h-72 overflow-y-auto overflow-y-scroll">
{filterResult.map((item, index) => (
<Item
key={index}
item={item}
/>
<React.Fragment key={index}>
{"options" in item ? (
<>
<div className="px-2.5">
<GroupItem
item={item}
/>
</div>

{index + 1 < filterResult.length && (
<hr className="my-1"/>
)}
</>
) : (
<div className="px-2.5">
<Item
item={item}
/>
</div>
)}
</React.Fragment>
))}

{filterResult.length === 0 && (
Expand Down
4 changes: 2 additions & 2 deletions src/components/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React from 'react'
import {SearchIcon} from "./Icons";

interface Props {
interface SearchInputProps {
placeholder?: string,
value: string,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
name?: string
}

const SearchInput: React.FC<Props> = ({placeholder = "", value = "", onChange, name = ""}) => {
const SearchInput: React.FC<SearchInputProps> = ({placeholder = "", value = "", onChange, name = ""}) => {
return (
<div className="relative py-1 px-2.5">
<SearchIcon className="absolute w-5 h-5 mt-2.5 pb-0.5 ml-2 text-gray-500"/>
Expand Down
24 changes: 17 additions & 7 deletions src/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,21 +22,31 @@ interface Props {
noOptionsMessage?: string
}


const Select: React.FC<Props> = ({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<SelectProps> = ({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<boolean>(menuIsOpen);
const [list, setList] = useState<Option[]>(options);
const [list, setList] = useState<ListOption>(options);
const [inputValue, setInputValue] = useState<string>("");
const ref = useRef<HTMLDivElement>(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]);

Expand Down
9 changes: 8 additions & 1 deletion src/components/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,11 @@ export interface Option {
value: string,
label: string,
disabled?: boolean
}
}

export interface GroupOption {
label: string,
options: Option[]
}

export type Options = Array<Option | GroupOption>

0 comments on commit e0011a1

Please sign in to comment.