mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary The canvas mode selector popover (Select/Hand mode) uses plain `div` elements for its menu items, making them completely inaccessible to keyboard-only users and screen readers. This PR replaces them with proper semantic HTML and ARIA attributes. ## Problem (AS-IS) As reported in #9519, the canvas mode selector popover has the following accessibility issues: 1. **Menu items are `div` elements** — they cannot receive keyboard focus, so users cannot Tab into the popover or activate items with Enter/Space. Keyboard-only users are completely locked out of switching between Select and Hand (pan) mode via the popover. 2. **No ARIA roles** — screen readers announce the popover content as generic text rather than an interactive menu. Users relying on assistive technology have no way to understand that these are selectable options. 3. **No keyboard navigation within the popover** — even if a user somehow focuses an item, there are no ArrowUp/ArrowDown handlers to move between options, which is the standard interaction pattern for `role="menu"` widgets (WAI-ARIA Menu Pattern). 4. **Decorative icons are not hidden from assistive technology** — icon elements (`<i>` tags) are exposed to screen readers, adding noise to the announcement. 5. **The bottom toolbar (`GraphCanvasMenu`) lacks semantic grouping** — the `ButtonGroup` container has no `role="toolbar"` or `aria-label`, so screen readers cannot convey that these buttons form a related control group. > Note: Pan mode itself already has keyboard shortcuts (`H` for Hand/Lock, `V` for Select/Unlock), but the popover UI that surfaces these options is not keyboard-accessible. ## Solution (TO-BE) 1. **Replace `div` → `button`** for menu items in `CanvasModeSelector` — buttons are natively focusable and activatable via Enter/Space without extra work. 2. **Add `role="menu"` on the container and `role="menuitem"` on each option** — screen readers now announce "Canvas Mode menu" with two menu items. 3. **Add ArrowUp/ArrowDown keyboard navigation** with wrap-around — pressing ArrowDown on "Select" moves focus to "Hand", and vice versa. This follows the WAI-ARIA Menu Pattern. 4. **Add `aria-label` to each menu item and `aria-hidden="true"` to decorative icons** — screen readers announce "Select" / "Hand" clearly without icon noise. 5. **Add `role="toolbar"` with `aria-label="Canvas Toolbar"` to the `ButtonGroup`** — screen readers identify the bottom control bar as a coherent toolbar. ## Changes - **What**: Accessibility improvements to `CanvasModeSelector` popover and `GraphCanvasMenu` toolbar - **Dependencies**: None — only HTML/ARIA attribute changes and two new i18n keys (`canvasMode`, `canvasToolbar`) ## Review Focus - Verify the `button` elements render visually identical to the previous `div` elements (same padding, hover styles, cursor) - Confirm ArrowUp/ArrowDown navigation works correctly in the popover - Check that screen readers announce the menu and toolbar correctly Fixes #9519 > Note: The issue also requests Space-bar hold-to-pan, Tab through node ports, and link creation mode keyboard shortcuts. These are significant new features beyond the scope of this accessibility fix and should be tracked separately. ## Test plan - [x] Unit tests for ARIA roles, button elements, aria-labels, aria-hidden, and arrow key navigation (7 tests) - [ ] Manual: open canvas mode selector popover → Tab focuses first item → ArrowDown/ArrowUp navigates → Enter/Space selects - [ ] Screen reader: verify "Canvas Mode menu" with "Select" and "Hand" menu items are announced ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9526-fix-improve-canvas-menu-keyboard-navigation-and-ARIA-accessibility-31c6d73d3650814c9487ecf748cf6a99) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
7.2 KiB
Vue
240 lines
7.2 KiB
Vue
<template>
|
|
<div>
|
|
<ZoomControlsModal :visible="isModalVisible" @close="hideModal" />
|
|
|
|
<!-- Backdrop -->
|
|
<div
|
|
v-if="hasActivePopup"
|
|
class="fixed inset-0 z-1200"
|
|
@click="hideModal"
|
|
></div>
|
|
|
|
<ButtonGroup
|
|
role="toolbar"
|
|
:aria-label="t('graphCanvasMenu.canvasToolbar')"
|
|
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
|
|
:style="{
|
|
...stringifiedMinimapStyles.buttonGroupStyles
|
|
}"
|
|
@wheel="canvasInteractions.handleWheel"
|
|
>
|
|
<CanvasModeSelector
|
|
:button-styles="stringifiedMinimapStyles.buttonStyles"
|
|
/>
|
|
|
|
<div class="h-[27px] w-px self-center bg-node-divider" />
|
|
|
|
<Button
|
|
v-tooltip.top="fitViewTooltip"
|
|
variant="secondary"
|
|
:aria-label="fitViewTooltip"
|
|
:style="stringifiedMinimapStyles.buttonStyles"
|
|
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
|
|
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
|
>
|
|
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
|
|
</Button>
|
|
|
|
<Button
|
|
v-tooltip.top="t('zoomControls.label')"
|
|
variant="secondary"
|
|
:class="zoomButtonClass"
|
|
:aria-label="t('zoomControls.label')"
|
|
data-testid="zoom-controls-button"
|
|
:style="stringifiedMinimapStyles.buttonStyles"
|
|
@click="toggleModal"
|
|
>
|
|
<span class="inline-flex items-center gap-1 px-2 text-xs">
|
|
<span>{{ canvasStore.appScalePercentage }}%</span>
|
|
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
|
|
</span>
|
|
</Button>
|
|
|
|
<div class="h-[27px] w-px self-center bg-node-divider" />
|
|
|
|
<Button
|
|
v-tooltip.top="minimapTooltip"
|
|
variant="secondary"
|
|
:aria-label="minimapTooltip"
|
|
data-testid="toggle-minimap-button"
|
|
:style="stringifiedMinimapStyles.buttonStyles"
|
|
:class="minimapButtonClass"
|
|
@click="onMinimapToggleClick"
|
|
>
|
|
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
|
|
</Button>
|
|
|
|
<Button
|
|
v-tooltip.top="{
|
|
value: linkVisibilityTooltip,
|
|
pt: {
|
|
root: {
|
|
style: 'z-index: 2; transform: translateY(-20px);'
|
|
}
|
|
}
|
|
}"
|
|
variant="secondary"
|
|
:class="linkVisibleClass"
|
|
:aria-label="linkVisibilityAriaLabel"
|
|
data-testid="toggle-link-visibility-button"
|
|
:style="stringifiedMinimapStyles.buttonStyles"
|
|
@click="onLinkVisibilityToggleClick"
|
|
>
|
|
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
|
|
</Button>
|
|
</ButtonGroup>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import ButtonGroup from 'primevue/buttongroup'
|
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import { useZoomControls } from '@/composables/useZoomControls'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
|
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
|
|
import { useCommandStore } from '@/stores/commandStore'
|
|
|
|
import CanvasModeSelector from './CanvasModeSelector.vue'
|
|
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
|
|
|
|
const { t } = useI18n()
|
|
const commandStore = useCommandStore()
|
|
const { formatKeySequence } = useCommandStore()
|
|
const canvasStore = useCanvasStore()
|
|
const settingStore = useSettingStore()
|
|
const canvasInteractions = useCanvasInteractions()
|
|
const minimap = useMinimap()
|
|
|
|
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
|
|
useZoomControls()
|
|
|
|
const stringifiedMinimapStyles = computed(() => {
|
|
const buttonGroupKeys = ['borderRadius']
|
|
const buttonKeys = ['borderRadius']
|
|
const additionalButtonStyles = {
|
|
border: 'none'
|
|
}
|
|
|
|
const containerStyles = minimap.containerStyles.value
|
|
|
|
const buttonStyles = {
|
|
...Object.fromEntries(
|
|
Object.entries(containerStyles).filter(([key]) =>
|
|
buttonKeys.includes(key)
|
|
)
|
|
),
|
|
...additionalButtonStyles
|
|
}
|
|
const buttonGroupStyles = Object.entries(containerStyles)
|
|
.filter(([key]) => buttonGroupKeys.includes(key))
|
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
|
|
|
|
return { buttonStyles, buttonGroupStyles }
|
|
})
|
|
|
|
// Computed properties for reactive states
|
|
const linkHidden = computed(
|
|
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
|
|
)
|
|
|
|
// Computed properties for command text
|
|
const fitViewCommandText = computed(() =>
|
|
formatKeySequence(
|
|
commandStore.getCommand('Comfy.Canvas.FitView')
|
|
).toUpperCase()
|
|
)
|
|
const minimapCommandText = computed(() =>
|
|
formatKeySequence(
|
|
commandStore.getCommand('Comfy.Canvas.ToggleMinimap')
|
|
).toUpperCase()
|
|
)
|
|
|
|
// Computed properties for button classes and states
|
|
const zoomButtonClass = computed(() => [
|
|
'bg-comfy-menu-bg',
|
|
isModalVisible.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
|
|
'hover:bg-interface-button-hover-surface!',
|
|
'p-0',
|
|
'h-8',
|
|
'w-15'
|
|
])
|
|
|
|
const minimapButtonClass = computed(() => ({
|
|
'bg-comfy-menu-bg': true,
|
|
'hover:bg-interface-button-hover-surface!': true,
|
|
'not-active:bg-interface-panel-selected-surface!': settingStore.get(
|
|
'Comfy.Minimap.Visible'
|
|
),
|
|
'p-0': true,
|
|
'w-8': true,
|
|
'h-8': true
|
|
}))
|
|
|
|
// Computed properties for tooltip and aria-label texts
|
|
const fitViewTooltip = computed(() => {
|
|
const label = t('graphCanvasMenu.fitView')
|
|
const shortcut = fitViewCommandText.value
|
|
return shortcut ? `${label} (${shortcut})` : label
|
|
})
|
|
const minimapTooltip = computed(() => {
|
|
const label = settingStore.get('Comfy.Minimap.Visible')
|
|
? t('zoomControls.hideMinimap')
|
|
: t('zoomControls.showMinimap')
|
|
const shortcut = minimapCommandText.value
|
|
return shortcut ? `${label} (${shortcut})` : label
|
|
})
|
|
const linkVisibilityTooltip = computed(() =>
|
|
linkHidden.value
|
|
? t('graphCanvasMenu.showLinks')
|
|
: t('graphCanvasMenu.hideLinks')
|
|
)
|
|
const linkVisibilityAriaLabel = computed(() =>
|
|
linkHidden.value
|
|
? t('graphCanvasMenu.showLinks')
|
|
: t('graphCanvasMenu.hideLinks')
|
|
)
|
|
const linkVisibleClass = computed(() => [
|
|
'bg-comfy-menu-bg',
|
|
linkHidden.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
|
|
'hover:bg-interface-button-hover-surface!',
|
|
'p-0',
|
|
'w-8',
|
|
'h-8'
|
|
])
|
|
|
|
onMounted(() => {
|
|
canvasStore.initScaleSync()
|
|
})
|
|
|
|
/**
|
|
* Track minimap toggle button click and execute the command.
|
|
*/
|
|
const onMinimapToggleClick = () => {
|
|
useTelemetry()?.trackUiButtonClicked({
|
|
button_id: 'graph_menu_minimap_toggle_clicked'
|
|
})
|
|
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
|
|
}
|
|
|
|
/**
|
|
* Track hide/show links button click and execute the command.
|
|
*/
|
|
const onLinkVisibilityToggleClick = () => {
|
|
useTelemetry()?.trackUiButtonClicked({
|
|
button_id: 'graph_menu_hide_links_toggle_clicked'
|
|
})
|
|
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
canvasStore.cleanupScaleSync()
|
|
})
|
|
</script>
|