Skip to content
Merged
57 changes: 47 additions & 10 deletions src/components/features/condition/condition-quadrant-plot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { useConditionQuadrantInteraction } from '@/hooks/use-condition-quadrant-

export type { QuadrantPoint } from '@/domains/condition/quadrant';

export interface ConditionHistoryListItem {
id: number;
label: string;
}

interface ConditionQuadrantPlotProps {
points?: QuadrantPoint[];
/** 입력 모드에서 선택한 점입니다. */
Expand All @@ -26,8 +31,12 @@ interface ConditionQuadrantPlotProps {
onMarkerPress?: (point: QuadrantPoint) => void;
/** 활성 단일 마커 아래에 표시할 기록 시각입니다. */
activeMarkerTime?: string | null;
/** 겹친 활성 마커 옆에 표시할 오름차순 기록 시각입니다. */
activeMarkerTimes?: string[];
/** 겹친 활성 마커 옆에 표시할 오름차순 기록 목록입니다. */
activeMarkerRecords?: ConditionHistoryListItem[];
/** 겹친 마커의 기록 목록에서 항목을 눌렀을 때 호출합니다. */
onHistoryRecordPress?: (recordId: number) => void;
/** 겹친 마커의 기록 목록에서 선택된 기록 id입니다. */
selectedHistoryRecordId?: number | null;
/** 마커·목록 밖을 눌렀을 때 선택을 해제합니다. */
onBackgroundPress?: () => void;
/** 기록이 없을 때 중앙 원점 마커를 보여줍니다. */
Expand All @@ -47,7 +56,9 @@ export function ConditionQuadrantPlot({
onSelect,
onMarkerPress,
activeMarkerTime = null,
activeMarkerTimes = [],
activeMarkerRecords = [],
onHistoryRecordPress,
selectedHistoryRecordId = null,
onBackgroundPress,
showOrigin = true,
}: ConditionQuadrantPlotProps) {
Expand Down Expand Up @@ -231,16 +242,33 @@ export function ConditionQuadrantPlot({
{activeMarkerTime}
</Typography>
) : null}
{active && badge && activeMarkerTimes.length > 0 ? (
{active && badge && activeMarkerRecords.length > 0 ? (
<View
style={[styles.historyList, toConditionHistoryListPosition(point.x, point.y)]}
>
{activeMarkerTimes.map((time, index) => (
<View key={`${time}-${index}`}>
{activeMarkerRecords.map((record, index) => (
<View key={record.id}>
{index > 0 ? <View style={styles.historyDivider} /> : null}
<Typography variant="caption" align="center" color={colors.gray[700]}>
{time}
</Typography>
<Pressable
accessibilityLabel={`${record.label} 기록 선택`}
accessibilityRole="button"
accessibilityState={{ selected: selectedHistoryRecordId === record.id }}
hitSlop={spacing[1]}
style={[
styles.historyRecord,
selectedHistoryRecordId === record.id && styles.historyRecordSelected,
]}
onPress={() => onHistoryRecordPress?.(record.id)}
>
<Typography
variant="caption"
align="center"
color={colors.gray[700]}
numberOfLines={1}
>
{record.label}
</Typography>
</Pressable>
</View>
))}
</View>
Expand Down Expand Up @@ -363,7 +391,7 @@ const styles = StyleSheet.create({
historyList: {
position: 'absolute',
zIndex: 1,
minWidth: 60,
width: spacing[16],
gap: spacing[1],
paddingHorizontal: spacing[3],
paddingVertical: spacing[2],
Expand All @@ -377,6 +405,15 @@ const styles = StyleSheet.create({
marginVertical: spacing[1],
backgroundColor: colors.gray[300],
},
historyRecord: {
alignSelf: 'stretch',
paddingHorizontal: spacing[1],
paddingVertical: spacing.px,
borderRadius: radius.sm,
},
historyRecordSelected: {
backgroundColor: colors.gray.white,
},
valueDot: {
position: 'absolute',
width: VALUE_SIZE,
Expand Down
205 changes: 23 additions & 182 deletions src/components/features/create-card/location-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,30 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, TextInput, View } from 'react-native';

import { BottomSheet } from '@/components/ui/BottomSheet';
import { Icon } from '@/components/ui/Icon';
import { Typography } from '@/components/ui/Typography';
import { colors, radius, spacing, typography } from '@/constants/theme';
import {
getLocationSuggestions,
getVisibleLocationRecentSearches,
isValidLocationSearchLabel,
LOCATION_SEARCH_MAX_LENGTH,
normalizeLocationSearchLabel,
} from '@/domains/schedule/location';

const LOCATION_TEXT_MAX_LENGTH = 200;

interface LocationBottomSheetProps {
visible: boolean;
recentSearches?: string[];
onClose: () => void;
onSelect: (location: string) => void;
onDeleteSearch?: (location: string) => void;
onDeleteAllSearches?: () => void;
}

export function LocationSheet({
visible,
recentSearches = [],
onClose,
onSelect,
onDeleteSearch,
onDeleteAllSearches,
}: LocationBottomSheetProps) {
export function LocationSheet({ visible, onClose, onSelect }: LocationBottomSheetProps) {
const searchInputRef = useRef<TextInput>(null);
const [query, setQuery] = useState('');
const visibleRecentSearches = useMemo(
() => getVisibleLocationRecentSearches(recentSearches),
[recentSearches],
);
const suggestions = useMemo(() => getLocationSuggestions(query), [query]);
const isSearching = query.length > 0;
const canDone = isValidLocationSearchLabel(query);
const [location, setLocation] = useState('');
const canDone = location.trim().length > 0;

const handleSheetShow = useCallback(() => {
searchInputRef.current?.focus();
}, []);

useEffect(() => {
if (!visible) {
setQuery('');
setLocation('');
}
}, [visible]);

Expand All @@ -55,11 +33,7 @@ export function LocationSheet({
return;
}

onSelect(normalizeLocationSearchLabel(query));
};

const handleSelect = (location: string) => {
onSelect(normalizeLocationSearchLabel(location));
onSelect(location.trim());
};

return (
Expand Down Expand Up @@ -107,102 +81,19 @@ export function LocationSheet({
</View>

<View style={styles.card}>
<View style={styles.searchBar}>
<Icon name="search" size={24} color={colors.gray[400]} />
<TextInput
ref={searchInputRef}
accessibilityLabel="장소 또는 주소 입력"
value={query}
placeholder="장소, 주소 입력"
placeholderTextColor={colors.gray[400]}
returnKeyType="search"
maxLength={LOCATION_SEARCH_MAX_LENGTH}
autoCorrect={false}
autoCapitalize="none"
style={styles.searchInput}
onChangeText={setQuery}
onSubmitEditing={handleDone}
/>
</View>

{isSearching ? (
<View style={styles.suggestionList}>
{suggestions.map((item) => (
<Pressable
key={item}
accessibilityLabel={`${item} 선택`}
accessibilityRole="button"
style={({ pressed }) => [styles.suggestionItem, pressed && styles.pressed]}
onPress={() => handleSelect(item)}
>
<Typography variant="bodyS" color={colors.gray[600]} numberOfLines={1}>
{item}
</Typography>
</Pressable>
))}
</View>
) : (
<View style={styles.recentSection}>
<View style={styles.recentHeader}>
<Typography variant="bodyM" color={colors.gray[900]}>
최근 검색어
</Typography>
<Pressable
accessibilityLabel="최근 검색어 전체 삭제"
accessibilityRole="button"
hitSlop={8}
style={({ pressed }) => [pressed && styles.pressed]}
onPress={onDeleteAllSearches}
>
<Typography variant="bodyS" color={colors.gray[400]} align="center">
전체삭제
</Typography>
</Pressable>
</View>

{visibleRecentSearches.length > 0 ? (
<View style={styles.searchList}>
{visibleRecentSearches.map((item) => (
<View key={item} style={styles.searchItem}>
<Pressable
accessibilityLabel={`${item} 선택`}
accessibilityRole="button"
style={({ pressed }) => [
styles.searchItemPressable,
pressed && styles.pressed,
]}
onPress={() => handleSelect(item)}
>
<Typography
variant="bodyS"
color={colors.gray[600]}
numberOfLines={1}
style={styles.searchItemText}
>
{item}
</Typography>
</Pressable>
<Pressable
accessibilityLabel={`${item} 검색어 삭제`}
accessibilityRole="button"
hitSlop={8}
style={({ pressed }) => pressed && styles.pressed}
onPress={() => onDeleteSearch?.(item)}
>
<Icon name="cancel" size={16} color={colors.gray[400]} />
</Pressable>
</View>
))}
</View>
) : (
<View style={styles.emptyHistory}>
<Typography variant="bodyM" color={colors.gray[400]} align="center">
최근 검색 내역이 없습니다
</Typography>
</View>
)}
</View>
)}
<TextInput
ref={searchInputRef}
accessibilityLabel="위치 입력"
value={location}
placeholder="위치를 입력해주세요"
placeholderTextColor={colors.gray[400]}
returnKeyType="done"
maxLength={LOCATION_TEXT_MAX_LENGTH}
autoCorrect={false}
style={styles.locationInput}
onChangeText={setLocation}
onSubmitEditing={handleDone}
/>
</View>
</BottomSheet>
);
Expand Down Expand Up @@ -239,16 +130,12 @@ const styles = StyleSheet.create({
},
card: {
width: '100%',
gap: spacing[4],
padding: spacing[3],
paddingBottom: spacing[5],
borderRadius: radius.panel,
backgroundColor: colors.alpha.white50,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[3],
locationInput: {
...typography.bodyS,
width: '100%',
maxWidth: 329,
height: spacing[10],
Expand All @@ -257,54 +144,8 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing[3],
borderRadius: radius.md,
backgroundColor: colors.alpha.white50,
},
searchInput: {
...typography.bodyS,
flex: 1,
minWidth: 0,
paddingVertical: 0,
color: colors.gray[800],
},
suggestionList: {
alignSelf: 'stretch',
gap: spacing[1],
paddingHorizontal: spacing[1],
},
suggestionItem: {
alignSelf: 'flex-start',
maxWidth: '100%',
},
recentSection: {
alignSelf: 'stretch',
gap: spacing[2],
paddingHorizontal: spacing[1],
},
recentHeader: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
},
searchList: {
gap: spacing[1],
},
searchItem: {
minHeight: spacing[7],
flexDirection: 'row',
alignItems: 'center',
gap: spacing[1],
},
searchItemPressable: {
flex: 1,
},
searchItemText: {
flex: 1,
},
emptyHistory: {
alignSelf: 'stretch',
paddingVertical: spacing[6],
alignItems: 'center',
justifyContent: 'center',
},
pressed: {
opacity: 0.72,
},
Expand Down
Loading
Loading