forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -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>
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user