import type { ControlSize } from "@charcuterie/tokens"
import type { ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import { Button } from "../Button/Button.tsx"
import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
import { Field } from "../Field/Field.tsx"
import {
DISABLED_CLASS,
FOCUS_RING_CLASS,
} from "../intentStyles.ts"
import { Swatch } from "../Swatch/Swatch.tsx"
import { toClassName } from "../toClassName.ts"
import { parseHexColor } from "./parseHexColor.ts"
const TEXT_INPUT_CLASS =
"w-full min-w-0 rounded-md border border-border-default bg-surface-raised font-mono text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border"
/**
* The browser's own swatch button. Its inner padding and the
* `::-webkit-color-swatch` border are what make the stock control look
* like a grey box with a colored stamp in it; zeroing both leaves a
* flat chip the height of the field beside it.
*/
const NATIVE_COLOR_INPUT_CLASS =
"h-(--control-height-md) w-16 shrink-0 cursor-pointer rounded-md border border-border-default bg-surface-raised p-1 hover:border-border-strong [&::-moz-color-swatch]:rounded-sm [&::-moz-color-swatch]:border-0 [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-sm [&::-webkit-color-swatch]:border-0"
export type ColorPickerCustomPanelProps = {
/** The color the panel opens on — the current custom value, or a seed. */
initialColor: string
isDisabled: boolean
label: string
onApply: (hex: string) => void
onCancel: () => void
getColorError?: (hex: string) => null | string
size: ControlSize
}
/**
* The custom-color surface `ColorPicker` reveals under its trigger.
*
* ### The native color input is allowed here, and only here
*
* Every other picker in this library is ours because the OS paints the
* native one and no token reaches inside it. A custom color is the one
* case where the OS dialog is the right tool: it has the wheel, the
* eyedropper and the sliders, none of which a person wants rebuilt.
* So the native `` is the custom MODE, inside a
* panel this component owns — never the whole picker, which is the
* shape the presets replace.
*
* ### Nothing is applied until the person says so
*
* The native input fires on every movement of the wheel. Applying each
* one would write dozens of colors to an app's server while a person
* drags, and a refused color would flash an error mid-drag. The panel
* holds a draft; **Use color** applies it, Enter in the hex field does
* the same, and Escape or **Cancel** drops it.
*/
export const ColorPickerCustomPanel = ({
getColorError,
initialColor,
isDisabled,
label,
onApply,
onCancel,
size,
}: ColorPickerCustomPanelProps): ReactNode => {
const [draftText, setDraftText] = useState(initialColor)
const [lastValidColor, setLastValidColor] = useState(
parseHexColor(initialColor) ?? "",
)
const hexInputElement = useRef(null)
// A frame late on purpose. The panel opens from a `Combobox` row, and
// closing that popup hands focus back to the trigger after this
// effect has run; focusing now would be undone a moment later.
useEffect(() => {
const frame = requestAnimationFrame(() => {
hexInputElement.current?.focus()
hexInputElement.current?.select()
})
return () => {
cancelAnimationFrame(frame)
}
}, [])
const draftColor = parseHexColor(draftText)
const error =
draftColor === null
? draftText.trim() === ""
? undefined
: "Enter three or six hex digits, such as 3A86FF."
: (getColorError?.(draftColor) ?? undefined)
const isApplicable =
!isDisabled &&
draftColor !== null &&
error === undefined
const apply = () => {
if (isApplicable) {
onApply(draftColor)
}
}
const updateDraft = (text: string) => {
setDraftText(text)
const parsed = parseHexColor(text)
if (parsed !== null) {
setLastValidColor(parsed)
}
}
return (
)
}