Init AxoTooltip component

This commit is contained in:
Jamie
2026-02-09 14:26:46 -08:00
committed by GitHub
parent b11bf88244
commit d34ebaab46
17 changed files with 653 additions and 54 deletions

View File

@@ -454,6 +454,8 @@ module.exports = {
{
files: ['ts/axo/**/*.tsx'],
rules: {
// Rule doesn't understand TypeScript namespaces
'no-inner-declarations': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-redeclare': [
'error',

View File

@@ -10,6 +10,7 @@ import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
import { isTestOrMockEnvironment } from '../environment.std.js';
const Namespace = 'AriaClickable';
@@ -229,7 +230,7 @@ export namespace AriaClickable {
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
if (isTestOrMockEnvironment()) {
assert(
computeAccessibleName(assert(ref.current)) !== '',
`${hiddenTriggerDisplayName} child must have an accessible name`

View File

@@ -61,7 +61,6 @@ export namespace ExperimentalAxoBadge {
let cachedNumberFormat: Intl.NumberFormat;
// eslint-disable-next-line no-inner-declarations
function formatBadgeCount(
value: number,
max: number,

View File

@@ -115,7 +115,6 @@ export namespace AxoContextMenu {
type TriggerElementGetter = (event: KeyboardEvent) => Element;
// eslint-disable-next-line no-inner-declarations
function useContextMenuTriggerKeyboardEventHandler(
getTriggerElement: TriggerElementGetter
) {

View File

@@ -72,7 +72,7 @@ function Template(props: {
<AxoDialog.Actions>
{props.iconAction ? (
<AxoDialog.IconAction
aria-label="Send"
label="Send message"
variant="primary"
symbol="send-fill"
onClick={action('onSend')}
@@ -121,7 +121,7 @@ export function Large(): React.JSX.Element {
export function IconAction(): React.JSX.Element {
return (
<Template contentSize="sm" iconAction>
{TEXT_SHORT}
{TEXT_LONG}
</Template>
);
}

View File

@@ -3,13 +3,14 @@
import { Dialog } from 'radix-ui';
import type { CSSProperties, FC, ReactNode } from 'react';
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js';
import type { AxoSymbol } from './AxoSymbol.dom.js';
import { tw } from './tw.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import { AxoButton } from './AxoButton.dom.js';
import { AxoIconButton } from './AxoIconButton.dom.js';
import { AxoTooltip } from './AxoTooltip.dom.js';
const Namespace = 'AxoDialog';
@@ -81,6 +82,7 @@ export namespace AxoDialog {
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
const [boundary, setBoundary] = useState<Element | null>(null);
const descriptionProps = useMemo((): Dialog.DialogContentProps => {
if (props.disableMissingAriaDescriptionWarning) {
@@ -95,18 +97,21 @@ export namespace AxoDialog {
return (
<Dialog.Portal>
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
<Dialog.Content
className={AxoBaseDialog.contentStyles}
onEscapeKeyDown={handleContentEscapeEvent}
onInteractOutside={handleContentEscapeEvent}
style={{
width: sizeConfig.width,
minWidth: 320,
}}
{...descriptionProps}
>
{props.children}
</Dialog.Content>
<AxoTooltip.CollisionBoundary boundary={boundary} padding={4}>
<Dialog.Content
ref={setBoundary}
className={AxoBaseDialog.contentStyles}
onEscapeKeyDown={handleContentEscapeEvent}
onInteractOutside={handleContentEscapeEvent}
style={{
width: sizeConfig.width,
minWidth: 320,
}}
{...descriptionProps}
>
{props.children}
</Dialog.Content>
</AxoTooltip.CollisionBoundary>
</Dialog.Overlay>
</Dialog.Portal>
);
@@ -182,7 +187,8 @@ export namespace AxoDialog {
size="sm"
variant="borderless-secondary"
symbol="chevron-[start]"
aria-label={props['aria-label']}
label={props['aria-label']}
tooltip={false}
onClick={props.onClick}
/>
</div>
@@ -208,7 +214,8 @@ export namespace AxoDialog {
size="sm"
variant="borderless-secondary"
symbol="x"
aria-label={props['aria-label']}
label={props['aria-label']}
tooltip={false}
/>
</Dialog.Close>
</div>
@@ -409,7 +416,7 @@ export namespace AxoDialog {
export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
export type IconActionProps = Readonly<{
'aria-label': string;
label: string;
variant: ActionVariant;
symbol: AxoSymbol.IconName;
onClick: () => void;
@@ -418,7 +425,7 @@ export namespace AxoDialog {
export const IconAction: FC<IconActionProps> = memo(props => {
return (
<AxoIconButton.Root
aria-label={props['aria-label']}
label={props.label}
variant={props.variant}
size="md"
symbol={props.symbol}

View File

@@ -29,6 +29,7 @@ import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
import { isTestOrMockEnvironment } from '../environment.std.js';
const Namespace = 'AxoDropdownMenu';
@@ -146,7 +147,7 @@ export namespace AxoDropdownMenu {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
if (isTestOrMockEnvironment()) {
assert(
ref.current instanceof HTMLElement,
`${triggerDisplayName} child must forward ref`

View File

@@ -16,7 +16,7 @@ export function Basic(): React.JSX.Element {
variant="secondary"
size="lg"
symbol="more"
aria-label="More"
label="More actions"
/>
);
}
@@ -80,7 +80,7 @@ export function Variants(): React.JSX.Element {
variant={variant}
size="lg"
symbol="more"
aria-label="More"
label="More actions"
/>
</div>
);
@@ -121,7 +121,7 @@ export function Sizes(): React.JSX.Element {
variant={variant}
size={size}
symbol="more"
aria-label="More"
label="More actions"
/>
</div>
);
@@ -168,7 +168,7 @@ export function States(): React.JSX.Element {
variant={variant}
size="lg"
symbol="more"
aria-label="More"
label="More actions"
{...AllStates[state]}
/>
</div>
@@ -210,7 +210,7 @@ export function Spinners(): React.JSX.Element {
variant={variant}
size={size}
symbol="more"
aria-label="More"
label="More actions"
experimentalSpinner={{ 'aria-label': 'Loading' }}
/>
</div>

View File

@@ -2,12 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ButtonHTMLAttributes, FC, ForwardedRef } from 'react';
import React, { forwardRef, memo } from 'react';
import React, { forwardRef, memo, useMemo } from 'react';
import { AxoSymbol } from './AxoSymbol.dom.js';
import type { TailwindStyles } from './tw.dom.js';
import { tw } from './tw.dom.js';
import type { SpinnerVariant } from '../components/SpinnerV2.dom.js';
import { SpinnerV2 } from '../components/SpinnerV2.dom.js';
import { AxoTooltip } from './AxoTooltip.dom.js';
const Namespace = 'AxoIconButton';
@@ -15,7 +16,7 @@ type GenericButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
export namespace AxoIconButton {
const baseStyles = tw(
'relative rounded-full select-none',
'relative rounded-full leading-none select-none',
'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:disabled:text-[GrayText]',
@@ -105,7 +106,8 @@ export namespace AxoIconButton {
export type RootProps = Readonly<{
// required: Should describe the purpose of the button, not the icon.
'aria-label': string;
label: string;
tooltip?: boolean | AxoTooltip.RootConfigProps;
variant: Variant;
size: Size;
@@ -122,12 +124,31 @@ export namespace AxoIconButton {
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { variant, size, symbol, experimentalSpinner, ...rest } = props;
const {
label,
tooltip = true,
variant,
size,
symbol,
experimentalSpinner,
...rest
} = props;
return (
const tooltipConfig = useMemo(() => {
if (!tooltip) {
return null;
}
if (typeof tooltip === 'object') {
return tooltip;
}
return { label };
}, [tooltip, label]);
const button = (
<button
ref={ref}
{...rest}
aria-label={label}
type="button"
className={tw(
baseStyles,
@@ -137,7 +158,7 @@ export namespace AxoIconButton {
>
<span
className={tw(
'align-top leading-none forced-color-adjust-none',
'align-top forced-color-adjust-none',
experimentalSpinner != null ? 'opacity-0' : null
)}
>
@@ -156,6 +177,19 @@ export namespace AxoIconButton {
)}
</button>
);
if (tooltipConfig != null) {
return (
<AxoTooltip.Root
{...tooltipConfig}
tooltipRepeatsTriggerAccessibleName={label === tooltipConfig.label}
>
{button}
</AxoTooltip.Root>
);
}
return button;
})
);
@@ -184,7 +218,6 @@ export namespace AxoIconButton {
'aria-label': string;
}>;
// eslint-disable-next-line no-inner-declarations
function Spinner(props: SpinnerProps): React.JSX.Element {
const variant = SpinnerVariants[props.buttonVariant];
const sizeConfig = SpinnerSizes[props.buttonSize];

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactNode } from 'react';
import React, { memo, useInsertionEffect } from 'react';
import { Direction } from 'radix-ui';
import { Direction, Tooltip } from 'radix-ui';
import { createScrollbarGutterCssProperties } from './_internal/scrollbars.dom.js';
type AxoProviderProps = Readonly<{
@@ -27,7 +27,9 @@ export const AxoProvider: FC<AxoProviderProps> = memo(props => {
};
});
return (
<Direction.Provider dir={props.dir}>{props.children}</Direction.Provider>
<Direction.Provider dir={props.dir}>
<Tooltip.Provider>{props.children}</Tooltip.Provider>
</Direction.Provider>
);
});

View File

@@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import type { CSSProperties, FC, ReactNode } from 'react';
import type { TailwindStyles } from './tw.dom.js';
import { tw } from './tw.dom.js';
@@ -8,6 +8,7 @@ import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
import { AxoTooltip } from './AxoTooltip.dom.js';
const Namespace = 'AxoScrollArea';
@@ -216,6 +217,7 @@ export namespace AxoScrollArea {
scrollbarVisibility,
scrollBehavior,
} = useStrictContext(ScrollAreaConfigContext);
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null);
const style = useMemo((): CSSProperties => {
const hasVerticalScrollbar = orientation !== 'horizontal';
@@ -261,19 +263,22 @@ export namespace AxoScrollArea {
}, [orientation, scrollbarWidth, scrollbarGutter]);
return (
<div
data-axo-scroll-area-viewport
className={tw(
baseViewportStyles,
ViewportScrollbarWidths[scrollbarWidth],
ViewportScrollbarGutters[scrollbarGutter],
ViewportScrollbarVisibilities[scrollbarVisibility],
ViewportScrollBehaviors[scrollBehavior]
)}
style={style}
>
{props.children}
</div>
<AxoTooltip.CollisionBoundary boundary={boundary}>
<div
ref={setBoundary}
data-axo-scroll-area-viewport
className={tw(
baseViewportStyles,
ViewportScrollbarWidths[scrollbarWidth],
ViewportScrollbarGutters[scrollbarGutter],
ViewportScrollbarVisibilities[scrollbarVisibility],
ViewportScrollBehaviors[scrollBehavior]
)}
style={style}
>
{props.children}
</div>
</AxoTooltip.CollisionBoundary>
);
});

View File

@@ -22,7 +22,6 @@ export namespace AxoSymbol {
const symbolStyles = tw('font-symbols select-none');
const labelStyles = tw('select-none');
// eslint-disable-next-line no-inner-declarations
function useRenderSymbol(
glyph: string,
label: string | null

View File

@@ -0,0 +1,189 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX, ReactNode } from 'react';
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AxoTooltip } from './AxoTooltip.dom.js';
import { AxoButton } from './AxoButton.dom.js';
import { tw } from './tw.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import { AxoDialog } from './AxoDialog.dom.js';
export default {
title: 'Axo/AxoTooltip',
} satisfies Meta;
const LONG_TEXT = (
<>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolore nesciunt
eligendi velit doloribus ipsum deleniti eaque voluptatibus, sequi quae
pariatur nulla ad maiores eos, necessitatibus esse mollitia odio consequatur
aliquid?
</>
);
function Row(props: { children: ReactNode }) {
return <div className={tw('flex flex-wrap gap-2')}>{props.children}</div>;
}
function Stack(props: { children: ReactNode }) {
return <div className={tw('flex flex-col gap-2')}>{props.children}</div>;
}
type ExampleProps = {
trigger?: string;
label?: ReactNode;
side?: AxoTooltip.Side;
align?: AxoTooltip.Align;
keyboardShortcut?: string;
experimentalTimestamp?: number;
};
function SimpleExample(props: ExampleProps) {
return (
<AxoTooltip.Root
__FORCE_OPEN
label={props.label ?? 'Hello World'}
side={props.side}
align={props.align}
keyboardShortcut={props.keyboardShortcut}
experimentalTimestamp={props.experimentalTimestamp}
>
<AxoButton.Root variant="primary" size="md" onClick={action('onClick')}>
{props.trigger ?? 'Hover Me'}
</AxoButton.Root>
</AxoTooltip.Root>
);
}
function Example(props: ExampleProps) {
const [boundary, setBoundary] = useState<HTMLElement | null>(null);
return (
<AxoTooltip.CollisionBoundary boundary={boundary}>
<div
className={tw('size-100 snap-both snap-mandatory overflow-auto border')}
ref={setBoundary}
>
<div className={tw('flex size-250 items-center justify-center')}>
<div className={tw('snap-center')}>
<SimpleExample {...props} />
</div>
</div>
</div>
</AxoTooltip.CollisionBoundary>
);
}
export function Sides(): JSX.Element {
return (
<Stack>
<Row>
<Example trigger="Top" side="top" />
<Example trigger="Bottom" side="bottom" />
<Example trigger="Inline Start" side="inline-start" />
<Example trigger="Inline End" side="inline-end" />
</Row>
<Row>
<Example trigger="Top" side="top" label={LONG_TEXT} />
<Example trigger="Bottom" side="bottom" label={LONG_TEXT} />
<Example trigger="Inline Start" side="inline-start" label={LONG_TEXT} />
<Example trigger="Inline End" side="inline-end" label={LONG_TEXT} />
</Row>
</Stack>
);
}
export function Align(): JSX.Element {
return (
<Stack>
<Row>
<Example trigger="Center" align="center" />
<Example trigger="Center" align="force-start" />
<Example trigger="Center" align="force-end" />
</Row>
<Row>
<Example trigger="Center" align="center" label={LONG_TEXT} />
<Example trigger="Center" align="force-start" label={LONG_TEXT} />
<Example trigger="Center" align="force-end" label={LONG_TEXT} />
</Row>
</Stack>
);
}
export function Accessories(): JSX.Element {
return (
<Stack>
<Row>
<Example trigger="None" />
<Example trigger="Keyboard Shortcut" keyboardShortcut="⌘⇧Y" />
<Example trigger="Timestamp" experimentalTimestamp={Date.now()} />
</Row>
<Row>
<Example label={LONG_TEXT} trigger="None" />
<Example
label={LONG_TEXT}
trigger="Keyboard Shortcut"
keyboardShortcut="⌘⇧Y"
/>
<Example
label={LONG_TEXT}
trigger="Timestamp"
experimentalTimestamp={Date.now()}
/>
</Row>
</Stack>
);
}
export function InScrollArea(): JSX.Element {
return (
<div className={tw('size-100')}>
<AxoScrollArea.Root scrollbarWidth="thin" orientation="both">
<AxoScrollArea.Hint edge="top" />
<AxoScrollArea.Hint edge="bottom" />
<AxoScrollArea.Hint edge="inline-start" />
<AxoScrollArea.Hint edge="inline-end" />
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div className={tw('grid w-max grid-cols-3 gap-50 p-50')}>
{Array.from({ length: 9 }, (_, index) => {
return <SimpleExample key={index} />;
})}
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</AxoScrollArea.Root>
</div>
);
}
export function InDialog(): JSX.Element {
return (
<AxoDialog.Root open>
<AxoDialog.Content size="md" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>Title</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('flex flex-col items-center-safe gap-50 py-50')}>
{Array.from({ length: 6 }, (_, index) => {
return <SimpleExample key={index} />;
})}
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.IconAction
variant="primary"
symbol="send-fill"
label="Send message"
onClick={action('onSave')}
/>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}

355
ts/axo/AxoTooltip.dom.tsx Normal file
View File

@@ -0,0 +1,355 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactNode } from 'react';
import React, {
createContext,
memo,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
import { Tooltip, Direction } from 'radix-ui';
import { computeAccessibleName } from 'dom-accessibility-api';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js';
import {
getElementAriaRole,
isAriaWidgetRole,
} from './_internal/ariaRoles.dom.js';
import { isTestOrMockEnvironment } from '../environment.std.js';
const { useDirection } = Direction;
const Namespace = 'AxoTooltip';
type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right';
export namespace AxoTooltip {
/**
* The duration from when the mouse enters a tooltip trigger until the
* tooltip opens.
*
* - auto: 700ms (default)
* - none: 0ms
* - TODO: Other durations?
*/
export type Delay = 'auto' | 'none';
const Delays: Record<Delay, number> = {
auto: 700,
none: 0,
};
/**
* How much time the user has to enter another tooltip trigger without
* incurring a delay again.
* - auto: 300ms (default)
* - never: 0ms
*/
export type SkipDelay = 'auto' | 'never';
const SkipDelays: Record<SkipDelay, number> = {
auto: 300,
never: 0,
};
/**
* The preferred side of the trigger to render against when open.
* Will be reversed when collisions occur.
*
* - top (default): Above the trigger, flips to bottom.
* - bottom: Below the trigger, flips to top.
* - inline-start: Left of trigger, or right in RTL language.
* - inline-end: Right of trigger, or left in RTL language.
*/
export type Side = 'top' | 'bottom' | 'inline-start' | 'inline-end';
/**
* The preferred alignment against the trigger.
* - center (default): Try to align the tooltip as center as can fit within
* any collision boundaries.
* - force-start/force-end: Force the tooltip and trigger to be aligned on
* their leading/trailing edges.
*/
export type Align = 'center' | 'force-start' | 'force-end';
const Aligns: Record<Align, Tooltip.TooltipContentProps['align']> = {
center: 'center',
'force-start': 'start',
'force-end': 'end',
};
export type ExperimentalTimestampFormat = 'testing-only';
/**
* Component: <AxoTooltip.Provider>
* --------------------------------
*/
export type ProviderProps = Readonly<{
delay?: Delay;
skipDelay?: SkipDelay;
children: ReactNode;
}>;
export const Provider: FC<ProviderProps> = memo(props => {
const { delay = 'auto', skipDelay = 'auto' } = props;
const delayDuration = useMemo(() => {
return Delays[delay];
}, [delay]);
const skipDelayDuration = useMemo(() => {
return SkipDelays[skipDelay];
}, [skipDelay]);
return (
<Tooltip.Provider
delayDuration={delayDuration}
skipDelayDuration={skipDelayDuration}
>
{props.children}
</Tooltip.Provider>
);
});
Provider.displayName = `${Namespace}.Provider`;
/**
* Component: <AxoTooltip.CollisionBoundary>
* -----------------------------------------
*/
const DEFAULT_COLLISION_PADDING = 8;
type CollisionBoundaryType = Readonly<{
elements: Array<Element | null>;
padding: number;
}>;
const CollisionBoundaryContext = createContext<CollisionBoundaryType>({
elements: [],
padding: DEFAULT_COLLISION_PADDING,
});
export type CollisionBoundaryProps = Readonly<{
boundary: Element | null;
padding?: number;
children: ReactNode;
}>;
export const CollisionBoundary: FC<CollisionBoundaryProps> = memo(props => {
const { boundary, padding } = props;
const context = useContext(CollisionBoundaryContext);
const value = useMemo((): CollisionBoundaryType => {
return {
elements: [...context.elements, boundary],
padding: padding ?? DEFAULT_COLLISION_PADDING, // Always reset to default
};
}, [context, boundary, padding]);
return (
<CollisionBoundaryContext.Provider value={value}>
{props.children}
</CollisionBoundaryContext.Provider>
);
});
CollisionBoundary.displayName = `${Namespace}.CollisionBoundary`;
/**
* Component: <AxoTooltip.Root>
* ----------------------------
*/
function generateTooltipArrowPath(): string {
let path = '';
path += 'M 0 0'; // start at top left
path += 'Q 3 0, 5 2'; // left inner curve
path += 'L 8 5'; // left edge
path += 'Q 9 6, 10 6'; // left tip curve
path += 'Q 11 6, 12 5'; // right tip curve
path += 'L 15 2'; // right edge
path += 'Q 17 0, 20 0'; // right inner curve, end at top right
path += 'Z'; // close
return path;
}
const TOOLTIP_ARROW_PATH = generateTooltipArrowPath();
const TOOLTIP_ARROW_WIDTH = 20;
const TOOLTIP_ARROW_HEIGHT = 6;
export type RootConfigProps = Readonly<{
delay?: Delay;
side?: Side;
align?: Align;
label: ReactNode;
// TODO(jamie): Need to spec timestamp formats
experimentalTimestamp?: number | null;
experimentalTimestampFormat?: ExperimentalTimestampFormat;
keyboardShortcut?: string | null;
}>;
export type RootProps = RootConfigProps &
Readonly<{
/**
* You may sometimes want to use [aria-hidden] when the tooltip is
* repeating the same content as [aria-label] which would make it purely
* a visual affordance.
*/
tooltipRepeatsTriggerAccessibleName?: boolean;
children: ReactNode;
/** @private exported for stories only */
__FORCE_OPEN?: boolean;
}>;
const rootDisplayName = `${Namespace}.Root`;
export const Root: FC<RootProps> = memo(props => {
const {
delay,
side = 'top',
align = 'center',
keyboardShortcut,
experimentalTimestamp,
} = props;
const direction = useDirection();
const collisionBoundary = useContext(CollisionBoundaryContext);
const triggerRef = useRef<HTMLButtonElement>(null);
const physicalDirection = useMemo((): PhysicalDirection => {
if (side === 'inline-start') {
return direction === 'rtl' ? 'right' : 'left';
}
if (side === 'inline-end') {
return direction === 'rtl' ? 'left' : 'right';
}
return side;
}, [side, direction]);
const hasArrow = useMemo(() => {
return side === 'top' || side === 'bottom';
}, [side]);
const delayDuration = useMemo(() => {
return delay != null ? Delays[delay] : undefined;
}, [delay]);
const formattedTimestamp = useMemo(() => {
if (experimentalTimestamp == null) {
return null;
}
const formatter = new Intl.DateTimeFormat('en', { timeStyle: 'short' });
return formatter.format(experimentalTimestamp);
}, [experimentalTimestamp]);
const hasAccessory = useMemo(() => {
return keyboardShortcut != null && formattedTimestamp != null;
}, [keyboardShortcut, formattedTimestamp]);
useEffect(() => {
if (isTestOrMockEnvironment()) {
assert(
triggerRef.current instanceof HTMLElement,
`${rootDisplayName} child must forward ref`
);
assert(
isAriaWidgetRole(getElementAriaRole(triggerRef.current)),
`${rootDisplayName} child must have a widget role like 'button'`
);
const triggerName = computeAccessibleName(triggerRef.current);
assert(
triggerName !== '',
`${rootDisplayName} child must have an accessible name`
);
if (props.tooltipRepeatsTriggerAccessibleName) {
return;
}
assert(
triggerName !== props.label,
`${rootDisplayName} label must not repeat child trigger's accessible name. ` +
'Use the tooltipRepeatsTriggerAccessibleName prop if you would ' +
'like to make the tooltip presentational only.'
);
}
});
return (
<Tooltip.Root
delayDuration={delayDuration}
{...(props.__FORCE_OPEN === true ? { open: true } : undefined)}
>
<Tooltip.Trigger asChild ref={triggerRef}>
{props.children}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side={physicalDirection}
align={Aligns[align]}
sideOffset={6}
arrowPadding={14}
collisionBoundary={collisionBoundary.elements}
collisionPadding={collisionBoundary.padding}
hideWhenDetached
className={tw(
'group flex items-baseline justify-center gap-2 overflow-hidden',
'rounded-[14px] px-2.5 py-1.5 type-body-small select-none',
'legacy-z-index-above-popup',
'bg-elevated-background-quaternary text-label-primary-on-color',
'shadow-elevation-3 shadow-no-outline',
'min-w-12',
hasAccessory ? 'max-w-[228px]' : 'max-w-[192px]'
)}
>
{hasArrow && (
<Tooltip.Arrow
asChild
width={TOOLTIP_ARROW_WIDTH}
height={TOOLTIP_ARROW_HEIGHT}
>
<svg
className={tw('fill-elevated-background-quaternary')}
xmlns="http://www.w3.org/2000/svg"
width={TOOLTIP_ARROW_WIDTH}
height={TOOLTIP_ARROW_HEIGHT}
viewBox={`0 0 ${TOOLTIP_ARROW_WIDTH} ${TOOLTIP_ARROW_HEIGHT}`}
>
<path d={TOOLTIP_ARROW_PATH} />
</svg>
</Tooltip.Arrow>
)}
<div
aria-hidden={props.tooltipRepeatsTriggerAccessibleName}
className={tw(
'line-clamp-4 max-h-full text-balance text-ellipsis hyphens-auto'
)}
>
{props.label}
</div>
{keyboardShortcut != null && (
<div
className={tw('type-body-small text-label-secondary-on-color')}
>
{keyboardShortcut}
</div>
)}
{formattedTimestamp != null && (
<div
className={tw(
'type-caption whitespace-nowrap text-label-secondary-on-color'
)}
>
{formattedTimestamp}
</div>
)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
});
Root.displayName = rootDisplayName;
}

View File

@@ -127,7 +127,7 @@ export function PanelHeader({
variant={isNonDefaultSorting ? 'primary' : 'borderless-secondary'}
size="md"
symbol="sort-vertical"
aria-label={i18n('icu:MediaGallery__sort')}
label={i18n('icu:MediaGallery__sort')}
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>

View File

@@ -442,7 +442,7 @@ function PinActionsMenu(props: {
variant="borderless-secondary"
size="md"
symbol="pin"
aria-label={i18n(
label={i18n(
'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel'
)}
/>

View File

@@ -839,6 +839,13 @@
"reasonCategory": "usageTrusted",
"updated": "2025-11-05T21:09:43.372Z"
},
{
"rule": "React-useRef",
"path": "ts/axo/AxoTooltip.dom.tsx",
"line": " const triggerRef = useRef<HTMLButtonElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2026-02-09T21:54:48.020Z"
},
{
"rule": "React-useRef",
"path": "ts/calling/useGetCallingFrameBuffer.std.ts",