feat: filtering comboboxes (#887)
This commit is contained in:
parent
1f8f36ace1
commit
43eccdaf48
|
|
@ -118,7 +118,7 @@ actions:
|
||||||
# Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
|
# Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
|
||||||
- title: Restart Docker Container
|
- title: Restart Docker Container
|
||||||
icon: restart
|
icon: restart
|
||||||
shell: docker restart {{ .CurrentEntity }}
|
shell: docker restart {{ container }}
|
||||||
arguments:
|
arguments:
|
||||||
- name: container
|
- name: container
|
||||||
title: Container name
|
title: Container name
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
<template>
|
||||||
|
<div class="choice-combobox" ref="rootRef">
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
type="text"
|
||||||
|
class="choice-combobox-input"
|
||||||
|
role="combobox"
|
||||||
|
autocomplete="off"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:aria-controls="listboxId"
|
||||||
|
:aria-activedescendant="activeDescendantId"
|
||||||
|
:placeholder="placeholderText"
|
||||||
|
:value="query"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@blur="handleBlur"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
:name="name"
|
||||||
|
type="text"
|
||||||
|
class="choice-combobox-hidden-value"
|
||||||
|
tabindex="-1"
|
||||||
|
:value="modelValue"
|
||||||
|
:required="required"
|
||||||
|
readonly
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="isOpen && filteredChoices.length > 0"
|
||||||
|
:id="listboxId"
|
||||||
|
role="listbox"
|
||||||
|
class="choice-combobox-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(choice, index) in filteredChoices"
|
||||||
|
:id="`${listboxId}-option-${index}`"
|
||||||
|
:key="choice.value"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="index === highlightedIndex"
|
||||||
|
:class="{
|
||||||
|
highlighted: index === highlightedIndex,
|
||||||
|
selected: choice.value === modelValue
|
||||||
|
}"
|
||||||
|
@mousedown.prevent="selectChoice(choice)"
|
||||||
|
>
|
||||||
|
{{ choiceLabel(choice) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
|
||||||
|
No matching options
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
choices: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const closeOthersEvent = 'olivetin-choice-combobox-close-others'
|
||||||
|
|
||||||
|
const rootRef = ref(null)
|
||||||
|
const searchInputRef = ref(null)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const query = ref('')
|
||||||
|
const highlightedIndex = ref(0)
|
||||||
|
|
||||||
|
const listboxId = computed(() => `${props.id}-listbox`)
|
||||||
|
|
||||||
|
const activeDescendantId = computed(() => {
|
||||||
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${listboxId.value}-option-${highlightedIndex.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const placeholderText = computed(() => {
|
||||||
|
if (props.required) {
|
||||||
|
return 'Search and select...'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Search options...'
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredChoices = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
if (!search) {
|
||||||
|
return props.choices
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.choices.filter(choice => {
|
||||||
|
const label = choiceLabel(choice).toLowerCase()
|
||||||
|
const value = String(choice.value).toLowerCase()
|
||||||
|
return label.includes(search) || value.includes(search)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
query.value = selectedLabel()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function choiceLabel(choice) {
|
||||||
|
return choice.title || choice.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLabel() {
|
||||||
|
const match = props.choices.find(choice => choice.value === props.modelValue)
|
||||||
|
if (!match) {
|
||||||
|
return props.modelValue || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return choiceLabel(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openList() {
|
||||||
|
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
||||||
|
isOpen.value = true
|
||||||
|
highlightedIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeList() {
|
||||||
|
isOpen.value = false
|
||||||
|
query.value = selectedLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitValue(value) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChoice(choice) {
|
||||||
|
emitValue(choice.value)
|
||||||
|
closeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
query.value = isOpen.value ? query.value : selectedLabel()
|
||||||
|
openList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchInput(event) {
|
||||||
|
query.value = event.target.value
|
||||||
|
openList()
|
||||||
|
highlightedIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlight(delta) {
|
||||||
|
if (filteredChoices.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = highlightedIndex.value + delta
|
||||||
|
if (nextIndex < 0) {
|
||||||
|
highlightedIndex.value = filteredChoices.value.length - 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex >= filteredChoices.value.length) {
|
||||||
|
highlightedIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightedIndex.value = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
openList()
|
||||||
|
moveHighlight(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
openList()
|
||||||
|
moveHighlight(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
selectChoice(filteredChoices.value[highlightedIndex.value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
closeList()
|
||||||
|
searchInputRef.value?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
closeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseOthers(event) {
|
||||||
|
if (event.detail.id !== props.id) {
|
||||||
|
closeList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideMouseDown(event) {
|
||||||
|
if (!isOpen.value || rootRef.value?.contains(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, open => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('mousedown', handleOutsideMouseDown, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener(closeOthersEvent, handleCloseOthers)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
||||||
|
document.removeEventListener(closeOthersEvent, handleCloseOthers)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.choice-combobox {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox:focus-within {
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-hidden-value {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--standout-bg-color, #fff);
|
||||||
|
color: var(--text-color, inherit);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-list li {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-list li.highlighted,
|
||||||
|
.choice-combobox-list li:hover {
|
||||||
|
background: var(--hover-background-color, #eef3ff);
|
||||||
|
color: var(--hover-text-color, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-list li.selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-empty {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
color: var(--disabled-text-color, #666);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.choice-combobox-list,
|
||||||
|
.choice-combobox-empty {
|
||||||
|
background-color: #4e4e4e;
|
||||||
|
color: #ddd;
|
||||||
|
border-color: var(--border-color, #595959);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-list li.highlighted,
|
||||||
|
.choice-combobox-list li:hover {
|
||||||
|
background-color: var(--hover-background-color, #1d345c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-combobox-empty {
|
||||||
|
color: var(--disabled-text-color, #999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -21,12 +21,9 @@
|
||||||
</option>
|
</option>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
<ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name"
|
||||||
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
|
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
|
||||||
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
|
@update:model-value="handleChoiceUpdate(arg, $event)" />
|
||||||
{{ choice.title || choice.value }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
||||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||||
|
|
@ -67,6 +64,7 @@
|
||||||
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { requestReconnectNow } from '../../../js/websocket.js'
|
import { requestReconnectNow } from '../../../js/websocket.js'
|
||||||
|
import ChoiceCombobox from '../components/ChoiceCombobox.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -240,6 +238,12 @@ function handleChange(arg, event) {
|
||||||
validateArgument(arg, event.target.value)
|
validateArgument(arg, event.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChoiceUpdate(arg, value) {
|
||||||
|
argValues.value[arg.name] = value
|
||||||
|
updateUrlWithArg(arg.name, value)
|
||||||
|
validateArgument(arg, value)
|
||||||
|
}
|
||||||
|
|
||||||
async function validateArgument(arg, value) {
|
async function validateArgument(arg, value) {
|
||||||
if (!arg.type || arg.type.startsWith('regex:')) {
|
if (!arg.type || arg.type.startsWith('regex:')) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, before, after } from 'mocha'
|
import { describe, it, before, after } from 'mocha'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { By, until, Condition } from 'selenium-webdriver'
|
import { By, until, Condition, Key } from 'selenium-webdriver'
|
||||||
import {
|
import {
|
||||||
getRootAndWait,
|
getRootAndWait,
|
||||||
getActionButtons,
|
getActionButtons,
|
||||||
|
|
@ -52,10 +52,19 @@ describe('config: multipleDropdowns', function () {
|
||||||
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
||||||
}), 8000)
|
}), 8000)
|
||||||
|
|
||||||
const selects = await webdriver.findElements(By.css('main select'))
|
const comboboxes = await webdriver.findElements(By.css('main .choice-combobox'))
|
||||||
|
|
||||||
expect(selects).to.have.length(2)
|
expect(comboboxes).to.have.length(2)
|
||||||
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
|
|
||||||
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)
|
const firstInput = await comboboxes[0].findElement(By.css('.choice-combobox-input'))
|
||||||
|
await firstInput.click()
|
||||||
|
expect(await comboboxes[0].findElements(By.css('.choice-combobox-list li'))).to.have.length(2)
|
||||||
|
|
||||||
|
await firstInput.sendKeys(Key.TAB)
|
||||||
|
|
||||||
|
await webdriver.wait(new Condition('wait for second combobox list', async () => {
|
||||||
|
const lists = await comboboxes[1].findElements(By.css('.choice-combobox-list li'))
|
||||||
|
return lists.length === 3
|
||||||
|
}), 2000)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue