@puppet/react-components
Advanced tools
Comparing version 5.28.0 to 5.29.0
{ | ||
"name": "@puppet/react-components", | ||
"version": "5.28.0", | ||
"version": "5.29.0", | ||
"author": "Puppet, Inc.", | ||
@@ -96,3 +96,3 @@ "license": "Apache-2.0", | ||
}, | ||
"gitHead": "c0dfba39b0bdfd9da896c924e94c5c250c8830a7" | ||
"gitHead": "679f3c013b3d114d20d84324c510fc75cb58d619" | ||
} |
import PropTypes from 'prop-types'; | ||
import Icon from '../library/icon'; | ||
@@ -84,1 +85,14 @@ /** | ||
}; | ||
export const optionMenuItemShape = { | ||
/** Value of the option */ | ||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||
/** The label to show */ | ||
label: PropTypes.node.isRequired, | ||
/** Optional icon associated with this option */ | ||
icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS), | ||
/** Optional custom icon associated with this option */ | ||
svg: PropTypes.element, | ||
/** Whether this option is disabled */ | ||
disabled: PropTypes.bool, | ||
}; |
@@ -6,2 +6,3 @@ import React, { Component } from 'react'; | ||
import { isNil, focus, cancelEvent } from '../../helpers/statics'; | ||
import { optionMenuItemShape } from '../../helpers/customPropTypes'; | ||
@@ -19,3 +20,2 @@ import { | ||
import OptionMenuListItem from './OptionMenuListItem'; | ||
import Icon from '../../library/icon'; | ||
@@ -28,9 +28,9 @@ const propTypes = { | ||
options: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) | ||
.isRequired, | ||
label: PropTypes.node.isRequired, | ||
icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS), | ||
disabled: PropTypes.bool, | ||
}), | ||
PropTypes.oneOfType([ | ||
PropTypes.shape(optionMenuItemShape), | ||
PropTypes.shape({ | ||
...optionMenuItemShape, | ||
value: PropTypes.arrayOf(PropTypes.shape(optionMenuItemShape)), | ||
}), | ||
]), | ||
), | ||
@@ -78,9 +78,17 @@ selected: PropTypes.oneOfType([ | ||
const getFocusableOptions = options => | ||
options.map(opt => (Array.isArray(opt.value) ? opt.value : opt)).flat(); | ||
const getFocusedId = (focusedIndex, id, options) => | ||
isNil(focusedIndex) || focusedIndex >= options.length | ||
typeof focusedIndex !== 'number' || focusedIndex >= options.length | ||
? undefined | ||
: getOptionId(id, options[focusedIndex].value); | ||
: getOptionId( | ||
id, | ||
getFocusableOptions(options)[Math.max(focusedIndex, 0)].value, | ||
); | ||
const getSelectionSet = selection => | ||
new Set(Array.isArray(selection) ? selection : [selection]); | ||
new Set( | ||
(Array.isArray(selection) ? selection : [selection]).filter(el => !!el), | ||
); | ||
@@ -139,3 +147,7 @@ class OptionMenuList extends Component { | ||
onMouseEnterItem(focusedIndex) { | ||
this.focusItem(focusedIndex); | ||
if (typeof focusedIndex === 'number') { | ||
this.focusItem(focusedIndex); | ||
} else { | ||
this.setState({ focusedIndex: null }); | ||
} | ||
} | ||
@@ -167,3 +179,5 @@ | ||
} else { | ||
this.focusItem(Math.min(options.length - 1, focusedIndex + 1)); | ||
this.focusItem( | ||
Math.min(getFocusableOptions(options).length - 1, focusedIndex + 1), | ||
); | ||
} | ||
@@ -173,3 +187,4 @@ } | ||
onKeyDown(e) { | ||
const { onEscape, onClickItem } = this.props; | ||
const { onEscape, onClickItem, options } = this.props; | ||
const { focusedIndex } = this.state; | ||
@@ -199,4 +214,7 @@ switch (e.keyCode) { | ||
case ENTER_KEY_CODE: { | ||
this.selectFocusedItem(); | ||
onClickItem(); | ||
const focused = getFocusableOptions(options)[focusedIndex]; | ||
if (focused && !focused.disabled) { | ||
this.selectFocusedItem(); | ||
onClickItem(); | ||
} | ||
cancelEvent(e); | ||
@@ -256,3 +274,3 @@ break; | ||
this.focusItem(options.length - 1); | ||
this.focusItem(getFocusableOptions(options).length - 1); | ||
} | ||
@@ -302,3 +320,3 @@ | ||
if (!isNil(focusedIndex)) { | ||
const { value } = options[focusedIndex]; | ||
const { value } = getFocusableOptions(options)[focusedIndex]; | ||
@@ -312,13 +330,2 @@ this.select(value); | ||
const { | ||
onClickItem, | ||
onMouseEnterItem, | ||
onCancel, | ||
onKeyDown, | ||
onKeyDownInAction, | ||
onFocus, | ||
onMenuBlur, | ||
onActionBlur, | ||
} = this; | ||
const { focusedIndex } = this.state; | ||
const { | ||
id, | ||
@@ -337,3 +344,2 @@ options, | ||
onBlur, | ||
focusedIndex: focussed, | ||
onFocusItem, | ||
@@ -349,5 +355,92 @@ footer, | ||
const { | ||
onClickItem, | ||
onMouseEnterItem, | ||
onCancel, | ||
onKeyDown, | ||
onKeyDownInAction, | ||
onFocus, | ||
onMenuBlur, | ||
onActionBlur, | ||
} = this; | ||
const selectionSet = getSelectionSet(selected); | ||
const focusedId = getFocusedId(focusedIndex, id, options); | ||
delete rest.focusedIndex; | ||
const { focusedIndex } = this.state; | ||
const focusedId = getFocusedId( | ||
focusedIndex, | ||
id, | ||
getFocusableOptions(options), | ||
); | ||
const renderListItems = (items, offset = 0) => { | ||
const list = []; | ||
items.forEach(item => { | ||
if (Array.isArray(item.value)) { | ||
const groupId = `group-${item.value | ||
.map(child => child.value) | ||
.join('-')}`; | ||
const labelId = `${groupId}-label`; | ||
list.push( | ||
<ul | ||
role="group" | ||
aria-labelledby={labelId} | ||
className="rc-menu-list-group" | ||
id={groupId} | ||
key={groupId} | ||
> | ||
{item.label && ( | ||
<OptionMenuListItem | ||
type="heading" | ||
disabled={item.disabled} | ||
id={labelId} | ||
key={labelId} | ||
onMouseEnter={() => onMouseEnterItem(null)} | ||
> | ||
{item.label} | ||
</OptionMenuListItem> | ||
)} | ||
{renderListItems( | ||
item.value.map(child => | ||
Object.assign(child, { | ||
disabled: item.disabled || child.disabled, | ||
}), | ||
), | ||
list.length + offset, | ||
)} | ||
</ul>, | ||
); | ||
// eslint-disable-next-line no-param-reassign | ||
offset += item.value.length - 1; | ||
} else { | ||
const index = list.length + offset; | ||
list.push( | ||
<OptionMenuListItem | ||
id={getOptionId(id, item.value)} | ||
key={item.value} | ||
focused={index === focusedIndex} | ||
selected={selectionSet.has(item.value)} | ||
icon={item.icon} | ||
svg={item.svg} | ||
disabled={item.disabled} | ||
onClick={() => | ||
item.disabled ? undefined : onClickItem(item.value) | ||
} | ||
onMouseEnter={() => onMouseEnterItem(index)} | ||
ref={option => { | ||
this.optionRefs[index] = option; | ||
}} | ||
> | ||
{item.label} | ||
</OptionMenuListItem>, | ||
); | ||
} | ||
}); | ||
return list; | ||
}; | ||
const list = ( | ||
@@ -369,20 +462,3 @@ <ul | ||
> | ||
{options.map(({ value, label, icon, svg, disabled }, index) => ( | ||
<OptionMenuListItem | ||
id={getOptionId(id, value)} | ||
key={value} | ||
focused={index === focusedIndex} | ||
selected={selectionSet.has(value)} | ||
icon={icon} | ||
svg={svg} | ||
disabled={disabled} | ||
onClick={disabled ? undefined : () => onClickItem(value)} | ||
onMouseEnter={() => onMouseEnterItem(index)} | ||
ref={option => { | ||
this.optionRefs[index] = option; | ||
}} | ||
> | ||
{label} | ||
</OptionMenuListItem> | ||
))} | ||
{renderListItems(options)} | ||
</ul> | ||
@@ -389,0 +465,0 @@ ); |
@@ -7,7 +7,12 @@ import React, { forwardRef } from 'react'; | ||
const types = { | ||
OPTION: 'option', | ||
HEADING: 'heading', | ||
}; | ||
const propTypes = { | ||
id: PropTypes.string.isRequired, | ||
children: PropTypes.node.isRequired, | ||
focused: PropTypes.bool.isRequired, | ||
selected: PropTypes.bool.isRequired, | ||
focused: PropTypes.bool, | ||
selected: PropTypes.bool, | ||
/** Optional: choose an icon */ | ||
@@ -17,5 +22,6 @@ icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS), | ||
svg: PropTypes.element, | ||
onClick: PropTypes.func.isRequired, | ||
onMouseEnter: PropTypes.func.isRequired, | ||
onClick: PropTypes.func, | ||
onMouseEnter: PropTypes.func, | ||
disabled: PropTypes.bool, | ||
type: PropTypes.oneOf(Object.values(types)), | ||
}; | ||
@@ -26,3 +32,8 @@ | ||
svg: null, | ||
focused: false, | ||
disabled: false, | ||
selected: false, | ||
onClick: null, | ||
onMouseEnter: null, | ||
type: types.OPTION, | ||
}; | ||
@@ -43,30 +54,48 @@ | ||
disabled, | ||
type, | ||
}, | ||
ref, | ||
) => ( | ||
<li | ||
role="option" | ||
id={id} | ||
className={classNames('rc-menu-list-item', { | ||
'rc-menu-list-item-focused': focused, | ||
'rc-menu-list-item-selected': selected, | ||
'rc-menu-list-item-disabled': disabled, | ||
})} | ||
aria-selected={selected} | ||
onClick={onClick} | ||
onMouseEnter={onMouseEnter} | ||
ref={ref} | ||
> | ||
{icon && <Icon className="rc-menu-list-item-icon" type={icon} />} | ||
{svg && !icon && <Icon className="rc-menu-list-item-icon" svg={svg} />} | ||
<span className="rc-menu-list-item-content">{children}</span> | ||
{selected && ( | ||
<Icon | ||
className="rc-menu-list-item-checkmark" | ||
type="check" | ||
size="small" | ||
/> | ||
)} | ||
</li> | ||
), | ||
) => { | ||
const isHeading = type === types.HEADING; | ||
const itemProps = { | ||
id, | ||
ref, | ||
onMouseEnter, | ||
onClick, | ||
role: isHeading ? 'presentation' : 'option', | ||
}; | ||
if (!isHeading) { | ||
itemProps['aria-selected'] = selected; | ||
} | ||
return ( | ||
<li | ||
{...itemProps} | ||
className={classNames( | ||
'rc-menu-list-item', | ||
{ | ||
'rc-menu-list-item-disabled': disabled, | ||
}, | ||
isHeading | ||
? 'rc-menu-list-group-heading' | ||
: { | ||
'rc-menu-list-item-focused': focused, | ||
'rc-menu-list-item-selected': selected, | ||
}, | ||
)} | ||
> | ||
{icon && <Icon className="rc-menu-list-item-icon" type={icon} />} | ||
{svg && !icon && <Icon className="rc-menu-list-item-icon" svg={svg} />} | ||
<span className="rc-menu-list-item-content">{children}</span> | ||
{!isHeading && selected && ( | ||
<Icon | ||
className="rc-menu-list-item-checkmark" | ||
type="check" | ||
size="small" | ||
/> | ||
)} | ||
</li> | ||
); | ||
}, | ||
); | ||
@@ -73,0 +102,0 @@ /* eslint-enable */ |
@@ -5,4 +5,6 @@ import React, { Component } from 'react'; | ||
import OptionMenuList from '../../internal/option-menu-list'; | ||
import { anchorOrientation } from '../../helpers/customPropTypes'; | ||
import Icon from '../icon'; | ||
import { | ||
anchorOrientation, | ||
optionMenuItemShape, | ||
} from '../../helpers/customPropTypes'; | ||
import Input from '../input'; | ||
@@ -27,12 +29,9 @@ import SelectTarget from './SelectTarget'; | ||
options: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
/** Select option value */ | ||
value: PropTypes.string.isRequired, | ||
/** Select option label */ | ||
label: PropTypes.string.isRequired, | ||
/** Optional icon associated with this option */ | ||
icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS), | ||
/** Optional custom icon associated with this option */ | ||
svg: PropTypes.element, | ||
}), | ||
PropTypes.oneOfType([ | ||
PropTypes.shape(optionMenuItemShape), | ||
PropTypes.shape({ | ||
...optionMenuItemShape, | ||
value: PropTypes.arrayOf(PropTypes.shape(optionMenuItemShape)), | ||
}), | ||
]), | ||
), | ||
@@ -135,4 +134,4 @@ /** Currently selected value or values */ | ||
static getDerivedStateFromProps(props, state) { | ||
if (isControlled(props) || !state.open) { | ||
static getDerivedStateFromProps(props) { | ||
if (isControlled(props)) { | ||
return { | ||
@@ -289,3 +288,3 @@ listValue: props.value, | ||
getButtonLabel() { | ||
const { type, options, value, placeholder } = this.props; | ||
const { type, value, placeholder } = this.props; | ||
if (!value || value.length === 0) { | ||
@@ -296,3 +295,3 @@ return placeholder; | ||
if (type === MULTISELECT) { | ||
const selectedOptions = options | ||
const selectedOptions = this.getOptions() | ||
.filter(option => value.includes(option.value)) | ||
@@ -304,3 +303,5 @@ .map(option => option.selectedLabel || option.label); | ||
const selectedOption = options.find(option => option.value === value); | ||
const selectedOption = this.getOptions().find( | ||
option => option.value === value, | ||
); | ||
@@ -312,3 +313,5 @@ return selectedOption.label; | ||
const { options, value, type, onFilter } = this.props; | ||
let filteredOptions = options; | ||
let opts = options | ||
.map(opt => (Array.isArray(opt.value) ? opt.value : opt)) | ||
.flat(); | ||
@@ -318,3 +321,3 @@ // If the ingesting app uses the onFilter event handler, it should provide the filtered options | ||
if (value && type === AUTOCOMPLETE && !onFilter) { | ||
filteredOptions = options.filter( | ||
opts = opts.filter( | ||
option => option.value.toLowerCase().indexOf(value.toLowerCase()) > -1, | ||
@@ -324,3 +327,3 @@ ); | ||
return filteredOptions; | ||
return opts; | ||
} | ||
@@ -368,3 +371,2 @@ | ||
onFocusItem, | ||
getOptions, | ||
open: onOpen, | ||
@@ -374,13 +376,14 @@ } = this; | ||
const { | ||
name, | ||
type, | ||
applyImmediately, | ||
className, | ||
disabled, | ||
className, | ||
style, | ||
error, | ||
value, | ||
footer, | ||
name, | ||
options, | ||
placeholder, | ||
applyImmediately, | ||
required, | ||
footer, | ||
style, | ||
type, | ||
value, | ||
} = this.props; | ||
@@ -471,3 +474,3 @@ | ||
showCancel={type === MULTISELECT && !applyImmediately} | ||
options={getOptions()} | ||
options={options} | ||
selected={listValue} | ||
@@ -474,0 +477,0 @@ focusedIndex={focusedIndex} |
@@ -84,2 +84,41 @@ ## Overview | ||
### Option groups | ||
To render an option group, provide an array of child options as the value for a regular option. Parent options should | ||
still have labels, and if a parent is disabled, all its child options will be disabled, too. | ||
```jsx | ||
const optionsWithGroups = [{ | ||
label: "Spices", | ||
value: [ | ||
{label: "Cinnamon", value: "cinnamon"}, | ||
{label: "Coriander", value: "coriander"}, | ||
{label: "Cumin", value: "cumin"}, | ||
] | ||
}, { | ||
label: "Oil", | ||
value: "oil" | ||
}, { | ||
label: "Vinegar", | ||
value: "vinegar" | ||
}, { | ||
label: "Herbs", | ||
disabled: true, | ||
value: [ | ||
{label: "Parsley", value: "parsley"}, | ||
{label: "Sage", value: "sage"}, | ||
{label: "Rosemary", value: "rosemary"}, | ||
] | ||
}]; | ||
<Select | ||
name="select-option-group-example" | ||
options={optionsWithGroups} | ||
value={state.value} | ||
onChange={value => { | ||
setState({value}); | ||
}} | ||
/>; | ||
``` | ||
### MultiSelect | ||
@@ -86,0 +125,0 @@ |
@@ -1,6 +0,11 @@ | ||
import chai, { expect } from 'chai'; | ||
import '../setup'; | ||
import { expect } from 'chai'; | ||
import sinon from 'sinon'; | ||
import { shallow, mount } from 'enzyme'; | ||
import React from 'react'; | ||
import React, { useState } from 'react'; | ||
import Input from '../../source/react/library/input/Input'; | ||
import Select from '../../source/react/library/select/Select'; | ||
import SelectTarget from '../../source/react/library/select/SelectTarget'; | ||
import OptionMenuList from '../../source/react/internal/option-menu-list'; | ||
import OptionMenuListItem from '../../source/react/internal/option-menu-list/OptionMenuListItem'; | ||
@@ -17,4 +22,35 @@ const options = [ | ||
{ value: 'raspberry', label: 'raspberry' }, | ||
{ | ||
label: 'herbs', | ||
value: [ | ||
{ label: 'basil', value: 'basil' }, | ||
{ label: 'parsley', value: 'parsley' }, | ||
{ label: 'thyme', value: 'thyme' }, | ||
], | ||
}, | ||
]; | ||
// eslint-disable-next-line react/prop-types | ||
const ExampleSelect = ({ value: initialValue, ...props }) => { | ||
const [value, setValue] = useState(initialValue || null); | ||
return ( | ||
<Select | ||
name="test" | ||
value={value} | ||
onChange={val => { | ||
setValue(val); | ||
}} | ||
open | ||
{...props} | ||
/> | ||
); | ||
}; | ||
const getChoiceByText = (wrapper, name) => | ||
wrapper | ||
.find(OptionMenuListItem) | ||
.filterWhere(n => n.text() === name) | ||
.first(); | ||
describe('<Select />', () => { | ||
@@ -65,2 +101,128 @@ it('should render without falling over', () => { | ||
}); | ||
it('starts with the listbox closed and shows it when the target button is clicked', () => { | ||
const wrapper = mount(<Select name="test" options={options} />); | ||
expect(wrapper.find(Select).state('open')).to.equal(false); | ||
const menu = wrapper.find(OptionMenuList); | ||
expect(menu.prop('aria-labelledby')).to.equal('test-label'); | ||
expect(menu.prop('role')).to.equal('listbox'); | ||
const target = wrapper.find(SelectTarget); | ||
expect(target.prop('aria-controls')).to.equal('test-menu'); | ||
expect(target.prop('aria-haspopup')).to.equal('listbox'); | ||
expect(target.prop('aria-expanded')).to.equal(false); | ||
target.find('button').simulate('click'); | ||
expect(wrapper.find(SelectTarget).prop('aria-expanded')).to.equal(true); | ||
expect(wrapper.find(Select).state('open')).to.equal(true); | ||
}); | ||
it('selects an option on click', () => { | ||
const wrapper = mount(<ExampleSelect name="test" options={options} open />); | ||
getChoiceByText(wrapper, 'kiwi').simulate('click'); | ||
expect(wrapper.find(Select).state('open')).to.equal(false); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.equal('kiwi'); | ||
expect(wrapper.find('input').prop('value')).to.equal('kiwi'); | ||
expect(wrapper.find(SelectTarget).text()).to.equal('kiwi'); | ||
wrapper.find(SelectTarget).simulate('click'); | ||
getChoiceByText(wrapper, 'parsley').simulate('click'); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.equal('parsley'); | ||
expect(wrapper.find('input').prop('value')).to.equal('parsley'); | ||
expect(wrapper.find(SelectTarget).text()).to.equal('parsley'); | ||
}); | ||
it('does _not_ deselect the option on second click', () => { | ||
const wrapper = mount(<ExampleSelect name="test" options={options} open />); | ||
getChoiceByText(wrapper, 'kiwi').simulate('click'); | ||
wrapper.find(SelectTarget).simulate('click'); | ||
getChoiceByText(wrapper, 'kiwi').simulate('click'); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.equal('kiwi'); | ||
expect(wrapper.find('input').prop('value')).to.equal('kiwi'); | ||
expect(wrapper.find(SelectTarget).text()).to.equal('kiwi'); | ||
}); | ||
it('displays option groups with headings correctly in the rendered listbox', () => { | ||
const wrapper = mount(<Select name="test" options={options} />); | ||
const list = wrapper.find('ul ul'); | ||
const headingItem = list.find(OptionMenuListItem).first(); | ||
expect(headingItem.prop('type')).to.equal('heading'); | ||
}); | ||
describe('with multiselect', () => { | ||
const getApplyButton = wrapper => | ||
wrapper | ||
.find('button') | ||
.filterWhere(n => n.text() === 'Apply') | ||
.first(); | ||
it('allows selection of multiple items on click + Apply', () => { | ||
const wrapper = mount( | ||
<ExampleSelect name="test" options={options} type="multiselect" open />, | ||
); | ||
const selections = ['banana', 'parsley', 'kiwi', 'thyme']; | ||
selections.forEach(item => { | ||
getChoiceByText(wrapper, item).simulate('click'); | ||
}); | ||
getApplyButton(wrapper).simulate('click'); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.eql(selections); | ||
expect(wrapper.find('input').prop('value')).to.eql(selections); | ||
expect(wrapper.find(SelectTarget).text()).to.equal( | ||
selections.sort().join(', '), | ||
); | ||
}); | ||
it('deselects a selected item on second click + Apply', () => { | ||
const wrapper = mount( | ||
<ExampleSelect name="test" options={options} type="multiselect" open />, | ||
); | ||
const selections = ['banana', 'parsley', 'kiwi', 'thyme']; | ||
selections.forEach(choice => { | ||
getChoiceByText(wrapper, choice).simulate('click'); | ||
}); | ||
getApplyButton(wrapper).simulate('click'); | ||
wrapper.find(SelectTarget).simulate('click'); | ||
[selections.shift(), selections.shift()].forEach(choice => { | ||
getChoiceByText(wrapper, choice).simulate('click'); | ||
}); | ||
getApplyButton(wrapper).simulate('click'); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.eql(selections); | ||
expect(wrapper.find(SelectTarget).text()).to.equal( | ||
selections.sort().join(', '), | ||
); | ||
expect( | ||
wrapper | ||
.find('input') | ||
.prop('value') | ||
.sort(), | ||
).to.eql(selections.sort()); | ||
}); | ||
it('applies selections without requiring the Apply button when applyImmediately is set', () => { | ||
const wrapper = mount( | ||
<ExampleSelect | ||
name="test" | ||
options={options} | ||
type="multiselect" | ||
applyImmediately | ||
open | ||
/>, | ||
); | ||
getChoiceByText(wrapper, 'kiwi').simulate('click'); | ||
expect(wrapper.find(OptionMenuList).prop('selected')).to.eql(['kiwi']); | ||
expect(wrapper.find(SelectTarget).text()).to.equal('kiwi'); | ||
expect(wrapper.find('input').prop('value')).to.eql(['kiwi']); | ||
}); | ||
}); | ||
}); |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
1396926
259
22769