From 22d4ce02ab4a315247afa776eea347bdf2c7e11d Mon Sep 17 00:00:00 2001
From: ellunium <34353493+ellunium@users.noreply.github.com>
Date: Fri, 7 Jun 2024 12:03:42 +0200
Subject: [PATCH] added renderTaglist
Added the renderTagList prop with test, examples and description to readme.
---
example/src/demos/CustomTagList.jsx | 90 ++++++++
example/src/index.html | 326 ++++++++++++++--------------
example/src/main.jsx | 4 +
example/src/styles.css | 44 +++-
readme.md | 19 ++
src/components/ReactTags.tsx | 5 +-
src/components/TagList.tsx | 28 ++-
src/test/ReactTags.test.tsx | 27 +++
8 files changed, 377 insertions(+), 166 deletions(-)
create mode 100644 example/src/demos/CustomTagList.jsx
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}"
+
+ {groupedSuggestions[key].map(({ suggestion, child }) => (
+
+ {child}
+
+ ))}
+
+
+ ))}
+ >
+ )
+ }
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
-
-
-
-
-
-
-
-
-
-
-
- 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(
>
{labelText}
-
+
{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 }) => (