Fix FunLightbox and FunTooltip

This commit is contained in:
Jamie Kyle
2025-06-03 06:29:51 -07:00
committed by GitHub
parent 0a91232634
commit 06ff9fa09e
13 changed files with 891 additions and 211 deletions

View File

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

View File

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

View File

@@ -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>

View File

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