/// /** * FamilyMember - Sub-type pattern extending PersonLike. * * Demonstrates the "fork-on-demand" concept for canonical base patterns. * A user who needs family tracking creates this pattern, which adds * domain-specific fields while remaining compatible with PersonLike. * * Any container accepting PersonLike[] can include FamilyMember items. */ import { computed, type Default, handler, NAME, pattern, UI, type VNode, Writable, } from "commontools"; import type { ContactPiece, FamilyMember, PersonLike, } from "./contact-types.tsx"; // Re-export for backwards compatibility export type { FamilyMember } from "./contact-types.tsx"; // ============================================================================ // Constants // ============================================================================ const RELATIONSHIP_OPTIONS = [ { value: "", label: "Select..." }, { value: "spouse", label: "Spouse" }, { value: "partner", label: "Partner" }, { value: "child", label: "Child" }, { value: "parent", label: "Parent" }, { value: "sibling", label: "Sibling" }, { value: "grandparent", label: "Grandparent" }, { value: "grandchild", label: "Grandchild" }, { value: "aunt", label: "Aunt" }, { value: "uncle", label: "Uncle" }, { value: "cousin", label: "Cousin" }, { value: "niece", label: "Niece" }, { value: "nephew", label: "Nephew" }, { value: "in-law", label: "In-law" }, { value: "other", label: "Other" }, ]; // ============================================================================ // Input/Output Schemas // ============================================================================ interface Input { member: Writable< Default< FamilyMember, { firstName: ""; lastName: ""; relationship: ""; birthday: ""; dietaryRestrictions: []; notes: ""; tags: []; allergies: []; giftIdeas: []; } > >; // Optional: reactive source of sibling contacts for sameAs linking. sameAs?: Writable; } interface Output { [NAME]: string; [UI]: VNode; member: FamilyMember; } // ============================================================================ // Handlers // ============================================================================ // Handler for ct-tags change event const updateDietaryRestrictions = handler< { detail: { tags: string[] } }, { member: Writable } >(({ detail }, { member }) => { const current = member.get(); member.set({ ...current, dietaryRestrictions: detail?.tags ?? [], }); }); const updateTags = handler< { detail: { tags: string[] } }, { member: Writable } >(({ detail }, { member }) => { const current = member.get(); member.set({ ...current, tags: detail?.tags ?? [] }); }); const updateAllergies = handler< { detail: { tags: string[] } }, { member: Writable } >(({ detail }, { member }) => { const current = member.get(); member.set({ ...current, allergies: detail?.tags ?? [] }); }); const updateGiftIdeas = handler< { detail: { tags: string[] } }, { member: Writable } >(({ detail }, { member }) => { const current = member.get(); member.set({ ...current, giftIdeas: detail?.tags ?? [] }); }); // sameAs handlers const selectSameAs = handler< { detail: { data?: PersonLike } }, { member: Writable; showPicker: Writable } >(({ detail }, { member, showPicker }) => { const linked = detail?.data; if (!linked) return; const current = member.get(); member.set({ ...current, sameAs: linked }); showPicker.set(false); }); const clearSameAs = handler }>( (_event, { member }) => { const current = member.get(); member.set({ ...current, sameAs: undefined }); }, ); const togglePicker = handler }>( (_event, { showPicker }) => { showPicker.set(!showPicker.get()); }, ); const toggleSection = handler }>( (_event, { section }) => { section.set(!section.get()); }, ); // ============================================================================ // UI Helpers // ============================================================================ function sectionHeader( label: string, expanded: Writable, count?: () => number, ) { return ( ); } // ============================================================================ // Pattern // ============================================================================ export default pattern(({ member, sameAs }) => { // Computed display name showing name and relationship const displayName = computed(() => { const first = member.key("firstName").get(); const last = member.key("lastName").get(); const rel = member.key("relationship").get(); let name = ""; if (first && last) name = `${first} ${last}`; else if (first) name = first; else if (last) name = last; if (name && rel) return `${name} (${rel})`; if (name) return name; return "Family Member"; }); // Computed: current sameAs link display const sameAsDisplay = computed(() => { const linked = member.key("sameAs").get(); if (!linked) return null; const first = linked.firstName || ""; const last = linked.lastName || ""; if (first && last) return `${first} ${last}`; if (first) return first; if (last) return last; return "Unknown"; }); // State: whether the sameAs picker is expanded const showPicker = Writable.of(false); // Section expansion state const showFamilyInfo = Writable.of(true); const showHealth = Writable.of(false); const showGifts = Writable.of(false); const showNotes = Writable.of(false); // Computed: autocomplete items from reactive sibling source, filtering self const sameAsItems = computed(() => { if (!sameAs) return []; const all = sameAs.get(); if (!all || all.length === 0) return []; const selfFirst = member.key("firstName").get(); const selfLast = member.key("lastName").get(); const hasSelfName = Boolean(selfFirst || selfLast); const result: Array<{ value: string; label: string; data: PersonLike }> = []; for (const c of all) { const p = c.person ?? c.member; if (!p) continue; // Only filter by name if this contact actually has a name set if ( hasSelfName && p.firstName === selfFirst && p.lastName === selfLast ) { continue; } const label = p.firstName && p.lastName ? `${p.firstName} ${p.lastName}` : p.firstName || p.lastName || "Person"; result.push({ value: label, label, data: p }); } return result; }); const hasSameAsCandidates = computed(() => sameAsItems.length > 0); return { [NAME]: displayName, [UI]: ( {/* Basic Info - always visible */} {/* Tags */} { /* Family Info Section * WORKAROUND: Each computed() must be the sole reactive child of its * parent element. Multiple computed() siblings break rendering. * Wrap each sectionHeader+computed pair in a
. */ }
{sectionHeader("Family Info", showFamilyInfo)} {computed(() => { if (!showFamilyInfo.get()) return null; return ( ); })}
{/* Health & Diet Section */}
{sectionHeader( "Health & Diet", showHealth, () => (member.key("dietaryRestrictions").get() || []).length + (member.key("allergies").get() || []).length, )} {computed(() => { if (!showHealth.get()) return null; return ( ); })}
{/* Gift Ideas Section */}
{sectionHeader( "Gift Ideas", showGifts, () => (member.key("giftIdeas").get() || []).length, )} {computed(() => { if (!showGifts.get()) return null; return ( ); })}
{/* Notes Section */}
{sectionHeader("Notes", showNotes)} {computed(() => { if (!showNotes.get()) return null; return ( ); })}
{/* sameAs Section - collapsed by default, only if candidates exist */}
{computed(() => { if (!hasSameAsCandidates) return null; const linkedName = sameAsDisplay; // If linked, show compact display if (linkedName) { return (
Same as: {linkedName} ×
); } // If picker is open, show autocomplete if (showPicker.get()) { return ( ); } // Collapsed: small link to expand return ( Link to another contact... ); })}
), member, }; });