fix: image preview a11y (#7252)

## Summary

Make image preview keyboard accessible, set the key listener on the node
itself for more robust and intuitive handling, also add better aria
labels.

Follow up PR: same on Video preview. 

## Changes

- **What**: LGraphNode.vue, ImagePreview.vue
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7252-fix-image-preview-a11y-2c46d73d3650815b9496f3d36a8942bf)
by [Unito](https://www.unito.io)
This commit is contained in:
Simula_r
2025-12-11 22:31:36 -08:00
committed by GitHub
parent c1808db7c4
commit 88bdc605a7
3 changed files with 102 additions and 21 deletions

View File

@@ -1,21 +1,25 @@
<template>
<div
v-if="imageUrls.length > 0"
class="image-preview outline-none group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
tabindex="0"
role="region"
:aria-label="$t('g.imagePreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
>
<!-- Image Wrapper -->
<div
class="h-full w-full overflow-hidden rounded-[5px] bg-node-component-surface relative"
ref="imageWrapperEl"
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
tabindex="0"
role="img"
:aria-label="$t('g.imagePreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
v-if="imageError"
role="alert"
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
>
<i
@@ -43,8 +47,11 @@
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-2.5">
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages"
@@ -96,6 +103,7 @@
v-for="(_, index) in imageUrls"
:key="index"
:class="getNavigationDotClass(index)"
:aria-current="index === currentIndex ? 'true' : undefined"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
@@ -112,7 +120,8 @@
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import type { ShallowRef } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -139,11 +148,13 @@ const actionButtonClass =
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
@@ -159,6 +170,15 @@ const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
const keyEvent = inject<ShallowRef<KeyboardEvent | null>>('keyEvent')
if (keyEvent) {
watch(keyEvent, (e) => {
if (!e) return
handleKeyDown(e)
})
}
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
@@ -247,6 +267,17 @@ const handleMouseLeave = () => {
isHovered.value = false
}
const handleFocusIn = () => {
isFocused.value = true
}
const handleFocusOut = (event: FocusEvent) => {
// Only unfocus if focus is leaving the wrapper entirely
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',

View File

@@ -5,6 +5,7 @@
<div
v-else
ref="nodeContainerRef"
tabindex="0"
:data-node-id="nodeData.id"
:class="
cn(
@@ -16,7 +17,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2',
'outline-transparent outline-2 focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
cursorClass,
@@ -48,6 +49,7 @@
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
@keydown="handleNodeKeydown"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
@@ -130,7 +132,16 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
import {
computed,
nextTick,
onErrorCaptured,
onMounted,
provide,
ref,
shallowRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -197,6 +208,13 @@ const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
})
const keyEvent = shallowRef<KeyboardEvent | null>(null)
provide('keyEvent', keyEvent)
const handleNodeKeydown = (event: KeyboardEvent) => {
keyEvent.value = event
}
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionStore = useExecutionStore()

View File

@@ -107,8 +107,9 @@ describe('ImagePreview', () => {
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover
await wrapper.trigger('mouseenter')
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
@@ -123,14 +124,45 @@ describe('ImagePreview', () => {
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger hover
await wrapper.trigger('mouseenter')
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await wrapper.trigger('mouseleave')
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})
it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
@@ -138,7 +170,7 @@ describe('ImagePreview', () => {
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.trigger('mouseenter')
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
@@ -150,7 +182,7 @@ describe('ImagePreview', () => {
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.trigger('mouseenter')
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
@@ -164,7 +196,7 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
@@ -183,7 +215,7 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Download button