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

Commit f979147

Browse files
author
Alexandru Buliga
authored
feat(dropdown): autocontrolled mode for open state (#900)
* feat(dropdown): autocontrolled mode for open state - introduced `open`, `defaultOpen` props - introduced `onOpenChange` event handled * changelog * addressed comments * - removed redundant open flag - reordered handlers and render function
1 parent ec4827b commit f979147

File tree

6 files changed

+100
-57
lines changed

6 files changed

+100
-57
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
2222
- Adding status behavior @kolaps33 ([#880](https://github.com/stardust-ui/react/pull/880))
2323
- Add basic animation library for Teams theme @bhamlefty @mnajdova ([#871](https://github.com/stardust-ui/react/pull/871)
2424
- Export `accept` and `urgent` SVG icons to the Teams Theme @joheredi([#929](https://github.com/stardust-ui/react/pull/929))
25+
- Add `open`, `defaultOpen` and `onOpenChange` props for `Dropdown` component (controlled mode) @Bugaa92 ([#900](https://github.com/stardust-ui/react/pull/900))
2526

2627
### Fixes
2728
- Display correctly images in portrait mode inside `Avatar` @layershifter ([#899](https://github.com/stardust-ui/react/pull/899))
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as React from 'react'
2+
import { Dropdown, Flex, Text } from '@stardust-ui/react'
3+
4+
const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth']
5+
6+
class DropdownExampleControlled extends React.Component {
7+
state = { open: false }
8+
9+
handleOpenChange = (e, { open }) => {
10+
this.setState({ open })
11+
}
12+
13+
render() {
14+
const open = this.state.open
15+
return (
16+
<Flex gap="gap.large" vAlign="center">
17+
<Dropdown
18+
open={open}
19+
onOpenChange={this.handleOpenChange}
20+
items={inputItems}
21+
placeholder="Select your hero"
22+
/>
23+
<Text weight="semibold" content={`Dropdown open state is: "${open}"`} />
24+
</Flex>
25+
)
26+
}
27+
}
28+
29+
export default DropdownExampleControlled

docs/src/examples/components/Dropdown/Usage/index.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import * as React from 'react'
22
import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample'
33
import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection'
44

5-
const Variations = () => (
6-
<ExampleSection title="Variations">
5+
const Usage = () => (
6+
<ExampleSection title="Usage">
7+
<ComponentExample
8+
title="Controlled"
9+
description="A dropdown can handle open state in controlled mode."
10+
examplePath="components/Dropdown/Usage/DropdownExampleControlled"
11+
/>
712
<ComponentExample
813
title="Render callbacks"
914
description="You can customize rendered elements with render callbacks."
@@ -12,4 +17,4 @@ const Variations = () => (
1217
</ExampleSection>
1318
)
1419

15-
export default Variations
20+
export default Usage

docs/src/examples/components/Popup/Types/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const Types = () => (
1212
/>
1313
<ComponentExample
1414
title="Controlled"
15-
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."
15+
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."
1616
examplePath="components/Popup/Types/PopupControlledExample"
1717
/>
1818
<ComponentExample

packages/react/src/components/Dropdown/Dropdown.tsx

Lines changed: 60 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
6262
/** The initial value for the index of the currently active selected item, in a multiple selection. */
6363
defaultActiveSelectedIndex?: number
6464

65+
/** Initial value for 'open' in uncontrolled mode */
66+
defaultOpen?: boolean
67+
6568
/** The initial value for the search query, if the dropdown is also a search. */
6669
defaultSearchQuery?: string
6770

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

120+
/**
121+
* Callback for change in dropdown open value.
122+
* @param {SyntheticEvent} event - React's original SyntheticEvent.
123+
* @param {Object} data - All props and the new open flag value in the edit text.
124+
*/
125+
onOpenChange?: ComponentEventHandler<DropdownProps>
126+
117127
/**
118128
* Callback for change in dropdown search query value.
119129
* @param {SyntheticEvent} event - React's original SyntheticEvent.
@@ -128,6 +138,9 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
128138
*/
129139
onSelectedChange?: ComponentEventHandler<DropdownProps>
130140

141+
/** Defines whether dropdown is displayed. */
142+
open?: boolean
143+
131144
/** A placeholder message for the input field. */
132145
placeholder?: string
133146

@@ -172,8 +185,8 @@ export interface DropdownState {
172185
activeSelectedIndex: number
173186
defaultHighlightedIndex: number
174187
focused: boolean
175-
isOpen?: boolean
176-
searchQuery?: string
188+
open: boolean
189+
searchQuery: string
177190
value: ShorthandValue | ShorthandCollection
178191
}
179192

@@ -204,6 +217,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
204217
clearable: PropTypes.bool,
205218
clearIndicator: customPropTypes.itemShorthand,
206219
defaultActiveSelectedIndex: PropTypes.number,
220+
defaultOpen: PropTypes.bool,
207221
defaultSearchQuery: PropTypes.string,
208222
defaultValue: PropTypes.oneOfType([
209223
customPropTypes.itemShorthand,
@@ -219,8 +233,10 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
219233
loadingMessage: customPropTypes.itemShorthand,
220234
multiple: PropTypes.bool,
221235
noResultsMessage: customPropTypes.itemShorthand,
236+
onOpenChange: PropTypes.func,
222237
onSearchQueryChange: PropTypes.func,
223238
onSelectedChange: PropTypes.func,
239+
open: PropTypes.bool,
224240
placeholder: PropTypes.string,
225241
renderItem: PropTypes.func,
226242
renderSelectedItem: PropTypes.func,
@@ -250,7 +266,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
250266
triggerButton: {},
251267
}
252268

253-
static autoControlledProps = ['activeSelectedIndex', 'searchQuery', 'value']
269+
static autoControlledProps = ['activeSelectedIndex', 'open', 'searchQuery', 'value']
254270

255271
static Item = DropdownItem
256272
static SearchInput = DropdownSearchInput
@@ -262,6 +278,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
262278
// used on single selection to open the dropdown with the selected option as highlighted.
263279
defaultHighlightedIndex: this.props.multiple ? undefined : null,
264280
focused: false,
281+
open: false,
265282
searchQuery: search ? '' : undefined,
266283
value: multiple ? [] : null,
267284
}
@@ -284,11 +301,12 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
284301
itemToString,
285302
toggleIndicator,
286303
} = this.props
287-
const { defaultHighlightedIndex, searchQuery, value } = this.state
304+
const { defaultHighlightedIndex, open, searchQuery, value } = this.state
288305

289306
return (
290307
<ElementType className={classes.root} {...unhandledProps}>
291308
<Downshift
309+
isOpen={open}
292310
onChange={this.handleSelectedChange}
293311
onInputValueChange={this.handleSearchQueryChange}
294312
inputValue={search ? searchQuery : null}
@@ -305,7 +323,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
305323
getMenuProps,
306324
getRootProps,
307325
getToggleButtonProps,
308-
isOpen,
309326
toggleMenu,
310327
highlightedIndex,
311328
selectItemAtIndex,
@@ -320,7 +337,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
320337
<Ref innerRef={innerRef}>
321338
<div
322339
className={cx(Dropdown.slotClassNames.container, classes.container)}
323-
onClick={search && !isOpen ? this.handleContainerClick : undefined}
340+
onClick={search && !open ? this.handleContainerClick : undefined}
324341
>
325342
<div
326343
ref={this.selectedItemsRef}
@@ -354,7 +371,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
354371
})
355372
: Indicator.create(toggleIndicator, {
356373
defaultProps: {
357-
direction: isOpen ? 'top' : 'bottom',
374+
direction: open ? 'top' : 'bottom',
358375
styles: styles.toggleIndicator,
359376
},
360377
overrideProps: (predefinedProps: IndicatorProps) => ({
@@ -367,7 +384,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
367384
{this.renderItemsList(
368385
styles,
369386
variables,
370-
isOpen,
371387
highlightedIndex,
372388
toggleMenu,
373389
selectItemAtIndex,
@@ -458,7 +474,6 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
458474
private renderItemsList(
459475
styles: ComponentSlotStylesInput,
460476
variables: ComponentVariablesInput,
461-
isOpen: boolean,
462477
highlightedIndex: number,
463478
toggleMenu: () => void,
464479
selectItemAtIndex: (index: number) => void,
@@ -467,6 +482,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
467482
getInputProps: (options?: GetInputPropsOptions) => any,
468483
) {
469484
const { search } = this.props
485+
const { open } = this.state
470486
const { innerRef, ...accessibilityMenuProps } = getMenuProps(
471487
{ refKey: 'innerRef' },
472488
{ suppressRefError: true },
@@ -501,8 +517,8 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
501517
{...accessibilityMenuProps}
502518
styles={styles.list}
503519
tabIndex={search ? undefined : -1} // needs to be focused when trigger button is activated.
504-
aria-hidden={!isOpen}
505-
items={isOpen ? this.renderItems(styles, variables, getItemProps, highlightedIndex) : []}
520+
aria-hidden={!open}
521+
items={open ? this.renderItems(styles, variables, getItemProps, highlightedIndex) : []}
506522
/>
507523
</Ref>
508524
)
@@ -576,13 +592,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
576592
}
577593

578594
private handleSearchQueryChange = (searchQuery: string) => {
579-
this.trySetState({ searchQuery })
580-
_.invoke(
581-
this.props,
582-
'onSearchQueryChange',
583-
{}, // we don't have event for it, but want to keep the event handling interface, event is empty.
584-
{ ...this.props, searchQuery },
585-
)
595+
this.trySetStateAndInvokeHandler('onSearchQueryChange', null, { searchQuery })
586596
}
587597

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

604614
private handleStateChange = (changes: StateChangeOptions<ShorthandValue>) => {
605-
if (changes.isOpen !== undefined && changes.isOpen !== this.state.isOpen) {
606-
this.setState({ isOpen: changes.isOpen })
615+
if (changes.isOpen !== undefined && changes.isOpen !== this.state.open) {
616+
this.trySetStateAndInvokeHandler('onOpenChange', null, { open: changes.isOpen })
607617
}
608618

609619
if (changes.isOpen && !this.props.search) {
@@ -664,19 +674,18 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
664674
item: ShorthandValue,
665675
rtl: boolean,
666676
) => ({
667-
onRemove: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
668-
this.handleSelectedItemRemove(e, item, predefinedProps, DropdownSelectedItemProps)
677+
onRemove: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
678+
this.handleSelectedItemRemove(e, item, predefinedProps, dropdownSelectedItemProps)
669679
},
670-
onClick: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
680+
onClick: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
671681
const { value } = this.state as { value: ShorthandCollection }
672-
this.trySetState({
673-
activeSelectedIndex: value.indexOf(item),
674-
})
682+
683+
this.trySetState({ activeSelectedIndex: value.indexOf(item) })
675684
e.stopPropagation()
676-
_.invoke(predefinedProps, 'onClick', e, DropdownSelectedItemProps)
685+
_.invoke(predefinedProps, 'onClick', e, dropdownSelectedItemProps)
677686
},
678-
onKeyDown: (e: React.SyntheticEvent, DropdownSelectedItemProps: DropdownSelectedItemProps) => {
679-
this.handleSelectedItemKeyDown(e, item, predefinedProps, DropdownSelectedItemProps, rtl)
687+
onKeyDown: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => {
688+
this.handleSelectedItemKeyDown(e, item, predefinedProps, dropdownSelectedItemProps, rtl)
680689
},
681690
})
682691

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

847856
private handleSelectedChange = (item: ShorthandValue) => {
848857
const { items, multiple, getA11ySelectionMessage } = this.props
849-
const newState = {
858+
859+
this.trySetStateAndInvokeHandler('onSelectedChange', null, {
850860
value: multiple ? [...(this.state.value as ShorthandCollection), item] : item,
851861
searchQuery: this.getSelectedItemAsString(item),
852-
}
853-
854-
this.trySetState(newState)
862+
})
855863

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

872880
this.tryFocusTriggerButton()
873-
874-
// we don't have event for it, but want to keep the event handling interface, event is empty.
875-
_.invoke(this.props, 'onSelectedChange', {}, { ...this.props, ...newState })
876881
}
877882

878883
private handleSelectedItemKeyDown(
@@ -896,21 +901,15 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
896901
break
897902
case previousKey:
898903
if (value.length > 0 && !_.isNil(activeSelectedIndex) && activeSelectedIndex > 0) {
899-
this.trySetState({
900-
activeSelectedIndex: activeSelectedIndex - 1,
901-
})
904+
this.trySetState({ activeSelectedIndex: activeSelectedIndex - 1 })
902905
}
903906
break
904907
case nextKey:
905908
if (value.length > 0 && !_.isNil(activeSelectedIndex)) {
906909
if (activeSelectedIndex < value.length - 1) {
907-
this.trySetState({
908-
activeSelectedIndex: activeSelectedIndex + 1,
909-
})
910+
this.trySetState({ activeSelectedIndex: activeSelectedIndex + 1 })
910911
} else {
911-
this.trySetState({
912-
activeSelectedIndex: null,
913-
})
912+
this.trySetState({ activeSelectedIndex: null })
914913
if (this.props.search) {
915914
e.preventDefault() // prevents caret to forward one position in input.
916915
this.inputRef.current.focus()
@@ -932,9 +931,7 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
932931
predefinedProps: DropdownSelectedItemProps,
933932
DropdownSelectedItemProps: DropdownSelectedItemProps,
934933
) {
935-
this.trySetState({
936-
activeSelectedIndex: null,
937-
})
934+
this.trySetState({ activeSelectedIndex: null })
938935
this.removeItemFromValue(item)
939936
this.tryFocusSearchInput()
940937
this.tryFocusTriggerButton()
@@ -953,14 +950,25 @@ class Dropdown extends AutoControlledComponent<Extendable<DropdownProps>, Dropdo
953950
poppedItem = value.pop()
954951
}
955952

956-
this.trySetState({ value })
957-
958953
if (getA11ySelectionMessage && getA11ySelectionMessage.onRemove) {
959954
this.setA11yStatus(getA11ySelectionMessage.onRemove(poppedItem))
960955
}
961956

962-
// we don't have event for it, but want to keep the event handling interface, event is empty.
963-
_.invoke(this.props, 'onSelectedChange', {}, { ...this.props, value })
957+
this.trySetStateAndInvokeHandler('onSelectedChange', null, { value })
958+
}
959+
960+
/**
961+
* Calls trySetState (for autoControlledProps) and invokes event handler exposed to user.
962+
* We don't have the event object for most events coming from Downshift se we send an empty event
963+
* because we want to keep the event handling interface
964+
*/
965+
private trySetStateAndInvokeHandler = (
966+
handlerName: keyof DropdownProps,
967+
event: React.SyntheticEvent<HTMLElement>,
968+
newState: Partial<DropdownState>,
969+
) => {
970+
this.trySetState(newState)
971+
_.invoke(this.props, handlerName, event, { ...this.props, ...newState })
964972
}
965973

966974
private tryFocusTriggerButton = () => {

packages/react/src/themes/teams/components/Dropdown/dropdownStyles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const dropdownStyles: ComponentSlotStylesInput<DropdownPropsAndState, DropdownVa
122122
width: getWidth(p, v),
123123
top: 'calc(100% + 2px)', // leave room for container + its border
124124
background: v.listBackgroundColor,
125-
...(p.isOpen && {
125+
...(p.open && {
126126
boxShadow: v.listBoxShadow,
127127
padding: v.listPadding,
128128
}),

0 commit comments

Comments
 (0)