feat: add opacity slider to zone color pickers (fixes #72)
The native <input type="color"> only supports 6-digit hex, stripping alpha and forcing background/border/text colors to be fully opaque on edit. Each color field now shows an opacity slider (0–100%) below the swatch. Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit colors are handled transparently (alpha defaults to 100%). - colorUtils.ts: hexToRgba / rgbaToHex8 helpers - GroupRectModal: opacity sliders for all three color fields - 26 new tests across colorUtils and GroupRectModal
This commit is contained in:
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { TextPosition } from '@/types'
|
||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
@@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorFields.map(({ key, label }) => (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
{colorFields.map(({ key, label }) => {
|
||||
const { hex6, alpha } = hexToRgba(form[key])
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={hex6}
|
||||
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={form[key]}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={alpha}
|
||||
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||
title={`Opacity: ${alpha}%`}
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_style).toBe('solid')
|
||||
})
|
||||
|
||||
it('shows opacity sliders for all three color fields', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
const sliders = screen.getAllByRole('slider')
|
||||
expect(sliders).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('default background_color is 8-digit hex with low alpha', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.background_color).toBe('#00d4ff0d')
|
||||
expect(submitted.background_color.length).toBe(9)
|
||||
})
|
||||
|
||||
it('moving background opacity slider updates background_color alpha', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
// background slider is the third one (Text, Border, Background)
|
||||
const sliders = screen.getAllByRole('slider')
|
||||
fireEvent.change(sliders[2], { target: { value: '50' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
// alpha 50% → 0x80 = 128
|
||||
expect(submitted.background_color).toBe('#00d4ff80')
|
||||
})
|
||||
|
||||
it('moving border opacity slider to 0 makes border fully transparent', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
const sliders = screen.getAllByRole('slider')
|
||||
fireEvent.change(sliders[1], { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_color).toBe('#00d4ff00')
|
||||
})
|
||||
|
||||
it('pre-fills opacity from 8-digit initial background_color', () => {
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ background_color: '#ff6e0080' }}
|
||||
/>
|
||||
)
|
||||
const sliders = screen.getAllByRole('slider')
|
||||
expect((sliders[2] as HTMLInputElement).value).toBe('50')
|
||||
})
|
||||
|
||||
it('shows opacity percentage in label', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
// Background default is 5% opacity
|
||||
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
|
||||
|
||||
describe('hexToRgba', () => {
|
||||
it('splits 8-digit hex into hex6 and alpha', () => {
|
||||
const { hex6, alpha } = hexToRgba('#00d4ff0d')
|
||||
expect(hex6).toBe('#00d4ff')
|
||||
expect(alpha).toBe(5)
|
||||
})
|
||||
|
||||
it('handles fully opaque 8-digit hex (ff)', () => {
|
||||
const { hex6, alpha } = hexToRgba('#00d4ffff')
|
||||
expect(hex6).toBe('#00d4ff')
|
||||
expect(alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('handles fully transparent 8-digit hex (00)', () => {
|
||||
const { hex6, alpha } = hexToRgba('#00d4ff00')
|
||||
expect(hex6).toBe('#00d4ff')
|
||||
expect(alpha).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults alpha to 100 for 6-digit hex', () => {
|
||||
const { hex6, alpha } = hexToRgba('#00d4ff')
|
||||
expect(hex6).toBe('#00d4ff')
|
||||
expect(alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('handles 6-digit hex without leading #', () => {
|
||||
const { hex6, alpha } = hexToRgba('ff6e00')
|
||||
expect(hex6).toBe('#ff6e00')
|
||||
expect(alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('handles 8-digit hex without leading #', () => {
|
||||
const { hex6, alpha } = hexToRgba('ff6e0080')
|
||||
expect(hex6).toBe('#ff6e00')
|
||||
expect(alpha).toBe(50)
|
||||
})
|
||||
|
||||
it('returns fallback for invalid input', () => {
|
||||
const { hex6, alpha } = hexToRgba('invalid')
|
||||
expect(hex6).toBe('#000000')
|
||||
expect(alpha).toBe(100)
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const { hex6 } = hexToRgba('#00D4FF0D')
|
||||
expect(hex6).toBe('#00D4FF')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rgbaToHex8', () => {
|
||||
it('combines hex6 and alpha into 8-digit hex', () => {
|
||||
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
|
||||
})
|
||||
|
||||
it('produces ff for alpha 100', () => {
|
||||
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
|
||||
})
|
||||
|
||||
it('produces 00 for alpha 0', () => {
|
||||
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
|
||||
})
|
||||
|
||||
it('produces 80 for alpha 50', () => {
|
||||
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
|
||||
})
|
||||
|
||||
it('clamps alpha below 0 to 0', () => {
|
||||
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
|
||||
})
|
||||
|
||||
it('clamps alpha above 100 to 100', () => {
|
||||
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
|
||||
})
|
||||
|
||||
it('pads single-digit alpha hex with leading zero', () => {
|
||||
const result = rgbaToHex8('#000000', 1)
|
||||
const alphaPart = result.slice(7)
|
||||
expect(alphaPart.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
|
||||
const original = '#00d4ff0d'
|
||||
const { hex6, alpha } = hexToRgba(original)
|
||||
expect(rgbaToHex8(hex6, alpha)).toBe(original)
|
||||
})
|
||||
|
||||
it('round-trips fully opaque color', () => {
|
||||
const original = '#a855f7ff'
|
||||
const { hex6, alpha } = hexToRgba(original)
|
||||
expect(rgbaToHex8(hex6, alpha)).toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100).
|
||||
* 6-digit input returns alpha 100.
|
||||
* Invalid input returns { hex6: '#000000', alpha: 100 }.
|
||||
*/
|
||||
export function hexToRgba(hex: string): { hex6: string; alpha: number } {
|
||||
const clean = hex.replace('#', '')
|
||||
if (clean.length === 8) {
|
||||
const alphaByte = parseInt(clean.slice(6, 8), 16)
|
||||
return {
|
||||
hex6: `#${clean.slice(0, 6)}`,
|
||||
alpha: Math.round((alphaByte / 255) * 100),
|
||||
}
|
||||
}
|
||||
if (clean.length === 6) {
|
||||
return { hex6: `#${clean}`, alpha: 100 }
|
||||
}
|
||||
return { hex6: '#000000', alpha: 100 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a 6-digit hex color and an alpha (0–100) into an 8-digit hex.
|
||||
*/
|
||||
export function rgbaToHex8(hex6: string, alpha: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, alpha))
|
||||
const alphaByte = Math.round((clamped / 100) * 255)
|
||||
const alphaHex = alphaByte.toString(16).padStart(2, '0')
|
||||
return `${hex6}${alphaHex}`
|
||||
}
|
||||
Reference in New Issue
Block a user