mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Fix mobile schedule picker layout
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,70 @@ export function validateSpecificDatetime(
|
||||
return null
|
||||
}
|
||||
|
||||
type RectLike = Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left'>
|
||||
|
||||
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<string | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
const [pos, setPos] = useState<SchedulePickerPlacement | null>(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 ? (
|
||||
<p className="text-xs text-red-500">{specificError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--app-hint)]">
|
||||
<p className="break-words text-xs text-[var(--app-hint)]">
|
||||
{t('composer.scheduleSpecificHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user