mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-24 14:46:18 +02:00
Fix label menu
This commit is contained in:
parent
3d636fcbd4
commit
045945d518
@ -290,16 +290,22 @@ func WorkflowsLabels(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Exclusive bool `json:"exclusive"`
|
||||||
|
ExclusiveOrder int `json:"exclusiveOrder"`
|
||||||
}
|
}
|
||||||
outputLabels := make([]*Label, 0, len(labels))
|
outputLabels := make([]*Label, 0, len(labels))
|
||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
outputLabels = append(outputLabels, &Label{
|
outputLabels = append(outputLabels, &Label{
|
||||||
ID: label.ID,
|
ID: label.ID,
|
||||||
Name: label.Name,
|
Name: label.Name,
|
||||||
Color: label.Color,
|
Color: label.Color,
|
||||||
|
Description: label.Description,
|
||||||
|
Exclusive: label.Exclusive,
|
||||||
|
ExclusiveOrder: label.ExclusiveOrder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -334,6 +334,26 @@ If the labels-list itself needs some layouts, use extra classes or "tw" helpers.
|
|||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Exclusive label priority order - the numeric display (div.scope-right without ui.label class) */
|
||||||
|
.ui.label.scope-parent > .scope-right:not(.ui) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2em;
|
||||||
|
padding: 0.5833em 0.833em;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-right-radius: var(--border-radius);
|
||||||
|
border-top-right-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ui.label.scope-parent > .scope-right:not(.ui) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.ui.label.archived-label {
|
.ui.label.archived-label {
|
||||||
filter: grayscale(0.5);
|
filter: grayscale(0.5);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@ -8,6 +8,8 @@ interface Label {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
exclusive?: boolean;
|
||||||
|
exclusiveOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@ -40,16 +42,109 @@ const getLabelTextColor = (hexColor: string) => {
|
|||||||
return contrastColor(hexColor);
|
return contrastColor(hexColor);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Convert hex color to RGB
|
||||||
|
const hexToRGB = (hex: string): {r: number; g: number; b: number} => {
|
||||||
|
const color = hex.replace(/^#/, '');
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(color.substring(0, 2), 16),
|
||||||
|
g: Number.parseInt(color.substring(2, 4), 16),
|
||||||
|
b: Number.parseInt(color.substring(4, 6), 16),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get relative luminance of a color
|
||||||
|
const getRelativeLuminance = (hex: string): number => {
|
||||||
|
const {r, g, b} = hexToRGB(hex);
|
||||||
|
const rsRGB = r / 255;
|
||||||
|
const gsRGB = g / 255;
|
||||||
|
const bsRGB = b / 255;
|
||||||
|
|
||||||
|
const rLinear = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4);
|
||||||
|
const gLinear = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4);
|
||||||
|
const bLinear = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4);
|
||||||
|
|
||||||
|
return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get scope and item colors for exclusive labels
|
||||||
|
const getScopeColors = (baseColor: string): {scopeColor: string; itemColor: string} => {
|
||||||
|
const luminance = getRelativeLuminance(baseColor);
|
||||||
|
const contrast = 0.01 + luminance * 0.03;
|
||||||
|
const darken = contrast + Math.max(luminance + contrast - 1.0, 0.0);
|
||||||
|
const lighten = contrast + Math.max(contrast - luminance, 0.0);
|
||||||
|
const darkenFactor = Math.max(luminance - darken, 0.0) / Math.max(luminance, 1.0 / 255.0);
|
||||||
|
const lightenFactor = Math.min(luminance + lighten, 1.0) / Math.max(luminance, 1.0 / 255.0);
|
||||||
|
|
||||||
|
const {r, g, b} = hexToRGB(baseColor);
|
||||||
|
|
||||||
|
const scopeR = Math.min(Math.round(r * darkenFactor), 255);
|
||||||
|
const scopeG = Math.min(Math.round(g * darkenFactor), 255);
|
||||||
|
const scopeB = Math.min(Math.round(b * darkenFactor), 255);
|
||||||
|
|
||||||
|
const itemR = Math.min(Math.round(r * lightenFactor), 255);
|
||||||
|
const itemG = Math.min(Math.round(g * lightenFactor), 255);
|
||||||
|
const itemB = Math.min(Math.round(b * lightenFactor), 255);
|
||||||
|
|
||||||
|
const scopeColor = `#${scopeR.toString(16).padStart(2, '0')}${scopeG.toString(16).padStart(2, '0')}${scopeB.toString(16).padStart(2, '0')}`;
|
||||||
|
const itemColor = `#${itemR.toString(16).padStart(2, '0')}${itemG.toString(16).padStart(2, '0')}${itemB.toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return {scopeColor, itemColor};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get exclusive scope from label name
|
||||||
|
const getExclusiveScope = (label: Label): string => {
|
||||||
|
if (!label.exclusive) return '';
|
||||||
|
const lastIndex = label.name.lastIndexOf('/');
|
||||||
|
if (lastIndex === -1 || lastIndex === 0 || lastIndex === label.name.length - 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return label.name.substring(0, lastIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get label scope part (before the '/')
|
||||||
|
const getLabelScope = (label: Label): string => {
|
||||||
|
const scope = getExclusiveScope(label);
|
||||||
|
return scope || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get label item part (after the '/')
|
||||||
|
const getLabelItem = (label: Label): string => {
|
||||||
|
const scope = getExclusiveScope(label);
|
||||||
|
if (!scope) return label.name;
|
||||||
|
return label.name.substring(scope.length + 1);
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle label selection
|
// Toggle label selection
|
||||||
const toggleLabel = (labelId: string) => {
|
const toggleLabel = (labelId: string) => {
|
||||||
if (props.readonly) return;
|
if (props.readonly) return;
|
||||||
|
|
||||||
|
const clickedLabel = props.labels.find((l) => String(l.id) === labelId);
|
||||||
|
if (!clickedLabel) return;
|
||||||
|
|
||||||
const currentValues = [...props.modelValue];
|
const currentValues = [...props.modelValue];
|
||||||
const index = currentValues.indexOf(labelId);
|
const index = currentValues.indexOf(labelId);
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
|
// Remove the label if already selected
|
||||||
currentValues.splice(index, 1);
|
currentValues.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
|
// Handle exclusive labels: remove other labels in same scope
|
||||||
|
const exclusiveScope = getExclusiveScope(clickedLabel);
|
||||||
|
if (exclusiveScope) {
|
||||||
|
// Remove all labels with the same exclusive scope
|
||||||
|
const labelsToRemove = props.labels
|
||||||
|
.filter((l) => {
|
||||||
|
const scope = getExclusiveScope(l);
|
||||||
|
return scope === exclusiveScope && String(l.id) !== labelId;
|
||||||
|
})
|
||||||
|
.map((l) => String(l.id));
|
||||||
|
|
||||||
|
labelsToRemove.forEach((id) => {
|
||||||
|
const idx = currentValues.indexOf(id);
|
||||||
|
if (idx > -1) currentValues.splice(idx, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (props.multiple) {
|
if (props.multiple) {
|
||||||
currentValues.push(labelId);
|
currentValues.push(labelId);
|
||||||
} else {
|
} else {
|
||||||
@ -98,14 +193,45 @@ onMounted(async () => {
|
|||||||
<div class="text" :class="{ default: !modelValue.length }">
|
<div class="text" :class="{ default: !modelValue.length }">
|
||||||
<span v-if="!modelValue.length">{{ placeholder }}</span>
|
<span v-if="!modelValue.length">{{ placeholder }}</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<template v-for="labelId in modelValue" :key="labelId">
|
||||||
v-for="labelId in modelValue"
|
<template v-if="labels.find(l => String(l.id) === labelId)">
|
||||||
:key="labelId"
|
<!-- Regular label (no exclusive scope) -->
|
||||||
class="ui label"
|
<span
|
||||||
:style="`background-color: ${labels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId)?.color)}`"
|
v-if="!labels.find(l => String(l.id) === labelId).exclusive || !getLabelScope(labels.find(l => String(l.id) === labelId))"
|
||||||
>
|
class="ui label"
|
||||||
{{ labels.find(l => String(l.id) === labelId)?.name }}
|
:style="`background-color: ${labels.find(l => String(l.id) === labelId).color}; color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)}`"
|
||||||
</span>
|
>
|
||||||
|
{{ labels.find(l => String(l.id) === labelId).name }}
|
||||||
|
</span>
|
||||||
|
<!-- Exclusive label with order: scope | item | order -->
|
||||||
|
<span
|
||||||
|
v-else-if="labels.find(l => String(l.id) === labelId).exclusiveOrder && labels.find(l => String(l.id) === labelId).exclusiveOrder > 0"
|
||||||
|
class="ui label scope-parent"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)} !important; background-color: ${getScopeColors(labels.find(l => String(l.id) === labelId).color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(labels.find(l => String(l.id) === labelId)) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-middle" :style="`color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)} !important; background-color: ${getScopeColors(labels.find(l => String(l.id) === labelId).color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(labels.find(l => String(l.id) === labelId)) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right">
|
||||||
|
{{ labels.find(l => String(l.id) === labelId).exclusiveOrder }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<!-- Exclusive label without order: scope | item -->
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ui label scope-parent"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)} !important; background-color: ${getScopeColors(labels.find(l => String(l.id) === labelId).color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(labels.find(l => String(l.id) === labelId)) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right" :style="`color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)} !important; background-color: ${getScopeColors(labels.find(l => String(l.id) === labelId).color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(labels.find(l => String(l.id) === labelId)) }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
@ -117,12 +243,43 @@ onMounted(async () => {
|
|||||||
:class="{ active: isLabelSelected(String(label.id)), selected: isLabelSelected(String(label.id)) }"
|
:class="{ active: isLabelSelected(String(label.id)), selected: isLabelSelected(String(label.id)) }"
|
||||||
@click.prevent="toggleLabel(String(label.id))"
|
@click.prevent="toggleLabel(String(label.id))"
|
||||||
>
|
>
|
||||||
|
<!-- Regular label (no exclusive scope) -->
|
||||||
<span
|
<span
|
||||||
|
v-if="!label.exclusive || !getLabelScope(label)"
|
||||||
class="ui label"
|
class="ui label"
|
||||||
:style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
|
:style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
|
||||||
>
|
>
|
||||||
{{ label.name }}
|
{{ label.name }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Exclusive label with order: scope | item | order -->
|
||||||
|
<span
|
||||||
|
v-else-if="label.exclusiveOrder && label.exclusiveOrder > 0"
|
||||||
|
class="ui label scope-parent"
|
||||||
|
:title="label.description"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-middle" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right">
|
||||||
|
{{ label.exclusiveOrder }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<!-- Exclusive label without order: scope | item -->
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ui label scope-parent"
|
||||||
|
:title="label.description"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(label) }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,19 +287,50 @@ onMounted(async () => {
|
|||||||
<!-- Readonly Mode: Display Selected Labels -->
|
<!-- Readonly Mode: Display Selected Labels -->
|
||||||
<div v-else class="ui labels">
|
<div v-else class="ui labels">
|
||||||
<span v-if="!selectedLabels.length" class="text-muted">None</span>
|
<span v-if="!selectedLabels.length" class="text-muted">None</span>
|
||||||
<span
|
<template v-for="label in selectedLabels" :key="label.id">
|
||||||
v-for="label in selectedLabels"
|
<!-- Regular label (no exclusive scope) -->
|
||||||
:key="label.id"
|
<span
|
||||||
class="ui label"
|
v-if="!label.exclusive || !getLabelScope(label)"
|
||||||
:style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
|
class="ui label"
|
||||||
>
|
:style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
|
||||||
{{ label.name }}
|
>
|
||||||
</span>
|
{{ label.name }}
|
||||||
|
</span>
|
||||||
|
<!-- Exclusive label with order: scope | item | order -->
|
||||||
|
<span
|
||||||
|
v-else-if="label.exclusiveOrder && label.exclusiveOrder > 0"
|
||||||
|
class="ui label scope-parent"
|
||||||
|
:title="label.description"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-middle" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right">
|
||||||
|
{{ label.exclusiveOrder }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<!-- Exclusive label without order: scope | item -->
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ui label scope-parent"
|
||||||
|
:title="label.description"
|
||||||
|
>
|
||||||
|
<div class="ui label scope-left" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`">
|
||||||
|
{{ getLabelScope(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="ui label scope-right" :style="`color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`">
|
||||||
|
{{ getLabelItem(label) }}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
/* Label selector styles */
|
/* Label selector specific styles - not scoped to allow global label.css to work */
|
||||||
.label-dropdown.ui.dropdown .menu > .item.active,
|
.label-dropdown.ui.dropdown .menu > .item.active,
|
||||||
.label-dropdown.ui.dropdown .menu > .item.selected {
|
.label-dropdown.ui.dropdown .menu > .item.selected {
|
||||||
background: var(--color-active);
|
background: var(--color-active);
|
||||||
@ -153,21 +341,11 @@ onMounted(async () => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label-dropdown.ui.dropdown > .text > .ui.label {
|
.label-dropdown.ui.dropdown > .text > .ui.label,
|
||||||
|
.label-dropdown.ui.dropdown > .text > .ui.label.scope-parent {
|
||||||
margin: 0.125rem;
|
margin: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.labels {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui.labels .ui.label {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--color-text-light-2);
|
color: var(--color-text-light-2);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user