feat: group contacts by first letter with sticky section headers
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user