diff --git a/.changeset/add-color-picker.md b/.changeset/add-color-picker.md new file mode 100644 index 000000000..bcffa4bd7 --- /dev/null +++ b/.changeset/add-color-picker.md @@ -0,0 +1,7 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add `ColorPicker` — a form-attachable color input. The field shows the current color as a swatch, accepts hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()` and `oklch()` text, and opens a popover where the color can be tuned on three axes: HST (OKHST hue/saturation/tone), LCH (OKLCH lightness/chroma/hue) or RGB. Every conversion runs through Glaze, so the value is always a real, in-gamut color. + +`formatMode` controls how the text relates to the value: `forced` (default) rewrites the text in `format`, `derive` keeps the notation the user typed but normalizes the value, and `free` passes the text through verbatim after verifying it parses. Also adds a `PipetteIcon`. diff --git a/.claude/skills/ui-kit-verification/SKILL.md b/.claude/skills/ui-kit-verification/SKILL.md deleted file mode 100644 index ccfc667dc..000000000 --- a/.claude/skills/ui-kit-verification/SKILL.md +++ /dev/null @@ -1,432 +0,0 @@ ---- -name: ui-kit-verification -description: "Verify a cube-ui-kit PR against the Cube Cloud console before merging: check the PR is settled enough to verify, install its canary snapshot in a fresh cloud branch, hunt down every breakage the new API introduces (including the silent ones types miss), migrate cloud, then hand the release back to the user and bump cloud off the canary once it publishes. Also covers palette work, where types and tests prove nothing: measuring token drift across all four scheme variants, bumping @tenphi/glaze, refreshing a stale canary on a long-lived branch, keeping cloud's three copies of the recipe in lockstep, and splitting a palette PR. Use when the user says 'verify the ui-kit PR in cloud', 'install the snapshot to cloud', 'check this ui-kit change against cloud', asks to migrate cloud to a ui-kit API introduced by a PR, or asks to bump Glaze / retune a seed / migrate the palette." -metadata: - version: '1.1.0' ---- - -# UI Kit Verification - -A `@cube-dev/ui-kit` PR is not done when its own tests pass — it is done when Cube Cloud still -works on top of it. Every PR publishes a canary snapshot to npm, so cloud can be built against -the exact PR build before it is merged. - -This skill runs that loop: **gate → snapshot → install → hunt breakages → migrate → report → -release → de-canary**. - -The output is a cloud branch the user reviews. Do not open a cloud PR or merge anything unless -asked. - -## Step 0 — Gate: is the PR ready to verify? - -Verifying an unsettled PR is wasted work — if the API still moves, you migrate cloud twice. Check -this before installing anything. - -```bash -gh pr view --repo cube-js/cube-ui-kit --json isDraft,reviewDecision,mergeStateStatus -gh pr checks --repo cube-js/cube-ui-kit -gh api graphql -f query='{repository(owner:"cube-js",name:"cube-ui-kit"){pullRequest(number:){ - reviewThreads(last:30){nodes{isResolved path}}}}}' \ - --jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|.path]' -``` - -**Stop and hand back to the user** when any of these hold: - -- **Unresolved review threads, or open review comments.** The API may still change in response to - them. Report what is outstanding; do not start migrating. -- **`isDraft: true`.** -- **A failing or pending required check** — `Tests & lint`, `Build & canary release`. If the canary - job has not succeeded there may be no snapshot to install, or a broken one. -- **Chromatic is awaiting a human.** `UI Tests` or `UI Review` outside the `pass` bucket means - someone has to look at the visual diff and accept or reject the new baselines. **Only the user - can do that** — it is a judgement about whether the rendered change is intended. Ask them to - review and approve the Chromatic changes, and wait. Never accept baselines on their behalf. - When they are settled the descriptions read like `Approved by `, - `N visual and accessibility changes accepted as baselines`, or `no changes`. - -**A missing approval is _not_ a blocker.** `reviewDecision: REVIEW_REQUIRED` with -`mergeStateStatus: BLOCKED` is the normal state of a PR that is otherwise green, and it is exactly -the state worth verifying — cloud verification is often what justifies the approval. Proceed when -approval is the only thing outstanding, and say so in your report. - -The gate is not only a starting condition. If review comments land on the ui-kit PR while you are -mid-migration, re-apply it: the API may be about to change underneath you, and finishing a migration -against a version that is about to move is worse than pausing. Report and wait. - -## Step 1 — Identify the PR and its canary snapshot - -Every PR gets an npm **dist-tag** named `pr_`, republished on every push. Read the tag -rather than the "NPM canary release" PR comment — the comment can be stale if it did not re-run. - -```bash -gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number,title,url -npm view @cube-dev/ui-kit dist-tags --json | python3 -c "import json,sys; print(json.load(sys.stdin)['pr_'])" -``` - -Confirm the snapshot is current before trusting it — compare its publish time to the PR's HEAD -commit. If the snapshot predates HEAD, the canary workflow has not finished; wait for it rather -than verifying a stale build. - -```bash -npm view @cube-dev/ui-kit time --json | python3 -c "import json,sys; print(json.load(sys.stdin)[''])" -git log -1 --format='%cI' -``` - -Report the resolved version to the user before installing. - -## Step 2 — Read the API change before touching cloud - -The migration is only as good as your model of what changed. Read, in this order: - -1. **The changeset** in `.changeset/*.md` — the user-facing summary of what moved, what is - deprecated, and what silently changed behaviour. -2. **`git diff origin/main...HEAD --stat`** — the blast radius. -3. **The rules doc for the area** (e.g. [input-components.md](../../../docs/rules/input-components.md) - for form fields) — the canonical shape of the new API, which is what cloud should be migrated _to_. -4. **The diff of the types and the resolution helpers** — for a prop change, the prop - interfaces (`src/shared/*.ts`, `**/types.ts`) and whatever normalizes them. This is where you - learn whether the old prop was _deprecated_ (still works) or _deleted_ (breaks), and that - distinction drives everything in Step 4. - -Write down, explicitly, three lists: - -- **Deleted** — removed exports and removed props. These break loudly at the type level. -- **Deprecated** — still accepted, normalized internally. These do _not_ break; they are the - migration work. -- **Behavioural** — same types, different runtime result (precedence changes, a component no - longer registering with a form, a state that now renders where it was previously ignored). - These break silently and are the reason this skill exists. - -## Step 3 — Branch and install in cloud - -Cloud lives in a separate checkout. Ask which one to use if there is more than one and the user -has not said — branch off `origin/master`, never off whatever feature branch a checkout happens -to be sitting on. - -```bash -git -C fetch origin master -git -C checkout -b origin/master -``` - -Install the snapshot with the repo's own script — it updates all four consumer packages -(`console-ui`, `sheets-ui`, `cloud-router-auth-ui`, `mcp-app-ui`) and refreshes `yarn.lock`: - -```bash -cd && yarn update-uikit -``` - -Then verify what actually landed, rather than trusting the install log: - -```bash -node -e "console.log(require('./node_modules/@cube-dev/ui-kit/package.json').version)" -``` - -A failure in the optional `sse4_crc32` / `node-gyp` build is pre-existing noise on ARM Macs and -does not mean the install failed — check the version, not the log. - -## Step 4 — Hunt the breakages - -Types find the deleted things. You have to go find the rest yourself. - -### 4a. Typecheck, and establish a baseline first - -Cloud does not typecheck clean from a fresh checkout: workspace packages -(`@cube-dev/platform-client`, `@cubejs-enterprise/cross-runtime`, `@cubejs-enterprise/console-ui`) -are unbuilt, producing `TS2307` plus a long cascade of `TS7006`/`TS7031` implicit-any errors. -**Never** report those as ui-kit breakage. - -```bash -cd /packages/console-ui && yarn typecheck > /tmp/tc-console.txt 2>&1 -cd /packages/sheets-ui && yarn tsc > /tmp/tc-sheets.txt 2>&1 -cd /packages/cloud-router-auth-ui && yarn tsc > /tmp/tc-auth.txt 2>&1 -``` - -Capture the **whole** output to a file and read the file. Do not pipe a typecheck through -`tail` — the interesting errors are usually not at the end, and a tail plus a `0` exit code from -the pipeline reads as "clean" when it is not. Check the exit code of `tsc` itself. - -Filter the noise, then attribute what is left: - -```bash -grep -vE "TS2307|TS7006|TS7031|Cannot find module|implicitly has an" /tmp/tc-console.txt -``` - -For each surviving error, decide whether it is yours: does the file import `@cube-dev/ui-kit`, -and does the error mention a ui-kit type? Errors in files that never import the ui kit are -pre-existing. Re-run the same typecheck after migrating and **diff the error lists** — that diff, -not the raw count, is the evidence that you fixed something and broke nothing. - -### 4b. Find the silent breakages - -This is the core of the skill. Types will not help here, so search for the _patterns_ the change -invalidated. For a validation-props change, that was: - -| Pattern | Why it breaks silently | -| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloud components reading a **removed-from-the-pipeline prop** off the result of `useFieldProps` | `useFieldProps` no longer returns it, so the destructured value is now permanently `undefined` and the styling it drove never renders. Nothing type-errors, because the component declares the prop itself. | -| Props passed to a ui-kit component through an **object-literal spread** (`{...{ a, b }}`, `{...(cond ? {…} : {})}`) | JSX excess-property checking does not reach through the spread, so a prop that no longer exists is silently dropped instead of erroring. | -| Local prop types **mirroring** a ui-kit prop union (`validationState?: 'valid' \| 'invalid'`) | Still valid TypeScript, now wired to nothing. | -| A component that **stopped registering with the form** | Still renders, silently no longer bound. Grep its usages for `name=`, `rules=`, and `form=`. | -| A prop that was **accepted and ignored**, and now takes effect | New visual state appears where nothing appeared before. Not a bug, but it belongs in the report. | -| A **precedence flip** (explicit prop now wins over derived state) | Grep for elements carrying _both_ the explicit prop and the derived source (e.g. both `name=` and `isInvalid=`) — those are the only places a flip can change behaviour. | - -Grep by prop name across all four packages, and read every hit rather than blind-replacing — -separate genuine ui-kit props from cloud's own identically-named fields (cloud has its own -`validationStates` error shape that must be left alone). - -```bash -grep -rn "" packages/*/src --include="*.ts" --include="*.tsx" -grep -rn "" packages/*/src -``` - -## Step 5 — Migrate - -Migrate to the new API rather than leaning on the deprecation shim — the deprecated path logs -dev warnings and is scheduled to disappear. Follow the rules doc from Step 2 so cloud lands on the -same shape the ui kit documents. - -Preserve the original semantics exactly. A conditional prop maps to the equivalent boolean, and -the tri-state cases are where mistakes hide: - -- `validationState={err ? 'invalid' : undefined}` → `isInvalid={!!err}` -- `validationState={ok ? 'valid' : 'invalid'}` → `isInvalid={!ok} isValid={ok}` (both states were - always set here — do not collapse it to a single prop) -- a `Record` lookup → a `Record`, renamed to match - -Keep the diff scoped to the API change. Cleanups the new API merely _permits_ — such as dropping a -`useFormProps` call that `useFieldProps` now applies internally — are a separate change; leave -them out unless asked. - -**Do not add visual states the ui kit now renders but the cloud component never had.** It is -tempting: the ui kit gained a valid state, so giving a cloud card its `#success` border to match -feels like finishing the job. It is a behaviour change smuggled into a mechanical rename, it is -easy to miss in a 30-file diff, and it will be applied inconsistently — the fields that map only -`isInvalid` (because that is all their old expression had) end up disagreeing with the ones you -"improved" inside the same commit. Map exactly what the old expression mapped. If the new state is -worth adding, that is its own PR with its own design decision. - -## Step 6 — Verify - -In a fresh checkout `console-ui`'s tests do not merely fail, they fail to _load_: every test file -errors on `Failed to resolve import "@cubejs-enterprise/cross-runtime"` because that workspace -package is unbuilt. Build it first, or you will read a total wipeout as ui-kit fallout: - -```bash -cd /packages/cross-runtime && yarn build -``` - -```bash -cd /packages/console-ui && yarn typecheck # then diff against the Step 4a baseline -cd /packages/console-ui && yarn lint -cd /packages/console-ui && yarn test -cd /packages/sheets-ui && yarn test -cd && npx prettier --check -``` - -Quote file lists carefully — a mangled shell argument list makes prettier report every file as -failing, which is a false alarm, not a formatting problem. - -### Proving a silent breakage is actually fixed - -The existing suites pass either way — they never asserted on the state that broke. To get real -evidence, assert on the **computed style**, which resolves tasty's generated CSS even in jsdom: - -```ts -const border = (el: HTMLElement) => getComputedStyle(el).border; -// invalid → "var(--border-width) solid var(--danger-color)" -// neutral → "var(--border-width) solid var(--border-color, currentColor)" -expect(border(screen.getByTestId('DirectoryTreeInput'))).toContain( - '--danger-color', -); -``` - -Write a throwaway probe spec first that dumps `outerHTML` and the computed style for both states, -and build the assertion from what you actually see. Do not guess at an assertion and ship it when -it passes — a vacuous test is worse than none here. Note that `console.log` is swallowed by this -vitest reporter; write probe output to a file and `cat` it. - -**Drive the state through the form, not through the prop.** A test that hands `isInvalid` in -directly asserts almost nothing — the component renders the caller's value either way, so it passes -against the broken version too and only _looks_ like a regression guard. Push the state in from the -form instead: - -```ts -type Values = { folder?: string | null }; -let form: CubeFormInstance | undefined; // never `any` — AGENTS.md forbids it in tests, - // and `any` hides a setFields signature change -function Harness() { - [form] = useForm(); // returns a tuple - return
{/* field with name= */}
; -} -// …render, assert neutral, then: -act(() => form!.setFields([{ name: 'folder', errors: ['Required'] }])); -``` - -`form.setFields` is the reliable lever. Clicking `SubmitButton` (that is the export name, not -`Submit`) did not run the rules in the console-ui harness — and a plain untouched `TextInput` -behaved identically, which is how you know it is the harness and not your migration. - -**Then prove the test can fail.** Temporarily revert the component to its pre-migration form and -confirm the spec goes red, and say so in the commit message. A regression test you have only ever -seen pass is a guess. - -Unit tests will not catch most Step 4b breakages; they are render-state bugs. If a migrated surface -matters, run the app and look at it (`yarn dev` in `packages/console-ui`), or say plainly in the -report that it was not visually verified. - -## Step 7 — Ask for the release, then de-canary - -The consumer PR **must not merge with a `0.0.0-canary-*` pin**: canary tags are mutable and -ephemeral, so a later `yarn install` or Docker rebuild can resolve to something else or fail once -the tag is collected. That makes the canary pin a blocker on the consumer PR which nothing you do -can clear — it needs a real published version to point at. - -So once verification is clean and the ui-kit PR is otherwise ready, **ask the user to make the -release.** Do not merge it yourself: the release is two merges into `main` — the feature PR, then -the `Version Packages` PR the changesets bot opens from it, which is the merge that actually -publishes — and both are gated on branch protection. Do not self-approve, and do not `--admin` past -protection. - -Give them what they need to decide, in one message: the verification result, that the ui-kit PR is -green and needs only their merge, and that the cloud PR is blocked on the canary until a version -exists. - -When the release lands: - -1. **Confirm the version is real on npm**, rather than trusting a green workflow: - ```bash - npm view @cube-dev/ui-kit dist-tags --json # the new version should be `latest` - ``` -2. **Bump cloud off the canary** — all four packages plus `yarn.lock`: - ```bash - cd && yarn update-uikit - grep -rn "canary" packages/*/package.json yarn.lock # must come back empty - ``` -3. **Re-run the consumer's checks.** The released build is not byte-identical to the canary, so - this is a real re-verification, not a formality — typecheck, lint, prettier, and the test suites. -4. **Push it to the cloud PR** as its own commit, so the history shows the canary being replaced - rather than quietly rewritten. Note in the message that it was re-verified against the released - build. -5. Then the consumer PR is mergeable — by the user, not by you. - -Expect a reviewer to flag the canary pin before this point, and do not treat it as something you -failed to fix — say explicitly that it is blocked on the release rather than leaving it looking -unaddressed. - -## Step 8 — Report - -Commit on the cloud branch and hand the user a review, not a summary of your activity: - -- The canary version installed, and the PR it came from. -- **Breakages found**, split into what typechecking caught and what it did not — the silent list - is the valuable half, and it is also feedback on the ui-kit PR itself (a prop that silently - stops working may deserve a migration note in the changeset). -- Every file migrated, grouped by kind of change. -- What you verified, and what you did not. -- Anything left pre-existing-broken, stated explicitly so it is not mistaken for fallout. -- **The one thing you need from them**, stated as a single ask rather than buried in status — the - Chromatic approval, the ui-kit approval, or the release. End on it. - -If the hunt turns up a genuine problem with the ui-kit PR — a breaking change presented as -backwards-compatible, a missing deprecation path — say so. That finding is worth more than the -migration. - ---- - -# Palette and token changes - -Everything above assumes the API surface changed. When the **palette** changes — a Glaze bump, a -seed retune, a `lightness`→`tone` migration — typechecking and the test suites tell you almost -nothing, because no test asserts on a colour that nobody wrote an assertion for. Measure instead. - -## Never eyeball a palette change; dump and diff it - -Resolve every token in **all four scheme variants** and diff against a baseline. Tasty needs a DOM, -so run it as a throwaway vitest spec (jsdom is already configured) rather than a plain node script: - -```ts -// src/__token-dump.test.ts — delete before committing -import { writeFileSync } from 'node:fs'; - -import { getPaletteTokens } from './tokens'; // cloud: '@/styles/palette' - -it('dumps', () => { - const t = getPaletteTokens() as Record>; - const out: Record = {}; - for (const k of Object.keys(t).sort()) - out[k] = Object.keys(t[k]) - .sort() - .map((s) => `${s}=${t[k][s]}`) - .join(' | '); - writeFileSync(process.env.TOKEN_DUMP_OUT!, JSON.stringify(out, null, 1)); - expect(Object.keys(out).length).toBeGreaterThan(50); -}); -``` - -Dump once per candidate (stash/patch the source between runs), then diff. To compare across a -format change (Glaze `0.x` emits `okhsl(...)`, `1.x` defaults to `oklch(...)`) a textual diff is -useless — convert both sides to RGB first and report a per-channel delta, grouped by token family -(`surface*` / `accent*` / `accent-disabled*`). A mean delta per group is what tells you whether a -change is a retune or a redesign. - -**Light mode alone will mislead you.** Text tokens usually carry `contrast: ['AA','AAA']`, and the -contrast solver — not the authored `tone` — fixes their value, so an authored-delta change looks -like a no-op in light mode and still moves the `@hc` / `@dark & @hc` variants. A relative tone that -_overshoots_ the window is often deliberate: it is what drives a token to the absolute extremes in -high contrast. Before "fixing" an inconsistent-looking delta, diff all four variants; if only HC -moves, you are about to trade away contrast where the user explicitly asked for more. - -## Long-lived ui-kit branches: canary drift - -A canary is only as current as its branch's last merge from `main`. A branch that has been open a -while will publish snapshots that **predate an API `main` shipped and cloud master already adopted** -— cloud then fails to typecheck against the canary even though neither side is individually wrong. - -Do not patch the cloud call sites and do not downgrade the pin. Fix it at the source: merge -`origin/main` into the ui-kit branch, push (the `Publish` workflow mints a fresh `pr_` canary), -then repin cloud. Check the branch first — `git log --oneline HEAD..origin/main` — and resolve the -inevitable `package.json` / lockfile conflict deliberately: keep the branch's `@tenphi/glaze` (it is -the branch's whole point) and take `main`'s newer `@tenphi/tasty`. - -A Glaze **major** bump is not a dependency bump. `1.x` removed `lightness` as a color-def input, so -the upgrade and the axis migration are one inseparable change — check `RegularColorDef` in -`node_modules/@tenphi/glaze/dist/index.d.mts` for what the version actually accepts before promising -a "just bump it" PR. - -## Cloud mirrors the recipe in three places — move them together - -Cloud does not import the ui-kit's theme builder, it **replicates** the recipe. A palette change has -to land in all of them or they silently diverge: - -| File | What it mirrors | -| ----------------------------------------------------------------------- | ------------------------------------------------------- | -| `packages/console-ui/src/styles/palette.ts` | seed + query/APM themes | -| `packages/sheets-ui/src/styles/palette.ts` | the query themes again, for the add-in | -| `packages/console-ui/src/modules/app-theme/engine/default-color-map.ts` | the ui-kit recipe verbatim, re-seeded per user accent | -| `.../app-theme/engine/types.ts` → `DEFAULT_APP_THEME` | accent/background/foreground read off the native tokens | -| `.../app-theme/engine/build-app-theme-tokens.ts` | the `glaze(hue, saturation, …)` re-seed call | - -`app-theme-tokens.spec.ts` compares the mirror to the shipped tokens within ±2/channel and will -catch a seed mismatch — but it only checks the handful of tokens named in its list, and a sub-±2 -divergence passes. Treat it as a smoke test, not proof, and re-read `DEFAULT_APP_THEME` from the -canary's own tokens whenever the palette moves. - -Two more traps specific to cloud: - -- **Per-hue saturation factors.** `apmThemes` scales the seed per hue (`SEED_SATURATION * 0.3`–`0.6`) - precisely because the theme is not pastel. A `pastel: true` variant can drop them — pastel - equalises chroma across hues — so copying a pastel palette onto a non-pastel seed silently makes - every lane resolve at full saturation. -- **Baked e2e colour baselines.** `playwright/tests/probation/workbooks/table-alignment-and-coloring.spec.ts` - hardcodes member header colours with a ±10/channel tolerance. Re-read them from the running app - after a palette change; if they still pass, say so rather than re-baking blindly. - -## Splitting a palette PR - -If a palette PR has to be split so an upgrade can land ahead of a postponed redesign, verify the -split empirically — the intuitive cut is often wrong. Dropping `pastel` while leaving the seed at its -pastel value moved accents _further_ from `main` (mean Δ 43.7) than keeping pastel did (31.0); only -reverting the seed alongside it brought the delta down (13.3). Seed and `pastel` move together or not -at all. Copying files wholesale also drags unrelated changes along — audit every `package.json` and -lockfile diff down to the lines you meant to change, and re-run `yarn install` after reverting a -dependency so the lockfile agrees with the manifest. diff --git a/.size-limit.cjs b/.size-limit.cjs index 05081d4d0..adce8e4cc 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,20 +20,24 @@ module.exports = [ }), ); }, - // 464.27 kB at the time of writing. Raised from 462 kB for Board selection - // and group movement: ~3.5 kB of engine (a rigid multi-item move primitive, - // selection state, marquee hit-testing, a live region) plus ~0.5 kB for the - // six `board.*` strings across twelve locales, which are all registered - // eagerly. Measured by building with and without the locale keys. + // 467.87 kB at the time of writing. Two features stack here: + // + // - Board selection and group movement raised it from 462 kB: ~3.5 kB of + // engine (a rigid multi-item move primitive, selection state, marquee + // hit-testing, a live region) plus ~0.5 kB for the six `board.*` strings + // across twelve locales, which are all registered eagerly. Measured by + // building with and without the locale keys. + // - `ColorPicker` adds ~3.8 kB of component, color model and channel + // definitions. // // The Button budget below is unchanged, which is the check that matters: - // none of this reaches a consumer who does not import `Board`. + // none of this reaches a consumer who imports neither. // // Headroom is deliberately small so real bloat still trips the budget. // // Note when checking locally: `size-limit` bundles the built `./dist`, it // does not build. Run `pnpm build` first or you will measure a stale bundle. - limit: '466kB', + limit: '469kB', }, { name: 'Tree shaking (just a Button)', diff --git a/src/components/fields/ColorPicker/ColorPicker.docs.mdx b/src/components/fields/ColorPicker/ColorPicker.docs.mdx new file mode 100644 index 000000000..42e473ace --- /dev/null +++ b/src/components/fields/ColorPicker/ColorPicker.docs.mdx @@ -0,0 +1,283 @@ +import { Meta, Story } from '@storybook/addon-docs/blocks'; + +import * as ColorPickerStories from './ColorPicker.stories.tsx'; + + + +# ColorPicker + +An input for a single color. It reads and writes color **text** — hex, `rgb()`, +`hsl()`, `okhsl()`, `okhst()` and `oklch()` — shows the current color as a +swatch, and opens a popover where the color can be dialed in on three +perceptual axes. + +Every conversion goes through [Glaze](https://github.com/tenphi/glaze), so the +canonical value the picker holds is OKHSL: bounded on every channel, and always +inside the sRGB gamut. That is what makes an out-of-gamut state impossible and +lets the same value be re-serialized into any of the supported notations without +drift. + +## When to Use + +- Theme and branding forms — a seed color, an accent, a chart series color +- Anywhere a color has to be typed *or* explored, rather than chosen from a + fixed palette +- When the stored format matters (a design token file wants `oklch()`, a legacy + API wants hex) and the input has to guarantee it + +Reach for a `RadioGroup` or `Picker` of swatches instead when the choice is +limited to a handful of approved colors. + +## Component + + + +--- + +### Properties + +- **`value`** `string | null` — The selected color (controlled) +- **`defaultValue`** `string | null` — The selected color (uncontrolled) +- **`onChange`** `(value: string | null) => void` — Fired with the color string, or `null` when the field is cleared +- **`format`** `'hex' | 'rgb' | 'hsl' | 'okhsl' | 'okhst' | 'oklch'` (default: `hex`) — Notation the value is written in, and the one `forced` mode displays +- **`formatMode`** `'forced' | 'derive' | 'free'` (default: `forced`) — How strictly the input text is tied to `format` +- **`defaultSpace`** `'hst' | 'lch' | 'rgb'` (default: `hst`) — Color concept the popover opens with +- **`placeholder`** `string` (default: `Pick a color`) — Text shown while the field is empty +- **`size`** `'small' | 'medium' | 'large' | (string & {})` (default: `medium`) — Input size +- **`isOpen`** `boolean` — Whether the popover is open (controlled) +- **`defaultOpen`** `boolean` (default: `false`) — Whether the popover is open initially +- **`onOpenChange`** `(isOpen: boolean) => void` — Fired when the popover opens or closes +- **`shouldFlip`** `boolean` (default: `true`) — Whether the popover may flip to the other side of the input +- **`onFocus`** `(event: FocusEvent) => void` — Fired when the text input receives focus +- **`onBlur`** `(event: FocusEvent) => void` — Fired when the text input loses focus + +Named CSS colors (`red`, `rebeccapurple`) are **not** accepted — they are not a +color space, and Glaze does not resolve them either. Alpha is parsed but +dropped: the value is always opaque. + +Focusing the field selects the whole value, so a pasted color replaces it +outright. The string is the unit of editing here: a single channel is tuned with +the sliders rather than by hand-editing one number inside `oklch(…)`. Clicking a +field that already has focus positions the caret as usual, so the text stays +editable by hand. + +### Base Properties + +Supports [Base properties](/docs/getting-started-base-properties--docs) + +### Field Properties + +Supports all [Field properties](/docs/getting-started-field-properties--docs) + +### Styling Properties + +#### styles + +Customizes the input wrapper — the element that owns the border, fill and size. + +**Sub-elements:** + +- `Prefix` — the container holding the color swatch +- `Suffix` — the container holding the validation state, then the popover trigger +- `InputIcon` — the swatch slot +- `State` — the validation / loading indicator + +#### inputStyles + +Customizes the `` element itself. + +#### triggerStyles + +Customizes the popover trigger button. + +#### swatchStyles + +Customizes the color swatch shown inside the input. + +### Style Properties + +These properties allow direct style application without using the `styles` prop: + +- **Base:** `display`, `font`, `preset`, `hide`, `whiteSpace`, `opacity`, `transition` +- **Position:** `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `scrollMargin` +- **Dimension:** `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex` +- **Block:** `border`, `radius`, `shadow`, `outline`, `padding`, `paddingInline`, `paddingBlock`, `overflow`, `scrollbar`, `textAlign` +- **Color:** `color`, `fill`, `fade`, `image` + +### Modifiers + +The `mods` property accepts the following modifiers you can override: + +| Modifier | Type | Description | +| ---------- | --------- | ------------------------------------------------- | +| `focused` | `boolean` | The text input has focus | +| `hovered` | `boolean` | The pointer is over the input | +| `disabled` | `boolean` | The picker is disabled | +| `valid` | `boolean` | The field is in the valid state | +| `invalid` | `boolean` | The field is in the invalid state | +| `prefix` | `boolean` | A prefix (the swatch) is rendered | +| `suffix` | `boolean` | A suffix (the trigger) is rendered | + +The swatch carries its own `empty` modifier while there is no color to show. + +## Format modes + +The three modes differ in how much freedom the *text* gets. The value is always +a real color in every one of them. + + + +| Mode | Text | Value | +| -------- | ----------------------------------- | ---------------------------------------------------- | +| `forced` | Rewritten in `format` on blur/Enter | Always written in `format` | +| `derive` | Left exactly as typed | Normalized in the notation the text is written in | +| `free` | Left exactly as typed | The text itself, verbatim | + +In every mode an entry that is not a color is rejected: the previous valid color +stays the value while it is being typed, and the text snaps back to it on blur. +Emptying the field is not an error — it commits `null`. + +```jsx +// A design-token form that must store OKLCH + + +// A CSS editor: keep whatever the author wrote, but guarantee it parses + +``` + +## Color spaces + +The popover edits one canonical color through three sets of axes: + +| Space | Axes | Good for | +| ----- | ----------------------------- | ----------------------------------------------------- | +| `hst` | Hue, Saturation, Tone (OKHST) | Building ramps — equal tone steps read as equal steps | +| `lch` | Lightness, Chroma, Hue (OKLCH)| Matching a value from a design-token file | +| `rgb` | Red, Green, Blue | Matching a value from a legacy palette or a screenshot| + +Each of these opens on its own space; the switcher inside the popover moves +between them at any time. + + + +`H` means the same angle in HST and LCH, so the hue strip is identical in both. +The chroma slider is bounded by the sRGB gamut at the current lightness and hue, +which is why its range changes as `L` moves. + +## Examples + +### Basic usage + +```jsx + +``` + +### Controlled + +```jsx +const [color, setColor] = useState('#7a4dbf'); + +; +``` + +### Inside a form + +```jsx +
+ + +``` + +### Opening on a specific space + +```jsx + +``` + +### Using the value as a style + +Because the emitted value is a plain color string, it can go straight into a +tasty style — including the `okhsl()` and `okhst()` notations, which tasty +parses natively. + +```jsx +const [color, setColor] = useState('okhst(264 80% 60%)'); + +<> + + +; +``` + +## Accessibility + +### Keyboard Navigation + +- `Tab` — moves focus to the text input, then to the popover trigger +- `Enter` — commits and normalizes the typed color without leaving the field +- `Space` / `Enter` on the trigger — opens the popover +- `Escape` — closes the popover +- `Left` / `Right`, `Home` / `End` on a channel slider — moves that channel +- `Left` / `Right` on the space switcher — moves between HST, LCH and RGB + +### Screen Reader Support + +- The text input announces as a textbox with the field's label +- The trigger announces as "Open the color picker" +- The popover announces as a dialog named "Color picker" +- Each channel slider sits in a group named after its axis (`Hue`, + `Saturation`, `Tone`, `Lightness`, `Chroma`, `Red`, `Green`, `Blue`) +- The swatch is decorative and is not announced — the text carries the value + +### ARIA Properties + +- `aria-label` — names the field when there is no visible label +- `aria-describedby` — wired to the field description by the form system + +## Best Practices + +1. **Do**: name the field, so the text input is announced meaningfully + + ```jsx + + ``` + +2. **Don't**: rely on the swatch alone to convey the value + + ```jsx + + ``` + +3. **Do**: pick the `format` your backend stores, and leave `formatMode` at + `forced` so the two can never disagree. + +4. **Don't**: use `free` mode when the value is later parsed by something + stricter than a browser — the text is passed through as written. + +## Integration with Forms + +This component supports all +[Field properties](/docs/getting-started-field-properties--docs) when used +within a Form. The field value is the color string, and clearing the input +stores `null`, which makes `required` rules behave as expected. + +## Suggested Improvements + +- An eyedropper that samples a pixel from the page, where + [`EyeDropper`](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) is + available +- Optional alpha support, which the canonical value currently drops +- A row of recent or preset swatches inside the popover +- Editable numeric inputs next to each channel slider, for exact entry without + going through the text field + +## Related Components + +- [HueSlider](/docs/forms-hueslider--docs) — just the hue axis, when only a hue is needed +- [Slider](/docs/forms-slider--docs) — the primitive each channel is built from +- [TextInput](/docs/forms-textinput--docs) — the input chrome this component reuses diff --git a/src/components/fields/ColorPicker/ColorPicker.stories.tsx b/src/components/fields/ColorPicker/ColorPicker.stories.tsx new file mode 100644 index 000000000..900c6c22b --- /dev/null +++ b/src/components/fields/ColorPicker/ColorPicker.stories.tsx @@ -0,0 +1,277 @@ +import { StoryFn } from '@storybook/react-vite'; +import { useState } from 'react'; +import { userEvent, within } from 'storybook/test'; + +import { VALIDATION_ARGS } from '../../../stories/FormFieldArgs'; +import { baseProps } from '../../../stories/lists/baseProps'; +import { Text } from '../../content/Text'; +import { Title } from '../../content/Title'; +import { Flow } from '../../layout/Flow'; +import { Space } from '../../layout/Space'; + +import { COLOR_FORMATS } from './color'; +import { ColorPicker, CubeColorPickerProps } from './ColorPicker'; + +export default { + title: 'Forms/ColorPicker', + component: ColorPicker, + parameters: { + controls: { + exclude: baseProps, + }, + }, + args: { + width: '30x', + }, + argTypes: { + /* Content */ + value: { + control: { type: 'text' }, + description: 'The selected color in controlled mode', + }, + defaultValue: { + control: { type: 'text' }, + description: 'The selected color in uncontrolled mode', + }, + placeholder: { + control: { type: 'text' }, + description: 'Text shown while the field is empty', + table: { defaultValue: { summary: 'Pick a color' } }, + }, + + /* Presentation */ + format: { + options: [...COLOR_FORMATS], + control: { type: 'radio' }, + description: 'Notation the value is written in', + table: { defaultValue: { summary: 'hex' } }, + }, + formatMode: { + options: ['forced', 'derive', 'free'], + control: { type: 'radio' }, + description: 'How strictly the input text is tied to `format`', + table: { defaultValue: { summary: 'forced' } }, + }, + defaultSpace: { + options: ['hst', 'lch', 'rgb'], + control: { type: 'radio' }, + description: 'Color concept the popover opens with', + table: { defaultValue: { summary: 'hst' } }, + }, + size: { + options: ['small', 'medium', 'large'], + control: { type: 'radio' }, + description: 'Input size', + table: { defaultValue: { summary: 'medium' } }, + }, + shouldFlip: { + control: { type: 'boolean' }, + description: 'Whether the popover may flip to the other side', + table: { defaultValue: { summary: true } }, + }, + + /* State */ + isOpen: { + control: { type: 'boolean' }, + description: 'Whether the popover is open (controlled)', + }, + defaultOpen: { + control: { type: 'boolean' }, + description: 'Whether the popover is open initially', + table: { defaultValue: { summary: false } }, + }, + isDisabled: { + control: { type: 'boolean' }, + description: 'Whether the picker is disabled', + table: { defaultValue: { summary: false } }, + }, + isReadOnly: { + control: { type: 'boolean' }, + description: 'Whether the color can be read but not changed', + table: { defaultValue: { summary: false } }, + }, + isRequired: { + control: { type: 'boolean' }, + description: 'Whether a color is required before form submission', + table: { defaultValue: { summary: false } }, + }, + isLoading: { + control: { type: 'boolean' }, + description: 'Show loading spinner and disable interactions', + table: { defaultValue: { summary: false } }, + }, + ...VALIDATION_ARGS, + autoFocus: { + control: { type: 'boolean' }, + description: 'Whether the element should receive focus on render', + table: { defaultValue: { summary: false } }, + }, + + /* Events */ + onChange: { + action: 'change', + description: 'Callback fired when the color changes', + control: { type: null }, + }, + onOpenChange: { + action: 'open-change', + description: 'Callback fired when the popover opens or closes', + control: { type: null }, + }, + onBlur: { action: 'blur', control: { type: null } }, + onFocus: { action: 'focus', control: { type: null } }, + + /* Styling */ + inputStyles: { + control: { type: null }, + table: { type: { summary: 'Styles' } }, + }, + triggerStyles: { + control: { type: null }, + table: { type: { summary: 'Styles' } }, + }, + swatchStyles: { + control: { type: null }, + table: { type: { summary: 'Styles' } }, + }, + }, +}; + +const Template: StoryFn = (props) => ( + +); + +export const Default = Template.bind({}); +Default.args = {}; + +export const WithValue = Template.bind({}); +WithValue.args = { defaultValue: '#26fcb2' }; + +export const WithLabel = Template.bind({}); +WithLabel.args = { + label: 'Brand color', + description: 'Any hex, rgb, hsl, okhsl, okhst or oklch notation works.', + defaultValue: '#7a4dbf', +}; + +export const Formats: StoryFn = (args) => ( + + {COLOR_FORMATS.map((format) => ( + + ))} + +); +Formats.parameters = { + docs: { + description: { + story: + 'With the default `forced` mode the text is always rewritten in `format`.', + }, + }, +}; + +export const FormatModes: StoryFn = (args) => { + const [forced, setForced] = useState('rgb(122, 77, 191)'); + const [derived, setDerived] = useState('rgb(122, 77, 191)'); + const [free, setFree] = useState('rgb(122, 77, 191)'); + + return ( + + + {JSON.stringify(forced)} + + + {JSON.stringify(derived)} + + + {JSON.stringify(free)} + + ); +}; +FormatModes.parameters = { + docs: { + description: { + story: + 'All three start from the same loosely written color. `forced` normalizes the text, `derive` keeps the notation but normalizes the value, and `free` passes the text through untouched.', + }, + }, +}; + +export const Spaces: StoryFn = (args) => ( + + + + + +); +Spaces.args = { defaultValue: '#26fcb2', width: '20x' }; +Spaces.parameters = { + docs: { + description: { + story: + 'Each picker opens on its own space. Only one popover can be open at a time, so open them one by one to compare.', + }, + }, +}; + +export const Sizes: StoryFn = (args) => ( + + + + + +); +Sizes.args = { defaultValue: '#7a4dbf' }; + +export const Disabled = Template.bind({}); +Disabled.args = { defaultValue: '#7a4dbf', isDisabled: true }; + +export const ReadOnly = Template.bind({}); +ReadOnly.args = { defaultValue: '#7a4dbf', isReadOnly: true }; + +export const Validation: StoryFn = (args) => ( + + Valid State + + + Invalid State + + +); + +export const Open = Template.bind({}); +Open.args = { defaultValue: '#7a4dbf', defaultOpen: true }; + +export const OpensOnTrigger: StoryFn = (args) => ( + +); +OpensOnTrigger.args = { defaultValue: '#7a4dbf' }; +OpensOnTrigger.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole('button')); +}; diff --git a/src/components/fields/ColorPicker/ColorPicker.test.tsx b/src/components/fields/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 000000000..da48bd495 --- /dev/null +++ b/src/components/fields/ColorPicker/ColorPicker.test.tsx @@ -0,0 +1,445 @@ +import { + act, + createEvent, + fireEvent, + renderWithForm, + renderWithRoot, + userEvent, + waitFor, +} from '../../../test'; + +import { ColorPicker } from './ColorPicker'; + +vi.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + it('shows the color as text and as a swatch', () => { + const { getByRole, getByTestId } = renderWithRoot( + , + ); + + expect(getByRole('textbox')).toHaveValue('#26fcb2'); + expect(getByTestId('ColorSwatch')).toHaveStyle({ + '--color-picker-color': '#26fcb2', + }); + }); + + it.each([['isValid'], ['isInvalid']])( + 'renders the %s indicator left of the trigger', + (state) => { + const { getByRole, container } = renderWithRoot( + , + ); + const indicator = container.querySelector('[data-element="State"]')!; + const trigger = getByRole('button', { name: /color picker/i }); + + expect(indicator).toBeInTheDocument(); + expect( + indicator.compareDocumentPosition(trigger) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }, + ); + + it('marks the swatch empty without a color', () => { + const { getByTestId } = renderWithRoot(); + + expect(getByTestId('ColorSwatch')).toHaveAttribute('data-empty'); + }); + + describe('text entry', () => { + it('reads every supported notation', async () => { + const onChange = vi.fn(); + const { getByRole } = renderWithRoot( + , + ); + + for (const notation of [ + 'rgb(255 0 0)', + 'hsl(0 100% 50%)', + 'okhsl(29.23 100% 56.81%)', + 'okhst(29.23 100% 58.59%)', + 'oklch(0.628 0.2577 29.23)', + ]) { + onChange.mockClear(); + await userEvent.clear(getByRole('textbox')); + await userEvent.paste(notation); + + expect(onChange).toHaveBeenLastCalledWith('#ff0000'); + } + }); + + const LONG_COLOR = 'oklch(0.5276 0.172 298.52)'; + + // Asserting the resulting selection range would prove nothing: tabbing into + // a text input selects its contents natively, so such a test passes even + // when the focus handler is never wired up. Spying on the call is what + // pins the behavior to this component. + it.each([ + ['a pointer', (input: HTMLElement) => userEvent.click(input)], + ['the keyboard', () => userEvent.tab()], + ])( + 'offers the whole value up for replacement when %s focuses it', + async (_, focusIt) => { + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox') as HTMLInputElement; + const select = vi.spyOn(input, 'select'); + + await focusIt(input); + + expect(input).toHaveFocus(); + expect(select).toHaveBeenCalled(); + }, + ); + + it('suppresses only the press that takes focus', async () => { + // The browser applies a click's caret after the focus handler, which would + // undo the selection. Defaulting that press away is what stops it, so the + // input must focus itself instead — and a press on an already-focused + // field has to stay untouched, or the caret could never be positioned. + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox') as HTMLInputElement; + + const taking = createEvent.mouseDown(input); + fireEvent(input, taking); + + expect(taking.defaultPrevented).toBe(true); + expect(input).toHaveFocus(); + + const afterwards = createEvent.mouseDown(input); + fireEvent(input, afterwards); + + expect(afterwards.defaultPrevented).toBe(false); + }); + + it('normalizes the text on blur in `forced` mode', async () => { + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('rgb(255 0 0)'); + + // Still exactly what was typed while the field is being edited. + expect(input).toHaveValue('rgb(255 0 0)'); + + await userEvent.tab(); + + expect(input).toHaveValue('#ff0000'); + }); + + it('keeps the notation and normalizes the value in `derive` mode', async () => { + const onChange = vi.fn(); + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('RGB(255, 0, 0)'); + await userEvent.tab(); + + expect(input).toHaveValue('RGB(255, 0, 0)'); + expect(onChange).toHaveBeenLastCalledWith('rgb(255 0 0)'); + }); + + it('passes the text through untouched in `free` mode', async () => { + const onChange = vi.fn(); + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('RGB(255, 0, 0)'); + await userEvent.tab(); + + expect(input).toHaveValue('RGB(255, 0, 0)'); + expect(onChange).toHaveBeenLastCalledWith('RGB(255, 0, 0)'); + }); + + it('holds the last valid color while the text is unparsable', async () => { + const onChange = vi.fn(); + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('nonsense'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('reverts to the last valid color on blur', async () => { + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.tripleClick(input); + await userEvent.paste('not a color'); + await userEvent.tab(); + + expect(input).toHaveValue('#ff0000'); + }); + + it('treats an emptied field as a deliberate "no color"', async () => { + const { getByRole } = renderWithRoot( + , + ); + const input = getByRole('textbox'); + + await userEvent.clear(input); + await userEvent.paste('not a color'); + await userEvent.tab(); + + expect(input).toHaveValue(''); + }); + + it('falls back to empty when there is no valid color to revert to', async () => { + const { getByRole } = renderWithRoot(); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('not a color'); + await userEvent.tab(); + + expect(input).toHaveValue(''); + }); + + it('normalizes on Enter without leaving the field', async () => { + const { getByRole } = renderWithRoot(); + const input = getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('rgb(255 0 0)'); + await userEvent.keyboard('{Enter}'); + + expect(input).toHaveValue('#ff0000'); + expect(input).toHaveFocus(); + }); + + it('emits null when cleared', async () => { + const onChange = vi.fn(); + const { getByRole, getByTestId } = renderWithRoot( + , + ); + + await userEvent.clear(getByRole('textbox')); + + expect(onChange).toHaveBeenLastCalledWith(null); + expect(getByTestId('ColorSwatch')).toHaveAttribute('data-empty'); + }); + + it('adopts a value set from the outside', async () => { + const { getByRole, rerender } = renderWithRoot( + , + ); + + expect(getByRole('textbox')).toHaveValue('#ff0000'); + + rerender(); + + await waitFor(() => expect(getByRole('textbox')).toHaveValue('#26fcb2')); + }); + }); + + describe('popover', () => { + const channels = () => + Array.from( + document.querySelectorAll('[data-input-type="slider"]'), + (el) => el.getAttribute('aria-label'), + ); + + it('opens and closes from the trigger', async () => { + const onOpenChange = vi.fn(); + const { getByRole, queryByRole } = renderWithRoot( + , + ); + const trigger = getByRole('button', { name: /color picker/i }); + + expect(queryByRole('dialog')).not.toBeInTheDocument(); + + await userEvent.click(trigger); + + await waitFor(() => expect(queryByRole('dialog')).toBeInTheDocument()); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + await userEvent.click(trigger); + + // Wait for the exit animation to unmount the popover. + await waitFor(() => + expect( + document.querySelector('[role="dialog"]'), + ).not.toBeInTheDocument(), + ); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }, 10000); + + it('offers one slider per channel of the active space', async () => { + const { getByRole } = renderWithRoot( + , + ); + + await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); + + expect(channels()).toEqual(['Hue', 'Saturation', 'Tone']); + + await userEvent.click(getByRole('radio', { name: 'RGB' })); + + expect(channels()).toEqual(['Red', 'Green', 'Blue']); + + await userEvent.click(getByRole('radio', { name: 'LCH' })); + + expect(channels()).toEqual(['Lightness', 'Chroma', 'Hue']); + }, 10000); + + it('opens on the space named by `defaultSpace`', async () => { + const { getByRole } = renderWithRoot( + , + ); + + await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); + + expect(channels()).toEqual(['Lightness', 'Chroma', 'Hue']); + }, 10000); + + it('writes a channel change straight back into the input', async () => { + const onChange = vi.fn(); + const { getAllByRole, getByRole } = renderWithRoot( + , + ); + + await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); + + const [, green] = getAllByRole('slider'); + + await act(async () => { + fireEvent.change(green, { target: { value: '200' } }); + }); + + expect(onChange).toHaveBeenLastCalledWith('#7ac8bf'); + expect(getByRole('textbox')).toHaveValue('#7ac8bf'); + }); + + it('writes the channel change in the notation of the current text', async () => { + const onChange = vi.fn(); + const { getAllByRole, getByRole } = renderWithRoot( + , + ); + + await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); + + const [, green] = getAllByRole('slider'); + + await act(async () => { + fireEvent.change(green, { target: { value: '200' } }); + }); + + expect(onChange).toHaveBeenLastCalledWith('rgb(122 200 191)'); + }); + + it('does not open while disabled', async () => { + const { getByRole, queryByRole } = renderWithRoot( + , + ); + + await userEvent.click(getByRole('button', { name: /color picker/i })); + + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('form integration', () => { + it('registers the field and publishes the normalized color', async () => { + const { getByRole, formInstance } = renderWithForm( + , + ); + + await userEvent.click(getByRole('textbox')); + await userEvent.paste('rgb(255 0 0)'); + + expect(formInstance.getFieldValue('brand')).toBe('#ff0000'); + }); + + it('takes its initial value from the form', () => { + const { getByRole } = renderWithForm( + , + { formProps: { defaultValues: { brand: 'rgb(38 252 178)' } } }, + ); + + expect(getByRole('textbox')).toHaveValue('#26fcb2'); + }); + + it('reports a validation error', async () => { + const { getByRole, getByText } = renderWithForm( + , + ); + + await userEvent.click(getByRole('textbox')); + await userEvent.paste('#ff0000'); + await userEvent.clear(getByRole('textbox')); + await userEvent.tab(); + + await waitFor(() => + expect(getByText('Pick a color')).toBeInTheDocument(), + ); + }); + }); +}); diff --git a/src/components/fields/ColorPicker/ColorPicker.tsx b/src/components/fields/ColorPicker/ColorPicker.tsx new file mode 100644 index 000000000..d588847fb --- /dev/null +++ b/src/components/fields/ColorPicker/ColorPicker.tsx @@ -0,0 +1,404 @@ +import { + BaseProps, + BaseStyleProps, + BlockStyleProps, + ColorStyleProps, + OuterStyleProps, + Styles, + tasty, +} from '@tenphi/tasty'; +import { + FocusEvent, + ForwardedRef, + forwardRef, + KeyboardEvent, + MouseEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { useTextField } from 'react-aria'; + +import { useEvent } from '../../../_internal'; +import { PipetteIcon } from '../../../icons'; +import { FieldBaseProps } from '../../../shared'; +import { mergeProps } from '../../../utils/react'; +import { ItemAction } from '../../actions'; +import { useFieldProps } from '../../form'; +import { Dialog, DialogTrigger } from '../../overlays/Dialog'; +import { TextInputBase } from '../TextInput/TextInputBase'; + +import { ColorSpace } from './channels'; +import { + ColorFormat, + ColorValue, + detectFormat, + formatColor, + parseColor, + toHex, +} from './color'; +import { ColorPickerPanel } from './ColorPickerPanel'; + +/** + * How the text in the input relates to the committed value. + * + * - `forced` — the text is rewritten in `format` whenever it is not mid-edit, + * and the value is always written in `format`. + * - `derive` — the user's own notation is left alone, and the value is + * normalized in whichever notation the text is written in. + * - `free` — the text *is* the value, verbatim. It is still verified, so an + * unparsable entry falls back to the last valid one. + */ +export type ColorPickerFormatMode = 'forced' | 'derive' | 'free'; + +/** What the popover starts from when there is no color to edit yet. */ +const FALLBACK_COLOR: ColorValue = { h: 264, s: 0.8, l: 0.6 }; + +const SwatchElement = tasty({ + qa: 'ColorSwatch', + styles: { + display: 'block', + width: '2.5x', + height: '2.5x', + radius: '1r', + fill: { + '': '(#color-picker, #clear)', + empty: '#clear', + }, + shadow: 'inset 0 0 0 1bw #dark.15', + // A single diagonal stroke is the conventional "no color" swatch. + image: { + '': false, + empty: + 'linear-gradient(to bottom right, #clear 46%, #danger 46%, #danger 54%, #clear 54%)', + }, + }, +}); + +const ColorPickerButton = tasty(ItemAction, { + qa: 'ColorPickerButton', + icon: , +}); + +export interface CubeColorPickerProps + extends BaseProps, + BaseStyleProps, + OuterStyleProps, + BlockStyleProps, + ColorStyleProps, + FieldBaseProps { + /** The selected color, as a color string. */ + value?: string | null; + /** The initial color of an uncontrolled picker. */ + defaultValue?: string | null; + /** Called with the normalized color string, or `null` when the field is cleared. */ + onChange?: (value: string | null) => void; + /** Notation the value is written in, and the one `forced` mode displays. */ + format?: ColorFormat; + /** How strictly the input text is tied to `format`. */ + formatMode?: ColorPickerFormatMode; + /** Color concept the popover opens with. */ + defaultSpace?: ColorSpace; + /** Whether the popover is open. Makes the disclosure controlled. */ + isOpen?: boolean; + /** Whether the popover is open initially. */ + defaultOpen?: boolean; + /** Called when the popover opens or closes. */ + onOpenChange?: (isOpen: boolean) => void; + /** Whether the popover may flip to the other side of the input. */ + shouldFlip?: boolean; + /** Text shown while the field is empty. */ + placeholder?: string; + /** The size of the input. */ + size?: 'small' | 'medium' | 'large' | (string & {}); + onFocus?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; + styles?: Styles; + /** Styles of the text input element. */ + inputStyles?: Styles; + /** Styles of the popover trigger button. */ + triggerStyles?: Styles; + /** Styles of the color swatch shown inside the input. */ + swatchStyles?: Styles; +} + +/** + * A color field: the color as text, a swatch of the current value, and a + * popover to dial it in across OKHST, OKLCH and RGB. + */ +export const ColorPicker = forwardRef(function ColorPicker( + allProps: CubeColorPickerProps, + ref: ForwardedRef, +) { + const props = useFieldProps(allProps, { + defaultValidationTrigger: 'onBlur', + valuePropsMapper: ({ value, onChange }) => ({ + value: value as string | null | undefined, + onChange, + }), + }); + + const { + qa, + value, + defaultValue, + onChange, + format = 'hex', + formatMode = 'forced', + defaultSpace = 'hst', + isOpen: controlledOpen, + defaultOpen, + onOpenChange, + shouldFlip, + placeholder = 'Pick a color', + size, + isDisabled, + isReadOnly, + isInvalid, + isValid, + isLoading, + autoFocus, + inputStyles, + triggerStyles, + swatchStyles, + labelProps: userLabelProps, + onBlur: userOnBlur, + onFocus, + label, + description, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + id, + } = props; + + const initialText = (value ?? defaultValue ?? '').toString(); + const [text, setText] = useState(() => { + const parsed = parseColor(initialText); + + return parsed && formatMode === 'forced' + ? formatColor(parsed, format) + : initialText; + }); + const [color, setColor] = useState(() => parseColor(initialText)); + const [space, setSpace] = useState(defaultSpace); + const [isOpen, setOpen] = useState(defaultOpen ?? false); + + const targetRef = useRef(null); + const inputRef = useRef(null); + /** The last text that parsed, so an invalid entry has something to fall back to. */ + const validText = useRef(parseColor(initialText) ? text : ''); + /** What was last handed to `onChange`, to tell our own updates from outside ones. */ + const emitted = useRef(value); + + const emit = useEvent((next: string | null) => { + emitted.current = next; + onChange?.(next); + }); + + /** The notation a value should be written in, given the current text. */ + const outputFormat = (source: string): ColorFormat => + formatMode === 'forced' ? format : detectFormat(source) ?? format; + + // Adopt values that did not come from here — a form reset, or a parent that + // overrides what the user picked. + useEffect(() => { + if (value === undefined || value === emitted.current) return; + + emitted.current = value; + + const raw = (value ?? '').toString(); + const parsed = parseColor(raw); + const nextText = + parsed && formatMode === 'forced' ? formatColor(parsed, format) : raw; + + setColor(parsed); + setText(nextText); + validText.current = parsed ? nextText : ''; + }, [value, format, formatMode]); + + const handleTextChange = useEvent((nextText: string) => { + setText(nextText); + + if (!nextText.trim()) { + setColor(null); + validText.current = ''; + emit(null); + + return; + } + + const parsed = parseColor(nextText); + + // Half-typed input keeps the last valid color, so the value stays a real + // color at every keystroke. + if (!parsed) return; + + setColor(parsed); + validText.current = nextText; + emit( + formatMode === 'free' + ? nextText.trim() + : formatColor(parsed, outputFormat(nextText)), + ); + }); + + /** Resolve whatever is in the input into a real color, or undo it. */ + const settle = useEvent(() => { + if (!text.trim()) return; + + const parsed = parseColor(text); + + if (!parsed) { + setText(validText.current); + + return; + } + + if (formatMode === 'forced') setText(formatColor(parsed, format)); + }); + + /** + * Focusing offers the whole value up for replacement, the way a hex field + * does. The string is the unit of editing here: a single channel is tuned + * with the sliders, not by hand-editing one number inside `oklch(…)`. + * + * Read off the event rather than `inputRef`, which `TextInputBase` re-points + * through `useCombinedRefs`. + */ + const handleFocus = useEvent((event: FocusEvent) => { + event.currentTarget.select(); + onFocus?.(event); + }); + + /** + * A click would otherwise drop a caret and undo that selection — the browser + * applies it after the focus handler has run. Suppressing the default action + * of the focusing press means no caret is ever placed; focus is then moved + * here instead, which selects. + * + * Only the press that *takes* focus is suppressed, so clicking a field that + * already has focus still positions the caret, and dragging still selects a + * range. + */ + const handleMouseDown = useEvent((event: MouseEvent) => { + if (document.activeElement === event.currentTarget) return; + + event.preventDefault(); + event.currentTarget.focus(); + }); + + const handleBlur = useEvent((event: FocusEvent) => { + settle(); + userOnBlur?.(event); + }); + + const handleKeyDown = useEvent((event: KeyboardEvent) => { + if (event.key === 'Enter') settle(); + }); + + const handleColorChange = useEvent((nextColor: ColorValue) => { + const nextText = formatColor(nextColor, outputFormat(text)); + + setColor(nextColor); + setText(nextText); + validText.current = nextText; + emit(nextText); + }); + + const handleOpenChange = useEvent((next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }); + + const { labelProps, inputProps } = useTextField( + { + id, + label, + description, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + isDisabled, + isReadOnly, + isInvalid, + autoFocus, + placeholder, + value: text, + type: 'text', + onChange: handleTextChange, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + }, + inputRef, + ); + + return ( + + } + inputRef={inputRef} + inputProps={{ + ...inputProps, + spellCheck: false, + onMouseDown: handleMouseDown, + }} + inputStyles={inputStyles} + labelProps={mergeProps(labelProps, userLabelProps)} + wrapperRef={targetRef} + isDisabled={isDisabled} + isReadOnly={isReadOnly} + isInvalid={isInvalid} + isValid={isValid} + isLoading={isLoading} + // The validation state reads left of the trigger, the way `DateInputBase` + // orders it for the date pickers. + suffixPosition="after" + suffix={ + + + + + + + } + /> + ); +}); + +(ColorPicker as any).cubeInputType = 'Text'; diff --git a/src/components/fields/ColorPicker/ColorPickerPanel.tsx b/src/components/fields/ColorPicker/ColorPickerPanel.tsx new file mode 100644 index 000000000..23ee0457d --- /dev/null +++ b/src/components/fields/ColorPicker/ColorPickerPanel.tsx @@ -0,0 +1,219 @@ +import { Styles, tasty } from '@tenphi/tasty'; +import { useMemo } from 'react'; + +import { useEvent } from '../../../_internal'; +import { Radio } from '../RadioGroup'; +import { Slider } from '../Slider'; + +import { + CHANNELS, + COLOR_SPACE_HINTS, + COLOR_SPACE_LABELS, + COLOR_SPACES, + ColorChannel, + ColorSpace, +} from './channels'; +import { + ColorFormat, + ColorValue, + formatColor, + getContrastingColor, + toHex, +} from './color'; + +const PanelElement = tasty({ + qa: 'ColorPickerPanel', + styles: { + display: 'grid', + flow: 'row', + gap: '1x', + padding: '1x', + width: '34x', + }, +}); + +const PreviewElement = tasty({ + qa: 'ColorPreview', + styles: { + display: 'grid', + placeItems: 'center', + height: '6x', + radius: true, + preset: 's4', + fill: '(#color-picker, #clear)', + color: '(#color-picker-contrast, #dark)', + shadow: 'inset 0 0 0 1bw #dark.1', + }, +}); + +const ChannelsElement = tasty({ + styles: { + display: 'grid', + gridColumns: 'max-content 1sf max-content', + placeItems: 'center stretch', + gap: '1x', + }, +}); + +const ChannelLabelElement = tasty({ + styles: { + preset: 'c2', + color: '#dark-03', + textAlign: 'center', + width: '2x', + }, +}); + +const ChannelTrackElement = tasty({ + styles: { + display: 'grid', + placeItems: 'center stretch', + }, +}); + +const ChannelValueElement = tasty({ + styles: { + preset: 's4', + color: '#dark-02', + textAlign: 'right', + whiteSpace: 'nowrap', + width: '5.5x', + }, +}); + +/** + * The gradient reaches the track through a plain custom property so the track + * keeps a single cached style rule. A color picker moves its channels + * continuously, and a fresh `styles` object per frame would emit a fresh CSS + * rule per frame. + */ +const TRACK_STYLES: Styles = { + height: '1x', + top: '.5x', + radius: '1r', + fill: '#clear', + image: '$channel-gradient', + shadow: 'inset 0 0 0 1bw #dark.1', + Fill: false, +}; + +/** A ring keeps the thumb visible wherever it sits on its own gradient. */ +const THUMB_STYLES: Styles = { + shadow: '0 0 0 2bw #surface, 0 0 0 3bw #dark.2', +}; + +const THUMB_TOKENS = { '#slider-thumb': '(#color-picker, #surface)' }; + +const SPACE_STYLES: Styles = { width: '100%' }; +const TAB_STYLES: Styles = { flexGrow: 1 }; + +export interface ColorPickerPanelProps { + color: ColorValue; + space: ColorSpace; + isDisabled?: boolean; + /** Notation used for the preview caption. */ + previewFormat: ColorFormat; + onChange: (color: ColorValue) => void; + onSpaceChange: (space: ColorSpace) => void; +} + +interface ChannelRowProps { + channel: ColorChannel; + color: ColorValue; + isDisabled?: boolean; + onChange: (color: ColorValue) => void; +} + +function ChannelRow({ channel, color, isDisabled, onChange }: ChannelRowProps) { + const max = channel.max(color); + + const handleChange = useEvent((value: number) => { + onChange(channel.apply(color, value)); + }); + + return ( + <> + + + + + {channel.display(color)} + + ); +} + +/** + * The popover body of `ColorPicker`: a preview, a switch between the three + * color concepts, and one gradient slider per channel of the active one. + */ +export function ColorPickerPanel(props: ColorPickerPanelProps) { + const { color, space, isDisabled, previewFormat, onChange, onSpaceChange } = + props; + + const colorTokens = useMemo( + () => ({ + '--color-picker-color': toHex(color), + '--color-picker-contrast-color': getContrastingColor(color), + }), + [color], + ); + + const handleSpaceChange = useEvent((value: string) => { + onSpaceChange(value as ColorSpace); + }); + + return ( + + {formatColor(color, previewFormat)} + + {COLOR_SPACES.map((item) => ( + + {COLOR_SPACE_LABELS[item]} + + ))} + + + {CHANNELS[space].map((channel) => ( + + ))} + + + ); +} diff --git a/src/components/fields/ColorPicker/channels.ts b/src/components/fields/ColorPicker/channels.ts new file mode 100644 index 000000000..4b7feb0cb --- /dev/null +++ b/src/components/fields/ColorPicker/channels.ts @@ -0,0 +1,208 @@ +import { + ColorValue, + fromOkhst, + fromOklch, + fromRgb, + isAchromatic, + maxChroma, + normalizeHue, + toHex, + toOkhst, + toOklch, + toRgb, +} from './color'; + +/** + * The three color concepts the popover can be driven by. They all edit the + * same canonical value — only the axes the user manipulates differ. + */ +export const COLOR_SPACES = ['hst', 'lch', 'rgb'] as const; + +export type ColorSpace = (typeof COLOR_SPACES)[number]; + +export const COLOR_SPACE_LABELS: Record = { + hst: 'HST', + lch: 'LCH', + rgb: 'RGB', +}; + +export const COLOR_SPACE_HINTS: Record = { + hst: 'OKHST — hue, saturation, tone', + lch: 'OKLCH — lightness, chroma, hue', + rgb: 'sRGB — red, green, blue', +}; + +export interface ColorChannel { + /** Single-letter axis name shown next to the slider. */ + label: string; + /** Accessible name for the slider. */ + title: string; + min: number; + step: number; + /** The upper bound. Chroma is gamut-bound, so it depends on the color. */ + max: (color: ColorValue) => number; + value: (color: ColorValue) => number; + /** The color this channel produces when moved to `value`. */ + apply: (color: ColorValue, value: number) => ColorValue; + /** Comma-separated gradient stops describing the channel's whole range. */ + stops: (color: ColorValue) => string; + /** Human-readable rendering of the current channel value. */ + display: (color: ColorValue) => string; +} + +const constant = (value: number) => () => value; + +function round(value: number, precision = 2): number { + const factor = 10 ** precision; + + return Math.round(value * factor) / factor; +} + +/** + * `count` evenly spaced gradient stops, always as hex. Perceptual channels are + * not linear in sRGB, so a two-stop gradient would misrepresent them — + * sampling the real conversion keeps the track honest. Hex (rather than an + * `okhsl()` string) because the gradient reaches the DOM as a raw custom + * property, without passing through Tasty's color functions. + */ +function ramp(count: number, at: (position: number) => ColorValue): string { + return Array.from({ length: count }, (_, index) => + toHex(at(index / (count - 1))), + ).join(', '); +} + +/** + * The hue strip is drawn at full saturation and a fixed OKHSL lightness, which + * keeps it perceptually even instead of spiking at yellow the way a CSS-HSL + * rainbow does. Its own saturation and tone stay fixed: the strip is the same + * in HST and in LCH, where `H` means the same thing. + */ +const HUE_STOPS = ramp(25, (position) => ({ + h: position * 360, + s: 1, + l: 0.6, +})); + +const hueChannel: ColorChannel = { + label: 'H', + title: 'Hue', + min: 0, + // 360° is the same angle as 0°, so the last usable stop is 359 — the range + // `HueSlider` exposes as well. + max: constant(359), + step: 1, + value: (color) => round(color.h, 1), + apply: (color, value) => ({ ...color, h: normalizeHue(value) }), + stops: () => HUE_STOPS, + display: (color) => `${Math.round(color.h)}°`, +}; + +const saturationChannel: ColorChannel = { + label: 'S', + title: 'Saturation', + min: 0, + max: constant(100), + step: 1, + value: (color) => round(color.s * 100, 1), + apply: (color, value) => ({ ...color, s: value / 100 }), + stops: (color) => ramp(7, (position) => ({ ...color, s: position })), + display: (color) => `${Math.round(color.s * 100)}%`, +}; + +const toneChannel: ColorChannel = { + label: 'T', + title: 'Tone', + min: 0, + max: constant(100), + step: 1, + value: (color) => round(toOkhst(color).t * 100, 1), + apply: (color, value) => + fromOkhst({ h: color.h, s: color.s, t: value / 100 }), + stops: (color) => + ramp(7, (position) => fromOkhst({ h: color.h, s: color.s, t: position })), + display: (color) => `${Math.round(toOkhst(color).t * 100)}%`, +}; + +const lightnessChannel: ColorChannel = { + label: 'L', + title: 'Lightness', + min: 0, + max: constant(100), + step: 1, + value: (color) => round(toOklch(color).l * 100, 1), + apply: (color, value) => { + const { c, h } = toOklch(color); + + return fromOklch({ l: value / 100, c, h }); + }, + stops: (color) => { + const { c, h } = toOklch(color); + + return ramp(7, (position) => fromOklch({ l: position, c, h })); + }, + display: (color) => `${Math.round(toOklch(color).l * 100)}%`, +}; + +const chromaChannel: ColorChannel = { + label: 'C', + title: 'Chroma', + min: 0, + // Chroma has no fixed ceiling: how far it can go depends on how far the sRGB + // gamut reaches at the current lightness and hue. Never zero, so the slider + // always has a range to divide by. + max: (color) => Math.max(round(maxChroma(color.h, color.l), 4), 0.001), + step: 0.001, + value: (color) => round(toOklch(color).c, 4), + apply: (color, value) => { + const { l, h } = toOklch(color); + + return fromOklch({ l, c: value, h }); + }, + stops: (color) => { + const { l, h } = toOklch(color); + const limit = maxChroma(color.h, color.l); + + return ramp(7, (position) => fromOklch({ l, c: position * limit, h })); + }, + display: (color) => toOklch(color).c.toFixed(3), +}; + +function rgbChannel( + key: 'r' | 'g' | 'b', + label: string, + title: string, +): ColorChannel { + const at = (color: ColorValue, value: number) => + fromRgb({ ...toRgb(color), [key]: value }); + + return { + label, + title, + min: 0, + max: constant(255), + step: 1, + value: (color) => toRgb(color)[key], + apply: (color, value) => { + const next = at(color, value); + + // A gray has no hue of its own, and the conversion invents one. Keeping + // the authored hue means switching back to HST or LCH resumes where the + // user left off. + return isAchromatic(next) ? { ...next, h: color.h } : next; + }, + // An sRGB channel is linear in the space CSS interpolates gradients in, so + // the two ends describe the whole ramp exactly. + stops: (color) => ramp(2, (position) => at(color, position * 255)), + display: (color) => `${toRgb(color)[key]}`, + }; +} + +export const CHANNELS: Record = { + hst: [hueChannel, saturationChannel, toneChannel], + lch: [lightnessChannel, chromaChannel, hueChannel], + rgb: [ + rgbChannel('r', 'R', 'Red'), + rgbChannel('g', 'G', 'Green'), + rgbChannel('b', 'B', 'Blue'), + ], +}; diff --git a/src/components/fields/ColorPicker/color.test.ts b/src/components/fields/ColorPicker/color.test.ts new file mode 100644 index 000000000..ddb6c0d90 --- /dev/null +++ b/src/components/fields/ColorPicker/color.test.ts @@ -0,0 +1,176 @@ +import { + ColorFormat, + detectFormat, + formatColor, + fromOklch, + maxChroma, + parseColor, + toHex, + toOklch, + toRgb, +} from './color'; + +describe('ColorPicker color model', () => { + describe('parseColor', () => { + it.each([ + ['#f00', '#ff0000'], + ['#FF0000', '#ff0000'], + ['#f008', '#ff0000'], + ['#ff000080', '#ff0000'], + ['rgb(255 0 0)', '#ff0000'], + ['rgb(255, 0, 0)', '#ff0000'], + ['rgba(255, 0, 0, 0.5)', '#ff0000'], + ['rgb(255 0 0 / 50%)', '#ff0000'], + ['rgb(100% 0% 0%)', '#ff0000'], + ['hsl(0 100% 50%)', '#ff0000'], + ['hsl(0deg 100% 50%)', '#ff0000'], + ['HSL(0, 100%, 50%)', '#ff0000'], + ['okhsl(29.23 100% 56.81%)', '#ff0000'], + ['okhst(29.23 100% 58.59%)', '#ff0000'], + ['oklch(0.628 0.2577 29.23)', '#ff0000'], + ])('reads %s', (input, hex) => { + const color = parseColor(input); + + expect(color).not.toBeNull(); + expect(toHex(color!)).toBe(hex); + }); + + it.each([ + [''], + [' '], + ['red'], + ['#ff'], + ['#gggggg'], + ['rgb(255 0)'], + ['rgb(255 0 0 0 0)'], + ['hsl(50% 100% 50%)'], + ['oklch(0.5 0.1)'], + ['lab(50% 20 30)'], + ['rgb(255 0 0'], + ['var(--color)'], + ])('rejects %s', (input) => { + expect(parseColor(input)).toBeNull(); + }); + + it('clamps out-of-range channels', () => { + expect(toHex(parseColor('rgb(300 -20 0)')!)).toBe('#ff0000'); + expect(toHex(parseColor('okhsl(0 500% 200%)')!)).toBe('#ffffff'); + }); + + it('rejects a long digit run without backtracking', () => { + // Guards the number pattern against polynomial backtracking: the digits + // can only be split one way, so a long non-match fails linearly. + const started = Date.now(); + + expect(parseColor(`rgb(${'9'.repeat(40_000)}x 0 0)`)).toBeNull(); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('wraps the hue angle', () => { + expect(parseColor('okhsl(420 100% 50%)')!.h).toBeCloseTo(60, 6); + expect(parseColor('okhsl(-60 100% 50%)')!.h).toBeCloseTo(300, 6); + }); + }); + + describe('formatColor', () => { + const red = parseColor('#ff0000')!; + + it.each([ + ['hex', '#ff0000'], + ['rgb', 'rgb(255 0 0)'], + ['hsl', 'hsl(0 100% 50%)'], + ['okhsl', 'okhsl(29.23 100% 56.81%)'], + ['okhst', 'okhst(29.23 100% 58.59%)'], + ['oklch', 'oklch(0.628 0.2577 29.23)'], + ])('writes %s', (format, expected) => { + expect(formatColor(red, format as ColorFormat)).toBe(expected); + }); + + it('round-trips every format', () => { + const formats: ColorFormat[] = [ + 'hex', + 'rgb', + 'hsl', + 'okhsl', + 'okhst', + 'oklch', + ]; + + for (const source of ['#26fcb2', '#1a1a2e', '#ffffff', '#000000']) { + for (const format of formats) { + const color = parseColor(source)!; + const text = formatColor(color, format); + + expect(toHex(parseColor(text)!), `${source} via ${format}`).toBe( + source, + ); + } + } + }); + + it('zeroes the hue of achromatic colors', () => { + // `#808080` converts with a residual hue angle that carries no meaning. + const gray = parseColor('#808080')!; + + expect(gray.s).toBeCloseTo(0, 4); + expect(formatColor(gray, 'okhsl')).toBe('okhsl(0 0% 53.57%)'); + expect(formatColor(gray, 'hsl')).toBe('hsl(0 0% 50.2%)'); + expect(formatColor(gray, 'oklch')).toBe('oklch(0.5999 0 0)'); + }); + + it('keeps a real hue at angle zero', () => { + // The achromatic shortcut must not swallow the hue of a saturated color + // that simply sits at 0°. + expect(formatColor({ h: 0, s: 1, l: 0.5 }, 'hsl')).not.toBe( + 'hsl(0 100% 50%)', + ); + }); + }); + + describe('detectFormat', () => { + it.each([ + ['#abc', 'hex'], + ['rgb(1 2 3)', 'rgb'], + ['rgba(1, 2, 3, 1)', 'rgb'], + ['hsl(1 2% 3%)', 'hsl'], + ['okhsl(1 2% 3%)', 'okhsl'], + ['okhst(1 2% 3%)', 'okhst'], + ['oklch(0.1 0.2 3)', 'oklch'], + ])('detects %s', (input, format) => { + expect(detectFormat(input)).toBe(format); + }); + + it('returns null for text that is not a color', () => { + expect(detectFormat('rgb(1 2)')).toBeNull(); + expect(detectFormat('hotpink')).toBeNull(); + }); + }); + + describe('gamut handling', () => { + it('clips OKLCh chroma to the sRGB gamut', () => { + const color = fromOklch({ l: 0.8, c: 0.4, h: 200 }); + + expect(color.s).toBe(1); + expect(toOklch(color).c).toBeCloseTo(maxChroma(200, color.l), 6); + }); + + it('keeps the hue of a zero-chroma OKLCh color', () => { + expect(fromOklch({ l: 0.5, c: 0, h: 123 }).h).toBe(123); + }); + + it('round-trips in-gamut OKLCh values', () => { + const source = parseColor('#26fcb2')!; + const restored = fromOklch(toOklch(source)); + + expect(restored.h).toBeCloseTo(source.h, 6); + expect(restored.s).toBeCloseTo(source.s, 6); + expect(restored.l).toBeCloseTo(source.l, 6); + }); + }); + + describe('toRgb', () => { + it('returns 0-255 integers', () => { + expect(toRgb(parseColor('#26fcb2')!)).toEqual({ r: 38, g: 252, b: 178 }); + }); + }); +}); diff --git a/src/components/fields/ColorPicker/color.ts b/src/components/fields/ColorPicker/color.ts new file mode 100644 index 000000000..5db3b132e --- /dev/null +++ b/src/components/fields/ColorPicker/color.ts @@ -0,0 +1,375 @@ +import { + contrastRatioFromLuminance, + formatHsl, + formatOkhsl, + formatOkhst, + formatOklch, + formatRgb, + hslToSrgb, + okhslToLinearSrgb, + okhslToOkhst, + okhslToOklch, + okhslToSrgb, + okhstToOkhsl, + oklabToOkhsl, + parseHexAlpha, + relativeLuminanceFromLinearRgb, + srgbToHex, + srgbToOkhsl, +} from '@tenphi/glaze'; + +import type { + OkhslColor, + OkhstColor, + OklchColor, + RgbColor, +} from '@tenphi/glaze'; + +export type { OkhslColor, OkhstColor, OklchColor, RgbColor }; + +/** + * Every color format the picker can read and write. + * + * `okhsl` and `okhst` are Glaze / Tasty spaces rather than native CSS ones, + * but Tasty parses both, so the produced strings are usable as style values. + */ +export const COLOR_FORMATS = [ + 'hex', + 'rgb', + 'hsl', + 'okhsl', + 'okhst', + 'oklch', +] as const; + +export type ColorFormat = (typeof COLOR_FORMATS)[number]; + +/** + * OKHSL is the canonical space of the picker: it is Glaze's authoring space, + * every channel is bounded, and — unlike OKLCh — every value inside those + * bounds is inside the sRGB gamut, so no state can be unrepresentable. + */ +export type ColorValue = OkhslColor; + +const FUNCTION_RE = /^([a-z]+)\(([^()]+)\)$/; +/** + * A number with an optional `%` or `deg` unit. + * + * The integer and fraction parts are deliberately written so that no two + * quantifiers can consume the same digit — `\d+(?:\.\d*)?` rather than + * `\d+\.?\d*`, whose `\d+` and `\d*` can split a digit run n ways and make + * rejecting a long one quadratic. This runs on whatever the user types. + */ +const NUMBER_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?(?:%|deg)?$/; + +/** + * Saturation below this rounds to `0%` in every output notation, so the color + * is achromatic for display purposes even when the conversion left a residue. + */ +const ACHROMATIC_SATURATION = 5e-5; + +function clamp(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} + +export function normalizeHue(hue: number): number { + if (!Number.isFinite(hue)) return 0; + + return ((hue % 360) + 360) % 360; +} + +/** + * The color carries no meaningful hue: every conversion out of it invents an + * angle, so callers that need one should keep the hue they already had. + */ +export function isAchromatic(color: ColorValue): boolean { + return color.s < ACHROMATIC_SATURATION; +} + +/** Both channel objects describe the same color, within display precision. */ +export function isSameColor(a?: ColorValue | null, b?: ColorValue | null) { + if (!a || !b) return a === b; + + return ( + Math.abs(a.h - b.h) < 1e-6 && + Math.abs(a.s - b.s) < 1e-6 && + Math.abs(a.l - b.l) < 1e-6 + ); +} + +/** + * Split a color-function body into its numeric arguments, dropping the alpha + * component. Both the modern space-separated and the legacy comma-separated + * syntaxes are accepted. + * + * Each argument keeps its unit so the caller can tell `50%` from `50`. + */ +function splitArguments(body: string): string[] | null { + const [values, ...rest] = body.split('/'); + + // More than one slash is never valid syntax. + if (rest.length > 1) return null; + + const args = values + .trim() + .split(values.includes(',') ? ',' : /\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + // Legacy `rgba(r, g, b, a)` / `hsla(h, s, l, a)` carry alpha as a fourth + // argument. Alpha is not part of the value, so it is dropped either way. + if (args.length === 4) args.pop(); + + return args.every((arg) => NUMBER_RE.test(arg)) ? args : null; +} + +/** Parse an argument that may carry a `%` unit. `scale` is the 100% value. */ +function scalar(arg: string, scale: number): number { + return arg.endsWith('%') ? (parseFloat(arg) / 100) * scale : parseFloat(arg); +} + +function hueArgument(arg: string): number { + // A percentage is not a valid hue angle. + return arg.endsWith('%') ? NaN : normalizeHue(parseFloat(arg)); +} + +export function fromRgb(color: RgbColor): ColorValue { + const [h, s, l] = srgbToOkhsl([ + clamp(color.r, 0, 255) / 255, + clamp(color.g, 0, 255) / 255, + clamp(color.b, 0, 255) / 255, + ]); + + return { h: normalizeHue(h), s: clamp(s, 0, 1), l: clamp(l, 0, 1) }; +} + +export function toRgb(color: ColorValue): RgbColor { + const [r, g, b] = okhslToSrgb(color.h, color.s, color.l); + + return { + r: Math.round(clamp(r, 0, 1) * 255), + g: Math.round(clamp(g, 0, 1) * 255), + b: Math.round(clamp(b, 0, 1) * 255), + }; +} + +export function fromOkhst(color: OkhstColor): ColorValue { + const { h, s, l } = okhstToOkhsl({ + h: normalizeHue(color.h), + s: clamp(color.s, 0, 1), + t: clamp(color.t, 0, 1), + }); + + return { h: normalizeHue(h), s: clamp(s, 0, 1), l: clamp(l, 0, 1) }; +} + +export function toOkhst(color: ColorValue): OkhstColor { + const { t } = okhslToOkhst(color); + + return { h: color.h, s: color.s, t: clamp(t, 0, 1) }; +} + +/** + * The largest OKLCh chroma that stays inside the sRGB gamut for the given hue + * and OKHSL lightness — that is exactly what OKHSL saturation `1` means. + */ +export function maxChroma(hue: number, lightness: number): number { + return okhslToOklch(normalizeHue(hue), 1, clamp(lightness, 0, 1))[1]; +} + +export function toOklch(color: ColorValue): OklchColor { + const [l, c, h] = okhslToOklch(color.h, color.s, color.l); + + return { l, c, h: normalizeHue(h) }; +} + +/** + * OKLCh addresses colors outside the sRGB gamut, so chroma is clipped to the + * gamut boundary at the requested lightness before it is stored. Hue is taken + * from the input rather than from the conversion, which loses it at zero + * chroma. + */ +export function fromOklch(color: OklchColor): ColorValue { + const h = normalizeHue(color.h); + const c = Math.max(color.c, 0); + const radians = (h * Math.PI) / 180; + const [, s, l] = oklabToOkhsl([ + clamp(color.l, 0, 1), + c * Math.cos(radians), + c * Math.sin(radians), + ]); + const limit = maxChroma(h, l); + + return { + h, + s: limit > 0 ? (c >= limit ? 1 : clamp(s, 0, 1)) : 0, + l: clamp(l, 0, 1), + }; +} + +export function toHex(color: ColorValue): string { + return srgbToHex(okhslToSrgb(color.h, color.s, color.l)); +} + +/** + * Read any supported color notation into the canonical space. Returns `null` + * for anything that is not a real color — that is what lets the picker reject + * partial input instead of guessing. + * + * Named CSS colors (`red`, `rebeccapurple`) are intentionally not supported, + * matching `glaze.color()`. + */ +export function parseColor(input: string): ColorValue | null { + const text = input.trim().toLowerCase(); + + if (!text) return null; + + if (text.startsWith('#')) { + const parsed = parseHexAlpha(text); + + return parsed ? fromRgb(rgbFromSrgb(parsed.rgb)) : null; + } + + const match = FUNCTION_RE.exec(text); + + if (!match) return null; + + const [, name, body] = match; + const args = splitArguments(body); + + if (!args || args.length !== 3) return null; + + switch (name) { + case 'rgb': + case 'rgba': { + const [r, g, b] = args.map((arg) => scalar(arg, 255)); + + return anyNaN(r, g, b) ? null : fromRgb({ r, g, b }); + } + case 'hsl': + case 'hsla': { + const h = hueArgument(args[0]); + const s = scalar(args[1], 100) / 100; + const l = scalar(args[2], 100) / 100; + + if (anyNaN(h, s, l)) return null; + + return fromRgb(rgbFromSrgb(hslToSrgb(h, clamp(s, 0, 1), clamp(l, 0, 1)))); + } + case 'okhsl': { + const h = hueArgument(args[0]); + const s = scalar(args[1], 100) / 100; + const l = scalar(args[2], 100) / 100; + + if (anyNaN(h, s, l)) return null; + + return { h, s: clamp(s, 0, 1), l: clamp(l, 0, 1) }; + } + case 'okhst': { + const h = hueArgument(args[0]); + const s = scalar(args[1], 100) / 100; + const t = scalar(args[2], 100) / 100; + + if (anyNaN(h, s, t)) return null; + + return fromOkhst({ h, s, t }); + } + case 'oklch': { + // In `oklch()` a percentage means 1 for lightness and 0.4 for chroma. + const l = scalar(args[0], 1); + const c = scalar(args[1], 0.4); + const h = hueArgument(args[2]); + + return anyNaN(l, c, h) ? null : fromOklch({ l, c, h }); + } + default: + return null; + } +} + +function anyNaN(...values: number[]): boolean { + return values.some((value) => !Number.isFinite(value)); +} + +function rgbFromSrgb([r, g, b]: [number, number, number]): RgbColor { + return { r: r * 255, g: g * 255, b: b * 255 }; +} + +/** Serialize the canonical color into one of the supported notations. */ +export function formatColor(color: ColorValue, format: ColorFormat): string { + // An achromatic color has no meaningful hue, and the conversions leave + // whatever angle the source happened to carry. Zero reads better. + const gray = isAchromatic(color); + const hue = gray ? 0 : color.h; + const saturation = color.s * 100; + const lightness = color.l * 100; + + switch (format) { + case 'rgb': + return formatRgb(hue, saturation, lightness); + case 'hsl': { + const text = formatHsl(hue, saturation, lightness); + + // CSS HSL derives its hue from the sRGB channels, which is undefined + // when they are equal — Glaze emits whatever the formula produces. Every + // other notation carries the hue through, so only HSL needs this. + return gray ? text.replace(/\(-?[\d.]+/, '(0') : text; + } + case 'okhsl': + return formatOkhsl(hue, saturation, lightness); + case 'okhst': + return formatOkhst(hue, saturation, toOkhst(color).t * 100); + case 'oklch': + return formatOklch(hue, saturation, lightness); + default: + return toHex(color); + } +} + +/** + * Which notation a string is written in, or `null` when it is not a color. + * Used to keep the user's own notation while still normalizing the value. + */ +export function detectFormat(input: string): ColorFormat | null { + const text = input.trim().toLowerCase(); + + if (!parseColor(text)) return null; + + if (text.startsWith('#')) return 'hex'; + + const name = FUNCTION_RE.exec(text)?.[1]; + + switch (name) { + case 'rgb': + case 'rgba': + return 'rgb'; + case 'hsl': + case 'hsla': + return 'hsl'; + case 'okhsl': + return 'okhsl'; + case 'okhst': + return 'okhst'; + case 'oklch': + return 'oklch'; + default: + return null; + } +} + +/** + * Black or white — whichever the WCAG contrast ratio favors on the given + * color. Keeps the popover preview label readable at any lightness. Returns a + * literal hex rather than a token, because the answer is measured against this + * exact fill and must not adapt with the color scheme. + */ +export function getContrastingColor(color: ColorValue): '#000000' | '#ffffff' { + const luminance = relativeLuminanceFromLinearRgb( + okhslToLinearSrgb(color.h, color.s, color.l), + ); + + return contrastRatioFromLuminance(luminance, 0) >= + contrastRatioFromLuminance(1, luminance) + ? '#000000' + : '#ffffff'; +} diff --git a/src/components/fields/ColorPicker/index.tsx b/src/components/fields/ColorPicker/index.tsx new file mode 100644 index 000000000..5f4fd40c5 --- /dev/null +++ b/src/components/fields/ColorPicker/index.tsx @@ -0,0 +1,11 @@ +export { ColorPicker } from './ColorPicker'; +export type { + CubeColorPickerProps, + ColorPickerFormatMode, +} from './ColorPicker'; +export { COLOR_FORMATS } from './color'; +// Exported under picker-specific names: `ColorSpace` is already taken by +// Tasty's own export in the package barrel. +export type { ColorFormat as ColorPickerFormat } from './color'; +export { COLOR_SPACES } from './channels'; +export type { ColorSpace as ColorPickerSpace } from './channels'; diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 1f60d91c5..19d287062 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -6,6 +6,7 @@ export * from './FileInput/FileInput'; export * from './TextArea'; export * from './CommandTextArea'; export * from './Checkbox'; +export * from './ColorPicker'; export * from './DatePicker'; export * from './RadioGroup'; export * from './SearchInput'; diff --git a/src/icons/PipetteIcon.tsx b/src/icons/PipetteIcon.tsx new file mode 100644 index 000000000..366e3d201 --- /dev/null +++ b/src/icons/PipetteIcon.tsx @@ -0,0 +1,5 @@ +import { IconColorPicker } from '@tabler/icons-react'; + +import { wrapIcon } from './wrap-icon'; + +export const PipetteIcon = wrapIcon('PipetteIcon', ); diff --git a/src/icons/index.ts b/src/icons/index.ts index 5cfbd4f0d..68fdaa0f0 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -86,6 +86,7 @@ export { PauseCircleIcon } from './PauseCircleIcon'; export { PauseIcon } from './PauseIcon'; export { PercentageIcon } from './PercentageIcon'; export { PieChartIcon } from './PieChartIcon'; +export { PipetteIcon } from './PipetteIcon'; export { PlayCircleIcon } from './PlayCircleIcon'; export { PlayIcon } from './PlayIcon'; export { PlusIcon } from './PlusIcon'; diff --git a/tasty.config.ts b/tasty.config.ts index e20e8bfc6..0123fa31a 100644 --- a/tasty.config.ts +++ b/tasty.config.ts @@ -244,6 +244,10 @@ export default { '#tabs-fade-right', '#slider-thumb', '#slider-thumb-hovered', + // ColorPicker: the color being edited, and the black/white that reads on + // top of it. Both are set as inline custom properties per render. + '#color-picker', + '#color-picker-contrast', // Custom Property Tokens (from src/tokens/base.ts) '$tab-indicator-size',