diff --git a/CHANGELOG.md b/CHANGELOG.md index 4258bd8ec5..d3166fc0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### BREAKING CHANGES - Use regular components instead of `Label` in `RadioGroupItem` @layershifter ([#1070](https://github.com/stardust-ui/react/pull/1070)) - Remove `Flex.Gap` component, and convert the `gap` styles to `margins` on the child elements of the `Flex` component @mnajdova ([#1074](https://github.com/stardust-ui/react/pull/1074)) +- Dropdown: control highlightedIndex from Dropdown @silviuavram ([#966](https://github.com/stardust-ui/react/pull/966)) ### Fixes - Add aria posinset and setsize, hide menu indicator from narration @jurokapsiar ([#1066](https://github.com/stardust-ui/react/pull/1066)) diff --git a/build/gulp/tasks/test-projects/rollup/rollup.config.js b/build/gulp/tasks/test-projects/rollup/rollup.config.js index c8b56ba550..830cf3496c 100644 --- a/build/gulp/tasks/test-projects/rollup/rollup.config.js +++ b/build/gulp/tasks/test-projects/rollup/rollup.config.js @@ -45,6 +45,8 @@ export default { 'ArrowUp', 'ArrowLeft', 'ArrowRight', + 'Backspace', + 'Delete', 'End', 'Enter', 'Escape', diff --git a/packages/react/package.json b/packages/react/package.json index 3355f03056..9957e15bcc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -7,7 +7,7 @@ "dependencies": { "@stardust-ui/react-proptypes": "^0.23.1", "classnames": "^2.2.5", - "downshift": "^3.2.0", + "downshift": "^3.2.6", "fela": "^10.2.0", "fela-plugin-fallback-value": "^10.2.0", "fela-plugin-placeholder-prefixer": "^10.2.0", diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index ec679cffac..ddafcfab04 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as PropTypes from 'prop-types' import * as _ from 'lodash' import cx from 'classnames' +import * as keyboardKey from 'keyboard-key' import { Extendable, @@ -29,7 +30,6 @@ import { handleRef, UIComponentProps, } from '../../lib' -import keyboardKey from 'keyboard-key' import Indicator, { IndicatorProps } from '../Indicator/Indicator' import List from '../List/List' import Ref from '../Ref/Ref' @@ -67,6 +67,9 @@ export interface DropdownProps extends UIComponentProps) => string + /** A dropdown can open with the first option already highlighted. */ + highlightFirstItemOnOpen?: boolean + + /** The index of the list item to be highlighted. */ + highlightedIndex?: number + /** A dropdown can be formatted to appear inline in the content of other components. */ inline?: boolean @@ -187,12 +196,12 @@ export interface DropdownProps extends UIComponentProps, Dropdo clearIndicator: customPropTypes.itemShorthand, defaultActiveSelectedIndex: PropTypes.number, defaultOpen: PropTypes.bool, + defaultHighlightedIndex: PropTypes.number, defaultSearchQuery: PropTypes.string, defaultValue: PropTypes.oneOfType([ customPropTypes.itemShorthand, @@ -233,6 +243,8 @@ class Dropdown extends AutoControlledComponent, Dropdo fluid: PropTypes.bool, getA11ySelectionMessage: PropTypes.object, getA11yStatusMessage: PropTypes.func, + highlightFirstItemOnOpen: PropTypes.bool, + highlightedIndex: PropTypes.number, inline: PropTypes.bool, items: customPropTypes.collectionShorthand, itemToString: PropTypes.func, @@ -273,7 +285,13 @@ class Dropdown extends AutoControlledComponent, Dropdo triggerButton: {}, } - static autoControlledProps = ['activeSelectedIndex', 'open', 'searchQuery', 'value'] + static autoControlledProps = [ + 'activeSelectedIndex', + 'highlightedIndex', + 'open', + 'searchQuery', + 'value', + ] static Item = DropdownItem static SearchInput = DropdownSearchInput @@ -281,12 +299,11 @@ class Dropdown extends AutoControlledComponent, Dropdo getInitialAutoControlledState({ multiple, search }: DropdownProps): DropdownState { return { - activeSelectedIndex: multiple ? null : undefined, a11ySelectionStatus: '', - // used on single selection to open the dropdown with the selected option as highlighted. - defaultHighlightedIndex: this.props.multiple ? undefined : null, + activeSelectedIndex: multiple ? null : undefined, focused: false, open: false, + highlightedIndex: this.props.highlightFirstItemOnOpen ? 0 : null, searchQuery: search ? '' : undefined, value: multiple ? [] : null, } @@ -309,7 +326,7 @@ class Dropdown extends AutoControlledComponent, Dropdo itemToString, toggleIndicator, } = this.props - const { defaultHighlightedIndex, open, searchQuery, value } = this.state + const { highlightedIndex, open, searchQuery, value } = this.state return ( @@ -322,7 +339,7 @@ class Dropdown extends AutoControlledComponent, Dropdo itemToString={itemToString} selectedItem={null} getA11yStatusMessage={getA11yStatusMessage} - defaultHighlightedIndex={defaultHighlightedIndex} + highlightedIndex={highlightedIndex} onStateChange={this.handleStateChange} > {({ @@ -632,11 +649,25 @@ class Dropdown extends AutoControlledComponent, Dropdo private handleStateChange = (changes: StateChangeOptions) => { if (changes.isOpen !== undefined && changes.isOpen !== this.state.open) { - this.trySetStateAndInvokeHandler('onOpenChange', null, { open: changes.isOpen }) + const newState = { open: changes.isOpen, highlightedIndex: this.state.highlightedIndex } + + if (changes.isOpen) { + const highlightedIndexOnArrowKeyOpen = this.getHighlightedIndexOnArrowKeyOpen(changes) + if (_.isNumber(highlightedIndexOnArrowKeyOpen)) { + newState.highlightedIndex = highlightedIndexOnArrowKeyOpen + } + if (!this.props.search) { + this.listRef.current.focus() + } + } else { + newState.highlightedIndex = this.getHighlightedIndexOnClose() + } + + this.trySetStateAndInvokeHandler('onOpenChange', null, newState) } - if (changes.isOpen && !this.props.search) { - this.listRef.current.focus() + if (this.state.open && _.isNumber(changes.highlightedIndex)) { + this.trySetState({ highlightedIndex: changes.highlightedIndex }) } } @@ -803,7 +834,7 @@ class Dropdown extends AutoControlledComponent, Dropdo private handleClear = (e: React.SyntheticEvent) => { const { activeSelectedIndex, - defaultHighlightedIndex, + highlightedIndex, searchQuery, value, } = this.getInitialAutoControlledState(this.props) @@ -811,13 +842,12 @@ class Dropdown extends AutoControlledComponent, Dropdo _.invoke(this.props, 'onSelectedChange', e, { ...this.props, activeSelectedIndex, - defaultHighlightedIndex, + highlightedIndex, searchQuery, value, }) - this.trySetState({ activeSelectedIndex, searchQuery, value }) - this.setState({ defaultHighlightedIndex }) + this.trySetState({ activeSelectedIndex, highlightedIndex, searchQuery, value }) this.tryFocusSearchInput() this.tryFocusTriggerButton() @@ -878,7 +908,7 @@ class Dropdown extends AutoControlledComponent, Dropdo }) if (!multiple) { - this.setState({ defaultHighlightedIndex: items.indexOf(item) }) + this.setState({ highlightedIndex: items.indexOf(item) }) } if (getA11ySelectionMessage && getA11ySelectionMessage.onAdd) { @@ -1021,6 +1051,48 @@ class Dropdown extends AutoControlledComponent, Dropdo private isValueEmpty = (value: ShorthandValue | ShorthandCollection) => { return _.isArray(value) ? value.length < 1 : !value } + + private getHighlightedIndexOnArrowKeyOpen = ( + changes: StateChangeOptions, + ): number => { + const itemsLength = this.getItemsFilteredBySearchQuery().length + switch (changes.type) { + // if open by ArrowUp, index should change by -1. + case Downshift.stateChangeTypes.keyDownArrowUp: + if (_.isNumber(this.state.highlightedIndex)) { + const newIndex = this.state.highlightedIndex - 1 + return newIndex < 0 ? itemsLength - 1 : newIndex + } + return itemsLength - 1 + // if open by ArrowDown, index should change by +1. + case Downshift.stateChangeTypes.keyDownArrowDown: + if (_.isNumber(this.state.highlightedIndex)) { + const newIndex = this.state.highlightedIndex + 1 + return newIndex >= itemsLength ? 0 : newIndex + } + return 0 + default: + return undefined + } + } + + private getHighlightedIndexOnClose = (): number => { + const { highlightFirstItemOnOpen, items, multiple, search } = this.props + const { value } = this.state + + if (!multiple && !search && value) { + // in single selection, if there is a selected item, highlight it. + return items.indexOf(value) + } + + if (highlightFirstItemOnOpen) { + // otherwise, if highlightFirstItemOnOpen prop is provied, highlight first item. + return 0 + } + + // otherwise, highlight no item. + return null + } } Dropdown.slotClassNames = { diff --git a/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx b/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx index 00bf46f601..3f9b302836 100644 --- a/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx +++ b/packages/react/test/specs/components/Dropdown/Dropdown-test.tsx @@ -1,24 +1,21 @@ -import * as faker from 'faker' import * as React from 'react' +import * as keyboardKey from 'keyboard-key' import Dropdown from 'src/components/Dropdown/Dropdown' import { isConformant } from 'test/specs/commonTests' import { mountWithProvider } from 'test/utils' +jest.dontMock('keyboard-key') + describe('Dropdown', () => { + const items = ['item1', 'item2', 'item3'] isConformant(Dropdown, { hasAccessibilityProp: false }) describe('clearable', () => { it('calls onChange on Icon click with an `empty` value', () => { const onSelectedChange = jest.fn() - const options = [faker.lorem.word(), faker.lorem.word(), faker.lorem.word()] const wrapper = mountWithProvider( - , + , ) wrapper.find({ className: Dropdown.slotClassNames.clearIndicator }).simulate('click') @@ -27,7 +24,7 @@ describe('Dropdown', () => { expect.objectContaining({ type: 'click' }), expect.objectContaining({ activeSelectedIndex: undefined, - defaultHighlightedIndex: null, + highlightedIndex: null, searchQuery: undefined, value: null, }), @@ -35,6 +32,158 @@ describe('Dropdown', () => { }) }) + describe('highlightedIndex', () => { + const onOpenChange = jest.fn() + + afterEach(() => { + onOpenChange.mockReset() + }) + + it('calls onOpen with highlightedIndex on click', () => { + const highlightedIndex = 1 + const wrapper = mountWithProvider( + , + ) + + wrapper.find({ className: Dropdown.slotClassNames.triggerButton }).simulate('click') + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex + 1 on arrow down', () => { + const highlightedIndex = 1 + const wrapper = mountWithProvider( + , + ) + + wrapper + .find({ className: Dropdown.slotClassNames.triggerButton }) + .simulate('focus') + .simulate('keydown', { keyCode: keyboardKey.ArrowDown, key: 'ArrowDown' }) + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: highlightedIndex + 1, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex - 1 on arrow down', () => { + const highlightedIndex = 1 + const wrapper = mountWithProvider( + , + ) + + wrapper + .find({ className: Dropdown.slotClassNames.triggerButton }) + .simulate('focus') + .simulate('keydown', { keyCode: keyboardKey.ArrowUp, key: 'ArrowUp' }) + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: highlightedIndex - 1, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex wrapped to 0 on arrow down', () => { + const highlightedIndex = items.length - 1 + const wrapper = mountWithProvider( + , + ) + + wrapper + .find({ className: Dropdown.slotClassNames.triggerButton }) + .simulate('focus') + .simulate('keydown', { keyCode: keyboardKey.ArrowDown, key: 'ArrowDown' }) + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: 0, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex wrapped to items.length - 1 on arrow up', () => { + const highlightedIndex = 0 + const wrapper = mountWithProvider( + , + ) + + wrapper + .find({ className: Dropdown.slotClassNames.triggerButton }) + .simulate('focus') + .simulate('keydown', { keyCode: keyboardKey.ArrowUp, key: 'ArrowUp' }) + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: items.length - 1, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex set to defaultHighlightedIndex', () => { + const defaultHighlightedIndex = 1 + const wrapper = mountWithProvider( + , + ) + + wrapper.find({ className: Dropdown.slotClassNames.triggerButton }).simulate('click') + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: defaultHighlightedIndex, + open: true, + }), + ) + }) + + it('calls onOpen with highlightedIndex always set to 0', () => { + const wrapper = mountWithProvider( + , + ) + const triggerButton = wrapper.find({ className: Dropdown.slotClassNames.triggerButton }) + + triggerButton.simulate('click') + expect(onOpenChange).toBeCalledTimes(1) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: 0, + open: true, + }), + ) + triggerButton.simulate('click').simulate('click') + expect(onOpenChange).toBeCalledTimes(3) + expect(onOpenChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ + highlightedIndex: 0, + open: true, + }), + ) + }) + }) + describe('getA11ySelectionMessage', () => { it('creates message container element', () => { mountWithProvider() diff --git a/yarn.lock b/yarn.lock index ce27581ab4..2cd7967b03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4039,10 +4039,10 @@ dot-prop@^4.1.0, dot-prop@^4.2.0: dependencies: is-obj "^1.0.0" -downshift@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/downshift/-/downshift-3.2.0.tgz#820f40575683d3fc521a9ffd409ef4a69cef531d" - integrity sha512-IXZ3IB7xU51R04olRGSp8GBmJhPlCL8tW5FjZrJh8e4kB4BGUWsQHZ9tNsO+qNLABieR5DIgYBZ9MncPlwZSDg== +downshift@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-3.2.6.tgz#dee518a17dfe28ab9ab38fc8261d06ee87cd4589" + integrity sha512-g9K3W7D5yS9gnJhPFzfGcABs/Fb01+TON+Eks4gJf3Q+3CnJNzWl+oJeJOiKzB8EnHhttZSy3RhTRgKRzG/Tqg== dependencies: "@babel/runtime" "^7.1.2" compute-scroll-into-view "^1.0.9"