diff --git a/apps/v4/content/docs/components/base/color-picker.mdx b/apps/v4/content/docs/components/base/color-picker.mdx new file mode 100644 index 00000000000..f4967963c09 --- /dev/null +++ b/apps/v4/content/docs/components/base/color-picker.mdx @@ -0,0 +1,94 @@ +--- +title: Color Picker +description: A hue-and-shade color picker built on react-color-strip. +base: base +component: true +links: + doc: https://www.npmjs.com/package/react-color-strip + api: https://github.com/aviralj02/react-color-strip#readme +--- + + + +## Installation + + + + + Command + Manual + + + +```bash +npx shadcn@latest add color-picker +``` + + + + + + + +Install the following dependencies: + +```bash +npm install react-color-strip +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Usage + +```tsx showLineNumbers +import { ColorPicker } from "@/components/ui/color-picker" +``` + +```tsx showLineNumbers +const [color, setColor] = React.useState("#ef4444") + +return ( + +) +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ------------------------- | ----------- | ----------------------------------------- | +| `value` | `string` | — | Controlled hex color value. | +| `defaultValue` | `string` | `"#ef4444"` | Initial color for uncontrolled usage. | +| `onChange` | `(value: string) => void` | — | Fires on every drag move. | +| `onChangeComplete` | `(value: string) => void` | — | Fires on pointer release. | +| `showShade` | `boolean` | `false` | Show the shade strip below the hue strip. | +| `disabled` | `boolean` | `false` | Disables all interaction. | +| `className` | `string` | — | Additional class names on the wrapper. | + +## About + +The `ColorPicker` component is built on top of [react-color-strip](https://www.npmjs.com/package/react-color-strip). It renders two strips — a hue selector and an optional shade strip (enabled via `showShade`). + +The component is **fully controlled or uncontrolled**. Pass `value` + `onChange` for controlled usage, or just `defaultValue` for uncontrolled. + +## API Reference + +See the [react-color-strip](https://github.com/aviralj02/react-color-strip#readme) documentation for the underlying strip primitives and pointer customization options. diff --git a/apps/v4/content/docs/components/base/meta.json b/apps/v4/content/docs/components/base/meta.json index c4fc782e843..1dec97f0bfe 100644 --- a/apps/v4/content/docs/components/base/meta.json +++ b/apps/v4/content/docs/components/base/meta.json @@ -16,6 +16,7 @@ "chart", "checkbox", "collapsible", + "color-picker", "combobox", "command", "context-menu", diff --git a/apps/v4/content/docs/components/radix/color-picker.mdx b/apps/v4/content/docs/components/radix/color-picker.mdx new file mode 100644 index 00000000000..8d9c2297334 --- /dev/null +++ b/apps/v4/content/docs/components/radix/color-picker.mdx @@ -0,0 +1,94 @@ +--- +title: Color Picker +description: A hue-and-shade color picker built on react-color-strip. +base: radix +component: true +links: + doc: https://www.npmjs.com/package/react-color-strip + api: https://github.com/aviralj02/react-color-strip#readme +--- + + + +## Installation + + + + + Command + Manual + + + +```bash +npx shadcn@latest add color-picker +``` + + + + + + + +Install the following dependencies: + +```bash +npm install react-color-strip +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Usage + +```tsx showLineNumbers +import { ColorPicker } from "@/components/ui/color-picker" +``` + +```tsx showLineNumbers +const [color, setColor] = React.useState("#ef4444") + +return ( + +) +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ------------------------- | ----------- | ----------------------------------------- | +| `value` | `string` | — | Controlled hex color value. | +| `defaultValue` | `string` | `"#ef4444"` | Initial color for uncontrolled usage. | +| `onChange` | `(value: string) => void` | — | Fires on every drag move. | +| `onChangeComplete` | `(value: string) => void` | — | Fires on pointer release. | +| `showShade` | `boolean` | `false` | Show the shade strip below the hue strip. | +| `disabled` | `boolean` | `false` | Disables all interaction. | +| `className` | `string` | — | Additional class names on the wrapper. | + +## About + +The `ColorPicker` component is built on top of [react-color-strip](https://www.npmjs.com/package/react-color-strip). It renders two strips — a hue selector and an optional shade strip (enabled via `showShade`). + +The component is **fully controlled or uncontrolled**. Pass `value` + `onChange` for controlled usage, or just `defaultValue` for uncontrolled. + +## API Reference + +See the [react-color-strip](https://github.com/aviralj02/react-color-strip#readme) documentation for the underlying strip primitives and pointer customization options. diff --git a/apps/v4/content/docs/components/radix/meta.json b/apps/v4/content/docs/components/radix/meta.json index 4ec87e8a046..058a85441da 100644 --- a/apps/v4/content/docs/components/radix/meta.json +++ b/apps/v4/content/docs/components/radix/meta.json @@ -16,6 +16,7 @@ "chart", "checkbox", "collapsible", + "color-picker", "combobox", "command", "context-menu", diff --git a/apps/v4/examples/__index__.tsx b/apps/v4/examples/__index__.tsx index f7ce86df78b..9a73aebcd64 100644 --- a/apps/v4/examples/__index__.tsx +++ b/apps/v4/examples/__index__.tsx @@ -1552,6 +1552,19 @@ export const ExamplesIndex: Record> = { return { default: mod.default || mod[exportName] } }), }, + "color-picker": { + name: "color-picker", + filePath: "examples/radix/color-picker.tsx", + component: React.lazy(async () => { + const mod = await import("./radix/color-picker") + const exportName = + Object.keys(mod).find( + (key) => + typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "color-picker" + return { default: mod.default || mod[exportName] } + }), + }, "combobox-auto-highlight": { name: "combobox-auto-highlight", filePath: "examples/radix/combobox-auto-highlight.tsx", @@ -7183,6 +7196,19 @@ export const ExamplesIndex: Record> = { return { default: mod.default || mod[exportName] } }), }, + "color-picker": { + name: "color-picker", + filePath: "examples/base/color-picker.tsx", + component: React.lazy(async () => { + const mod = await import("./base/color-picker") + const exportName = + Object.keys(mod).find( + (key) => + typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "color-picker" + return { default: mod.default || mod[exportName] } + }), + }, "combobox-auto-highlight": { name: "combobox-auto-highlight", filePath: "examples/base/combobox-auto-highlight.tsx", diff --git a/apps/v4/examples/base/color-picker.tsx b/apps/v4/examples/base/color-picker.tsx new file mode 100644 index 00000000000..78311a0d206 --- /dev/null +++ b/apps/v4/examples/base/color-picker.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useState } from "react" + +import { ColorPicker } from "@/styles/base-nova/ui/color-picker" + +export function ColorPickerDemo() { + const [color, setColor] = useState("#ef4444") + + return ( +
+ +
+
+ {color} +
+
+ ) +} diff --git a/apps/v4/examples/radix/color-picker.tsx b/apps/v4/examples/radix/color-picker.tsx new file mode 100644 index 00000000000..eb5f1c92e42 --- /dev/null +++ b/apps/v4/examples/radix/color-picker.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useState } from "react" + +import { ColorPicker } from "@/styles/radix-nova/ui/color-picker" + +export function ColorPickerDemo() { + const [color, setColor] = useState("#ef4444") + + return ( +
+ +
+
+ {color} +
+
+ ) +} diff --git a/apps/v4/package.json b/apps/v4/package.json index 237b3b85b5d..8cd392b0f06 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -66,6 +66,7 @@ "postcss": "^8.5.1", "radix-ui": "^1.4.3", "react": "19.2.3", + "react-color-strip": "^2.0.1", "react-day-picker": "^9.7.0", "react-dom": "19.2.3", "react-hook-form": "^7.62.0", diff --git a/apps/v4/registry/__index__.tsx b/apps/v4/registry/__index__.tsx index 9245d09bcde..4f8f3417622 100644 --- a/apps/v4/registry/__index__.tsx +++ b/apps/v4/registry/__index__.tsx @@ -380,6 +380,31 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + "color-picker": { + name: "color-picker", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: undefined, + files: [ + { + path: "registry/new-york-v4/ui/color-picker.tsx", + type: "registry:ui", + target: "", + }, + ], + component: React.lazy(async () => { + const mod = await import("@/registry/new-york-v4/ui/color-picker") + const exportName = + Object.keys(mod).find( + (key) => + typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "color-picker" + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, combobox: { name: "combobox", title: "undefined", diff --git a/apps/v4/registry/bases/__index__.tsx b/apps/v4/registry/bases/__index__.tsx index 12357dfe2b4..a7d814e253a 100644 --- a/apps/v4/registry/bases/__index__.tsx +++ b/apps/v4/registry/bases/__index__.tsx @@ -480,6 +480,38 @@ export const Index: Record> = { }, }, }, + "color-picker": { + name: "color-picker", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: undefined, + files: [ + { + path: "registry/bases/radix/ui/color-picker.tsx", + type: "registry:ui", + target: "", + }, + ], + component: React.lazy(async () => { + const mod = await import("@/registry/bases/radix/ui/color-picker") + const exportName = + Object.keys(mod).find( + (key) => + typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "color-picker" + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/color-picker", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/color-picker-example.tsx", + api: "https://www.npmjs.com/package/react-color-strip", + }, + }, + }, combobox: { name: "combobox", title: "undefined", @@ -5391,6 +5423,38 @@ export const Index: Record> = { }, }, }, + "color-picker": { + name: "color-picker", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: undefined, + files: [ + { + path: "registry/bases/base/ui/color-picker.tsx", + type: "registry:ui", + target: "", + }, + ], + component: React.lazy(async () => { + const mod = await import("@/registry/bases/base/ui/color-picker") + const exportName = + Object.keys(mod).find( + (key) => + typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "color-picker" + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/color-picker", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/color-picker-example.tsx", + api: "https://www.npmjs.com/package/react-color-strip", + }, + }, + }, combobox: { name: "combobox", title: "undefined", diff --git a/apps/v4/registry/bases/base/ui/color-picker.tsx b/apps/v4/registry/bases/base/ui/color-picker.tsx new file mode 100644 index 00000000000..01289c493f8 --- /dev/null +++ b/apps/v4/registry/bases/base/ui/color-picker.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import ColorStrip from "react-color-strip" + +import { cn } from "@/lib/utils" + +type ColorValue = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } +} + +interface ColorPickerProps { + value?: string + defaultValue?: string + onChange?: (value: string) => void + onChangeComplete?: (value: string) => void + disabled?: boolean + showShade?: boolean + className?: string +} + +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0") + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +const POINTER_STYLE = { + width: 14, + height: 14, + backgroundColor: "white", + border: "1.5px solid hsl(var(--border))", + borderRadius: "50%", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)", + scaleOnDrag: true, + dragScale: 1.15, +} + +function ColorPicker({ + value, + defaultValue = "#ef4444", + onChange, + onChangeComplete, + disabled = false, + showShade = false, + className, +}: ColorPickerProps) { + const isControlled = value !== undefined + const initial = value ?? defaultValue + + const [internalColor, setInternalColor] = React.useState(initial) + const [hueColor, setHueColor] = React.useState(initial) + + const containerRef = React.useRef(null) + const [stripWidth, setStripWidth] = React.useState(300) + + React.useLayoutEffect(() => { + if (containerRef.current) { + setStripWidth(containerRef.current.offsetWidth) + } + }, []) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setStripWidth(Math.floor(entry.contentRect.width)) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const currentColor = isControlled ? value : internalColor + + const handleHueChange = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChange?.(pureHue) + } + + const handleHueChangeComplete = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChangeComplete?.(pureHue) + } + + const handleShadeChange = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChange?.(colorVal.hex) + } + + const handleShadeChangeComplete = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChangeComplete?.(colorVal.hex) + } + + return ( +
+ + {showShade && ( + + )} +
+ ) +} + +export { ColorPicker } +export type { ColorPickerProps, ColorValue } diff --git a/apps/v4/registry/bases/radix/ui/color-picker.tsx b/apps/v4/registry/bases/radix/ui/color-picker.tsx new file mode 100644 index 00000000000..01289c493f8 --- /dev/null +++ b/apps/v4/registry/bases/radix/ui/color-picker.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import ColorStrip from "react-color-strip" + +import { cn } from "@/lib/utils" + +type ColorValue = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } +} + +interface ColorPickerProps { + value?: string + defaultValue?: string + onChange?: (value: string) => void + onChangeComplete?: (value: string) => void + disabled?: boolean + showShade?: boolean + className?: string +} + +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0") + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +const POINTER_STYLE = { + width: 14, + height: 14, + backgroundColor: "white", + border: "1.5px solid hsl(var(--border))", + borderRadius: "50%", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)", + scaleOnDrag: true, + dragScale: 1.15, +} + +function ColorPicker({ + value, + defaultValue = "#ef4444", + onChange, + onChangeComplete, + disabled = false, + showShade = false, + className, +}: ColorPickerProps) { + const isControlled = value !== undefined + const initial = value ?? defaultValue + + const [internalColor, setInternalColor] = React.useState(initial) + const [hueColor, setHueColor] = React.useState(initial) + + const containerRef = React.useRef(null) + const [stripWidth, setStripWidth] = React.useState(300) + + React.useLayoutEffect(() => { + if (containerRef.current) { + setStripWidth(containerRef.current.offsetWidth) + } + }, []) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setStripWidth(Math.floor(entry.contentRect.width)) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const currentColor = isControlled ? value : internalColor + + const handleHueChange = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChange?.(pureHue) + } + + const handleHueChangeComplete = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChangeComplete?.(pureHue) + } + + const handleShadeChange = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChange?.(colorVal.hex) + } + + const handleShadeChangeComplete = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChangeComplete?.(colorVal.hex) + } + + return ( +
+ + {showShade && ( + + )} +
+ ) +} + +export { ColorPicker } +export type { ColorPickerProps, ColorValue } diff --git a/apps/v4/registry/new-york-v4/examples/_registry.ts b/apps/v4/registry/new-york-v4/examples/_registry.ts index ac78c238fc0..03230e3d73f 100644 --- a/apps/v4/registry/new-york-v4/examples/_registry.ts +++ b/apps/v4/registry/new-york-v4/examples/_registry.ts @@ -584,6 +584,17 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "color-picker", + type: "registry:example", + registryDependencies: ["color-picker"], + files: [ + { + path: "examples/color-picker.tsx", + type: "registry:example", + }, + ], + }, { name: "combobox-demo", type: "registry:example", diff --git a/apps/v4/registry/new-york-v4/examples/color-picker.tsx b/apps/v4/registry/new-york-v4/examples/color-picker.tsx new file mode 100644 index 00000000000..956ff35a54e --- /dev/null +++ b/apps/v4/registry/new-york-v4/examples/color-picker.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useState } from "react" + +import { ColorPicker } from "@/registry/new-york-v4/ui/color-picker" + +export default function ColorPickerDemo() { + const [color, setColor] = useState("#ef4444") + + return ( +
+ + +
+
+ {color} +
+
+ ) +} diff --git a/apps/v4/registry/new-york-v4/ui/_registry.ts b/apps/v4/registry/new-york-v4/ui/_registry.ts index b31e372ede0..a0d9c3ce092 100644 --- a/apps/v4/registry/new-york-v4/ui/_registry.ts +++ b/apps/v4/registry/new-york-v4/ui/_registry.ts @@ -168,6 +168,17 @@ export const ui: Registry["items"] = [ }, ], }, + { + name: "color-picker", + type: "registry:ui", + dependencies: ["react-color-strip"], + files: [ + { + path: "ui/color-picker.tsx", + type: "registry:ui", + }, + ], + }, { name: "combobox", type: "registry:ui", diff --git a/apps/v4/registry/new-york-v4/ui/color-picker.tsx b/apps/v4/registry/new-york-v4/ui/color-picker.tsx new file mode 100644 index 00000000000..01289c493f8 --- /dev/null +++ b/apps/v4/registry/new-york-v4/ui/color-picker.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import ColorStrip from "react-color-strip" + +import { cn } from "@/lib/utils" + +type ColorValue = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } +} + +interface ColorPickerProps { + value?: string + defaultValue?: string + onChange?: (value: string) => void + onChangeComplete?: (value: string) => void + disabled?: boolean + showShade?: boolean + className?: string +} + +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0") + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +const POINTER_STYLE = { + width: 14, + height: 14, + backgroundColor: "white", + border: "1.5px solid hsl(var(--border))", + borderRadius: "50%", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)", + scaleOnDrag: true, + dragScale: 1.15, +} + +function ColorPicker({ + value, + defaultValue = "#ef4444", + onChange, + onChangeComplete, + disabled = false, + showShade = false, + className, +}: ColorPickerProps) { + const isControlled = value !== undefined + const initial = value ?? defaultValue + + const [internalColor, setInternalColor] = React.useState(initial) + const [hueColor, setHueColor] = React.useState(initial) + + const containerRef = React.useRef(null) + const [stripWidth, setStripWidth] = React.useState(300) + + React.useLayoutEffect(() => { + if (containerRef.current) { + setStripWidth(containerRef.current.offsetWidth) + } + }, []) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setStripWidth(Math.floor(entry.contentRect.width)) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const currentColor = isControlled ? value : internalColor + + const handleHueChange = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChange?.(pureHue) + } + + const handleHueChangeComplete = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChangeComplete?.(pureHue) + } + + const handleShadeChange = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChange?.(colorVal.hex) + } + + const handleShadeChangeComplete = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChangeComplete?.(colorVal.hex) + } + + return ( +
+ + {showShade && ( + + )} +
+ ) +} + +export { ColorPicker } +export type { ColorPickerProps, ColorValue } diff --git a/apps/v4/styles/base-nova/ui/color-picker.tsx b/apps/v4/styles/base-nova/ui/color-picker.tsx new file mode 100644 index 00000000000..01289c493f8 --- /dev/null +++ b/apps/v4/styles/base-nova/ui/color-picker.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import ColorStrip from "react-color-strip" + +import { cn } from "@/lib/utils" + +type ColorValue = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } +} + +interface ColorPickerProps { + value?: string + defaultValue?: string + onChange?: (value: string) => void + onChangeComplete?: (value: string) => void + disabled?: boolean + showShade?: boolean + className?: string +} + +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0") + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +const POINTER_STYLE = { + width: 14, + height: 14, + backgroundColor: "white", + border: "1.5px solid hsl(var(--border))", + borderRadius: "50%", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)", + scaleOnDrag: true, + dragScale: 1.15, +} + +function ColorPicker({ + value, + defaultValue = "#ef4444", + onChange, + onChangeComplete, + disabled = false, + showShade = false, + className, +}: ColorPickerProps) { + const isControlled = value !== undefined + const initial = value ?? defaultValue + + const [internalColor, setInternalColor] = React.useState(initial) + const [hueColor, setHueColor] = React.useState(initial) + + const containerRef = React.useRef(null) + const [stripWidth, setStripWidth] = React.useState(300) + + React.useLayoutEffect(() => { + if (containerRef.current) { + setStripWidth(containerRef.current.offsetWidth) + } + }, []) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setStripWidth(Math.floor(entry.contentRect.width)) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const currentColor = isControlled ? value : internalColor + + const handleHueChange = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChange?.(pureHue) + } + + const handleHueChangeComplete = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChangeComplete?.(pureHue) + } + + const handleShadeChange = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChange?.(colorVal.hex) + } + + const handleShadeChangeComplete = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChangeComplete?.(colorVal.hex) + } + + return ( +
+ + {showShade && ( + + )} +
+ ) +} + +export { ColorPicker } +export type { ColorPickerProps, ColorValue } diff --git a/apps/v4/styles/radix-nova/ui/color-picker.tsx b/apps/v4/styles/radix-nova/ui/color-picker.tsx new file mode 100644 index 00000000000..01289c493f8 --- /dev/null +++ b/apps/v4/styles/radix-nova/ui/color-picker.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import ColorStrip from "react-color-strip" + +import { cn } from "@/lib/utils" + +type ColorValue = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } +} + +interface ColorPickerProps { + value?: string + defaultValue?: string + onChange?: (value: string) => void + onChangeComplete?: (value: string) => void + disabled?: boolean + showShade?: boolean + className?: string +} + +function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + const toHex = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, "0") + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` +} + +const POINTER_STYLE = { + width: 14, + height: 14, + backgroundColor: "white", + border: "1.5px solid hsl(var(--border))", + borderRadius: "50%", + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)", + scaleOnDrag: true, + dragScale: 1.15, +} + +function ColorPicker({ + value, + defaultValue = "#ef4444", + onChange, + onChangeComplete, + disabled = false, + showShade = false, + className, +}: ColorPickerProps) { + const isControlled = value !== undefined + const initial = value ?? defaultValue + + const [internalColor, setInternalColor] = React.useState(initial) + const [hueColor, setHueColor] = React.useState(initial) + + const containerRef = React.useRef(null) + const [stripWidth, setStripWidth] = React.useState(300) + + React.useLayoutEffect(() => { + if (containerRef.current) { + setStripWidth(containerRef.current.offsetWidth) + } + }, []) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setStripWidth(Math.floor(entry.contentRect.width)) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const currentColor = isControlled ? value : internalColor + + const handleHueChange = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChange?.(pureHue) + } + + const handleHueChangeComplete = (colorVal: ColorValue) => { + const pureHue = hslToHex(colorVal.hsl.h, 100, 50) + setHueColor(pureHue) + if (!isControlled) setInternalColor(pureHue) + onChangeComplete?.(pureHue) + } + + const handleShadeChange = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChange?.(colorVal.hex) + } + + const handleShadeChangeComplete = (colorVal: ColorValue) => { + if (!isControlled) setInternalColor(colorVal.hex) + onChangeComplete?.(colorVal.hex) + } + + return ( +
+ + {showShade && ( + + )} +
+ ) +} + +export { ColorPicker } +export type { ColorPickerProps, ColorValue } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3067dc6f898..d58f07c8500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-color-strip: + specifier: ^2.0.1 + version: 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-day-picker: specifier: ^9.7.0 version: 9.8.1(react@19.2.3) @@ -7016,6 +7019,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-color-strip@2.0.1: + resolution: {integrity: sha512-+ybjx4EkETKRiZTBMzc8VTXPfPCeATU8Y7AFF/eBXgzppYb0JXn7nDGCqUu1kPLEee03TnE6vgNaOIaMdJ5r3Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-day-picker@9.8.1: resolution: {integrity: sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw==} engines: {node: '>=18'} @@ -15436,6 +15445,12 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-color-strip@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + rollup: 4.60.1 + react-day-picker@9.8.1(react@19.2.3): dependencies: '@date-fns/tz': 1.3.1