[#341] Added support for dynamic context in procedure editor components.

This commit is contained in:
Fabio Manganiello 2024-09-21 20:45:50 +02:00
parent 0e40d77bc7
commit 6dd1d481d5
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
21 changed files with 694 additions and 215 deletions

View file

@ -5,14 +5,15 @@
<!-- Supported action arguments -->
<div class="arg" :key="name" v-for="name in Object.keys(action.args)">
<label>
<input type="text"
class="action-arg-value"
:class="{required: action.args[name].required}"
<ContextAutocomplete :value="action.args[name].value"
:disabled="running"
:items="contextAutocompleteItems"
:placeholder="name"
:value="action.args[name].value"
:quote="true"
:select-on-tab="false"
@input="onArgEdit(name, $event)"
@focus="onSelect(name)">
@blur="onSelect(name)"
@focus="onSelect(name)" />
<span class="required-flag" v-if="action.args[name].required">*</span>
</label>
@ -36,12 +37,13 @@
@input="onExtraArgNameEdit(i, $event.target.value)">
</label>
<label class="col-6">
<input type="text"
class="action-extra-arg-value"
placeholder="Value"
<ContextAutocomplete :value="arg.value"
:disabled="running"
:value="arg.value"
@input="onExtraArgValueEdit(i, $event.target.value)">
:items="contextAutocompleteItems"
:quote="true"
:select-on-tab="false"
placeholder="Value"
@input="onExtraArgValueEdit(i, $event.detail)" />
</label>
<label class="col-1 buttons">
<button type="button" class="action-extra-arg-del" title="Remove argument" @click="$emit('remove', i)">
@ -68,10 +70,16 @@
<script>
import Argdoc from "./Argdoc"
import ContextAutocomplete from "./ContextAutocomplete"
import Mixin from "./Mixin"
export default {
name: 'ActionArgs',
components: { Argdoc },
mixins: [Mixin],
components: {
Argdoc,
ContextAutocomplete,
},
emits: [
'add',
'arg-edit',
@ -117,7 +125,7 @@ export default {
onArgEdit(name, event) {
this.$emit('arg-edit', {
name: name,
value: event.target.value,
value: event.target?.value || event.detail,
})
},

View file

@ -77,6 +77,7 @@
</h2>
<ActionArgs :action="action"
:context="context"
:loading="loading"
:running="running"
:selected-arg="selectedArg"
@ -143,6 +144,11 @@ export default {
},
props: {
context: {
type: Object,
default: () => ({}),
},
value: {
type: Object,
},
@ -391,14 +397,14 @@ export default {
this.actionDocsCache[this.action.name].html = this.selectedDoc
this.setUrlArgs({action: this.action.name})
const firstArg = this.$el.querySelector('.action-arg-value')
this.$nextTick(() => {
const firstArg = this.$el.querySelector('.args-body input[type=text]')
if (firstArg) {
firstArg.focus()
} else {
this.$nextTick(() => {
this.actionInput.focus()
})
}
})
this.response = undefined
this.error = undefined

View file

@ -55,6 +55,7 @@
<div class="action-editor-container">
<Modal ref="actionEditor" title="Edit Action">
<ActionEditor :value="value"
:context="context"
:with-save="!readOnly"
@input="onInput"
v-if="this.$refs.actionEditor?.$data?.isVisible" />
@ -68,9 +69,11 @@ import ActionEditor from "@/components/Action/ActionEditor"
import Draggable from "@/components/elements/Draggable"
import Droppable from "@/components/elements/Droppable"
import ExtensionIcon from "@/components/elements/ExtensionIcon"
import Mixin from "./Mixin"
import Modal from "@/components/Modal"
export default {
mixins: [Mixin],
emits: [
'delete',
'drag',
@ -90,6 +93,11 @@ export default {
},
props: {
context: {
type: Object,
default: () => ({}),
},
draggable: {
type: Boolean,
default: true,

View file

@ -11,6 +11,7 @@
<div class="actions-list" :class="actionListClasses">
<ActionsList :value="value[key]"
:context="context"
:dragging="dragging"
:has-else="hasElse"
:indent="indent"

View file

@ -68,6 +68,7 @@
:value="newAction"
@drop="onDrop(0, $event)">
<ActionTile :value="newAction"
:context="contexts[Object.keys(contexts).length - 1]"
:draggable="false"
@input="addAction" />
</ListItem>
@ -244,6 +245,7 @@ export default {
props: {
value: action,
active: this.isDragging,
context: this.contexts[index],
isInsideLoop: !!(this.isInsideLoop || this.getFor(action) || this.getWhile(action)),
readOnly: this.readOnly,
ref: `action-tile-${index}`,
@ -314,6 +316,34 @@ export default {
}, {}) || {}
},
contexts() {
const commonCtx = {...this.context}
const contexts = this.newValue?.reduce?.((acc, action, index) => {
acc[index] = this.getContext(action, index, commonCtx)
return acc
}, {}) || {}
const nContexts = Object.keys(contexts).length
if (nContexts > 0) {
contexts[nContexts] = this.getContext(
null,
nContexts,
contexts[nContexts - 1]
)
contexts[nContexts] = {
...this.context,
...commonCtx,
...contexts[nContexts - 1],
...contexts[nContexts],
}
} else {
contexts[0] = {...this.context}
}
return contexts
},
dragBlockIndex() {
if (this.dragIndex == null) {
return
@ -678,6 +708,33 @@ export default {
this.$emit('collapse')
},
getContext(action, index, context) {
const ctx = {...(context || this.context || {})}
if (index > 0) {
ctx.output = {
source: 'action',
action: this.newValue[index - 1],
}
}
if (this.isSet(action)) {
Object.keys(action.set).forEach((name) => {
if (!name?.length) {
return
}
context[name] = { source: 'local' }
})
}
const iterator = this.getFor(action)?.iterator
if (iterator?.length) {
context[iterator] = { source: 'for' }
}
return ctx
},
getParentBlock(indices) {
indices = [...indices]
let parent = this.newValue

View file

@ -7,6 +7,7 @@
:value="value"
v-on="componentsData.on">
<ActionTile :value="value"
:context="context"
:draggable="!readOnly"
:read-only="readOnly"
:with-delete="!readOnly"
@ -47,6 +48,11 @@ export default {
default: false,
},
context: {
type: Object,
default: () => ({}),
},
readOnly: {
type: Boolean,
default: false,

View file

@ -2,6 +2,7 @@
<div class="condition-block">
<ActionsBlock :value="value"
:collapsed="collapsed"
:context="context"
:dragging="isDragging"
:has-else="hasElse"
:is-inside-loop="isInsideLoop"
@ -37,7 +38,7 @@
<EndBlockTile value="end if"
icon="fas fa-question"
:active="active"
:spacer-bottom="spacerBottom"
:spacer-bottom="spacerBottom || dragging_"
@drop="onDrop"
v-if="isElse || !hasElse" />
</template>
@ -144,6 +145,7 @@ export default {
return {
props: {
active: this.active,
context: this.context,
readOnly: this.readOnly,
spacerBottom: this.spacerBottom,
spacerTop: this.spacerTop,

View file

@ -46,10 +46,13 @@
:visible="true"
@close="showConditionEditor = false">
<ExpressionEditor :value="value"
:context="context"
ref="conditionEditor"
@input.prevent.stop="onConditionChange"
v-if="showConditionEditor">
<div class="header">
Condition
</div>
</ExpressionEditor>
</Modal>
</div>
@ -59,6 +62,7 @@
<script>
import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile"
@ -76,6 +80,7 @@ export default {
'input',
],
mixins: [Mixin],
components: {
ExpressionEditor,
ListItem,
@ -213,5 +218,11 @@ export default {
.drag-spacer {
height: 0;
}
:deep(.expression-editor) {
.header {
display: block;
}
}
}
</style>

View file

@ -0,0 +1,170 @@
<template>
<div class="autocomplete-with-context">
<Autocomplete
v-bind="autocompleteProps"
@blur="onBlur"
@focus="onFocus"
@input="onInput"
@select="onSelect"
ref="input" />
<div class="spacer" ref="spacer" />
</div>
</template>
<script>
import Autocomplete from "@/components/elements/Autocomplete"
import AutocompleteProps from "@/mixins/Autocomplete/Props"
export default {
emits: ["blur", "focus", "input"],
components: { Autocomplete },
mixins: [AutocompleteProps],
props: {
quote: {
type: Boolean,
default: false,
},
},
computed: {
autocompleteItemsElement() {
return this.$refs.input?.$el?.querySelector(".items")
},
autocompleteItemsHeight() {
return this.autocompleteItemsElement?.clientHeight || 0
},
autocompleteProps() {
return {
...Object.fromEntries(
Object.entries(this.$props).filter(([key]) => key !== "quote")
),
items: this.items,
value: this.value,
inputOnBlur: false,
inputOnSelect: false,
showAllItems: true,
showResultsWhenBlank: true,
}
},
textInput() {
return this.$refs.input?.$refs?.input
},
},
methods: {
emitInput(value) {
this.$emit(
"input",
new CustomEvent("input", {
detail: value,
bubbles: true,
cancelable: true,
})
)
},
isWithinQuote(selection) {
let ret = false
let value = '' + this.value
selection = [...selection]
while (selection[0] > 0) {
if (value[selection[0] - 1] === '$' && value[selection[0]] === '{') {
ret = true
break
}
selection[0]--
}
if (!ret)
return false
ret = false
while (selection[1] < value.length) {
if (value[selection[1]] === '}') {
ret = true
break
}
selection[1]++
}
return ret
},
onBlur(event) {
this.$emit("blur", event)
this.$refs.spacer.style.height = "0"
},
onFocus(event) {
this.$emit("focus", event)
setTimeout(() => {
this.$refs.spacer.style.height = `${this.autocompleteItemsHeight}px`
}, 10)
},
onInput(event) {
const selection = this.textSelection()
this.emitInput(event)
this.resetSelection(selection)
},
onSelect(value) {
this.$nextTick(() => {
const selection = this.textSelection()
value = this.quote && !this.isWithinQuote(selection) ? `\${${value}}` : value
const newValue = typeof this.value === 'string' ? (
this.value.slice(0, selection[0]) +
value +
this.value.slice(selection[1])
) : value
this.emitInput(newValue)
this.resetSelection([selection[0] + value.length, selection[0] + value.length])
})
},
resetSelection(selection) {
setTimeout(() => {
this.textInput?.focus()
this.textInput?.setSelectionRange(selection[0], selection[1])
}, 10)
},
textSelection() {
return [this.textInput?.selectionStart, this.textInput?.selectionEnd]
},
},
}
</script>
<style lang="scss">
.autocomplete-with-context {
.item {
width: 100%;
display: flex;
flex-direction: column;
@include from($tablet) {
flex-direction: row;
}
.suffix {
display: flex;
font-size: 0.9em;
color: $disabled-fg;
@include from($tablet) {
margin-left: auto;
}
}
}
}
</style>

View file

@ -3,14 +3,11 @@
<label for="expression">
<slot />
<input type="text"
name="expression"
autocomplete="off"
:autofocus="true"
:placeholder="placeholder"
:value="value"
ref="text"
@input.stop="onInput" />
<ContextAutocomplete :value="newValue"
:items="contextAutocompleteItems"
:quote="quote"
@input.stop="onInput"
ref="input" />
</label>
<label>
@ -22,10 +19,13 @@
</template>
<script>
import ContextAutocomplete from "./ContextAutocomplete"
import Mixin from "./Mixin"
export default {
emits: [
'input',
],
emits: ['input'],
mixins: [Mixin],
components: { ContextAutocomplete },
props: {
value: {
@ -42,17 +42,23 @@ export default {
type: String,
default: '',
},
quote: {
type: Boolean,
default: false,
},
},
data() {
return {
hasChanges: false,
newValue: null,
}
},
methods: {
onSubmit(event) {
const value = this.$refs.text.value.trim()
const value = this.newValue?.trim()
if (!value.length && !this.allowEmpty) {
return
}
@ -62,7 +68,10 @@ export default {
},
onInput(event) {
const value = '' + event.target.value
if (event?.detail == null)
return
const value = '' + event.detail
if (!value?.trim()?.length) {
this.hasChanges = this.allowEmpty
} else {
@ -70,7 +79,7 @@ export default {
}
this.$nextTick(() => {
this.$refs.text.value = value
this.newValue = value
})
},
},
@ -83,12 +92,14 @@ export default {
mounted() {
this.hasChanges = false
this.newValue = this.value
if (!this.value?.trim?.()?.length) {
this.hasChanges = this.allowEmpty
}
this.$nextTick(() => {
this.$refs.text.focus()
this.textInput?.focus()
})
},
}
@ -107,8 +118,7 @@ export default {
label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 1em;

View file

@ -1,7 +1,7 @@
<template>
<div class="row item list-item" :class="itemClass">
<div class="spacer-wrapper" :class="{ hidden: !spacerTop }">
<div class="spacer" :class="{ active }" ref="dropTargetTop">
<div class="spacer top" :class="{ active }" ref="dropTargetTop">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">
@ -14,14 +14,14 @@
<Droppable :element="$refs.dropTargetTop" :disabled="readOnly" v-on="droppableData.top.on" />
</div>
<div class="spacer" v-if="dragging" />
<div class="spacer top" v-if="dragging" />
<slot />
<div class="spacer" v-if="dragging" />
<div class="spacer bottom" v-if="dragging" />
<div class="spacer-wrapper" :class="{ hidden: !spacerBottom }">
<div class="spacer" :class="{ active }" ref="dropTargetBottom">
<div class="spacer bottom" :class="{ active }" ref="dropTargetBottom">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">

View file

@ -2,6 +2,7 @@
<div class="loop-block">
<ActionsBlock :value="value"
:collapsed="collapsed"
:context="context_"
:dragging="isDragging"
:indent="indent"
:is-inside-loop="true"
@ -25,7 +26,7 @@
<EndBlockTile :value="`end ${type}`"
icon="fas fa-arrow-rotate-right"
:active="active"
:spacer-bottom="spacerBottom"
:spacer-bottom="spacerBottom || dragging"
@drop="onDrop" />
</template>
</ActionsBlock>
@ -134,6 +135,18 @@ export default {
return () => {}
},
context_() {
const ctx = {...this.context}
const iterator = this.loop?.iterator?.trim()
if (iterator?.length) {
ctx[iterator] = {
source: 'for',
}
}
return ctx
},
isDragging() {
return this.dragging_ || this.dragging
},
@ -159,6 +172,7 @@ export default {
props: {
...this.loop,
active: this.active,
context: this.context_,
readOnly: this.readOnly,
spacerBottom: this.spacerBottom,
spacerTop: this.spacerTop,

View file

@ -7,7 +7,7 @@
autocomplete="off"
:autofocus="true"
placeholder="Iterator"
:value="iterator"
:value="newValue.iterator"
ref="iterator"
@input.stop="onInput('iterator', $event)" />
</label>
@ -15,14 +15,12 @@
in
<label for="iterable">
<input type="text"
name="iterable"
autocomplete="off"
:autofocus="true"
<ContextAutocomplete :value="newValue.iterable"
:items="contextAutocompleteItems"
placeholder="Iterable"
:value="iterable"
ref="iterable"
@input.stop="onInput('iterable', $event)" />
@input.stop="onInput('iterable', $event)"
ref="iterable" />
</label>
<label class="async">
@ -44,12 +42,13 @@
</template>
<script>
export default {
emits: [
'change',
'input',
],
import ContextAutocomplete from "./ContextAutocomplete"
import Mixin from "./Mixin"
export default {
emits: ['change', 'input'],
mixins: [Mixin],
components: { ContextAutocomplete },
props: {
async: {
type: Boolean,
@ -70,6 +69,11 @@ export default {
data() {
return {
hasChanges: true,
newValue: {
iterator: null,
iterable: null,
async: null,
},
}
},
@ -86,7 +90,7 @@ export default {
},
onInput(target, event) {
const value = '' + event.target.value
const value = '' + (event.target?.value || event.detail)
if (!value?.trim()?.length) {
this.hasChanges = false
} else {
@ -104,7 +108,7 @@ export default {
}
this.$nextTick(() => {
event.target.value = value
this.newValue[target] = value
})
},
},
@ -112,10 +116,21 @@ export default {
watch: {
value() {
this.hasChanges = false
this.newValue = {
iterator: this.iterator,
iterable: this.iterable,
async: this.async,
}
},
},
mounted() {
this.newValue = {
iterator: this.iterator,
iterable: this.iterable,
async: this.async,
}
this.$nextTick(() => {
this.$refs.iterator.focus()
})
@ -126,20 +141,12 @@ export default {
<style lang="scss" scoped>
@import "common";
.loop-editor {
min-width: 40em;
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
label {
@mixin label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1em;
input[type="text"] {
width: 100%;
@ -151,5 +158,26 @@ export default {
padding-bottom: 0;
}
}
.loop-editor {
min-width: 40em;
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
label {
@include label;
padding: 1em;
}
}
:deep(label) {
@include label;
.autocomplete-with-context,
.autocomplete {
width: 100%;
}
}
</style>

View file

@ -37,12 +37,14 @@
<LoopEditor :iterator="iterator"
:iterable="iterable"
:async="async"
:context="context"
@change="onLoopChange"
v-if="showLoopEditor && type === 'for'">
Loop
</LoopEditor>
<ExpressionEditor :value="condition"
:context="context"
@input.prevent.stop="onConditionChange"
v-else-if="showLoopEditor && type === 'while'">
Loop Condition
@ -56,10 +58,12 @@
import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem"
import LoopEditor from "./LoopEditor"
import Mixin from "./Mixin"
import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile"
export default {
mixins: [Mixin],
emits: [
'change',
'click',

View file

@ -1,5 +1,41 @@
<script>
export default {
props: {
context: {
type: Object,
default: () => ({}),
},
},
computed: {
contextAutocompleteItems() {
return Object.entries(this.context).reduce((acc, [key, value]) => {
let suffix = '<span class="source">from <b>' + (
value?.source === 'local'
? 'local context'
: value?.source
) + '</b>'
if (value?.source === 'action' && value?.action?.action)
suffix += ` (<i>${value?.action?.action}</i>)`
suffix += '</span>'
acc.push(
{
text: key,
suffix: suffix,
}
)
return acc
}, [])
},
textInput() {
return this.$refs.input?.$refs?.input?.$refs?.input
},
},
methods: {
getCondition(value) {
value = this.getKey(value) || value

View file

@ -25,6 +25,8 @@
@close="showExprEditor = false">
<ExpressionEditor :value="value"
:allow-empty="true"
:context="context"
:quote="true"
placeholder="Optional return value"
ref="exprEditor"
@input.prevent.stop="onExprChange"
@ -39,10 +41,12 @@
<script>
import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile"
export default {
mixins: [Mixin],
emits: [
'change',
'click',

View file

@ -1,6 +1,7 @@
<template>
<ListItem class="set-variables-tile"
:class="{active}"
:dragging="dragging_"
:value="value"
:active="active"
:read-only="readOnly"
@ -43,10 +44,12 @@
v-model="newValue[i][0]">&nbsp;=
</span>
<span class="value">
<input type="text"
<ContextAutocomplete :value="newValue[i][1]"
:items="contextAutocompleteItems"
:quote="true"
:select-on-tab="false"
placeholder="Value"
@input.prevent.stop
v-model="newValue[i][1]">
@input.prevent.stop="newValue[i][1] = $event.detail" />
</span>
</div>
@ -59,12 +62,13 @@
v-model="newVariable.name">&nbsp;=
</span>
<span class="value">
<input type="text"
<ContextAutocomplete :value="newVariable.value"
:items="contextAutocompleteItems"
:quote="true"
:select-on-tab="false"
placeholder="Value"
ref="newVarValue"
@blur="onBlur(null)"
@input.prevent.stop
v-model="newVariable.value">
@input.prevent.stop="newVariable.value = $event.detail"
@blur="onBlur(null)" />
</span>
</div>
@ -80,11 +84,14 @@
</template>
<script>
import ContextAutocomplete from "./ContextAutocomplete"
import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile"
export default {
mixins: [Mixin],
emits: [
'click',
'delete',
@ -98,6 +105,7 @@ export default {
],
components: {
ContextAutocomplete,
ListItem,
Modal,
Tile,
@ -109,6 +117,11 @@ export default {
default: false,
},
dragging: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
@ -162,7 +175,7 @@ export default {
data() {
return {
dragging: false,
dragging_: false,
newValue: [],
newVariable: {
name: '',
@ -253,17 +266,17 @@ export default {
return
}
this.dragging = true
this.dragging_ = true
this.$emit('drag', event)
},
onDragEnd(event) {
this.dragging = false
this.dragging_ = false
this.$emit('dragend', event)
},
onDrop(event) {
this.dragging = false
this.dragging_ = false
if (this.readOnly) {
return
}
@ -315,12 +328,6 @@ export default {
@import "common";
.set-variables-tile {
&.active {
.spacer {
display: none;
}
}
.variables {
margin-left: 2.5em;
}
@ -342,6 +349,7 @@ export default {
.variable {
display: flex;
margin-bottom: 1em;
.value {
flex: 1;

View file

@ -148,6 +148,7 @@ form {
}
}
:deep(.args-body) {
.args-list {
padding-top: 0.5em;
overflow: auto;
@ -165,6 +166,11 @@ form {
width: $params-desktop-width;
}
label {
display: flex;
align-items: center;
}
.arg {
margin-bottom: .25em;
@include until($tablet) {
@ -173,19 +179,22 @@ form {
.required-flag {
width: 1.25em;
display: inline-block;
font-weight: bold;
margin-left: 0.25em;
text-align: center;
}
}
input {
.autocomplete-with-context {
width: calc(100% - 1.5em);
}
}
.action-arg-value {
width: 100%;
}
}
}
.args-body {
max-height: calc(50vh - 4.5em);

View file

@ -63,6 +63,7 @@
</h3>
<ActionsList :value="newValue.actions"
:context="context"
:read-only="readOnly"
@input="onActionsEdit" />
</div>
@ -312,6 +313,16 @@ export default {
return JSON.stringify(this.newValue)
},
context() {
return this.newValue?.args?.reduce((acc, arg) => {
acc[arg] = {
source: 'args',
}
return acc
}, {})
},
modal_() {
if (this.readOnly)
return null

View file

@ -1,5 +1,5 @@
<template>
<div class="autocomplete">
<div class="autocomplete" :class="{ 'with-items': showItems }">
<label :text="label">
<input
type="text"
@ -8,73 +8,43 @@
:placeholder="placeholder"
:disabled="disabled"
:value="value"
@focus="onFocus"
@input="onInput"
@focus.stop="onFocus"
@input.stop="onInput"
@blur="onBlur"
@keydown="onInputKeyDown"
@keyup="onInputKeyUp"
>
</label>
<div class="items" v-if="showItems">
<div class="items" ref="items" v-if="showItems">
<div
class="item"
:class="{ active: i === curIndex }"
:key="getItemText(item)"
:data-item="getItemText(item)"
v-for="(item, i) in visibleItems"
@click="onItemSelect(item)">
<span v-html="item.prefix" v-if="item.prefix"></span>
@click.stop="onItemSelect(item)">
<span class="prefix" v-html="item.prefix" v-if="item.prefix"></span>
<span class="matching" v-if="value?.length">{{ getItemText(item).substr(0, value.length) }}</span>
<span class="normal">{{ getItemText(item).substr(value?.length || 0) }}</span>
<span v-html="item.suffix" v-if="item.suffix"></span>
<span class="suffix" v-html="item.suffix" v-if="item.suffix"></span>
</div>
</div>
</div>
</template>
<script>
import Props from "@/mixins/Autocomplete/Props"
export default {
emits: ["input"],
props: {
items: {
type: Array,
required: true,
},
value: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: false,
},
label: {
type: String,
},
placeholder: {
type: String,
},
showResultsWhenBlank: {
type: Boolean,
default: false,
},
},
emits: ["blur", "focus", "input", "select"],
mixins: [Props],
data() {
return {
visible: false,
curIndex: -1,
curIndex: null,
selectItemTimer: null,
}
},
@ -84,7 +54,7 @@ export default {
},
visibleItems() {
if (!this.value?.length)
if (!this.value?.length || this.showAllItems)
return this.items
const val = this.value.toUpperCase()
@ -103,109 +73,140 @@ export default {
methods: {
getItemText(item) {
return item.text || item
},
selectNextItem() {
this.curIndex++
this.normalizeIndex()
},
selectPrevItem() {
this.curIndex--
this.normalizeIndex()
},
normalizeIndex() {
// Go to the beginning after reaching the end
if (this.curIndex >= this.visibleItems.length)
this.curIndex = 0
// Go to the end after moving back from the start
if (this.curIndex < 0)
this.curIndex = this.visibleItems.length - 1
// Scroll to the element
const curText = this.getItemText(this.visibleItems[this.curIndex])
const el = this.$el.querySelector(`[data-item='${curText}']`)
if (el)
el.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth",
})
return item?.text || item
},
valueIsInItems() {
if (this.showAllItems)
return true
if (!this.value)
return false
return this.itemsText.indexOf(this.value) >= 0
},
onFocus() {
onFocus(e) {
this.$emit("focus", e)
if (this.showResultsWhenBlank || this.value?.length)
this.visible = true
},
onInput(e) {
let val = e.target.value
let val = e.target?.value
if (val == null) {
e.stopPropagation?.()
return
}
if (this.valueIsInItems())
this.visible = false
e.stopPropagation()
this.$emit("input", val.text || val)
this.curIndex = -1
this.curIndex = null
this.visible = true
},
onBlur(e) {
if (this.inputOnBlur) {
this.onInput(e)
this.$nextTick(() => {
if (this.valueIsInItems())
this.visible = false
})
},
onItemSelect(item) {
this.$emit("input", item.text || item)
this.$nextTick(() => {
if (this.valueIsInItems()) {
this.visible = false
}
})
return
}
e.stopPropagation()
this.$nextTick(() => this.$refs.input.focus())
setTimeout(() => {
if (this.selectItemTimer) {
this.$nextTick(() => this.$refs.input.focus())
return
}
this.$emit("blur", e)
if (this.valueIsInItems()) {
this.visible = false
}
}, 200)
},
onItemSelect(item) {
if (this.selectItemTimer) {
clearTimeout(this.selectItemTimer)
this.selectItemTimer = null
}
this.selectItemTimer = setTimeout(() => {
this.selectItemTimer = null
}, 250)
const text = item.text || item
this.$emit("select", text)
if (this.inputOnSelect)
this.$emit("input", text)
this.$nextTick(() => {
if (this.valueIsInItems()) {
if (this.inputOnSelect) {
this.visible = false
} else {
this.visible = true
this.curIndex = this.visibleItems.indexOf(item)
if (this.curIndex < 0)
this.curIndex = null
this.$refs.input.focus()
}
}
})
},
onInputKeyUp(e) {
if (["ArrowUp", "ArrowDown", "Tab", "Enter", "Escape"].indexOf(e.key) >= 0)
if (
["ArrowUp", "ArrowDown", "Escape"].indexOf(e.key) >= 0 ||
(e.key === "Tab" && this.selectOnTab) ||
(e.key === "Enter" && this.curIndex != null)
) {
e.stopPropagation()
}
if (e.key === "Enter" && this.valueIsInItems()) {
if (e.key === "Enter" && this.valueIsInItems() && this.curIndex != null) {
this.$refs.input.blur()
this.visible = false
}
},
onInputKeyDown(e) {
if (!this.showItems)
return
e.stopPropagation()
if (
e.key === 'ArrowDown' ||
(e.key === 'Tab' && !e.shiftKey) ||
(e.key === 'Tab' && !e.shiftKey && this.selectOnTab) ||
(e.key === 'j' && e.ctrlKey)
) {
this.selectNextItem()
this.curIndex = this.curIndex == null ? 0 : this.curIndex + 1
e.preventDefault()
} else if (
e.key === 'ArrowUp' ||
(e.key === 'Tab' && e.shiftKey) ||
(e.key === 'Tab' && e.shiftKey && this.selectOnTab) ||
(e.key === 'k' && e.ctrlKey)
) {
this.selectPrevItem()
this.curIndex = this.curIndex == null ? this.visibleItems.length - 1 : this.curIndex - 1
e.preventDefault()
} else if (e.key === 'Enter') {
if (this.curIndex > -1 && this.visible) {
if (this.curIndex != null && this.curIndex >= 0 && this.visible) {
e.preventDefault()
this.onItemSelect(this.visibleItems[this.curIndex])
this.$refs.input.focus()
this.$nextTick(() => this.$refs.input.focus())
}
} else if (e.key === 'Escape') {
this.visible = false
@ -220,6 +221,33 @@ export default {
},
},
watch: {
curIndex() {
// Do nothing if the index is not set
if (this.curIndex == null)
return
// Go to the beginning after reaching the end
if (this.curIndex >= this.visibleItems.length)
this.curIndex = 0
// Go to the end after moving back from the start
if (this.curIndex < 0)
this.curIndex = this.visibleItems.length - 1
// Scroll to the element
const curText = this.getItemText(this.visibleItems[this.curIndex])
const el = this.$el.querySelector(`[data-item='${curText}']`)
if (el) {
el.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth",
})
}
},
},
mounted() {
document.addEventListener("click", this.onDocumentClick)
if (this.autofocus)

View file

@ -0,0 +1,58 @@
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
value: {
type: [String, Number, Object, Array, Boolean],
default: "",
},
disabled: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: false,
},
label: {
type: String,
},
placeholder: {
type: String,
},
inputOnBlur: {
type: Boolean,
default: true,
},
inputOnSelect: {
type: Boolean,
default: true,
},
selectOnTab: {
type: Boolean,
default: true,
},
showAllItems: {
type: Boolean,
default: false,
},
showResultsWhenBlank: {
type: Boolean,
default: false,
},
},
}
</script>