Files
Desktop/ts/axo/AxoIconButton.dom.tsx
T

413 lines
12 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, Ref, MouseEvent, FocusEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import { tw } from './tw.dom.tsx';
import { AxoTooltip } from './AxoTooltip.dom.tsx';
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
import { forwardExtraPropsForRadix } from './_internal/props.dom.tsx';
import { AxoBaseSpinner } from './status/_AxoBaseSpinner.dom.tsx';
/**
* A circular icon-only button with an accessible label and built-in tooltip.
*
* @example Anatomy
* ```tsx
* <AxoIconButton.Root />
* ```
*
* @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 AxoIconButton {
/**
* <AxoIconButton.Root>
* --------------------------------------------------------------------------
*/
const baseStyles = tw(
'relative rounded-full leading-none',
'not-forced-colors:outline-none keyboard-mode:focus:axo-focus-ring',
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:aria-disabled:text-[GrayText]',
'forced-colors:aria-pressed:bg-[SelectedItem] forced-colors:aria-pressed:text-[SelectedItemText]'
);
const pressedInvertedStyles = tw(
'aria-pressed:bg-inverted aria-pressed:not-aria-disabled:active:bg-inverted-pressed',
'aria-pressed:not-aria-disabled:data-[axo-dropdownmenu-state=open]:bg-inverted-pressed',
'aria-pressed:text-primary-inverted aria-pressed:aria-disabled:text-disabled-inverted'
);
const pressedPrimaryStyles = tw(
'aria-pressed:bg-accent aria-pressed:not-aria-disabled:active:bg-accent-pressed',
'aria-pressed:not-aria-disabled:data-[axo-dropdownmenu-state=open]:bg-accent-pressed',
'aria-pressed:text-primary-oncolor aria-pressed:aria-disabled:text-disabled-oncolor'
);
const Variants = variants<Variant>('AxoIconButton.Variant', {
'strong-secondary': tw(
'bg-secondary text-primary',
'not-aria-disabled:active:bg-secondary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-secondary-pressed',
'aria-disabled:text-disabled',
pressedInvertedStyles
),
'strong-primary': tw(
'bg-accent text-primary-oncolor',
'not-aria-disabled:active:bg-accent-pressed',
'data-[axo-dropdownmenu-state=open]:bg-accent-pressed',
'aria-disabled:text-disabled-oncolor'
),
'strong-affirmative': tw(
'bg-affirmative text-primary-oncolor',
'not-aria-disabled:active:bg-affirmative-pressed',
'data-[axo-dropdownmenu-state=open]:bg-affirmative-pressed',
'aria-disabled:text-disabled-oncolor'
),
'strong-warning': tw(
'bg-warning-bright text-primary-onbright',
'not-aria-disabled:active:bg-warning-bright-pressed',
'data-[axo-dropdownmenu-state=open]:bg-warning-bright-pressed',
'aria-disabled:text-disabled-onbright'
),
'strong-destructive': tw(
'bg-destructive text-primary-oncolor',
'not-aria-disabled:active:bg-destructive-pressed',
'data-[axo-dropdownmenu-state=open]:bg-destructive-pressed',
'aria-disabled:text-disabled-oncolor'
),
'subtle-secondary': tw(
'bg-primary text-primary',
'not-aria-disabled:active:bg-primary-pressed',
'aria-disabled:text-disabled',
pressedInvertedStyles
),
'subtle-primary': tw(
'bg-accent-tint text-accent',
'not-aria-disabled:active:bg-accent-tint-pressed',
'aria-disabled:text-accent-disabled'
),
'subtle-affirmative': tw(
'bg-affirmative-tint text-affirmative',
'not-aria-disabled:active:bg-affirmative-tint-pressed',
'aria-disabled:text-affirmative-disabled'
),
'subtle-warning': tw(
'bg-warning-tint text-warning',
'not-aria-disabled:active:bg-warning-tint-pressed',
'aria-disabled:text-warning-disabled'
),
'subtle-destructive': tw(
'bg-destructive-tint text-destructive',
'not-aria-disabled:active:bg-destructive-tint-pressed',
'aria-disabled:text-destructive-disabled'
),
'implied-secondary': tw(
'bg-transparent text-primary',
'not-aria-disabled:hover:not-aria-pressed:bg-primary',
'not-aria-disabled:active:bg-primary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-primary-pressed',
'focus:bg-primary',
'aria-disabled:text-disabled',
pressedPrimaryStyles
),
'material-subtle': tw(
'bg-material-secondary text-primary backdrop-blur-thin',
'not-aria-disabled:active:bg-material-tertiary-pressed',
'aria-disabled:text-disabled',
pressedInvertedStyles
),
'material-strong': tw(
'bg-material-primary text-primary backdrop-blur-thin',
'not-aria-disabled:active:bg-material-quaternary-pressed',
'aria-disabled:text-disabled'
),
'elevated-secondary': tw(
'bg-material-tertiary text-primary backdrop-blur-thin',
'not-aria-disabled:active:bg-material-tertiary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-material-tertiary-pressed',
'aria-disabled:text-disabled',
'shadow-elevation-1'
),
});
/** @testexport */
export function _getAllVariants(): ReadonlyArray<Variant> {
return Variants.keys();
}
const Sizes = variants<Size>('AxoIconButton.Size', {
sm: tw('p-[5px]'),
md: tw('p-1.5'),
lg: tw('p-2'),
});
const IconSizes = variants<Size, AxoSymbol.IconSize>('AxoIconButton.Size', {
sm: 18,
md: 20,
lg: 20,
});
/** @testexport */
export function _getAllSizes(): ReadonlyArray<Size> {
return Sizes.keys();
}
/**
* Visual style of the button.
*/
export type Variant =
| 'strong-secondary'
| 'strong-primary'
| 'strong-affirmative'
| 'strong-warning'
| 'strong-destructive'
| 'subtle-secondary'
| 'subtle-primary'
| 'subtle-affirmative'
| 'subtle-warning'
| 'subtle-destructive'
| 'implied-secondary'
| 'material-subtle'
| 'material-strong'
| 'elevated-secondary';
/**
* Size of the button.
*/
export type Size = 'sm' | 'md' | 'lg';
export type RootProps = Readonly<{
/**
* Ref to the underlying `<button>` element.
*/
ref?: Ref<HTMLButtonElement>;
/**
* Accessible label for the button. Should describe the action, not the icon.
* Also used as the default tooltip text.
*/
label: string;
/**
* Tooltip shown on hover.
* - `true` (default): uses `label` as the tooltip text.
* - `false`: no tooltip.
* - `AxoTooltip.RootConfigProps`: custom tooltip configuration.
*/
tooltip?: boolean | AxoTooltip.RootConfigProps;
/**
* Visual style of the button.
*/
variant: Variant;
/**
* Size of the button.
*/
size: Size;
/**
* Icon to display inside the button.
*/
symbol: AxoSymbol.Name;
/**
* Stroke weight override for the icon.
*/
iconWeight?: AxoSymbol.Weight;
/**
* When `true`, shows a spinner and prevents interaction.
*/
pending?: boolean | null;
/**
* When set, the button behaves as a toggle with `aria-pressed` semantics.
*/
pressed?: boolean | null;
/**
* When `true`, prevents interaction.
*/
disabled?: boolean | null;
/**
* Called when the button is clicked. Not called when `pending` or `disabled`.
*/
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
/**
* Called when the mouse enters the button.
*/
onMouseEnter?: (event: MouseEvent<HTMLButtonElement>) => void;
/**
* Called when the mouse focuses the button.
*/
onFocus?: (event: FocusEvent<HTMLButtonElement>) => void;
}>;
/**
* A circular icon-only button.
* Wraps in a tooltip by default using `label`.
*
* @example Close button
* ```tsx
* <AxoIconButton.Root
* label="Close"
* variant="implied-secondary"
* size="md"
* symbol="x"
* onClick={onClose}
* />
* ```
*
* @example Toggle mute button
* ```tsx
* <AxoIconButton.Root
* label={muted ? 'Unmute' : 'Mute'}
* variant="implied-secondary"
* size="md"
* symbol={muted ? 'mic-slash' : 'mic'}
* pressed={muted}
* onClick={toggleMuted}
* />
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const {
ref,
label,
tooltip = true,
variant,
size,
symbol,
iconWeight,
pending,
pressed,
disabled,
onClick,
onMouseEnter,
onFocus,
...rest
} = props;
const intl = useAxoIntl();
const tooltipConfig = useMemo(() => {
if (!tooltip) {
return null;
}
if (typeof tooltip === 'object') {
return tooltip;
}
return { label };
}, [tooltip, label]);
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (pending || disabled) {
event.preventDefault();
return;
}
onClick?.(event);
},
[pending, disabled, onClick]
);
const button = (
<button
ref={ref}
type="button"
aria-label={pending ? intl.get('AxoButton.Pending') : label}
aria-pressed={pressed ?? undefined}
aria-disabled={(pending || disabled) ?? undefined}
onClick={handleClick}
onMouseEnter={onMouseEnter}
onFocus={onFocus}
className={tw(baseStyles, Variants.get(variant), Sizes.get(size))}
{...forwardExtraPropsForRadix(rest)}
>
<span
aria-hidden={pending ?? undefined}
className={tw(
'align-top forced-color-adjust-none',
pending ? 'opacity-0' : null
)}
>
<AxoSymbol.Icon
size={IconSizes.get(size)}
symbol={symbol}
label={null}
weight={iconWeight}
/>
</span>
{pending && <Spinner buttonVariant={variant} buttonSize={size} />}
</button>
);
if (tooltipConfig != null) {
return (
<AxoTooltip.Root
{...tooltipConfig}
tooltipRepeatsTriggerAccessibleName={label === tooltipConfig.label}
>
{button}
</AxoTooltip.Root>
);
}
return button;
});
Root.displayName = 'AxoIconButton.Root';
/**
* <AxoIconButton.Spinner>
* --------------------------------------------------------------------------
*/
const SpinnerVariants = variants<Variant, AxoBaseSpinner.Variant>(
'AxoIconButton.Variant',
{
'strong-primary': 'oncolor',
'strong-secondary': 'default',
'strong-affirmative': 'oncolor',
'strong-warning': 'oncolor',
'strong-destructive': 'oncolor',
'subtle-primary': 'default',
'subtle-secondary': 'default',
'subtle-affirmative': 'default',
'subtle-warning': 'default',
'subtle-destructive': 'default',
'elevated-secondary': 'default',
'material-subtle': 'default',
'material-strong': 'default',
'implied-secondary': 'default',
}
);
const SpinnerSizes = variants<Size, number>('AxoIconButton.Size', {
lg: 18,
md: 18,
sm: 16,
});
/** @internal */
type SpinnerProps = Readonly<{
buttonSize: Size;
buttonVariant: Variant;
}>;
/** @internal */
const Spinner: FC<SpinnerProps> = memo(props => {
const size = SpinnerSizes.get(props.buttonSize);
const variant = SpinnerVariants.get(props.buttonVariant);
return (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<AxoBaseSpinner.Root
size={size}
weight="regular"
variant={variant}
value="indeterminate"
track={false}
/>
</span>
);
});
Spinner.displayName = 'AxoIconButton.Spinner';
}