feat: filtering comboboxes (#887)

This commit is contained in:
jamesread 2026-06-19 22:59:56 +01:00
parent 1f8f36ace1
commit 43eccdaf48
4 changed files with 373 additions and 15 deletions

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -1,8 +1,8 @@
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,
takeScreenshotOnFailure, takeScreenshotOnFailure,
} from '../../lib/elements.js' } from '../../lib/elements.js'
@ -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)
}) })
}) })