Custom Colored Labels (#801)

* feat: custom coloured labels

* chore: lint

* feat: add ColorSelector component for improved color selection in labels and integrate it into CreateModal and Selector components

* style: lint

* fix: update ColorSelector and Selector components to use empty string instead of null for default color values for types
This commit is contained in:
Tonya
2025-06-23 15:52:32 +01:00
committed by GitHub
parent 2afa5d1374
commit ef39549c37
14 changed files with 261 additions and 6 deletions

View File

@@ -3578,6 +3578,9 @@ const docTemplate = `{
"repo.LabelOut": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},
@@ -3598,6 +3601,9 @@ const docTemplate = `{
"repo.LabelSummary": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},

View File

@@ -3576,6 +3576,9 @@
"repo.LabelOut": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},
@@ -3596,6 +3599,9 @@
"repo.LabelSummary": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},

View File

@@ -997,6 +997,8 @@ definitions:
type: object
repo.LabelOut:
properties:
color:
type: string
createdAt:
type: string
description:
@@ -1010,6 +1012,8 @@ definitions:
type: object
repo.LabelSummary:
properties:
color:
type: string
createdAt:
type: string
description:

View File

@@ -35,6 +35,7 @@ type (
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -49,6 +50,7 @@ func mapLabelSummary(label *ent.Label) LabelSummary {
ID: label.ID,
Name: label.Name,
Description: label.Description,
Color: label.Color,
CreatedAt: label.CreatedAt,
UpdatedAt: label.UpdatedAt,
}

View File

@@ -3576,6 +3576,9 @@
"repo.LabelOut": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},
@@ -3596,6 +3599,9 @@
"repo.LabelSummary": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},

View File

@@ -997,6 +997,8 @@ definitions:
type: object
repo.LabelOut:
properties:
color:
type: string
createdAt:
type: string
description:
@@ -1010,6 +1012,8 @@ definitions:
type: object
repo.LabelSummary:
properties:
color:
type: string
createdAt:
type: string
description:

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import MdiClose from "~icons/mdi/close";
import MdiDiceMultiple from "~icons/mdi/dice-multiple";
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: String,
required: true,
default: "",
},
label: {
type: String,
default: "",
},
inline: {
type: Boolean,
default: false,
},
showHex: {
type: Boolean,
default: false,
},
size: {
type: [String, Number],
default: 24,
},
startingColor: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue"]);
const id = useId();
const swatchStyle = computed(() => ({
backgroundColor: props.modelValue || "hsl(var(--muted))",
width: typeof props.size === "number" ? `${props.size}px` : props.size,
height: typeof props.size === "number" ? `${props.size}px` : props.size,
}));
const value = useVModel(props, "modelValue", emits);
// Initialize with starting color if provided and current value is empty
onMounted(() => {
if (props.startingColor && (!value.value || value.value === "")) {
value.value = props.startingColor;
}
});
function clearColor() {
value.value = "";
}
function randomizeColor() {
const randomColor =
"#" +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, "0");
value.value = randomColor;
}
</script>
<template>
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
<Label :for="id" class="flex w-full px-1">
<span>{{ label }}</span>
</Label>
<div class="flex items-center gap-2">
<span
:style="swatchStyle"
class="inline-block cursor-pointer rounded-full border ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
role="button"
tabindex="0"
@click="$refs.colorInput.click()"
/>
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
modelValue || t("components.color_selector.no_color")
}}</span>
<div class="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
class="size-6 p-0"
:aria-label="t('components.color_selector.randomize')"
@click="randomizeColor"
>
<MdiDiceMultiple class="size-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="size-6 p-0"
:aria-label="t('components.color_selector.clear')"
@click="clearColor"
>
<MdiClose class="size-3" />
</Button>
</div>
<input :id="id" ref="colorInput" v-model="value" type="color" class="sr-only" tabindex="-1" />
</div>
</div>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<Label class="flex w-full px-1 py-2" :for="id">
<span>{{ label }}</span>
</Label>
<div class="col-span-3 mt-2 flex items-center gap-2">
<span
:style="swatchStyle"
class="inline-block cursor-pointer rounded-full border ring-offset-background focus:outline-none focus:outline-primary focus:ring-2 focus:ring-ring focus:ring-offset-2"
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
role="button"
tabindex="0"
@click="$refs.colorInput.click()"
/>
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
modelValue || t("components.color_selector.no_color")
}}</span>
<div class="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
class="size-6 p-0"
:aria-label="t('components.color_selector.randomize')"
@click="randomizeColor"
>
<MdiDiceMultiple class="size-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="size-6 p-0"
:aria-label="t('components.color_selector.clear')"
@click="clearColor"
>
<MdiClose class="size-3" />
</Button>
</div>
<input :id="id" ref="colorInput" v-model="value" type="color" class="sr-only" tabindex="-1" />
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
import MdiArrowUp from "~icons/mdi/arrow-up";
import MdiTagOutline from "~icons/mdi/tag-outline";
import { getContrastTextColor } from "~/lib/utils";
export type sizes = "sm" | "md" | "lg" | "xl";
defineProps({
@@ -18,12 +19,17 @@
<template>
<NuxtLink
class="group/label-chip flex gap-2 rounded-full bg-accent text-accent-foreground shadow transition duration-300 hover:bg-accent/50"
class="group/label-chip flex gap-2 rounded-full shadow transition duration-300 hover:bg-accent/50"
:class="{
'p-4 py-1 text-base': size === 'lg',
'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg',
'p-2 py-0.5 text-xs': size === 'sm',
}"
:style="
label.color
? { backgroundColor: label.color, color: getContrastTextColor(label.color) }
: { backgroundColor: 'hsl(var(--accent))' }
"
:to="`/label/${label.id}`"
>
<div class="relative">

View File

@@ -14,6 +14,7 @@
:label="$t('components.label.create_modal.label_description')"
:max-length="255"
/>
<ColorSelector v-model="form.color" :label="$t('components.label.create_modal.label_color')" :show-hex="true" />
<div class="mt-4 flex flex-row-reverse">
<ButtonGroup>
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
@@ -31,6 +32,7 @@
import { toast } from "@/components/ui/sonner";
import BaseModal from "@/components/App/CreateModal.vue";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
import ColorSelector from "@/components/Form/ColorSelector.vue";
const { t } = useI18n();

View File

@@ -11,6 +11,11 @@
>
<div class="flex flex-wrap items-center gap-2 px-3">
<TagsInputItem v-for="item in modelValue" :key="item" :value="item">
<span
v-if="shortenedLabels.find(l => l.id === item)?.color"
class="ml-2 inline-block size-4 rounded-full"
:style="{ backgroundColor: shortenedLabels.find(l => l.id === item)?.color }"
/>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
@@ -55,6 +60,11 @@
}
"
>
<span
class="mr-2 inline-block size-4 rounded-full align-middle"
:class="{ border: shortenedLabels.find(l => l.id === label.value)?.color }"
:style="{ backgroundColor: shortenedLabels.find(l => l.id === label.value)?.color }"
/>
{{ label.label }}
</CommandItem>
</CommandGroup>
@@ -127,14 +137,14 @@
return filtered;
});
const createAndAdd = async (name: string) => {
const createAndAdd = async (name: string, color = "") => {
if (name.length > 50) {
toast.error(t("components.label.create_modal.toast.label_name_too_long"));
return;
}
const { error, data } = await api.labels.create({
name,
color: "", // Future!
color,
description: "",
});

View File

@@ -638,6 +638,7 @@ export interface LabelCreate {
}
export interface LabelOut {
color: string;
createdAt: Date | string;
description: string;
id: string;
@@ -646,6 +647,7 @@ export interface LabelOut {
}
export interface LabelSummary {
color: string;
createdAt: Date | string;
description: string;
id: string;

View File

@@ -4,3 +4,35 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Returns either '#000' or '#fff' depending on which has better contrast with the given background color.
* Accepts hex (#RRGGBB or #RGB) or rgb(a) strings.
*/
export function getContrastTextColor(bgColor: string): string {
let r = 0;
let g = 0;
let b = 0;
if (bgColor.startsWith("#")) {
let hex = bgColor.slice(1);
if (hex.length === 3) {
hex = hex
.split("")
.map(x => x + x)
.join("");
}
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
} else if (bgColor.startsWith("rgb")) {
const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
r = parseInt(match[1]);
g = parseInt(match[2]);
b = parseInt(match[3]);
}
}
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000" : "#fff";
}

View File

@@ -24,6 +24,13 @@
"new_version_available_link": "Click here to view the release notes"
}
},
"color_selector": {
"color": "Color",
"clear": "Clear color",
"no_color": "No color",
"no_color_selected": "No color selected",
"randomize": "Randomize color"
},
"form": {
"password": {
"toggle_show": "Toggle Password Show"
@@ -144,7 +151,8 @@
"create_failed": "Couldn't create label",
"create_success": "Label created",
"label_name_too_long": "Label name must not be longer than 50 characters"
}
},
"label_color": "Label Color"
},
"selector": {
"select_labels": "Select Labels"

View File

@@ -10,6 +10,8 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import ColorSelector from "@/components/Form/ColorSelector.vue";
import { getContrastTextColor } from "~/lib/utils";
definePageMeta({
middleware: ["auth"],
@@ -128,7 +130,12 @@
:label="$t('components.label.create_modal.label_description')"
:max-length="255"
/>
<!-- TODO: color -->
<ColorSelector
v-model="updateData.color"
:label="$t('components.label.create_modal.label_color')"
:show-hex="true"
:starting-color="label.color"
/>
<DialogFooter>
<Button type="submit" :loading="updating"> {{ $t("global.update") }} </Button>
</DialogFooter>
@@ -144,7 +151,12 @@
<header :class="{ 'mb-2': label.description }">
<div class="flex flex-wrap items-end gap-2">
<div
class="mb-auto flex size-12 items-center justify-center rounded-full bg-secondary text-secondary-foreground"
class="mb-auto flex size-12 items-center justify-center rounded-full"
:style="
label.color
? { backgroundColor: label.color, color: getContrastTextColor(label.color) }
: { backgroundColor: 'hsl(var(--secondary))', color: 'hsl(var(--secondary-foreground))' }
"
>
<MdiPackageVariant class="size-7" />
</div>