@@ -278,7 +276,7 @@ export function ExampleMuteNotificationsDialog(): JSX.Element {
Mute notifications
-
+
@@ -345,7 +343,7 @@ export function ExampleLanguageDialog(): JSX.Element {
Language
-
+
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @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:
- * ---------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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
+ *
+ *
+ *
+ * Delete attachment?
+ *
+ *
+ *
+ *
+ * This attachment will be permanently deleted.
+ *
+ *
+ *
+ *
+ *
+ * Cancel
+ *
+ *
+ * Delete
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @example Trigger-based dialog
+ * ```tsx
+ *
+ *
+ *
+ * Open settings
+ *
+ *
+ *
+ *
+ * Settings
+ *
+ *
+ * ...
+ *
+ *
+ * ```
+ */
export const Root: FC = memo(props => {
return (
@@ -37,51 +127,81 @@ export namespace AxoDialog {
);
});
- Root.displayName = `${Namespace}.Root`;
+ Root.displayName = 'AxoDialog.Root';
/**
- * Component:
- * ------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
export type TriggerProps = Readonly<{
+ /**
+ * The element that opens the dialog when clicked.
+ */
children: ReactNode;
}>;
+ /**
+ * The button that opens the dialog.
+ */
export const Trigger: FC = memo(props => {
return {props.children};
});
- Trigger.displayName = `${Namespace}.Trigger`;
+ Trigger.displayName = 'AxoDialog.Trigger';
/**
- * Component:
- * ------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
- type ContentSizeConfig = Readonly<{
- width: number;
- minWidth: number;
- }>;
-
- const ContentSizes: Record = {
- 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('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 = memo(props => {
- const sizeConfig = ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
const [boundary, setBoundary] = useState(null);
@@ -102,13 +222,12 @@ export namespace AxoDialog {
{props.children}
@@ -120,17 +239,25 @@ export namespace AxoDialog {
);
});
- Content.displayName = `${Namespace}.Content`;
+ Content.displayName = 'AxoDialog.Content';
/**
- * Component:
- * -----------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
- * ----------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
- * ---------------------------
+ *
+ * --------------------------------------------------------------------------
*/
export type BackProps = Readonly<{
- 'aria-label': string;
- onClick: () => void;
+ /**
+ * Called when the back button is clicked.
+ */
+ onClick: (event: MouseEvent) => void;
}>;
+ /**
+ * A back-navigation button rendered in the leading column of `Header`.
+ */
export const Back: FC = memo(props => {
+ const intl = useAxoIntl();
return (
@@ -198,18 +341,18 @@ export namespace AxoDialog {
);
});
- Back.displayName = `${Namespace}.Back`;
+ Back.displayName = 'AxoDialog.Back';
/**
- * Component:
- * ----------------------------
+ *
+ * --------------------------------------------------------------------------
*/
- export type CloseProps = Readonly<{
- 'aria-label': string;
- }>;
-
- export const Close: FC = memo(props => {
+ /**
+ * The button that closes the dialog.
+ */
+ export const Close: FC = memo(() => {
+ const intl = useAxoIntl();
return (
@@ -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}
/>
@@ -225,31 +368,66 @@ export namespace AxoDialog {
);
});
- Close.displayName = `${Namespace}.Close`;
+ Close.displayName = 'AxoDialog.Close';
+
+ /**
+ *
+ * --------------------------------------------------------------------------
+ */
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 = memo(props => {
return {props.children}
;
});
- ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`;
+ ExperimentalSearch.displayName = 'AxoDialog.ExperimentalSearch';
/**
- * Component:
- * ---------------------------
+ *
+ * --------------------------------------------------------------------------
*/
+ /**
+ * 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 = memo(props => {
const { padding = 'normal', maxHeight = 440 } = props;
@@ -280,32 +458,44 @@ export namespace AxoDialog {
);
});
- Body.displayName = `${Namespace}.Body`;
+ Body.displayName = 'AxoDialog.Body';
/**
- * Component:
- * ----------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return {props.children};
});
- Description.displayName = `${Namespace}.Description`;
+ Description.displayName = 'AxoDialog.Description';
/**
- * Component:
- * ---------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
@@ -314,17 +504,26 @@ export namespace AxoDialog {
);
});
- Footer.displayName = `${Namespace}.Footer`;
+ Footer.displayName = 'AxoDialog.Footer';
/**
- * Component:
- * ------------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
- * ------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
- * ------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
+ /**
+ * 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) => void;
+ /**
+ * The button label.
+ */
children: ReactNode;
}>;
+ /**
+ * A button for use inside `Actions`.
+ */
export const Action: FC = memo(props => {
return (
- * ---------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
+ /**
+ * 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) => void;
}>;
+ /**
+ * An icon-only button for use inside `Actions`.
+ */
export const IconAction: FC = memo(props => {
return (
+ * {children}
+ *
+ * ```
+ */
export namespace AxoDragRegion {
/**
*
- * --------------------
+ * --------------------------------------------------------------------------
*/
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
+ *
+ *
+ *
+ * ```
+ *
+ * @example Sidebar header (draggable when no overlay is open)
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
export const Root: FC = memo(props => {
return (
@@ -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(() => {
diff --git a/ts/axo/AxoDropdownMenu.dom.tsx b/ts/axo/AxoDropdownMenu.dom.tsx
index 56fb72afdd..fc56fedfdc 100644
--- a/ts/axo/AxoDropdownMenu.dom.tsx
+++ b/ts/axo/AxoDropdownMenu.dom.tsx
@@ -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 () => (
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- *
- * )
+ *
+ *
+ *
+ *
+ *
+ *
+ * Mute notifications
+ *
+ * Delete
+ *
+ *
* ```
+ *
+ * @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 {
+ /**
+ *
+ * --------------------------------------------------------------------------
+ */
+
+ /**
+ * 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(
- `${Namespace}.RootContext`
+ 'AxoDropdownMenu.RootContext'
);
- /**
- * Component:
- * ---------------------------------
- */
-
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
+ *
+ *
+ *
+ *
+ *
+ * Mute notifications
+ *
+ *
+ * ```
*/
export const Root: FC = memo(props => {
const { modal, onOpenChange } = props;
@@ -140,17 +156,15 @@ export namespace AxoDropdownMenu {
);
});
- Root.displayName = `${Namespace}.Root`;
+ Root.displayName = 'AxoDropdownMenu.Root';
/**
- * Component:
- * ------------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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`
+ ' child must forward ref'
);
assert(
isAriaWidgetRole(getElementAriaRole(ref.current)),
- `${triggerDisplayName} child must have a widget role like 'button'`
+ " child must have a widget role like 'button'"
);
assert(
computeAccessibleName(ref.current) !== '',
- `${triggerDisplayName} child must have an accessible name`
+ ' child must have an accessible name'
);
}
});
@@ -190,11 +204,11 @@ export namespace AxoDropdownMenu {
);
});
- Trigger.displayName = triggerDisplayName;
+ Trigger.displayName = 'AxoDropdownMenu.Trigger';
/**
- * Component:
- * ------------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
export type ContentProps = AxoBaseMenu.MenuContentProps;
@@ -229,11 +243,11 @@ export namespace AxoDropdownMenu {
);
});
- Content.displayName = `${Namespace}.Content`;
+ Content.displayName = 'AxoDropdownMenu.Content';
/**
- * Component:
- * -------------------------------------
+ *
+ * --------------------------------------------------------------------------
*/
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 = memo(props => {
return (
- * ---------------------------------
+ *