From 8351418baeb7445e4d54e02a0c12510c50879c8c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:37:46 +0100 Subject: [PATCH 1/8] fix(frontend): resolve late-loaded input-assist matches --- .../components/common/InputAssistField.vue | 18 +++++++++++---- .../tests/components/InputAssistField.spec.ts | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index 00bedd5074..923f3674a5 100644 --- a/frontend/taskdeck-web/src/components/common/InputAssistField.vue +++ b/frontend/taskdeck-web/src/components/common/InputAssistField.vue @@ -37,7 +37,7 @@ const activeDescendant = computed(() => { return `${componentId}-option-${activeIndex.value}` }) -watch(filteredOptions, (options) => { +watch(filteredOptions, (options, previousOptions) => { if (options.length === 0) { activeIndex.value = 0 return @@ -46,6 +46,16 @@ watch(filteredOptions, (options) => { if (activeIndex.value >= options.length) { activeIndex.value = 0 } + + if (!panelOpen.value) { + return + } + + const exactMatch = findExactMatch(props.modelValue) + const previousExactMatch = findExactMatch(props.modelValue, previousOptions) + if (exactMatch && !previousExactMatch) { + selectOption(exactMatch) + } }) function openPanel() { @@ -65,18 +75,18 @@ 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 diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts index 7a219c892a..35d1f06e2f 100644 --- a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts @@ -113,4 +113,26 @@ 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') + + 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' }) + }) }) From 561daddccbd37f144111e21d53c5c60698fed6bc Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:47:05 +0100 Subject: [PATCH 2/8] fix(frontend): scope late selection to option changes --- .../components/common/InputAssistField.vue | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index 923f3674a5..1e280e4011 100644 --- a/frontend/taskdeck-web/src/components/common/InputAssistField.vue +++ b/frontend/taskdeck-web/src/components/common/InputAssistField.vue @@ -37,7 +37,7 @@ const activeDescendant = computed(() => { return `${componentId}-option-${activeIndex.value}` }) -watch(filteredOptions, (options, previousOptions) => { +watch(filteredOptions, (options) => { if (options.length === 0) { activeIndex.value = 0 return @@ -46,17 +46,22 @@ watch(filteredOptions, (options, previousOptions) => { if (activeIndex.value >= options.length) { activeIndex.value = 0 } +}) - if (!panelOpen.value) { - return - } +watch( + () => props.options, + (options, previousOptions) => { + if (!panelOpen.value) { + return + } - const exactMatch = findExactMatch(props.modelValue) - const previousExactMatch = findExactMatch(props.modelValue, previousOptions) - if (exactMatch && !previousExactMatch) { - selectOption(exactMatch) - } -}) + const exactMatch = findExactMatch(props.modelValue, options) + const previousExactMatch = findExactMatch(props.modelValue, previousOptions) + if (exactMatch && !previousExactMatch) { + selectOption(exactMatch) + } + }, +) function openPanel() { if (props.disabled) { From e111632fa28ccd12f6f7dbf6650f34d1e54c11bf Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:47:21 +0100 Subject: [PATCH 3/8] test(frontend): prove late option selection contract --- .../tests/components/InputAssistField.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts index 35d1f06e2f..5f35f33869 100644 --- a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts @@ -125,6 +125,7 @@ describe('InputAssistField', () => { 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) @@ -135,4 +136,19 @@ 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('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() + }) }) From 36b97e20cd820561fa7fac8b712c421731f995b6 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:54:48 +0100 Subject: [PATCH 4/8] test(frontend): pin disabled late-option behavior --- .../tests/components/InputAssistField.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts index 5f35f33869..19c84284f5 100644 --- a/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/InputAssistField.spec.ts @@ -151,4 +151,23 @@ describe('InputAssistField', () => { 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() + }) }) From d5e48cd0bf91b611cea3fc54018b2b089c268baf Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:49:50 +0100 Subject: [PATCH 5/8] fix(frontend): reject input-assist selection while disabled --- .../taskdeck-web/src/components/common/InputAssistField.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index 1e280e4011..d076438a6f 100644 --- a/frontend/taskdeck-web/src/components/common/InputAssistField.vue +++ b/frontend/taskdeck-web/src/components/common/InputAssistField.vue @@ -98,6 +98,10 @@ function findExactMatch(value: string, options: InputAssistOption[] = props.opti } function selectOption(option: InputAssistOption) { + if (props.disabled) { + return + } + setModelValue(option.value) emit('select', option) closePanel() From 5314f9e12eec964dfaff2ca904cc6dbc7ec6fd22 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:33:42 +0100 Subject: [PATCH 6/8] test(frontend): cover late input-assist options after blur --- .../components/InputAssistFieldFocus.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts 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..604b565ff2 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts @@ -0,0 +1,107 @@ +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 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('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) + }) +}) From 147c837adececb8c7bd77bd75a77e61d14bc22bd Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:35:11 +0100 Subject: [PATCH 7/8] fix(frontend): revoke late-option selection when input loses focus --- .../taskdeck-web/src/components/common/InputAssistField.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index d076438a6f..6ef890ef26 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)) @@ -51,7 +52,8 @@ watch(filteredOptions, (options) => { watch( () => props.options, (options, previousOptions) => { - if (!panelOpen.value) { + // The blur grace period keeps pointer selection available, not async ownership. + if (!inputFocused || !panelOpen.value) { return } @@ -129,6 +131,7 @@ function onInput(event: Event) { } function onBlur() { + inputFocused = false blurCloseTimer = setTimeout(() => { closePanel() blurCloseTimer = null @@ -136,6 +139,7 @@ function onBlur() { } function onFocus() { + inputFocused = true if (blurCloseTimer) { clearTimeout(blurCloseTimer) blurCloseTimer = null From 3a75b2f82d0c9349f0d41900a8dd501b7debd31a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 02:08:26 +0100 Subject: [PATCH 8/8] fix(frontend): avoid ambiguous late label selection --- .../components/common/InputAssistField.vue | 19 ++++++++++++++++-- .../components/InputAssistFieldFocus.spec.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/components/common/InputAssistField.vue b/frontend/taskdeck-web/src/components/common/InputAssistField.vue index 6ef890ef26..52e53f742d 100644 --- a/frontend/taskdeck-web/src/components/common/InputAssistField.vue +++ b/frontend/taskdeck-web/src/components/common/InputAssistField.vue @@ -57,8 +57,8 @@ watch( return } - const exactMatch = findExactMatch(props.modelValue, options) - const previousExactMatch = findExactMatch(props.modelValue, previousOptions) + const exactMatch = findLateOptionMatch(props.modelValue, options) + const previousExactMatch = findLateOptionMatch(props.modelValue, previousOptions) if (exactMatch && !previousExactMatch) { selectOption(exactMatch) } @@ -99,6 +99,21 @@ function findExactMatch(value: string, options: InputAssistOption[] = props.opti ?? 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 diff --git a/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts b/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts index 604b565ff2..75ecaae61b 100644 --- a/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/InputAssistFieldFocus.spec.ts @@ -8,6 +8,11 @@ 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[] = []) { @@ -76,6 +81,21 @@ describe('InputAssistField late-option focus ownership', () => { 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()