improvement: rtl direction, solar calendar and persian formatting added for persian users.

This commit is contained in:
2026-07-14 01:27:11 +03:30
parent 27eae25f61
commit ec2b23f4b1
50 changed files with 1410 additions and 528 deletions

View File

@@ -0,0 +1,175 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { useLocale } from 'next-intl';
import { CalendarDays } from 'lucide-react';
import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime';
import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles';
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
import {
formatIsoAsGregorianDateInput,
maskGregorianDateTyping,
parseGregorianDateInputText,
} from '@/lib/i18n/dateInputFormat';
import { usesPersianCalendar } from '@/lib/i18n/format';
import {
formatIsoAsPersianDateInput,
maskJalaliDateTyping,
parsePersianDateInputText,
} from '@/lib/i18n/persianCalendar';
export type AppDateInputProps = {
id?: string;
value: string;
onChange: (value: string) => void;
onBlur?: (value: string) => void;
disabled?: boolean;
className?: string;
};
/**
* Locale-aware date field — wire value is always `YYYY-MM-DD` or empty.
* Visual shell matches native `.form-select` (padding, text alignment, icon inset).
*/
export function AppDateInput({
id,
value,
onChange,
onBlur,
disabled = false,
className = '',
}: AppDateInputProps) {
const locale = useLocale();
const persian = usesPersianCalendar(locale);
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState('');
const [panelOpen, setPanelOpen] = useState(false);
useEffect(() => {
setText(
persian ? formatIsoAsPersianDateInput(value) : formatIsoAsGregorianDateInput(value),
);
}, [persian, value]);
useEffect(() => {
if (!panelOpen) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setPanelOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setPanelOpen(false);
}
}
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [panelOpen]);
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
const fieldClass = `${FORM_DATE_INPUT_CLASS} w-full ${className}`.trim();
function formatDisplay(iso: string): string {
return persian ? formatIsoAsPersianDateInput(iso) : formatIsoAsGregorianDateInput(iso);
}
function maskTyping(raw: string): string {
return persian ? maskJalaliDateTyping(raw) : maskGregorianDateTyping(raw);
}
function parseTyping(raw: string): string | null {
return persian ? parsePersianDateInputText(raw) : parseGregorianDateInputText(raw);
}
function commitText(nextText: string): string {
const trimmed = nextText.trim();
if (!trimmed) {
onChange('');
setText('');
return '';
}
const iso = parseTyping(trimmed);
if (iso) {
onChange(iso);
setText(formatDisplay(iso));
return iso;
}
setText(value ? formatDisplay(value) : '');
return value;
}
function handlePanelChange(day: Date) {
const iso = toDateInputValue(day);
onChange(iso);
setText(formatDisplay(iso));
}
return (
<div ref={rootRef} className="relative w-full">
<input
id={id}
type="text"
inputMode="numeric"
autoComplete="off"
value={text}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => setText(maskTyping(e.target.value))}
onBlur={() => {
const committed = commitText(text);
onBlur?.(committed);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const committed = commitText(text);
onBlur?.(committed);
(e.target as HTMLInputElement).blur();
}
}}
className={fieldClass}
/>
<button
type="button"
disabled={disabled}
aria-expanded={panelOpen}
aria-controls={`${panelId}-parts`}
aria-label={panelOpen ? undefined : 'Open calendar'}
onClick={() => {
if (!disabled) setPanelOpen((open) => !open);
}}
className="pointer-events-auto absolute top-1/2 end-3 flex h-4 w-4 -translate-y-1/2 items-center justify-center text-text-muted hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-60"
>
<CalendarDays className="h-4 w-4 icon-flat" aria-hidden />
</button>
{panelOpen && !disabled ? (
<div
id={`${panelId}-parts`}
role="dialog"
className="absolute left-0 right-0 top-full z-50 mt-1 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
>
<CalendarDayPartsPanel
panelId={panelId}
value={panelAnchorDate}
onChange={handlePanelChange}
closePanelOnDaySelect
onAfterSelect={(day) => {
setPanelOpen(false);
onBlur?.(toDateInputValue(day));
}}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { formatAppInteger, usesPersianCalendar } from '@/lib/i18n/format';
import {
formatPersianMonthLabel,
getLocalPersianParts,
jalaliDaysInMonth,
persianPartsToLocalDate,
persianYearRange,
} from '@/lib/i18n/persianCalendar';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import { CompactSelect } from '@/components/ui/shared/CompactSelect';
const MONTH_KEYS = [
'monthJanuary',
'monthFebruary',
'monthMarch',
'monthApril',
'monthMay',
'monthJune',
'monthJuly',
'monthAugust',
'monthSeptember',
'monthOctober',
'monthNovember',
'monthDecember',
] as const;
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0);
}
function yearRange(anchor: Date): number[] {
const anchorYear = anchor.getFullYear();
const years: number[] = [];
for (let y = anchorYear - 10; y <= anchorYear + 2; y += 1) {
years.push(y);
}
return years;
}
export type CalendarDayPartsPanelProps = {
panelId: string;
value: Date;
onChange: (day: Date) => void;
closePanelOnDaySelect?: boolean;
onAfterSelect?: (day: Date) => void;
};
/** Year / month / day dropdown row — shared by schedule picker and date fields. */
export function CalendarDayPartsPanel({
panelId,
value,
onChange,
closePanelOnDaySelect = false,
onAfterSelect,
}: CalendarDayPartsPanelProps) {
const locale = useLocale();
const t = useTranslations('schedule');
const normalizedValue = startOfLocalDay(value);
const persian = usesPersianCalendar(locale);
const jalaliParts = persian ? getLocalPersianParts(normalizedValue) : null;
const gregorianYear = normalizedValue.getFullYear();
const gregorianMonth = normalizedValue.getMonth();
const gregorianDay = normalizedValue.getDate();
const years =
persian && jalaliParts ? persianYearRange(jalaliParts.year) : yearRange(normalizedValue);
const selectedYear = jalaliParts?.year ?? gregorianYear;
const selectedMonth = jalaliParts?.month ?? gregorianMonth;
const selectedDay = jalaliParts?.day ?? gregorianDay;
const dayCount = persian
? jalaliDaysInMonth(selectedYear, selectedMonth)
: daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) {
const maxDay = persian ? jalaliDaysInMonth(year, month) : daysInMonth(year, month);
const clampedDay = Math.min(Math.max(1, day), maxDay);
onChange(
persian
? persianPartsToLocalDate(year, month, clampedDay)
: buildLocalDay(year, month, clampedDay),
);
if (closePanel) {
const nextDay = persian
? persianPartsToLocalDate(year, month, clampedDay)
: buildLocalDay(year, month, clampedDay);
onAfterSelect?.(nextDay);
}
}
function formatPanelYear(year: number): string {
return persian ? formatAppInteger(year, locale) : String(year);
}
function formatPanelDay(day: number): string {
return persian ? formatAppInteger(day, locale) : String(day);
}
return (
<div className="grid grid-cols-3 gap-2">
<div>
<label htmlFor={`${panelId}-year`} className="mb-1 block text-xs font-medium text-text-muted">
{t('year')}
</label>
<CompactSelect
id={`${panelId}-year`}
value={selectedYear}
onChange={(e) => applyParts(Number(e.target.value), selectedMonth, selectedDay)}
>
{years.map((year) => (
<option key={year} value={year}>
{formatPanelYear(year)}
</option>
))}
</CompactSelect>
</div>
<div>
<label htmlFor={`${panelId}-month`} className="mb-1 block text-xs font-medium text-text-muted">
{t('month')}
</label>
<CompactSelect
id={`${panelId}-month`}
value={selectedMonth}
onChange={(e) => applyParts(selectedYear, Number(e.target.value), selectedDay)}
>
{persian
? Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<option key={month} value={month}>
{formatPersianMonthLabel(selectedYear, month)}
</option>
))
: MONTH_KEYS.map((key, index) => (
<option key={key} value={index}>
{t(key)}
</option>
))}
</CompactSelect>
</div>
<div>
<label htmlFor={`${panelId}-day`} className="mb-1 block text-xs font-medium text-text-muted">
{t('day')}
</label>
<CompactSelect
id={`${panelId}-day`}
value={selectedDay}
onChange={(e) =>
applyParts(selectedYear, selectedMonth, Number(e.target.value), closePanelOnDaySelect)
}
>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
<option key={day} value={day}>
{formatPanelDay(day)}
</option>
))}
</CompactSelect>
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { isRtlLocale } from '@/i18n/routing';
import { formatAppPickerDateLabel } from '@/lib/i18n/format';
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
import { Checkbox } from '@/components/ui/shared/Checkbox';
export type CalendarDaySelectProps = {
value: Date;
onChange: (day: Date) => void;
label?: string;
emptyLabel?: string;
isEmpty?: boolean;
showHeader?: boolean;
showTodayToggle?: boolean;
showNavArrows?: boolean;
disabled?: boolean;
className?: string;
triggerClassName?: string;
id?: string;
onBlur?: () => void;
closePanelOnDaySelect?: boolean;
};
export function CalendarDaySelect({
value,
onChange,
label,
emptyLabel,
isEmpty = false,
showHeader = false,
showTodayToggle = false,
showNavArrows = false,
disabled = false,
className,
triggerClassName,
id,
onBlur,
closePanelOnDaySelect = true,
}: CalendarDaySelectProps) {
const locale = useLocale();
const rtl = isRtlLocale(locale);
const t = useTranslations('schedule');
const PrevIcon = rtl ? ChevronRight : ChevronLeft;
const NextIcon = rtl ? ChevronLeft : ChevronRight;
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const today = startOfLocalDay(new Date());
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
const resolvedLabel = label ?? t('defaultLabel');
const labelText = isEmpty
? (emptyLabel ?? t('chooseDate'))
: formatAppPickerDateLabel(normalizedValue, locale);
useEffect(() => {
if (!panelOpen) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setPanelOpen(false);
onBlur?.();
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setPanelOpen(false);
onBlur?.();
}
}
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [onBlur, panelOpen]);
const triggerButton = (
<button
type="button"
id={id}
disabled={disabled}
onClick={() => {
if (disabled) return;
setPanelOpen((open) => !open);
}}
aria-expanded={panelOpen}
aria-controls={panelId}
aria-haspopup="dialog"
className={
triggerClassName ??
`flex flex-1 min-w-0 items-center justify-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-60 ${
isEmpty ? 'text-text-muted' : ''
}`
}
>
<span className="truncate">{labelText}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
);
return (
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
{showHeader ? (
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
{showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null}
</div>
) : null}
<div
className={`flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)] ${
showNavArrows ? '' : 'py-0.5'
}`}
>
{showNavArrows ? (
<button
type="button"
disabled={disabled}
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-60"
aria-label={t('previousDay')}
>
<PrevIcon className="h-4 w-4 icon-flat" />
</button>
) : null}
{triggerButton}
{showNavArrows ? (
<button
type="button"
disabled={disabled}
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-60"
aria-label={t('nextDay')}
>
<NextIcon className="h-4 w-4 icon-flat" />
</button>
) : null}
</div>
{panelOpen && !disabled ? (
<div
id={panelId}
role="dialog"
aria-label={t('chooseDate')}
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
>
<CalendarDayPartsPanel
panelId={panelId}
value={normalizedValue}
onChange={onChange}
closePanelOnDaySelect={closePanelOnDaySelect}
onAfterSelect={(day) => {
setPanelOpen(false);
onBlur?.();
}}
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
type CompactSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
/** Compact styled `<select>` — chevron from global `.form-select` styles. */
export function CompactSelect({ className = '', children, ...props }: CompactSelectProps) {
return (
<select
className={`form-select w-full appearance-none rounded-[var(--radius-sm)] border border-border bg-background-card/90 text-text-primary text-sm ps-3 pe-10 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
{...props}
>
{children}
</select>
);
}

View File

@@ -1,6 +1,5 @@
'use client';
import { ChevronDown } from 'lucide-react';
import React, { forwardRef, useId } from 'react';
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
@@ -24,32 +23,23 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`
form-select w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-card text-text-primary
pl-4 pr-14 py-2.5 sm:py-2 text-base sm:text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
>
{children}
</select>
<div
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
aria-hidden
>
<ChevronDown className="h-4 w-4 icon-flat" />
</div>
</div>
<select
ref={ref}
id={selectId}
className={`
form-select w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-card text-text-primary
ps-3 pe-10 py-2.5 sm:py-2 text-base sm:text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
>
{children}
</select>
{error && (
<p className="mt-1 text-sm text-red-500">

View File

@@ -1,10 +1,6 @@
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { CalendarDaySelect } from '@/components/ui/shared/CalendarDaySelect';
interface ScheduleDayPickerProps {
value: Date;
@@ -14,47 +10,6 @@ interface ScheduleDayPickerProps {
showTodayToggle?: boolean;
}
const MONTH_KEYS = [
'monthJanuary',
'monthFebruary',
'monthMarch',
'monthApril',
'monthMay',
'monthJune',
'monthJuly',
'monthAugust',
'monthSeptember',
'monthOctober',
'monthNovember',
'monthDecember',
] as const;
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function buildLocalDay(year: number, month: number, day: number): Date {
return new Date(year, month, day, 0, 0, 0, 0);
}
function yearRange(anchor: Date): number[] {
const anchorYear = anchor.getFullYear();
const startYear = anchorYear - 10;
const endYear = anchorYear + 2;
const years: number[] = [];
for (let y = startYear; y <= endYear; y += 1) {
years.push(y);
}
return years;
}
const selectClassName = `
w-full appearance-none rounded-[var(--radius-sm)] border border-border
bg-background-card/90 text-text-primary text-sm
pl-2 pr-7 py-1.5
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
`;
/**
* Calendar day navigator (arrows + year/month/day panel).
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
@@ -65,215 +20,14 @@ export function ScheduleDayPicker({
label,
showTodayToggle = true,
}: ScheduleDayPickerProps) {
const t = useTranslations('schedule');
const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false);
const normalizedValue = startOfLocalDay(value);
const today = startOfLocalDay(new Date());
const isTodaySelected = compareLocalDayStart(normalizedValue, today) === 0;
const resolvedLabel = label ?? t('defaultLabel');
const labelText = normalizedValue.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
const years = yearRange(normalizedValue);
const selectedYear = normalizedValue.getFullYear();
const selectedMonth = normalizedValue.getMonth();
const selectedDay = normalizedValue.getDate();
const dayCount = daysInMonth(selectedYear, selectedMonth);
function applyParts(year: number, month: number, day: number, closePanel = false) {
const maxDay = daysInMonth(year, month);
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
if (closePanel) {
setPanelOpen(false);
}
}
useEffect(() => {
if (!panelOpen) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setPanelOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setPanelOpen(false);
}
}
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [panelOpen]);
return (
<div ref={rootRef} className="relative w-full max-w-md">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
{showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null}
</div>
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label={t('previousDay')}
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
<button
type="button"
onClick={() => setPanelOpen((open) => !open)}
aria-expanded={panelOpen}
aria-controls={panelId}
aria-haspopup="dialog"
className="flex flex-1 min-w-0 items-center justify-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
>
<span className="truncate">{labelText}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
<button
type="button"
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label={t('nextDay')}
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
</div>
{panelOpen && (
<div
id={panelId}
role="dialog"
aria-label={t('chooseDate')}
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
>
<div className="grid grid-cols-3 gap-2">
<div>
<label
htmlFor={`${panelId}-year`}
className="mb-1 block text-xs font-medium text-text-muted"
>
{t('year')}
</label>
<div className="relative">
<select
id={`${panelId}-year`}
value={selectedYear}
onChange={(e) =>
applyParts(Number(e.target.value), selectedMonth, selectedDay)
}
className={selectClassName}
>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
<div>
<label
htmlFor={`${panelId}-month`}
className="mb-1 block text-xs font-medium text-text-muted"
>
{t('month')}
</label>
<div className="relative">
<select
id={`${panelId}-month`}
value={selectedMonth}
onChange={(e) =>
applyParts(selectedYear, Number(e.target.value), selectedDay)
}
className={selectClassName}
>
{MONTH_KEYS.map((key, index) => (
<option key={key} value={index}>
{t(key)}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
<div>
<label
htmlFor={`${panelId}-day`}
className="mb-1 block text-xs font-medium text-text-muted"
>
{t('day')}
</label>
<div className="relative">
<select
id={`${panelId}-day`}
value={selectedDay}
onChange={(e) =>
applyParts(
selectedYear,
selectedMonth,
Number(e.target.value),
true,
)
}
className={selectClassName}
>
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
aria-hidden
/>
</div>
</div>
</div>
</div>
)}
</div>
<CalendarDaySelect
value={value}
onChange={onChange}
label={label}
showHeader
showTodayToggle={showTodayToggle}
showNavArrows
/>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { memo, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { Link, usePathname } from '@/i18n/navigation';
import {
LayoutDashboard,
@@ -31,6 +31,7 @@ import {
counterpartOrganizationType,
organizationTypeIcon,
} from '@/components/shared/organizationTypeIcon';
import { isRtlLocale } from '@/i18n/routing';
type MenuItem = {
name: string;
@@ -46,6 +47,8 @@ type SidebarProps = {
};
function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
const locale = useLocale();
const rtl = isRtlLocale(locale);
const t = useTranslations('nav');
const tCommon = useTranslations('common');
const pathname = usePathname();
@@ -98,8 +101,8 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
return (
<aside
className={`fixed inset-y-0 left-0 z-50 w-56 min-w-56 shrink-0 bg-background-secondary/95 border-r border-border text-text-primary flex flex-col backdrop-blur-sm transition-transform duration-200 ease-out lg:relative lg:translate-x-0 lg:z-auto ${
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
className={`app-sidebar fixed inset-y-0 left-0 z-50 w-56 min-w-56 shrink-0 bg-background-secondary/95 border-r border-border text-text-primary flex flex-col backdrop-blur-sm transition-transform duration-200 ease-out lg:relative lg:translate-x-0 lg:z-auto ${
mobileOpen ? 'translate-x-0' : rtl ? 'translate-x-full lg:translate-x-0' : '-translate-x-full lg:translate-x-0'
}`}
>
<div className="h-[71px] px-4 flex items-center justify-between gap-2">

View File

@@ -6,11 +6,12 @@ interface TableProps {
footer?: ReactNode;
}
/** Shared data table — logical alignment (`text-start` / `text-end`) for LTR and RTL. */
export function Table({ headers, body, footer }: TableProps) {
return (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] [&_th]:px-3 sm:[&_th]:px-6 [&_td]:px-3 sm:[&_td]:px-6">
<table className="w-full min-w-[36rem] border-collapse [&_th]:px-3 sm:[&_th]:px-6 [&_th]:py-3 [&_th]:text-start [&_td]:px-3 sm:[&_td]:px-6 [&_td]:py-1.5 [&_td]:text-start [&_th.text-center]:text-center [&_td.text-center]:text-center [&_th.text-end]:text-end [&_td.text-end]:text-end">
<thead className="bg-background-secondary/70 border-b border-border">
{headers}
</thead>