mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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_START = new Date(new Date().getFullYear() - 100, 0, 1);
|
|
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;
|
|
/** Width of the input, same as Mantine's `w` on TextInput/Select — needed
|
|
* to line this field up with siblings in a filter bar. */
|
|
w?: number | string;
|
|
/** 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,
|
|
w,
|
|
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-start"
|
|
width="auto"
|
|
trapFocus
|
|
withArrow
|
|
withinPortal
|
|
// The field lives inside a Modal's scrollable body while the dropdown is
|
|
// portaled to <body> — a different scroll container than its reference.
|
|
// The default `absolute` strategy sums offsets across that scroll chain
|
|
// and can get it wrong (dropdown flipped off-screen, or not following
|
|
// the field as the modal scrolls). `fixed` positions purely off the
|
|
// reference's viewport rect, sidestepping that.
|
|
floatingStrategy="fixed"
|
|
// Prevents the dropdown from re-flipping position mid-interaction —
|
|
// switching months resizes the grid (Pagume has far fewer days), which
|
|
// otherwise nudges the floating box right as a nav click lands, making
|
|
// the click miss.
|
|
preventPositionChangeWhenVisible
|
|
>
|
|
<Popover.Target>
|
|
<TextInput
|
|
label={label}
|
|
required={required}
|
|
value={displayValue}
|
|
readOnly
|
|
disabled={disabled}
|
|
size={size}
|
|
name={name}
|
|
w={w}
|
|
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>
|
|
|
|
{/* Inside a Modal there may not be room below the field for the full
|
|
calendar + time input + action row — scroll the dropdown itself
|
|
rather than relying on the page (which doesn't scroll while a
|
|
Modal is open) or the Modal body (the dropdown is portaled out of
|
|
it, so its scroll never reaches this). */}
|
|
<Popover.Dropdown p="md" mah="70dvh" style={{ overflowY: 'auto' }}>
|
|
{calendarType === 'AMH' ? (
|
|
<EthiopicDayPicker
|
|
className="amharic-daypicker"
|
|
mode="single"
|
|
selected={selected ?? undefined}
|
|
defaultMonth={selected ?? undefined}
|
|
startMonth={YEAR_DROPDOWN_START}
|
|
endMonth={YEAR_DROPDOWN_END}
|
|
numerals="latn"
|
|
captionLayout="dropdown"
|
|
formatters={ETH_FORMATTERS}
|
|
onSelect={(date: Date | undefined) => {
|
|
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
|
if (!withTime) close();
|
|
}}
|
|
/>
|
|
) : (
|
|
<GregorianDayPicker
|
|
className="amharic-daypicker"
|
|
mode="single"
|
|
selected={selected ?? undefined}
|
|
defaultMonth={selected ?? undefined}
|
|
startMonth={YEAR_DROPDOWN_START}
|
|
endMonth={YEAR_DROPDOWN_END}
|
|
captionLayout="dropdown"
|
|
onSelect={(date: Date | undefined) => {
|
|
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
|
if (!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>
|
|
);
|
|
}
|