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

Commit 5d930fa

Browse files
author
Alexandru Buliga
authored
feat(prototypes): mention scenario with dropdown (#931)
* 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
1 parent ccc755b commit 5d930fa

File tree

16 files changed

+309
-27
lines changed

16 files changed

+309
-27
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
3434
- Expose `Popup`'s content Ref @sophieH29 ([#913](https://github.com/stardust-ui/react/pull/913))
3535
- Fix `Button` Teams theme styles to use semibold weight @notandrew ([#829](https://github.com/stardust-ui/react/pull/829))
3636

37+
### Documentation
38+
- 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))
39+
3740
<!--------------------------------[ v0.21.1 ]------------------------------- -->
3841
## [v0.21.1](https://github.com/stardust-ui/react/tree/v0.21.1) (2019-02-14)
3942
[Compare changes](https://github.com/stardust-ui/react/compare/v0.21.0...v0.21.1)

docs/src/components/Sidebar/Sidebar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,10 @@ class Sidebar extends React.Component<any, any> {
343343
styles: menuItemStyles,
344344
},
345345
{
346-
key: 'asyncdropdown',
347-
content: 'Async Dropdown Search',
346+
key: 'dropdowns',
347+
content: 'Dropdowns',
348348
as: NavLink,
349-
to: '/prototype-async-dropdown-search',
349+
to: '/prototype-dropdowns',
350350
styles: menuItemStyles,
351351
},
352352
{

docs/src/prototypes/AsyncDropdownSearch/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/src/prototypes/Prototypes.tsx

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 { Box, Header, Segment } from '@stardust-ui/react'
3+
4+
interface PrototypeSectionProps {
5+
title?: React.ReactNode
6+
}
7+
8+
interface ComponentPrototypeProps extends PrototypeSectionProps {
9+
description?: React.ReactNode
10+
}
11+
12+
export const PrototypeSection: React.FC<ComponentPrototypeProps> = props => (
13+
<Box style={{ margin: 20 }}>
14+
{props.title && <Header as="h1">{props.title}</Header>}
15+
{props.children}
16+
</Box>
17+
)
18+
19+
export const ComponentPrototype: React.FC<ComponentPrototypeProps> = props => (
20+
<Box style={{ marginTop: 20 }}>
21+
{(props.title || props.description) && (
22+
<Segment>
23+
{props.title && <Header as="h3">{props.title}</Header>}
24+
{props.description && <p>{props.description}</p>}
25+
</Segment>
26+
)}
27+
<Segment>{props.children}</Segment>
28+
</Box>
29+
)

docs/src/prototypes/AsyncDropdownSearch/AsyncDropdownSearch.tsx renamed to docs/src/prototypes/dropdowns/AsyncDropdownSearch.tsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Divider, Dropdown, DropdownProps, Header, Loader, Segment } from '@stardust-ui/react'
1+
import { Dropdown, DropdownProps, Flex, Label, Loader } from '@stardust-ui/react'
22
import * as faker from 'faker'
33
import * as _ from 'lodash'
44
import * as React from 'react'
@@ -54,12 +54,13 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {
5454

5555
fetchItems = () => {
5656
clearTimeout(this.searchTimer)
57-
this.setState({ loading: true })
57+
if (this.state.items.length > 10) return
5858

59+
this.setState({ loading: true })
5960
this.searchTimer = setTimeout(() => {
6061
this.setState(prevState => ({
6162
loading: false,
62-
items: [...prevState.items, ..._.times<Entry>(10, createEntry)],
63+
items: [...prevState.items, ..._.times<Entry>(2, createEntry)],
6364
}))
6465
}, 2000)
6566
}
@@ -68,13 +69,8 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {
6869
const { items, loading, searchQuery, value } = this.state
6970

7071
return (
71-
<div style={{ margin: 20 }}>
72-
<Segment>
73-
<Header content="Async Dropdown Search" />
74-
<p>Use the field to perform a simulated search.</p>
75-
</Segment>
76-
77-
<Segment>
72+
<Flex gap="gap.medium">
73+
<Flex.Item size="size.quarter">
7874
<Dropdown
7975
fluid
8076
items={items}
@@ -90,11 +86,16 @@ class AsyncDropdownSearch extends React.Component<{}, SearchPageState> {
9086
searchQuery={searchQuery}
9187
toggleIndicator={false}
9288
value={value}
89+
noResultsMessage="We couldn't find any matches"
9390
/>
94-
<Divider />
95-
<CodeSnippet mode="json" value={this.state} />
96-
</Segment>
97-
</div>
91+
</Flex.Item>
92+
<Flex.Item grow>
93+
<div>
94+
<Label color="black">Dropdown State</Label>
95+
<CodeSnippet mode="json" value={this.state} />
96+
</div>
97+
</Flex.Item>
98+
</Flex>
9899
)
99100
}
100101
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as React from 'react'
2+
import * as _ from 'lodash'
3+
import keyboardKey from 'keyboard-key'
4+
import { Dropdown, DropdownProps } from '@stardust-ui/react'
5+
6+
import { atMentionItems } from './dataMocks'
7+
import { insertTextAtCursorPosition } from './utils'
8+
import { PortalAtCursorPosition } from './PortalAtCursorPosition'
9+
10+
interface MentionsWithDropdownState {
11+
dropdownOpen?: boolean
12+
searchQuery?: string
13+
}
14+
15+
const editorStyle: React.CSSProperties = {
16+
backgroundColor: '#eee',
17+
borderRadius: '5px',
18+
border: '1px dashed grey',
19+
padding: '5px',
20+
minHeight: '100px',
21+
outline: 0,
22+
}
23+
24+
class MentionsWithDropdown extends React.Component<{}, MentionsWithDropdownState> {
25+
private readonly initialState: MentionsWithDropdownState = {
26+
dropdownOpen: false,
27+
searchQuery: '',
28+
}
29+
30+
private contendEditableRef = React.createRef<HTMLDivElement>()
31+
32+
state = this.initialState
33+
34+
render() {
35+
const { dropdownOpen, searchQuery } = this.state
36+
37+
return (
38+
<>
39+
<div
40+
contentEditable
41+
ref={this.contendEditableRef}
42+
onKeyUp={this.handleEditorKeyUp}
43+
style={editorStyle}
44+
/>
45+
<PortalAtCursorPosition open={dropdownOpen}>
46+
<Dropdown
47+
defaultOpen={true}
48+
inline
49+
search
50+
items={atMentionItems}
51+
toggleIndicator={null}
52+
searchInput={{
53+
input: { autoFocus: true, size: searchQuery.length + 1 },
54+
onInputKeyDown: this.handleInputKeyDown,
55+
}}
56+
onOpenChange={this.handleOpenChange}
57+
onSearchQueryChange={this.handleSearchQueryChange}
58+
noResultsMessage="We couldn't find any matches."
59+
/>
60+
</PortalAtCursorPosition>
61+
</>
62+
)
63+
}
64+
65+
private handleEditorKeyUp = (e: React.KeyboardEvent) => {
66+
if (!this.state.dropdownOpen && e.shiftKey && keyboardKey.getCode(e) === keyboardKey.AtSign) {
67+
this.setState({ dropdownOpen: true })
68+
}
69+
}
70+
71+
private handleOpenChange = (e: React.SyntheticEvent, { open }: DropdownProps) => {
72+
if (!open) {
73+
this.resetStateAndUpdateEditor()
74+
}
75+
}
76+
77+
private handleSearchQueryChange = (e: React.SyntheticEvent, { searchQuery }: DropdownProps) => {
78+
this.setState({ searchQuery })
79+
}
80+
81+
private handleInputKeyDown = (e: React.KeyboardEvent) => {
82+
const keyCode = keyboardKey.getCode(e)
83+
switch (keyCode) {
84+
case keyboardKey.Backspace: // 8
85+
if (this.state.searchQuery === '') {
86+
this.resetStateAndUpdateEditor()
87+
}
88+
break
89+
case keyboardKey.Escape: // 27
90+
this.resetStateAndUpdateEditor()
91+
break
92+
}
93+
}
94+
95+
private resetStateAndUpdateEditor = () => {
96+
const { searchQuery, dropdownOpen } = this.state
97+
98+
if (dropdownOpen) {
99+
this.setState(this.initialState, () => {
100+
this.tryFocusEditor()
101+
102+
// after the dropdown is closed the value of the search query is inserted in the editor at cursor position
103+
insertTextAtCursorPosition(searchQuery)
104+
})
105+
}
106+
}
107+
108+
private tryFocusEditor = () => _.invoke(this.contendEditableRef.current, 'focus')
109+
}
110+
111+
export default MentionsWithDropdown
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react'
2+
import * as ReactDOM from 'react-dom'
3+
import { insertSpanAtCursorPosition, removeElement } from './utils'
4+
5+
export interface PortalAtCursorPositionProps {
6+
mountNodeId: string
7+
open?: boolean
8+
}
9+
10+
export class PortalAtCursorPosition extends React.Component<PortalAtCursorPositionProps> {
11+
private mountNodeInstance: HTMLElement = null
12+
13+
static defaultProps = {
14+
mountNodeId: 'portal-at-cursor-position',
15+
}
16+
17+
public componentWillUnmount() {
18+
this.removeMountNode()
19+
}
20+
21+
public render() {
22+
const { children, open } = this.props
23+
24+
this.setupMountNode()
25+
return open && this.mountNodeInstance
26+
? ReactDOM.createPortal(children, this.mountNodeInstance)
27+
: null
28+
}
29+
30+
private setupMountNode = () => {
31+
const { mountNodeId, open } = this.props
32+
33+
if (open) {
34+
this.mountNodeInstance = this.mountNodeInstance || insertSpanAtCursorPosition(mountNodeId)
35+
} else {
36+
this.removeMountNode()
37+
}
38+
}
39+
40+
private removeMountNode = () => {
41+
if (this.mountNodeInstance) {
42+
removeElement(this.mountNodeInstance)
43+
this.mountNodeInstance = null
44+
}
45+
}
46+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as _ from 'lodash'
2+
import { name, internet } from 'faker'
3+
4+
interface AtMentionItem {
5+
header: string
6+
image: string
7+
content: string
8+
}
9+
10+
export const atMentionItems: AtMentionItem[] = _.times(10, () => ({
11+
header: `${name.firstName()} ${name.lastName()}`,
12+
image: internet.avatar(),
13+
content: name.title(),
14+
}))
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as React from 'react'
2+
import { PrototypeSection, ComponentPrototype } from '../Prototypes'
3+
import AsyncDropdownSearch from './AsyncDropdownSearch'
4+
import MentionsWithDropdown from './MentionsWithDropdown'
5+
6+
export default () => (
7+
<PrototypeSection title="Dropdowns">
8+
<ComponentPrototype
9+
title="Async Dropdown Search"
10+
description="Use the field to perform a simulated search."
11+
>
12+
<AsyncDropdownSearch />
13+
</ComponentPrototype>
14+
<ComponentPrototype
15+
title="Editable Area with Dropdown"
16+
description="Type text into editable area below. Use the '@' key to mention people."
17+
>
18+
<MentionsWithDropdown />
19+
</ComponentPrototype>
20+
</PrototypeSection>
21+
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
const getRangeAtCursorPosition = () => {
2+
if (!window.getSelection) {
3+
return null
4+
}
5+
6+
const sel = window.getSelection()
7+
if (!sel.getRangeAt || !sel.rangeCount) {
8+
return null
9+
}
10+
11+
return sel.getRangeAt(0)
12+
}
13+
14+
export const insertSpanAtCursorPosition = (id: string) => {
15+
if (!id) {
16+
throw '[insertSpanAtCursorPosition]: id must be supplied'
17+
}
18+
19+
const range = getRangeAtCursorPosition()
20+
if (!range) {
21+
return null
22+
}
23+
24+
const elem = document.createElement('span')
25+
elem.id = id
26+
range.insertNode(elem)
27+
28+
return elem
29+
}
30+
31+
export const insertTextAtCursorPosition = (text: string) => {
32+
if (!text) {
33+
return null
34+
}
35+
36+
const range = getRangeAtCursorPosition()
37+
if (!range) {
38+
return null
39+
}
40+
41+
const textNode = document.createTextNode(text)
42+
range.insertNode(textNode)
43+
range.setStartAfter(textNode)
44+
45+
return textNode
46+
}
47+
48+
export const removeElement = (element: string | HTMLElement): HTMLElement => {
49+
const elementToRemove = typeof element === 'string' ? document.getElementById(element) : element
50+
return elementToRemove.parentNode.removeChild(elementToRemove)
51+
}

0 commit comments

Comments
 (0)