feat(AmharicDatePicker): implement Amharic date picker component with calendar type switching and time input

This commit is contained in:
estifanos
2026-08-05 10:14:03 +00:00
parent 5fb1926ac0
commit 7ba760d74b
11 changed files with 89 additions and 37 deletions

View File

@@ -1,4 +1,5 @@
export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";

View File

@@ -0,0 +1,72 @@
/* Restyle the react-day-picker month/year dropdown caption to match Mantine
inputs — the library ships it as bare text + an invisible <select>, with
no border/box affordance and a hardcoded blue chevron. */
.amharic-daypicker {
--rdp-accent-color: var(--mantine-primary-color-filled);
--rdp-nav_button-width: 2.75rem;
--rdp-nav_button-height: 2.75rem;
}
.amharic-daypicker .rdp-dropdowns {
gap: 0.375rem;
}
.amharic-daypicker .rdp-dropdown_root {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-body);
padding: 0.25rem 0.5rem;
}
.amharic-daypicker .rdp-dropdown_root:hover {
background-color: var(--mantine-color-default-hover);
}
.amharic-daypicker .rdp-caption_label {
gap: 0.25rem;
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: var(--mantine-color-text);
}
.amharic-daypicker .rdp-dropdown_root .rdp-chevron {
width: 12px;
height: 12px;
fill: var(--mantine-color-dimmed);
}
/* Prev/next month buttons: bigger tap target + visible button box (border,
background, accent-colored chevron) instead of the default bare, tiny
blue arrow — the dropdown captions replaced them as the primary nav, so
they need to stay easy to spot and hit. */
.amharic-daypicker .rdp-button_previous,
.amharic-daypicker .rdp-button_next {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-body);
}
.amharic-daypicker .rdp-button_previous:not([aria-disabled='true']):hover,
.amharic-daypicker .rdp-button_next:not([aria-disabled='true']):hover {
background-color: var(--mantine-color-default-hover);
}
.amharic-daypicker .rdp-nav .rdp-chevron {
width: 20px;
height: 20px;
fill: var(--mantine-primary-color-filled);
}
.amharic-daypicker .rdp-month_caption {
font-size: inherit;
font-weight: 600;
}
/* Day cells are already circular (--rdp-day_button-border-radius: 100%);
the library only outlines the selected one by default — fill it instead
so the selection reads as a solid, unambiguous mark. */
.amharic-daypicker .rdp-selected .rdp-day_button {
background-color: var(--mantine-primary-color-filled);
border-color: var(--mantine-primary-color-filled);
color: var(--mantine-color-white);
}

View File

@@ -0,0 +1,340 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Button,
Group,
MantineSize,
Popover,
TextInput,
} from '@mantine/core';
import { TimeInput } from '@mantine/dates';
import { useDisclosure } from '@mantine/hooks';
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
import { IconCalendarEvent } from '@tabler/icons-react';
import { EthDateTime } from 'ethiopian-calendar-date-converter';
import '@daypicker/react/dist/style.css';
import './AmharicDatePicker.css';
const EC_MONTHS_AM = [
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
];
// EthDateTime.fromEuropeanDate() computes the day from a raw UTC-epoch
// difference. A local-midnight Date in any positive-UTC-offset timezone
// (e.g. Ethiopia, UTC+3) lands in the previous UTC day and converts to
// yesterday's Ethiopian date. Re-embedding the same Y/M/D at UTC noon fixes
// the day regardless of the runtime's timezone.
function toEthDateTime(date: Date): EthDateTime {
const utcNoon = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12),
);
return EthDateTime.fromEuropeanDate(utcNoon);
}
function ethMonthName(date: Date): string {
try {
return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? '';
} catch {
return '';
}
}
function toAmharicDisplay(date: Date): string {
try {
const eth = toEthDateTime(date);
return `${ethMonthName(date)} ${eth.date}/${eth.year}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
// react-day-picker calls these with the Gregorian Date it tracks internally;
// override so the caption/dropdown show Amharic month names instead of the
// library's Latin transliteration (triggered by numerals="latn" below).
const ETH_FORMATTERS = {
formatCaption: (month: Date) =>
`${ethMonthName(month)} ${toEthDateTime(month).year}`,
formatMonthDropdown: (month: Date) => ethMonthName(month),
};
type DateWireFormat = 'iso' | 'date';
// yyyy-MM-dd, parsed/formatted as a LOCAL calendar date — no Date-object/UTC
// round-trip at all, so it can't suffer the timezone off-by-one class of bug
// the ISO path below works around. This is the shape filter query params and
// plain `date: string` DTO fields expect.
function parsePlainDate(value: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
function formatPlainDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// ISO wire format is a full ISO-8601 instant string (e.g.
// "2026-07-31T00:00:00.000Z"), what most backend date fields expect. When
// `withTime` is off, the picker only selects a calendar day, so the time is
// pinned to UTC midnight — reading back with getUTC*() (not local get*())
// keeps the calendar day stable regardless of the runtime's timezone,
// avoiding the same off-by-one class of bug the UTC-noon workaround above
// exists for.
function parseWireValue(
value: string | null | undefined,
format: DateWireFormat,
withTime: boolean,
): Date | null {
if (!value) return null;
if (format === 'date') return parsePlainDate(value);
const d = new Date(value);
if (isNaN(d.getTime())) return null;
return withTime
? d
: new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function formatWireValue(
date: Date,
format: DateWireFormat,
withTime: boolean,
): string {
if (format === 'date') return formatPlainDate(date);
return withTime
? date.toISOString()
: new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
).toISOString();
}
// Combines a calendar day with a time-of-day, keeping whichever half isn't
// changing. `base` is the currently selected Date (may be null if nothing
// picked yet); `day`/`time` override only the half that's provided.
function mergeDateTime(
base: Date | null,
day?: Date,
time?: { hours: number; minutes: number },
): Date {
const result = day ? new Date(day) : new Date(base ?? new Date());
if (time) {
result.setHours(time.hours, time.minutes, 0, 0);
} else if (day && base) {
result.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0);
}
return result;
}
// Accepts either wire shape for display-only formatting — tries the plain
// yyyy-MM-dd shape first, falls back to ISO — so these keep working
// regardless of which `dateFormat` produced the stored string.
function parseAnyDateString(value: string): Date | null {
return parsePlainDate(value) ?? parseWireValue(value, 'iso', false);
}
export function toEthiopicDateLabel(date: Date | string): string {
const d = typeof date === 'string' ? parseAnyDateString(date) : date;
if (!d) return '';
try {
const eth = toEthDateTime(d);
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
} catch {
return d.toLocaleDateString('en-US');
}
}
export function toGregorianDateLabel(date: Date | string): string {
const d = typeof date === 'string' ? parseAnyDateString(date) : date;
if (!d) return '';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
const YEAR_DROPDOWN_END = new Date(new Date().getFullYear() + 50, 11, 31);
export interface AmharicDatePickerProps {
label?: React.ReactNode;
value?: string | null;
onChange?: (value: string) => void;
required?: boolean;
placeholder?: string;
error?: React.ReactNode;
disabled?: boolean;
size?: MantineSize;
name?: string;
onBlur?: () => void;
/** Show a time-of-day field alongside the calendar. Off by default —
* most callers only need a calendar day. */
withTime?: boolean;
/** Wire format for `value`/`onChange`: a full ISO-8601 instant (default,
* what most backend date fields expect) or a bare `yyyy-mm-dd` calendar
* date (what filter query params and plain `date: string` DTO fields
* expect). */
dateFormat?: DateWireFormat;
}
export function AmharicDatePicker({
label,
value,
onChange,
required,
placeholder,
error,
disabled,
size,
name,
onBlur,
withTime = false,
dateFormat = 'iso',
}: AmharicDatePickerProps) {
const { t, i18n } = useTranslation();
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() =>
i18n.language?.startsWith('am') ? 'AMH' : 'EN',
);
const [opened, { close, toggle }] = useDisclosure(false);
const selected = parseWireValue(value, dateFormat, withTime);
const dateLabel = selected
? calendarType === 'EN'
? selected.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: toAmharicDisplay(selected)
: '';
const timeLabel =
withTime && selected
? ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
: '';
const displayValue = dateLabel + timeLabel;
return (
<Popover
opened={opened}
onChange={close}
position="bottom"
width="auto"
trapFocus
withArrow
>
<Popover.Target>
<TextInput
label={label}
required={required}
value={displayValue}
readOnly
disabled={disabled}
size={size}
name={name}
error={error}
placeholder={placeholder}
onClick={() => !disabled && toggle()}
onBlur={onBlur}
leftSection={
<Button
type="button"
variant="light"
size="compact-xs"
tabIndex={0}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
}}
aria-label={t('common.switchCalendar')}
>
{calendarType}
</Button>
}
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
rightSection={
<ActionIcon size="md" variant="transparent" disabled={disabled} onClick={() => toggle()}>
<IconCalendarEvent size={20} />
</ActionIcon>
}
/>
</Popover.Target>
<Popover.Dropdown p="md">
{calendarType === 'AMH' ? (
<EthiopicDayPicker
className="amharic-daypicker"
mode="single"
selected={selected ?? undefined}
defaultMonth={selected ?? undefined}
endMonth={YEAR_DROPDOWN_END}
numerals="latn"
captionLayout="dropdown"
formatters={ETH_FORMATTERS}
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
close();
}}
/>
) : (
<GregorianDayPicker
className="amharic-daypicker"
mode="single"
selected={selected ?? undefined}
defaultMonth={selected ?? undefined}
endMonth={YEAR_DROPDOWN_END}
captionLayout="dropdown"
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
close();
}}
/>
)}
{withTime && (
<TimeInput
label={t('common.time')}
mt="sm"
disabled={!selected}
value={
selected
? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
: ''
}
onChange={(e) => {
const [h, m] = e.currentTarget.value.split(':').map(Number);
if (Number.isNaN(h) || Number.isNaN(m)) return;
onChange?.(
formatWireValue(mergeDateTime(selected, undefined, { hours: h, minutes: m }), dateFormat, true),
);
}}
/>
)}
<Group justify="space-between" mt="sm">
<Button
variant="subtle"
size="xs"
onClick={() => {
onChange?.('');
close();
}}
>
{calendarType === 'AMH' ? 'አጽዳ' : 'Clear'}
</Button>
<Button
variant="light"
size="xs"
onClick={() => {
onChange?.(formatWireValue(new Date(), dateFormat, withTime));
close();
}}
>
{calendarType === 'AMH' ? 'ዛሬ' : 'Today'}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}