Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
caf2c03c6a FE-1261 feat: editable hex/RGB/alpha inputs in color picker 2026-07-15 21:01:03 -04:00
6 changed files with 389 additions and 39 deletions

View File

@@ -0,0 +1,141 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ColorPickerPanel from '@/components/ui/color-picker/ColorPickerPanel.vue'
import type { HSVA } from '@/utils/colorUtil'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
color: {
hex: 'Hex',
rgba: 'RGBA',
hue: 'Hue',
alpha: 'Alpha',
red: 'Red',
green: 'Green',
blue: 'Blue',
saturationBrightness: 'Saturation and brightness'
}
}
}
})
function renderPanel(initial: HSVA, mode: 'hex' | 'rgba' = 'hex') {
const hsva = ref<HSVA>(initial)
const displayMode = ref<'hex' | 'rgba'>(mode)
const Host = defineComponent(
() => () =>
h(ColorPickerPanel, {
hsva: hsva.value,
'onUpdate:hsva': (value: HSVA) => {
hsva.value = value
},
displayMode: displayMode.value,
'onUpdate:displayMode': (value: 'hex' | 'rgba') => {
displayMode.value = value
},
alpha: true
})
)
render(Host, { global: { plugins: [i18n] } })
return { hsva, user: userEvent.setup() }
}
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
describe('ColorPickerPanel hex input', () => {
it('updates the hsva model when a valid 6-digit hex is typed', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'ff0000')
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('accepts 3-digit shorthand hex', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, '0f0')
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
})
it('preserves the existing alpha when editing hex', async () => {
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 40 })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'ffffff')
expect(hsva.value.a).toBe(40)
})
it('ignores invalid input and leaves the model unchanged', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'nothex')
expect(hsva.value).toEqual(black)
})
it('reformats the draft to canonical hex on blur', async () => {
const { user } = renderPanel({ ...black })
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, '0f0')
await user.tab()
expect(input.value).toBe('#00ff00')
})
})
describe('ColorPickerPanel rgba inputs', () => {
it('updates the hsva model when an RGB channel is edited', async () => {
const { hsva, user } = renderPanel({ ...black }, 'rgba')
const red = screen.getByRole('spinbutton', { name: 'Red' })
await user.clear(red)
await user.type(red, '255')
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('clamps an out-of-range channel to 255 on blur', async () => {
const { user } = renderPanel({ ...black }, 'rgba')
const red = screen.getByRole<HTMLInputElement>('spinbutton', {
name: 'Red'
})
await user.clear(red)
await user.type(red, '300')
await user.tab()
expect(red.value).toBe('255')
})
it('edits alpha as a percentage', async () => {
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 100 })
const alpha = screen.getByRole('spinbutton', { name: 'Alpha' })
await user.clear(alpha)
await user.type(alpha, '50')
expect(hsva.value.a).toBe(50)
})
})

View File

@@ -1,33 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
@@ -66,19 +36,79 @@ const { t } = useI18n()
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
<input
v-model="hex.draft"
type="text"
spellcheck="false"
:aria-label="t('color.hex')"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none"
@focus="hex.beginEdit"
@input="hex.commit"
@keydown.enter="hex.commit"
@blur="hex.reset"
/>
</template>
<template v-else>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
<input
v-for="channel in rgbChannels"
:key="channel.key"
v-model.number="rgb.draft[channel.key]"
type="number"
:min="0"
:max="255"
:aria-label="t(channel.label)"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none [&::-webkit-inner-spin-button]:appearance-none"
@focus="rgb.beginEdit"
@input="rgb.commit"
@keydown.enter="rgb.commit"
@blur="rgb.reset"
/>
</template>
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
<div
v-if="alpha"
class="flex shrink-0 items-center border-l border-border-subtle pl-1"
>
<input
v-model.number="alphaField.draft"
type="number"
:min="0"
:max="100"
:aria-label="t('color.alpha')"
class="w-6 min-w-0 appearance-none border-none bg-transparent p-0 text-right outline-none [&::-webkit-inner-spin-button]:appearance-none"
@focus="alphaField.beginEdit"
@input="alphaField.commit"
@keydown.enter="alphaField.commit"
@blur="alphaField.reset"
/>
<span>%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
import { rgbChannels, useColorPicker } from './useColorPicker'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const { t } = useI18n()
const { hex, rgb, alpha: alphaField } = useColorPicker(hsva)
</script>

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { useColorPicker } from './useColorPicker'
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
function setup(initial: HSVA) {
const hsva = ref<HSVA>(initial)
const scope = effectScope()
const api = scope.run(() => useColorPicker(hsva))!
return { hsva, api, stop: () => scope.stop() }
}
describe('useColorPicker', () => {
it('commits a valid hex and ignores malformed input', () => {
const { hsva, api } = setup({ ...black })
api.hex.draft = '#00ff00'
api.hex.commit()
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
const unchanged = hsva.value
api.hex.draft = 'zzz'
api.hex.commit()
expect(hsva.value).toBe(unchanged)
})
it('clamps rgb channels before converting to hsv', () => {
const { hsva, api } = setup({ ...black })
api.rgb.draft.r = 300
api.rgb.commit()
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('clamps alpha to the 0..100 range', () => {
const { hsva, api } = setup({ h: 0, s: 0, v: 0, a: 100 })
api.alpha.draft = 150
api.alpha.commit()
expect(hsva.value.a).toBe(100)
})
it('syncs the draft from the model when not editing', async () => {
const { hsva, api } = setup({ ...black })
hsva.value = { ...hsva.value, h: 0, s: 100, v: 100 }
await nextTick()
expect(api.hex.draft).toBe('#ff0000')
})
it('freezes the draft against external model changes while editing', async () => {
const { hsva, api } = setup({ ...black })
api.hex.beginEdit()
api.hex.draft = '#123456'
hsva.value = { h: 0, s: 100, v: 100, a: 100 }
await nextTick()
expect(api.hex.draft).toBe('#123456')
})
it('resyncs the draft to the model on reset', () => {
const { api } = setup({ ...black })
api.hex.beginEdit()
api.hex.draft = '#abcdef'
api.hex.reset()
expect(api.hex.draft).toBe('#000000')
})
})

View File

@@ -0,0 +1,79 @@
import { clamp } from 'es-toolkit'
import type { Ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import {
hexToHsva,
hsbToRgb,
normalizeHex,
rgbToHex,
rgbToHsv
} from '@/utils/colorUtil'
export const rgbChannels = [
{ key: 'r', label: 'color.red' },
{ key: 'g', label: 'color.green' },
{ key: 'b', label: 'color.blue' }
] as const
function useDraftField<T>(source: () => T, apply: (draft: T) => void) {
const draft = ref(source()) as Ref<T>
const isEditing = ref(false)
watch(source, (value) => {
if (!isEditing.value) draft.value = value
})
return reactive({
draft,
beginEdit: () => {
isEditing.value = true
},
commit: () => apply(draft.value),
reset: () => {
isEditing.value = false
draft.value = source()
}
})
}
export function useColorPicker(hsva: Ref<HSVA>) {
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const hex = useDraftField(
() => hexString.value,
(draft) => {
const normalized = normalizeHex(draft)
if (!normalized) return
const next = hexToHsva(normalized)
hsva.value = { ...hsva.value, h: next.h, s: next.s, v: next.v }
}
)
const rgbField = useDraftField(
() => ({ ...rgb.value }),
({ r, g, b }) => {
if (![r, g, b].every(Number.isFinite)) return
const hsv = rgbToHsv({
r: clamp(Math.round(r), 0, 255),
g: clamp(Math.round(g), 0, 255),
b: clamp(Math.round(b), 0, 255)
})
hsva.value = { ...hsva.value, h: hsv.h, s: hsv.s, v: hsv.v }
}
)
const alpha = useDraftField(
() => hsva.value.a,
(draft) => {
if (!Number.isFinite(draft)) return
hsva.value = { ...hsva.value, a: clamp(Math.round(draft), 0, 100) }
}
)
return { hex, rgb: rgbField, alpha }
}

View File

@@ -10,6 +10,7 @@ import {
hsvaToHex,
isTransparent,
luminance,
normalizeHex,
parseToRgb,
readableTextColor,
rgbToHex,
@@ -101,6 +102,22 @@ describe('colorUtil conversions', () => {
})
})
describe('normalizeHex', () => {
it('canonicalizes 3- and 6-digit hex with or without a leading #', () => {
expect(normalizeHex('#ff0000')).toBe('#ff0000')
expect(normalizeHex('ff0000')).toBe('#ff0000')
expect(normalizeHex('#0f0')).toBe('#0f0')
expect(normalizeHex(' 0f0 ')).toBe('#0f0')
})
it('rejects malformed input', () => {
expect(normalizeHex('nothex')).toBeNull()
expect(normalizeHex('#ff00')).toBeNull()
expect(normalizeHex('#ff00000')).toBeNull()
expect(normalizeHex('')).toBeNull()
})
})
describe('hexToInt', () => {
it('converts 6-digit hex to packed integer', () => {
expect(hexToInt('#ff0000')).toBe(0xff0000)

View File

@@ -366,7 +366,7 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
}
}
function rgbToHsv({ r, g, b }: RGB): {
export function rgbToHsv({ r, g, b }: RGB): {
h: number
s: number
v: number
@@ -397,6 +397,11 @@ function rgbToHsv({ r, g, b }: RGB): {
return { h, s, v }
}
export function normalizeHex(value: string): string | null {
const digits = value.trim().replace(/^#/, '')
return /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(digits) ? `#${digits}` : null
}
export function hexToHsva(hex: string): HSVA {
const normalized = hex.startsWith('#') ? hex : `#${hex}`
let a = 100