diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c43b281ca..22a21b9482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### BREAKING CHANGES - Update variable names in themes, add missing sizes @layershifter ([#762](https://github.com/stardust-ui/react/pull/762)) +- Rename `toggleButton` prop to `toggleIndicator` and make it visible by default @layershifter ([#729](https://github.com/stardust-ui/react/pull/729)) + +### Features +- Add `loading` prop for `Dropdown` @layershifter ([#729](https://github.com/stardust-ui/react/pull/729)) ## [v0.18.0](https://github.com/stardust-ui/react/tree/v0.18.0) (2019-01-24) diff --git a/docs/src/components/CodeSnippet.tsx b/docs/src/components/CodeSnippet.tsx index f58678f11c..071cfa6848 100644 --- a/docs/src/components/CodeSnippet.tsx +++ b/docs/src/components/CodeSnippet.tsx @@ -1,29 +1,34 @@ +import * as _ from 'lodash' import * as React from 'react' import formatCode from '../utils/formatCode' import Editor, { EDITOR_BACKGROUND_COLOR } from './Editor' +export type CodeSnippetValue = string | string[] | Object + export interface CodeSnippetProps { fitted?: boolean label?: string - mode?: 'jsx' | 'html' | 'sh' - value: string | string[] + mode?: 'json' | 'jsx' | 'html' | 'sh' + value: CodeSnippetValue style?: React.CSSProperties } -const joinToString = (stringOrArray: string | string[]) => { - return typeof stringOrArray === 'string' ? stringOrArray : stringOrArray.join('\n') +const normalizeToString = (value: CodeSnippetValue): string => { + if (_.isArray(value)) return value.join('\n') + return _.isObject(value) ? JSON.stringify(value, null, 2) : (value as string) } const formatters = { sh: (val: string = ''): string => val.replace(/^/g, '$ '), html: (val: string = ''): string => formatCode(val, 'html'), + json: (val: string): string => val, jsx: (val: string = ''): string => formatCode(val, 'babylon'), } -const CodeSnippet = ({ fitted, label, value, mode = 'jsx', ...restProps }: CodeSnippetProps) => { +const CodeSnippet = ({ fitted, label, mode, value, ...restProps }: CodeSnippetProps) => { const format = formatters[mode] - const formattedValue = format(joinToString(value)) + const formattedValue = format(normalizeToString(value)) // remove eof line break, they are not helpful for snippets .replace(/\n$/, '') @@ -66,4 +71,9 @@ const CodeSnippet = ({ fitted, label, value, mode = 'jsx', ...restProps }: CodeS ) } + +CodeSnippet.defaultProps = { + mode: 'jsx', +} + export default CodeSnippet diff --git a/docs/src/components/Editor/Editor.tsx b/docs/src/components/Editor/Editor.tsx index 5a7800b5bb..17ca6bf077 100644 --- a/docs/src/components/Editor/Editor.tsx +++ b/docs/src/components/Editor/Editor.tsx @@ -5,6 +5,7 @@ import AceEditor, { AceEditorProps } from 'react-ace' import * as ace from 'brace' import 'brace/ext/language_tools' import 'brace/mode/html' +import 'brace/mode/json' import 'brace/mode/jsx' import 'brace/mode/sh' import 'brace/theme/tomorrow_night' @@ -47,7 +48,7 @@ languageTools.addCompleter(semanticUIReactCompleter) export interface EditorProps extends AceEditorProps { active?: boolean highlightGutterLine?: boolean - mode?: 'html' | 'jsx' | 'sh' + mode?: 'html' | 'jsx' | 'sh' | 'json' value?: string showCursor?: boolean } @@ -61,7 +62,7 @@ class Editor extends React.Component { static propTypes = { value: PropTypes.string.isRequired, - mode: PropTypes.oneOf(['html', 'jsx', 'sh']), + mode: PropTypes.oneOf(['html', 'json', 'jsx', 'sh']), active: PropTypes.bool, showCursor: PropTypes.bool, } diff --git a/docs/src/components/Sidebar/Sidebar.tsx b/docs/src/components/Sidebar/Sidebar.tsx index 6c3a632dac..2fd65d095e 100644 --- a/docs/src/components/Sidebar/Sidebar.tsx +++ b/docs/src/components/Sidebar/Sidebar.tsx @@ -273,6 +273,14 @@ class Sidebar extends React.Component { > Chat message with popover + + Async Dropdown Search + void +} + +const DropdownExampleLoadingKnobs: React.FC = props => { + const { loading, onKnobChange } = props + + return ( + + + + ) +} + +DropdownExampleLoadingKnobs.defaultProps = { + loading: true, +} + +export default DropdownExampleLoadingKnobs diff --git a/docs/src/examples/components/Dropdown/State/DropdownExampleLoading.shorthand.tsx b/docs/src/examples/components/Dropdown/State/DropdownExampleLoading.shorthand.tsx new file mode 100644 index 0000000000..1504c449ad --- /dev/null +++ b/docs/src/examples/components/Dropdown/State/DropdownExampleLoading.shorthand.tsx @@ -0,0 +1,17 @@ +import { Dropdown } from '@stardust-ui/react' +import * as React from 'react' + +const inputItems = ['Bruce Wayne', 'Natasha Romanoff', 'Steven Strange', 'Alfred Pennyworth'] + +const DropdownExampleLoading: React.FC<{ knobs: { loading: boolean } }> = ({ knobs }) => ( + +) + +export default DropdownExampleLoading diff --git a/docs/src/examples/components/Dropdown/State/index.tsx b/docs/src/examples/components/Dropdown/State/index.tsx new file mode 100644 index 0000000000..00564198a6 --- /dev/null +++ b/docs/src/examples/components/Dropdown/State/index.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' + +import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection' + +const State = () => ( + + + +) + +export default State diff --git a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx b/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx deleted file mode 100644 index 3fb6470421..0000000000 --- a/docs/src/examples/components/Dropdown/Variations/DropdownExampleMultipleSearchToggleButton.shorthand.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react' -import { Dropdown } from '@stardust-ui/react' - -const inputItems = [ - 'Bruce Wayne', - 'Natasha Romanoff', - 'Steven Strange', - 'Alfred Pennyworth', - `Scarlett O'Hara`, - 'Imperator Furiosa', - 'Bruce Banner', - 'Peter Parker', - 'Selina Kyle', -] - -const DropdownExample = () => ( - -) - -const getA11ySelectionMessage = { - onAdd: item => `${item} has been selected.`, - onRemove: item => `${item} has been removed.`, -} - -const getA11yStatusMessage = ({ - isOpen, - itemToString, - previousResultCount, - resultCount, - selectedItem, -}) => { - if (!isOpen) { - return selectedItem ? itemToString(selectedItem) : '' - } - if (!resultCount) { - return 'No results are available.' - } - if (resultCount !== previousResultCount) { - return `${resultCount} result${ - resultCount === 1 ? ' is' : 's are' - } available, use up and down arrow keys to navigate. Press Enter key to select.` - } - return '' -} - -export default DropdownExample diff --git a/docs/src/examples/components/Dropdown/Variations/index.tsx b/docs/src/examples/components/Dropdown/Variations/index.tsx index ca3ecfd32f..d750fec0bd 100644 --- a/docs/src/examples/components/Dropdown/Variations/index.tsx +++ b/docs/src/examples/components/Dropdown/Variations/index.tsx @@ -14,11 +14,6 @@ const Variations = () => ( description="A multiple search dropdown that fits the width of the container." examplePath="components/Dropdown/Variations/DropdownExampleMultipleSearchFluid" /> - ) diff --git a/docs/src/examples/components/Dropdown/index.tsx b/docs/src/examples/components/Dropdown/index.tsx index 460502dabd..7418c542cc 100644 --- a/docs/src/examples/components/Dropdown/index.tsx +++ b/docs/src/examples/components/Dropdown/index.tsx @@ -2,12 +2,14 @@ import * as React from 'react' import Types from './Types' import Variations from './Variations' +import State from './State' import Usage from './Usage' const DropdownExamples = () => (
+
) diff --git a/docs/src/prototypes/AsyncDropdownSearch/AsyncDropdownSearch.tsx b/docs/src/prototypes/AsyncDropdownSearch/AsyncDropdownSearch.tsx new file mode 100644 index 0000000000..d082cdee1d --- /dev/null +++ b/docs/src/prototypes/AsyncDropdownSearch/AsyncDropdownSearch.tsx @@ -0,0 +1,102 @@ +import { Divider, Dropdown, DropdownProps, Header, Loader, Segment } from '@stardust-ui/react' +import * as faker from 'faker' +import * as _ from 'lodash' +import * as React from 'react' + +import CodeSnippet from '../../components/CodeSnippet' + +// ---------------------------------------- +// Types +// ---------------------------------------- +type Entry = { + header: string + image: string + content: string +} + +interface SearchPageState { + loading: boolean + items: Entry[] + searchQuery: string + value: Entry[] +} + +// ---------------------------------------- +// Mock Data +// ---------------------------------------- +const createEntry = (): Entry => ({ + image: faker.internet.avatar(), + header: `${faker.name.firstName()} ${faker.name.lastName()}`, + content: faker.commerce.department(), +}) + +// ---------------------------------------- +// Prototype Search Page View +// ---------------------------------------- +class AsyncDropdownSearch extends React.Component<{}, SearchPageState> { + state = { + loading: false, + searchQuery: '', + items: [], + value: [], + } + + searchTimer: number + + handleSelectedChange = (e: React.SyntheticEvent, { searchQuery, value }: DropdownProps) => { + this.setState({ value: value as Entry[], searchQuery }) + } + + handleSearchQueryChange = (e: React.SyntheticEvent, { searchQuery }: DropdownProps) => { + this.setState({ searchQuery }) + this.fetchItems() + } + + fetchItems = () => { + clearTimeout(this.searchTimer) + this.setState({ loading: true }) + + this.searchTimer = setTimeout(() => { + this.setState(prevState => ({ + loading: false, + items: [...prevState.items, ..._.times(10, createEntry)], + })) + }, 2000) + } + + render() { + const { items, loading, searchQuery, value } = this.state + + return ( +
+ +
+

Use the field to perform a simulated search.

+ + + + , + }} + multiple + onSearchQueryChange={this.handleSearchQueryChange} + onSelectedChange={this.handleSelectedChange} + placeholder="Try to enter something..." + search + searchQuery={searchQuery} + toggleIndicator={false} + value={value} + /> + + + +
+ ) + } +} + +export default AsyncDropdownSearch diff --git a/docs/src/prototypes/AsyncDropdownSearch/index.ts b/docs/src/prototypes/AsyncDropdownSearch/index.ts new file mode 100644 index 0000000000..4f527e450b --- /dev/null +++ b/docs/src/prototypes/AsyncDropdownSearch/index.ts @@ -0,0 +1 @@ +export { default } from './AsyncDropdownSearch' diff --git a/docs/src/routes.tsx b/docs/src/routes.tsx index e1060aaa85..f44f5b380d 100644 --- a/docs/src/routes.tsx +++ b/docs/src/routes.tsx @@ -60,6 +60,12 @@ const Router = () => ( path="/prototype-search-page" component={require('./prototypes/SearchPage/index').default} />, + , extends DownshiftA11yStatusMessageOptions { @@ -84,11 +81,17 @@ export interface DropdownProps extends UIComponentProps string + /** A dropdown can show that it is currently loading data. */ + loading?: boolean + + /** A message to be displayed in the list when dropdown is loading. */ + loadingMessage?: ShorthandValue + /** A dropdown can perform a multiple selection. */ multiple?: boolean - /** A string to be displayed in the list when dropdown has no available items to show. */ - noResultsMessage?: string + /** A message to be displayed in the list when dropdown has no available items to show. */ + noResultsMessage?: ShorthandValue /** * Callback for change in dropdown search query value. @@ -134,8 +137,8 @@ export interface DropdownProps extends UIComponentProps) { - const { search, multiple, toggleButton, getA11yStatusMessage, itemToString } = this.props + const { search, multiple, getA11yStatusMessage, itemToString, toggleIndicator } = this.props const { searchQuery } = this.state return ( @@ -288,7 +294,13 @@ export default class Dropdown extends AutoControlledComponent< variables, ) : this.renderTriggerButton(styles, getToggleButtonProps)} - {toggleButton && this.renderToggleButton(getToggleButtonProps, classes, isOpen)} + {Indicator.create(toggleIndicator, { + defaultProps: { + direction: isOpen ? 'top' : 'bottom', + onClick: getToggleButtonProps().onClick, + styles: styles.toggleIndicator, + }, + })} {this.renderItemsList( styles, variables, @@ -348,7 +360,7 @@ export default class Dropdown extends AutoControlledComponent< ) => void, variables, ): JSX.Element { - const { searchInput, multiple, placeholder, toggleButton } = this.props + const { searchInput, multiple, placeholder, toggleIndicator } = this.props const { searchQuery, value } = this.state const noPlaceholder = @@ -357,7 +369,7 @@ export default class Dropdown extends AutoControlledComponent< return DropdownSearchInput.create(searchInput || {}, { defaultProps: { placeholder: noPlaceholder ? '' : placeholder, - hasToggleButton: !!toggleButton, + hasToggleButton: !!toggleIndicator, variables, inputRef: this.inputRef, }, @@ -372,19 +384,6 @@ export default class Dropdown extends AutoControlledComponent< }) } - private renderToggleButton( - getToggleButtonProps: (options?: GetToggleButtonPropsOptions) => any, - classes: ComponentSlotClasses, - isOpen: boolean, - ) { - const { onClick } = getToggleButtonProps() - return ( - - {isOpen ? String.fromCharCode(9650) : String.fromCharCode(9660)} - - ) - } - private renderItemsList( styles: ComponentSlotStylesInput, variables: ComponentVariablesInput, @@ -396,7 +395,10 @@ export default class Dropdown extends AutoControlledComponent< getItemProps: (options: GetItemPropsOptions) => any, getInputProps: (options?: GetInputPropsOptions) => any, ) { - const accessibilityMenuProps = getMenuProps({ refKey: 'innerRef' }, { suppressRefError: true }) + const { innerRef, ...accessibilityMenuProps } = getMenuProps( + { refKey: 'innerRef' }, + { suppressRefError: true }, + ) const { search } = this.props // If it's just a selection, some attributes and listeners from Downshift input need to go on the menu list. if (!search) { @@ -413,7 +415,7 @@ export default class Dropdown extends AutoControlledComponent< ) } } - const { innerRef, ...accessibilityMenuPropsRest } = accessibilityMenuProps + return ( { @@ -422,7 +424,7 @@ export default class Dropdown extends AutoControlledComponent< }} > ) => any, highlightedIndex: number, ) { - const { renderItem, noResultsMessage } = this.props + const { loading, loadingMessage, noResultsMessage, renderItem } = this.props + const filteredItems = this.getItemsFilteredBySearchQuery() + const items = _.map(filteredItems, (item, index) => + DropdownItem.create(item, { + defaultProps: { + active: highlightedIndex === index, + variables, + ...(typeof item === 'object' && + !item.hasOwnProperty('key') && { + key: (item as any).header, + }), + }, + overrideProps: () => this.handleItemOverrides(item, index, getItemProps), + render: renderItem, + }), + ) - if (filteredItems.length > 0) { - return filteredItems.map((item, index) => { - return DropdownItem.create(item, { + return [ + ...items, + loading && + ListItem.create(loadingMessage, { defaultProps: { - active: highlightedIndex === index, - variables, - ...(typeof item === 'object' && - !item.hasOwnProperty('key') && { - key: (item as any).header, - }), + key: 'loading-message', + styles: styles.loadingMessage, }, - overrideProps: () => this.handleItemOverrides(item, index, getItemProps), - render: renderItem, - }) - }) - } - // render no match message. - return [ - noResultsMessage - ? { - key: 'dropdown-no-results', - content: , - styles: styles.emptyListItem, - } - : null, + }), + !loading && + items.length === 0 && + ListItem.create(noResultsMessage, { + key: 'no-results-message', + styles: styles.noResultsMessage, + }), ] } @@ -709,7 +716,12 @@ export default class Dropdown extends AutoControlledComponent< } // 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: newValue }) + _.invoke( + this.props, + 'onSelectedChange', + {}, + { ...this.props, searchQuery: '', value: newValue }, + ) } private handleSelectedItemRemove(e: React.SyntheticEvent, item: ShorthandValue) { diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index a0c3fc73af..e688aa03cf 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -70,7 +70,6 @@ class ListItem extends UIComponent, ListItemState> { static propTypes = { ...commonPropTypes.createCommon({ - children: false, content: false, }), contentMedia: PropTypes.any, diff --git a/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts b/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts index 16c52581ff..ad70ab946d 100644 --- a/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts +++ b/src/themes/teams/components/Dropdown/dropdownSearchInputStyles.ts @@ -17,13 +17,13 @@ const dropdownSearchInputStyles: ComponentSlotStylesInput< }), combobox: ({ - variables: { comboboxFlexBasis, toggleButtonSize }, + variables: { comboboxFlexBasis, toggleIndicatorSize }, props: { hasToggleButton }, }): ICSSInJSStyle => ({ flexBasis: comboboxFlexBasis, flexGrow: 1, ...(hasToggleButton && { - marginRight: toggleButtonSize, + marginRight: toggleIndicatorSize, }), }), } diff --git a/src/themes/teams/components/Dropdown/dropdownStyles.ts b/src/themes/teams/components/Dropdown/dropdownStyles.ts index f808ad6fd1..7e87c34b49 100644 --- a/src/themes/teams/components/Dropdown/dropdownStyles.ts +++ b/src/themes/teams/components/Dropdown/dropdownStyles.ts @@ -73,14 +73,19 @@ const dropdownStyles: ComponentSlotStylesInput background: listBackgroundColor, }), - emptyListItem: ({ variables: { listItemBackgroundColor } }) => ({ - backgroundColor: listItemBackgroundColor, + loadingMessage: ({ variables: v }): ICSSInJSStyle => ({ + backgroundColor: v.listItemBackgroundColor, }), - toggleButton: ({ variables: { toggleButtonSize, width }, props: { fluid } }): ICSSInJSStyle => ({ + noResultsMessage: ({ variables: v }): ICSSInJSStyle => ({ + backgroundColor: v.listItemBackgroundColor, + fontWeight: 'bold', + }), + + toggleIndicator: ({ props: p, variables: v }): ICSSInJSStyle => ({ position: 'absolute', - height: toggleButtonSize, - width: toggleButtonSize, + height: v.toggleIndicatorSize, + width: v.toggleIndicatorSize, cursor: 'pointer', backgroundColor: 'transparent', margin: 0, @@ -88,7 +93,7 @@ const dropdownStyles: ComponentSlotStylesInput justifyContent: 'center', alignItems: 'center', userSelect: 'none', - ...(fluid ? { right: 0 } : { left: `calc(${width} - ${toggleButtonSize})` }), + ...(p.fluid ? { right: 0 } : { left: `calc(${v.width} - ${v.toggleIndicatorSize})` }), }), } diff --git a/src/themes/teams/components/Dropdown/dropdownVariables.ts b/src/themes/teams/components/Dropdown/dropdownVariables.ts index bf50a4d00f..63879ae015 100644 --- a/src/themes/teams/components/Dropdown/dropdownVariables.ts +++ b/src/themes/teams/components/Dropdown/dropdownVariables.ts @@ -15,7 +15,7 @@ export interface DropdownVariables { listItemBackgroundColorActive: string listItemColorActive: string listMaxHeight: string - toggleButtonSize: string + toggleIndicatorSize: string width: string } @@ -37,6 +37,6 @@ export default (siteVars): DropdownVariables => ({ listItemBackgroundColorActive: siteVars.brand, listItemColorActive: siteVars.white, listMaxHeight: '20rem', - toggleButtonSize: pxToRem(32), + toggleIndicatorSize: pxToRem(32), width: pxToRem(356), })