Skip to content

Commit 9b449e5

Browse files
author
Ibrahim Haizel
committed
feat: add group key functionality to MultiSelectSearchAutocomplete with examples
1 parent 96707a6 commit 9b449e5

File tree

3 files changed

+298
-9
lines changed

3 files changed

+298
-9
lines changed

src/lib/components/ui/MultiSelectSearchAutocomplete.svelte

Lines changed: 196 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
// Import Choices.js dynamically to avoid SSR issues
88
let Choices: any;
99
10+
// Extend SelectItem to allow additional properties for group key functionality
11+
type ExtendedSelectItem = SelectItem & { [key: string]: any };
12+
type ExtendedSelectGroup = SelectGroup & { choices: ExtendedSelectItem[] };
13+
1014
let {
1115
// Core attributes - pass through to Select component
1216
id,
@@ -59,6 +63,9 @@
5963
minLength = 0,
6064
tTooShort = (n: number) => `Enter ${n} or more characters for suggestions`,
6165
66+
// Group key for displaying additional context in options
67+
groupKey = undefined,
68+
6269
// Custom styling props
6370
choicesItemBackgroundColor = "#f3f2f1",
6471
choicesItemBorderColor = "#b1b4b6",
@@ -68,8 +75,8 @@
6875
}: {
6976
id: string;
7077
name: string;
71-
items?: SelectItem[];
72-
groups?: SelectGroup[];
78+
items?: ExtendedSelectItem[];
79+
groups?: ExtendedSelectGroup[];
7380
value?: (string | number)[] | string | number | undefined;
7481
multiple?: boolean;
7582
label: string;
@@ -94,6 +101,7 @@
94101
sourceSelector?: (query: string, options: any[]) => "api" | "options";
95102
minLength?: number;
96103
tTooShort?: (n: number) => string;
104+
groupKey?: string;
97105
choicesItemBackgroundColor?: string;
98106
choicesItemBorderColor?: string;
99107
choicesItemTextColor?: string;
@@ -117,6 +125,20 @@
117125
let lastQuery = "";
118126
const baseNoChoicesText = "No choices to choose from";
119127
128+
// Helper function for getting group text
129+
function getGroupText(item: any): string | undefined {
130+
if (!groupKey || !item || typeof item !== "object") return undefined;
131+
return item[groupKey] ? String(item[groupKey]) : undefined;
132+
}
133+
134+
// HTML escaping function (simple version)
135+
function escapeHtml(text: string): string {
136+
if (typeof document === "undefined") return text; // SSR safety
137+
const div = document.createElement("div");
138+
div.textContent = text;
139+
return div.innerHTML;
140+
}
141+
120142
// Computed values for component configuration
121143
let computedPlaceholderText = $derived(
122144
placeholderText || (multiple ? "Select all that apply" : "Select one"),
@@ -146,18 +168,36 @@
146168
const flattened: ChoiceItem[] = [];
147169
// Include enhancedItems() first (single-select placeholder support)
148170
for (const it of enhancedItems) {
171+
const groupText = getGroupText(it);
172+
const safeLabel = escapeHtml(String(it.text));
173+
const safeGroup = groupText ? escapeHtml(groupText) : "";
174+
149175
flattened.push({
150176
value: it.value,
151-
label: String(it.text),
177+
label: safeGroup
178+
? `<span class="choices__item-label">
179+
<span class="choices__item-main">${safeLabel}</span>
180+
<span class="gem-c-select-with-search__suggestion-group">${safeGroup}</span>
181+
</span>`
182+
: safeLabel,
152183
disabled: it.disabled,
153184
});
154185
}
155186
// Then any explicit groups
156187
for (const g of groups) {
157188
for (const choice of g.choices) {
189+
const groupText = getGroupText(choice);
190+
const safeLabel = escapeHtml(String(choice.text));
191+
const safeGroup = groupText ? escapeHtml(groupText) : "";
192+
158193
flattened.push({
159194
value: choice.value,
160-
label: String(choice.text),
195+
label: safeGroup
196+
? `<span class="choices__item-label">
197+
<span class="choices__item-main">${safeLabel}</span>
198+
<span class="gem-c-select-with-search__suggestion-group">${safeGroup}</span>
199+
</span>`
200+
: safeLabel,
161201
disabled: g.disabled || choice.disabled,
162202
});
163203
}
@@ -231,10 +271,24 @@
231271
| any[]
232272
| undefined;
233273
if (!Array.isArray(data)) return [];
274+
234275
const mapped: ChoiceItem[] = data.map((entry) => {
235276
const label = toLabel(entry);
236-
return { value: toValue(entry, label), label };
277+
const groupText = getGroupText(entry);
278+
const safeLabel = escapeHtml(label);
279+
const safeGroup = groupText ? escapeHtml(groupText) : "";
280+
281+
return {
282+
value: toValue(entry, label),
283+
label: safeGroup
284+
? `<span class="choices__item-label">
285+
<span class="choices__item-main">${safeLabel}</span>
286+
<span class="gem-c-select-with-search__suggestion-group">${safeGroup}</span>
287+
</span>`
288+
: safeLabel,
289+
};
237290
});
291+
238292
return mapped;
239293
}
240294
@@ -443,8 +497,9 @@
443497
});
444498
445499
// Check if placeholder option already exists
446-
const existingPlaceholder =
447-
selectElement.querySelector('option[value=""]');
500+
const existingPlaceholder = selectElement.querySelector(
501+
'option[value=""]',
502+
) as HTMLOptionElement | null;
448503
console.log("🔍 Existing placeholder check:", {
449504
found: !!existingPlaceholder,
450505
placeholder: existingPlaceholder
@@ -469,13 +524,65 @@
469524
console.log("✅ Added placeholder option to DOM");
470525
}
471526
527+
// If groupKey is provided, update all existing options to include group text
528+
if (groupKey && selectElement) {
529+
console.log("🔧 Updating DOM options with group text");
530+
// Update items options
531+
items.forEach((item, index) => {
532+
const optionIndex = multiple ? index : index + 1; // +1 for placeholder
533+
if (selectElement && selectElement.options[optionIndex]) {
534+
const groupText = getGroupText(item);
535+
const safeLabel = escapeHtml(String(item.text));
536+
const safeGroup = groupText ? escapeHtml(groupText) : "";
537+
const option = selectElement.options[optionIndex];
538+
539+
if (safeGroup) {
540+
option.innerHTML = `<span class="choices__item-label">
541+
<span class="choices__item-main">${safeLabel}</span>
542+
<span class="gem-c-select-with-search__suggestion-group">${safeGroup}</span>
543+
</span>`;
544+
console.log(
545+
"✅ Updated item option with group text:",
546+
option.innerHTML,
547+
);
548+
}
549+
}
550+
});
551+
552+
// Update grouped options
553+
let optionIndex = multiple ? items.length : items.length + 1; // +1 for placeholder
554+
groups.forEach((group) => {
555+
group.choices.forEach((choice) => {
556+
if (selectElement && selectElement.options[optionIndex]) {
557+
const groupText = getGroupText(choice);
558+
const safeLabel = escapeHtml(String(choice.text));
559+
const safeGroup = groupText ? escapeHtml(groupText) : "";
560+
const option = selectElement.options[optionIndex];
561+
562+
if (safeGroup) {
563+
option.innerHTML = `<span class="choices__item-label">
564+
<span class="choices__item-main">${safeLabel}</span>
565+
<span class="gem-c-select-with-search__suggestion-group">${safeGroup}</span>
566+
</span>`;
567+
console.log(
568+
"✅ Updated grouped option with group text:",
569+
option.innerHTML,
570+
);
571+
}
572+
}
573+
optionIndex++;
574+
});
575+
});
576+
}
577+
472578
// Log the DOM structure after ensuring placeholder exists
473579
console.log("📋 DOM structure AFTER placeholder check:", {
474580
totalOptions: selectElement.options.length,
475581
options: Array.from(selectElement.options).map((opt, idx) => ({
476582
index: idx,
477583
value: (opt as HTMLOptionElement).value,
478584
text: (opt as HTMLOptionElement).text,
585+
innerHTML: (opt as HTMLOptionElement).innerHTML,
479586
selected: (opt as HTMLOptionElement).selected,
480587
disabled: (opt as HTMLOptionElement).disabled,
481588
})),
@@ -537,6 +644,33 @@
537644
duplicateItemsAllowed: false,
538645
callbackOnInit: function () {
539646
console.log("🎉 Choices.js initialized successfully");
647+
648+
// Apply group text to initial choices if groupKey is provided
649+
if (groupKey && this.choices && this.choices.length > 0) {
650+
console.log("🔧 Applying group text to initial choices");
651+
this.choices.forEach((choice: any) => {
652+
if (
653+
choice &&
654+
choice.label &&
655+
!choice.label.includes(
656+
'<span class="gem-c-select-with-search__suggestion-group">',
657+
)
658+
) {
659+
// Find the original item to get group text
660+
const originalItem = staticChoices.find(
661+
(item) => String(item.value) === String(choice.value),
662+
);
663+
if (originalItem && originalItem.label !== choice.label) {
664+
choice.label = originalItem.label;
665+
console.log(
666+
"✅ Updated choice label with group text:",
667+
choice.label,
668+
);
669+
}
670+
}
671+
});
672+
}
673+
540674
// For multiple select, move input field to top of feedback area
541675
if (this.dropdown.type === "select-multiple") {
542676
const inner = this.containerInner.element;
@@ -560,6 +694,28 @@
560694
// Store reference on the element for external access
561695
(selectElement as any).choices = choicesInstance;
562696
697+
// Ensure initial choices have group text applied if groupKey is provided
698+
if (groupKey && choicesInstance && staticChoices.length > 0) {
699+
console.log("🔧 Ensuring initial choices have group text applied");
700+
// Force refresh of choices with group text
701+
setTimeout(() => {
702+
if (choicesInstance) {
703+
choicesInstance.clearChoices();
704+
choicesInstance.setChoices(
705+
staticChoices.map((c) => ({
706+
value: String(c.value),
707+
label: c.label,
708+
disabled: c.disabled,
709+
})),
710+
"value",
711+
"label",
712+
true,
713+
);
714+
console.log("✅ Initial choices refreshed with group text");
715+
}
716+
}, 0);
717+
}
718+
563719
// Log the DOM structure after Choices.js initialization
564720
console.log("🔍 DOM structure AFTER Choices.js initialization:", {
565721
totalOptions: selectElement.options.length,
@@ -961,6 +1117,7 @@
9611117
<div
9621118
class="gem-c-select-with-search"
9631119
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};`}
1120+
data-group-key={groupKey}
9641121
>
9651122
{#snippet rightIcon()}
9661123
<button
@@ -1841,4 +1998,36 @@
18411998
) {
18421999
padding: 4px 8px;
18432000
}
2001+
2002+
/* Group text styling similar to SearchAutocomplete */
2003+
:global(
2004+
.gem-c-select-with-search
2005+
.choices__item
2006+
.gem-c-select-with-search__suggestion-group
2007+
) {
2008+
opacity: 0.8;
2009+
font-size: smaller;
2010+
font-weight: normal;
2011+
}
2012+
2013+
/* Flex container for label + group text alignment */
2014+
:global(.gem-c-select-with-search .choices__item-label) {
2015+
display: inline-flex;
2016+
align-items: baseline; /* aligns text baselines for consistent vertical alignment */
2017+
gap: 5px; /* spacing between label and group text */
2018+
}
2019+
2020+
/* Main text styling */
2021+
/* :global(.gem-c-select-with-search .choices__item-main) {
2022+
font-weight: bold;
2023+
} */
2024+
2025+
/* Override the bold weight for the group text specifically */
2026+
:global(
2027+
.gem-c-select-with-search
2028+
.choices__item
2029+
.gem-c-select-with-search__suggestion-group
2030+
) {
2031+
font-weight: normal;
2032+
}
18442033
</style>

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@
2424
},
2525
{
2626
id: "4",
27-
heading: "4. Remote API source (postcodes.io)",
27+
heading: "4. Group Key Feature with Locations",
2828
content: Example4,
2929
},
30+
{
31+
id: "5",
32+
heading: "5. Remote API source (postcodes.io)",
33+
content: Example5,
34+
},
3035
];
3136
</script>
3237

@@ -127,8 +132,58 @@
127132
<CodeBlock code={codeBlocks.codeBlock3} language="svelte"></CodeBlock>
128133
{/snippet}
129134

130-
<!-- Example 4: Remote API source (postcodes.io) -->
135+
<!-- Example 4: Group Key Feature with Locations -->
131136
{#snippet Example4()}
137+
<div class="p-5 bg-white space-y-6">
138+
<div>
139+
<h6 class="font-semibold mb-3">Single Select with Group Key:</h6>
140+
<MultiSelectSearchAutocomplete
141+
id="location-select-single"
142+
name="location-single"
143+
label="Select a location"
144+
hint="Options show location name and region"
145+
groupKey="region"
146+
items={[
147+
{ value: "hounslow", text: "Hounslow", region: "London" },
148+
{ value: "birmingham", text: "Birmingham", region: "West Midlands" },
149+
{ value: "manchester", text: "Manchester", region: "North West" },
150+
{ value: "leeds", text: "Leeds", region: "Yorkshire" },
151+
{ value: "bristol", text: "Bristol", region: "South West" },
152+
{ value: "cardiff", text: "Cardiff", region: "Wales" },
153+
{ value: "edinburgh", text: "Edinburgh", region: "Scotland" },
154+
{ value: "belfast", text: "Belfast", region: "Northern Ireland" },
155+
]}
156+
multiple={false}
157+
/>
158+
</div>
159+
160+
<div>
161+
<h6 class="font-semibold mb-3">Multiple Select with Group Key:</h6>
162+
<MultiSelectSearchAutocomplete
163+
id="location-select-multiple"
164+
name="location-multiple"
165+
label="Select multiple locations"
166+
hint="You can select multiple locations from different regions"
167+
groupKey="region"
168+
items={[
169+
{ value: "hounslow", text: "Hounslow", region: "London" },
170+
{ value: "birmingham", text: "Birmingham", region: "West Midlands" },
171+
{ value: "manchester", text: "Manchester", region: "North West" },
172+
{ value: "leeds", text: "Leeds", region: "Yorkshire" },
173+
{ value: "bristol", text: "Bristol", region: "South West" },
174+
{ value: "cardiff", text: "Cardiff", region: "Wales" },
175+
{ value: "edinburgh", text: "Edinburgh", region: "Scotland" },
176+
{ value: "belfast", text: "Belfast", region: "Northern Ireland" },
177+
]}
178+
multiple={true}
179+
/>
180+
</div>
181+
</div>
182+
<CodeBlock code={codeBlocks.codeBlock4} language="svelte"></CodeBlock>
183+
{/snippet}
184+
185+
<!-- Example 5: Remote API source (postcodes.io) -->
186+
{#snippet Example5()}
132187
<div class="p-5 bg-white">
133188
<!-- Uses postcodes.io API: https://postcodes.io/ -->
134189
<MultiSelectSearchAutocomplete

0 commit comments

Comments
 (0)