fix: fix message focus
This commit is contained in:
@@ -6,11 +6,12 @@
|
||||
]"
|
||||
ref="rootEl"
|
||||
:data-message-id="message.id"
|
||||
:tabindex="tabindex || -1"
|
||||
:tabindex="tabindex ?? -1"
|
||||
:aria-label="messageAriaLabel"
|
||||
role="option"
|
||||
@keydown="handleKeydown"
|
||||
@click="handleClick"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<div class="message__content">
|
||||
{{ message.content }}
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||
'focus': []
|
||||
}>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -260,14 +262,6 @@ const handleDelete = async () => {
|
||||
}
|
||||
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) {
|
||||
console.error('Failed to delete message:', error)
|
||||
@@ -358,3 +352,7 @@ const handleDelete = async () => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
const handleFocus = () => {
|
||||
// Emit a focus event so the parent list can update its focused index
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<div class="messages" role="presentation">
|
||||
<!-- Regular Messages -->
|
||||
@@ -12,6 +12,7 @@
|
||||
<!-- Unsent Messages -->
|
||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||
: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"
|
||||
@open-dialog="emit('open-message-dialog', $event)" />
|
||||
</div>
|
||||
@@ -62,27 +63,30 @@ const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 mess
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
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) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 1)
|
||||
newIndex = Math.max(0, currentIndex - 1)
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
|
||||
newIndex = Math.min(totalMessages.value - 1, currentIndex + 1)
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
event.preventDefault()
|
||||
newIndex = Math.max(0, focusedMessageIndex.value - 10)
|
||||
newIndex = Math.max(0, currentIndex - 10)
|
||||
break
|
||||
|
||||
case 'PageDown':
|
||||
event.preventDefault()
|
||||
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
|
||||
newIndex = Math.min(totalMessages.value - 1, currentIndex + 10)
|
||||
break
|
||||
|
||||
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) => {
|
||||
focusedMessageIndex.value = index
|
||||
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 = () => {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
@@ -144,19 +184,46 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for new messages and auto-scroll
|
||||
watch(() => [props.messages.length, props.unsentMessages.length], () => {
|
||||
// When new messages arrive, focus the last message and scroll to bottom
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
// Watch for list length changes
|
||||
// - If items were added, move focus to the newest and scroll to bottom.
|
||||
// - If items were removed, keep current index when possible; otherwise clamp.
|
||||
watch(
|
||||
() => [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()
|
||||
} else {
|
||||
focusMessage(newTotal - 1)
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
// For deletions, defer to the totalMessages watcher below to clamp and focus
|
||||
}
|
||||
scrollToBottom()
|
||||
})
|
||||
)
|
||||
|
||||
// Reset focus when messages change significantly
|
||||
watch(() => totalMessages.value, (newTotal) => {
|
||||
if (focusedMessageIndex.value >= newTotal) {
|
||||
focusedMessageIndex.value = Math.max(0, newTotal - 1)
|
||||
watch(() => totalMessages.value, (newTotal, oldTotal) => {
|
||||
if (newTotal === 0) return
|
||||
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()
|
||||
// Focus the last message on mount
|
||||
if (totalMessages.value > 0) {
|
||||
focusedMessageIndex.value = totalMessages.value - 1
|
||||
focusMessage(totalMessages.value - 1)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -248,4 +315,4 @@ defineExpose({
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user