Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: color picker #496

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ColorChannel } from "./utils/convert";

type ColorPickerGradientFunction = () => string;

export function ColorPickerAreaBackground() {}
23 changes: 23 additions & 0 deletions packages/core/src/color-picker/color-picker-area.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ColorChannel } from "./utils/convert";
import { createContext, useContext } from "solid-js";
import type { ColorPickerIntlTranslations } from "./color-picker.intl";
import { CoreColor } from "./utils/convert";
import { colorScopeHasChannels } from "./utils/validators";

export interface ColorPickerAreaProps {
xChannel: ColorChannel;
yChannel: ColorChannel;
}

export const ColorPickerArea = createContext<ColorPickerAreaProps>();

export function useColorPickerAreaContext() {
const context = useContext(ColorPickerArea);
if (context === undefined) {
throw new Error(
"[kobalte]: `useColorPickerContext` must be used within a `ColorPicker` component",
);
}
colorScopeHasChannels([context.xChannel, context.yChannel]);
return context;
}
23 changes: 23 additions & 0 deletions packages/core/src/color-picker/color-picker-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type Accessor, createContext, useContext } from "solid-js";
import type { ColorPickerIntlTranslations } from "./color-picker.intl";
import { CoreColor } from "./utils/convert";

export interface ColorPickerContextValue {
translations: Accessor<ColorPickerIntlTranslations>;
value: Accessor<string | null | undefined>;
alpha: Accessor<number>;
setAlpha: (value: number) => void;
}

export const ColorPickerContext = createContext<ColorPickerContextValue>();

export function useColorPickerContext() {
const context = useContext(ColorPickerContext);
if (context === undefined) {
throw new Error(
"[kobalte]: `useColorPickerContext` must be used within a `ColorPicker` component",
);
}

return context;
}
70 changes: 70 additions & 0 deletions packages/core/src/color-picker/color-picker-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { mergeDefaultProps } from "@kobalte/utils";

import {
COLOR_PICKER_INTL_TRANSLATIONS,
type ColorPickerIntlTranslations,
} from "./color-picker.intl";
import { createSignal, ParentProps, splitProps } from "solid-js";

import { createControllableSignal } from "../primitives/create-controllable-signal";
import { ColorPickerContextValue, ColorPickerContext } from "./color-picker-context";
import { ColorPickerViewContextProvider } from "./color-picker-view-context";
import { coreColorToHex, HSVColor } from "./utils/convert";

export interface ColorPickerRootOptions {
/** The value of the menu that should be open when initially rendered. Use when you do not need to control the value state. */
defaultValue?: string;

/** The controlled value of the menu to open. Should be used in conjunction with onValueChange. */
value?: string | null;

/** Event handler called when the value changes. */
onValueChange?: (value: string | undefined | null) => void;

/**
* If `true`, the color picker will return the color with an alpha channel.
*
* You're responsible for showing and hiding the alpha channel in your application.
*/
withAlpha?: boolean;

/** The localized strings of the component. */
translations?: ColorPickerIntlTranslations;
}

export interface ColorPickerRootProps extends ParentProps<ColorPickerRootOptions> {}

export function ColorPickerRoot (props: ColorPickerRootProps) {
const mergedProps = mergeDefaultProps({
translations: COLOR_PICKER_INTL_TRANSLATIONS,
}, props);

const [local, others] = splitProps(mergedProps, ["value", "onValueChange", "defaultValue", "translations"]);

const [value, setValue] = createControllableSignal({
value: () => local.value,
defaultValue: () => local.defaultValue,
onChange: local.onValueChange,
});

const [alpha, setAlpha] = createSignal(1)

const context: ColorPickerContextValue = {
translations: () => local.translations,
value,
alpha,
setAlpha,
}

const HSVPicker = new HSVColor();

return (<ColorPickerContext.Provider value={context}>
<ColorPickerViewContextProvider provider={HSVPicker} onChange={(value) => {
// @ts-ignore we know number[] has a length of 3
const newColor = HSVPicker.fromCoreColor(value);
setValue(coreColorToHex(newColor, props.withAlpha ? alpha() : undefined));
}}>
{others.children}
</ColorPickerViewContextProvider>
</ColorPickerContext.Provider>);
}
81 changes: 81 additions & 0 deletions packages/core/src/color-picker/color-picker-view-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Accessor, createContext, createEffect, createSignal, useContext } from "solid-js";
import { Color, ColorChannel } from "./utils/convert";
import { createStore } from "solid-js/store";
import { useColorPickerContext } from "./color-picker-context";

/**
* This context enables view components to change the context color mode
*/
interface ColorPickerViewContextValue {
provider: Accessor<Color>;
data: Accessor<number[] | undefined>;
setChannel: (channel: ColorChannel, value: number) => void;
setColor: (newColor: number[]) => void;
}

const ColorPickerViewContext = createContext<ColorPickerViewContextValue>();

/**
* This hook enables view components to access the context color mode
*/
export const useColorPickerViewContext = function() {
return useContext(ColorPickerViewContext)
}

export interface ColorPickerViewContextProviderProps {
children: any;
provider: Color;
onChange?: (data: number[]) => void;
}

/**
* This provider enables a certain group of inputs to control color in a given format.
* It propagates these local changes upward, converting formats as it goes.
* Upword changes also propagate downward.
*/
export function ColorPickerViewContextProvider(props: ColorPickerViewContextProviderProps) {
const [provider] = createSignal(props.provider);
const [data, setData] = createStore<number[]>([]);
const parent = useColorPickerViewContext();
const picker = useColorPickerContext();
const setChannel = (channel: ColorChannel, newValue: number) => {
if (channel == "alpha") {
picker.setAlpha(newValue);
return;
}
const indexOfModifiedChannel = provider().channels.indexOf(channel);
if (indexOfModifiedChannel === -1) {
throw new Error(`[kobalte]: color picker view of type ${provider().constructor.name} does not support channel ${channel}`);
}
setData(indexOfModifiedChannel, newValue);
if (parent) {
const coreData = provider().toCoreColor(data);
parent.setColor(parent.provider().fromCoreColor(coreData));
}
props?.onChange?.(data);
};
const setColor = (newColor: number[]) => {
setData(newColor);
if (parent) {
const coreData = provider().toCoreColor(data);
parent.setColor(parent.provider().fromCoreColor(coreData));
}
props?.onChange?.(data);
};
if (parent) {
createEffect(() => {
const newParentData = provider().toCoreColor(parent.data()!);
const newCurrentData = provider().toCoreColor(data);
if (newCurrentData !== data) {
setData(provider().fromCoreColor(newParentData));
}
});
}

return (
<ColorPickerViewContext.Provider value={{ provider, data: () => data, setChannel, setColor }}>
{props.children}
</ColorPickerViewContext.Provider>
);
}

4 changes: 4 additions & 0 deletions packages/core/src/color-picker/color-picker.intl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const COLOR_PICKER_INTL_TRANSLATIONS = {
};

export type ColorPickerIntlTranslations = typeof COLOR_PICKER_INTL_TRANSLATIONS;
80 changes: 80 additions & 0 deletions packages/core/src/color-picker/utils/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Consise implementations by Kamil Kiełczewski
*/

export type CoreColor = [number, number, number];
export type ColorChannel = "hue" | "saturation" | "brightness" | "lightness" | "red" | "green" | "blue" | "alpha" | string;
export type ColorFormat = "rgb" | "hsv" | "hsl" | Color;

export abstract class Color {
/**
* Core color will almost certainly be RGB forever.
*/
abstract toCoreColor(color: number[]): CoreColor;
abstract fromCoreColor(color: CoreColor): any[];
abstract readonly channels: ColorChannel[];
}

export class RGBColor extends Color {
toCoreColor([r, g, b]: CoreColor): CoreColor {
return [r, g, b];
}

fromCoreColor(color: CoreColor): [number, number, number] {
return color;
}

channels = ["red", "green", "blue"];
}

export class HSVColor extends Color {
/**
*
* @param h 0-360
* @param s 0-1
* @param v 0-1
*/
toCoreColor([h, s, v]: [number, number, number]): CoreColor {
let f = (n: number, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
return [f(5), f(3), f(1)] as CoreColor;
}

fromCoreColor([r, g, b]: CoreColor): [number, number, number] {
let v = Math.max(r, g, b), c = v - Math.min(r, g, b);
let h = c && ((v == r) ? (g - b) / c : ((v == g) ? 2 + (b - r) / c : 4 + (r - g) / c));
return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
}

channels = ["hue", "saturation", "brightness"];
}

export class HSLColor extends Color {
/**
*
* @param h 0-360
* @param s 0-1
* @param l 0-1
*/
toCoreColor([h, s, l]: [number, number, number]): CoreColor {
let a = s * Math.min(l, 1 - l);
let f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [f(0), f(8), f(4)] as CoreColor;
}

fromCoreColor([r, g, b]: CoreColor): [number, number, number] {
let v = Math.max(r, g, b), c = v - Math.min(r, g, b), f = (1 - Math.abs(v + v - c - 1));
let h = c && ((v == r) ? (g - b) / c : ((v == g) ? 2 + (b - r) / c : 4 + (r - g) / c));
return [60 * (h < 0 ? h + 6 : h), f ? c / f : 0, (v + v - c) / 2];
}

channels = ["hue", "saturation", "lightness"];
}

export function coreColorToHex([r, g, b]: CoreColor, a?: number): string {
return "#" + [r, g, b]
.map(c => {
const value = c <= 1 ? Math.round(c * 255) : c;
return value.toString(16).padStart(2, "0");
}).join("") +
(a !== undefined ? Math.round(a * 255).toString(16).padStart(2, "0") : "");
}
13 changes: 13 additions & 0 deletions packages/core/src/color-picker/utils/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useColorPickerViewContext } from "../color-picker-view-context";
import { ColorChannel } from "./convert";

export function colorScopeHasChannels(channels: ColorChannel[]) {
const scope = useColorPickerViewContext()
if (!scope) {
throw new Error("[kobalte]: color picker view not found")
}
const scopeChannels = Object.values(scope.provider().channels)
if (!channels.every(channel => scopeChannels.includes(channel))) {
throw new Error(`[kobalte]: color picker view of type ${scope.provider().constructor.name} does not support channels ${channels.join(", ")}`)
}
}
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.