@@ -8,6 +8,8 @@ interface Label {
88 name: string ;
99 color: string ;
1010 description? : string ;
11+ exclusive? : boolean ;
12+ exclusiveOrder? : number ;
1113}
1214
1315const props = withDefaults (defineProps <{
@@ -40,16 +42,109 @@ const getLabelTextColor = (hexColor: string) => {
4042 return contrastColor (hexColor );
4143};
4244
45+ // Convert hex color to RGB
46+ const hexToRGB = (hex : string ): {r: number ; g: number ; b: number } => {
47+ const color = hex .replace (/ ^ #/ , ' ' );
48+ return {
49+ r: Number .parseInt (color .substring (0 , 2 ), 16 ),
50+ g: Number .parseInt (color .substring (2 , 4 ), 16 ),
51+ b: Number .parseInt (color .substring (4 , 6 ), 16 ),
52+ };
53+ };
54+
55+ // Get relative luminance of a color
56+ const getRelativeLuminance = (hex : string ): number => {
57+ const {r, g, b} = hexToRGB (hex );
58+ const rsRGB = r / 255 ;
59+ const gsRGB = g / 255 ;
60+ const bsRGB = b / 255 ;
61+
62+ const rLinear = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math .pow ((rsRGB + 0.055 ) / 1.055 , 2.4 );
63+ const gLinear = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math .pow ((gsRGB + 0.055 ) / 1.055 , 2.4 );
64+ const bLinear = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math .pow ((bsRGB + 0.055 ) / 1.055 , 2.4 );
65+
66+ return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear ;
67+ };
68+
69+ // Get scope and item colors for exclusive labels
70+ const getScopeColors = (baseColor : string ): {scopeColor: string ; itemColor: string } => {
71+ const luminance = getRelativeLuminance (baseColor );
72+ const contrast = 0.01 + luminance * 0.03 ;
73+ const darken = contrast + Math .max (luminance + contrast - 1.0 , 0.0 );
74+ const lighten = contrast + Math .max (contrast - luminance , 0.0 );
75+ const darkenFactor = Math .max (luminance - darken , 0.0 ) / Math .max (luminance , 1.0 / 255.0 );
76+ const lightenFactor = Math .min (luminance + lighten , 1.0 ) / Math .max (luminance , 1.0 / 255.0 );
77+
78+ const {r, g, b} = hexToRGB (baseColor );
79+
80+ const scopeR = Math .min (Math .round (r * darkenFactor ), 255 );
81+ const scopeG = Math .min (Math .round (g * darkenFactor ), 255 );
82+ const scopeB = Math .min (Math .round (b * darkenFactor ), 255 );
83+
84+ const itemR = Math .min (Math .round (r * lightenFactor ), 255 );
85+ const itemG = Math .min (Math .round (g * lightenFactor ), 255 );
86+ const itemB = Math .min (Math .round (b * lightenFactor ), 255 );
87+
88+ const scopeColor = ` #${scopeR .toString (16 ).padStart (2 , ' 0' )}${scopeG .toString (16 ).padStart (2 , ' 0' )}${scopeB .toString (16 ).padStart (2 , ' 0' )} ` ;
89+ const itemColor = ` #${itemR .toString (16 ).padStart (2 , ' 0' )}${itemG .toString (16 ).padStart (2 , ' 0' )}${itemB .toString (16 ).padStart (2 , ' 0' )} ` ;
90+
91+ return {scopeColor , itemColor };
92+ };
93+
94+ // Get exclusive scope from label name
95+ const getExclusiveScope = (label : Label ): string => {
96+ if (! label .exclusive ) return ' ' ;
97+ const lastIndex = label .name .lastIndexOf (' /' );
98+ if (lastIndex === - 1 || lastIndex === 0 || lastIndex === label .name .length - 1 ) {
99+ return ' ' ;
100+ }
101+ return label .name .substring (0 , lastIndex );
102+ };
103+
104+ // Get label scope part (before the '/')
105+ const getLabelScope = (label : Label ): string => {
106+ const scope = getExclusiveScope (label );
107+ return scope || ' ' ;
108+ };
109+
110+ // Get label item part (after the '/')
111+ const getLabelItem = (label : Label ): string => {
112+ const scope = getExclusiveScope (label );
113+ if (! scope ) return label .name ;
114+ return label .name .substring (scope .length + 1 );
115+ };
116+
43117// Toggle label selection
44118const toggleLabel = (labelId : string ) => {
45119 if (props .readonly ) return ;
46120
121+ const clickedLabel = props .labels .find ((l ) => String (l .id ) === labelId );
122+ if (! clickedLabel ) return ;
123+
47124 const currentValues = [... props .modelValue ];
48125 const index = currentValues .indexOf (labelId );
49126
50127 if (index > - 1 ) {
128+ // Remove the label if already selected
51129 currentValues .splice (index , 1 );
52130 } else {
131+ // Handle exclusive labels: remove other labels in same scope
132+ const exclusiveScope = getExclusiveScope (clickedLabel );
133+ if (exclusiveScope ) {
134+ // Remove all labels with the same exclusive scope
135+ const labelsToRemove = props .labels
136+ .filter ((l ) => {
137+ const scope = getExclusiveScope (l );
138+ return scope === exclusiveScope && String (l .id ) !== labelId ;
139+ })
140+ .map ((l ) => String (l .id ));
141+
142+ labelsToRemove .forEach ((id ) => {
143+ const idx = currentValues .indexOf (id );
144+ if (idx > - 1 ) currentValues .splice (idx , 1 );
145+ });
146+ }
147+
53148 if (props .multiple ) {
54149 currentValues .push (labelId );
55150 } else {
@@ -98,14 +193,45 @@ onMounted(async () => {
98193 <div class =" text" :class =" { default: !modelValue.length }" >
99194 <span v-if =" !modelValue.length" >{{ placeholder }}</span >
100195 <template v-else >
101- <span
102- v-for =" labelId in modelValue"
103- :key =" labelId"
104- class =" ui label"
105- :style =" `background-color: ${labels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId)?.color)}`"
106- >
107- {{ labels.find(l => String(l.id) === labelId)?.name }}
108- </span >
196+ <template v-for =" labelId in modelValue " :key =" labelId " >
197+ <template v-if =" labels .find (l => String (l .id ) === labelId )" >
198+ <!-- Regular label (no exclusive scope) -->
199+ <span
200+ v-if =" !labels.find(l => String(l.id) === labelId).exclusive || !getLabelScope(labels.find(l => String(l.id) === labelId))"
201+ class =" ui label"
202+ :style =" `background-color: ${labels.find(l => String(l.id) === labelId).color}; color: ${getLabelTextColor(labels.find(l => String(l.id) === labelId).color)}`"
203+ >
204+ {{ labels.find(l => String(l.id) === labelId).name }}
205+ </span >
206+ <!-- Exclusive label with order: scope | item | order -->
207+ <span
208+ v-else-if =" labels.find(l => String(l.id) === labelId).exclusiveOrder && labels.find(l => String(l.id) === labelId).exclusiveOrder > 0"
209+ class =" ui label scope-parent"
210+ >
211+ <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`" >
212+ {{ getLabelScope(labels.find(l => String(l.id) === labelId)) }}
213+ </div >
214+ <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`" >
215+ {{ getLabelItem(labels.find(l => String(l.id) === labelId)) }}
216+ </div >
217+ <div class =" ui label scope-right" >
218+ {{ labels.find(l => String(l.id) === labelId).exclusiveOrder }}
219+ </div >
220+ </span >
221+ <!-- Exclusive label without order: scope | item -->
222+ <span
223+ v-else
224+ class =" ui label scope-parent"
225+ >
226+ <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`" >
227+ {{ getLabelScope(labels.find(l => String(l.id) === labelId)) }}
228+ </div >
229+ <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`" >
230+ {{ getLabelItem(labels.find(l => String(l.id) === labelId)) }}
231+ </div >
232+ </span >
233+ </template >
234+ </template >
109235 </template >
110236 </div >
111237 <div class =" menu" >
@@ -117,32 +243,94 @@ onMounted(async () => {
117243 :class =" { active: isLabelSelected(String(label.id)), selected: isLabelSelected(String(label.id)) }"
118244 @click.prevent =" toggleLabel(String(label.id))"
119245 >
246+ <!-- Regular label (no exclusive scope) -->
120247 <span
248+ v-if =" !label.exclusive || !getLabelScope(label)"
121249 class =" ui label"
122250 :style =" `background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
123251 >
124252 {{ label.name }}
125253 </span >
254+ <!-- Exclusive label with order: scope | item | order -->
255+ <span
256+ v-else-if =" label.exclusiveOrder && label.exclusiveOrder > 0"
257+ class =" ui label scope-parent"
258+ :title =" label.description"
259+ >
260+ <div class =" ui label scope-left" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`" >
261+ {{ getLabelScope(label) }}
262+ </div >
263+ <div class =" ui label scope-middle" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`" >
264+ {{ getLabelItem(label) }}
265+ </div >
266+ <div class =" ui label scope-right" >
267+ {{ label.exclusiveOrder }}
268+ </div >
269+ </span >
270+ <!-- Exclusive label without order: scope | item -->
271+ <span
272+ v-else
273+ class =" ui label scope-parent"
274+ :title =" label.description"
275+ >
276+ <div class =" ui label scope-left" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`" >
277+ {{ getLabelScope(label) }}
278+ </div >
279+ <div class =" ui label scope-right" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`" >
280+ {{ getLabelItem(label) }}
281+ </div >
282+ </span >
126283 </div >
127284 </div >
128285 </div >
129286
130287 <!-- Readonly Mode: Display Selected Labels -->
131288 <div v-else class =" ui labels" >
132289 <span v-if =" !selectedLabels.length" class =" text-muted" >None</span >
133- <span
134- v-for =" label in selectedLabels"
135- :key =" label.id"
136- class =" ui label"
137- :style =" `background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
138- >
139- {{ label.name }}
140- </span >
290+ <template v-for =" label in selectedLabels " :key =" label .id " >
291+ <!-- Regular label (no exclusive scope) -->
292+ <span
293+ v-if =" !label.exclusive || !getLabelScope(label)"
294+ class =" ui label"
295+ :style =" `background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`"
296+ >
297+ {{ label.name }}
298+ </span >
299+ <!-- Exclusive label with order: scope | item | order -->
300+ <span
301+ v-else-if =" label.exclusiveOrder && label.exclusiveOrder > 0"
302+ class =" ui label scope-parent"
303+ :title =" label.description"
304+ >
305+ <div class =" ui label scope-left" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`" >
306+ {{ getLabelScope(label) }}
307+ </div >
308+ <div class =" ui label scope-middle" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`" >
309+ {{ getLabelItem(label) }}
310+ </div >
311+ <div class =" ui label scope-right" >
312+ {{ label.exclusiveOrder }}
313+ </div >
314+ </span >
315+ <!-- Exclusive label without order: scope | item -->
316+ <span
317+ v-else
318+ class =" ui label scope-parent"
319+ :title =" label.description"
320+ >
321+ <div class =" ui label scope-left" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).scopeColor} !important`" >
322+ {{ getLabelScope(label) }}
323+ </div >
324+ <div class =" ui label scope-right" :style =" `color: ${getLabelTextColor(label.color)} !important; background-color: ${getScopeColors(label.color).itemColor} !important`" >
325+ {{ getLabelItem(label) }}
326+ </div >
327+ </span >
328+ </template >
141329 </div >
142330</template >
143331
144- <style scoped >
145- /* Label selector styles */
332+ <style >
333+ /* Label selector specific styles - not scoped to allow global label.css to work */
146334.label-dropdown.ui.dropdown .menu > .item.active ,
147335.label-dropdown.ui.dropdown .menu > .item.selected {
148336 background : var (--color-active );
@@ -153,21 +341,11 @@ onMounted(async () => {
153341 margin : 0 ;
154342}
155343
156- .label-dropdown.ui.dropdown > .text > .ui.label {
344+ .label-dropdown.ui.dropdown > .text > .ui.label ,
345+ .label-dropdown.ui.dropdown > .text > .ui.label.scope-parent {
157346 margin : 0.125rem ;
158347}
159348
160- .ui.labels {
161- display : flex ;
162- flex-wrap : wrap ;
163- gap : 0.5rem ;
164- align-items : center ;
165- }
166-
167- .ui.labels .ui.label {
168- margin : 0 ;
169- }
170-
171349.text-muted {
172350 color : var (--color-text-light-2 );
173351}
0 commit comments