Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
@@ -0,0 +1,453 @@
<script setup lang="ts">
import type { HexColor, HslaColor, HsvaColor, RgbaColor } from "@uiw/color-convert";
import {
hexToHsva,
hslaToHsva,
hsvaToHex,
hsvaToHsla,
hsvaToRgba,
rgbaToHsva,
} from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
import ContrastRatio from "./ContrastRatio.vue";
import ObjectColorInput from "./ObjectColorInput.vue";
export interface ColorPickerValue {
hex: string;
hsl: HslaColor;
hsla: HslaColor;
rgb: RgbaColor;
rgba: RgbaColor;
}
export interface ColorPickerProps {
value?: `#${string}` | HsvaColor | HslaColor | RgbaColor;
type?: "hsl" | "hsla" | "rgb" | "rgba" | "hex";
swatches?: HexColor[];
hideContrastRatio?: boolean;
hideDefaultSwatches?: boolean;
class?: string;
open?: boolean;
}
const props = withDefaults(defineProps<ColorPickerProps>(), {
type: "hsl",
swatches: () => [],
hideContrastRatio: false,
hideDefaultSwatches: false,
open: false,
});
const emit = defineEmits<{
"value-change": [value: ColorPickerValue];
"update:open": [value: boolean];
}>();
const colorType = ref(props.type);
const isDropdownOpen = ref(false);
const isOpen = ref(props.open);
const colorOptions = [
{ value: "hex", label: "HEX" },
{ value: "hsl", label: "HSL" },
{ value: "hsla", label: "HSLA" },
{ value: "rgb", label: "RGB" },
{ value: "rgba", label: "RGBA" },
];
function getColorAsHsva(color: ColorPickerProps["value"]): HsvaColor {
if (!color) return { h: 0, s: 0, v: 0, a: 1 };
if (typeof color === "string") {
return hexToHsva(color);
} else if ("h" in color && "s" in color && "v" in color) {
return color;
} else if ("r" in color) {
return rgbaToHsva(color);
} else {
return hslaToHsva(color);
}
}
const colorHsv = ref<HsvaColor>(getColorAsHsva(props.value));
watch(
() => props.value,
(newValue) => {
if (newValue) {
colorHsv.value = getColorAsHsva(newValue);
}
},
{ deep: true },
);
watch(
() => props.open,
(newValue) => {
isOpen.value = newValue;
},
);
watch(isOpen, (newValue) => {
emit("update:open", newValue);
});
const hexValue = computed(() => hsvaToHex(colorHsv.value));
const hslValue = computed(() => hsvaToHsla(colorHsv.value));
const rgbValue = computed(() => hsvaToRgba(colorHsv.value));
const defaultSwatches = ["#F8371A", "#F97C1B", "#FAC81C", "#3FD0B6", "#2CADF6", "#6462FC"];
const sortedSwatches = computed(() => {
const allSwatches = [...defaultSwatches, ...props.swatches];
return allSwatches.sort((a, b) => hexToHsva(a).h - hexToHsva(b).h);
});
const isDraggingSaturation = ref(false);
const isDraggingHue = ref(false);
const isDraggingAlpha = ref(false);
const saturationRef = ref<HTMLElement>();
const hueRef = ref<HTMLElement>();
const alphaRef = ref<HTMLElement>();
function handleValueChange(color: HsvaColor) {
colorHsv.value = color;
const hslColor = hsvaToHsla(color);
const rgbColor = hsvaToRgba(color);
emit("value-change", {
hex: hsvaToHex(color),
hsl: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
hsla: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
rgb: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
rgba: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
});
}
function updateFromHex(value: string | number) {
try {
const newColor = hexToHsva(String(value));
handleValueChange(newColor);
} catch (error) {
// Invalid hex color, ignore
}
}
function updateFromObject(value: HslaColor | RgbaColor) {
try {
const newColor = "r" in value ? rgbaToHsva(value) : hslaToHsva(value);
handleValueChange(newColor);
} catch (error) {
// Invalid color object, ignore
}
}
function setColorFromHex(hex: string) {
handleValueChange(hexToHsva(hex));
}
function toggleDropdown() {
isDropdownOpen.value = !isDropdownOpen.value;
}
function selectColorType(value: string) {
colorType.value = value as "hsl" | "hsla" | "rgb" | "rgba" | "hex";
isDropdownOpen.value = false;
}
function startSaturationDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingSaturation.value = true;
updateSaturation(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingSaturation.value) {
updateSaturation(e);
}
}
function handleEnd() {
isDraggingSaturation.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateSaturation(event: MouseEvent | TouchEvent) {
if (!saturationRef.value) return;
const rect = saturationRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const relativeX = (clientX - rect.left) / rect.width;
const relativeY = (clientY - rect.top) / rect.height;
const x = Math.max(0.01, Math.min(0.99, relativeX));
const y = Math.max(0.01, Math.min(0.99, relativeY));
handleValueChange({
...colorHsv.value,
s: x * 100,
v: (1 - y) * 100,
});
}
function handleHueClick(event: MouseEvent) {
updateHue(event);
}
function startHueDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingHue.value = true;
updateHue(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingHue.value) {
updateHue(e);
}
}
function handleEnd() {
isDraggingHue.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateHue(event: MouseEvent | TouchEvent) {
if (!hueRef.value) return;
const rect = hueRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
h: x * 360,
});
}
function startAlphaDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingAlpha.value = true;
updateAlpha(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingAlpha.value) {
updateAlpha(e);
}
}
function handleEnd() {
isDraggingAlpha.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateAlpha(event: MouseEvent | TouchEvent) {
if (!alphaRef.value) return;
const rect = alphaRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
a: x,
});
}
</script>
<template>
<div class="relative">
<div @click="isOpen = !isOpen">
<slot />
</div>
<div
v-if="isOpen"
class="bg-popover absolute top-full left-0 z-50 mt-2 w-80 max-w-[calc(100vw-3rem)] rounded-md border p-4 shadow-md"
>
<div class="space-y-4">
<div
ref="saturationRef"
class="relative aspect-4/2 w-full cursor-crosshair rounded border"
:style="{
background: `linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%)), linear-gradient(to top, #000, transparent)`,
}"
@mousedown="startSaturationDrag"
@touchstart="startSaturationDrag"
>
<div
class="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
:style="{
left: `${colorHsv.s}%`,
top: `${100 - colorHsv.v}%`,
transform: 'translate(-50%, -50%)',
}"
/>
</div>
<div
ref="hueRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background:
'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)',
}"
@mousedown="startHueDrag"
@touchstart="startHueDrag"
@click="handleHueClick"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${(colorHsv.h / 360) * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<button
type="button"
class="border-input bg-background inline-flex h-8 w-20 items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs font-medium uppercase"
@click="toggleDropdown"
>
<span>{{ colorType }}</span>
<Icon name="ph:caret-down-fill" class="size-3.5" />
</button>
<div
v-if="isDropdownOpen"
class="border-input bg-popover absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md"
>
<div class="p-1">
<button
v-for="option in colorOptions"
:key="option.value"
type="button"
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-xs font-medium uppercase select-none"
@click="selectColorType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="flex grow">
<input
v-if="colorType === 'hex'"
:value="hexValue"
type="text"
class="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-3 py-1 text-sm focus:outline-none"
placeholder="#000000"
@input="updateFromHex(($event.target as HTMLInputElement).value)"
/>
<ObjectColorInput
v-else
:value="colorType === 'hsl' || colorType === 'hsla' ? hslValue : rgbValue"
:label="colorType"
@value-change="updateFromObject"
/>
</div>
</div>
<div
v-if="colorType === 'rgba' || colorType === 'hsla'"
class="space-y-2"
>
<div
ref="alphaRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background: `linear-gradient(to right, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 0) 0%, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 1) 100%), repeating-conic-gradient(#ccc 0% 25%, transparent 0% 50%) 50% / 8px 8px`,
}"
@mousedown="startAlphaDrag"
@touchstart="startAlphaDrag"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${colorHsv.a * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
</div>
<div v-if="swatches.length > 0 || !hideDefaultSwatches">
<div class="bg-border h-px" />
<div
v-if="!hideDefaultSwatches"
class="flex flex-wrap justify-start gap-2 pt-2"
>
<button
v-for="color in sortedSwatches"
:key="`${color}-swatch`"
type="button"
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
:style="{ backgroundColor: color }"
:aria-label="`Set color to ${color}`"
@click="setColorFromHex(color)"
@keydown.enter="setColorFromHex(color)"
/>
</div>
</div>
<div v-if="!hideContrastRatio">
<div class="bg-border h-px" />
<ContrastRatio :color="colorHsv" />
</div>
</div>
</div>
<div
v-if="isOpen"
class="fixed inset-0 z-40"
@click="isOpen = false"
/>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import type { HsvaColor } from "@uiw/color-convert";
import { hsvaToRgba } from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
export interface ContrastRatioProps {
color: HsvaColor;
}
const props = defineProps<ContrastRatioProps>();
const darkModeContrastRatio = ref(0);
const lightModeContrastValue = ref(0);
const rgbaColor = computed(() => hsvaToRgba(props.color));
const shouldUseWhiteText = computed(() => {
const rgb = rgbaColor.value;
// Calculate luminance of the color background
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const rLinear = r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
const gLinear = g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
const bLinear = b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
const luminance = 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
// Use white text if the background is dark (luminance < 0.5)
return luminance < 0.5;
});
const currentContrastRatio = computed(() => {
// For accessibility, use the better of the two contrast ratios (dark or light mode)
// This gives the most accurate assessment of accessibility across different contexts
return Math.max(darkModeContrastRatio.value, lightModeContrastValue.value);
});
const isAccessible = computed(() => {
return {
aa: currentContrastRatio.value >= 4.5,
aaa: currentContrastRatio.value >= 7,
};
});
function calculateContrastRatios(color: HsvaColor) {
const rgb = hsvaToRgba(color);
function toSRGB(c: number) {
const channel = c / 255;
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}
function alphaBlend(foreground: number, background: number, alpha: number) {
return foreground * alpha + background * (1 - alpha);
}
// Apply alpha blending for light background (white: 255)
const lightR = alphaBlend(rgb.r, 255, rgb.a);
const lightG = alphaBlend(rgb.g, 255, rgb.a);
const lightB = alphaBlend(rgb.b, 255, rgb.a);
// Apply alpha blending for dark background (dark gray: 32)
const darkR = alphaBlend(rgb.r, 32, rgb.a);
const darkG = alphaBlend(rgb.g, 32, rgb.a);
const darkB = alphaBlend(rgb.b, 32, rgb.a);
// Calculate luminance for light background composition
const lightRSRGB = toSRGB(lightR);
const lightGSRGB = toSRGB(lightG);
const lightBSRGB = toSRGB(lightB);
const lightLuminance = 0.2126 * lightRSRGB + 0.7152 * lightGSRGB + 0.0722 * lightBSRGB;
// Calculate luminance for dark background composition
const darkRSRGB = toSRGB(darkR);
const darkGSRGB = toSRGB(darkG);
const darkBSRGB = toSRGB(darkB);
const darkLuminance = 0.2126 * darkRSRGB + 0.7152 * darkGSRGB + 0.0722 * darkBSRGB;
// Text color luminances (what we're measuring contrast against)
const whiteTextLuminance = 1.0;
const blackTextLuminance = 0.0;
// Calculate contrast ratios: text against the color background (alpha-blended)
// For dark mode: white text against the dark-composited color
const darkModeRatio =
(Math.max(whiteTextLuminance, darkLuminance) + 0.05) /
(Math.min(whiteTextLuminance, darkLuminance) + 0.05);
// For light mode: black text against the light-composited color
const lightModeRatio =
(Math.max(blackTextLuminance, lightLuminance) + 0.05) /
(Math.min(blackTextLuminance, lightLuminance) + 0.05);
darkModeContrastRatio.value = Number(darkModeRatio.toFixed(2));
lightModeContrastValue.value = Number(lightModeRatio.toFixed(2));
}
watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true });
</script>
<template>
<div class="flex items-center justify-between gap-4 pt-2 select-none">
<div class="flex items-center gap-4">
<div
class="flex size-10 items-center justify-center rounded border"
:style="{
backgroundColor: `rgba(${rgbaColor.r}, ${rgbaColor.g}, ${rgbaColor.b}, ${rgbaColor.a})`,
backgroundSize: '8px 8px',
}"
>
<span
class="font-medium"
:style="{
color: shouldUseWhiteText ? 'white' : 'black',
}"
>A</span
>
</div>
<div class="flex flex-col justify-between">
<span class="text-muted-foreground text-xs text-nowrap whitespace-nowrap">
Contrast Ratio
</span>
<span class="text-sm">{{ currentContrastRatio }}</span>
</div>
</div>
<div class="flex items-center justify-end gap-1 *:select-none">
<Badge
:variant="isAccessible.aa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AA
</Badge>
<Badge
:variant="isAccessible.aaa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aaa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AAA
</Badge>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { HslaColor, RgbaColor } from "@uiw/color-convert";
import { computed } from "vue";
interface ObjectColorInputProps {
label: "hsl" | "hsla" | "rgb" | "rgba";
value: HslaColor | RgbaColor;
}
const props = defineProps<ObjectColorInputProps>();
const emit = defineEmits<{ "value-change": [value: HslaColor | RgbaColor] }>();
const inputValues = computed(() => {
return getInputValues(props.value);
});
function getInputValues(value: HslaColor | RgbaColor): number[] {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
if (isHsl) {
const hsl = value as HslaColor;
const values = [Math.round(hsl.h), Math.round(hsl.s), Math.round(hsl.l)];
if (hasAlpha) {
values.push(Math.round(hsl.a * 100));
}
return values;
} else {
const rgb = value as RgbaColor;
const values = [Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)];
if (hasAlpha) {
values.push(Math.round(rgb.a * 100));
}
return values;
}
}
function getInputClass(index: number, totalInputs: number): string {
const baseClass =
"flex-1 w-0 h-8 px-2 py-1 text-xs border border-input bg-background text-center overflow-hidden focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0";
const isFirst = index === 0;
const isLast = index === totalInputs - 1;
const isMiddle = !isFirst && !isLast;
let positionClasses = "";
if (isFirst) {
positionClasses = "rounded-l-md -mr-px";
} else if (isLast) {
positionClasses = "rounded-r-md";
} else if (isMiddle) {
positionClasses = "rounded-none -mr-px";
}
return `${baseClass} ${positionClasses}`;
}
const inputs = computed(() => {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
let inputsData;
if (isHsl) {
const hsl = props.value as HslaColor;
inputsData = [
{ key: "h", value: Math.round(hsl.h), min: 0, max: 360, prop: "h" },
{ key: "s", value: Math.round(hsl.s), min: 0, max: 100, prop: "s" },
{ key: "l", value: Math.round(hsl.l), min: 0, max: 100, prop: "l" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(hsl.a * 100), min: 0, max: 100, prop: "a" });
}
} else {
const rgb = props.value as RgbaColor;
inputsData = [
{ key: "r", value: Math.round(rgb.r), min: 0, max: 255, prop: "r" },
{ key: "g", value: Math.round(rgb.g), min: 0, max: 255, prop: "g" },
{ key: "b", value: Math.round(rgb.b), min: 0, max: 255, prop: "b" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(rgb.a * 100), min: 0, max: 100, prop: "a" });
}
}
return inputsData.map((input, index) => ({
...input,
class: getInputClass(index, inputsData.length),
}));
});
function handleChange(idx: number, event: Event) {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (Number.isNaN(value)) return;
const input = inputs.value[idx];
const clampedValue = Math.max(input.min, Math.min(input.max, value));
const finalValue = input.prop === "a" ? clampedValue / 100 : clampedValue;
emit("value-change", { ...props.value, [input.prop]: finalValue } as HslaColor | RgbaColor);
}
</script>
<template>
<div class="flex w-full min-w-0">
<input
v-for="(input, idx) in inputs"
:key="input.key"
:value="inputValues[idx]"
:min="input.min"
:max="input.max"
:class="input.class"
step="1"
@input="handleChange(idx, $event)"
/>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
export { default as ColorPicker } from "./ColorPicker.vue";
export type { ColorPickerProps, ColorPickerValue } from "./ColorPicker.vue";
export { default as ContrastRatio } from "./ContrastRatio.vue";
export { default as ObjectColorInput } from "./ObjectColorInput.vue";