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

feat(prototypes): mention scenario with dropdown #931

Merged
merged 11 commits into from
Feb 22, 2019
6 changes: 3 additions & 3 deletions docs/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,10 @@ class Sidebar extends React.Component<any, any> {
styles: menuItemStyles,
},
{
key: 'asyncdropdown',
content: 'Async Dropdown Search',
key: 'dropdowns',
content: 'Dropdowns',
as: NavLink,
to: '/prototype-async-dropdown-search',
to: '/prototype-dropdowns',
styles: menuItemStyles,
},
{
Expand Down
1 change: 0 additions & 1 deletion docs/src/prototypes/AsyncDropdownSearch/index.ts

This file was deleted.

29 changes: 29 additions & 0 deletions docs/src/prototypes/Prototypes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react'
import { Box, Header, Segment } from '@stardust-ui/react'

interface PrototypeSectionProps {
title?: string
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
}

interface ComponentPrototypeProps extends PrototypeSectionProps {
description?: string
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
}

export const PrototypeSection: React.FC<ComponentPrototypeProps> = props => (
<Box style={{ margin: 20 }}>
{props.title && <Header as="h1">{props.title}</Header>}
{props.children}
</Box>
)

export const ComponentPrototype: React.FC<ComponentPrototypeProps> = props => (
<Box style={{ marginTop: 20 }}>
{(props.title || props.description) && (
<Segment>
{props.title && <Header as="h3">{props.title}</Header>}
{props.description && <p>{props.description}</p>}
</Segment>
)}
<Segment>{props.children}</Segment>
</Box>
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Divider, Dropdown, DropdownProps, Header, Loader, Segment } from '@stardust-ui/react'
import { Divider, Dropdown, DropdownProps, Loader } from '@stardust-ui/react'
import * as faker from 'faker'
import * as _ from 'lodash'
import * as React from 'react'
Expand Down Expand Up @@ -68,33 +68,26 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {
const { items, loading, searchQuery, value } = this.state

return (
<div style={{ margin: 20 }}>
<Segment>
<Header content="Async Dropdown Search" />
<p>Use the field to perform a simulated search.</p>
</Segment>

<Segment>
<Dropdown
fluid
items={items}
loading={loading}
loadingMessage={{
content: <Loader label="Loading..." labelPosition="end" size="larger" />,
}}
multiple
onSearchQueryChange={this.handleSearchQueryChange}
onSelectedChange={this.handleSelectedChange}
placeholder="Try to enter something..."
search
searchQuery={searchQuery}
toggleIndicator={false}
value={value}
/>
<Divider />
<CodeSnippet mode="json" value={this.state} />
</Segment>
</div>
<>
<Dropdown
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
fluid
items={items}
loading={loading}
loadingMessage={{
content: <Loader label="Loading..." labelPosition="end" size="larger" />,
}}
multiple
onSearchQueryChange={this.handleSearchQueryChange}
onSelectedChange={this.handleSelectedChange}
placeholder="Try to enter something..."
search
searchQuery={searchQuery}
toggleIndicator={false}
value={value}
/>
<Divider />
<CodeSnippet mode="json" value={this.state} />
</>
)
}
}
Expand Down
109 changes: 109 additions & 0 deletions docs/src/prototypes/dropdowns/MentionsWithDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as React from 'react'
import * as _ from 'lodash'
import keyboardKey from 'keyboard-key'
import { Dropdown, DropdownProps } from '@stardust-ui/react'

import { atMentionItems } from './dataMocks'
import { insertTextAtCursorPosition } from './utils'
import { PortalAtCursorPosition } from './PortalAtCursorPosition'

interface MentionsWithDropdownState {
dropdownOpen?: boolean
searchQuery?: string
}

const editorStyle: React.CSSProperties = {
backgroundColor: 'lightgrey',
padding: '5px',
minHeight: '100px',
outline: 0,
}

class MentionsWithDropdown extends React.Component<{}, MentionsWithDropdownState> {
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
private readonly initialState: MentionsWithDropdownState = {
dropdownOpen: false,
searchQuery: '',
}

private contendEditableRef = React.createRef<HTMLDivElement>()

state = this.initialState

render() {
const { dropdownOpen, searchQuery } = this.state

return (
<>
<div
contentEditable
ref={this.contendEditableRef}
onKeyUp={this.handleEditorKeyUp}
style={editorStyle}
/>
<PortalAtCursorPosition open={dropdownOpen}>
<Dropdown
defaultOpen={true}
inline
search
items={atMentionItems}
toggleIndicator={null}
searchInput={{
input: { autoFocus: true, size: searchQuery.length + 1 },
kuzhelov marked this conversation as resolved.
Show resolved Hide resolved
onInputKeyDown: this.handleInputKeyDown,
}}
onOpenChange={this.handleOpenChange}
onSearchQueryChange={this.handleSearchQueryChange}
noResultsMessage="We couldn't find any matches."
/>
</PortalAtCursorPosition>
</>
)
}

private handleEditorKeyUp = (e: React.KeyboardEvent) => {
if (!this.state.dropdownOpen && keyboardKey.getCode(e) === keyboardKey.AtSign) {
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
this.setState({ dropdownOpen: true })
}
}

private handleOpenChange = (e: React.SyntheticEvent, { open }: DropdownProps) => {
if (!open) {
this.resetStateAndUpdateEditor()
}
}

private handleSearchQueryChange = (e: React.SyntheticEvent, { searchQuery }: DropdownProps) => {
this.setState({ searchQuery })
}

private handleInputKeyDown = (e: React.KeyboardEvent) => {
const keyCode = keyboardKey.getCode(e)
switch (keyCode) {
case keyboardKey.Backspace: // 8
if (this.state.searchQuery === '') {
this.resetStateAndUpdateEditor()
}
break
case keyboardKey.Escape: // 27
this.resetStateAndUpdateEditor()
break
}
}

private resetStateAndUpdateEditor = () => {
const { searchQuery, dropdownOpen } = this.state

if (dropdownOpen) {
this.setState(this.initialState, () => {
this.tryFocusEditor()

// after the dropdown is closed the value of the search query is inserted in the editor at cursor position
insertTextAtCursorPosition(searchQuery)
})
}
}

private tryFocusEditor = () => _.invoke(this.contendEditableRef.current, 'focus')
}

export default MentionsWithDropdown
46 changes: 46 additions & 0 deletions docs/src/prototypes/dropdowns/PortalAtCursorPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { insertSpanAtCursorPosition, removeElement } from './utils'

export interface PortalAtCursorPositionProps {
mountNodeId: string
open?: boolean
}

export class PortalAtCursorPosition extends React.Component<PortalAtCursorPositionProps> {
private mountNodeInstance: HTMLElement = null

static defaultProps = {
mountNodeId: 'portal-at-cursor-position',
}

public componentWillUnmount() {
this.removeMountNode()
}

public render() {
const { children, open } = this.props

this.setupMountNode()
return open && this.mountNodeInstance
? ReactDOM.createPortal(children, this.mountNodeInstance)
: null
}

private setupMountNode = () => {
const { mountNodeId, open } = this.props

if (open) {
this.mountNodeInstance = this.mountNodeInstance || insertSpanAtCursorPosition(mountNodeId)
} else {
this.removeMountNode()
}
}

private removeMountNode = () => {
if (this.mountNodeInstance) {
removeElement(this.mountNodeInstance)
this.mountNodeInstance = null
}
}
}
14 changes: 14 additions & 0 deletions docs/src/prototypes/dropdowns/dataMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as _ from 'lodash'
import { name, internet } from 'faker'

interface AtMentionItem {
header: string
image: string
content: string
}

export const atMentionItems: AtMentionItem[] = _.times(10, () => ({
header: `${name.firstName()} ${name.lastName()}`,
image: internet.avatar(),
content: name.title(),
}))
21 changes: 21 additions & 0 deletions docs/src/prototypes/dropdowns/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react'
import { PrototypeSection, ComponentPrototype } from '../Prototypes'
import AsyncDropdownSearch from './AsyncDropdownSearch'
import MentionsWithDropdown from './MentionsWithDropdown'

export default () => (
<PrototypeSection title="Dropdowns">
<ComponentPrototype
title="Async Dropdown Search"
description="Use the field to perform a simulated search."
>
<AsyncDropdownSearch />
</ComponentPrototype>
<ComponentPrototype
title="Input with Dropdown"
description="Use the '@' key to mention people."
>
<MentionsWithDropdown />
</ComponentPrototype>
</PrototypeSection>
)
51 changes: 51 additions & 0 deletions docs/src/prototypes/dropdowns/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const getRangeAtCursorPosition = () => {
if (!window.getSelection) {
return null
}

const sel = window.getSelection()
if (!sel.getRangeAt || !sel.rangeCount) {
return null
}

return sel.getRangeAt(0)
}

export const insertSpanAtCursorPosition = (id: string) => {
if (!id) {
throw '[insertSpanAtCursorPosition]: id must be supplied'
}

const range = getRangeAtCursorPosition()
if (!range) {
return null
}

const elem = document.createElement('span')
elem.id = id
range.insertNode(elem)

return elem
}

export const insertTextAtCursorPosition = (text: string) => {
if (!text) {
throw '[insertTextAtCursorPosition]: text must be supplied'
}

const range = getRangeAtCursorPosition()
if (!range) {
return null
}

const textNode = document.createTextNode(text)
range.insertNode(textNode)
range.setStartAfter(textNode)

return textNode
}

export const removeElement = (element: string | HTMLElement): HTMLElement => {
const elementToRemove = typeof element === 'string' ? document.getElementById(element) : element
return elementToRemove.parentNode.removeChild(elementToRemove)
}
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 3 additions & 3 deletions docs/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ const Router = () => (
/>,
<DocsLayout
exact
key="/prototype-async-dropdown-search"
path="/prototype-async-dropdown-search"
component={require('./prototypes/AsyncDropdownSearch/index').default}
key="/prototype-dropdowns"
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
path="/prototype-dropdowns"
component={require('./prototypes/dropdowns/index').default}
/>,
<DocsLayout
exact
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ export interface DropdownProps extends UIComponentProps<DropdownProps, DropdownS
items?: ShorthandCollection

/**
* Function to be passed to create string from selected item, if it's a shorthand object. Used when dropdown also has a search function.
* Function that converts an item to string. Used when dropdown has the search boolean prop set to true.
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
* By default, it:
* - returns the header property (if it exists on an item)
* - converts an item to string (if the item is a primitive)
*/
itemToString?: (item: ShorthandValue) => string

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,11 @@ class DropdownSearchInput extends UIComponent<ReactProps<DropdownSearchInputProp
inputRef={inputRef}
onFocus={this.handleFocus}
onKeyUp={this.handleKeyUp}
{...unhandledProps}
wrapper={{
styles: styles.root,
...accessibilityComboboxProps,
...unhandledProps.wrapper,
}}
input={{
type: 'text',
Expand All @@ -122,8 +124,8 @@ class DropdownSearchInput extends UIComponent<ReactProps<DropdownSearchInputProp
onBlur: this.handleInputBlur,
onKeyDown: this.handleInputKeyDown,
...accessibilityInputProps,
...unhandledProps.input,
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
}}
{...unhandledProps}
/>
)
}
Expand Down
Loading