|
71 | 71 | choicesItemBorderColor = "#b1b4b6",
|
72 | 72 | choicesItemTextColor = "black",
|
73 | 73 | 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 |
74 | 99 | ...attributes
|
75 | 100 | }: {
|
76 | 101 | id: string;
|
|
106 | 131 | choicesItemBorderColor?: string;
|
107 | 132 | choicesItemTextColor?: string;
|
108 | 133 | choicesItemDividerPadding?: string;
|
| 134 | + enableSelectedItemCircles?: boolean; |
| 135 | + selectedItemCircleColor?: string; |
| 136 | + selectedItemCircleColorPalette?: string[]; |
109 | 137 | } & Omit<
|
110 | 138 | import("svelte/elements").HTMLSelectAttributes,
|
111 | 139 | | "id"
|
|
125 | 153 | let lastQuery = "";
|
126 | 154 | const baseNoChoicesText = "No choices to choose from";
|
127 | 155 |
|
| 156 | + // Global color assignment tracking to ensure consistency across component lifecycle |
| 157 | + let globalColorAssignments = new Map<string, string>(); // itemKey -> color |
| 158 | +
|
128 | 159 | // Helper function for getting group text
|
129 | 160 | function getGroupText(item: any): string | undefined {
|
130 | 161 | if (!groupKey || !item || typeof item !== "object") return undefined;
|
|
139 | 170 | return div.innerHTML;
|
140 | 171 | }
|
141 | 172 |
|
| 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 | +
|
142 | 362 | // Computed values for component configuration
|
143 | 363 | let computedPlaceholderText = $derived(
|
144 | 364 | placeholderText || (multiple ? "Select all that apply" : "Select one"),
|
|
645 | 865 | callbackOnInit: function () {
|
646 | 866 | console.log("🎉 Choices.js initialized successfully");
|
647 | 867 |
|
| 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 | +
|
648 | 935 | // Apply group text to initial choices if groupKey is provided
|
649 | 936 | if (groupKey && this.choices && this.choices.length > 0) {
|
650 | 937 | console.log("🔧 Applying group text to initial choices");
|
|
1092 | 1379 | onDestroy(() => {
|
1093 | 1380 | console.log("🧹 MultiSelectSearchAutocomplete: onDestroy called");
|
1094 | 1381 | 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 | +
|
1095 | 1388 | selectElement?.removeEventListener("change", handleChoicesChange);
|
1096 | 1389 | selectElement?.removeEventListener("choice", () => {});
|
1097 | 1390 | choicesInstance.destroy();
|
|
1116 | 1409 |
|
1117 | 1410 | <div
|
1118 | 1411 | 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};`} |
1120 | 1413 | data-group-key={groupKey}
|
| 1414 | + data-enable-circles={enableSelectedItemCircles} |
| 1415 | + data-circle-palette={selectedItemCircleColorPalette.join(",")} |
1121 | 1416 | >
|
1122 | 1417 | {#snippet rightIcon()}
|
1123 | 1418 | <button
|
|
2030 | 2325 | ) {
|
2031 | 2326 | font-weight: normal;
|
2032 | 2327 | }
|
| 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 | + } |
2033 | 2365 | </style>
|
0 commit comments