improvement: some improvement done for appointment and treatment shared components.

This commit is contained in:
2026-07-17 11:21:08 +03:30
parent d2ad1fd7b6
commit 1b63bfff00
12 changed files with 204 additions and 38 deletions

View File

@@ -9,4 +9,6 @@ alwaysApply: false
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`). - List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`). - **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable. - **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
- **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`).
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup. - Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.

View File

@@ -36,7 +36,7 @@ Use when adding or changing user-visible dates/times/numbers, RTL layout, or loc
- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts` - `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts`
- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts` - `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts`
- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR) - Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR)
- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker) - Popup: **`CalendarDayPartsPanel`** (shared with schedule picker); positioned via `useAnchoredPanelPosition` (fixed + viewport clamp / flip) so RTL/LTR edge fields stay on-screen
- **Do not** add native `<input type="date">` — one component for all locales - **Do not** add native `<input type="date">` — one component for all locales
**Select chevron** **Select chevron**

View File

@@ -56,7 +56,7 @@ frontend/src/
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`. **Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.

View File

@@ -573,6 +573,7 @@
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
"loadingSchedule": "Loading schedule…", "loadingSchedule": "Loading schedule…",
"infoPastViewOnly": "Past appointments are view-only.", "infoPastViewOnly": "Past appointments are view-only.",
"infoEditBlockedHasTreatment": "This appointment cannot be edited because a treatment is linked to it.",
"infoSelectPatient": "Select a patient before booking.", "infoSelectPatient": "Select a patient before booking.",
"errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.", "errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.",
"successUpdated": "Appointment updated.", "successUpdated": "Appointment updated.",

View File

@@ -574,6 +574,7 @@
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.",
"loadingSchedule": "در حال بارگذاری برنامه...", "loadingSchedule": "در حال بارگذاری برنامه...",
"infoPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", "infoPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.",
"infoEditBlockedHasTreatment": "این نوبت قابل ویرایش نیست چون یک درمان به آن متصل است.",
"infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.", "infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.",
"errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائه‌دهنده است و قابل ویرایش نیست.", "errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائه‌دهنده است و قابل ویرایش نیست.",
"successUpdated": "نوبت به‌روزرسانی شد.", "successUpdated": "نوبت به‌روزرسانی شد.",

View File

@@ -573,6 +573,7 @@
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
"loadingSchedule": "Rooster laden...", "loadingSchedule": "Rooster laden...",
"infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", "infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.",
"infoEditBlockedHasTreatment": "Deze afspraak kan niet worden bewerkt omdat er een behandeling aan gekoppeld is.",
"infoSelectPatient": "Selecteer een patiënt voor het boeken.", "infoSelectPatient": "Selecteer een patiënt voor het boeken.",
"errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.", "errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.",
"successUpdated": "Afspraak bijgewerkt.", "successUpdated": "Afspraak bijgewerkt.",

View File

@@ -203,13 +203,16 @@ export function AppointmentBookingModal({
} }
const now = new Date(); const now = new Date();
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { if (
!editingAppointment &&
isSameLocalCalendarDay(scheduleDate, now) &&
startAt.getTime() < now.getTime()
) {
setError(t('errorPastSchedule')); setError(t('errorPastSchedule'));
return; return;
} }
const today = new Date(); if (!editingAppointment && compareLocalDayStart(scheduleDate, now) < 0) {
if (compareLocalDayStart(scheduleDate, today) < 0) {
setError(t('errorPastViewOnly')); setError(t('errorPastViewOnly'));
return; return;
} }

View File

@@ -81,6 +81,10 @@ interface AppointmentScheduleGridProps {
appointments: AppointmentRecord[]; appointments: AppointmentRecord[];
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
canBook: boolean; canBook: boolean;
/** Same message used for title + click toast when booking is blocked. */
slotBlockReason?: string | null;
/** Same message used for title + click toast when editing an appointment is blocked. */
appointmentEditBlockReason?: (appointment: AppointmentRecord) => string | null;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void; onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void;
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void; onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
@@ -92,6 +96,8 @@ export function AppointmentScheduleGrid({
appointments, appointments,
treatmentCatalog, treatmentCatalog,
canBook, canBook,
slotBlockReason = null,
appointmentEditBlockReason,
onSlotClick, onSlotClick,
onAppointmentClick, onAppointmentClick,
onAppointmentOutsideHours, onAppointmentOutsideHours,
@@ -161,9 +167,6 @@ export function AppointmentScheduleGrid({
}); });
return; return;
} }
if (!canBook) {
return;
}
onAppointmentClick?.(apt); onAppointmentClick?.(apt);
} }
@@ -216,7 +219,7 @@ export function AppointmentScheduleGrid({
return ( return (
<div <div
key={hour} key={hour}
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50" className="absolute inset-x-0 text-[11px] text-text-muted flex items-center justify-center px-0.5 border-b border-border/50"
style={{ top, height }} style={{ top, height }}
> >
{formatMinuteLabel(hour * 60, locale)} {formatMinuteLabel(hour * 60, locale)}
@@ -247,13 +250,15 @@ export function AppointmentScheduleGrid({
SCHEDULE_SLOT_MINUTES, SCHEDULE_SLOT_MINUTES,
p.dayBlocks, p.dayBlocks,
); );
const slotDisabled = !canBook || columnFullyDisabled || !slotActive; const structuralDisabled = columnFullyDisabled || !slotActive;
const bookingBlocked = Boolean(slotBlockReason) || !canBook;
const slotLooksDisabled = structuralDisabled || bookingBlocked;
return ( return (
<button <button
key={`${p.userId}-${slotStartMinute}`} key={`${p.userId}-${slotStartMinute}`}
type="button" type="button"
disabled={slotDisabled} disabled={structuralDisabled}
title={ title={
columnFullyDisabled columnFullyDisabled
? p.hasWorkingHours ? p.hasWorkingHours
@@ -261,14 +266,16 @@ export function AppointmentScheduleGrid({
: t('slotHoursNotConfigured') : t('slotHoursNotConfigured')
: !slotActive : !slotActive
? t('slotOutsideHours') ? t('slotOutsideHours')
: slotDisabled : slotBlockReason
? slotBlockReason
: bookingBlocked
? t('slotCannotCreate') ? t('slotCannotCreate')
: t('slotBookAt', { : t('slotBookAt', {
time: formatMinuteLabel(slotStartMinute, locale), time: formatMinuteLabel(slotStartMinute, locale),
}) })
} }
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${ className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled slotLooksDisabled
? 'cursor-not-allowed bg-background-secondary/35 opacity-60' ? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
: 'hover:bg-primary/8 cursor-pointer' : 'hover:bg-primary/8 cursor-pointer'
}`} }`}
@@ -302,8 +309,10 @@ export function AppointmentScheduleGrid({
p.dayBlocks, p.dayBlocks,
); );
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`; const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const editBlockReason = appointmentEditBlockReason?.(apt) ?? null;
const bannerTitle = [ const bannerTitle = [
patientName, patientName,
editBlockReason,
outsideHours ? t('outsideHoursBlocked') : null, outsideHours ? t('outsideHoursBlocked') : null,
clusterSize > 1 clusterSize > 1
? t('overlappingChoose', { count: clusterSize }) ? t('overlappingChoose', { count: clusterSize })
@@ -328,7 +337,11 @@ export function AppointmentScheduleGrid({
) )
} }
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : '' outsideHours || editBlockReason
? 'opacity-70'
: ''
} ${
outsideHours ? 'ring-1 ring-amber-500/60' : ''
} ${ } ${
isUnderOneHour isUnderOneHour
? 'items-center justify-center px-0.5 py-0' ? 'items-center justify-center px-0.5 py-0'
@@ -377,10 +390,6 @@ export function AppointmentScheduleGrid({
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
anchorRect={overlapPopover.anchorRect} anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => { onSelect={(apt) => {
if (!canBook) {
setOverlapPopover(null);
return;
}
const provider = providers.find((p) => p.userId === apt.providerUserId); const provider = providers.find((p) => p.userId === apt.providerUserId);
if ( if (
provider && provider &&

View File

@@ -25,6 +25,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
export function AppointmentsPage() { export function AppointmentsPage() {
const t = useTranslations('appointments'); const t = useTranslations('appointments');
const tErrors = useTranslations('errors'); const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const router = useRouter(); const router = useRouter();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
@@ -117,8 +118,19 @@ export function AppointmentsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
function appointmentEditBlockReason(appointment: AppointmentRecord): string | null {
if (!canManageAppointments) {
return tCommon('readOnlyAccess');
}
if (isViewingPastDay && appointment.hasTreatment) {
return t('infoEditBlockedHasTreatment');
}
return null;
}
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (!canManageAppointments) { if (!canManageAppointments) {
toast.showInfo(tCommon('readOnlyAccess'));
return; return;
} }
if (isViewingPastDay) { if (isViewingPastDay) {
@@ -133,11 +145,9 @@ export function AppointmentsPage() {
} }
function handleAppointmentClick(appointment: AppointmentRecord) { function handleAppointmentClick(appointment: AppointmentRecord) {
if (!canManageAppointments) { const blockReason = appointmentEditBlockReason(appointment);
return; if (blockReason) {
} toast.showInfo(blockReason);
if (isViewingPastDay) {
toast.showInfo(t('infoPastViewOnly'));
return; return;
} }
const provider = providers.find((p) => p.userId === appointment.providerUserId); const provider = providers.find((p) => p.userId === appointment.providerUserId);
@@ -254,6 +264,14 @@ export function AppointmentsPage() {
appointments={appointments} appointments={appointments}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
canBook={canManageAppointments && !isViewingPastDay} canBook={canManageAppointments && !isViewingPastDay}
appointmentEditBlockReason={appointmentEditBlockReason}
slotBlockReason={
!canManageAppointments
? tCommon('readOnlyAccess')
: isViewingPastDay
? t('infoPastViewOnly')
: null
}
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)} onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)} onAppointmentClick={(apt) => handleAppointmentClick(apt)}
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
@@ -281,7 +299,6 @@ export function AppointmentsPage() {
loading={savingAppointment} loading={savingAppointment}
canDelete={ canDelete={
canManageAppointments && canManageAppointments &&
!isViewingPastDay &&
!!activeEditingAppointment && !!activeEditingAppointment &&
!activeEditingAppointment.hasTreatment !activeEditingAppointment.hasTreatment
} }

View File

@@ -6,6 +6,7 @@ import { CalendarDays } from 'lucide-react';
import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime'; import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime';
import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles'; import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles';
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel'; import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
import { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
import { import {
formatIsoAsGregorianDateInput, formatIsoAsGregorianDateInput,
maskGregorianDateTyping, maskGregorianDateTyping,
@@ -45,6 +46,11 @@ export function AppDateInput({
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState(''); const [text, setText] = useState('');
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
open: panelOpen && !disabled,
anchorRef: rootRef,
align: 'start',
});
useEffect(() => { useEffect(() => {
setText( setText(
@@ -56,9 +62,11 @@ export function AppDateInput({
if (!panelOpen) return; if (!panelOpen) return;
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) { const target = event.target as Node;
setPanelOpen(false); if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
} }
setPanelOpen(false);
} }
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
@@ -73,7 +81,7 @@ export function AppDateInput({
document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
}; };
}, [panelOpen]); }, [panelOpen, panelRef]);
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date()); const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13'; const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
@@ -154,9 +162,19 @@ export function AppDateInput({
{panelOpen && !disabled ? ( {panelOpen && !disabled ? (
<div <div
ref={panelRef}
id={`${panelId}-parts`} id={`${panelId}-parts`}
role="dialog" role="dialog"
className="absolute start-0 top-full z-50 mt-1 min-w-[17.5rem] w-max max-w-[calc(100vw-1.5rem)] rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg" style={
panelStyle ?? {
position: 'fixed',
top: 0,
left: 0,
visibility: 'hidden',
pointerEvents: 'none',
}
}
className="min-w-[17.5rem] rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
> >
<CalendarDayPartsPanel <CalendarDayPartsPanel
panelId={panelId} panelId={panelId}

View File

@@ -8,6 +8,7 @@ import { formatAppPickerDateLabel } from '@/lib/i18n/format';
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel'; import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime'; import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
export type CalendarDaySelectProps = { export type CalendarDaySelectProps = {
value: Date; value: Date;
@@ -50,6 +51,11 @@ export function CalendarDaySelect({
const panelId = useId(); const panelId = useId();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
open: panelOpen && !disabled,
anchorRef: rootRef,
align: 'stretch',
});
const normalizedValue = startOfLocalDay(value); const normalizedValue = startOfLocalDay(value);
const today = startOfLocalDay(new Date()); const today = startOfLocalDay(new Date());
@@ -63,11 +69,13 @@ export function CalendarDaySelect({
if (!panelOpen) return; if (!panelOpen) return;
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) { const target = event.target as Node;
if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setPanelOpen(false); setPanelOpen(false);
onBlur?.(); onBlur?.();
} }
}
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -82,7 +90,7 @@ export function CalendarDaySelect({
document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
}; };
}, [onBlur, panelOpen]); }, [onBlur, panelOpen, panelRef]);
const triggerButton = ( const triggerButton = (
<button <button
@@ -166,17 +174,27 @@ export function CalendarDaySelect({
{panelOpen && !disabled ? ( {panelOpen && !disabled ? (
<div <div
ref={panelRef}
id={panelId} id={panelId}
role="dialog" role="dialog"
aria-label={t('chooseDate')} 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" style={
panelStyle ?? {
position: 'fixed',
top: 0,
left: 0,
visibility: 'hidden',
pointerEvents: 'none',
}
}
className="rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
> >
<CalendarDayPartsPanel <CalendarDayPartsPanel
panelId={panelId} panelId={panelId}
value={normalizedValue} value={normalizedValue}
onChange={onChange} onChange={onChange}
closePanelOnDaySelect={closePanelOnDaySelect} closePanelOnDaySelect={closePanelOnDaySelect}
onAfterSelect={(day) => { onAfterSelect={() => {
setPanelOpen(false); setPanelOpen(false);
onBlur?.(); onBlur?.();
}} }}

View File

@@ -0,0 +1,96 @@
'use client';
import { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
const VIEWPORT_PADDING = 12;
const DEFAULT_GAP = 8;
export type AnchoredPanelAlign = 'start' | 'stretch';
type UseAnchoredPanelPositionArgs = {
open: boolean;
/** Element the panel should attach to (field / trigger shell). */
anchorRef: RefObject<HTMLElement | null>;
/** Prefer aligning the panel's inline-start with the anchor's inline-start. */
align?: AnchoredPanelAlign;
gap?: number;
};
/**
* Positions a panel with `position: fixed`, clamped to the viewport.
* Flips above the anchor when there is not enough room below.
* Handles LTR/RTL via `document.documentElement.dir`.
*/
export function useAnchoredPanelPosition({
open,
anchorRef,
align = 'start',
gap = DEFAULT_GAP,
}: UseAnchoredPanelPositionArgs) {
const panelRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties | undefined>(undefined);
useLayoutEffect(() => {
if (!open) {
setStyle(undefined);
return;
}
function update() {
const anchor = anchorRef.current;
const panel = panelRef.current;
if (!anchor || !panel) return;
const anchorRect = anchor.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const isRtl = document.documentElement.dir === 'rtl';
let width =
align === 'stretch'
? Math.min(anchorRect.width, vw - VIEWPORT_PADDING * 2)
: Math.min(panelRect.width || panel.offsetWidth, vw - VIEWPORT_PADDING * 2);
if (width < 1) width = Math.min(280, vw - VIEWPORT_PADDING * 2);
let left: number;
if (align === 'stretch') {
left = anchorRect.left;
} else if (isRtl) {
left = anchorRect.right - width;
} else {
left = anchorRect.left;
}
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - width - VIEWPORT_PADDING));
const height = panelRect.height || panel.offsetHeight;
let top = anchorRect.bottom + gap;
if (top + height > vh - VIEWPORT_PADDING) {
const above = anchorRect.top - height - gap;
top = above >= VIEWPORT_PADDING ? above : Math.max(VIEWPORT_PADDING, vh - height - VIEWPORT_PADDING);
}
setStyle({
position: 'fixed',
top,
left,
width,
zIndex: 50,
});
}
update();
// Second pass after fonts/layout settle panel size.
const raf = window.requestAnimationFrame(update);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [align, anchorRef, gap, open]);
return { panelRef, style };
}