mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(ui): тёмный DateField на react-day-picker вместо нативного input
Нативный <input type="date"> выглядел чужеродно (браузерный светлый календарь). Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля, локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт 'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода статистики (свой период). Остальные места — следующими шагами.
This commit is contained in:
106
src/components/DateField.tsx
Normal file
106
src/components/DateField.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { enUS, faIR, ru, zhCN } from 'date-fns/locale';
|
||||
import { useState } from 'react';
|
||||
import { DayPicker, type Matcher } from 'react-day-picker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
|
||||
|
||||
import 'react-day-picker/style.css';
|
||||
|
||||
interface DateFieldProps {
|
||||
/** Selected date as 'YYYY-MM-DD', or '' when empty. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Inclusive bounds as 'YYYY-MM-DD'. */
|
||||
min?: string;
|
||||
max?: string;
|
||||
/** Override the trigger button classes. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LOCALES = { ru, en: enUS, zh: zhCN, fa: faIR } as const;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseISO = (s: string): Date | undefined => {
|
||||
if (!s) return undefined;
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
if (!y || !m || !d) return undefined;
|
||||
return new Date(y, m - 1, d);
|
||||
};
|
||||
|
||||
function NavChevron({ orientation }: { orientation?: 'left' | 'right' | 'up' | 'down' }) {
|
||||
return orientation === 'right' ? (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark-themed date field — Radix Popover trigger + react-day-picker calendar.
|
||||
* Drop-in replacement for `<input type="date">`: takes/returns 'YYYY-MM-DD'.
|
||||
* Month/weekday names follow the UI language (date-fns locale), navigation uses
|
||||
* the shared barrel chevrons, theming via the .rdp-dark variable overrides.
|
||||
*/
|
||||
export function DateField({ value, onChange, placeholder, min, max, className }: DateFieldProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = parseISO(value);
|
||||
const locale = LOCALES[i18n.language as keyof typeof LOCALES] ?? enUS;
|
||||
const display = selected
|
||||
? selected.toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '';
|
||||
|
||||
const disabled: Matcher[] = [];
|
||||
const minDate = parseISO(min ?? '');
|
||||
const maxDate = parseISO(max ?? '');
|
||||
if (minDate) disabled.push({ before: minDate });
|
||||
if (maxDate) disabled.push({ after: maxDate });
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
className ??
|
||||
'flex items-center gap-2 rounded-lg border border-dark-600 bg-dark-800 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:border-dark-500'
|
||||
}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4 shrink-0 text-dark-500" />
|
||||
<span className={selected ? '' : 'text-dark-500'}>{display || placeholder}</span>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="rdp-dark z-50 rounded-xl border border-dark-700 bg-dark-800 p-2 shadow-xl"
|
||||
>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
locale={locale}
|
||||
selected={selected}
|
||||
defaultMonth={selected}
|
||||
disabled={disabled}
|
||||
components={{ Chevron: NavChevron }}
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
onChange(toISO(d));
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { getMonthToDateRange, isMonthToDate } from '../../utils/period';
|
||||
import { DateField } from '../DateField';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: { days?: number; startDate?: string; endDate?: string };
|
||||
@@ -68,18 +69,18 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
|
||||
{showCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
<DateField
|
||||
value={value.startDate || ''}
|
||||
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
onChange={(v) => handleDateChange('startDate', v)}
|
||||
max={value.endDate}
|
||||
placeholder={t('admin.salesStats.period.from')}
|
||||
/>
|
||||
<span className="text-dark-500">{'—'}</span>
|
||||
<input
|
||||
type="date"
|
||||
<DateField
|
||||
value={value.endDate || ''}
|
||||
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
onChange={(v) => handleDateChange('endDate', v)}
|
||||
min={value.startDate}
|
||||
placeholder={t('admin.salesStats.period.to')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1283,6 +1283,8 @@
|
||||
"subtitle": "Revenue, trials, subscriptions, and renewals",
|
||||
"period": {
|
||||
"thisMonth": "This month",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"week": "7 days",
|
||||
"month": "30 days",
|
||||
"quarter": "90 days",
|
||||
|
||||
@@ -1156,6 +1156,8 @@
|
||||
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
|
||||
"period": {
|
||||
"thisMonth": "این ماه",
|
||||
"from": "از",
|
||||
"to": "تا",
|
||||
"week": "۷ روز",
|
||||
"month": "۳۰ روز",
|
||||
"quarter": "۹۰ روز",
|
||||
|
||||
@@ -1305,6 +1305,8 @@
|
||||
"subtitle": "Доход, триалы, подписки и продления",
|
||||
"period": {
|
||||
"thisMonth": "Этот месяц",
|
||||
"from": "Начало",
|
||||
"to": "Конец",
|
||||
"week": "7 дней",
|
||||
"month": "30 дней",
|
||||
"quarter": "90 дней",
|
||||
|
||||
@@ -1156,6 +1156,8 @@
|
||||
"subtitle": "收入、试用、订阅和续订",
|
||||
"period": {
|
||||
"thisMonth": "本月",
|
||||
"from": "从",
|
||||
"to": "到",
|
||||
"week": "7天",
|
||||
"month": "30天",
|
||||
"quarter": "90天",
|
||||
|
||||
@@ -1392,6 +1392,44 @@ input[type='checkbox']:hover:not(:checked) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* react-day-picker (DateField) — dark theme via the library's CSS variables. */
|
||||
.rdp-dark {
|
||||
--rdp-accent-color: rgb(var(--color-accent-500));
|
||||
--rdp-accent-background-color: rgb(var(--color-accent-500) / 0.18);
|
||||
--rdp-today-color: rgb(var(--color-accent-400));
|
||||
--rdp-day-height: 2.25rem;
|
||||
--rdp-day-width: 2.25rem;
|
||||
--rdp-outside-opacity: 0.35;
|
||||
--rdp-disabled-opacity: 0.3;
|
||||
--rdp-selected-border: none;
|
||||
color: rgb(var(--color-dark-200));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.rdp-dark .rdp-month_caption,
|
||||
.rdp-dark .rdp-caption_label {
|
||||
color: rgb(var(--color-dark-100));
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.rdp-dark .rdp-weekday {
|
||||
color: rgb(var(--color-dark-500));
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.rdp-dark .rdp-day_button {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.rdp-dark .rdp-day_button:hover:not([disabled]) {
|
||||
background-color: rgb(var(--color-dark-700));
|
||||
}
|
||||
.rdp-dark .rdp-selected .rdp-day_button {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rdp-dark .rdp-chevron {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.light input[type='checkbox'] {
|
||||
border-color: rgb(var(--color-champagne-400));
|
||||
background-color: rgb(var(--color-champagne-100));
|
||||
|
||||
Reference in New Issue
Block a user