Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

feat(dropdown): autocontrolled mode for open state #900

Merged
merged 4 commits into from
Feb 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Adding status behavior @kolaps33 ([#880](https://github.com/stardust-ui/react/pull/880))
- Add basic animation library for Teams theme @bhamlefty @mnajdova ([#871](https://github.com/stardust-ui/react/pull/871)
- Export `accept` and `urgent` SVG icons to the Teams Theme @joheredi([#929](https://github.com/stardust-ui/react/pull/929))
- Add `open`, `defaultOpen` and `onOpenChange` props for `Dropdown` component (controlled mode) @Bugaa92 ([#900](https://github.com/stardust-ui/react/pull/900))

### Fixes
- Display correctly images in portrait mode inside `Avatar` @layershifter ([#899](https://github.com/stardust-ui/react/pull/899))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react'
import { Dropdown, Flex, Text } from '@stardust-ui/react'

const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth']

class DropdownExampleControlled extends React.Component {
state = { open: false }

handleOpenChange = (e, { open }) => {
this.setState({ open })
}

render() {
const open = this.state.open
return (
<Flex gap="gap.large" vAlign="center">
<Dropdown
open={open}
onOpenChange={this.handleOpenChange}
items={inputItems}
placeholder="Select your hero"
/>
<Text weight="semibold" content={`Dropdown open state is: "${open}"`} />
</Flex>
)
}
}

export default DropdownExampleControlled
11 changes: 8 additions & 3 deletions docs/src/examples/components/Dropdown/Usage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import * as React from 'react'
import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample'
import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection'

const Variations = () => (
<ExampleSection title="Variations">
const Usage = () => (
<ExampleSection title="Usage">
<ComponentExample
title="Controlled"
description="A dropdown can handle open state in controlled mode."
examplePath="components/Dropdown/Usage/DropdownExampleControlled"
/>
<ComponentExample
title="Render callbacks"
description="You can customize rendered elements with render callbacks."
Expand All @@ -12,4 +17,4 @@ const Variations = () => (
</ExampleSection>
)

export default Variations
export default Usage
2 changes: 1 addition & 1 deletion docs/src/examples/components/Popup/Types/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const Types = () => (
/>
<ComponentExample
title="Controlled"
description="Note that if Popup is controlled, then its 'open' prop value could be changed either by parent component, or by user actions (e.g. key press) - thus it is necessary to handle 'onOpenChanged' event. Try to type some text into popup's input field and press ESC to see the effect."
description="Note that if Popup is controlled, then its 'open' prop value could be changed either by parent component, or by user actions (e.g. key press) - thus it is necessary to handle 'onOpenChange' event. Try to type some text into popup's input field and press ESC to see the effect."
examplePath="components/Popup/Types/PopupControlledExample"
/>
<ComponentExample
Expand Down
112 changes: 60 additions & 52 deletions packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
/** The initial value for the index of the currently active selected item, in a multiple selection. */
defaultActiveSelectedIndex?: number

/** Initial value for 'open' in uncontrolled mode */
defaultOpen?: boolean

/** The initial value for the search query, if the dropdown is also a search. */
defaultSearchQuery?: string

Expand Down Expand Up @@ -114,6 +117,13 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
/** A message to be displayed in the list when dropdown has no available items to show. */
noResultsMessage?: ShorthandValue

/**
* Callback for change in dropdown open value.
* @param {SyntheticEvent} event - React's original SyntheticEvent.
* @param {Object} data - All props and the new open flag value in the edit text.
*/
onOpenChange?: ComponentEventHandler<DropdownProps>

/**
* Callback for change in dropdown search query value.
* @param {SyntheticEvent} event - React's original SyntheticEvent.
Expand All @@ -128,6 +138,9 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
*/
onSelectedChange?: ComponentEventHandler<DropdownProps>

/** Defines whether dropdown is displayed. */
open?: boolean

/** A placeholder message for the input field. */
placeholder?: string

Expand Down Expand Up @@ -172,8 +185,8 @@ export interface DropdownState {
activeSelectedIndex: number
defaultHighlightedIndex: number
focused: boolean
isOpen?: boolean
searchQuery?: string
open: boolean
searchQuery: string
value: ShorthandValue | ShorthandCollection
}

Expand Down Expand Up @@ -204,6 +217,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
clearable: PropTypes.bool,
clearIndicator: customPropTypes.itemShorthand,
defaultActiveSelectedIndex: PropTypes.number,
defaultOpen: PropTypes.bool,
defaultSearchQuery: PropTypes.string,
defaultValue: PropTypes.oneOfType([
customPropTypes.itemShorthand,
Expand All @@ -219,8 +233,10 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
loadingMessage: customPropTypes.itemShorthand,
multiple: PropTypes.bool,
noResultsMessage: customPropTypes.itemShorthand,
onOpenChange: PropTypes.func,
onSearchQueryChange: PropTypes.func,
onSelectedChange: PropTypes.func,
open: PropTypes.bool,
placeholder: PropTypes.string,
renderItem: PropTypes.func,
renderSelectedItem: PropTypes.func,
Expand Down Expand Up @@ -250,7 +266,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
triggerButton: {},
}

static autoControlledProps = ['activeSelectedIndex', 'searchQuery', 'value']
static autoControlledProps = ['activeSelectedIndex', 'open', 'searchQuery', 'value']

static Item = DropdownItem
static SearchInput = DropdownSearchInput
Expand All @@ -262,6 +278,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
// used on single selection to open the dropdown with the selected option as highlighted.
defaultHighlightedIndex: this.props.multiple ? undefined : null,
focused: false,
open: false,
searchQuery: search ? '' : undefined,
value: multiple ? [] : null,
}
Expand All @@ -284,11 +301,12 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
itemToString,
toggleIndicator,
} = this.props
const { defaultHighlightedIndex, searchQuery, value } = this.state
const { defaultHighlightedIndex, open, searchQuery, value } = this.state

return (
<ElementType className={classes.root} {...unhandledProps}>
<Downshift
isOpen={open}
onChange={this.handleSelectedChange}
onInputValueChange={this.handleSearchQueryChange}
inputValue={search ? searchQuery : null}
Expand All @@ -305,7 +323,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
getMenuProps,
getRootProps,
getToggleButtonProps,
isOpen,
toggleMenu,
highlightedIndex,
selectItemAtIndex,
Expand All @@ -320,7 +337,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
<Ref innerRef={innerRef}>
<div
className={cx(Dropdown.slotClassNames.container, classes.container)}
onClick={search && !isOpen ? this.handleContainerClick : undefined}
onClick={search && !open ? this.handleContainerClick : undefined}
>
<div
ref={this.selectedItemsRef}
Expand Down Expand Up @@ -354,7 +371,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
})
: Indicator.create(toggleIndicator, {
defaultProps: {
direction: isOpen ? 'top' : 'bottom',
direction: open ? 'top' : 'bottom',
styles: styles.toggleIndicator,
},
overrideProps: (predefinedProps: IndicatorProps) => ({
Expand All @@ -367,7 +384,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
{this.renderItemsList(
styles,
variables,
isOpen,
highlightedIndex,
toggleMenu,
selectItemAtIndex,
Expand Down Expand Up @@ -458,7 +474,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
private renderItemsList(
styles: ComponentSlotStylesInput,
variables: ComponentVariablesInput,
isOpen: boolean,
highlightedIndex: number,
toggleMenu: () => void,
selectItemAtIndex: (index: number) => void,
Expand All @@ -467,6 +482,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
getInputProps: (options?: GetInputPropsOptions) => any,
) {
const { search } = this.props
const { open } = this.state
const { innerRef, ...accessibilityMenuProps } = getMenuProps(
{ refKey: 'innerRef' },
{ suppressRefError: true },
Expand Down Expand Up @@ -501,8 +517,8 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
{...accessibilityMenuProps}
styles={styles.list}
tabIndex={search ? undefined : -1} // needs to be focused when trigger button is activated.
aria-hidden={!isOpen}
items={isOpen ? this.renderItems(styles, variables, getItemProps, highlightedIndex) : []}
aria-hidden={!open}
items={open ? this.renderItems(styles, variables, getItemProps, highlightedIndex) : []}
/>
</Ref>
)
Expand Down Expand Up @@ -576,13 +592,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
}

private handleSearchQueryChange = (searchQuery: string) => {
this.trySetState({ searchQuery })
_.invoke(
this.props,
'onSearchQueryChange',
{}, // we don't have event for it, but want to keep the event handling interface, event is empty.
{ ...this.props, searchQuery },
)
this.trySetStateAndInvokeHandler('onSearchQueryChange', null, { searchQuery })
}

private handleDownshiftStateChanges = (
Expand All @@ -602,8 +612,8 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
}

private handleStateChange = (changes: StateChangeOptions<ShorthandValue>) => {
if (changes.isOpen !== undefined && changes.isOpen !== this.state.isOpen) {
this.setState({ isOpen: changes.isOpen })
if (changes.isOpen !== undefined && changes.isOpen !== this.state.open) {
this.trySetStateAndInvokeHandler('onOpenChange', null, { open: changes.isOpen })
}

if (changes.isOpen && !this.props.search) {
Expand Down Expand Up @@ -664,19 +674,18 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
item: ShorthandValue,
rtl: boolean,
) => ({
onRemove: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
this.handleSelectedItemRemove(e, item, predefinedProps, DropdownSelectedItemProps)
onRemove: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
this.handleSelectedItemRemove(e, item, predefinedProps, dropdownSelectedItemProps)
},
onClick: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
onClick: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
const { value } = this.state as { value: ShorthandCollection }
this.trySetState({
activeSelectedIndex: value.indexOf(item),
})

this.trySetState({ activeSelectedIndex: value.indexOf(item) })
e.stopPropagation()
_.invoke(predefinedProps, 'onClick', e, DropdownSelectedItemProps)
_.invoke(predefinedProps, 'onClick', e, dropdownSelectedItemProps)
},
onKeyDown: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
this.handleSelectedItemKeyDown(e, item, predefinedProps, DropdownSelectedItemProps, rtl)
onKeyDown: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
this.handleSelectedItemKeyDown(e, item, predefinedProps, dropdownSelectedItemProps, rtl)
},
})

Expand Down Expand Up @@ -846,12 +855,11 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo

private handleSelectedChange = (item: ShorthandValue) => {
const { items, multiple, getA11ySelectionMessage } = this.props
const newState = {

this.trySetStateAndInvokeHandler('onSelectedChange', null, {
value: multiple ? [...(this.state.value as ShorthandCollection), item] : item,
searchQuery: this.getSelectedItemAsString(item),
}

this.trySetState(newState)
})

if (!multiple) {
this.setState({ defaultHighlightedIndex: items.indexOf(item) })
Expand All @@ -870,9 +878,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
}

this.tryFocusTriggerButton()

// we don't have event for it, but want to keep the event handling interface, event is empty.
_.invoke(this.props, 'onSelectedChange', {}, { ...this.props, ...newState })
}

private handleSelectedItemKeyDown(
Expand All @@ -896,21 +901,15 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
break
case previousKey:
if (value.length > 0 && !_.isNil(activeSelectedIndex) && activeSelectedIndex > 0) {
this.trySetState({
activeSelectedIndex: activeSelectedIndex - 1,
})
this.trySetState({ activeSelectedIndex: activeSelectedIndex - 1 })
}
break
case nextKey:
if (value.length > 0 && !_.isNil(activeSelectedIndex)) {
if (activeSelectedIndex < value.length - 1) {
this.trySetState({
activeSelectedIndex: activeSelectedIndex + 1,
})
this.trySetState({ activeSelectedIndex: activeSelectedIndex + 1 })
} else {
this.trySetState({
activeSelectedIndex: null,
})
this.trySetState({ activeSelectedIndex: null })
if (this.props.search) {
e.preventDefault() // prevents caret to forward one position in input.
this.inputRef.current.focus()
Expand All @@ -932,9 +931,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
predefinedProps: DropdownSelectedItemProps,
DropdownSelectedItemProps: DropdownSelectedItemProps,
) {
this.trySetState({
activeSelectedIndex: null,
})
this.trySetState({ activeSelectedIndex: null })
this.removeItemFromValue(item)
this.tryFocusSearchInput()
this.tryFocusTriggerButton()
Expand All @@ -953,14 +950,25 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
poppedItem = value.pop()
}

this.trySetState({ value })

if (getA11ySelectionMessage && getA11ySelectionMessage.onRemove) {
this.setA11yStatus(getA11ySelectionMessage.onRemove(poppedItem))
}

// we don't have event for it, but want to keep the event handling interface, event is empty.
_.invoke(this.props, 'onSelectedChange', {}, { ...this.props, value })
this.trySetStateAndInvokeHandler('onSelectedChange', null, { value })
}

/**
* Calls trySetState (for autoControlledProps) and invokes event handler exposed to user.
* We don't have the event object for most events coming from Downshift se we send an empty event
* because we want to keep the event handling interface
*/
private trySetStateAndInvokeHandler = (
handlerName: keyof DropdownProps,
event: React.SyntheticEvent<HTMLElement>,
newState: Partial<DropdownState>,
) => {
this.trySetState(newState)
_.invoke(this.props, handlerName, event, { ...this.props, ...newState })
}

private tryFocusTriggerButton = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const dropdownStyles: ComponentSlotStylesInput<DropdownPropsAndState, DropdownVa
width: getWidth(p, v),
top: 'calc(100% + 2px)', // leave room for container + its border
background: v.listBackgroundColor,
...(p.isOpen && {
...(p.open && {
boxShadow: v.listBoxShadow,
padding: v.listPadding,
}),
Expand Down