mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Fix FunLightbox and FunTooltip
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ForwardedRef, ReactNode } from 'react';
|
||||
import React, { forwardRef, useEffect, useRef } from 'react';
|
||||
import { type PressEvent, useLongPress } from 'react-aria';
|
||||
import type { LongPressEvent } from '@react-types/shared';
|
||||
import { Button } from 'react-aria-components';
|
||||
import { mergeRefs } from '@react-aria/utils';
|
||||
import { PressResponder } from '@react-aria/interactions';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -28,49 +29,120 @@ export type FunItemButtonProps = Readonly<
|
||||
{
|
||||
'aria-label': string;
|
||||
excludeFromTabOrder: boolean;
|
||||
onPress: (event: PressEvent) => void;
|
||||
onContextMenu?: (event: MouseEvent) => void;
|
||||
onClick: (event: PointerEvent) => void;
|
||||
onContextMenu?: (event: PointerEvent) => void;
|
||||
children: ReactNode;
|
||||
} & FunItemButtonLongPressProps
|
||||
>;
|
||||
|
||||
export const FunItemButton = forwardRef(function FunItemButton(
|
||||
props: FunItemButtonProps,
|
||||
outerRef: ForwardedRef<HTMLButtonElement>
|
||||
ref: ForwardedRef<HTMLButtonElement>
|
||||
): JSX.Element {
|
||||
const { onContextMenu } = props;
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
const {
|
||||
'aria-label': ariaLabel,
|
||||
excludeFromTabOrder,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
children,
|
||||
longPressAccessibilityDescription,
|
||||
onLongPress,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const { longPressProps } = useLongPress({
|
||||
isDisabled: props.onLongPress == null,
|
||||
accessibilityDescription: props.longPressAccessibilityDescription,
|
||||
onLongPress: props.onLongPress,
|
||||
});
|
||||
const longPressProps = useLongPress(onLongPress ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
strictAssert(innerRef.current, 'Missing ref element');
|
||||
const element = innerRef.current;
|
||||
if (onContextMenu == null) {
|
||||
return () => null;
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu);
|
||||
return () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu);
|
||||
};
|
||||
}, [onContextMenu]);
|
||||
const handleClick = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
if (!event.defaultPrevented) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<PressResponder {...longPressProps}>
|
||||
<Button
|
||||
ref={mergeRefs(innerRef, outerRef)}
|
||||
type="button"
|
||||
className="FunItem__Button"
|
||||
aria-label={props['aria-label']}
|
||||
excludeFromTabOrder={props.excludeFromTabOrder}
|
||||
onPress={props.onPress}
|
||||
>
|
||||
{props.children}
|
||||
</Button>
|
||||
</PressResponder>
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useCallback } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { Dialog, Popover } from 'react-aria-components';
|
||||
import classNames from 'classnames';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { ThemeType } from '../../../types/Util';
|
||||
|
||||
export type FunPopoverProps = Readonly<{
|
||||
@@ -16,8 +17,14 @@ export type FunPopoverProps = Readonly<{
|
||||
export function FunPopover(props: FunPopoverProps): JSX.Element {
|
||||
const shouldCloseOnInteractOutside = useCallback(
|
||||
(element: Element): boolean => {
|
||||
// Don't close when quill steals focus
|
||||
const match = element.closest('.module-composition-input__input');
|
||||
const match = element.closest(
|
||||
[
|
||||
// Don't close when quill steals focus
|
||||
'.module-composition-input__input',
|
||||
// Don't close when clicking tooltip
|
||||
'.FunTooltip',
|
||||
].join(', ')
|
||||
);
|
||||
if (match != null) {
|
||||
return false;
|
||||
}
|
||||
@@ -27,16 +34,18 @@ export function FunPopover(props: FunPopoverProps): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
data-fun-overlay
|
||||
className={classNames('FunPopover', {
|
||||
'light-theme': props.theme === ThemeType.light,
|
||||
'dark-theme': props.theme === ThemeType.dark,
|
||||
})}
|
||||
placement={props.placement}
|
||||
shouldCloseOnInteractOutside={shouldCloseOnInteractOutside}
|
||||
>
|
||||
<Dialog className="FunPopover__Dialog">{props.children}</Dialog>
|
||||
</Popover>
|
||||
<Tooltip.Provider>
|
||||
<Popover
|
||||
data-fun-overlay
|
||||
className={classNames('FunPopover', {
|
||||
'light-theme': props.theme === ThemeType.light,
|
||||
'dark-theme': props.theme === ThemeType.dark,
|
||||
})}
|
||||
placement={props.placement}
|
||||
shouldCloseOnInteractOutside={shouldCloseOnInteractOutside}
|
||||
>
|
||||
<Dialog className="FunPopover__Dialog">{props.children}</Dialog>
|
||||
</Popover>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import classNames from 'classnames';
|
||||
import type { Transition } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -13,6 +13,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useId,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import type { Selection } from 'react-aria-components';
|
||||
import { ListBox, ListBoxItem } from 'react-aria-components';
|
||||
@@ -25,6 +26,7 @@ import * as log from '../../../logging/log';
|
||||
import * as Errors from '../../../types/errors';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { FunImage } from './FunImage';
|
||||
import { FunTooltip } from './FunTooltip';
|
||||
|
||||
/**
|
||||
* Sub Nav
|
||||
@@ -246,11 +248,30 @@ function FunSubNavListBoxItemButton(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const FunSubNavListBoxItemTooltipTarget = forwardRef(
|
||||
function FunSubNavListBoxItemTooltipTarget(props, ref: Ref<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
{...props}
|
||||
className="FunSubNav__ListBoxItem__TooltipTarget"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function FunSubNavListBoxItem(
|
||||
props: FunSubNavListBoxItemProps
|
||||
): JSX.Element {
|
||||
const context = useContext(FunSubNavListBoxContext);
|
||||
strictAssert(context, 'Must be wrapped with <FunSubNavListBox>');
|
||||
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const handleTooltipOpenChange = useCallback((open: boolean) => {
|
||||
setTooltipOpen(open);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ListBoxItem
|
||||
id={props.id}
|
||||
@@ -260,22 +281,35 @@ export function FunSubNavListBoxItem(
|
||||
>
|
||||
{({ isSelected, isFocusVisible }) => {
|
||||
return (
|
||||
<FunSubNavListBoxItemButton isSelected={isSelected}>
|
||||
<span className="FunSubNav__ListBoxItem__ButtonIcon">
|
||||
{props.children}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
className="FunSubNav__ListBoxItem__ButtonIndicator"
|
||||
layoutId={`FunSubNav__ListBoxItem__ButtonIndicator--${context.id}`}
|
||||
layoutDependency={context.selected}
|
||||
transition={FunSubNavListBoxItemTransition}
|
||||
/>
|
||||
)}
|
||||
{!isSelected && isFocusVisible && (
|
||||
<div className="FunSubNav__ListBoxItem__ButtonIndicator" />
|
||||
)}
|
||||
</FunSubNavListBoxItemButton>
|
||||
<>
|
||||
<FunTooltip
|
||||
open={tooltipOpen || (isSelected && isFocusVisible)}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
side="top"
|
||||
content={props.label}
|
||||
collisionBoundarySelector=".FunPanel"
|
||||
collisionPadding={6}
|
||||
disableHoverableContent
|
||||
>
|
||||
<FunSubNavListBoxItemTooltipTarget />
|
||||
</FunTooltip>
|
||||
<FunSubNavListBoxItemButton isSelected={isSelected}>
|
||||
<span className="FunSubNav__ListBoxItem__ButtonIcon">
|
||||
{props.children}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
className="FunSubNav__ListBoxItem__ButtonIndicator"
|
||||
layoutId={`FunSubNav__ListBoxItem__ButtonIndicator--${context.id}`}
|
||||
layoutDependency={context.selected}
|
||||
transition={FunSubNavListBoxItemTransition}
|
||||
/>
|
||||
)}
|
||||
{!isSelected && isFocusVisible && (
|
||||
<div className="FunSubNav__ListBoxItem__ButtonIndicator" />
|
||||
)}
|
||||
</FunSubNavListBoxItemButton>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ListBoxItem>
|
||||
|
||||
@@ -1,19 +1,57 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { type ReactNode } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { Tooltip } from 'react-aria-components';
|
||||
import React, { useRef, useState, type ReactNode } from 'react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useLayoutEffect } from '@react-aria/utils';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
|
||||
export type FunTooltipProps = Readonly<{
|
||||
placement?: Placement;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
disableHoverableContent?: boolean;
|
||||
side?: Tooltip.TooltipContentProps['side'];
|
||||
align?: Tooltip.TooltipContentProps['align'];
|
||||
collisionBoundarySelector?: string;
|
||||
collisionPadding?: number;
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export function FunTooltip(props: FunTooltipProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [collisionBoundary, setCollisionBoundary] = useState<Element | null>(
|
||||
null
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (props.collisionBoundarySelector == null) {
|
||||
return;
|
||||
}
|
||||
strictAssert(ref.current, 'missing ref');
|
||||
const trigger = ref.current;
|
||||
setCollisionBoundary(trigger.closest(props.collisionBoundarySelector));
|
||||
}, [props.collisionBoundarySelector]);
|
||||
|
||||
return (
|
||||
<Tooltip className="FunTooltip" placement={props.placement}>
|
||||
{props.children}
|
||||
</Tooltip>
|
||||
<Tooltip.Root
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
disableHoverableContent={props.disableHoverableContent}
|
||||
>
|
||||
<Tooltip.Trigger ref={ref} asChild>
|
||||
{props.children}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={props.side}
|
||||
align={props.align}
|
||||
className="FunTooltip"
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={props.collisionPadding}
|
||||
>
|
||||
<span className="FunTooltip__Text">{props.content}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user