149 lines
3.4 KiB
TypeScript
149 lines
3.4 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import type {
|
|
ForwardedRef,
|
|
ReactNode,
|
|
DOMAttributes,
|
|
PointerEvent,
|
|
} from 'react';
|
|
import React, { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
|
import { mergeProps } from '@react-aria/utils';
|
|
import { strictAssert } from '../../../util/assert';
|
|
|
|
/**
|
|
* Button
|
|
*/
|
|
|
|
export type FunItemButtonLongPressProps = Readonly<
|
|
| {
|
|
longPressAccessibilityDescription?: never;
|
|
onLongPress?: never;
|
|
}
|
|
| {
|
|
longPressAccessibilityDescription: string;
|
|
onLongPress: (event: LongPressEvent) => void;
|
|
}
|
|
>;
|
|
|
|
export type FunItemButtonProps = Readonly<
|
|
{
|
|
'aria-label': string;
|
|
excludeFromTabOrder: boolean;
|
|
onClick: (event: PointerEvent) => void;
|
|
onContextMenu?: (event: PointerEvent) => void;
|
|
children: ReactNode;
|
|
} & FunItemButtonLongPressProps
|
|
>;
|
|
|
|
export const FunItemButton = forwardRef(function FunItemButton(
|
|
props: FunItemButtonProps,
|
|
ref: ForwardedRef<HTMLButtonElement>
|
|
): JSX.Element {
|
|
const {
|
|
'aria-label': ariaLabel,
|
|
excludeFromTabOrder,
|
|
onClick,
|
|
onContextMenu,
|
|
children,
|
|
longPressAccessibilityDescription,
|
|
onLongPress,
|
|
...rest
|
|
} = props;
|
|
|
|
const longPressProps = useLongPress(onLongPress ?? null);
|
|
|
|
const handleClick = useCallback(
|
|
(event: PointerEvent) => {
|
|
if (!event.defaultPrevented) {
|
|
onClick(event);
|
|
}
|
|
},
|
|
[onClick]
|
|
);
|
|
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/role-supports-aria-props
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
className="FunItem__Button"
|
|
aria-label={ariaLabel}
|
|
aria-description={longPressAccessibilityDescription}
|
|
tabIndex={excludeFromTabOrder ? -1 : undefined}
|
|
{...mergeProps(
|
|
longPressProps,
|
|
{
|
|
onClick: handleClick,
|
|
onContextMenu,
|
|
},
|
|
rest
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
type LongPressEvent = Readonly<{
|
|
pointerType: PointerEvent['pointerType'];
|
|
}>;
|
|
|
|
function useLongPress(
|
|
onLongPress: ((event: LongPressEvent) => void) | null
|
|
): DOMAttributes<Element> {
|
|
const { cleanup, props } = useMemo(() => {
|
|
if (onLongPress == null) {
|
|
return { props: {} };
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
let isLongPressed = false;
|
|
let lastLongPress: number | null = null;
|
|
|
|
function reset() {
|
|
clearTimeout(timer);
|
|
isLongPressed = false;
|
|
}
|
|
|
|
function handleCancel(event: PointerEvent) {
|
|
if (isLongPressed) {
|
|
lastLongPress = event.timeStamp;
|
|
}
|
|
reset();
|
|
}
|
|
|
|
function handleStart(event: PointerEvent) {
|
|
const press: LongPressEvent = { pointerType: event.pointerType };
|
|
reset();
|
|
timer = setTimeout(() => {
|
|
isLongPressed = true;
|
|
strictAssert(onLongPress != null, 'Missing callback');
|
|
onLongPress(press);
|
|
}, 500);
|
|
}
|
|
|
|
function handleClick(event: PointerEvent) {
|
|
if (event.timeStamp === lastLongPress) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
return {
|
|
cleanup: reset,
|
|
props: {
|
|
onPointerDown: handleStart,
|
|
onPointerUp: handleCancel,
|
|
onPointerCancel: handleCancel,
|
|
onPointerLeave: handleCancel,
|
|
onClick: handleClick,
|
|
} satisfies DOMAttributes<Element>,
|
|
};
|
|
}, [onLongPress]);
|
|
|
|
useEffect(() => {
|
|
return cleanup;
|
|
}, [cleanup]);
|
|
|
|
return props;
|
|
}
|