From a860707f9dbcf3f219d2223feab0ce06d45de32a Mon Sep 17 00:00:00 2001 From: William Reiske Date: Thu, 14 May 2026 14:48:50 -0700 Subject: [PATCH] fix(DateRangePicker): auto-flip popup when it would overflow viewport Adds an `align` prop ('start' | 'end' | 'auto', default 'auto') that controls the horizontal alignment of the desktop calendar popup. Under 'auto' we measure the trigger after open and flip from left-aligned to right-aligned when the popup would otherwise spill past the right edge of the viewport. Fixes the picker being clipped off the page when the trigger sits in a right-aligned slot (e.g. a page header's actions area). --- package.json | 2 +- .../DateRangePicker/DateRangePicker.tsx | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6198b231..7b918a16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mieweb/ui", - "version": "0.5.0", + "version": "0.6.1", "description": "A themeable, accessible React component library built with Tailwind CSS", "author": "Medical Informatics Engineering, Inc.", "license": "SEE LICENSE IN LICENSE", diff --git a/src/components/DateRangePicker/DateRangePicker.tsx b/src/components/DateRangePicker/DateRangePicker.tsx index ec524056..07b3806d 100644 --- a/src/components/DateRangePicker/DateRangePicker.tsx +++ b/src/components/DateRangePicker/DateRangePicker.tsx @@ -52,6 +52,15 @@ export interface DateRangePickerProps { placeholder?: string; /** Custom className */ className?: string; + /** + * Horizontal alignment of the desktop popup relative to the trigger. + * - `'start'` (default historical behavior): popup left edge aligns with trigger left edge. + * - `'end'`: popup right edge aligns with trigger right edge. + * - `'auto'`: starts as `'start'`, then automatically flips to `'end'` if the popup + * would overflow the right edge of the viewport. Recommended for triggers placed + * in the right side of a layout (e.g. page header action slots). + */ + align?: 'start' | 'end' | 'auto'; /** Whether to show the preset sidebar in the calendar popup (default: true) */ showPresets?: boolean; /** Display variant: desktop (default), mobile (bottom sheet), or responsive (auto-adapts at md breakpoint) */ @@ -316,6 +325,7 @@ export function DateRangePicker({ activePreset, placeholder = 'Pick a date range', className, + align = 'auto', showPresets = true, variant = 'desktop', labels = {}, @@ -342,6 +352,13 @@ export function DateRangePicker({ const calendarRef = React.useRef(null); const triggerRef = React.useRef(null); + // Resolved horizontal alignment for the desktop popup. Starts at the + // requested value and may flip to 'end' under 'auto' when the popup + // would overflow the viewport on the right. + const [resolvedAlign, setResolvedAlign] = React.useState<'start' | 'end'>( + align === 'end' ? 'end' : 'start' + ); + const isMobileVariant = variant === 'mobile'; const isResponsive = variant === 'responsive'; @@ -392,6 +409,32 @@ export function DateRangePicker({ } }, [isMobileVariant, isCalendarOpen]); + // Resolve popup horizontal alignment. For explicit 'start' or 'end' we + // honor the consumer's choice. For 'auto' we measure the trigger and pick + // 'end' when right-aligning would keep more of the popup on-screen — this + // prevents the calendar from spilling past the right edge of the viewport + // when the trigger sits in a right-aligned header slot. + React.useLayoutEffect(() => { + if (isMobileVariant || !isCalendarOpen) return; + if (align === 'start' || align === 'end') { + setResolvedAlign(align); + return; + } + if (typeof window === 'undefined') return; + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + // Approximate popup width: preset sidebar (200px) + dual calendar panel + // (~640px including padding). Slight overestimate is fine — we just want + // to flip when 'start' alignment would clearly overflow. + const estimatedPopupWidth = showPresets ? 840 : 640; + const margin = 8; + const overflowsRight = + rect.left + estimatedPopupWidth > window.innerWidth - margin; + const fitsLeftAligned = rect.right - estimatedPopupWidth >= margin; + setResolvedAlign(overflowsRight && fitsLeftAligned ? 'end' : 'start'); + }, [align, isCalendarOpen, isMobileVariant, showPresets]); + const handlePresetSelect = (presetKey: string) => { const range = calculateDateRange(presetKey); setRangeStart(range.start); @@ -769,7 +812,8 @@ export function DateRangePicker({