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": { "repo.LabelOut": {
"type": "object", "type": "object",
"properties": { "properties": {
"color": {
"type": "string"
},
"createdAt": { "createdAt": {
"type": "string" "type": "string"
}, },
@@ -3598,6 +3601,9 @@ const docTemplate = `{
"repo.LabelSummary": { "repo.LabelSummary": {
"type": "object", "type": "object",
"properties": { "properties": {
"color": {
"type": "string"
},
"createdAt": { "createdAt": {
"type": "string" "type": "string"
}, },

View File

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

View File

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

View File

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

View File

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

View File

@@ -997,6 +997,8 @@ definitions:
type: object type: object
repo.LabelOut: repo.LabelOut:
properties: properties:
color:
type: string
createdAt: createdAt:
type: string type: string
description: description:
@@ -1010,6 +1012,8 @@ definitions:
type: object type: object
repo.LabelSummary: repo.LabelSummary:
properties: properties:
color:
type: string
createdAt: createdAt:
type: string type: string
description: 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 type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
import MdiArrowUp from "~icons/mdi/arrow-up"; import MdiArrowUp from "~icons/mdi/arrow-up";
import MdiTagOutline from "~icons/mdi/tag-outline"; import MdiTagOutline from "~icons/mdi/tag-outline";
import { getContrastTextColor } from "~/lib/utils";
export type sizes = "sm" | "md" | "lg" | "xl"; export type sizes = "sm" | "md" | "lg" | "xl";
defineProps({ defineProps({
@@ -18,12 +19,17 @@
<template> <template>
<NuxtLink <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="{ :class="{
'p-4 py-1 text-base': size === 'lg', 'p-4 py-1 text-base': size === 'lg',
'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg', 'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg',
'p-2 py-0.5 text-xs': size === 'sm', '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}`" :to="`/label/${label.id}`"
> >
<div class="relative"> <div class="relative">

View File

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

View File

@@ -11,6 +11,11 @@
> >
<div class="flex flex-wrap items-center gap-2 px-3"> <div class="flex flex-wrap items-center gap-2 px-3">
<TagsInputItem v-for="item in modelValue" :key="item" :value="item"> <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 /> <TagsInputItemText />
<TagsInputItemDelete /> <TagsInputItemDelete />
</TagsInputItem> </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 }} {{ label.label }}
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
@@ -127,14 +137,14 @@
return filtered; return filtered;
}); });
const createAndAdd = async (name: string) => { const createAndAdd = async (name: string, color = "") => {
if (name.length > 50) { if (name.length > 50) {
toast.error(t("components.label.create_modal.toast.label_name_too_long")); toast.error(t("components.label.create_modal.toast.label_name_too_long"));
return; return;
} }
const { error, data } = await api.labels.create({ const { error, data } = await api.labels.create({
name, name,
color: "", // Future! color,
description: "", description: "",
}); });

View File

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

View File

@@ -4,3 +4,35 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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" "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": { "form": {
"password": { "password": {
"toggle_show": "Toggle Password Show" "toggle_show": "Toggle Password Show"
@@ -144,7 +151,8 @@
"create_failed": "Couldn't create label", "create_failed": "Couldn't create label",
"create_success": "Label created", "create_success": "Label created",
"label_name_too_long": "Label name must not be longer than 50 characters" "label_name_too_long": "Label name must not be longer than 50 characters"
} },
"label_color": "Label Color"
}, },
"selector": { "selector": {
"select_labels": "Select Labels" "select_labels": "Select Labels"

View File

@@ -10,6 +10,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import ColorSelector from "@/components/Form/ColorSelector.vue";
import { getContrastTextColor } from "~/lib/utils";
definePageMeta({ definePageMeta({
middleware: ["auth"], middleware: ["auth"],
@@ -128,7 +130,12 @@
:label="$t('components.label.create_modal.label_description')" :label="$t('components.label.create_modal.label_description')"
:max-length="255" :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> <DialogFooter>
<Button type="submit" :loading="updating"> {{ $t("global.update") }} </Button> <Button type="submit" :loading="updating"> {{ $t("global.update") }} </Button>
</DialogFooter> </DialogFooter>
@@ -144,7 +151,12 @@
<header :class="{ 'mb-2': label.description }"> <header :class="{ 'mb-2': label.description }">
<div class="flex flex-wrap items-end gap-2"> <div class="flex flex-wrap items-end gap-2">
<div <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" /> <MdiPackageVariant class="size-7" />
</div> </div>