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>
|
||
|
|
);
|
||
|
|
}
|