diff --git a/example/src/demos/CustomTagList.jsx b/example/src/demos/CustomTagList.jsx new file mode 100644 index 0000000..02b50c1 --- /dev/null +++ b/example/src/demos/CustomTagList.jsx @@ -0,0 +1,90 @@ +import React, { useCallback, useState } from 'react' +import { ReactTags } from '../../../src' +import { suggestions } from '../countries' + +function isValid(value) { + return /^[a-z]{4,12}$/i.test(value) +} + +export function CustomTagList() { + const [selected, setSelected] = useState([]) + + const onAdd = useCallback( + (newTag) => { + setSelected([...selected, newTag]) + }, + [selected] + ) + + const onDelete = useCallback( + (tagIndex) => { + setSelected(selected.filter((_, i) => i !== tagIndex)) + }, + [selected] + ) + + const onValidate = useCallback((value) => isValid(value), []) + + function getTagByTagKey(key, child) { + return { + suggestion: suggestions.find( + (suggestion) => `${suggestion.value}-${suggestion.label}` === key + ), + child, + } + } + + function groupChildrenByFirstCharacter(mappedSuggestions) { + return mappedSuggestions.reduce((acc, { suggestion, child }) => { + if (suggestion) { + const firstChar = suggestion.label.charAt(0).toUpperCase() + if (!acc[firstChar]) { + acc[firstChar] = [] + } + acc[firstChar].push({ suggestion, child }) + } + return acc + }, {}) + } + + function CustomTagList({ children, label, classNames, listRef }) { + const mappedSuggestions = children.map((child) => getTagByTagKey(child.key, child)) + const groupedSuggestions = groupChildrenByFirstCharacter(mappedSuggestions) + + return ( + <> + {Object.keys(groupedSuggestions).map((key) => ( +
+

Countries starting with the letter "{key}"

+ +
+ ))} + + ) + } + + return ( + <> +

Select the countries you have visited below. They will be grouped alphabetically:

+ + + ) +} diff --git a/example/src/index.html b/example/src/index.html index 89d0cdd..6e6ec62 100644 --- a/example/src/index.html +++ b/example/src/index.html @@ -1,164 +1,172 @@ - - - - - React Tags - - - - - - - - - -
-

React Tags

+ + + + + + React Tags + + + + + + + + + + +
+

React Tags

+

+ React Tag Autocomplete is a simple, accessible, tagging component ready to drop into your + React projects ⚛️ +

+
+

Examples

+
+
+
+
+
+

Custom tag list

+
+
+
+
+

Custom tags

+
+
+
+
+

Custom validity

+
+
+
+
+

Async suggestions

+
+
+
+
+

Using the API

+
+
+
+
+

Documentation

- React Tag Autocomplete is a simple, accessible, tagging component ready to drop into your - React projects ⚛️ + Please refer to + the project readme + for detailed usage information.

-
-

Examples

-
-
-
-
-
-

Custom tags

-
-
-
-
-

Custom validity

-
-
-
-
-

Async suggestions

-
-
-
-
-

Using the API

-
-
-
-
-

Documentation

-

- Please refer to - the project readme - for detailed usage information. -

-
-
- - - + +
+ + + + diff --git a/example/src/main.jsx b/example/src/main.jsx index 97d542d..7c5c39b 100644 --- a/example/src/main.jsx +++ b/example/src/main.jsx @@ -1,6 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { CustomTags } from './demos/CustomTags' +import { CustomTagList } from './demos/CustomTagList' import { CountrySelector } from './demos/CountrySelector' import { CustomValidity } from './demos/CustomValidity' import { UsingTheAPI } from './demos/UsingTheAPI' @@ -9,6 +10,9 @@ import { AsyncSuggestions } from './demos/AsyncSuggestions' // HACK: Wait for onload to ensure styles are loaded due to a bug with Safari // window.onload = () => { + const container0 = ReactDOM.createRoot(document.getElementById('demo-0')) + container0.render() + const container1 = ReactDOM.createRoot(document.getElementById('demo-1')) container1.render() diff --git a/example/src/styles.css b/example/src/styles.css index 8820a6f..9e907bb 100644 --- a/example/src/styles.css +++ b/example/src/styles.css @@ -69,7 +69,20 @@ display: inline-block; width: 0.65rem; height: 0.65rem; - clip-path: polygon(10% 0, 0 10%, 40% 50%, 0 90%, 10% 100%, 50% 60%, 90% 100%, 100% 90%, 60% 50%, 100% 10%, 90% 0, 50% 40%); + clip-path: polygon( + 10% 0, + 0 10%, + 40% 50%, + 0 90%, + 10% 100%, + 50% 60%, + 90% 100%, + 100% 90%, + 60% 50%, + 100% 10%, + 90% 0, + 50% 40% + ); margin-left: 0.5rem; font-size: 0.875rem; background-color: #7c7d86; @@ -119,7 +132,9 @@ background: #ffffff; border: 1px solid #afb8c1; border-radius: 6px; - box-shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -4px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + box-shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -4px, + rgba(0, 0, 0, 0.05) 0 4px 6px -2px; } .react-tags__listbox-option { @@ -154,3 +169,28 @@ .react-tags__listbox-option-highlight { background-color: #ffdd00; } + +.tag-group { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + padding: 8px; + gap: 8px; + background-color: #00000003; + margin: 0.25rem 0.5rem 0.5rem 0.25rem; + justify-content: flex-start; + border: 1px solid #e2e2e2; + align-items: flex-start; + border-radius: 4px; +} + +.tag-group ul { + margin: 0; +} + +.tag-group > h2 { + font-size: 1rem; + line-height: 1.5rem; + color: #00000080; +} diff --git a/readme.md b/readme.md index 1b25255..5e49b16 100644 --- a/readme.md +++ b/readme.md @@ -105,6 +105,7 @@ function CountrySelector() { - [`renderListBox`](#renderListBox-optional) - [`renderOption`](#renderOption-optional) - [`renderRoot`](#renderRoot-optional) +- [`renderTagList`](#renderTagList-optional) - [`renderTag`](#renderTag-optional) - [`selected`](#selected-optional) - [`suggestions`](#suggestions-optional) @@ -377,6 +378,24 @@ function CustomRoot({ children, classNames, isActive, isDisabled, isInvalid, ... } ``` +#### renderTagList (optional) + +A custom tag list component to render. Receives the list object, required tag list element attributes, and [`classNames`](#classNames-optional) as props. Defaults to `null`. + +```jsx +function CustomTagList({ children, label, classNames, listRef }) { + return ( +
    + {children.map((child) => ( +
  • + {child} +
  • + ))} +
+ ) +} +``` + #### renderTag (optional) A custom selected tag component to render. Receives the selected tag object, required tag element attributes, and [`classNames`](#classNames-optional) as props. Defaults to `null`. diff --git a/src/components/ReactTags.tsx b/src/components/ReactTags.tsx index fcb2cd8..082a057 100644 --- a/src/components/ReactTags.tsx +++ b/src/components/ReactTags.tsx @@ -23,6 +23,7 @@ import type { OptionRenderer, RootRenderer, TagRenderer, + TagListRenderer, } from '.' import type { ClassNames, @@ -99,6 +100,7 @@ type ReactTagsProps = { renderOption?: OptionRenderer renderRoot?: RootRenderer renderTag?: TagRenderer + renderTagList?: TagListRenderer selected: TagSelected[] suggestions: TagSuggestion[] suggestionsTransform?: SuggestionsTransform @@ -143,6 +145,7 @@ function ReactTags( renderOption, renderRoot, renderTag, + renderTagList, selected = [], suggestions = [], suggestionsTransform = matchTagsPartial, @@ -200,7 +203,7 @@ function ReactTags( > - + {managerRef.current.state.selected.map((tag, index) => ( ))} diff --git a/src/components/TagList.tsx b/src/components/TagList.tsx index 4d61d6a..261d502 100644 --- a/src/components/TagList.tsx +++ b/src/components/TagList.tsx @@ -2,16 +2,23 @@ import React, { useContext } from 'react' import { useTagList } from '../hooks' import { GlobalContext } from '../contexts' import type { TagProps } from './' +import type { ClassNames } from '../sharedTypes' -export type TagListProps = { +type TagListRendererProps = React.ComponentPropsWithoutRef<'ul'> & { children: React.ReactElement[] + classNames: ClassNames label: string + listRef: React.MutableRefObject } -export function TagList({ children, label }: TagListProps): JSX.Element { - const { classNames } = useContext(GlobalContext) - const { listRef } = useTagList() +export type TagListRenderer = (props: TagListRendererProps) => JSX.Element +const DefaultTagList: TagListRenderer = ({ + children, + label, + classNames, + listRef, +}: TagListRendererProps) => { return (
    {children.map((child) => ( @@ -22,3 +29,16 @@ export function TagList({ children, label }: TagListProps): JSX.Element {
) } + +export type TagListProps = { + children: React.ReactElement[] + label: string + render?: TagListRenderer +} + +export function TagList({ children, label, render = DefaultTagList }: TagListProps): JSX.Element { + const { classNames } = useContext(GlobalContext) + const { listRef } = useTagList() + + return render({ classNames, children, label, listRef }) +} diff --git a/src/test/ReactTags.test.tsx b/src/test/ReactTags.test.tsx index c7ed781..4175da7 100644 --- a/src/test/ReactTags.test.tsx +++ b/src/test/ReactTags.test.tsx @@ -924,6 +924,33 @@ describe('React Tags Autocomplete', () => { }) }) + it('renders a custom tag list component when provided', () => { + const renderer: Harness['props']['renderTagList'] = ({ + children, + label, + classNames, + listRef, + }) => ( +
    + {children.map((child) => ( +
  • + {child} +
  • + ))} +
+ ) + + harness = new Harness({ renderTagList: renderer, selected: [{ ...suggestions[10] }] }) + + expect(harness.selectedList.id).toBe('custom-tag-list') + }) + it('renders custom selected tag components when provided', () => { const renderer: Harness['props']['renderTag'] = ({ classNames, tag, ...props }) => (