Skip to content

Commit 88384bb

Browse files
author
Ibrahim Haizel
committed
feat: add circle indicators for selected items with customisable colors and palettes
1 parent 9b449e5 commit 88384bb

File tree

2 files changed

+426
-2
lines changed

2 files changed

+426
-2
lines changed

src/lib/components/ui/MultiSelectSearchAutocomplete.svelte

Lines changed: 333 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,31 @@
7171
choicesItemBorderColor = "#b1b4b6",
7272
choicesItemTextColor = "black",
7373
choicesItemDividerPadding = "10px",
74+
// Circle feature props
75+
enableSelectedItemCircles = false,
76+
selectedItemCircleColor = "#1d70b8", // Default color when not using palette
77+
selectedItemCircleColorPalette = [
78+
// Complete GOV.UK Design System palette (19 colors)
79+
"#1d70b8", // Blue (primary)
80+
"#d4351c", // Red
81+
"#00703c", // Green
82+
"#f47738", // Orange
83+
"#4c2c92", // Purple
84+
"#801650", // Bright purple
85+
"#28a197", // Turquoise
86+
"#b58840", // Brown
87+
"#d53880", // Pink
88+
"#6f72af", // Light purple
89+
"#f499be", // Light pink
90+
"#85994b", // Light green
91+
"#ffdd00", // Yellow
92+
"#12436d", // Dark blue
93+
"#505a5f", // Dark grey
94+
"#626a6e", // Mid grey
95+
"#b1b4b6", // Light grey
96+
"#0b0c0c", // Black
97+
"#ffffff", // White (with border for visibility)
98+
], // Complete GOV.UK Design System palette
7499
...attributes
75100
}: {
76101
id: string;
@@ -106,6 +131,9 @@
106131
choicesItemBorderColor?: string;
107132
choicesItemTextColor?: string;
108133
choicesItemDividerPadding?: string;
134+
enableSelectedItemCircles?: boolean;
135+
selectedItemCircleColor?: string;
136+
selectedItemCircleColorPalette?: string[];
109137
} & Omit<
110138
import("svelte/elements").HTMLSelectAttributes,
111139
| "id"
@@ -125,6 +153,9 @@
125153
let lastQuery = "";
126154
const baseNoChoicesText = "No choices to choose from";
127155
156+
// Global color assignment tracking to ensure consistency across component lifecycle
157+
let globalColorAssignments = new Map<string, string>(); // itemKey -> color
158+
128159
// Helper function for getting group text
129160
function getGroupText(item: any): string | undefined {
130161
if (!groupKey || !item || typeof item !== "object") return undefined;
@@ -139,6 +170,195 @@
139170
return div.innerHTML;
140171
}
141172
173+
// Extended color palette using proven data visualization algorithms
174+
function generateExtendedColorPalette(count: number): string[] {
175+
if (count === 0) return [];
176+
177+
// Start with the predefined GOV.UK palette
178+
const baseColors = [...selectedItemCircleColorPalette];
179+
180+
if (count <= baseColors.length) {
181+
return baseColors.slice(0, count);
182+
}
183+
184+
// For more colors, generate using Plotly.js-style algorithm
185+
const extendedColors = [...baseColors];
186+
const needed = count - baseColors.length;
187+
188+
// Use golden ratio and HSL for optimal color distribution
189+
const goldenRatio = 0.618033988749895;
190+
let hue = 0;
191+
192+
for (let i = 0; i < needed; i++) {
193+
// Vary saturation and lightness for accessibility
194+
const saturation = 65 + (i % 3) * 10; // 65%, 75%, 85%
195+
const lightness = 45 + (i % 4) * 10; // 45%, 55%, 65%, 75%
196+
197+
// Use golden ratio for optimal hue distribution
198+
hue = (hue + goldenRatio) % 1;
199+
const hslHue = Math.floor(hue * 360);
200+
201+
const color = `hsl(${hslHue}, ${saturation}%, ${lightness}%)`;
202+
extendedColors.push(color);
203+
}
204+
205+
return extendedColors;
206+
}
207+
208+
// Helper function to get optimized color palette based on number of selected items
209+
function getOptimizedColorPalette(selectedCount: number): string[] {
210+
return generateExtendedColorPalette(selectedCount);
211+
}
212+
213+
// Helper function to get current number of selected items
214+
function getCurrentSelectedCount(): number {
215+
if (!choicesInstance) return 0;
216+
217+
try {
218+
const currentValue = choicesInstance.getValue(true);
219+
if (Array.isArray(currentValue)) {
220+
return currentValue.length;
221+
} else if (currentValue && typeof currentValue === "object") {
222+
return 1;
223+
} else if (currentValue) {
224+
return 1;
225+
}
226+
return 0;
227+
} catch (error) {
228+
console.warn("Error getting selected count:", error);
229+
return 0;
230+
}
231+
}
232+
233+
// Function to refresh all circles with optimized colors based on current selection count
234+
function refreshAllCircles() {
235+
if (!enableSelectedItemCircles || !choicesInstance) return;
236+
237+
const selectedItems =
238+
choicesInstance.containerInner.element.querySelectorAll(".choices__item");
239+
const selectedCount = selectedItems.length;
240+
const optimizedPalette = getOptimizedColorPalette(selectedCount);
241+
242+
console.log("🎨 Refreshing circles:", {
243+
selectedCount,
244+
paletteSize: optimizedPalette.length,
245+
palette: optimizedPalette,
246+
globalAssignments: Array.from(globalColorAssignments.entries()),
247+
});
248+
249+
// Create a map to track which colors have been used to prevent duplicates
250+
const usedColors = new Set();
251+
const colorAssignments = new Map(); // item -> color mapping
252+
253+
selectedItems.forEach((item, index) => {
254+
if (item instanceof HTMLElement) {
255+
// Remove existing circle
256+
const existingCircle = item.querySelector(".choices__item-circle");
257+
if (existingCircle) {
258+
existingCircle.remove();
259+
}
260+
261+
// Determine color for this item - use sequential assignment to prevent duplicates
262+
let circleColor = selectedItemCircleColor;
263+
264+
if (optimizedPalette.length > 0) {
265+
// First, try to use the item's previously assigned color if it exists
266+
const itemKey = item.textContent + item.dataset.value;
267+
268+
console.log(`🎨 Assigning color for "${itemKey}":`, {
269+
hasGlobalAssignment: globalColorAssignments.has(itemKey),
270+
hasLocalAssignment: colorAssignments.has(itemKey),
271+
usedColors: Array.from(usedColors),
272+
availableColors: optimizedPalette.filter((c) => !usedColors.has(c)),
273+
});
274+
275+
// Check global assignments first, then local
276+
if (globalColorAssignments.has(itemKey)) {
277+
circleColor = globalColorAssignments.get(itemKey)!;
278+
usedColors.add(circleColor);
279+
colorAssignments.set(itemKey, circleColor);
280+
console.log(`✅ Using existing global color: ${circleColor}`);
281+
} else if (colorAssignments.has(itemKey)) {
282+
circleColor = colorAssignments.get(itemKey);
283+
console.log(`✅ Using existing local color: ${circleColor}`);
284+
} else {
285+
// Find the first available color that hasn't been used yet
286+
let colorIndex = 0;
287+
while (
288+
colorIndex < optimizedPalette.length &&
289+
usedColors.has(optimizedPalette[colorIndex])
290+
) {
291+
colorIndex++;
292+
}
293+
294+
// If we found an unused color, use it
295+
if (colorIndex < optimizedPalette.length) {
296+
circleColor = optimizedPalette[colorIndex];
297+
usedColors.add(circleColor);
298+
colorAssignments.set(itemKey, circleColor);
299+
// Store in global assignments for consistency
300+
globalColorAssignments.set(itemKey, circleColor);
301+
console.log(
302+
`🆕 Assigned new color: ${circleColor} (index: ${colorIndex})`,
303+
);
304+
} else {
305+
// All colors used, cycle back to the beginning
306+
const cycleIndex =
307+
colorAssignments.size % optimizedPalette.length;
308+
circleColor = optimizedPalette[cycleIndex];
309+
colorAssignments.set(itemKey, circleColor);
310+
// Store in global assignments for consistency
311+
globalColorAssignments.set(itemKey, circleColor);
312+
console.log(
313+
`🔄 Cycled to color: ${circleColor} (cycle index: ${cycleIndex})`,
314+
);
315+
}
316+
}
317+
}
318+
319+
// Create circle element
320+
const circle = document.createElement("span");
321+
circle.className = "choices__item-circle";
322+
circle.style.cssText = `
323+
display: inline-block;
324+
width: 20px;
325+
height: 20px;
326+
border-radius: 50%;
327+
margin-right: 8px;
328+
background-color: ${circleColor};
329+
flex-shrink: 0;
330+
`;
331+
332+
// Insert circle before the first text node
333+
const firstChild = item.firstChild;
334+
if (firstChild) {
335+
item.insertBefore(circle, firstChild);
336+
} else {
337+
item.appendChild(circle);
338+
}
339+
}
340+
});
341+
}
342+
343+
// Simple function to add circle to selected items
344+
function addCircleToSelectedItem(itemElement: HTMLElement) {
345+
// Check if circles are enabled
346+
if (!enableSelectedItemCircles) {
347+
return;
348+
}
349+
350+
// Check if circle already exists
351+
if (itemElement.querySelector(".choices__item-circle")) {
352+
return;
353+
}
354+
355+
// For individual item addition, we need to refresh all circles to maintain consistency
356+
// This ensures proper color distribution and prevents duplicates
357+
setTimeout(() => {
358+
refreshAllCircles();
359+
}, 0);
360+
}
361+
142362
// Computed values for component configuration
143363
let computedPlaceholderText = $derived(
144364
placeholderText || (multiple ? "Select all that apply" : "Select one"),
@@ -645,6 +865,73 @@
645865
callbackOnInit: function () {
646866
console.log("🎉 Choices.js initialized successfully");
647867
868+
// Set up event listeners for adding circles to selected items
869+
this.containerInner.element.addEventListener("click", (event) => {
870+
const target = event.target as HTMLElement;
871+
if (target.classList.contains("choices__button")) {
872+
// When remove button is clicked, the item will be removed
873+
// We don't need to handle this case as the item is gone
874+
}
875+
});
876+
877+
// Set up MutationObserver to watch for new items being added or removed
878+
const observer = new MutationObserver((mutations) => {
879+
let shouldRefresh = false;
880+
881+
mutations.forEach((mutation) => {
882+
if (mutation.type === "childList") {
883+
// Check if any choices__item elements were added or removed
884+
const hasItemChanges =
885+
Array.from(mutation.addedNodes).some(
886+
(node) =>
887+
node instanceof HTMLElement &&
888+
node.classList.contains("choices__item"),
889+
) ||
890+
Array.from(mutation.removedNodes).some(
891+
(node) =>
892+
node instanceof HTMLElement &&
893+
node.classList.contains("choices__item"),
894+
);
895+
896+
if (hasItemChanges) {
897+
shouldRefresh = true;
898+
}
899+
}
900+
});
901+
902+
// Refresh all circles with optimized palette when items change
903+
if (shouldRefresh) {
904+
setTimeout(() => {
905+
refreshAllCircles();
906+
}, 0);
907+
}
908+
});
909+
910+
// Start observing the container for changes
911+
observer.observe(this.containerInner.element, {
912+
childList: true,
913+
subtree: true,
914+
});
915+
916+
// Store observer reference for cleanup
917+
(this as any)._circleObserver = observer;
918+
919+
// Add circles to any existing selected items
920+
setTimeout(() => {
921+
refreshAllCircles();
922+
}, 0);
923+
924+
// Listen for when choices are set programmatically
925+
const originalSetChoices = this.setChoices.bind(this);
926+
this.setChoices = function (...args) {
927+
const result = originalSetChoices.apply(this, args);
928+
// Refresh all circles with optimized palette
929+
setTimeout(() => {
930+
refreshAllCircles();
931+
}, 0);
932+
return result;
933+
};
934+
648935
// Apply group text to initial choices if groupKey is provided
649936
if (groupKey && this.choices && this.choices.length > 0) {
650937
console.log("🔧 Applying group text to initial choices");
@@ -1092,6 +1379,12 @@
10921379
onDestroy(() => {
10931380
console.log("🧹 MultiSelectSearchAutocomplete: onDestroy called");
10941381
if (choicesInstance) {
1382+
// Clean up MutationObserver if it exists
1383+
if ((choicesInstance as any)._circleObserver) {
1384+
(choicesInstance as any)._circleObserver.disconnect();
1385+
console.log("✅ Circle observer disconnected");
1386+
}
1387+
10951388
selectElement?.removeEventListener("change", handleChoicesChange);
10961389
selectElement?.removeEventListener("choice", () => {});
10971390
choicesInstance.destroy();
@@ -1116,8 +1409,10 @@
11161409

11171410
<div
11181411
class="gem-c-select-with-search"
1119-
style={`--cross-icon-url: url("${crossIconUrl}"); --choices-item-bg-color: ${choicesItemBackgroundColor}; --choices-item-border-color: ${choicesItemBorderColor}; --choices-item-text-color: ${choicesItemTextColor}; --choices-item-divider-padding: ${choicesItemDividerPadding};`}
1412+
style={`--cross-icon-url: url("${crossIconUrl}"); --choices-item-bg-color: ${choicesItemBackgroundColor}; --choices-item-border-color: ${choicesItemBorderColor}; --choices-item-text-color: ${choicesItemTextColor}; --choices-item-divider-padding: ${choicesItemDividerPadding}; --selected-item-circle-color: ${selectedItemCircleColor};`}
11201413
data-group-key={groupKey}
1414+
data-enable-circles={enableSelectedItemCircles}
1415+
data-circle-palette={selectedItemCircleColorPalette.join(",")}
11211416
>
11221417
{#snippet rightIcon()}
11231418
<button
@@ -2030,4 +2325,41 @@
20302325
) {
20312326
font-weight: normal;
20322327
}
2328+
2329+
/* Circle styling for selected items in button only */
2330+
:global(
2331+
.gem-c-select-with-search
2332+
.choices__list--multiple
2333+
.choices__item
2334+
.choices__item-circle
2335+
) {
2336+
display: inline-block;
2337+
width: 20px;
2338+
height: 20px;
2339+
border-radius: 50%;
2340+
margin-right: 8px;
2341+
flex-shrink: 0;
2342+
background-color: var(--selected-item-circle-color, #1d70b8);
2343+
/* Add subtle border for light colors and white */
2344+
border: 1px solid rgba(0, 0, 0, 0.1);
2345+
/* Ensure proper contrast for generated HSL colors */
2346+
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
2347+
}
2348+
2349+
/* Ensure proper alignment of circle with text in selected items */
2350+
:global(.gem-c-select-with-search .choices__list--multiple .choices__item) {
2351+
display: inline-flex;
2352+
align-items: center;
2353+
gap: 0;
2354+
}
2355+
2356+
/* Hide circles in dropdown choices */
2357+
:global(
2358+
.gem-c-select-with-search
2359+
.choices__list--dropdown
2360+
.choices__item
2361+
.choices__item-circle
2362+
) {
2363+
display: none;
2364+
}
20332365
</style>

0 commit comments

Comments
 (0)