import React, { useMemo, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import { MutedText, Text } from "./text"; import { SearchInput } from "./input"; export interface TagOption { id: string; name: string; /** Accent color used for the chip border + icon (defaults to muted). */ color?: string | null; } export interface TagMultiSelectProps { tags: T[]; selectedIds: string[]; onToggle: (tagId: string) => void; /** Resolves the Ionicons glyph for a tag. Defaults to `pricetag`. */ iconFor?: (tag: T) => keyof typeof Ionicons.glyphMap; /** Pixel cap on the scrollable list height. Defaults to 240. */ listMaxHeight?: number; } /** * Searchable multi-select picker for tags / labels. Designed for lists * that can be in the thousands — the list scrolls, fuzzy filters by * name, and selected items appear as removable chips above the search. * * Generic over the tag shape; pass `iconFor` to map your tag's icon * value onto an Ionicons glyph. */ export function TagMultiSelect({ tags, selectedIds, onToggle, iconFor, listMaxHeight = 240, }: TagMultiSelectProps) { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const mutedColor = isDark ? "#A3A3A3" : "#737373"; const [query, setQuery] = useState(""); const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedTags = useMemo( () => selectedIds.map((id) => tags.find((t) => t.id === id)).filter((t): t is T => !!t), [selectedIds, tags] ); const filteredTags = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) { return tags; } return tags.filter((t) => t.name.toLowerCase().includes(q)); }, [query, tags]); const resolveIcon = (tag: T): keyof typeof Ionicons.glyphMap => iconFor ? iconFor(tag) : "pricetag"; return ( {selectedTags.length > 0 && ( {selectedTags.map((tag) => { const accent = tag.color ?? mutedColor; return ( onToggle(tag.id)} className="flex-row items-center gap-1 rounded-md border px-2 py-1 active:opacity-70" style={{ borderColor: `${accent}55` }} accessibilityLabel={`Remove tag ${tag.name}`} accessibilityRole="button" > {tag.name} ); })} )} {filteredTags.length === 0 ? ( {query ? `No tags match "${query}"` : "No tags yet"} ) : ( {filteredTags.map((tag) => { const selected = selectedSet.has(tag.id); const accent = tag.color ?? mutedColor; const selectedColor = isDark ? "#FFFFFF" : "#000000"; const checkboxColor = selected ? selectedColor : mutedColor; return ( onToggle(tag.id)} accessibilityLabel={`${selected ? "Deselect" : "Select"} tag ${tag.name}`} accessibilityRole="checkbox" accessibilityState={{ checked: selected }} className="flex-row items-center gap-2 px-3 py-2 active:bg-neutral-100 dark:active:bg-neutral-800" > {tag.name} ); })} )} ); }