diff --git a/docs/app/Examples/modules/Dropdown/States/DisabledItem.js b/docs/app/Examples/modules/Dropdown/States/DisabledItem.js new file mode 100644 index 0000000000..62ad83dd6b --- /dev/null +++ b/docs/app/Examples/modules/Dropdown/States/DisabledItem.js @@ -0,0 +1,15 @@ +import _ from 'lodash' +import faker from 'faker' +import React from 'react' +import { Dropdown } from 'stardust' + +const options = _.times(10, (i) => { + const name = faker.name.findName() + return { text: name, value: _.snakeCase(name), disabled: i % 3 === 0 } +}) + +const DropdownItemDisabledExample = () => ( + +) + +export default DropdownItemDisabledExample diff --git a/docs/app/Examples/modules/Dropdown/index.js b/docs/app/Examples/modules/Dropdown/index.js index 2a8a5c6200..7258fbdcf9 100644 --- a/docs/app/Examples/modules/Dropdown/index.js +++ b/docs/app/Examples/modules/Dropdown/index.js @@ -46,6 +46,9 @@ const DropdownExamples = () => ( description='A disabled dropdown menu or item does not allow user interaction' examplePath='modules/Dropdown/States/Disabled' /> + ) diff --git a/src/modules/Dropdown/Dropdown.js b/src/modules/Dropdown/Dropdown.js index e48abbcc01..0735a5b20d 100644 --- a/src/modules/Dropdown/Dropdown.js +++ b/src/modules/Dropdown/Dropdown.js @@ -458,14 +458,17 @@ export default class Dropdown extends Component { debug('handleItemClick()') debug(value) const { multiple, onAddItem, options } = this.props + const item = this.getItemByValue(value) || {} // prevent toggle() in handleClick() e.stopPropagation() - // prevent closeOnDocumentClick() if multiple - if (multiple) { + // prevent closeOnDocumentClick() if multiple or item is disabled + if (multiple || item.disabled) { e.nativeEvent.stopImmediatePropagation() } + if (item.disabled) return + // notify the onAddItem prop if this is a new value if (onAddItem && !_.some(options, { value })) onAddItem(value) @@ -516,7 +519,7 @@ export default class Dropdown extends Component { if (search && newQuery && !open) this.open() this.setState({ - selectedIndex: 0, + selectedIndex: this.getEnabledIndices()[0], searchQuery: newQuery, }) } @@ -525,9 +528,11 @@ export default class Dropdown extends Component { // Getters // ---------------------------------------- - getMenuOptions = () => { + // There are times when we need to calculate the options based on a value + // that hasn't yet been persisted to state. + getMenuOptions = (value = this.state.value) => { const { multiple, search, allowAdditions, additionPosition, additionLabel, options } = this.props - const { searchQuery, value } = this.state + const { searchQuery } = this.state let filteredOptions = options @@ -562,6 +567,15 @@ export default class Dropdown extends Component { return _.get(options, `[${selectedIndex}]`) } + getEnabledIndices = (givenOptions) => { + const options = givenOptions || this.getMenuOptions() + + return _.reduce(options, (memo, item, index) => { + if (!item.disabled) memo.push(index) + return memo + }, []) + } + getItemByValue = (value) => { const { options } = this.props return _.find(options, { value }) @@ -582,27 +596,34 @@ export default class Dropdown extends Component { debug('value', value) const { multiple } = this.props const { selectedIndex } = this.state - const options = this.getMenuOptions() + const options = this.getMenuOptions(value) + const enabledIndicies = this.getEnabledIndices(options) const newState = { searchQuery: '', } // update the selected index if (!selectedIndex) { + const firstIndex = enabledIndicies[0] + // Select the currently active item, if none, use the first item. // Multiple selects remove active items from the list, // their initial selected index should be 0. - newState.selectedIndex = multiple ? 0 : this.getMenuItemIndexByValue(value || _.get(options, '[0].value')) + newState.selectedIndex = multiple + ? firstIndex + : this.getMenuItemIndexByValue(value || _.get(options, `[${firstIndex}].value`)) } else if (multiple) { // multiple selects remove options from the menu as they are made active // keep the selected index within range of the remaining items if (selectedIndex >= options.length - 1) { - newState.selectedIndex = selectedIndex - 1 + newState.selectedIndex = enabledIndicies[enabledIndicies.length - 1] } } else { + const activeIndex = this.getMenuItemIndexByValue(value) + // regular selects can only have one active item // set the selected index to the currently active item - newState.selectedIndex = this.getMenuItemIndexByValue(value) + newState.selectedIndex = _.includes(enabledIndicies, activeIndex) ? activeIndex : undefined } this.trySetState({ value }, newState) @@ -623,20 +644,24 @@ export default class Dropdown extends Component { this.onChange(e, newValue) } - moveSelectionBy = (offset) => { + moveSelectionBy = (offset, startIndex = this.state.selectedIndex) => { debug('moveSelectionBy()') debug(`offset: ${offset}`) - const { selectedIndex } = this.state const options = this.getMenuOptions() const lastIndex = options.length - 1 + // Prevent infinite loop + if (_.every(options, 'disabled')) return + // next is after last, wrap to beginning // next is before first, wrap to end - let nextIndex = selectedIndex + offset + let nextIndex = startIndex + offset if (nextIndex > lastIndex) nextIndex = 0 else if (nextIndex < 0) nextIndex = lastIndex + if (options[nextIndex].disabled) return this.moveSelectionBy(offset, nextIndex) + this.setState({ selectedIndex: nextIndex }) this.scrollSelectedItemIntoView() } @@ -819,6 +844,7 @@ export default class Dropdown extends Component { selected={selectedIndex === i} onMouseDown={e => e.preventDefault()} // prevent default to allow item select without closing on blur {...opt} + style={{ ...opt.style, pointerEvents: 'all' }} // Needed for handling click events on disabled items /> )) } diff --git a/src/modules/Dropdown/DropdownItem.js b/src/modules/Dropdown/DropdownItem.js index 329b4d7ebd..20147fcfec 100644 --- a/src/modules/Dropdown/DropdownItem.js +++ b/src/modules/Dropdown/DropdownItem.js @@ -16,6 +16,7 @@ function DropdownItem(props) { active, children, className, + disabled, description, icon, onClick, @@ -30,6 +31,7 @@ function DropdownItem(props) { const classes = cx( useKeyOnly(active, 'active'), + useKeyOnly(disabled, 'disabled'), useKeyOnly(selected, 'selected'), 'item', className, @@ -78,6 +80,9 @@ DropdownItem.propTypes = { /** Additional text with less emphasis. */ description: PropTypes.string, + /** A dropdown item can be disabled. */ + disabled: PropTypes.bool, + /** Add an icon to the item. */ icon: PropTypes.string, diff --git a/test/specs/modules/Dropdown/Dropdown-test.js b/test/specs/modules/Dropdown/Dropdown-test.js index 9392707147..3266ea7336 100644 --- a/test/specs/modules/Dropdown/Dropdown-test.js +++ b/test/specs/modules/Dropdown/Dropdown-test.js @@ -115,6 +115,27 @@ describe('Dropdown Component', () => { .first() .should.have.prop('selected', true) }) + it('defaults to the first non-disabled item', () => { + options[0].disabled = true + wrapperShallow() + + // selection moved to second item + wrapper + .find('DropdownItem') + .first() + .should.have.prop('selected', false) + + wrapper + .find('DropdownItem') + .at(1) + .should.have.prop('selected', true) + }) + it('is null when all options disabled', () => { + const disabledOptions = options.map((o) => ({ ...o, disabled: true })) + + wrapperRender() + .should.not.have.descendants('.selected') + }) it('is set when clicking an item', () => { // random item, skip the first as its selected by default const randomIndex = 1 + _.random(options.length - 2) @@ -125,6 +146,22 @@ describe('Dropdown Component', () => { .simulate('click') .should.have.prop('selected', true) }) + it('is ignored when clicking a disabled item', () => { + // random item, skip the first as its selected by default + const randomIndex = 1 + _.random(options.length - 2) + const nativeEvent = { nativeEvent: { stopImmediatePropagation: _.noop } } + + options[randomIndex].disabled = true + + wrapperMount() + .simulate('click', nativeEvent) + .find('DropdownItem') + .at(randomIndex) + .simulate('click', nativeEvent) + .should.not.have.prop('selected', true) + + dropdownMenuIsOpen() + }) it('moves down on arrow down when open', () => { wrapperMount() @@ -193,6 +230,29 @@ describe('Dropdown Component', () => { .find('.selected') .should.contain.text('a2') }) + it('skips over disabled items', () => { + const opts = [ + { text: 'a1', value: 'a1' }, + { text: 'skip this one', value: 'skip this one', disabled: true }, + { text: 'a2', value: 'a2' }, + ] + // search for 'a' + wrapperMount() + .simulate('click') + .find('input.search') + .simulate('change', { target: { value: 'a' } }) + + wrapper + .find('.selected') + .should.contain.text('a1') + + // move selection down + domEvent.keyDown(document, { key: 'ArrowDown' }) + + wrapper + .find('.selected') + .should.contain.text('a2') + }) it('scrolls the selected item into view', () => { // get enough options to make the menu scrollable const opts = getOptions(20)