improvement: some improvement done for appointment and treatment shared components.
This commit is contained in:
@@ -9,4 +9,6 @@ alwaysApply: false
|
||||
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -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`
|
||||
- `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)
|
||||
- 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
|
||||
|
||||
**Select chevron**
|
||||
|
||||
@@ -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).
|
||||
- **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`.
|
||||
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
||||
"loadingSchedule": "Loading schedule…",
|
||||
"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.",
|
||||
"errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.",
|
||||
"successUpdated": "Appointment updated.",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
||||
"loadingSchedule": "در حال بارگذاری برنامه...",
|
||||
"infoPastViewOnly": "نوبتهای گذشته فقط قابل مشاهده هستند.",
|
||||
"infoEditBlockedHasTreatment": "این نوبت قابل ویرایش نیست چون یک درمان به آن متصل است.",
|
||||
"infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.",
|
||||
"errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائهدهنده است و قابل ویرایش نیست.",
|
||||
"successUpdated": "نوبت بهروزرسانی شد.",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
||||
"loadingSchedule": "Rooster laden...",
|
||||
"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.",
|
||||
"errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.",
|
||||
"successUpdated": "Afspraak bijgewerkt.",
|
||||
|
||||
@@ -203,13 +203,16 @@ export function AppointmentBookingModal({
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
|
||||
if (
|
||||
!editingAppointment &&
|
||||
isSameLocalCalendarDay(scheduleDate, now) &&
|
||||
startAt.getTime() < now.getTime()
|
||||
) {
|
||||
setError(t('errorPastSchedule'));
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
if (compareLocalDayStart(scheduleDate, today) < 0) {
|
||||
if (!editingAppointment && compareLocalDayStart(scheduleDate, now) < 0) {
|
||||
setError(t('errorPastViewOnly'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ interface AppointmentScheduleGridProps {
|
||||
appointments: AppointmentRecord[];
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
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;
|
||||
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
|
||||
@@ -92,6 +96,8 @@ export function AppointmentScheduleGrid({
|
||||
appointments,
|
||||
treatmentCatalog,
|
||||
canBook,
|
||||
slotBlockReason = null,
|
||||
appointmentEditBlockReason,
|
||||
onSlotClick,
|
||||
onAppointmentClick,
|
||||
onAppointmentOutsideHours,
|
||||
@@ -161,9 +167,6 @@ export function AppointmentScheduleGrid({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!canBook) {
|
||||
return;
|
||||
}
|
||||
onAppointmentClick?.(apt);
|
||||
}
|
||||
|
||||
@@ -216,7 +219,7 @@ export function AppointmentScheduleGrid({
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
{formatMinuteLabel(hour * 60, locale)}
|
||||
@@ -247,13 +250,15 @@ export function AppointmentScheduleGrid({
|
||||
SCHEDULE_SLOT_MINUTES,
|
||||
p.dayBlocks,
|
||||
);
|
||||
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
|
||||
const structuralDisabled = columnFullyDisabled || !slotActive;
|
||||
const bookingBlocked = Boolean(slotBlockReason) || !canBook;
|
||||
const slotLooksDisabled = structuralDisabled || bookingBlocked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${p.userId}-${slotStartMinute}`}
|
||||
type="button"
|
||||
disabled={slotDisabled}
|
||||
disabled={structuralDisabled}
|
||||
title={
|
||||
columnFullyDisabled
|
||||
? p.hasWorkingHours
|
||||
@@ -261,14 +266,16 @@ export function AppointmentScheduleGrid({
|
||||
: t('slotHoursNotConfigured')
|
||||
: !slotActive
|
||||
? t('slotOutsideHours')
|
||||
: slotDisabled
|
||||
: slotBlockReason
|
||||
? slotBlockReason
|
||||
: bookingBlocked
|
||||
? t('slotCannotCreate')
|
||||
: t('slotBookAt', {
|
||||
time: formatMinuteLabel(slotStartMinute, locale),
|
||||
})
|
||||
}
|
||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||
slotDisabled
|
||||
slotLooksDisabled
|
||||
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
||||
: 'hover:bg-primary/8 cursor-pointer'
|
||||
}`}
|
||||
@@ -302,8 +309,10 @@ export function AppointmentScheduleGrid({
|
||||
p.dayBlocks,
|
||||
);
|
||||
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
|
||||
const editBlockReason = appointmentEditBlockReason?.(apt) ?? null;
|
||||
const bannerTitle = [
|
||||
patientName,
|
||||
editBlockReason,
|
||||
outsideHours ? t('outsideHoursBlocked') : null,
|
||||
clusterSize > 1
|
||||
? 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 ${
|
||||
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
||||
outsideHours || editBlockReason
|
||||
? 'opacity-70'
|
||||
: ''
|
||||
} ${
|
||||
outsideHours ? 'ring-1 ring-amber-500/60' : ''
|
||||
} ${
|
||||
isUnderOneHour
|
||||
? 'items-center justify-center px-0.5 py-0'
|
||||
@@ -377,10 +390,6 @@ export function AppointmentScheduleGrid({
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
anchorRect={overlapPopover.anchorRect}
|
||||
onSelect={(apt) => {
|
||||
if (!canBook) {
|
||||
setOverlapPopover(null);
|
||||
return;
|
||||
}
|
||||
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
||||
if (
|
||||
provider &&
|
||||
|
||||
@@ -25,6 +25,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
|
||||
export function AppointmentsPage() {
|
||||
const t = useTranslations('appointments');
|
||||
const tErrors = useTranslations('errors');
|
||||
const tCommon = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
||||
@@ -117,8 +118,19 @@ export function AppointmentsPage() {
|
||||
.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) {
|
||||
if (!canManageAppointments) {
|
||||
toast.showInfo(tCommon('readOnlyAccess'));
|
||||
return;
|
||||
}
|
||||
if (isViewingPastDay) {
|
||||
@@ -133,11 +145,9 @@ export function AppointmentsPage() {
|
||||
}
|
||||
|
||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||
if (!canManageAppointments) {
|
||||
return;
|
||||
}
|
||||
if (isViewingPastDay) {
|
||||
toast.showInfo(t('infoPastViewOnly'));
|
||||
const blockReason = appointmentEditBlockReason(appointment);
|
||||
if (blockReason) {
|
||||
toast.showInfo(blockReason);
|
||||
return;
|
||||
}
|
||||
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
||||
@@ -254,6 +264,14 @@ export function AppointmentsPage() {
|
||||
appointments={appointments}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
canBook={canManageAppointments && !isViewingPastDay}
|
||||
appointmentEditBlockReason={appointmentEditBlockReason}
|
||||
slotBlockReason={
|
||||
!canManageAppointments
|
||||
? tCommon('readOnlyAccess')
|
||||
: isViewingPastDay
|
||||
? t('infoPastViewOnly')
|
||||
: null
|
||||
}
|
||||
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
|
||||
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
||||
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
|
||||
@@ -281,7 +299,6 @@ export function AppointmentsPage() {
|
||||
loading={savingAppointment}
|
||||
canDelete={
|
||||
canManageAppointments &&
|
||||
!isViewingPastDay &&
|
||||
!!activeEditingAppointment &&
|
||||
!activeEditingAppointment.hasTreatment
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
|
||||
import {
|
||||
formatIsoAsGregorianDateInput,
|
||||
maskGregorianDateTyping,
|
||||
@@ -45,6 +46,11 @@ export function AppDateInput({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
|
||||
open: panelOpen && !disabled,
|
||||
anchorRef: rootRef,
|
||||
align: 'start',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setText(
|
||||
@@ -56,9 +62,11 @@ export function AppDateInput({
|
||||
if (!panelOpen) return;
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setPanelOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
@@ -73,7 +81,7 @@ export function AppDateInput({
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [panelOpen]);
|
||||
}, [panelOpen, panelRef]);
|
||||
|
||||
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
|
||||
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
|
||||
@@ -154,9 +162,19 @@ export function AppDateInput({
|
||||
|
||||
{panelOpen && !disabled ? (
|
||||
<div
|
||||
ref={panelRef}
|
||||
id={`${panelId}-parts`}
|
||||
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
|
||||
panelId={panelId}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { useAnchoredPanelPosition } from '@/lib/hooks/useAnchoredPanelPosition';
|
||||
|
||||
export type CalendarDaySelectProps = {
|
||||
value: Date;
|
||||
@@ -50,6 +51,11 @@ export function CalendarDaySelect({
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const { panelRef, style: panelStyle } = useAnchoredPanelPosition({
|
||||
open: panelOpen && !disabled,
|
||||
anchorRef: rootRef,
|
||||
align: 'stretch',
|
||||
});
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const today = startOfLocalDay(new Date());
|
||||
@@ -63,11 +69,13 @@ export function CalendarDaySelect({
|
||||
if (!panelOpen) return;
|
||||
|
||||
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);
|
||||
onBlur?.();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -82,7 +90,7 @@ export function CalendarDaySelect({
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onBlur, panelOpen]);
|
||||
}, [onBlur, panelOpen, panelRef]);
|
||||
|
||||
const triggerButton = (
|
||||
<button
|
||||
@@ -166,17 +174,27 @@ export function CalendarDaySelect({
|
||||
|
||||
{panelOpen && !disabled ? (
|
||||
<div
|
||||
ref={panelRef}
|
||||
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"
|
||||
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
|
||||
panelId={panelId}
|
||||
value={normalizedValue}
|
||||
onChange={onChange}
|
||||
closePanelOnDaySelect={closePanelOnDaySelect}
|
||||
onAfterSelect={(day) => {
|
||||
onAfterSelect={() => {
|
||||
setPanelOpen(false);
|
||||
onBlur?.();
|
||||
}}
|
||||
|
||||
96
frontend/src/lib/hooks/useAnchoredPanelPosition.ts
Normal file
96
frontend/src/lib/hooks/useAnchoredPanelPosition.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user