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 ( +
+ + + 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 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 ( +
+ + 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 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";