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> <template>
<div <div
v-if="imageUrls.length > 0" 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" class="image-preview 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"
> >
<!-- Image Wrapper --> <!-- Image Wrapper -->
<div <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 --> <!-- Error State -->
<div <div
v-if="imageError" 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" class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
> >
<i <i
@@ -43,8 +47,11 @@
@error="handleImageError" @error="handleImageError"
/> />
<!-- Floating Action Buttons (appear on hover) --> <!-- Floating Action Buttons (appear on hover and focus) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-2.5"> <div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Mask/Edit Button --> <!-- Mask/Edit Button -->
<button <button
v-if="!hasMultipleImages" v-if="!hasMultipleImages"
@@ -96,6 +103,7 @@
v-for="(_, index) in imageUrls" v-for="(_, index) in imageUrls"
:key="index" :key="index"
:class="getNavigationDotClass(index)" :class="getNavigationDotClass(index)"
:aria-current="index === currentIndex ? 'true' : undefined"
:aria-label=" :aria-label="
$t('g.viewImageOfTotal', { $t('g.viewImageOfTotal', {
index: index + 1, index: index + 1,
@@ -112,7 +120,8 @@
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue' import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton' 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 { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil' import { downloadFile } from '@/base/common/downloadUtil'
@@ -139,11 +148,13 @@ const actionButtonClass =
// Component state // Component state
const currentIndex = ref(0) const currentIndex = ref(0)
const isHovered = ref(false) const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null) const actualDimensions = ref<string | null>(null)
const imageError = ref(false) const imageError = ref(false)
const showLoader = ref(false) const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>() const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn( 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 hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 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 for URL changes and reset state
watch( watch(
() => props.imageUrls, () => props.imageUrls,
@@ -247,6 +267,17 @@ const handleMouseLeave = () => {
isHovered.value = false 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) => { const getNavigationDotClass = (index: number) => {
return [ return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0', 'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',

View File

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

View File

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