On coarse-pointer devices the hover action bar is unreachable and iOS Safari has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement, cancelled by scroll/lift) now opens an ActionSheet — slides up from the bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick reactions row + 'Add reaction' on top and the same folds Menu the desktop popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu echo of the same press is dropped; the press-induced text/image selection is cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is unchanged (right-click → anchored popout). Verified with Playwright touch emulation (Pixel 7, CDP touch events): long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark / Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses; a scroll gesture does not open it; tapping Reply closes it and arms the composer. Still wants a real iPhone + Android pass before it's called done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { Box, Overlay, OverlayBackdrop, config, color, toRem } from 'folds';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
|
|
type ActionSheetProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
'aria-label'?: string;
|
|
children: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* [Gitea #166] A bottom sheet for touch screens: slides up from the bottom,
|
|
* swipe-down or backdrop tap dismisses. Content is whatever the caller
|
|
* renders (quick reactions + a folds Menu, for message actions).
|
|
*/
|
|
export function ActionSheet({
|
|
open,
|
|
onClose,
|
|
children,
|
|
'aria-label': ariaLabel,
|
|
}: ActionSheetProps) {
|
|
const [dragY, setDragY] = useState(0);
|
|
const startY = useRef<number | null>(null);
|
|
// The long-press that opened us ends with a touchend/click that lands
|
|
// "outside" the sheet — ignore outside clicks for the first moments.
|
|
const openedAt = useRef(Date.now());
|
|
|
|
const onTouchStart = useCallback((evt: React.TouchEvent) => {
|
|
startY.current = evt.touches[0]?.clientY ?? null;
|
|
}, []);
|
|
const onTouchMove = useCallback((evt: React.TouchEvent) => {
|
|
if (startY.current === null) return;
|
|
const dy = (evt.touches[0]?.clientY ?? startY.current) - startY.current;
|
|
setDragY(Math.max(0, dy));
|
|
}, []);
|
|
const onTouchEnd = useCallback(() => {
|
|
const dismiss = dragY > 80;
|
|
startY.current = null;
|
|
setDragY(0);
|
|
if (dismiss) onClose();
|
|
}, [dragY, onClose]);
|
|
|
|
if (!open) return null;
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: onClose,
|
|
clickOutsideDeactivates: () => Date.now() - openedAt.current > 600,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Box
|
|
role="dialog"
|
|
aria-modal
|
|
aria-label={ariaLabel}
|
|
direction="Column"
|
|
style={{
|
|
position: 'fixed',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
maxHeight: '75vh',
|
|
background: color.Surface.Container,
|
|
color: color.Surface.OnContainer,
|
|
borderTopLeftRadius: toRem(16),
|
|
borderTopRightRadius: toRem(16),
|
|
boxShadow: '0 -8px 32px rgba(0,0,0,0.35)',
|
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
transform: dragY ? `translateY(${dragY}px)` : undefined,
|
|
transition: dragY ? undefined : 'transform 120ms ease-out',
|
|
overflow: 'hidden',
|
|
}}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
onTouchCancel={onTouchEnd}
|
|
>
|
|
<Box justifyContent="Center" shrink="No" style={{ padding: config.space.S200 }}>
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
width: toRem(36),
|
|
height: toRem(4),
|
|
borderRadius: toRem(2),
|
|
background: color.Surface.ContainerLine,
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Box direction="Column" style={{ overflowY: 'auto' }}>
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
</FocusTrap>
|
|
</Overlay>
|
|
);
|
|
}
|