From 18072fdeea6a7c9de83daa844a3957e1294e8fcf Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 28 Jun 2026 20:04:56 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20ReUI=20color=20picker=20=D0=B2=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=83=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../components/domain/project-edit-sheet.tsx | 31 ++- apps/web/src/components/reui/color-picker.tsx | 220 ++++++++++++++++++ 2 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/reui/color-picker.tsx diff --git a/apps/web/src/components/domain/project-edit-sheet.tsx b/apps/web/src/components/domain/project-edit-sheet.tsx index 5fcb2ea..87c6624 100644 --- a/apps/web/src/components/domain/project-edit-sheet.tsx +++ b/apps/web/src/components/domain/project-edit-sheet.tsx @@ -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 ( <> - - + + ( + + )} + /> ) diff --git a/apps/web/src/components/reui/color-picker.tsx b/apps/web/src/components/reui/color-picker.tsx new file mode 100644 index 0000000..6adb0ce --- /dev/null +++ b/apps/web/src/components/reui/color-picker.tsx @@ -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 { + 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 ( +
+ + + + + } + /> + + +
+ Быстрый выбор +
+ {PROJECT_COLOR_PRESETS.map((preset) => ( +
+
+
+
+ handleHexChange(e.target.value)} + onBlur={handleHexBlur} + /> +
+ ) +} + +export { ColorPicker, normalizeHex, isValidHex, PROJECT_COLOR_PRESETS }