Skip to content

Commit b62fe05

Browse files
authored
feat: v8 (#1509)
BREAKING CHANGE: Release Downshift v8. ## PRs included: #1440 #1445 #1463 #1510 #1482 ## Changes These changes will also be covered in the [V8 migration guide](https://github.com/downshift-js/downshift/blob/master/src/hooks/MIGRATION_V8.md). ### isItemDisabled Both `useCombobox` and `useSelect` now support the `isItemDisabled` function. This new API is used to mark menu items as disabled, and as such remove the from the navigation and prevent them from being selected. The old API required passing the `disabled` prop to the `getItemProps` function. This old API has been removed and you will receive a console warning if you are trying to use the `disabled` prop in getItemProps. Example of API migration: Old: ```jsx const items = [{value: 'item1'}, {value: 'item2'}] const {getInputProps, ...rest} = useCombobox({items}) return ( // ... rest <li {...getItemProps({item, disabled: item.value === 'item2'})}> ) ``` New: ```jsx const items = [{value: 'item1'}, {value: 'item2'}] const {getInputProps, ...rest} = useCombobox({items, isItemDisabled(item, _index) { return item.value === 'item2' }}) return ( // ... rest <li {...getItemProps({item})}> ) ``` The API for Downshift remains unchange. ### useCombobox input click [ARIA 1.2](combobox-aria-example) recommends to toggle the menu open state at input click. Previously, in v7, the menu was opened on receiving focus, from both mouse and keyboard. Starting with v8, input focus will not trigger any state change anymore. Only the input click event will be handled and will trigger a menu toggle. Consequently: - getInputProps **will not** return any _Focus_ event handler. - getInputProps **will** return a _Click_ event handler. - `useCombobox.stateChangeTypes.InputFocus` has been removed. - `useCombobox.stateChangeTypes.InputClick` has been added instead. We recommend having the default toggle on input click behaviour as it's part of the official ARIA combobox 1.2 spec, but if you wish to override it and not toggle the menu on click, use the stateReducer: ```js function stateReducer(state, actionAndChanges) { const {changes, type} = actionAndChanges switch (type) { case useCombobox.stateChangeTypes.InputClick: return { ...changes, isOpen: state.isOpen, // do not toggle the menu when input is clicked. } default: return changes } } ``` If you want to return to the v7 behaviour and open the menu on focus, keep the reducer above so you remove the toggle behaviour, and call the _openMenu_ imperative function, returned by useCombobox, in a _onFocus_ handler passed to _getInputProps_: ```js <input {...getInputProps({ onFocus() { openMenu() }, })} /> ``` Consider to use the default 1.2 ARIA behaviour provided by default in order to align your widget to the accessibility official spec. This behaviour consistency improves the user experience, since all comboboxes should behave the same and there won't be need for any additional guess work done by your users. ### Getter props return value types Previously, the return value from the getter props returned by both Downshift and the hooks was `any`. In v8 we improved the types in order to reflect what is actually returned: the default values return by the getter prop function and whatever else the user passes as arguments. The type changes are done in [this PR](#1482), make sure you adapt your TS code, if applicable. Also, in the `Downshift` component, the return values for some getter prop values have changed from `null` to `undefined`, since that is what HTML elements expect (value or undefined). These values are also reflected in the TS types. - getRootProps: 'aria-owns': isOpen ? this.menuId : ~~null~~undefined, - getInputProps: - 'aria-controls': isOpen ? this.menuId : ~~null~~undefined - 'aria-activedescendant': isOpen && typeof highlightedIndex === 'number' && highlightedIndex >= 0 ? this.getItemId(highlightedIndex) : ~~null~~undefined - getMenuProps: props && props['aria-label'] ? ~~null~~undefined : this.labelId, ### environment propTypes The `environment` prop is useful when you are using downshift in a context different than regular DOM. Its TS type has been updated to contain `Node` and the propTypes have also been updated to reflect the properties which are required by downshift from `environment`. [combobox-aria-example]: https://www.w3.org/WAI/ARIA/apg/example-index/combobox/combobox-autocomplete-list.html
1 parent 57f8690 commit b62fe05

Some content is hidden

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

44 files changed

+2500
-1374
lines changed

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,8 @@ const ui = (
10431043
Allows reseting the internal id counter which is used to generate unique ids for
10441044
Downshift component.
10451045

1046+
**This is unnecessary if you are using React 18 or newer**
1047+
10461048
You should never need to use this in the browser. Only if you are running an
10471049
universal React app that is rendered on the server you should call
10481050
[resetIdCounter](#resetidcounter) before every render so that the ids that get
@@ -1506,8 +1508,7 @@ MIT
15061508
https://courses.reacttraining.com/courses/advanced-react/lectures/3172720
15071509
[react-training]: https://reacttraining.com/
15081510
[advanced-react]: https://courses.reacttraining.com/courses/enrolled/200086
1509-
[use-a-render-prop]:
1510-
https://medium.com/@mjackson/use-a-render-prop-50de598f11ce
1511+
[use-a-render-prop]: https://medium.com/@mjackson/use-a-render-prop-50de598f11ce
15111512
[semver]: http://semver.org/
15121513
[hooks-readme]: https://github.com/downshift-js/downshift/blob/master/src/hooks
15131514
[useselect-readme]:

other/ssr/__tests__/index.js

+20-20
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import * as React from 'react'
22
import * as ReactDOMServer from 'react-dom/server'
33
import Downshift, {resetIdCounter} from '../../../src'
44

5-
// something to commit
6-
75
test('does not throw an error when server rendering', () => {
86
expect(() => {
97
ReactDOMServer.renderToString(
@@ -19,25 +17,27 @@ test('does not throw an error when server rendering', () => {
1917
}).not.toThrow()
2018
})
2119

22-
test('resets idCounter', () => {
23-
const getRenderedString = () => {
24-
resetIdCounter()
25-
return ReactDOMServer.renderToString(
26-
<Downshift id="my-autocomplete-component">
27-
{({getInputProps, getLabelProps}) => (
28-
<div>
29-
<label {...getLabelProps()} />
30-
<input {...getInputProps()} />
31-
</div>
32-
)}
33-
</Downshift>,
34-
)
35-
}
20+
if (!('useId' in React)) {
21+
test('resets idCounter', () => {
22+
const getRenderedString = () => {
23+
resetIdCounter()
24+
return ReactDOMServer.renderToString(
25+
<Downshift id="my-autocomplete-component">
26+
{({getInputProps, getLabelProps}) => (
27+
<div>
28+
<label {...getLabelProps()} />
29+
<input {...getInputProps()} />
30+
</div>
31+
)}
32+
</Downshift>,
33+
)
34+
}
3635

37-
const firstRun = getRenderedString()
38-
const secondRun = getRenderedString()
36+
const firstRun = getRenderedString()
37+
const secondRun = getRenderedString()
3938

40-
expect(firstRun).toBe(secondRun)
41-
})
39+
expect(firstRun).toBe(secondRun)
40+
})
41+
}
4242

4343
/* eslint jsx-a11y/label-has-for:0 */

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@testing-library/user-event": "^14.4.3",
9696
"@types/jest": "^26.0.24",
9797
"@types/react": "^17.0.15",
98+
"@types/react-native": "^0.71.3",
9899
"@typescript-eslint/eslint-plugin": "^4.28.5",
99100
"@typescript-eslint/parser": "^4.28.5",
100101
"babel-plugin-macros": "^3.1.0",

src/__tests__/__snapshots__/set-a11y-status.js.snap

+21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`creates new status div if there is none 1`] = `
4+
Array [
5+
Array [
6+
id,
7+
a11y-status-message,
8+
],
9+
Array [
10+
role,
11+
status,
12+
],
13+
Array [
14+
aria-live,
15+
polite,
16+
],
17+
Array [
18+
aria-relevant,
19+
additions text,
20+
],
21+
]
22+
`;
23+
324
exports[`does add anything for an empty string 1`] = `
425
<div
526
aria-live=polite

src/__tests__/downshift.aria.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Downshift from '../'
44
import {resetIdCounter} from '../utils'
55

66
beforeEach(() => {
7-
resetIdCounter()
7+
if (!('useId' in React)) resetIdCounter()
88
})
99

1010
test('basic snapshot', () => {

src/__tests__/downshift.props.js

+4
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,12 @@ test('uses given environment', () => {
8484
const environment = {
8585
addEventListener: jest.fn(),
8686
removeEventListener: jest.fn(),
87+
Node,
8788
document: {
8889
getElementById: jest.fn(() => document.createElement('div')),
90+
createElement: jest.fn(),
91+
body: {},
92+
activeElement: {},
8993
},
9094
}
9195
const {unmount, setHighlightedIndex} = setup({environment})

src/__tests__/set-a11y-status.js

+35
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,41 @@ test('performs cleanup after a timeout', () => {
3636
expect(document.body.firstChild).toMatchSnapshot()
3737
})
3838

39+
test('creates new status div if there is none', () => {
40+
const statusDiv = {setAttribute: jest.fn(), style: {}}
41+
const document = {
42+
getElementById: jest.fn(() => null),
43+
createElement: jest.fn().mockReturnValue(statusDiv),
44+
body: {
45+
appendChild: jest.fn(),
46+
},
47+
}
48+
49+
const setA11yStatus = setup()
50+
setA11yStatus('hello', document)
51+
52+
expect(document.getElementById).toHaveBeenCalledTimes(1)
53+
expect(document.getElementById).toHaveBeenCalledWith('a11y-status-message')
54+
expect(document.createElement).toHaveBeenCalledTimes(1)
55+
expect(document.createElement).toHaveBeenCalledWith('div')
56+
expect(statusDiv.setAttribute).toHaveBeenCalledTimes(4)
57+
expect(statusDiv.setAttribute.mock.calls).toMatchSnapshot()
58+
expect(statusDiv.style).toEqual({
59+
border: '0',
60+
clip: 'rect(0 0 0 0)',
61+
height: '1px',
62+
margin: '-1px',
63+
overflow: 'hidden',
64+
padding: '0',
65+
position: 'absolute',
66+
width: '1px',
67+
})
68+
expect(document.body.appendChild).toHaveBeenCalledTimes(1)
69+
expect(document.body.appendChild).toHaveBeenCalledWith(statusDiv)
70+
// eslint-disable-next-line jest-dom/prefer-to-have-text-content
71+
expect(statusDiv.textContent).toEqual('hello')
72+
})
73+
3974
function setup() {
4075
jest.resetModules()
4176
return require('../set-a11y-status').default
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {getHighlightedIndex} from '../utils'
2+
3+
test('should return next index', () => {
4+
const offset = 1
5+
const start = 0
6+
const items = {length: 3}
7+
function isItemDisabled() {
8+
return false
9+
}
10+
11+
expect(getHighlightedIndex(start, offset, items, isItemDisabled)).toEqual(1)
12+
})
13+
14+
test('should return previous index', () => {
15+
const offset = -1
16+
const start = 2
17+
const items = {length: 3}
18+
function isItemDisabled() {
19+
return false
20+
}
21+
22+
expect(getHighlightedIndex(start, offset, items, isItemDisabled)).toEqual(1)
23+
})
24+
25+
test('should return previous index based on moveAmount', () => {
26+
const offset = -2
27+
const start = 2
28+
const items = {length: 3}
29+
function isItemDisabled() {
30+
return false
31+
}
32+
33+
expect(getHighlightedIndex(start, offset, items, isItemDisabled)).toEqual(0)
34+
})
35+
36+
test('should wrap to first if circular and reached end', () => {
37+
const offset = 3
38+
const start = 2
39+
const items = {length: 3}
40+
function isItemDisabled() {
41+
return false
42+
}
43+
const circular = true
44+
45+
expect(
46+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
47+
).toEqual(0)
48+
})
49+
50+
test('should not wrap to first if not circular and reached end', () => {
51+
const offset = 3
52+
const start = 2
53+
const items = {length: 3}
54+
function isItemDisabled() {
55+
return false
56+
}
57+
const circular = false
58+
59+
expect(
60+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
61+
).toEqual(2)
62+
})
63+
64+
test('should wrap to last if circular and reached start', () => {
65+
const offset = -3
66+
const start = 2
67+
const items = {length: 3}
68+
function isItemDisabled() {
69+
return false
70+
}
71+
const circular = true
72+
73+
expect(
74+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
75+
).toEqual(2)
76+
})
77+
78+
test('should not wrap to last if not circular and reached start', () => {
79+
const offset = -3
80+
const start = 2
81+
const items = {length: 3}
82+
function isItemDisabled() {
83+
return false
84+
}
85+
const circular = false
86+
87+
expect(
88+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
89+
).toEqual(0)
90+
})
91+
92+
test('should skip disabled when moving downwards', () => {
93+
const offset = 1
94+
const start = 0
95+
const items = {length: 3}
96+
function isItemDisabled(_item, index) {
97+
return index === 1
98+
}
99+
100+
expect(getHighlightedIndex(start, offset, items, isItemDisabled)).toEqual(2)
101+
})
102+
103+
test('should skip disabled when moving upwards', () => {
104+
const offset = -1
105+
const start = 2
106+
const items = {length: 3}
107+
function isItemDisabled(_item, index) {
108+
return index === 1
109+
}
110+
111+
expect(getHighlightedIndex(start, offset, items, isItemDisabled)).toEqual(0)
112+
})
113+
114+
test('should skip disabled and wrap to last if circular when reaching first', () => {
115+
const offset = -1
116+
const start = 1
117+
const items = {length: 3}
118+
function isItemDisabled(_item, index) {
119+
return index === 0
120+
}
121+
const circular = true
122+
123+
expect(
124+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
125+
).toEqual(2)
126+
})
127+
128+
test('should skip disabled and wrap to second to last if circular when reaching first and last is disabled', () => {
129+
const offset = -1
130+
const start = 1
131+
const items = {length: 3}
132+
function isItemDisabled(_item, index) {
133+
return [0, 2].includes(index)
134+
}
135+
const circular = true
136+
137+
expect(
138+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
139+
).toEqual(1)
140+
})
141+
142+
test('should skip disabled and not wrap to last if circular when reaching first', () => {
143+
const offset = -1
144+
const start = 1
145+
const items = {length: 3}
146+
function isItemDisabled(_item, index) {
147+
return index === 0
148+
}
149+
const circular = false
150+
151+
expect(
152+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
153+
).toEqual(1)
154+
})
155+
156+
test('should skip disabled and wrap to first if circular when reaching last', () => {
157+
const offset = 1
158+
const start = 1
159+
const items = {length: 3}
160+
function isItemDisabled(_item, index) {
161+
return index === 2
162+
}
163+
const circular = true
164+
165+
expect(
166+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
167+
).toEqual(0)
168+
})
169+
170+
test('should skip disabled and wrap to second if circular when reaching last and first is disabled', () => {
171+
const offset = 1
172+
const start = 1
173+
const items = {length: 3}
174+
function isItemDisabled(_item, index) {
175+
return [0, 2].includes(index)
176+
}
177+
const circular = true
178+
179+
expect(
180+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
181+
).toEqual(1)
182+
})
183+
184+
test('should skip disabled and not wrap to first if circular when reaching last', () => {
185+
const offset = 1
186+
const start = 1
187+
const items = {length: 3}
188+
function isItemDisabled(_item, index) {
189+
return index === 2
190+
}
191+
const circular = false
192+
193+
expect(
194+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
195+
).toEqual(1)
196+
})
197+
198+
test('should not select any if all disabled when arrow up', () => {
199+
const offset = -1
200+
const start = -1
201+
const items = {length: 3}
202+
function isItemDisabled() {
203+
return true
204+
}
205+
const circular = true
206+
207+
expect(
208+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
209+
).toEqual(-1)
210+
})
211+
212+
test('should not select any if all disabled when arrow down', () => {
213+
const offset = 1
214+
const start = -1
215+
const items = {length: 3}
216+
function isItemDisabled() {
217+
return true
218+
}
219+
const circular = true
220+
221+
expect(
222+
getHighlightedIndex(start, offset, items, isItemDisabled, circular),
223+
).toEqual(-1)
224+
})

0 commit comments

Comments
 (0)