mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-14 23:18:54 +00:00
Init AxoTooltip component
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -61,7 +61,6 @@ export namespace ExperimentalAxoBadge {
|
||||
|
||||
let cachedNumberFormat: Intl.NumberFormat;
|
||||
|
||||
// eslint-disable-next-line no-inner-declarations
|
||||
function formatBadgeCount(
|
||||
value: number,
|
||||
max: number,
|
||||
|
||||
@@ -115,7 +115,6 @@ export namespace AxoContextMenu {
|
||||
|
||||
type TriggerElementGetter = (event: KeyboardEvent) => Element;
|
||||
|
||||
// eslint-disable-next-line no-inner-declarations
|
||||
function useContextMenuTriggerKeyboardEventHandler(
|
||||
getTriggerElement: TriggerElementGetter
|
||||
) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
189
ts/axo/AxoTooltip.dom.stories.tsx
Normal file
189
ts/axo/AxoTooltip.dom.stories.tsx
Normal 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
355
ts/axo/AxoTooltip.dom.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -442,7 +442,7 @@ function PinActionsMenu(props: {
|
||||
variant="borderless-secondary"
|
||||
size="md"
|
||||
symbol="pin"
|
||||
aria-label={i18n(
|
||||
label={i18n(
|
||||
'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user