Skip to content
44 changes: 41 additions & 3 deletions frontend/taskdeck-web/src/components/common/InputAssistField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const componentId = `td-input-assist-${Math.random().toString(36).slice(2, 10)}`
const inputRef = ref<HTMLInputElement | null>(null)
const panelOpen = ref(false)
const activeIndex = ref(0)
let inputFocused = false
let blurCloseTimer: ReturnType<typeof setTimeout> | null = null

const filteredOptions = computed(() => filterInputAssistOptions(props.options, props.modelValue))
Expand All @@ -48,6 +49,22 @@ watch(filteredOptions, (options) => {
}
})

watch(
() => props.options,
(options, previousOptions) => {
// The blur grace period keeps pointer selection available, not async ownership.
if (!inputFocused || !panelOpen.value) {
return
}

const exactMatch = findLateOptionMatch(props.modelValue, options)
const previousExactMatch = findLateOptionMatch(props.modelValue, previousOptions)
if (exactMatch && !previousExactMatch) {
selectOption(exactMatch)
}
},
)

function openPanel() {
if (props.disabled) {
return
Expand All @@ -65,24 +82,43 @@ function setModelValue(value: string) {
emit('update:modelValue', value)
}

function findExactMatch(value: string): InputAssistOption | null {
function findExactMatch(value: string, options: InputAssistOption[] = props.options): InputAssistOption | null {
const normalizedInput = value.trim().toLowerCase()
if (!normalizedInput) {
return null
}

const byValue = props.options.find((option) => option.value.trim().toLowerCase() === normalizedInput)
const byValue = options.find((option) => option.value.trim().toLowerCase() === normalizedInput)
if (byValue) {
return byValue
}

return props.options.find((option) => {
return options.find((option) => {
return option.label.trim().toLowerCase() === normalizedInput
})
?? null
}

function findLateOptionMatch(value: string, options: InputAssistOption[]): InputAssistOption | null {
const normalizedInput = value.trim().toLowerCase()
if (!normalizedInput) {
return null
}

const byValue = options.find((option) => option.value.trim().toLowerCase() === normalizedInput)
if (byValue) {
return byValue
}

const byLabel = options.filter((option) => option.label.trim().toLowerCase() === normalizedInput)
return byLabel.length === 1 ? byLabel[0] : null
}

function selectOption(option: InputAssistOption) {
if (props.disabled) {
return
}

setModelValue(option.value)
emit('select', option)
closePanel()
Expand Down Expand Up @@ -110,13 +146,15 @@ function onInput(event: Event) {
}

function onBlur() {
inputFocused = false
blurCloseTimer = setTimeout(() => {
closePanel()
blurCloseTimer = null
}, 120)
}

function onFocus() {
inputFocused = true
if (blurCloseTimer) {
clearTimeout(blurCloseTimer)
blurCloseTimer = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,61 @@ describe('InputAssistField', () => {
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['health.check'])
expect(wrapper.emitted('select')?.at(-1)?.[0]).toMatchObject({ value: 'health.check', label: 'Health Check' })
})

it('selects an exact value when matching options arrive after input', async () => {
const wrapper = mount(InputAssistField, {
props: {
modelValue: '',
options: [],
},
})

const input = wrapper.get('input')
await input.trigger('focus')
await input.setValue('health.check')
await wrapper.setProps({ modelValue: 'health.check' })

expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
expect(wrapper.findAll('[role="option"]')).toHaveLength(0)

await wrapper.setProps({ options })

expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['health.check'])
expect(wrapper.emitted('select')?.at(-1)?.[0]).toMatchObject({ value: 'health.check', label: 'Health Check' })
})

it('does not synthesize selection when only the controlled value changes', async () => {
const wrapper = mount(InputAssistField, {
props: {
modelValue: '',
options,
},
})

await wrapper.get('input').trigger('focus')
await wrapper.setProps({ modelValue: 'health.check' })

expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
expect(wrapper.emitted('select')).toBeUndefined()
})

it('does not synthesize selection when matching options arrive while disabled', async () => {
const wrapper = mount(InputAssistField, {
props: {
modelValue: '',
options: [],
},
})

await wrapper.get('input').trigger('focus')
await wrapper.setProps({ modelValue: 'health.check' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)

await wrapper.setProps({ options, disabled: true })

expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.emitted('select')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import InputAssistField from '../../components/common/InputAssistField.vue'
import type { InputAssistOption } from '../../utils/inputAssist'

const options: InputAssistOption[] = [
{ value: 'health.check', label: 'Health Check' },
]

const ambiguousOptions: InputAssistOption[] = [
{ value: 'board.one', label: 'Shared Board' },
{ value: 'board.two', label: 'Shared Board' },
]

const cleanups: Array<() => void> = []

function mountField(initialOptions: InputAssistOption[] = []) {
const host = document.createElement('div')
const outside = document.createElement('button')
document.body.append(host, outside)
const wrapper = mount(InputAssistField, {
attachTo: host,
props: { modelValue: '', options: initialOptions },
})
cleanups.push(() => {
wrapper.unmount()
host.remove()
outside.remove()
})
return { wrapper, input: wrapper.get('input'), outside }
}

describe('InputAssistField late-option focus ownership', () => {
beforeEach(() => vi.useFakeTimers())

afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup()
vi.clearAllTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})

it.each(['health.check', 'Health Check'])(
'does not select or reclaim focus when %s resolves during the blur delay',
async (typedValue) => {
const { wrapper, input, outside } = mountField()
input.element.focus()
await input.setValue(typedValue)
await wrapper.setProps({ modelValue: typedValue })
const updatesBeforeResponse = wrapper.emitted('update:modelValue')?.length

outside.focus()
await nextTick()
expect(document.activeElement).toBe(outside)
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
const refocus = vi.spyOn(input.element, 'focus')

await wrapper.setProps({ options })

expect(wrapper.emitted('select')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toHaveLength(updatesBeforeResponse!)
expect(refocus).not.toHaveBeenCalled()
expect(document.activeElement).toBe(outside)
await vi.advanceTimersByTimeAsync(120)
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
},
)

it('still canonicalizes a late label match while the input remains focused', async () => {
const { wrapper, input } = mountField()
input.element.focus()
await input.setValue('Health Check')
await wrapper.setProps({ modelValue: 'Health Check' })

await wrapper.setProps({ options })

expect(wrapper.emitted('select')).toEqual([[options[0]]])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['health.check'])
expect(document.activeElement).toBe(input.element)
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})

it('does not auto-select an ambiguous late label match', async () => {
const { wrapper, input } = mountField()
input.element.focus()
await input.setValue('Shared Board')
await wrapper.setProps({ modelValue: 'Shared Board' })
const updatesBeforeResponse = wrapper.emitted('update:modelValue')?.length

await wrapper.setProps({ options: ambiguousOptions })

expect(wrapper.emitted('select')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toHaveLength(updatesBeforeResponse!)
expect(document.activeElement).toBe(input.element)
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
})

it('restores late-option eligibility after an intentional return to the input', async () => {
const { wrapper, input, outside } = mountField()
input.element.focus()
await input.setValue('health.check')
await wrapper.setProps({ modelValue: 'health.check' })
outside.focus()
input.element.focus()
await nextTick()

await wrapper.setProps({ options })

expect(wrapper.emitted('select')).toEqual([[options[0]]])
expect(document.activeElement).toBe(input.element)
})

it('preserves explicit option selection during the blur delay', async () => {
const { wrapper, input, outside } = mountField(options)
input.element.focus()
await nextTick()
outside.focus()
await nextTick()

await wrapper.get('[role="option"]').trigger('mousedown')

expect(wrapper.emitted('select')).toEqual([[options[0]]])
expect(wrapper.emitted('update:modelValue')).toEqual([['health.check']])
expect(document.activeElement).toBe(input.element)
})
})
Loading