Add arrow key nav to channel list
This commit is contained in:
@@ -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;
|
||||
|
@@ -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>
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user