Skip to content
This repository has been archived by the owner on Aug 21, 2023. It is now read-only.

Commit

Permalink
SelectDropdown: Add support for value (#446)
Browse files Browse the repository at this point in the history
* SelectDropdown: Add support for value

This update improves the `SelectDropdown` to be able to accept a
`value` prop. This adds API parity with `Select` as well as the
native HTML select.

`value` will be used to find the actual item from the original `items` prop.

Both `selectedItem` and `value` is supported for `SelectDropdown`.

An enhancement was made to `Dropdown` to add a new `triggerStyle` prop,
allowing you to specify inline styles for the Trigger wrapper
component.

* 2.5.13-0

* Fix derived state.selectedItem from props.selectedItem

* Add test for derived selectedItem state
  • Loading branch information
Jon Quach committed Jan 10, 2019
1 parent b01817f commit 34712f2
Show file tree
Hide file tree
Showing 13 changed files with 183 additions and 24 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@helpscout/hsds-react",
"version": "2.5.12",
"version": "2.5.13-0",
"private": false,
"main": "dist/index.js",
"module": "dist/index.es.js",
Expand Down
6 changes: 4 additions & 2 deletions src/components/Dropdown/V2/Dropdown.Trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Props {
onKeyDown: (event: KeyboardEvent) => void
onClick: (event: Event) => void
openDropdown: () => void
style: any
toggleOpen: () => void
}

Expand All @@ -35,6 +36,7 @@ export class Trigger extends React.PureComponent<Props> {
onClick: noop,
openDropdown: noop,
closeDropdown: noop,
style: {},
toggleOpen: noop,
}

Expand Down Expand Up @@ -112,9 +114,9 @@ const PropConnectedTrigger = propConnect(COMPONENT_KEY.Trigger)(Trigger)
const ConnectedTrigger: any = connect(
// mapStateToProps
(state: any) => {
const { isOpen, triggerId } = state
const { isOpen, triggerId, triggerStyle } = state

return { isOpen, id: triggerId }
return { isOpen, id: triggerId, style: triggerStyle }
},
// mapDispatchToProps
{
Expand Down
1 change: 1 addition & 0 deletions src/components/Dropdown/V2/Dropdown.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const initialState = {
stateReducer: state => state,
subscribe: noop,
trigger: 'Dropdown',
triggerStyle: {},
withScrollLock: true,
zIndex: 1080,
}
Expand Down
1 change: 1 addition & 0 deletions src/components/Dropdown/V2/Dropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface DropdownProps extends DropdownMenuDimensions {
stateReducer: (...args: any[]) => void
trigger: any
triggerRef: (node: HTMLElement) => void
triggerStyle: any
withScrollLock: boolean
}

Expand Down
1 change: 1 addition & 0 deletions src/components/Dropdown/V2/docs/Dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ This component supports async rendering of items. Render the `Dropdown` as you w
| subscribe | `Function` | | Subscribes to internal Dropdown state changes. |
| trigger | `string` | `Dropdown` | The text to render into the trigger. |
| triggerRef | `Function` | | Retrieves the Dropdown Trigger DOM node. |
| triggerStyle | `Object` | | Inline styles for the Trigger wrapper. |
| withScrollLock | `boolean` | `true` | Scroll locks the Dropdown menu. |
| zIndex | `number` | `1080` | CSS `z-index` for the menu. |

Expand Down
17 changes: 9 additions & 8 deletions src/components/SelectDropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ A SelectDropdown component is an enhanced version of the default HTML `<select>`

## Props

| Prop | Type | Default | Description |
| ------------ | ---------- | --------- | --------------------------------------------------------------------- |
| onChange | `Function` | | Callback when an item is selected. |
| errorIcon | `string` | `alert` | [Icon](../Icon) to render for an `error` state. |
| errorMessage | `string` | | Message to display (in a [Tooltip](../Tooltip)) for an `error` state. |
| isFocused | `boolean` | | Renders the focus UI. |
| placeholder | `string` | | Placeholder text if there are no selected items. |
| state | `string` | `default` | State to render for the component. |
| Prop | Type | Default | Description |
| ------------ | ----------------- | --------- | --------------------------------------------------------------------- |
| onChange | `Function` | | Callback when an item is selected. |
| errorIcon | `string` | `alert` | [Icon](../Icon) to render for an `error` state. |
| errorMessage | `string` | | Message to display (in a [Tooltip](../Tooltip)) for an `error` state. |
| isFocused | `boolean` | | Renders the focus UI. |
| placeholder | `string` | | Placeholder text if there are no selected items. |
| state | `string` | `default` | State to render for the component. |
| value | `string`/`number` | | The selected value. |

For additional customization and props, check out [Dropdown](../Dropdown/V2/docs/Dropdown.md).
5 changes: 5 additions & 0 deletions src/components/SelectDropdown/SelectDropdown.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const InputUI = styled('div')`
padding-left: 16px;
padding-right: 16px;
position: relative;
text-decoration: none;
&:hover {
text-decoration: none;
}
* {
box-sizing: border-box;
Expand Down
52 changes: 44 additions & 8 deletions src/components/SelectDropdown/SelectDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { initialState } from '../Dropdown/V2/Dropdown.store'
import { DropdownProps } from '../Dropdown/V2/Dropdown.types'
import { itemIsActive } from '../Dropdown/V2/Dropdown.utils'
import { COMPONENT_KEY } from './SelectDropdown.utils'
import { find } from '../../utilities/arrays'
import { classNames } from '../../utilities/classNames'
import { noop } from '../../utilities/other'
import {
Expand All @@ -18,6 +19,7 @@ import {
BackdropUI,
ErrorUI,
} from './SelectDropdown.css'
import { isObject } from '../../utilities/is'

export interface Props extends DropdownProps {
onChange: (...args: any) => void
Expand All @@ -26,6 +28,7 @@ export interface Props extends DropdownProps {
isFocused: boolean
placeholder: string
state: string
value?: any
}
export interface State {
isFocused: boolean
Expand All @@ -48,17 +51,29 @@ export class SelectDropdown extends React.PureComponent<Props, State> {
state: 'default',
trigger: undefined,
width: '100%',
value: undefined,
}

state = {
isFocused: this.props.isFocused,
selectedItem: this.props.selectedItem || this.props.items[0],
selectedItem:
this.props.selectedItem ||
this.getSelectedItem(this.props.items, this.props.value) ||
this.props.items[0],
}

componentWillReceiveProps(nextProps) {
if (nextProps.selectedItem !== this.state.selectedItem) {
if (nextProps.selectedItem !== this.props.selectedItem) {
this.setState({
selectedItem: nextProps.selectedItem,
selectedItem: this.getSelectedItem(
nextProps.items,
nextProps.selectedItem
),
})
}
if (nextProps.value !== this.props.value) {
this.setState({
selectedItem: this.getSelectedItem(nextProps.items, nextProps.value),
})
}
}
Expand Down Expand Up @@ -86,6 +101,12 @@ export class SelectDropdown extends React.PureComponent<Props, State> {
})
}

getClassName() {
const { className } = this.props

return classNames('c-SelectDropdown', className)
}

getActiveItem() {
return this.props.items.filter(item =>
itemIsActive(this.state.selectedItem, item)
Expand All @@ -105,6 +126,23 @@ export class SelectDropdown extends React.PureComponent<Props, State> {
return trigger || placeholder
}

getSelectedItem(items: Array<any>, value: any): any {
if (isObject(value)) return value

return find(items, item => {
if (isObject(item)) {
return item.value === value
}
return item === value
})
}

getTriggerStyle() {
return {
textDecoration: 'none',
}
}

renderError() {
const { errorIcon, errorMessage, state } = this.props
const shouldRenderError = state === 'error'
Expand Down Expand Up @@ -145,19 +183,17 @@ export class SelectDropdown extends React.PureComponent<Props, State> {
}

render() {
const { className, ...rest } = this.props

const componentClassName = classNames('c-SelectDropdown', className)
return (
<SelectDropdownUI className="c-SelectDropdownWrapper">
<AutoDropdown
{...rest}
className={componentClassName}
{...this.props}
className={this.getClassName()}
renderTrigger={this.renderTrigger()}
selectedItem={this.state.selectedItem}
onBlur={this.handleOnBlur}
onFocus={this.handleOnFocus}
onSelect={this.handleOnChange}
triggerStyle={this.getTriggerStyle()}
/>
</SelectDropdownUI>
)
Expand Down
54 changes: 51 additions & 3 deletions src/components/SelectDropdown/__tests__/SelectDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,13 @@ describe('Events', () => {

describe('selectedItem', () => {
test('Updates the selectedItem on if prop changes', () => {
const prevItem = jest.fn()
const nextItem = jest.fn()
const prevItem = {}
const nextItem = {}
const items = [prevItem, nextItem]

const wrapper = mount(<SelectDropdown selectedItem={prevItem} />)
const wrapper = mount(
<SelectDropdown items={items} selectedItem={prevItem} />
)

expect(wrapper.find('Dropdown').prop('selectedItem')).toBe(prevItem)

Expand All @@ -105,6 +108,18 @@ describe('selectedItem', () => {

expect(wrapper.find('Dropdown').prop('selectedItem')).toBe(prevItem)
})

test('Does not reset the selectItem on an unrelated change', () => {
const item = jest.fn()

const wrapper = mount(<SelectDropdown selectedItem={undefined} />)

wrapper.setState({ selectedItem: item })
wrapper.setProps({ selectedItem: undefined })

// @ts-ignore
expect(wrapper.state().selectedItem).toBe(item)
})
})

describe('renderTrigger', () => {
Expand Down Expand Up @@ -189,3 +204,36 @@ describe('Error', () => {
expect(el.prop('title')).toBe('Uh oh!')
})
})

describe('value', () => {
test('Sets initial selectedItem state with value onMount', () => {
const items = [
{
value: 'will',
},
{
value: 'ron',
},
]
const wrapper = mount(<SelectDropdown items={items} value="ron" />)

// @ts-ignore
expect(wrapper.state().selectedItem.value).toBe('ron')
})

test('Updates selectedItem state on value change', () => {
const items = [
{
value: 'will',
},
{
value: 'ron',
},
]
const wrapper = mount(<SelectDropdown items={items} />)
wrapper.setProps({ value: 'ron' })

// @ts-ignore
expect(wrapper.state().selectedItem.value).toBe('ron')
})
})
31 changes: 30 additions & 1 deletion src/utilities/__tests__/arrays.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { first, last, random } from '../arrays'
import { first, last, random, find } from '../arrays'

describe('first', () => {
test('Returns undefined if empty', () => {
Expand Down Expand Up @@ -30,3 +30,32 @@ describe('Random', () => {
expect(array).toContain(random(array))
})
})

describe('find', () => {
test('Returns undefined if empty', () => {
expect(find()).toBeFalsy()
})

test('Can find an item from an array', () => {
const array = [1, 2, 3]
const result = find(array, item => item === 2)

expect(result).toBe(2)
})

test('Polyfills if Array.prototype is undefined', () => {
const fn = Array.prototype.find
// Temporarily remove the prototype
// eslint-disable-next-line
Array.prototype.find = undefined

const array = [1, 2, 3]
const result = find(array, item => item === 2)

expect(result).toBe(2)

// Restore the prototype
// eslint-disable-next-line
Array.prototype.find = fn
})
})
16 changes: 16 additions & 0 deletions src/utilities/arrays.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { noop } from './other'

/**
* Returns the first item of an array
* @param {Array} array The array.
Expand Down Expand Up @@ -32,3 +34,17 @@ export const random = (array: Array<any> = []): any => {
export const includes = (array: Array<any> = [], item: any): boolean => {
return array.indexOf(item) >= 0
}

/**
* Simple polyfill for Array.prototype.find
* @param {Array} array The array.
* @param {Function} callback Callback to match.
* @returns {boolean} The result.
*/
export const find = (
array: Array<any> = [],
callback: (item) => any = noop
): any => {
if (Array.prototype.find) return array.find(callback)
return array.filter(callback)[0]
}
19 changes: 19 additions & 0 deletions stories/SelectDropdown.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,22 @@ stories.add('Default', () => {
}
return <SelectDropdown {...props} />
})

stories.add('Statefully controlled', () => {
class Example extends React.Component {
state = {
items: [{ label: 'Will', value: '123' }, { label: 'Ron', value: '456' }],
value: null,
}

onChange = value => {
this.setState({ value })
}

render() {
return <SelectDropdown {...this.state} onChange={this.onChange} />
}
}

return <Example />
})

0 comments on commit 34712f2

Please sign in to comment.