Axo dialog design updates & aria checks

This commit is contained in:
Jamie
2025-11-05 15:15:46 -08:00
committed by GitHub
parent 28388578ce
commit 1fcb4d1041
39 changed files with 540 additions and 247 deletions
+1 -5
View File
@@ -78,11 +78,7 @@ function CardButton(props: {
}) {
return (
<AriaClickable.SubWidget>
<AxoButton.Root
variant={props.variant}
size="medium"
onClick={props.onClick}
>
<AxoButton.Root variant={props.variant} size="md" onClick={props.onClick}>
{props.children}
</AxoButton.Root>
</AriaClickable.SubWidget>
+7 -3
View File
@@ -20,7 +20,12 @@ const EXAMPLE_TITLE_LONG = (
</>
);
const EXAMPLE_DESCRIPTION = <>Exporting chat</>;
const EXAMPLE_DESCRIPTION = (
<>
Your chat will be downloaded in the background. You can continue to use
Signal during this process.
</>
);
const EXAMPLE_DESCRIPTION_LONG = (
<>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nobis, amet aut
@@ -72,12 +77,11 @@ function Template(props: {
return (
<AxoAlertDialog.Root open={open} onOpenChange={setOpen}>
<AxoAlertDialog.Trigger>
<AxoButton.Root variant="subtle-primary" size="medium">
<AxoButton.Root variant="subtle-primary" size="md">
Open
</AxoButton.Root>
</AxoAlertDialog.Trigger>
<AxoAlertDialog.Content
size="md"
escape={
props.requireExplicitChoice
? 'cancel-is-destructive'
+23 -30
View File
@@ -12,7 +12,7 @@ import type { AxoSymbol } from './AxoSymbol.dom.js';
const Namespace = 'AxoAlertDialog';
const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog;
const { useContentEscapeBehavior } = AxoBaseDialog;
/**
* Displays a menu located at the pointer, triggered by a right click or a long press.
@@ -74,30 +74,29 @@ export namespace AxoAlertDialog {
* --------------------------------
*/
export type ContentSize = AxoBaseDialog.ContentSize;
export type ContentEscape = AxoBaseDialog.ContentEscape;
export type ContentProps = AxoBaseDialog.ContentProps;
export type ContentProps = Readonly<{
escape: ContentEscape;
children: ReactNode;
}>;
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = AxoBaseDialog.ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
return (
<AxoBaseDialog.ContentSizeProvider value={props.size}>
<AlertDialog.Portal>
<AlertDialog.Overlay className={AxoBaseDialog.overlayStyles}>
<AlertDialog.Content
onEscapeKeyDown={handleContentEscapeEvent}
className={AxoBaseDialog.contentStyles}
style={{
minWidth: sizeConfig.minWidth,
width: sizeConfig.width,
}}
>
{props.children}
</AlertDialog.Content>
</AlertDialog.Overlay>
</AlertDialog.Portal>
</AxoBaseDialog.ContentSizeProvider>
<AlertDialog.Portal>
<AlertDialog.Overlay className={AxoBaseDialog.overlayStyles}>
<AlertDialog.Content
onEscapeKeyDown={handleContentEscapeEvent}
className={AxoBaseDialog.contentStyles}
style={{
minWidth: 300,
width: 300,
}}
>
{props.children}
</AlertDialog.Content>
</AlertDialog.Overlay>
</AlertDialog.Portal>
);
});
@@ -113,14 +112,8 @@ export namespace AxoAlertDialog {
}>;
export const Body: FC<BodyProps> = memo(props => {
const contentSize = useContentSize();
const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize];
return (
<AxoScrollArea.Root
maxHeight={contentSizeConfig.maxBodyHeight}
scrollbarWidth="none"
>
<AxoScrollArea.Root maxHeight={440} scrollbarWidth="none">
<AxoScrollArea.Hint edge="bottom" />
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
@@ -208,7 +201,7 @@ export namespace AxoAlertDialog {
export const Cancel: FC<CancelProps> = memo(props => {
return (
<AlertDialog.Cancel asChild>
<AxoButton.Root variant="secondary" size="medium" width="fill">
<AxoButton.Root variant="secondary" size="md" width="full">
{props.children}
</AxoButton.Root>
</AlertDialog.Cancel>
@@ -239,8 +232,8 @@ export namespace AxoAlertDialog {
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow}
size="medium"
width="fill"
size="md"
width="full"
>
{props.children}
</AxoButton.Root>
+16 -16
View File
@@ -145,7 +145,7 @@ const LONG_TEXT = (
function Fit(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="primary" size="medium" width="fit">
<AxoButton.Root variant="primary" size="md" width="fit">
Fit {props.longText && LONG_TEXT}
</AxoButton.Root>
);
@@ -153,15 +153,15 @@ function Fit(props: { longText?: boolean }) {
function Grow(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="affirmative" size="medium" width="grow">
<AxoButton.Root variant="affirmative" size="md" width="grow">
Grow {props.longText && LONG_TEXT}
</AxoButton.Root>
);
}
function Fill(props: { longText?: boolean }) {
function Full(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="destructive" size="medium" width="fill">
<AxoButton.Root variant="destructive" size="md" width="full">
Fill {props.longText && LONG_TEXT}
</AxoButton.Root>
);
@@ -180,7 +180,7 @@ function WidthTestTemplate(props: {
<>
<Fit />
<Grow />
<Fill />
<Full />
</>
)}
@@ -239,26 +239,26 @@ function WidthTestTemplate(props: {
<p>Fill</p>
{props.children(
<>
<Fill />
<Fill />
<Fill />
<Full />
<Full />
<Full />
</>
)}
<p>Fill: With long text</p>
<p>Full: With long text</p>
{props.children(
<>
<Fill longText />
<Fill longText />
<Fill longText />
<Full longText />
<Full longText />
<Full longText />
</>
)}
<p>Fill: With mixed length texts</p>
<p>Full: With mixed length texts</p>
{props.children(
<>
<Fill />
<Fill />
<Fill longText />
<Full />
<Full />
<Full longText />
</>
)}
</div>
+8 -8
View File
@@ -128,9 +128,9 @@ const AxoButtonVariants = {
};
const AxoButtonSizes = {
large: tw('min-w-16 px-4 py-2 type-body-medium font-medium'),
medium: tw('min-w-14 px-3 py-1.5 type-body-medium font-medium'),
small: tw('min-w-12 px-2 py-1 type-body-small font-medium'),
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 BaseButtonAttrs = Omit<
@@ -171,9 +171,9 @@ const AxoButtonSpinnerSizes: Record<
AxoButtonSize,
{ size: number; strokeWidth: number }
> = {
large: { size: 20, strokeWidth: 2 },
medium: { size: 20, strokeWidth: 2 },
small: { size: 16, strokeWidth: 1.5 },
lg: { size: 20, strokeWidth: 2 },
md: { size: 20, strokeWidth: 2 },
sm: { size: 16, strokeWidth: 1.5 },
};
type ExperimentalButtonSpinnerProps = Readonly<{
@@ -204,7 +204,7 @@ export namespace AxoButton {
export type Variant = AxoButtonVariant;
export type Size = AxoButtonSize;
export type Width = 'fit' | 'grow' | 'fill';
export type Width = 'fit' | 'grow' | 'full';
const Widths: Record<Width, TailwindStyles> = {
/* Always try to fit to the content of the button */
@@ -212,7 +212,7 @@ export namespace AxoButton {
/* Allow the button to grow within a flex container */
grow: tw('grow'),
/* Always try to fill the available space */
fill: tw('w-full'),
full: tw('w-full'),
};
export type RootProps = BaseButtonAttrs &
+1 -1
View File
@@ -25,7 +25,7 @@ export namespace AxoCheckbox {
disabled={props.disabled}
required={props.required}
className={tw(
'flex size-5 items-center justify-center rounded-full',
'flex size-5 items-center justify-center rounded-full leading-none',
'border border-border-primary inset-shadow-on-color',
'data-[state=unchecked]:bg-fill-primary',
'data-[state=unchecked]:pressed:bg-fill-primary-pressed',
+221 -3
View File
@@ -1,12 +1,14 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import React, { useId, useMemo, useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AxoDialog } from './AxoDialog.dom.js';
import { AxoButton } from './AxoButton.dom.js';
import { tw } from './tw.dom.js';
import { AxoCheckbox } from './AxoCheckbox.dom.js';
import { getScrollbarGutters } from './_internal/scrollbars.dom.js';
export default {
title: 'Axo/AxoDialog',
@@ -46,7 +48,7 @@ function Template(props: {
return (
<AxoDialog.Root open={open} onOpenChange={setOpen}>
<AxoDialog.Trigger>
<AxoButton.Root variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="md">
Open Dialog
</AxoButton.Root>
</AxoDialog.Trigger>
@@ -143,3 +145,219 @@ export function FooterContentLongAndTight(): JSX.Element {
</Template>
);
}
function Spacer(props: { height: 8 | 12 }) {
return <div style={{ height: props.height }} />;
}
function TextInputField(props: { placeholder: string }) {
const style = useMemo(() => {
const bodyPadding = 24;
const inputPadding = 16;
return { marginInline: inputPadding - bodyPadding };
}, []);
return (
<div className={tw('py-1.5')} style={style}>
<input
placeholder={props.placeholder}
className={tw(
'w-full px-3 py-1.5',
'border-[0.5px] border-border-primary shadow-elevation-0',
'rounded-lg bg-fill-primary',
'placeholder:text-label-placeholder'
)}
/>
</div>
);
}
export function ExampleNicknameAndNoteDialog(): JSX.Element {
const [open, setOpen] = useState(true);
return (
<AxoDialog.Root open={open} onOpenChange={setOpen}>
<AxoDialog.Trigger>
<AxoButton.Root variant="secondary" size="md">
Open Dialog
</AxoButton.Root>
</AxoDialog.Trigger>
<AxoDialog.Content size="sm" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>Nickname</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
</AxoDialog.Header>
<AxoDialog.Body>
<p className={tw('mb-4 type-body-small text-label-secondary')}>
Nicknames &amp; notes are stored with Signal and end-to-end
encrypted. They are only visible to you.
</p>
<div
className={tw('mx-auto size-20 rounded-full bg-color-fill-primary')}
/>
<Spacer height={12} />
<TextInputField placeholder="First name" />
<TextInputField placeholder="Last name" />
<TextInputField placeholder="Note" />
<Spacer height={12} />
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
Cancel
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={action('onSave')}>
Save
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function CheckboxField(props: { label: string }) {
const id = useId();
const [checked, setChecked] = useState(false);
return (
<div className={tw('flex gap-3 py-2.5')}>
<AxoCheckbox.Root
id={id}
checked={checked}
onCheckedChange={setChecked}
/>
<label
htmlFor={id}
className={tw('truncate type-body-large text-label-primary')}
>
{props.label}
</label>
</div>
);
}
export function ExampleMuteNotificationsDialog(): JSX.Element {
const [open, setOpen] = useState(true);
return (
<AxoDialog.Root open={open} onOpenChange={setOpen}>
<AxoDialog.Trigger>
<AxoButton.Root variant="secondary" size="md">
Open Dialog
</AxoButton.Root>
</AxoDialog.Trigger>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>Mute notifications</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
</AxoDialog.Header>
<AxoDialog.Body>
<Spacer height={8} />
<CheckboxField label="Mute for 1 hour" />
<CheckboxField label="Mute for 8 hours" />
<CheckboxField label="Mute for 1 day" />
<CheckboxField label="Mute for 1 week" />
<CheckboxField label="Mute always" />
<Spacer height={8} />
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
Cancel
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={action('onSave')}>
Save
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function ExampleItem(props: { label: string; description: string }) {
const labelId = useId();
const descriptionId = useId();
const style = useMemo((): CSSProperties => {
return {
paddingInline: 24 - getScrollbarGutters('thin', 'custom').vertical,
};
}, []);
return (
<div
role="option"
aria-selected={false}
aria-labelledby={labelId}
aria-describedby={descriptionId}
tabIndex={0}
className={tw('rounded-lg py-2.5 hover:bg-fill-secondary')}
style={style}
>
<div
id={labelId}
className={tw('truncate type-body-large text-label-primary')}
>
{props.label}
</div>
<div
id={descriptionId}
className={tw('truncate type-body-small text-label-secondary')}
>
{props.description}
</div>
</div>
);
}
export function ExampleLanguageDialog(): JSX.Element {
const [open, setOpen] = useState(true);
return (
<AxoDialog.Root open={open} onOpenChange={setOpen}>
<AxoDialog.Trigger>
<AxoButton.Root variant="secondary" size="md">
Open Dialog
</AxoButton.Root>
</AxoDialog.Trigger>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>Language</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
</AxoDialog.Header>
<AxoDialog.ExperimentalSearch>
<input
type="search"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder="Search languages"
className={tw('w-full rounded-lg bg-fill-secondary px-3 py-[5px]')}
/>
</AxoDialog.ExperimentalSearch>
<AxoDialog.Body padding="only-scrollbar-gutter">
<div role="listbox">
<ExampleItem label="System Language" description="English" />
<ExampleItem label="Afrikaans" description="Afrikaans" />
<ExampleItem label="Arabic" description="العربية" />
<ExampleItem label="Azerbaijani" description="Azərbaycan dili" />
<ExampleItem label="Bulgarian" description="Български" />
<ExampleItem label="Bangla" description="বাংলা" />
<ExampleItem label="Bosnian" description="bosanski" />
<ExampleItem label="Catalan" description="català" />
<ExampleItem label="Czech" description="Čeština" />
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={action('onCancel')}>
Cancel
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={action('onSet')}>
Set
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
+90 -59
View File
@@ -19,30 +19,30 @@ import { AxoButton } from './AxoButton.dom.js';
const Namespace = 'AxoDialog';
const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog;
const { useContentEscapeBehavior } = AxoBaseDialog;
// We want to have 25px of padding on either side of header/body/footer, but
// We want to have 24px of padding on either side of header/body/footer, but
// it's import that we remain aligned with the vertical scrollbar gutters that
// we need to measure in the browser to know the value of.
//
// Chrome currently renders vertical scrollbars as 11px with
// `scrollbar-width: thin` but that could change someday or based on some OS
// settings. So we'll target 24px but we'll tolerate different values.
const SCROLLBAR_WIDTH_EXPECTED = 11; /* (keep in sync with chromium) */
const SCROLLBAR_WIDTH_ACTUAL = getScrollbarGutters('thin', 'custom').vertical;
function getPadding(target: number, scrollbars: boolean): number {
const scrollbarWidthExpected = 11;
const paddingBeforeScrollbarWidth = target - scrollbarWidthExpected;
const DIALOG_PADDING_TARGET = 20;
if (scrollbars) {
// If this element has scrollbars we should just rely on the rendered gutter
return paddingBeforeScrollbarWidth;
}
const DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH =
DIALOG_PADDING_TARGET - SCROLLBAR_WIDTH_EXPECTED;
const scrollbarWidthActual = getScrollbarGutters('thin', 'custom').vertical;
const DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH =
SCROLLBAR_WIDTH_ACTUAL + DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH;
const DIALOG_HEADER_PADDING_BLOCK = 10;
const DIALOG_HEADER_ICON_BUTTON_MARGIN =
DIALOG_HEADER_PADDING_BLOCK - DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH;
// If this element doesn't have scrollbars, we need to add the exact value of
// the actual scrollbar gutter
return scrollbarWidthActual + paddingBeforeScrollbarWidth;
}
export namespace AxoDialog {
/**
@@ -86,31 +86,44 @@ export namespace AxoDialog {
* ------------------------------
*/
export type ContentSize = AxoBaseDialog.ContentSize;
type ContentSizeConfig = Readonly<{
width: number;
minWidth: number;
}>;
const ContentSizes: Record<ContentSize, ContentSizeConfig> = {
sm: { width: 360, minWidth: 360 },
md: { width: 420, minWidth: 360 },
lg: { width: 720, minWidth: 360 },
};
export type ContentSize = 'sm' | 'md' | 'lg';
export type ContentEscape = AxoBaseDialog.ContentEscape;
export type ContentProps = AxoBaseDialog.ContentProps;
export type ContentProps = Readonly<{
size: ContentSize;
escape: ContentEscape;
children: ReactNode;
}>;
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = AxoBaseDialog.ContentSizes[props.size];
const sizeConfig = ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
return (
<AxoBaseDialog.ContentSizeProvider value={props.size}>
<Dialog.Portal>
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
<Dialog.Content
className={AxoBaseDialog.contentStyles}
onEscapeKeyDown={handleContentEscapeEvent}
onInteractOutside={handleContentEscapeEvent}
style={{
width: sizeConfig.width,
minWidth: sizeConfig.minWidth,
}}
>
{props.children}
</Dialog.Content>
</Dialog.Overlay>
</Dialog.Portal>
</AxoBaseDialog.ContentSizeProvider>
<Dialog.Portal>
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
<Dialog.Content
className={AxoBaseDialog.contentStyles}
onEscapeKeyDown={handleContentEscapeEvent}
onInteractOutside={handleContentEscapeEvent}
style={{
width: sizeConfig.width,
minWidth: 320,
}}
>
{props.children}
</Dialog.Content>
</Dialog.Overlay>
</Dialog.Portal>
);
});
@@ -128,14 +141,13 @@ export namespace AxoDialog {
export const Header: FC<HeaderProps> = memo(props => {
const style = useMemo(() => {
return {
paddingBlock: DIALOG_HEADER_PADDING_BLOCK,
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
paddingInline: getPadding(10, false),
};
}, []);
return (
<div
className={tw(
'grid items-center',
'grid items-center py-2.5',
'grid-cols-[[back-slot]_1fr_[title-slot]_auto_[close-slot]_1fr]'
)}
style={style}
@@ -167,12 +179,12 @@ export namespace AxoDialog {
type="button"
aria-label={label}
className={tw(
'rounded-full p-1.5',
'rounded-full p-[5px] leading-none',
'hovered:bg-fill-secondary pressed:bg-fill-secondary-pressed',
'outline-0 outline-border-focused focused:outline-[2.5px]'
)}
>
<AxoSymbol.Icon symbol={symbol} size={20} label={null} />
<AxoSymbol.Icon symbol={symbol} size={18} label={null} />
</button>
);
}
@@ -186,17 +198,25 @@ export namespace AxoDialog {
*/
export type TitleProps = Readonly<{
screenReaderOnly?: boolean;
children: ReactNode;
}>;
export const Title: FC<TitleProps> = memo(props => {
const style = useMemo(() => {
return {
paddingInline: 24 - getPadding(10, false),
};
}, []);
return (
<Dialog.Title
className={tw(
'col-[title-slot]',
'col-[title-slot] py-0.5',
'truncate text-center',
'type-title-small text-label-primary'
'type-body-medium font-semibold text-label-primary',
props.screenReaderOnly && 'sr-only'
)}
style={style}
>
{props.children}
</Dialog.Title>
@@ -215,11 +235,8 @@ export namespace AxoDialog {
}>;
export const Back: FC<BackProps> = memo(props => {
const style = useMemo((): CSSProperties => {
return { marginInlineStart: DIALOG_HEADER_ICON_BUTTON_MARGIN };
}, []);
return (
<div className={tw('col-[back-slot] text-start')} style={style}>
<div className={tw('col-[back-slot] text-start')}>
<HeaderIconButton
label={props['aria-label']}
symbol="chevron-[start]"
@@ -240,11 +257,8 @@ export namespace AxoDialog {
}>;
export const Close: FC<CloseProps> = memo(props => {
const style = useMemo((): CSSProperties => {
return { marginInlineEnd: DIALOG_HEADER_ICON_BUTTON_MARGIN };
}, []);
return (
<div className={tw('col-[close-slot] text-end')} style={style}>
<div className={tw('col-[close-slot] text-end')}>
<Dialog.Close asChild>
<HeaderIconButton label={props['aria-label']} symbol="x" />
</Dialog.Close>
@@ -254,6 +268,23 @@ export namespace AxoDialog {
Close.displayName = `${Namespace}.Close`;
export type ExperimentalSearchProps = Readonly<{
children: ReactNode;
}>;
export const ExperimentalSearch: FC<ExperimentalSearchProps> = memo(props => {
const style = useMemo(() => {
return { paddingInline: getPadding(16, false) };
}, []);
return (
<div style={style} className={tw('pb-2')}>
{props.children}
</div>
);
});
ExperimentalSearch.displayName = `${Namespace}.ExperimentalSearch`;
/**
* Component: <AxoDialog.Body>
* ---------------------------
@@ -268,22 +299,18 @@ export namespace AxoDialog {
export const Body: FC<BodyProps> = memo(props => {
const { padding = 'normal' } = props;
const contentSize = useContentSize();
const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize];
const style = useMemo((): CSSProperties => {
return {
paddingInline:
padding === 'normal'
? DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH
: undefined,
paddingInline: padding === 'normal' ? getPadding(24, true) : undefined,
};
}, [padding]);
return (
<AxoScrollArea.Root
maxHeight={contentSizeConfig.maxBodyHeight}
maxHeight={440}
scrollbarWidth="thin"
scrollbarVisibility="as-needed"
>
<AxoScrollArea.Hint edge="top" />
<AxoScrollArea.Hint edge="bottom" />
@@ -325,13 +352,13 @@ export namespace AxoDialog {
export const Footer: FC<FooterProps> = memo(props => {
const style = useMemo((): CSSProperties => {
return {
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
paddingInline: getPadding(12, false),
};
}, []);
return (
<div
className={tw('flex flex-wrap items-center gap-3 py-3')}
className={tw('flex flex-wrap items-center gap-3 py-2.5')}
style={style}
>
{props.children}
@@ -351,6 +378,9 @@ export namespace AxoDialog {
}>;
export const FooterContent: FC<FooterContentProps> = memo(props => {
const style = useMemo(() => {
return { paddingInlineStart: 24 - getPadding(12, false) };
}, []);
return (
<div
className={tw(
@@ -364,6 +394,7 @@ export namespace AxoDialog {
'flex-grow',
'type-body-large text-label-primary'
)}
style={style}
>
{props.children}
</div>
@@ -422,7 +453,7 @@ export namespace AxoDialog {
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow}
size="medium"
size="md"
width="grow"
>
{props.children}
+3 -3
View File
@@ -28,7 +28,7 @@ export function Basic(): JSX.Element {
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="md">
Open Dropdown Menu
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
@@ -114,7 +114,7 @@ export function WithHeader(): JSX.Element {
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="md">
Open Dropdown Menu
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
@@ -222,7 +222,7 @@ export function StressTestLongText(): JSX.Element {
<Container>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="md">
Open Dropdown Menu
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
+28 -3
View File
@@ -1,8 +1,9 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useId } from 'react';
import React, { memo, useEffect, useId, useRef } from 'react';
import { DropdownMenu } from 'radix-ui';
import type { FC, ReactNode } from 'react';
import { getRole, computeAccessibleName } from 'dom-accessibility-api';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js';
@@ -11,6 +12,7 @@ import {
useAriaLabellingContext,
useCreateAriaLabellingContext,
} from './_internal/AriaLabellingContext.dom.js';
import { assert } from './_internal/assert.dom.js';
const Namespace = 'AxoDropdownMenu';
@@ -88,18 +90,41 @@ export namespace AxoDropdownMenu {
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
* against the trigger.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
assert(
ref.current instanceof HTMLElement,
`${triggerDisplayName} child must forward ref`
);
assert(
getRole(ref.current) === 'button',
`${triggerDisplayName} child must be a <button> or role=button`
);
assert(
computeAccessibleName(ref.current) !== '',
`${triggerDisplayName} child must have an accessible name`
);
}
});
return (
<DropdownMenu.Trigger asChild>{props.children}</DropdownMenu.Trigger>
<DropdownMenu.Trigger ref={ref} asChild>
{props.children}
</DropdownMenu.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
Trigger.displayName = triggerDisplayName;
/**
* Component: <AxoDropdownMenu.Content>
+1 -1
View File
@@ -100,7 +100,7 @@ export function Basic(): JSX.Element {
return (
<div className={tw('flex h-96 w-full items-center justify-center gap-8')}>
<Template renderer="AxoDropdownMenu">
<AxoButton.Root variant="secondary" size="medium">
<AxoButton.Root variant="secondary" size="md">
Open Dropdown Menu
</AxoButton.Root>
</Template>
+33 -13
View File
@@ -66,9 +66,12 @@ export namespace AxoScrollArea {
export type ScrollBehavior = 'auto' | 'smooth';
export type ScrollbarVisibility = 'auto' | 'as-needed';
type ScrollAreaConfig = Readonly<{
scrollbarWidth: ScrollbarWidth;
scrollbarGutter: ScrollbarGutter;
scrollbarVisibility: ScrollbarVisibility;
scrollBehavior: ScrollBehavior;
}>;
@@ -93,6 +96,7 @@ export namespace AxoScrollArea {
maxHeight?: number;
scrollbarWidth: ScrollbarWidth;
scrollbarGutter?: ScrollbarGutter;
scrollbarVisibility?: ScrollbarVisibility;
scrollBehavior?: ScrollBehavior;
children: ReactNode;
}>;
@@ -104,12 +108,18 @@ export namespace AxoScrollArea {
maxHeight,
scrollbarWidth = 'thin',
scrollbarGutter = 'stable-both-edges',
scrollbarVisibility = 'auto',
scrollBehavior = 'auto',
} = props;
const config = useMemo((): ScrollAreaConfig => {
return { scrollbarWidth, scrollbarGutter, scrollBehavior };
}, [scrollbarWidth, scrollbarGutter, scrollBehavior]);
return {
scrollbarWidth,
scrollbarGutter,
scrollbarVisibility,
scrollBehavior,
};
}, [scrollbarWidth, scrollbarGutter, scrollbarVisibility, scrollBehavior]);
const style = useMemo((): CSSProperties => {
return {
@@ -158,13 +168,6 @@ export namespace AxoScrollArea {
'outline-0'
);
// Note: Use "scroll" for `overflow-x` because scrollbar-gutter doesnt fix the space
const ViewportOrientations: Record<Orientation, TailwindStyles> = {
vertical: tw('overflow-x-hidden overflow-y-auto'),
horizontal: tw('overflow-x-scroll overflow-y-hidden'),
both: tw('overflow-x-scroll overflow-y-auto'),
};
const ViewportScrollbarWidths: Record<ScrollbarWidth, TailwindStyles> = {
wide: tw('scrollbar-width-auto'),
thin: tw('scrollbar-width-thin'),
@@ -177,6 +180,16 @@ export namespace AxoScrollArea {
'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 ViewportScrollBehaviors: Record<ScrollBehavior, TailwindStyles> = {
auto: tw('scroll-auto'),
smooth: tw('scroll-smooth'),
@@ -188,8 +201,12 @@ export namespace AxoScrollArea {
export const Viewport: FC<ViewportProps> = memo(props => {
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth, scrollbarGutter, scrollBehavior } =
useAxoScrollAreaConfig();
const {
scrollbarWidth,
scrollbarGutter,
scrollbarVisibility,
scrollBehavior,
} = useAxoScrollAreaConfig();
const style = useMemo((): CSSProperties => {
const hasVerticalScrollbar = orientation !== 'horizontal';
@@ -239,9 +256,9 @@ export namespace AxoScrollArea {
data-axo-scroll-area-viewport
className={tw(
baseViewportStyles,
ViewportOrientations[orientation],
ViewportScrollbarWidths[scrollbarWidth],
ViewportScrollbarGutters[scrollbarGutter],
ViewportScrollbarVisibilities[scrollbarVisibility],
ViewportScrollBehaviors[scrollBehavior]
)}
style={style}
@@ -452,7 +469,10 @@ export namespace AxoScrollArea {
return (
<div
className={tw('flex size-full flex-col', AXO_MASK_CLASS_NAME)}
className={tw(
'flex size-full flex-col overflow-hidden',
AXO_MASK_CLASS_NAME
)}
style={style}
>
{props.children}
+3 -3
View File
@@ -84,7 +84,7 @@ export namespace AxoSelect {
*/
export type TriggerVariant = 'default' | 'floating' | 'borderless';
export type TriggerWidth = 'hug' | 'full';
export type TriggerWidth = 'fit' | 'full';
export type TriggerChevron = 'always' | 'on-hover';
const baseTriggerStyles = tw(
@@ -116,7 +116,7 @@ export namespace AxoSelect {
};
const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
hug: tw(),
fit: tw(),
full: tw('w-full'),
};
@@ -170,7 +170,7 @@ export namespace AxoSelect {
*/
export const Trigger: FC<TriggerProps> = memo(props => {
const variant = props.variant ?? 'default';
const width = props.width ?? 'hug';
const width = props.width ?? 'fit';
const chevron = props.chevron ?? 'always';
const variantStyles = TriggerVariants[variant];
const widthStyles = TriggerWidths[width];
+3 -2
View File
@@ -65,14 +65,15 @@ export namespace AxoSymbol {
*/
export type IconName = AxoSymbolIconName;
export type IconSize = 12 | 14 | 16 | 20 | 24 | 48;
export type IconSize = 12 | 14 | 16 | 18 | 20 | 24 | 48;
type IconSizeConfig = { size: number; fontSize: number };
const IconSizes: Record<IconSize, IconSizeConfig> = {
12: { size: 12, fontSize: 11 },
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 },
48: { size: 48, fontSize: 44 },
+2 -34
View File
@@ -1,10 +1,9 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createContext, useCallback, useContext } from 'react';
import { useCallback } from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.dom.js';
import { assert } from './assert.dom.js';
export namespace AxoBaseDialog {
/**
@@ -46,6 +45,7 @@ export namespace AxoBaseDialog {
*/
export const contentStyles = tw(
'relative',
'max-h-full min-h-fit max-w-full min-w-fit',
'rounded-3xl bg-elevated-background-primary shadow-elevation-3 select-none',
'outline-0 outline-border-focused focused:outline-[2.5px]',
@@ -53,21 +53,6 @@ export namespace AxoBaseDialog {
'animate-scale-98 animate-translate-y-1'
);
export type ContentSize = 'sm' | 'md' | 'lg';
export type ContentSizeConfig = Readonly<{
width: number;
minWidth: number;
maxBodyHeight: number;
}>;
// TODO: These sizes are not finalized
export const ContentSizes: Record<ContentSize, ContentSizeConfig> = {
sm: { width: 320, minWidth: 320, maxBodyHeight: 440 },
md: { width: 440, minWidth: 320, maxBodyHeight: 440 },
lg: { width: 560, minWidth: 440, maxBodyHeight: 440 },
};
export type ContentEscape = 'cancel-is-noop' | 'cancel-is-destructive';
export function useContentEscapeBehavior(
@@ -82,21 +67,4 @@ export namespace AxoBaseDialog {
[escape]
);
}
export type ContentProps = Readonly<{
escape: ContentEscape;
size: ContentSize;
children: ReactNode;
}>;
const ContentSizeContext = createContext<ContentSize | null>(null);
export const ContentSizeProvider = ContentSizeContext.Provider;
export function useContentSize(): ContentSize {
return assert(
useContext(ContentSizeContext),
'Must be wrapped with dialog <Content> component'
);
}
}