diff --git a/ts/axo/AxoButton.dom.tsx b/ts/axo/AxoButton.dom.tsx
index 76e372fed8..9bf0e78b86 100644
--- a/ts/axo/AxoButton.dom.tsx
+++ b/ts/axo/AxoButton.dom.tsx
@@ -11,6 +11,8 @@ import { SpinnerV2 } from '../components/SpinnerV2.dom.js';
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-0 outline-border-focused focused:outline-[2.5px]',
@@ -133,11 +135,6 @@ const AxoButtonSizes = {
sm: tw('min-w-12 px-2 py-1 type-body-small font-medium'),
} as const satisfies Record;
-type BaseButtonAttrs = Omit<
- ButtonHTMLAttributes,
- 'className' | 'style' | 'children'
->;
-
type AxoButtonVariant = keyof typeof AxoButtonVariants;
type AxoButtonSize = keyof typeof AxoButtonSizes;
@@ -215,16 +212,19 @@ export namespace AxoButton {
full: tw('w-full'),
};
- export type RootProps = BaseButtonAttrs &
- Readonly<{
- variant: AxoButtonVariant;
- size: AxoButtonSize;
- width?: Width;
- symbol?: AxoSymbol.InlineGlyphName;
- arrow?: boolean;
- experimentalSpinner?: { 'aria-label': string } | null;
- children: ReactNode;
- }>;
+ export type RootProps = Readonly<{
+ variant: AxoButtonVariant;
+ size: AxoButtonSize;
+ width?: Width;
+ symbol?: AxoSymbol.InlineGlyphName;
+ arrow?: boolean;
+ experimentalSpinner?: { 'aria-label': string } | null;
+ disabled?: GenericButtonProps['disabled'];
+ onClick?: GenericButtonProps['onClick'];
+ children: ReactNode;
+ // Note: Technically we forward all props for Radix, but we restrict the
+ // props that the type accepts
+ }>;
export const Root: FC = memo(
forwardRef((props, ref: ForwardedRef) => {
@@ -251,9 +251,9 @@ export namespace AxoButton {
return (
@@ -331,7 +358,10 @@ export function ExampleLanguageDialog(): JSX.Element {
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder="Search languages"
- className={tw('w-full rounded-lg bg-fill-secondary px-3 py-[5px]')}
+ className={tw(
+ 'w-full rounded-lg bg-fill-secondary px-3 py-[5px]',
+ 'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]'
+ )}
/>
diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx
index 8fba78a3c7..c9b0b01f0b 100644
--- a/ts/axo/AxoDialog.dom.tsx
+++ b/ts/axo/AxoDialog.dom.tsx
@@ -16,6 +16,7 @@ import { tw } from './tw.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import { getScrollbarGutters } from './_internal/scrollbars.dom.js';
import { AxoButton } from './AxoButton.dom.js';
+import { AxoIconButton } from './AxoIconButton.dom.js';
const Namespace = 'AxoDialog';
@@ -237,9 +238,11 @@ export namespace AxoDialog {
export const Back: FC = memo(props => {
return (
-
);
@@ -260,7 +263,12 @@ export namespace AxoDialog {
return (
);
@@ -462,4 +470,31 @@ export namespace AxoDialog {
});
Action.displayName = `${Namespace}.Action`;
+
+ /**
+ * Component:
+ * ------------------------------
+ */
+
+ export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
+
+ export type IconActionProps = Readonly<{
+ 'aria-label': string;
+ variant: ActionVariant;
+ symbol: AxoSymbol.IconName;
+ onClick: () => void;
+ }>;
+
+ export const IconAction: FC = memo(props => {
+ return (
+
+ );
+ });
+
+ IconAction.displayName = `${Namespace}.IconAction`;
}
diff --git a/ts/axo/AxoIconButton.dom.stories.tsx b/ts/axo/AxoIconButton.dom.stories.tsx
new file mode 100644
index 0000000000..901abb8bd9
--- /dev/null
+++ b/ts/axo/AxoIconButton.dom.stories.tsx
@@ -0,0 +1,224 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import React, { Fragment } from 'react';
+import type { Meta } from '@storybook/react';
+import { AxoIconButton } from './AxoIconButton.dom.js';
+import type { TailwindStyles } from './tw.dom.js';
+import { tw } from './tw.dom.js';
+
+export default {
+ title: 'Axo/AxoIconButton',
+} satisfies Meta;
+
+export function Basic(): JSX.Element {
+ return (
+
+ );
+}
+
+const Backgrounds: Record = {
+ 'background-primary': tw('bg-background-primary'),
+ 'background-secondary': tw('bg-background-secondary'),
+ 'background-overlay': tw('bg-background-overlay'),
+ 'elevated-background-primary': tw('bg-elevated-background-primary'),
+ 'elevated-background-secondary': tw('bg-elevated-background-secondary'),
+ 'elevated-background-tertiary': tw('bg-elevated-background-tertiary'),
+ 'elevated-background-quaternary': tw('bg-elevated-background-quaternary'),
+};
+
+const Themes: Record = {
+ light: tw('scheme-only-light'),
+ dark: tw('scheme-only-dark'),
+};
+
+function getRows() {
+ return Object.keys(Themes).flatMap(theme => {
+ return Object.keys(Backgrounds).map(background => {
+ return { theme, background };
+ });
+ });
+}
+
+export function Variants(): JSX.Element {
+ const variants = AxoIconButton._getAllVariants();
+ return (
+
+ {getRows().map((row, rowIndex) => {
+ return (
+
+
+
+ {row.background} ({row.theme})
+
+
+ {variants.map((variant, variantIndex) => {
+ return (
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
+
+export function Sizes(): JSX.Element {
+ return (
+
+ {AxoIconButton._getAllSizes().map((size, sizeIndex) => {
+ return (
+
+
+ {size}
+
+ {AxoIconButton._getAllVariants().map((variant, variantIndex) => {
+ return (
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
+
+const AllStates: Record> = {
+ 'disabled=true': { disabled: true },
+ 'aria-pressed=false': { 'aria-pressed': false },
+ 'aria-pressed=true': { 'aria-pressed': true },
+};
+
+export function States(): JSX.Element {
+ return (
+
+ {Object.keys(AllStates).map((state, stateIndex) => {
+ return (
+
+
+ {state}
+
+ {AxoIconButton._getAllVariants().map((variant, variantIndex) => {
+ return (
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
+
+export function Spinners(): JSX.Element {
+ return (
+
+ {AxoIconButton._getAllSizes().map((size, sizeIndex) => {
+ return (
+
+
+ {size}
+
+ {AxoIconButton._getAllVariants().map((variant, variantIndex) => {
+ return (
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+}
diff --git a/ts/axo/AxoIconButton.dom.tsx b/ts/axo/AxoIconButton.dom.tsx
new file mode 100644
index 0000000000..a94ada9503
--- /dev/null
+++ b/ts/axo/AxoIconButton.dom.tsx
@@ -0,0 +1,196 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { ButtonHTMLAttributes, FC, ForwardedRef } from 'react';
+import React, { forwardRef, memo } 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';
+
+const Namespace = 'AxoIconButton';
+
+type GenericButtonProps = ButtonHTMLAttributes;
+
+export namespace AxoIconButton {
+ const baseStyles = tw(
+ 'relative rounded-full align-top 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]',
+ 'forced-colors:aria-pressed:bg-[SelectedItem] forced-colors:aria-pressed:text-[SelectedItemText]'
+ );
+
+ const pressedStyles = {
+ fillInverted: tw(
+ 'aria-pressed:bg-fill-inverted aria-pressed:pressed:bg-fill-inverted-pressed',
+ 'aria-pressed:text-label-primary-inverted aria-pressed:disabled:text-label-disabled-inverted'
+ ),
+ colorFillPrimary: tw(
+ 'aria-pressed:bg-color-fill-primary aria-pressed:pressed:bg-color-fill-primary-pressed',
+ 'aria-pressed:text-label-primary-on-color aria-pressed:disabled:text-label-disabled-on-color'
+ ),
+ };
+
+ const Variants: Record = {
+ secondary: tw(
+ 'bg-fill-secondary pressed:bg-fill-secondary-pressed',
+ 'text-label-primary disabled:text-label-disabled',
+ pressedStyles.fillInverted
+ ),
+ primary: tw(
+ 'bg-color-fill-primary pressed:bg-color-fill-primary-pressed',
+ 'text-label-primary-on-color disabled:text-label-disabled-on-color',
+ pressedStyles.fillInverted
+ ),
+ affirmative: tw(
+ 'bg-color-fill-affirmative pressed:bg-color-fill-affirmative-pressed',
+ 'text-label-primary-on-color disabled:text-label-disabled-on-color',
+ pressedStyles.fillInverted
+ ),
+ destructive: tw(
+ 'bg-color-fill-destructive pressed:bg-color-fill-destructive-pressed',
+ 'text-label-primary-on-color disabled:text-label-disabled-on-color',
+ pressedStyles.fillInverted
+ ),
+ 'borderless-secondary': tw(
+ 'hovered:bg-fill-secondary pressed:bg-fill-secondary-pressed',
+ 'text-label-primary disabled:text-label-disabled',
+ pressedStyles.colorFillPrimary
+ ),
+ 'floating-secondary': tw(
+ 'bg-fill-floating pressed:bg-fill-floating-pressed',
+ 'text-label-primary disabled:text-label-disabled',
+ 'shadow-elevation-1',
+ pressedStyles.fillInverted
+ ),
+ };
+
+ export function _getAllVariants(): ReadonlyArray {
+ return Object.keys(Variants) as ReadonlyArray;
+ }
+
+ type SizeConfig = Readonly<{
+ buttonStyles: TailwindStyles;
+ iconSize: AxoSymbol.IconSize;
+ }>;
+
+ const Sizes: Record = {
+ sm: { buttonStyles: tw('p-[5px]'), iconSize: 18 },
+ md: { buttonStyles: tw('p-1.5'), iconSize: 20 },
+ lg: { buttonStyles: tw('p-2'), iconSize: 20 },
+ };
+
+ export function _getAllSizes(): ReadonlyArray {
+ return Object.keys(Sizes) as ReadonlyArray;
+ }
+
+ export type Variant =
+ | 'secondary'
+ | 'primary'
+ | 'affirmative'
+ | 'destructive'
+ | 'borderless-secondary'
+ | 'floating-secondary';
+
+ export type Size = 'sm' | 'md' | 'lg';
+
+ export type RootProps = Readonly<{
+ // required: Should describe the purpose of the button, not the icon.
+ 'aria-label': string;
+
+ variant: Variant;
+ size: Size;
+ symbol: AxoSymbol.IconName;
+
+ experimentalSpinner?: { 'aria-label': string } | null;
+
+ disabled?: GenericButtonProps['disabled'];
+ onClick?: GenericButtonProps['onClick'];
+ 'aria-pressed'?: GenericButtonProps['aria-pressed'];
+ // Note: Technically we forward all props for Radix, but we restrict the
+ // props that the type accepts
+ }>;
+
+ export const Root: FC = memo(
+ forwardRef((props, ref: ForwardedRef) => {
+ const { variant, size, symbol, experimentalSpinner, ...rest } = props;
+
+ return (
+
+ );
+ })
+ );
+
+ Root.displayName = `${Namespace}.Root`;
+
+ const SpinnerVariants: Record = {
+ primary: 'axo-button-spinner-on-color',
+ secondary: 'axo-button-spinner-secondary',
+ affirmative: 'axo-button-spinner-on-color',
+ destructive: 'axo-button-spinner-on-color',
+ 'floating-secondary': 'axo-button-spinner-secondary',
+ 'borderless-secondary': 'axo-button-spinner-secondary',
+ };
+
+ type SpinnerSizeConfig = { size: number; strokeWidth: number };
+
+ const SpinnerSizes: Record = {
+ lg: { size: 20, strokeWidth: 2 },
+ md: { size: 20, strokeWidth: 2 },
+ sm: { size: 16, strokeWidth: 1.5 },
+ };
+
+ type SpinnerProps = Readonly<{
+ buttonVariant: Variant;
+ buttonSize: Size;
+ 'aria-label': string;
+ }>;
+
+ // eslint-disable-next-line no-inner-declarations
+ function Spinner(props: SpinnerProps): JSX.Element {
+ const variant = SpinnerVariants[props.buttonVariant];
+ const sizeConfig = SpinnerSizes[props.buttonSize];
+ return (
+
+
+
+ );
+ }
+}
diff --git a/ts/axo/AxoScrollArea.dom.tsx b/ts/axo/AxoScrollArea.dom.tsx
index c8d9a4c934..54a60095bb 100644
--- a/ts/axo/AxoScrollArea.dom.tsx
+++ b/ts/axo/AxoScrollArea.dom.tsx
@@ -141,8 +141,7 @@ export namespace AxoScrollArea {
'rounded-[2px] outline-border-focused',
// Move the outline from the viewport to the parent
// so it doesn't get cut off by
- '[:where(.keyboard-mode)_&:has([data-axo-scroll-area-viewport]:focus)]:outline-[2.5px]',
- 'forced-colors:border forced-colors:border-[ButtonBorder]'
+ '[:where(.keyboard-mode)_&:has([data-axo-scroll-area-viewport]:focus)]:outline-[2.5px]'
)}
style={style}
>
@@ -310,15 +309,16 @@ export namespace AxoScrollArea {
'absolute z-10',
'opacity-0',
'from-shadow-outline to-transparent dark:from-shadow-elevation-1',
- 'animate-duration-1 [animation-name:axo-scroll-area-hint-reveal]'
+ 'animate-duration-1 [animation-name:axo-scroll-area-hint-reveal]',
+ 'forced-colors:bg-[ButtonBorder]'
);
// Need `animation-fill-mode` so we can customize the `animation-range`
- const edgeStartStyles = tw('animate-forwards');
- const edgeEndStyles = tw('animate-backwards animate-reverse');
+ const edgeStartStyles = tw('animate-both');
+ const edgeEndStyles = tw('animate-both animate-reverse');
- const edgeYStyles = tw('inset-x-0 h-0.5');
- const edgeXStyles = tw('inset-y-0 w-0.5');
+ const edgeYStyles = tw('inset-x-0 h-0.5 forced-colors:h-px');
+ const edgeXStyles = tw('inset-y-0 w-0.5 forced-colors:w-px');
const HintEdges: Record = {
top: tw(
diff --git a/ts/axo/_internal/AxoBaseDialog.dom.tsx b/ts/axo/_internal/AxoBaseDialog.dom.tsx
index 8ae7c728e4..773f764f13 100644
--- a/ts/axo/_internal/AxoBaseDialog.dom.tsx
+++ b/ts/axo/_internal/AxoBaseDialog.dom.tsx
@@ -36,7 +36,8 @@ export namespace AxoBaseDialog {
// Allow the entire overlay to be scrolled in case the window is extremely small
'overflow-auto scrollbar-width-none',
'data-[state=closed]:animate-exit data-[state=open]:animate-enter',
- 'animate-opacity-0'
+ 'animate-opacity-0',
+ 'forced-colors:bg-[Canvas]'
);
/**
@@ -48,9 +49,10 @@ export namespace AxoBaseDialog {
'relative',
'max-h-full min-h-fit max-w-full min-w-fit',
'rounded-3xl bg-elevated-background-primary shadow-elevation-3 select-none',
- 'outline-0 outline-border-focused focused:outline-[2.5px]',
+ 'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
'data-[state=closed]:animate-exit data-[state=open]:animate-enter',
- 'animate-scale-98 animate-translate-y-1'
+ 'animate-scale-98 animate-translate-y-1',
+ 'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]'
);
export type ContentEscape = 'cancel-is-noop' | 'cancel-is-destructive';
diff --git a/ts/components/PreferencesNotificationProfiles.dom.tsx b/ts/components/PreferencesNotificationProfiles.dom.tsx
index 711e27b684..87499f0d6e 100644
--- a/ts/components/PreferencesNotificationProfiles.dom.tsx
+++ b/ts/components/PreferencesNotificationProfiles.dom.tsx
@@ -837,8 +837,6 @@ function NotificationProfilesNamePage({
@@ -907,16 +905,9 @@ function NotificationProfilesAllowedPage({
/>
-
+
+ {i18n('icu:next2')}
+
>
);
@@ -1076,12 +1067,7 @@ function NotificationProfilesSchedulePage({
-
+
{isEditing ? i18n('icu:done') : i18n('icu:next2')}
@@ -1111,12 +1097,7 @@ function NotificationProfilesDonePage({
{i18n('icu:NotificationProfiles--done-description')}
-
+
{i18n('icu:done')}
@@ -1406,12 +1387,7 @@ function NotificationProfilesEditPage({
-
+
{i18n('icu:done')}