diff --git a/.changeset/mobile-bottom-sheet-consolidation.md b/.changeset/mobile-bottom-sheet-consolidation.md new file mode 100644 index 0000000000..5c8693f603 --- /dev/null +++ b/.changeset/mobile-bottom-sheet-consolidation.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Rework the mobile emoji picker as a proper bottom sheet that keeps its size when the keyboard opens and can be swiped down to dismiss. diff --git a/src/app/components/MobileMenuItem.tsx b/src/app/components/MobileMenuItem.tsx index 9416aa5f72..9694424998 100644 --- a/src/app/components/MobileMenuItem.tsx +++ b/src/app/components/MobileMenuItem.tsx @@ -19,5 +19,5 @@ export function MobileMenuItem({ isMobile, onClick, ...props }: MobileMenuItemPr const activation = useMobileTapActivation(isMobile, (evt) => { onClick?.(evt); }); - return ; + return ; } diff --git a/src/app/components/MobileSwipeDownModal.test.tsx b/src/app/components/MobileSwipeDownModal.test.tsx new file mode 100644 index 0000000000..06296014e7 --- /dev/null +++ b/src/app/components/MobileSwipeDownModal.test.tsx @@ -0,0 +1,669 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { PointerEventHandler } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as css from '$features/room/message/styles.css'; +import { MobileMenuItem } from './MobileMenuItem'; +import { MobileSwipeDownModal, VIEWPORT_SETTLE_MS } from './MobileSwipeDownModal'; + +vi.mock('$state/hooks/settings', () => ({ + useSetting: () => [false, vi.fn<() => void>()], +})); + +const setInnerHeight = (value: number) => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value }); +}; + +/** use-gesture reads identifiers and page coords, so partial touches crash it. */ +const touchList = (target: HTMLElement, clientY: number) => { + const point = { identifier: 0, target, clientX: 0, clientY, pageX: 0, pageY: clientY }; + return { touches: [point], targetTouches: [point], changedTouches: [point] }; +}; + +const dragDown = (target: HTMLElement, from: number, to: number) => { + fireEvent.touchStart(target, touchList(target, from)); + fireEvent.touchMove(target, touchList(target, to)); + const released = touchList(target, to); + fireEvent.touchEnd(target, { ...released, touches: [], targetTouches: [] }); +}; + +const renderWithScroller = (requestClose: () => void) => { + render( + + {() => ( +
+
+
+ )} + + ); + return { scroller: screen.getByTestId('scroller'), row: screen.getByTestId('row') }; +}; + +/** jsdom reports zero for both, so a scroll container has to be declared. */ +const makeScrollable = (element: HTMLElement, scrollTop: number) => { + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: 1000 }); + Object.defineProperty(element, 'clientHeight', { configurable: true, value: 200 }); + element.scrollTop = scrollTop; +}; + +/** Completes the exit animation immediately, so a dismissal reaches `requestClose`. */ +const finishingAnimate = (() => ({ + addEventListener: (_event: string, callback: () => void) => callback(), +})) as unknown as HTMLElement['animate']; + +/** jsdom implements neither of these, and the sheet drives both. */ +function stubElementAnimations(animate: HTMLElement['animate'] = finishingAnimate) { + const descriptors = (['getAnimations', 'animate'] as const).map( + (key) => [key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key)] as const + ); + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: () => [], + }); + Object.defineProperty(HTMLElement.prototype, 'animate', { configurable: true, value: animate }); + + return () => { + descriptors.forEach(([key, descriptor]) => { + if (descriptor) Object.defineProperty(HTMLElement.prototype, key, descriptor); + else delete (HTMLElement.prototype as Partial)[key]; + }); + }; +} + +/** + * Drives the visual viewport the way a soft keyboard does. The sheet only acts on a + * settled measurement, so `emitResize` (notify only) and `resizeTo` (notify, then let + * the settle elapse) are deliberately separate. + */ +function mockVisualViewport(height: number) { + const originalViewport = window.visualViewport; + const originalInnerHeight = window.innerHeight; + const restoreAnimations = stubElementAnimations(); + const listeners = new Set<() => void>(); + const viewport = { + height, + offsetTop: 0, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener), + }; + + Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport }); + vi.useFakeTimers(); + + const emitResize = (next: number, offsetTop = viewport.offsetTop) => { + viewport.height = next; + viewport.offsetTop = offsetTop; + act(() => listeners.forEach((listener) => listener())); + }; + + return { + emitResize, + /** Advances exactly the settle window, so a longer one would fail the test. */ + resizeTo(next: number, offsetTop?: number) { + emitResize(next, offsetTop); + act(() => vi.advanceTimersByTime(VIEWPORT_SETTLE_MS)); + }, + rotate(nextInnerHeight: number) { + // `innerHeight` still reports the pre-rotation size when this event fires; + // the new size only lands with the resize that follows. + act(() => window.dispatchEvent(new Event('orientationchange'))); + setInnerHeight(nextInnerHeight); + emitResize(nextInnerHeight); + act(() => vi.advanceTimersByTime(VIEWPORT_SETTLE_MS)); + }, + restore() { + vi.useRealTimers(); + restoreAnimations(); + setInnerHeight(originalInnerHeight); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: originalViewport, + }); + }, + }; +} + +describe('MobileSwipeDownModal', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders immediately without a mount delay', () => { + render( + void>()}> + {() =>
} + + ); + + expect(screen.getByTestId('immediate-content')).toBeInTheDocument(); + }); + + it('lifts the sheet clear of the keyboard with a transition, without resizing it', () => { + const viewport = mockVisualViewport(800); + let contentRenderCount = 0; + + try { + setInnerHeight(800); + render( + void>()} + keyboardAware + sheetClassName="picker-sheet" + > + {() => { + contentRenderCount += 1; + return
; + }} + + ); + + const panel = screen.getByTestId('stable-content').closest('.picker-sheet') as HTMLElement; + + viewport.resizeTo(500); + + // The height itself is CSS-owned (see pickerHeight); what the component must + // get right is the variables feeding it, and lifting rather than resizing. + expect(panel.style.transform).toBe('translate3d(0, -300px, 0)'); + expect(panel.style.transition).toContain('transform'); + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('800px'); + expect(panel.style.getPropertyValue('--mobile-sheet-visible')).toBe('500px'); + expect(panel.style.getPropertyValue('--mobile-sheet-safe-bottom')).toBe('0px'); + expect(contentRenderCount).toBe(1); + } finally { + viewport.restore(); + } + }); + + it('ignores the transient where only the visual viewport has shrunk', () => { + const viewport = mockVisualViewport(807); + + try { + setInnerHeight(807); + render( + void>()} + keyboardAware + sheetClassName="transient-sheet" + > + {() =>
} + + ); + + const panel = screen + .getByTestId('transient-content') + .closest('.transient-sheet') as HTMLElement; + + // Stage one: the visual viewport shrinks alone and momentarily looks like a + // keyboard worth lifting over. Stage two lands before the settle elapses. + viewport.emitResize(517); + // Bounds hardcoded on purpose: a longer settle would stop tracking the + // keyboard, a shorter one would act on the half-shrunk viewport. + act(() => vi.advanceTimersByTime(VIEWPORT_SETTLE_MS - 1)); + expect(panel.style.transform).toBe(''); + act(() => vi.advanceTimersByTime(1)); + expect(VIEWPORT_SETTLE_MS).toBeLessThanOrEqual(250); + expect(VIEWPORT_SETTLE_MS).toBeGreaterThanOrEqual(120); + + setInnerHeight(516); + viewport.resizeTo(517); + + // The layout viewport shrank too, so `bottom: 0` already sits the sheet right. + expect(panel.style.transform).toBe(''); + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('807px'); + } finally { + viewport.restore(); + } + }); + + it('keeps its size when the platform shrinks the layout viewport for the keyboard', () => { + const viewport = mockVisualViewport(800); + + try { + setInnerHeight(800); + render( + void>()} + keyboardAware + sheetClassName="resized-sheet" + > + {() =>
} + + ); + + const panel = screen.getByTestId('resized-content').closest('.resized-sheet') as HTMLElement; + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('800px'); + + setInnerHeight(480); + viewport.resizeTo(480); + + // Nothing to lift over, and the sheet must not follow the shrink. + expect(panel.style.transform).toBe(''); + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('800px'); + } finally { + viewport.restore(); + } + }); + + it('ignores drags once closing, so the exit animation still unmounts the sheet', async () => { + const requestClose = vi.fn<() => void>(); + const running: { cancel: ReturnType }[] = []; + let finish: (() => void) | undefined; + const originalGetAnimations = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' + ); + const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'animate'); + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: () => running, + }); + Object.defineProperty(HTMLElement.prototype, 'animate', { + configurable: true, + value: () => { + const animation = { + cancel: vi.fn<() => void>(), + addEventListener: (_event: string, callback: () => void) => { + finish = callback; + }, + }; + running.push(animation); + return animation; + }, + }); + + try { + render( + + {() =>
} + + ); + await act(async () => {}); + + const handle = screen.getByTestId('mobile-sheet-drag-handle'); + const panel = handle.parentElement as HTMLElement; + const scrim = panel.parentElement as HTMLElement; + + fireEvent.click(scrim); + const closeAnimation = running.at(-1)!; + + // Cancelling the exit animation would strip its `finish` listener, which is + // the only thing that unmounts the sheet, leaving an invisible scrim. + dragDown(handle, 100, 300); + + expect(closeAnimation.cancel).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe(''); + + finish?.(); + expect(requestClose).toHaveBeenCalledTimes(1); + } finally { + if (originalGetAnimations) { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', originalGetAnimations); + } else { + delete (HTMLElement.prototype as Partial).getAnimations; + } + if (originalAnimate) { + Object.defineProperty(HTMLElement.prototype, 'animate', originalAnimate); + } else { + delete (HTMLElement.prototype as Partial).animate; + } + } + }); + + describe('dragging the body', () => { + it('activates a marked menu item exactly once with small jitter', async () => { + const requestClose = vi.fn<() => void>(); + const activate = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + render( + + {() => ( + + Leave Room + + )} + + ); + await act(async () => {}); + + const button = screen.getByTestId('marked-menu-item'); + const panel = button.parentElement?.parentElement as HTMLElement; + const pointer = { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + button: 0, + clientX: 0, + clientY: 100, + timeStamp: 100, + }; + + fireEvent.pointerDown(button, pointer); + fireEvent.pointerMove(button, { ...pointer, clientY: 102, timeStamp: 105 }); + fireEvent.pointerUp(button, { ...pointer, clientY: 102, timeStamp: 110 }); + fireEvent.click(button, { pointerType: 'touch' }); + + expect(activate).toHaveBeenCalledTimes(1); + expect(requestClose).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe(''); + } finally { + restore(); + } + }); + + it('does not translate or dismiss when dragging a marked menu item', async () => { + const requestClose = vi.fn<() => void>(); + const activate = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + render( + + {() => ( + + Leave Room + + )} + + ); + await act(async () => {}); + + const button = screen.getByTestId('marked-menu-item'); + const panel = button.parentElement?.parentElement as HTMLElement; + dragDown(button, 100, 240); + + expect(activate).not.toHaveBeenCalled(); + expect(requestClose).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe(''); + } finally { + restore(); + } + }); + + it('dismisses when the list inside is already at the top', async () => { + const requestClose = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + const { scroller, row } = renderWithScroller(requestClose); + await act(async () => {}); + makeScrollable(scroller, 0); + + dragDown(row, 100, 240); + + expect(requestClose).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + + it('leaves the gesture to a list that is scrolled away from the top', async () => { + const requestClose = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + const { scroller, row } = renderWithScroller(requestClose); + await act(async () => {}); + const panel = scroller.parentElement?.parentElement as HTMLElement; + makeScrollable(scroller, 120); + + dragDown(row, 100, 240); + + expect(requestClose).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe(''); + } finally { + restore(); + } + }); + + it('keeps refusing right after a list handed back the gesture', async () => { + const requestClose = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + const { scroller, row } = renderWithScroller(requestClose); + await act(async () => {}); + makeScrollable(scroller, 120); + + // First gesture is refused because the list is scrolled. + dragDown(row, 100, 240); + // The fling lands at the top; a drag started immediately after must still be + // the list's, or the sheet snatches the tail of a scroll. + scroller.scrollTop = 0; + dragDown(row, 100, 240); + + expect(requestClose).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('leaves the gesture alone while text is selected', async () => { + const requestClose = vi.fn<() => void>(); + const restore = stubElementAnimations(); + const selection = { toString: () => 'selected text' } as Selection; + vi.spyOn(window, 'getSelection').mockReturnValue(selection); + + try { + const { scroller, row } = renderWithScroller(requestClose); + await act(async () => {}); + makeScrollable(scroller, 0); + + // Dragging out a selection must not also drag the sheet away. + dragDown(row, 100, 240); + + expect(requestClose).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('still drags from the handle while the list is scrolled', async () => { + const requestClose = vi.fn<() => void>(); + const restore = stubElementAnimations(); + + try { + const { scroller, row } = renderWithScroller(requestClose); + await act(async () => {}); + makeScrollable(scroller, 500); + + // Refused first, which arms the cooldown. The handle must be exempt from it, + // or grabbing the handle right after a scroll would do nothing. + dragDown(row, 100, 240); + expect(requestClose).not.toHaveBeenCalled(); + + dragDown(screen.getByTestId('mobile-sheet-drag-handle'), 100, 240); + + expect(requestClose).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + }); + + it('accounts for a visual viewport scrolled under the keyboard', () => { + const viewport = mockVisualViewport(800); + + try { + setInnerHeight(800); + render( + void>()} + keyboardAware + sheetClassName="offset-sheet" + > + {() =>
} + + ); + + const panel = screen.getByTestId('offset-content').closest('.offset-sheet') as HTMLElement; + + // iOS scrolls the visual viewport rather than only shrinking it, so the + // covered strip is innerHeight - offsetTop - height, not innerHeight - height. + viewport.resizeTo(500, 60); + + expect(panel.style.transform).toBe('translate3d(0, -240px, 0)'); + } finally { + viewport.restore(); + } + }); + + it('re-measures after rotation instead of keeping the latched height', () => { + const viewport = mockVisualViewport(800); + + try { + setInnerHeight(800); + render( + void>()} + keyboardAware + sheetClassName="rotated-sheet" + > + {() =>
} + + ); + + const panel = screen.getByTestId('rotated-content').closest('.rotated-sheet') as HTMLElement; + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('800px'); + + // The latch is monotonic, so without a reset landscape would keep the taller + // portrait height forever. + viewport.rotate(400); + + expect(panel.style.getPropertyValue('--mobile-sheet-viewport')).toBe('400px'); + } finally { + viewport.restore(); + } + }); + + it('composes the drag offset with the keyboard offset in one transform', () => { + const viewport = mockVisualViewport(800); + + try { + setInnerHeight(800); + render( + void>()} + keyboardAware + sheetClassName="composed-sheet" + > + {() =>
} + + ); + viewport.resizeTo(500); + + const panel = screen + .getByTestId('composed-content') + .closest('.composed-sheet') as HTMLElement; + const handle = screen.getByTestId('mobile-sheet-drag-handle'); + expect(panel.style.transform).toBe('translate3d(0, -300px, 0)'); + + // Dragging down while lifted must subtract from the keyboard offset rather + // than clobber it. Both live on the same transform. + fireEvent.touchStart(handle, touchList(handle, 100)); + fireEvent.touchMove(handle, touchList(handle, 115)); + expect(panel.style.transform).toBe('translate3d(0, -286px, 0)'); + expect(panel.style.transition).toBe(''); + + // Released under the dismiss thresholds: springs back to the lifted position. + const released = touchList(handle, 115); + fireEvent.touchEnd(handle, { ...released, touches: [], targetTouches: [] }); + expect(panel.style.transform).toBe('translate3d(0, -300px, 0)'); + expect(panel.style.transition).toContain('transform'); + } finally { + viewport.restore(); + } + }); + + it('uses the supplied portal target instead of document.body', async () => { + const portalTarget = document.createElement('div'); + portalTarget.dataset.testid = 'portal-target'; + document.body.append(portalTarget); + render( + void>()} + containerRef={{ current: portalTarget }} + > + {() =>
} + + ); + await act(async () => {}); + + const target = screen.getByTestId('portal-target'); + expect(target).toContainElement(screen.getByTestId('portal-content')); + }); + + it('contains a custom-target sheet within the active pane', async () => { + const portalTarget = document.createElement('div'); + portalTarget.dataset.testid = 'contained-pane'; + document.body.append(portalTarget); + + render( + void>()} + containerRef={{ current: portalTarget }} + > + {() =>
} + + ); + await act(async () => {}); + + const content = screen.getByTestId('contained-content'); + const panel = content.parentElement?.parentElement as HTMLElement; + const scrim = portalTarget.firstElementChild as HTMLElement; + + expect(scrim.parentElement).toBe(portalTarget); + expect(scrim.classList).toContain(css.MessageMobileOptionsWrappedContained); + expect(panel.classList).toContain(css.MessageMobileOptionsContainerContained); + }); + + it('shields panel pointer events from the portal host', async () => { + const onPointerDown = vi.fn>(); + const portalTarget = document.createElement('div'); + document.body.append(portalTarget); + render( +
+ void>()} + containerRef={{ current: portalTarget }} + > + {() =>
} + +
+ ); + await act(async () => {}); + fireEvent.pointerDown(screen.getByTestId('shielded-content')); + + expect(onPointerDown).not.toHaveBeenCalled(); + }); + + it('dismisses from the drag handle', async () => { + const requestClose = vi.fn<() => void>(); + const animations: Keyframe[][] = []; + const restoreAnimations = stubElementAnimations(((keyframes: Keyframe[]) => { + animations.push(keyframes); + return { addEventListener: (_event: string, callback: () => void) => callback() }; + }) as unknown as HTMLElement['animate']); + + try { + render( + + {() =>
} + + ); + await act(async () => {}); + + const handle = screen.getByTestId('mobile-sheet-drag-handle'); + dragDown(handle, 100, 240); + + expect(requestClose).toHaveBeenCalledTimes(1); + expect(animations.at(-1)).toEqual([ + { transform: 'translate3d(0, 139px, 0)' }, + { transform: 'translate3d(0, 100%, 0)' }, + ]); + } finally { + restoreAnimations(); + } + }); +}); diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 23665c67a8..fc9cc61fee 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -1,59 +1,195 @@ -import React, { createContext, useCallback, useContext, useRef, useState, useEffect } from 'react'; +import type { ComponentProps, RefObject } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { Box } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useDrag } from '@use-gesture/react'; import * as css from '$features/room/message/styles.css'; import { useDismissOnBack } from '$utils/androidBack'; +import { stopPropagation } from '$utils/keyboard'; import { getMobileSheetTiming, useMobileSheetAnimation } from './mobileSheetAnimation'; +import { MOBILE_SHEET_DURATION_MS, MOBILE_SHEET_EASING } from './mobileSheetAnimationConstants'; import * as animationCss from './mobileSheetAnimation.css'; interface MobileSwipeDownModalProps { - children: ( - dragHandle: React.ReactNode, - dragHandlers: { - onTouchStart: (e: React.TouchEvent) => void; - onTouchMove: (e: React.TouchEvent) => void; - onTouchEnd: () => void; - } - ) => React.ReactNode; + children: () => React.ReactNode; requestClose: () => void; + containerRef?: RefObject; + focusTrap?: boolean; + dialogLabel?: string; + skipReturnFocusRef?: RefObject; + sheetClassName?: string; + keyboardAware?: boolean; } +type FocusTrapOptions = ComponentProps['focusTrapOptions']; + const MobileSheetCloseContext = createContext<(() => void) | null>(null); +/** Long enough to outlast the gap between the visual and layout viewport resizes. */ +export const VIEWPORT_SETTLE_MS = 180; + +const DISMISS_DISTANCE_PX = 100; +const DISMISS_VELOCITY = 0.5; +/** Keeps a fling that lands at the top of a list from turning into a sheet drag. */ +const DRAG_BLOCKED_COOLDOWN_MS = 200; + +const HANDLE_ATTRIBUTE = 'data-mobile-sheet-handle'; +const NO_DRAG_ATTRIBUTE = 'data-mobile-sheet-no-drag'; + +function getKeyboardOverlap(): number { + const viewport = window.visualViewport; + if (!viewport) return 0; + return Math.max(0, window.innerHeight - viewport.offsetTop - viewport.height); +} + +/** Nearest ancestor between `from` and the sheet that can actually scroll vertically. */ +function findScroller(from: HTMLElement | null, boundary: HTMLElement): HTMLElement | null { + let element = from; + while (element && element !== boundary) { + const { overflowY } = window.getComputedStyle(element); + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + element.scrollHeight > element.clientHeight + ) { + return element; + } + element = element.parentElement; + } + return null; +} + export function useMobileSheetClose() { return useContext(MobileSheetCloseContext); } -export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDownModalProps) { - const containerRef = useRef(null); - const touchStartY = useRef(null); +export function MobileSwipeDownModal({ + children, + requestClose, + containerRef: portalRef, + focusTrap = false, + dialogLabel, + skipReturnFocusRef, + sheetClassName, + keyboardAware = false, +}: MobileSwipeDownModalProps) { + const sheetRef = useRef(null); const backdropTouchRef = useRef(false); const touchYDiff = useRef(0); - const startTime = useRef(0); - const [mounted, setMounted] = useState(false); + const keyboardOffset = useRef(0); const [closing, setClosing] = useState(false); const closingRef = useRef(false); - const resetAnimationRef = useRef(); const { shouldReduceMotion } = useMobileSheetAnimation(); - useEffect(() => { - setMounted(true); - }, []); + // Keyboard and drag offsets share one transform; `bottom` would relayout the subtree. + const applySheetOffset = useCallback( + (animate: boolean) => { + const sheet = sheetRef.current; + if (!sheet) return; + // The entrance keyframes animate `transform` too, and a CSS animation outranks + // an inline style, so leaving one running would mask this write until it ends. + sheet.getAnimations().forEach((animation) => animation.cancel()); + const y = touchYDiff.current - keyboardOffset.current; + sheet.style.transition = + animate && !shouldReduceMotion + ? `transform ${MOBILE_SHEET_DURATION_MS}ms ${MOBILE_SHEET_EASING}` + : ''; + sheet.style.transform = y === 0 ? '' : `translate3d(0, ${y}px, 0)`; + // Once lifted above the keyboard the bottom inset is no longer a safe area. + sheet.style.setProperty( + '--mobile-sheet-safe-bottom', + keyboardOffset.current > 0 ? '0px' : '' + ); + }, + [shouldReduceMotion] + ); + + useLayoutEffect(() => { + if (!keyboardAware) return undefined; + + // Some platforms shrink the layout viewport for the keyboard too. The tallest + // one seen has no keyboard in it, so sizing off that keeps the sheet one size. + let stableViewportHeight = 0; + const publishViewportHeight = () => { + const next = Math.max(stableViewportHeight, window.innerHeight); + if (next === stableViewportHeight) return; + stableViewportHeight = next; + sheetRef.current?.style.setProperty('--mobile-sheet-viewport', `${next}px`); + }; + + // The two viewports shrink in separate stages. Mid-way the visual one alone + // looks like a keyboard to lift over, and acting on it throws the sheet off + // screen once the layout viewport catches up, so only the settled value counts. + let settleTimer: number | undefined; + const applySettled = () => { + // Cancelling the exit animation would strand the sheet: its `finish` listener + // is the only thing that unmounts it. + if (closingRef.current) return; + publishViewportHeight(); + const visible = window.visualViewport?.height ?? window.innerHeight; + sheetRef.current?.style.setProperty('--mobile-sheet-visible', `${visible}px`); + + const next = getKeyboardOverlap(); + if (next === keyboardOffset.current) return; + keyboardOffset.current = next; + applySheetOffset(true); + }; + const scheduleSettle = () => { + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(applySettled, VIEWPORT_SETTLE_MS); + }; + const handleViewportChange = () => { + publishViewportHeight(); + scheduleSettle(); + }; + const handleOrientationChange = () => { + // `innerHeight` still reports the pre-rotation size here, so drop the latch and + // let the settled pass re-measure rather than re-latching the stale value. + stableViewportHeight = 0; + scheduleSettle(); + }; + const viewport = window.visualViewport; + + applySettled(); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange); + window.addEventListener('orientationchange', handleOrientationChange); + viewport?.addEventListener('resize', handleViewportChange); + viewport?.addEventListener('scroll', handleViewportChange); + + return () => { + window.clearTimeout(settleTimer); + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange); + window.removeEventListener('orientationchange', handleOrientationChange); + viewport?.removeEventListener('resize', handleViewportChange); + viewport?.removeEventListener('scroll', handleViewportChange); + }; + }, [keyboardAware, applySheetOffset]); const closeWithAnimation = useCallback(() => { if (closingRef.current) return; closingRef.current = true; setClosing(true); - const container = containerRef.current; + const container = sheetRef.current; if (!container) { requestClose(); return; } container.getAnimations().forEach((animation) => animation.cancel()); + container.style.transition = ''; + const startY = touchYDiff.current - keyboardOffset.current; const animation = container.animate( - [{ transform: 'translate3d(0, 0, 0)' }, { transform: 'translate3d(0, 100%, 0)' }], + [{ transform: `translate3d(0, ${startY}px, 0)` }, { transform: 'translate3d(0, 100%, 0)' }], { ...getMobileSheetTiming(shouldReduceMotion), duration: shouldReduceMotion ? 0 : 140, @@ -65,57 +201,72 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown // Android back closes the overlay instead of navigating away. useDismissOnBack(closeWithAnimation); - const handleTouchStart = (e: React.TouchEvent) => { - touchStartY.current = e.touches[0]?.clientY ?? null; - startTime.current = Date.now(); - }; + /** + * Whether this gesture belongs to the sheet or to a list inside it. Decided once, + * at the start, so a fling that reaches the top of a list mid-gesture cannot be + * stolen by the sheet. + */ + const claimedRef = useRef(false); + const dragBlockedAtRef = useRef(0); - const handleTouchMove = (e: React.TouchEvent) => { - if (touchStartY.current === null || !e.touches[0]) return; - const touchY = e.touches[0].clientY; - const diff = touchY - touchStartY.current; - - // Only allow pulling down - if (diff > 0) { - touchYDiff.current = diff; - resetAnimationRef.current?.cancel(); - containerRef.current?.getAnimations().forEach((animation) => animation.cancel()); - if (containerRef.current) { - containerRef.current.style.transform = `translate3d(0, ${diff}px, 0)`; - } + const claimGesture = useCallback((target: EventTarget | null) => { + const sheet = sheetRef.current; + if (!sheet || closingRef.current) return false; + const from = target instanceof HTMLElement ? target : null; + // The handle is not over any content, so it always drags. + if (from?.closest(`[${HANDLE_ATTRIBUTE}]`)) return true; + if (from?.closest(`[${NO_DRAG_ATTRIBUTE}]`)) return false; + if (window.getSelection()?.toString()) return false; + if (Date.now() - dragBlockedAtRef.current < DRAG_BLOCKED_COOLDOWN_MS) return false; + + const scroller = findScroller(from, sheet); + if (scroller && scroller.scrollTop > 0) { + dragBlockedAtRef.current = Date.now(); + return false; } - }; + return true; + }, []); - const handleTouchEnd = () => { - const endTime = Date.now(); - if ( - touchYDiff.current > 100 || - (endTime - startTime.current < 600 && touchYDiff.current > 20) - ) { - closeWithAnimation(); - } else { - const currentOffset = touchYDiff.current; - touchYDiff.current = 0; - if (containerRef.current) { - const container = containerRef.current; - resetAnimationRef.current = container.animate( - [ - { transform: `translate3d(0, ${currentOffset}px, 0)` }, - { transform: 'translate3d(0, 0, 0)' }, - ], - getMobileSheetTiming(shouldReduceMotion) - ); - resetAnimationRef.current.addEventListener( - 'finish', - () => { - container.style.transform = ''; - }, - { once: true } - ); + useDrag( + ({ first, last, movement: [, my], velocity: [, vy], direction: [, dy], event, cancel }) => { + if (first) { + claimedRef.current = claimGesture(event.target); + if (!claimedRef.current) { + cancel(); + return; + } + } + if (!claimedRef.current) return; + + // Only pull down; an upward move unwinds back to the resting offset. + touchYDiff.current = Math.max(0, my); + if (touchYDiff.current > 0) event.preventDefault(); + + if (last) { + claimedRef.current = false; + const flicked = dy > 0 && vy > DISMISS_VELOCITY && touchYDiff.current > 20; + if (touchYDiff.current > DISMISS_DISTANCE_PX || flicked) { + closeWithAnimation(); + return; + } + touchYDiff.current = 0; + applySheetOffset(true); + return; } + + applySheetOffset(false); + }, + { + // React cannot attach non-passive listeners, so the gesture binds to the element + // itself; without that `preventDefault` is a no-op and the list scrolls anyway. + target: sheetRef, + eventOptions: { passive: false }, + pointer: { capture: false, touch: true }, + axis: 'y', + // MobileMenuItem owns tap activation for marked actions; the sheet owns drag arbitration. + filterTaps: false, } - touchStartY.current = null; - }; + ); // A sheet opened by a long press mounts under the finger, and releasing it // synthesises a click on the backdrop. Only a touch that started on the @@ -127,44 +278,91 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown closeWithAnimation(); }; - const dragHandlers = { - onTouchStart: handleTouchStart, - onTouchMove: handleTouchMove, - onTouchEnd: handleTouchEnd, - }; - const dragHandleJSX = ( -
+
); - if (!mounted) return null; + const target = portalRef ? portalRef.current : document.body; + if (!target) return null; + + const sheetContent = ( + + {children()} + + ); + + const focusTrapOptions: FocusTrapOptions = { + initialFocus: false, + fallbackFocus: () => sheetRef.current ?? target, + preventScroll: true, + returnFocusOnDeactivate: true, + setReturnFocus: (previousActiveElement: HTMLElement) => + skipReturnFocusRef?.current ? false : previousActiveElement, + allowOutsideClick: true, + clickOutsideDeactivates: false, + escapeDeactivates: (event: KeyboardEvent) => { + if (!stopPropagation(event)) return false; + closeWithAnimation(); + return false; + }, + }; + + const dialog = focusTrap ? ( + +
+ {sheetContent} +
+
+ ) : ( + sheetContent + ); return createPortal( { backdropTouchRef.current = e.target === e.currentTarget; - e.stopPropagation(); }} - onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} - onTouchEnd={(e: React.TouchEvent) => e.stopPropagation()} + onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} + onPointerMove={(e: React.PointerEvent) => e.stopPropagation()} + onPointerUp={(e: React.PointerEvent) => e.stopPropagation()} > e.stopPropagation()} + onPointerMove={(e: React.PointerEvent) => e.stopPropagation()} + onPointerUp={(e: React.PointerEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()} > - - {children(dragHandleJSX, dragHandlers)} - + {dragHandleJSX} +
{dialog}
, - document.body + target ); } diff --git a/src/app/components/ResponsiveMenu.css.ts b/src/app/components/ResponsiveMenu.css.ts index 36c452f924..82ad7c44cb 100644 --- a/src/app/components/ResponsiveMenu.css.ts +++ b/src/app/components/ResponsiveMenu.css.ts @@ -29,7 +29,9 @@ export const SheetContentThemed = style({}); // Targets the caller's menu element, which may be any component. Reaching it by // selector rather than cloneElement keeps it working when the caller does not -// forward className. +// forward className. The sheet panel draws the background, radius and shadow, so +// the menu inside it must draw none of its own or its border shows up under the +// drag handle. globalStyle(`${SheetContent} > *:last-child`, { // !important beats the inline maxWidth/width the callers set for their desktop popout. width: '100% !important', @@ -38,23 +40,12 @@ globalStyle(`${SheetContent} > *:last-child`, { position: 'relative', display: 'flex', flexDirection: 'column', - borderBottomLeftRadius: '0 !important', - borderBottomRightRadius: '0 !important', - borderBottom: 'none !important', - borderTopLeftRadius: `${toRem(20)} !important`, - borderTopRightRadius: `${toRem(20)} !important`, - paddingBottom: `calc(${config.space.S400} + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))) !important`, -}); - -globalStyle(`${SheetContent} > *:last-child::after`, { - content: '""', - position: 'absolute', - top: '100%', - left: 0, - right: 0, - height: '300px', - backgroundColor: 'inherit', - border: 'none', + border: 'none !important', + borderRadius: '0 !important', + background: 'transparent !important', + boxShadow: 'none !important', + paddingTop: '0 !important', + paddingBottom: `${config.space.S400} !important`, }); globalStyle(`${SheetContent}.${SheetContentThemed} > *:last-child`, { diff --git a/src/app/components/ResponsiveMenu.test.tsx b/src/app/components/ResponsiveMenu.test.tsx index c683f4aef1..3f9c21b59f 100644 --- a/src/app/components/ResponsiveMenu.test.tsx +++ b/src/app/components/ResponsiveMenu.test.tsx @@ -45,11 +45,8 @@ vi.mock('focus-trap-react', () => ({ vi.mock('./MobileSwipeDownModal', () => ({ MobileSwipeDownModal: ({ children, requestClose }: any) => (
- {children(
drag-handle
, { - onTouchStart: vi.fn<() => void>(), - onTouchMove: vi.fn<() => void>(), - onTouchEnd: vi.fn<() => void>(), - })} +
drag-handle
+ {children()}
), })); diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx index ca5862da28..6ec36f8d29 100644 --- a/src/app/components/ResponsiveMenu.tsx +++ b/src/app/components/ResponsiveMenu.tsx @@ -114,7 +114,7 @@ export function ResponsiveMenu({ )} {anchor && mobile === 'sheet' && ( - {(dragHandle) => ( + {() => ( - {dragHandle} {menu} diff --git a/src/app/components/attachment-sheet/AttachmentSheet.css.ts b/src/app/components/attachment-sheet/AttachmentContent.css.ts similarity index 82% rename from src/app/components/attachment-sheet/AttachmentSheet.css.ts rename to src/app/components/attachment-sheet/AttachmentContent.css.ts index c06e5f7f6a..31a60ee09a 100644 --- a/src/app/components/attachment-sheet/AttachmentSheet.css.ts +++ b/src/app/components/attachment-sheet/AttachmentContent.css.ts @@ -1,51 +1,23 @@ import { style } from '@vanilla-extract/css'; import { color, toRem } from 'folds'; -export const Backdrop = style({ - position: 'absolute', - inset: 0, - zIndex: 1000, - background: 'rgba(0, 0, 0, 0.42)', - touchAction: 'none', -}); - export const Sheet = style({ - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - zIndex: 1001, display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, boxSizing: 'border-box', - background: color.Surface.Container, - borderTopLeftRadius: toRem(20), - borderTopRightRadius: toRem(20), - paddingBottom: `calc(${toRem(12)} + env(safe-area-inset-bottom, 0px))`, - maxHeight: '100%', + // The panel supplies the safe-area inset; this is just the gutter under the actions. + paddingBottom: toRem(12), overflowX: 'hidden', overflowY: 'auto', - boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.15)', + overscrollBehavior: 'contain', }); export const SheetHeader = style({ position: 'relative', flexShrink: 0, - padding: `${toRem(22)} ${toRem(16)} ${toRem(8)}`, -}); - -export const DragHandle = style({ - position: 'absolute', - top: toRem(8), - left: '50%', - width: toRem(40), - height: toRem(4), - borderRadius: toRem(4), - background: color.Surface.OnContainer, - opacity: 0.3, - transform: 'translateX(-50%)', + padding: `0 ${toRem(16)} ${toRem(8)}`, }); export const Heading = style({ diff --git a/src/app/components/attachment-sheet/AttachmentContent.test.tsx b/src/app/components/attachment-sheet/AttachmentContent.test.tsx new file mode 100644 index 0000000000..30676d18bb --- /dev/null +++ b/src/app/components/attachment-sheet/AttachmentContent.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AttachmentContent } from './AttachmentContent'; + +describe('AttachmentContent', () => { + it('dispatches native attachment actions synchronously and suppresses focus restore', () => { + const skipReturnFocusRef = { current: false }; + const onPickPhotos = vi.fn<() => void>(); + + render( + void>()} + onPickPoll={vi.fn<() => void>()} + onPickLocation={vi.fn<() => void>()} + skipReturnFocusRef={skipReturnFocusRef} + /> + ); + + screen.getByRole('button', { name: 'Open photo gallery' }).click(); + + expect(onPickPhotos).toHaveBeenCalledOnce(); + expect(skipReturnFocusRef.current).toBe(true); + }); +}); diff --git a/src/app/components/attachment-sheet/AttachmentContent.tsx b/src/app/components/attachment-sheet/AttachmentContent.tsx new file mode 100644 index 0000000000..dbca1b0604 --- /dev/null +++ b/src/app/components/attachment-sheet/AttachmentContent.tsx @@ -0,0 +1,92 @@ +import type { MutableRefObject } from 'react'; +import type { Icon } from '@phosphor-icons/react'; +import { + GridFour, + Image as ImageIcon, + ListBullets, + MapPinPlusIcon, + PlusCircle, +} from '$components/icons/phosphor'; +import * as css from './AttachmentContent.css'; + +interface AttachmentAction { + icon: Icon; + label: string; + onClick: () => void; +} + +export interface AttachmentContentProps { + onPickPhotos: () => void; + onPickFile: () => void; + onPickPoll: () => void; + onPickLocation: () => void; + skipReturnFocusRef: MutableRefObject; +} + +export function AttachmentContent({ + onPickPhotos, + onPickFile, + onPickPoll, + onPickLocation, + skipReturnFocusRef, +}: AttachmentContentProps) { + const actions: AttachmentAction[] = [ + { icon: PlusCircle, label: 'Add File', onClick: onPickFile }, + { icon: ListBullets, label: 'Create Poll', onClick: onPickPoll }, + { icon: MapPinPlusIcon, label: 'Add Location', onClick: onPickLocation }, + ]; + + const handleAction = (action: () => void) => { + skipReturnFocusRef.current = true; + action(); + }; + + return ( +
+
+

+ Share +

+
+ +
+ +
+ +
+ {actions.map((action) => ( + + ))} +
+
+ ); +} diff --git a/src/app/components/attachment-sheet/AttachmentSheet.test.tsx b/src/app/components/attachment-sheet/AttachmentSheet.test.tsx deleted file mode 100644 index cb35b5c1e5..0000000000 --- a/src/app/components/attachment-sheet/AttachmentSheet.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { type PointerEventHandler, useRef, useState } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AttachmentSheet } from './AttachmentSheet'; - -vi.mock('$state/hooks/settings', () => ({ - useSetting: () => [false, vi.fn<() => void>()], -})); - -vi.mock('$utils/platform', () => ({ - isMobileOrTablet: () => false, -})); - -const callbacks = () => ({ - onClose: vi.fn<() => void>(), - onPickPhotos: vi.fn<() => void>(), - onPickFile: vi.fn<() => void>(), - onPickPoll: vi.fn<() => void>(), - onPickLocation: vi.fn<() => void>(), -}); - -type Handlers = ReturnType; - -function AttachmentSheetHarness({ - handlers, - onPointerDown, -}: { - handlers: Handlers; - onPointerDown?: PointerEventHandler; -}) { - const [open, setOpen] = useState(false); - const containerRef = useRef(null); - - return ( -
- -
- { - handlers.onClose(); - setOpen(false); - }} - /> -
- ); -} - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('AttachmentSheet', () => { - it('portals into a host whose ref was attached while the sheet was closed', () => { - const handlers = callbacks(); - render(); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); - - const chatPane = screen.getByTestId('chat-pane'); - const dialog = screen.getByRole('dialog', { name: 'Share' }); - const backdrop = dialog.previousElementSibling; - - expect(chatPane).toContainElement(dialog); - expect(dialog.parentElement).toBe(chatPane); - expect(backdrop).not.toBeNull(); - expect(backdrop?.parentElement).toBe(chatPane); - expect(dialog.parentElement).not.toBe(document.body); - }); - - it('does not render the dialog without a portal target', () => { - const { container } = render( - - ); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(container).toBeEmptyDOMElement(); - }); - - it('delegates Photos selection without directly closing', () => { - const handlers = callbacks(); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); - fireEvent.click(screen.getByRole('button', { name: 'Open photo gallery' })); - - expect(handlers.onPickPhotos).toHaveBeenCalledOnce(); - expect(handlers.onClose).not.toHaveBeenCalled(); - }); - - it('shields room pointer gestures from icon descendants', () => { - const handlers = callbacks(); - const onPointerDown = vi.fn>(); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'Open attachments' })); - const iconPath = screen - .getByRole('button', { name: 'Open photo gallery' }) - .querySelector('path'); - - expect(iconPath).not.toBeNull(); - fireEvent.pointerDown(iconPath!); - expect(onPointerDown).not.toHaveBeenCalled(); - }); - - it('does not move focus on open, closes once on Escape, and restores the opener', async () => { - const handlers = callbacks(); - render(); - const opener = screen.getByRole('button', { name: 'Open attachments' }); - - opener.focus(); - fireEvent.click(opener); - expect(screen.getByRole('dialog', { name: 'Share' })).not.toHaveFocus(); - expect(opener).toHaveFocus(); - fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); - - expect(handlers.onClose).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(opener).toHaveFocus(); - }); - }); -}); diff --git a/src/app/components/attachment-sheet/AttachmentSheet.tsx b/src/app/components/attachment-sheet/AttachmentSheet.tsx deleted file mode 100644 index f2c57478d6..0000000000 --- a/src/app/components/attachment-sheet/AttachmentSheet.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { type RefObject, useLayoutEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { useDrag } from '@use-gesture/react'; -import FocusTrap from 'focus-trap-react'; -import { useAndroidBackHandler } from '$utils/androidBack'; -import { stopPropagation } from '$utils/keyboard'; -import { isMobileOrTablet } from '$utils/platform'; -import { getMobileSheetTiming, useMobileSheetAnimation } from '$components/mobileSheetAnimation'; -import * as animationCss from '$components/mobileSheetAnimation.css'; -import type { Icon } from '@phosphor-icons/react'; -import { - Image as ImageIcon, - PlusCircle, - ListBullets, - MapPinPlusIcon, - GridFour, -} from '$components/icons/phosphor'; -import * as css from './AttachmentSheet.css'; - -interface AttachmentAction { - icon: Icon; - label: string; - onClick: () => void; -} - -export interface AttachmentSheetProps { - open: boolean; - onClose: () => void; - onPickPhotos: () => void; - onPickFile: () => void; - onPickPoll: () => void; - onPickLocation: () => void; - containerRef: RefObject; -} - -const SWIPE_THRESHOLD = 100; -const VELOCITY_THRESHOLD = 0.5; - -export function AttachmentSheet({ - open, - onClose, - onPickPhotos, - onPickFile, - onPickPoll, - onPickLocation, - containerRef, -}: AttachmentSheetProps) { - const containerEl = containerRef.current; - const sheetRef = useRef(null); - const skipReturnFocusRef = useRef(false); - const dragYRef = useRef(0); - const resetAnimationRef = useRef(); - const { shouldReduceMotion } = useMobileSheetAnimation(); - - useLayoutEffect(() => { - if (open) { - skipReturnFocusRef.current = false; - dragYRef.current = 0; - resetAnimationRef.current?.cancel(); - if (sheetRef.current) sheetRef.current.style.transform = ''; - } - }, [open]); - - useAndroidBackHandler(() => { - onClose(); - return true; - }, open); - - const gesturesEnabled = isMobileOrTablet(); - - const bind = useDrag( - ({ first, active, offset: [, oy], velocity: [, vy], direction: [, dy], event }) => { - if (event && 'target' in event && event.target instanceof Element) { - if (event.target.closest('[data-gestures="ignore"]')) { - return; - } - } - - if (!gesturesEnabled) return; - - event.stopPropagation(); - - const val = Math.max(0, oy); - - if (active) { - if (first) { - resetAnimationRef.current?.cancel(); - sheetRef.current?.getAnimations().forEach((animation) => animation.cancel()); - } - dragYRef.current = val; - if (sheetRef.current) { - sheetRef.current.style.transform = `translate3d(0, ${val}px, 0)`; - } - } else { - const swipedDown = val > SWIPE_THRESHOLD || (vy > VELOCITY_THRESHOLD && dy > 0); - - if (swipedDown) { - onClose(); - } else if (sheetRef.current) { - const sheet = sheetRef.current; - resetAnimationRef.current = sheet.animate( - [ - { transform: `translate3d(0, ${dragYRef.current}px, 0)` }, - { transform: 'translate3d(0, 0, 0)' }, - ], - getMobileSheetTiming(shouldReduceMotion) - ); - resetAnimationRef.current.addEventListener( - 'finish', - () => { - dragYRef.current = 0; - sheet.style.transform = ''; - }, - { once: true } - ); - } - } - }, - { - axis: 'y', - bounds: { top: 0, bottom: 300 }, - rubberband: true, - filterTaps: true, - pointer: { capture: true }, - from: () => [0, dragYRef.current], - } - ); - - const actions: AttachmentAction[] = [ - { icon: PlusCircle, label: 'Add File', onClick: onPickFile }, - { icon: ListBullets, label: 'Create Poll', onClick: onPickPoll }, - { icon: MapPinPlusIcon, label: 'Add Location', onClick: onPickLocation }, - ]; - - const handleAction = (action: () => void) => { - skipReturnFocusRef.current = true; - action(); - }; - - const sheetContent = ( - <> -
- - -
- -
- -
- {actions.map((action) => ( - - ))} -
- - ); - - const sheetElement = open ? ( - <> -
event.stopPropagation()} - data-gestures="ignore" - aria-hidden="true" - /> - - sheetRef.current ?? containerEl, - preventScroll: true, - returnFocusOnDeactivate: true, - setReturnFocus: (previousActiveElement: HTMLElement) => - skipReturnFocusRef.current ? false : previousActiveElement, - allowOutsideClick: true, - clickOutsideDeactivates: false, - escapeDeactivates: (event: KeyboardEvent) => { - if (!stopPropagation(event)) return false; - onClose(); - return false; - }, - }} - > -
event.stopPropagation()} - > - {sheetContent} -
-
- - ) : null; - - // Never render inline: without the active pane as a portal target, the sheet - // could briefly anchor to the room layout and cover the sidebar. - if (!containerEl) return null; - - return createPortal(sheetElement, containerEl); -} diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 1d21b6726f..ace613acef 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -494,6 +494,7 @@ export const CustomEditor = forwardRef( if (!isMobileOrTablet()) return; if (suppressBlurRefocusRef?.current) return; const next = evt.relatedTarget as HTMLElement | null; + if (!next) return; if (next && next !== editableRef.current && next.isContentEditable) return; ReactEditor.focus(editor); }} diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 45f1ac781d..403ea73e54 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -62,6 +62,7 @@ import type { GifData } from './types'; import { EmojiBoardTab, EmojiType } from './types'; import { useGifSearch } from './useGifSearch'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; +import * as css from './components/styles.css'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -413,7 +414,13 @@ function EmojiGroupHolder({ }; return ( - + v.title; @@ -467,6 +476,7 @@ export function EmojiBoard({ allowTextCustomEmoji, addToRecentEmoji = true, isFullWidth, + sheet = false, }: Readonly) { const mx = useMatrixClient(); const [saveStickerEmojiBandwidth] = useSetting(settingsAtom, 'saveStickerEmojiBandwidth'); @@ -658,13 +668,104 @@ export function EmojiBoard({ } }, [tab, virtualizer, groups.length]); + const layout = ( + + {onTabChange && } + + + } + sidebar={ + emojiTab ? ( + + ) : ( + !gifTab && ( + + ) + ) + } + isFullWidth={isFullWidth} + sheet={sheet} + > + + + {tab !== EmojiBoardTab.Gif && searchedItems && ( + + {searchedItems.map((element, index) => renderItem(element, index))} + + )} +
+ {vItems.map((vItem) => { + const group = groups[vItem.index]!; + + return ( + + + {group.items.map(renderItem)} + + + ); + })} +
+ {tab === EmojiBoardTab.Sticker && groups.length === 0 && } + {gifTab && ( + v.items.map(() => 'gif')).length === 0} + /> + )} +
+
+ {!gifTab && !isFullWidth && } + + ); + + if (sheet) return layout; + return ( true, isKeyForward: (evt: KeyboardEvent) => @@ -674,92 +775,7 @@ export function EmojiBoard({ escapeDeactivates: true, }} > - - {onTabChange && } - - - } - sidebar={ - emojiTab ? ( - - ) : ( - !gifTab && ( - - ) - ) - } - isFullWidth={isFullWidth} - > - - - {tab !== EmojiBoardTab.Gif && searchedItems && ( - - {searchedItems.map((element, index) => renderItem(element, index))} - - )} -
- {vItems.map((vItem) => { - const group = groups[vItem.index]!; - - return ( - - - {group.items.map(renderItem)} - - - ); - })} -
- {tab === EmojiBoardTab.Sticker && groups.length === 0 && } - {gifTab && ( - v.items.map(() => 'gif')).length === 0} - /> - )} -
-
- {!gifTab && } -
+ {layout}
); } diff --git a/src/app/components/emoji-board/components/Layout.test.tsx b/src/app/components/emoji-board/components/Layout.test.tsx new file mode 100644 index 0000000000..1689c857db --- /dev/null +++ b/src/app/components/emoji-board/components/Layout.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Base } from './styles.css'; +import { EmojiBoardLayout } from './Layout'; + +const renderLayout = (props: Partial[0]> = {}) => + render( + } {...props}> +
+ + ); + +describe('EmojiBoardLayout', () => { + // folds' `Line` is an unlabelled div, so the separator is counted positionally: + // the board holds the main column, then the separator, then the sidebar. + it('omits the sidebar separator when there is no sidebar', () => { + // The GIF tab passes no sidebar, and a stray vertical rule showed up there. + renderLayout(); + expect(screen.getByTestId('board').children).toHaveLength(1); + expect(screen.queryByTestId('sidebar')).toBeNull(); + }); + + it('renders a separator alongside a sidebar', () => { + renderLayout({ sidebar:
}); + expect(screen.getByTestId('board').children).toHaveLength(3); + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); + }); + + it('treats full-viewport width and sheet presentation as independent', () => { + const { rerender } = renderLayout({ isFullWidth: true }); + const widthOnly = screen.getByTestId('board').className; + + rerender( + } isFullWidth sheet> +
+ + ); + const widthAndSheet = screen.getByTestId('board').className; + + expect(widthOnly).toContain(Base({ isFullWidth: true })); + expect(widthAndSheet).toContain(Base({ isFullWidth: true, sheet: true })); + expect(widthOnly).not.toBe(widthAndSheet); + }); + + it('keeps the header and body wrappers so the scroll area can fill the sheet', () => { + renderLayout({ sheet: true }); + expect(screen.getByTestId('header').parentElement?.className).toBeTruthy(); + expect(screen.getByTestId('body').parentElement?.className).toBeTruthy(); + }); +}); diff --git a/src/app/components/emoji-board/components/Layout.tsx b/src/app/components/emoji-board/components/Layout.tsx index 16b74a3f8b..b9a53580cd 100644 --- a/src/app/components/emoji-board/components/Layout.tsx +++ b/src/app/components/emoji-board/components/Layout.tsx @@ -10,22 +10,27 @@ export const EmojiBoardLayout = as< sidebar?: ReactNode; children: ReactNode; isFullWidth?: boolean; + sheet?: boolean; } ->(({ className, header, sidebar, children, isFullWidth, ...props }, ref) => ( +>(({ className, header, sidebar, children, isFullWidth, sheet, ...props }, ref) => ( - - + + {header} - {children} + {children} - + {sidebar && } {sidebar} )); diff --git a/src/app/components/emoji-board/components/styles.css.ts b/src/app/components/emoji-board/components/styles.css.ts index f2db876cfd..2d13697483 100644 --- a/src/app/components/emoji-board/components/styles.css.ts +++ b/src/app/components/emoji-board/components/styles.css.ts @@ -25,16 +25,54 @@ export const Base = recipe({ true: { maxWidth: '100vw', width: `calc(100vw - ${config.borderWidth.B300})`, + display: 'flex', + flexDirection: 'row', + }, + }, + sheet: { + true: { + backgroundColor: 'transparent', + border: 'none', + borderRadius: 0, + boxShadow: 'none', + height: '100%', + flex: 1, + minHeight: 0, }, }, }, }); +export const Main = style({ + minWidth: 0, + display: 'flex', + flexDirection: 'column', + minHeight: 0, +}); + +export const Body = style({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, +}); + +export const ContentScroll = style({ + flex: 1, + minHeight: 0, + // Keeps a fling that reaches either end from chaining out into the sheet. + overscrollBehavior: 'contain', +}); + export const Header = style({ padding: config.space.S300, paddingBottom: 0, }); +export const SheetHeader = style({ + paddingTop: 0, +}); + /** * Sidebar */ diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 454dda3b32..af3d56127e 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -338,7 +338,6 @@ type OptionEmojiMenuProps = { isQuickOptions?: boolean; isModal?: boolean; ActualMessage?: ReactNode; - dragOpts?: DragOptsProps; }; function OptionsEmojiBoard({ mEvent, @@ -350,7 +349,6 @@ function OptionsEmojiBoard({ isQuickOptions, isModal, ActualMessage, - dragOpts, }: OptionEmojiMenuProps) { const position = (!isQuickOptions && 'Left') || @@ -365,7 +363,6 @@ function OptionsEmojiBoard({ style={isModal ? { width: '100%' } : {}} content={ - {dragOpts?.dragHandle} {ActualMessage} void; - onTouchMove?: (evt: React.TouchEvent) => void; - onTouchEnd?: () => void; -}; - export type OptionMenuProps = { mEvent: MatrixEvent; room: Room; @@ -569,7 +559,6 @@ export type OptionMenuProps = { setIsEmoji?: Dispatch>; ActualMessage?: ReactNode; isModal?: boolean; - dragOpts?: DragOptsProps; }; function OptionMenu({ @@ -589,7 +578,6 @@ function OptionMenu({ setIsEmoji, ActualMessage, isModal, - dragOpts, isGif, }: OptionMenuProps) { const setModal = useSetAtom(modalAtom); @@ -642,7 +630,6 @@ function OptionMenu({ imagePackRooms={imagePackRooms} isModal={isModal} ActualMessage={} - dragOpts={dragOpts} /> )} - - {dragOpts?.dragHandle} + {ActualMessage && !emojiBoardAnchor && ( <> @@ -674,9 +660,6 @@ function OptionMenu({ grow="Yes" shrink="No" style={{ maxHeight: '75%' }} - onTouchStart={dragOpts?.onTouchStart} - onTouchMove={dragOpts?.onTouchMove} - onTouchEnd={dragOpts?.onTouchEnd} onContextMenu={(e) => e.preventDefault()} > {canSendReaction && onReactionToggle && setIsEmoji && ( @@ -853,7 +836,7 @@ export function MobileOptionsInternal({ options }: { options: OptionMenuProps }) if (isActive) return ( - {(dragHandleJSX, dragHandlers) => ( + {() => ( )} diff --git a/src/app/components/modal-overlay/ModalOverlay.test.tsx b/src/app/components/modal-overlay/ModalOverlay.test.tsx index d74936e520..7f0becb2bc 100644 --- a/src/app/components/modal-overlay/ModalOverlay.test.tsx +++ b/src/app/components/modal-overlay/ModalOverlay.test.tsx @@ -65,11 +65,8 @@ vi.mock('folds', () => ({ vi.mock('$components/MobileSwipeDownModal', () => ({ MobileSwipeDownModal: ({ children }: any) => (
- {children(
drag-handle
, { - onTouchStart: vi.fn<() => void>(), - onTouchMove: vi.fn<() => void>(), - onTouchEnd: vi.fn<() => void>(), - })} +
drag-handle
+ {children()}
), })); @@ -84,6 +81,7 @@ vi.mock('$utils/keyboard', () => ({ vi.mock('$features/room/message/styles.css', () => ({ MessageOptionsMenu: 'mock-message-options-menu', + MessageOptionsSheetMenu: 'mock-message-options-sheet-menu', MessageMobileDragHandle: 'mock-mobile-drag-handle', MessageMobileDragIndicator: 'mock-mobile-drag-indicator', MessageMobileOptionsWrapped: 'mock-mobile-options-wrapped', diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx index a473a182b2..d5ab3306f7 100644 --- a/src/app/components/modal-overlay/ModalOverlay.tsx +++ b/src/app/components/modal-overlay/ModalOverlay.tsx @@ -44,8 +44,12 @@ export function ModalOverlay({ const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; const ownedModalRef = useRef(null); - // Android back closes the overlay instead of navigating away. - useDismissOnBack(requestClose, open); + const sheet = isMobile && mobile === 'sheet'; + + // Android back closes the overlay instead of navigating away. The sheet registers + // its own handler, and a child's runs first, so registering here too would skip + // the sheet's exit animation. + useDismissOnBack(requestClose, open && !sheet); if (open && isMobile && mobile === 'fullscreen') { return ( @@ -69,7 +73,7 @@ export function ModalOverlay({ ); } - if (open && isMobile && mobile === 'sheet') { + if (open && sheet) { const focusTrapOptions = { initialFocus: false, fallbackFocus: () => document.body, @@ -79,13 +83,10 @@ export function ModalOverlay({ }; return ( - {(dragHandle) => ( + {() => ( -
- - {dragHandle} - {children} - +
+ {children}
)} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 4cb913fc8a..2fb6c8e0aa 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -45,7 +45,6 @@ import { } from 'folds'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useDismissOnBack } from '$utils/androidBack'; import type { AutocompleteQuery } from '$components/editor'; import { AutocompletePrefix, @@ -190,7 +189,9 @@ import { ImageUsage } from '$plugins/custom-emoji'; import { getPackImageInfo } from '$plugins/custom-emoji/utils'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; -import { AttachmentSheet } from '$components/attachment-sheet/AttachmentSheet'; +import * as messageCss from '$features/room/message/styles.css'; +import { AttachmentContent } from '$components/attachment-sheet/AttachmentContent'; +import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -504,6 +505,7 @@ export const RoomInput = forwardRef( ); const [AddMenuAnchor, setAddMenuAnchor] = useState(); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); + const attachmentSkipReturnFocusRef = useRef(false); const [showPollPicker, setShowPollPicker] = useState(false); const [showLocationPicker, setShowLocationPicker] = useState(false); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); @@ -514,11 +516,22 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); - // Android back closes the mobile emoji board instead of navigating away. - useDismissOnBack(() => setEmojiBoardTab(undefined), emojiBoardTab !== undefined); - + const closeEmojiBoard = useCallback(() => { + if (isMobileOrTablet()) { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) activeElement.blur(); + } + setEmojiBoardTab(undefined); + }, []); const toggleEmojiBoardTab = useCallback((tab: EmojiBoardTab) => { - setEmojiBoardTab((prev) => (prev === tab ? undefined : tab)); + setEmojiBoardTab((prev) => { + if (prev !== tab) return tab; + if (isMobileOrTablet()) { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) activeElement.blur(); + } + return undefined; + }); }, []); const [personaPickerTab, setPersonaPickerTab] = useState( @@ -2037,7 +2050,10 @@ export const RoomInput = forwardRef( {isMobileOrTablet() ? ( <> setShowAttachmentSheet(true)} + onClick={() => { + attachmentSkipReturnFocusRef.current = false; + setShowAttachmentSheet(true); + }} onPointerDown={suppressEditorRefocus} variant="SurfaceVariant" size="300" @@ -2048,27 +2064,37 @@ export const RoomInput = forwardRef( > {composerIcon(PlusCircle)} - setShowAttachmentSheet(false)} - onPickPhotos={() => { - pickFile('image/*,.tgs'); - setShowAttachmentSheet(false); - }} - onPickFile={() => { - pickFile('*'); - setShowAttachmentSheet(false); - }} - onPickPoll={() => { - setShowAttachmentSheet(false); - setShowPollPicker(true); - }} - onPickLocation={() => { - setShowAttachmentSheet(false); - setShowLocationPicker(true); - }} - containerRef={fileDropContainerRef} - /> + {showAttachmentSheet && ( + setShowAttachmentSheet(false)} + containerRef={fileDropContainerRef} + focusTrap + dialogLabel="Share" + skipReturnFocusRef={attachmentSkipReturnFocusRef} + > + {() => ( + { + pickFile('image/*,.tgs'); + setShowAttachmentSheet(false); + }} + onPickFile={() => { + pickFile('*'); + setShowAttachmentSheet(false); + }} + onPickPoll={() => { + setShowAttachmentSheet(false); + setShowPollPicker(true); + }} + onPickLocation={() => { + setShowAttachmentSheet(false); + setShowLocationPicker(true); + }} + skipReturnFocusRef={attachmentSkipReturnFocusRef} + /> + )} + + )} ) : ( <> @@ -2177,11 +2203,12 @@ export const RoomInput = forwardRef( imagePackRooms={imagePackRooms} returnFocusOnDeactivate={false} isFullWidth={isMobileOrTablet()} + sheet={isMobileOrTablet()} onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} onGifSelect={handleGifSelect} - requestClose={() => setEmojiBoardTab(undefined)} + requestClose={closeEmojiBoard} /> ); const triggers = ( @@ -2255,20 +2282,17 @@ export const RoomInput = forwardRef( return ( <> {triggers} - }> -
- {emojiBoard} -
-
+ {() => emojiBoard} + + )} ); } diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index 6f576cd9b6..bb815f2f8e 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -44,12 +44,18 @@ export const MessageOptionsWrappedMessage = style({ overflow: 'auto', }); -export const MessageOptionsMenu = style({ +const messageOptionsMenuLayout = { width: '100%', maxHeight: '100%', position: 'relative', display: 'flex', flexDirection: 'column', +} as const; + +// Portaled out of the sheet by PopOut, so it still draws its own sheet-like surface +// and pads for the safe area itself. +export const MessageOptionsMenu = style({ + ...messageOptionsMenuLayout, borderBottomLeftRadius: '0 !important', borderBottomRightRadius: '0 !important', borderBottom: 'none !important', @@ -70,6 +76,17 @@ export const MessageOptionsMenu = style({ }, }); +// Inside the sheet panel, which owns the background, radius, shadow and safe area. +export const MessageOptionsSheetMenu = style({ + ...messageOptionsMenuLayout, + border: 'none !important', + borderRadius: '0 !important', + background: 'transparent !important', + boxShadow: 'none !important', + paddingTop: '0 !important', + paddingBottom: `${config.space.S400} !important`, +}); + export const PreventSelect = style({ WebkitUserSelect: 'none', msUserSelect: 'none', @@ -91,6 +108,11 @@ export const MessageMobileOptionsWrapped = style({ backgroundColor: color.Other.Overlay, }); +export const MessageMobileOptionsWrappedContained = style({ + position: 'absolute', + width: '100%', +}); + export const MessageMobileOptionsContainer = style({ position: 'fixed', bottom: 0, @@ -103,20 +125,50 @@ export const MessageMobileOptionsContainer = style({ flexDirection: 'column', justifyContent: 'flex-end', overflow: 'visible', + backgroundColor: color.Surface.Container, + borderTopLeftRadius: toRem(20), + borderTopRightRadius: toRem(20), + boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.15)', + paddingBottom: 'var(--mobile-sheet-safe-bottom, env(safe-area-inset-bottom, 0px))', }); -export const MessageMobileDragHandle = style({ +export const MessageMobileOptionsContainerContained = style({ position: 'absolute', - top: '0', - left: '0', - right: '0', +}); + +export const MessageMobileSheetFill = style({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, +}); + +// Ratio is against the keyboard-free height so the picker keeps one size while +// typing. The ceiling only binds where a keyboard would otherwise cover the sheet. +const pickerHeight = `min(max(calc(var(--mobile-sheet-viewport, 100vh) * 0.5), ${toRem( + 280 +)}), var(--mobile-sheet-visible, 100vh))`; + +export const MessageMobileOptionsContainerPicker = style({ + height: pickerHeight, + maxHeight: pickerHeight, + boxSizing: 'border-box', + // `clip` creates no scrollport, so focusing the search input cannot scroll the + // sheet on top of its own transform. `hidden` is the pre-Chrome-90 fallback. + overflow: ['hidden', 'clip'], +}); + +export const MessageMobileDragHandle = style({ + flex: '0 0 32px', height: '32px', display: 'flex', alignItems: 'flex-start', - paddingTop: '6px', justifyContent: 'center', + boxSizing: 'border-box', + paddingTop: toRem(8), zIndex: 10, - pointerEvents: 'none', + touchAction: 'none', + cursor: 'grab', }); export const MessageMobileDragIndicator = style({ @@ -125,7 +177,6 @@ export const MessageMobileDragIndicator = style({ borderRadius: '2px', backgroundColor: color.SurfaceVariant.OnContainer, opacity: 0.5, - pointerEvents: 'auto', }); export const BubbleAvatarBase = style({