From d34ebaab4659ef27cd2eba789a4a14d3449208e9 Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:26:46 -0800 Subject: [PATCH] Init AxoTooltip component --- .eslintrc.js | 2 + ts/axo/AriaClickable.dom.tsx | 3 +- ts/axo/AxoBadge.dom.tsx | 1 - ts/axo/AxoContextMenu.dom.tsx | 1 - ts/axo/AxoDialog.dom.stories.tsx | 4 +- ts/axo/AxoDialog.dom.tsx | 41 +- ts/axo/AxoDropdownMenu.dom.tsx | 3 +- ts/axo/AxoIconButton.dom.stories.tsx | 10 +- ts/axo/AxoIconButton.dom.tsx | 47 ++- ts/axo/AxoProvider.dom.tsx | 6 +- ts/axo/AxoScrollArea.dom.tsx | 33 +- ts/axo/AxoSymbol.dom.tsx | 1 - ts/axo/AxoTooltip.dom.stories.tsx | 189 ++++++++++ ts/axo/AxoTooltip.dom.tsx | 355 ++++++++++++++++++ .../media-gallery/PanelHeader.dom.tsx | 2 +- .../pinned-messages/PinnedMessagesBar.dom.tsx | 2 +- ts/util/lint/exceptions.json | 7 + 17 files changed, 653 insertions(+), 54 deletions(-) create mode 100644 ts/axo/AxoTooltip.dom.stories.tsx create mode 100644 ts/axo/AxoTooltip.dom.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 4f67a9207a..d9e94ac198 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', diff --git a/ts/axo/AriaClickable.dom.tsx b/ts/axo/AriaClickable.dom.tsx index 7e97715100..25523073ff 100644 --- a/ts/axo/AriaClickable.dom.tsx +++ b/ts/axo/AriaClickable.dom.tsx @@ -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` diff --git a/ts/axo/AxoBadge.dom.tsx b/ts/axo/AxoBadge.dom.tsx index 5155b5c29e..fbba583a72 100644 --- a/ts/axo/AxoBadge.dom.tsx +++ b/ts/axo/AxoBadge.dom.tsx @@ -61,7 +61,6 @@ export namespace ExperimentalAxoBadge { let cachedNumberFormat: Intl.NumberFormat; - // eslint-disable-next-line no-inner-declarations function formatBadgeCount( value: number, max: number, diff --git a/ts/axo/AxoContextMenu.dom.tsx b/ts/axo/AxoContextMenu.dom.tsx index 2a475f7117..7f9ee86c63 100644 --- a/ts/axo/AxoContextMenu.dom.tsx +++ b/ts/axo/AxoContextMenu.dom.tsx @@ -115,7 +115,6 @@ export namespace AxoContextMenu { type TriggerElementGetter = (event: KeyboardEvent) => Element; - // eslint-disable-next-line no-inner-declarations function useContextMenuTriggerKeyboardEventHandler( getTriggerElement: TriggerElementGetter ) { diff --git a/ts/axo/AxoDialog.dom.stories.tsx b/ts/axo/AxoDialog.dom.stories.tsx index 700b023c93..6b52dfecc0 100644 --- a/ts/axo/AxoDialog.dom.stories.tsx +++ b/ts/axo/AxoDialog.dom.stories.tsx @@ -72,7 +72,7 @@ function Template(props: { {props.iconAction ? ( - {TEXT_SHORT} + {TEXT_LONG} ); } diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 8208af96b9..dd58e9a31a 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -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 = memo(props => { const sizeConfig = ContentSizes[props.size]; const handleContentEscapeEvent = useContentEscapeBehavior(props.escape); + const [boundary, setBoundary] = useState(null); const descriptionProps = useMemo((): Dialog.DialogContentProps => { if (props.disableMissingAriaDescriptionWarning) { @@ -95,18 +97,21 @@ export namespace AxoDialog { return ( - - {props.children} - + + + {props.children} + + ); @@ -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} /> @@ -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} /> @@ -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 = memo(props => { return ( (null); useEffect(() => { - if (process.env.NODE_ENV === 'development') { + if (isTestOrMockEnvironment()) { assert( ref.current instanceof HTMLElement, `${triggerDisplayName} child must forward ref` diff --git a/ts/axo/AxoIconButton.dom.stories.tsx b/ts/axo/AxoIconButton.dom.stories.tsx index c862f3bde3..a415b454f8 100644 --- a/ts/axo/AxoIconButton.dom.stories.tsx +++ b/ts/axo/AxoIconButton.dom.stories.tsx @@ -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" /> ); @@ -121,7 +121,7 @@ export function Sizes(): React.JSX.Element { variant={variant} size={size} symbol="more" - aria-label="More" + label="More actions" /> ); @@ -168,7 +168,7 @@ export function States(): React.JSX.Element { variant={variant} size="lg" symbol="more" - aria-label="More" + label="More actions" {...AllStates[state]} /> @@ -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' }} /> diff --git a/ts/axo/AxoIconButton.dom.tsx b/ts/axo/AxoIconButton.dom.tsx index ec4a943f66..ab7825890a 100644 --- a/ts/axo/AxoIconButton.dom.tsx +++ b/ts/axo/AxoIconButton.dom.tsx @@ -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; 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 = memo( forwardRef((props, ref: ForwardedRef) => { - 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 = ( ); + + if (tooltipConfig != null) { + return ( + + {button} + + ); + } + + 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]; diff --git a/ts/axo/AxoProvider.dom.tsx b/ts/axo/AxoProvider.dom.tsx index 4212e17cad..acbc0e2ab0 100644 --- a/ts/axo/AxoProvider.dom.tsx +++ b/ts/axo/AxoProvider.dom.tsx @@ -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 = memo(props => { }; }); return ( - {props.children} + + {props.children} + ); }); diff --git a/ts/axo/AxoScrollArea.dom.tsx b/ts/axo/AxoScrollArea.dom.tsx index 467f7cc733..ee3eec5fd3 100644 --- a/ts/axo/AxoScrollArea.dom.tsx +++ b/ts/axo/AxoScrollArea.dom.tsx @@ -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(null); const style = useMemo((): CSSProperties => { const hasVerticalScrollbar = orientation !== 'horizontal'; @@ -261,19 +263,22 @@ export namespace AxoScrollArea { }, [orientation, scrollbarWidth, scrollbarGutter]); return ( -
- {props.children} -
+ +
+ {props.children} +
+
); }); diff --git a/ts/axo/AxoSymbol.dom.tsx b/ts/axo/AxoSymbol.dom.tsx index 8afe302f4b..4b1e2727f0 100644 --- a/ts/axo/AxoSymbol.dom.tsx +++ b/ts/axo/AxoSymbol.dom.tsx @@ -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 diff --git a/ts/axo/AxoTooltip.dom.stories.tsx b/ts/axo/AxoTooltip.dom.stories.tsx new file mode 100644 index 0000000000..dbf85a921f --- /dev/null +++ b/ts/axo/AxoTooltip.dom.stories.tsx @@ -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
{props.children}
; +} + +function Stack(props: { children: ReactNode }) { + return
{props.children}
; +} + +type ExampleProps = { + trigger?: string; + label?: ReactNode; + side?: AxoTooltip.Side; + align?: AxoTooltip.Align; + keyboardShortcut?: string; + experimentalTimestamp?: number; +}; + +function SimpleExample(props: ExampleProps) { + return ( + + + {props.trigger ?? 'Hover Me'} + + + ); +} + +function Example(props: ExampleProps) { + const [boundary, setBoundary] = useState(null); + return ( + +
+
+
+ +
+
+
+
+ ); +} + +export function Sides(): JSX.Element { + return ( + + + + + + + + + + + + + + + ); +} + +export function Align(): JSX.Element { + return ( + + + + + + + + + + + + + ); +} + +export function Accessories(): JSX.Element { + return ( + + + + + + + + + + + + + ); +} + +export function InScrollArea(): JSX.Element { + return ( +
+ + + + + + + +
+ {Array.from({ length: 9 }, (_, index) => { + return ; + })} +
+
+
+
+
+ ); +} + +export function InDialog(): JSX.Element { + return ( + + + + Title + + + +
+ {Array.from({ length: 6 }, (_, index) => { + return ; + })} +
+
+ + + + + +
+
+ ); +} diff --git a/ts/axo/AxoTooltip.dom.tsx b/ts/axo/AxoTooltip.dom.tsx new file mode 100644 index 0000000000..aa4bf32f05 --- /dev/null +++ b/ts/axo/AxoTooltip.dom.tsx @@ -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 = { + 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 = { + 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 = { + center: 'center', + 'force-start': 'start', + 'force-end': 'end', + }; + + export type ExperimentalTimestampFormat = 'testing-only'; + + /** + * Component: + * -------------------------------- + */ + + export type ProviderProps = Readonly<{ + delay?: Delay; + skipDelay?: SkipDelay; + children: ReactNode; + }>; + + export const Provider: FC = memo(props => { + const { delay = 'auto', skipDelay = 'auto' } = props; + + const delayDuration = useMemo(() => { + return Delays[delay]; + }, [delay]); + + const skipDelayDuration = useMemo(() => { + return SkipDelays[skipDelay]; + }, [skipDelay]); + + return ( + + {props.children} + + ); + }); + + Provider.displayName = `${Namespace}.Provider`; + + /** + * Component: + * ----------------------------------------- + */ + + const DEFAULT_COLLISION_PADDING = 8; + + type CollisionBoundaryType = Readonly<{ + elements: Array; + padding: number; + }>; + const CollisionBoundaryContext = createContext({ + elements: [], + padding: DEFAULT_COLLISION_PADDING, + }); + + export type CollisionBoundaryProps = Readonly<{ + boundary: Element | null; + padding?: number; + children: ReactNode; + }>; + + export const CollisionBoundary: FC = 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 ( + + {props.children} + + ); + }); + + CollisionBoundary.displayName = `${Namespace}.CollisionBoundary`; + + /** + * Component: + * ---------------------------- + */ + + 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 = memo(props => { + const { + delay, + side = 'top', + align = 'center', + keyboardShortcut, + experimentalTimestamp, + } = props; + const direction = useDirection(); + const collisionBoundary = useContext(CollisionBoundaryContext); + const triggerRef = useRef(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 ( + + + {props.children} + + + + {hasArrow && ( + + + + + + )} +
+ {props.label} +
+ {keyboardShortcut != null && ( +
+ {keyboardShortcut} +
+ )} + {formattedTimestamp != null && ( +
+ {formattedTimestamp} +
+ )} +
+
+
+ ); + }); + + Root.displayName = rootDisplayName; +} diff --git a/ts/components/conversation/media-gallery/PanelHeader.dom.tsx b/ts/components/conversation/media-gallery/PanelHeader.dom.tsx index 231031825d..cd4e876196 100644 --- a/ts/components/conversation/media-gallery/PanelHeader.dom.tsx +++ b/ts/components/conversation/media-gallery/PanelHeader.dom.tsx @@ -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')} /> diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx index d0de241f47..3da4991d37 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx @@ -442,7 +442,7 @@ function PinActionsMenu(props: { variant="borderless-secondary" size="md" symbol="pin" - aria-label={i18n( + label={i18n( 'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel' )} /> diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0b4b82ad3c..91dbd22740 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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(null);", + "reasonCategory": "usageTrusted", + "updated": "2026-02-09T21:54:48.020Z" + }, { "rule": "React-useRef", "path": "ts/calling/useGetCallingFrameBuffer.std.ts",