Skip to content

Commit fea4ac0

Browse files
authored
feat: create useTagGroup hook (#1665)
* migrate some utils to ts * start useTagGroup * docusaurus changes * change some styles in docusaurus * add focus * focus, delete, add * more updates on active and focus * tests and some fixes * improve styles * improve types * more tests * ids * move utils to ts * fix build ts errors * fix state change types in taggroup * some more import fixes * move test utils to __tests__ * add accessible description * make coverage 100% * change to listbox + tests * cypress test * fix rebase issue * fix rebase issue * improve types support * improve environment type * merge correctly legacy and generated types * do not allow incorrect indeces * remove rollup ts plugin * fix rebase error * export from ts file * export useTagGroup type * revert index * add another docusaurus example * change types export to modules again * fix initial focus * add onChange * wip readme * finish readme * fix title * fix example ts * abstract focus into hook * add migration guide * readme typo and type move * close menu on blur
1 parent 512b533 commit fea4ac0

File tree

127 files changed

+5609
-1723
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

127 files changed

+5609
-1723
lines changed

cypress/e2e/useTagGroup.cy.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
describe('useTagGroup', () => {
2+
const colors = ['Black', 'Red', 'Green', 'Blue', 'Orange']
3+
4+
beforeEach(() => {
5+
cy.visit('/useTagGroup')
6+
7+
// Ensure the listbox exists
8+
cy.findByRole('listbox', {name: /colors example/i}).should('exist')
9+
10+
// Ensure it has 5 color tags
11+
cy.findAllByRole('option').should('have.length', 5)
12+
})
13+
14+
it('clicks a tag and navigates with circular arrow keys', () => {
15+
// Click first tag ("Black")
16+
cy.findByRole('option', {name: /Black/i}).click().should('have.focus')
17+
18+
// Arrow Right navigation through all tags
19+
for (let index = 0; index < colors.length; index++) {
20+
const nextIndex = (index + 1) % colors.length
21+
cy.focused().trigger('keydown', {key: 'ArrowRight'})
22+
cy.findByRole('option', {name: colors[nextIndex]}).should('have.focus')
23+
}
24+
25+
// Arrow Left navigation through all tags (circular)
26+
for (let index = colors.length - 1; index >= 0; index--) {
27+
const prevIndex = (index + colors.length) % colors.length
28+
cy.focused().trigger('keydown', {key: 'ArrowLeft'})
29+
cy.findByRole('option', {name: colors[prevIndex]}).should('have.focus')
30+
}
31+
32+
// Circular on the left.
33+
cy.focused().trigger('keydown', {key: 'ArrowLeft'})
34+
cy.findByRole('option', {name: colors[colors.length - 1]}).should(
35+
'have.focus',
36+
)
37+
})
38+
39+
it('deletes a tag using Delete and Backspace', () => {
40+
// Focus "Red"
41+
cy.findByRole('option', {name: /Red/i}).click()
42+
43+
// Delete key
44+
cy.focused().trigger('keydown', {key: 'Delete'})
45+
cy.findAllByRole('option').should('have.length', 4)
46+
47+
// Next tag should be "Green"
48+
cy.focused().should('contain.text', 'Green')
49+
50+
// Backspace key removes "Green"
51+
cy.focused().trigger('keydown', {key: 'Backspace'})
52+
cy.findAllByRole('option').should('have.length', 3)
53+
54+
// Focus should now be on "Blue"
55+
cy.focused().should('contain.text', 'Blue')
56+
})
57+
58+
it('removes a tag via remove button', () => {
59+
// Remove "Blue" via its remove button
60+
cy.findByRole('option', {name: /Blue/i}).within(() => {
61+
cy.findByRole('button', {name: /remove/i}).click()
62+
})
63+
64+
// Verify 4 tags remain
65+
cy.findAllByRole('option').should('have.length', 4)
66+
67+
// Orange tag should have focus.
68+
cy.findByRole('option', {name: /Orange/i}).should('have.focus')
69+
})
70+
71+
it('adds a tag from the list', () => {
72+
// Focus "Red"
73+
cy.findByRole('option', {name: /Red/i}).click()
74+
75+
// Clicks the Lime option from the add tags list.
76+
cy.findByRole('button', {name: /Lime/i}).click()
77+
78+
// Verify 6 tags are visible
79+
cy.findAllByRole('option').should('have.length', 6)
80+
81+
cy.findByRole('option', {name: /Lime/i}).should('be.visible')
82+
// Including the new option
83+
})
84+
})

docusaurus.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const config = {
3131
blog: false,
3232
pages: {
3333
path: 'docusaurus/pages',
34-
include: ['**/*.{js,jsx}'],
34+
include: ['**/*.{js,jsx,tsx}'],
3535
},
3636
}),
3737
],

docusaurus/pages/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export default function Docs() {
2020
<li>
2121
<a href="./combobox">Downshift</a>
2222
</li>
23+
<li>
24+
<a href="./useTagGroup">useTagGroup</a>
25+
</li>
26+
<li>
27+
<a href="./useTagGroupCombobox">useTagGroupCombobox</a>
28+
</li>
2329
</ul>
2430
</div>
2531
)

docusaurus/pages/useMultipleCombobox.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
colors,
66
containerStyles,
77
menuStyles,
8-
selectedItemsContainerSyles,
9-
selectedItemStyles,
8+
tagGroupSyles,
9+
tagStyles,
1010
} from '../utils'
1111

1212
const initialSelectedItems = [colors[0], colors[1]]
@@ -105,14 +105,14 @@ export default function DropdownMultipleCombobox() {
105105
>
106106
Choose an element:
107107
</label>
108-
<div style={selectedItemsContainerSyles}>
108+
<div style={tagGroupSyles}>
109109
{selectedItems.map(function renderSelectedItem(
110110
selectedItemForRender,
111111
index,
112112
) {
113113
return (
114114
<span
115-
style={selectedItemStyles}
115+
style={tagStyles}
116116
key={`selected-item-${index}`}
117117
{...getSelectedItemProps({
118118
selectedItem: selectedItemForRender,

docusaurus/pages/useMultipleSelect.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
colors,
66
containerStyles,
77
menuStyles,
8-
selectedItemsContainerSyles,
9-
selectedItemStyles,
8+
tagGroupSyles,
9+
tagStyles,
1010
} from '../utils'
1111

1212
const initialSelectedItems = [colors[0], colors[1]]
@@ -76,14 +76,14 @@ export default function DropdownMultipleSelect() {
7676
>
7777
Choose an element:
7878
</label>
79-
<div style={selectedItemsContainerSyles}>
79+
<div style={tagGroupSyles}>
8080
{selectedItems.map(function renderSelectedItem(
8181
selectedItemForRender,
8282
index,
8383
) {
8484
return (
8585
<span
86-
style={selectedItemStyles}
86+
style={tagStyles}
8787
key={`selected-item-${index}`}
8888
{...getSelectedItemProps({
8989
selectedItem: selectedItemForRender,

docusaurus/pages/useTagGroup.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.tag-group {
2+
display: inline-flex;
3+
gap: 8px;
4+
align-items: center;
5+
flex-wrap: wrap;
6+
padding: 6px;
7+
}
8+
9+
.tag {
10+
border: solid 1px darkgreen;
11+
background-color: green;
12+
padding: 0 6px;
13+
margin: 0 2px;
14+
border-radius: 10px;
15+
cursor: default;
16+
}
17+
18+
.tag:hover {
19+
opacity: 0.5;
20+
}
21+
22+
.tag:focus {
23+
background-color: red;
24+
border-color: darkred;
25+
}
26+
27+
.tag-remove-button {
28+
padding: 4px;
29+
cursor: pointer;
30+
border: none;
31+
background-color: transparent;
32+
}
33+
34+
.item-to-add {
35+
cursor: pointer;
36+
}
37+
38+
.selected-tag {
39+
font-style: italic;
40+
}

docusaurus/pages/useTagGroup.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as React from 'react'
2+
3+
import {useTagGroup} from '../../src'
4+
import {colors} from '../utils'
5+
6+
import './useTagGroup.css'
7+
8+
export default function TagGroup() {
9+
const initialItems = colors.slice(0, 5)
10+
const {
11+
addItem,
12+
getTagProps,
13+
getTagRemoveProps,
14+
getTagGroupProps,
15+
items,
16+
activeIndex,
17+
} = useTagGroup({initialItems})
18+
const itemsToAdd = colors.filter(color => !items.includes(color))
19+
20+
return (
21+
<div>
22+
<div
23+
{...getTagGroupProps({'aria-label': 'colors example'})}
24+
className="tag-group"
25+
>
26+
{items.map((color, index) => (
27+
<span
28+
className={`${index === activeIndex ? 'selected-tag' : ''} tag`}
29+
key={color}
30+
{...getTagProps({index, 'aria-label': color})}
31+
>
32+
{color}
33+
<button
34+
className="tag-remove-button"
35+
type="button"
36+
{...getTagRemoveProps({index, 'aria-label': 'remove'})}
37+
>
38+
&#10005;
39+
</button>
40+
</span>
41+
))}
42+
</div>
43+
<div>Add more items:</div>
44+
<ul>
45+
{itemsToAdd.map(item => (
46+
<li key={item}>
47+
<button
48+
className="item-to-add"
49+
tabIndex={0}
50+
onClick={() => {
51+
addItem(item)
52+
}}
53+
onKeyDown={({key}) => {
54+
key === 'Enter' && addItem(item)
55+
}}
56+
>
57+
{item}
58+
</button>
59+
</li>
60+
))}
61+
</ul>
62+
</div>
63+
)
64+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
.wrapper {
2+
width: 18rem;
3+
display: flex;
4+
flex-direction: column;
5+
gap: 0.25rem;
6+
}
7+
8+
.wrapper label {
9+
width: fit-content;
10+
}
11+
12+
.input-wrapper {
13+
display: flex;
14+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
15+
background-color: white;
16+
gap: 0.125rem;
17+
}
18+
19+
.text-input {
20+
width: 100%;
21+
padding: 0.375rem;
22+
}
23+
24+
.toggle-button {
25+
padding-left: 0.5rem;
26+
padding-right: 0.5rem;
27+
}
28+
29+
.menu {
30+
position: absolute;
31+
width: 18rem;
32+
background-color: white;
33+
margin-top: 0.25rem;
34+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
35+
max-height: 20rem;
36+
overflow-y: scroll;
37+
padding: 0;
38+
z-index: 10;
39+
}
40+
41+
.menu.hidden {
42+
display: none;
43+
}
44+
45+
.menu-item {
46+
padding: 0.5rem 0.75rem;
47+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
48+
display: flex;
49+
flex-direction: column;
50+
}
51+
52+
.menu-item.highlighted {
53+
background-color: #93c5fd;
54+
}

0 commit comments

Comments
 (0)