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' }],