fix: fix message focus
This commit is contained in:
@@ -6,11 +6,12 @@
|
|||||||
]"
|
]"
|
||||||
ref="rootEl"
|
ref="rootEl"
|
||||||
:data-message-id="message.id"
|
:data-message-id="message.id"
|
||||||
:tabindex="tabindex || -1"
|
:tabindex="tabindex ?? -1"
|
||||||
:aria-label="messageAriaLabel"
|
:aria-label="messageAriaLabel"
|
||||||
role="option"
|
role="option"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
|
@focus="handleFocus"
|
||||||
>
|
>
|
||||||
<div class="message__content">
|
<div class="message__content">
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
@@ -53,6 +54,7 @@ interface Props {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'focus': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -260,14 +262,6 @@ const handleDelete = async () => {
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
// focus the closest message
|
|
||||||
await nextTick()
|
|
||||||
if (targetToFocus && document.contains(targetToFocus)) {
|
|
||||||
if (!targetToFocus.hasAttribute('tabindex')) targetToFocus.setAttribute('tabindex', '-1')
|
|
||||||
targetToFocus.focus()
|
|
||||||
} else {
|
|
||||||
focusFallbackToInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete message:', error)
|
console.error('Failed to delete message:', error)
|
||||||
@@ -358,3 +352,7 @@ const handleDelete = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
const handleFocus = () => {
|
||||||
|
// Emit a focus event so the parent list can update its focused index
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" tabindex="0" role="listbox"
|
<div class="messages-container" ref="containerRef" @keydown="handleKeydown" @focusin="handleFocusIn" tabindex="-1" role="listbox"
|
||||||
:aria-label="messagesAriaLabel">
|
:aria-label="messagesAriaLabel">
|
||||||
<div class="messages" role="presentation">
|
<div class="messages" role="presentation">
|
||||||
<!-- Regular Messages -->
|
<!-- Regular Messages -->
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<!-- Unsent Messages -->
|
<!-- Unsent Messages -->
|
||||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||||
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||||
|
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
||||||
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
||||||
@open-dialog="emit('open-message-dialog', $event)" />
|
@open-dialog="emit('open-message-dialog', $event)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -62,27 +63,30 @@ const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 mess
|
|||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (totalMessages.value === 0) return
|
if (totalMessages.value === 0) return
|
||||||
|
|
||||||
let newIndex = focusedMessageIndex.value
|
// Derive current index from actual focused DOM if possible
|
||||||
|
const activeIdx = getActiveMessageIndex()
|
||||||
|
let currentIndex = activeIdx != null ? activeIdx : focusedMessageIndex.value
|
||||||
|
let newIndex = currentIndex
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
newIndex = Math.max(0, currentIndex - 1)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
newIndex = Math.min(totalMessages.value - 1, currentIndex + 1)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
newIndex = Math.max(0, currentIndex - 10)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'PageDown':
|
case 'PageDown':
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
|
newIndex = Math.min(totalMessages.value - 1, currentIndex + 10)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'Home':
|
case 'Home':
|
||||||
@@ -110,6 +114,19 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
const el = target.closest('[data-message-index]') as HTMLElement | null
|
||||||
|
if (!el) return
|
||||||
|
const idxAttr = el.getAttribute('data-message-index')
|
||||||
|
if (idxAttr == null) return
|
||||||
|
const idx = parseInt(idxAttr, 10)
|
||||||
|
if (!Number.isNaN(idx) && idx !== focusedMessageIndex.value) {
|
||||||
|
focusedMessageIndex.value = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const focusMessage = (index: number) => {
|
const focusMessage = (index: number) => {
|
||||||
focusedMessageIndex.value = index
|
focusedMessageIndex.value = index
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -136,6 +153,29 @@ const focusMessageById = (messageId: string | number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNearBottom = (threshold = 48) => {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return true
|
||||||
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||||
|
return distance <= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInputActive = () => {
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active) return false
|
||||||
|
// Keep focus on the message composer when typing/sending
|
||||||
|
return !!active.closest('.message-input') && active.classList.contains('base-textarea__field')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActiveMessageIndex = (): number | null => {
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (!active) return null
|
||||||
|
const el = active.closest('[data-message-index]') as HTMLElement | null
|
||||||
|
if (!el) return null
|
||||||
|
const idx = el.getAttribute('data-message-index')
|
||||||
|
return idx != null ? parseInt(idx, 10) : null
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
@@ -144,19 +184,46 @@ const scrollToBottom = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for new messages and auto-scroll
|
// Watch for list length changes
|
||||||
watch(() => [props.messages.length, props.unsentMessages.length], () => {
|
// - If items were added, move focus to the newest and scroll to bottom.
|
||||||
// When new messages arrive, focus the last message and scroll to bottom
|
// - If items were removed, keep current index when possible; otherwise clamp.
|
||||||
if (totalMessages.value > 0) {
|
watch(
|
||||||
focusedMessageIndex.value = totalMessages.value - 1
|
() => [props.messages.length, props.unsentMessages.length],
|
||||||
}
|
([newM, newU], [oldM = 0, oldU = 0]) => {
|
||||||
|
const oldTotal = (oldM ?? 0) + (oldU ?? 0)
|
||||||
|
const newTotal = (newM ?? 0) + (newU ?? 0)
|
||||||
|
|
||||||
|
if (newTotal > oldTotal) {
|
||||||
|
// New message(s) appended: only jump if user is near bottom and not typing
|
||||||
|
const shouldStickToBottom = isNearBottom() || focusedMessageIndex.value === oldTotal - 1
|
||||||
|
if (shouldStickToBottom && newTotal > 0) {
|
||||||
|
if (isInputActive()) {
|
||||||
|
// Preserve input focus; optionally keep scroll at bottom
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
} else {
|
||||||
|
focusMessage(newTotal - 1)
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For deletions, defer to the totalMessages watcher below to clamp and focus
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Reset focus when messages change significantly
|
// Reset focus when messages change significantly
|
||||||
watch(() => totalMessages.value, (newTotal) => {
|
watch(() => totalMessages.value, (newTotal, oldTotal) => {
|
||||||
if (focusedMessageIndex.value >= newTotal) {
|
if (newTotal === 0) return
|
||||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
if (isInputActive()) return
|
||||||
|
const current = focusedMessageIndex.value
|
||||||
|
let nextIndex = current
|
||||||
|
if (current >= newTotal) {
|
||||||
|
// If we deleted the last item, move to the new last
|
||||||
|
nextIndex = Math.max(0, newTotal - 1)
|
||||||
|
}
|
||||||
|
// Avoid double focusing if the correct item is already focused
|
||||||
|
const activeIdx = getActiveMessageIndex()
|
||||||
|
if (activeIdx !== nextIndex) {
|
||||||
|
focusMessage(nextIndex)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,7 +231,7 @@ onMounted(() => {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
// Focus the last message on mount
|
// Focus the last message on mount
|
||||||
if (totalMessages.value > 0) {
|
if (totalMessages.value > 0) {
|
||||||
focusedMessageIndex.value = totalMessages.value - 1
|
focusMessage(totalMessages.value - 1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user