From 99bd0e27c6f9c2a320cd25f79a03b67a406a35c1 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Thu, 31 Oct 2024 16:13:48 +0300 Subject: [PATCH] feat: ColorField and ColorWheel --- apps/docs/src/examples/color-field.module.css | 84 ++ apps/docs/src/examples/color-field.tsx | 97 ++ apps/docs/src/examples/color-wheel.module.css | 33 + apps/docs/src/examples/color-wheel.tsx | 120 +++ apps/docs/src/routes/docs/core.tsx | 10 + .../docs/core/components/color-field.mdx | 302 ++++++ .../docs/core/components/color-wheel.mdx | 314 ++++++ .../color-field/color-field-context.tsx | 19 + .../colors/color-field/color-field-input.tsx | 42 + .../colors/color-field/color-field-root.tsx | 108 +++ .../core/src/colors/color-field/index.tsx | 62 ++ .../color-wheel/color-wheel-context.tsx | 34 + .../colors/color-wheel/color-wheel-input.tsx | 77 ++ .../colors/color-wheel/color-wheel-root.tsx | 282 ++++++ .../colors/color-wheel/color-wheel-thumb.tsx | 153 +++ .../colors/color-wheel/color-wheel-track.tsx | 135 +++ .../color-wheel/color-wheel-value-label.tsx | 35 + .../color-wheel/create-color-wheel-state.ts | 135 +++ .../core/src/colors/color-wheel/index.tsx | 90 ++ packages/core/src/colors/color-wheel/utils.ts | 35 + packages/core/src/colors/intl.ts | 47 + packages/core/src/colors/types.ts | 116 +++ packages/core/src/colors/utils.ts | 896 ++++++++++++++++++ packages/core/src/index.tsx | 5 + 24 files changed, 3231 insertions(+) create mode 100644 apps/docs/src/examples/color-field.module.css create mode 100644 apps/docs/src/examples/color-field.tsx create mode 100644 apps/docs/src/examples/color-wheel.module.css create mode 100644 apps/docs/src/examples/color-wheel.tsx create mode 100644 apps/docs/src/routes/docs/core/components/color-field.mdx create mode 100644 apps/docs/src/routes/docs/core/components/color-wheel.mdx create mode 100644 packages/core/src/colors/color-field/color-field-context.tsx create mode 100644 packages/core/src/colors/color-field/color-field-input.tsx create mode 100644 packages/core/src/colors/color-field/color-field-root.tsx create mode 100644 packages/core/src/colors/color-field/index.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-context.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-input.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-root.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-thumb.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-track.tsx create mode 100644 packages/core/src/colors/color-wheel/color-wheel-value-label.tsx create mode 100644 packages/core/src/colors/color-wheel/create-color-wheel-state.ts create mode 100644 packages/core/src/colors/color-wheel/index.tsx create mode 100644 packages/core/src/colors/color-wheel/utils.ts create mode 100644 packages/core/src/colors/intl.ts create mode 100644 packages/core/src/colors/types.ts create mode 100644 packages/core/src/colors/utils.ts diff --git a/apps/docs/src/examples/color-field.module.css b/apps/docs/src/examples/color-field.module.css new file mode 100644 index 000000000..a8ecbea59 --- /dev/null +++ b/apps/docs/src/examples/color-field.module.css @@ -0,0 +1,84 @@ +.color-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.color-field__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; +} + +.color-field__input { + display: inline-flex; + width: 200px; + border-radius: 6px; + padding: 6px 12px; + font-size: 16px; + outline: none; + background-color: white; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: + border-color 250ms, + color 250ms; +} + +.color-field__input:hover { + border-color: hsl(240 5% 65%); +} + +.color-field__input:focus-visible { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; +} + +.color-field__input[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); +} + +.color-field__input::placeholder { + color: hsl(240 4% 46%); +} + +.color-field__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.color-field__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + +[data-kb-theme="dark"] .color-field__input { + background-color: hsl(240 4% 16%); + border: 1px solid hsl(240 5% 34%); + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .color-field__input:hover { + border-color: hsl(240 4% 46%); +} + +[data-kb-theme="dark"] .color-field__input[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); +} + +[data-kb-theme="dark"] .color-field__input::placeholder { + color: hsl(0 100% 100% / 0.5); +} + +[data-kb-theme="dark"] .color-field__label { + color: hsl(240 5% 84%); +} + +[data-kb-theme="dark"] .color-field__description { + color: hsl(240 5% 65%); +} diff --git a/apps/docs/src/examples/color-field.tsx b/apps/docs/src/examples/color-field.tsx new file mode 100644 index 000000000..dc8410f24 --- /dev/null +++ b/apps/docs/src/examples/color-field.tsx @@ -0,0 +1,97 @@ +import { createSignal } from "solid-js"; +import { ColorField } from "../../../../packages/core/src/colors/color-field"; + +import style from "./color-field.module.css"; + +export function BasicExample() { + return ( + + Favorite color + + + ); +} + +export function DefaultValueExample() { + return ( + + Favorite color + + + ); +} + +export function ControlledExample() { + const [value, setValue] = createSignal("#7f007f"); + + return ( + <> + + Favorite color + + +

Your favorite color is: {value()}

+ + ); +} + +export function DescriptionExample() { + return ( + + Favorite color + + + Choose the color you like the most. + + + ); +} + +export function ErrorMessageExample() { + const [value, setValue] = createSignal("#7f007f"); + + return ( + + Favorite color + + + Hmm, I prefer black. + + + ); +} + +export function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + + alert(JSON.stringify(Object.fromEntries(formData), null, 2)); + }; + + return ( +
+ + Favorite color + + +
+ + +
+
+ ); +} diff --git a/apps/docs/src/examples/color-wheel.module.css b/apps/docs/src/examples/color-wheel.module.css new file mode 100644 index 000000000..eaaa3c721 --- /dev/null +++ b/apps/docs/src/examples/color-wheel.module.css @@ -0,0 +1,33 @@ +.ColorWheelRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; +} + +.ColorWheelTrack { + position: relative; + height: 160px; + width: 160px; +} + +.ColorWheelThumb { + display: block; + width: 16px; + height: 16px; + border-radius: 9999px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #0000006b; +} + +.ColorWheelThumb:focus { + outline: none; +} + +.ColorWheelLabel { + display: flex; + justify-content: space-between; + width: 100%; +} diff --git a/apps/docs/src/examples/color-wheel.tsx b/apps/docs/src/examples/color-wheel.tsx new file mode 100644 index 000000000..00c4f5dce --- /dev/null +++ b/apps/docs/src/examples/color-wheel.tsx @@ -0,0 +1,120 @@ +import { createSignal } from "solid-js"; +import { ColorWheel } from "../../../../packages/core/src/colors/color-wheel"; +import { parseColor } from "../../../../packages/core/src/colors/utils"; +import style from "./color-wheel.module.css"; + +export function BasicExample() { + return ( + + + + + + + + ); +} + +export function DefaultValueExample() { + return ( + + + + + + + + ); +} + +export function ThicknessExample() { + return ( + + + + + + + + ); +} + +export function ControlledValueExample() { + const [value, setValue] = createSignal(parseColor("hsl(0, 100%, 50%)")); + + return ( + <> + + + + + + + +

Current color value: {value().toString("hsl")}

+ + ); +} + +export function CustomValueLabelExample() { + return ( + + color + .toFormat("hsl") + .withChannelValue("saturation", 100) + .withChannelValue("lightness", 50) + .withChannelValue("alpha", 1) + .toString() + } + > +
+ +
+ + + + + +
+ ); +} + +export function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + + alert(JSON.stringify(Object.fromEntries(formData), null, 2)); + }; + + return ( +
+ + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index d4b1efb21..68639ebdb 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -65,6 +65,16 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Collapsible", href: "/docs/core/components/collapsible", }, + { + title: "Color Field", + href: "/docs/core/components/color-field", + status: "new", + }, + { + title: "Color Wheel", + href: "/docs/core/components/color-wheel", + status: "new", + }, { title: "Combobox", href: "/docs/core/components/combobox", diff --git a/apps/docs/src/routes/docs/core/components/color-field.mdx b/apps/docs/src/routes/docs/core/components/color-field.mdx new file mode 100644 index 000000000..e3e0d3d7f --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/color-field.mdx @@ -0,0 +1,302 @@ +import { Preview, TabsSnippets } from "../../../../components"; +import { + ControlledExample, + DefaultValueExample, + HTMLFormExample, + BasicExample, + DescriptionExample, + ErrorMessageExample, +} from "../../../../examples/color-field"; + +# Color Field + +Allows users to enter and adjust a hex color value. + +## Import + +```ts +import { ColorField } from "@kobalte/core/color-field"; +// or +import { Root, Label, ... } from "@kobalte/core/color-field"; +// or (deprecated) +import { ColorField } from "@kobalte/core"; +``` + +## Features + +- Support for parsing and formatting a hex color value. +- Validates keyboard entry as the user types so that only valid hex characters are accepted. +- Visual and ARIA labeling support. +- Required and invalid states exposed to assistive technology via ARIA. +- Support for description and error message help text linked to the input via ARIA. +- Syncs with form reset events. +- Can be controlled or uncontrolled. + +## Anatomy + +The color field consists of: + +- **ColorField**: The root container for the color field. +- **ColorField.Label**: The label that gives the user information on the color field. +- **ColorField.Input**: The native HTML input of the color field. +- **ColorField.Description**: The description that gives the user more information on the color field. +- **ColorField.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the color field. + +```tsx + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + {/* */} + + ```tsx + import { ColorField } from "@kobalte/core/color-field"; + import "./style.css"; + + function App() { + return ( + + Favorite color + + + ); + } + ``` + + + + ```css + .color-field { + display: flex; + flex-direction: column; + gap: 4px; + } + + .color-field__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; + } + + .color-field__input { + display: inline-flex; + width: 200px; + border-radius: 6px; + padding: 6px 12px; + font-size: 16px; + outline: none; + background-color: white; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: border-color 250ms, color 250ms; + } + + .color-field__input:hover { + border-color: hsl(240 5% 65%); + } + + .color-field__input:focus-visible { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; + } + + .color-field__input[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); + } + + .color-field__input::placeholder { + color: hsl(240 4% 46%); + } + + .color-field__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; + } + + .color-field__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; + } + ``` + + + {/* */} + + +## Usage + +### Default value + +An initial, uncontrolled value can be provided using the `defaultValue` prop. + + + + + +```tsx {0} + + Favorite color + + +``` + +### Controlled value + +The `value` prop can be used to make the value controlled. The `onChange` event is fired when the user type into the input and receive the new value. + + + + + +```tsx {3,7} +import { createSignal } from "solid-js"; + +function ControlledExample() { + const [value, setValue] = createSignal("#7f007f"); + + return ( + <> + + Favorite color + + +

Your favorite color is: {value()}

+ + ); +} +``` + +### Description + +The `ColorField.Description` component can be used to associate additional help text with a color field. + + + + + +```tsx {3} + + Favorite color + + + Choose the color you like the most. + + +``` + +### Error message + +The `ColorField.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the color field as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,14} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [value, setValue] = createSignal("#7f007f"); + + return ( + + Favorite color + + Hmm, I prefer black. + + ); +} +``` + +### HTML forms + +The color field `name` prop can be used for integration with HTML forms. + + + + + +```tsx {7} +function HTMLFormExample() { + const onSubmit = (e: SubmitEvent) => { + // handle form submission. + }; + + return ( +
+ + Favorite color + + +
+ ); +} +``` + +## API Reference + +### ColorField + +`ColorField` is equivalent to the `Root` import from `@kobalte/core/color-field` (and deprecated `ColorField.Root`). + +| Prop | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `string`
The controlled value of the color field to check. | +| defaultValue | `string`
The default value when initially rendered. Useful when you do not need to control the value. | +| onChange | `(value: string) => void`
Event handler called when the value of the color field changes. | +| name | `string`
The name of the color field, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | +| validationState | `'valid' \| 'invalid'`
Whether the color field should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must fill the color field before the owning form can be submitted. | +| disabled | `boolean`
Whether the color field is disabled. | +| readOnly | `boolean`
Whether the color field items can be selected but not changed by the user. | + +| Data attribute | Description | +| :------------- | :--------------------------------------------------------------------------------------- | +| data-valid | Present when the color field is valid according to the validation rules. | +| data-invalid | Present when the color field is invalid according to the validation rules. | +| data-required | Present when the user must fill the color field before the owning form can be submitted. | +| data-disabled | Present when the color field is disabled. | +| data-readonly | Present when the color field is read only. | + +`ColorField.Label`, `ColorField.Input`, `ColorField.Description` and `ColorField.ErrorMesssage` share the same data-attributes. + +### ColorField.ErrorMessage + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + +## Rendered elements + +| Component | Default rendered element | +| :------------------------ | :----------------------- | +| `ColorField` | `div` | +| `ColorField.Label` | `label` | +| `ColorField.Input` | `input` | +| `ColorField.Description` | `div` | +| `ColorField.ErrorMessage` | `div` | diff --git a/apps/docs/src/routes/docs/core/components/color-wheel.mdx b/apps/docs/src/routes/docs/core/components/color-wheel.mdx new file mode 100644 index 000000000..9d8652e56 --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/color-wheel.mdx @@ -0,0 +1,314 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; +import { + BasicExample, + DefaultValueExample, + ThicknessExample, + ControlledValueExample, + CustomValueLabelExample, + HTMLFormExample, +} from "../../../../examples/color-wheel"; + +# Color Wheel + +Allows users to adjust the hue of an HSL or HSB color value on a circular track. + +## Import + +```ts +import { ColorWheel } from "@kobalte/core/color-wheel"; +// or +import { Root, Track, ... } from "@kobalte/core/color-wheel"; +// or (deprecated) +import { ColorWheel } from "@kobalte/core"; +``` + +## Features + +- Support for adjusting the hue of an HSL or HSB color value. +- Support click or touch on track to change value. +- Support right or left direction. +- Support for custom value label. +- Localized color descriptions for screen reader users. +- Can be controlled or uncontrolled. + +## Anatomy + +The color wheel consists of: + +- **ColorWheel:** The root container for the color wheel. +- **ColorWheel.Track:** The component that visually represents the color wheel track. +- **ColorWheel.Thumb:** The thumb that is used to visually indicate a value in the color wheel. +- **ColorWheel.Input:** The native html input that is visually hidden in the color wheel thumb. +- **ColorWheel.Label:** The label that gives the user information on the color wheel. +- **ColorWheel.ValueLabel:** The accessible label text representing the current value in a human-readable format. + +```tsx + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + +{/* */} + + ```tsx + import { ColorWheel } from "@kobalte/core/color-wheel"; + import "./style.css"; + + function App() { + return ( + + + + + + + + ); + } + ``` + + + + ```css + .ColorWheelRoot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + touch-action: none; + } + + .ColorWheelTrack { + position: relative; + height: 160px; + width: 160px; + } + + .ColorWheelThumb { + display: block; + width: 16px; + height: 16px; + border-radius: 9999px; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #0000006b; + } + + .ColorWheelThumb:focus { + outline: none; + } + + .ColorWheelLabel { + display: flex; + justify-content: space-between; + width: 100%; + } + ``` + + +{/* */} + + +## Usage + +### Default value + +By default, `ColorWheel` is uncontrolled with a default value of red (hue = 0). You can change the default value using the `defaultValue` prop. + + + + + +```tsx {6} +import { ColorWheel } from "@kobalte/core/color-wheel"; +import { parseColor } from "@kobalte/core"; + +function App() { + return ( + + + + + + + + ); +} +``` + +### Controlled value + +A `ColorWheel` can be made controlled using the `value` prop. The `onChange` event is fired when the user drags the thumb and receive the new value. + + + + + +```tsx {3, 8-9} +import { createSignal } from "solid-js"; + +function ControlledValueExample() { + const [value, setValue] = createSignal(parseColor("hsl(0, 100%, 50%)")); + + return ( + <> + + + + + + + +

Current color value: {value().toString("hsl")}

+ + ); +} +``` + +### Thickness + +The `thickness` prop controls the width of the `ColorWheel`'s circular track. This prop is required. + + + + + +```tsx {0} + + + + + + + +``` + +### Custom Value Label + + + + + +```tsx {2-8} + + color + .toFormat("hsl") + .withChannelValue("saturation", 100) + .withChannelValue("lightness", 50) + .withChannelValue("alpha", 1) + .toString() + } +> +
+ +
+ + + + + +
+``` + +### HTML forms + +`ColorWheel` supports the `name` prop for integration with HTML forms. + + + + + +```tsx {7} +function HTMLFormExample() { + const onSubmit = (e: SubmitEvent) => { + // handle form submission. + }; + + return ( +
+ + + + + + + +
+ ); +} +``` + +## API Reference + +### ColorWheel + +`ColorWheel` is equivalent to the `Root` import from `@kobalte/core/color-wheel` (and deprecated `ColorWheel.Root`). + +| Prop | Description | +| :-------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| value | `Color`
The controlled value of the color wheel. Must be used in conjunction with `onChange`. | +| defaultValue | `Color`
The value of the color wheel when initially rendered. Use when you do not need to control the state of the color wheel. | +| thickness | `number`
The thickness of the track. | +| onChange | `(value: Color) => void`
Event handler called when the value changes. | +| onChangeEnd | `(value: Color) => void`
Event handler called when the value changes at the end of an interaction. | +| getValueLabel | `(param: Color) => string`
A function to get the accessible label text representing the current value in a human-readable format. | +| name | `string`
The name of the color wheel, used when submitting an HTML form. | +| validationState | `'valid' \| 'invalid'`
Whether the color wheel should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must check a radio group item before the owning form can be submitted. | +| disabled | `boolean`
Whether the color wheel is disabled. | +| readOnly | `boolean`
Whether the color wheel items can be selected but not changed by the user. | +| translations | `ColorIntlTranslations`
Localization strings. | + +| Data attribute | Description | +| :------------- | :------------------------------------------------------------------------- | +| data-valid | Present when the color wheel is valid according to the validation rules. | +| data-invalid | Present when the color wheel is invalid according to the validation rules. | +| data-required | Present when the color wheel is required. | +| data-disabled | Present when the color wheel is disabled. | +| data-readonly | Present when the color wheel is read only. | + +`ColorWheel.ValueLabel`, `ColorWheel.Input`, `ColorWheel.Thumb` and `ColorWheel.Track` share the same data-attributes. + +## Rendered elements + +| Component | Default rendered element | +| :---------------------- | :----------------------- | +| `ColorWheel` | `div` | +| `ColorWheel.Track` | `div` | +| `ColorWheel.Thumb` | `span` | +| `ColorWheel.Input` | `input` | +| `ColorWheel.ValueLabel` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-------------------- | :---------------------------------------------------- | +| PageUp | Increments the value of the thumb by a larger step. | +| PageDown | Decrements the value of the thumb by a larger step. | +| ArrowDown | Decrements the value of the thumb by the step amount. | +| ArrowUp | Increments the value of the thumb by the step amount. | +| ArrowRight | Increments the value of the thumb by the step value. | +| ArrowLeft | Decrements the value of the thumb by the step value. | +| Home | Sets the value of the thumb to the minimum value. | +| End | Sets the value of the thumb to the maximum value. | diff --git a/packages/core/src/colors/color-field/color-field-context.tsx b/packages/core/src/colors/color-field/color-field-context.tsx new file mode 100644 index 000000000..f7e7f8853 --- /dev/null +++ b/packages/core/src/colors/color-field/color-field-context.tsx @@ -0,0 +1,19 @@ +import { type JSX, createContext, useContext } from "solid-js"; + +export interface ColorFieldContextValue { + onBlur: JSX.FocusEventHandlerUnion; +} + +export const ColorFieldContext = createContext(); + +export function useColorFieldContext() { + const context = useContext(ColorFieldContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useColorFieldContext` must be used within a `ColorField` component", + ); + } + + return context; +} diff --git a/packages/core/src/colors/color-field/color-field-input.tsx b/packages/core/src/colors/color-field/color-field-input.tsx new file mode 100644 index 000000000..f748b34bf --- /dev/null +++ b/packages/core/src/colors/color-field/color-field-input.tsx @@ -0,0 +1,42 @@ +import { composeEventHandlers } from "@kobalte/utils"; +import { type Component, type JSX, type ValidComponent, splitProps } from "solid-js"; +import type { ElementOf, PolymorphicProps } from "../../polymorphic"; +import * as TextField from "../../text-field"; +import { useColorFieldContext } from "./color-field-context"; + +export interface ColorFieldInputOptions extends TextField.TextFieldInputOptions {} + +export interface ColorFieldInputCommonProps { + onBlur: JSX.FocusEventHandlerUnion; +} + +export interface ColorFieldInputRenderProps + extends ColorFieldInputCommonProps, + TextField.TextFieldInputRenderProps { + autoComplete: "off"; + autoCorrect: "off"; + spellCheck: "false"; +} + +export type ColorFieldInputProps = + ColorFieldInputOptions & Partial>>; + +export function ColorFieldInput( + props: PolymorphicProps>, +) { + const context = useColorFieldContext(); + + const [local, others] = splitProps(props, ["onBlur"]); + + return ( + > + > + autoComplete="off" + autoCorrect="off" + spellCheck="false" + onBlur={composeEventHandlers([local.onBlur, context.onBlur])} + {...others} + /> + ); +} diff --git a/packages/core/src/colors/color-field/color-field-root.tsx b/packages/core/src/colors/color-field/color-field-root.tsx new file mode 100644 index 000000000..6fbcaaa84 --- /dev/null +++ b/packages/core/src/colors/color-field/color-field-root.tsx @@ -0,0 +1,108 @@ +import { mergeDefaultProps } from "@kobalte/utils"; +import { + type Component, + type JSX, + type ValidComponent, + batch, + createMemo, + createSignal, + createUniqueId, + splitProps, +} from "solid-js"; +import type { ElementOf, PolymorphicProps } from "../../polymorphic"; +import { createControllableSignal } from "../../primitives"; +import * as TextField from "../../text-field"; +import { parseColor } from "../utils"; +import { ColorFieldContext, type ColorFieldContextValue } from "./color-field-context"; + +export interface ColorFieldRootOptions extends TextField.TextFieldRootOptions {} + +export interface ColorFieldRootCommonProps { + id: string; +} + +export interface ColorFieldRootRenderProps + extends ColorFieldRootCommonProps, + TextField.TextFieldRootRenderProps {} + +export type ColorFieldRootProps = + ColorFieldRootOptions & Partial>>; + +export function ColorFieldRoot( + props: PolymorphicProps>, +) { + const defaultId = `colorfield-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps({ id: defaultId }, props as ColorFieldRootProps); + + const [local, others] = splitProps(mergedProps, ["value", "defaultValue", "onChange"]); + + const defaultValue = createMemo(() => { + let defaultValue = local.defaultValue; + try { + defaultValue = parseColor( + defaultValue?.startsWith("#") ? defaultValue : `#${defaultValue}`, + ).toString("hex"); + } catch { + defaultValue = ""; + } + return defaultValue; + }); + + const [value, setValue] = createControllableSignal({ + value: () => local.value, + defaultValue, + onChange: value => local.onChange?.(value), + }); + + const [prevValue, setPrevValue] = createSignal(value()); + + const onChange = (value: string) => { + if (isAllowedInput(value)) { + setValue(value); + } + }; + + const onBlur: JSX.FocusEventHandlerUnion = e => { + if (!value()!.length) { + setPrevValue(""); + return; + } + let newValue: string; + try { + newValue = parseColor(value()!.startsWith("#") ? value()! : `#${value()}`).toString("hex"); + } catch { + if (prevValue()) { + setValue(prevValue()!); + } else { + setValue(""); + } + return; + } + batch(() => { + setValue(newValue); + setPrevValue(newValue); + }); + }; + + const context: ColorFieldContextValue = { + onBlur, + }; + + return ( + + > + > + value={value()} + defaultValue={defaultValue()} + onChange={onChange} + {...others} + /> + + ); +} + +function isAllowedInput(value: string): boolean { + return value === "" || !!value.match(/^#?[0-9a-f]{0,6}$/i)?.[0]; +} diff --git a/packages/core/src/colors/color-field/index.tsx b/packages/core/src/colors/color-field/index.tsx new file mode 100644 index 000000000..172a0d077 --- /dev/null +++ b/packages/core/src/colors/color-field/index.tsx @@ -0,0 +1,62 @@ +import { + type FormControlDescriptionCommonProps as ColorFieldDescriptionCommonProps, + type FormControlDescriptionOptions as ColorFieldDescriptionOptions, + type FormControlDescriptionProps as ColorFieldDescriptionProps, + type FormControlDescriptionRenderProps as ColorFieldDescriptionRenderProps, + type FormControlErrorMessageCommonProps as ColorFieldErrorMessageCommonProps, + type FormControlErrorMessageOptions as ColorFieldErrorMessageOptions, + type FormControlErrorMessageProps as ColorFieldErrorMessageProps, + type FormControlErrorMessageRenderProps as ColorFieldErrorMessageRenderProps, + type FormControlLabelCommonProps as ColorFieldLabelCommonProps, + type FormControlLabelOptions as ColorFieldLabelOptions, + type FormControlLabelProps as ColorFieldLabelProps, + type FormControlLabelRenderProps as ColorFieldLabelRenderProps, + FormControlDescription as Description, + FormControlErrorMessage as ErrorMessage, + FormControlLabel as Label, +} from "../../form-control"; +import { + type ColorFieldInputCommonProps, + type ColorFieldInputOptions, + type ColorFieldInputProps, + type ColorFieldInputRenderProps, + ColorFieldInput as Input, +} from "./color-field-input"; +import { + type ColorFieldRootCommonProps, + type ColorFieldRootOptions, + type ColorFieldRootProps, + type ColorFieldRootRenderProps, + ColorFieldRoot as Root, +} from "./color-field-root"; + +export type { + ColorFieldDescriptionOptions, + ColorFieldDescriptionCommonProps, + ColorFieldDescriptionRenderProps, + ColorFieldDescriptionProps, + ColorFieldErrorMessageOptions, + ColorFieldErrorMessageCommonProps, + ColorFieldErrorMessageRenderProps, + ColorFieldErrorMessageProps, + ColorFieldInputOptions, + ColorFieldInputCommonProps, + ColorFieldInputRenderProps, + ColorFieldInputProps, + ColorFieldLabelOptions, + ColorFieldLabelCommonProps, + ColorFieldLabelRenderProps, + ColorFieldLabelProps, + ColorFieldRootOptions, + ColorFieldRootCommonProps, + ColorFieldRootRenderProps, + ColorFieldRootProps, +}; +export { Description, ErrorMessage, Input, Label, Root }; + +export const ColorField = Object.assign(Root, { + Description, + ErrorMessage, + Input, + Label, +}); diff --git a/packages/core/src/colors/color-wheel/color-wheel-context.tsx b/packages/core/src/colors/color-wheel/color-wheel-context.tsx new file mode 100644 index 000000000..b8ae5dd16 --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-context.tsx @@ -0,0 +1,34 @@ +import { type Accessor, createContext, useContext } from "solid-js"; +import type { Color } from "../types"; +import type { ColorWheelState } from "./create-color-wheel-state"; + +export interface ColorWheelContextValue { + state: ColorWheelState; + outerRadius: Accessor; + innerRadius: Accessor; + onDragStart: ((value: number[]) => void) | undefined; + onDrag: ((deltas: { deltaX: number; deltaY: number }) => void) | undefined; + onDragEnd: (() => void) | undefined; + getThumbValueLabel: () => string; + getValueLabel: (param: Color) => string; + onStepKeyDown: (event: KeyboardEvent) => void; + thumbRef: Accessor; + setThumbRef: (el: HTMLElement) => void; + trackRef: Accessor; + setTrackRef: (el: HTMLElement) => void; + generateId: (part: string) => string; +} + +export const ColorWheelContext = createContext(); + +export function useColorWheelContext() { + const context = useContext(ColorWheelContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useColorWheelContext` must be used within a `ColorWheel` component", + ); + } + + return context; +} diff --git a/packages/core/src/colors/color-wheel/color-wheel-input.tsx b/packages/core/src/colors/color-wheel/color-wheel-input.tsx new file mode 100644 index 000000000..1907ec13b --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-input.tsx @@ -0,0 +1,77 @@ +import { callHandler, mergeDefaultProps, visuallyHiddenStyles } from "@kobalte/utils"; +import { type ComponentProps, type JSX, splitProps } from "solid-js"; + +import { combineStyle } from "@solid-primitives/props"; +import { + FORM_CONTROL_FIELD_PROP_NAMES, + createFormControlField, + useFormControlContext, +} from "../../form-control"; +import { useColorWheelContext } from "./color-wheel-context"; + +export interface ColorWheelInputProps extends ComponentProps<"input"> { + style?: JSX.CSSProperties | string; +} + +export function ColorWheelInput(props: ColorWheelInputProps) { + const formControlContext = useFormControlContext(); + const context = useColorWheelContext(); + + const mergedProps = mergeDefaultProps( + { + id: context.generateId("input"), + }, + props, + ); + + const [local, formControlFieldProps, others] = splitProps( + mergedProps, + ["style", "onChange"], + FORM_CONTROL_FIELD_PROP_NAMES, + ); + + const { fieldProps } = createFormControlField(formControlFieldProps); + + const onChange: JSX.ChangeEventHandlerUnion = e => { + callHandler(e, local.onChange); + + const target = e.target as HTMLInputElement; + + context.state.setHue(Number.parseFloat(target.value)); + + // Unlike in React, inputs `value` can be out of sync with our value state. + // even if an input is controlled (ex: ``, + // typing on the input will change its internal `value`. + + // To prevent this, we need to force the input `value` to be in sync with the slider value state. + target.value = String(context.state.hue()) ?? ""; + }; + + return ( + + ); +} diff --git a/packages/core/src/colors/color-wheel/color-wheel-root.tsx b/packages/core/src/colors/color-wheel/color-wheel-root.tsx new file mode 100644 index 000000000..71b6e4a2d --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-root.tsx @@ -0,0 +1,282 @@ +import { + type ValidationState, + access, + createGenerateId, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { + type ValidComponent, + createEffect, + createSignal, + createUniqueId, + splitProps, +} from "solid-js"; + +import { + FORM_CONTROL_PROP_NAMES, + FormControlContext, + type FormControlDataSet, + createFormControl, +} from "../../form-control"; +import { useLocale } from "../../i18n"; +import { type ElementOf, Polymorphic, type PolymorphicProps } from "../../polymorphic"; +import { createFormResetListener } from "../../primitives"; +import { COLOR_INTL_TRANSLATIONS, type ColorIntlTranslations } from "../intl"; +import type { Color } from "../types"; +import { ColorWheelContext, type ColorWheelContextValue } from "./color-wheel-context"; +import { createColorWheelState } from "./create-color-wheel-state"; + +export interface ColorWheelRootOptions { + /** The localized strings of the component. */ + translations?: ColorIntlTranslations; + + /** The controlled value of the color wheel. */ + value?: Color; + + /** The value of the color wheel when initially rendered. */ + defaultValue?: Color; + + /** The thickness of the track. */ + thickness: number; + + /** Event handler called when the value changes. */ + onChange?: (value: Color) => void; + + /** Called when the value changes at the end of an interaction. */ + onChangeEnd?: (value: Color) => void; + + /** + * A function to get the accessible label text representing the current value in a human-readable format. + */ + getValueLabel?: (param: Color) => string; + + /** + * A unique identifier for the component. + * The id is used to generate id attributes for nested components. + * If no id prop is provided, a generated id will be used. + */ + id?: string; + + /** + * The name of the color wheel, used when submitting an HTML form. + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). + */ + name?: string; + + /** Whether the color wheel should display its "valid" or "invalid" visual styling. */ + validationState?: ValidationState; + + /** Whether the user must select an item before the owning form can be submitted. */ + required?: boolean; + + /** Whether the color wheel is disabled. */ + disabled?: boolean; + + /** Whether the color wheel is read only. */ + readOnly?: boolean; +} + +export interface ColorWheelRootCommonProps { + id: string; + ref: T | ((el: T) => void); +} + +export interface ColorWheelRootRenderProps extends ColorWheelRootCommonProps, FormControlDataSet { + role: "group"; +} + +export type ColorWheelRootProps = + ColorWheelRootOptions & Partial>>; + +export function ColorWheelRoot( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const defaultId = `colorwheel-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + getValueLabel: param => param.formatChannelValue("hue"), + translations: COLOR_INTL_TRANSLATIONS, + disabled: false, + }, + props as ColorWheelRootProps, + ); + + const [local, formControlProps, others] = splitProps( + mergedProps, + [ + "ref", + "value", + "defaultValue", + "thickness", + "onChange", + "onChangeEnd", + "getValueLabel", + "translations", + "disabled", + ], + FORM_CONTROL_PROP_NAMES, + ); + + const { formControlContext } = createFormControl(formControlProps); + const { direction } = useLocale(); + + const [trackRef, setTrackRef] = createSignal(); + const [thumbRef, setThumbRef] = createSignal(); + const [outerRadius, setOuterRadius] = createSignal(); + + createEffect(() => { + setOuterRadius(trackRef()!.getBoundingClientRect()?.width / 2); + }); + + const innerRadius = () => outerRadius()! - local.thickness; + const thumbRadius = () => (outerRadius()! + innerRadius()) / 2; + + const state = createColorWheelState({ + value: () => local.value, + defaultValue: () => local.defaultValue, + thumbRadius, + onChange: local.onChange, + onChangeEnd: local.onChangeEnd, + isDisabled: () => formControlContext.isDisabled() ?? false, + }); + + createFormResetListener( + () => ref, + () => state.resetValue(), + ); + + const isLTR = () => direction() === "ltr"; + + let currentPosition: { x: number; y: number } | null = null; + const onDragStart = (value: number[]) => { + state.setIsDragging(true); + state.setThumbValue(value[0], value[1], Math.sqrt(value[0] * value[0] + value[1] * value[1])); + currentPosition = null; + }; + + const onDrag = ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { + if (currentPosition === null) { + currentPosition = state.getThumbPosition(); + } + currentPosition.x += deltaX; + currentPosition.y += deltaY; + state.setThumbValue(currentPosition.x, currentPosition.y, thumbRadius()); + local.onChange?.(state.value()); + }; + + const onDragEnd = () => { + state.setIsDragging(false); + thumbRef()?.focus(); + }; + + const getThumbValueLabel = () => + `${state.value().formatChannelValue("hue")}, ${context.state.value().getHueName(local.translations)}`; + + const onHomeKeyDown = (event: KeyboardEvent) => { + if (!formControlContext.isDisabled()) { + event.preventDefault(); + event.stopPropagation(); + state.setHue(state.minValue()); + } + }; + + const onEndKeyDown = (event: KeyboardEvent) => { + if (!formControlContext.isDisabled()) { + event.preventDefault(); + event.stopPropagation(); + state.setHue(state.maxValue()); + } + }; + + const onStepKeyDown = (event: KeyboardEvent) => { + if (!formControlContext.isDisabled()) { + switch (event.key) { + case "Left": + case "ArrowLeft": + event.preventDefault(); + event.stopPropagation(); + if (!isLTR()) { + state.increment(event.shiftKey ? state.pageSize() : state.step()); + } else { + state.decrement(event.shiftKey ? state.pageSize() : state.step()); + } + break; + case "Down": + case "ArrowDown": + event.preventDefault(); + event.stopPropagation(); + state.decrement(event.shiftKey ? state.pageSize() : state.step()); + break; + case "Up": + case "ArrowUp": + event.preventDefault(); + event.stopPropagation(); + state.increment(event.shiftKey ? state.pageSize() : state.step()); + break; + case "Right": + case "ArrowRight": + event.preventDefault(); + event.stopPropagation(); + if (!isLTR()) { + state.decrement(event.shiftKey ? state.pageSize() : state.step()); + } else { + state.increment(event.shiftKey ? state.pageSize() : state.step()); + } + break; + case "Home": + onHomeKeyDown(event); + break; + case "End": + onEndKeyDown(event); + break; + case "PageUp": + event.preventDefault(); + event.stopPropagation(); + state.increment(state.pageSize()); + break; + case "PageDown": + event.preventDefault(); + event.stopPropagation(); + state.decrement(state.pageSize()); + break; + } + } + }; + + const context: ColorWheelContextValue = { + state, + outerRadius, + innerRadius, + onDragStart, + onDrag, + onDragEnd, + getThumbValueLabel, + getValueLabel: local.getValueLabel, + onStepKeyDown, + trackRef, + setTrackRef, + thumbRef, + setThumbRef, + generateId: createGenerateId(() => access(formControlProps.id)!), + }; + + return ( + + + + as="div" + ref={mergeRefs(el => (ref = el), local.ref)} + role="group" + id={access(formControlProps.id)!} + {...formControlContext.dataset()} + {...others} + /> + + + ); +} diff --git a/packages/core/src/colors/color-wheel/color-wheel-thumb.tsx b/packages/core/src/colors/color-wheel/color-wheel-thumb.tsx new file mode 100644 index 000000000..539c31fc3 --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-thumb.tsx @@ -0,0 +1,153 @@ +import { callHandler, mergeDefaultProps, mergeRefs } from "@kobalte/utils"; +import { combineStyle } from "@solid-primitives/props"; +import { type JSX, type ValidComponent, createSignal, splitProps } from "solid-js"; +import { + FORM_CONTROL_FIELD_PROP_NAMES, + createFormControlField, + useFormControlContext, +} from "../../form-control"; +import { type ElementOf, Polymorphic, type PolymorphicProps } from "../../polymorphic"; +import { useColorWheelContext } from "./color-wheel-context"; + +export interface ColorWheelThumbOptions {} + +export interface ColorWheelThumbCommonProps { + id: string; + style?: JSX.CSSProperties | string; + onPointerDown: JSX.EventHandlerUnion; + onPointerMove: JSX.EventHandlerUnion; + onPointerUp: JSX.EventHandlerUnion; + onKeyDown: JSX.EventHandlerUnion; + "aria-label": string | undefined; + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; +} + +export interface ColorWheelThumbRenderProps extends ColorWheelThumbCommonProps { + role: "slider"; + tabIndex: 0 | undefined; + "aria-valuetext": string; + "aria-valuemin": number; + "aria-valuenow": number | undefined; + "aria-valuemax": number; +} + +export type ColorWheelThumbProps = + ColorWheelThumbOptions & Partial>>; + +export function ColorWheelThumb( + props: PolymorphicProps>, +) { + const context = useColorWheelContext(); + const formControlContext = useFormControlContext(); + + const mergedProps = mergeDefaultProps( + { + id: context.generateId("thumb"), + }, + props as ColorWheelThumbProps, + ); + + const [local, formControlFieldProps, others] = splitProps( + mergedProps, + ["style", "onKeyDown", "onPointerDown", "onPointerMove", "onPointerUp"], + FORM_CONTROL_FIELD_PROP_NAMES, + ); + + const { fieldProps } = createFormControlField(formControlFieldProps); + + const onKeyDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onKeyDown); + context.onStepKeyDown(e); + }; + + const [sRect, setRect] = createSignal(); + const getValueFromPointer = (pointerPosition: { x: number; y: number }) => { + const rect = sRect() || context.trackRef()!.getBoundingClientRect(); + setRect(rect); + return [ + pointerPosition.x - rect.left - rect.width / 2, + pointerPosition.y - rect.top - rect.height / 2, + ]; + }; + + let startPosition = { x: 0, y: 0 }; + + const onPointerDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onPointerDown); + + const target = e.currentTarget as HTMLElement; + + e.preventDefault(); + e.stopPropagation(); + target.setPointerCapture(e.pointerId); + target.focus(); + + const value = getValueFromPointer({ x: e.clientX, y: e.clientY }); + startPosition = { x: e.clientX, y: e.clientY }; + context.onDragStart?.(value); + }; + + const onPointerMove: JSX.EventHandlerUnion = e => { + e.stopPropagation(); + callHandler(e, local.onPointerMove); + + const target = e.currentTarget as HTMLElement; + + if (target.hasPointerCapture(e.pointerId)) { + const delta = { + deltaX: e.clientX - startPosition.x, + deltaY: e.clientY - startPosition.y, + }; + + context.onDrag?.(delta); + startPosition = { x: e.clientX, y: e.clientY }; + } + }; + + const onPointerUp: JSX.EventHandlerUnion = e => { + e.stopPropagation(); + callHandler(e, local.onPointerUp); + + const target = e.currentTarget as HTMLElement; + + if (target.hasPointerCapture(e.pointerId)) { + target.releasePointerCapture(e.pointerId); + context.onDragEnd?.(); + } + }; + + return ( + + as="span" + ref={mergeRefs(context.setThumbRef, props.ref)} + role="slider" + id={fieldProps.id()} + tabIndex={context.state.isDisabled() ? undefined : 0} + style={combineStyle( + { + position: "absolute", + left: `${context.outerRadius()! + context.state.getThumbPosition().x}px`, + top: `${context.outerRadius()! + context.state.getThumbPosition().y}px`, + transform: "translate(-50%, -50%)", + "forced-color-adjust": "none", + "touch-action": "none", + }, + local.style, + )} + aria-valuetext={context.getThumbValueLabel()} + aria-valuemin={context.state.minValue()} + aria-valuenow={context.state.hue()} + aria-valuemax={context.state.maxValue()} + aria-label={fieldProps.ariaLabel()} + aria-labelledby={fieldProps.ariaLabelledBy()} + aria-describedby={fieldProps.ariaDescribedBy()} + onKeyDown={onKeyDown} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + {...formControlContext.dataset()} + {...others} + /> + ); +} diff --git a/packages/core/src/colors/color-wheel/color-wheel-track.tsx b/packages/core/src/colors/color-wheel/color-wheel-track.tsx new file mode 100644 index 000000000..ae7f37009 --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-track.tsx @@ -0,0 +1,135 @@ +import { callHandler, mergeRefs } from "@kobalte/utils"; +import { combineStyle } from "@solid-primitives/props"; +import { type JSX, type ValidComponent, createMemo, createSignal, splitProps } from "solid-js"; +import { type FormControlDataSet, useFormControlContext } from "../../form-control"; +import { type ElementOf, Polymorphic, type PolymorphicProps } from "../../polymorphic"; +import { useColorWheelContext } from "./color-wheel-context"; + +export interface ColorWheelTrackOptions {} + +export interface ColorWheelTrackCommonProps { + style?: JSX.CSSProperties | string; + onPointerDown: JSX.EventHandlerUnion; + onPointerMove: JSX.EventHandlerUnion; + onPointerUp: JSX.EventHandlerUnion; +} + +export interface ColorWheelTrackRenderProps + extends ColorWheelTrackCommonProps, + FormControlDataSet {} + +export type ColorWheelTrackProps = + ColorWheelTrackOptions & Partial>>; + +export function ColorWheelTrack( + props: PolymorphicProps>, +) { + const context = useColorWheelContext(); + const formControlContext = useFormControlContext(); + + const [local, others] = splitProps(props, [ + "style", + "onPointerDown", + "onPointerMove", + "onPointerUp", + ]); + + const [sRect, setRect] = createSignal(); + + const getValueFromPointer = (pointerPosition: { x: number; y: number }) => { + const rect = sRect() || context.trackRef()!.getBoundingClientRect(); + setRect(rect); + return [ + pointerPosition.x - rect.left - rect.width / 2, + pointerPosition.y - rect.top - rect.height / 2, + ]; + }; + + let startPosition = { x: 0, y: 0 }; + + const onPointerDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onPointerDown); + + const target = e.target as HTMLElement; + target.setPointerCapture(e.pointerId); + + e.preventDefault(); + const value = getValueFromPointer({ x: e.clientX, y: e.clientY }); + startPosition = { x: e.clientX, y: e.clientY }; + context.onDragStart?.(value); + }; + + const onPointerMove: JSX.EventHandlerUnion = e => { + callHandler(e, local.onPointerMove); + + const target = e.target as HTMLElement; + + if (target.hasPointerCapture(e.pointerId)) { + context.onDrag?.({ + deltaX: e.clientX - startPosition.x, + deltaY: e.clientY - startPosition.y, + }); + startPosition = { x: e.clientX, y: e.clientY }; + } + }; + + const onPointerUp: JSX.EventHandlerUnion = e => { + callHandler(e, local.onPointerUp); + + const target = e.target as HTMLElement; + + if (target.hasPointerCapture(e.pointerId)) { + target.releasePointerCapture(e.pointerId); + setRect(undefined); + context.onDragEnd?.(); + } + }; + + const backgroundStyle = createMemo(() => { + return { + background: ` + conic-gradient( + from 90deg, + hsl(0, 100%, 50%), + hsl(30, 100%, 50%), + hsl(60, 100%, 50%), + hsl(90, 100%, 50%), + hsl(120, 100%, 50%), + hsl(150, 100%, 50%), + hsl(180, 100%, 50%), + hsl(210, 100%, 50%), + hsl(240, 100%, 50%), + hsl(270, 100%, 50%), + hsl(300, 100%, 50%), + hsl(330, 100%, 50%), + hsl(360, 100%, 50%) + ) + `, + }; + }); + + return ( + + as="div" + ref={mergeRefs(context.setTrackRef, props.ref)} + style={combineStyle( + { + "touch-action": "none", + "forced-color-adjust": "none", + ...backgroundStyle(), + "clip-path": `path(evenodd, "${circlePath(context.outerRadius()!, context.outerRadius()!, context.outerRadius()!)} ${circlePath(context.outerRadius()!, context.outerRadius()!, context.innerRadius())}")`, + }, + local.style, + )} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + {...formControlContext.dataset()} + {...others} + /> + ); +} + +function circlePath(cx: number, cy: number, r: number) { + return `M ${cx}, ${cy} m ${-r}, 0 a ${r}, ${r}, 0, 1, 0, ${r * 2}, 0 a ${r}, ${r}, 0, 1, 0 ${-r * 2}, 0`; +} diff --git a/packages/core/src/colors/color-wheel/color-wheel-value-label.tsx b/packages/core/src/colors/color-wheel/color-wheel-value-label.tsx new file mode 100644 index 000000000..de75c63aa --- /dev/null +++ b/packages/core/src/colors/color-wheel/color-wheel-value-label.tsx @@ -0,0 +1,35 @@ +import type { JSX, ValidComponent } from "solid-js"; + +import { type FormControlDataSet, useFormControlContext } from "../../form-control"; +import { type ElementOf, Polymorphic, type PolymorphicProps } from "../../polymorphic"; +import { useColorWheelContext } from "./color-wheel-context"; + +export interface ColorWheelValueLabelOptions {} + +export interface ColorWheelValueLabelCommonProps {} + +export interface ColorWheelValueLabelRenderProps + extends ColorWheelValueLabelCommonProps, + FormControlDataSet { + children: JSX.Element; +} + +export type ColorWheelValueLabelProps = + ColorWheelValueLabelOptions & Partial>>; + +export function ColorWheelValueLabel( + props: PolymorphicProps>, +) { + const context = useColorWheelContext(); + const formControlContext = useFormControlContext(); + + return ( + + as="div" + {...formControlContext.dataset()} + {...(props as ColorWheelValueLabelProps)} + > + {context.getValueLabel(context.state.value())} + + ); +} diff --git a/packages/core/src/colors/color-wheel/create-color-wheel-state.ts b/packages/core/src/colors/color-wheel/create-color-wheel-state.ts new file mode 100644 index 000000000..8f4f61d31 --- /dev/null +++ b/packages/core/src/colors/color-wheel/create-color-wheel-state.ts @@ -0,0 +1,135 @@ +import { mergeDefaultProps } from "@kobalte/utils"; +import { type Accessor, createMemo, createSignal } from "solid-js"; +import { createControllableSignal } from "../../primitives"; +import type { Color } from "../types"; +import { parseColor } from "../utils"; +import { angleToCartesian, cartesianToAngle, mod, roundDown, roundToStep } from "./utils"; + +export interface ColorWheelState { + readonly value: Accessor; + setValue: (value: Color) => void; + readonly hue: Accessor; + setHue: (value: number) => void; + step: Accessor; + pageSize: Accessor; + maxValue: Accessor; + minValue: Accessor; + increment: (stepSize: number) => void; + decrement: (stepSize: number) => void; + getThumbPosition: () => { x: number; y: number }; + setThumbValue: (x: number, y: number, radius: number) => void; + readonly isDragging: Accessor; + setIsDragging: (value: boolean) => void; + resetValue: () => void; + readonly isDisabled: Accessor; +} + +interface StateOpts { + value: Accessor; + defaultValue: Accessor; + thumbRadius?: Accessor; + onChange?: (value: Color) => void; + onChangeEnd?: (value: Color) => void; + isDisabled?: Accessor; +} + +export function createColorWheelState(props: StateOpts): ColorWheelState { + const mergedProps: StateOpts = mergeDefaultProps( + { + isDisabled: () => false, + }, + props, + ); + + const defaultValue = createMemo(() => { + return mergedProps.defaultValue() ?? parseColor("hsl(0, 100%, 50%)"); + }); + + const [value, setValue] = createControllableSignal({ + value: mergedProps.value, + defaultValue, + onChange: value => mergedProps.onChange?.(value), + }); + + const color = createMemo(() => { + const colorSpace = value()!.getColorSpace(); + return colorSpace === "hsl" || colorSpace === "hsb" ? value()! : value()!.toFormat("hsl"); + }); + + const channelRange = () => color().getChannelRange("hue"); + + const step = () => channelRange().step; + const pageSize = () => channelRange().pageSize; + const maxValue = () => channelRange().maxValue; + const minValue = () => channelRange().minValue; + + const [isDragging, setIsDragging] = createSignal(false); + + const resetValue = () => { + setValue(defaultValue()); + }; + + const hue = () => color().getChannelValue("hue"); + + const setHue = (value: number) => { + let newValue = value > 360 ? 0 : value; + newValue = roundToStep(mod(newValue, 360), step()); + if (hue() !== newValue) { + setValue(color().withChannelValue("hue", newValue)); + } + }; + + const increment = (stepSize = 1) => { + const newStepSize = Math.max(stepSize, step()); + let newValue = hue() + newStepSize; + if (newValue >= maxValue()) { + newValue = minValue(); + } + setHue(roundToStep(mod(newValue, 360), newStepSize)); + }; + + const decrement = (stepSize = 1) => { + const newStepSize = Math.max(stepSize, step()); + if (hue() === 0) { + setHue(roundDown(360 / newStepSize) * newStepSize); + } else { + setHue(roundToStep(mod(hue() - newStepSize, 360), newStepSize)); + } + }; + + const getThumbPosition = () => angleToCartesian(hue(), mergedProps.thumbRadius!()); + + const setThumbValue = (x: number, y: number, radius: number) => { + if (mergedProps.isDisabled!()) return; + setHue(cartesianToAngle(x, y, radius)); + }; + + const updateDragging = (dragging: boolean) => { + if (mergedProps.isDisabled!()) return; + const wasDragging = isDragging(); + setIsDragging(dragging); + + if (wasDragging && !isDragging()) { + mergedProps.onChangeEnd?.(color()); + } + }; + + return { + value: color, + setValue, + hue, + setHue, + step, + pageSize, + maxValue, + minValue, + increment, + decrement, + getThumbPosition, + setThumbValue, + isDragging, + setIsDragging: updateDragging, + resetValue, + isDisabled: mergedProps.isDisabled!, + }; +} diff --git a/packages/core/src/colors/color-wheel/index.tsx b/packages/core/src/colors/color-wheel/index.tsx new file mode 100644 index 000000000..73cd6c2fb --- /dev/null +++ b/packages/core/src/colors/color-wheel/index.tsx @@ -0,0 +1,90 @@ +import { + type FormControlDescriptionCommonProps as ColorWheelDescriptionCommonProps, + type FormControlDescriptionOptions as ColorWheelDescriptionOptions, + type FormControlDescriptionProps as ColorWheelDescriptionProps, + type FormControlDescriptionRenderProps as ColorWheelDescriptionRenderProps, + type FormControlErrorMessageCommonProps as ColorWheelErrorMessageCommonProps, + type FormControlErrorMessageOptions as ColorWheelErrorMessageOptions, + type FormControlErrorMessageProps as ColorWheelErrorMessageProps, + type FormControlErrorMessageRenderProps as ColorWheelErrorMessageRenderProps, + type FormControlLabelCommonProps as ColorWheelLabelCommonProps, + type FormControlLabelOptions as ColorWheelLabelOptions, + type FormControlLabelProps as ColorWheelLabelProps, + type FormControlLabelRenderProps as ColorWheelLabelRenderProps, + FormControlDescription as Description, + FormControlErrorMessage as ErrorMessage, + FormControlLabel as Label, +} from "../../form-control"; + +import { type ColorWheelInputProps, ColorWheelInput as Input } from "./color-wheel-input"; +import { + type ColorWheelRootCommonProps, + type ColorWheelRootOptions, + type ColorWheelRootProps, + type ColorWheelRootRenderProps, + ColorWheelRoot as Root, +} from "./color-wheel-root"; +import { + type ColorWheelThumbCommonProps, + type ColorWheelThumbOptions, + type ColorWheelThumbProps, + type ColorWheelThumbRenderProps, + ColorWheelThumb as Thumb, +} from "./color-wheel-thumb"; +import { + type ColorWheelTrackCommonProps, + type ColorWheelTrackOptions, + type ColorWheelTrackProps, + type ColorWheelTrackRenderProps, + ColorWheelTrack as Track, +} from "./color-wheel-track"; +import { + type ColorWheelValueLabelCommonProps, + type ColorWheelValueLabelOptions, + type ColorWheelValueLabelProps, + type ColorWheelValueLabelRenderProps, + ColorWheelValueLabel as ValueLabel, +} from "./color-wheel-value-label"; + +export type { + ColorWheelDescriptionProps, + ColorWheelDescriptionOptions, + ColorWheelDescriptionCommonProps, + ColorWheelDescriptionRenderProps, + ColorWheelErrorMessageOptions, + ColorWheelErrorMessageCommonProps, + ColorWheelErrorMessageRenderProps, + ColorWheelErrorMessageProps, + ColorWheelInputProps, + ColorWheelLabelOptions, + ColorWheelLabelCommonProps, + ColorWheelLabelRenderProps, + ColorWheelLabelProps, + ColorWheelRootOptions, + ColorWheelRootCommonProps, + ColorWheelRootRenderProps, + ColorWheelRootProps, + ColorWheelThumbOptions, + ColorWheelThumbCommonProps, + ColorWheelThumbRenderProps, + ColorWheelThumbProps, + ColorWheelTrackOptions, + ColorWheelTrackCommonProps, + ColorWheelTrackRenderProps, + ColorWheelTrackProps, + ColorWheelValueLabelOptions, + ColorWheelValueLabelCommonProps, + ColorWheelValueLabelRenderProps, + ColorWheelValueLabelProps, +}; +export { Description, ErrorMessage, Input, Label, Root, Thumb, Track, ValueLabel }; + +export const ColorWheel = Object.assign(Root, { + Description, + ErrorMessage, + Input, + Label, + Thumb, + Track, + ValueLabel, +}); diff --git a/packages/core/src/colors/color-wheel/utils.ts b/packages/core/src/colors/color-wheel/utils.ts new file mode 100644 index 000000000..f79337752 --- /dev/null +++ b/packages/core/src/colors/color-wheel/utils.ts @@ -0,0 +1,35 @@ +export function roundToStep(value: number, step: number): number { + return Math.round(value / step) * step; +} + +export function mod(n: number, m: number) { + return ((n % m) + m) % m; +} + +export function roundDown(v: number) { + const r = Math.floor(v); + if (r === v) { + return v - 1; + } + return r; +} + +export function degToRad(deg: number) { + return (deg * Math.PI) / 180; +} + +export function radToDeg(rad: number) { + return (rad * 180) / Math.PI; +} + +export function angleToCartesian(angle: number, radius: number): { x: number; y: number } { + const rad = degToRad(360 - angle + 90); + const x = Math.sin(rad) * radius; + const y = Math.cos(rad) * radius; + return { x, y }; +} + +export function cartesianToAngle(x: number, y: number, radius: number): number { + const deg = radToDeg(Math.atan2(y / radius, x / radius)); + return (deg + 360) % 360; +} diff --git a/packages/core/src/colors/intl.ts b/packages/core/src/colors/intl.ts new file mode 100644 index 000000000..e8662ca1c --- /dev/null +++ b/packages/core/src/colors/intl.ts @@ -0,0 +1,47 @@ +export const COLOR_INTL_TRANSLATIONS = { + hue: "Hue", + saturation: "Saturation", + lightness: "Lightness", + brightness: "Brightness", + red: "Red", + green: "Green", + blue: "Blue", + alpha: "Alpha", + colorName: (lightness: string, chroma: string, hue: string) => + `${lightness} ${chroma} ${hue}`, + transparentColorName: ( + lightness: string, + chroma: string, + hue: string, + percentTransparent: string, + ) => `${lightness} ${chroma} ${hue}, ${percentTransparent} transparent`, + "very dark": "very dark", + dark: "dark", + light: "light", + "very light": "very light", + pale: "pale", + grayish: "grayish", + vibrant: "vibrant", + black: "black", + white: "white", + gray: "gray", + pink: "pink", + "pink red": "pink red", + "red orange": "red orange", + brown: "brown", + orange: "orange", + "orange yellow": "orange yellow", + "brown yellow": "brown yellow", + yellow: "yellow", + "yellow green": "yellow green", + "green cyan": "green cyan", + cyan: "cyan", + "cyan blue": "cyan blue", + "blue purple": "blue purple", + purple: "purple", + "purple magenta": "purple magenta", + magenta: "magenta", + "magenta pink": "magenta pink", +}; + +export type ColorIntlTranslations = typeof COLOR_INTL_TRANSLATIONS; diff --git a/packages/core/src/colors/types.ts b/packages/core/src/colors/types.ts new file mode 100644 index 000000000..6a0975e6d --- /dev/null +++ b/packages/core/src/colors/types.ts @@ -0,0 +1,116 @@ +/* + * Portions of this file are based on code from react-spectrum. + * Apache License Version 2.0, Copyright 2020 Adobe. + * + * Credits to the React Spectrum team: + * https://github.com/adobe/react-spectrum/blob/68e305768cb829bab7b9836dded593bd731259f3/packages/%40react-types/color/src/index.d.ts + * + */ + +import type { ColorIntlTranslations } from "./intl"; + +/** A list of supported color formats. */ +export type ColorFormat = + | "hex" + | "hexa" + | "rgb" + | "rgba" + | "hsl" + | "hsla" + | "hsb" + | "hsba"; + +export type ColorSpace = "rgb" | "hsl" | "hsb"; + +/** A list of color channels. */ +export type ColorChannel = + | "hue" + | "saturation" + | "brightness" + | "lightness" + | "red" + | "green" + | "blue" + | "alpha"; + +export type ColorAxes = { + xChannel: ColorChannel; + yChannel: ColorChannel; + zChannel: ColorChannel; +}; + +export interface ColorChannelRange { + /** The minimum value of the color channel. */ + minValue: number; + /** The maximum value of the color channel. */ + maxValue: number; + /** The step value of the color channel, used when incrementing and decrementing. */ + step: number; + /** The page step value of the color channel, used when incrementing and decrementing. */ + pageSize: number; +} + +/** Represents a color value. */ +export interface Color { + /** Converts the color to the given color format, and returns a new Color object. */ + toFormat(format: ColorFormat): Color; + /** Converts the color to a string in the given format. */ + toString(format?: ColorFormat | "css"): string; + /** Returns a duplicate of the color value. */ + clone(): Color; + /** Converts the color to hex, and returns an integer representation. */ + toHexInt(): number; + /** + * Returns the numeric value for a given channel. + * Throws an error if the channel is unsupported in the current color format. + */ + getChannelValue(channel: ColorChannel): number; + /** + * Sets the numeric value for a given channel, and returns a new Color object. + * Throws an error if the channel is unsupported in the current color format. + */ + withChannelValue(channel: ColorChannel, value: number): Color; + /** + * Returns the minimum, maximum, and step values for a given channel. + */ + getChannelRange(channel: ColorChannel): ColorChannelRange; + /** + * Returns a localized color channel name for a given channel and locale, + * for use in visual or accessibility labels. + */ + getChannelName( + channel: ColorChannel, + translations: ColorIntlTranslations, + ): string; + /** + * Returns the number formatting options for the given channel. + */ + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions; + /** + * Formats the numeric value for a given channel for display according to the provided locale. + */ + formatChannelValue(channel: ColorChannel): string; + /** + * Returns the color space, 'rgb', 'hsb' or 'hsl', for the current color. + */ + getColorSpace(): ColorSpace; + /** + * Returns the color space axes, xChannel, yChannel, zChannel. + */ + getColorSpaceAxes(xyChannels: { + xChannel?: ColorChannel; + yChannel?: ColorChannel; + }): ColorAxes; + /** + * Returns an array of the color channels within the current color space space. + */ + getColorChannels(): [ColorChannel, ColorChannel, ColorChannel]; + /** + * Returns a localized name for the color, for use in visual or accessibility labels. + */ + getColorName(translations: ColorIntlTranslations): string; + /** + * Returns a localized name for the hue, for use in visual or accessibility labels. + */ + getHueName(translations: ColorIntlTranslations): string; +} diff --git a/packages/core/src/colors/utils.ts b/packages/core/src/colors/utils.ts new file mode 100644 index 000000000..0bf32e8d9 --- /dev/null +++ b/packages/core/src/colors/utils.ts @@ -0,0 +1,896 @@ +/* + * Portions of this file are based on code from react-spectrum. + * Apache License Version 2.0, Copyright 2020 Adobe. + * + * Credits to the React Spectrum team: + * https://github.com/adobe/react-spectrum/blob/68e305768cb829bab7b9836dded593bd731259f3/packages/%40react-stately/color/src/Color.ts + * + */ + +import { clamp } from "@kobalte/utils"; +import { createNumberFormatter } from "../i18n"; +import type { ColorIntlTranslations } from "./intl"; +import type { + ColorAxes, + ColorChannel, + ColorChannelRange, + ColorFormat, + ColorSpace, + Color as IColor, +} from "./types"; + +/** Parses a color from a string value. Throws an error if the string could not be parsed. */ +export function parseColor(value: string): IColor { + const res = + RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value); + if (res) { + return res; + } + + throw new Error(`Invalid color value: ${value}`); +} + +export function normalizeColor(v: string | IColor) { + if (typeof v === "string") { + return parseColor(v); + } + return v; +} + +/** Returns a list of color channels for a given color space. */ +export function getColorChannels(colorSpace: ColorSpace) { + switch (colorSpace) { + case "rgb": + return RGBColor.colorChannels; + case "hsl": + return HSLColor.colorChannels; + case "hsb": + return HSBColor.colorChannels; + } +} + +/** + * Returns the hue value normalized to the range of 0 to 360. + */ +export function normalizeHue(hue: number) { + if (hue === 360) { + return hue; + } + + return ((hue % 360) + 360) % 360; +} + +// Lightness threshold between orange and brown. +const ORANGE_LIGHTNESS_THRESHOLD = 0.68; +// Lightness threshold between pure yellow and "yellow green". +const YELLOW_GREEN_LIGHTNESS_THRESHOLD = 0.85; +// The maximum lightness considered to be "dark". +const MAX_DARK_LIGHTNESS = 0.55; +// The chroma threshold between gray and color. +const GRAY_THRESHOLD = 0.001; +const OKLCH_HUES: [number, string][] = [ + [0, "pink"], + [15, "red"], + [48, "orange"], + [94, "yellow"], + [135, "green"], + [175, "cyan"], + [264, "blue"], + [284, "purple"], + [320, "magenta"], + [349, "pink"], +]; + +abstract class Color implements IColor { + abstract toFormat(format: ColorFormat): IColor; + abstract toString(format: ColorFormat | "css"): string; + abstract clone(): IColor; + abstract getChannelRange(channel: ColorChannel): ColorChannelRange; + abstract getChannelFormatOptions( + channel: ColorChannel, + ): Intl.NumberFormatOptions; + abstract formatChannelValue(channel: ColorChannel): string; + + toHexInt(): number { + return this.toFormat("rgb").toHexInt(); + } + + getChannelValue(channel: ColorChannel): number { + if (channel in this) { + //@ts-expect-error + return this[channel]; + } + + throw new Error(`Unsupported color channel: ${channel}`); + } + + withChannelValue(channel: ColorChannel, value: number): IColor { + if (channel in this) { + const x = this.clone(); + //@ts-expect-error + x[channel] = value; + return x; + } + + throw new Error(`Unsupported color channel: ${channel}`); + } + + getChannelName(channel: ColorChannel, translations: ColorIntlTranslations) { + return translations[channel]; + } + + abstract getColorSpace(): ColorSpace; + + getColorSpaceAxes(xyChannels: { + xChannel?: ColorChannel; + yChannel?: ColorChannel; + }): ColorAxes { + const { xChannel, yChannel } = xyChannels; + const xCh = + xChannel || this.getColorChannels().find((c) => c !== yChannel)!; + const yCh = yChannel || this.getColorChannels().find((c) => c !== xCh)!; + const zCh = this.getColorChannels().find((c) => c !== xCh && c !== yCh)!; + + return { xChannel: xCh, yChannel: yCh, zChannel: zCh }; + } + + abstract getColorChannels(): [ColorChannel, ColorChannel, ColorChannel]; + + getColorName(translations: ColorIntlTranslations): string { + // Convert to oklch color space, which has perceptually uniform lightness across all hues. + let [l, c, h] = toOKLCH(this); + + if (l > 0.999) { + return translations.white; + } + + if (l < 0.001) { + return translations.black; + } + + let hue: string; + [hue, l] = this.getOklchHue(l, c, h, translations); + + let lightness = ""; + let chroma = ""; + if (c <= 0.1 && c >= GRAY_THRESHOLD) { + if (l >= 0.7) { + chroma = "pale"; + } else { + chroma = "grayish"; + } + } else if (c >= 0.15) { + chroma = "vibrant"; + } + + if (l < 0.3) { + lightness = "very dark"; + } else if (l < MAX_DARK_LIGHTNESS) { + lightness = "dark"; + } else if (l < 0.7) { + // none + } else if (l < 0.85) { + lightness = "light"; + } else { + lightness = "very light"; + } + + if (chroma) { + //@ts-expect-error + chroma = translations[chroma]; + } + + if (lightness) { + //@ts-expect-error + lightness = translations[lightness]; + } + + const alpha = this.getChannelValue("alpha"); + if (alpha < 1) { + const percentTransparent = createNumberFormatter(() => ({ + style: "percent", + }))().format(1 - alpha); + return translations + .transparentColorName(lightness, chroma, hue, percentTransparent) + .replace(/\s+/g, " ") + .trim(); + } + return translations + .colorName(lightness, chroma, hue) + .replace(/\s+/g, " ") + .trim(); + } + + private getOklchHue( + l: number, + c: number, + h: number, + translations: ColorIntlTranslations, + ): [string, number] { + if (c < GRAY_THRESHOLD) { + return [translations.gray, l]; + } + + for (let i = 0; i < OKLCH_HUES.length; i++) { + let [hue, hueName] = OKLCH_HUES[i]; + const [nextHue, nextHueName] = OKLCH_HUES[i + 1] || [360, "pink"]; + if (h >= hue && h < nextHue) { + // Split orange hue into brown/orange depending on lightness. + if (hueName === "orange") { + if (l < ORANGE_LIGHTNESS_THRESHOLD) { + hueName = "brown"; + } else { + // Adjust lightness. + // biome-ignore lint/style/noParameterAssign: + l = l - ORANGE_LIGHTNESS_THRESHOLD + MAX_DARK_LIGHTNESS; + } + } + + // If the hue is at least halfway to the next hue, add the next hue name as well. + if (h > hue + (nextHue - hue) / 2 && hueName !== nextHueName) { + hueName = `${hueName} ${nextHueName}`; + } else if ( + hueName === "yellow" && + l < YELLOW_GREEN_LIGHTNESS_THRESHOLD + ) { + // Yellow shifts toward green at lower lightnesses. + hueName = "yellow green"; + } + //@ts-expect-error + const name = translations[hueName]; + return [name, l]; + } + } + + throw new Error("Unexpected hue"); + } + + getHueName(translations: ColorIntlTranslations): string { + const [l, c, h] = toOKLCH(this); + const [name] = this.getOklchHue(l, c, h, translations); + return name; + } +} + +class RGBColor extends Color { + constructor( + private red: number, + private green: number, + private blue: number, + private alpha: number, + ) { + super(); + } + + static parse(value: string) { + let colors: Array = []; + // matching #rgb, #rgba, #rrggbb, #rrggbbaa + if (/^#[\da-f]+$/i.test(value) && [4, 5, 7, 9].includes(value.length)) { + const values = ( + value.length < 6 ? value.replace(/[^#]/gi, "$&$&") : value + ) + .slice(1) + .split(""); + while (values.length > 0) { + colors.push(Number.parseInt(values.splice(0, 2).join(""), 16)); + } + colors[3] = colors[3] !== undefined ? colors[3] / 255 : undefined; + } + + // matching rgb(rrr, ggg, bbb), rgba(rrr, ggg, bbb, 0.a) + const match = value.match(/^rgba?\((.*)\)$/); + if (match?.[1]) { + colors = match[1].split(",").map((value) => Number(value.trim())); + colors = colors.map((num, i) => { + return clamp(num ?? 0, 0, i < 3 ? 255 : 1); + }); + } + if ( + colors[0] === undefined || + colors[1] === undefined || + colors[2] === undefined + ) { + return undefined; + } + + return colors.length < 3 + ? undefined + : new RGBColor(colors[0], colors[1], colors[2], colors[3] ?? 1); + } + + toString(format: ColorFormat | "css" = "css") { + switch (format) { + case "hex": + return `#${( + this.red.toString(16).padStart(2, "0") + + this.green.toString(16).padStart(2, "0") + + this.blue.toString(16).padStart(2, "0") + ).toUpperCase()}`; + case "hexa": + return `#${( + this.red.toString(16).padStart(2, "0") + + this.green.toString(16).padStart(2, "0") + + this.blue.toString(16).padStart(2, "0") + + Math.round(this.alpha * 255) + .toString(16) + .padStart(2, "0") + ).toUpperCase()}`; + case "rgb": + return `rgb(${this.red}, ${this.green}, ${this.blue})`; + case "css": + case "rgba": + return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`; + default: + return this.toFormat(format).toString(format); + } + } + + toFormat(format: ColorFormat): IColor { + switch (format) { + case "hex": + case "hexa": + case "rgb": + case "rgba": + return this; + case "hsb": + case "hsba": + return this.toHSB(); + case "hsl": + case "hsla": + return this.toHSL(); + default: + throw new Error(`Unsupported color conversion: rgb -> ${format}`); + } + } + + toHexInt(): number { + return (this.red << 16) | (this.green << 8) | this.blue; + } + + /** + * Converts an RGB color value to HSB. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB. + * @returns An HSBColor object. + */ + private toHSB(): IColor { + const red = this.red / 255; + const green = this.green / 255; + const blue = this.blue / 255; + const min = Math.min(red, green, blue); + const brightness = Math.max(red, green, blue); + const chroma = brightness - min; + const saturation = brightness === 0 ? 0 : chroma / brightness; + let hue = 0; // achromatic + + if (chroma !== 0) { + switch (brightness) { + case red: + hue = (green - blue) / chroma + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / chroma + 2; + break; + case blue: + hue = (red - green) / chroma + 4; + break; + } + + hue /= 6; + } + + return new HSBColor( + toFixedNumber(hue * 360, 2), + toFixedNumber(saturation * 100, 2), + toFixedNumber(brightness * 100, 2), + this.alpha, + ); + } + + /** + * Converts an RGB color value to HSL. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB. + * @returns An HSLColor object. + */ + private toHSL(): IColor { + const red = this.red / 255; + const green = this.green / 255; + const blue = this.blue / 255; + const min = Math.min(red, green, blue); + const max = Math.max(red, green, blue); + const lightness = (max + min) / 2; + const chroma = max - min; + let hue: number; + let saturation: number; + + if (chroma === 0) { + hue = saturation = 0; // achromatic + } else { + saturation = chroma / (lightness < 0.5 ? max + min : 2 - max - min); + + switch (max) { + case red: + hue = (green - blue) / chroma + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / chroma + 2; + break; + default: + hue = (red - green) / chroma + 4; + break; + } + + hue /= 6; + } + + return new HSLColor( + toFixedNumber(hue * 360, 2), + toFixedNumber(saturation * 100, 2), + toFixedNumber(lightness * 100, 2), + this.alpha, + ); + } + + clone(): IColor { + return new RGBColor(this.red, this.green, this.blue, this.alpha); + } + + getChannelRange(channel: ColorChannel): ColorChannelRange { + switch (channel) { + case "red": + case "green": + case "blue": + return { minValue: 0x0, maxValue: 0xff, step: 0x1, pageSize: 0x11 }; + case "alpha": + return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions { + switch (channel) { + case "red": + case "green": + case "blue": + return { style: "decimal" }; + case "alpha": + return { style: "percent" }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + formatChannelValue(channel: ColorChannel) { + const options = this.getChannelFormatOptions(channel); + const value = this.getChannelValue(channel); + return createNumberFormatter(() => options)().format(value); + } + + getColorSpace(): ColorSpace { + return "rgb"; + } + + static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [ + "red", + "green", + "blue", + ]; + getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + return RGBColor.colorChannels; + } +} + +// X = +// before/after a comma, 0 or more whitespaces are allowed +// - hsb(X, X%, X%) +// - hsba(X, X%, X%, X) +const HSB_REGEX = + /hsb\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsba\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/; + +class HSBColor extends Color { + constructor( + private hue: number, + private saturation: number, + private brightness: number, + private alpha: number, + ) { + super(); + } + + static parse(value: string): HSBColor | undefined { + let m: RegExpMatchArray | null; + if ((m = value.match(HSB_REGEX))) { + const [h, s, b, a] = (m[1] ?? m[2]) + .split(",") + .map((n) => Number(n.trim().replace("%", ""))); + return new HSBColor( + normalizeHue(h), + clamp(s, 0, 100), + clamp(b, 0, 100), + clamp(a ?? 1, 0, 1), + ); + } + } + + toString(format: ColorFormat | "css" = "css") { + switch (format) { + case "css": + return this.toHSL().toString("css"); + case "hex": + return this.toRGB().toString("hex"); + case "hexa": + return this.toRGB().toString("hexa"); + case "hsb": + return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)`; + case "hsba": + return `hsba(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%, ${this.alpha})`; + default: + return this.toFormat(format).toString(format); + } + } + + toFormat(format: ColorFormat): IColor { + switch (format) { + case "hsb": + case "hsba": + return this; + case "hsl": + case "hsla": + return this.toHSL(); + case "rgb": + case "rgba": + return this.toRGB(); + default: + throw new Error(`Unsupported color conversion: hsb -> ${format}`); + } + } + + /** + * Converts a HSB color to HSL. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL. + * @returns An HSLColor object. + */ + private toHSL(): IColor { + let saturation = this.saturation / 100; + const brightness = this.brightness / 100; + const lightness = brightness * (1 - saturation / 2); + saturation = + lightness === 0 || lightness === 1 + ? 0 + : (brightness - lightness) / Math.min(lightness, 1 - lightness); + + return new HSLColor( + toFixedNumber(this.hue, 2), + toFixedNumber(saturation * 100, 2), + toFixedNumber(lightness * 100, 2), + this.alpha, + ); + } + + /** + * Converts a HSV color value to RGB. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative. + * @returns An RGBColor object. + */ + private toRGB(): IColor { + const hue = this.hue; + const saturation = this.saturation / 100; + const brightness = this.brightness / 100; + const fn = (n: number, k = (n + hue / 60) % 6) => + brightness - saturation * brightness * Math.max(Math.min(k, 4 - k, 1), 0); + return new RGBColor( + Math.round(fn(5) * 255), + Math.round(fn(3) * 255), + Math.round(fn(1) * 255), + this.alpha, + ); + } + + clone(): IColor { + return new HSBColor(this.hue, this.saturation, this.brightness, this.alpha); + } + + getChannelRange(channel: ColorChannel): ColorChannelRange { + switch (channel) { + case "hue": + return { minValue: 0, maxValue: 360, step: 1, pageSize: 15 }; + case "saturation": + case "brightness": + return { minValue: 0, maxValue: 100, step: 1, pageSize: 10 }; + case "alpha": + return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions { + switch (channel) { + case "hue": + return { style: "unit", unit: "degree", unitDisplay: "narrow" }; + case "saturation": + case "brightness": + case "alpha": + return { style: "percent" }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + formatChannelValue(channel: ColorChannel) { + const options = this.getChannelFormatOptions(channel); + let value = this.getChannelValue(channel); + if (channel === "saturation" || channel === "brightness") { + value /= 100; + } + return createNumberFormatter(() => options)().format(value); + } + + getColorSpace(): ColorSpace { + return "hsb"; + } + + static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [ + "hue", + "saturation", + "brightness", + ]; + getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + return HSBColor.colorChannels; + } +} + +// X = +// before/after a comma, 0 or more whitespaces are allowed +// - hsl(X, X%, X%) +// - hsla(X, X%, X%, X) +const HSL_REGEX = + /hsl\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsla\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/; + +class HSLColor extends Color { + constructor( + private hue: number, + private saturation: number, + private lightness: number, + private alpha: number, + ) { + super(); + } + + static parse(value: string): HSLColor | undefined { + let m: RegExpMatchArray | null; + if ((m = value.match(HSL_REGEX))) { + const [h, s, l, a] = (m[1] ?? m[2]) + .split(",") + .map((n) => Number(n.trim().replace("%", ""))); + return new HSLColor( + normalizeHue(h), + clamp(s, 0, 100), + clamp(l, 0, 100), + clamp(a ?? 1, 0, 1), + ); + } + } + + toString(format: ColorFormat | "css" = "css") { + switch (format) { + case "hex": + return this.toRGB().toString("hex"); + case "hexa": + return this.toRGB().toString("hexa"); + case "hsl": + return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)`; + case "css": + case "hsla": + return `hsla(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%, ${this.alpha})`; + default: + return this.toFormat(format).toString(format); + } + } + toFormat(format: ColorFormat): IColor { + switch (format) { + case "hsl": + case "hsla": + return this; + case "hsb": + case "hsba": + return this.toHSB(); + case "rgb": + case "rgba": + return this.toRGB(); + default: + throw new Error(`Unsupported color conversion: hsl -> ${format}`); + } + } + + /** + * Converts a HSL color to HSB. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_HSV. + * @returns An HSBColor object. + */ + private toHSB(): IColor { + let saturation = this.saturation / 100; + const lightness = this.lightness / 100; + const brightness = + lightness + saturation * Math.min(lightness, 1 - lightness); + saturation = brightness === 0 ? 0 : 2 * (1 - lightness / brightness); + return new HSBColor( + toFixedNumber(this.hue, 2), + toFixedNumber(saturation * 100, 2), + toFixedNumber(brightness * 100, 2), + this.alpha, + ); + } + + /** + * Converts a HSL color to RGB. + * Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative. + * @returns An RGBColor object. + */ + private toRGB(): IColor { + const hue = this.hue; + const saturation = this.saturation / 100; + const lightness = this.lightness / 100; + const a = saturation * Math.min(lightness, 1 - lightness); + const fn = (n: number, k = (n + hue / 30) % 12) => + lightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return new RGBColor( + Math.round(fn(0) * 255), + Math.round(fn(8) * 255), + Math.round(fn(4) * 255), + this.alpha, + ); + } + + clone(): IColor { + return new HSLColor(this.hue, this.saturation, this.lightness, this.alpha); + } + + getChannelRange(channel: ColorChannel): ColorChannelRange { + switch (channel) { + case "hue": + return { minValue: 0, maxValue: 360, step: 1, pageSize: 15 }; + case "saturation": + case "lightness": + return { minValue: 0, maxValue: 100, step: 1, pageSize: 10 }; + case "alpha": + return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions { + switch (channel) { + case "hue": + return { style: "unit", unit: "degree", unitDisplay: "narrow" }; + case "saturation": + case "lightness": + case "alpha": + return { style: "percent" }; + default: + throw new Error(`Unknown color channel: ${channel}`); + } + } + + formatChannelValue(channel: ColorChannel) { + const options = this.getChannelFormatOptions(channel); + let value = this.getChannelValue(channel); + if (channel === "saturation" || channel === "lightness") { + value /= 100; + } + return createNumberFormatter(() => options)().format(value); + } + + getColorSpace(): ColorSpace { + return "hsl"; + } + + static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = [ + "hue", + "saturation", + "lightness", + ]; + getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] { + return HSLColor.colorChannels; + } +} + +// https://www.w3.org/TR/css-color-4/#color-conversion-code +function toOKLCH(color: Color) { + const rgb = color.toFormat("rgb"); + let red = rgb.getChannelValue("red") / 255; + let green = rgb.getChannelValue("green") / 255; + let blue = rgb.getChannelValue("blue") / 255; + [red, green, blue] = lin_sRGB(red, green, blue); + const [x, y, z] = lin_sRGB_to_XYZ(red, green, blue); + const [l, a, b] = XYZ_to_OKLab(x, y, z); + return OKLab_to_OKLCH(l, a, b); +} + +function OKLab_to_OKLCH( + l: number, + a: number, + b: number, +): [number, number, number] { + const hue = (Math.atan2(b, a) * 180) / Math.PI; + return [ + l, + Math.sqrt(a ** 2 + b ** 2), // Chroma + hue >= 0 ? hue : hue + 360, // Hue, in degrees [0 to 360) + ]; +} + +function lin_sRGB(r: number, g: number, b: number): [number, number, number] { + // convert an array of sRGB values + // where in-gamut values are in the range [0 - 1] + // to linear light (un-companded) form. + // https://en.wikipedia.org/wiki/SRGB + // Extended transfer function: + // for negative values, linear portion is extended on reflection of axis, + // then reflected power function is used. + return [lin_sRGB_component(r), lin_sRGB_component(g), lin_sRGB_component(b)]; +} + +function lin_sRGB_component(val: number) { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs <= 0.04045) { + return val / 12.92; + } + + return sign * ((abs + 0.055) / 1.055) ** 2.4; +} + +function lin_sRGB_to_XYZ(r: number, g: number, b: number) { + // convert an array of linear-light sRGB values to CIE XYZ + // using sRGB's own white, D65 (no chromatic adaptation) + const M = [ + 506752 / 1228815, + 87881 / 245763, + 12673 / 70218, + 87098 / 409605, + 175762 / 245763, + 12673 / 175545, + 7918 / 409605, + 87881 / 737289, + 1001167 / 1053270, + ]; + return multiplyMatrix(M, r, g, b); +} + +function XYZ_to_OKLab(x: number, y: number, z: number) { + // Given XYZ relative to D65, convert to OKLab + const XYZtoLMS = [ + 0.819022437996703, 0.3619062600528904, -0.1288737815209879, + 0.0329836539323885, 0.9292868615863434, 0.0361446663506424, + 0.0481771893596242, 0.2642395317527308, 0.6335478284694309, + ]; + const LMStoOKLab = [ + 0.210454268309314, 0.7936177747023054, -0.0040720430116193, + 1.9779985324311684, -2.4285922420485799, 0.450593709617411, + 0.0259040424655478, 0.7827717124575296, -0.8086757549230774, + ]; + + const [a, b, c] = multiplyMatrix(XYZtoLMS, x, y, z); + return multiplyMatrix(LMStoOKLab, Math.cbrt(a), Math.cbrt(b), Math.cbrt(c)); +} + +function multiplyMatrix( + m: number[], + x: number, + y: number, + z: number, +): [number, number, number] { + const a = m[0] * x + m[1] * y + m[2] * z; + const b = m[3] * x + m[4] * y + m[5] * z; + const c = m[6] * x + m[7] * y + m[8] * z; + return [a, b, c]; +} + +function toFixedNumber(value: number, digits: number, base = 10): number { + const pow = base ** digits; + + return Math.round(value * pow) / pow; +} diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 4a72a633e..80547c6d5 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -1,5 +1,8 @@ // utils export * from "./color-mode"; +export * from "./colors/intl"; +export * from "./colors/types"; +export * from "./colors/utils"; export * from "./form-control"; export * from "./i18n"; export * from "./list"; @@ -18,6 +21,8 @@ export * as Button from "./button"; //export * as Calendar from "./calendar"; export * as Checkbox from "./checkbox"; export * as Collapsible from "./collapsible"; +export * as ColorField from "./colors/color-field"; +export * as ColorWheel from "./colors/color-wheel"; export * as Combobox from "./combobox"; export * as ContextMenu from "./context-menu"; //export * as DatePicker from "./date-picker";