Skip to content

Commit

Permalink
fix(useCombobox): use mouse and touch events to trigger blur (#893)
Browse files Browse the repository at this point in the history
* improve condition in focusLands

* use the same blur logic as in downshift

* improve reducer test in useCombobox

* fix useCombobox test written

* add simiar test to useCombobox

* add tests and fix coverage

* update snapshot

* add docs section for stateReducer
  • Loading branch information
silviuaavram committed Jan 13, 2020
1 parent a9c3177 commit 6b7a79b
Show file tree
Hide file tree
Showing 10 changed files with 525 additions and 73 deletions.
48 changes: 24 additions & 24 deletions .size-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
{
"dist/downshift.cjs.js": {
"bundled": 113058,
"minified": 52081,
"gzipped": 11521
"bundled": 115803,
"minified": 53212,
"gzipped": 11705
},
"preact/dist/downshift.cjs.js": {
"bundled": 111776,
"minified": 51043,
"gzipped": 11421
"bundled": 114524,
"minified": 52177,
"gzipped": 11602
},
"preact/dist/downshift.umd.js": {
"bundled": 140807,
"minified": 50220,
"gzipped": 13039
"bundled": 142983,
"minified": 48987,
"gzipped": 13309
},
"preact/dist/downshift.umd.min.js": {
"bundled": 124445,
"minified": 40311,
"gzipped": 11141
"bundled": 126831,
"minified": 41458,
"gzipped": 11505
},
"dist/downshift.umd.min.js": {
"bundled": 128680,
"minified": 41630,
"gzipped": 11599
"bundled": 131063,
"minified": 42774,
"gzipped": 12065
},
"dist/downshift.umd.js": {
"bundled": 170432,
"minified": 59160,
"gzipped": 15594
"bundled": 172605,
"minified": 57900,
"gzipped": 15877
},
"preact/dist/downshift.esm.js": {
"bundled": 111277,
"minified": 50618,
"gzipped": 11354,
"bundled": 114004,
"minified": 51731,
"gzipped": 11532,
"treeshaked": {
"rollup": {
"code": 1752,
Expand All @@ -44,9 +44,9 @@
}
},
"dist/downshift.esm.js": {
"bundled": 112677,
"minified": 51716,
"gzipped": 11470,
"bundled": 115316,
"minified": 52799,
"gzipped": 11635,
"treeshaked": {
"rollup": {
"code": 1751,
Expand Down
111 changes: 110 additions & 1 deletion docs/hooks/useCombobox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ according to the input value. The getter props are used as follows:
| `getToggleButtonProps` | `<button>` | Controls the open state of the list. |
| `getComboboxProps` | `<div>` | Container for `input` and `toggleButton`. |
| `getInputProps` | `<input>` | Can be used to filter the options. Also displays the selected item. |
| `getMenuProps` | `<ul>` | Makes list focusable, adds ARIA attributes and event handlers. |
| `getMenuProps` | `<ul>` | Adds ARIA attributes and event handlers. |
| `getItemProps` | `<li>` | Called with `index` and `item`, adds ARIA attributes and event listeners. |
| `isOpen` | | Only when it's true we render the `<li>` elements. |
| `highlightedIndex` | `<li>` | Used to style the highlighted item. |
Expand Down Expand Up @@ -116,3 +116,112 @@ combobox `<div>` and the `<ul>` needs to be at the same level with the combobox
}}

</Playground>

## State Reducer

For an even more granular control of the state changing process, you can add
your own reducer on top of the default one. When it's called it will receive the
previous `state` and the `actionAndChanges` object. The latter contains the
change `type`, which explains why the state is changed. It also contains the
`changes` proposed by `Downshift` that should occur as a consequence of that
change type. You are supposed to return the new state according to your needs.

In the example below, let's say we want to show stuff uppercased all the time.
We will catch the `InputChange` event, get the proposed `inputValue` from the
default reducer, lowercase the value, and return that along with the rest of the
changes. We will do the same thing for the cases when a selection is performed.
We will check that `highlightedIndex` from state was greater than `-1`, since
`InputBlur` can perform selection in combobox, but only if an item was selected.
After that, we will return the uppercased input value.

In all other state change types, we return `Downshift` default changes.

[CodeSandbox](https://codesandbox.io/s/useselect-variations-state-reducer-ysc2r)

<Playground style={playgroundStyles}>
{() => {
function stateReducer(state, actionAndChanges) {
// this prevents the menu from being closed when the user selects an item with 'Enter' or mouse
switch (actionAndChanges.type) {
case useCombobox.stateChangeTypes.InputChange:
return {
// return normal changes.
...actionAndChanges.changes,
// but taking the change from default reducer and uppercasing it.
inputValue: actionAndChanges.changes.inputValue.toUpperCase(),
}
// also on selection.
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.InputBlur:
return {
...actionAndChanges.changes,
// if we had an item highlighted in the previous state.
...(state.highlightedIndex > -1 && {
// we will show it uppercased.
inputValue: actionAndChanges.changes.inputValue.toUpperCase()
})
}
default:
return actionAndChanges.changes // otherwise business as usual.
}
}

const DropdownCombobox = () => {
const [inputItems, setInputItems] = useState(items)
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getItemProps,
} = useCombobox({
items: inputItems,
onInputValueChange: ({inputValue}) => {
setInputItems(
items.filter(item =>
item.toLowerCase().startsWith(inputValue.toLowerCase()),
),
)
},
stateReducer
})

return (
<>
<label {...getLabelProps()}>Choose an element:</label>
<div style={comboboxStyles} {...getComboboxProps()}>
<input {...getInputProps()} />
<button {...getToggleButtonProps()} aria-label={'toggle menu'}>
&#8595;
</button>
</div>
<ul {...getMenuProps()} style={menuStyles}>
{isOpen &&
inputItems.map((item, index) => (
<li
style={
highlightedIndex === index
? {backgroundColor: '#bde4ff'}
: {}
}
key={`${item}${index}`}
{...getItemProps({item, index})}
>
{item}
</li>
))}
</ul>
</>
)
}

return <DropdownCombobox />

}}

</Playground>
12 changes: 6 additions & 6 deletions docs/hooks/useSelect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -292,12 +292,12 @@ proposed by `Downshift` that should occur as a consequence of that change type.
You are supposed to return the new state according to your needs.

In the example below, we are catching the selection event types,
`MenuKeyDownEnter` and `ItemClick` (we are disregarding `MenuBlur`). To keep
menu open, we are overriding `isOpen` with `state.isOpen` and `highlightedIndex`
with `state.highlightedIndex` to keep the same appearance to the user (menu open
with same item highlighted) after selection. But selection is still performed,
since we are also returning the destructured `actionAndChanges.changes` which
contains the `selectedItem` given to us by the `Downshift` default reducer.
`MenuKeyDownEnter` and `ItemClick`. To keep menu open, we are overriding
`isOpen` with `state.isOpen` and `highlightedIndex` with
`state.highlightedIndex` to keep the same appearance to the user (menu open with
same item highlighted) after selection. But selection is still performed, since
we are also returning the destructured `actionAndChanges.changes` which contains
the `selectedItem` given to us by the `Downshift` default reducer.

In all other state change types, we return `Downshift` default changes.

Expand Down
22 changes: 10 additions & 12 deletions src/downshift.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
getA11yStatusMessage,
getElementProps,
isDOMElement,
isOrContainsNode,
targetWithinDownshift,
isPlainObject,
noop,
normalizeArrowKey,
Expand Down Expand Up @@ -1056,16 +1056,6 @@ class Downshift extends Component {
this.internalClearTimeouts()
}
} else {
const targetWithinDownshift = (target, checkActiveElement = true) => {
const {document} = this.props.environment
return [this._rootNode, this._menuNode].some(
contextNode =>
contextNode &&
(isOrContainsNode(contextNode, target) ||
(checkActiveElement &&
isOrContainsNode(contextNode, document.activeElement))),
)
}
// this.isMouseDown helps us track whether the mouse is currently held down.
// This is useful when the user clicks on an item in the list, but holds the mouse
// down long enough for the list to disappear (because the blur event fires on the input)
Expand All @@ -1078,7 +1068,12 @@ class Downshift extends Component {
this.isMouseDown = false
// if the target element or the activeElement is within a downshift node
// then we don't want to reset downshift
const contextWithinDownshift = targetWithinDownshift(event.target)
const contextWithinDownshift = targetWithinDownshift(
event.target,
this._rootNode,
this._menuNode,
this.props.environment.document,
)
if (!contextWithinDownshift && this.getState().isOpen) {
this.reset({type: stateChangeTypes.mouseUp}, () =>
this.props.onOuterClick(this.getStateAndHelpers()),
Expand All @@ -1102,6 +1097,9 @@ class Downshift extends Component {
const onTouchEnd = event => {
const contextWithinDownshift = targetWithinDownshift(
event.target,
this._rootNode,
this._menuNode,
this.props.environment.document,
false,
)
if (
Expand Down
44 changes: 44 additions & 0 deletions src/hooks/useCombobox/__tests__/getInputProps.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable jest/no-disabled-tests */
import {act as rtlAct} from '@testing-library/react-hooks'
import {fireEvent, cleanup} from '@testing-library/react'
import * as stateChangeTypes from '../stateChangeTypes'
import {noop} from '../../../utils'
import {setup, setupHook, defaultIds, dataTestIds, items} from '../testUtils'

Expand Down Expand Up @@ -704,6 +705,49 @@ describe('getInputProps', () => {

expect(input.value).toBe('bla')
})

test('by mouse is not triggered if target is within downshift', () => {
const stateReducer = jest.fn().mockImplementation(s => s)
const wrapper = setup({isOpen: true, stateReducer})
document.body.appendChild(wrapper.container)
const input = wrapper.getByTestId(dataTestIds.input)

fireEvent.mouseDown(input)
fireEvent.mouseUp(input)

expect(stateReducer).not.toHaveBeenCalled()

fireEvent.mouseDown(document.body)
fireEvent.mouseUp(document.body)

expect(stateReducer).toHaveBeenCalledTimes(1)
expect(stateReducer).toHaveBeenCalledWith(
expect.objectContaining({}),
expect.objectContaining({type: stateChangeTypes.InputBlur}),
)
})

test('by touch is not triggered if target is within downshift', () => {
const stateReducer = jest.fn().mockImplementation(s => s)
const wrapper = setup({isOpen: true, stateReducer})
document.body.appendChild(wrapper.container)
const input = wrapper.getByTestId(dataTestIds.input)

fireEvent.touchStart(input)
fireEvent.touchMove(input)
fireEvent.touchEnd(input)

expect(stateReducer).not.toHaveBeenCalled()

fireEvent.touchStart(document.body)
fireEvent.touchEnd(document.body)

expect(stateReducer).toHaveBeenCalledTimes(1)
expect(stateReducer).toHaveBeenCalledWith(
expect.objectContaining({}),
expect.objectContaining({type: stateChangeTypes.InputBlur}),
)
})
})
})
})
Expand Down

0 comments on commit 6b7a79b

Please sign in to comment.