substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
233 lines (208 loc) • 5.83 kB
JavaScript
import { Component, $$, domHelpers } from '../dom'
import { debounce, platform, uuid, keys, parseKeyEvent } from '../util'
import Input from './Input'
const DEFAULT_QUERY_SELECT_DELAY = 500
export default class QuerySelect extends Component {
constructor (...args) {
super(...args)
if (this.props.local) {
// no debounce
this._delayedQuery = debounce(this._query.bind(this), 0)
} else {
this._delayedQuery = debounce(this._query.bind(this), DEFAULT_QUERY_SELECT_DELAY)
}
// volatile state
this._selected = null
this._options = null
}
didMount () {
if (this.props.autofocus) {
this.focus()
}
}
render () {
const { placeholder } = this.props
const el = $$('div', { class: 'sc-query-select' })
el.append(
$$(Input, { placeholder })
.ref('input')
.on('focus', this._onFocus)
.on('input', this._onInput)
.on('keydown', this._onKeydown)
.on('click', this._onClick)
.on('change', domHelpers.stop)
)
return el
}
focus () {
this.refs.input.focus()
}
reset () {
this._hideOptions()
this.refs.input.val('')
}
async _query () {
const { query } = this.props
const queryString = this.refs.input.val()
// TODO: not to hide options feels a bit less jumpy to me
// this._hideOptions()
const queryId = uuid()
this._lastQueryId = queryId
try {
this._running = true
this._resetSelection()
const options = await query(queryString)
// ATTENTION: stop if this query has been superseded by a new one
// if (this._lastQueryId !== queryId) return
this._options = options
this._showOptions(options)
} finally {
this._running = false
}
}
_hideOptions () {
this.send('releasePopover', this)
}
async _showOptions (options) {
if (options.length > 0) {
// console.log('QuerySelect._showOptions', options)
this._popoverId = await this.send('requestPopover', {
requester: this,
desiredPos: this._getDesiredPopoverPos(),
content: () => {
return this._renderOptions(options)
},
position: 'relative',
allowClickInsideOf: this.refs.input.getElement(),
onClose: () => {
this._showsOptions = false
}
})
this._showsOptions = true
}
}
_rerenderOptions (options) {
if (this._popoverId) {
console.log('QuerySelect._rerenderOptions', options)
this.send('requestPopover', {
update: true,
requestId: this._popoverId,
content: () => {
return this._renderOptions(options)
}
})
}
}
_renderOptions (options) {
const optionRenderer = this.props.optionRenderer || this._renderOption
const { width } = this._getInputRect()
const optionsEl = $$('div', { class: 'se-options' }).css('width', width)
for (let idx = 0; idx < options.length; idx++) {
const option = options[idx]
const optionEl = optionRenderer(option)
optionEl.on('click', this._onClickOption.bind(this, option))
if (idx === this._selected) {
optionEl.addClass('sm-selected')
}
optionsEl.append(optionEl)
}
return optionsEl
}
_renderOption (option) {
return $$(QuerySelectOption, { option })
}
_getInputRect () {
if (platform.inBrowser) {
const input = this.refs.input
return input.el.getNativeElement().getBoundingClientRect()
}
return { x: 0, y: 0, width: 0, height: 0 }
}
_getDesiredPopoverPos () {
const inputRect = this._getInputRect()
if (inputRect) {
let { left: x, top: y, height, width } = inputRect
y = y + height + 10
x = x + width / 2
return { x, y }
}
}
_selectOption (option) {
this._hideOptions()
this.el.emit('change', option)
}
_resetSelection () {
this._selected = null
}
_onInput (event) {
// console.log('QuerySelect._onInput()')
domHelpers.stop(event)
this._delayedQuery()
}
_onFocus (event) {
domHelpers.stop(event)
// console.log('QuerySelect._onFocus()')
if (this.props.queryOnFocus) {
this._delayedQuery()
}
}
_onClick (event) {
if (!this._running) {
this._delayedQuery()
}
}
_onKeydown (event) {
const options = this._options || []
const N = options.length
switch (event.keyCode) {
case keys.UP: {
domHelpers.stopAndPrevent(event)
if (this._selected === null) {
this._selected = N - 1
} else {
this._selected = (N + this._selected - 1) % N
}
this._rerenderOptions(options)
break
}
case keys.DOWN: {
domHelpers.stopAndPrevent(event)
if (this._selected === null) {
this._selected = 0
} else {
this._selected = (this._selected + 1) % N
}
this._rerenderOptions(options)
break
}
case keys.ESCAPE: {
if (this._showsOptions) {
domHelpers.stopAndPrevent(event)
this._hideOptions()
}
break
}
case keys.ENTER: {
if (this._options && this._selected !== null) {
const combo = parseKeyEvent(event, true)
// only plain ENTER (no meta)
if (combo === '') {
const option = this._options[this._selected]
if (option) {
domHelpers.stopAndPrevent(event)
this._selectOption(option)
}
}
}
}
}
}
_onClickOption (option, e) {
e.stopPropagation()
this._selectOption(option)
}
}
function QuerySelectOption (props) {
const option = props.option
return $$('div', { class: `sc-query-select-option sm-${option.action}` }, option.label)
}