feat: group contacts by first letter with sticky section headers

This commit is contained in:
Linus Rath
2026-04-21 23:15:13 +02:00
parent 361ad49f5f
commit 7d6a3c8c76
+58 -27
View File
@@ -233,6 +233,30 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); 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<string, ContactCard[]>();
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 hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
@@ -505,33 +529,40 @@ export function ContactList({
</div> </div>
) : ( ) : (
<div> <div>
{sorted.map((contact) => ( {groupedSections.map(({ letter, items }) => (
<ContactListItem <section key={letter}>
key={contact.id} <div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
contact={contact} {letter}
isSelected={contact.id === selectedContactId} </div>
isChecked={selectedContactIds.has(contact.id)} {items.map((contact) => (
hasSelection={hasSelection} <ContactListItem
density={density} key={contact.id}
selectedContactIds={selectedContactIds} contact={contact}
onClick={(e) => { isSelected={contact.id === selectedContactId}
if (e.ctrlKey || e.metaKey) { isChecked={selectedContactIds.has(contact.id)}
e.preventDefault(); hasSelection={hasSelection}
onToggleSelection(contact.id); density={density}
} else if (e.shiftKey) { selectedContactIds={selectedContactIds}
e.preventDefault(); onClick={(e) => {
onSelectRangeContacts(contact.id, sortedIds); if (e.ctrlKey || e.metaKey) {
} else { e.preventDefault();
if (hasSelection) onClearSelection(); onToggleSelection(contact.id);
onSelectContact(contact.id); } else if (e.shiftKey) {
} e.preventDefault();
}} onSelectRangeContacts(contact.id, sortedIds);
onCheckboxClick={(e) => { } else {
e.stopPropagation(); if (hasSelection) onClearSelection();
onToggleSelection(contact.id); onSelectContact(contact.id);
}} }
onContextMenu={(e, c) => openContextMenu(e, c)} }}
/> onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
/>
))}
</section>
))} ))}
</div> </div>
)} )}