mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||
|
||||
+65
@@ -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 }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user