feat: replace PrimeVue ColorPicker with custom component (#9647)

## Summary
- Replace PrimeVue `ColorPicker` with a custom component built on Reka
UI Popover
- New `ColorPicker` supports HSV saturation-value picking, hue/alpha
sliders, hex/rgba display toggle
- Simplify `WidgetColorPicker` by removing PrimeVue-specific
normalization logic
- Add Storybook stories for both `ColorPicker` and `WidgetColorPicker`

## Test plan
- [x] Unit tests pass (9 widget tests, 47 colorUtil tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify color picker visually in Storybook
- [ ] Test color picking in node widgets with hex/rgb/hsb formats

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Dante
2026-03-13 12:45:10 +09:00
committed by GitHub
parent bfabf128ce
commit c318cc4c14
11 changed files with 780 additions and 254 deletions

View File

@@ -0,0 +1,137 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { computed, ref, toRefs } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ColorFormat } from '@/utils/colorUtil'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import WidgetColorPicker from './WidgetColorPicker.vue'
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetColorPicker> {
format: ColorFormat
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetColorPicker',
component: WidgetColorPicker,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
format: {
control: 'select',
options: ['hex', 'rgb', 'hsb']
}
},
args: {
format: 'hex'
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="grid grid-cols-[auto_1fr] gap-1 w-80"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#E06CBD')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const RGBFormat: Story = {
args: { format: 'rgb' },
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#3498DB')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const HSBFormat: Story = {
args: { format: 'hsb' },
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#2ECC71')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const CustomColor: Story = {
render: (args) => ({
components: { WidgetColorPicker },
setup() {
const { format } = toRefs(args)
const value = ref('#FF5733')
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
name: 'accent_color',
type: 'STRING',
value: '',
options: { format: format.value }
}))
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}
export const WithLabel: Story = {
render: () => ({
components: { WidgetColorPicker },
setup() {
const value = ref('#9B59B6')
const widget: SimplifiedWidget<string, WidgetOptions> = {
name: 'background',
type: 'STRING',
value: '',
label: 'Background Color',
options: { format: 'hex' }
}
return { value, widget }
},
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
})
}

View File

@@ -1,11 +1,10 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import type { ColorPickerProps } from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import WidgetColorPicker from './WidgetColorPicker.vue'
import { createMockWidget } from './widgetTestUtils'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -13,7 +12,7 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
describe('WidgetColorPicker Value Binding', () => {
const createColorWidget = (
value: string = '#000000',
options: Partial<ColorPickerProps> = {},
options: Record<string, unknown> = {},
callback?: (value: string) => void
) =>
createMockWidget<string>({
@@ -26,12 +25,10 @@ describe('WidgetColorPicker Value Binding', () => {
const mountComponent = (
widget: SimplifiedWidget<string>,
modelValue: string,
readonly = false
modelValue: string
) => {
return mount(WidgetColorPicker, {
global: {
plugins: [PrimeVue],
components: {
ColorPicker,
WidgetLayoutField
@@ -39,93 +36,35 @@ describe('WidgetColorPicker Value Binding', () => {
},
props: {
widget,
modelValue,
readonly
modelValue
}
})
}
const setColorPickerValue = async (
wrapper: ReturnType<typeof mount>,
value: unknown
) => {
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
await colorPicker.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when color changes', async () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const emitted = await setColorPickerValue(wrapper, '#00ff00')
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.vm.$emit('update:modelValue', '#00ff00')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('handles different color formats', async () => {
const widget = createColorWidget('#ffffff')
const wrapper = mountComponent(widget, '#ffffff')
const emitted = await setColorPickerValue(wrapper, '#123abc')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#123abc')
})
it('handles missing callback gracefully', async () => {
const widget = createColorWidget('#000000', {}, undefined)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.vm.$emit('update:modelValue', '#ff00ff')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff00ff')
})
it('normalizes bare hex without # to #hex on emit', async () => {
const widget = createColorWidget('ff0000')
const wrapper = mountComponent(widget, 'ff0000')
const emitted = await setColorPickerValue(wrapper, '00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async (context) => {
context.skip('needs diagnosis')
const widget = createColorWidget('#000000')
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff0000')
})
it('normalizes hsb() strings to #hex on emit', async () => {
const widget = createColorWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
})
it('normalizes HSB object values to #hex on emit', async () => {
const widget = createColorWidget('#000000', { format: 'hsb' })
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, {
h: 240,
s: 100,
b: 100
})
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#0000ff')
})
})
describe('Component Rendering', () => {
@@ -133,110 +72,37 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
it('normalizes display to a single leading #', () => {
// Case 1: model value already includes '#'
let widget = createColorWidget('#ff0000')
let wrapper = mountComponent(widget, '#ff0000')
let colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
// Case 2: model value missing '#'
widget = createColorWidget('ff0000')
wrapper = mountComponent(widget, 'ff0000')
colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe('#ff0000')
})
it('renders layout field wrapper', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
expect(layoutField.exists()).toBe(true)
})
it('displays current color value as text', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#ff0000')
})
it('updates color text when value changes', async () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
await setColorPickerValue(wrapper, '#00ff00')
// Need to check the local state update
const colorText = wrapper.find('[data-testid="widget-color-text"]')
// Be specific about the displayed value including the leading '#'
expect.soft(colorText.text()).toBe('#00ff00')
})
it('uses default color when no value provided', () => {
const widget = createColorWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
// Should use the default value from the composable
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
})
describe('Color Formats', () => {
it('handles valid hex colors', async () => {
const validHexColors = [
'#000000',
'#ffffff',
'#ff0000',
'#00ff00',
'#0000ff',
'#123abc'
]
for (const color of validHexColors) {
const widget = createColorWidget(color)
const wrapper = mountComponent(widget, color)
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect.soft(colorText.text()).toBe(color)
}
})
it('handles short hex colors', () => {
const widget = createColorWidget('#fff')
const wrapper = mountComponent(widget, '#fff')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#fff')
})
it('passes widget options to color picker', () => {
const colorOptions = {
format: 'hex' as const,
inline: true
}
const widget = createColorWidget('#ff0000', colorOptions)
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
expect(colorPicker.props('format')).toBe('hex')
expect(colorPicker.props('inline')).toBe(true)
})
})
describe('Widget Layout Integration', () => {
it('passes widget to layout field', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
expect(layoutField.props('widget')).toEqual(widget)
})
@@ -244,16 +110,13 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
// Should have layout field containing label with color picker and text
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
const label = wrapper.find('label')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorText = wrapper.find('span')
const layoutField = wrapper.findComponent({
name: 'WidgetLayoutField'
})
const colorPicker = wrapper.findComponent(ColorPicker)
expect(layoutField.exists()).toBe(true)
expect(label.exists()).toBe(true)
expect(colorPicker.exists()).toBe(true)
expect(colorText.exists()).toBe(true)
})
})
@@ -262,27 +125,15 @@ describe('WidgetColorPicker Value Binding', () => {
const widget = createColorWidget('')
const wrapper = mountComponent(widget, '')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
it('handles invalid color formats gracefully', async () => {
const widget = createColorWidget('invalid-color')
const wrapper = mountComponent(widget, 'invalid-color')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#000000')
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#000000')
})
it('handles widget with no options', () => {
const widget = createColorWidget('#ff0000')
const wrapper = mountComponent(widget, '#ff0000')
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.exists()).toBe(true)
})
})

View File

@@ -1,90 +1,42 @@
<!-- Needs custom color picker for alpha support -->
<template>
<WidgetLayoutField :widget="widget">
<label
:class="
cn(WidgetInputBaseClass, 'flex w-full items-center gap-2 px-4 py-2')
"
>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
class="h-4 w-8 overflow-hidden rounded-full! border-none"
:aria-label="widget.name"
:pt="{
preview: '!w-full !h-full !border-none'
}"
@update:model-value="onPickerUpdate"
/>
<span
class="min-w-[4ch] truncate text-xs"
data-testid="widget-color-text"
>{{ toHexFromFormat(localValue, format) }}</span
>
</label>
<ColorPicker v-model="localValue" @update:model-value="onUpdate" />
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
import type { ColorFormat, HSB } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import type { ColorFormat } from '@/utils/colorUtil'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { WidgetInputBaseClass } from './layout'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
const props = defineProps<{
const { widget } = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = defineModel<string>({ required: true })
const format = computed<ColorFormat>(() => {
const optionFormat = props.widget.options?.format
return isColorFormat(optionFormat) ? optionFormat : 'hex'
const format = isColorFormat(widget.options?.format)
? widget.options.format
: 'hex'
const localValue = ref(toHexFromFormat(modelValue.value || '#000000', format))
watch(modelValue, (newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format)
})
type PickerValue = string | HSB
const localValue = ref<PickerValue>(
toHexFromFormat(
props.modelValue || '#000000',
isColorFormat(props.widget.options?.format)
? props.widget.options.format
: 'hex'
)
)
watch(
() => props.modelValue,
(newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
}
)
function onPickerUpdate(val: unknown) {
localValue.value = val as PickerValue
emit('update:modelValue', toHexFromFormat(val, format.value))
function onUpdate(val: string) {
localValue.value = val
modelValue.value = val
}
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>