Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 120 additions & 40 deletions components/ui/filter-chip.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<View style={dropdownStyles.container}>
{items.map((item, index) => {
const isSelected = item.value === selectedValue;
const isLast = index === items.length - 1;

return (
<View key={item.value}>
<Pressable
onPress={() => guardedOnSelect?.(item.value)}
style={({ pressed }) => [
dropdownStyles.item,
isSelected && dropdownStyles.itemSelected,
pressed && dropdownStyles.itemPressed,
]}
accessibilityRole="menuitem"
accessibilityState={{ selected: isSelected }}
>
<Text
style={[
dropdownStyles.itemLabel,
{ color: isSelected ? Colors.brand.text : Colors.brand.textSecondary },
]}
>
{item.label}
</Text>
</Pressable>
{!isLast && <View style={dropdownStyles.divider} />}
</View>
);
})}
</View>
<Modal transparent visible animationType="none" onRequestClose={onDismiss}>
<View style={dropdownStyles.modalRoot}>
<Pressable style={dropdownStyles.backdrop} onPress={onDismiss} />
<View style={[dropdownStyles.container, { width, maxHeight, left, top }]}>
<ScrollView
style={dropdownStyles.scroll}
nestedScrollEnabled
showsVerticalScrollIndicator={items.length > 6}
keyboardShouldPersistTaps="handled"
>
{items.map((item, index) => {
const isSelected = item.value === selectedValue;
const isLast = index === items.length - 1;

return (
<View key={item.value}>
<Pressable
onPress={() => guardedOnSelect?.(item.value)}
style={({ pressed }) => [
dropdownStyles.item,
isSelected && dropdownStyles.itemSelected,
pressed && dropdownStyles.itemPressed,
]}
accessibilityRole="menuitem"
accessibilityState={{ selected: isSelected }}
>
<Text
style={[
dropdownStyles.itemLabel,
{ color: isSelected ? Colors.brand.text : Colors.brand.textSecondary },
]}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.label}
</Text>
</Pressable>
{!isLast && <View style={dropdownStyles.divider} />}
</View>
);
})}
</ScrollView>
</View>
</View>
</Modal>
);
}

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 },
Expand All @@ -87,6 +120,9 @@ const dropdownStyles = StyleSheet.create({
elevation: 4,
overflow: 'hidden',
},
scroll: {
flexGrow: 0,
},
item: {
paddingVertical: 12,
paddingHorizontal: 16,
Expand Down Expand Up @@ -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<View>(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
Expand All @@ -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?.();
}
Expand All @@ -147,6 +208,7 @@ export function FilterChip({
return (
<View style={chipStyles.wrapper}>
<Pressable
ref={chipRef}
onPress={guardedHandlePress}
style={({ pressed }) => [
chipStyles.chip,
Expand All @@ -156,7 +218,13 @@ export function FilterChip({
accessibilityRole="button"
accessibilityState={{ disabled, expanded: isOpen }}
>
<Text style={[chipStyles.label, { color: labelColor }]}>{displayLabel}</Text>
<Text
style={[chipStyles.label, { color: labelColor }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{displayLabel}
</Text>
{icon && (
<View style={isOpen ? chipStyles.iconRotated : undefined}>
<IconSymbol
Expand All @@ -173,6 +241,11 @@ export function FilterChip({
items={items}
selectedValue={selectedValue ?? ''}
onSelect={handleSelect}
width={dropdownWidth}
maxHeight={dropdownMaxHeight}
left={dropdownLeft}
top={dropdownTop}
onDismiss={() => setIsOpen(false)}
/>
)}
</View>
Expand All @@ -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,
Expand All @@ -205,6 +283,8 @@ const chipStyles = StyleSheet.create({
},
label: {
...Typography.caption,
flexShrink: 1,
minWidth: 0,
},
iconRotated: {
transform: [{ rotate: '180deg' }],
Expand Down
Loading