mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 08:58:38 +01:00
Init AxoTooltip component
This commit is contained in:
@@ -454,6 +454,8 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
files: ['ts/axo/**/*.tsx'],
|
files: ['ts/axo/**/*.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
|
// Rule doesn't understand TypeScript namespaces
|
||||||
|
'no-inner-declarations': 'off',
|
||||||
'@typescript-eslint/no-namespace': 'off',
|
'@typescript-eslint/no-namespace': 'off',
|
||||||
'@typescript-eslint/no-redeclare': [
|
'@typescript-eslint/no-redeclare': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
createStrictContext,
|
createStrictContext,
|
||||||
useStrictContext,
|
useStrictContext,
|
||||||
} from './_internal/StrictContext.dom.js';
|
} from './_internal/StrictContext.dom.js';
|
||||||
|
import { isTestOrMockEnvironment } from '../environment.std.js';
|
||||||
|
|
||||||
const Namespace = 'AriaClickable';
|
const Namespace = 'AriaClickable';
|
||||||
|
|
||||||
@@ -229,7 +230,7 @@ export namespace AriaClickable {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (isTestOrMockEnvironment()) {
|
||||||
assert(
|
assert(
|
||||||
computeAccessibleName(assert(ref.current)) !== '',
|
computeAccessibleName(assert(ref.current)) !== '',
|
||||||
`${hiddenTriggerDisplayName} child must have an accessible name`
|
`${hiddenTriggerDisplayName} child must have an accessible name`
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export namespace ExperimentalAxoBadge {
|
|||||||
|
|
||||||
let cachedNumberFormat: Intl.NumberFormat;
|
let cachedNumberFormat: Intl.NumberFormat;
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
|
||||||
function formatBadgeCount(
|
function formatBadgeCount(
|
||||||
value: number,
|
value: number,
|
||||||
max: number,
|
max: number,
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export namespace AxoContextMenu {
|
|||||||
|
|
||||||
type TriggerElementGetter = (event: KeyboardEvent) => Element;
|
type TriggerElementGetter = (event: KeyboardEvent) => Element;
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
|
||||||
function useContextMenuTriggerKeyboardEventHandler(
|
function useContextMenuTriggerKeyboardEventHandler(
|
||||||
getTriggerElement: TriggerElementGetter
|
getTriggerElement: TriggerElementGetter
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function Template(props: {
|
|||||||
<AxoDialog.Actions>
|
<AxoDialog.Actions>
|
||||||
{props.iconAction ? (
|
{props.iconAction ? (
|
||||||
<AxoDialog.IconAction
|
<AxoDialog.IconAction
|
||||||
aria-label="Send"
|
label="Send message"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
symbol="send-fill"
|
symbol="send-fill"
|
||||||
onClick={action('onSend')}
|
onClick={action('onSend')}
|
||||||
@@ -121,7 +121,7 @@ export function Large(): React.JSX.Element {
|
|||||||
export function IconAction(): React.JSX.Element {
|
export function IconAction(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Template contentSize="sm" iconAction>
|
<Template contentSize="sm" iconAction>
|
||||||
{TEXT_SHORT}
|
{TEXT_LONG}
|
||||||
</Template>
|
</Template>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-17
@@ -3,13 +3,14 @@
|
|||||||
|
|
||||||
import { Dialog } from 'radix-ui';
|
import { Dialog } from 'radix-ui';
|
||||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
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 { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js';
|
||||||
import type { AxoSymbol } from './AxoSymbol.dom.js';
|
import type { AxoSymbol } from './AxoSymbol.dom.js';
|
||||||
import { tw } from './tw.dom.js';
|
import { tw } from './tw.dom.js';
|
||||||
import { AxoScrollArea } from './AxoScrollArea.dom.js';
|
import { AxoScrollArea } from './AxoScrollArea.dom.js';
|
||||||
import { AxoButton } from './AxoButton.dom.js';
|
import { AxoButton } from './AxoButton.dom.js';
|
||||||
import { AxoIconButton } from './AxoIconButton.dom.js';
|
import { AxoIconButton } from './AxoIconButton.dom.js';
|
||||||
|
import { AxoTooltip } from './AxoTooltip.dom.js';
|
||||||
|
|
||||||
const Namespace = 'AxoDialog';
|
const Namespace = 'AxoDialog';
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export namespace AxoDialog {
|
|||||||
export const Content: FC<ContentProps> = memo(props => {
|
export const Content: FC<ContentProps> = memo(props => {
|
||||||
const sizeConfig = ContentSizes[props.size];
|
const sizeConfig = ContentSizes[props.size];
|
||||||
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
|
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
|
||||||
|
const [boundary, setBoundary] = useState<Element | null>(null);
|
||||||
|
|
||||||
const descriptionProps = useMemo((): Dialog.DialogContentProps => {
|
const descriptionProps = useMemo((): Dialog.DialogContentProps => {
|
||||||
if (props.disableMissingAriaDescriptionWarning) {
|
if (props.disableMissingAriaDescriptionWarning) {
|
||||||
@@ -95,18 +97,21 @@ export namespace AxoDialog {
|
|||||||
return (
|
return (
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
|
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
|
||||||
<Dialog.Content
|
<AxoTooltip.CollisionBoundary boundary={boundary} padding={4}>
|
||||||
className={AxoBaseDialog.contentStyles}
|
<Dialog.Content
|
||||||
onEscapeKeyDown={handleContentEscapeEvent}
|
ref={setBoundary}
|
||||||
onInteractOutside={handleContentEscapeEvent}
|
className={AxoBaseDialog.contentStyles}
|
||||||
style={{
|
onEscapeKeyDown={handleContentEscapeEvent}
|
||||||
width: sizeConfig.width,
|
onInteractOutside={handleContentEscapeEvent}
|
||||||
minWidth: 320,
|
style={{
|
||||||
}}
|
width: sizeConfig.width,
|
||||||
{...descriptionProps}
|
minWidth: 320,
|
||||||
>
|
}}
|
||||||
{props.children}
|
{...descriptionProps}
|
||||||
</Dialog.Content>
|
>
|
||||||
|
{props.children}
|
||||||
|
</Dialog.Content>
|
||||||
|
</AxoTooltip.CollisionBoundary>
|
||||||
</Dialog.Overlay>
|
</Dialog.Overlay>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
);
|
);
|
||||||
@@ -182,7 +187,8 @@ export namespace AxoDialog {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="borderless-secondary"
|
variant="borderless-secondary"
|
||||||
symbol="chevron-[start]"
|
symbol="chevron-[start]"
|
||||||
aria-label={props['aria-label']}
|
label={props['aria-label']}
|
||||||
|
tooltip={false}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +214,8 @@ export namespace AxoDialog {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="borderless-secondary"
|
variant="borderless-secondary"
|
||||||
symbol="x"
|
symbol="x"
|
||||||
aria-label={props['aria-label']}
|
label={props['aria-label']}
|
||||||
|
tooltip={false}
|
||||||
/>
|
/>
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +416,7 @@ export namespace AxoDialog {
|
|||||||
export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
|
export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
|
||||||
|
|
||||||
export type IconActionProps = Readonly<{
|
export type IconActionProps = Readonly<{
|
||||||
'aria-label': string;
|
label: string;
|
||||||
variant: ActionVariant;
|
variant: ActionVariant;
|
||||||
symbol: AxoSymbol.IconName;
|
symbol: AxoSymbol.IconName;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -418,7 +425,7 @@ export namespace AxoDialog {
|
|||||||
export const IconAction: FC<IconActionProps> = memo(props => {
|
export const IconAction: FC<IconActionProps> = memo(props => {
|
||||||
return (
|
return (
|
||||||
<AxoIconButton.Root
|
<AxoIconButton.Root
|
||||||
aria-label={props['aria-label']}
|
label={props.label}
|
||||||
variant={props.variant}
|
variant={props.variant}
|
||||||
size="md"
|
size="md"
|
||||||
symbol={props.symbol}
|
symbol={props.symbol}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
createStrictContext,
|
createStrictContext,
|
||||||
useStrictContext,
|
useStrictContext,
|
||||||
} from './_internal/StrictContext.dom.js';
|
} from './_internal/StrictContext.dom.js';
|
||||||
|
import { isTestOrMockEnvironment } from '../environment.std.js';
|
||||||
|
|
||||||
const Namespace = 'AxoDropdownMenu';
|
const Namespace = 'AxoDropdownMenu';
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ export namespace AxoDropdownMenu {
|
|||||||
const ref = useRef<HTMLButtonElement>(null);
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (isTestOrMockEnvironment()) {
|
||||||
assert(
|
assert(
|
||||||
ref.current instanceof HTMLElement,
|
ref.current instanceof HTMLElement,
|
||||||
`${triggerDisplayName} child must forward ref`
|
`${triggerDisplayName} child must forward ref`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function Basic(): React.JSX.Element {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
symbol="more"
|
symbol="more"
|
||||||
aria-label="More"
|
label="More actions"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ export function Variants(): React.JSX.Element {
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
size="lg"
|
size="lg"
|
||||||
symbol="more"
|
symbol="more"
|
||||||
aria-label="More"
|
label="More actions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -121,7 +121,7 @@ export function Sizes(): React.JSX.Element {
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
symbol="more"
|
symbol="more"
|
||||||
aria-label="More"
|
label="More actions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -168,7 +168,7 @@ export function States(): React.JSX.Element {
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
size="lg"
|
size="lg"
|
||||||
symbol="more"
|
symbol="more"
|
||||||
aria-label="More"
|
label="More actions"
|
||||||
{...AllStates[state]}
|
{...AllStates[state]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +210,7 @@ export function Spinners(): React.JSX.Element {
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
symbol="more"
|
symbol="more"
|
||||||
aria-label="More"
|
label="More actions"
|
||||||
experimentalSpinner={{ 'aria-label': 'Loading' }}
|
experimentalSpinner={{ 'aria-label': 'Loading' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ButtonHTMLAttributes, FC, ForwardedRef } from 'react';
|
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 { AxoSymbol } from './AxoSymbol.dom.js';
|
||||||
import type { TailwindStyles } from './tw.dom.js';
|
import type { TailwindStyles } from './tw.dom.js';
|
||||||
import { tw } from './tw.dom.js';
|
import { tw } from './tw.dom.js';
|
||||||
import type { SpinnerVariant } from '../components/SpinnerV2.dom.js';
|
import type { SpinnerVariant } from '../components/SpinnerV2.dom.js';
|
||||||
import { SpinnerV2 } from '../components/SpinnerV2.dom.js';
|
import { SpinnerV2 } from '../components/SpinnerV2.dom.js';
|
||||||
|
import { AxoTooltip } from './AxoTooltip.dom.js';
|
||||||
|
|
||||||
const Namespace = 'AxoIconButton';
|
const Namespace = 'AxoIconButton';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ type GenericButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
|
|
||||||
export namespace AxoIconButton {
|
export namespace AxoIconButton {
|
||||||
const baseStyles = tw(
|
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]',
|
'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:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
|
||||||
'forced-colors:disabled:text-[GrayText]',
|
'forced-colors:disabled:text-[GrayText]',
|
||||||
@@ -105,7 +106,8 @@ export namespace AxoIconButton {
|
|||||||
|
|
||||||
export type RootProps = Readonly<{
|
export type RootProps = Readonly<{
|
||||||
// required: Should describe the purpose of the button, not the icon.
|
// required: Should describe the purpose of the button, not the icon.
|
||||||
'aria-label': string;
|
label: string;
|
||||||
|
tooltip?: boolean | AxoTooltip.RootConfigProps;
|
||||||
|
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
size: Size;
|
size: Size;
|
||||||
@@ -122,12 +124,31 @@ export namespace AxoIconButton {
|
|||||||
|
|
||||||
export const Root: FC<RootProps> = memo(
|
export const Root: FC<RootProps> = memo(
|
||||||
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
|
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
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
aria-label={label}
|
||||||
type="button"
|
type="button"
|
||||||
className={tw(
|
className={tw(
|
||||||
baseStyles,
|
baseStyles,
|
||||||
@@ -137,7 +158,7 @@ export namespace AxoIconButton {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={tw(
|
className={tw(
|
||||||
'align-top leading-none forced-color-adjust-none',
|
'align-top forced-color-adjust-none',
|
||||||
experimentalSpinner != null ? 'opacity-0' : null
|
experimentalSpinner != null ? 'opacity-0' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -156,6 +177,19 @@ export namespace AxoIconButton {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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;
|
'aria-label': string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
|
||||||
function Spinner(props: SpinnerProps): React.JSX.Element {
|
function Spinner(props: SpinnerProps): React.JSX.Element {
|
||||||
const variant = SpinnerVariants[props.buttonVariant];
|
const variant = SpinnerVariants[props.buttonVariant];
|
||||||
const sizeConfig = SpinnerSizes[props.buttonSize];
|
const sizeConfig = SpinnerSizes[props.buttonSize];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import React, { memo, useInsertionEffect } 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';
|
import { createScrollbarGutterCssProperties } from './_internal/scrollbars.dom.js';
|
||||||
|
|
||||||
type AxoProviderProps = Readonly<{
|
type AxoProviderProps = Readonly<{
|
||||||
@@ -27,7 +27,9 @@ export const AxoProvider: FC<AxoProviderProps> = memo(props => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
return (
|
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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { CSSProperties, FC, ReactNode } from 'react';
|
||||||
import type { TailwindStyles } from './tw.dom.js';
|
import type { TailwindStyles } from './tw.dom.js';
|
||||||
import { tw } from './tw.dom.js';
|
import { tw } from './tw.dom.js';
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createStrictContext,
|
createStrictContext,
|
||||||
useStrictContext,
|
useStrictContext,
|
||||||
} from './_internal/StrictContext.dom.js';
|
} from './_internal/StrictContext.dom.js';
|
||||||
|
import { AxoTooltip } from './AxoTooltip.dom.js';
|
||||||
|
|
||||||
const Namespace = 'AxoScrollArea';
|
const Namespace = 'AxoScrollArea';
|
||||||
|
|
||||||
@@ -216,6 +217,7 @@ export namespace AxoScrollArea {
|
|||||||
scrollbarVisibility,
|
scrollbarVisibility,
|
||||||
scrollBehavior,
|
scrollBehavior,
|
||||||
} = useStrictContext(ScrollAreaConfigContext);
|
} = useStrictContext(ScrollAreaConfigContext);
|
||||||
|
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const style = useMemo((): CSSProperties => {
|
const style = useMemo((): CSSProperties => {
|
||||||
const hasVerticalScrollbar = orientation !== 'horizontal';
|
const hasVerticalScrollbar = orientation !== 'horizontal';
|
||||||
@@ -261,19 +263,22 @@ export namespace AxoScrollArea {
|
|||||||
}, [orientation, scrollbarWidth, scrollbarGutter]);
|
}, [orientation, scrollbarWidth, scrollbarGutter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AxoTooltip.CollisionBoundary boundary={boundary}>
|
||||||
data-axo-scroll-area-viewport
|
<div
|
||||||
className={tw(
|
ref={setBoundary}
|
||||||
baseViewportStyles,
|
data-axo-scroll-area-viewport
|
||||||
ViewportScrollbarWidths[scrollbarWidth],
|
className={tw(
|
||||||
ViewportScrollbarGutters[scrollbarGutter],
|
baseViewportStyles,
|
||||||
ViewportScrollbarVisibilities[scrollbarVisibility],
|
ViewportScrollbarWidths[scrollbarWidth],
|
||||||
ViewportScrollBehaviors[scrollBehavior]
|
ViewportScrollbarGutters[scrollbarGutter],
|
||||||
)}
|
ViewportScrollbarVisibilities[scrollbarVisibility],
|
||||||
style={style}
|
ViewportScrollBehaviors[scrollBehavior]
|
||||||
>
|
)}
|
||||||
{props.children}
|
style={style}
|
||||||
</div>
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</AxoTooltip.CollisionBoundary>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export namespace AxoSymbol {
|
|||||||
const symbolStyles = tw('font-symbols select-none');
|
const symbolStyles = tw('font-symbols select-none');
|
||||||
const labelStyles = tw('select-none');
|
const labelStyles = tw('select-none');
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
|
||||||
function useRenderSymbol(
|
function useRenderSymbol(
|
||||||
glyph: string,
|
glyph: string,
|
||||||
label: string | null
|
label: string | null
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'}
|
variant={isNonDefaultSorting ? 'primary' : 'borderless-secondary'}
|
||||||
size="md"
|
size="md"
|
||||||
symbol="sort-vertical"
|
symbol="sort-vertical"
|
||||||
aria-label={i18n('icu:MediaGallery__sort')}
|
label={i18n('icu:MediaGallery__sort')}
|
||||||
/>
|
/>
|
||||||
</AxoDropdownMenu.Trigger>
|
</AxoDropdownMenu.Trigger>
|
||||||
<AxoDropdownMenu.Content>
|
<AxoDropdownMenu.Content>
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ function PinActionsMenu(props: {
|
|||||||
variant="borderless-secondary"
|
variant="borderless-secondary"
|
||||||
size="md"
|
size="md"
|
||||||
symbol="pin"
|
symbol="pin"
|
||||||
aria-label={i18n(
|
label={i18n(
|
||||||
'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel'
|
'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -839,6 +839,13 @@
|
|||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2025-11-05T21:09:43.372Z"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/calling/useGetCallingFrameBuffer.std.ts",
|
"path": "ts/calling/useGetCallingFrameBuffer.std.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user