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 00000000..a8ecbea5
--- /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 00000000..35f86b39
--- /dev/null
+++ b/apps/docs/src/examples/color-field.tsx
@@ -0,0 +1,117 @@
+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 (
+
+ );
+}
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 00000000..eaaa3c72
--- /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 00000000..1813d033
--- /dev/null
+++ b/apps/docs/src/examples/color-wheel.tsx
@@ -0,0 +1,131 @@
+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 81ed3064..653c08e8 100644
--- a/apps/docs/src/routes/docs/core.tsx
+++ b/apps/docs/src/routes/docs/core.tsx
@@ -80,6 +80,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [
href: "/docs/core/components/color-channel-field",
status: "new",
},
+ {
+ title: "Color Field",
+ href: "/docs/core/components/color-field",
+ status: "new",
+ },
{
title: "Color Slider",
href: "/docs/core/components/color-slider",
@@ -90,6 +95,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [
href: "/docs/core/components/color-swatch",
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 00000000..e3e0d3d7
--- /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 (
+
+ );
+}
+```
+
+## 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 00000000..9d8652e5
--- /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 00000000..f7e7f885
--- /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 00000000..4ffb675c
--- /dev/null
+++ b/packages/core/src/colors/color-field/color-field-input.tsx
@@ -0,0 +1,56 @@
+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<
+ T extends HTMLElement = HTMLInputElement,
+> {
+ onBlur: JSX.FocusEventHandlerUnion;
+}
+
+export interface ColorFieldInputRenderProps
+ extends ColorFieldInputCommonProps,
+ TextField.TextFieldInputRenderProps {
+ autoComplete: "off";
+ autoCorrect: "off";
+ spellCheck: "false";
+}
+
+export type ColorFieldInputProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..70259196
--- /dev/null
+++ b/packages/core/src/colors/color-field/color-field-root.tsx
@@ -0,0 +1,130 @@
+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<
+ T extends HTMLElement = HTMLElement,
+> {
+ id: string;
+}
+
+export interface ColorFieldRootRenderProps
+ extends ColorFieldRootCommonProps,
+ TextField.TextFieldRootRenderProps {}
+
+export type ColorFieldRootProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..172a0d07
--- /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 00000000..b8ae5dd1
--- /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 00000000..5e486a82
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/color-wheel-input.tsx
@@ -0,0 +1,85 @@
+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 00000000..2948fe5b
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/color-wheel-root.tsx
@@ -0,0 +1,298 @@
+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<
+ T extends HTMLElement = HTMLElement,
+> {
+ id: string;
+ ref: T | ((el: T) => void);
+}
+
+export interface ColorWheelRootRenderProps
+ extends ColorWheelRootCommonProps,
+ FormControlDataSet {
+ role: "group";
+}
+
+export type ColorWheelRootProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..c78725b5
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/color-wheel-thumb.tsx
@@ -0,0 +1,167 @@
+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<
+ T extends HTMLElement = HTMLElement,
+> {
+ 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<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..85a311dc
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/color-wheel-track.tsx
@@ -0,0 +1,155 @@
+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<
+ T extends HTMLElement = HTMLElement,
+> {
+ style?: JSX.CSSProperties | string;
+ onPointerDown: JSX.EventHandlerUnion;
+ onPointerMove: JSX.EventHandlerUnion;
+ onPointerUp: JSX.EventHandlerUnion;
+}
+
+export interface ColorWheelTrackRenderProps
+ extends ColorWheelTrackCommonProps,
+ FormControlDataSet {}
+
+export type ColorWheelTrackProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..4ce80bef
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/color-wheel-value-label.tsx
@@ -0,0 +1,46 @@
+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<
+ T extends HTMLElement = HTMLElement,
+> {}
+
+export interface ColorWheelValueLabelRenderProps
+ extends ColorWheelValueLabelCommonProps,
+ FormControlDataSet {
+ children: JSX.Element;
+}
+
+export type ColorWheelValueLabelProps<
+ T extends ValidComponent | HTMLElement = HTMLElement,
+> = 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 00000000..6cfb524d
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/create-color-wheel-state.ts
@@ -0,0 +1,144 @@
+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 00000000..76b94a88
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/index.tsx
@@ -0,0 +1,102 @@
+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 00000000..8c5f4e56
--- /dev/null
+++ b/packages/core/src/colors/color-wheel/utils.ts
@@ -0,0 +1,38 @@
+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/index.tsx b/packages/core/src/index.tsx
index 486d42aa..973cf311 100644
--- a/packages/core/src/index.tsx
+++ b/packages/core/src/index.tsx
@@ -3,6 +3,9 @@ export * from "./colors/intl";
export * from "./colors/types";
export * from "./colors/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";
@@ -22,10 +25,6 @@ export * as Button from "./button";
//export * as Calendar from "./calendar";
export * as Checkbox from "./checkbox";
export * as Collapsible from "./collapsible";
-export * as ColorArea from "./colors/color-area";
-export * as ColorChannelField from "./colors/color-channel-field";
-export * as ColorSlider from "./colors/color-slider";
-export * as ColorSwatch from "./colors/color-swatch";
export * as Combobox from "./combobox";
export * as ContextMenu from "./context-menu";
//export * as DatePicker from "./date-picker";