src/components/DateField.tsx
Rama main · réplica de lectura del repositorio original.
// 记账日期胶囊:点开应用内自绘日历浮层卡片
// v0.11.26:头部固定展示完整选中日期;已选今天时隐藏底部无效的「今天」;与统计页共用 MonthSwitcher
// v0.11.24:从贴底全宽面板改为居中卡片——宽度、圆角、描边与记账页卡片一致,整体上移更协调
// v0.11.23:弃用系统 Material 日历弹窗(白底蓝字与暖色系界面风格割裂),改为与全站一致的暖色圆角面板
import React, { useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { COLORS, FONT_SIZE, RADIUS, SPACING } from '../constants';
import {
addMonths,
formatDate,
formatDateFull,
formatDateLabel,
getDaysInMonth,
getFirstDayOfMonth,
getMonthName,
getToday,
parseDate,
} from '../utils/dateUtils';
import MonthSwitcher from './MonthSwitcher';
interface Props {
value: string; // YYYY-MM-DD
onChange: (date: string) => void;
// 无障碍文案前缀(如「记账日期」「记录日期」)
label?: string;
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export default function DateField({ value, onChange, label = '记账日期' }: Props) {
const [show, setShow] = useState(false);
// 面板内先选、点「确定」才提交,避免滑动浏览月份时误改已保存的日期
const [pending, setPending] = useState(value);
const [viewDate, setViewDate] = useState(() => parseDate(value));
// 不允许记未来:记账是已经发生的事,未来日期会污染本月/近 7 天统计
const today = getToday();
const year = viewDate.getFullYear();
const month = viewDate.getMonth() + 1;
const now = new Date();
// 只能翻到当前月(含),未来月份整月不可选
const canGoNext = year < now.getFullYear() || (year === now.getFullYear() && month - 1 < now.getMonth());
// 固定 6 行(42 格):不同月份行数不同时面板高度不跳动
const cells = useMemo(() => {
const list: (number | null)[] = [];
const firstDay = getFirstDayOfMonth(year, month);
const days = getDaysInMonth(year, month);
for (let i = 0; i < firstDay; i++) list.push(null);
for (let d = 1; d <= days; d++) list.push(d);
while (list.length < 42) list.push(null);
return list;
}, [year, month]);
const open = () => {
setPending(value);
setViewDate(parseDate(value));
setShow(true);
};
return (
<View style={styles.wrap}>
<Pressable
style={styles.chip}
onPress={open}
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
accessibilityRole="button"
accessibilityLabel={`${label} ${formatDateLabel(value)},点击修改`}
>
<Text style={styles.icon}>📅</Text>
<Text style={styles.text}>{formatDateLabel(value)}</Text>
<Text style={styles.chevron}>▾</Text>
</Pressable>
<Modal
visible={show}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setShow(false)}
>
<View style={styles.overlay}>
{/* 点遮罩关闭,不提交未确认的选择 */}
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setShow(false)}
accessibilityRole="button"
accessibilityLabel="关闭日期选择"
/>
<View style={styles.card}>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>选择日期</Text>
<Text style={styles.sheetSub}>{formatDateFull(pending)}</Text>
</View>
{/* 月份切换:右侧到当前月为止;与统计页共用同一套箭头样式 */}
<View style={styles.monthRow}>
<MonthSwitcher
label={getMonthName(viewDate)}
onPrev={() => setViewDate(addMonths(viewDate, -1))}
onNext={() => setViewDate(addMonths(viewDate, 1))}
nextDisabled={!canGoNext}
/>
</View>
<View style={styles.weekRow}>
{WEEKDAYS.map((w) => (
<Text key={w} style={styles.weekText}>
{w}
</Text>
))}
</View>
<View style={styles.grid}>
{cells.map((day, idx) => {
if (day === null) {
return <View key={`blank-${idx}`} style={styles.cell} />;
}
const dateStr = formatDate(new Date(year, month - 1, day));
const isFuture = dateStr > today;
const isSelected = dateStr === pending;
const isToday = dateStr === today;
return (
<View key={dateStr} style={styles.cell}>
<Pressable
style={[styles.day, isToday && styles.dayToday, isSelected && styles.daySelected]}
onPress={() => setPending(dateStr)}
disabled={isFuture}
accessibilityRole="button"
accessibilityLabel={`${month}月${day}日${isToday ? ' 今天' : ''}`}
accessibilityState={{ selected: isSelected, disabled: isFuture }}
>
<Text
style={[
styles.dayText,
isToday && styles.dayTextToday,
isSelected && styles.dayTextSelected,
isFuture && styles.dayTextFuture,
]}
>
{day}
</Text>
</Pressable>
</View>
);
})}
</View>
<View style={styles.footer}>
{/* 已选今天时不再显示「今天」按钮(否则点了也没反应) */}
{pending !== today ? (
<Pressable
style={styles.todayBtn}
onPress={() => {
setPending(today);
setViewDate(new Date());
}}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="选择今天"
>
<Text style={styles.todayText}>今天</Text>
</Pressable>
) : (
<View />
)}
<View style={styles.footerActions}>
<Pressable
style={styles.cancelBtn}
onPress={() => setShow(false)}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel="取消"
>
<Text style={styles.cancelText}>取消</Text>
</Pressable>
<Pressable
style={styles.confirmBtn}
onPress={() => {
onChange(pending);
setShow(false);
}}
accessibilityRole="button"
accessibilityLabel="确定"
>
<Text style={styles.confirmText}>确定</Text>
</Pressable>
</View>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
flexDirection: 'row',
alignItems: 'center',
},
chip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.bgAlt,
borderRadius: RADIUS.pill,
paddingHorizontal: SPACING.sm + 2,
paddingVertical: SPACING.xs + 1,
gap: 4,
},
icon: {
fontSize: FONT_SIZE.xs,
},
text: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
fontWeight: '600',
},
chevron: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
fontWeight: '700',
},
// ===== 居中浮层卡片(宽度/圆角/描边与记账页卡片一致) =====
overlay: {
flex: 1,
backgroundColor: COLORS.overlay,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: SPACING.md,
},
card: {
width: '100%',
maxWidth: 420,
backgroundColor: COLORS.surface,
borderRadius: RADIUS.lg,
borderWidth: 1,
borderColor: COLORS.border,
paddingHorizontal: SPACING.md,
paddingTop: SPACING.md,
paddingBottom: SPACING.sm,
// 浮起感:暖色阴影 + Android elevation
shadowColor: COLORS.text,
shadowOpacity: 0.16,
shadowRadius: 24,
shadowOffset: { width: 0, height: 8 },
elevation: 12,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sheetTitle: {
fontSize: FONT_SIZE.lg,
fontWeight: '800',
color: COLORS.text,
},
sheetSub: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
fontWeight: '600',
},
monthRow: {
marginTop: SPACING.sm,
},
weekRow: {
flexDirection: 'row',
marginTop: SPACING.sm,
},
weekText: {
width: '14.2857%',
textAlign: 'center',
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
fontWeight: '600',
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: SPACING.xs,
},
cell: {
width: '14.2857%',
height: 42,
alignItems: 'center',
justifyContent: 'center',
},
day: {
width: 36,
height: 36,
borderRadius: RADIUS.pill,
alignItems: 'center',
justifyContent: 'center',
},
dayToday: {
borderWidth: 1.5,
borderColor: COLORS.accent,
},
daySelected: {
backgroundColor: COLORS.accent,
},
dayText: {
fontSize: FONT_SIZE.md,
color: COLORS.text,
fontWeight: '500',
},
dayTextToday: {
color: COLORS.accent,
fontWeight: '700',
},
dayTextSelected: {
color: COLORS.white,
fontWeight: '700',
},
dayTextFuture: {
color: COLORS.textTertiary,
opacity: 0.45,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: SPACING.sm,
paddingTop: SPACING.sm,
paddingBottom: SPACING.xs,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: COLORS.border,
},
todayBtn: {
backgroundColor: COLORS.bgAlt,
borderRadius: RADIUS.pill,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
},
todayText: {
fontSize: FONT_SIZE.sm,
color: COLORS.accentDark,
fontWeight: '700',
},
footerActions: {
flexDirection: 'row',
alignItems: 'center',
},
cancelBtn: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
},
cancelText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
fontWeight: '600',
},
confirmBtn: {
backgroundColor: COLORS.accent,
borderRadius: RADIUS.pill,
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.sm + 2,
marginLeft: SPACING.xs,
},
confirmText: {
fontSize: FONT_SIZE.md,
color: COLORS.white,
fontWeight: '700',
},
});