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

Commit

Permalink
feat(prototypes): mention scenario with dropdown (#931)
Browse files Browse the repository at this point in the history
* proto(dropdown): @mention scenario

* reverted AsyncDropdownSearch changes

* implemented the new Dropdown using ReactDOM.createPortal instead of ReactDOM.render

* - addressed PR comments;
- refactoring of using portal
- fix for all styling regressions

* another round of comments addressed

* renamed file because of case insensitive behavior on Mac

* - fixed bug with dropdown not being deleted from editor;
- fixed bug with text not being inserted when dropdown is closed;
- small refactoring of CustomPortal -> PortalAtCursorPosition

* improved documentation for itemToString prop

* addressed comments and fixed issue with creating empty text node

* improve visual appearance of async loading example

* changelog
  • Loading branch information
Alexandru Buliga authored Feb 22, 2019
1 parent ccc755b commit 5d930fa
Show file tree
Hide file tree
Showing 16 changed files with 309 additions and 27 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Expose `Popup`'s content Ref @sophieH29 ([#913](https://github.com/stardust-ui/react/pull/913))
- Fix `Button` Teams theme styles to use semibold weight @notandrew ([#829](https://github.com/stardust-ui/react/pull/829))

### Documentation
- Add `Editable Area with Dropdown` prototype for mentioning people using `@` character (only available in development mode) @Bugaa92 ([#931](https://github.com/stardust-ui/react/pull/931))

<!--------------------------------[ v0.21.1 ]------------------------------- -->
## [v0.21.1](https://github.com/stardust-ui/react/tree/v0.21.1) (2019-02-14)
[Compare changes](https://github.com/stardust-ui/react/compare/v0.21.0...v0.21.1)
Expand Down
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?: React.ReactNode
}

interface ComponentPrototypeProps extends PrototypeSectionProps {
description?: React.ReactNode
}

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 { Dropdown, DropdownProps, Flex, Label, Loader } from '@stardust-ui/react'
import * as faker from 'faker'
import * as _ from 'lodash'
import * as React from 'react'
Expand Down Expand Up @@ -54,12 +54,13 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {

fetchItems = () => {
clearTimeout(this.searchTimer)
this.setState({ loading: true })
if (this.state.items.length > 10) return

this.setState({ loading: true })
this.searchTimer = setTimeout(() => {
this.setState(prevState => ({
loading: false,
items: [...prevState.items, ..._.times<Entry>(10, createEntry)],
items: [...prevState.items, ..._.times<Entry>(2, createEntry)],
}))
}, 2000)
}
Expand All @@ -68,13 +69,8 @@ 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>
<Flex gap="gap.medium">
<Flex.Item size="size.quarter">
<Dropdown
fluid
items={items}
Expand All @@ -90,11 +86,16 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {
searchQuery={searchQuery}
toggleIndicator={false}
value={value}
noResultsMessage="We couldn't find any matches"
/>
<Divider />
<CodeSnippet mode="json" value={this.state} />
</Segment>
</div>
</Flex.Item>
<Flex.Item grow>
<div>
<Label color="black">Dropdown State</Label>
<CodeSnippet mode="json" value={this.state} />
</div>
</Flex.Item>
</Flex>
)
}
}
Expand Down
111 changes: 111 additions & 0 deletions docs/src/prototypes/dropdowns/MentionsWithDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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: '#eee',
borderRadius: '5px',
border: '1px dashed grey',
padding: '5px',
minHeight: '100px',
outline: 0,
}

class MentionsWithDropdown extends React.Component<{}, MentionsWithDropdownState> {
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 },
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 && e.shiftKey && keyboardKey.getCode(e) === keyboardKey.AtSign) {
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="Editable Area with Dropdown"
description="Type text into editable area below. 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) {
return null
}

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)
}
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"
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.
* 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
Loading

0 comments on commit 5d930fa

Please sign in to comment.