From d7ee96cc043d28f7ef42a7004faec0bc217cb1fb Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 6 May 2026 10:37:35 -0700 Subject: [PATCH] Axo improvements and documentation --- .storybook/preview.tsx | 23 +- _locales/en/messages.json | 16 + ts/axo/AriaClickable.dom.stories.tsx | 5 +- ts/axo/AriaClickable.dom.tsx | 252 +++---- ts/axo/AxoAlertDialog.dom.tsx | 187 +++-- ts/axo/AxoAvatar.dom.tsx | 422 ++++++++--- ts/axo/AxoBadge.dom.stories.tsx | 6 +- ts/axo/AxoBadge.dom.tsx | 101 ++- ts/axo/AxoButton.dom.stories.tsx | 26 +- ts/axo/AxoButton.dom.tsx | 673 ++++++++++-------- ts/axo/AxoCheckbox.dom.stories.tsx | 2 +- ts/axo/AxoCheckbox.dom.tsx | 106 ++- ts/axo/AxoContextMenu.dom.tsx | 211 +++--- ts/axo/AxoDialog.dom.stories.tsx | 12 +- ts/axo/AxoDialog.dom.tsx | 419 ++++++++--- ts/axo/AxoDragRegion.dom.tsx | 58 +- ts/axo/AxoDropdownMenu.dom.tsx | 243 ++++--- ts/axo/AxoIconButton.dom.stories.tsx | 6 +- ts/axo/AxoIconButton.dom.tsx | 380 ++++++---- ts/axo/AxoMenuBuilder.dom.tsx | 242 ++++++- ts/axo/AxoProvider.dom.tsx | 18 +- ts/axo/AxoRadioGroup.dom.tsx | 106 ++- ts/axo/AxoScrollArea.dom.tsx | 416 +++++++---- ts/axo/AxoSegmentedControl.dom.stories.tsx | 6 +- ts/axo/AxoSegmentedControl.dom.tsx | 179 +++-- ts/axo/AxoSelect.dom.tsx | 314 +++++--- ts/axo/AxoSwitch.dom.tsx | 42 +- ts/axo/AxoSymbol.dom.tsx | 161 ++++- ts/axo/AxoTextField.dom.stories.tsx | 2 +- ts/axo/AxoTextField.dom.tsx | 177 ++++- ts/axo/AxoTheme.dom.stories.tsx | 2 +- ts/axo/AxoTheme.dom.tsx | 76 +- ts/axo/AxoTooltip.dom.stories.tsx | 2 +- ts/axo/AxoTooltip.dom.tsx | 274 ++++--- ts/axo/_internal/AriaLabellingContext.dom.tsx | 16 + ts/axo/_internal/AxoBaseDialog.dom.tsx | 35 +- ts/axo/_internal/AxoBaseMenu.dom.tsx | 166 ++++- .../_internal/AxoBaseSegmentedControl.dom.tsx | 326 +++++---- ts/axo/_internal/AxoIntl.dom.tsx | 72 ++ ts/axo/_internal/FlexWrapDetector.dom.tsx | 6 + ts/axo/_internal/StrictContext.dom.tsx | 15 + ts/axo/_internal/ariaRoles.dom.tsx | 14 +- ts/axo/_internal/assert.std.tsx | 89 +++ ts/axo/_internal/scrollbars.dom.tsx | 10 + ts/axo/_internal/utf8.std.ts | 29 +- ts/axo/_internal/variants.dom.tsx | 36 + ts/axo/tw.dom.tsx | 13 + ts/components/CallQualitySurveyDialog.dom.tsx | 36 +- ...DeleteAttachmentConfirmationDialog.dom.tsx | 4 +- .../DonationPrivacyInformationModal.dom.tsx | 2 +- .../KeyTransparencyErrorDialog.dom.tsx | 10 +- .../KeyTransparencyOnboardingDialog.dom.tsx | 6 +- ts/components/MediaEditor.dom.tsx | 8 +- ts/components/PlaintextExportWorkflow.dom.tsx | 10 +- ts/components/Preferences.dom.tsx | 5 +- ts/components/PreferencesInternal.dom.tsx | 20 +- ts/components/PreferencesLocalBackups.dom.tsx | 9 +- .../PreferencesNotificationProfiles.dom.tsx | 18 +- ts/components/ProfileEditor.dom.tsx | 4 +- ts/components/SafetyNumberModal.dom.tsx | 2 +- ts/components/SafetyTipsModal.dom.tsx | 4 +- ts/components/UsernameEditor.dom.tsx | 4 +- .../conversation/CollapseSet.dom.tsx | 4 +- .../ProfileNameWarningModal.dom.tsx | 2 +- .../GroupMemberLabelEditor.dom.tsx | 9 +- .../conversation-details/PanelRow.dom.tsx | 5 +- .../media-gallery/ListItem.dom.tsx | 2 +- .../pinned-messages/PinMessageDialog.dom.tsx | 2 +- .../pinned-messages/PinnedMessagesBar.dom.tsx | 2 +- .../leftPane/LeftPaneChatFolders.dom.tsx | 4 +- ts/quill/mentions/blot.dom.tsx | 23 +- ts/shims/renderClearingDataView.preload.tsx | 18 +- ts/state/roots/createApp.preload.tsx | 6 +- ts/state/roots/createGroupV2JoinModal.dom.tsx | 16 +- ts/state/smart/Preferences.preload.tsx | 454 ++++++------ ts/util/longRunningTaskWrapper.dom.tsx | 12 +- ts/util/showConfirmationDialog.dom.tsx | 66 +- ts/windows/AppProvider.dom.tsx | 32 + ts/windows/about/app.dom.tsx | 27 +- ts/windows/calldiagnostic/app.dom.tsx | 14 +- ts/windows/debuglog/app.dom.tsx | 27 +- ts/windows/permissions/app.dom.tsx | 25 +- ts/windows/screenShare/app.dom.tsx | 31 +- 83 files changed, 4576 insertions(+), 2339 deletions(-) create mode 100644 ts/axo/_internal/AxoIntl.dom.tsx create mode 100644 ts/axo/_internal/variants.dom.tsx create mode 100644 ts/windows/AppProvider.dom.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4e3b7c5284..d2a775102c 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,8 +3,6 @@ import '../ts/window.d.ts'; -import { StrictMode } from 'react'; - import '@signalapp/quill-cjs/dist/quill.core.css'; import '../stylesheets/manifest.scss'; import '../stylesheets/tailwind-config.css'; @@ -20,7 +18,7 @@ import { StorybookThemeContext } from './StorybookThemeContext.std.ts'; import { SystemThemeType, ThemeType } from '../ts/types/Util.std.ts'; import { setupI18n } from '../ts/util/setupI18n.dom.tsx'; import { HourCyclePreference } from '../ts/types/I18N.std.ts'; -import { AxoProvider } from '../ts/axo/AxoProvider.dom.tsx'; +import { AppProvider } from '../ts/windows/AppProvider.dom.tsx'; import type { StateType } from '../ts/state/reducer.preload.ts'; import { ScrollerLockContext, @@ -185,14 +183,6 @@ window.Signal = { }, }; -function withStrictMode(Story, context) { - return ( - - - - ); -} - const withGlobalTypesProvider = (Story, context) => { const theme = context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark; @@ -285,19 +275,16 @@ function withFunProvider(Story, context) { ); } -function withAxoProvider(Story, context) { - const globalValue = context.globals.direction ?? 'ltr'; - const dir = globalValue === 'auto' ? 'ltr' : globalValue; +function withAppProvider(Story, context) { return ( - + - + ); } export const decorators = [ - withStrictMode, - withAxoProvider, + withAppProvider, withGlobalTypesProvider, withMockStoreProvider, withScrollLockProvider, diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f0e131e125..5ac4ad3b7e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -11,6 +11,22 @@ } ] }, + "icu:AxoButton.Pending": { + "messageformat": "Pending", + "description": "Axo Design System > Button Component > Pending State > Generic accessibility label" + }, + "icu:AxoDialog.Back": { + "messageformat": "Back", + "description": "Axo Design System > Dialog Component > Back Button > Generic accessibility label" + }, + "icu:AxoDialog.Close": { + "messageformat": "Close", + "description": "Axo Design System > Dialog Component > Close Button > Generic accessibility label" + }, + "icu:AxoTextField.Clear": { + "messageformat": "Clear", + "description": "Axo Design System > Text Field Component > Clear Button > Generic accessibility label" + }, "icu:AddUserToAnotherGroupModal__title": { "messageformat": "Add to a group", "description": "Shown as the title of the dialog that allows you to add a contact to an group" diff --git a/ts/axo/AriaClickable.dom.stories.tsx b/ts/axo/AriaClickable.dom.stories.tsx index 5659930225..8d8e138654 100644 --- a/ts/axo/AriaClickable.dom.stories.tsx +++ b/ts/axo/AriaClickable.dom.stories.tsx @@ -52,10 +52,7 @@ function CardSeeMoreLink(props: { onClick: () => void; children: ReactNode }) { > {props.children} - + ); } diff --git a/ts/axo/AriaClickable.dom.tsx b/ts/axo/AriaClickable.dom.tsx index 99cc9d8e5b..605f5226db 100644 --- a/ts/axo/AriaClickable.dom.tsx +++ b/ts/axo/AriaClickable.dom.tsx @@ -1,14 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { - memo, - useCallback, - useEffect, - useRef, - useState, - forwardRef, -} from 'react'; -import type { ReactNode, MouseEvent, FC, ForwardedRef } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactNode, MouseEvent, FC, Ref } from 'react'; import { useLayoutEffect, mergeRefs } from '@react-aria/utils'; import { computeAccessibleName } from 'dom-accessibility-api'; import { tw } from './tw.dom.tsx'; @@ -19,58 +12,68 @@ import { } from './_internal/StrictContext.dom.tsx'; import { isTestOrMockEnvironment } from '../environment.std.ts'; -const Namespace = 'AriaClickable'; - /** + * Makes an arbitrary region clickable as a single accessible button, while + * allowing nested interactive widgets (buttons, links) to remain independently + * focusable and clickable. + * + * The `HiddenTrigger` is an invisible full-area ` - ); - } else { - result = ( -
- {props.children} -
- ); - } - return result; - }); - - Content.displayName = `${Namespace}.Content`; + const baseContentStyles = tw( + 'relative size-full rounded-full contain-strict' + ); /** - * Component: - * --------------------------- + * The content of the avatar. + * + * Renders as a button when `onClick` is provided, otherwise as an image. + */ + export const Content: FC = memo(props => { + if (props.onClick != null) { + return ( + + {props.children} + + ); + } + + return ( +
+ {props.children} +
+ ); + }); + + Content.displayName = 'AxoAvatar.Content'; + + /** + * + * -------------------------------------------------------------------------- + */ + + /** @internal */ + type ContentButtonProps = Readonly<{ + label: string | null; + onClick: (event: MouseEvent) => void; + children: ReactNode; + }>; + + /** @internal */ + const ContentButton: FC = memo(props => { + const { onClick } = props; + + const handleClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + onClick(event); + }, + [onClick] + ); + + return ( + + ); + }); + + ContentButton.displayName = 'AxoAvatar.ContentButton'; + + /** + * + * -------------------------------------------------------------------------- */ export type IconProps = Readonly<{ + /** + * The icon to display. Sized proportionally to the avatar. + */ symbol: AxoSymbol.IconName; }>; + /** + * Displays a centered icon on a secondary fill background. + */ export const Icon: FC = memo(props => { const size = useStrictContext(SizeContext); return ( @@ -194,22 +296,43 @@ export namespace AxoAvatar { ); }); - Icon.displayName = `${Namespace}.Icon`; + Icon.displayName = 'AxoAvatar.Icon'; /** - * Component: - * ---------------------------- + * + * -------------------------------------------------------------------------- */ export type ImageProps = Readonly<{ + /** + * URL of the avatar image. + */ src: string; + /** + * Intrinsic width of the source image in pixels. + */ srcWidth: number; + /** + * Intrinsic height of the source image in pixels. + */ srcHeight: number; + /** + * When `true`, applies a blur and slight zoom to the image. + */ blur: boolean; + /** + * Icon to show if the image fails to load. + */ fallbackIcon: AxoSymbol.IconName; + /** + * Color theme for the fallback icon background. + */ fallbackColor: AxoTokens.Avatar.ColorName; }>; + /** + * Lazily loads a profile image. Falls back to `Icon` on load error. + */ export const Image: FC = memo(props => { const { src } = props; @@ -263,16 +386,23 @@ export namespace AxoAvatar { ); }); - Image.displayName = `${Namespace}.Image`; + Image.displayName = 'AxoAvatar.Image'; /** - * Component: + * + * -------------------------------------------------------------------------- */ export type PresetProps = Readonly<{ + /** + * One of the pre-designed avatar presets (e.g. `"cat"`, `"abstract_01"`). + */ preset: AxoTokens.Avatar.PresetName; }>; + /** + * Displays one of the built-in preset avatar illustrations. + */ export const Preset: FC = memo(props => { const { preset } = props; @@ -298,25 +428,34 @@ export namespace AxoAvatar { ); }); - Preset.displayName = `${Namespace}.Preset`; + Preset.displayName = 'AxoAvatar.Preset'; /** - * Component: - * ---------------------------------- + * + * -------------------------------------------------------------------------- */ + /** @testexport */ export const MIN_CLICK_TO_VIEW_SIZE = 80; export type ClickToViewProps = Readonly<{ + /** + * Accessible label and visible text for the overlay (e.g. `"View"`). + */ label: string; }>; + /** + * A semi-transparent overlay with a tap icon and label, used on profile + * photos that open a full-screen viewer when clicked. + * Only valid at size ≥ `MIN_CLICK_TO_VIEW_SIZE` (80px). + */ export const ClickToView: FC = memo(props => { const size = useStrictContext(SizeContext); assert( size >= MIN_CLICK_TO_VIEW_SIZE, - `Cannot render ${Namespace}.ClickToView at a size smaller than ${MIN_CLICK_TO_VIEW_SIZE}` + `Cannot render at a size smaller than ${MIN_CLICK_TO_VIEW_SIZE}` ); return ( @@ -335,18 +474,27 @@ export namespace AxoAvatar { ); }); - ClickToView.displayName = `${Namespace}.ClickToView`; + ClickToView.displayName = 'AxoAvatar.ClickToView'; /** - * Component: - * ------------------------------- + * + * -------------------------------------------------------------------------- */ export type InitialsProps = Readonly<{ + /** + * 1–2 character string to display (e.g. `"JK"`). + */ initials: string; + /** + * Color theme for the background and text. + */ color: AxoTokens.Avatar.ColorName; }>; + /** + * Renders initials as an SVG on a colored background. + */ export const Initials: FC = memo(props => { const style = useMemo((): CSSProperties => { const color = AxoTokens.Avatar.getColorValues(props.color); @@ -374,17 +522,24 @@ export namespace AxoAvatar { ); }); - Initials.displayName = `${Namespace}.Initials`; + Initials.displayName = 'AxoAvatar.Initials'; /** - * Component: - * ------------------------------- + * + * -------------------------------------------------------------------------- */ export type GradientProps = Readonly<{ + /** + * A numeric hash (typically derived from a conversation or user ID) that + * deterministically selects one of the available gradient themes. + */ identifierHash: number; }>; + /** + * Fills the avatar with a gradient background chosen by `identifierHash`. + */ export const Gradient: FC = memo(props => { const { identifierHash } = props; const style = useMemo((): CSSProperties => { @@ -399,18 +554,25 @@ export namespace AxoAvatar { ); }); - Gradient.displayName = `${Namespace}.Gradient`; + Gradient.displayName = 'AxoAvatar.Gradient'; /** - * Component: - * ---------------------------- + * + * -------------------------------------------------------------------------- */ + /** + * Paths to an SVG badge image for light and dark color schemes. + */ export type BadgeSvg = Readonly<{ light: string; dark: string; }>; + /** + * Badge SVGs at each of the three rendered sizes (16, 24, and 36px). + * The correct size is chosen automatically based on the avatar size. + */ export type BadgeSvgs = Readonly<{ 16: BadgeSvg; 24: BadgeSvg; @@ -418,12 +580,22 @@ export namespace AxoAvatar { }>; export type BadgeProps = Readonly<{ + /** + * Accessible label for the badge (e.g. `"Signal Planet"`). + */ label: string; + /** + * SVG paths for all badge sizes and color schemes. + */ svgs: BadgeSvgs; - onClick?: MouseEventHandler | null; + /** + * When provided, renders the badge as a clickable ` - ); - } else { - result = ( -
- {children} -
+ ); } - return result; + return ( +
+ {children} +
+ ); }); - Badge.displayName = `${Namespace}.Badge`; + Badge.displayName = 'AxoAvatar.Badge'; + + /** + * + * -------------------------------------------------------------------------- + */ + + /** @internal */ + type BadgeButtonProps = Readonly<{ + label: string; + onClick: (event: MouseEvent) => void; + children: ReactNode; + }>; + + /** @internal */ + const BadgeButton: FC = memo(props => { + const { onClick } = props; + + const handleClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + onClick(event); + }, + [onClick] + ); + + return ( + + ); + }); + + BadgeButton.displayName = 'AxoAvatar.BadgeButton'; } diff --git a/ts/axo/AxoBadge.dom.stories.tsx b/ts/axo/AxoBadge.dom.stories.tsx index 7e5a5392d9..36a2dc53ea 100644 --- a/ts/axo/AxoBadge.dom.stories.tsx +++ b/ts/axo/AxoBadge.dom.stories.tsx @@ -11,7 +11,7 @@ export default { } satisfies Meta; export function All(): JSX.Element { - const values: ReadonlyArray = [ + const values: ReadonlyArray = [ -1, 0, 1, @@ -32,7 +32,7 @@ export function All(): JSX.Element { })} - {ExperimentalAxoBadge._getAllBadgeSizes().map(size => { + {ExperimentalAxoBadge._getAllSizes().map(size => { return ( {size} @@ -44,7 +44,7 @@ export function All(): JSX.Element { value={value} max={99} maxDisplay="99+" - aria-label={null} + label={null} /> ); diff --git a/ts/axo/AxoBadge.dom.tsx b/ts/axo/AxoBadge.dom.tsx index f8c3933100..6ab9671ec2 100644 --- a/ts/axo/AxoBadge.dom.tsx +++ b/ts/axo/AxoBadge.dom.tsx @@ -3,11 +3,9 @@ import type { FC } from 'react'; import { memo, useMemo } from 'react'; import { AxoSymbol } from './AxoSymbol.dom.tsx'; -import type { TailwindStyles } from './tw.dom.tsx'; import { tw } from './tw.dom.tsx'; import { unreachable } from './_internal/assert.std.tsx'; - -const Namespace = 'AxoBadge'; +import { variants } from './_internal/variants.dom.tsx'; /** * @example Anatomy @@ -24,8 +22,21 @@ const Namespace = 'AxoBadge'; * ```` */ export namespace ExperimentalAxoBadge { - export type BadgeSize = 'sm' | 'md' | 'lg'; - export type BadgeValue = number | 'mention' | 'unread'; + /** + * Visual size of the badge. + * - `sm`: 14px height + * - `md`: 16px height + * - `lg`: 18px height + */ + export type Size = 'sm' | 'md' | 'lg'; + + /** + * What the badge represents. + * - `number`: A numeric count, displayed with optional overflow formatting. + * - `'mention'`: Shows an `@`-sign icon. + * - `'unread'`: A dot with no text content. + */ + export type Value = number | 'mention' | 'unread'; const baseStyles = tw( 'flex size-fit items-center justify-center-safe overflow-clip', @@ -35,28 +46,21 @@ export namespace ExperimentalAxoBadge { 'select-none' ); - type BadgeConfig = Readonly<{ - rootStyles: TailwindStyles; - countStyles: TailwindStyles; - }>; + const Sizes = variants('AxoBadge.Size', { + sm: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'), + md: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'), + lg: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'), + }); - const BadgeSizes: Record = { - sm: { - rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'), - countStyles: tw('px-[3px]'), - }, - md: { - rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'), - countStyles: tw('px-[4px]'), - }, - lg: { - rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'), - countStyles: tw('px-[5px]'), - }, - }; + const CountSizes = variants('AxoBadge.Size', { + sm: tw('px-[3px]'), + md: tw('px-[4px]'), + lg: tw('px-[5px]'), + }); - export function _getAllBadgeSizes(): ReadonlyArray { - return Object.keys(BadgeSizes) as Array; + /** @testexport */ + export function _getAllSizes(): ReadonlyArray { + return Sizes.keys(); } let cachedNumberFormat: Intl.NumberFormat; @@ -74,21 +78,43 @@ export namespace ExperimentalAxoBadge { } /** - * Component: - * -------------------------- + * + * -------------------------------------------------------------------------- */ export type RootProps = Readonly<{ - size: BadgeSize; - value: BadgeValue; + /** Visual size of the badge. */ + size: Size; + /** What the badge represents. */ + value: Value; + /** When `value` is a number, values above this are replaced with `maxDisplay`. */ max: number; + /** The string shown when the numeric `value` exceeds `max` (e.g. `"999+"`). */ maxDisplay: string; - 'aria-label': string | null; + /** Accessible label for screen readers. Pass `null` if the badge is purely decorative. */ + label: string | null; }>; + /** + * Renders a colored pill badge. + * + * @example Count with overflow + * ```tsx + * + * ``` + * + * @example Mention + * ```tsx + * + * ``` + * + * @example Unread dot + * ```tsx + * + * ``` + */ export const Root: FC = memo(props => { - const { value, max, maxDisplay } = props; - const config = BadgeSizes[props.size]; + const { size, value, max, maxDisplay } = props; const children = useMemo(() => { if (value === 'unread') { @@ -99,23 +125,20 @@ export namespace ExperimentalAxoBadge { } if (typeof value === 'number') { return ( - + {formatBadgeCount(value, max, maxDisplay)} ); } unreachable(value); - }, [value, max, maxDisplay, config]); + }, [size, value, max, maxDisplay]); return ( - + {children} ); }); - Root.displayName = `${Namespace}.Root`; + Root.displayName = 'AxoBadge.Root'; } diff --git a/ts/axo/AxoButton.dom.stories.tsx b/ts/axo/AxoButton.dom.stories.tsx index 9bcc49b11b..ccf4dc59ce 100644 --- a/ts/axo/AxoButton.dom.stories.tsx +++ b/ts/axo/AxoButton.dom.stories.tsx @@ -4,11 +4,7 @@ import type { ReactNode, JSX } from 'react'; import { useState } from 'react'; import type { Meta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { - _getAllAxoButtonVariants, - _getAllAxoButtonSizes, - AxoButton, -} from './AxoButton.dom.tsx'; +import { AxoButton } from './AxoButton.dom.tsx'; import { tw } from './tw.dom.tsx'; import { AxoSwitch } from './AxoSwitch.dom.tsx'; @@ -17,8 +13,8 @@ export default { } satisfies Meta; export function Basic(): JSX.Element { - const variants = _getAllAxoButtonVariants(); - const sizes = _getAllAxoButtonSizes(); + const variants = AxoButton._getAllVariants(); + const sizes = AxoButton._getAllSizes(); return (
{sizes.map(size => { @@ -93,19 +89,19 @@ export function Basic(): JSX.Element { } export function Spinner(): JSX.Element { - const sizes = _getAllAxoButtonSizes(); - const variants = _getAllAxoButtonVariants(); + const variants = AxoButton._getAllVariants(); + const sizes = AxoButton._getAllSizes(); - const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(true); function handleClick() { - setLoading(true); + setPending(true); } return ( <>
- + Loading
@@ -117,10 +113,8 @@ export function Spinner(): JSX.Element { Save diff --git a/ts/axo/AxoButton.dom.tsx b/ts/axo/AxoButton.dom.tsx index ddafd6c329..085bc6d801 100644 --- a/ts/axo/AxoButton.dom.tsx +++ b/ts/axo/AxoButton.dom.tsx @@ -1,316 +1,429 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { memo, forwardRef } from 'react'; -import type { - ButtonHTMLAttributes, - FC, - ForwardedRef, - ReactNode, - JSX, -} from 'react'; -import type { TailwindStyles } from './tw.dom.tsx'; +import { memo, useCallback } from 'react'; +import type { FC, ReactNode, JSX, MouseEvent, Ref } from 'react'; import { tw } from './tw.dom.tsx'; import { AxoSymbol } from './AxoSymbol.dom.tsx'; -import { assert } from './_internal/assert.std.tsx'; import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx'; import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx'; +import { useAxoIntl } from './_internal/AxoIntl.dom.tsx'; +import { variants } from './_internal/variants.dom.tsx'; -const Namespace = 'AxoButton'; - -type GenericButtonProps = ButtonHTMLAttributes; - -const baseAxoButtonStyles = tw( - 'relative inline-flex max-w-full items-center-safe justify-center-safe rounded-full select-none', - 'outline-none keyboard-mode:focus:outline-focus-ring', - 'forced-colors:border' -); - -const AxoButtonTypes = { - default: tw(baseAxoButtonStyles), - subtle: tw( - baseAxoButtonStyles, - 'bg-fill-secondary', - 'enabled:active:bg-fill-secondary-pressed' - ), - floating: tw( - baseAxoButtonStyles, - 'bg-fill-floating', - 'shadow-elevation-1', - 'enabled:active:bg-fill-floating-pressed' - ), - borderless: tw( - baseAxoButtonStyles, - 'bg-transparent', - 'enabled:hover:bg-fill-secondary', - 'enabled:active:bg-fill-secondary-pressed' - ), -} as const satisfies Record; - -const AxoButtonVariants = { - // default - secondary: tw( - AxoButtonTypes.default, - 'bg-fill-secondary text-label-primary', - 'enabled:active:bg-fill-secondary-pressed', - 'disabled:text-label-disabled' - ), - primary: tw( - AxoButtonTypes.default, - 'bg-color-fill-primary text-label-primary-on-color', - 'enabled:active:bg-color-fill-primary-pressed', - 'disabled:text-label-disabled-on-color' - ), - affirmative: tw( - AxoButtonTypes.default, - 'bg-color-fill-affirmative text-label-primary-on-color', - 'enabled:active:bg-color-fill-affirmative-pressed', - 'disabled:text-label-disabled-on-color' - ), - destructive: tw( - AxoButtonTypes.default, - 'bg-color-fill-destructive text-label-primary-on-color', - 'enabled:active:bg-color-fill-destructive-pressed', - 'disabled:text-label-disabled-on-color' - ), - - // subtle - 'subtle-primary': tw( - AxoButtonTypes.subtle, - 'text-color-label-primary', - 'disabled:text-color-label-primary-disabled' - ), - 'subtle-affirmative': tw( - AxoButtonTypes.subtle, - 'text-color-label-affirmative', - 'disabled:text-color-label-affirmative-disabled' - ), - 'subtle-destructive': tw( - AxoButtonTypes.subtle, - 'text-color-label-destructive', - 'disabled:text-color-label-destructive-disabled' - ), - - // floating - 'floating-secondary': tw( - AxoButtonTypes.floating, - 'text-label-primary', - 'disabled:text-label-disabled' - ), - 'floating-primary': tw( - AxoButtonTypes.floating, - 'text-color-label-primary', - 'disabled:text-color-label-primary-disabled' - ), - 'floating-affirmative': tw( - AxoButtonTypes.floating, - 'text-color-label-affirmative', - 'disabled:text-color-label-affirmative-disabled' - ), - 'floating-destructive': tw( - AxoButtonTypes.floating, - 'text-color-label-destructive', - 'disabled:text-color-label-destructive-disabled' - ), - - // borderless - 'borderless-secondary': tw( - AxoButtonTypes.borderless, - 'text-label-primary', - 'disabled:text-label-disabled' - ), - 'borderless-primary': tw( - AxoButtonTypes.borderless, - 'text-color-label-primary', - 'disabled:text-color-label-primary-disabled' - ), - 'borderless-affirmative': tw( - AxoButtonTypes.borderless, - 'text-color-label-affirmative', - 'disabled:text-color-label-affirmative-disabled' - ), - 'borderless-destructive': tw( - AxoButtonTypes.borderless, - 'text-color-label-destructive', - 'disabled:text-color-label-destructive-disabled' - ), -}; - -const AxoButtonSizes = { - lg: tw('min-w-16 px-4 py-2 type-body-medium font-medium'), - md: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'), - sm: tw('min-w-12 px-2 py-1 type-body-small font-medium'), -} as const satisfies Record; - -type AxoButtonVariant = keyof typeof AxoButtonVariants; -type AxoButtonSize = keyof typeof AxoButtonSizes; - -/** @testexport */ -export function _getAllAxoButtonVariants(): ReadonlyArray { - return Object.keys(AxoButtonVariants) as Array; -} - -/** @testexport */ -export function _getAllAxoButtonSizes(): ReadonlyArray { - return Object.keys(AxoButtonSizes) as Array; -} - -const AxoButtonSpinnerVariants: Record = { - primary: 'axo-button-spinner-on-color', - secondary: 'axo-button-spinner-secondary', - affirmative: 'axo-button-spinner-on-color', - destructive: 'axo-button-spinner-on-color', - 'subtle-primary': 'axo-button-spinner-primary', - 'subtle-affirmative': 'axo-button-spinner-affirmative', - 'subtle-destructive': 'axo-button-spinner-destructive', - 'floating-primary': 'axo-button-spinner-primary', - 'floating-secondary': 'axo-button-spinner-secondary', - 'floating-affirmative': 'axo-button-spinner-affirmative', - 'floating-destructive': 'axo-button-spinner-destructive', - 'borderless-primary': 'axo-button-spinner-primary', - 'borderless-secondary': 'axo-button-spinner-secondary', - 'borderless-affirmative': 'axo-button-spinner-affirmative', - 'borderless-destructive': 'axo-button-spinner-destructive', -}; - -const AxoButtonSpinnerSizes: Record< - AxoButtonSize, - { size: number; strokeWidth: number } -> = { - lg: { size: 20, strokeWidth: 2 }, - md: { size: 20, strokeWidth: 2 }, - sm: { size: 16, strokeWidth: 1.5 }, -}; - -type ExperimentalButtonSpinnerProps = Readonly<{ - buttonVariant: AxoButtonVariant; - buttonSize: AxoButtonSize; - 'aria-label': string; -}>; - -function ExperimentalButtonSpinner( - props: ExperimentalButtonSpinnerProps -): JSX.Element { - const variant = AxoButtonSpinnerVariants[props.buttonVariant]; - const sizeConfig = AxoButtonSpinnerSizes[props.buttonSize]; - return ( - - - - ); -} - +/** + * A text button with optional leading icon and trailing arrow. + * + * @example Anatomy + * ```tsx + * + * ``` + * + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | Button Pattern - ARIA Authoring Practices Guide} + * @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3} + */ export namespace AxoButton { - export type Variant = AxoButtonVariant; - export type Size = AxoButtonSize; + /** + * Visual style of the button. + */ + export type Variant = + | 'secondary' + | 'primary' + | 'affirmative' + | 'destructive' + | 'subtle-primary' + | 'subtle-affirmative' + | 'subtle-destructive' + | 'floating-secondary' + | 'floating-primary' + | 'floating-affirmative' + | 'floating-destructive' + | 'borderless-secondary' + | 'borderless-primary' + | 'borderless-affirmative' + | 'borderless-destructive'; + /** + * Size of the button. + */ + export type Size = 'sm' | 'md' | 'lg'; + + /** + * How the button sizes itself horizontally. + * - `fit`: Shrinks to fit its content (default). + * - `grow`: Expands to fill available space in a flex container. + * - `full`: Always fills the full width of its container. + */ export type Width = 'fit' | 'grow' | 'full'; - // Note: Omitted 'prev' because arrow appears on trailing side, - // back buttons should probably all use AxoIconButton. + /** + * Trailing arrow shown on the button. + * - `next`: Chevron pointing forward, for navigation. + * - `expand`: Chevron pointing down, for revealing content. + * - `collapse`: Chevron pointing up, for hiding content. + * + * Note: Omitted 'prev' because arrow appears on trailing side, + * back buttons should probably all use AxoIconButton. + */ export type Arrow = 'collapse' | 'expand' | 'next'; - const Widths: Record = { + const baseStyles = tw( + 'relative inline-flex max-w-full items-center-safe justify-center-safe rounded-full select-none', + 'outline-none keyboard-mode:focus:outline-focus-ring', + 'forced-colors:border' + ); + + const baseSubtleVariant = tw( + baseStyles, + 'bg-fill-secondary', + 'not-aria-disabled:active:bg-fill-secondary-pressed' + ); + + const baseFloatingVariant = tw( + baseStyles, + 'bg-fill-floating', + 'shadow-elevation-1', + 'not-aria-disabled:active:bg-fill-floating-pressed' + ); + + const baseBorderlessVariant = tw( + baseStyles, + 'bg-transparent', + 'not-aria-disabled:hover:bg-fill-secondary', + 'not-aria-disabled:active:bg-fill-secondary-pressed' + ); + + const VariantStyles = variants('AxoButton.Variant', { + // default + secondary: tw( + baseStyles, + 'bg-fill-secondary text-label-primary', + 'not-aria-disabled:active:bg-fill-secondary-pressed', + 'aria-disabled:text-label-disabled' + ), + primary: tw( + baseStyles, + 'bg-color-fill-primary text-label-primary-on-color', + 'not-aria-disabled:active:bg-color-fill-primary-pressed', + 'aria-disabled:text-label-disabled-on-color' + ), + affirmative: tw( + baseStyles, + 'bg-color-fill-affirmative text-label-primary-on-color', + 'not-aria-disabled:active:bg-color-fill-affirmative-pressed', + 'aria-disabled:text-label-disabled-on-color' + ), + destructive: tw( + baseStyles, + 'bg-color-fill-destructive text-label-primary-on-color', + 'not-aria-disabled:active:bg-color-fill-destructive-pressed', + 'aria-disabled:text-label-disabled-on-color' + ), + + // subtle + 'subtle-primary': tw( + baseSubtleVariant, + 'text-color-label-primary', + 'aria-disabled:text-color-label-primary-disabled' + ), + 'subtle-affirmative': tw( + baseSubtleVariant, + 'text-color-label-affirmative', + 'aria-disabled:text-color-label-affirmative-disabled' + ), + 'subtle-destructive': tw( + baseSubtleVariant, + 'text-color-label-destructive', + 'aria-disabled:text-color-label-destructive-disabled' + ), + + // floating + 'floating-secondary': tw( + baseFloatingVariant, + 'text-label-primary', + 'aria-disabled:text-label-disabled' + ), + 'floating-primary': tw( + baseFloatingVariant, + 'text-color-label-primary', + 'aria-disabled:text-color-label-primary-disabled' + ), + 'floating-affirmative': tw( + baseFloatingVariant, + 'text-color-label-affirmative', + 'aria-disabled:text-color-label-affirmative-disabled' + ), + 'floating-destructive': tw( + baseFloatingVariant, + 'text-color-label-destructive', + 'aria-disabled:text-color-label-destructive-disabled' + ), + + // borderless + 'borderless-secondary': tw( + baseBorderlessVariant, + 'text-label-primary', + 'aria-disabled:text-label-disabled' + ), + 'borderless-primary': tw( + baseBorderlessVariant, + 'text-color-label-primary', + 'aria-disabled:text-color-label-primary-disabled' + ), + 'borderless-affirmative': tw( + baseBorderlessVariant, + 'text-color-label-affirmative', + 'aria-disabled:text-color-label-affirmative-disabled' + ), + 'borderless-destructive': tw( + baseBorderlessVariant, + 'text-color-label-destructive', + 'aria-disabled:text-color-label-destructive-disabled' + ), + }); + + const SizeStyles = variants('AxoButton.Size', { + sm: tw('min-w-12 px-2 py-1 type-body-small font-medium'), + md: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'), + lg: tw('min-w-16 px-4 py-2 type-body-medium font-medium'), + }); + + const WidthStyles = variants('AxoButton.Width', { /* Always try to fit to the content of the button */ fit: tw(''), /* Allow the button to grow within a flex container */ grow: tw('grow'), /* Always try to fill the available space */ full: tw('w-full'), - }; + }); - const Arrows: Record = { + type ArrowSymbol = AxoSymbol.InlineGlyphName; + const Arrows = variants('AxoButton.Arrow', { collapse: 'chevron-up', expand: 'chevron-down', next: 'chevron-[end]', - }; + }); + + /** @testexport */ + export function _getAllVariants(): ReadonlyArray { + return VariantStyles.keys(); + } + + /** @testexport */ + export function _getAllSizes(): ReadonlyArray { + return SizeStyles.keys(); + } + + /** + * + * -------------------------------------------------------------------------- + */ export type RootProps = Readonly<{ - variant: AxoButtonVariant; - size: AxoButtonSize; + /** + * Ref to the underlying ` + ); + }); + + Root.displayName = 'AxoButton.Root'; + + /** + * + * ------------------- + */ + + const SpinnerVariants = variants( + 'AxoButton.Variant', + { + primary: 'axo-button-spinner-on-color', + secondary: 'axo-button-spinner-secondary', + affirmative: 'axo-button-spinner-on-color', + destructive: 'axo-button-spinner-on-color', + 'subtle-primary': 'axo-button-spinner-primary', + 'subtle-affirmative': 'axo-button-spinner-affirmative', + 'subtle-destructive': 'axo-button-spinner-destructive', + 'floating-primary': 'axo-button-spinner-primary', + 'floating-secondary': 'axo-button-spinner-secondary', + 'floating-affirmative': 'axo-button-spinner-affirmative', + 'floating-destructive': 'axo-button-spinner-destructive', + 'borderless-primary': 'axo-button-spinner-primary', + 'borderless-secondary': 'axo-button-spinner-secondary', + 'borderless-affirmative': 'axo-button-spinner-affirmative', + 'borderless-destructive': 'axo-button-spinner-destructive', + } ); - Root.displayName = `${Namespace}.Root`; + type SpinnerSizeConfig = Readonly<{ + size: number; + strokeWidth: number; + }>; + + const SpinnerSizes = variants('AxoButton.Size', { + lg: { size: 20, strokeWidth: 2 }, + md: { size: 20, strokeWidth: 2 }, + sm: { size: 16, strokeWidth: 1.5 }, + }); + + /** @internal */ + type SpinnerProps = Readonly<{ + buttonVariant: Variant; + buttonSize: Size; + }>; + + /** @internal */ + function Spinner(props: SpinnerProps): JSX.Element { + const variant = SpinnerVariants.get(props.buttonVariant); + const sizeConfig = SpinnerSizes.get(props.buttonSize); + return ( + + + + ); + } } diff --git a/ts/axo/AxoCheckbox.dom.stories.tsx b/ts/axo/AxoCheckbox.dom.stories.tsx index 78a5206975..8fb110fbfa 100644 --- a/ts/axo/AxoCheckbox.dom.stories.tsx +++ b/ts/axo/AxoCheckbox.dom.stories.tsx @@ -33,7 +33,7 @@ export function Basic(): JSX.Element { return ( <>

AxoCheckbox

- {AxoCheckbox._getAllCheckboxVariants().map(variant => { + {AxoCheckbox._getAllVariants().map(variant => { return (