fix(books): prevent focus loss when typing in empty series row

The SeriesMembershipEditor was losing focus on every keystroke when typing into a previously empty series row because the key was bound to the seriesName, causing the input component to remount. This introduces stable row keys to ensure the input retains focus while updating the model.

Closes #370
This commit is contained in:
neon
2026-06-21 04:25:03 +00:00
parent 1988ed0956
commit 1c38cf2084
2 changed files with 104 additions and 6 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next'
import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue'
import type { EditableSeriesMembership } from '../../../composables/useMetadataEditor'
@@ -16,6 +16,31 @@ const visibleMemberships = computed<EditableSeriesMembership[]>(() =>
props.modelValue.length > 0 ? props.modelValue : [{ seriesName: '', seriesIndex: null }],
)
const rowKeys = ref<string[]>([])
let nextRowKey = 0
function createRowKey() {
nextRowKey += 1
return `series-membership-${nextRowKey}`
}
function syncRowKeys() {
const visibleLength = Math.max(1, props.modelValue.length)
if (rowKeys.value.length < visibleLength) {
rowKeys.value = [...rowKeys.value, ...Array.from({ length: visibleLength - rowKeys.value.length }, () => createRowKey())]
return
}
if (rowKeys.value.length > visibleLength) {
rowKeys.value = rowKeys.value.slice(0, visibleLength)
}
}
watch(() => props.modelValue.length, syncRowKeys, { immediate: true })
function getRowKey(index: number) {
return rowKeys.value[index] ?? `series-membership-fallback-${index}`
}
function updateMembership(index: number, patch: Partial<EditableSeriesMembership>) {
if (props.disabled) return
if (props.modelValue.length === 0 && index === 0) {
@@ -37,15 +62,17 @@ function updateSeriesIndex(index: number, event: Event) {
function addMembership() {
if (props.disabled) return
if (props.modelValue.length > 0) {
rowKeys.value = [...rowKeys.value, createRowKey()]
}
emit('update:modelValue', [...props.modelValue, { seriesName: '', seriesIndex: null }])
}
function removeMembership(index: number) {
if (props.disabled) return
emit(
'update:modelValue',
props.modelValue.filter((_, i) => i !== index),
)
const next = props.modelValue.filter((_, i) => i !== index)
rowKeys.value = next.length > 0 ? rowKeys.value.filter((_, i) => i !== index) : [createRowKey()]
emit('update:modelValue', next)
}
function moveMembership(index: number, offset: -1 | 1) {
@@ -56,6 +83,12 @@ function moveMembership(index: number, offset: -1 | 1) {
const [item] = next.splice(index, 1)
if (!item) return
next.splice(target, 0, item)
const nextKeys = [...rowKeys.value]
const [key] = nextKeys.splice(index, 1)
if (key) {
nextKeys.splice(target, 0, key)
rowKeys.value = nextKeys
}
emit('update:modelValue', next)
}
</script>
@@ -64,7 +97,7 @@ function moveMembership(index: number, offset: -1 | 1) {
<div class="flex flex-col gap-2">
<div
v-for="(membership, index) in visibleMemberships"
:key="modelValue.length === 0 ? 'empty-series-row' : `${index}:${membership.seriesName}`"
:key="getRowKey(index)"
class="grid grid-cols-[2rem_minmax(0,1fr)_5.5rem_auto] items-center gap-2"
>
<button
@@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EditableSeriesMembership } from '../../../../composables/useMetadataEditor'
import SeriesMembershipEditor from '../SeriesMembershipEditor.vue'
function mountHost(initialMemberships: EditableSeriesMembership[] = []) {
const Host = defineComponent({
components: { SeriesMembershipEditor },
setup() {
const memberships = ref<EditableSeriesMembership[]>(initialMemberships)
const searchFn = vi.fn<(query: string) => Promise<string[]>>().mockResolvedValue([])
return { memberships, searchFn }
},
template: '<SeriesMembershipEditor v-model="memberships" :search-fn="searchFn" />',
})
const attachTo = document.createElement('div')
document.body.appendChild(attachTo)
return mount(Host, {
attachTo,
global: {
stubs: {
teleport: true,
},
},
})
}
describe('SeriesMembershipEditor', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
document.body.innerHTML = ''
})
it('keeps the series name input focused while typing into an empty series row', async () => {
const wrapper = mountHost()
const input = wrapper.get<HTMLInputElement>('input[type="text"]')
input.element.focus()
await input.trigger('focus')
expect(document.activeElement).toBe(input.element)
await input.setValue('D')
await nextTick()
const updatedInput = wrapper.get<HTMLInputElement>('input[type="text"]')
expect(updatedInput.element).toBe(input.element)
expect(document.activeElement).toBe(input.element)
await updatedInput.setValue('Dune')
await nextTick()
const finalInput = wrapper.get<HTMLInputElement>('input[type="text"]')
expect(finalInput.element).toBe(input.element)
expect(document.activeElement).toBe(input.element)
expect((wrapper.vm as { memberships: EditableSeriesMembership[] }).memberships).toEqual([{ seriesName: 'Dune', seriesIndex: null }])
})
})