Axo improvements and documentation

This commit is contained in:
Jamie
2026-05-06 10:37:35 -07:00
committed by GitHub
parent 2ed50fa045
commit d7ee96cc04
83 changed files with 4576 additions and 2339 deletions
+5 -18
View File
@@ -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 (
<StrictMode>
<Story {...context} />
</StrictMode>
);
}
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 (
<AxoProvider dir={dir}>
<AppProvider>
<Story {...context} />
</AxoProvider>
</AppProvider>
);
}
export const decorators = [
withStrictMode,
withAxoProvider,
withAppProvider,
withGlobalTypesProvider,
withMockStoreProvider,
withScrollLockProvider,
+16
View File
@@ -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"
+1 -4
View File
@@ -52,10 +52,7 @@ function CardSeeMoreLink(props: { onClick: () => void; children: ReactNode }) {
>
{props.children}
</span>
<AriaClickable.HiddenTrigger
aria-labelledby={id}
onClick={props.onClick}
/>
<AriaClickable.HiddenTrigger labelledby={id} onClick={props.onClick} />
</>
);
}
+133 -119
View File
@@ -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 `<button>` that sits below
* any `SubWidget` children. It propagates hover/press/focus state as
* `data-hovered`, `data-pressed`, and `data-focused` attributes on `Root`
* so the containing element can style itself accordingly.
*
* @example Anatomy
* ```tsx
* export default () => (
* <AriaClickable.Root>
* <h3>Card Title</h3>
* <p>
* Lorem ipsum dolor sit amet consectetur adipisicing elit...
* <span id="see-more-1">See more</span>
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
* </p>
* <AriaClickable.SubWidget>
* <AxoButton.Root>Delete</AxoButton.Root>
* </AriaClickable.SubWidget>
* <AriaClickable.SubWidget>
* <AxoLink>Edit</AxoLink>
* </AriaClickable.SubWidget>
* </AriaClickable.Root>
* );
* <AriaClickable.Root>
* <h3>Card Title</h3>
* <p>
* Lorem ipsum dolor sit amet consectetur adipisicing elit...
* <span id="see-more-1">See more</span>
* <AriaClickable.HiddenTrigger labelledby="see-more-1" onClick={onOpen} />
* </p>
* <AriaClickable.SubWidget>
* <AxoButton.Root variant="subtle-destructive" size="sm" onClick={onDelete}>Delete</AxoButton.Root>
* </AriaClickable.SubWidget>
* </AriaClickable.Root>
* ```
*/
export namespace AriaClickable {
/** @internal */
type TriggerState = Readonly<{
hovered: boolean;
pressed: boolean;
focused: boolean;
}>;
/** @internal */
const INITIAL_TRIGGER_STATE: TriggerState = {
hovered: false,
pressed: false,
focused: false,
};
/** @internal */
type TriggerStateUpdate = (state: TriggerState) => void;
const TriggerStateUpdateContext = createStrictContext<TriggerStateUpdate>(
`${Namespace}.Root`
);
/** @internal */
const TriggerStateUpdateContext =
createStrictContext<TriggerStateUpdate>('AriaClickable.Root');
/**
* Component: <AriaClickable.Root>
* -------------------------------
* <AriaClickable.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/** Additional CSS classes applied to the container `<div>`. */
className?: string;
children: ReactNode;
}>;
/**
* A `position: relative` container that exposes `data-hovered`, `data-pressed`,
* and `data-focused` attributes driven by the `HiddenTrigger` state.
*/
export const Root: FC<RootProps> = memo(props => {
const [hovered, setHovered] = useState(INITIAL_TRIGGER_STATE.hovered);
const [pressed, setPressed] = useState(INITIAL_TRIGGER_STATE.pressed);
@@ -98,11 +101,11 @@ export namespace AriaClickable {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AriaClickable.Root';
/**
* Component: <AriaClickable.SubAction>
* ------------------------------------
* <AriaClickable.SubWidget>
* --------------------------------------------------------------------------
*/
export type SubWidgetProps = Readonly<{
@@ -144,9 +147,15 @@ export namespace AriaClickable {
);
});
SubWidget.displayName = `${Namespace}.SubWidget`;
SubWidget.displayName = 'AriaClickable.SubWidget';
/**
* <AriaClickable.DeadArea>
* --------------------------------------------------------------------------
*/
export type DeadAreaProps = Readonly<{
/** Additional CSS classes for sizing/positioning the dead area. */
className?: string;
children: ReactNode;
}>;
@@ -167,18 +176,19 @@ export namespace AriaClickable {
);
});
DeadArea.displayName = `${Namespace}.DeadArea`;
DeadArea.displayName = 'AriaClickable.DeadArea';
/**
* Component: <AriaClickable.HiddenTrigger>
* ------------------------------------
* <AriaClickable.HiddenTrigger>
* --------------------------------------------------------------------------
*/
export type HiddenTriggerProps = Readonly<{
/**
* Describe the action to be taken `onClick`
*/
'aria-label'?: string;
/** Ref to the underlying `<button>` element. */
ref?: Ref<HTMLButtonElement>;
/** Describe the action to be taken `onClick` */
label?: string;
/**
* This should reference the ID of an element that describes the action that
* will be taken `onClick`, not the entire clickable root.
@@ -186,16 +196,14 @@ export namespace AriaClickable {
* @example
* ```tsx
* <span id="see-more-1">See more</span>
* <HiddenTrigger aria-labelledby="see-more-1"/>
* <HiddenTrigger labelledby="see-more-1"/>
* ```
*/
'aria-labelledby'?: string;
onClick: (event: MouseEvent) => void;
// Note: Technically we forward all props for Radix, but we restrict the
// props that the type accepts
}>;
labelledby?: string;
const hiddenTriggerDisplayName = `${Namespace}.HiddenTrigger`;
/** Called when the button is clicked. */
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}>;
/**
* Provides an invisible button that fills the entire area of
@@ -206,82 +214,88 @@ export namespace AriaClickable {
* - This should be inserted in the expected focus order, which is likely
* before any <AriaClickable.SubWidget>.
*/
export const HiddenTrigger = memo(
forwardRef(
(props: HiddenTriggerProps, ref: ForwardedRef<HTMLButtonElement>) => {
const innerRef = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useStrictContext(
TriggerStateUpdateContext
);
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
const { onClick } = props;
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => {
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
}, [onTriggerStateUpdate]);
const innerRef = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext);
useLayoutEffect(() => {
const button = assert(innerRef.current, 'Missing ref');
let timer: ReturnType<typeof setTimeout>;
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => {
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
}, [onTriggerStateUpdate]);
function update() {
onTriggerStateUpdateRef.current({
hovered: button.matches(':hover:not(:disabled)'),
pressed: button.matches(':active:not(:disabled)'),
focused: button.matches('.keyboard-mode :focus'),
});
}
useLayoutEffect(() => {
const button = assert(innerRef.current, 'Missing ref');
let timer: ReturnType<typeof setTimeout>;
function delayedUpdate() {
clearTimeout(timer);
timer = setTimeout(update, 1);
}
update();
button.addEventListener('pointerenter', update);
button.addEventListener('pointerleave', update);
button.addEventListener('pointerdown', update);
button.addEventListener('pointerup', update);
button.addEventListener('focus', update);
button.addEventListener('blur', update);
// need delay
button.addEventListener('keydown', delayedUpdate);
button.addEventListener('keyup', delayedUpdate);
return () => {
clearTimeout(timer);
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
button.removeEventListener('pointerenter', update);
button.removeEventListener('pointerleave', update);
button.removeEventListener('pointerdown', update);
button.removeEventListener('pointerup', update);
button.removeEventListener('focus', update);
button.removeEventListener('blur', update);
// need delay
button.removeEventListener('keydown', delayedUpdate);
button.removeEventListener('keyup', delayedUpdate);
};
}, []);
useEffect(() => {
if (isTestOrMockEnvironment()) {
assert(
computeAccessibleName(assert(innerRef.current)) !== '',
`${hiddenTriggerDisplayName} child must have an accessible name`
);
}
function update() {
onTriggerStateUpdateRef.current({
hovered: button.matches(':hover:not(:disabled)'),
pressed: button.matches(':active:not(:disabled)'),
focused: button.matches('.keyboard-mode :focus'),
});
}
return (
<button
ref={mergeRefs(ref, innerRef)}
{...props}
type="button"
className={tw('absolute inset-0 z-10 outline-none')}
/>
function delayedUpdate() {
clearTimeout(timer);
timer = setTimeout(update, 1);
}
update();
button.addEventListener('pointerenter', update);
button.addEventListener('pointerleave', update);
button.addEventListener('pointerdown', update);
button.addEventListener('pointerup', update);
button.addEventListener('focus', update);
button.addEventListener('blur', update);
// need delay
button.addEventListener('keydown', delayedUpdate);
button.addEventListener('keyup', delayedUpdate);
return () => {
clearTimeout(timer);
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
button.removeEventListener('pointerenter', update);
button.removeEventListener('pointerleave', update);
button.removeEventListener('pointerdown', update);
button.removeEventListener('pointerup', update);
button.removeEventListener('focus', update);
button.removeEventListener('blur', update);
// need delay
button.removeEventListener('keydown', delayedUpdate);
button.removeEventListener('keyup', delayedUpdate);
};
}, []);
useEffect(() => {
if (isTestOrMockEnvironment()) {
assert(
computeAccessibleName(assert(innerRef.current)) !== '',
'AriaClickable.HiddenTrigger child must have an accessible name'
);
}
)
);
});
HiddenTrigger.displayName = hiddenTriggerDisplayName;
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClick(event);
},
[onClick]
);
return (
<button
ref={mergeRefs(props.ref, innerRef)}
type="button"
aria-label={props.label}
aria-labelledby={props.labelledby}
onClick={handleClick}
className={tw('absolute inset-0 z-10 outline-none')}
/>
);
});
HiddenTrigger.displayName = 'AriaClickable.HiddenTrigger';
}
+143 -44
View File
@@ -12,21 +12,17 @@ import type { AxoSymbol } from './AxoSymbol.dom.tsx';
import { FlexWrapDetector } from './_internal/FlexWrapDetector.dom.tsx';
import { AxoTheme } from './AxoTheme.dom.tsx';
const Namespace = 'AxoAlertDialog';
const { useContentEscapeBehavior } = AxoBaseDialog;
/**
* Displays a menu located at the pointer, triggered by a right click or a long press.
*
* Note: For menus that are triggered by a normal button press, you should use
* `AxoDropdownMenu`.
* A modal dialog that interrupts the user with important content and expects a
* response. Unlike `AxoDialog`, it has no header or close button — the user
* must explicitly choose an action.
*
* @example Anatomy
* ```tsx
* <AxoAlertDialog.Root>
* <AxoAlertDialog.Trigger>
* </AxoAlertDialog.Trigger>
* <AxoAlertDialog.Trigger />
* <AxoAlertDialog.Content>
* <AxoAlertDialog.Body>
* <AxoAlertDialog.Title />
@@ -39,15 +35,42 @@ const { useContentEscapeBehavior } = AxoBaseDialog;
* </AxoAlertDialog.Content>
* </AxoAlertDialog.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/alert-dialig | Alert Dialog - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/ | Alert and Message Dialogs Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#alertdialog | `alertdialog` role - WAI-ARIA 1.3}
*/
export namespace AxoAlertDialog {
/**
* Component: <AxoAlertDialog.Root>
* --------------------------------
* <AxoAlertDialog.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = AxoBaseDialog.RootProps;
/**
* Contains all the parts of an alert dialog.
*
* @example Discard confirmation
* ```tsx
* <AxoAlertDialog.Root open={open} onOpenChange={onOpenChange}>
* <AxoAlertDialog.Content escape="cancel-is-noop">
* <AxoAlertDialog.Body>
* <AxoAlertDialog.Title>Discard draft?</AxoAlertDialog.Title>
* <AxoAlertDialog.Description>
* Your message will be deleted and cannot be recovered.
* </AxoAlertDialog.Description>
* </AxoAlertDialog.Body>
* <AxoAlertDialog.Footer>
* <AxoAlertDialog.Cancel>Cancel</AxoAlertDialog.Cancel>
* <AxoAlertDialog.Action variant="destructive" onClick={onDiscard}>
* Discard
* </AxoAlertDialog.Action>
* </AxoAlertDialog.Footer>
* </AxoAlertDialog.Content>
* </AxoAlertDialog.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<AlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
@@ -56,32 +79,50 @@ export namespace AxoAlertDialog {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoAlertDialog.Root';
/**
* Component: <AxoAlertDialog.Trigger>
* --------------------------------
* <AxoAlertDialog.Trigger>
* --------------------------------------------------------------------------
*/
export type TriggerProps = AxoBaseDialog.TriggerProps;
/**
* A button that opens the dialog.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
return <AlertDialog.Trigger asChild>{props.children}</AlertDialog.Trigger>;
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = 'AxoAlertDialog.Trigger';
/**
* Component: <AxoAlertDialog.Content>
* --------------------------------
* <AxoAlertDialog.Content>
* --------------------------------------------------------------------------
*/
/**
* How dangerous the cancel action is considered.
* - `cancel-is-noop`: Canceling is safe — pressing Escape or clicking outside closes the dialog.
* - `cancel-is-destructive`: Canceling would lose user state — pressing Escape or clicking outside is disabled.
*/
export type ContentEscape = AxoBaseDialog.ContentEscape;
export type ContentProps = Readonly<{
/**
* What happens when the user presses `Escape` or clicks outside.
*/
escape: ContentEscape;
/**
* Should be `Body` and `Footer` elements.
*/
children: ReactNode;
}>;
/**
* Contains content to be rendered when the dialog is open.
*/
export const Content: FC<ContentProps> = memo(props => {
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
return (
@@ -104,17 +145,24 @@ export namespace AxoAlertDialog {
);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoAlertDialog.Content';
/**
* Component: <AxoAlertDialog.Body>
* ---------------------------------
* <AxoAlertDialog.Body>
* --------------------------------------------------------------------------
*/
export type BodyProps = Readonly<{
/**
* The scrollable body content.
*/
children: ReactNode;
}>;
/**
* Scrollable content area before `Footer`.
* Automatically shows scroll hint and a thin scrollbar.
*/
export const Body: FC<BodyProps> = memo(props => {
return (
<AxoScrollArea.Root maxHeight={440} scrollbarWidth="none">
@@ -130,17 +178,23 @@ export namespace AxoAlertDialog {
);
});
Body.displayName = `${Namespace}.Body`;
Body.displayName = 'AxoAlertDialog.Body';
/**
* Component: <AxoAlertDialog.Footer>
* ---------------------------------
* <AxoAlertDialog.Footer>
* --------------------------------------------------------------------------
*/
export type FooterProps = Readonly<{
/**
* Should be a `Cancel` and one or more `Action` elements.
*/
children: ReactNode;
}>;
/**
* A row of buttons at the bottom of the dialog.
*/
export const Footer: FC<FooterProps> = memo(props => {
return (
<FlexWrapDetector>
@@ -162,18 +216,28 @@ export namespace AxoAlertDialog {
);
});
Footer.displayName = `${Namespace}.Footer`;
Footer.displayName = 'AxoAlertDialog.Footer';
/**
* Component: <AxoAlertDialog.Title>
* ---------------------------------
* <AxoAlertDialog.Title>
* --------------------------------------------------------------------------
*/
export type TitleProps = Readonly<{
/**
* There must always be a title for the dialog, but if you don't want it to
* be visually displayed you can pass `screenReaderOnly: true`
*/
screenReaderOnly?: boolean;
/**
* The title text.
*/
children: ReactNode;
}>;
/**
* An accessible name to be announced when the dialog is opened.
*/
export const Title: FC<TitleProps> = memo(props => {
return (
<AlertDialog.Title
@@ -187,17 +251,23 @@ export namespace AxoAlertDialog {
);
});
Title.displayName = `${Namespace}.Title`;
Title.displayName = 'AxoAlertDialog.Title';
/**
* Component: <AxoAlertDialog.Description>
* ---------------------------------------
* <AxoAlertDialog.Description>
* --------------------------------------------------------------------------
*/
export type DescriptionProps = Readonly<{
/**
* The description text.
*/
children: ReactNode;
}>;
/**
* An optional accessible description to be announced when the dialog is opened.
*/
export const Description: FC<DescriptionProps> = memo(props => {
return (
<AlertDialog.Description
@@ -208,19 +278,28 @@ export namespace AxoAlertDialog {
);
});
Description.displayName = `${Namespace}.Description`;
Description.displayName = 'AxoAlertDialog.Description';
/**
* Component: <AxoAlertDialog.Cancel>
* ----------------------------------
* <AxoAlertDialog.Cancel>
* --------------------------------------------------------------------------
*/
export type CancelProps = Readonly<{
/**
* The button label.
*/
children: ReactNode;
/**
* When `true`, prevents the user from dismissing via this button.
*/
disabled?: boolean;
focusableWhenDisabled?: boolean;
}>;
/**
* A button that dismisses the dialog without taking the primary action.
* Automatically closes the dialog — no `onClick` needed.
*/
export const Cancel: FC<CancelProps> = memo(props => {
return (
<AlertDialog.Cancel asChild>
@@ -229,7 +308,6 @@ export namespace AxoAlertDialog {
size="md"
width="grow"
disabled={props.disabled}
focusableWhenDisabled={props.focusableWhenDisabled}
>
{props.children}
</AxoButton.Root>
@@ -237,13 +315,16 @@ export namespace AxoAlertDialog {
);
});
Cancel.displayName = `${Namespace}.Cancel`;
Cancel.displayName = 'AxoAlertDialog.Cancel';
/**
* Component: <AxoAlertDialog.Action>
* ----------------------------------
* <AxoAlertDialog.Action>
* --------------------------------------------------------------------------
*/
/**
* Visual style of an action button.
*/
export type ActionVariant =
| 'primary'
| 'secondary'
@@ -251,29 +332,47 @@ export namespace AxoAlertDialog {
| 'subtle-destructive';
export type ActionProps = Readonly<{
/**
* Visual style of the button.
*/
variant: ActionVariant;
/**
* Optional leading icon.
*/
symbol?: AxoSymbol.InlineGlyphName;
/**
* When `true`, shows a forward arrow on the trailing side.
*/
arrow?: boolean;
/**
* Called when the button is clicked.
*/
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
/**
* When `true`, prevents interaction.
*/
disabled?: boolean;
focusableWhenDisabled?: boolean;
/**
* The button label.
*/
children: ReactNode;
}>;
/**
* A button that confirms or takes a named action. Requires an explicit
* `onClick` — unlike `Cancel`, it does not auto-close the dialog.
*/
export const Action: FC<ActionProps> = memo(props => {
return (
<AlertDialog.Action
asChild
onClick={props.onClick}
disabled={props.disabled}
>
<AlertDialog.Action asChild>
<AxoButton.Root
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow ? 'next' : null}
size="md"
width="grow"
focusableWhenDisabled
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</AxoButton.Root>
@@ -281,5 +380,5 @@ export namespace AxoAlertDialog {
);
});
Action.displayName = `${Namespace}.Action`;
Action.displayName = 'AxoAlertDialog.Action';
}
+315 -107
View File
@@ -5,12 +5,11 @@ import type {
CSSProperties,
FC,
ImgHTMLAttributes,
MouseEventHandler,
MouseEvent,
ReactNode,
} from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import {
createStrictContext,
@@ -18,27 +17,33 @@ import {
} from './_internal/StrictContext.dom.tsx';
import { assert } from './_internal/assert.std.tsx';
import { AxoTokens } from './AxoTokens.std.ts';
const Namespace = 'AxoAvatar';
import { variants } from './_internal/variants.dom.tsx';
/**
* A circular avatar for displaying a user or group's profile image, initials,
* gradient, or icon — with optional badge and unread-story ring overlays.
*
* @example Anatomy
* ```tsx
* <AxoAvatar.Root>
* <AxoAvatar.Content>
* {
* <AxoAvatar.Icon/> ||
* <AxoAvatar.Initials/> ||
* <AxoAvatar.Gradient/> ||
* <AxoAvatar.Image/>
* }
* <AxoAvatar.ClickToView/>
* {/* One of: *\/}
* <AxoAvatar.Image />
* <AxoAvatar.Initials />
* <AxoAvatar.Gradient />
* <AxoAvatar.Icon />
* <AxoAvatar.Preset />
* {/* Optional overlay: *\/}
* <AxoAvatar.ClickToView />
* </AxoAvatar.Content>
* <AxoAvatar.Badge/>
* </AxoAlertDialog.Root>
* <AxoAvatar.Badge />
* </AxoAvatar.Root>
* ```
*/
export namespace AxoAvatar {
/**
* Width and height of the avatar in pixels.
*/
export type Size =
| 20
| 24
@@ -55,9 +60,10 @@ export namespace AxoAvatar {
| 96
| 216;
const SizeContext = createStrictContext<Size>(`${Namespace}.Root`);
/** @internal */
const SizeContext = createStrictContext<Size>('AxoAvatar.Root');
const RootSizes: Record<Size, TailwindStyles> = {
const RootSizes = variants<Size>('AxoAvatar.Size', {
20: tw('size-[20px]'),
24: tw('size-[24px]'),
28: tw('size-[28px]'),
@@ -72,9 +78,9 @@ export namespace AxoAvatar {
80: tw('size-[80px]'),
96: tw('size-[96px]'),
216: tw('size-[216px]'),
};
});
const RingSizes: Record<Size, TailwindStyles | null> = {
const RingSizes = variants<Size>('AxoAvatar.Size', {
20: tw('border p-[1.5px]'),
24: tw('border p-[1.5px]'),
28: tw('border-[1.5px] p-[2px]'),
@@ -89,33 +95,61 @@ export namespace AxoAvatar {
80: tw('border-[2.5px] p-[3.5px]'),
96: tw('border-[3px] p-[4px]'),
216: tw('border-4 p-[6px]'),
};
});
/** @testexport */
export function _getAllSizes(): ReadonlyArray<Size> {
return Object.keys(RootSizes).map(size => Number(size) as Size);
return RootSizes.keys().map(size => Number(size) as Size);
}
const DefaultColor = tw('bg-fill-secondary text-label-primary');
/**
* Component: <AxoAvatar.Root>
* ---------------------------
* <AxoAvatar.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/**
* Width and height of the avatar in pixels.
*/
size: Size;
/**
* Story ring shown around the avatar.
* - `unread`: Colored ring indicating an unread story.
* - `read`: Dimmed ring indicating a viewed story.
* - `null`: No ring.
*/
ring?: 'unread' | 'read' | null;
/**
* Should be a `Content` element, optionally followed by a `Badge`.
*/
children: ReactNode;
}>;
/**
* Contains all the parts of an avatar.
*
* @example Contact avatar with initials and badge
* ```tsx
* <AxoAvatar.Root size={40} ring={hasUnreadStory ? 'unread' : null}>
* <AxoAvatar.Content label={contact.name}>
* <AxoAvatar.Initials initials={contact.initials} color={contact.color} />
* </AxoAvatar.Content>
* {contact.badge && (
* <AxoAvatar.Badge label={contact.badge.name} svgs={contact.badge.svgs} />
* )}
* </AxoAvatar.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<SizeContext.Provider value={props.size}>
<div
className={tw(
'relative shrink-0 rounded-full contain-layout select-none',
RootSizes[props.size],
props.ring != null && RingSizes[props.size],
RootSizes.get(props.size),
props.ring != null && RingSizes.get(props.size),
props.ring === 'unread' && 'border-border-selected',
props.ring === 'read' && 'border-label-secondary'
)}
@@ -126,59 +160,127 @@ export namespace AxoAvatar {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoAvatar.Root';
/**
* Component: <AxoAvatar.Content>
* ------------------------------
* <AxoAvatar.Content>
* --------------------------------------------------------------------------
*/
export type ContentProps = Readonly<{
/**
* Accessible label for the avatar.
*
* Pass `null` when the avatar is purely decorative and a nearby element
* already identifies the contact.
*/
label: string | null;
onClick?: MouseEventHandler<HTMLButtonElement> | null;
/**
* When provided, renders the content as a `<button>` with this click handler.
* Without it, renders as a `<div role="img">`.
*/
onClick?: ((event: MouseEvent<HTMLButtonElement>) => void) | null;
/**
* The visual content of the avatar, should be one of:
*
* - `Image`
* - `Initials`
* - `Gradient`
* - `Icon`
* - `Preset`
*
* Optionally followed by `ClickToView`.
*/
children: ReactNode;
}>;
export const Content: FC<ContentProps> = memo(props => {
const ariaLabel = props.label ?? undefined;
const baseClassName = tw('relative size-full rounded-full contain-strict');
let result: ReactNode;
if (props.onClick != null) {
result = (
<button
type="button"
aria-label={ariaLabel}
className={tw(
baseClassName,
'outline-none keyboard-mode:focus:outline-focus-ring'
)}
onClick={props.onClick}
>
{props.children}
</button>
);
} else {
result = (
<div role="img" aria-label={ariaLabel} className={baseClassName}>
{props.children}
</div>
);
}
return result;
});
Content.displayName = `${Namespace}.Content`;
const baseContentStyles = tw(
'relative size-full rounded-full contain-strict'
);
/**
* Component: <AxoAvatar.Icon>
* ---------------------------
* The content of the avatar.
*
* Renders as a button when `onClick` is provided, otherwise as an image.
*/
export const Content: FC<ContentProps> = memo(props => {
if (props.onClick != null) {
return (
<ContentButton label={props.label} onClick={props.onClick}>
{props.children}
</ContentButton>
);
}
return (
<div
role="img"
aria-label={props.label ?? undefined}
className={baseContentStyles}
>
{props.children}
</div>
);
});
Content.displayName = 'AxoAvatar.Content';
/**
* <AxoAvatar.ContentButton>
* --------------------------------------------------------------------------
*/
/** @internal */
type ContentButtonProps = Readonly<{
label: string | null;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
children: ReactNode;
}>;
/** @internal */
const ContentButton: FC<ContentButtonProps> = memo(props => {
const { onClick } = props;
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClick(event);
},
[onClick]
);
return (
<button
type="button"
aria-label={props.label ?? undefined}
className={tw(
baseContentStyles,
'outline-none keyboard-mode:focus:outline-focus-ring'
)}
onClick={handleClick}
>
{props.children}
</button>
);
});
ContentButton.displayName = 'AxoAvatar.ContentButton';
/**
* <AxoAvatar.Icon>
* --------------------------------------------------------------------------
*/
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<IconProps> = memo(props => {
const size = useStrictContext(SizeContext);
return (
@@ -194,22 +296,43 @@ export namespace AxoAvatar {
);
});
Icon.displayName = `${Namespace}.Icon`;
Icon.displayName = 'AxoAvatar.Icon';
/**
* Component: <AxoAvatar.Image>
* ----------------------------
* <AxoAvatar.Image>
* --------------------------------------------------------------------------
*/
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<ImageProps> = memo(props => {
const { src } = props;
@@ -263,16 +386,23 @@ export namespace AxoAvatar {
);
});
Image.displayName = `${Namespace}.Image`;
Image.displayName = 'AxoAvatar.Image';
/**
* Component: <AxoAvatar.PresetImage>
* <AxoAvatar.Preset>
* --------------------------------------------------------------------------
*/
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<PresetProps> = memo(props => {
const { preset } = props;
@@ -298,25 +428,34 @@ export namespace AxoAvatar {
);
});
Preset.displayName = `${Namespace}.Preset`;
Preset.displayName = 'AxoAvatar.Preset';
/**
* Component: <AxoAvatar.ClickToView>
* ----------------------------------
* <AxoAvatar.ClickToView>
* --------------------------------------------------------------------------
*/
/** @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<ClickToViewProps> = 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 <AxoAvatar.ClickToView> 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: <AxoAvatar.Initials>
* -------------------------------
* <AxoAvatar.Initials>
* --------------------------------------------------------------------------
*/
export type InitialsProps = Readonly<{
/**
* 12 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<InitialsProps> = 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: <AxoAvatar.Gradient>
* -------------------------------
* <AxoAvatar.Gradient>
* --------------------------------------------------------------------------
*/
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<GradientProps> = 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: <AxoAvatar.Badge>
* ----------------------------
* <AxoAvatar.Badge>
* --------------------------------------------------------------------------
*/
/**
* 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<HTMLButtonElement> | null;
/**
* When provided, renders the badge as a clickable `<button>`.
*/
onClick?: ((event: MouseEvent<HTMLButtonElement>) => void) | null;
}>;
const BadgeSvgSizes: Record<Size, keyof BadgeSvgs | null> = {
type BadgeSvgSize = keyof BadgeSvgs | null;
const BadgeSvgSizes = variants<Size, BadgeSvgSize>('AxoAvatar.Size', {
20: null,
24: null,
28: 16,
@@ -438,14 +610,26 @@ export namespace AxoAvatar {
80: 36,
96: 36,
216: 36,
};
});
const baseBadgeStyles = tw(
'absolute rounded-full',
// Proportionately sized & positioned based on the size of the avatar
'-inset-e-[calc(2.75px-3%)] -bottom-[calc(6.25px-1%)] size-[calc(5px+37.5%)]'
);
/**
* A donor badge overlaid on the bottom-end corner of the avatar.
* Automatically picks the right size for the current avatar size.
*
* Note: Not rendered at sizes 20 or 24 (too small).
*/
export const Badge: FC<BadgeProps> = memo(props => {
const { svgs } = props;
const avatarSize = useStrictContext(SizeContext);
const badge = useMemo(() => {
const badgeSize = BadgeSvgSizes[avatarSize];
const badgeSize = BadgeSvgSizes.get(avatarSize);
if (badgeSize == null) {
return null;
}
@@ -489,37 +673,61 @@ export namespace AxoAvatar {
</>
);
const baseClassName = tw(
'absolute rounded-full',
// Proportionately sized & positioned based on the size of the avatar
'-inset-e-[calc(2.75px-3%)] -bottom-[calc(6.25px-1%)] size-[calc(5px+37.5%)]'
);
let result: ReactNode;
if (props.onClick != null) {
result = (
<button
type="button"
aria-label={props.label}
onClick={props.onClick}
className={tw(
baseClassName,
'outline-focus-ring-inset outline-none keyboard-mode:focus:outline-focus-ring'
)}
>
return (
<BadgeButton label={props.label} onClick={props.onClick}>
{children}
</button>
);
} else {
result = (
<div className={tw(baseClassName, 'pointer-events-none')}>
{children}
</div>
</BadgeButton>
);
}
return result;
return (
<div className={tw(baseBadgeStyles, 'pointer-events-none')}>
{children}
</div>
);
});
Badge.displayName = `${Namespace}.Badge`;
Badge.displayName = 'AxoAvatar.Badge';
/**
* <AxoAvatar.BadgeButton>
* --------------------------------------------------------------------------
*/
/** @internal */
type BadgeButtonProps = Readonly<{
label: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
children: ReactNode;
}>;
/** @internal */
const BadgeButton: FC<BadgeButtonProps> = memo(props => {
const { onClick } = props;
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClick(event);
},
[onClick]
);
return (
<button
type="button"
aria-label={props.label}
onClick={handleClick}
className={tw(
baseBadgeStyles,
'outline-focus-ring-inset outline-none keyboard-mode:focus:outline-focus-ring'
)}
>
{props.children}
</button>
);
});
BadgeButton.displayName = 'AxoAvatar.BadgeButton';
}
+3 -3
View File
@@ -11,7 +11,7 @@ export default {
} satisfies Meta;
export function All(): JSX.Element {
const values: ReadonlyArray<ExperimentalAxoBadge.BadgeValue> = [
const values: ReadonlyArray<ExperimentalAxoBadge.Value> = [
-1,
0,
1,
@@ -32,7 +32,7 @@ export function All(): JSX.Element {
})}
</thead>
<tbody>
{ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
{ExperimentalAxoBadge._getAllSizes().map(size => {
return (
<tr key={size}>
<th>{size}</th>
@@ -44,7 +44,7 @@ export function All(): JSX.Element {
value={value}
max={99}
maxDisplay="99+"
aria-label={null}
label={null}
/>
</td>
);
+62 -39
View File
@@ -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<Size>('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<BadgeSize, BadgeConfig> = {
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<Size>('AxoBadge.Size', {
sm: tw('px-[3px]'),
md: tw('px-[4px]'),
lg: tw('px-[5px]'),
});
export function _getAllBadgeSizes(): ReadonlyArray<BadgeSize> {
return Object.keys(BadgeSizes) as Array<BadgeSize>;
/** @testexport */
export function _getAllSizes(): ReadonlyArray<Size> {
return Sizes.keys();
}
let cachedNumberFormat: Intl.NumberFormat;
@@ -74,21 +78,43 @@ export namespace ExperimentalAxoBadge {
}
/**
* Component: <AxoBadge.Root>
* --------------------------
* <AxoBadge.Root>
* --------------------------------------------------------------------------
*/
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
* <ExperimentalAxoBadge.Root size="md" value={42} max={99} maxDisplay="99+" label="42 unread messages" />
* ```
*
* @example Mention
* ```tsx
* <ExperimentalAxoBadge.Root size="md" value="mention" max={0} maxDisplay="" label="You were mentioned" />
* ```
*
* @example Unread dot
* ```tsx
* <ExperimentalAxoBadge.Root size="md" value="unread" max={0} maxDisplay="" label="Marked unread" />
* ```
*/
export const Root: FC<RootProps> = 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 (
<span aria-hidden className={config.countStyles}>
<span aria-hidden className={CountSizes.get(size)}>
{formatBadgeCount(value, max, maxDisplay)}
</span>
);
}
unreachable(value);
}, [value, max, maxDisplay, config]);
}, [size, value, max, maxDisplay]);
return (
<span
aria-label={props['aria-label'] ?? undefined}
className={config.rootStyles}
>
<span aria-label={props.label ?? undefined} className={Sizes.get(size)}>
{children}
</span>
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoBadge.Root';
}
+10 -16
View File
@@ -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 (
<div className={tw('grid gap-1')}>
{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 (
<>
<div className={tw('mb-4 flex gap-2')}>
<AxoSwitch.Root checked={loading} onCheckedChange={setLoading} />
<AxoSwitch.Root checked={pending} onCheckedChange={setPending} />
<span>Loading</span>
</div>
<div className={tw('flex flex-col gap-2')}>
@@ -117,10 +113,8 @@ export function Spinner(): JSX.Element {
<AxoButton.Root
variant={variant}
size={size}
disabled={loading}
experimentalSpinner={
loading ? { 'aria-label': 'Loading' } : null
}
disabled={pending}
pending={pending}
onClick={handleClick}
>
Save
+393 -280
View File
@@ -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<HTMLButtonElement>;
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<string, TailwindStyles>;
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<string, TailwindStyles>;
type AxoButtonVariant = keyof typeof AxoButtonVariants;
type AxoButtonSize = keyof typeof AxoButtonSizes;
/** @testexport */
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
}
/** @testexport */
export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
}
const AxoButtonSpinnerVariants: Record<AxoButtonVariant, SpinnerVariant> = {
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 (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
size={sizeConfig.size}
strokeWidth={sizeConfig.strokeWidth}
variant={variant}
value="indeterminate"
ariaLabel={props['aria-label']}
/>
</span>
);
}
/**
* A text button with optional leading icon and trailing arrow.
*
* @example Anatomy
* ```tsx
* <AxoButton.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 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<Width, TailwindStyles> = {
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<Variant>('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<Size>('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<Width>('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<Arrow, AxoSymbol.InlineGlyphName> = {
type ArrowSymbol = AxoSymbol.InlineGlyphName;
const Arrows = variants<Arrow, ArrowSymbol>('AxoButton.Arrow', {
collapse: 'chevron-up',
expand: 'chevron-down',
next: 'chevron-[end]',
};
});
/** @testexport */
export function _getAllVariants(): ReadonlyArray<Variant> {
return VariantStyles.keys();
}
/** @testexport */
export function _getAllSizes(): ReadonlyArray<Size> {
return SizeStyles.keys();
}
/**
* <AxoButton.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
variant: AxoButtonVariant;
size: AxoButtonSize;
/**
* Ref to the underlying `<button>` element.
*/
ref?: Ref<HTMLButtonElement>;
/**
* Visual style of the button.
*/
variant: Variant;
/**
* Size of the button.
*/
size: Size;
/**
* How the button sizes itself horizontally. Defaults to `fit`.
*/
width?: Width;
/**
* Optional leading icon.
*/
symbol?: AxoSymbol.InlineGlyphName;
/**
* Optional trailing arrow icon.
*/
arrow?: Arrow | null;
experimentalSpinner?: { 'aria-label': string } | null;
disabled?: GenericButtonProps['disabled'];
focusableWhenDisabled?: boolean;
onClick?: GenericButtonProps['onClick'];
'aria-expanded'?: boolean | null;
'aria-controls'?: string | null;
/**
* When `true`, shows a loading spinner and prevents interaction.
*/
pending?: boolean | null;
/**
* When set, the button behaves as a toggle with `aria-pressed` semantics.
*/
pressed?: boolean | null;
/**
* When set, adds `aria-expanded` for disclosure buttons that show/hide content.
*/
expanded?: boolean | null;
/**
* `aria-controls` — the `id` of the element this button controls.
*/
controls?: string | 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;
/**
* The button label.
*/
children: ReactNode;
// Note: Technically we forward all props for Radix, but we restrict the
// props that the type accepts
}>;
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const {
variant,
size,
width,
symbol,
arrow,
experimentalSpinner,
disabled,
focusableWhenDisabled,
'aria-expanded': ariaExpanded,
'aria-controls': ariaControls,
children,
...rest
} = props;
const variantStyles = assert(
AxoButtonVariants[variant],
`${Namespace}: Invalid variant ${variant}`
);
const sizeStyles = assert(
AxoButtonSizes[size],
`${Namespace}: Invalid size ${size}`
);
const widthStyles = Widths[width ?? 'fit'];
/**
* A text button with an optional leading icon and trailing arrow.
*
* @example Dialog actions
* ```tsx
* <AxoButton.Root variant="secondary" size="md" width="grow" onClick={onCancel}>
* Cancel
* </AxoButton.Root>
* <AxoButton.Root variant="primary" size="md" width="grow" pending={isSaving} onClick={onSave}>
* Save
* </AxoButton.Root>
* ```
*
* @example Inline destructive action with icon
* ```tsx
* <AxoButton.Root
* variant="subtle-destructive"
* size="md"
* symbol="trash"
* onClick={onDelete}
* >
* Delete
* </AxoButton.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const {
variant,
size,
width,
symbol,
arrow,
pending,
disabled,
pressed,
expanded,
controls,
onClick,
children,
...rest
} = props;
return (
<button
ref={ref}
disabled={disabled && !focusableWhenDisabled}
aria-disabled={focusableWhenDisabled && disabled}
aria-expanded={ariaExpanded ?? undefined}
aria-controls={ariaControls ?? undefined}
{...rest}
type="button"
className={tw(variantStyles, sizeStyles, widthStyles)}
>
<span
className={tw(
'flex shrink grow items-center-safe justify-center-safe gap-1 overflow-hidden',
experimentalSpinner != null ? 'opacity-0' : null
)}
>
{symbol != null && (
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
)}
<span className={tw('min-w-0 shrink grow truncate')}>
{children}
</span>
{arrow != null && (
<AxoSymbol.InlineGlyph symbol={Arrows[arrow]} label={null} />
)}
</span>
{experimentalSpinner != null && (
<ExperimentalButtonSpinner
buttonVariant={variant}
buttonSize={size}
aria-label={experimentalSpinner['aria-label']}
/>
const intl = useAxoIntl();
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (pending || disabled) {
event.preventDefault();
return;
}
onClick?.(event);
},
[pending, disabled, onClick]
);
return (
<button
ref={props.ref}
type="button"
aria-label={pending ? intl.get('AxoButton.Pending') : undefined}
aria-disabled={(pending || disabled) ?? undefined}
aria-expanded={expanded ?? undefined}
aria-pressed={pressed ?? undefined}
aria-controls={controls ?? undefined}
onClick={handleClick}
className={tw(
VariantStyles.get(variant),
SizeStyles.get(size),
WidthStyles.get(width ?? 'fit')
)}
{...rest}
>
<span
aria-hidden={pending ?? undefined}
className={tw(
'flex shrink grow items-center-safe justify-center-safe gap-1 overflow-hidden',
pending ? 'opacity-0' : null
)}
</button>
);
})
>
{symbol != null && (
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
)}
<span className={tw('min-w-0 shrink grow truncate')}>{children}</span>
{arrow != null && (
<AxoSymbol.InlineGlyph symbol={Arrows.get(arrow)} label={null} />
)}
</span>
{pending && <Spinner buttonVariant={variant} buttonSize={size} />}
</button>
);
});
Root.displayName = 'AxoButton.Root';
/**
* <AxoButton.Spinner>
* -------------------
*/
const SpinnerVariants = variants<Variant, SpinnerVariant>(
'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<Size, SpinnerSizeConfig>('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 (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
size={sizeConfig.size}
strokeWidth={sizeConfig.strokeWidth}
variant={variant}
value="indeterminate"
/>
</span>
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function Basic(): JSX.Element {
return (
<>
<h1 className={tw('type-title-large')}>AxoCheckbox</h1>
{AxoCheckbox._getAllCheckboxVariants().map(variant => {
{AxoCheckbox._getAllVariants().map(variant => {
return (
<section>
<Template
+79 -27
View File
@@ -1,47 +1,99 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
import { memo } from 'react';
import { Checkbox } from 'radix-ui';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const Namespace = 'AxoCheckbox';
/**
* A control that allows the user to toggle between checked and not checked.
*
* TODO(jamie): We need to figure out a better way to label these.
*
* @example Anatomy
* ```tsx
* <AxoCheckbox.Root />
* ```
* @see {@link https://www.radix-ui.com/primitives/docs/components/checkbox | Checkbox - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/ | Checkbox Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#checkbox | `checkbox` role - WAI-ARIA 1.3}
*/
export namespace AxoCheckbox {
type VariantConfig = {
rootStyles: TailwindStyles;
iconSize: AxoSymbol.IconSize;
};
const Variants: Record<Variant, VariantConfig> = {
round: {
rootStyles: tw('size-5 rounded-full'),
iconSize: 14,
},
square: {
rootStyles: tw('size-4 rounded-sm'),
iconSize: 12,
},
};
export function _getAllCheckboxVariants(): ReadonlyArray<Variant> {
return Object.keys(Variants) as Array<Variant>;
}
/**
* <AxoCheckbox.Root>
* --------------------------------------------------------------------------
*/
/**
* Shape of the checkbox.
* - `round`: Circular (20px). Used for selection in lists.
* - `square`: Rounded-square (16px). Default for form checkboxes.
*/
export type Variant = 'round' | 'square';
const RootStyles = variants<Variant>('AxoCheckbox.Variant', {
round: tw('size-5 rounded-full'),
square: tw('size-4 rounded-sm'),
});
const IconSizes = variants<Variant, AxoSymbol.IconSize>(
'AxoCheckbox.Variant',
{
round: 14,
square: 12,
}
);
/** @testexport */
export function _getAllVariants(): ReadonlyArray<Variant> {
return RootStyles.keys();
}
export type RootProps = Readonly<{
/**
* HTML `id` used to associate a `<label htmlFor={id}>` with this checkbox.
*/
id?: string;
/**
* Shape of the checkbox.
*/
variant: Variant;
/**
* The controlled checked state of the checkbox.
* Must be used in conjunction with `onCheckedChange`.
*/
checked: boolean;
/**
* Event handler called when the checked state of the checkbox changes.
*/
onCheckedChange: (nextChecked: boolean) => void;
/**
* When `true`, prevents the user from interacting with the checkbox.
*/
disabled?: boolean;
/**
* When `true`, indicates that the user must check the checkbox before the
* owning form can be submitted.
*/
required?: boolean;
}>;
export const Root = memo((props: RootProps) => {
const variantConfig = Variants[props.variant];
/**
* The checkbox control.
*
* @example Labeled checkbox
* ```tsx
* <AxoCheckbox.Root
* id={id}
* variant="square"
* checked={includeMedia}
* onCheckedChange={setIncludeMedia}
* />
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<Checkbox.Root
id={props.id}
@@ -50,7 +102,7 @@ export namespace AxoCheckbox {
disabled={props.disabled}
required={props.required}
className={tw(
variantConfig.rootStyles,
RootStyles.get(props.variant),
'flex items-center justify-center',
'border border-border-primary inset-shadow-on-color',
'data-[state=unchecked]:bg-fill-primary',
@@ -67,7 +119,7 @@ export namespace AxoCheckbox {
<Checkbox.Indicator asChild>
<AxoSymbol.Icon
symbol="check"
size={variantConfig.iconSize}
size={IconSizes.get(props.variant)}
label={null}
/>
</Checkbox.Indicator>
@@ -75,5 +127,5 @@ export namespace AxoCheckbox {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoCheckbox.Root';
}
+129 -82
View File
@@ -2,12 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ContextMenu } from 'radix-ui';
import type {
FC,
KeyboardEvent,
KeyboardEventHandler,
MouseEvent as ReactMouseEvent,
} from 'react';
import type { FC, KeyboardEvent, MouseEvent } from 'react';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.tsx';
import { tw } from './tw.dom.tsx';
@@ -21,63 +16,88 @@ import { AxoTheme } from './AxoTheme.dom.tsx';
const { useDisableDragRegions } = AxoDragRegion;
const Namespace = 'AxoContextMenu';
/**
* Displays a menu located at the pointer, triggered by a right click or a long press.
* Displays a menu at the pointer position, triggered by a right-click or the
* platform context-menu keyboard shortcut.
*
* Note: For menus that are triggered by a normal button press, you should use
* `AxoDropdownMenu`.
* For menus triggered by a button press, use `AxoDropdownMenu` instead.
*
* @example Anatomy
* ```tsx
* import { AxoContextMenu } from "./axo/ContextMenu/AxoContentMenu.tsx";
*
* export default () => (
* <AxoContextMenu.Root>
* <AxoContextMenu.Trigger />
*
* <AxoContextMenu.Root>
* <AxoContextMenu.Trigger>
* <AxoContextMenu.Content>
* <AxoContextMenu.Label />
* <AxoContextMenu.Item />
*
* <AxoContextMenu.Group>
* <AxoContextMenu.Item />
* </AxoContextMenu.Group>
*
* <AxoContextMenu.CheckboxItem/>
*
* <AxoContextMenu.CheckboxItem />
* <AxoContextMenu.RadioGroup>
* <AxoContextMenu.RadioItem/>
* <AxoContextMenu.RadioItem />
* </AxoContextMenu.RadioGroup>
*
* <AxoContextMenu.Sub>
* <AxoContextMenu.SubTrigger />
* <AxoContextMenu.SubContent />
* </AxoContextMenu.Sub>
*
* <AxoContextMenu.Separator />
* </AxoContextMenu.Content>
* </AxoContextMenu.Root>
* )
* </AxoContextMenu.Trigger>
* </AxoContextMenu.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/context-menu | Context Menu - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
*/
export namespace AxoContextMenu {
/**
* <AxoContextMenu.Root>
* --------------------------------------------------------------------------
*/
/** @internal */
type RootContextType = Readonly<{
open: boolean;
}>;
/** @internal */
const RootContext = createStrictContext<RootContextType>(
`${Namespace}.RootContext`
'AxoContextMenu.RootContext'
);
/**
* Component: <AxoContextMenu.Root>
* --------------------------------
*/
export type RootProps = AxoBaseMenu.MenuRootProps;
/**
* Contains all the parts of a context menu.
*
* @example Conversation list item with context menu
* ```tsx
* <AxoContextMenu.Root onOpenChange={onOpenChange}>
* <AxoContextMenu.Trigger>
* <ConversationListItem conversation={conversation} />
* </AxoContextMenu.Trigger>
* <AxoContextMenu.Content>
* <AxoContextMenu.Item symbol="message-badge" onSelect={onMarkUnread}>
* Mark unread
* </AxoContextMenu.Item>
* <AxoContextMenu.Sub>
* <AxoContextMenu.SubTrigger symbol="bell-slash">
* Mute notifications
* </AxoContextMenu.SubTrigger>
* <AxoContextMenu.SubContent>
* <AxoContextMenu.Item onSelect={() => onMute('1-hour')}>For 1 hour</AxoContextMenu.Item>
* <AxoContextMenu.Item onSelect={() => onMute('always')}>Always</AxoContextMenu.Item>
* </AxoContextMenu.SubContent>
* </AxoContextMenu.Sub>
* <AxoContextMenu.Separator />
* <AxoContextMenu.Item symbol="trash" onSelect={onDelete}>
* Delete conversation
* </AxoContextMenu.Item>
* </AxoContextMenu.Content>
* </AxoContextMenu.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const { onOpenChange } = props;
const [open, setOpen] = useState(false);
@@ -105,15 +125,16 @@ export namespace AxoContextMenu {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoContextMenu.Root';
/**
* Component: <AxoContextMenu.Trigger>
* -----------------------------------
* <AxoContextMenu.Trigger>
* --------------------------------------------------------------------------
*/
type TriggerElementGetter = (event: KeyboardEvent) => Element;
/** @internal */
function useContextMenuTriggerKeyboardEventHandler(
getTriggerElement: TriggerElementGetter
) {
@@ -143,7 +164,7 @@ export namespace AxoContextMenu {
const clientRect = trigger.getBoundingClientRect();
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
new globalThis.MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: clientRect.left,
@@ -158,12 +179,17 @@ export namespace AxoContextMenu {
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
/**
* The area that opens the context menu.
* Wrap it around the target you want the context menu to open from when
* right-clicking (or using the relevant keyboard shortcuts).
*/
export const Trigger: FC<TriggerProps> = memo(props => {
const context = useStrictContext(RootContext);
const [disableCurrentEvent, setDisableCurrentEvent] = useState(false);
const handleContextMenuCapture = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
(event: MouseEvent<HTMLElement>) => {
const { target, currentTarget } = event;
if (
target instanceof HTMLElement &&
@@ -207,20 +233,48 @@ export namespace AxoContextMenu {
);
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = 'AxoContextMenu.Trigger';
export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler {
/**
* useAxoContextMenuOutsideKeyboardTrigger()
* --------------------------------------------------------------------------
*/
/**
* Returns a `onKeyDown` handler that fires the context-menu keyboard shortcut
* on the `AxoContextMenu.Trigger` found inside `event.currentTarget`.
*
* Use this when the keyboard handler must live on a parent element that is
* separate from the `Trigger` (e.g. a focusable row that contains a trigger
* deeper in its tree). Attach the returned handler to the focusable element's
* `onKeyDown`.
*
* @example Row-level keyboard handler
* ```tsx
* const handleKeyDown = AxoContextMenu.useAxoContextMenuOutsideKeyboardTrigger();
*
* <div role="row" tabIndex={0} onKeyDown={handleKeyDown}>
* <AxoContextMenu.Root>
* <AxoContextMenu.Trigger>{rowContent}</AxoContextMenu.Trigger>
* <AxoContextMenu.Content>...</AxoContextMenu.Content>
* </AxoContextMenu.Root>
* </div>
* ```
*/
export function useAxoContextMenuOutsideKeyboardTrigger(): (
event: KeyboardEvent
) => void {
return useContextMenuTriggerKeyboardEventHandler(event => {
return assert(
event.currentTarget.querySelector('[data-axo-contextmenu-trigger]'),
`Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?`
`Couldn't find <AxoContextMenu.Trigger> element, did you forget to pass all html props through?`
);
});
}
/**
* Component: <AxoContextMenu.Content>
* -----------------------------------
* <AxoContextMenu.Content>
* --------------------------------------------------------------------------
*/
export type ContentProps = AxoBaseMenu.MenuContentProps;
@@ -248,23 +302,17 @@ export namespace AxoContextMenu {
);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoContextMenu.Content';
/**
* Component: <AxoContextMenu.Item>
* --------------------------------
* <AxoContextMenu.Item>
* --------------------------------------------------------------------------
*/
export type ItemProps = AxoBaseMenu.MenuItemProps;
/**
* The component that contains the context menu items.
* @example
* ```tsx
* <AxoContextMenu.Item icon={<svg/>}>
* {i18n("myContextMenuText")}
* </AxoContentMenu.Item>
* ````
* A single selectable item in the context menu.
*/
export const Item: FC<ItemProps> = memo(props => {
return (
@@ -291,11 +339,11 @@ export namespace AxoContextMenu {
);
});
Item.displayName = `${Namespace}.Item`;
Item.displayName = 'AxoContextMenu.Item';
/**
* Component: <AxoContextMenu.Group>
* ---------------------------------
* <AxoContextMenu.Group>
* --------------------------------------------------------------------------
*/
export type GroupProps = AxoBaseMenu.MenuGroupProps;
@@ -311,11 +359,11 @@ export namespace AxoContextMenu {
);
});
Group.displayName = `${Namespace}.Group`;
Group.displayName = 'AxoContextMenu.Group';
/**
* Component: <AxoContextMenu.Label>
* ---------------------------------
* <AxoContextMenu.Label>
* --------------------------------------------------------------------------
*/
export type LabelProps = AxoBaseMenu.MenuLabelProps;
@@ -333,11 +381,11 @@ export namespace AxoContextMenu {
);
});
Label.displayName = `${Namespace}.Label`;
Label.displayName = 'AxoContextMenu.Label';
/**
* Component: <AxoContextMenu.CheckboxItem>
* ----------------------------------------
* <AxoContextMenu.CheckboxItem>
* --------------------------------------------------------------------------
*/
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
@@ -379,11 +427,11 @@ export namespace AxoContextMenu {
);
});
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
CheckboxItem.displayName = 'AxoContextMenu.CheckboxItem';
/**
* Component: <AxoContextMenu.RadioGroup>
* --------------------------------------
* <AxoContextMenu.RadioGroup>
* --------------------------------------------------------------------------
*/
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
@@ -403,11 +451,11 @@ export namespace AxoContextMenu {
);
});
RadioGroup.displayName = `${Namespace}.RadioGroup`;
RadioGroup.displayName = 'AxoContextMenu.RadioGroup';
/**
* Component: <AxoContextMenu.RadioItem>
* -------------------------------------
* <AxoContextMenu.RadioItem>
* --------------------------------------------------------------------------
*/
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
@@ -446,11 +494,11 @@ export namespace AxoContextMenu {
);
});
RadioItem.displayName = `${Namespace}.RadioItem`;
RadioItem.displayName = 'AxoContextMenu.RadioItem';
/**
* Component: <AxoContextMenu.Separator>
* -------------------------------------
* <AxoContextMenu.Separator>
* --------------------------------------------------------------------------
*/
/**
@@ -462,11 +510,11 @@ export namespace AxoContextMenu {
);
});
Separator.displayName = `${Namespace}.Separator`;
Separator.displayName = 'AxoContextMenu.Separator';
/**
* Component: <AxoContextMenu.Sub>
* -------------------------------
* <AxoContextMenu.Sub>
* --------------------------------------------------------------------------
*/
export type SubProps = AxoBaseMenu.MenuSubProps;
@@ -478,18 +526,17 @@ export namespace AxoContextMenu {
return <ContextMenu.Sub>{props.children}</ContextMenu.Sub>;
});
Sub.displayName = `${Namespace}.Sub`;
Sub.displayName = 'AxoContextMenu.Sub';
/**
* Component: <AxoContextMenu.SubTrigger>
* --------------------------------------
* <AxoContextMenu.SubTrigger>
* --------------------------------------------------------------------------
*/
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
/**
* An item that opens a submenu. Must be rendered inside
* {@link ContextMenu.Sub}.
* An item that opens a submenu. Must be rendered inside `AxoContextMenu.Sub`.
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
@@ -509,11 +556,11 @@ export namespace AxoContextMenu {
);
});
SubTrigger.displayName = `${Namespace}.SubTrigger`;
SubTrigger.displayName = 'AxoContextMenu.SubTrigger';
/**
* Component: <AxoContextMenu.SubContent>
* --------------------------------------
* <AxoContextMenu.SubContent>
* --------------------------------------------------------------------------
*/
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
@@ -534,5 +581,5 @@ export namespace AxoContextMenu {
);
});
SubContent.displayName = `${Namespace}.SubContent`;
SubContent.displayName = 'AxoContextMenu.SubContent';
}
+5 -7
View File
@@ -54,11 +54,9 @@ function Template(props: {
</AxoDialog.Trigger>
<AxoDialog.Content size={props.contentSize} escape="cancel-is-noop">
<AxoDialog.Header>
{props.back && (
<AxoDialog.Back aria-label="Back" onClick={action('onBack')} />
)}
{props.back && <AxoDialog.Back onClick={action('onBack')} />}
<AxoDialog.Title>Title</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body padding={props.bodyPadding}>
{props.children}
@@ -210,7 +208,7 @@ export function ExampleNicknameAndNoteDialog(): JSX.Element {
<AxoDialog.Content size="sm" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>Nickname</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<p className={tw('mb-4 type-body-small text-label-secondary')}>
@@ -278,7 +276,7 @@ export function ExampleMuteNotificationsDialog(): JSX.Element {
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>Mute notifications</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<Spacer height={8} />
@@ -345,7 +343,7 @@ export function ExampleLanguageDialog(): JSX.Element {
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>Language</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.ExperimentalSearch>
<input
+337 -82
View File
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { Dialog } from 'radix-ui';
import type { CSSProperties, FC, ReactNode } from 'react';
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
import { memo, useMemo, useState } from 'react';
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.tsx';
import type { AxoSymbol } from './AxoSymbol.dom.tsx';
@@ -12,23 +12,113 @@ import { AxoButton } from './AxoButton.dom.tsx';
import { AxoIconButton } from './AxoIconButton.dom.tsx';
import { AxoTooltip } from './AxoTooltip.dom.tsx';
import { AxoTheme } from './AxoTheme.dom.tsx';
const Namespace = 'AxoDialog';
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const { useContentEscapeBehavior } = AxoBaseDialog;
/**
* A window overlaid on either the primary window or another dialog window,
* rendering the content underneath inert.
*
* @example Anatomy
* ```tsx
* <AxoDialog.Root>
* <AxoDialog.Trigger />
* <AxoDialog.Content>
* <AxoDialog.Header>
* <AxoDialog.Back />
* <AxoDialog.Title />
* <AxoDialog.Close />
* </AxoDialog.Header>
* <AxoDialog.Body>
* <AxoDialog.Description />
* </AxoDialog.Body>
* <AxoDialog.Footer>
* <AxoDialog.FooterContent />
* <AxoDialog.Actions>
* <AxoDialog.Action/>
* <AxoDialog.Action/>
* </AxoDialog.Actions>
* </AxoDialog.Footer>
* </AxoDialog.Content>
* </AxoDialog.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/dialog | Dialog - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ | Dialog (Modal) Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#dialog | `dialog` role - WAI-ARIA 1.3}
*/
export namespace AxoDialog {
/**
* Component: <AxoDialog.Root>
* ---------------------------
* <AxoDialog.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/**
* The controlled open state of the dialog.
* Must be used in conjunction with `onOpenChange`.
*/
open?: boolean;
/**
* Event handler called when the open state of the dialog changes.
*/
onOpenChange?: (open: boolean) => void;
/**
* Should be a `Trigger` and `Content`.
*/
children: ReactNode;
}>;
/**
* Contains all the parts of a dialog.
*
* @example Controlled dialog (most common)
* ```tsx
* <AxoDialog.Root open={open} onOpenChange={onOpenChange}>
* <AxoDialog.Content size="sm" escape="cancel-is-noop">
* <AxoDialog.Header>
* <AxoDialog.Title>Delete attachment?</AxoDialog.Title>
* <AxoDialog.Close />
* </AxoDialog.Header>
* <AxoDialog.Body>
* <AxoDialog.Description>
* This attachment will be permanently deleted.
* </AxoDialog.Description>
* </AxoDialog.Body>
* <AxoDialog.Footer>
* <AxoDialog.Actions>
* <AxoDialog.Action variant="secondary" onClick={onCancel}>
* Cancel
* </AxoDialog.Action>
* <AxoDialog.Action variant="destructive" onClick={onDelete}>
* Delete
* </AxoDialog.Action>
* </AxoDialog.Actions>
* </AxoDialog.Footer>
* </AxoDialog.Content>
* </AxoDialog.Root>
* ```
*
* @example Trigger-based dialog
* ```tsx
* <AxoDialog.Root>
* <AxoDialog.Trigger>
* <AxoButton.Root variant="secondary" size="md" width="fit" onClick={noop}>
* Open settings
* </AxoButton.Root>
* </AxoDialog.Trigger>
* <AxoDialog.Content size="md" escape="cancel-is-destructive">
* <AxoDialog.Header>
* <AxoDialog.Title>Settings</AxoDialog.Title>
* <AxoDialog.Close />
* </AxoDialog.Header>
* <AxoDialog.Body>...</AxoDialog.Body>
* </AxoDialog.Content>
* </AxoDialog.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange} modal>
@@ -37,51 +127,81 @@ export namespace AxoDialog {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoDialog.Root';
/**
* Component: <AxoDialog.Trigger>
* ------------------------------
* <AxoDialog.Trigger>
* --------------------------------------------------------------------------
*/
export type TriggerProps = Readonly<{
/**
* The element that opens the dialog when clicked.
*/
children: ReactNode;
}>;
/**
* The button that opens the dialog.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
return <Dialog.Trigger asChild>{props.children}</Dialog.Trigger>;
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = 'AxoDialog.Trigger';
/**
* Component: <AxoDialog.Content>
* ------------------------------
* <AxoDialog.Content>
* --------------------------------------------------------------------------
*/
type ContentSizeConfig = Readonly<{
width: number;
minWidth: number;
}>;
const ContentSizes: Record<ContentSize, ContentSizeConfig> = {
xs: { width: 300, minWidth: 300 },
sm: { width: 360, minWidth: 360 },
md: { width: 420, minWidth: 360 },
lg: { width: 720, minWidth: 360 },
};
/**
* Width of the dialog.
* - `xs` 300px
* - `sm` 360px
* - `md` 420px
* - `lg` 720px
*/
export type ContentSize = 'xs' | 'sm' | 'md' | 'lg';
/**
* How dangerous the cancel action is considered.
* - `cancel-is-noop`: Canceling is safe — pressing Escape or clicking outside closes the dialog.
* - `cancel-is-destructive`: Canceling would lose user state — pressing Escape or clicking outside is disabled.
*/
export type ContentEscape = AxoBaseDialog.ContentEscape;
const ContentSizeStyles = variants<ContentSize>('AxoDialog.ContentSize', {
xs: tw('w-[300px] min-w-[300px]'),
sm: tw('w-[360px] min-w-[360px]'),
md: tw('w-[420px] min-w-[360px]'),
lg: tw('w-[720px] min-w-[360px]'),
});
export type ContentProps = Readonly<{
/**
* Width of the dialog.
*/
size: ContentSize;
/**
* What happens when the user presses `Escape` or clicks outside.
*/
escape: ContentEscape;
/**
* Suppresses the Radix UI warning about a missing `aria-describedby`.
* Prefer adding a visually-hidden `Description` instead of using this.
*/
disableMissingAriaDescriptionWarning?: boolean;
/**
* Should be `Header`, `Body`, `Footer`, and/or `Description` elements.
*/
children: ReactNode;
}>;
/**
* Contains content to be rendered in the open dialog.
*/
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
const [boundary, setBoundary] = useState<Element | null>(null);
@@ -102,13 +222,12 @@ export namespace AxoDialog {
<AxoTooltip.CollisionBoundary boundary={boundary} padding={4}>
<Dialog.Content
ref={setBoundary}
className={AxoBaseDialog.contentStyles}
className={tw(
AxoBaseDialog.contentStyles,
ContentSizeStyles.get(props.size)
)}
onEscapeKeyDown={handleContentEscapeEvent}
onInteractOutside={handleContentEscapeEvent}
style={{
width: sizeConfig.width,
minWidth: 320,
}}
{...descriptionProps}
>
{props.children}
@@ -120,17 +239,25 @@ export namespace AxoDialog {
);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoDialog.Content';
/**
* Component: <AxoDialog.Header>
* -----------------------------
* <AxoDialog.Header>
* --------------------------------------------------------------------------
*/
export type HeaderProps = Readonly<{
/**
* Should be `Back`, `Title`, and/or `Close` elements.
*/
children: ReactNode;
}>;
/**
* A three-column grid header: back button on the left, title in the center,
* close button on the right. Omitting `Back` or `Close` leaves their column
* empty so the title stays centered.
*/
export const Header: FC<HeaderProps> = memo(props => {
return (
<div
@@ -144,18 +271,28 @@ export namespace AxoDialog {
);
});
Header.displayName = `${Namespace}.Header`;
Header.displayName = 'AxoDialog.Header';
/**
* Component: <AxoDialog.Title>
* ----------------------------
* <AxoDialog.Title>
* --------------------------------------------------------------------------
*/
export type TitleProps = Readonly<{
/**
* There must always be a title for the dialog, but if you don't want it to
* be visually displayed you can pass `screenReaderOnly: true`
*/
screenReaderOnly?: boolean;
/**
* The title text.
*/
children: ReactNode;
}>;
/**
* An accessible title to be announced when the dialog is opened.
*/
export const Title: FC<TitleProps> = memo(props => {
return (
<Dialog.Title
@@ -171,26 +308,32 @@ export namespace AxoDialog {
);
});
Title.displayName = `${Namespace}.Title`;
Title.displayName = 'AxoDialog.Title';
/**
* Component: <AxoDialog.Back>
* ---------------------------
* <AxoDialog.Back>
* --------------------------------------------------------------------------
*/
export type BackProps = Readonly<{
'aria-label': string;
onClick: () => void;
/**
* Called when the back button is clicked.
*/
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}>;
/**
* A back-navigation button rendered in the leading column of `Header`.
*/
export const Back: FC<BackProps> = memo(props => {
const intl = useAxoIntl();
return (
<div className={tw('col-[back-slot] text-start')}>
<AxoIconButton.Root
size="sm"
variant="borderless-secondary"
symbol="chevron-[start]"
label={props['aria-label']}
label={intl.get('AxoDialog.Back')}
tooltip={false}
onClick={props.onClick}
/>
@@ -198,18 +341,18 @@ export namespace AxoDialog {
);
});
Back.displayName = `${Namespace}.Back`;
Back.displayName = 'AxoDialog.Back';
/**
* Component: <AxoDialog.Close>
* ----------------------------
* <AxoDialog.Close>
* --------------------------------------------------------------------------
*/
export type CloseProps = Readonly<{
'aria-label': string;
}>;
export const Close: FC<CloseProps> = memo(props => {
/**
* The button that closes the dialog.
*/
export const Close: FC = memo(() => {
const intl = useAxoIntl();
return (
<div className={tw('col-[close-slot] text-end leading-none')}>
<Dialog.Close asChild>
@@ -217,7 +360,7 @@ export namespace AxoDialog {
size="sm"
variant="borderless-secondary"
symbol="x"
label={props['aria-label']}
label={intl.get('AxoDialog.Close')}
tooltip={false}
/>
</Dialog.Close>
@@ -225,31 +368,66 @@ export namespace AxoDialog {
);
});
Close.displayName = `${Namespace}.Close`;
Close.displayName = 'AxoDialog.Close';
/**
* <AxoDialog.Search>
* --------------------------------------------------------------------------
*/
export type ExperimentalSearchProps = Readonly<{
/**
* A search input element.
*/
children: ReactNode;
}>;
/**
* A padded slot for a search input, placed between `Header` and `Body`.
* Pair with `Body` using `padding="only-scrollbar-gutter"` so the list
* content aligns with the search field.
*/
export const ExperimentalSearch: FC<ExperimentalSearchProps> = memo(props => {
return <div className={tw('px-4 pb-2')}>{props.children}</div>;
});
ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`;
ExperimentalSearch.displayName = 'AxoDialog.ExperimentalSearch';
/**
* Component: <AxoDialog.Body>
* ---------------------------
* <AxoDialog.Body>
* --------------------------------------------------------------------------
*/
/**
* Horizontal padding applied to the body content.
* - `normal`: Standard 24px inline padding (default).
* - `only-scrollbar-gutter`: No padding, only reserves space for the scrollbar.
* Use when content (e.g. a list) provides its own padding, or when paired
* with `ExperimentalSearch` so items align with the search field.
*/
export type BodyPadding = 'normal' | 'only-scrollbar-gutter';
export type BodyProps = Readonly<{
/**
* Horizontal padding applied to the body content.
* Defaults to `normal`.
*/
padding?: BodyPadding;
/**
* Maximum height before the body becomes scrollable.
* Defaults to `440`.
*/
maxHeight?: number;
/**
* The scrollable body content.
*/
children: ReactNode;
}>;
/**
* Scrollable content area between `Header` and `Footer`.
* Automatically shows scroll hints and a thin scrollbar.
*/
export const Body: FC<BodyProps> = memo(props => {
const { padding = 'normal', maxHeight = 440 } = props;
@@ -280,32 +458,44 @@ export namespace AxoDialog {
);
});
Body.displayName = `${Namespace}.Body`;
Body.displayName = 'AxoDialog.Body';
/**
* Component: <AxoDialog.Description>
* ----------------------------------
* <AxoDialog.Description>
* --------------------------------------------------------------------------
*/
export type DescriptionProps = Readonly<{
/**
* The description text.
*/
children: ReactNode;
}>;
/**
* An optional accessible description to be announced when the dialog is opened.
*/
export const Description: FC<DescriptionProps> = memo(props => {
return <Dialog.Description>{props.children}</Dialog.Description>;
});
Description.displayName = `${Namespace}.Description`;
Description.displayName = 'AxoDialog.Description';
/**
* Component: <AxoDialog.Body>
* ---------------------------
* <AxoDialog.Body>
* --------------------------------------------------------------------------
*/
export type FooterProps = Readonly<{
/**
* Should be `FooterContent` and/or `Actions` elements.
*/
children: ReactNode;
}>;
/**
* A row of action buttons at the bottom of the dialog.
*/
export const Footer: FC<FooterProps> = memo(props => {
return (
<div className={tw('flex flex-wrap items-center gap-3 px-3 py-2.5')}>
@@ -314,17 +504,26 @@ export namespace AxoDialog {
);
});
Footer.displayName = `${Namespace}.Footer`;
Footer.displayName = 'AxoDialog.Footer';
/**
* Component: <AxoDialog.FooterContent>
* ------------------------------------
* <AxoDialog.FooterContent>
* --------------------------------------------------------------------------
*/
export type FooterContentProps = Readonly<{
/**
* Supplementary text shown alongside the action buttons.
*/
children: ReactNode;
}>;
/**
* Optional text content placed in `Footer` alongside `Actions`.
*
* Flows into its own row when the available width is too narrow to share a
* line.
*/
export const FooterContent: FC<FooterContentProps> = memo(props => {
return (
<div
@@ -346,17 +545,23 @@ export namespace AxoDialog {
);
});
FooterContent.displayName = `${Namespace}.FooterContent`;
FooterContent.displayName = 'AxoDialog.FooterContent';
/**
* Component: <AxoDialog.Actions>
* ------------------------------
* <AxoDialog.Actions>
* --------------------------------------------------------------------------
*/
export type ActionsProps = Readonly<{
/**
* Should be `Action` and/or `IconAction` elements.
*/
children: ReactNode;
}>;
/**
* A right-aligned group of action buttons inside `Footer`.
*/
export const Actions: FC<ActionsProps> = memo(props => {
return (
<div
@@ -375,35 +580,63 @@ export namespace AxoDialog {
);
});
Actions.displayName = `${Namespace}.Actions`;
Actions.displayName = 'AxoDialog.Actions';
/**
* Component: <AxoDialog.Actions>
* ------------------------------
* <AxoDialog.Actions>
* --------------------------------------------------------------------------
*/
/**
* Visual style of an action button.
* - `primary`: High-emphasis confirm action.
* - `secondary`: Low-emphasis cancel or alternative action.
* - `destructive`: Irreversible or dangerous action.
*/
export type ActionVariant = 'primary' | 'destructive' | 'secondary';
export type ActionProps = Readonly<{
/**
* Visual style of the button.
*/
variant: ActionVariant;
/**
* Optional leading icon.
*/
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
experimentalSpinner?: { 'aria-label': string } | null;
disabled?: boolean;
focusableWhenDisabled?: boolean;
onClick: () => void;
/**
* When `true`, shows a forward arrow on the trailing side.
*/
arrow?: boolean | null;
/**
* When `true`, shows a loading spinner and prevents interaction.
*/
pending?: boolean | null;
/**
* When `true`, prevents interaction.
*/
disabled?: boolean | null;
/**
* Event handler called when the button is clicked.
*/
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
/**
* The button label.
*/
children: ReactNode;
}>;
/**
* A button for use inside `Actions`.
*/
export const Action: FC<ActionProps> = memo(props => {
return (
<AxoButton.Root
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow ? 'next' : null}
experimentalSpinner={props.experimentalSpinner}
pending={props.pending}
disabled={props.disabled}
focusableWhenDisabled={props.focusableWhenDisabled}
size="md"
width="grow"
onClick={props.onClick}
@@ -413,22 +646,44 @@ export namespace AxoDialog {
);
});
Action.displayName = `${Namespace}.Action`;
Action.displayName = 'AxoDialog.Action';
/**
* Component: <AxoDialog.IconAction>
* ---------------------------------
* <AxoDialog.IconAction>
* --------------------------------------------------------------------------
*/
/**
* Visual style of an icon action button.
* - `primary`: High-emphasis confirm action.
* - `secondary`: Low-emphasis cancel or alternative action.
* - `destructive`: Irreversible or dangerous action.
*/
export type IconActionVariant = 'primary' | 'destructive' | 'secondary';
export type IconActionProps = Readonly<{
/**
* Accessible label for screen readers.
* Should describe the action of the button, not the icon.
*/
label: string;
variant: ActionVariant;
/**
* Visual style of the button.
*/
variant: IconActionVariant;
/**
* The icon to display.
*/
symbol: AxoSymbol.IconName;
onClick: () => void;
/**
* Event handler called when the button is clicked.
*/
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}>;
/**
* An icon-only button for use inside `Actions`.
*/
export const IconAction: FC<IconActionProps> = memo(props => {
return (
<AxoIconButton.Root
@@ -441,5 +696,5 @@ export namespace AxoDialog {
);
});
IconAction.displayName = `${Namespace}.IconAction`;
IconAction.displayName = 'AxoDialog.IconAction';
}
+50 -8
View File
@@ -5,19 +5,53 @@ import { memo, useEffect } from 'react';
import type { FC, ReactNode } from 'react';
import { Slot } from 'radix-ui';
const Namespace = 'AxoDragRegion';
/**
* Marks an element as a native window drag region, allowing the user to drag
* the app window by clicking and dragging that area (Electron `-webkit-app-region: drag`).
*
* @example Anatomy
* ```tsx
* <AxoDragRegion.Root>
* {children}
* </AxoDragRegion.Root>
* ```
*/
export namespace AxoDragRegion {
/**
* <AxoDragRegion.Root>
* --------------------
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/**
* When `true`, the region remains draggable even while `useDisableDragRegions`
* is active. Used in title bar which should always be draggable.
*/
always?: boolean;
/**
* The element to mark as a drag region. The attribute is applied directly
* to the child element via `Slot` — no wrapper DOM node is added.
*/
children: ReactNode;
}>;
/**
* Marks its child element as a native window drag region.
*
* @example Title bar (always draggable)
* ```tsx
* <AxoDragRegion.Root always>
* <div className={tw('h-8')} />
* </AxoDragRegion.Root>
* ```
*
* @example Sidebar header (draggable when no overlay is open)
* ```tsx
* <AxoDragRegion.Root>
* <header>{children}</header>
* </AxoDragRegion.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<Slot.Root data-axo-drag-region={props.always ? 'always' : 'auto'}>
@@ -26,19 +60,27 @@ export namespace AxoDragRegion {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoDragRegion.Root';
/**
* useDisableDragRegions()
* -----------------------
* --------------------------------------------------------------------------
*/
const DISABLE_ATTRIBUTE = 'data-axo-drag-region-disable';
/**
* New elements added to the DOM may not trigger a recalculation of draggable regions,
* which can cause pointer events on elements rendered on top of a draggable region to be
* ignored incorrectly.
* Suspends all non-`always` drag regions while `condition` is `true`.
*
* Call this in any overlay (menus, call bars) that renders on top of a drag
* region. New DOM elements don't always trigger a recalculation of draggable
* regions in Electron, which can cause pointer events on overlapping elements
* to be silently swallowed.
*
* @example Disable drag regions while a menu is open
* ```tsx
* useDisableDragRegions(open);
* ```
*/
export function useDisableDragRegions(condition: boolean): void {
useEffect(() => {
+138 -105
View File
@@ -35,8 +35,6 @@ import { AxoTheme } from './AxoTheme.dom.tsx';
const { useDisableDragRegions } = AxoDragRegion;
const Namespace = 'AxoDropdownMenu';
/**
* Displays a menu to the user—such as a set of actions or functions—triggered
* by a button.
@@ -46,64 +44,82 @@ const Namespace = 'AxoDropdownMenu';
*
* @example Anatomy
* ```tsx
* import { AxoDropdownMenu } from "./axo/DropdownMenu/AxoDropdownMenu.tsx";
*
* export default () => (
* <AxoDropdownMenu.Root>
* <AxoDropdownMenu.Trigger>
* <button>Click Me</button>
* </AxoDropdownMenu.Trigger>
*
* <AxoDropdownMenu.Content>
* <AxoDropdownMenu.Label />
* <AxoDropdownMenu.Item />
*
* <AxoDropdownMenu.Group>
* <AxoDropdownMenu.Item />
* </AxoDropdownMenu.Group>
*
* <AxoDropdownMenu.CheckboxItem/>
*
* <AxoDropdownMenu.RadioGroup>
* <AxoDropdownMenu.RadioItem/>
* </AxoDropdownMenu.RadioGroup>
*
* <AxoDropdownMenu.Sub>
* <AxoDropdownMenu.SubTrigger />
* <AxoDropdownMenu.SubContent />
* </AxoDropdownMenu.Sub>
*
* <AxoDropdownMenu.Separator />
* </AxoDropdownMenu.Content>
* </AxoDropdownMenu.Root>
* )
* <AxoDropdownMenu.Root>
* <AxoDropdownMenu.Trigger>
* <AxoIconButton.Root variant="secondary" size="sm" symbol="more" label="More options" />
* </AxoDropdownMenu.Trigger>
* <AxoDropdownMenu.Content>
* <AxoDropdownMenu.Header label="Conversation" />
* <AxoDropdownMenu.Item symbol="bell-slash" onSelect={onMute}>Mute notifications</AxoDropdownMenu.Item>
* <AxoDropdownMenu.Separator />
* <AxoDropdownMenu.Item symbol="trash" onSelect={onDelete}>Delete</AxoDropdownMenu.Item>
* </AxoDropdownMenu.Content>
* </AxoDropdownMenu.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/dropdown-menu | Dropdown Menu - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
*/
export namespace AxoDropdownMenu {
/**
* <AxoDropdownMenu.Root>
* --------------------------------------------------------------------------
*/
/**
* The preferred alignment against the trigger.
* May change when collisions occur.
*/
export type Align = AxoBaseMenu.Align;
/**
* The preferred side of the trigger to render against when open.
* Will be reversed when collisions occur.
*/
export type Side = AxoBaseMenu.Side;
/** @internal */
type RootContextType = Readonly<{
open: boolean;
}>;
/** @internal */
const RootContext = createStrictContext<RootContextType>(
`${Namespace}.RootContext`
'AxoDropdownMenu.RootContext'
);
/**
* Component: <AxoDropdownMenu.Root>
* ---------------------------------
*/
export type RootProps = AxoBaseMenu.MenuRootProps &
Readonly<{
/**
* The modality of the dropdown menu. When set to `true`, interaction
* with outside elements will be disabled and only menu content will be
* visible to screen readers.
* Defaults to `true`.
*/
modal?: boolean;
/**
* The controlled open state of the dropdown menu.
* Must be used in conjunction with `onOpenChange`.
*/
open?: boolean;
}>;
/**
* Contains all the parts of a dropdown menu.
*
* @example Controlled open state
* ```tsx
* <AxoDropdownMenu.Root open={open} onOpenChange={setOpen}>
* <AxoDropdownMenu.Trigger>
* <AxoIconButton.Root variant="secondary" size="sm" symbol="more" label="More options" />
* </AxoDropdownMenu.Trigger>
* <AxoDropdownMenu.Content>
* <AxoDropdownMenu.Item symbol="bell-slash" onSelect={onMute}>Mute notifications</AxoDropdownMenu.Item>
* </AxoDropdownMenu.Content>
* </AxoDropdownMenu.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const { modal, onOpenChange } = props;
@@ -140,17 +156,15 @@ export namespace AxoDropdownMenu {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoDropdownMenu.Root';
/**
* Component: <AxoDropdownMenu.Trigger>
* ------------------------------------
* <AxoDropdownMenu.Trigger>
* --------------------------------------------------------------------------
*/
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
const triggerDisplayName = `${Namespace}.Trigger`;
/**
* The button that toggles the dropdown menu.
* By default, the {@link AxoDropdownMenu.Content} will position itself
@@ -164,15 +178,15 @@ export namespace AxoDropdownMenu {
if (isTestOrMockEnvironment()) {
assert(
ref.current instanceof HTMLElement,
`${triggerDisplayName} child must forward ref`
'<AxoDropdownMenu.Trigger> child must forward ref'
);
assert(
isAriaWidgetRole(getElementAriaRole(ref.current)),
`${triggerDisplayName} child must have a widget role like 'button'`
"<AxoDropdownMenu.Trigger> child must have a widget role like 'button'"
);
assert(
computeAccessibleName(ref.current) !== '',
`${triggerDisplayName} child must have an accessible name`
'<AxoDropdownMenu.Trigger> child must have an accessible name'
);
}
});
@@ -190,11 +204,11 @@ export namespace AxoDropdownMenu {
);
});
Trigger.displayName = triggerDisplayName;
Trigger.displayName = 'AxoDropdownMenu.Trigger';
/**
* Component: <AxoDropdownMenu.Content>
* ------------------------------------
* <AxoDropdownMenu.Content>
* --------------------------------------------------------------------------
*/
export type ContentProps = AxoBaseMenu.MenuContentProps;
@@ -229,11 +243,11 @@ export namespace AxoDropdownMenu {
);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoDropdownMenu.Content';
/**
* Component: <AxoDropdownMenu.CustomItem>
* -------------------------------------
* <AxoDropdownMenu.CustomItem>
* --------------------------------------------------------------------------
*/
export type CustomItemProps = Pick<
@@ -241,13 +255,27 @@ export namespace AxoDropdownMenu {
'disabled' | 'textValue' | 'keyboardShortcut' | 'onSelect'
> &
Readonly<{
/**
* Content of the "leading" slot, will be used to align with other
* leading items.
*/
leading?: ReactNode;
// trailing?: ReactNode;
/** The primary label text of the item. */
text: ReactNode;
// prefix?: ReactNode;
/**
* Content appended after the label in the content slot
* (e.g. a count badge or status indicator).
*/
suffix?: ReactNode;
// TODO(jamie): trailing?: ReactNode;
// TODO(jamie): prefix?: ReactNode;
}>;
/**
* A menu item with a fully customizable leading slot and content slot.
* Use when the built-in `Item` doesn't cover your layout needs.
*/
export const CustomItem: FC<CustomItemProps> = memo(props => {
return (
<DropdownMenu.Item
@@ -269,23 +297,17 @@ export namespace AxoDropdownMenu {
);
});
CustomItem.displayName = `${Namespace}.CustomItem`;
CustomItem.displayName = 'AxoDropdownMenu.CustomItem';
/**
* Component: <AxoDropdownMenu.Item>
* ---------------------------------
* <AxoDropdownMenu.Item>
* --------------------------------------------------------------------------
*/
export type ItemProps = AxoBaseMenu.MenuItemProps;
/**
* The component that contains the dropdown menu items.
* @example
* ```tsx
* <AxoDropdownMenu.Item icon={<svg/>}>
* {i18n("myContextMenuText")}
* </AxoContentMenu.Item>
* ````
* A single selectable row in the dropdown menu.
*/
export const Item: FC<ItemProps> = memo(props => {
return (
@@ -312,17 +334,17 @@ export namespace AxoDropdownMenu {
);
});
Item.displayName = `${Namespace}.Item`;
Item.displayName = 'AxoDropdownMenu.Item';
/**
* Component: <AxoDropdownMenu.Group>
* ----------------------------------
* <AxoDropdownMenu.Group>
* --------------------------------------------------------------------------
*/
export type GroupProps = AxoBaseMenu.MenuGroupProps;
/**
* Used to group multiple {@link AxoDropdownMenu.Item}'s.
* Group multiple {@link AxoDropdownMenu.Item}'s.
*/
export const Group: FC<GroupProps> = memo(props => {
return (
@@ -332,17 +354,17 @@ export namespace AxoDropdownMenu {
);
});
Group.displayName = `${Namespace}.Group`;
Group.displayName = 'AxoDropdownMenu.Group';
/**
* Component: <AxoDropdownMenu.Label>
* ----------------------------------
* <AxoDropdownMenu.Label>
* --------------------------------------------------------------------------
*/
export type LabelProps = AxoBaseMenu.MenuLabelProps;
/**
* Used to render a label. It won't be focusable using arrow keys.
* Render a label. It won't be focusable using arrow keys.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
@@ -354,24 +376,31 @@ export namespace AxoDropdownMenu {
);
});
Label.displayName = `${Namespace}.Label`;
Label.displayName = 'AxoDropdownMenu.Label';
/**
* Component: <AxoDropdownMenu.Header>
* -----------------------------------
* <AxoDropdownMenu.Header>
* --------------------------------------------------------------------------
*/
export type HeaderProps = Readonly<{
/** The primary title shown at the top of the content panel. */
label: ReactNode;
/** Optional secondary text below the title. */
description?: ReactNode;
}>;
/**
* A non-interactive header at the top of a `Content` or `SubContent` panel.
* Automatically registers itself as the accessible label and description
* for the containing menu.
*/
export const Header: FC<HeaderProps> = memo(props => {
const labelId = useId();
const descriptionId = useId();
const { labelRef, descriptionRef } = useAriaLabellingContext(
`${Namespace}.Content/SubContent`
'AxoDropdownMenu.Content/SubContent'
);
return (
@@ -396,11 +425,11 @@ export namespace AxoDropdownMenu {
);
});
Header.displayName = `${Namespace}.Header`;
Header.displayName = 'AxoDropdownMenu.Header';
/**
* Component: <AxoDropdownMenu.CheckboxItem>
* -----------------------------------------
* <AxoDropdownMenu.CheckboxItem>
* --------------------------------------------------------------------------
*/
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
@@ -442,17 +471,17 @@ export namespace AxoDropdownMenu {
);
});
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
CheckboxItem.displayName = 'AxoDropdownMenu.CheckboxItem';
/**
* Component: <AxoDropdownMenu.RadioGroup>
* ---------------------------------------
* <AxoDropdownMenu.RadioGroup>
* --------------------------------------------------------------------------
*/
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
/**
* Used to group multiple {@link AxoDropdownMenu.RadioItem}'s.
* Group multiple {@link AxoDropdownMenu.RadioItem}'s.
*/
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
return (
@@ -466,11 +495,11 @@ export namespace AxoDropdownMenu {
);
});
RadioGroup.displayName = `${Namespace}.RadioGroup`;
RadioGroup.displayName = 'AxoDropdownMenu.RadioGroup';
/**
* Component: <AxoDropdownMenu.RadioItem>
* --------------------------------------
* <AxoDropdownMenu.RadioItem>
* --------------------------------------------------------------------------
*/
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
@@ -511,15 +540,15 @@ export namespace AxoDropdownMenu {
);
});
RadioItem.displayName = `${Namespace}.RadioItem`;
RadioItem.displayName = 'AxoDropdownMenu.RadioItem';
/**
* Component: <AxoDropdownMenu.Separator>
* --------------------------------------
* <AxoDropdownMenu.Separator>
* --------------------------------------------------------------------------
*/
/**
* Used to visually separate items in the dropdown menu.
* Visually separate items in the dropdown menu.
*/
export const Separator: FC = memo(() => {
return (
@@ -527,12 +556,17 @@ export namespace AxoDropdownMenu {
);
});
Separator.displayName = `${Namespace}.Separator`;
Separator.displayName = 'AxoDropdownMenu.Separator';
/**
* Component: <AxoDropdownMenu.ContentSeparator>
* <AxoDropdownMenu.ContentSeparator>
* --------------------------------------------------------------------------
*/
/**
* Like `Separator`, but spans only the content column rather than the full
* row.
*/
export const ContentSeparator: FC = memo(() => {
return (
<DropdownMenu.Separator
@@ -541,11 +575,11 @@ export namespace AxoDropdownMenu {
);
});
ContentSeparator.displayName = `${Namespace}.ContentSeparator`;
ContentSeparator.displayName = 'AxoDropdownMenu.ContentSeparator';
/**
* Component: <AxoDropdownMenu.Sub>
* -------------------------------
* <AxoDropdownMenu.Sub>
* --------------------------------------------------------------------------
*/
export type SubProps = AxoBaseMenu.MenuSubProps;
@@ -557,18 +591,17 @@ export namespace AxoDropdownMenu {
return <DropdownMenu.Sub>{props.children}</DropdownMenu.Sub>;
});
Sub.displayName = `${Namespace}.Sub`;
Sub.displayName = 'AxoDropdownMenu.Sub';
/**
* Component: <AxoDropdownMenu.SubTrigger>
* ---------------------------------------
* <AxoDropdownMenu.SubTrigger>
* --------------------------------------------------------------------------
*/
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
/**
* An item that opens a submenu. Must be rendered inside
* {@link ContextMenu.Sub}.
* An item that opens a submenu. Must be rendered inside {@link AxoDropdownMenu.Sub}.
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
@@ -592,11 +625,11 @@ export namespace AxoDropdownMenu {
);
});
SubTrigger.displayName = `${Namespace}.SubTrigger`;
SubTrigger.displayName = 'AxoDropdownMenu.SubTrigger';
/**
* Component: <AxoDropdownMenu.SubContent>
* ---------------------------------------
* <AxoDropdownMenu.SubContent>
* --------------------------------------------------------------------------
*/
export type SubContentProps = AxoBaseMenu.MenuSubContentProps;
@@ -622,5 +655,5 @@ export namespace AxoDropdownMenu {
);
});
SubContent.displayName = `${Namespace}.SubContent`;
SubContent.displayName = 'AxoDropdownMenu.SubContent';
}
+3 -3
View File
@@ -135,8 +135,8 @@ export function Sizes(): JSX.Element {
const AllStates: Record<string, Partial<AxoIconButton.RootProps>> = {
'disabled=true': { disabled: true },
'aria-pressed=false': { 'aria-pressed': false },
'aria-pressed=true': { 'aria-pressed': true },
'pressed=false': { pressed: false },
'pressed=true': { pressed: true },
};
export function States(): JSX.Element {
@@ -211,7 +211,7 @@ export function Spinners(): JSX.Element {
size={size}
symbol="more"
label="More actions"
experimentalSpinner={{ 'aria-label': 'Loading' }}
pending
/>
</div>
);
+239 -141
View File
@@ -1,99 +1,117 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ButtonHTMLAttributes, FC, ForwardedRef, JSX } from 'react';
import { forwardRef, memo, useMemo } from 'react';
import type { FC, Ref, MouseEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import type { SpinnerVariant } from '../components/SpinnerV2.dom.tsx';
import { SpinnerV2 } from '../components/SpinnerV2.dom.tsx';
import { AxoTooltip } from './AxoTooltip.dom.tsx';
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const Namespace = 'AxoIconButton';
type GenericButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
/**
* 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 select-none',
'not-forced-colors:outline-none keyboard-mode:focus:outline-focus-ring',
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:disabled:text-[GrayText]',
'forced-colors:aria-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:enabled:active: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:enabled:active:bg-color-fill-primary-pressed',
'aria-pressed:text-label-primary-on-color aria-pressed:disabled:text-label-disabled-on-color'
),
};
const pressedInvertedStyles = tw(
'aria-pressed:bg-fill-inverted aria-pressed:not-aria-disabled:active:bg-fill-inverted-pressed',
'aria-pressed:text-label-primary-inverted aria-pressed:aria-disabled:text-label-disabled-inverted'
);
const Variants: Record<Variant, TailwindStyles> = {
const pressedPrimaryStyles = tw(
'aria-pressed:bg-color-fill-primary aria-pressed:not-aria-disabled:active:bg-color-fill-primary-pressed',
'aria-pressed:text-label-primary-on-color aria-pressed:aria-disabled:text-label-disabled-on-color'
);
const Variants = variants<Variant>('AxoIconButton.Variant', {
secondary: tw(
'bg-fill-secondary enabled:active:bg-fill-secondary-pressed',
'bg-fill-secondary not-aria-disabled:active:bg-fill-secondary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
'text-label-primary disabled:text-label-disabled',
pressedStyles.fillInverted
'text-label-primary aria-disabled:text-label-disabled',
pressedInvertedStyles
),
primary: tw(
'bg-color-fill-primary enabled:active:bg-color-fill-primary-pressed',
'bg-color-fill-primary not-aria-disabled:active:bg-color-fill-primary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-primary-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
pressedInvertedStyles
),
affirmative: tw(
'bg-color-fill-affirmative enabled:active:bg-color-fill-affirmative-pressed',
'bg-color-fill-affirmative not-aria-disabled:active:bg-color-fill-affirmative-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-affirmative-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
pressedInvertedStyles
),
destructive: tw(
'bg-color-fill-destructive enabled:active:bg-color-fill-destructive-pressed',
'bg-color-fill-destructive not-aria-disabled:active:bg-color-fill-destructive-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-destructive-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
'text-label-primary-on-color aria-disabled:text-label-disabled-on-color',
pressedInvertedStyles
),
'borderless-secondary': tw(
'enabled:hover:not-aria-pressed:bg-fill-secondary enabled:active:bg-fill-secondary-pressed',
'not-aria-disabled:hover:not-aria-pressed:bg-fill-secondary not-aria-disabled:active:bg-fill-secondary-pressed',
'focus:bg-fill-secondary',
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
'text-label-primary disabled:text-label-disabled',
pressedStyles.colorFillPrimary
'text-label-primary aria-disabled:text-label-disabled',
pressedPrimaryStyles
),
'floating-secondary': tw(
'bg-fill-floating enabled:active:bg-fill-floating-pressed',
'bg-fill-floating not-aria-disabled:active:bg-fill-floating-pressed',
'data-[axo-dropdownmenu-state=open]:bg-fill-floating-pressed',
'text-label-primary disabled:text-label-disabled',
'text-label-primary aria-disabled:text-label-disabled',
'shadow-elevation-1',
pressedStyles.fillInverted
pressedInvertedStyles
),
};
});
/** @testexport */
export function _getAllVariants(): ReadonlyArray<Variant> {
return Object.keys(Variants) as ReadonlyArray<Variant>;
return Variants.keys();
}
type SizeConfig = Readonly<{
buttonStyles: TailwindStyles;
iconSize: AxoSymbol.IconSize;
}>;
const Sizes = variants<Size>('AxoIconButton.Size', {
sm: tw('p-[5px]'),
md: tw('p-1.5'),
lg: tw('p-2'),
});
const Sizes: Record<Size, SizeConfig> = {
sm: { buttonStyles: tw('p-[5px]'), iconSize: 18 },
md: { buttonStyles: tw('p-1.5'), iconSize: 20 },
lg: { buttonStyles: tw('p-2'), iconSize: 20 },
};
const IconSizes = variants<Size, AxoSymbol.IconSize>('AxoIconButton.Size', {
sm: 18,
md: 20,
lg: 20,
});
/** @testexport */
export function _getAllSizes(): ReadonlyArray<Size> {
return Object.keys(Sizes) as ReadonlyArray<Size>;
return Sizes.keys();
}
/**
* Visual style of the button.
*/
export type Variant =
| 'secondary'
| 'primary'
@@ -102,128 +120,207 @@ export namespace AxoIconButton {
| 'borderless-secondary'
| 'floating-secondary';
/**
* Size of the button.
*/
export type Size = 'sm' | 'md' | 'lg';
export type RootProps = Readonly<{
// required: Should describe the purpose of the button, not the icon.
/**
* 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.IconName;
/**
* Stroke weight override for the icon.
*/
iconWeight?: AxoSymbol.Weight;
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
/**
* 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;
}>;
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const {
label,
tooltip = true,
variant,
size,
symbol,
iconWeight,
experimentalSpinner,
...rest
} = props;
/**
* A circular icon-only button.
* Wraps in a tooltip by default using `label`.
*
* @example Close button
* ```tsx
* <AxoIconButton.Root
* label="Close"
* variant="borderless-secondary"
* size="md"
* symbol="x"
* onClick={onClose}
* />
* ```
*
* @example Toggle mute button
* ```tsx
* <AxoIconButton.Root
* label={muted ? 'Unmute' : 'Mute'}
* variant="borderless-secondary"
* size="md"
* symbol={muted ? 'mic-slash' : 'mic'}
* pressed={muted}
* onClick={toggleMuted}
* />
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const {
label,
tooltip = true,
variant,
size,
symbol,
iconWeight,
pending,
pressed,
disabled,
onClick,
...rest
} = props;
const intl = useAxoIntl();
const tooltipConfig = useMemo(() => {
if (!tooltip) {
return null;
}
if (typeof tooltip === 'object') {
return tooltip;
}
return { label };
}, [tooltip, label]);
const tooltipConfig = useMemo(() => {
if (!tooltip) {
return null;
}
if (typeof tooltip === 'object') {
return tooltip;
}
return { label };
}, [tooltip, label]);
const button = (
<button
ref={ref}
{...rest}
aria-label={label}
type="button"
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (pending || disabled) {
event.preventDefault();
return;
}
onClick?.(event);
},
[pending, disabled, onClick]
);
const button = (
<button
ref={props.ref}
type="button"
aria-label={pending ? intl.get('AxoButton.Pending') : label}
aria-pressed={pressed ?? undefined}
aria-disabled={(pending || disabled) ?? undefined}
onClick={handleClick}
className={tw(baseStyles, Variants.get(variant), Sizes.get(size))}
{...rest}
>
<span
aria-hidden={pending ?? undefined}
className={tw(
baseStyles,
Variants[variant],
Sizes[size].buttonStyles
'align-top forced-color-adjust-none',
pending ? 'opacity-0' : null
)}
>
<span
className={tw(
'align-top forced-color-adjust-none',
experimentalSpinner != null ? 'opacity-0' : null
)}
>
<AxoSymbol.Icon
size={Sizes[size].iconSize}
symbol={symbol}
label={null}
weight={iconWeight}
/>
</span>
{experimentalSpinner != null && (
<Spinner
buttonVariant={variant}
buttonSize={size}
aria-label={experimentalSpinner['aria-label']}
/>
)}
</button>
<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>
);
}
if (tooltipConfig != null) {
return (
<AxoTooltip.Root
{...tooltipConfig}
tooltipRepeatsTriggerAccessibleName={label === tooltipConfig.label}
>
{button}
</AxoTooltip.Root>
);
}
return button;
});
return button;
})
Root.displayName = 'AxoIconButton.Root';
/**
* <AxoIconButton.Spinner>
* --------------------------------------------------------------------------
*/
const SpinnerVariants = variants<Variant, SpinnerVariant>(
'AxoIconButton.Variant',
{
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',
}
);
Root.displayName = `${Namespace}.Root`;
const SpinnerVariants: Record<Variant, SpinnerVariant> = {
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<Size, SpinnerSizeConfig> = {
const SpinnerSizes = variants<Size, SpinnerSizeConfig>('AxoIconButton.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;
'aria-label': string;
}>;
function Spinner(props: SpinnerProps): JSX.Element {
const variant = SpinnerVariants[props.buttonVariant];
const sizeConfig = SpinnerSizes[props.buttonSize];
/** @internal */
const Spinner: FC<SpinnerProps> = memo(props => {
const variant = SpinnerVariants.get(props.buttonVariant);
const sizeConfig = SpinnerSizes.get(props.buttonSize);
return (
<span className={tw('absolute inset-0 flex items-center justify-center')}>
<SpinnerV2
@@ -231,9 +328,10 @@ export namespace AxoIconButton {
strokeWidth={sizeConfig.strokeWidth}
variant={variant}
value="indeterminate"
ariaLabel={props['aria-label']}
/>
</span>
);
}
});
Spinner.displayName = 'AxoIconButton.Spinner';
}
+212 -30
View File
@@ -12,21 +12,95 @@ import {
useStrictContext,
} from './_internal/StrictContext.dom.tsx';
const Namespace = 'AxoMenuBuilder';
/**
* Create a menu that can either be a dropdown menu or context menu by passing
* a `renderer` prop.
*
* Use this when the same menu structure needs to work as both an
* `AxoDropdownMenu` and an `AxoContextMenu`.
*
* @example Anatomy
* ```tsx
* <AxoMenuBuilder.Root renderer={renderer}>
* <AxoMenuBuilder.Trigger />
* <AxoMenuBuilder.Content>
* <AxoMenuBuilder.Item />
* <AxoMenuBuilder.Separator />
* <AxoMenuBuilder.CheckboxItem />
* <AxoMenuBuilder.RadioGroup>
* <AxoMenuBuilder.RadioItem />
* </AxoMenuBuilder.RadioGroup>
* <AxoMenuBuilder.Sub>
* <AxoMenuBuilder.SubTrigger />
* <AxoMenuBuilder.SubContent />
* </AxoMenuBuilder.Sub>
* </AxoMenuBuilder.Content>
* </AxoMenuBuilder.Root>
* ```
*
* @see {AxoDropdownMenu}
* @see {AxoContextMenu}
* @see {@link https://www.radix-ui.com/primitives/docs/components/dropdown-menu | Dropdown Menu - Radix Docs}
* @see {@link https://www.radix-ui.com/primitives/docs/components/context-menu | Context Menu - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ | Menu Button Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#button | `button` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#menu | `menu` role - WAI-ARIA 1.3}
*/
export namespace AxoMenuBuilder {
/**
* <AxoMenuBuilder.Root>
* --------------------------------------------------------------------------
*/
/**
* Which menu primitive renders the component tree.
* - `AxoDropdownMenu`: Opened by clicking the `Trigger` element.
* - `AxoContextMenu`: Opened by right-clicking the `Trigger` element.
*/
export type Renderer = 'AxoDropdownMenu' | 'AxoContextMenu';
/**
* The horizontal alignment of the menu content relative to the trigger.
*/
export type Align = AxoBaseMenu.Align;
/**
* The preferred side of the trigger to render the menu content against.
*/
export type Side = AxoBaseMenu.Side;
const MenuBuilderContext = createStrictContext<Renderer>(`${Namespace}.Root`);
/** @internal */
const RendererContext = createStrictContext<Renderer>('AxoMenuBuilder.Root');
/**
* <AxoMenuBuilder.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = AxoBaseMenu.MenuRootProps &
Readonly<{
/**
* Which menu primitive to use.
* Passed down to all child components via context.
*/
renderer: Renderer;
}>;
/**
* Container for the menu. Sets the `renderer` in context so all child
* components delegate to the correct underlying primitive.
*
* @example Same menu as dropdown or context menu
* ```tsx
* <AxoMenuBuilder.Root renderer={renderer} onOpenChange={setOpen}>
* <AxoMenuBuilder.Trigger>{children}</AxoMenuBuilder.Trigger>
* <AxoMenuBuilder.Content>
* <AxoMenuBuilder.Item symbol="reply" onSelect={onReply}>Reply</AxoMenuBuilder.Item>
* <AxoMenuBuilder.Item symbol="copy" onSelect={onCopy}>Copy</AxoMenuBuilder.Item>
* </AxoMenuBuilder.Content>
* </AxoMenuBuilder.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const { renderer, ...rest } = props;
@@ -40,16 +114,25 @@ export namespace AxoMenuBuilder {
}
return (
<MenuBuilderContext.Provider value={props.renderer}>
<RendererContext.Provider value={props.renderer}>
{child}
</MenuBuilderContext.Provider>
</RendererContext.Provider>
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoMenuBuilder.Root';
/**
* <AxoMenuBuilder.Trigger>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Trigger` or `AxoContextMenu.Trigger` based
* on the active renderer.
*/
export const Trigger: FC<AxoBaseMenu.MenuTriggerProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Trigger {...props} />;
}
@@ -59,10 +142,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = 'AxoMenuBuilder.Trigger';
/**
* <AxoMenuBuilder.Content>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Content` or `AxoContextMenu.Content` based
* on the active renderer.
*/
export const Content: FC<AxoBaseMenu.MenuContentProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Content {...props} />;
}
@@ -72,10 +164,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoMenuBuilder.Content';
/**
* <AxoMenuBuilder.Item>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Item` or `AxoContextMenu.Item` based on the
* active renderer.
*/
export const Item: FC<AxoBaseMenu.MenuItemProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Item {...props} />;
}
@@ -85,10 +186,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Item.displayName = `${Namespace}.Item`;
Item.displayName = 'AxoMenuBuilder.Item';
/**
* <AxoMenuBuilder.Group>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Group` or `AxoContextMenu.Group` based on
* the active renderer.
*/
export const Group: FC<AxoBaseMenu.MenuGroupProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Group {...props} />;
}
@@ -98,10 +208,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Group.displayName = `${Namespace}.Group`;
Group.displayName = 'AxoMenuBuilder.Group';
/**
* <AxoMenuBuilder.Label>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Label` or `AxoContextMenu.Label` based on
* the active renderer.
*/
export const Label: FC<AxoBaseMenu.MenuLabelProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Label {...props} />;
}
@@ -111,10 +230,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Label.displayName = `${Namespace}.Label`;
Label.displayName = 'AxoMenuBuilder.Label';
/**
* <AxoMenuBuilder.Separator>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Separator` or `AxoContextMenu.Separator`
* based on the active renderer.
*/
export const Separator: FC = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Separator {...props} />;
}
@@ -124,11 +252,20 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Separator.displayName = `${Namespace}.Separator`;
Separator.displayName = 'AxoMenuBuilder.Separator';
/**
* <AxoMenuBuilder.CheckboxItem>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.CheckboxItem` or
* `AxoContextMenu.CheckboxItem` based on the active renderer.
*/
export const CheckboxItem: FC<AxoBaseMenu.MenuCheckboxItemProps> = memo(
props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.CheckboxItem {...props} />;
}
@@ -139,10 +276,19 @@ export namespace AxoMenuBuilder {
}
);
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
CheckboxItem.displayName = 'AxoMenuBuilder.CheckboxItem';
/**
* <AxoMenuBuilder.RadioGroup>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.RadioGroup` or `AxoContextMenu.RadioGroup`
* based on the active renderer.
*/
export const RadioGroup: FC<AxoBaseMenu.MenuRadioGroupProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioGroup {...props} />;
}
@@ -152,10 +298,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
RadioGroup.displayName = `${Namespace}.RadioGroup`;
RadioGroup.displayName = 'AxoMenuBuilder.RadioGroup';
/**
* <AxoMenuBuilder.RadioItem>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.RadioItem` or `AxoContextMenu.RadioItem`
* based on the active renderer.
*/
export const RadioItem: FC<AxoBaseMenu.MenuRadioItemProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioItem {...props} />;
}
@@ -165,10 +320,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
RadioItem.displayName = `${Namespace}.RadioItem`;
RadioItem.displayName = 'AxoMenuBuilder.RadioItem';
/**
* <AxoMenuBuilder.Sub>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.Sub` or `AxoContextMenu.Sub` based on the
* active renderer.
*/
export const Sub: FC<AxoBaseMenu.MenuSubProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Sub {...props} />;
}
@@ -178,10 +342,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
Sub.displayName = `${Namespace}.Sub`;
Sub.displayName = 'AxoMenuBuilder.Sub';
/**
* <AxoMenuBuilder.SubTrigger>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.SubTrigger` or `AxoContextMenu.SubTrigger`
* based on the active renderer.
*/
export const SubTrigger: FC<AxoBaseMenu.MenuSubTriggerProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubTrigger {...props} />;
}
@@ -191,10 +364,19 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
SubTrigger.displayName = `${Namespace}.SubTrigger`;
SubTrigger.displayName = 'AxoMenuBuilder.SubTrigger';
/**
* <AxoMenuBuilder.SubContent>
* --------------------------------------------------------------------------
*/
/**
* Delegates to `AxoDropdownMenu.SubContent` or `AxoContextMenu.SubContent`
* based on the active renderer.
*/
export const SubContent: FC<AxoBaseMenu.MenuSubContentProps> = memo(props => {
const renderer = useStrictContext(MenuBuilderContext);
const renderer = useStrictContext(RendererContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubContent {...props} />;
}
@@ -204,5 +386,5 @@ export namespace AxoMenuBuilder {
unreachable(renderer);
});
SubContent.displayName = `${Namespace}.SubContent`;
SubContent.displayName = 'AxoMenuBuilder.SubContent';
}
+14 -4
View File
@@ -4,14 +4,21 @@ import type { FC, ReactNode } from 'react';
import { memo, useInsertionEffect } from 'react';
import { Direction, Tooltip } from 'radix-ui';
import { createScrollbarGutterCssProperties } from './_internal/scrollbars.dom.tsx';
import { AxoIntl } from './_internal/AxoIntl.dom.tsx';
type AxoProviderProps = Readonly<{
export type AxoProviderProps = Readonly<{
/** Text direction for the application. */
dir: 'ltr' | 'rtl';
/** Localized strings used by Axo components. */
messages: AxoIntl.Messages;
children: ReactNode;
}>;
let runOnceGlobally = false;
/**
* Root provider for all Axo components.
*/
export const AxoProvider: FC<AxoProviderProps> = memo(props => {
useInsertionEffect(() => {
if (runOnceGlobally) {
@@ -26,10 +33,13 @@ export const AxoProvider: FC<AxoProviderProps> = memo(props => {
runOnceGlobally = false;
};
});
return (
<Direction.Provider dir={props.dir}>
<Tooltip.Provider>{props.children}</Tooltip.Provider>
</Direction.Provider>
<AxoIntl.Provider messages={props.messages}>
<Direction.Provider dir={props.dir}>
<Tooltip.Provider>{props.children}</Tooltip.Provider>
</Direction.Provider>
</AxoIntl.Provider>
);
});
+85 -21
View File
@@ -10,32 +10,72 @@ import {
useStrictContext,
} from './_internal/StrictContext.dom.tsx';
const Namespace = 'AxoRadioGroup';
/**
* A set of checkable buttons—known as radio buttons—where no more than one of
* the buttons can be checked at a time.
*
* @example Anatomy
* ```tsx
* <AxoRadioGroup.Root>
* <AxoRadioGroup.Item>
* <AxoRadioGroup.Indicator/>
* <AxoRadioGroup.Label>...</AxoRadioGroup.Label>
* <AxoRadioGroup.Root value={value} onValueChange={setValue}>
* <AxoRadioGroup.Item value="option-a">
* <AxoRadioGroup.Indicator />
* <AxoRadioGroup.Label>Option A</AxoRadioGroup.Label>
* </AxoRadioGroup.Item>
* </AxoAlertDialog.Root>
* </AxoRadioGroup.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/radio-group | Radio Group - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/radio/ | Radio Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#radiogroup | `radiogroup` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#radio | `radio` role - WAI-ARIA 1.3}
*/
export namespace AxoRadioGroup {
/**
* Component: <AxoRadioGroup.Root>
* -------------------------------
* <AxoRadioGroup.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/**
* The controlled value of the radio item to check.
* Should be used in conjunction with `onValueChange`.
*/
value: string | null;
/**
* Event handler called when the value changes.
*/
onValueChange: (value: string) => void;
/**
* When `true`, prevents the user from interacting with radio items.
*/
disabled?: boolean;
/**
* Should be `Item` elements.
*/
children: ReactNode;
}>;
/**
* Contains all the parts of a radio group.
*
* @example Notification preference
* ```tsx
* <AxoRadioGroup.Root value={notify} onValueChange={setNotify}>
* <AxoRadioGroup.Item value="all">
* <AxoRadioGroup.Indicator />
* <AxoRadioGroup.Label>All messages</AxoRadioGroup.Label>
* </AxoRadioGroup.Item>
* <AxoRadioGroup.Item value="mentions">
* <AxoRadioGroup.Indicator />
* <AxoRadioGroup.Label>Mentions only</AxoRadioGroup.Label>
* </AxoRadioGroup.Item>
* <AxoRadioGroup.Item value="off">
* <AxoRadioGroup.Indicator />
* <AxoRadioGroup.Label>Off</AxoRadioGroup.Label>
* </AxoRadioGroup.Item>
* </AxoRadioGroup.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<RadioGroup.Root
@@ -49,27 +89,42 @@ export namespace AxoRadioGroup {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoRadioGroup.Root';
/**
* Component: <AxoRadioGroup.Item>
* -------------------------------
* <AxoRadioGroup.Item>
* --------------------------------------------------------------------------
*/
/** @internal */
type ItemContextType = Readonly<{
id: string;
value: string;
disabled: boolean;
}>;
const ItemContext = createStrictContext<ItemContextType>(`${Namespace}.Item`);
/** @internal */
const ItemContext =
createStrictContext<ItemContextType>('AxoRadioGroup.Item');
export type ItemProps = Readonly<{
/**
* The value given as data when submitted with a name.
*/
value: string;
/**
* When true, prevents the user from interacting with the radio item.
*/
disabled?: boolean;
/**
* Should be an `Indicator` and a `Label`.
*/
children: ReactNode;
}>;
/**
* A single radio option.
*/
export const Item: FC<ItemProps> = memo(props => {
const { value, disabled = false } = props;
const id = useId();
@@ -87,13 +142,16 @@ export namespace AxoRadioGroup {
);
});
Item.displayName = `${Namespace}.Item`;
Item.displayName = 'AxoRadioGroup.Item';
/**
* Component: <AxoRadioGroup.Indicator>
* ------------------------------------
* <AxoRadioGroup.Indicator>
* --------------------------------------------------------------------------
*/
/**
* Renders when the radio item is in a checked state.
*/
export const Indicator: FC = memo(() => {
const context = useStrictContext(ItemContext);
return (
@@ -117,7 +175,7 @@ export namespace AxoRadioGroup {
<RadioGroup.Indicator asChild>
<span
className={tw(
'size-[9px] rounded-full',
'size-2.25 rounded-full',
'data-[state=checked]:bg-label-primary-on-color',
'data-[state=checked]:data-disabled:bg-label-disabled-on-color',
'forced-colors:data-[state=checked]:bg-[SelectedItemText]'
@@ -128,17 +186,23 @@ export namespace AxoRadioGroup {
);
});
Indicator.displayName = `${Namespace}.Indicator`;
Indicator.displayName = 'AxoRadioGroup.Indicator';
/**
* Component: <AxoRadioGroup.Indicator>
* ------------------------------------
* <AxoRadioGroup.Label>
* --------------------------------------------------------------------------
*/
export type LabelProps = Readonly<{
/**
* The visible text for this option.
*/
children: ReactNode;
}>;
/**
* Text label for a radio item.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
<span className={tw('truncate type-body-large text-label-primary')}>
@@ -147,5 +211,5 @@ export namespace AxoRadioGroup {
);
});
Label.displayName = `${Namespace}.Label`;
Label.displayName = 'AxoRadioGroup.Label';
}
+271 -145
View File
@@ -2,38 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { memo, useMemo, useState } from 'react';
import type { CSSProperties, FC, ReactNode } from 'react';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.tsx';
import { AxoTooltip } from './AxoTooltip.dom.tsx';
const Namespace = 'AxoScrollArea';
const AXO_SCROLL_AREA_TIMELINE_VERTICAL = '--axo-scroll-area-timeline-vertical';
const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL =
'--axo-scroll-area-timeline-horizontal';
type AxoScrollAreaOrientation = 'vertical' | 'horizontal' | 'both';
const AxoScrollAreaOrientationContext =
createStrictContext<AxoScrollAreaOrientation>(`${Namespace}.Root`);
function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
return useStrictContext(AxoScrollAreaOrientationContext);
}
import { variants } from './_internal/variants.dom.tsx';
/**
* Displays a menu located at the pointer, triggered by a right click or a long press.
*
* Note: For menus that are triggered by a normal button press, you should use
* `AxoDropdownMenu`.
* A scrollable container with configurable scrollbar styling, optional scroll
* hints (edge indicators), and an optional gradient mask.
*
* @example Anatomy
* ```tsx
* <AxoScrollArea.Root>
* <AxoScrollArea.Root scrollbarWidth="thin">
* <AxoScrollArea.Hint edge="top"/>
* <AxoScrollArea.Hint edge="bottom"/>
* <AxoScrollArea.Mask>
@@ -48,53 +31,123 @@ function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
*/
export namespace AxoScrollArea {
/**
* Context: ScrollAreaOrientation
* <AxoScrollArea.Root>
* --------------------------------------------------------------------------
*/
export type Orientation = AxoScrollAreaOrientation;
const AXO_SCROLL_AREA_TIMELINE_VERTICAL =
'--axo-scroll-area-timeline-vertical';
const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL =
'--axo-scroll-area-timeline-horizontal';
/**
* Context: ScrollAreaConfig
* Which directions the area scrolls:
* - `vertical`: Scrolls up/down (default).
* - `horizontal`: Scrolls left/right.
* - `both`: Scrolls in both axes.
*/
export type Orientation = 'vertical' | 'horizontal' | 'both';
/**
* Width of the native scrollbar track:
* - `wide`: Full-width system scrollbar.
* - `thin`: Narrow overlay-style scrollbar.
* - `none`: No scrollbar rendered (content still scrollable).
*/
export type ScrollbarWidth = 'wide' | 'thin' | 'none';
/**
* Space reserved for the scrollbar to prevent layout shifts.
* - `unstable`: No reserved space, layout shifts when scrollbar appears (default).
* - `stable-one-edge`: Reserved space on one side only.
* - `stable-both-edges`: Reserved space on both sides, keeps content centered.
*/
export type ScrollbarGutter =
| 'unstable'
| 'stable-one-edge'
| 'stable-both-edges';
/**
* Whether programmatic scrolling (e.g. `scrollIntoView`) is animated by default.
* - `auto`: Instant scroll (default).
* - `smooth`: Animated scroll.
*/
export type ScrollBehavior = 'auto' | 'smooth';
/**
* When the scrollbar thumb is visible.
* - `auto`: Always visible when content overflows (default).
* - `as-needed`: Fades out when the area is not hovered or focused.
*/
export type ScrollbarVisibility = 'auto' | 'as-needed';
type ScrollAreaConfig = Readonly<{
/** @internal */
type RootContextText = Readonly<{
orientation: Orientation;
scrollbarWidth: ScrollbarWidth;
scrollbarGutter: ScrollbarGutter;
scrollbarVisibility: ScrollbarVisibility;
scrollBehavior: ScrollBehavior;
}>;
const ScrollAreaConfigContext = createStrictContext<ScrollAreaConfig>(
`${Namespace}.Root`
);
/**
* Component: <AxoScrollArea.Root>
* -------------------------------
*/
/** @internal */
const RootContext =
createStrictContext<RootContextText>('AxoScrollArea.Root');
export type RootProps = Readonly<{
/**
* Which axis(es) the area scrolls. Defaults to `vertical`.
*/
orientation?: Orientation;
/**
* Constrains the width of the scroll area.
*/
maxWidth?: number;
/**
* Constrains the height of the scroll area.
* Use when the container is trying to fit the height of its children.
*/
maxHeight?: number;
/**
* Width of the native scrollbar track.
* Use when the container is trying to fit the width of its children.
*/
scrollbarWidth: ScrollbarWidth;
/**
* Space reserved for the scrollbar. Defaults to `stable-both-edges`.
*/
scrollbarGutter?: ScrollbarGutter;
/**
* When the scrollbar thumb is visible. Defaults to `auto`.
*/
scrollbarVisibility?: ScrollbarVisibility;
/**
* Whether programmatic scrolling is animated by default.
* Defaults to `auto`.
*/
scrollBehavior?: ScrollBehavior;
/**
* Should be `Hint`, `Mask`, and/or `Viewport` elements.
*/
children: ReactNode;
}>;
/**
* Container that configures the scroll area. Provides scroll settings to
* child `Viewport`, `Hint`, and `Mask` elements.
*
* @example Vertical scroll with thin scrollbar
* ```tsx
* <AxoScrollArea.Root scrollbarWidth="thin" scrollbarVisibility="as-needed" maxHeight={400}>
* <AxoScrollArea.Hint edge="top" />
* <AxoScrollArea.Hint edge="bottom" />
* <AxoScrollArea.Viewport>
* <AxoScrollArea.Content>{items}</AxoScrollArea.Content>
* </AxoScrollArea.Viewport>
* </AxoScrollArea.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const {
orientation = 'vertical',
@@ -106,14 +159,21 @@ export namespace AxoScrollArea {
scrollBehavior = 'auto',
} = props;
const config = useMemo((): ScrollAreaConfig => {
const context = useMemo((): RootContextText => {
return {
orientation,
scrollbarWidth,
scrollbarGutter,
scrollbarVisibility,
scrollBehavior,
};
}, [scrollbarWidth, scrollbarGutter, scrollbarVisibility, scrollBehavior]);
}, [
orientation,
scrollbarWidth,
scrollbarGutter,
scrollbarVisibility,
scrollBehavior,
]);
const style = useMemo((): CSSProperties => {
return {
@@ -126,31 +186,29 @@ export namespace AxoScrollArea {
}, [maxWidth, maxHeight]);
return (
<AxoScrollAreaOrientationContext.Provider value={orientation}>
<ScrollAreaConfigContext.Provider value={config}>
<div
className={tw(
'relative z-0',
'flex size-full flex-col overflow-hidden',
'rounded-[2px]',
// Move the outline from the viewport to the parent
// so it doesn't get cut off by <Mask>
'keyboard-mode:has-[[data-axo-scroll-area-viewport]:focus]:outline-focus-ring'
)}
style={style}
>
{props.children}
</div>
</ScrollAreaConfigContext.Provider>
</AxoScrollAreaOrientationContext.Provider>
<RootContext.Provider value={context}>
<div
className={tw(
'relative z-0',
'flex size-full flex-col overflow-hidden',
'rounded-[2px]',
// Move the outline from the viewport to the parent
// so it doesn't get cut off by <Mask>
'keyboard-mode:has-[[data-axo-scroll-area-viewport]:focus]:outline-focus-ring'
)}
style={style}
>
{props.children}
</div>
</RootContext.Provider>
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoScrollArea.Root';
/**
* Component: <AxoScrollArea.Viewport>
* -----------------------------------
* <AxoScrollArea.Viewport>
* --------------------------------------------------------------------------
*/
const baseViewportStyles = tw(
@@ -161,62 +219,79 @@ export namespace AxoScrollArea {
'outline-none'
);
const ViewportScrollbarWidths: Record<ScrollbarWidth, TailwindStyles> = {
wide: tw('scrollbar-width-auto'),
thin: tw('scrollbar-width-thin'),
none: tw('scrollbar-width-none'),
};
const ViewportScrollbarWidths = variants<ScrollbarWidth>(
'AxoScrollArea.ScrollbarWidth',
{
wide: tw('scrollbar-width-auto'),
thin: tw('scrollbar-width-thin'),
none: tw('scrollbar-width-none'),
}
);
const ViewportScrollbarGutters: Record<ScrollbarGutter, TailwindStyles> = {
unstable: tw('scrollbar-gutter-auto'),
'stable-one-edge': tw('scrollbar-gutter-stable'),
'stable-both-edges': tw('scrollbar-gutter-stable'),
};
const ViewportScrollbarGutters = variants<ScrollbarGutter>(
'AxoScrollArea.ScrollbarGutter',
{
unstable: tw('scrollbar-gutter-auto'),
'stable-one-edge': tw('scrollbar-gutter-stable'),
'stable-both-edges': tw('scrollbar-gutter-stable'),
}
);
const ViewportScrollbarVisibilities: Record<
ScrollbarVisibility,
TailwindStyles
> = {
auto: tw(),
'as-needed': tw(
'transition-[scrollbar-color] duration-150 not-hover:not-focus-within:scrollbar-thumb-transparent'
),
};
const ViewportScrollbarVisibilities = variants<ScrollbarVisibility>(
'AxoScrollArea.ScrollbarVisibility',
{
auto: tw(),
'as-needed': tw(
'transition-[scrollbar-color] duration-150 not-hover:not-focus-within:scrollbar-thumb-transparent'
),
}
);
const ViewportScrollBehaviors: Record<ScrollBehavior, TailwindStyles> = {
auto: tw('scroll-auto'),
smooth: tw('scroll-smooth'),
};
const ViewportScrollBehaviors = variants<ScrollBehavior>(
'AxoScrollArea.ScrollBehavior',
{
auto: tw('scroll-auto'),
smooth: tw('scroll-smooth'),
}
);
type GutterCss = { horizontal: string; vertical: string };
const ScrollbarWidthGutterVertical = variants<ScrollbarWidth, string>(
'AxoScrollArea.ScrollbarWidth',
{
wide: 'var(--axo-scrollbar-gutter-auto-vertical)',
thin: 'var(--axo-scrollbar-gutter-thin-vertical)',
none: '0px',
}
);
const ScrollbarWidthToGutterCss: Record<ScrollbarWidth, GutterCss> = {
wide: {
vertical: 'var(--axo-scrollbar-gutter-auto-vertical)',
horizontal: 'var(--axo-scrollbar-gutter-auto-horizontal)',
},
thin: {
vertical: 'var(--axo-scrollbar-gutter-thin-vertical)',
horizontal: 'var(--axo-scrollbar-gutter-thin-horizontal)',
},
none: {
vertical: '0px',
horizontal: '0px',
},
};
const ScrollbarWidthGutterHorizontal = variants<ScrollbarWidth, string>(
'AxoScrollArea.ScrollbarWidth',
{
wide: 'var(--axo-scrollbar-gutter-auto-horizontal)',
thin: 'var(--axo-scrollbar-gutter-thin-horizontal)',
none: '0px',
}
);
export type ViewportProps = Readonly<{
/**
* Should be a `Content` element.
*/
children: ReactNode;
}>;
/**
* The scrollable element.
* Must be placed inside `Root`, and should wrap a `Content`.
*/
export const Viewport: FC<ViewportProps> = memo(props => {
const orientation = useAxoScrollAreaOrientation();
const {
orientation,
scrollbarWidth,
scrollbarGutter,
scrollbarVisibility,
scrollBehavior,
} = useStrictContext(ScrollAreaConfigContext);
} = useStrictContext(RootContext);
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null);
const style = useMemo((): CSSProperties => {
@@ -230,11 +305,10 @@ export namespace AxoScrollArea {
let paddingInlineStart: string | undefined;
if (scrollbarGutter === 'stable-both-edges') {
if (hasVerticalScrollbar) {
paddingInlineStart =
ScrollbarWidthToGutterCss[scrollbarWidth].vertical;
paddingInlineStart = ScrollbarWidthGutterVertical.get(scrollbarWidth);
}
if (hasHorizontalScrollbar) {
paddingTop = ScrollbarWidthToGutterCss[scrollbarWidth].horizontal;
paddingTop = ScrollbarWidthGutterHorizontal.get(scrollbarWidth);
}
}
@@ -269,10 +343,10 @@ export namespace AxoScrollArea {
data-axo-scroll-area-viewport
className={tw(
baseViewportStyles,
ViewportScrollbarWidths[scrollbarWidth],
ViewportScrollbarGutters[scrollbarGutter],
ViewportScrollbarVisibilities[scrollbarVisibility],
ViewportScrollBehaviors[scrollBehavior]
ViewportScrollbarWidths.get(scrollbarWidth),
ViewportScrollbarGutters.get(scrollbarGutter),
ViewportScrollbarVisibilities.get(scrollbarVisibility),
ViewportScrollBehaviors.get(scrollBehavior)
)}
style={style}
>
@@ -282,14 +356,17 @@ export namespace AxoScrollArea {
);
});
Viewport.displayName = `${Namespace}.Viewport`;
Viewport.displayName = 'AxoScrollArea.Viewport';
/**
* Component: <AxoScrollArea.Content>
* ----------------------------------
* <AxoScrollArea.Content>
* --------------------------------------------------------------------------
*/
export type ContentProps = Readonly<{
/**
* The scrollable content.
*/
children: ReactNode;
}>;
@@ -307,17 +384,27 @@ export namespace AxoScrollArea {
'grow'
);
/**
* Wrapper for the content inside `Viewport`.
*
* Sizes itself to fit content while also filling available space.
*/
export const Content: FC<ContentProps> = memo(props => {
return <div className={contentStyles}>{props.children}</div>;
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoScrollArea.Content';
/**
* Component: <AxoScrollArea.Hint>
* -------------------------------
* <AxoScrollArea.Hint>
* --------------------------------------------------------------------------
*/
/**
* Which edge of the scroll area the hint appears on.
* - `top` / `bottom`: For vertically scrollable areas.
* - `inline-start` / `inline-end`: For horizontally scrollable areas.
*/
export type Edge = 'top' | 'bottom' | 'inline-start' | 'inline-end';
const edgeStyles = tw(
@@ -335,47 +422,57 @@ export namespace AxoScrollArea {
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<Edge, TailwindStyles> = {
const HintEdges = variants<Edge>('AxoScrollArea.Edge', {
top: tw(
edgeStyles,
edgeYStyles,
edgeStartStyles,
'top-0',
'bg-linear-to-b'
tw(edgeStyles, edgeYStyles, edgeStartStyles),
'top-0 bg-linear-to-b'
),
bottom: tw(
edgeStyles,
edgeYStyles,
edgeEndStyles,
'bottom-0',
'bg-linear-to-t'
tw(edgeStyles, edgeYStyles, edgeEndStyles),
'bottom-0 bg-linear-to-t'
),
'inline-start': tw(
edgeStyles,
edgeXStyles,
edgeStartStyles,
'inset-s-0',
'bg-linear-to-r rtl:bg-linear-to-l'
tw(edgeStyles, edgeXStyles, edgeStartStyles),
'inset-s-0 bg-linear-to-r rtl:bg-linear-to-l'
),
'inline-end': tw(
edgeStyles,
edgeXStyles,
edgeEndStyles,
'inset-e-0',
'bg-linear-to-l rtl:bg-linear-to-r'
tw(edgeStyles, edgeXStyles, edgeEndStyles),
'inset-e-0 bg-linear-to-l rtl:bg-linear-to-r'
),
};
});
export type HintProps = Readonly<{
/**
* Scroll offset (px) at which the hint becomes fully visible.
* Defaults to `1` (appears as soon as the user has scrolled 1px).
*/
animationStartOffset?: number;
/**
* Scroll offset (px) from the end at which the hint starts to fade out.
* Defaults to `20`.
*/
animationEndOffset?: number;
/**
* Which edge of the scroll area to show the hint on.
*/
edge: Edge;
}>;
/**
* A thin gradient line that fades in at a scroll edge to signal there is
* more content in that direction.
*
* Place inside `Root`, outside `Viewport`.
*
* @example
* ```tsx
* <AxoScrollArea.Hint edge="top" />
* <AxoScrollArea.Hint edge="bottom" />
* ```
*/
export const Hint: FC<HintProps> = memo(props => {
const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props;
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
const { orientation, scrollbarWidth } = useStrictContext(RootContext);
const style = useMemo((): CSSProperties => {
const isVerticalEdge = edge === 'top' || edge === 'bottom';
@@ -384,11 +481,11 @@ export namespace AxoScrollArea {
return {
insetInlineEnd:
edge !== 'inline-start' && orientation === 'both'
? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal
? ScrollbarWidthGutterHorizontal.get(scrollbarWidth)
: undefined,
bottom:
edge !== 'top' && orientation === 'both'
? ScrollbarWidthToGutterCss[scrollbarWidth].vertical
? ScrollbarWidthGutterVertical.get(scrollbarWidth)
: undefined,
animationTimeline: isVerticalEdge
? AXO_SCROLL_AREA_TIMELINE_VERTICAL
@@ -408,31 +505,61 @@ export namespace AxoScrollArea {
animationEndOffset,
]);
return <div className={HintEdges[edge]} style={style} />;
return <div className={HintEdges.get(edge)} style={style} />;
});
Hint.displayName = `${Namespace}.Hint`;
Hint.displayName = 'AxoScrollArea.Hint';
/**
* Component: <AxoScrollArea.Mask>
* -------------------------------
* <AxoScrollArea.Mask>
* --------------------------------------------------------------------------
*/
export type MaskProps = Readonly<{
/**
* Fully-transparent (clipped) zone at the start edge in px.
* Defaults to `0`.
*/
maskStart?: number;
/**
* Gradient blend zone at each masked edge in px.
* Defaults to `4`.
*/
maskPadding?: number;
/**
* Fade-out zone at the scrollable end edge in px.
* Defaults to `40`.
*/
maskEnd?: number;
/**
* Scroll offset at which the start-edge mask begins animating in.
* Defaults to `maskStart`.
*/
animationStart?: number;
/**
* Blend zone used during animation.
* Defaults to `maskPadding`.
*/
animationPadding?: number;
/**
* Scroll offset at which the end-edge mask is fully visible.
* Defaults to `maskEnd * 3`.
*/
animationEnd?: number;
/**
* Should be a `Viewport` element.
*/
children: ReactNode;
}>;
// These styles are very complex so they are in a separate CSS file
const AXO_MASK_CLASS_NAME = 'axo-scroll-area-mask';
/**
* Applies a gradient fade mask at the scroll edges so content appears to
* dissolve rather than abruptly clip. The mask animates in/out based on
* scroll position. Wrap `Viewport` with this when a fade effect is needed.
*/
export const Mask: FC<MaskProps> = memo(props => {
const {
maskStart = 0,
@@ -443,18 +570,17 @@ export namespace AxoScrollArea {
animationEnd = maskEnd * 3,
} = props;
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
const { orientation, scrollbarWidth } = useStrictContext(RootContext);
const style = useMemo(() => {
const hasVerticalScrollbar = orientation !== 'horizontal';
const hasHorizontalScrollbar = orientation !== 'vertical';
const verticalGutter = hasVerticalScrollbar
? ScrollbarWidthToGutterCss[scrollbarWidth].vertical
? ScrollbarWidthGutterVertical.get(scrollbarWidth)
: '0px';
const horizontalGutter = hasHorizontalScrollbar
? ScrollbarWidthToGutterCss[scrollbarWidth].horizontal
? ScrollbarWidthGutterHorizontal.get(scrollbarWidth)
: '0px';
return {
@@ -491,5 +617,5 @@ export namespace AxoScrollArea {
);
});
Mask.displayName = `${Namespace}.Mask`;
Mask.displayName = 'AxoScrollArea.Mask';
}
+3 -3
View File
@@ -46,7 +46,7 @@ function Template(props: {
value={42}
max={99}
maxDisplay="99+"
aria-label={null}
label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
@@ -60,7 +60,7 @@ function Template(props: {
value="mention"
max={99}
maxDisplay="99+"
aria-label={null}
label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
@@ -73,7 +73,7 @@ function Template(props: {
value="unread"
max={99}
maxDisplay="99+"
aria-label={null}
label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
+135 -44
View File
@@ -1,13 +1,14 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import type { FC, Ref, ReactNode } from 'react';
import { memo, useCallback } from 'react';
import { ToggleGroup } from 'radix-ui';
import { ExperimentalAxoBaseSegmentedControl } from './_internal/AxoBaseSegmentedControl.dom.tsx';
const Namespace = 'AxoSegmentedControl';
/**
* A row of mutually-exclusive buttons, used for tab-style navigation or
* selecting a single option from a small set.
*
* @example Anatomy
* ```tsx
* <AxoSegmentedControl.Root>
@@ -17,28 +18,87 @@ const Namespace = 'AxoSegmentedControl';
* </AxoSegmentedControl.Item>
* </AxoSegmentedControl.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/toggle-group | Toggle Group - Radix Docs}
* @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}
* @see {@link https://w3c.github.io/aria/#aria-pressed | `aria-pressed` state - WAI-ARIA 1.3}
*/
export namespace ExperimentalAxoSegmentedControl {
/**
* <AxoSegmentedControl.Root>
* --------------------------------------------------------------------------
*/
/**
* Visual style of the control.
*/
export type Variant = ExperimentalAxoBaseSegmentedControl.Variant;
/**
* Component: <AxoSegmentedControl.Root>
* -------------------------------------
* Width of the entire control:
* - `fit`: Shrinks to fit the combined width of all items.
* - `full`: Stretches to fill the container.
*/
export type RootWidth = ExperimentalAxoBaseSegmentedControl.RootWidth;
/**
* How each item is sized within the control:
* - `fit`: Each item shrinks to fit its content.
* - `equal`: All items share equal width.
*/
export type ItemWidth = ExperimentalAxoBaseSegmentedControl.ItemWidth;
export type RootProps = Readonly<{
/**
* Width of the entire control.
*/
width: RootWidth;
/**
* How each item is sized within the control.
*/
itemWidth: ItemWidth;
/**
* Visual style of the control.
*/
variant: Variant;
/**
* The controlled value of the pressed item.
* Must be used in conjunction with `onValueChange`.
*/
value: string | null;
/**
* Event handler called when the pressed state of an item changes.
*/
onValueChange: (newValue: string | null) => void;
/**
* Should be `Item` elements.
*/
children: ReactNode;
}>;
export const Root = memo((props: RootProps) => {
/**
* Container for the segmented control.
*
* @example Tab-style navigation
* ```tsx
* <AxoSegmentedControl.Root
* variant="no-track"
* width="full"
* itemWidth="equal"
* value={tab}
* onValueChange={setTab}
* >
* <AxoSegmentedControl.Item value="media">
* <AxoSegmentedControl.ItemText>Media</AxoSegmentedControl.ItemText>
* </AxoSegmentedControl.Item>
* <AxoSegmentedControl.Item value="audio">
* <AxoSegmentedControl.ItemText>Audio</AxoSegmentedControl.ItemText>
* </AxoSegmentedControl.Item>
* </AxoSegmentedControl.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const { onValueChange } = props;
const handleValueChange = useCallback(
@@ -70,45 +130,65 @@ export namespace ExperimentalAxoSegmentedControl {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoSegmentedControl.Root';
/**
* Component: <AxoSegmentedControl.Item>
* -------------------------------------
* <AxoSegmentedControl.Item>
* --------------------------------------------------------------------------
*/
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
value: string;
children: ReactNode;
}>;
export const Item: FC<ItemProps> = memo(
forwardRef((props: ItemProps, ref: ForwardedRef<HTMLButtonElement>) => {
const { value, children, ...rest } = props;
return (
<ToggleGroup.Item {...rest} ref={ref} value={value} asChild>
<ExperimentalAxoBaseSegmentedControl.Item value={value}>
{children}
</ExperimentalAxoBaseSegmentedControl.Item>
</ToggleGroup.Item>
);
})
);
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoSegmentedControl.ItemText>
* -----------------------------------------
*/
export type ItemTextProps = Readonly<{
maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
export type ItemProps = Readonly<{
/**
* Ref to the underlying `<button>` element.
*/
ref?: Ref<HTMLButtonElement>;
/**
* A unique value for the item.
*/
value: string;
/**
* Should be an `ItemText`, optionally followed by an `ExperimentalItemBadge`.
*/
children: ReactNode;
}>;
export const ItemText: FC<ItemTextProps> = memo((props: ItemTextProps) => {
/**
* An item in the group.
*/
export const Item: FC<ItemProps> = memo(props => {
const { value, children, ...rest } = props;
return (
<ToggleGroup.Item asChild ref={props.ref} value={value} {...rest}>
<ExperimentalAxoBaseSegmentedControl.Item value={value}>
{children}
</ExperimentalAxoBaseSegmentedControl.Item>
</ToggleGroup.Item>
);
});
Item.displayName = 'AxoSegmentedControl.Item';
/**
* <AxoSegmentedControl.ItemText>
* --------------------------------------------------------------------------
*/
export type ItemTextProps = Readonly<{
/**
* CSS `max-width` to apply to the text, e.g. `"12ch"` to prevent long
* labels from stretching the control.
*/
maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
/**
* The visible label for this item.
*/
children: ReactNode;
}>;
/**
* The text label inside an `Item`.
*/
export const ItemText: FC<ItemTextProps> = memo(props => {
return (
<ExperimentalAxoBaseSegmentedControl.ItemText maxWidth={props.maxWidth}>
{props.children}
@@ -116,15 +196,26 @@ export namespace ExperimentalAxoSegmentedControl {
);
});
ItemText.displayName = `${Namespace}.ItemText`;
ItemText.displayName = 'AxoSegmentedControl.ItemText';
/**
* Component: <AxoSegmentedControl.ItemBadge>
* ------------------------------------------
* <AxoSegmentedControl.ItemBadge>
* --------------------------------------------------------------------------
*/
export type ExperimentalItemBadgeProps =
ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadgeProps;
export const { ExperimentalItemBadge } = ExperimentalAxoBaseSegmentedControl;
/**
* A badge shown on an item, typically for unread counts.
*/
export const ExperimentalItemBadge: FC<ExperimentalItemBadgeProps> = memo(
props => {
return (
<ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadge {...props} />
);
}
);
ExperimentalItemBadge.displayName = 'AxoSegmentedControl.ItemBadge';
}
+223 -91
View File
@@ -5,59 +5,107 @@ import type { FC, ReactNode } from 'react';
import { Select } from 'radix-ui';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.tsx';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import { ExperimentalAxoBadge } from './AxoBadge.dom.tsx';
import { AxoTheme } from './AxoTheme.dom.tsx';
const Namespace = 'AxoSelect';
import { variants } from './_internal/variants.dom.tsx';
/**
* Displays a list of options for the user to pick from—triggered by a button.
*
* @example Anatomy
* ```tsx
* export default () => (
* <AxoSelect.Root>
* <AxoSelect.Trigger/>
* <AxoSelect.Content>
* <AxoSelect.Item>
* <AxoSelect.ItemText/>
* <AxoSelect.ItemBadge/>
* <AxoSelect.Root value={value} onValueChange={setValue}>
* <AxoSelect.Trigger placeholder="Choose…" />
* <AxoSelect.Content>
* <AxoSelect.Item value="a">
* <AxoSelect.ItemText>Option A</AxoSelect.ItemText>
* </AxoSelect.Item>
* <AxoSelect.Separator />
* <AxoSelect.Group>
* <AxoSelect.Label>Group</AxoSelect.Label>
* <AxoSelect.Item value="b">
* <AxoSelect.ItemText>Option B</AxoSelect.ItemText>
* </AxoSelect.Item>
* <AxoSelect.Separator/>
* <AxoSelect.Group>
* <AxoSelect.Label/>
* <AxoSelect.Item>
* <AxoSelect.ItemText/>
* </AxoSelect.Item>
* </AxoSelect.Group>
* </AxoSelect.Content>
* </AxoSelect.Root>
* );
* </AxoSelect.Group>
* </AxoSelect.Content>
* </AxoSelect.Root>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/select | Select - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ | Button Pattern - ARIA Authoring Practices Guide}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ | Select-Only Combobox Example - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#listbox | `listbox` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#select | `select` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#combobox | `combobox` role - WAI-ARIA 1.3}
*/
export namespace AxoSelect {
/**
* Component: <AxoSelect.Root>
* ---------------------------
* <AxoSelect.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/**
* The name of the select. Submitted with its owning form as part of a
* name/value pair.
*/
name?: string;
/**
* Associates the select with a `<form>` element by its id.
*/
form?: string;
/**
* Browser autocomplete hint (e.g. `"country"`, `"language"`).
*/
autoComplete?: string;
/**
* When `true`, prevents the user from interacting with select.
*/
disabled?: boolean;
/**
* When `true`, indicates that the user must select a value before the
* owning form can be submitted.
*/
required?: boolean;
/**
* The controlled open state of the select.
* Must be used in conjunction with `onOpenChange`.
*/
open?: boolean;
/**
* Event handler called when the open state of the select changes.
*/
onOpenChange?: (open: boolean) => void;
/**
* The controlled value of the select.
* Should be used in conjunction with `onValueChange`.
*/
value: string | null;
/**
* Event handler called when the value changes.
*/
onValueChange: (value: string) => void;
/**
* Should be a `Trigger` and a `Content`.
*/
children: ReactNode;
}>;
/**
* Contains all the parts of a select.
*
* @example Notification sound picker
* ```tsx
* <AxoSelect.Root value={sound} onValueChange={setSound}>
* <AxoSelect.Trigger placeholder="Choose a sound" />
* <AxoSelect.Content>
* <AxoSelect.Item value="note"><AxoSelect.ItemText>Note</AxoSelect.ItemText></AxoSelect.Item>
* <AxoSelect.Item value="chord"><AxoSelect.ItemText>Chord</AxoSelect.ItemText></AxoSelect.Item>
* <AxoSelect.Item value="none"><AxoSelect.ItemText>None</AxoSelect.ItemText></AxoSelect.Item>
* </AxoSelect.Content>
* </AxoSelect.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
@@ -77,15 +125,33 @@ export namespace AxoSelect {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoSelect.Root';
/**
* Component: <AxoSelect.Trigger>
* ---------------------------
* <AxoSelect.Trigger>
* --------------------------------------------------------------------------
*/
/**
* Visual style of the trigger button.
* - `default`: Filled secondary background (default).
* - `floating`: Elevated with a drop shadow.
* - `borderless`: Transparent, shows a background only on hover.
*/
export type TriggerVariant = 'default' | 'floating' | 'borderless';
/**
* Width of the trigger button.
* - `fit`: Shrinks to fit the selected value (default).
* - `full`: Stretches to fill the container.
*/
export type TriggerWidth = 'fit' | 'full';
/**
* When the dropdown chevron is shown.
* - `always`: Always visible (default).
* - `on-hover`: Only visible on hover/focus.
*/
export type TriggerChevron = 'always' | 'on-hover';
const baseTriggerStyles = tw(
@@ -96,7 +162,7 @@ export namespace AxoSelect {
'forced-colors:border'
);
const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
const TriggerVariants = variants<TriggerVariant>('AxoSelect.TriggerVariant', {
default: tw(
baseTriggerStyles,
'bg-fill-secondary',
@@ -114,33 +180,33 @@ export namespace AxoSelect {
'enabled:hover:bg-fill-secondary',
'enabled:active:bg-fill-secondary-pressed'
),
};
});
const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
const TriggerWidths = variants<TriggerWidth>('AxoSelect.TriggerWidth', {
fit: tw(),
full: tw('w-full'),
};
});
type TriggerChevronConfig = {
chevronStyles: TailwindStyles;
contentStyles: TailwindStyles;
};
const baseContentStyles = tw('flex min-w-0 flex-1');
const TriggerChevrons: Record<TriggerChevron, TriggerChevronConfig> = {
always: {
chevronStyles: tw('ps-2 pe-2.5'),
contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'),
},
'on-hover': {
chevronStyles: tw(
const TriggerChevronStyles = variants<TriggerChevron>(
'AxoSelect.TriggerChevron',
{
always: tw('ps-2 pe-2.5'),
'on-hover': tw(
'absolute inset-y-0 inset-e-0 w-9.5',
'flex items-center justify-end pe-2',
'opacity-0 group-enabled:group-hover:opacity-100 group-enabled:group-focus:opacity-100 group-data-[state=open]:opacity-100',
'transition-opacity duration-150'
),
contentStyles: tw(
}
);
const baseContentStyles = tw('flex min-w-0 flex-1');
const TriggerChevronContentStyles = variants<TriggerChevron>(
'AxoSelect.TriggerChevron',
{
always: tw(baseContentStyles, 'py-[5px] ps-3'),
'on-hover': tw(
baseContentStyles,
'px-3 py-[5px]',
'[--axo-select-trigger-mask-start:black]',
@@ -153,14 +219,30 @@ export namespace AxoSelect {
'mask-right rtl:mask-left',
'[transition-property:--axo-select-trigger-mask-start] duration-150'
),
},
};
}
);
export type TriggerProps = Readonly<{
/**
* Visual style of the button. Defaults to `default`.
*/
variant?: TriggerVariant;
/**
* Width of the button. Defaults to `fit`.
*/
width?: TriggerWidth;
/**
* When to show the dropdown chevron. Defaults to `always`.
*/
chevron?: TriggerChevron;
/**
* The content that will be rendered inside the `Trigger` when `value` is
* `null`.
*/
placeholder: string;
/**
* Custom rendering of the selected value inside the trigger.
*/
children?: ReactNode;
}>;
@@ -173,32 +255,37 @@ export namespace AxoSelect {
const variant = props.variant ?? 'default';
const width = props.width ?? 'fit';
const chevron = props.chevron ?? 'always';
const variantStyles = TriggerVariants[variant];
const widthStyles = TriggerWidths[width];
const chevronConfig = TriggerChevrons[chevron];
return (
<Select.Trigger className={tw(variantStyles, widthStyles)}>
<div className={chevronConfig.contentStyles}>
<Select.Trigger
className={tw(TriggerVariants.get(variant), TriggerWidths.get(width))}
>
<div className={TriggerChevronContentStyles.get(chevron)}>
<AxoBaseMenu.ItemText>
<Select.Value placeholder={props.placeholder}>
{props.children}
</Select.Value>
</AxoBaseMenu.ItemText>
</div>
<Select.Icon className={chevronConfig.chevronStyles}>
<Select.Icon className={TriggerChevronStyles.get(chevron)}>
<AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
</Select.Icon>
</Select.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = 'AxoSelect.Trigger';
/**
* Component: <AxoSelect.Content>
* ------------------------------
* <AxoSelect.Content>
* --------------------------------------------------------------------------
*/
/**
* Positioning strategy for the dropdown popup.
* - `item-aligned`: Overlays the list on the trigger, with the selected item
* aligned to the trigger position (default).
* - `dropdown`: Drops down from below the trigger like a standard dropdown.
*/
export type ContentPosition = 'item-aligned' | 'dropdown';
type ContentPositionConfig = {
@@ -208,20 +295,29 @@ export namespace AxoSelect {
sideOffset?: Select.SelectContentProps['sideOffset'];
};
const ContentPositions: Record<ContentPosition, ContentPositionConfig> = {
'item-aligned': {
position: 'item-aligned',
},
dropdown: {
position: 'popper',
alignOffset: 0,
collisionPadding: 6,
sideOffset: 8,
},
};
const ContentPositions = variants<ContentPosition, ContentPositionConfig>(
'AxoSelect.ContentPosition',
{
'item-aligned': {
position: 'item-aligned',
},
dropdown: {
position: 'popper',
alignOffset: 0,
collisionPadding: 6,
sideOffset: 8,
},
}
);
export type ContentProps = Readonly<{
/**
* Positioning strategy for the popup. Defaults to `item-aligned`.
*/
position?: ContentPosition;
/**
* Should be `Item`, `Group`, `Label`, and/or `Separator` elements.
*/
children: ReactNode;
}>;
@@ -231,7 +327,7 @@ export namespace AxoSelect {
*/
export const Content: FC<ContentProps> = memo(props => {
const position = props.position ?? 'item-aligned';
const positionConfig = ContentPositions[position];
const positionConfig = ContentPositions.get(position);
return (
<Select.Portal>
<AxoTheme.Inherit>
@@ -269,18 +365,38 @@ export namespace AxoSelect {
);
});
Content.displayName = `${Namespace}.Content`;
Content.displayName = 'AxoSelect.Content';
/**
* Component: <AxoSelect.Item>
* ---------------------------
* <AxoSelect.Item>
* --------------------------------------------------------------------------
*/
export type ItemProps = Readonly<{
/**
* The value given as data when submitted with a name.
*/
value: string;
/**
* When `true`, prevents the user from interacting with the item.
*/
disabled?: boolean;
/**
* Optional text used for typeahead purposes.
* By default the typeahead behavior will use the `.textContent` of the
* `ItemText` part. Use this when the content is complex, or you have
* non-textual content inside.
*/
textValue?: string;
/**
* Optional leading icon. If one item has an icon, prefer to give all items
* an icon.
*/
symbol?: AxoSymbol.IconName;
/**
* Should be an `ItemText`, optionally followed by an
* `ExperimentalItemBadge`.
*/
children: ReactNode;
}>;
@@ -314,16 +430,23 @@ export namespace AxoSelect {
);
});
Item.displayName = `${Namespace}.Content`;
Item.displayName = 'AxoSelect.Content';
/**
* Component: <AxoSelect.ItemText>
* <AxoSelect.ItemText>
* --------------------------------------------------------------------------
*/
export type ItemTextProps = Readonly<{
/** The visible label for this item. Also shown inside the trigger when selected. */
children: ReactNode;
}>;
/**
* The textual part of the item. It should only contain the text you want to
* see in the trigger when that item is selected. It should not be styled to
* ensure correct positioning.
*/
export const ItemText: FC<ItemTextProps> = memo(props => {
return (
<AxoBaseMenu.ItemText>
@@ -332,11 +455,11 @@ export namespace AxoSelect {
);
});
ItemText.displayName = `${Namespace}.ItemText`;
ItemText.displayName = 'AxoSelect.ItemText';
/**
* Component: <AxoSelect.ItemBadge>
* --------------------------------
* <AxoSelect.ItemBadge>
* --------------------------------------------------------------------------
*/
export type ExperimentalItemBadgeProps = Omit<
@@ -344,6 +467,9 @@ export namespace AxoSelect {
'size'
>;
/**
* A badge shown at the trailing edge of an item, typically for unread counts.
*/
export const ExperimentalItemBadge = memo(
(props: ExperimentalItemBadgeProps) => {
return (
@@ -353,28 +479,31 @@ export namespace AxoSelect {
value={props.value}
max={props.max}
maxDisplay={props.maxDisplay}
aria-label={props['aria-label']}
label={props.label}
/>
</span>
);
}
);
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
ExperimentalItemBadge.displayName = 'AxoSelect.ItemBadge';
/**
* Component: <AxoSelect.Group>
* ----------------------------
* <AxoSelect.Group>
* --------------------------------------------------------------------------
*/
export type GroupProps = Readonly<{
/**
* Should be a `Label` followed by one or more `Item` elements.
*/
children: ReactNode;
}>;
/**
* Used to group multiple items.
* Use in conjunction with {@link AxoSelect.Label to ensure good accessibility
* via automatic labelling.
* Group multiple items.
* Use in conjunction with {@link AxoSelect.Label} to ensure good
* accessibility via automatic labelling.
*/
export const Group: FC<GroupProps> = memo(props => {
return (
@@ -384,19 +513,22 @@ export namespace AxoSelect {
);
});
Group.displayName = `${Namespace}.Group`;
Group.displayName = 'AxoSelect.Group';
/**
* Component: <AxoSelect.Label>
* ---------------------------
* <AxoSelect.Label>
* --------------------------------------------------------------------------
*/
export type LabelProps = Readonly<{
/**
* The text label for the group.
*/
children: ReactNode;
}>;
/**
* Used to render the label of a group. It won't be focusable using arrow keys.
* Render the label of a group. It won't be focusable using arrow keys.
*/
export const Label: FC<LabelProps> = memo(props => {
return (
@@ -408,19 +540,19 @@ export namespace AxoSelect {
);
});
Label.displayName = `${Namespace}.Label`;
Label.displayName = 'AxoSelect.Label';
/**
* Component: <AxoSelect.Separator>
* ---------------------------
* <AxoSelect.Separator>
* --------------------------------------------------------------------------
*/
/**
* Used to visually separate items in the select.
* Visually separate items in the select.
*/
export const Separator: FC = memo(() => {
return <Select.Separator className={AxoBaseMenu.selectSeperatorStyles} />;
});
Separator.displayName = `${Namespace}.Separator`;
Separator.displayName = 'AxoSelect.Separator';
}
+38 -4
View File
@@ -1,21 +1,55 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
import { memo } from 'react';
import { Switch } from 'radix-ui';
import { tw } from './tw.dom.tsx';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
const Namespace = 'AxoSwitch';
/**
* A control that allows the user to toggle between checked and not checked.
*
* TODO(jamie): We need to figure out a better way to label these.
*
* @example Anatomy
* ```tsx
* <AxoSwitch.Root checked={checked} onCheckedChange={setChecked} />
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/switch | Switch - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/switch/ | Switch Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#switch | `switch` role - WAI-ARIA 1.3}
*/
export namespace AxoSwitch {
/**
* <AxoSwitch.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/** The controlled state of the switch. Must be used in conjunction with `onCheckedChange`. */
checked: boolean;
/** Event handler called when the state of the switch changes. */
onCheckedChange: (nextChecked: boolean) => void;
/** When true, prevents the user from interacting with the switch. */
disabled?: boolean;
/** When true, indicates that the user must check the switch before the owning form can be submitted. */
required?: boolean;
}>;
export const Root = memo((props: RootProps) => {
/**
* Contains all the parts of a switch. An input will also render when used
* within a form to ensure events propagate correctly.
*
* @example Inside a label element
* ```tsx
* <label className={tw('flex items-center gap-3')}>
* <span className={tw('grow')}>Allow multiple votes</span>
* <AxoSwitch.Root checked={allowMultiple} onCheckedChange={setAllowMultiple} />
* </label>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
return (
<Switch.Root
checked={props.checked}
@@ -74,5 +108,5 @@ export namespace AxoSwitch {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoSwitch.Root';
}
+122 -39
View File
@@ -4,7 +4,6 @@ import type { FC, JSX } from 'react';
import { memo, useMemo } from 'react';
import { Direction } from 'radix-ui';
import { VisuallyHidden } from 'react-aria';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import {
getAxoSymbolIcon,
@@ -14,21 +13,44 @@ import type {
AxoSymbolIconName,
AxoSymbolInlineGlyphName,
} from './_internal/AxoSymbolDefs.generated.std.ts';
import { variants } from './_internal/variants.dom.tsx';
const { useDirection } = Direction;
const Namespace = 'AxoSymbol';
/**
* Renders symbols from the Axo symbol font — either as a fixed-size block icon
* or as an inline glyph that flows with surrounding text.
*
* @example Anatomy
* ```tsx
* // Fixed-size icon, e.g. in buttons or list items
* <AxoSymbol.Icon size={20} symbol="check" label={null} />
*
* // Inline glyph, e.g. directional arrows inside button labels
* <AxoSymbol.InlineGlyph symbol="arrow-end" label={null} />
* ```
*/
export namespace AxoSymbol {
const symbolStyles = tw('font-symbols select-none');
const labelStyles = tw('select-none');
/**
* Stroke weight of the symbol.
* - `300` light
* - `400` regular (default)
* - `700` semibold
*/
export type Weight = 300 | 400 | 700;
const WeightStyles = {
/**
* useRenderSymbol()
* --------------------------------------
*/
const WeightStyles = variants<Weight>('AxoSymbol.Weight', {
300: tw('font-light'),
400: tw(),
700: tw('font-semibold'),
} as const satisfies Record<Weight, TailwindStyles>;
});
/** @internal */
function useRenderSymbol(
glyph: string,
label: string | null,
@@ -37,11 +59,16 @@ export namespace AxoSymbol {
return useMemo(() => {
return (
<>
<span aria-hidden className={tw(symbolStyles, WeightStyles[weight])}>
<span
aria-hidden
className={tw('font-symbols select-none', WeightStyles.get(weight))}
>
{glyph}
</span>
{label != null && (
<VisuallyHidden className={labelStyles}>{label}</VisuallyHidden>
<VisuallyHidden className={tw('select-none')}>
{label}
</VisuallyHidden>
)}
</>
);
@@ -49,17 +76,52 @@ export namespace AxoSymbol {
}
/**
* Component: <AxoSymbol.InlineGlyph>
* --------------------------------------
* <AxoSymbol.InlineGlyph>
* --------------------------------------------------------------------------
*/
/**
* A symbol font name that can be used as an inline glyph. May have small
* visual differences from the "icon" appearance to look better inline with
* text.
*
* Note: Auto-generated from the symbol font.
*
*/
export type InlineGlyphName = AxoSymbolInlineGlyphName;
export type InlineGlyphProps = Readonly<{
/** The icon to render. */
symbol: InlineGlyphName;
/**
* Accessible label for screen readers. Pass `null` if the glyph is purely
* decorative and the surrounding context (Ex: a button's aria-label) already
* conveys the meaning.
*/
label: string | null;
}>;
/**
* An inline symbol that flows with surrounding text. Use when you want to
* match the font size and don't care about the width of the icon.
*
* @example Within a labeled element
* ```tsx
* <button>
* Expand items
* <AxoSymbol.InlineGlyph symbol="arrow-down" label={null} />
* </button>
* ```
*
* @example Outside a labeled element
* ```tsx
* <p>
* {"Then click on the "}
* <AxoSymbol.InlineGlyph symbol="arrow-[end]" label="Next page" />
* {" button"}
* </p>
* ```
*/
export const InlineGlyph: FC<InlineGlyphProps> = memo(props => {
const direction = useDirection();
const glyph = getAxoSymbolInlineGlyph(props.symbol, direction);
@@ -67,37 +129,53 @@ export namespace AxoSymbol {
return content;
});
InlineGlyph.displayName = `${Namespace}.InlineGlyph`;
InlineGlyph.displayName = 'AxoSymbol.InlineGlyph';
/**
* Component: <AxoSymbol.Icon>
* --------------------------------------
* <AxoSymbol.Icon>
* --------------------------------------------------------------------------
*/
/**
* A symbol font name that can be used as an icon. May have small
* visual differences from the "inline glyph" appearance to look better as a
* standalone icon.
*
* Note: Auto-generated from the symbol font.
*/
export type IconName = AxoSymbolIconName;
/** Available icon sizes in pixels. */
export type IconSize = 12 | 14 | 16 | 18 | 20 | 24 | 36 | 48;
type IconSizeConfig = { size: number; fontSize: number };
const IconSizes: Record<IconSize, IconSizeConfig> = {
12: { size: 12, fontSize: 10 },
14: { size: 14, fontSize: 12 },
16: { size: 16, fontSize: 14 },
18: { size: 18, fontSize: 16 },
20: { size: 20, fontSize: 18 },
24: { size: 24, fontSize: 22 },
36: { size: 36, fontSize: 34 },
48: { size: 48, fontSize: 44 },
};
const IconSizes = variants<IconSize>('AxoSymbol.IconSize', {
12: tw('size-[12px] text-[10px]'),
14: tw('size-[14px] text-[12px]'),
16: tw('size-[16px] text-[14px]'),
18: tw('size-[18px] text-[16px]'),
20: tw('size-[20px] text-[18px]'),
24: tw('size-[24px] text-[22px]'),
36: tw('size-[36px] text-[34px]'),
48: tw('size-[48px] text-[44px]'),
});
/** @testexport */
export function _getAllIconSizes(): ReadonlyArray<IconSize> {
return Object.keys(IconSizes).map(size => Number(size) as IconSize);
return IconSizes.keys().map(size => Number(size) as IconSize);
}
export type IconProps = Readonly<{
/** Size of the icon in pixels. */
size: IconSize;
/** The icon to render. Automatically mirrored in RTL layouts. */
symbol: IconName;
/**
* Accessible label for screen readers. Pass `null` if the icon is purely
* decorative and the surrounding context (Ex: a button's aria-label) already
* conveys the meaning.
*/
label: string | null;
/** Stroke weight of the icon. Defaults to `400`. */
weight?: Weight;
}>;
@@ -105,27 +183,32 @@ export namespace AxoSymbol {
'inline-flex size-[1em] shrink-0 items-center justify-center align-middle leading-none'
);
/**
* A fixed-size icon. Prefer using this when the width and height both matter.
*
* @example Within a labeled element
* ```tsx
* <button aria-label="Expand items">
* <AxoSymbol.Icon size={20} symbol="arrow-down" label={null} />
* </button>
* ```
*
* @example Outside a labeled element
* ```tsx
* <AxoSymbol.Icon size={20} symbol="shield-check" label="Verified" />
* ```
*/
export const Icon: FC<IconProps> = memo(props => {
const config = IconSizes[props.size];
const direction = useDirection();
const weight = props.weight ?? 400;
const glyph = getAxoSymbolIcon(props.symbol, direction);
const content = useRenderSymbol(glyph, props.label, weight);
const style = useMemo(() => {
return {
width: config.size,
height: config.size,
fontSize: config.fontSize,
};
}, [config]);
return (
<span className={iconStyles} style={style}>
<span className={tw(iconStyles, IconSizes.get(props.size))}>
{content}
</span>
);
});
Icon.displayName = `${Namespace}.Icon`;
Icon.displayName = 'AxoSymbol.Icon';
}
+1 -1
View File
@@ -96,7 +96,7 @@ function TemplateInput(props: TemplateInputProps) {
maxGraphemes={props.showCount ? MAX_GRAPHEMES_SHORT : 200}
maxBytes={props.showCount ? MAX_BYTES_SHORT : 800}
showCount={props.showCount}
showClearWithLabel={props.showClear ? 'Clear' : null}
showClear={props.showClear}
sizing={props.sizing}
disabled={props.disabled}
/>
+146 -31
View File
@@ -5,40 +5,104 @@ import type { FC, InputEvent, MouseEvent, ReactNode, RefObject } from 'react';
import { mergeRefs } from '@react-aria/utils';
import { AxoSymbol } from './AxoSymbol.dom.tsx';
import { tw } from './tw.dom.tsx';
import type { TailwindStyles } from './tw.dom.tsx';
import { assert } from './_internal/assert.std.tsx';
import { utf8 } from './_internal/utf8.std.ts';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.tsx';
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const Namespace = 'AxoTextField';
/**
* A single-line text input with optional icons, action buttons, and
* character/byte limiting.
*
* @example Anatomy
* ```tsx
* <AxoTextField.Root>
* <AxoTextField.Input />
* <AxoTextField.Separator />
* <AxoTextField.Input />
* <AxoTextField.Action />
* </AxoTextField.Root>
* ```
* @see {@link https://w3c.github.io/aria/#textbox | `textbox` role - WAI-ARIA 1.3}
* @see {@link https://w3c.github.io/aria/#group | `group` role - WAI-ARIA 1.3}
*/
export namespace AxoTextField {
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type Sizing = 'fixed' | 'grow' | 'fit';
/**
* <AxoTextField.Root>
* --------------------------------------------------------------------------
*/
/** @internal */
type RootContextType = Readonly<{
disabled?: boolean;
readOnly?: boolean;
}>;
const RootContext = createStrictContext<RootContextType>(`${Namespace}.Root`);
/** @internal */
const RootContext = createStrictContext<RootContextType>('AxoTextField.Root');
/**
* The preferred width of the text field.
*
* TODO(jamie): Get real sizes from design
*
* - `xs` 200px
* - `sm` 300px
* - `md` 400px
* - `lg` 500px
* - `xl` 600px
* - `full` stretches to fill the container (default)
*
* All sizes shrink to fit the container if it is narrower than the minimum.
*/
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type RootProps = Readonly<{
/** Leading icon displayed before the input. */
symbol?: AxoSymbol.IconName;
/** Controls the width of the entire field. Defaults to `full`. */
width?: Width;
/** Disables all inputs and actions within the field. */
disabled?: boolean;
/** Makes all inputs within the field read-only. */
readOnly?: boolean;
/** Should be `Input`, `Action`, and/or `Separator` elements. */
children: ReactNode;
}>;
/**
* Container for the text field. Provides shared `disabled`/`readOnly` state
* to child inputs and actions.
*
* @example Basic usage
* ```tsx
* <AxoTextField.Root>
* <AxoTextField.Input
* placeholder="First name"
* value={value}
* onValueChange={setValue}
* maxGraphemes={26}
* maxBytes={128}
* showCount
* showClear
* />
* </AxoTextField.Root>
* ```
*
* @example Segmented field with icon and action
* ```tsx
* <AxoTextField.Root symbol="at">
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
* <AxoTextField.Separator />
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
* </AxoTextField.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const { disabled, readOnly } = props;
@@ -56,21 +120,21 @@ export namespace AxoTextField {
);
});
Root.displayName = `${Namespace}.Root`;
Root.displayName = 'AxoTextField.Root';
/**
* <AxoTextField.Group>
* --------------------------------------------------------------------------
*/
const GroupWidthStyles: Record<Width, TailwindStyles> = {
const GroupWidthStyles = variants<Width>('AxoTextField.Width', {
xs: tw('w-[calc-size(fit-content,min(max(200px,size),100%))]'),
sm: tw('w-[calc-size(fit-content,min(max(300px,size),100%))]'),
md: tw('w-[calc-size(fit-content,min(max(400px,size),100%))]'),
lg: tw('w-[calc-size(fit-content,min(max(500px,size),100%))]'),
xl: tw('w-[calc-size(fit-content,min(max(600px,size),100%))]'),
full: tw('w-full'),
};
});
/** @internal */
type GroupProps = Readonly<{
@@ -86,7 +150,7 @@ export namespace AxoTextField {
className={tw(
'group flex items-stretch',
'overflow-hidden',
GroupWidthStyles[props.width],
GroupWidthStyles.get(props.width),
'curved-lg bg-fill-primary',
'border-[0.5px] border-border-primary',
'shadow-elevation-0 shadow-no-outline',
@@ -105,38 +169,57 @@ export namespace AxoTextField {
);
});
Group.displayName = `${Namespace}.Group`;
Group.displayName = 'AxoTextField.Group';
/**
* <AxoTextField.Input>
* --------------------------------------------------------------------------
*/
/**
* How an `Input` sizes itself within the field group.
* - `fixed`: Takes up all remaining space (default).
* - `grow`: Expands with typed content, up to available space.
* - `fit`: Shrinks to fit typed content, useful for segmented fields.
*/
export type Sizing = 'fixed' | 'grow' | 'fit';
export type InputProps = Readonly<{
/** Ref to the underlying `<input>` element. */
ref?: RefObject<HTMLInputElement | null>;
/** Provide your own id for the `<input>` to target with a `<label>`. Auto-generated if omitted. */
id?: string;
/** Form field name for native form submissions. */
name?: string;
/** Placeholder text shown when the input is empty. */
placeholder: string;
/** How the input sizes itself within the field group. Defaults to `fixed`. */
sizing?: Sizing;
/** Controlled value of the input. */
value: string;
/** Called with the new value on every change. */
onValueChange: (value: string) => void;
/** Maximum number of Unicode grapheme clusters allowed. */
maxGraphemes: number;
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
maxBytes: number;
/** Shows a remaining-character counter that appears as the limit is approached. */
showCount?: boolean;
showClearWithLabel?: string | null;
/** Shows a clear button when the input has a value. */
showClear?: boolean;
/** Marks the input as required for form validation. */
required?: boolean;
/** Disables this input. Also disabled if `Root` has `disabled` set. */
disabled?: boolean;
/** Makes this input read-only. Also read-only if `Root` has `readOnly` set. */
readOnly?: boolean;
/** Focuses the input on mount. */
autoFocus?: boolean;
/** Enables or disables browser spell checking. */
spellCheck?: boolean;
}>;
/** The text input field. Must be placed inside `Root`. */
export const Input: FC<InputProps> = memo(props => {
const { onValueChange, maxBytes, maxGraphemes } = props;
const context = useStrictContext(RootContext);
@@ -259,13 +342,12 @@ export namespace AxoTextField {
maxGraphemes={props.maxGraphemes}
/>
)}
{props.showClearWithLabel != null && (
{props.showClear && (
<Clear
inputRef={inputRef}
inputId={inputId}
value={props.value}
onValueChange={onValueChange}
label={props.showClearWithLabel}
disabled={disabled || readOnly}
/>
)}
@@ -273,13 +355,14 @@ export namespace AxoTextField {
);
});
Input.displayName = `${Namespace}.Input`;
Input.displayName = 'AxoTextField.Input';
/**
* <AxoTextField.Icon>
* --------------------------------------------------------------------------
*/
/** @internal */
type IconProps = Readonly<{
symbol: AxoSymbol.IconName;
}>;
@@ -298,7 +381,7 @@ export namespace AxoTextField {
);
});
Icon.displayName = `${Namespace}.Icon`;
Icon.displayName = 'AxoTextField.Icon';
/**
* <AxoTextField.Count>
@@ -308,6 +391,7 @@ export namespace AxoTextField {
const SHOW_REMAINING_COUNT_THRESHOLD = 0.5;
const WARN_REMAINING_COUNT_THRESHOLD = 0.25;
/** @internal */
type CountProps = Readonly<{
value: string;
maxBytes: number;
@@ -364,17 +448,17 @@ export namespace AxoTextField {
);
});
Count.displayName = `${Namespace}.Count`;
Count.displayName = 'AxoTextField.Count';
/**
* <AxoTextField.Clear>
* --------------------------------------------------------------------------
*/
/** @internal */
type ClearProps = Readonly<{
inputRef: RefObject<HTMLInputElement | null>;
inputId: string;
label: string;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
@@ -383,6 +467,7 @@ export namespace AxoTextField {
/** @internal */
const Clear: FC<ClearProps> = memo(props => {
const { inputRef, value, onValueChange } = props;
const intl = useAxoIntl();
const handleClear = useCallback(
(event: MouseEvent) => {
@@ -400,7 +485,7 @@ export namespace AxoTextField {
return (
<button
type="button"
aria-label={props.label}
aria-label={intl.get('AxoTextField.Clear')}
aria-controls={props.inputId}
className={tw(
'z-10',
@@ -428,7 +513,7 @@ export namespace AxoTextField {
);
});
Clear.displayName = `${Namespace}.Clear`;
Clear.displayName = 'AxoTextField.Clear';
/**
* <AxoTextField.Action>
@@ -436,12 +521,28 @@ export namespace AxoTextField {
*/
export type ActionProps = Readonly<{
/** Accessible label for the button describing the action to be taken, not the icon. */
label: string;
/** Icon to display inside the button. */
symbol: AxoSymbol.IconName;
/** Called when the button is clicked. */
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
/** Overrides the `disabled` state from `Root` for this button only. */
disabled?: boolean;
}>;
/**
* An icon button placed inside a `Root`, typically used for supplementary
* actions like inserting an emoji or opening a menu.
*
* @example
* ```tsx
* <AxoTextField.Root>
* <AxoTextField.Input ... />
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
* </AxoTextField.Root>
* ```
*/
export const Action: FC<ActionProps> = memo(props => {
const { onClick } = props;
const context = useStrictContext(RootContext);
@@ -454,9 +555,11 @@ export namespace AxoTextField {
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!disabled) {
onClick?.(event);
if (disabled) {
event.preventDefault();
return;
}
onClick?.(event);
},
[disabled, onClick]
);
@@ -488,13 +591,25 @@ export namespace AxoTextField {
);
});
Action.displayName = `${Namespace}.Action`;
Action.displayName = 'AxoTextField.Action';
/**
* <AxoTextField.Separator>
* --------------------------------------------------------------------------
*/
/**
* A vertical divider between segments in a multi-input field.
*
* @example Username + discriminator
* ```tsx
* <AxoTextField.Root symbol="at">
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
* <AxoTextField.Separator />
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
* </AxoTextField.Root>
* ```
*/
export const Separator: FC = memo(() => {
return (
<span className={tw('flex py-2 ps-3 pe-2')}>
@@ -511,5 +626,5 @@ export namespace AxoTextField {
);
});
Separator.displayName = `${Namespace}.Separator`;
Separator.displayName = 'AxoTextField.Separator';
}
+1 -1
View File
@@ -101,7 +101,7 @@ function AxoDialogTest() {
<AxoDialog.Content size="md" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>AxoDialog</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<p>Lorem ipsum, dolor sit amet consectetur adipisicing elit.</p>
+65 -11
View File
@@ -3,55 +3,109 @@
import type { FC, ReactNode } from 'react';
import { memo, createContext, useContext } from 'react';
import type { TailwindStyles } from './tw.dom.tsx';
import { tw } from './tw.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const Namespace = 'AxoTheme';
/**
* Controls the color scheme applied to a subtree.
*
* Use {@link Override} at section boundaries to set or force a color scheme,
* and {@link Inherit} inside portals to re-apply the parent's scheme across
* the portal boundary.
*
* @example Anatomy
* ```tsx
* <AxoTheme.Override theme="force-dark">
* <AxoTheme.Inherit>
* {/* portal content inherits force-dark *\/}
* </AxoTheme.Inherit>
* </AxoTheme.Override>
* ```
*/
export namespace AxoTheme {
/**
* <AxoTheme.Overide>
* --------------------------------------------------------------------------
*/
/**
* The color scheme to apply to a subtree.
* - `auto`: Reverts to the app's setting, which inherits from the OS (default).
* - `force-light`: Always light, regardless of system setting.
* - `force-dark`: Always dark, regardless of system setting.
*/
export type ThemeOverride = 'force-light' | 'force-dark' | 'auto';
const ThemeOverrides: Record<ThemeOverride, TailwindStyles> = {
const ThemeOverrides = variants<ThemeOverride>('AxoTheme.ThemeOverride', {
'force-light': tw('scheme-light'),
'force-dark': tw('scheme-dark'),
auto: tw('scheme-light dark:scheme-dark'),
};
});
/** @internal */
const ThemeOverrideContext = createContext<ThemeOverride>('auto');
/**
* <AxoTheme.Overide>
* ------------------
* --------------------------------------------------------------------------
*/
export type OverrideProps = Readonly<{
/** The color scheme to apply. */
theme: ThemeOverride;
/** The subtree to apply the color scheme to. */
children: ReactNode;
}>;
/**
* Sets the color scheme for a subtree. Use at section boundaries where the
* theme should differ from the rest of the page (e.g. always-dark overlays).
*
* @example Force dark theme in a calling UI
* ```tsx
* <AxoTheme.Override theme="force-dark">
* <CallingControls />
* </AxoTheme.Override>
* ```
*/
export const Override: FC<OverrideProps> = memo(props => {
return (
<ThemeOverrideContext.Provider value={props.theme}>
<div className={ThemeOverrides[props.theme]}>{props.children}</div>
<div className={ThemeOverrides.get(props.theme)}>{props.children}</div>
</ThemeOverrideContext.Provider>
);
});
Override.displayName = `${Namespace}.Override`;
Override.displayName = 'AxoTheme.Override';
/**
* <AxoTheme.Inherit>
* ------------------
* --------------------------------------------------------------------------
*/
export type InheritProps = Readonly<{
/** The portal content that should inherit the parent's color scheme. */
children: ReactNode;
}>;
/**
* Re-applies the nearest {@link Override}'s color scheme to a subtree.
* Required inside portals, since portal content renders outside the DOM
* tree and won't automatically inherit CSS from the parent.
*
* @example Wrap portal content so it inherits the active theme
* ```tsx
* <Dialog.Portal>
* <AxoTheme.Inherit>
* <Dialog.Content>...</Dialog.Content>
* </AxoTheme.Inherit>
* </Dialog.Portal>
* ```
*/
export const Inherit: FC<InheritProps> = memo(props => {
const theme = useContext(ThemeOverrideContext);
return <div className={ThemeOverrides[theme]}>{props.children}</div>;
return <div className={ThemeOverrides.get(theme)}>{props.children}</div>;
});
Inherit.displayName = `${Namespace}.Inherit`;
Inherit.displayName = 'AxoTheme.Inherit';
}
+1 -1
View File
@@ -164,7 +164,7 @@ export function InDialog(): JSX.Element {
<AxoDialog.Content size="md" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>Title</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('flex flex-col items-center-safe gap-50 py-50')}>
+174 -100
View File
@@ -19,13 +19,29 @@ import {
} from './_internal/ariaRoles.dom.tsx';
import { isTestOrMockEnvironment } from '../environment.std.ts';
import { AxoTheme } from './AxoTheme.dom.tsx';
import { variants } from './_internal/variants.dom.tsx';
const { useDirection } = Direction;
const Namespace = 'AxoTooltip';
type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right';
/**
* A popup that displays information related to an element when the element
* receives keyboard focus or the mouse hovers over it.
*
* @example Anatomy
* ```tsx
* <AxoTooltip.Provider>
* <AxoTooltip.CollisionBoundary>
* <AxoTooltip.Root>
* <button/>
* </AxoTooltip.Root>
* </AxoTooltip.CollisionBoundary>
* </AxoTooltip.Provider>
* ```
*
* @see {@link https://www.radix-ui.com/primitives/docs/components/tooltip | Tooltip - Radix Docs}
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ | Tooltip Pattern - ARIA Authoring Practices Guide}
* @see {@link https://w3c.github.io/aria/#tooltip | `tooltip` role - WAI-ARIA 1.3}
*/
export namespace AxoTooltip {
/**
* The duration from when the mouse enters a tooltip trigger until the
@@ -37,10 +53,10 @@ export namespace AxoTooltip {
*/
export type Delay = 'auto' | 'none';
const Delays: Record<Delay, number> = {
const Delays = variants<Delay, number>('AxoTooltip.Delay', {
auto: 700,
none: 0,
};
});
/**
* How much time the user has to enter another tooltip trigger without
@@ -50,10 +66,113 @@ export namespace AxoTooltip {
*/
export type SkipDelay = 'auto' | 'never';
const SkipDelays: Record<SkipDelay, number> = {
const SkipDelays = variants<SkipDelay, number>('AxoTooltip.SkipDelay', {
auto: 300,
never: 0,
};
});
/**
* <AxoTooltip.Provider>
* --------------------------------------------------------------------------
*/
export type ProviderProps = Readonly<{
/** The duration from when the mouse enters a tooltip trigger until the tooltip opens. */
delay?: Delay;
/** How much time a user has to enter another trigger without incurring a delay again. */
skipDelay?: SkipDelay;
/** The subtree of components that can use tooltips. */
children: ReactNode;
}>;
/** Wraps your app to provide global functionality to your tooltips. */
export const Provider: FC<ProviderProps> = memo(props => {
const { delay = 'auto', skipDelay = 'auto' } = props;
return (
<Tooltip.Provider
delayDuration={Delays.get(delay)}
skipDelayDuration={SkipDelays.get(skipDelay)}
>
{props.children}
</Tooltip.Provider>
);
});
Provider.displayName = 'AxoTooltip.Provider';
/**
* <AxoTooltip.CollisionBoundary>
* --------------------------------------------------------------------------
*/
const DEFAULT_COLLISION_PADDING = 8;
/** @internal */
type CollisionBoundaryType = Readonly<{
elements: Array<Element | null>;
padding: number;
}>;
/** @internal */
const CollisionBoundaryContext = createContext<CollisionBoundaryType>({
elements: [],
padding: DEFAULT_COLLISION_PADDING,
});
export type CollisionBoundaryProps = Readonly<{
/**
* The element used as the collision boundary. By default this is the viewport,
* though you can provide additional element(s) to be included in this check.
*/
boundary: Element | null;
/**
* The distance in pixels from the boundary edges where collision detection
* should occur. Accepts a number (same for all sides).
*/
padding?: number;
/** The subtree of components that share this collision boundary. */
children: ReactNode;
}>;
/**
* Constrains tooltip positioning to stay within a specific element,
* overriding any parent boundary.
* @example
* ```tsx
* const [boundary, setBoundary] = useState<HTMLElement | null>(null);
* <AxoTooltip.CollisionBoundary boundary={boundary}>
* <div ref={setBoundary}>
* <AxoTooltip.Root label="Hello">
* <button />
* </AxoTooltip.Root>
* </div>
* </AxoTooltip.CollisionBoundary>
* ```
*/
export const CollisionBoundary: FC<CollisionBoundaryProps> = 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 (
<CollisionBoundaryContext.Provider value={value}>
{props.children}
</CollisionBoundaryContext.Provider>
);
});
CollisionBoundary.displayName = 'AxoTooltip.CollisionBoundary';
/**
* <AxoTooltip.Root>
* --------------------------------------------------------------------------
*/
/**
* The preferred side of the trigger to render against when open.
@@ -75,95 +194,25 @@ export namespace AxoTooltip {
*/
export type Align = 'center' | 'force-start' | 'force-end';
const Aligns: Record<Align, Tooltip.TooltipContentProps['align']> = {
type AlignValue = Tooltip.TooltipContentProps['align'];
const Aligns = variants<Align, AlignValue>('AxoTooltip.Align', {
center: 'center',
'force-start': 'start',
'force-end': 'end',
};
});
/**
* The format used to display a timestamp alongside the tooltip label.
*
* TODO(jamie): Need to spec timestamp formats.
*/
export type ExperimentalTimestampFormat = 'testing-only';
/**
* Component: <AxoTooltip.Provider>
* --------------------------------
*/
export type ProviderProps = Readonly<{
delay?: Delay;
skipDelay?: SkipDelay;
children: ReactNode;
}>;
export const Provider: FC<ProviderProps> = memo(props => {
const { delay = 'auto', skipDelay = 'auto' } = props;
const delayDuration = useMemo(() => {
return Delays[delay];
}, [delay]);
const skipDelayDuration = useMemo(() => {
return SkipDelays[skipDelay];
}, [skipDelay]);
return (
<Tooltip.Provider
delayDuration={delayDuration}
skipDelayDuration={skipDelayDuration}
>
{props.children}
</Tooltip.Provider>
);
});
Provider.displayName = `${Namespace}.Provider`;
/**
* Component: <AxoTooltip.CollisionBoundary>
* -----------------------------------------
*/
const DEFAULT_COLLISION_PADDING = 8;
type CollisionBoundaryType = Readonly<{
elements: Array<Element | null>;
padding: number;
}>;
const CollisionBoundaryContext = createContext<CollisionBoundaryType>({
elements: [],
padding: DEFAULT_COLLISION_PADDING,
});
export type CollisionBoundaryProps = Readonly<{
boundary: Element | null;
padding?: number;
children: ReactNode;
}>;
export const CollisionBoundary: FC<CollisionBoundaryProps> = 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 (
<CollisionBoundaryContext.Provider value={value}>
{props.children}
</CollisionBoundaryContext.Provider>
);
});
CollisionBoundary.displayName = `${Namespace}.CollisionBoundary`;
/**
* Component: <AxoTooltip.Root>
* ----------------------------
*/
/** @internal */
type PhysicalDirection = 'top' | 'bottom' | 'left' | 'right';
/** @internal */
function generateTooltipArrowPath(): string {
let path = '';
path += 'M 0 0'; // start at top left
@@ -182,13 +231,19 @@ export namespace AxoTooltip {
const TOOLTIP_ARROW_HEIGHT = 6;
export type RootConfigProps = Readonly<{
/** Override the duration given to the `Provider` to customise the open delay for a specific tooltip. */
delay?: Delay;
/** The preferred side of the trigger to render against when open. Will be reversed when collisions occur. */
side?: Side;
/** The preferred alignment against the trigger. May change when collisions occur. */
align?: Align;
/** The primary text content of the tooltip. */
label: ReactNode;
// TODO(jamie): Need to spec timestamp formats
/** An additional timestamp to display after the label. */
experimentalTimestamp?: number | null;
/** The timestamp format to use when a timestamp has been provided. */
experimentalTimestampFormat?: ExperimentalTimestampFormat;
/** An supplementary keyboard shortcut to display after the label for the button that the tooltip wraps. */
keyboardShortcut?: string | null;
}>;
@@ -200,13 +255,32 @@ export namespace AxoTooltip {
* a visual affordance.
*/
tooltipRepeatsTriggerAccessibleName?: boolean;
/** The trigger element that activates the tooltip on hover/focus. Must be a widget with an accessible name. */
children: ReactNode;
/** @private exported for stories only */
__FORCE_OPEN?: boolean;
}>;
const rootDisplayName = `${Namespace}.Root`;
/**
* Wraps a trigger element and shows a tooltip on hover/focus.
*
* The child must be a focusable widget with an accessible name that does
* not duplicate the tooltip label.
*
* @example Tooltip repeats the label of the button to provide a visual label
* ```tsx
* <AxoTooltip.Root label="Send message" tooltipRepeatsTriggerAccessibleName>
* <button aria-label="Send message"/>
* </AxoTooltip.Root>
* ```
*
* @example Tooltip provides supplemental info to some button with an already visible label
* ```tsx
* <AxoTooltip.Root label="Already in a call">
* <button disabled>Join Call</button>
* </AxoTooltip.Root>
* ```
*/
export const Root: FC<RootProps> = memo(props => {
const {
delay,
@@ -234,7 +308,7 @@ export namespace AxoTooltip {
}, [side]);
const delayDuration = useMemo(() => {
return delay != null ? Delays[delay] : undefined;
return delay != null ? Delays.get(delay) : undefined;
}, [delay]);
const formattedTimestamp = useMemo(() => {
@@ -253,16 +327,16 @@ export namespace AxoTooltip {
if (isTestOrMockEnvironment()) {
assert(
triggerRef.current instanceof HTMLElement,
`${rootDisplayName} child must forward ref`
`<AxoTooltip.Root> child must forward ref`
);
assert(
isAriaWidgetRole(getElementAriaRole(triggerRef.current)),
`${rootDisplayName} child must have a widget role like 'button'`
`<AxoTooltip.Root> child must have a widget role like 'button'`
);
const triggerName = computeAccessibleName(triggerRef.current);
assert(
triggerName !== '',
`${rootDisplayName} child must have an accessible name`
`<AxoTooltip.Root> child must have an accessible name`
);
if (props.tooltipRepeatsTriggerAccessibleName) {
@@ -271,7 +345,7 @@ export namespace AxoTooltip {
assert(
triggerName !== props.label,
`${rootDisplayName} label must not repeat child trigger's accessible name. ` +
`<AxoTooltip.Root> label must not repeat child trigger's accessible name. ` +
'Use the tooltipRepeatsTriggerAccessibleName prop if you would ' +
'like to make the tooltip presentational only.'
);
@@ -290,7 +364,7 @@ export namespace AxoTooltip {
<AxoTheme.Inherit>
<Tooltip.Content
side={physicalDirection}
align={Aligns[align]}
align={Aligns.get(align)}
sideOffset={6}
arrowPadding={14}
collisionBoundary={collisionBoundary.elements}
@@ -356,5 +430,5 @@ export namespace AxoTooltip {
);
});
Root.displayName = rootDisplayName;
Root.displayName = 'AxoTooltip.Root';
}
@@ -5,21 +5,31 @@ import type { RefCallback } from 'react';
import { useMemo, useState } from 'react';
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
/** @internal */
type AriaLabellingContextType = Readonly<{
labelRef: RefCallback<HTMLElement>;
descriptionRef: RefCallback<HTMLElement>;
}>;
/** @internal */
const AriaLabellingContext = createStrictContext<AriaLabellingContextType>(
'AriaLabellingContext.Provider'
);
export type CreateAriaLabellingContextResult = Readonly<{
/** The labelling context to pass to `AriaLabellingProvider`. */
context: AriaLabellingContextType;
/** The `id` of the label element, once mounted. `undefined` if no label has been registered. */
labelId: string | undefined;
/** The `id` of the description element, once mounted. `undefined` if none has been registered. */
descriptionId: string | undefined;
}>;
/**
* Creates a labelling context that captures element IDs from nested label and
* description refs. Use the returned `labelId`/`descriptionId` to wire up
* `aria-labelledby`/`aria-describedby` on the containing widget.
*/
export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResult {
const [labelId, setLabelId] = useState<string | undefined>();
const [descriptionId, setDescriptionId] = useState<string | undefined>();
@@ -39,8 +49,14 @@ export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResul
return { context, labelId, descriptionId };
}
/** Context provider. Pass the `context` returned by `useCreateAriaLabellingContext`. */
export const AriaLabellingProvider = AriaLabellingContext.Provider;
/**
* Reads the labelling context and returns `labelRef`/`descriptionRef` callbacks
* for registering label and description elements. Must be called inside an
* `AriaLabellingProvider`.
*/
export function useAriaLabellingContext(
providerName: string
): AriaLabellingContextType {
+26 -9
View File
@@ -5,21 +5,24 @@ import { useCallback } from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.dom.tsx';
/** @internal */
export namespace AxoBaseDialog {
/**
* AxoBaseDialog: Root
* -------------------
* <AxoBaseDialog.Root>
* --------------------------------------------------------------------------
*/
export type RootProps = Readonly<{
/** Controlled open state. Must be used with `onOpenChange`. */
open?: boolean;
/** Called when the open state changes. */
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}>;
/**
* AxoBaseDialog: Trigger
* ----------------------
* <AxoBaseDialog.Trigger>
* --------------------------------------------------------------------------
*/
export type TriggerProps = Readonly<{
@@ -27,8 +30,8 @@ export namespace AxoBaseDialog {
}>;
/**
* AxoBaseDialog: Overlay
* ----------------------
* <AxoBaseDialog.Overlay>
* --------------------------------------------------------------------------
*/
export const overlayStyles = tw(
@@ -42,13 +45,13 @@ export namespace AxoBaseDialog {
);
/**
* AxoBaseDialog: Content
* ----------------------
* <AxoBaseDialog.Content>
* --------------------------------------------------------------------------
*/
export const contentStyles = tw(
'relative',
'max-h-full min-h-fit max-w-full min-w-fit',
'max-h-full min-h-fit',
'curved-3xl bg-elevated-background-primary shadow-elevation-3 select-none',
'text-label-primary',
'not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
@@ -57,8 +60,22 @@ export namespace AxoBaseDialog {
'forced-colors:border forced-colors:border-[ButtonBorder] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]'
);
/**
* useContentEscapeBehavior()
* --------------------------------------------------------------------------
*/
/**
* How dangerous the cancel action is considered.
* - `cancel-is-noop`: Canceling is safe pressing Escape or clicking outside closes the dialog.
* - `cancel-is-destructive`: Canceling would lose user state pressing Escape or clicking outside is disabled.
*/
export type ContentEscape = 'cancel-is-noop' | 'cancel-is-destructive';
/**
* Returns an escape-key handler for `onEscapeKeyDown`.
* Prevents default when `escape` is `cancel-is-destructive`.
*/
export function useContentEscapeBehavior(
escape: ContentEscape
): (event: Event) => void {
+128 -38
View File
@@ -10,7 +10,17 @@ import { isTestOrMockEnvironment } from '../../environment.std.ts';
const LEGACY_CONTEXT_MENU_Z_INDEX = tw('legacy-z-index-context-menu');
export namespace AxoBaseMenu {
/**
* Horizontal alignment of the menu content relative to the trigger.
* - `start`: Aligns the start edges.
* - `center`: Centers the content over the trigger.
* - `end`: Aligns the end edges.
*/
export type Align = 'center' | 'end' | 'start';
/**
* Preferred side of the trigger to render the menu content against.
*/
export type Side = 'top' | 'right' | 'bottom' | 'left';
// <Content/SubContent>
@@ -83,19 +93,31 @@ export namespace AxoBaseMenu {
*/
type BaseSelectableItemProps = BaseNavigableItemProps &
Readonly<{
/**
* Keyboard shortcut hint displayed on the trailing side of the item
* (e.g. `"⌘C"`).
* Note: Decorative, does not register the shortcut.
*/
keyboardShortcut?: string;
/**
* Called when the item is selected via mouse or keyboard.
* Call `event.preventDefault()` to keep the menu open after selection.
*/
onSelect?: (event: Event) => void;
}>;
/**
* AxoBaseMenu: Item Slots
* -----------------------
* <AxoBaseMenu.ItemLeadingSlot>
* --------------------------------------------------------------------------
*/
export type ItemLeadingSlotProps = Readonly<{
children: ReactNode;
}>;
/**
* First grid column of a menu item row. Holds the icon or check indicator.
*/
export function ItemLeadingSlot(props: ItemLeadingSlotProps): JSX.Element {
return (
<span
@@ -106,10 +128,18 @@ export namespace AxoBaseMenu {
);
}
/**
* <AxoBaseMenu.ItemContentSlot>
* --------------------------------------------------------------------------
*/
export type ItemContentSlotProps = Readonly<{
children: ReactNode;
}>;
/**
* Second grid column of a menu item row. Holds the label and keyboard shortcut.
*/
export function ItemContentSlot(props: ItemContentSlotProps): JSX.Element {
return (
<span className={tw('col-start-2 col-end-2 flex min-w-0 items-center')}>
@@ -119,8 +149,8 @@ export namespace AxoBaseMenu {
}
/**
* AxoBaseMenu: Item Parts
* -----------------------
* <AxoBaseMenu.ItemText>
* --------------------------------------------------------------------------
*/
export const itemTextStyles = tw('flex-auto grow-0 truncate text-start');
@@ -129,34 +159,73 @@ export namespace AxoBaseMenu {
children: ReactNode;
}>;
/**
* Truncated label text inside a menu item.
*/
export function ItemText(props: ItemTextProps): JSX.Element {
return <span className={itemTextStyles}>{props.children}</span>;
}
/**
* <AxoBaseMenu.ItemCheckPlaceholder>
* --------------------------------------------------------------------------
*/
export type ItemCheckPlaceholderProps = Readonly<{
children: ReactNode;
}>;
/**
* Fixed-width container for the check indicator. Reserves horizontal space
* even when unchecked so item text stays aligned across all items.
*/
export function ItemCheckPlaceholder(
props: ItemCheckPlaceholderProps
): JSX.Element {
return <span className={tw('w-3.5')}>{props.children}</span>;
}
/**
* <AxoBaseMenu.ItemCheck>
* --------------------------------------------------------------------------
*/
/**
* The checkmark icon rendered inside `ItemCheckPlaceholder` when
* a checkbox or radio item is in a checked state.
*/
export function ItemCheck(): JSX.Element {
return <AxoSymbol.Icon size={14} symbol="check" label={null} />;
}
export function ItemSymbol(props: {
/**
* <AxoBaseMenu.ItemSymbol>
* --------------------------------------------------------------------------
*/
export type ItemSymbolProps = Readonly<{
symbol: AxoSymbol.IconName;
}): JSX.Element {
}>;
/**
* An icon rendered in the leading slot of a menu item.
*/
export function ItemSymbol(props: ItemSymbolProps): JSX.Element {
return <AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />;
}
/**
* <AxoBaseMenu.ItemKeyboardShortcut>
* --------------------------------------------------------------------------
*/
export type ItemKeyboardShortcutProps = Readonly<{
keyboardShortcut: string;
}>;
/**
* Right-aligned keyboard shortcut hint (e.g. `"⌘C"`) inside a menu item.
*/
export function ItemKeyboardShortcut(
props: ItemKeyboardShortcutProps
): JSX.Element {
@@ -173,21 +242,27 @@ export namespace AxoBaseMenu {
}
/**
* AxoBaseMenu: Root
* -----------------
* <AxoBaseMenu.Root>
* --------------------------------------------------------------------------
*/
export type MenuRootProps = Readonly<{
// Note: Radix context menus don't have an `open` prop
// so we have to push it down to the dropdown menu props
/**
* Note: Radix context menus don't have an `open` prop
* so we have to push it down to the dropdown menu props
*/
onOpenChange?: (open: boolean) => void;
/**
* When `true`, pointer events outside the menu are disabled while it's open.
* Defaults to `true` for dropdown menus.
*/
modal?: boolean;
children: ReactNode;
}>;
/**
* AxoBaseMenu: Trigger
* --------------------
* <AxoBaseMenu.Trigger>
* --------------------------------------------------------------------------
*/
export type MenuTriggerProps = Readonly<{
@@ -200,13 +275,25 @@ export namespace AxoBaseMenu {
}>;
/**
* AxoBaseMenu: Content
* --------------------
* <AxoBaseMenu.Content>
* --------------------------------------------------------------------------
*/
export type MenuContentProps = Readonly<{
/**
* Horizontal alignment of the content relative to the trigger.
* Only applies to `AxoDropdownMenu`.
*/
align?: Align;
/**
* Preferred side of the trigger to render the content against.
* Only applies to `AxoDropdownMenu`.
*/
side?: Side;
/**
* Called when focus would be restored after the menu closes.
* Call `event.preventDefault()` to suppress auto-focus restoration.
*/
onCloseAutoFocus?: (e: Event) => void;
children: ReactNode;
}>;
@@ -222,16 +309,16 @@ export namespace AxoBaseMenu {
export const selectContentViewportStyles = tw(baseContentGridStyles);
/**
* AxoBaseMenu: Item
* -----------------
* <AxoBaseMenu.Item>
* --------------------------------------------------------------------------
*/
export type MenuItemProps = BaseSelectableItemProps &
Readonly<{
/**
* Event handler called when the user selects an item (via mouse or
* keyboard). Calling event.preventDefault in this handler will prevent the
* context menu from closing when selecting that item.
* keyboard). Calling `event.preventDefault()` in this handler will
* prevent the context menu from closing when selecting that item.
*/
onSelect: (event: Event) => void;
children: ReactNode;
@@ -241,8 +328,8 @@ export namespace AxoBaseMenu {
export const selectItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: Group
* ------------------
* <AxoBaseMenu.Group>
* --------------------------------------------------------------------------
*/
export type MenuGroupProps = Readonly<{
@@ -253,8 +340,8 @@ export namespace AxoBaseMenu {
export const selectGroupStyles = tw(baseGroupStyles);
/**
* AxoBaseMenu: Label
* ------------------
* <AxoBaseMenu.Label>
* --------------------------------------------------------------------------
*/
export type MenuLabelProps = Readonly<{
@@ -270,7 +357,8 @@ export namespace AxoBaseMenu {
export const selectLabelStyles = tw(baseLabelStyles);
/**
* AxoBaseMenu: Header
* <AxoBaseMenu.Header>
* --------------------------------------------------------------------------
*/
export const menuHeaderStyles = tw('col-span-full col-start-1 p-1.5');
@@ -282,8 +370,8 @@ export namespace AxoBaseMenu {
);
/**
* AxoBaseMenu: CheckboxItem
* -------------------------
* <AxoBaseMenu.CheckboxItem>
* --------------------------------------------------------------------------
*/
export type MenuCheckboxItemProps = BaseSelectableItemProps &
@@ -303,8 +391,8 @@ export namespace AxoBaseMenu {
export const menuCheckboxItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: RadioGroup
* -----------------------
* <AxoBaseMenu.RadioGroup>
* --------------------------------------------------------------------------
*/
export type MenuRadioGroupProps = Readonly<{
@@ -312,7 +400,6 @@ export namespace AxoBaseMenu {
* The value of the selected item in the group.
*/
value: string | null;
/**
* Event handler called when the value changes.
*/
@@ -323,12 +410,15 @@ export namespace AxoBaseMenu {
export const menuRadioGroupStyles = tw(baseGroupStyles);
/**
* AxoBaseMenu: RadioItem
* ----------------------
* <AxoBaseMenu.RadioItem>
* --------------------------------------------------------------------------
*/
export type MenuRadioItemProps = BaseSelectableItemProps &
Readonly<{
/**
* The value submitted when this item is selected.
*/
value: string;
children: ReactNode;
}>;
@@ -336,8 +426,8 @@ export namespace AxoBaseMenu {
export const menuRadioItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: Separator
* ----------------------
* <AxoBaseMenu.Separator>
* --------------------------------------------------------------------------
*/
const baseSeparatorStyles = tw('my-1 border-t-[0.5px] border-border-primary');
@@ -353,8 +443,8 @@ export namespace AxoBaseMenu {
export const selectSeperatorStyles = tw(baseItemStyles, baseSeparatorStyles);
/**
* AxoBaseMenu: Sub
* ----------------
* <AxoBaseMenu.Sub>
* --------------------------------------------------------------------------
*/
export type MenuSubProps = Readonly<{
@@ -362,8 +452,8 @@ export namespace AxoBaseMenu {
}>;
/**
* AxoBaseMenu: SubTrigger
* -----------------------
* <AxoBaseMenu.SubTrigger>
* --------------------------------------------------------------------------
*/
export type MenuSubTriggerProps = BaseNavigableItemProps &
@@ -379,8 +469,8 @@ export namespace AxoBaseMenu {
);
/**
* AxoBaseMenu: SubContent
* -----------------------
* <AxoBaseMenu.SubContent>
* --------------------------------------------------------------------------
*/
export type MenuSubContentProps = Readonly<{
+170 -156
View File
@@ -1,22 +1,13 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
ButtonHTMLAttributes,
CSSProperties,
FC,
ForwardedRef,
HTMLAttributes,
ReactNode,
} from 'react';
import { forwardRef, memo, useId, useMemo } from 'react';
import type { CSSProperties, FC, Ref, ReactNode } from 'react';
import { memo, useId, useMemo } from 'react';
import type { Transition } from 'motion/react';
import { motion } from 'motion/react';
import type { TailwindStyles } from '../tw.dom.tsx';
import { tw } from '../tw.dom.tsx';
import { ExperimentalAxoBadge } from '../AxoBadge.dom.tsx';
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
const Namespace = 'AxoBaseSegmentedControl';
import { variants } from './variants.dom.tsx';
/**
* Used to share styles/animations for SegmentedControls, Toolbar ToggleGroups,
@@ -34,12 +25,37 @@ const Namespace = 'AxoBaseSegmentedControl';
* ```
*/
export namespace ExperimentalAxoBaseSegmentedControl {
/**
* <AxoBaseSegmentedControl.Root>
* --------------------------------------------------------------------------
*/
/**
* Visual style variant.
*/
export type Variant = 'track' | 'no-track';
/**
* How the control sizes itself horizontally:
* - `fit`: Shrinks to fit its content.
* - `full`: Fills available width.
*/
export type RootWidth = 'fit' | 'full';
/**
* How each item sizes itself within the control:
* - `fit`: Items size to their content.
* - `equal`: All items share equal width.
*/
export type ItemWidth = 'fit' | 'equal';
/**
* The currently selected value(s).
* A string for single-select, an array for multi-select, or `null` for nothing selected.
*/
export type RootValue = string | ReadonlyArray<string> | null;
/** @internal */
type RootContextType = Readonly<{
id: string;
value: RootValue;
@@ -48,40 +64,87 @@ export namespace ExperimentalAxoBaseSegmentedControl {
itemWidth: ItemWidth;
}>;
const RootContext = createStrictContext<RootContextType>(`${Namespace}.Root`);
/** @internal */
const RootContext = createStrictContext<RootContextType>(
`AxoBaseSegmentedControl.Root`
);
type VariantConfig = {
rootStyles: TailwindStyles;
indicatorStyles: TailwindStyles;
};
const baseRootStyles = tw(
'flex min-w-min flex-row items-center justify-items-stretch',
'rounded-full',
'forced-colors:border',
'forced-colors:border-[ButtonBorder]'
);
const base: VariantConfig = {
rootStyles: tw(
'flex min-w-min flex-row items-center justify-items-stretch',
'rounded-full',
'forced-colors:border',
'forced-colors:border-[ButtonBorder]'
),
indicatorStyles: tw(
'pointer-events-none absolute inset-0 z-10 rounded-full',
'forced-colors:bg-[SelectedItem]'
),
};
const RootStyles = variants<Variant>(`AxoBaseSegmentedControl.Variant`, {
track: tw(baseRootStyles, 'bg-fill-secondary'),
'no-track': baseRootStyles,
});
const Variants: Record<Variant, VariantConfig> = {
track: {
rootStyles: tw(base.rootStyles, 'bg-fill-secondary'),
indicatorStyles: tw(
base.indicatorStyles,
'bg-fill-primary',
'shadow-elevation-1'
),
},
'no-track': {
rootStyles: tw(base.rootStyles),
indicatorStyles: tw(base.indicatorStyles, 'bg-fill-selected'),
},
};
const baseIndicatorStyles = tw(
'pointer-events-none absolute inset-0 z-10 rounded-full',
'forced-colors:bg-[SelectedItem]'
);
const IndicatorStyles = variants<Variant>(`AxoBaseSegmentedControl.Variant`, {
track: tw(baseIndicatorStyles, 'bg-fill-primary', 'shadow-elevation-1'),
'no-track': tw(baseIndicatorStyles, 'bg-fill-selected'),
});
/**
* <AxoBaseSegmentedControl.Root>
* --------------------------------------------------------------------------
*/
const RootWidths = variants<RootWidth>(`AxoBaseSegmentedControl.RootWidth`, {
fit: tw('w-fit'),
full: tw('w-full'),
});
export type RootProps = Readonly<{
/** Ref to the underlying `<div>` element. */
ref?: Ref<HTMLDivElement>;
/** The currently selected value(s). */
value: RootValue;
/** Visual style variant. */
variant: Variant;
/** How the control sizes itself horizontally. */
width: RootWidth;
/** How each item sizes itself within the control. */
itemWidth: ItemWidth;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(props => {
const { value, variant, width, itemWidth, children, ...rest } = props;
const id = useId();
const context = useMemo(() => {
return { id, value, variant, rootWidth: width, itemWidth };
}, [id, value, variant, width, itemWidth]);
return (
<RootContext.Provider value={context}>
<div
ref={props.ref}
className={tw(RootStyles.get(variant), RootWidths.get(width))}
{...rest}
>
{children}
</div>
</RootContext.Provider>
);
});
Root.displayName = 'AxoBaseSegmentedControl.Root';
/**
* <AxoBaseSegmentedControl.Item>
* --------------------------------------------------------------------------
*/
const ItemWidths = variants<ItemWidth>(`AxoBaseSegmentedControl.ItemWidth`, {
fit: tw('min-w-0 shrink grow basis-auto'),
equal: tw('flex-1'),
});
const IndicatorTransition: Transition = {
type: 'spring',
@@ -90,130 +153,80 @@ export namespace ExperimentalAxoBaseSegmentedControl {
mass: 1,
};
/**
* Component: <AxoBaseSegmentedControl.Root>
* -----------------------------------------
*/
export type ItemProps = Readonly<{
/** Ref to the underlying `<button>` element. */
ref?: Ref<HTMLButtonElement>;
/** The value this item represents. */
value: string;
children: ReactNode;
}>;
const RootWidths: Record<RootWidth, TailwindStyles> = {
fit: tw('w-fit'),
full: tw('w-full'),
};
export const Item: FC<ItemProps> = memo(props => {
const { value, children, ...rest } = props;
const context = useStrictContext(RootContext);
export type RootProps = HTMLAttributes<HTMLDivElement> &
Readonly<{
value: RootValue;
variant: Variant;
width: RootWidth;
itemWidth: ItemWidth;
}>;
const isSelected = useMemo(() => {
if (context.value == null) {
return false;
}
export const Root: FC<RootProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLDivElement>) => {
const { value, variant, width, itemWidth, ...rest } = props;
const id = useId();
const config = Variants[variant];
const widthStyles = RootWidths[width];
const context = useMemo(() => {
return { id, value, variant, rootWidth: width, itemWidth };
}, [id, value, variant, width, itemWidth]);
return (
<RootContext.Provider value={context}>
<div
ref={ref}
{...rest}
className={tw(config.rootStyles, widthStyles)}
if (Array.isArray(context.value)) {
return context.value.includes(value);
}
return context.value === value;
}, [value, context.value]);
return (
<button
ref={props.ref}
type="button"
className={tw(
'relative flex min-w-0 items-center justify-center px-3 py-[5px]',
'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
'outline-border-focused not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]',
ItemWidths.get(context.itemWidth),
isSelected && tw('forced-colors:text-[SelectedItemText]'),
!isSelected &&
tw(
'data-[axo-contextmenu-state=open]:bg-fill-secondary',
'forced-colors:data-[axo-contextmenu-state=open]:bg-[Highlight]'
)
)}
{...rest}
>
{children}
{isSelected && (
<motion.span
layoutId={`${context.id}.Indicator`}
className={IndicatorStyles.get(context.variant)}
transition={IndicatorTransition}
style={{ borderRadius: 14 }}
/>
</RootContext.Provider>
);
})
);
)}
</button>
);
});
Root.displayName = `${Namespace}.Root`;
Item.displayName = 'AxoBaseSegmentedControl.Item';
/**
* Component: <AxoBaseSegmentedControl.Item>
* -----------------------------------------
*/
const ItemWidths: Record<ItemWidth, TailwindStyles> = {
fit: tw('min-w-0 shrink grow basis-auto'),
equal: tw('flex-1'),
};
export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
value: string;
}>;
export const Item: FC<ItemProps> = memo(
forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { value, ...rest } = props;
const context = useStrictContext(RootContext);
const config = Variants[context.variant];
const itemWidthStyles = ItemWidths[context.itemWidth];
const isSelected = useMemo(() => {
if (context.value == null) {
return false;
}
if (Array.isArray(context.value)) {
return context.value.includes(value);
}
return context.value === value;
}, [value, context.value]);
return (
<button
ref={ref}
type="button"
{...rest}
className={tw(
'relative flex min-w-0 items-center justify-center px-3 py-[5px]',
'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
'outline-border-focused not-forced-colors:outline-none not-forced-colors:keyboard-mode:focus:outline-focus-ring',
'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]',
itemWidthStyles,
isSelected && tw('forced-colors:text-[SelectedItemText]'),
!isSelected &&
tw(
'data-[axo-contextmenu-state=open]:bg-fill-secondary',
'forced-colors:data-[axo-contextmenu-state=open]:bg-[Highlight]'
)
)}
>
{props.children}
{isSelected && (
<motion.span
layoutId={`${context.id}.Indicator`}
className={config.indicatorStyles}
transition={IndicatorTransition}
style={{ borderRadius: 14 }}
/>
)}
</button>
);
})
);
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoBaseSegmentedControl.ItemText>
* ---------------------------------------------
* <AxoBaseSegmentedControl.ItemText>
* --------------------------------------------------------------------------
*/
/** CSS `max-width` value for the item label, used to prevent overflow. */
export type ItemMaxWidth = CSSProperties['maxWidth'];
export type ItemTextProps = Readonly<{
/** Maximum width for the label before it truncates. */
maxWidth?: ItemMaxWidth;
children: ReactNode;
}>;
/** Truncated label text inside a segmented control item. */
export const ItemText: FC<ItemTextProps> = memo(props => {
return (
<span
@@ -225,11 +238,11 @@ export namespace ExperimentalAxoBaseSegmentedControl {
);
});
ItemText.displayName = `${Namespace}.ItemText`;
ItemText.displayName = 'AxoBaseSegmentedControl.ItemText';
/**
* Component: <AxoBaseSegmentedControl.ItemBadge>
* ----------------------------------------------
* <AxoBaseSegmentedControl.ItemBadge>
* --------------------------------------------------------------------------
*/
export type ExperimentalItemBadgeProps = Omit<
@@ -237,6 +250,7 @@ export namespace ExperimentalAxoBaseSegmentedControl {
'size'
>;
/** A badge rendered to the right of the item label. */
export const ExperimentalItemBadge = memo(
(props: ExperimentalItemBadgeProps) => {
return (
@@ -246,12 +260,12 @@ export namespace ExperimentalAxoBaseSegmentedControl {
value={props.value}
max={props.max}
maxDisplay={props.maxDisplay}
aria-label={props['aria-label']}
label={props.label}
/>
</span>
);
}
);
ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
ExperimentalItemBadge.displayName = 'AxoBaseSegmentedControl.ItemBadge';
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactNode } from 'react';
import { memo, useState } from 'react';
import { createStrictContext, useStrictContext } from './StrictContext.dom.tsx';
const IntlContext =
createStrictContext<AxoIntl.ContextType>('AxoIntl.Provider');
/** @internal Localization context for built-in Axo UI strings. */
export namespace AxoIntl {
const DefaultMessages = {
'AxoButton.Pending': 'Pending',
'AxoDialog.Back': 'Back',
'AxoDialog.Close': 'Close',
'AxoTextField.Clear': 'Clear',
};
/** A key for a built-in Axo UI string. */
export type MessageKey = keyof typeof DefaultMessages;
/** Map of all message keys to their translated strings. */
export type Messages = Record<MessageKey, string>;
/** The intl API available via `useAxoIntl`. */
export type ContextType = Readonly<{
get: (key: MessageKey) => string;
}>;
function createIntlContext(messages: Messages): ContextType {
return {
get(key) {
return messages[key] ?? DefaultMessages[key];
},
};
}
/**
* <AxoIntl.Provider>
* --------------------------------------------------------------------------
*/
export type ProviderProps = Readonly<{
/** Translated strings for all Axo message keys. */
messages: Messages;
children: ReactNode;
}>;
/** Provides translated strings to all Axo components in the tree. */
export const Provider: FC<ProviderProps> = memo(props => {
const { messages } = props;
const [intl] = useState(() => {
return createIntlContext(messages);
});
return (
<IntlContext.Provider value={intl}>{props.children}</IntlContext.Provider>
);
});
Provider.displayName = 'AxoIntl.Provider';
}
/**
* useAxoIntl()
* --------------------------------------------------------------------------
*/
/** Returns the intl context for reading translated Axo UI strings. */
export function useAxoIntl(): AxoIntl.ContextType {
return useStrictContext(IntlContext);
}
@@ -7,6 +7,12 @@ export type FlexWrapDetectorProps = Readonly<{
children: ReactNode;
}>;
/**
* Detects when flex items wrap and exposes `container-scrollable` /
* `container-not-scrollable` container-query states to descendants.
* Used internally by `AxoAlertDialog.Footer` to toggle between stacked
* (full-width) and inline (equal-basis) button layouts.
*/
export const FlexWrapDetector = memo(function FlexWrapDetector(
props: FlexWrapDetectorProps
) {
+15
View File
@@ -7,16 +7,31 @@ import { createContext, useContext } from 'react';
const EMPTY: unique symbol = Symbol('STRICT_CONTEXT_EMPTY');
const WRAPPER: unique symbol = Symbol('STRICT_CONTEXT_MESSAGE');
/**
* A React context that throws if consumed outside its provider,
* rather than silently returning `undefined`.
*/
export type StrictContext<T> = Context<T | typeof EMPTY> & {
[WRAPPER]: string;
};
/**
* Similar to `createContext()` but does not accept a nullable value in
* `Provider`.
*
* Use with `useStrictContext()` to assert that the component is wrapped with a
* provider.
*/
export function createStrictContext<T>(wrapper: string): StrictContext<T> {
return Object.assign(createContext<T | typeof EMPTY>(EMPTY), {
[WRAPPER]: wrapper,
});
}
/**
* Similar to `useContext()` but throws a descriptive error if the component is
* not wrapped with a provider.
*/
export function useStrictContext<T>(
context: StrictContext<T>,
message?: string
+11 -3
View File
@@ -21,7 +21,13 @@ const AbstractRoles = {
window: true,
} as const satisfies Record<string, true>;
/** An abstract WAI-ARIA role (e.g. `widget`, `landmark`). Cannot be assigned to elements directly. */
export type AbstractAriaRole = keyof typeof AbstractRoles;
/**
* A concrete WAI-ARIA role assignable to an element, including roles
* missing from React's built-in `AriaRole` type.
*/
export type AriaRole =
| Exclude<ReactAriaRole, object>
// Missing from React's types
@@ -152,7 +158,7 @@ const ParentRoles = {
dialog: ['window'],
} as const satisfies Record<AnyAriaRole, ReadonlyArray<AnyAriaRole>>;
function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
function _inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
if (role === superRole) {
return true;
}
@@ -160,7 +166,7 @@ function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
const parentRoles = assert(ParentRoles[role], `Unknown Aria role: ${role}`);
for (const parentRole of parentRoles) {
if (inherits(parentRole, superRole)) {
if (_inherits(parentRole, superRole)) {
return true;
}
}
@@ -172,6 +178,7 @@ function isValidAriaRole(role: string | null): role is AriaRole {
return role != null && Object.hasOwn(ParentRoles, role);
}
/** Returns the computed WAI-ARIA role of a DOM element, or `null` if the role is not a valid concrete role. */
export function getElementAriaRole(element: Element): AriaRole | null {
const role = getRole(element);
if (isValidAriaRole(role)) {
@@ -180,6 +187,7 @@ export function getElementAriaRole(element: Element): AriaRole | null {
return null;
}
/** Returns `true` if `role` inherits from the `widget` abstract role (i.e. it is interactive). */
export function isAriaWidgetRole(role: AriaRole | null): boolean {
return role != null && inherits(role, 'widget');
return role != null && _inherits(role, 'widget');
}
+89
View File
@@ -1,11 +1,82 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/**
* An error class representing an assertion that failed.
* @example
* ```ts
* function example(value: number) {
* assert(Number.isSafeInteger(value))
* // >> AssertionError: Value is not an integer
* }
* ```
*/
class AssertionError extends TypeError {
override name = 'AssertionError';
}
/**
* Assert a condition is true.
* @example
* ```ts
* function example(value: number) {
* assert(Number.isSafeInteger(value)) // throws if not safe integer
* // ...
* }
* ```
*
* Can alternatively be used to assert a value is not null or undefined,
* returning the non-nullable value.
* @example
* ```ts
* function example(value: string | null | undefined) {
* let result = assert(value) // asserts is not null or undefined
* result.toUpperCase() // string
* }
* ```
*
* Optionally provide a message:
* @example
* ```ts
* function example(value: number) {
* assert(Number.isSafeInteger(value), "Value is not an integer")
* // >> AssertionError: Value is not an integer
* // ...
* }
* ```
*/
export function assert(condition: boolean, message?: string): asserts condition;
/**
* Assert a value is not null or undefined, returning the non-nullable value.
* @example
* ```ts
* function example(value: string | null | undefined) {
* let result = assert(value) // asserts is not null or undefined
* result.toUpperCase() // string
* }
*
* ```
*
* Can alternatively be used to assert a condition is true.
* @example
* ```ts
* function example(value: number) {
* assert(Number.isSafeInteger(value)) // throws if not safe integer
* // ...
* }
* ```
*
* Optionally provide a message:
* @example
* ```ts
* function example(value: string | null | undefined) {
* let result = assert(value, "Missing value")
* // >> AssertionError: Missing value
* result.toUpperCase() // string
* }
* ```
*/
export function assert<T>(input: T, message?: string): NonNullable<T>;
export function assert<T>(input: T, message?: string): NonNullable<T> {
if (input === false || input == null) {
@@ -17,6 +88,24 @@ export function assert<T>(input: T, message?: string): NonNullable<T> {
return input;
}
/**
* Assert that a state should never be reached.
*
* Can be used to make an exhaustive check on a value.
*
* @example
* ```tsx
* function example(value: 'one' | 'two') {
* if (value === 'one') {
* // ...
* } else if (value === 'two') {
* // ...
* } else {
* unreachable(value);
* }
* }
* ```
*/
export function unreachable(_value: never): never {
// oxlint-disable-next-line no-debugger
debugger;
+10
View File
@@ -153,6 +153,16 @@ function applyGlobalProperties(
};
}
/**
* Mounts hidden scrollers into the DOM to measure system scrollbar gutter widths.
*
* Provides values as custom CSS properties on `<html>`:
*
* - `--axo-scrollbar-gutter-auto-vertical`
* - `--axo-scrollbar-gutter-auto-horizontal`
* - `--axo-scrollbar-gutter-thin-vertical`
* - `--axo-scrollbar-gutter-thin-horizontal`
*/
export function createScrollbarGutterCssProperties(): Unsubscribe {
const autoObserver = new ScrollbarGuttersObserver('auto');
const thinObserver = new ScrollbarGuttersObserver('thin');
+24 -5
View File
@@ -6,7 +6,10 @@ export namespace utf8 {
const HIGH_SURROGATE = 0b11011000_00000000;
const LOW_SURROGATE = 0b11011100_00000000;
/** Source: https://codeberg.org/indutny/protopiler/src/branch/main/lib/utf8.mjs */
/**
* Source: https://codeberg.org/indutny/protopiler/src/branch/main/lib/utf8.mjs
* @internal
*/
function _getByteLength(
input: string,
onlyNeedToKnowIsOver: number | null
@@ -53,6 +56,7 @@ export namespace utf8 {
return size;
}
/** Returns the UTF-8 byte length of `input`. */
export function getByteLength(input: string): number {
return _getByteLength(input, null);
}
@@ -60,36 +64,45 @@ export namespace utf8 {
const MIN_BYTES_PER_CHAR = 1;
const MAX_BYTES_PER_CHAR = 3;
/** @internal */
function _minPossibleBytes(input: string) {
return input.length * MIN_BYTES_PER_CHAR;
}
/** @internal */
function _maxPossibleBytes(input: string) {
return input.length * MAX_BYTES_PER_CHAR;
}
/** Returns `true` if the UTF-8 byte length of `input` is less than `n`. */
export function lt(input: string, n: number): boolean {
return _maxPossibleBytes(input) < n || _getByteLength(input, n) < n;
}
/** Returns `true` if the UTF-8 byte length of `input` is less than or equal to `n`. */
export function lte(input: string, n: number): boolean {
return _maxPossibleBytes(input) <= n || _getByteLength(input, n) <= n;
}
/** Returns `true` if the UTF-8 byte length of `input` is greater than `n`. */
export function gt(input: string, n: number): boolean {
return _minPossibleBytes(input) > n || _getByteLength(input, n) > n;
}
/** Returns `true` if the UTF-8 byte length of `input` is greater than or equal to `n`. */
export function gte(input: string, n: number): boolean {
return _minPossibleBytes(input) >= n || _getByteLength(input, n) >= n;
}
let segmenter: Intl.Segmenter;
function getSegmenter() {
/** @internal */
function _getSegmenter() {
segmenter ??= new Intl.Segmenter('und', { granularity: 'grapheme' });
return segmenter;
}
/** @internal */
function _getGraphemeCount(
input: string,
onlyNeedToKnowIsOver: number | null
@@ -100,7 +113,7 @@ export namespace utf8 {
let result = 0;
// oxlint-disable-next-line no-unused-vars
for (const _grapheme of getSegmenter().segment(input)) {
for (const _grapheme of _getSegmenter().segment(input)) {
result += 1;
if (onlyNeedToKnowIsOver != null && result > onlyNeedToKnowIsOver) {
@@ -111,10 +124,12 @@ export namespace utf8 {
return result;
}
/** Returns the number of user-perceived grapheme clusters in `input`. */
export function getGraphemeCount(input: string): number {
return _getGraphemeCount(input, null);
}
/** Returns `input` truncated to at most `maxBytes` grapheme clusters. */
export function truncateGraphemes(input: string, maxBytes: number): string {
if (maxBytes === 0) {
return '';
@@ -123,7 +138,7 @@ export namespace utf8 {
let count = 0;
let result = '';
for (const item of getSegmenter().segment(input)) {
for (const item of _getSegmenter().segment(input)) {
count += 1;
result += item.segment;
@@ -135,6 +150,10 @@ export namespace utf8 {
return result;
}
/**
* Returns `input` truncated to fit within both `maxBytes` UTF-8 bytes and
* `maxGraphemes` grapheme clusters, whichever limit is reached first.
*/
export function truncateBytesAndGraphemes(
value: string,
maxBytes: number,
@@ -148,7 +167,7 @@ export namespace utf8 {
let remainingBytes = maxBytes;
let remainingGraphemes = maxGraphemes;
for (const grapheme of getSegmenter().segment(value)) {
for (const grapheme of _getSegmenter().segment(value)) {
remainingBytes -= _getByteLength(grapheme.segment, remainingBytes);
remainingGraphemes -= 1;
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Must be namespace import
// See: https://react.dev/reference/react/captureOwnerStack#captureownerstack-is-not-available
import * as React from 'react';
import type { TailwindStyles } from '../tw.dom.tsx';
import { isTestOrMockEnvironment } from '../../environment.std.ts';
export type Variants<
Key extends string | number,
Value = TailwindStyles,
> = Readonly<{
get: (key: Key) => Value;
keys: () => ReadonlyArray<`${Key}`>;
}>;
export function variants<Key extends string | number, Value = TailwindStyles>(
typeName: string,
values: Record<Key, Value>
): Variants<Key, Value> {
return {
get(key) {
if (!Object.hasOwn(values, key) && isTestOrMockEnvironment()) {
// Will not exist in production builds
// See: https://react.dev/reference/react/captureOwnerStack#captureownerstack-is-not-available
const ownerStack = React.captureOwnerStack?.() ?? '';
throw new Error(`Unexpected ${typeName}: "${key}"${ownerStack}`);
}
return values[key];
},
keys() {
return Object.keys(values) as Array<`${Key}`>;
},
};
}
+13
View File
@@ -1,8 +1,21 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/** Opaque type for styles returned by tw() */
export type TailwindStyles = string & { __Styles: never };
/**
* Joins Tailwind CSS class names, filtering out falsy values.
*
* @example
* ```tsx
* tw('flex items-center gap-2')
* // => 'flex items-center gap-2'
*
* tw('my-3', isActive && 'bg-blue-500', isDisabled && 'opacity-50')
* // => 'my-3 bg-blue-500' (when isActive=true, isDisabled=false)
* ```
*/
export function tw(
...classNames: ReadonlyArray<
TailwindStyles | string | boolean | null | undefined
+5 -31
View File
@@ -123,11 +123,7 @@ export function CallQualitySurveyDialog(
<AxoDialog.Title>
{i18n('icu:CallQualitySurvey__HowWasYourCall__PageTitle')}
</AxoDialog.Title>
<AxoDialog.Close
aria-label={i18n(
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
)}
/>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<p className={tw('mb-3 type-body-medium text-label-primary')}>
@@ -173,9 +169,6 @@ export function CallQualitySurveyDialog(
<>
<AxoDialog.Header>
<AxoDialog.Back
aria-label={i18n(
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
)}
onClick={() => {
setPage(Page.HOW_WAS_YOUR_CALL);
}}
@@ -183,11 +176,7 @@ export function CallQualitySurveyDialog(
<AxoDialog.Title>
{i18n('icu:CallQualitySurvey__WhatIssuesDidYouHave__PageTitle')}
</AxoDialog.Title>
<AxoDialog.Close
aria-label={i18n(
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
)}
/>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<p className={tw('mb-3 type-body-medium text-label-primary')}>
@@ -303,9 +292,6 @@ export function CallQualitySurveyDialog(
<>
<AxoDialog.Header>
<AxoDialog.Back
aria-label={i18n(
'icu:CallQualitySurvey__BackButton__AccessibilityLabel'
)}
onClick={() => {
if (!userSatisfied) {
setPage(Page.WHAT_ISSUES_DID_YOU_HAVE);
@@ -317,11 +303,7 @@ export function CallQualitySurveyDialog(
<AxoDialog.Title>
{i18n('icu:CallQualitySurvey__ConfirmSubmission__PageTitle')}
</AxoDialog.Title>
<AxoDialog.Close
aria-label={i18n(
'icu:CallQualitySurvey__CloseButton__AccessibilityLabel'
)}
/>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<p className={tw('mb-3 type-body-medium text-label-primary')}>
@@ -367,15 +349,7 @@ export function CallQualitySurveyDialog(
<AxoDialog.Action
variant="primary"
onClick={handleSubmit}
experimentalSpinner={
isSubmitting
? {
'aria-label': i18n(
'icu:CallQualitySurvey__ConfirmSubmission__Submitting'
),
}
: null
}
pending={isSubmitting}
>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__SubmitButton'
@@ -615,7 +589,7 @@ function IssueToggle(props: {
variant={props.isSelected ? 'primary' : 'secondary'}
size="md"
symbol={isSelected ? 'check' : ISSUE_ICONS[issue]}
aria-pressed={props.isSelected}
pressed={props.isSelected}
onClick={handleClick}
>
{getIssueLabel(i18n, issue)}
@@ -26,9 +26,7 @@ export function DeleteAttachmentConfirmationDialog({
<AxoDialog.Title>
{i18n('icu:DeleteAttachmentModal__Title')}
</AxoDialog.Title>
<AxoDialog.Close
aria-label={i18n('icu:DeleteAttachmentModal__Close')}
/>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
{i18n('icu:DeleteAttachmentModal__Body')}
@@ -42,7 +42,7 @@ export function DonationPrivacyInformationModal({
<AxoDialog.Title screenReaderOnly>
{i18n('icu:PreferencesDonations__privacy-modal-title')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<img
@@ -71,15 +71,7 @@ export function KeyTransparencyErrorDialog(
<AxoDialog.Action
variant="primary"
onClick={handleSubmit}
experimentalSpinner={
isSubmitting
? {
'aria-label': i18n(
'icu:KeyTransparencyErrorDialog__Submitting'
),
}
: null
}
pending={isSubmitting}
>
{i18n('icu:KeyTransparencyErrorDialog__Submit')}
</AxoDialog.Action>
@@ -28,11 +28,7 @@ export function KeyTransparencyOnboardingDialog(
<AxoDialog.Root open={open} onOpenChange={onOpenChange}>
<AxoDialog.Content escape="cancel-is-noop" size="sm">
<AxoDialog.Header>
<AxoDialog.Close
aria-label={i18n(
'icu:KeyTransparencyOnboardingDialog__CloseButton__AccessibilityLabel'
)}
/>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('mt-1.5 mb-3 flex items-center justify-center')}>
+2 -6
View File
@@ -1437,14 +1437,10 @@ export function MediaEditor({
/>
</div>
<AxoButton.Root
disabled={!image || isSaving || isSending}
variant="primary"
size="md"
experimentalSpinner={
isSending
? { 'aria-label': doneButtonLabel || i18n('icu:save') }
: null
}
disabled={!image}
pending={isSaving || isSending}
onClick={async () => {
if (!fabricCanvas) {
return;
@@ -106,15 +106,7 @@ export function PlaintextExportWorkflow({
</AxoDialog.Action>
<AxoDialog.Action
variant="primary"
experimentalSpinner={
shouldShowSpinner
? {
'aria-label': i18n(
'icu:PlaintextExport--Confirmation--WaitingLabel'
),
}
: null
}
pending={shouldShowSpinner}
onClick={() => verifyWithOSForExport(includeMedia)}
>
{i18n('icu:PlaintextExport--Confirmation--ContinueButton')}
+1 -4
View File
@@ -1359,10 +1359,7 @@ export function Preferences({
<AxoButton.Root
variant="secondary"
size="lg"
disabled={nowSyncing}
experimentalSpinner={
nowSyncing ? { 'aria-label': i18n('icu:syncing') } : null
}
pending={nowSyncing}
onClick={async () => {
setShowSyncFailed(false);
setNowSyncing(true);
+3 -17
View File
@@ -348,12 +348,7 @@ export function PreferencesInternal({
variant="secondary"
size="lg"
onClick={validateBackup}
disabled={isValidationPending}
experimentalSpinner={
isValidationPending
? { 'aria-label': i18n('icu:loading') }
: null
}
pending={isValidationPending}
>
{i18n('icu:Preferences__internal__validate-backup')}
</AxoButton.Root>
@@ -526,12 +521,7 @@ export function PreferencesInternal({
variant="secondary"
size="lg"
onClick={() => handleGenerateReceipt(receipt)}
disabled={isGeneratingReceipt}
experimentalSpinner={
isGeneratingReceipt
? { 'aria-label': i18n('icu:loading') }
: null
}
pending={isGeneratingReceipt}
>
Download
</AxoButton.Root>
@@ -762,11 +752,7 @@ export function PreferencesInternal({
variant="secondary"
size="lg"
onClick={handleKeyTransparencyCheck}
experimentalSpinner={
isKeyTransparencyRunning
? { 'aria-label': i18n('icu:loading') }
: null
}
pending={isKeyTransparencyRunning}
>
Check
</AxoButton.Root>
@@ -266,10 +266,7 @@ export function PreferencesLocalBackups({
<AxoButton.Root
variant="secondary"
size="lg"
disabled={isAuthPending}
experimentalSpinner={
isAuthPending ? { 'aria-label': i18n('icu:loading') } : null
}
pending={isAuthPending}
onClick={async () => {
if (
!previouslyViewedBackupKeyHash ||
@@ -514,9 +511,7 @@ function DisableLocalBackupsDialog({
</AxoDialog.Action>
<AxoDialog.Action
variant="destructive"
experimentalSpinner={
isPending ? { 'aria-label': i18n('icu:loading') } : null
}
pending={isPending}
onClick={handleDisableLocalBackups}
>
{i18n('icu:Preferences__local-backups-turn-off-action')}
@@ -1241,7 +1241,7 @@ function NotificationProfilesEditPage({
/>
<AriaClickable.HiddenTrigger
onClick={onEditName}
aria-labelledby="edit-icon"
labelledby="edit-icon"
/>
</span>
@@ -1255,22 +1255,12 @@ function NotificationProfilesEditPage({
onUpdateOverrideState(value);
}}
>
<AxoSelect.Trigger placeholder={currentActiveString}>
{currentActiveString}
</AxoSelect.Trigger>
<AxoSelect.Trigger placeholder="" />
<AxoSelect.Content>
<AxoSelect.Item
key="isActive"
value={activeString}
textValue={activeString}
>
<AxoSelect.Item value={activeString}>
<AxoSelect.ItemText>{activeString}</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item
key="isNotActive"
value={notActiveString}
textValue={notActiveString}
>
<AxoSelect.Item value={notActiveString}>
<AxoSelect.ItemText>{notActiveString}</AxoSelect.ItemText>
</AxoSelect.Item>
</AxoSelect.Content>
+2 -2
View File
@@ -377,7 +377,7 @@ export function ProfileEditor({
maxBytes={128}
autoFocus
showCount
showClearWithLabel={i18n('icu:clear')}
showClear
/>
</AxoTextField.Root>
@@ -394,7 +394,7 @@ export function ProfileEditor({
maxGraphemes={26}
maxBytes={128}
showCount
showClearWithLabel={i18n('icu:clear')}
showClear
/>
</AxoTextField.Root>
</div>
+1 -1
View File
@@ -55,7 +55,7 @@ export function SafetyNumberModal({
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>{title}</AxoDialog.Title>
{hasXButton && <AxoDialog.Close aria-label={i18n('icu:close')} />}
{hasXButton && <AxoDialog.Close />}
</AxoDialog.Header>
<AxoDialog.Body maxHeight={560}>{content}</AxoDialog.Body>
</AxoDialog.Content>
+2 -2
View File
@@ -81,7 +81,7 @@ function SafetyTipsSummary({
<AxoDialog.Title>
{i18n('icu:SafetyTipsModal__Title-v2')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:close')} />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('py-4')}>
@@ -199,7 +199,7 @@ function SafetyTipsDetails({ i18n }: { i18n: LocalizerType }): JSX.Element {
{i18n('icu:SafetyTipsModal__Title-v2')}
</AxoDialog.Title>
</div>
<AxoDialog.Close aria-label={i18n('icu:close')} />
<AxoDialog.Close />
</AxoDialog.Header>
<Tabs.Root
value={tips[pageIndex]?.key}
+1 -3
View File
@@ -397,9 +397,7 @@ export function UsernameEditor({
size="lg"
disabled={!canSave}
onClick={onSave}
experimentalSpinner={
isConfirming ? { 'aria-label': i18n('icu:loading') } : null
}
pending={isConfirming}
>
{i18n('icu:save')}
</AxoButton.Root>
@@ -405,8 +405,8 @@ function CollapseSetButton(
variant="secondary"
arrow={arrow}
symbol={symbol}
aria-expanded={ariaExpanded}
aria-controls={disclosureContentId}
expanded={ariaExpanded}
controls={disclosureContentId}
onClick={onClick}
>
{text}
@@ -27,7 +27,7 @@ export function ProfileNameWarningModal({
disableMissingAriaDescriptionWarning
>
<AxoDialog.Header>
<AxoDialog.Close aria-label={i18n('icu:close')} />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<div className={tw('flex justify-center')}>
@@ -112,11 +112,6 @@ export function GroupMemberLabelEditor({
(labelStringForSave || undefined) !== (existingLabelString || undefined);
const canSave =
isDirty && ((!labelEmoji && !labelStringForSave) || labelStringForSave);
const spinner = isSaving
? {
'aria-label': i18n('icu:ConversationDetails--member-label--saving'),
}
: undefined;
const contactLabelForMessage = labelStringForSave
? { labelEmoji, labelString: labelStringForSave }
@@ -373,8 +368,8 @@ export function GroupMemberLabelEditor({
<AxoButton.Root
variant="primary"
size="md"
experimentalSpinner={spinner}
disabled={!canSave || isSaving}
pending={isSaving}
disabled={!canSave}
onClick={() => {
setIsSaving(true);
updateGroupMemberLabel(
@@ -54,10 +54,7 @@ export function PanelRow({
className={classNames(bem('root', 'button'), className)}
>
{!disabled && (
<AriaClickable.HiddenTrigger
onClick={onClick}
aria-labelledby={labelId}
/>
<AriaClickable.HiddenTrigger onClick={onClick} labelledby={labelId} />
)}
<div className={bem('inner')}>
{content}
@@ -150,7 +150,7 @@ export function ListItem({
</div>
{renderContextMenu(
mediaItem,
<AriaClickable.HiddenTrigger aria-label={label} onClick={handleClick} />
<AriaClickable.HiddenTrigger label={label} onClick={handleClick} />
)}
<AriaClickable.SubWidget>
<button
@@ -233,7 +233,7 @@ function PinMessageSelectDurationDialog(props: {
<AxoDialog.Title>
{i18n('icu:PinMessageDialog__Title')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<AxoRadioGroup.Root
@@ -271,7 +271,7 @@ function HiddenTrigger(props: {
return (
<AriaClickable.HiddenTrigger
aria-label={i18n(
label={i18n(
'icu:PinnedMessagesBar__GoToMessageClickableArea__AccessibilityLabel'
)}
onClick={handlePinGoToCurrent}
@@ -219,7 +219,7 @@ function ChatFolderSelectItem(props: {
maxCount: UNREAD_BADGE_MAX_COUNT,
}
)}
aria-label={null}
label={null}
/>
)}
</AxoSelect.Item>
@@ -263,7 +263,7 @@ function ChatFolderSegmentedControlItem(props: {
'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
{ maxCount: UNREAD_BADGE_MAX_COUNT }
)}
aria-label={null}
label={null}
/>
)}
</ExperimentalAxoSegmentedControl.Item>
+9 -14
View File
@@ -1,16 +1,13 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import EmbedBlot from '@signalapp/quill-cjs/blots/embed.js';
import { createRoot } from 'react-dom/client';
import { Emojify } from '../../components/conversation/Emojify.dom.tsx';
import { normalizeAci } from '../../util/normalizeAci.std.ts';
import type { MentionBlotValue } from '../util.dom.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
const { i18n } = window.SignalContext;
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
export class MentionBlot extends EmbedBlot {
static override blotName = 'mention';
@@ -49,16 +46,14 @@ export class MentionBlot extends EmbedBlot {
const mentionSpan = document.createElement('span');
createRoot(mentionSpan).render(
<StrictMode>
<AxoProvider dir={i18n.getLocaleDirection()}>
<span className="module-composition-input__at-mention">
<bdi>
@
<Emojify text={mention.title} />
</bdi>
</span>
</AxoProvider>
</StrictMode>
<AppProvider>
<span className="module-composition-input__at-mention">
<bdi>
@
<Emojify text={mention.title} />
</bdi>
</span>
</AppProvider>
);
node.appendChild(mentionSpan);
+7 -11
View File
@@ -1,26 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ClearingData } from '../components/ClearingData.dom.tsx';
import { strictAssert } from '../util/assert.std.ts';
import { deleteAllData } from './deleteAllData.preload.ts';
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../windows/AppProvider.dom.tsx';
export function renderClearingDataView(): void {
const appContainer = document.getElementById('app-container');
strictAssert(appContainer != null, 'No #app-container');
createRoot(appContainer).render(
<StrictMode>
<AxoProvider dir={window.SignalContext.i18n.getLocaleDirection()}>
<ClearingData
deleteAllData={deleteAllData}
i18n={window.SignalContext.i18n}
/>
</AxoProvider>
</StrictMode>
<AppProvider>
<ClearingData
deleteAllData={deleteAllData}
i18n={window.SignalContext.i18n}
/>
</AppProvider>
);
}
+3 -3
View File
@@ -8,14 +8,14 @@ import type { Store } from 'redux';
import { SmartApp } from '../smart/App.preload.tsx';
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.preload.tsx';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
export const createApp = (store: Store): ReactElement => (
<AxoProvider dir={window.SignalContext.i18n.getLocaleDirection()}>
<AppProvider>
<Provider store={store}>
<SmartVoiceNotesPlaybackProvider>
<SmartApp />
</SmartVoiceNotesPlaybackProvider>
</Provider>
</AxoProvider>
</AppProvider>
);
@@ -4,14 +4,12 @@
// TODO DESKTOP-4761
import type { ReactElement } from 'react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
import { ModalHost } from '../../components/ModalHost.dom.tsx';
import type { SmartGroupV2JoinDialogProps } from '../smart/GroupV2JoinDialog.dom.tsx';
import { SmartGroupV2JoinDialog } from '../smart/GroupV2JoinDialog.dom.tsx';
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
export const createGroupV2JoinModal = (
store: Store,
@@ -20,10 +18,12 @@ export const createGroupV2JoinModal = (
const { onClose } = props;
return (
<Provider store={store}>
<ModalHost modalName="createGroupV2JoinModal" onClose={onClose}>
<SmartGroupV2JoinDialog {...props} />
</ModalHost>
</Provider>
<AppProvider>
<Provider store={store}>
<ModalHost modalName="createGroupV2JoinModal" onClose={onClose}>
<SmartGroupV2JoinDialog {...props} />
</ModalHost>
</Provider>
</AppProvider>
);
};
+226 -228
View File
@@ -1,7 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import type { AudioDevice } from '@signalapp/ringrtc';
@@ -91,7 +91,6 @@ import { useBackupActions } from '../ducks/backups.preload.ts';
import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.ts';
import { SmartPreferencesChatFoldersPage } from './PreferencesChatFoldersPage.preload.tsx';
import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.preload.tsx';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import {
getCurrentChatFoldersCount,
getHasAnyCurrentCustomChatFolders,
@@ -122,6 +121,7 @@ import type { ZoomFactorType } from '../../types/StorageKeys.std.ts';
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.preload.ts';
import { getBackupKeyHash } from '../../services/backups/crypto.preload.ts';
import { Emoji } from '../../axo/emoji.std.ts';
import { AppProvider } from '../../windows/AppProvider.dom.tsx';
const DEFAULT_NOTIFICATION_SETTING = 'message';
@@ -895,231 +895,229 @@ export function SmartPreferences(): JSX.Element | null {
};
return (
<StrictMode>
<AxoProvider dir={i18n.getLocaleDirection()}>
<Preferences
backupKey={backupKey}
backupKeyHash={backupKeyHash}
addCustomColor={addCustomColor}
autoDownloadAttachment={autoDownloadAttachment}
availableCameras={availableCameras}
availableLocales={availableLocales}
availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers}
backupTier={backupLevelFromNumber(backupTier)}
backupSubscriptionStatus={
backupSubscriptionStatus ?? { status: 'not-found' }
}
backupFreeMediaDays={backupFreeMediaDays}
backupMediaDownloadStatus={{
completedBytes: backupMediaDownloadCompletedBytes ?? 0,
totalBytes: backupMediaDownloadTotalBytes ?? 0,
isPaused: Boolean(backupMediaDownloadPaused),
isIdle: Boolean(attachmentDownloadManagerIdled),
}}
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
badge={badge}
blockedContacts={blockedContacts}
blockedGroups={blockedGroups}
currentChatFoldersCount={currentChatFoldersCount}
cloudBackupStatus={cloudBackupStatus}
customColors={customColors}
defaultConversationColor={defaultConversationColor}
deviceName={deviceName}
disableLocalBackups={backupsService.disableLocalBackups}
emojiSkinToneDefault={emojiSkinToneDefault}
phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData}
editCustomColor={editCustomColor}
getConversationsWithCustomColor={getConversationsWithCustomColor}
getMessageCountBySchemaVersion={
DataReader.getMessageCountBySchemaVersion
}
getMessageSampleForSchemaVersion={
DataReader.getMessageSampleForSchemaVersion
}
getPreferredBadge={getPreferredBadge}
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
hasAudioNotifications={hasAudioNotifications}
hasAutoConvertEmoji={hasAutoConvertEmoji}
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
hasAutoLaunch={hasAutoLaunch}
hasKeepMutedChatsArchived={hasKeepMutedChatsArchived}
hasCallNotifications={hasCallNotifications}
hasCallRingtoneNotification={hasCallRingtoneNotification}
hasContentProtection={hasContentProtection}
hasCountMutedConversations={hasCountMutedConversations}
hasFailedStorySends={hasFailedStorySends}
hasHideMenuBar={hasHideMenuBar}
hasIncomingCallNotifications={hasIncomingCallNotifications}
hasKeyTransparencyDisabled={hasKeyTransparencyDisabled}
hasLinkPreviews={hasLinkPreviews}
hasMediaCameraPermissions={hasMediaCameraPermissions}
hasMediaPermissions={hasMediaPermissions}
hasMessageAudio={hasMessageAudio}
hasMinimizeToAndStartInSystemTray={hasMinimizeToAndStartInSystemTray}
hasMinimizeToSystemTray={hasMinimizeToSystemTray}
hasNotificationAttention={hasNotificationAttention}
hasNotifications={hasNotifications}
hasPreferContactAvatars={hasPreferContactAvatars}
hasReadReceipts={hasReadReceipts}
hasRelayCalls={hasRelayCalls}
hasSealedSenderIndicators={hasSealedSenderIndicators}
hasSpellCheck={hasSpellCheck}
hasStoriesDisabled={hasStoriesDisabled}
hasTextFormatting={hasTextFormatting}
hasTypingIndicators={hasTypingIndicators}
i18n={i18n}
initialSpellCheckSetting={initialSpellCheckSetting}
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
isAutoLaunchSupported={isAutoLaunchSupported}
isContentProtectionNeeded={isContentProtectionNeeded}
isContentProtectionSupported={isContentProtectionSupported}
isHideMenuBarSupported={isHideMenuBarSupported}
isKeyTransparencyAvailable={isKeyTransparencyAvailable}
isMinimizeToAndStartInSystemTraySupported={
isMinimizeToAndStartInSystemTraySupported
}
isNotificationAttentionSupported={isNotificationAttentionSupported}
isPlaintextExportEnabled={isPlaintextExportEnabled}
isSyncSupported={isSyncSupported}
isSystemTraySupported={isSystemTraySupported}
isInternalUser={isInternalUser}
lastLocalBackup={lastLocalBackup}
lastSyncTime={lastSyncTime}
localBackupFolder={localBackupFolder}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
me={me}
navTabsCollapsed={navTabsCollapsed}
notificationContent={notificationContent}
notificationProfileCount={notificationProfileCount}
onAudioNotificationsChange={onAudioNotificationsChange}
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
onAutoLaunchChange={onAutoLaunchChange}
onBackupKeyViewed={onBackupKeyViewed}
onCallNotificationsChange={onCallNotificationsChange}
onCallRingtoneNotificationChange={onCallRingtoneNotificationChange}
onContentProtectionChange={onContentProtectionChange}
onCountMutedConversationsChange={onCountMutedConversationsChange}
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
onHasKeyTransparencyDisabledChanged={
onHasKeyTransparencyDisabledChanged
}
onHasStoriesDisabledChanged={onHasStoriesDisabledChanged}
onHideMenuBarChange={onHideMenuBarChange}
onIncomingCallNotificationsChange={onIncomingCallNotificationsChange}
onKeepMutedChatsArchivedChange={onKeepMutedChatsArchivedChange}
onLastSyncTimeChange={onLastSyncTimeChange}
onLinkPreviewsChange={onLinkPreviewsChange}
onLocaleChange={onLocaleChange}
onMediaCameraPermissionsChange={onMediaCameraPermissionsChange}
onMediaPermissionsChange={onMediaPermissionsChange}
onMessageAudioChange={onMessageAudioChange}
onMinimizeToAndStartInSystemTrayChange={
onMinimizeToAndStartInSystemTrayChange
}
onMinimizeToSystemTrayChange={onMinimizeToSystemTrayChange}
onNotificationAttentionChange={onNotificationAttentionChange}
onNotificationContentChange={onNotificationContentChange}
onNotificationsChange={onNotificationsChange}
onStartUpdate={startUpdate}
onPreferContactAvatarsChange={onPreferContactAvatarsChange}
onReadReceiptsChange={onReadReceiptsChange}
onRelayCallsChange={onRelayCallsChange}
onSealedSenderIndicatorsChange={onSealedSenderIndicatorsChange}
onSelectedCameraChange={onSelectedCameraChange}
onSelectedMicrophoneChange={onSelectedMicrophoneChange}
onSelectedSpeakerChange={onSelectedSpeakerChange}
onSentMediaQualityChange={onSentMediaQualityChange}
onSpellCheckChange={onSpellCheckChange}
onTextFormattingChange={onTextFormattingChange}
onThemeChange={onThemeChange}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onTypingIndicatorsChange={onTypingIndicatorsChange}
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
onWhoCanFindMeChange={onWhoCanFindMeChange}
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
openFileInFolder={openFileInFolder}
osName={osName}
otherTabsUnreadStats={otherTabsUnreadStats}
settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder}
preferredSystemLocales={preferredSystemLocales}
preferredWidthFromStorage={preferredWidthFromStorage}
refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor}
renderDonationsPane={renderDonationsPane}
renderNotificationProfilesHome={renderNotificationProfilesHome}
renderNotificationProfilesCreateFlow={
renderNotificationProfilesCreateFlow
}
renderProfileEditor={renderProfileEditor}
renderToastManager={renderToastManagerWithoutMegaphone}
renderUpdateDialog={renderUpdateDialog}
renderPreferencesChatFoldersPage={renderPreferencesChatFoldersPage}
renderPreferencesEditChatFolderPage={
renderPreferencesEditChatFolderPage
}
previouslyViewedBackupKeyHash={previouslyViewedBackupKeyHash}
promptOSAuth={promptOSAuth}
resetAllChatColors={resetAllChatColors}
resetDefaultChatColor={resetDefaultChatColor}
resolvedLocale={resolvedLocale}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
resumeBackupMediaDownload={resumeBackupMediaDownload}
pauseBackupMediaDownload={pauseBackupMediaDownload}
cancelBackupMediaDownload={cancelBackupMediaDownload}
selectedCamera={selectedCamera}
selectedMicrophone={selectedMicrophone}
selectedSpeaker={selectedSpeaker}
sentMediaQualitySetting={sentMediaQualitySetting}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
setSettingsLocation={setSettingsLocation}
shouldShowUpdateDialog={shouldShowUpdateDialog}
showToast={showToast}
startLocalBackupExport={startLocalBackupExport}
startPlaintextExport={startPlaintextExport}
theme={theme}
themeSetting={themeSetting}
universalExpireTimer={universalExpireTimer}
validateBackup={validateBackup}
whoCanFindMe={whoCanFindMe}
whoCanSeeMe={whoCanSeeMe}
zoomFactor={zoomFactor}
donationReceipts={donationReceipts}
internalAddDonationReceipt={internalAddDonationReceipt}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
addVisibleMegaphone={addVisibleMegaphone}
internalDeleteAllMegaphones={internalDeleteAllMegaphones}
__dangerouslyRunAbitraryReadOnlySqlQuery={
__dangerouslyRunAbitraryReadOnlySqlQuery
}
cqsTestMode={cqsTestMode}
setCqsTestMode={setCqsTestMode}
dredDuration={items.dredDuration}
setDredDuration={setDredDuration}
setIsDirectVp9Enabled={setIsDirectVp9Enabled}
isDirectVp9Enabled={items.isDirectVp9Enabled}
setDirectMaxBitrate={setDirectMaxBitrate}
directMaxBitrate={items.directMaxBitrate}
setIsGroupVp9Enabled={setIsGroupVp9Enabled}
isGroupVp9Enabled={items.isGroupVp9Enabled}
setGroupMaxBitrate={setGroupMaxBitrate}
groupMaxBitrate={items.groupMaxBitrate}
sfuUrl={items.sfuUrl}
setSfuUrl={setSfuUrl}
forceKeyTransparencyCheck={forceKeyTransparencyCheck}
keyTransparencySelfHealth={items.keyTransparencySelfHealth}
weArePrimaryDevice={weArePrimaryDevice}
/>
</AxoProvider>
</StrictMode>
<AppProvider>
<Preferences
backupKey={backupKey}
backupKeyHash={backupKeyHash}
addCustomColor={addCustomColor}
autoDownloadAttachment={autoDownloadAttachment}
availableCameras={availableCameras}
availableLocales={availableLocales}
availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers}
backupTier={backupLevelFromNumber(backupTier)}
backupSubscriptionStatus={
backupSubscriptionStatus ?? { status: 'not-found' }
}
backupFreeMediaDays={backupFreeMediaDays}
backupMediaDownloadStatus={{
completedBytes: backupMediaDownloadCompletedBytes ?? 0,
totalBytes: backupMediaDownloadTotalBytes ?? 0,
isPaused: Boolean(backupMediaDownloadPaused),
isIdle: Boolean(attachmentDownloadManagerIdled),
}}
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
badge={badge}
blockedContacts={blockedContacts}
blockedGroups={blockedGroups}
currentChatFoldersCount={currentChatFoldersCount}
cloudBackupStatus={cloudBackupStatus}
customColors={customColors}
defaultConversationColor={defaultConversationColor}
deviceName={deviceName}
disableLocalBackups={backupsService.disableLocalBackups}
emojiSkinToneDefault={emojiSkinToneDefault}
phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData}
editCustomColor={editCustomColor}
getConversationsWithCustomColor={getConversationsWithCustomColor}
getMessageCountBySchemaVersion={
DataReader.getMessageCountBySchemaVersion
}
getMessageSampleForSchemaVersion={
DataReader.getMessageSampleForSchemaVersion
}
getPreferredBadge={getPreferredBadge}
hasAnyCurrentCustomChatFolders={hasAnyCurrentCustomChatFolders}
hasAudioNotifications={hasAudioNotifications}
hasAutoConvertEmoji={hasAutoConvertEmoji}
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
hasAutoLaunch={hasAutoLaunch}
hasKeepMutedChatsArchived={hasKeepMutedChatsArchived}
hasCallNotifications={hasCallNotifications}
hasCallRingtoneNotification={hasCallRingtoneNotification}
hasContentProtection={hasContentProtection}
hasCountMutedConversations={hasCountMutedConversations}
hasFailedStorySends={hasFailedStorySends}
hasHideMenuBar={hasHideMenuBar}
hasIncomingCallNotifications={hasIncomingCallNotifications}
hasKeyTransparencyDisabled={hasKeyTransparencyDisabled}
hasLinkPreviews={hasLinkPreviews}
hasMediaCameraPermissions={hasMediaCameraPermissions}
hasMediaPermissions={hasMediaPermissions}
hasMessageAudio={hasMessageAudio}
hasMinimizeToAndStartInSystemTray={hasMinimizeToAndStartInSystemTray}
hasMinimizeToSystemTray={hasMinimizeToSystemTray}
hasNotificationAttention={hasNotificationAttention}
hasNotifications={hasNotifications}
hasPreferContactAvatars={hasPreferContactAvatars}
hasReadReceipts={hasReadReceipts}
hasRelayCalls={hasRelayCalls}
hasSealedSenderIndicators={hasSealedSenderIndicators}
hasSpellCheck={hasSpellCheck}
hasStoriesDisabled={hasStoriesDisabled}
hasTextFormatting={hasTextFormatting}
hasTypingIndicators={hasTypingIndicators}
i18n={i18n}
initialSpellCheckSetting={initialSpellCheckSetting}
isAutoDownloadUpdatesSupported={isAutoDownloadUpdatesSupported}
isAutoLaunchSupported={isAutoLaunchSupported}
isContentProtectionNeeded={isContentProtectionNeeded}
isContentProtectionSupported={isContentProtectionSupported}
isHideMenuBarSupported={isHideMenuBarSupported}
isKeyTransparencyAvailable={isKeyTransparencyAvailable}
isMinimizeToAndStartInSystemTraySupported={
isMinimizeToAndStartInSystemTraySupported
}
isNotificationAttentionSupported={isNotificationAttentionSupported}
isPlaintextExportEnabled={isPlaintextExportEnabled}
isSyncSupported={isSyncSupported}
isSystemTraySupported={isSystemTraySupported}
isInternalUser={isInternalUser}
lastLocalBackup={lastLocalBackup}
lastSyncTime={lastSyncTime}
localBackupFolder={localBackupFolder}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
me={me}
navTabsCollapsed={navTabsCollapsed}
notificationContent={notificationContent}
notificationProfileCount={notificationProfileCount}
onAudioNotificationsChange={onAudioNotificationsChange}
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
onAutoLaunchChange={onAutoLaunchChange}
onBackupKeyViewed={onBackupKeyViewed}
onCallNotificationsChange={onCallNotificationsChange}
onCallRingtoneNotificationChange={onCallRingtoneNotificationChange}
onContentProtectionChange={onContentProtectionChange}
onCountMutedConversationsChange={onCountMutedConversationsChange}
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
onHasKeyTransparencyDisabledChanged={
onHasKeyTransparencyDisabledChanged
}
onHasStoriesDisabledChanged={onHasStoriesDisabledChanged}
onHideMenuBarChange={onHideMenuBarChange}
onIncomingCallNotificationsChange={onIncomingCallNotificationsChange}
onKeepMutedChatsArchivedChange={onKeepMutedChatsArchivedChange}
onLastSyncTimeChange={onLastSyncTimeChange}
onLinkPreviewsChange={onLinkPreviewsChange}
onLocaleChange={onLocaleChange}
onMediaCameraPermissionsChange={onMediaCameraPermissionsChange}
onMediaPermissionsChange={onMediaPermissionsChange}
onMessageAudioChange={onMessageAudioChange}
onMinimizeToAndStartInSystemTrayChange={
onMinimizeToAndStartInSystemTrayChange
}
onMinimizeToSystemTrayChange={onMinimizeToSystemTrayChange}
onNotificationAttentionChange={onNotificationAttentionChange}
onNotificationContentChange={onNotificationContentChange}
onNotificationsChange={onNotificationsChange}
onStartUpdate={startUpdate}
onPreferContactAvatarsChange={onPreferContactAvatarsChange}
onReadReceiptsChange={onReadReceiptsChange}
onRelayCallsChange={onRelayCallsChange}
onSealedSenderIndicatorsChange={onSealedSenderIndicatorsChange}
onSelectedCameraChange={onSelectedCameraChange}
onSelectedMicrophoneChange={onSelectedMicrophoneChange}
onSelectedSpeakerChange={onSelectedSpeakerChange}
onSentMediaQualityChange={onSentMediaQualityChange}
onSpellCheckChange={onSpellCheckChange}
onTextFormattingChange={onTextFormattingChange}
onThemeChange={onThemeChange}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onTypingIndicatorsChange={onTypingIndicatorsChange}
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
onWhoCanFindMeChange={onWhoCanFindMeChange}
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
openFileInFolder={openFileInFolder}
osName={osName}
otherTabsUnreadStats={otherTabsUnreadStats}
settingsLocation={settingsLocation}
pickLocalBackupFolder={pickLocalBackupFolder}
preferredSystemLocales={preferredSystemLocales}
preferredWidthFromStorage={preferredWidthFromStorage}
refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor}
renderDonationsPane={renderDonationsPane}
renderNotificationProfilesHome={renderNotificationProfilesHome}
renderNotificationProfilesCreateFlow={
renderNotificationProfilesCreateFlow
}
renderProfileEditor={renderProfileEditor}
renderToastManager={renderToastManagerWithoutMegaphone}
renderUpdateDialog={renderUpdateDialog}
renderPreferencesChatFoldersPage={renderPreferencesChatFoldersPage}
renderPreferencesEditChatFolderPage={
renderPreferencesEditChatFolderPage
}
previouslyViewedBackupKeyHash={previouslyViewedBackupKeyHash}
promptOSAuth={promptOSAuth}
resetAllChatColors={resetAllChatColors}
resetDefaultChatColor={resetDefaultChatColor}
resolvedLocale={resolvedLocale}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
resumeBackupMediaDownload={resumeBackupMediaDownload}
pauseBackupMediaDownload={pauseBackupMediaDownload}
cancelBackupMediaDownload={cancelBackupMediaDownload}
selectedCamera={selectedCamera}
selectedMicrophone={selectedMicrophone}
selectedSpeaker={selectedSpeaker}
sentMediaQualitySetting={sentMediaQualitySetting}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
setSettingsLocation={setSettingsLocation}
shouldShowUpdateDialog={shouldShowUpdateDialog}
showToast={showToast}
startLocalBackupExport={startLocalBackupExport}
startPlaintextExport={startPlaintextExport}
theme={theme}
themeSetting={themeSetting}
universalExpireTimer={universalExpireTimer}
validateBackup={validateBackup}
whoCanFindMe={whoCanFindMe}
whoCanSeeMe={whoCanSeeMe}
zoomFactor={zoomFactor}
donationReceipts={donationReceipts}
internalAddDonationReceipt={internalAddDonationReceipt}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
addVisibleMegaphone={addVisibleMegaphone}
internalDeleteAllMegaphones={internalDeleteAllMegaphones}
__dangerouslyRunAbitraryReadOnlySqlQuery={
__dangerouslyRunAbitraryReadOnlySqlQuery
}
cqsTestMode={cqsTestMode}
setCqsTestMode={setCqsTestMode}
dredDuration={items.dredDuration}
setDredDuration={setDredDuration}
setIsDirectVp9Enabled={setIsDirectVp9Enabled}
isDirectVp9Enabled={items.isDirectVp9Enabled}
setDirectMaxBitrate={setDirectMaxBitrate}
directMaxBitrate={items.directMaxBitrate}
setIsGroupVp9Enabled={setIsGroupVp9Enabled}
isGroupVp9Enabled={items.isGroupVp9Enabled}
setGroupMaxBitrate={setGroupMaxBitrate}
groupMaxBitrate={items.groupMaxBitrate}
sfuUrl={items.sfuUrl}
setSfuUrl={setSfuUrl}
forceKeyTransparencyCheck={forceKeyTransparencyCheck}
keyTransparencySelfHealth={items.keyTransparencySelfHealth}
weArePrimaryDevice={weArePrimaryDevice}
/>
</AppProvider>
);
}
+4 -8
View File
@@ -1,7 +1,6 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import * as Errors from '../types/errors.std.ts';
@@ -10,8 +9,7 @@ import { createLogger } from '../logging/log.std.ts';
import { ProgressModal } from '../components/ProgressModal.dom.tsx';
import { clearTimeoutIfNecessary } from './clearTimeoutIfNecessary.std.ts';
import { sleep } from './sleep.std.ts';
// oxlint-disable-next-line signal-desktop/no-restricted-paths
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../windows/AppProvider.dom.tsx';
const log = createLogger('longRunningTaskWrapper');
@@ -41,11 +39,9 @@ export async function longRunningTaskWrapper<T>({
log.info(`${idLog}: Creating spinner`);
progressRoot = createRoot(progressNode);
progressRoot.render(
<StrictMode>
<AxoProvider dir={i18n.getLocaleDirection()}>
<ProgressModal i18n={i18n} description={spinnerText} />
</AxoProvider>
</StrictMode>
<AppProvider>
<ProgressModal i18n={i18n} description={spinnerText} />
</AppProvider>
);
spinnerStart = Date.now();
}, TWO_SECONDS);
+31 -35
View File
@@ -1,12 +1,10 @@
// Copyright 2015 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
// oxlint-disable-next-line signal-desktop/no-restricted-paths
import { ConfirmationDialog } from '../components/ConfirmationDialog.dom.tsx';
// oxlint-disable-next-line signal-desktop/no-restricted-paths
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../windows/AppProvider.dom.tsx';
type ConfirmationDialogViewProps = {
onTopOfEverything?: boolean;
@@ -60,38 +58,36 @@ export function showConfirmationDialog(
confirmationDialogRoot = createRoot(confirmationDialogViewNode);
confirmationDialogRoot.render(
<StrictMode>
<AxoProvider dir={i18n.getLocaleDirection()}>
<ConfirmationDialog
dialogName={options.dialogName}
onTopOfEverything={options.onTopOfEverything}
actions={[
{
action: () => {
options.resolve();
},
style: options.confirmStyle,
text: options.okText || i18n('icu:ok'),
<AppProvider>
<ConfirmationDialog
dialogName={options.dialogName}
onTopOfEverything={options.onTopOfEverything}
actions={[
{
action: () => {
options.resolve();
},
]}
cancelText={options.cancelText || i18n('icu:cancel')}
i18n={i18n}
onCancel={() => {
if (options.reject) {
options.reject(
new Error('showConfirmationDialog: onCancel called')
);
}
}}
onClose={() => {
removeConfirmationDialog();
}}
title={options.title}
noMouseClose={options.noMouseClose}
>
{options.description}
</ConfirmationDialog>
</AxoProvider>
</StrictMode>
style: options.confirmStyle,
text: options.okText || i18n('icu:ok'),
},
]}
cancelText={options.cancelText || i18n('icu:cancel')}
i18n={i18n}
onCancel={() => {
if (options.reject) {
options.reject(
new Error('showConfirmationDialog: onCancel called')
);
}
}}
onClose={() => {
removeConfirmationDialog();
}}
title={options.title}
noMouseClose={options.noMouseClose}
>
{options.description}
</ConfirmationDialog>
</AppProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactNode } from 'react';
import { memo, StrictMode } from 'react';
import { AxoProvider } from '../axo/AxoProvider.dom.tsx';
import type { AxoProviderProps } from '../axo/AxoProvider.dom.tsx';
export type AppProviderProps = Readonly<{
children: ReactNode;
}>;
export const AppProvider: FC<AppProviderProps> = memo(
function AppProvider(props) {
const { i18n } = window.SignalContext;
const dir = window.SignalContext.getResolvedMessagesLocaleDirection();
const messages: AxoProviderProps['messages'] = {
'AxoButton.Pending': i18n('icu:AxoButton.Pending'),
'AxoDialog.Back': i18n('icu:AxoDialog.Back'),
'AxoDialog.Close': i18n('icu:AxoDialog.Close'),
'AxoTextField.Clear': i18n('icu:AxoTextField.Clear'),
};
return (
<StrictMode>
<AxoProvider dir={dir} messages={messages}>
{props.children}
</AxoProvider>
</StrictMode>
);
}
);
+11 -16
View File
@@ -1,13 +1,12 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { About } from '../../components/About.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../AppProvider.dom.tsx';
const { AboutWindowProps } = window.Signal;
const { i18n } = window.SignalContext;
@@ -18,18 +17,14 @@ const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<About
closeAbout={() => window.SignalContext.executeMenuRole('close')}
appEnv={AboutWindowProps.appEnv}
platform={AboutWindowProps.platform}
arch={AboutWindowProps.arch}
i18n={i18n}
version={window.SignalContext.getVersion()}
/>
</AxoProvider>
</StrictMode>
<AppProvider>
<About
closeAbout={() => window.SignalContext.executeMenuRole('close')}
appEnv={AboutWindowProps.appEnv}
platform={AboutWindowProps.platform}
arch={AboutWindowProps.arch}
i18n={i18n}
version={window.SignalContext.getVersion()}
/>
</AppProvider>
);
+5 -9
View File
@@ -1,12 +1,12 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode, useSyncExternalStore, type JSX } from 'react';
import { useSyncExternalStore, type JSX } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { CallDiagnosticWindow } from '../../components/CallDiagnosticWindow.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../AppProvider.dom.tsx';
const { CallDiagnosticWindowProps } = window.Signal;
strictAssert(CallDiagnosticWindowProps, 'window values not provided');
@@ -33,11 +33,7 @@ const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<App />
</AxoProvider>
</StrictMode>
<AppProvider>
<App />
</AppProvider>
);
+11 -16
View File
@@ -1,12 +1,11 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { DebugLogWindow } from '../../components/DebugLogWindow.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../AppProvider.dom.tsx';
const { DebugLogWindowProps } = window.Signal;
const { i18n } = window.SignalContext;
@@ -17,18 +16,14 @@ const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<DebugLogWindow
closeWindow={() => window.SignalContext.executeMenuRole('close')}
downloadLog={DebugLogWindowProps.downloadLog}
i18n={i18n}
fetchLogs={DebugLogWindowProps.fetchLogs}
uploadLogs={DebugLogWindowProps.uploadLogs}
mode={DebugLogWindowProps.mode}
/>
</AxoProvider>
</StrictMode>
<AppProvider>
<DebugLogWindow
closeWindow={() => window.SignalContext.executeMenuRole('close')}
downloadLog={DebugLogWindowProps.downloadLog}
i18n={i18n}
fetchLogs={DebugLogWindowProps.fetchLogs}
uploadLogs={DebugLogWindowProps.uploadLogs}
mode={DebugLogWindowProps.mode}
/>
</AppProvider>
);
+9 -16
View File
@@ -1,13 +1,10 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { PermissionsPopup } from '../../components/PermissionsPopup.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../AppProvider.dom.tsx';
const { PermissionsWindowProps } = window.Signal;
const { i18n } = window.SignalContext;
@@ -31,16 +28,12 @@ const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<PermissionsPopup
i18n={i18n}
message={message}
onAccept={PermissionsWindowProps.onAccept}
onClose={PermissionsWindowProps.onClose}
/>
</AxoProvider>
</StrictMode>
<AppProvider>
<PermissionsPopup
i18n={i18n}
message={message}
onAccept={PermissionsWindowProps.onAccept}
onClose={PermissionsWindowProps.onClose}
/>
</AppProvider>
);
+12 -19
View File
@@ -1,15 +1,12 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { CallingScreenSharingController } from '../../components/CallingScreenSharingController.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { drop } from '../../util/drop.std.ts';
import { parseEnvironment, setEnvironment } from '../../environment.std.ts';
import { AxoProvider } from '../../axo/AxoProvider.dom.tsx';
import { AppProvider } from '../AppProvider.dom.tsx';
const { ScreenShareWindowProps } = window.Signal;
const { i18n } = window.SignalContext;
@@ -33,21 +30,17 @@ function render() {
strictAssert(app != null, 'No #app');
createRoot(app).render(
<StrictMode>
<AxoProvider
dir={window.SignalContext.getResolvedMessagesLocaleDirection()}
>
<div className="App dark-theme">
<CallingScreenSharingController
i18n={i18n}
onCloseController={onCloseController}
onStopSharing={ScreenShareWindowProps.onStopSharing}
status={ScreenShareWindowProps.getStatus()}
presentedSourceName={ScreenShareWindowProps.presentedSourceName}
/>
</div>
</AxoProvider>
</StrictMode>
<AppProvider>
<div className="App dark-theme">
<CallingScreenSharingController
i18n={i18n}
onCloseController={onCloseController}
onStopSharing={ScreenShareWindowProps.onStopSharing}
status={ScreenShareWindowProps.getStatus()}
presentedSourceName={ScreenShareWindowProps.presentedSourceName}
/>
</div>
</AppProvider>
);
}
render();