mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-28 16:09:49 +01:00
413 lines
12 KiB
TypeScript
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';
|
|
}
|