Skip to content

Commit 8574ff6

Browse files
author
Ibrahim Haizel
committed
feat: add two-way binding example for MultiSelectSearchAutocomplete with synchronised color state
1 parent 9884a67 commit 8574ff6

File tree

3 files changed

+436
-17
lines changed

3 files changed

+436
-17
lines changed

src/lib/components/ui/MultiSelectSearchAutocomplete.svelte

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { onMount, onDestroy } from "svelte";
3+
import { SvelteMap } from "svelte/reactivity";
34
import Select, { type SelectItem, type SelectGroup } from "./Select.svelte";
45
import IconSearch from "../../icons/IconSearch.svelte";
56
import crossIconUrl from "./../../assets/govuk_publishing_components/images/cross-icon.svg?url";
@@ -20,6 +21,10 @@
2021
value = $bindable<(string | number)[] | string | number | undefined>(),
2122
multiple = false,
2223
24+
// Bindable state props for external synchronization
25+
bindSelectedItemIndexMap = $bindable(new Map<string, number>()),
26+
bindNextSelectionIndex = $bindable(0),
27+
2328
// Label and hints - pass through to Select component
2429
label,
2530
labelIsPageHeading = false,
@@ -96,6 +101,7 @@
96101
"#b1b4b6", // Light grey
97102
"#0b0c0c", // Black
98103
], // Complete GOV.UK Design System palette (19 colors)
104+
99105
...attributes
100106
}: {
101107
id: string;
@@ -134,6 +140,11 @@
134140
enableSelectedItemCircles?: boolean;
135141
selectedItemCircleColor?: string;
136142
selectedItemCircleColorPalette?: string[];
143+
144+
// Bindable state props for external synchronization
145+
// Use these to sync color state with other components
146+
bindSelectedItemIndexMap?: Map<string, number> | SvelteMap<string, number>; // Maps item values to their color indices
147+
bindNextSelectionIndex?: number; // Next available color index
137148
} & Omit<
138149
import("svelte/elements").HTMLSelectAttributes,
139150
| "id"
@@ -170,8 +181,22 @@
170181
// Sequential color mapping: each selected item gets a unique color based on selection order
171182
// This ensures perfect visual distinction and predictable color assignment
172183
// 1st selection = color[0], 2nd selection = color[1], 3rd selection = color[2], etc.
173-
let selectedItemIndexMap = new Map<string, number>(); // Maps item value to selection index
174-
let nextSelectionIndex = 0; // Tracks the next available color index
184+
185+
// Architecture: Single source of truth using external bindings with fallbacks
186+
// This eliminates state duplication and sync complexity
187+
// Use $derived for reading external bindings reactively
188+
// This ensures that when external bindings change, our local state updates automatically
189+
let selectedItemIndexMap = $derived(
190+
bindSelectedItemIndexMap || new SvelteMap<string, number>(),
191+
);
192+
let nextSelectionIndex = $derived(bindNextSelectionIndex ?? 0);
193+
194+
// Helper function to update external bindings when we modify state
195+
function updateNextSelectionIndex(newValue: number) {
196+
if (bindNextSelectionIndex !== undefined) {
197+
bindNextSelectionIndex = newValue;
198+
}
199+
}
175200
176201
// Get the maximum number of selections allowed (limited by palette size)
177202
// With 19 GOV.UK colors, maximum selections = 19 before cycling begins
@@ -196,39 +221,28 @@
196221
// If we've reached the palette limit, cycle back to the beginning
197222
// This means the 20th selection will get color[0], 21st will get color[1], etc.
198223
if (nextSelectionIndex >= maxSelections) {
199-
nextSelectionIndex = 0;
224+
updateNextSelectionIndex(0);
200225
}
201226
202227
// Assign the next available color index to this item
203228
const colorIndex = nextSelectionIndex;
229+
// SvelteMap.set() automatically triggers reactivity and updates external bindings
204230
selectedItemIndexMap.set(valueKey, colorIndex);
205-
nextSelectionIndex++;
231+
updateNextSelectionIndex(colorIndex + 1);
206232
207233
// Log the index map update for debugging
208234
console.log("🎨 Color index map updated:", {
209235
itemValue: valueKey,
210236
assignedColorIndex: colorIndex,
211237
assignedColor: selectedItemCircleColorPalette[colorIndex],
212-
nextSelectionIndex,
238+
nextSelectionIndex: colorIndex + 1,
213239
totalMappedItems: selectedItemIndexMap.size,
214240
currentMap: Object.fromEntries(selectedItemIndexMap),
215241
});
216242
217243
return selectedItemCircleColorPalette[colorIndex];
218244
}
219245
220-
// Function to reset the selection index when items are removed
221-
// Call this when you want to clear all selections and start fresh
222-
function resetSelectionIndexes() {
223-
console.log("🔄 Color index map reset:", {
224-
previousMapSize: selectedItemIndexMap.size,
225-
previousNextIndex: nextSelectionIndex,
226-
});
227-
selectedItemIndexMap.clear();
228-
nextSelectionIndex = 0;
229-
console.log("✅ Color index map reset complete");
230-
}
231-
232246
// Color cache to ensure consistent colors for the same values
233247
const colorCache = new Map<string, string>();
234248

src/wrappers/components/ui/multi-select-search-autocomplete/Examples.svelte

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@
55
import * as codeBlocks from "./codeBlocks.js";
66
77
import MultiSelectSearchAutocomplete from "$lib/components/ui/MultiSelectSearchAutocomplete.svelte";
8+
import Button from "$lib/components/ui/Button.svelte";
9+
import { SvelteMap } from "svelte/reactivity";
10+
11+
// State variables for two-way binding example
12+
let globalColorMap = new SvelteMap();
13+
let globalNextIndex = 0;
14+
let globalSelectedValues = []; // Shared selected values between components
15+
16+
// GOV.UK color palette for the example
17+
const selectedItemCircleColorPalette = [
18+
"#1d70b8", // Blue (primary)
19+
"#d4351c", // Red
20+
"#00703c", // Green
21+
"#f47738", // Orange
22+
"#4c2c92", // Purple
23+
"#801650", // Bright purple
24+
"#28a197", // Turquoise
25+
"#b58840", // Brown
26+
"#d53880", // Pink
27+
"#6f72af", // Light purple
28+
"#f499be", // Light pink
29+
"#85994b", // Light green
30+
"#ffdd00", // Yellow
31+
"#12436d", // Dark blue
32+
"#505a5f", // Dark grey
33+
"#626a6e", // Mid grey
34+
"#b1b4b6", // Light grey
35+
"#0b0c0c", // Black
36+
];
837
938
let accordionSnippetSections = [
1039
{
@@ -32,6 +61,11 @@
3261
heading: "5. Remote API source (postcodes.io)",
3362
content: Example5,
3463
},
64+
{
65+
id: "6",
66+
heading: "6. Two-Way Binding with Color State",
67+
content: Example6,
68+
},
3569
];
3670
</script>
3771

@@ -201,3 +235,188 @@
201235
</div>
202236
<CodeBlock code={codeBlocks.codeBlockApi} language="svelte"></CodeBlock>
203237
{/snippet}
238+
239+
<!-- Example 6: Two-Way Binding with Color State -->
240+
<!-- This example demonstrates how to use the new bindable state props to sync color state between multiple components -->
241+
{#snippet Example6()}
242+
<div class="p-5 bg-white space-y-6">
243+
<div>
244+
<h2 class="govuk-heading-m">
245+
Two-Way Binding Demo: Synchronised Multi-Select Components
246+
</h2>
247+
<p class="govuk-body">
248+
Both components below share the same options and color state. Select or
249+
deselect items in either component to see them sync in real-time.
250+
</p>
251+
</div>
252+
253+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
254+
<div class="bg-white p-4 rounded-lg border border-gray-200">
255+
<h6 class="font-semibold mb-3 text-gray-800">
256+
Component 1 - Left Panel
257+
</h6>
258+
<MultiSelectSearchAutocomplete
259+
id="bound-select-1"
260+
name="bound-1"
261+
label="Select fruits (Component 1)"
262+
hint="Select items here and watch Component 2 sync automatically"
263+
items={[
264+
{ value: "apple", text: "🍎 Apple" },
265+
{ value: "banana", text: "🍌 Banana" },
266+
{ value: "cherry", text: "🍒 Cherry" },
267+
{ value: "date", text: "📅 Date" },
268+
{ value: "elderberry", text: "🫐 Elderberry" },
269+
{ value: "fig", text: "🫒 Fig" },
270+
{ value: "grape", text: "🍇 Grape" },
271+
{ value: "honeydew", text: "🍈 Honeydew" },
272+
{ value: "kiwi", text: "🥝 Kiwi" },
273+
{ value: "lemon", text: "🍋 Lemon" },
274+
{ value: "mango", text: "🥭 Mango" },
275+
{ value: "nectarine", text: "🍑 Nectarine" },
276+
]}
277+
multiple={true}
278+
bind:value={globalSelectedValues}
279+
bind:bindSelectedItemIndexMap={globalColorMap}
280+
bind:bindNextSelectionIndex={globalNextIndex}
281+
/>
282+
</div>
283+
284+
<div class="bg-white p-4 rounded-lg border border-gray-200">
285+
<h6 class="font-semibold mb-3 text-gray-800">
286+
Component 2 - Right Panel
287+
</h6>
288+
<MultiSelectSearchAutocomplete
289+
id="bound-select-2"
290+
name="bound-2"
291+
label="Select fruits (Component 2)"
292+
hint="This component automatically syncs with Component 1"
293+
items={[
294+
{ value: "apple", text: "🍎 Apple" },
295+
{ value: "banana", text: "🍌 Banana" },
296+
{ value: "cherry", text: "🍒 Cherry" },
297+
{ value: "date", text: "📅 Date" },
298+
{ value: "elderberry", text: "🫐 Elderberry" },
299+
{ value: "fig", text: "🫒 Fig" },
300+
{ value: "grape", text: "🍇 Grape" },
301+
{ value: "honeydew", text: "🍈 Honeydew" },
302+
{ value: "kiwi", text: "🥝 Kiwi" },
303+
{ value: "lemon", text: "🍋 Lemon" },
304+
{ value: "mango", text: "🥭 Mango" },
305+
{ value: "nectarine", text: "🍑 Nectarine" },
306+
]}
307+
multiple={true}
308+
bind:value={globalSelectedValues}
309+
bind:bindSelectedItemIndexMap={globalColorMap}
310+
bind:bindNextSelectionIndex={globalNextIndex}
311+
/>
312+
</div>
313+
</div>
314+
315+
<div>
316+
<h2 class="govuk-heading-m">🎨 Live Colour State Synchronisation</h2>
317+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
318+
<div class="space-y-3">
319+
<div class="flex items-center justify-between">
320+
<span class="font-medium text-gray-700">Next Color Index:</span>
321+
<span
322+
class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full font-mono"
323+
>
324+
{globalNextIndex}
325+
</span>
326+
</div>
327+
<div class="flex items-center justify-between">
328+
<span class="font-medium text-gray-700">Total Selected Items:</span>
329+
<span
330+
class="px-3 py-1 bg-green-100 text-green-800 rounded-full font-mono"
331+
>
332+
{globalColorMap.size}
333+
</span>
334+
</div>
335+
<div class="flex items-center justify-between">
336+
<span class="font-medium text-gray-700">Available Colors:</span>
337+
<span
338+
class="px-3 py-1 bg-purple-100 text-purple-800 rounded-full font-mono"
339+
>
340+
{selectedItemCircleColorPalette.length - globalColorMap.size}
341+
</span>
342+
</div>
343+
<div class="flex items-center justify-between">
344+
<span class="font-medium text-gray-700">Selected Values:</span>
345+
<span
346+
class="px-3 py-1 bg-orange-100 text-orange-800 rounded-full font-mono"
347+
>
348+
{globalSelectedValues.length}
349+
</span>
350+
</div>
351+
</div>
352+
353+
<div class="space-y-2">
354+
<span class="font-medium text-gray-700 block mb-2"
355+
>Color Mapping:</span
356+
>
357+
<div class="max-h-32 overflow-y-auto space-y-1">
358+
{#each Array.from(globalColorMap.entries()) as [itemValue, colorIndex]}
359+
<div class="flex items-center gap-2 p-2 bg-white rounded border">
360+
<span
361+
class="w-4 h-4 rounded-full border-2 border-white shadow-sm"
362+
style="background-color: {selectedItemCircleColorPalette[
363+
colorIndex
364+
]}"
365+
></span>
366+
<span class="text-sm font-medium">{itemValue}</span>
367+
<span class="text-xs text-gray-500 ml-auto"
368+
>→ Color {colorIndex}</span
369+
>
370+
</div>
371+
{/each}
372+
{#if globalColorMap.size === 0}
373+
<div
374+
class="text-sm text-gray-500 italic p-2 bg-white rounded border"
375+
>
376+
No items selected yet
377+
</div>
378+
{/if}
379+
</div>
380+
</div>
381+
</div>
382+
383+
<div class="govuk-!-margin-top-6 govuk-button-group">
384+
<Button
385+
buttonType="warning"
386+
textContent="Reset All Selections"
387+
onClickFunction={() => {
388+
// Reset all state - single source of truth means no need for component resets!
389+
globalColorMap.clear();
390+
globalNextIndex = 0;
391+
globalSelectedValues = [];
392+
}}
393+
/>
394+
<Button
395+
buttonType="secondary"
396+
textContent="Add Demo Selections"
397+
onClickFunction={() => {
398+
// Add some demo selections
399+
globalColorMap.set("apple", 0);
400+
globalColorMap.set("banana", 1);
401+
globalColorMap.set("cherry", 2);
402+
globalNextIndex = 3;
403+
globalSelectedValues = ["apple", "banana", "cherry"];
404+
}}
405+
/>
406+
<Button
407+
buttonType="default"
408+
textContent="Debug State"
409+
onClickFunction={() => {
410+
console.log("🔍 Debug State:", {
411+
globalColorMap: Object.fromEntries(globalColorMap),
412+
globalNextIndex,
413+
globalSelectedValues,
414+
mapSize: globalColorMap.size,
415+
});
416+
}}
417+
/>
418+
</div>
419+
</div>
420+
</div>
421+
<CodeBlock code={codeBlocks.codeBlock6} language="svelte"></CodeBlock>
422+
{/snippet}

0 commit comments

Comments
 (0)