diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index 00bedd5074..52e53f742d 100644 --- a/frontend/taskdeck-web/src/components/common/InputAssistField.vue +++ b/frontend/taskdeck-web/src/components/common/InputAssistField.vue @@ -25,6 +25,7 @@ const componentId = `td-input-assist-${Math.random().toString(36).slice(2, 10)}` const inputRef = ref(null) const panelOpen = ref(false) const activeIndex = ref(0) +let inputFocused = false let blurCloseTimer: ReturnType | null = null const filteredOptions = computed(() => filterInputAssistOptions(props.options, props.modelValue)) @@ -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 @@ -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() @@ -110,6 +146,7 @@ function onInput(event: Event) { } function onBlur() { + inputFocused = false blurCloseTimer = setTimeout(() => { closePanel() blurCloseTimer = null @@ -117,6 +154,7 @@ function onBlur() { } function onFocus() { + inputFocused = true if (blurCloseTimer) { clearTimeout(blurCloseTimer) blurCloseTimer = null diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts index 7a219c892a..19c84284f5 100644 --- a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts @@ -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() + }) }) diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts new file mode 100644 index 0000000000..75ecaae61b --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts @@ -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) + }) +})