Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
5a6531101c fix: a11y: Add keyboard accessibility to custom ColorPicker slider and saturation/value controls (#9650)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:09:40 +01:00
5 changed files with 239 additions and 241 deletions

View File

@@ -46,6 +46,9 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in stacked PR (#9647)
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
'src/components/ui/color-picker/ColorPickerSlider.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
],

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const { t } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
function handleKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1
let handled = true
switch (e.key) {
case 'ArrowLeft':
saturation.value = clamp(saturation.value - step, 0, 100)
break
case 'ArrowRight':
saturation.value = clamp(saturation.value + step, 0, 100)
break
case 'ArrowUp':
value.value = clamp(value.value + step, 0, 100)
break
case 'ArrowDown':
value.value = clamp(value.value - step, 0, 100)
break
case 'Home':
saturation.value = 0
value.value = 0
break
case 'End':
saturation.value = 100
value.value = 100
break
default:
handled = false
}
if (handled) e.preventDefault()
}
</script>
<template>
<div
ref="containerRef"
role="group"
tabindex="0"
:aria-label="t('colorPicker.saturationAndBrightness')"
class="relative aspect-square w-full cursor-crosshair rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-highlight"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@keydown="handleKeydown"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const { t } = useI18n()
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
const ariaLabel = computed(() =>
type === 'hue' ? t('colorPicker.hue') : t('colorPicker.alpha')
)
const ariaValueText = computed(() =>
type === 'hue' ? `${modelValue.value}°` : `${modelValue.value}%`
)
function clamp(v: number, min: number, maxVal: number) {
return Math.max(min, Math.min(maxVal, v))
}
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
function handleKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1
let handled = true
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
modelValue.value = clamp(modelValue.value - step, 0, max.value)
break
case 'ArrowRight':
case 'ArrowUp':
modelValue.value = clamp(modelValue.value + step, 0, max.value)
break
case 'Home':
modelValue.value = 0
break
case 'End':
modelValue.value = max.value
break
default:
handled = false
}
if (handled) e.preventDefault()
}
</script>
<template>
<div
role="slider"
tabindex="0"
:aria-label="ariaLabel"
:aria-valuenow="modelValue"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuetext="ariaValueText"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px outline-none focus-visible:ring-2 focus-visible:ring-highlight"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@keydown="handleKeydown"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -468,6 +468,11 @@
"issueReport": {
"helpFix": "Help Fix This"
},
"colorPicker": {
"hue": "Hue",
"alpha": "Alpha",
"saturationAndBrightness": "Color saturation and brightness"
},
"color": {
"noColor": "No Color",
"default": "Default",

View File

@@ -1,241 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import { ChangeTracker } from './changeTracker'
vi.mock('@/scripts/app', () => ({
app: {
graph: {},
rootGraph: {
extra: {},
nodes: [{ id: 1 }],
serialize: vi.fn()
}
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
})
}))
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => ({
show: vi.fn()
})
}))
vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
...(await importOriginal()),
resolveNode: vi.fn(() => undefined)
}))
import { app } from '@/scripts/app'
function createWorkflowJSON(
nodeIds: number[],
extra: Record<string, unknown> = {}
): ComfyWorkflowJSON {
return {
last_node_id: nodeIds.length,
last_link_id: 0,
nodes: nodeIds.map((id) => ({
id,
type: 'TestNode',
pos: [0, 0],
size: [100, 100],
flags: {},
order: id,
mode: 0,
outputs: [],
inputs: [],
properties: {}
})),
links: [],
groups: [],
config: {},
version: 0.4,
extra
} as ComfyWorkflowJSON
}
function createLoadedWorkflow(
path: string,
state: ComfyWorkflowJSON
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflow({
path,
modified: Date.now(),
size: 100
})
workflow.changeTracker = new ChangeTracker(workflow, state)
workflow.content = JSON.stringify(state)
workflow.originalContent = JSON.stringify(state)
return workflow as LoadedComfyWorkflow
}
describe('ChangeTracker.isLoadingGraph guard', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
workflowStore = useWorkflowStore()
ChangeTracker.isLoadingGraph = false
vi.mocked(app.rootGraph).extra = {}
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
})
afterEach(() => {
ChangeTracker.isLoadingGraph = false
})
describe('checkState short-circuit', () => {
it('skips checkState when isLoadingGraph is true', () => {
const workflowA = createWorkflowJSON([1])
const workflowB = createWorkflowJSON([2, 3])
const workflow = createLoadedWorkflow('workflows/test.json', workflowA)
const tracker = workflow.changeTracker
// Simulate rootGraph containing a different workflow's data
vi.mocked(app.rootGraph.serialize).mockReturnValue(
workflowB as unknown as ISerialisedGraph
)
// With guard enabled, checkState should not capture the wrong state
ChangeTracker.isLoadingGraph = true
tracker.checkState()
expect(tracker.activeState).toEqual(workflowA)
expect(tracker.undoQueue).toHaveLength(0)
})
it('captures state when isLoadingGraph is false', () => {
const workflowA = createWorkflowJSON([1])
const workflowB = createWorkflowJSON([2, 3])
const workflow = createLoadedWorkflow('workflows/test.json', workflowA)
const tracker = workflow.changeTracker
vi.mocked(app.rootGraph.serialize).mockReturnValue(
workflowB as unknown as ISerialisedGraph
)
// Without guard, checkState should capture the new state
ChangeTracker.isLoadingGraph = false
tracker.checkState()
expect(tracker.activeState).toEqual(workflowB)
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.undoQueue[0]).toEqual(workflowA)
})
it('prevents cross-workflow corruption during tab switch', () => {
const stateA = createWorkflowJSON([1], { name: 'workflowA' })
const stateB = createWorkflowJSON([2, 3], { name: 'workflowB' })
const workflowA = createLoadedWorkflow('workflows/a.json', stateA)
// Simulate the corruption scenario:
// 1. workflowA is the active workflow in the store
workflowStore.activeWorkflow = workflowA
// 2. During loadGraphData, rootGraph is configured with workflowB's data
// but activeWorkflow still points to workflowA
ChangeTracker.isLoadingGraph = true
vi.mocked(app.rootGraph.serialize).mockReturnValue(
stateB as unknown as ISerialisedGraph
)
// 3. An extension calls checkState during onAfterGraphConfigured
workflowA.changeTracker.checkState()
// 4. With the guard, workflowA's state should NOT be corrupted
expect(workflowA.changeTracker.activeState).toEqual(stateA)
expect(workflowA.changeTracker.undoQueue).toHaveLength(0)
// 5. After loading completes, the guard is lifted
ChangeTracker.isLoadingGraph = false
})
it('allows corruption when guard is bypassed (regression confirmation)', () => {
const stateA = createWorkflowJSON([1], { name: 'workflowA' })
const stateB = createWorkflowJSON([2, 3], { name: 'workflowB' })
const workflowA = createLoadedWorkflow('workflows/a.json', stateA)
workflowStore.activeWorkflow = workflowA
// Without the guard, checkState writes workflowB data into workflowA
ChangeTracker.isLoadingGraph = false
vi.mocked(app.rootGraph.serialize).mockReturnValue(
stateB as unknown as ISerialisedGraph
)
workflowA.changeTracker.checkState()
// workflowA's activeState is now corrupted with workflowB's data
expect(workflowA.changeTracker.activeState).toEqual(stateB)
})
})
describe('appModeStore linearData sync guard', () => {
it('does not sync linearData when isLoadingGraph is true', async () => {
// Dynamically import appModeStore after mocks are set up
const { useAppModeStore } = await import('@/stores/appModeStore')
const store = useAppModeStore()
const workflow = createLoadedWorkflow(
'workflows/test.json',
createWorkflowJSON([1])
)
workflow.activeMode = 'builder:inputs'
workflowStore.activeWorkflow = workflow
await nextTick()
// Set the guard
ChangeTracker.isLoadingGraph = true
vi.mocked(app.rootGraph).extra = {}
// Modify selections — the watcher should skip writing to graph.extra
store.selectedOutputs.push(1)
await nextTick()
expect(app.rootGraph.extra.linearData).toBeUndefined()
})
it('syncs linearData when isLoadingGraph is false', async () => {
const { useAppModeStore } = await import('@/stores/appModeStore')
const store = useAppModeStore()
const workflow = createLoadedWorkflow(
'workflows/test.json',
createWorkflowJSON([1])
)
workflow.activeMode = 'builder:inputs'
workflowStore.activeWorkflow = workflow
await nextTick()
ChangeTracker.isLoadingGraph = false
vi.mocked(app.rootGraph).extra = {}
store.selectedOutputs.push(1)
await nextTick()
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [],
outputs: [1]
})
})
})
})