diff --git a/package-lock.json b/package-lock.json index 079410aa..0785d310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.25.10", "@uiw/react-codemirror": "^4.25.10", "@uiw/react-json-view": "^2.0.0-alpha.43", @@ -74,6 +75,7 @@ "playwright": "1.58.1", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hotkeys-hook": "^5.3.3", "react-resizable-panels": "^4.9.0", "react-zoom-pan-pinch": "^3.7.0", "rimraf": "^6.1.3", @@ -7603,6 +7605,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@uidotdev/usehooks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.25.10", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", @@ -17180,6 +17196,20 @@ "react": "^19.2.7" } }, + "node_modules/react-hotkeys-hook": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.3.3.tgz", + "integrity": "sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 9d486020..b02b8ca7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "author": "CodeZero", "devDependencies": { "@ark-ui/react": "^5.36.2", - "@internationalized/date": "^3.12.0", "@babel/plugin-proposal-decorators": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@codemirror/autocomplete": "^6.20.3", @@ -29,6 +28,7 @@ "@codemirror/view": "^6.43.6", "@dagrejs/dagre": "^3.0.0", "@icons-pack/react-simple-icons": "^13.13.0", + "@internationalized/date": "^3.12.0", "@lezer/common": "^1.5.2", "@mdx-js/react": "^3.1.1", "@radix-ui/react-checkbox": "^1.3.9", @@ -61,6 +61,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.25.10", "@uiw/react-codemirror": "^4.25.10", "@uiw/react-json-view": "^2.0.0-alpha.43", @@ -83,6 +84,7 @@ "playwright": "1.58.1", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hotkeys-hook": "^5.3.3", "react-resizable-panels": "^4.9.0", "react-zoom-pan-pinch": "^3.7.0", "rimraf": "^6.1.3", @@ -115,14 +117,14 @@ "types": "dist/index.d.ts", "peerDependencies": { "@ark-ui/react": "^5.36.2", - "@internationalized/date": "^3.12.0", - "@icons-pack/react-simple-icons": "^13.13.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.1", "@codemirror/lint": "^6.9.4", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.13", + "@icons-pack/react-simple-icons": "^13.13.0", + "@internationalized/date": "^3.12.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -137,6 +139,7 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tabler/icons-react": "3.41.1", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", "@uiw/react-json-view": "^2.0.0-alpha.43", @@ -147,6 +150,7 @@ "motion": "^12.40.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", + "react-hotkeys-hook": "^5.0.0", "react-resizable-panels": "^4.3.1", "react-zoom-pan-pinch": "^3.7.0", "slate": "^0.124.0", diff --git a/src/components/form/ColorInput.style.scss b/src/components/form/ColorInput.style.scss new file mode 100644 index 00000000..b19555c2 --- /dev/null +++ b/src/components/form/ColorInput.style.scss @@ -0,0 +1,233 @@ +@use "../../styles/helpers"; +@use "../../styles/box"; +@use "../../styles/variables"; + +.color-input { + + &__control { + flex: 1; + min-width: 0; + align-self: stretch; + display: flex; + align-items: center; + gap: variables.$xs; + } + + &__swatch-wrapper { + position: relative; + width: 20px; + height: 20px; + border-radius: variables.$borderRadius - variables.$xs; + overflow: hidden; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px helpers.borderColor(variables.$white); + } + + &__transparency-grid { + position: absolute; + inset: 0; + } + + // Ark sets `position: relative` inline on the swatch, so fill via width/height + // (an absolute `inset: 0` would be ignored and collapse it to 0 height). + &__swatch { + width: 100%; + height: 100%; + } + + // Shown when no color has been picked yet: a neutral empty square with a slash. + &__swatch--empty { + background: + linear-gradient(to top right, + transparent calc(50% - 1px), helpers.color(variables.$white, .4), + transparent calc(50% + 1px)); + } + + &__value-text { + font-variant-numeric: tabular-nums; + + & { + @include helpers.fontStyle(); + } + + &--placeholder { + opacity: .5; + } + } + + &__trigger { + cursor: pointer; + display: flex; + align-items: center; + } + + // ---- menu content ---- + + &__content { + padding: variables.$xxs; + } + + &__body { + display: flex; + flex-direction: column; + gap: variables.$xs; + width: 240px; + max-width: 100%; + } + + &__area { + position: relative; + width: 100%; + height: 140px; + border-radius: variables.$borderRadius - variables.$xxs; + overflow: hidden; + } + + // Ark sets `position: relative` inline on the background/track, which beats a + // stylesheet `position: absolute`. So fill via width/height instead of inset. + &__area-background { + width: 100%; + height: 100%; + border-radius: inherit; + } + + &__area-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid variables.$white; + box-shadow: 0 0 0 1px rgba(variables.$black, .35); + + & { + @include helpers.noFocusStyle(); + } + } + + &__sliders { + display: flex; + flex-direction: column; + gap: variables.$xs; + } + + &__channel-slider { + position: relative; + height: 14px; + border-radius: 999px; + overflow: hidden; + + & { + @include helpers.noFocusStyle(); + } + } + + &__channel-slider-grid { + position: absolute; + inset: 0; + border-radius: inherit; + } + + &__channel-slider-track { + width: 100%; + height: 100%; + border-radius: inherit; + } + + &__channel-slider-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid variables.$white; + box-shadow: 0 0 0 1px rgba(variables.$black, .35); + transform: translate(-50%, -50%); + + & { + @include helpers.noFocusStyle(); + } + } + + // ---- channel / hex inputs ---- + + &__channels { + display: flex; + flex-direction: column; + gap: variables.$xs; + } + + &__notation { + width: 100%; + + .segmented-control__item { + flex: 1; + text-align: center; + justify-content: center; + font-size: variables.$sm; + } + } + + // ---- copy / paste footer ---- + + &__actions { + width: 100%; + gap: variables.$xs; + margin-top: variables.$xxs; + + .button { + flex: 1; + gap: variables.$xxs; + justify-content: center; + align-items: center; + } + } + + &__channel-row { + display: flex; + align-items: flex-end; + gap: variables.$xxs; + } + + &__channel { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + + &--hex { + align-items: stretch; + + .color-input__channel-label { + text-align: center; + } + } + } + + &__channel-label { + font-size: variables.$xs; + opacity: .6; + + & { + @include helpers.fontStyle(); + } + } + + &__channel-input { + width: 100%; + min-width: 0; + text-align: center; + padding: variables.$xxs; + border-radius: variables.$borderRadius - variables.$xxs; + font-variant-numeric: tabular-nums; + + & { + @include box.box(variables.$primary, variables.$white, variables.$white); + @include helpers.noFocusStyle(); + @include helpers.fontStyle(); + font-size: variables.$sm; + } + + &:focus, &[data-focus] { + @include box.boxActiveStyle(variables.$primary, variables.$white, variables.$white); + } + } +} diff --git a/src/components/form/ColorInput.tsx b/src/components/form/ColorInput.tsx new file mode 100644 index 00000000..5305285f --- /dev/null +++ b/src/components/form/ColorInput.tsx @@ -0,0 +1,348 @@ +import {ColorPicker, parseColor, type Color, type UseColorPickerContext} from "@ark-ui/react"; +import {ComponentProps, mergeComponentProps} from "../../utils"; +import React from "react"; +import {InputWrapper, InputWrapperProps} from "./InputWrapper"; +import {Flex} from "../flex/Flex"; +import {Badge} from "../badge/Badge"; +import {Button} from "../button/Button"; +import {Menu, MenuContent, MenuContentProps, MenuPortal, MenuTrigger, MenuTriggerProps} from "../menu/Menu"; +import {SegmentedControl, SegmentedControlItem} from "../segmented-control/SegmentedControl"; +import {useCopyToClipboard} from "@uidotdev/usehooks"; +import {useHotkeys} from "react-hotkeys-hook"; +import "./ColorInput.style.scss" + +type ColorChannel = "hue" | "saturation" | "brightness" | "lightness" | "red" | "green" | "blue" | "alpha" + +type ColorNotation = "rgba" | "hsla" | "hexa" + +interface ValueChangeDetails { + value: Color + valueAsString: string +} + +interface ColorInputState { + hasValue: boolean + placeholder: React.ReactNode +} + +export type ColorProps = InputWrapperProps & { + placeholder?: React.ReactNode + name?: string + disabled?: boolean + readOnly?: boolean + closeOnSelect?: boolean + onValueChange?: (details: ValueChangeDetails) => void + children?: React.ReactNode +} +export type ColorInputControlProps = ComponentProps & ColorPicker.ControlProps +export type ColorInputSwatchProps = ComponentProps & ColorPicker.ValueSwatchProps +export type ColorInputValueTextProps = ComponentProps & Omit, "value"> +export type ColorInputTriggerProps = ComponentProps & MenuTriggerProps +export type ColorInputContentProps = ComponentProps & MenuContentProps & { + defaultNotation?: ColorNotation +} + +const ColorInputStateContext = React.createContext({hasValue: false, placeholder: null}) + +const COLOR_PATTERN = /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(rgba?|hsla?|hsba?)\(\s*-?[\d.]+%?(\s*,\s*-?[\d.]+%?){2,3}\s*\))$/i + +const safeParseColor = (input: string): Color | undefined => { + const value = input.trim() + return COLOR_PATTERN.test(value) ? parseColor(value) : undefined +} + +const toColor = (input: unknown): Color | undefined => { + if (typeof input === "string") return safeParseColor(input) + if (input == null) return undefined + return input as Color +} + +const parsePastedColor = (text: string): { color: Color, notation: ColorNotation } | null => { + const trimmed = text.trim().replace(/°/g, "").replace(/deg\b/gi, "") + if (!trimmed) return null + + const parsed = safeParseColor(trimmed) + if (parsed) { + const lower = trimmed.toLowerCase() + const notation: ColorNotation = lower.startsWith("#") ? "hexa" : lower.startsWith("hsl") ? "hsla" : "rgba" + return {color: parsed, notation} + } + + const isBareHex = /^[0-9a-f]+$/i.test(trimmed) && ( + trimmed.length === 6 || trimmed.length === 8 || + ((trimmed.length === 3 || trimmed.length === 4) && /[a-f]/i.test(trimmed)) + ) + if (isBareHex) return {color: parseColor(`#${trimmed}`), notation: "hexa"} + + const parts = trimmed.split(/[\s,]+/).filter(Boolean) + const nums = parts.map((part) => Number(part.replace("%", ""))) + if ((parts.length === 3 || parts.length === 4) && nums.every((n) => !Number.isNaN(n))) { + const alpha = parts.length === 4 ? nums[3] : 1 + const isHsl = parts[1].includes("%") || parts[2].includes("%") + return isHsl + ? {color: parseColor(`hsla(${nums[0]}, ${nums[1]}%, ${nums[2]}%, ${alpha})`), notation: "hsla"} + : {color: parseColor(`rgba(${nums[0]}, ${nums[1]}, ${nums[2]}, ${alpha})`), notation: "rgba"} + } + return null +} + +export const ColorInput: React.FC = (props) => { + + const { + title, description, left, leftType, rightType, right, formValidation, initialValue, defaultValue, value, + placeholder = "Pick a color", name, disabled, readOnly, closeOnSelect, onValueChange, children + } = props + + const initial = toColor(value ?? initialValue ?? defaultValue) + const [current, setCurrent] = React.useState(() => initial ?? parseColor("hsla(0, 0%, 0%, 1)")) + const [hasValue, setHasValue] = React.useState(() => initial !== undefined) + const [open, setOpen] = React.useState(false) + + React.useEffect(() => { + if (value === undefined) return + const next = toColor(value) + setHasValue(next !== undefined) + if (next) setCurrent(next) + }, [value]) + + const handleValueChange = (details: ValueChangeDetails) => { + setCurrent(details.value) + setHasValue(true) + formValidation?.setValue?.(details.value.toString("hsla")) + onValueChange?.(details) + } + + return setOpen(details.open), + name, disabled, readOnly, closeOnSelect + }) as ColorPicker.RootProps}> + + + + {children} + + + + +} + +export const ColorInputControl: React.FC = (props) => { + const {children, ...rest} = props + return + {children} + + +} + +export const ColorInputSwatch: React.FC = (props) => { + const {hasValue} = React.useContext(ColorInputStateContext) + return
+ {hasValue ? <> + + + : ( +
+ )} +
+} + +export const ColorInputValueText: React.FC = (props) => { + const {hasValue, placeholder} = React.useContext(ColorInputStateContext) + if (!hasValue) { + return + {placeholder} + + } + return + {(api) => ( + + {api.value.toString("hsla")} + + )} + +} + +export const ColorInputTrigger: React.FC = (props) => { + return +} + +export const ColorInputContent: React.FC = (props) => { + const {defaultNotation, ...rest} = props + return + +
+ + + + + + +
+ + + + + + + + + +
+ + + {(api) => } + + +
+
+
+} + +const ColorInputChannels: React.FC<{ + api: UseColorPickerContext + defaultNotation: ColorNotation +}> = ({api, defaultNotation}) => { + + const [notation, setNotation] = React.useState(defaultNotation) + + const handleNotation = (next: string) => { + if (next === "rgba" || next === "hsla" || next === "hexa") setNotation(next) + } + + return
{ + if (event.key !== "Escape" && !event.metaKey && !event.ctrlKey) event.stopPropagation() + }}> + + + RGBA + HSLA + HEXA + + + {notation === "hexa" ? ( + + ) : ( +
+ {(notation === "rgba" ? ["red", "green", "blue"] : ["hue", "saturation", "lightness"]).map((channel) => ( + + ))} + +
+ )} + + + +
+} + +const ColorInputActions: React.FC<{ + api: UseColorPickerContext + notation: ColorNotation + setNotation: (notation: ColorNotation) => void +}> = ({api, notation, setNotation}) => { + + const {hasValue} = React.useContext(ColorInputStateContext) + const [, copyToClipboard] = useCopyToClipboard() + const apiRef = React.useRef(api) + apiRef.current = api + + const copyColor = () => { + if (!hasValue) return + copyToClipboard(notation === "hexa" ? api.value.toString("hexa") : api.value.toString(notation)) + } + + const pasteColor = () => { + navigator.clipboard.readText().then((text) => { + const result = parsePastedColor(text) + if (!result) return + apiRef.current.setValue(result.color) + setNotation(result.notation) + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + }, () => undefined) + } + + useHotkeys("mod+c", copyColor, {enabled: hasValue, enableOnFormTags: true}, [hasValue, notation, api]) + useHotkeys("mod+v", pasteColor, {enableOnFormTags: true}) + + const mod = /mac|iphone|ipad|ipod/i.test(navigator.userAgent) ? "⌘" : "Ctrl" + + return + + + +} + +const ColorInputChannel: React.FC<{ + channel: ColorChannel + format: "rgba" | "hsla" + api: UseColorPickerContext +}> = ({channel, format, api}) => { + return +} + +const ColorInputHexInput: React.FC<{ api: UseColorPickerContext }> = ({api}) => { + return { + const color = safeParseColor(value) + if (color) api.setValue(color) + }}/> +} + +const ColorInputTextInput: React.FC<{ + committed: string + commit: (value: string) => void + inputMode?: "numeric" | "decimal" | "text" +}> = ({committed, commit, inputMode = "text"}) => { + const [draft, setDraft] = React.useState(committed) + const focused = React.useRef(false) + + React.useEffect(() => { + if (!focused.current) setDraft(committed) + }, [committed]) + + return (focused.current = true)} + onChange={(event) => { + setDraft(event.target.value) + commit(event.target.value) + }} + onBlur={() => { + focused.current = false + setDraft(committed) + }}/> +} diff --git a/src/components/form/Input.stories.tsx b/src/components/form/Input.stories.tsx index 86fcac15..394092d8 100644 --- a/src/components/form/Input.stories.tsx +++ b/src/components/form/Input.stories.tsx @@ -5,6 +5,7 @@ import {Button} from "../button/Button"; import { IconCalendar, IconChevronDown, + IconColorPicker, IconFileInfoFilled, IconKey, IconLogin, @@ -34,6 +35,14 @@ import { SelectViewport } from "./SelectInput"; import {DateInput, DateInputContent, DateInputControl, DateInputField, DateInputTrigger} from "./DateInput"; +import { + ColorInput, + ColorInputContent, + ColorInputControl, + ColorInputSwatch, + ColorInputTrigger, + ColorInputValueText +} from "./ColorInput"; import {Flex} from "../flex/Flex"; import {ButtonGroup} from "../button-group/ButtonGroup"; import {EditorInput, EditorTokenRule} from "./EditorInput"; @@ -544,6 +553,91 @@ export const DateWithInitialValue = () => { } +export const Color = () => { + + const [inputs, validate] = useForm({ + initialValues: { + color: "" + }, + validate: { + color: (value) => { + if (!value) { + return "Please pick a color" + } + return null + } + }, + onSubmit: (values) => { + console.log(values) + } + }) + + return + validate()} + description={"Enter a color in rgba, hex or hsla. The stored value is hsla."} + right={ + + + + + + } rightType={"action"} {...inputs.getInputProps("color")}> + + + + + + + +} + +// Seeds a non-empty initial color through useForm: getInputProps("color") hands the +// stored hsla string across as initialValue, which ColorInput parses to seed the Ark +// machine on mount. The swatch and value text should render pre-filled without interaction. +export const ColorWithInitialValue = () => { + + const [inputs, validate] = useForm<{ color: string }>({ + initialValues: { + color: "hsla(275, 89%, 60%, 1)" + }, + validate: { + color: (value) => { + if (!value) { + return "Please pick a color" + } + return null + } + }, + onSubmit: (values) => { + console.log(values) + } + }) + + return + validate()} + description={"Pre-filled from the form's initialValues."} + right={ + + + + + + } rightType={"action"} {...inputs.getInputProps("color")}> + + + + + + + +} + // ---- Badge tag rendering ---- // Shows {{ variable }} tokens rendered as Badge components via tokenRules export const EditorBadgeTags = () => { diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 67f09e8b..be7663bd 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,4 +1,5 @@ export * from "./CheckboxInput" +export * from "./ColorInput" export * from "./DateInput" export * from "./EditorInput" export * from "./EmailInput"