From 7d6a3c8c7652b3ffc15c9b8c2e8be4a2b72828d5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:15:13 +0200 Subject: [PATCH] feat: group contacts by first letter with sticky section headers --- components/contacts/contact-list.tsx | 85 +++++++++++++++++++--------- 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 3b8cf48d..a72e9390 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -233,6 +233,30 @@ export function ContactList({ const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); + // Group sorted contacts by first letter of display name. Non-letter + // starters (digits, symbols, empty) collect under "#" which sorts last. + const groupedSections = useMemo(() => { + const collator = new Intl.Collator(locale, { sensitivity: "base" }); + const groups = new Map(); + for (const contact of sorted) { + const name = getContactDisplayName(contact).trim(); + const first = name.charAt(0); + const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u) + ? first.toLocaleUpperCase(locale) + : "#"; + const bucket = groups.get(letter); + if (bucket) bucket.push(contact); + else groups.set(letter, [contact]); + } + return Array.from(groups.entries()) + .sort(([a], [b]) => { + if (a === "#") return 1; + if (b === "#") return -1; + return collator.compare(a, b); + }) + .map(([letter, items]) => ({ letter, items })); + }, [sorted, locale]); + const hasSelection = selectedContactIds.size > 0; const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); @@ -505,33 +529,40 @@ export function ContactList({ ) : (
- {sorted.map((contact) => ( - { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - onToggleSelection(contact.id); - } else if (e.shiftKey) { - e.preventDefault(); - onSelectRangeContacts(contact.id, sortedIds); - } else { - if (hasSelection) onClearSelection(); - onSelectContact(contact.id); - } - }} - onCheckboxClick={(e) => { - e.stopPropagation(); - onToggleSelection(contact.id); - }} - onContextMenu={(e, c) => openContextMenu(e, c)} - /> + {groupedSections.map(({ letter, items }) => ( +
+
+ {letter} +
+ {items.map((contact) => ( + { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + onToggleSelection(contact.id); + } else if (e.shiftKey) { + e.preventDefault(); + onSelectRangeContacts(contact.id, sortedIds); + } else { + if (hasSelection) onClearSelection(); + onSelectContact(contact.id); + } + }} + onCheckboxClick={(e) => { + e.stopPropagation(); + onToggleSelection(contact.id); + }} + onContextMenu={(e, c) => openContextMenu(e, c)} + /> + ))} +
))}
)}