mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 01:50:08 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user