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)