diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index f48c92e5..bd51bc9c 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -467,8 +467,8 @@ export function HappyComposer(props: { }, [haptic]) const handleSubmit = useCallback((event?: ReactFormEvent) => { - if (event && !attachmentsReady) { - event.preventDefault() + event?.preventDefault() + if (!attachmentsReady) { return } setShowContinueHint(false) diff --git a/web/src/components/AssistantChat/ScheduleTimePicker.test.tsx b/web/src/components/AssistantChat/ScheduleTimePicker.test.tsx new file mode 100644 index 00000000..2e7c5d72 --- /dev/null +++ b/web/src/components/AssistantChat/ScheduleTimePicker.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { ScheduleTimePicker } from './ScheduleTimePicker' + +describe('ScheduleTimePicker interactions', () => { + it('submits the specific datetime when Enter is pressed in the datetime input', () => { + const anchorRef = { current: document.createElement('button') } + const onSchedule = vi.fn() + const onClose = vi.fn() + const future = new Date(Date.now() + 60 * 60 * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + const value = `${future.getFullYear()}-${pad(future.getMonth() + 1)}-${pad(future.getDate())}T${pad(future.getHours())}:${pad(future.getMinutes())}` + + const onParentKeyDown = vi.fn() + + render( + +
+ +
+
+ ) + + fireEvent.click(screen.getByRole('button', { name: /specific/i })) + const input = screen.getByDisplayValue('') + fireEvent.change(input, { target: { value } }) + + const defaultNotPrevented = fireEvent.keyDown(input, { key: 'Enter' }) + + expect(defaultNotPrevented).toBe(false) + expect(onParentKeyDown).not.toHaveBeenCalled() + expect(onSchedule).toHaveBeenCalledWith({ type: 'absolute', ms: new Date(value).getTime() }) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/components/AssistantChat/ScheduleTimePicker.tsx b/web/src/components/AssistantChat/ScheduleTimePicker.tsx index f0090600..b9fdbab7 100644 --- a/web/src/components/AssistantChat/ScheduleTimePicker.tsx +++ b/web/src/components/AssistantChat/ScheduleTimePicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react' import { useTranslation } from '@/lib/use-translation' // --------------------------------------------------------------------------- @@ -166,6 +166,7 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche const panelRef = useRef(null) const [pos, setPos] = useState(null) const [isMobilePanel, setIsMobilePanel] = useState(false) + const [isContentConstrained, setIsContentConstrained] = useState(false) // Compute fixed position and keep it inside the visual viewport. On mobile, // use a bottom panel instead of anchoring to the tiny toolbar button. @@ -176,23 +177,28 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche if (!anchor) return const mobile = window.matchMedia('(max-width: 640px), (pointer: coarse)').matches setIsMobilePanel(mobile) + const fullHeight = (panel?.scrollHeight ?? (tab === 'specific' ? 260 : 180)) + 4 if (mobile) { + const viewportHeight = window.visualViewport?.height ?? window.innerHeight + setIsContentConstrained(fullHeight > Math.min(viewportHeight * 0.85, viewportHeight - 24)) setPos(null) return } const rect = anchor.getBoundingClientRect() const viewport = window.visualViewport - setPos(computeSchedulePickerPlacement({ + const placement = computeSchedulePickerPlacement({ anchor: rect, - panelWidth: panel?.offsetWidth || 288, - panelHeight: panel?.offsetHeight || 280, + panelWidth: panel?.offsetWidth || 320, + panelHeight: fullHeight, viewport: { width: viewport?.width ?? window.innerWidth, height: viewport?.height ?? window.innerHeight, offsetLeft: viewport?.offsetLeft ?? 0, offsetTop: viewport?.offsetTop ?? 0, }, - })) + }) + setIsContentConstrained(placement.maxHeight < fullHeight) + setPos(placement) } measure() window.addEventListener('resize', measure, { passive: true }) @@ -271,6 +277,13 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche if (specificError) setSpecificError(null) } + const handleSpecificKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter') return + event.preventDefault() + event.stopPropagation() + handleSpecificSubmit() + } + return (
e.stopPropagation()} > @@ -353,7 +366,8 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche min={minDatetimeLocal} max={maxDatetimeLocal} onChange={(e) => handleSpecificChange(e.target.value)} - className="w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)] focus:outline-none focus:ring-1 focus:ring-[var(--app-link)]" + onKeyDown={handleSpecificKeyDown} + className="w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm text-[var(--app-fg)] focus:outline-none focus:ring-1 focus:ring-[var(--app-link)]" /> {specificError ? (

{specificError}

@@ -366,7 +380,7 @@ export function ScheduleTimePicker({ onSchedule, onClose, anchorRef, pendingSche type="button" disabled={!specificValue} onClick={handleSpecificSubmit} - className="w-full rounded-lg bg-blue-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40" + className="w-full rounded-lg bg-blue-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40" > {t('composer.scheduleSend')}