-
Notifications
You must be signed in to change notification settings - Fork 377
Single select #1277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Single select #1277
Changes from all commits
0f63d78
06cdfb1
9b987ab
53103cb
6c3ae82
5611b70
8ae8634
82d87ca
67d779e
3aa421b
0045629
3d8d74c
a3e0083
15cfdaf
0cc32ed
ca7a3fe
3cfd99e
86c23ac
027b6c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { HTMLProps, FormEvent } from 'react'; | ||
|
|
||
| export const SelectVariant = { | ||
| single: 'single' | ||
| }; | ||
|
|
||
| export interface SelectProps extends HTMLProps<HTMLOptionElement> { | ||
| isExpanded?: boolean; | ||
| onToggle(value: boolean): void; | ||
| placeholderText?: string; | ||
| selections?: string; | ||
| variant?: string; | ||
| width?: string | number; | ||
| } | ||
|
|
||
| declare const Select: React.FunctionComponent<SelectProps>; | ||
|
|
||
| export default Select; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { Select, SelectOption } from '@patternfly/react-core'; | ||
| import SingleSelectInput from './examples/SingleSelectInput'; | ||
|
|
||
| export default { | ||
| title: 'Select', | ||
| components: { | ||
| Select, | ||
| SelectOption | ||
| }, | ||
| examples: [{ component: SingleSelectInput, title: 'Single Select Input' }] | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import React from 'react'; | ||
| import styles from '@patternfly/patternfly/components/Select/select.css'; | ||
| import { css } from '@patternfly/react-styles'; | ||
| import PropTypes from 'prop-types'; | ||
| import SingleSelect from './SingleSelect'; | ||
| import SelectToggle from './SelectToggle'; | ||
| import { SelectContext, SelectVariant } from './selectConstants'; | ||
|
|
||
| // seed for the aria-labelledby ID | ||
| let currentId = 0; | ||
|
|
||
| const propTypes = { | ||
| /** Content rendered inside the Select */ | ||
| children: PropTypes.node, | ||
| /** Classes applied to the root of the Select */ | ||
| className: PropTypes.string, | ||
| /** Flag to indicate if select is expanded */ | ||
| isExpanded: PropTypes.bool, | ||
| /** Placeholder text of Select */ | ||
| placeholderText: PropTypes.string, | ||
| /** Selected item */ | ||
| selections: PropTypes.string, | ||
| /** Id of label for the Select aria-labelledby */ | ||
| ariaLabelledBy: PropTypes.string, | ||
| /** Callback for selection behavior */ | ||
| onSelect: PropTypes.func.isRequired, | ||
| /** Callback for toggle button behavior */ | ||
| onToggle: PropTypes.func.isRequired, | ||
| /** Variant of rendered Select */ | ||
| variant: PropTypes.oneOf(['single']), | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** Width of the select container as a number of px or string percentage */ | ||
| width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | ||
| /** Additional props are spread to the container <ul> */ | ||
| '': PropTypes.any | ||
| }; | ||
|
|
||
| const defaultProps = { | ||
| children: null, | ||
| className: '', | ||
| isExpanded: false, | ||
| ariaLabelledBy: '', | ||
| selections: null, | ||
| placeholderText: null, | ||
| variant: SelectVariant.single, | ||
| width: '100%' | ||
| }; | ||
|
|
||
| class Select extends React.Component { | ||
| parentRef = React.createRef(); | ||
| state = { openedOnEnter: false }; | ||
|
|
||
| onEnter = () => { | ||
| this.setState({ openedOnEnter: true }); | ||
| }; | ||
|
|
||
| onClose = () => { | ||
| this.setState({ openedOnEnter: false }); | ||
| }; | ||
|
|
||
| render() { | ||
| const { | ||
| children, | ||
| className, | ||
| variant, | ||
| onToggle, | ||
| onSelect, | ||
| isExpanded, | ||
| selections, | ||
| ariaLabelledBy, | ||
| placeholderText, | ||
| width, | ||
| ...props | ||
| } = this.props; | ||
| const { openedOnEnter } = this.state; | ||
| const selectToggleId = `pf-toggle-id-${currentId++}`; | ||
| let childPlaceholderText = null; | ||
| if (!selections && !placeholderText) { | ||
| const childPlaceholder = children.filter(child => child.props.isPlaceholder === true); | ||
| childPlaceholderText = | ||
| (childPlaceholder[0] && childPlaceholder[0].props.value) || (children[0] && children[0].props.value); | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={css(styles.select, isExpanded && styles.modifiers.expanded, className)} | ||
| ref={this.parentRef} | ||
| style={{ width }} | ||
| > | ||
| <SelectContext.Provider value={{ onSelect, onClose: this.onClose }}> | ||
| {variant === 'single' && ( | ||
| <React.Fragment> | ||
| <SelectToggle | ||
| id={selectToggleId} | ||
| parentRef={this.parentRef.current} | ||
| isExpanded={isExpanded} | ||
| onToggle={onToggle} | ||
| onEnter={this.onEnter} | ||
| onClose={this.onClose} | ||
| aria-labelledby={`${ariaLabelledBy} ${selectToggleId}`} | ||
| style={{ width }} | ||
| > | ||
| {selections || placeholderText || childPlaceholderText} | ||
| </SelectToggle> | ||
| {isExpanded && ( | ||
| <SingleSelect | ||
| {...props} | ||
| selected={selections} | ||
| openedOnEnter={openedOnEnter} | ||
| aria-labelledby={ariaLabelledBy} | ||
| > | ||
| {children} | ||
| </SingleSelect> | ||
| )} | ||
| </React.Fragment> | ||
| )} | ||
| </SelectContext.Provider> | ||
| </div> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| Select.propTypes = propTypes; | ||
| Select.defaultProps = defaultProps; | ||
|
|
||
| export default Select; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import React from 'react'; | ||
| import { mount } from 'enzyme'; | ||
| import Select from './Select'; | ||
| import SelectOption from './SelectOption'; | ||
|
|
||
| const selectOptions = [ | ||
| <SelectOption value="Mr" key="0" />, | ||
| <SelectOption value="Mrs" key="1" />, | ||
| <SelectOption value="Ms" key="2" />, | ||
| <SelectOption value="Other" key="3" /> | ||
| ]; | ||
|
|
||
| describe('select', () => { | ||
| describe('single select', () => { | ||
| test('renders closed successfully', () => { | ||
| const view = mount( | ||
| <Select variant="single" onSelect={jest.fn()} onToggle={jest.fn()}> | ||
| {selectOptions} | ||
| </Select> | ||
| ); | ||
| expect(view).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| test('renders expanded successfully', () => { | ||
| const view = mount( | ||
| <Select variant="single" onSelect={jest.fn()} onToggle={jest.fn()} isExpanded> | ||
| {selectOptions} | ||
| </Select> | ||
| ); | ||
| expect(view).toMatchSnapshot(); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('API', () => { | ||
| test('click on item', () => { | ||
| const mockToggle = jest.fn(); | ||
| const mockSelect = jest.fn(); | ||
| const view = mount( | ||
| <Select variant="single" onToggle={mockToggle} onSelect={mockSelect} isExpanded> | ||
| {selectOptions} | ||
| </Select> | ||
| ); | ||
| view | ||
| .find('button') | ||
| .at(1) | ||
| .simulate('click'); | ||
| expect(mockToggle.mock.calls).toHaveLength(0); | ||
| expect(mockSelect.mock.calls).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('children only, no console error', () => { | ||
| const myMock = jest.fn(); | ||
| global.console = { error: myMock }; | ||
| mount( | ||
| <Select variant="single" onSelect={jest.fn()} onToggle={jest.fn()} isExpanded> | ||
| {selectOptions} | ||
| </Select> | ||
| ); | ||
| expect(myMock).not.toBeCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { HTMLProps, FormEvent } from 'react'; | ||
| import { Omit } from '../../typeUtils'; | ||
|
|
||
| export interface SelectOptionProps extends Omit<HTMLProps<HTMLOptionElement>, 'disabled'> { | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| value?: string; | ||
| index?: number; | ||
| isValid?: boolean; | ||
| isDisabled?: boolean; | ||
| isPlaceholder?: boolean; | ||
| isSelected?: boolean; | ||
| onClick?: Function; | ||
| sendRef?: Function; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's this used for? |
||
| keyHandler?: Function; | ||
| } | ||
|
|
||
| declare const SelectOption: React.FunctionComponent<SelectOptionProps>; | ||
|
|
||
| export default SelectOption; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import React from 'react'; | ||
| import styles from '@patternfly/patternfly/components/Select/select.css'; | ||
| import { css } from '@patternfly/react-styles'; | ||
| import PropTypes from 'prop-types'; | ||
| import { SelectContext, KeyTypes } from './selectConstants'; | ||
|
|
||
| const propTypes = { | ||
kmcfaul marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** additional classes added to the Select Option */ | ||
| className: PropTypes.string, | ||
| /** the value for the option */ | ||
| value: PropTypes.string, | ||
| /** internal index of the option */ | ||
| index: PropTypes.number, | ||
| /** flag indicating if the option is disabled */ | ||
| isDisabled: PropTypes.bool, | ||
| /** flag indicating if the option acts as a placeholder */ | ||
| isPlaceholder: PropTypes.bool, | ||
| /** Internal flag indicating if the option is selected */ | ||
| isSelected: PropTypes.bool, | ||
| /** Optional on click callback */ | ||
| onClick: PropTypes.func, | ||
| /** Internal callback for ref tracking */ | ||
| sendRef: PropTypes.func, | ||
dlabaj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** Internal callback for keyboard navigation */ | ||
| keyHandler: PropTypes.func, | ||
| /** Additional props are spread to the container <button> */ | ||
| '': PropTypes.any | ||
| }; | ||
|
|
||
| const defaultProps = { | ||
| className: '', | ||
| value: null, | ||
| index: 0, | ||
| isDisabled: false, | ||
| isPlaceholder: false, | ||
| isSelected: false, | ||
| onClick: Function.prototype, | ||
| sendRef: Function.prototype, | ||
| keyHandler: Function.prototype | ||
| }; | ||
|
|
||
| class SelectOption extends React.Component { | ||
| ref = React.createRef(); | ||
|
|
||
| componentDidMount() { | ||
| this.props.sendRef(this.ref.current, this.props.index); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Linting error no props validation on index. |
||
| } | ||
|
|
||
| onKeyDown = event => { | ||
| if (event.key === KeyTypes.Tab) return; | ||
| event.preventDefault(); | ||
| if (event.key === KeyTypes.ArrowUp) { | ||
| this.props.keyHandler(this.props.index, 'up'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Linting error no props validation on keyHandler. |
||
| } else if (event.key === KeyTypes.ArrowDown) { | ||
| this.props.keyHandler(this.props.index, 'down'); | ||
| } else if (event.key === KeyTypes.Enter) { | ||
| this.ref.current.click && this.ref.current.click(); | ||
| } | ||
| }; | ||
|
|
||
| render() { | ||
| const { | ||
| className, | ||
| value, | ||
| onClick, | ||
| isDisabled, | ||
| isPlaceholder, | ||
| isSelected, | ||
| sendRef, | ||
| keyHandler, | ||
| index, | ||
| ...props | ||
| } = this.props; | ||
| return ( | ||
| <SelectContext.Consumer> | ||
| {({ onSelect, onClose }) => ( | ||
| <li role="presentation"> | ||
| <button | ||
| {...props} | ||
| className={css( | ||
| styles.selectMenuItem, | ||
| isSelected && styles.selectMenuItemMatch, | ||
| isDisabled && styles.modifiers.disabled, | ||
| className | ||
| )} | ||
| onClick={event => { | ||
| if (!isDisabled) { | ||
| onClick && onClick(event); | ||
| onSelect && onSelect(event, value, isPlaceholder); | ||
| onClose && onClose(); | ||
| } | ||
| }} | ||
| role="option" | ||
| aria-selected={isSelected || null} | ||
| ref={this.ref} | ||
| onKeyDown={this.onKeyDown} | ||
| > | ||
| {value} | ||
| </button> | ||
| </li> | ||
| )} | ||
| </SelectContext.Consumer> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| SelectOption.propTypes = propTypes; | ||
| SelectOption.defaultProps = defaultProps; | ||
|
|
||
| export default SelectOption; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import React from 'react'; | ||
| import { shallow } from 'enzyme'; | ||
| import SelectOption from './SelectOption'; | ||
|
|
||
| describe('select options', () => { | ||
| test('renders with value parameter successfully', () => { | ||
| const view = shallow(<SelectOption value="test" sendRef={jest.fn()} />); | ||
| expect(view).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| describe('hover', () => { | ||
| test('renders with hover successfully', () => { | ||
| const view = shallow(<SelectOption isHovered value="test" sendRef={jest.fn()} />); | ||
| expect(view).toMatchSnapshot(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('disabled', () => { | ||
| test('renders disabled successfully', () => { | ||
| const view = shallow(<SelectOption isDisabled value="test" sendRef={jest.fn()} />); | ||
| expect(view).toMatchSnapshot(); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.