diff --git a/components/ui/filter-chip.tsx b/components/ui/filter-chip.tsx index a348b6e..ac45e29 100644 --- a/components/ui/filter-chip.tsx +++ b/components/ui/filter-chip.tsx @@ -1,9 +1,20 @@ -import { useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useRef, useState } from 'react'; +import { Modal, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; +const EXPANDED_LABEL_THRESHOLD = 6; +const MAX_VISIBLE_LABEL_CHARS = 15; +const LABEL_MAX_WIDTH = Typography.caption.fontSize * MAX_VISIBLE_LABEL_CHARS; +const DROPDOWN_MIN_WIDTH = 120; +const DROPDOWN_HORIZONTAL_PADDING = 32; +const DROPDOWN_MAX_CONTENT_WIDTH = LABEL_MAX_WIDTH + DROPDOWN_HORIZONTAL_PADDING; +const DROPDOWN_MAX_HEIGHT_RATIO = 0.6; +const DROPDOWN_SCREEN_PADDING = 20; +const DROPDOWN_MIN_HEIGHT = 120; + // ─── Types ──────────────────────────────────────────────────────────────────── export type FilterChipVariant = 'active' | 'inactive'; @@ -30,55 +41,77 @@ interface DropdownProps { items: FilterChipItem[]; selectedValue: string; onSelect: (value: string) => void; + width: number; + maxHeight: number; + left: number; + top: number; + onDismiss: () => void; } -function Dropdown({ items, selectedValue, onSelect }: DropdownProps) { +function Dropdown({ items, selectedValue, onSelect, width, maxHeight, left, top, onDismiss }: DropdownProps) { const guardedOnSelect = useGuardedPress(onSelect, { lockMs: 250 }); return ( - - {items.map((item, index) => { - const isSelected = item.value === selectedValue; - const isLast = index === items.length - 1; - - return ( - - guardedOnSelect?.(item.value)} - style={({ pressed }) => [ - dropdownStyles.item, - isSelected && dropdownStyles.itemSelected, - pressed && dropdownStyles.itemPressed, - ]} - accessibilityRole="menuitem" - accessibilityState={{ selected: isSelected }} - > - - {item.label} - - - {!isLast && } - - ); - })} - + + + + + 6} + keyboardShouldPersistTaps="handled" + > + {items.map((item, index) => { + const isSelected = item.value === selectedValue; + const isLast = index === items.length - 1; + + return ( + + guardedOnSelect?.(item.value)} + style={({ pressed }) => [ + dropdownStyles.item, + isSelected && dropdownStyles.itemSelected, + pressed && dropdownStyles.itemPressed, + ]} + accessibilityRole="menuitem" + accessibilityState={{ selected: isSelected }} + > + + {item.label} + + + {!isLast && } + + ); + })} + + + + ); } const dropdownStyles = StyleSheet.create({ + modalRoot: { + flex: 1, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + }, container: { position: 'absolute', - top: '100%', - left: 0, - marginTop: 4, backgroundColor: Colors.brand.surface, borderRadius: 10, - minWidth: 120, + minWidth: DROPDOWN_MIN_WIDTH, zIndex: 100, shadowColor: Colors.brand.text, shadowOffset: { width: 0, height: 2 }, @@ -87,6 +120,9 @@ const dropdownStyles = StyleSheet.create({ elevation: 4, overflow: 'hidden', }, + scroll: { + flexGrow: 0, + }, item: { paddingVertical: 12, paddingHorizontal: 16, @@ -120,9 +156,27 @@ export function FilterChip({ onPress, }: FilterChipProps) { const [isOpen, setIsOpen] = useState(false); + const [chipBounds, setChipBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }); + const chipRef = useRef(null); + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); const currentItem = items.find((i) => i.value === selectedValue); const displayLabel = label ?? currentItem?.label ?? ''; + const hasLongDropdownItem = items.some((item) => item.label.length > EXPANDED_LABEL_THRESHOLD); + const dropdownWidth = Math.min( + hasLongDropdownItem ? DROPDOWN_MAX_CONTENT_WIDTH : DROPDOWN_MIN_WIDTH, + Math.max(DROPDOWN_MIN_WIDTH, width - 40), + ); + const dropdownLeft = Math.max( + DROPDOWN_SCREEN_PADDING, + Math.min(chipBounds.x, width - dropdownWidth - DROPDOWN_SCREEN_PADDING), + ); + const dropdownTop = chipBounds.y + chipBounds.height + insets.top + 4; + const dropdownMaxHeight = Math.max( + DROPDOWN_MIN_HEIGHT, + Math.min(height * DROPDOWN_MAX_HEIGHT_RATIO, height - dropdownTop - DROPDOWN_SCREEN_PADDING), + ); const isActive = variant === 'active'; const labelColor = disabled @@ -132,7 +186,14 @@ export function FilterChip({ function handlePress() { if (disabled) return; if (isActive && items.length > 0) { - setIsOpen((prev) => !prev); + if (isOpen) { + setIsOpen(false); + } else { + chipRef.current?.measureInWindow((x, y, measuredWidth, measuredHeight) => { + setChipBounds({ x, y, width: measuredWidth, height: measuredHeight }); + setIsOpen(true); + }); + } } onPress?.(); } @@ -147,6 +208,7 @@ export function FilterChip({ return ( [ chipStyles.chip, @@ -156,7 +218,13 @@ export function FilterChip({ accessibilityRole="button" accessibilityState={{ disabled, expanded: isOpen }} > - {displayLabel} + + {displayLabel} + {icon && ( setIsOpen(false)} /> )} @@ -183,12 +256,17 @@ const chipStyles = StyleSheet.create({ wrapper: { position: 'relative', alignSelf: 'flex-start', + flexShrink: 1, + minWidth: 0, + maxWidth: '100%', zIndex: 10, }, chip: { flexDirection: 'row', alignItems: 'center', + flexShrink: 1, gap: 4, + maxWidth: '100%', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 100, @@ -205,6 +283,8 @@ const chipStyles = StyleSheet.create({ }, label: { ...Typography.caption, + flexShrink: 1, + minWidth: 0, }, iconRotated: { transform: [{ rotate: '180deg' }],