Implement SearchBox highlight active result (WIP)
This commit is contained in:
parent
9c8febb1ff
commit
d2a84b29fe
|
@ -15,6 +15,7 @@
|
||||||
color: $white;
|
color: $white;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
@ -47,34 +48,18 @@
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box-container {
|
||||||
display: flex;
|
position: absolute;
|
||||||
align-items: center;
|
top: 100%;
|
||||||
|
right: $search-box-offset + 20px; // keep space for scrollbar
|
||||||
.search-icon {
|
margin-top: $search-box-offset;
|
||||||
margin-right: $menu-padding;
|
z-index: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
border: none;
|
|
||||||
font-family: $font-family-menu;
|
|
||||||
font-size: $font-size;
|
|
||||||
padding: $input-padding;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents {
|
.contents {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.search {
|
|
||||||
position: absolute;
|
|
||||||
top: $search-box-offset;
|
|
||||||
right: $search-box-offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
height: $input-padding;
|
height: $input-padding;
|
||||||
|
|
|
@ -4,9 +4,11 @@
|
||||||
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
|
import { faSearch, faUndo, faRedo } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { createHistory } from './history.js'
|
import { createHistory } from './history.js'
|
||||||
import Node from './JSONNode.svelte'
|
import Node from './JSONNode.svelte'
|
||||||
|
import { existsIn, setIn } from './utils/immutabilityHelpers.js'
|
||||||
import { keyComboFromEvent } from './utils/keyBindings.js'
|
import { keyComboFromEvent } from './utils/keyBindings.js'
|
||||||
import { search } from './utils/search.js'
|
import { flattenSearch, search } from './utils/search.js'
|
||||||
import { immutableJSONPatch } from './utils/immutableJSONPatch'
|
import { immutableJSONPatch } from './utils/immutableJSONPatch'
|
||||||
|
import { isEqual } from 'lodash'
|
||||||
|
|
||||||
export let json = {}
|
export let json = {}
|
||||||
export let onChangeJson = () => {
|
export let onChangeJson = () => {
|
||||||
|
@ -62,7 +64,35 @@
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: refactor the search solution, it's too complex. Also, move it in a separate component
|
||||||
|
let searchResult
|
||||||
|
let activeSearchResult = undefined
|
||||||
|
let activeSearchResultIndex
|
||||||
|
let flatSearchResult
|
||||||
|
let searchResultWithActive
|
||||||
$: searchResult = searchText ? doSearch(json, searchText) : undefined
|
$: searchResult = searchText ? doSearch(json, searchText) : undefined
|
||||||
|
$: flatSearchResult = flattenSearch(searchResult)
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (!activeSearchResult || !existsIn(searchResult, activeSearchResult.path.concat(activeSearchResult.what))) {
|
||||||
|
activeSearchResult = flatSearchResult[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: activeSearchResultIndex = flatSearchResult.findIndex(item => isEqual(item, activeSearchResult))
|
||||||
|
$: searchResultWithActive = searchResult
|
||||||
|
? activeSearchResult
|
||||||
|
? setIn(searchResult, activeSearchResult.path.concat(activeSearchResult.what), 'search active')
|
||||||
|
: searchResult
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
function nextSearchResult () {
|
||||||
|
activeSearchResult = flatSearchResult[activeSearchResultIndex + 1] || activeSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousSearchResult () {
|
||||||
|
activeSearchResult = flatSearchResult[activeSearchResultIndex - 1] || activeSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
function handleChangeKey(key, oldKey) {
|
function handleChangeKey(key, oldKey) {
|
||||||
// console.log('handleChangeKey', { key, oldKey })
|
// console.log('handleChangeKey', { key, oldKey })
|
||||||
|
@ -178,26 +208,33 @@
|
||||||
<Icon data={faRedo} />
|
<Icon data={faRedo} />
|
||||||
</button>
|
</button>
|
||||||
<div class="space"></div>
|
<div class="space"></div>
|
||||||
|
{#if showSearch}
|
||||||
|
<div class="search-box-container">
|
||||||
|
<SearchBox
|
||||||
|
text={searchText}
|
||||||
|
resultCount={flatSearchResult.length}
|
||||||
|
activeIndex={activeSearchResultIndex}
|
||||||
|
onChange={(text) => searchText = text}
|
||||||
|
onNext={nextSearchResult}
|
||||||
|
onPrevious={previousSearchResult}
|
||||||
|
onClose={() => {
|
||||||
|
showSearch = false
|
||||||
|
searchText = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="contents">
|
<div class="contents">
|
||||||
<Node
|
<Node
|
||||||
value={json}
|
value={json}
|
||||||
searchResult={searchResult}
|
searchResult={searchResultWithActive}
|
||||||
expanded={true}
|
expanded={true}
|
||||||
onChangeKey={handleChangeKey}
|
onChangeKey={handleChangeKey}
|
||||||
onPatch={handlePatch}
|
onPatch={handlePatch}
|
||||||
getParentPath={getPath}
|
getParentPath={getPath}
|
||||||
/>
|
/>
|
||||||
<div class='bottom'></div>
|
<div class='bottom'></div>
|
||||||
{#if showSearch}
|
|
||||||
<div class="search">
|
|
||||||
<SearchBox
|
|
||||||
text={searchText}
|
|
||||||
onChange={text => searchText = text}
|
|
||||||
onClose={() => showSearch = false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -147,4 +147,8 @@ div.empty {
|
||||||
.key.search,
|
.key.search,
|
||||||
.value.search {
|
.value.search {
|
||||||
background-color: $highlight-color;
|
background-color: $highlight-color;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: $highlight-active-color;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -80,21 +80,15 @@
|
||||||
function getValueClass (value, searchResult) {
|
function getValueClass (value, searchResult) {
|
||||||
const type = valueType (value)
|
const type = valueType (value)
|
||||||
|
|
||||||
return classnames('value', type, {
|
return classnames('value', type, searchResult && searchResult[SEARCH_VALUE], {
|
||||||
url: isUrl(value),
|
url: isUrl(value),
|
||||||
empty: typeof value === 'string' && value.length === 0,
|
empty: typeof value === 'string' && value.length === 0,
|
||||||
search: searchResult
|
|
||||||
? !!searchResult[SEARCH_VALUE]
|
|
||||||
: false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeyClass(key, searchResult) {
|
function getKeyClass(key, searchResult) {
|
||||||
return classnames('key', {
|
return classnames('key', searchResult && searchResult[SEARCH_PROPERTY], {
|
||||||
empty: key === '',
|
empty: key === ''
|
||||||
search: searchResult
|
|
||||||
? !!searchResult[SEARCH_PROPERTY]
|
|
||||||
: false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,13 +6,14 @@ $search-size: 24px;
|
||||||
border: 2px solid $theme-color;
|
border: 2px solid $theme-color;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
background: $white;
|
background: $white;
|
||||||
|
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.24);
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
|
|
||||||
.search-icon {
|
button {
|
||||||
color: $light-gray;
|
color: $light-gray;
|
||||||
display: block;
|
display: block;
|
||||||
width: $search-size;
|
width: $search-size;
|
||||||
|
@ -24,13 +25,18 @@ $search-size: 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.search-icon {
|
button {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active {
|
&:active {
|
||||||
color: $gray;
|
color: $gray;
|
||||||
|
|
||||||
|
&.search-icon {
|
||||||
|
color: $light-gray;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,44 +7,47 @@
|
||||||
const DEBOUNCE_DELAY = 300 // milliseconds TODO: make the debounce delay configurable?
|
const DEBOUNCE_DELAY = 300 // milliseconds TODO: make the debounce delay configurable?
|
||||||
|
|
||||||
export let text = ''
|
export let text = ''
|
||||||
|
let inputText = ''
|
||||||
|
export let resultCount = 0
|
||||||
|
export let activeIndex = 0
|
||||||
export let onChange = () => {}
|
export let onChange = () => {}
|
||||||
|
export let onPrevious = () => {}
|
||||||
|
export let onNext = () => {}
|
||||||
export let onClose = () => {}
|
export let onClose = () => {}
|
||||||
|
|
||||||
let activeResultIndex = 0
|
|
||||||
let resultCount = 0
|
|
||||||
|
|
||||||
$: onChangeDebounced = debounce(onChange, DEBOUNCE_DELAY)
|
$: onChangeDebounced = debounce(onChange, DEBOUNCE_DELAY)
|
||||||
|
|
||||||
function handleSubmit (event) {
|
function handleSubmit (event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
const pendingChanges = text !== inputText
|
||||||
|
if (pendingChanges) {
|
||||||
onChangeDebounced.cancel()
|
onChangeDebounced.cancel()
|
||||||
onChange(text)
|
onChange(inputText)
|
||||||
|
} else {
|
||||||
|
onNext()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput (event) {
|
function handleInput (event) {
|
||||||
text = event.target.value
|
inputText = event.target.value
|
||||||
|
|
||||||
onChangeDebounced(text)
|
onChangeDebounced(inputText)
|
||||||
// TODO: fire debounced onChange
|
// TODO: fire debounced onChange
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose () {
|
|
||||||
onChange('')
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown (event) {
|
function handleKeyDown (event) {
|
||||||
const combo = keyComboFromEvent(event)
|
const combo = keyComboFromEvent(event)
|
||||||
|
|
||||||
if (combo === 'Ctrl+Enter' || combo === 'Command+Enter') {
|
if (combo === 'Ctrl+Enter' || combo === 'Command+Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// this.props.onFocusActive() // FIXME
|
// TODO: move focus to the active element
|
||||||
}
|
}
|
||||||
|
|
||||||
if (combo === 'Escape') {
|
if (combo === 'Escape') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
handleClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,15 +71,15 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="search-count" class:visible={text !== ''}>
|
<div class="search-count" class:visible={text !== ''}>
|
||||||
{activeResultIndex}/{resultCount}
|
{activeIndex + 1}/{resultCount}
|
||||||
</div>
|
</div>
|
||||||
<button class="search-icon search-next">
|
<button class="search-next" on:click={onNext} type="button">
|
||||||
<Icon data={faChevronDown} />
|
<Icon data={faChevronDown} />
|
||||||
</button>
|
</button>
|
||||||
<button class="search-icon search-previous">
|
<button class="search-previous" on:click={onPrevious} type="button">
|
||||||
<Icon data={faChevronUp} />
|
<Icon data={faChevronUp} />
|
||||||
</button>
|
</button>
|
||||||
<button class="search-icon search-clear" on:click={handleClose}>
|
<button class="search-clear" on:click={onClose} type="button">
|
||||||
<Icon data={faTimes} />
|
<Icon data={faTimes} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -7,7 +7,7 @@ export function search (key, value, searchText) {
|
||||||
let results = undefined
|
let results = undefined
|
||||||
|
|
||||||
if (typeof key === 'string' && containsCaseInsensitive(key, searchText)) {
|
if (typeof key === 'string' && containsCaseInsensitive(key, searchText)) {
|
||||||
results = createOrAdd(results, SEARCH_PROPERTY, true)
|
results = createOrAdd(results, SEARCH_PROPERTY, 'search')
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = valueType(value)
|
const type = valueType(value)
|
||||||
|
@ -27,13 +27,49 @@ export function search (key, value, searchText) {
|
||||||
})
|
})
|
||||||
} else { // type is a value
|
} else { // type is a value
|
||||||
if (containsCaseInsensitive(value, searchText)) {
|
if (containsCaseInsensitive(value, searchText)) {
|
||||||
results = createOrAdd(results, SEARCH_VALUE, true)
|
results = createOrAdd(results, SEARCH_VALUE, 'search')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function flattenSearch (searchResult) {
|
||||||
|
const resultArray = []
|
||||||
|
|
||||||
|
function _flattenSearch (value, path) {
|
||||||
|
if (value) {
|
||||||
|
if (value[SEARCH_PROPERTY]) {
|
||||||
|
resultArray.push({
|
||||||
|
what: SEARCH_PROPERTY,
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (value[SEARCH_VALUE]) {
|
||||||
|
resultArray.push({
|
||||||
|
what: SEARCH_VALUE,
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = valueType(value)
|
||||||
|
if (type === 'array') {
|
||||||
|
searchResult.forEach((item, index) => {
|
||||||
|
_flattenSearch(item, path.concat(index))
|
||||||
|
})
|
||||||
|
} else if (type === 'object') {
|
||||||
|
Object.keys(value).forEach(prop => {
|
||||||
|
_flattenSearch(value[prop], path.concat(prop))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_flattenSearch(searchResult, [])
|
||||||
|
|
||||||
|
return resultArray
|
||||||
|
}
|
||||||
|
|
||||||
function createOrAdd(object, key, value) {
|
function createOrAdd(object, key, value) {
|
||||||
if (object) {
|
if (object) {
|
||||||
object[key] = value
|
object[key] = value
|
||||||
|
|
Loading…
Reference in New Issue