feat(web): добавить ReUI color picker в форму проекта
Docker / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 20:04:56 +07:00
co-authored by Cursor
parent f1fbd637a7
commit 18072fdeea
2 changed files with 248 additions and 3 deletions
@@ -1,6 +1,9 @@
import { Controller } from 'react-hook-form'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { ColorPicker } from '@/components/reui/color-picker'
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
const EMPTY: ProjectFormValues = { name: '', color: '' }
@@ -39,14 +42,36 @@ export function ProjectEditSheet({
submitting={submitting}
>
{(form) => {
const { register, formState: { errors } } = form
const {
register,
control,
formState: { errors },
} = form
return (
<>
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Цвет (hex)" htmlFor="project-color" description="Например #3b82f6 — для badge в списке VPS">
<Input id="project-color" placeholder="#3b82f6" {...register('color')} />
<FormField
label="Цвет (hex)"
htmlFor="project-color"
description="Например #3b82f6 — для badge в списке VPS"
error={errors.color?.message}
invalid={!!errors.color}
>
<Controller
control={control}
name="color"
render={({ field }) => (
<ColorPicker
id="project-color"
value={field.value ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={!!errors.color}
/>
)}
/>
</FormField>
</>
)
@@ -0,0 +1,220 @@
import { useId, useMemo, useState } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@cfdm/ui/lib/utils'
import { Input } from '@cfdm/ui/components/input'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
const PROJECT_COLOR_PRESETS = [
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#ec4899',
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#64748b',
'#18181b',
] as const
const colorPickerGroupVariants = cva(
'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
{
variants: {
size: {
sm: 'h-7 text-sm',
default: 'h-8 text-sm',
lg: 'h-9 text-sm',
},
},
defaultVariants: {
size: 'default',
},
},
)
const colorPickerSwatchVariants = cva(
'flex shrink-0 cursor-pointer items-center justify-center border-e border-input transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50',
{
variants: {
size: {
sm: 'w-9',
default: 'w-10',
lg: 'w-11',
},
},
defaultVariants: {
size: 'default',
},
},
)
const colorPickerInputVariants = cva(
'min-w-0 flex-1 border-0 bg-transparent shadow-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent',
{
variants: {
size: {
sm: 'h-7 rounded-e-lg px-2',
default: 'h-8 rounded-e-lg px-2.5',
lg: 'h-9 rounded-e-lg px-2.5',
},
},
defaultVariants: {
size: 'default',
},
},
)
function isValidHex(value: string): boolean {
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value.trim())
}
function normalizeHex(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
if (/^#[0-9A-Fa-f]{3}$/.test(withHash)) {
const [, r, g, b] = withHash
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
}
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) {
return withHash.toLowerCase()
}
return withHash
}
function toPickerValue(value: string): string {
const normalized = normalizeHex(value)
return isValidHex(normalized) ? normalized : '#3b82f6'
}
interface ColorPickerProps extends VariantProps<typeof colorPickerGroupVariants> {
id?: string
value?: string
onChange?: (value: string) => void
onBlur?: () => void
disabled?: boolean
placeholder?: string
'aria-invalid'?: boolean
className?: string
}
function ColorPicker({
id,
value = '',
onChange,
onBlur,
disabled,
placeholder = '#3b82f6',
size = 'default',
className,
'aria-invalid': ariaInvalid,
}: ColorPickerProps) {
const generatedId = useId()
const inputId = id ?? generatedId
const [open, setOpen] = useState(false)
const sizeValue = size ?? 'default'
const displayValue = value ?? ''
const swatchColor = useMemo(() => {
const normalized = normalizeHex(displayValue)
return isValidHex(normalized) ? normalized : undefined
}, [displayValue])
const handleHexChange = (next: string) => {
onChange?.(next)
}
const handleHexBlur = () => {
const normalized = normalizeHex(displayValue)
if (normalized !== displayValue) {
onChange?.(normalized)
}
onBlur?.()
}
const applyColor = (next: string) => {
onChange?.(normalizeHex(next))
}
return (
<div
className={cn(colorPickerGroupVariants({ size: sizeValue }), className)}
data-slot="color-picker"
data-size={sizeValue}
aria-invalid={ariaInvalid}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
render={
<button
type="button"
className={cn(colorPickerSwatchVariants({ size: sizeValue }))}
aria-label="Выбрать цвет"
disabled={disabled}
>
<span
className={cn(
'size-4 rounded-sm ring-1 ring-foreground/10',
!swatchColor && 'bg-muted',
)}
style={swatchColor ? { backgroundColor: swatchColor } : undefined}
/>
</button>
}
/>
<PopoverContent align="start" className="w-64 gap-3 p-3">
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
Палитра
<input
type="color"
value={toPickerValue(displayValue)}
disabled={disabled}
className="h-28 w-full cursor-pointer rounded-md border border-input bg-transparent p-1"
onChange={(e) => applyColor(e.target.value)}
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Быстрый выбор</span>
<div className="grid grid-cols-6 gap-1.5">
{PROJECT_COLOR_PRESETS.map((preset) => (
<button
key={preset}
type="button"
aria-label={preset}
disabled={disabled}
className={cn(
'aspect-square rounded-md ring-1 ring-foreground/10 transition-transform hover:scale-105',
swatchColor === preset && 'ring-2 ring-ring',
)}
style={{ backgroundColor: preset }}
onClick={() => {
applyColor(preset)
setOpen(false)
}}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
<Input
id={inputId}
value={displayValue}
disabled={disabled}
placeholder={placeholder}
aria-invalid={ariaInvalid}
className={colorPickerInputVariants({ size: sizeValue })}
style={swatchColor ? { color: swatchColor } : undefined}
onChange={(e) => handleHexChange(e.target.value)}
onBlur={handleHexBlur}
/>
</div>
)
}
export { ColorPicker, normalizeHex, isValidHex, PROJECT_COLOR_PRESETS }