From c8c122812fc41579d7d8a124409833b171bdf49e Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 20 May 2026 20:04:07 +0800 Subject: [PATCH] Fix mobile schedule picker layout --- .../AssistantChat/ScheduleTimePicker.test.ts | 72 ++++++++++- .../AssistantChat/ScheduleTimePicker.tsx | 116 +++++++++++++++--- 2 files changed, 172 insertions(+), 16 deletions(-) diff --git a/web/src/components/AssistantChat/ScheduleTimePicker.test.ts b/web/src/components/AssistantChat/ScheduleTimePicker.test.ts index 26677f2a..12e808fc 100644 --- a/web/src/components/AssistantChat/ScheduleTimePicker.test.ts +++ b/web/src/components/AssistantChat/ScheduleTimePicker.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { clampToMaxDays, parsePreset, validateSpecificDatetime, resolvePendingSchedule } from './ScheduleTimePicker' +import { + clampToMaxDays, + computeSchedulePickerPlacement, + parsePreset, + validateSpecificDatetime, + resolvePendingSchedule +} from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' /** @@ -139,3 +145,67 @@ describe('validateSpecificDatetime', () => { expect(validateSpecificDatetime(tooOld, now)).toBe('scheduleErrorPast') }) }) + +describe('computeSchedulePickerPlacement', () => { + const panel = { panelWidth: 288, panelHeight: 180 } + + it('clamps left edge when the anchor is near the mobile viewport right side', () => { + const placement = computeSchedulePickerPlacement({ + anchor: { top: 500, right: 368, bottom: 532, left: 336 }, + ...panel, + viewport: { width: 375, height: 667 }, + }) + + expect(placement.left).toBe(79) + expect(placement.left + panel.panelWidth).toBeLessThanOrEqual(375 - 8) + }) + + it('opens above the anchor when there is enough room above', () => { + const placement = computeSchedulePickerPlacement({ + anchor: { top: 500, right: 132, bottom: 532, left: 100 }, + ...panel, + viewport: { width: 390, height: 700 }, + }) + + expect(placement.placement).toBe('above') + expect(placement.top).toBe(312) + expect(placement.maxHeight).toBe(panel.panelHeight) + }) + + it('opens below the anchor when only below has enough room', () => { + const placement = computeSchedulePickerPlacement({ + anchor: { top: 80, right: 132, bottom: 112, left: 100 }, + ...panel, + viewport: { width: 390, height: 700 }, + }) + + expect(placement.placement).toBe('below') + expect(placement.top).toBe(120) + expect(placement.maxHeight).toBe(panel.panelHeight) + }) + + it('uses the larger side with a constrained maxHeight when neither side fully fits', () => { + const placement = computeSchedulePickerPlacement({ + anchor: { top: 140, right: 132, bottom: 172, left: 100 }, + panelWidth: 288, + panelHeight: 260, + viewport: { width: 390, height: 300 }, + }) + + expect(placement.placement).toBe('above') + expect(placement.top).toBe(8) + expect(placement.maxHeight).toBe(124) + }) + + it('keeps placement inside an offset visual viewport', () => { + const placement = computeSchedulePickerPlacement({ + anchor: { top: 260, right: 372, bottom: 292, left: 340 }, + ...panel, + viewport: { width: 375, height: 500, offsetTop: 100, offsetLeft: 10 }, + }) + + expect(placement.left).toBe(89) + expect(placement.top).toBeGreaterThanOrEqual(108) + expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(600 - 8) + }) +}) diff --git a/web/src/components/AssistantChat/ScheduleTimePicker.tsx b/web/src/components/AssistantChat/ScheduleTimePicker.tsx index 452e12ec..f0090600 100644 --- a/web/src/components/AssistantChat/ScheduleTimePicker.tsx +++ b/web/src/components/AssistantChat/ScheduleTimePicker.tsx @@ -69,6 +69,70 @@ export function validateSpecificDatetime( return null } +type RectLike = Pick + +export type SchedulePickerViewport = { + width: number + height: number + offsetTop?: number + offsetLeft?: number +} + +export type SchedulePickerPlacement = { + top: number + left: number + maxHeight: number + placement: 'above' | 'below' +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +export function computeSchedulePickerPlacement(params: { + anchor: RectLike + panelWidth: number + panelHeight: number + viewport: SchedulePickerViewport + margin?: number + gap?: number +}): SchedulePickerPlacement { + const margin = params.margin ?? 8 + const gap = params.gap ?? 8 + const viewportLeft = params.viewport.offsetLeft ?? 0 + const viewportTop = params.viewport.offsetTop ?? 0 + const viewportRight = viewportLeft + params.viewport.width + const viewportBottom = viewportTop + params.viewport.height + + const panelWidth = Math.min(params.panelWidth, Math.max(0, params.viewport.width - margin * 2)) + const minLeft = viewportLeft + margin + const maxLeft = viewportRight - panelWidth - margin + const left = clamp(params.anchor.left, minLeft, Math.max(minLeft, maxLeft)) + + const spaceAbove = params.anchor.top - gap - (viewportTop + margin) + const spaceBelow = viewportBottom - margin - (params.anchor.bottom + gap) + const fitsAbove = params.panelHeight <= spaceAbove + const fitsBelow = params.panelHeight <= spaceBelow + + if (fitsAbove || (!fitsBelow && spaceAbove >= spaceBelow)) { + const maxHeight = Math.max(0, Math.min(params.panelHeight, spaceAbove)) + return { + placement: 'above', + top: Math.max(viewportTop + margin, params.anchor.top - gap - maxHeight), + left, + maxHeight, + } + } + + const maxHeight = Math.max(0, Math.min(params.panelHeight, spaceBelow)) + return { + placement: 'below', + top: params.anchor.bottom + gap, + left, + maxHeight, + } +} + // --------------------------------------------------------------------------- // Relative presets // --------------------------------------------------------------------------- @@ -100,33 +164,49 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche const [specificValue, setSpecificValue] = useState('') const [specificError, setSpecificError] = useState(null) const panelRef = useRef(null) - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const [pos, setPos] = useState(null) + const [isMobilePanel, setIsMobilePanel] = useState(false) - // Compute fixed position above the anchor button, re-measure on resize/scroll + // Compute fixed position and keep it inside the visual viewport. On mobile, + // use a bottom panel instead of anchoring to the tiny toolbar button. useLayoutEffect(() => { function measure() { const anchor = anchorRef.current const panel = panelRef.current if (!anchor) return + const mobile = window.matchMedia('(max-width: 640px), (pointer: coarse)').matches + setIsMobilePanel(mobile) + if (mobile) { + setPos(null) + return + } const rect = anchor.getBoundingClientRect() - const panelHeight = panel ? panel.offsetHeight : 280 // fallback estimate - const topAbove = rect.top - panelHeight - 8 - const topBelow = rect.bottom + 8 - const fitsAbove = topAbove >= 8 - setPos({ - top: fitsAbove ? topAbove : topBelow, - left: rect.left, - }) + const viewport = window.visualViewport + setPos(computeSchedulePickerPlacement({ + anchor: rect, + panelWidth: panel?.offsetWidth || 288, + panelHeight: panel?.offsetHeight || 280, + viewport: { + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + offsetLeft: viewport?.offsetLeft ?? 0, + offsetTop: viewport?.offsetTop ?? 0, + }, + })) } measure() window.addEventListener('resize', measure, { passive: true }) window.addEventListener('scroll', measure, { passive: true, capture: true }) + window.visualViewport?.addEventListener('resize', measure, { passive: true }) + window.visualViewport?.addEventListener('scroll', measure, { passive: true }) return () => { window.removeEventListener('resize', measure) window.removeEventListener('scroll', measure, true) + window.visualViewport?.removeEventListener('resize', measure) + window.visualViewport?.removeEventListener('scroll', measure) } // anchorRef is a useRef object — stable identity, so this effect runs once on mount. - }, [anchorRef]) + }, [anchorRef, tab]) // Click-outside closes the panel. // @@ -197,11 +277,17 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche role="dialog" aria-label={t('composer.scheduleSend')} style={ - pos - ? { position: 'fixed', top: pos.top, left: pos.left } + isMobilePanel + ? { position: 'fixed' } + : pos + ? { position: 'fixed', top: pos.top, left: pos.left, maxHeight: pos.maxHeight } : { position: 'fixed', visibility: 'hidden' } } - className="z-50 w-72 rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg" + className={ + isMobilePanel + ? 'z-50 box-border max-h-[min(60dvh,calc(var(--app-viewport-height,100dvh)-24px))] overflow-y-auto rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+12px)]' + : 'z-50 box-border w-72 overflow-y-auto rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg' + } onPointerDown={(e) => e.stopPropagation()} > {/* Header */} @@ -272,7 +358,7 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche {specificError ? (

{specificError}

) : ( -

+

{t('composer.scheduleSpecificHint')}

)}