Add arrow key nav to channel list

This commit is contained in:
2025-08-21 13:45:13 +02:00
parent fa1cbdf97e
commit f2ac7d7209
4 changed files with 188 additions and 16 deletions

View File

@@ -1,20 +1,26 @@
<template>
<div class="channel-list-container">
<ul class="channel-list" role="list">
<div class="channel-list-container" ref="containerRef">
<ul class="channel-list" role="list" aria-label="Channels">
<ChannelListItem
v-for="channel in channels"
v-for="(channel, index) in channels"
:key="channel.id"
:channel="channel"
:is-active="channel.id === currentChannelId"
:unread-count="unreadCounts[channel.id]"
@select="$emit('select-channel', $event)"
:tabindex="index === focusedChannelIndex ? 0 : -1"
:channel-index="index"
:data-channel-index="index"
@select="handleChannelSelect"
@info="$emit('channel-info', $event)"
@keydown="handleChannelKeydown"
@focus="handleChannelFocus"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import ChannelListItem from './ChannelListItem.vue'
import type { Channel } from '@/types'
@@ -24,12 +30,129 @@ interface Props {
unreadCounts: Record<number, number>
}
defineProps<Props>()
defineEmits<{
const emit = defineEmits<{
'select-channel': [channelId: number]
'channel-info': [channel: Channel]
}>()
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
const focusedChannelIndex = ref(0)
// Handle individual channel events
const handleChannelSelect = (channelId: number) => {
emit('select-channel', channelId)
}
const handleChannelFocus = (index: number) => {
focusedChannelIndex.value = index
}
const handleChannelKeydown = (event: KeyboardEvent, channelIndex: number) => {
if (props.channels.length === 0) return
// Don't handle keys with modifiers - let them bubble up for global shortcuts
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}
let newIndex = channelIndex
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
newIndex = Math.max(0, channelIndex - 1)
break
case 'ArrowDown':
event.preventDefault()
newIndex = Math.min(props.channels.length - 1, channelIndex + 1)
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = props.channels.length - 1
break
case 'Enter':
case ' ':
event.preventDefault()
const selectedChannel = props.channels[channelIndex]
if (selectedChannel) {
emit('select-channel', selectedChannel.id)
}
return
case 'i':
case 'I':
// Only handle 'i' without modifiers
if (!event.shiftKey) {
event.preventDefault()
const infoChannel = props.channels[channelIndex]
if (infoChannel) {
emit('channel-info', infoChannel)
}
return
}
break
default:
return
}
if (newIndex !== channelIndex) {
focusChannel(newIndex)
}
}
const focusChannel = (index: number) => {
focusedChannelIndex.value = index
nextTick(() => {
const buttonElement = containerRef.value?.querySelector(`[data-channel-index="${index}"] .channel-button`) as HTMLElement
if (buttonElement) {
buttonElement.focus()
buttonElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
// Watch for channels changes and adjust focus
watch(() => props.channels.length, (newLength) => {
if (focusedChannelIndex.value >= newLength) {
focusedChannelIndex.value = Math.max(0, newLength - 1)
}
})
// Set initial focus to current channel or first channel
watch(() => props.currentChannelId, (newChannelId) => {
if (newChannelId) {
const index = props.channels.findIndex(channel => channel.id === newChannelId)
if (index !== -1) {
focusedChannelIndex.value = index
}
}
}, { immediate: true })
onMounted(() => {
// Focus the current channel if available
if (props.currentChannelId) {
const index = props.channels.findIndex(channel => channel.id === props.currentChannelId)
if (index !== -1) {
focusedChannelIndex.value = index
}
}
})
defineExpose({
focusChannel
})
</script>
<style scoped>
@@ -39,6 +162,7 @@ defineEmits<{
padding: 0.5rem 0;
}
.channel-list {
list-style: none;
margin: 0;

View File

@@ -4,13 +4,18 @@
'channel-item',
{ 'channel-item--active': isActive }
]"
:data-channel-index="channelIndex"
role="listitem"
>
<div class="channel-wrapper">
<button
class="channel-button"
@click="$emit('select', channel.id)"
@focus="handleFocus"
@keydown="handleKeydown"
:tabindex="tabindex"
:aria-pressed="isActive"
:aria-label="`Select channel ${channel.name}`"
:aria-label="channelAriaLabel"
>
<span class="channel-name">{{ channel.name }}</span>
<span v-if="unreadCount" class="channel-unread">
@@ -31,20 +36,46 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Channel } from '@/types'
interface Props {
channel: Channel
isActive: boolean
unreadCount?: number
tabindex?: number
channelIndex?: number
}
defineProps<Props>()
defineEmits<{
const emit = defineEmits<{
select: [channelId: number]
info: [channel: Channel]
focus: [index: number]
keydown: [event: KeyboardEvent, index: number]
}>()
const props = defineProps<Props>()
// Better ARIA label that announces the channel name and unread count
const channelAriaLabel = computed(() => {
let label = `${props.channel.name} channel`
if (props.unreadCount) {
label += `, ${props.unreadCount} unread message${props.unreadCount > 1 ? 's' : ''}`
}
return label
})
const handleFocus = () => {
if (props.channelIndex !== undefined) {
emit('focus', props.channelIndex)
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (props.channelIndex !== undefined) {
emit('keydown', event, props.channelIndex)
}
}
</script>
<style scoped>

View File

@@ -25,11 +25,8 @@ export function useKeyboardShortcuts() {
}
const handleKeydown = (event: KeyboardEvent) => {
// Skip shortcuts when focused on input/textarea elements
const target = event.target as HTMLElement
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') {
return
}
const isInInputField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA'
const config: ShortcutConfig = {
key: event.key.toLowerCase(),
@@ -44,6 +41,16 @@ export function useKeyboardShortcuts() {
const shortcut = shortcuts.value.get(shortcutKey)
if (shortcut) {
// Allow certain shortcuts to work globally, even in input fields
const isGlobalShortcut = (shortcut.ctrlKey && shortcut.shiftKey) ||
shortcut.altKey ||
shortcut.key === 'escape'
// Skip shortcuts that shouldn't work in input fields
if (isInInputField && !isGlobalShortcut) {
return
}
if (shortcut.preventDefault !== false) {
event.preventDefault()
}

View File

@@ -309,6 +309,11 @@ const selectChannel = async (channelId: number) => {
}
scrollToBottom()
// Auto-focus message input when switching channels
nextTick(() => {
messageInput.value?.focus()
})
}
const handleSendMessage = async (content: string) => {
@@ -452,7 +457,12 @@ onMounted(async () => {
await selectChannel(appStore.channels[0].id)
}
// 6. Set up periodic sync for unsent messages
// 6. Auto-focus message input on page load
nextTick(() => {
messageInput.value?.focus()
})
// 7. Set up periodic sync for unsent messages
const syncInterval = setInterval(async () => {
if (appStore.unsentMessages.length > 0) {
try {