Init AxoAlertDialog

This commit is contained in:
Jamie
2025-10-29 10:21:15 -07:00
committed by GitHub
parent 7b48f59f59
commit 21b5ae0145
23 changed files with 2668 additions and 79 deletions

View File

@@ -269,8 +269,10 @@ function withFunProvider(Story, context) {
}
function withAxoProvider(Story, context) {
const globalValue = context.globals.direction ?? 'ltr';
const dir = globalValue === 'auto' ? 'ltr' : globalValue;
return (
<AxoProvider dir={context.globals.direction ?? 'ltr'}>
<AxoProvider dir={dir}>
<Story {...context} />
</AxoProvider>
);

View File

@@ -1,4 +1,9 @@
@import 'tailwindcss' source(none);
@import './tailwind-plugins/animate-general.css';
@import './tailwind-plugins/animate-enter-exit.css';
@import './tailwind-plugins/scrollbar.css';
@import '../ts/axo/_styles.css';
@source "../ts";
@source "../test";
@@ -23,6 +28,7 @@
/* prettier-ignore */
@theme {
--color-*: initial; /* reset defaults */
--color-transparent: transparent;
/* Colors/Labels */
--color-label-primary: light-dark(--alpha(#000 / 85%), --alpha(#FFF / 85%));
@@ -368,24 +374,30 @@
--east-in-out-cubic: cubic-bezier(0.65, 0, 0.35, 1);
}
/**
* Transitions
* ----------------------------------------------------------------------------
*/
@theme {
--default-transition-duration: 120ms;
--default-transition-timing-function: var(--ease-out-cubic);
}
/**
* Animations
* ----------------------------------------------------------------------------
*/
@theme {
--default-animation-duration: 120ms;
--default-animation-timing-function: var(--ease-out-cubic);
--animate-*: initial; /* reset defaults */
--animate-fade-out: animate-fade-out 120ms var(--ease-out-cubic);
--animate-spinner-v2-rotate: animate-spinner-v2-rotate 2s linear infinite;
--animate-spinner-v2-dash: animate-spinner-v2-dash 1.5s ease-in-out infinite;
}
@layer base {
@keyframes animate-fade-out {
to {
opacity: 0;
}
}
@keyframes animate-spinner-v2-rotate {
0% {
transform: rotate(-180deg);
@@ -416,3 +428,12 @@
inherits: false;
initial-value: transparent;
}
/**
* Scrollbars
*/
@theme {
--default-scrollbar-track: transparent;
--default-scrollbar-thumb: #b9b9b9;
}

View File

@@ -0,0 +1,142 @@
/**
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Properties
*/
@property --tw-animate-opacity {
syntax: '*';
inherits: false;
}
@property --tw-animate-rotate {
syntax: '*';
inherits: false;
}
@property --tw-animate-scale {
syntax: '*';
inherits: false;
}
@property --tw-animate-translate-x {
syntax: '*';
inherits: false;
}
@property --tw-animate-translate-y {
syntax: '*';
inherits: false;
}
/**
* Utilities
*/
@utility animate-enter {
animation-name: tw-animate-enter;
animation-duration: var(
--tw-animate-duration,
var(--default-animation-duration)
);
animation-timing-function: var(
--tw-animate-ease,
var(--default-animation-timing-function)
);
}
@utility animate-exit {
animation-name: tw-animate-exit;
animation-duration: var(
--tw-animate-duration,
var(--default-animation-duration)
);
animation-timing-function: var(
--tw-animate-ease,
var(--default-animation-timing-function)
);
}
/**
* animate-opacity
*/
@utility animate-opacity-* {
--tw-animate-opacity: calc(--value(integer) * 1%);
}
/**
* animate-rotate
*/
@utility animate-rotate-* {
--tw-animate-rotate: rotate(calc(--value(integer) * 1deg));
}
@utility -animate-rotate-* {
--tw-animate-rotate: rotate(calc(--value(integer) * -1deg));
}
/**
* animate-scale
*/
@utility animate-scale-* {
--tw-animate-scale: scale(calc(--value(number) * 1%));
}
@utility -animate-scale-* {
--tw-animate-scale: scale(calc(--value(number) * -1%));
}
/**
* animate-translate
*/
@utility animate-translate-x-* {
--tw-animate-translate-x: translateX(--spacing(--value(integer)));
--tw-animate-translate-x: translateX(--value([percentage], [length]));
}
@utility -animate-translate-x-* {
--tw-animate-translate-x: translateX(--spacing(--value(integer) * -1));
--tw-animate-translate-x: translateX(
calc(--value([percentage], [length]) * -1)
);
}
@utility animate-translate-y-* {
--tw-animate-translate-y: translateY(--spacing(--value(integer)));
--tw-animate-translate-y: translateY(--value([percentage], [length]));
}
@utility -animate-translate-y-* {
--tw-animate-translate-y: translateY(--spacing(--value(integer) * -1));
--tw-animate-translate-y: translateY(
calc(--value([percentage], [length]) * -1)
);
}
/**
* Keyframes
*/
@layer utilities {
@keyframes tw-animate-enter {
from {
opacity: var(--tw-animate-opacity);
/* prettier-ignore */
transform:
var(--tw-animate-rotate,)
var(--tw-animate-scale,)
var(--tw-animate-translate-x,)
var(--tw-animate-translate-y,);
}
}
@keyframes tw-animate-exit {
to {
opacity: var(--tw-animate-opacity);
/* prettier-ignore */
transform:
var(--tw-animate-rotate,)
var(--tw-animate-scale,)
var(--tw-animate-translate-x,)
var(--tw-animate-translate-y,);
}
}
}

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Theme
*/
@theme {
--default-animation-duration: var(--default-transition-duration);
--default-animation-timing-function: var(--default-animation-timing-function);
}
/**
* Properties
*/
@property --tw-animate-duration {
syntax: '*';
inherits: false;
}
@property --tw-animate-ease {
syntax: '*';
inherits: false;
}
/**
* Utilities
*/
/** `animation-duration` */
@utility animate-duration-* {
--tw-animate-duration: calc(--value(integer) * 1ms);
animation-duration: calc(--value(integer) * 1ms);
}
/** `animation-delay` */
@utility animate-delay-* {
animation-delay: calc(--value(integer) * 1ms);
}
/** `animation-timing-function` */
@utility animate-ease-* {
/* prettier-ignore */
--tw-animate-ease: --value(--ease-*);
/* prettier-ignore */
animation-timing-function: --value(--ease-*);
}
/** `animation-fill-mode` */
@utility animate-forwards {
animation-fill-mode: forwards;
}
@utility animate-backwards {
animation-fill-mode: backwards;
}
@utility animate-both {
animation-fill-mode: both;
}
@utility animate-none {
animation-fill-mode: none;
}
/** `animation-play-state` */
@utility paused {
animation-play-state: paused;
}
@utility running {
animation-play-state: running;
}
/** `animation-direction` */
@utility animate-normal {
animation-direction: normal;
}
@utility animate-reverse {
animation-direction: reverse;
}
@utility animate-alternate {
animation-direction: alternate;
}
@utility animate-alternate-reverse {
animation-direction: alternate-reverse;
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Theme
*/
@theme {
--default-scrollbar-track: transparent;
--default-scrollbar-thumb: currentColor;
}
/**
* Properties
*/
@property --tw-scrollbar-track {
syntax: '*';
inherits: false;
}
@property --tw-scrollbar-thumb {
syntax: '*';
inherits: false;
}
/**
* Utilities
*/
@utility scrollbar-track-* {
/* prettier-ignore */
--tw-scrollbar-track: --value(--color-*);
/* prettier-ignore */
--tw-scrollbar-track: --value([*]);
/* prettier-ignore */
scrollbar-color:
var(--tw-scrollbar-thumb, var(--default-scrollbar-thumb))
var(--tw-scrollbar-track, var(--default-scrollbar-track));
}
@utility scrollbar-thumb-* {
/* prettier-ignore */
--tw-scrollbar-thumb: --value(--color-*);
/* prettier-ignore */
--tw-scrollbar-thumb: --value([*]);
/* prettier-ignore */
scrollbar-color:
var(--tw-scrollbar-thumb, var(--default-scrollbar-thumb))
var(--tw-scrollbar-track, var(--default-scrollbar-track));
}
@utility scrollbar-width-auto {
scrollbar-width: auto;
}
@utility scrollbar-width-thin {
scrollbar-width: thin;
}
@utility scrollbar-width-none {
scrollbar-width: none;
}
@utility scrollbar-gutter-auto {
scrollbar-gutter: auto;
}
@utility scrollbar-gutter-stable {
scrollbar-gutter: stable;
}
@utility scrollbar-gutter-stable-both-edges {
scrollbar-gutter: stable both-edges;
}
@layer base {
:root {
scrollbar-color: var(--default-scrollbar-thumb)
var(--default-scrollbar-track);
}
}

View File

@@ -0,0 +1,129 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import React, { useState } from 'react';
import { AxoAlertDialog } from './AxoAlertDialog.dom.js';
import { AxoButton } from './AxoButton.dom.js';
export default {
title: 'Axo/AxoAlertDialog',
} satisfies Meta;
const EXAMPLE_TITLE = <>Exporting chat</>;
const EXAMPLE_TITLE_LONG = (
<>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Est vel
repudiandae magnam tempora temporibus nihil repellendus ullam. Ex veniam
ipsa voluptate, quae ullam qui eius enim explicabo laborum modi minima!
</>
);
const EXAMPLE_DESCRIPTION = <>Exporting chat</>;
const EXAMPLE_DESCRIPTION_LONG = (
<>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nobis, amet aut
quasi possimus repudiandae accusamus dolore. Iure, ad neque qui recusandae
quod asperiores! Facere nulla illum suscipit dolores sint libero! Quibusdam
hic, facilis soluta quae voluptatum eius voluptates alias ipsa, autem sed
tempore atque nesciunt illum blanditiis tempora fugiat. Quidem odit optio
sint! Iste rerum, molestias doloremque asperiores ipsa nostrum! Provident
impedit quam aspernatur libero veniam sint et tempore maiores! Porro
incidunt numquam sapiente deserunt id possimus atque at. Repudiandae
recusandae blanditiis autem ad numquam animi omnis eos perspiciatis harum!
Accusantium nesciunt eligendi laboriosam ipsam reprehenderit voluptate,
minima necessitatibus molestias reiciendis repellendus maiores assumenda
alias atque odit, voluptatum facere voluptas excepturi, nostrum quidem
beatae quasi quis? Provident, quaerat autem! Numquam. Laborum, aut quidem
molestias beatae eius, id molestiae officiis, dolores perspiciatis ratione
doloremque eligendi? Aut facilis temporibus inventore beatae nihil dolores
quidem alias ab expedita, quas fugit recusandae at dignissimos. Ullam
veritatis eligendi dicta asperiores minus quisquam! Odit dolorem ipsum
repudiandae enim excepturi omnis quisquam molestias ullam placeat delectus
necessitatibus eligendi illo, pariatur mollitia, alias sit ad amet eveniet
tenetur. Rem debitis, aperiam iusto officia fugiat consectetur hic voluptate
reprehenderit. Est quisquam, saepe fuga odit ex recusandae vero earum
asperiores aspernatur at, fugit temporibus eligendi tempore nemo obcaecati
libero dolore. Tenetur illum facere delectus sapiente architecto, minima
accusamus officia sed quos. Ipsum odit exercitationem ullam iure deleniti ea
eius, quia illum debitis cum quae pariatur assumenda officia dolores. Quasi,
temporibus? Distinctio iure quis nihil eaque ut cum quibusdam officiis,
eveniet maxime, debitis eos asperiores itaque voluptatem aliquam expedita?
Sint, animi eos. Repudiandae deleniti beatae quam dolores optio ipsa totam
perferendis. Nulla nostrum laudantium provident est itaque inventore neque,
eveniet facere vero voluptatibus alias nisi repellat placeat ipsa ea, amet
numquam iusto voluptates dolorem, sint odit optio quam. Dolores, molestiae!
Dolorem?
</>
);
const EXAMPLE_ACTION = <>OK</>;
const EXAMPLE_ACTION_LONG = <>Consectetur adipisicing elit</>;
const EXAMPLE_CANCEL = <>Cancel</>;
const EXAMPLE_CANCEL_LONG = <>Lorem ipsum dolor sit amet</>;
function Template(props: {
visuallyHiddenTitle?: boolean;
requireExplicitChoice?: boolean;
extraLongText?: boolean;
}) {
const [open, setOpen] = useState(true);
return (
<AxoAlertDialog.Root open={open} onOpenChange={setOpen}>
<AxoAlertDialog.Trigger>
<AxoButton.Root variant="subtle-primary" size="medium">
Open
</AxoButton.Root>
</AxoAlertDialog.Trigger>
<AxoAlertDialog.Content
size="md"
escape={
props.requireExplicitChoice
? 'cancel-is-destructive'
: 'cancel-is-noop'
}
>
<AxoAlertDialog.Body>
<AxoAlertDialog.Title screenReaderOnly={props.visuallyHiddenTitle}>
{props.extraLongText ? EXAMPLE_TITLE_LONG : EXAMPLE_TITLE}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{props.extraLongText
? EXAMPLE_DESCRIPTION_LONG
: EXAMPLE_DESCRIPTION}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>
{props.extraLongText ? EXAMPLE_CANCEL_LONG : EXAMPLE_CANCEL}
</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action
variant="primary"
symbol={props.extraLongText ? 'check' : undefined}
arrow={props.extraLongText}
onClick={action('Action clicked')}
>
{props.extraLongText ? EXAMPLE_ACTION_LONG : EXAMPLE_ACTION}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
export function Basic(): JSX.Element {
return <Template />;
}
export function VisuallyHiddenTitle(): JSX.Element {
return <Template visuallyHiddenTitle />;
}
export function RequireExplicitChoice(): JSX.Element {
return <Template requireExplicitChoice />;
}
export function ExtraLongText(): JSX.Element {
return <Template extraLongText />;
}

View File

@@ -0,0 +1,252 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AlertDialog } from 'radix-ui';
import type { FC, ReactNode } from 'react';
import React, { memo } from 'react';
import { AxoButton } from './AxoButton.dom.js';
import { tw } from './tw.dom.js';
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import type { AxoSymbol } from './AxoSymbol.dom.js';
const Namespace = 'AxoAlertDialog';
const { useContentEscapeBehavior, useContentSize } = 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`.
*
* @example Anatomy
* ```tsx
* <AxoAlertDialog.Root>
* <AxoAlertDialog.Trigger>
* </AxoAlertDialog.Trigger>
* <AxoAlertDialog.Content>
* <AxoAlertDialog.Body>
* <AxoAlertDialog.Title />
* <AxoAlertDialog.Description />
* </AxoAlertDialog.Body>
* <AxoAlertDialog.Footer>
* <AxoAlertDialog.Cancel />
* <AxoAlertDialog.Action />
* </AxoAlertDialog.Footer>
* </AxoAlertDialog.Content>
* </AxoAlertDialog.Root>
* ```
*/
export namespace AxoAlertDialog {
/**
* Component: <AxoAlertDialog.Root>
* --------------------------------
*/
export type RootProps = AxoBaseDialog.RootProps;
export const Root: FC<RootProps> = memo(props => {
return (
<AlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
{props.children}
</AlertDialog.Root>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoAlertDialog.Trigger>
* --------------------------------
*/
export type TriggerProps = AxoBaseDialog.TriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => {
return <AlertDialog.Trigger asChild>{props.children}</AlertDialog.Trigger>;
});
Trigger.displayName = `${Namespace}.Trigger`;
/**
* Component: <AxoAlertDialog.Content>
* --------------------------------
*/
export type ContentSize = AxoBaseDialog.ContentSize;
export type ContentEscape = AxoBaseDialog.ContentEscape;
export type ContentProps = AxoBaseDialog.ContentProps;
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>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoAlertDialog.Body>
* ---------------------------------
*/
export type BodyProps = Readonly<{
children: ReactNode;
}>;
export const Body: FC<BodyProps> = memo(props => {
const contentSize = useContentSize();
const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize];
return (
<AxoScrollArea.Root
maxHeight={contentSizeConfig.maxBodyHeight}
scrollbarWidth="none"
>
<AxoScrollArea.Hint edge="bottom" />
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div className={tw('flex flex-col gap-1 px-6 pt-5')}>
{props.children}
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</AxoScrollArea.Root>
);
});
Body.displayName = `${Namespace}.Body`;
/**
* Component: <AxoAlertDialog.Footer>
* ---------------------------------
*/
export type FooterProps = Readonly<{
children: ReactNode;
}>;
export const Footer: FC<FooterProps> = memo(props => {
return <div className={tw('flex gap-2 px-6 py-4')}>{props.children}</div>;
});
Footer.displayName = `${Namespace}.Footer`;
/**
* Component: <AxoAlertDialog.Title>
* ---------------------------------
*/
export type TitleProps = Readonly<{
screenReaderOnly?: boolean;
children: ReactNode;
}>;
export const Title: FC<TitleProps> = memo(props => {
return (
<AlertDialog.Title
className={tw(
'text-center type-title-small text-label-primary',
props.screenReaderOnly && 'sr-only'
)}
>
{props.children}
</AlertDialog.Title>
);
});
Title.displayName = `${Namespace}.Title`;
/**
* Component: <AxoAlertDialog.Description>
* ---------------------------------------
*/
export type DescriptionProps = Readonly<{
children: ReactNode;
}>;
export const Description: FC<DescriptionProps> = memo(props => {
return (
<AlertDialog.Description
className={tw('text-center type-body-large text-label-secondary')}
>
{props.children}
</AlertDialog.Description>
);
});
Description.displayName = `${Namespace}.Description`;
/**
* Component: <AxoAlertDialog.Cancel>
* ----------------------------------
*/
export type CancelProps = Readonly<{
children: ReactNode;
}>;
export const Cancel: FC<CancelProps> = memo(props => {
return (
<AlertDialog.Cancel asChild>
<AxoButton.Root variant="secondary" size="medium" width="fill">
{props.children}
</AxoButton.Root>
</AlertDialog.Cancel>
);
});
Cancel.displayName = `${Namespace}.Cancel`;
/**
* Component: <AxoAlertDialog.Action>
* ----------------------------------
*/
export type ActionVariant = 'primary' | 'destructive';
export type ActionProps = Readonly<{
variant: ActionVariant;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
onClick: () => void;
children: ReactNode;
}>;
export const Action: FC<ActionProps> = memo(props => {
return (
<AlertDialog.Action asChild onClick={props.onClick}>
<AxoButton.Root
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow}
size="medium"
width="fill"
>
{props.children}
</AxoButton.Root>
</AlertDialog.Action>
);
});
Action.displayName = `${Namespace}.Action`;
}

View File

@@ -1,5 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@@ -133,3 +134,169 @@ export function Spinner(): JSX.Element {
</>
);
}
const LONG_TEXT = (
<>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Id dicta dolorum
magnam quibusdam nam commodi vel esse voluptatibus ut sint error consectetur
nihil, ad, optio maiores, ipsa explicabo officiis animi.
</>
);
function Fit(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="primary" size="medium" width="fit">
Fit {props.longText && LONG_TEXT}
</AxoButton.Root>
);
}
function Grow(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="affirmative" size="medium" width="grow">
Grow {props.longText && LONG_TEXT}
</AxoButton.Root>
);
}
function Fill(props: { longText?: boolean }) {
return (
<AxoButton.Root variant="destructive" size="medium" width="fill">
Fill {props.longText && LONG_TEXT}
</AxoButton.Root>
);
}
function WidthTestTemplate(props: {
title: string;
children: (children: ReactNode) => ReactNode;
}) {
return (
<div className={tw('space-y-2')}>
<h2 className={tw('type-title-large')}>{props.title}</h2>
<p>Mixed</p>
{props.children(
<>
<Fit />
<Grow />
<Fill />
</>
)}
<p>Fit</p>
{props.children(
<>
<Fit />
<Fit />
<Fit />
</>
)}
<p>Fit: With long text</p>
{props.children(
<>
<Fit longText />
<Fit longText />
<Fit longText />
</>
)}
<p>Fit: With mixed length texts</p>
{props.children(
<>
<Fit />
<Fit />
<Fit longText />
</>
)}
<p>Fit</p>
{props.children(
<>
<Grow />
<Grow />
<Grow />
</>
)}
<p>Grow: With long text</p>
{props.children(
<>
<Grow longText />
<Grow longText />
<Grow longText />
</>
)}
<p>Grow: With mixed length texts</p>
{props.children(
<>
<Grow />
<Grow />
<Grow longText />
</>
)}
<p>Fill</p>
{props.children(
<>
<Fill />
<Fill />
<Fill />
</>
)}
<p>Fill: With long text</p>
{props.children(
<>
<Fill longText />
<Fill longText />
<Fill longText />
</>
)}
<p>Fill: With mixed length texts</p>
{props.children(
<>
<Fill />
<Fill />
<Fill longText />
</>
)}
</div>
);
}
export function WidthsTest(): JSX.Element {
return (
<div className={tw('space-y-16 pb-4')}>
<WidthTestTemplate title="Block">
{items => <div>{items}</div>}
</WidthTestTemplate>
<WidthTestTemplate title="Flex">
{items => <div className={tw('flex')}>{items}</div>}
</WidthTestTemplate>
<WidthTestTemplate title="Flex: Wrapped">
{items => <div className={tw('flex flex-wrap')}>{items}</div>}
</WidthTestTemplate>
<WidthTestTemplate title="Flex: Column">
{items => <div className={tw('flex flex-col')}>{items}</div>}
</WidthTestTemplate>
<WidthTestTemplate title="Flex: Dialog footer layout">
{items => (
<div className={tw('flex flex-wrap')}>
<div className={tw('ms-auto flex max-w-full flex-wrap')}>
{items}
</div>
</div>
)}
</WidthTestTemplate>
<WidthTestTemplate title="Grid">
{items => <div className={tw('grid grid-cols-3')}>{items}</div>}
</WidthTestTemplate>
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { SpinnerV2 } from '../components/SpinnerV2.dom.js';
const Namespace = 'AxoButton';
const baseAxoButtonStyles = tw(
'relative inline-flex items-center-safe justify-center-safe rounded-full select-none',
'relative inline-flex max-w-full items-center-safe justify-center-safe rounded-full select-none',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'forced-colors:border'
);
@@ -204,10 +204,22 @@ export namespace AxoButton {
export type Variant = AxoButtonVariant;
export type Size = AxoButtonSize;
export type Width = 'fit' | 'grow' | 'fill';
const Widths: Record<Width, TailwindStyles> = {
/* 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 */
fill: tw('w-full'),
};
export type RootProps = BaseButtonAttrs &
Readonly<{
variant: AxoButtonVariant;
size: AxoButtonSize;
width?: Width;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
experimentalSpinner?: { 'aria-label': string } | null;
@@ -219,6 +231,7 @@ export namespace AxoButton {
const {
variant,
size,
width,
symbol,
arrow,
experimentalSpinner,
@@ -233,24 +246,27 @@ export namespace AxoButton {
AxoButtonSizes[size],
`${Namespace}: Invalid size ${size}`
);
const widthStyles = Widths[width ?? 'fit'];
return (
<button
ref={ref}
type="button"
className={tw(variantStyles, sizeStyles)}
className={tw(variantStyles, sizeStyles, widthStyles)}
{...rest}
>
<span
className={tw(
'flex shrink grow items-center-safe justify-center-safe gap-1 truncate',
'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} />
)}
{children}
<span className={tw('min-w-0 shrink grow truncate')}>
{children}
</span>
{arrow && (
<AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
)}

View File

@@ -0,0 +1,145 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { 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';
export default {
title: 'Axo/AxoDialog',
} satisfies Meta;
const TEXT_SHORT = <>Lorem ipsum dolor</>;
const TEXT_LONG = (
<>
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Eum repudiandae
repellendus quo natus, placeat incidunt neque, exercitationem itaque, error
molestiae omnis laudantium? Ex aperiam quas impedit ut ratione cumque
repudiandae!
</>
);
function Box(props: { children: ReactNode }) {
return (
<div
className={tw(
'flex items-center justify-center rounded-2xl bg-color-fill-primary p-10 type-title-large font-semibold text-label-primary-on-color'
)}
>
{props.children}
</div>
);
}
function Template(props: {
back?: boolean;
contentSize: AxoDialog.ContentSize;
bodyPadding?: AxoDialog.BodyPadding;
footerContent?: ReactNode;
children: ReactNode;
}): JSX.Element {
const [open, setOpen] = useState(true);
return (
<AxoDialog.Root open={open} onOpenChange={setOpen}>
<AxoDialog.Trigger>
<AxoButton.Root variant="secondary" size="medium">
Open Dialog
</AxoButton.Root>
</AxoDialog.Trigger>
<AxoDialog.Content size={props.contentSize} escape="cancel-is-noop">
<AxoDialog.Header>
{props.back && <AxoDialog.Back aria-label="Back" />}
<AxoDialog.Title>Title</AxoDialog.Title>
<AxoDialog.Close aria-label="Close" />
</AxoDialog.Header>
<AxoDialog.Body padding={props.bodyPadding}>
{props.children}
</AxoDialog.Body>
<AxoDialog.Footer>
{props.footerContent && (
<AxoDialog.FooterContent>
{props.footerContent}
</AxoDialog.FooterContent>
)}
<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>
);
}
export function Basic(): JSX.Element {
return (
<Template contentSize="md">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Laboriosam est
cum consequuntur natus repudiandae vel aperiam minus pariatur,
repellendus reprehenderit ad unde, sit magnam dicta ut deleniti veniam
modi ea.
</p>
</Template>
);
}
export function Small(): JSX.Element {
return <Template contentSize="sm">{TEXT_LONG}</Template>;
}
export function Large(): JSX.Element {
return <Template contentSize="lg">{TEXT_LONG}</Template>;
}
export function LongContent(): JSX.Element {
return (
<Template contentSize="md">
<div className={tw('flex flex-col gap-2')}>
{Array.from({ length: 10 }, (_, index) => {
return <Box key={index}>{index + 1}</Box>;
})}
</div>
</Template>
);
}
export function BackButton(): JSX.Element {
return (
<Template contentSize="md" back>
{TEXT_LONG}
</Template>
);
}
export function FooterContent(): JSX.Element {
return (
<Template contentSize="md" footerContent={TEXT_SHORT}>
{TEXT_LONG}
</Template>
);
}
export function FooterContentLong(): JSX.Element {
return (
<Template contentSize="md" footerContent={TEXT_LONG}>
{TEXT_LONG}
</Template>
);
}
export function FooterContentLongAndTight(): JSX.Element {
return (
<Template contentSize="sm" footerContent={TEXT_LONG}>
{TEXT_LONG}
</Template>
);
}

434
ts/axo/AxoDialog.dom.tsx Normal file
View File

@@ -0,0 +1,434 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Dialog } from 'radix-ui';
import type {
CSSProperties,
FC,
ForwardedRef,
HTMLAttributes,
ReactNode,
} from 'react';
import React, { forwardRef, memo, useMemo } from 'react';
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { tw } from './tw.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import { getScrollbarGutters } from './_internal/scrollbars.dom.js';
import { AxoButton } from './AxoButton.dom.js';
const Namespace = 'AxoDialog';
const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog;
// We want to have 25px 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;
const DIALOG_PADDING_TARGET = 20;
const DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH =
DIALOG_PADDING_TARGET - SCROLLBAR_WIDTH_EXPECTED;
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;
export namespace AxoDialog {
/**
* Component: <AxoDialog.Root>
* ---------------------------
*/
export type RootProps = Readonly<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(props => {
return (
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange} modal>
{props.children}
</Dialog.Root>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoDialog.Trigger>
* ------------------------------
*/
export type TriggerProps = Readonly<{
children: ReactNode;
}>;
export const Trigger: FC<TriggerProps> = memo(props => {
return <Dialog.Trigger asChild>{props.children}</Dialog.Trigger>;
});
Trigger.displayName = `${Namespace}.Trigger`;
/**
* Component: <AxoDialog.Content>
* ------------------------------
*/
export type ContentSize = AxoBaseDialog.ContentSize;
export type ContentEscape = AxoBaseDialog.ContentEscape;
export type ContentProps = AxoBaseDialog.ContentProps;
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = AxoBaseDialog.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>
);
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoDialog.Header>
* -----------------------------
*/
export type HeaderProps = Readonly<{
children: ReactNode;
}>;
export const Header: FC<HeaderProps> = memo(props => {
const style = useMemo(() => {
return {
paddingBlock: DIALOG_HEADER_PADDING_BLOCK,
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
};
}, []);
return (
<div
className={tw(
'grid items-center',
'grid-cols-[[back-slot]_1fr_[title-slot]_auto_[close-slot]_1fr]'
)}
style={style}
>
{props.children}
</div>
);
});
Header.displayName = `${Namespace}.Header`;
type HeaderIconButtonProps = HTMLAttributes<HTMLButtonElement> &
Readonly<{
label: string;
symbol: AxoSymbol.IconName;
}>;
const HeaderIconButton = forwardRef(
(
props: HeaderIconButtonProps,
ref: ForwardedRef<HTMLButtonElement>
): JSX.Element => {
const { label, symbol, ...rest } = props;
return (
<button
ref={ref}
{...rest}
type="button"
aria-label={label}
className={tw(
'rounded-full p-1.5',
'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} />
</button>
);
}
);
HeaderIconButton.displayName = `${Namespace}._HeaderIconButton`;
/**
* Component: <AxoDialog.Title>
* ----------------------------
*/
export type TitleProps = Readonly<{
children: ReactNode;
}>;
export const Title: FC<TitleProps> = memo(props => {
return (
<Dialog.Title
className={tw(
'col-[title-slot]',
'truncate text-center',
'type-title-small text-label-primary'
)}
>
{props.children}
</Dialog.Title>
);
});
Title.displayName = `${Namespace}.Title`;
/**
* Component: <AxoDialog.Back>
* ---------------------------
*/
export type BackProps = Readonly<{
'aria-label': string;
}>;
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}>
<HeaderIconButton
label={props['aria-label']}
symbol="chevron-[start]"
/>
</div>
);
});
Back.displayName = `${Namespace}.Back`;
/**
* Component: <AxoDialog.Close>
* ----------------------------
*/
export type CloseProps = Readonly<{
'aria-label': string;
}>;
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}>
<Dialog.Close asChild>
<HeaderIconButton label={props['aria-label']} symbol="x" />
</Dialog.Close>
</div>
);
});
Close.displayName = `${Namespace}.Close`;
/**
* Component: <AxoDialog.Body>
* ---------------------------
*/
export type BodyPadding = 'normal' | 'only-scrollbar-gutter';
export type BodyProps = Readonly<{
padding?: BodyPadding;
children: ReactNode;
}>;
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,
};
}, [padding]);
return (
<AxoScrollArea.Root
maxHeight={contentSizeConfig.maxBodyHeight}
scrollbarWidth="thin"
>
<AxoScrollArea.Hint edge="top" />
<AxoScrollArea.Hint edge="bottom" />
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div style={style}>{props.children}</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</AxoScrollArea.Root>
);
});
Body.displayName = `${Namespace}.Body`;
/**
* Component: <AxoDialog.Description>
* ----------------------------------
*/
export type DescriptionProps = Readonly<{
children: ReactNode;
}>;
export const Description: FC<DescriptionProps> = memo(props => {
return <Dialog.Description>{props.children}</Dialog.Description>;
});
Description.displayName = `${Namespace}.Description`;
/**
* Component: <AxoDialog.Body>
* ---------------------------
*/
export type FooterProps = Readonly<{
children: ReactNode;
}>;
export const Footer: FC<FooterProps> = memo(props => {
const style = useMemo((): CSSProperties => {
return {
paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH,
};
}, []);
return (
<div
className={tw('flex flex-wrap items-center gap-3 py-3')}
style={style}
>
{props.children}
</div>
);
});
Footer.displayName = `${Namespace}.Footer`;
/**
* Component: <AxoDialog.FooterContent>
* ------------------------------------
*/
export type FooterContentProps = Readonly<{
children: ReactNode;
}>;
export const FooterContent: FC<FooterContentProps> = memo(props => {
return (
<div
className={tw(
// Allow the flex layout to place it in the same row as the actions
// if it can be wrapped to fit within the available space:
'basis-[min-content]',
// But if the text needs to wrap and the available space could only
// fit 1-2 words per line, push it up into its own row:
'min-w-[calc-size(fit-content,min(20ch,size))]',
// Allow it to fill its own row
'flex-grow',
'type-body-large text-label-primary'
)}
>
{props.children}
</div>
);
});
FooterContent.displayName = `${Namespace}.FooterContent`;
/**
* Component: <AxoDialog.Actions>
* ------------------------------
*/
export type ActionsProps = Readonly<{
children: ReactNode;
}>;
export const Actions: FC<ActionsProps> = memo(props => {
return (
<div
className={tw(
// Align the buttons to the right even when there's no FooterContent:
'ms-auto',
// Allow buttons to wrap to their own lines
'flex flex-wrap',
// Prevents buttons that don't fit in the container from overflowing
'max-w-full',
'items-center gap-x-2 gap-y-3'
)}
>
{props.children}
</div>
);
});
Actions.displayName = `${Namespace}.Actions`;
/**
* Component: <AxoDialog.Actions>
* ------------------------------
*/
export type ActionVariant = 'primary' | 'destructive' | 'secondary';
export type ActionProps = Readonly<{
variant: ActionVariant;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
onClick: () => void;
children: ReactNode;
}>;
export const Action: FC<ActionProps> = memo(props => {
return (
<AxoButton.Root
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow}
size="medium"
width="grow"
>
{props.children}
</AxoButton.Root>
);
});
Action.displayName = `${Namespace}.Action`;
}

199
ts/axo/AxoScrollArea.css Normal file
View File

@@ -0,0 +1,199 @@
/**
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Component: <AxoScrollArea.Hint>
* -------------------------------
*/
@layer components {
@keyframes axo-scroll-area-hint-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
/**
* Component: <AxoScrollArea.Mask>
* -------------------------------
*/
/* Disabling prettier because this is a lot easier to read */
/* prettier-ignore */
@layer components {
/**
* These @property declarations are needed to support animating gradients.
* We need different values for every side so they can be separately animated.
*/
@property --axo-scroll-area-mask-top-from-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-top-via-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-bottom-from-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-bottom-via-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-inline-start-from-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-inline-start-via-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-inline-end-from-color { syntax: '<color>'; inherits: false; initial-value: black; }
@property --axo-scroll-area-mask-inline-end-via-color { syntax: '<color>'; inherits: false; initial-value: black; }
@keyframes axo-scroll-area-mask-top-from-color { to { --axo-scroll-area-mask-top-from-color: transparent; } }
@keyframes axo-scroll-area-mask-top-via-color { to { --axo-scroll-area-mask-top-via-color: transparent; } }
@keyframes axo-scroll-area-mask-bottom-from-color { to { --axo-scroll-area-mask-bottom-from-color: transparent; } }
@keyframes axo-scroll-area-mask-bottom-via-color { to { --axo-scroll-area-mask-bottom-via-color: transparent; } }
@keyframes axo-scroll-area-mask-inline-start-from-color { to { --axo-scroll-area-mask-inline-start-from-color: transparent; } }
@keyframes axo-scroll-area-mask-inline-start-via-color { to { --axo-scroll-area-mask-inline-start-via-color: transparent; } }
@keyframes axo-scroll-area-mask-inline-end-from-color { to { --axo-scroll-area-mask-inline-end-from-color: transparent; } }
@keyframes axo-scroll-area-mask-inline-end-via-color { to { --axo-scroll-area-mask-inline-end-via-color: transparent; } }
.axo-scroll-area-mask {
/* Note: gradient syntax doesn't support "inline-start/inline-end" */
--axo-scroll-area-mask-inline-start-side: left;
--axo-scroll-area-mask-inline-end-side: right;
&:dir(rtl) {
--axo-scroll-area-mask-inline-start-side: right;
--axo-scroll-area-mask-inline-end-side: left;
}
/**
* Note: mask-image/composite/size/position
* all need to be kept in sync in the same exact order.
*/
mask-image:
/* scrollbar-vertical */
linear-gradient(black),
/* scrollbar-horizontal */
linear-gradient(black),
/* top */
linear-gradient(
to bottom,
var(--axo-scroll-area-mask-top-from-color) var(--axo-scroll-area-mask-start),
var(--axo-scroll-area-mask-top-via-color) var(--axo-scroll-area-mask-padding),
black var(--axo-scroll-area-mask-end)
),
/* bottom */
linear-gradient(
to top,
var(--axo-scroll-area-mask-bottom-from-color) var(--axo-scroll-area-mask-start),
var(--axo-scroll-area-mask-bottom-via-color) var(--axo-scroll-area-mask-padding),
black var(--axo-scroll-area-mask-end)
),
/* inline-start */
linear-gradient(
to var(--axo-scroll-area-mask-inline-end-side),
var(--axo-scroll-area-mask-inline-start-from-color) var(--axo-scroll-area-mask-start),
var(--axo-scroll-area-mask-inline-start-via-color) var(--axo-scroll-area-mask-padding),
black var(--axo-scroll-area-mask-end)
),
/* inline-end */
linear-gradient(
to var(--axo-scroll-area-mask-inline-start-side),
var(--axo-scroll-area-mask-inline-end-from-color) var(--axo-scroll-area-mask-start),
var(--axo-scroll-area-mask-inline-end-via-color) var(--axo-scroll-area-mask-padding),
black var(--axo-scroll-area-mask-end)
);
mask-composite:
/* scrollbar-vertical */
add,
/* scrollbar-horizontal */
add,
/* top */
intersect,
/* bottom */
intersect,
/* inline-start */
intersect,
/* inline-end */
intersect;
mask-size:
/* scrollbar-vertical */
var(--axo-scroll-area-mask-scrollbar-gutter-vertical) 100%,
/* scrollbar-horizontal */
100% var(--axo-scroll-area-mask-scrollbar-gutter-horizontal),
/* top */
calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-vertical)) calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-horizontal)),
/* bottom */
calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-vertical)) calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-horizontal)),
/* inline-start */
calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-vertical)) calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-horizontal)),
/* inline-end */
calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-vertical)) calc(100% - var(--axo-scroll-area-mask-scrollbar-gutter-horizontal));
mask-position:
/* scrollbar-vertical */
top 0px var(--axo-scroll-area-mask-inline-end-side) 0px,
/* scrollbar-horizontal */
bottom 0px var(--axo-scroll-area-mask-inline-start-side) 0px,
/* top */
top 0px var(--axo-scroll-area-mask-inline-start-side) 0px,
/* bottom */
bottom var(--axo-scroll-area-mask-scrollbar-gutter-horizontal) var(--axo-scroll-area-mask-inline-start-side) 0px,
/* inline-start */
top 0px var(--axo-scroll-area-mask-inline-start-side) 0px,
/* inline-end */
top 0px var(--axo-scroll-area-mask-inline-end-side) var(--axo-scroll-area-mask-scrollbar-gutter-vertical);
mask-mode: alpha;
mask-repeat: no-repeat;
/**
* Note: animation-name/timeline/range/direction
* all need to be kept in sync in the same exact order.
*/
animation-name:
/* top */
axo-scroll-area-mask-top-from-color,
axo-scroll-area-mask-top-via-color,
/* bottom */
axo-scroll-area-mask-bottom-from-color,
axo-scroll-area-mask-bottom-via-color,
/* inline-start */
axo-scroll-area-mask-inline-start-from-color,
axo-scroll-area-mask-inline-start-via-color,
/* inline-end */
axo-scroll-area-mask-inline-end-from-color,
axo-scroll-area-mask-inline-end-via-color;
animation-timeline:
/* top */
--axo-scroll-area-timeline-vertical,
--axo-scroll-area-timeline-vertical,
/* bottom */
--axo-scroll-area-timeline-vertical,
--axo-scroll-area-timeline-vertical,
/* inline-start */
--axo-scroll-area-timeline-horizontal,
--axo-scroll-area-timeline-horizontal,
/* inline-end */
--axo-scroll-area-timeline-horizontal,
--axo-scroll-area-timeline-horizontal;
animation-range:
/* top */
var(--axo-scroll-area-animation-start) var(--axo-scroll-area-animation-padding),
var(--axo-scroll-area-animation-start) var(--axo-scroll-area-animation-end),
/* bottom (range is flipped) */
calc(100% - var(--axo-scroll-area-animation-padding)) calc(100% - var(--axo-scroll-area-animation-start)),
calc(100% - var(--axo-scroll-area-animation-end)) calc(100% - var(--axo-scroll-area-animation-start)),
/* inline-start */
var(--axo-scroll-area-animation-start) var(--axo-scroll-area-animation-padding),
var(--axo-scroll-area-animation-start) var(--axo-scroll-area-animation-end),
/* inline-end (range is flipped) */
calc(100% - var(--axo-scroll-area-animation-padding)) calc(100% - var(--axo-scroll-area-animation-start)),
calc(100% - var(--axo-scroll-area-animation-end)) calc(100% - var(--axo-scroll-area-animation-start));
animation-direction:
/* top */
normal,
normal,
/* bottom */
reverse,
reverse,
/* inline-start */
normal,
normal,
/* inline-end */
reverse,
reverse;
animation-duration: 1ms;
animation-fill-mode: both;
}
}

View File

@@ -0,0 +1,274 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useMemo } from 'react';
import type { Meta } from '@storybook/react';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import { tw } from './tw.dom.js';
import { getScrollbarGutters } from './_internal/scrollbars.dom.js';
import { AxoSymbol } from './AxoSymbol.dom.js';
export default {
title: 'Axo/AxoScrollArea',
} satisfies Meta;
function Box(props: { children: ReactNode }) {
return (
<div
className={tw(
'flex items-center justify-center rounded-2xl bg-color-fill-primary p-10 type-title-large font-semibold text-label-primary-on-color'
)}
>
{props.children}
</div>
);
}
function MaybeMask(props: { mask?: boolean; children: ReactNode }) {
if (props.mask) {
return <AxoScrollArea.Mask>{props.children}</AxoScrollArea.Mask>;
}
return <>{props.children}</>;
}
function VerticalTemplate(props: {
items: number;
centered?: boolean;
fit?: boolean;
hints?: boolean;
mask?: boolean;
}) {
const paddingInline = useMemo(() => {
return getScrollbarGutters('thin', 'custom').vertical;
}, []);
return (
<div className={tw('w-64 rounded-2xl bg-background-secondary')}>
<h1
className={tw('pt-3 pb-2 type-title-large')}
style={{ paddingInline }}
>
Header
</h1>
<div className={tw(props.fit || 'h-100')}>
<AxoScrollArea.Root
scrollbarWidth="thin"
maxHeight={props.fit ? 400 : undefined}
>
{props.hints && <AxoScrollArea.Hint edge="top" />}
{props.hints && <AxoScrollArea.Hint edge="bottom" />}
<MaybeMask mask={props.mask}>
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div
className={tw(
'flex flex-col gap-2',
props.centered && 'min-h-full justify-center'
)}
>
{Array.from({ length: props.items }, (_, index) => {
return <Box key={index}>{index + 1}</Box>;
})}
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</MaybeMask>
</AxoScrollArea.Root>
</div>
<p className={tw('pt-2 pb-3 type-title-large')} style={{ paddingInline }}>
Footer
</p>
</div>
);
}
function VerticalVariants(props: { mask?: boolean; hints?: boolean }) {
return (
<div className={tw('flex w-fit flex-row gap-2')}>
<VerticalTemplate {...props} items={10} />
<VerticalTemplate {...props} items={10} centered />
<VerticalTemplate {...props} items={10} fit />
<VerticalTemplate {...props} items={2} />
<VerticalTemplate {...props} items={2} centered />
<VerticalTemplate {...props} items={2} fit />
<VerticalTemplate {...props} items={0} />
<VerticalTemplate {...props} items={0} centered />
<VerticalTemplate {...props} items={0} fit />
</div>
);
}
export function Vertical(): JSX.Element {
return <VerticalVariants />;
}
export function VerticalWithHints(): JSX.Element {
return <VerticalVariants hints />;
}
export function VerticalWithMask(): JSX.Element {
return <VerticalVariants mask />;
}
function HorizontalTemplate(props: {
items: number;
centered?: boolean;
fit?: boolean;
hints?: boolean;
mask?: boolean;
}) {
return (
<div
className={tw(
'flex h-32 w-fit flex-row rounded-2xl bg-background-secondary'
)}
>
<div className={tw('flex flex-col justify-center p-4')}>
<AxoSymbol.Icon label={null} symbol="arrow-[start]" size={24} />
</div>
<div className={tw(props.fit || 'w-100')}>
<AxoScrollArea.Root
orientation="horizontal"
scrollbarWidth="thin"
maxWidth={props.fit ? 400 : undefined}
>
{props.hints && <AxoScrollArea.Hint edge="inline-start" />}
{props.hints && <AxoScrollArea.Hint edge="inline-end" />}
<MaybeMask mask={props.mask}>
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div
className={tw(
'flex h-full flex-row items-stretch gap-2',
props.centered && 'justify-center-safe'
)}
>
{Array.from({ length: props.items }, (_, index) => {
return <Box key={index}>{index + 1}</Box>;
})}
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</MaybeMask>
</AxoScrollArea.Root>
</div>
<div className={tw('flex flex-col justify-center p-4')}>
<AxoSymbol.Icon label={null} symbol="arrow-[end]" size={24} />
</div>
</div>
);
}
function HorizontalVariants(props: { mask?: boolean; hints?: boolean }) {
return (
<div className={tw('flex flex-col gap-2')}>
<HorizontalTemplate {...props} items={10} />
<HorizontalTemplate {...props} items={10} centered />
<HorizontalTemplate {...props} items={10} fit />
<HorizontalTemplate {...props} items={2} />
<HorizontalTemplate {...props} items={2} centered />
<HorizontalTemplate {...props} items={2} fit />
<HorizontalTemplate {...props} items={0} />
</div>
);
}
export function Horizontal(): JSX.Element {
return <HorizontalVariants />;
}
export function HorizontalWithHints(): JSX.Element {
return <HorizontalVariants hints />;
}
export function HorizontalWithMask(): JSX.Element {
return <HorizontalVariants mask />;
}
function BothTemplate(props: {
cols: number;
rows: number;
centered?: boolean;
fit?: boolean;
hints?: boolean;
mask?: boolean;
}) {
return (
<div
className={tw(
props.fit || 'size-100',
'rounded-lg bg-background-secondary'
)}
>
<AxoScrollArea.Root
orientation="both"
scrollbarWidth="thin"
scrollbarGutter="stable-both-edges"
maxWidth={props.fit ? 400 : undefined}
maxHeight={props.fit ? 400 : undefined}
>
{props.hints && <AxoScrollArea.Hint edge="top" />}
{props.hints && <AxoScrollArea.Hint edge="bottom" />}
{props.hints && <AxoScrollArea.Hint edge="inline-start" />}
{props.hints && <AxoScrollArea.Hint edge="inline-end" />}
<MaybeMask mask={props.mask}>
<AxoScrollArea.Viewport>
<AxoScrollArea.Content>
<div
className={tw(
'flex flex-col gap-2',
props.centered && 'min-h-full justify-center'
)}
>
{Array.from({ length: props.rows }, (_, row) => {
return (
<div
key={row}
className={tw(
'flex flex-row gap-2',
props.centered && 'justify-center'
)}
>
{Array.from({ length: props.cols }, (_2, col) => {
return <Box key={col}>{col + 1}</Box>;
})}
</div>
);
})}
</div>
</AxoScrollArea.Content>
</AxoScrollArea.Viewport>
</MaybeMask>
</AxoScrollArea.Root>
</div>
);
}
function BothVariants(props: { mask?: boolean; hints?: boolean }) {
return (
<div className={tw('flex flex-col items-start justify-start gap-2')}>
<BothTemplate {...props} cols={10} rows={10} />
<BothTemplate {...props} cols={10} rows={10} centered />
<BothTemplate {...props} cols={10} rows={10} fit />
<BothTemplate {...props} cols={2} rows={2} />
<BothTemplate {...props} cols={2} rows={2} centered />
<BothTemplate {...props} cols={2} rows={2} fit />
<BothTemplate {...props} cols={0} rows={0} />
<BothTemplate {...props} cols={0} rows={0} centered />
<BothTemplate {...props} cols={0} rows={0} fit />
</div>
);
}
export function Both(): JSX.Element {
return <BothVariants />;
}
export function BothWithHints(): JSX.Element {
return <BothVariants hints />;
}
export function BothWithMask(): JSX.Element {
return <BothVariants mask />;
}

View File

@@ -0,0 +1,464 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { createContext, memo, useContext, useMemo } from 'react';
import type { CSSProperties, FC, ReactNode } from 'react';
import type { TailwindStyles } from './tw.dom.js';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js';
import { getScrollbarGutters } from './_internal/scrollbars.dom.js';
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 =
createContext<AxoScrollAreaOrientation | null>(null);
export function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
return assert(
useContext(AxoScrollAreaOrientationContext),
`Must be wrapped with <${Namespace}.Root>`
);
}
/**
* 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`.
*
* @example Anatomy
* ```tsx
* <AxoScrollArea.Root>
* <AxoScrollArea.Hint edge="top"/>
* <AxoScrollArea.Hint edge="bottom"/>
* <AxoScrollArea.Mask>
* <AxoScrollArea.Viewport>
* <AxoScrollArea.Content>
* ...
* </AxoScrollArea.Content>
* </AxoScrollArea.Viewport>
* </AxoScrollArea.Mask>
* </AxoScrollArea.Root>
* ```
*/
export namespace AxoScrollArea {
/**
* Context: ScrollAreaOrientation
*/
export type Orientation = AxoScrollAreaOrientation;
/**
* Context: ScrollAreaConfig
*/
export type ScrollbarWidth = 'wide' | 'thin' | 'none';
export type ScrollbarGutter =
| 'unstable'
| 'stable-one-edge'
| 'stable-both-edges';
export type ScrollBehavior = 'auto' | 'smooth';
type ScrollAreaConfig = Readonly<{
scrollbarWidth: ScrollbarWidth;
scrollbarGutter: ScrollbarGutter;
scrollBehavior: ScrollBehavior;
}>;
const ScrollAreaConfigContext = createContext<ScrollAreaConfig | null>(null);
// eslint-disable-next-line no-inner-declarations
function useAxoScrollAreaConfig(): ScrollAreaConfig {
return assert(
useContext(ScrollAreaConfigContext),
`Must be wrapped with <${Namespace}.Root>`
);
}
/**
* Component: <AxoScrollArea.Root>
* -------------------------------
*/
export type RootProps = Readonly<{
orientation?: Orientation;
maxWidth?: number;
maxHeight?: number;
scrollbarWidth: ScrollbarWidth;
scrollbarGutter?: ScrollbarGutter;
scrollBehavior?: ScrollBehavior;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(props => {
const {
orientation = 'vertical',
maxWidth,
maxHeight,
scrollbarWidth = 'thin',
scrollbarGutter = 'stable-both-edges',
scrollBehavior = 'auto',
} = props;
const config = useMemo((): ScrollAreaConfig => {
return { scrollbarWidth, scrollbarGutter, scrollBehavior };
}, [scrollbarWidth, scrollbarGutter, scrollBehavior]);
const style = useMemo((): CSSProperties => {
return {
maxWidth,
maxHeight,
// `timeline-scope` allows elements outside of the scrollable element
// to subscribe to the `scroll-timeline` below, which we need for <Hint>
timelineScope: `${AXO_SCROLL_AREA_TIMELINE_VERTICAL}, ${AXO_SCROLL_AREA_TIMELINE_HORIZONTAL}`,
};
}, [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] outline-border-focused',
// Move the outline from the viewport to the parent
// so it doesn't get cut off by <Mask>
'[:where(.keyboard-mode)_&:has([data-axo-scroll-area-viewport]:focus)]:outline-[2.5px]',
'forced-colors:border forced-colors:border-[ButtonBorder]'
)}
style={style}
>
{props.children}
</div>
</ScrollAreaConfigContext.Provider>
</AxoScrollAreaOrientationContext.Provider>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoScrollArea.Viewport>
* -----------------------------------
*/
const baseViewportStyles = tw(
'relative z-0',
'flex size-full flex-col',
'overscroll-contain',
// <Root> handles the focus ring
'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'),
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 ViewportScrollBehaviors: Record<ScrollBehavior, TailwindStyles> = {
auto: tw('scroll-auto'),
smooth: tw('scroll-smooth'),
};
export type ViewportProps = Readonly<{
children: ReactNode;
}>;
export const Viewport: FC<ViewportProps> = memo(props => {
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth, scrollbarGutter, scrollBehavior } =
useAxoScrollAreaConfig();
const style = useMemo((): CSSProperties => {
const hasVerticalScrollbar = orientation !== 'horizontal';
const hasHorizontalScrollbar = orientation !== 'vertical';
// `scrollbar-gutter: stable both-edges` is broken in Chrome
// See: https://issues.chromium.org/issues/40064879)
// Instead we use padding to polyfill the feature
let paddingTop: number | undefined;
let paddingInlineStart: number | undefined;
if (scrollbarGutter === 'stable-both-edges') {
const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom');
if (hasVerticalScrollbar) {
paddingInlineStart = scrollbarGutters.vertical;
}
if (hasHorizontalScrollbar) {
paddingTop = scrollbarGutters.horizontal;
}
}
// Enable overflow based on the orientation of the scroll area
let overflowY: CSSProperties['overflowY'] = 'hidden';
let overflowX: CSSProperties['overflowX'] = 'hidden';
if (hasVerticalScrollbar) {
overflowY = 'auto';
}
if (hasHorizontalScrollbar) {
// `scrollbar-gutter: stable` only applies to the vertical scrollbar.
// By using `overflow-x: scroll` we can emulate the same behavior
const needsScrollbarGutterFix = scrollbarGutter !== 'unstable';
overflowX = needsScrollbarGutterFix ? 'scroll' : 'auto';
}
return {
overflowX,
overflowY,
paddingInlineStart,
paddingTop,
// Add `scroll-timeline` so that components like <Hint> and <Mask> can
// animated based on the current scroll position
scrollTimeline: `${AXO_SCROLL_AREA_TIMELINE_VERTICAL} y, ${AXO_SCROLL_AREA_TIMELINE_HORIZONTAL} x`,
};
}, [orientation, scrollbarWidth, scrollbarGutter]);
return (
<div
data-axo-scroll-area-viewport
className={tw(
baseViewportStyles,
ViewportOrientations[orientation],
ViewportScrollbarWidths[scrollbarWidth],
ViewportScrollbarGutters[scrollbarGutter],
ViewportScrollBehaviors[scrollBehavior]
)}
style={style}
>
{props.children}
</div>
);
});
Viewport.displayName = `${Namespace}.Viewport`;
/**
* Component: <AxoScrollArea.Content>
* ----------------------------------
*/
export type ContentProps = Readonly<{
children: ReactNode;
}>;
const contentStyles = tw(
//
// CSS scrollers come in two forms:
// 1. Parent determines the width/height of the scroller.
// 2. Parent is sized based on the content of the scroller.
//
// For #2, we'll make the intrisic size fit to the content.
'size-fit',
// For #1, we'll fill the available space (this has no effect on #2).
'min-h-full min-w-full',
// Also support flex containers for #1
'grow'
);
export const Content: FC<ContentProps> = memo(props => {
return <div className={contentStyles}>{props.children}</div>;
});
Content.displayName = `${Namespace}.Content`;
/**
* Component: <AxoScrollArea.Hint>
* -------------------------------
*/
export type Edge = 'top' | 'bottom' | 'inline-start' | 'inline-end';
const edgeStyles = tw(
'absolute z-10',
'opacity-0',
'from-shadow-outline to-transparent dark:from-shadow-elevation-1',
'animate-duration-1 [animation-name:axo-scroll-area-hint-reveal]'
);
// Need `animation-fill-mode` so we can customize the `animation-range`
const edgeStartStyles = tw('animate-forwards');
const edgeEndStyles = tw('animate-backwards animate-reverse');
const edgeYStyles = tw('inset-x-0 h-0.5');
const edgeXStyles = tw('inset-y-0 w-0.5');
const HintEdges: Record<Edge, TailwindStyles> = {
top: tw(
edgeStyles,
edgeYStyles,
edgeStartStyles,
'top-0',
'bg-gradient-to-b'
),
bottom: tw(
edgeStyles,
edgeYStyles,
edgeEndStyles,
'bottom-0',
'bg-gradient-to-t'
),
'inline-start': tw(
edgeStyles,
edgeXStyles,
edgeStartStyles,
'start-0',
'bg-gradient-to-r rtl:bg-gradient-to-l'
),
'inline-end': tw(
edgeStyles,
edgeXStyles,
edgeEndStyles,
'end-0',
'bg-gradient-to-l rtl:bg-gradient-to-r'
),
};
export type HintProps = Readonly<{
animationStartOffset?: number;
animationEndOffset?: number;
edge: Edge;
}>;
export const Hint: FC<HintProps> = memo(props => {
const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props;
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useAxoScrollAreaConfig();
const style = useMemo((): CSSProperties => {
const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom');
const isVerticalEdge = edge === 'top' || edge === 'bottom';
const isStartEdge = edge === 'top' || edge === 'inline-start';
return {
insetInlineEnd:
edge !== 'inline-start' && orientation === 'both'
? scrollbarGutters.horizontal
: undefined,
bottom:
edge !== 'top' && orientation === 'both'
? scrollbarGutters.vertical
: undefined,
animationTimeline: isVerticalEdge
? AXO_SCROLL_AREA_TIMELINE_VERTICAL
: AXO_SCROLL_AREA_TIMELINE_HORIZONTAL,
animationRangeStart: isStartEdge
? `${animationStartOffset}px`
: `calc(100% - ${animationEndOffset}px)`,
animationRangeEnd: isStartEdge
? `${animationEndOffset}px`
: `calc(100% - ${animationStartOffset}px)`,
};
}, [
scrollbarWidth,
edge,
orientation,
animationStartOffset,
animationEndOffset,
]);
return <div className={HintEdges[edge]} style={style} />;
});
Hint.displayName = `${Namespace}.Hint`;
/**
* Component: <AxoScrollArea.Mask>
* -------------------------------
*/
export type MaskProps = Readonly<{
maskStart?: number;
maskPadding?: number;
maskEnd?: number;
animationStart?: number;
animationPadding?: number;
animationEnd?: number;
children: ReactNode;
}>;
// These styles are very complex so they are in a separate CSS file
const AXO_MASK_CLASS_NAME = 'axo-scroll-area-mask';
export const Mask: FC<MaskProps> = memo(props => {
const {
maskStart = 0,
maskPadding = 4,
maskEnd = 40,
animationStart = maskStart,
animationPadding = maskPadding,
animationEnd = maskEnd * 3,
} = props;
const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useAxoScrollAreaConfig();
const style = useMemo(() => {
const scrollbarGutters = getScrollbarGutters(scrollbarWidth, 'custom');
const hasVerticalScrollbar = orientation !== 'horizontal';
const hasHorizontalScrollbar = orientation !== 'vertical';
const verticalGutter = hasVerticalScrollbar
? `${scrollbarGutters.vertical}px`
: '0px';
const horizontalGutter = hasHorizontalScrollbar
? `${scrollbarGutters.horizontal}px`
: '0px';
return {
'--axo-scroll-area-mask-scrollbar-gutter-vertical': verticalGutter,
'--axo-scroll-area-mask-scrollbar-gutter-horizontal': horizontalGutter,
'--axo-scroll-area-mask-start': `${maskStart}px`,
'--axo-scroll-area-mask-padding': `${maskPadding}px`,
'--axo-scroll-area-mask-end': `${maskEnd}px`,
'--axo-scroll-area-animation-start': `${animationStart}px`,
'--axo-scroll-area-animation-padding': `${animationPadding}px`,
'--axo-scroll-area-animation-end': `${animationEnd}px`,
} as CSSProperties;
}, [
scrollbarWidth,
orientation,
maskStart,
maskPadding,
maskEnd,
animationStart,
animationPadding,
animationEnd,
]);
return (
<div
className={tw('flex size-full flex-col', AXO_MASK_CLASS_NAME)}
style={style}
>
{props.children}
</div>
);
});
Mask.displayName = `${Namespace}.Mask`;
}

View File

@@ -89,7 +89,7 @@ export namespace AxoSymbol {
}>;
const iconStyles = tw(
'inline-flex size-[1em] shrink-0 items-center justify-center'
'inline-flex size-[1em] shrink-0 items-center justify-center align-top'
);
export const Icon: FC<IconProps> = memo(props => {

View File

@@ -0,0 +1,102 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createContext, useCallback, useContext } from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.dom.js';
import { assert } from './assert.dom.js';
export namespace AxoBaseDialog {
/**
* AxoBaseDialog: Root
* -------------------
*/
export type RootProps = Readonly<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}>;
/**
* AxoBaseDialog: Trigger
* ----------------------
*/
export type TriggerProps = Readonly<{
children: ReactNode;
}>;
/**
* AxoBaseDialog: Overlay
* ----------------------
*/
export const overlayStyles = tw(
'absolute inset-0 flex items-center-safe justify-center-safe bg-background-overlay p-4',
// Allow the entire overlay to be scrolled in case the window is extremely small
'overflow-auto scrollbar-width-none',
'data-[state=closed]:animate-exit data-[state=open]:animate-enter',
'animate-opacity-0'
);
/**
* AxoBaseDialog: Content
* ----------------------
*/
export const contentStyles = tw(
'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]',
'data-[state=closed]:animate-exit data-[state=open]:animate-enter',
'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(
escape: ContentEscape
): (event: Event) => void {
return useCallback(
event => {
if (escape === 'cancel-is-destructive') {
event.preventDefault();
}
},
[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'
);
}
}

View File

@@ -11,7 +11,7 @@ export namespace AxoBaseMenu {
'max-w-[300px] min-w-[200px]',
'select-none',
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
'data-[state=closed]:animate-fade-out',
'animate-opacity-0 data-[state=closed]:animate-exit',
'forced-colors:border',
'forced-colors:bg-[Canvas]',
'forced-colors:text-[CanvasText]'

View File

@@ -0,0 +1,84 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from './assert.dom.js';
export type ScrollbarWidth = 'wide' | 'thin' | 'none';
export type ScrollbarColor = 'native' | 'custom';
const ScrollbarWidths: Record<ScrollbarWidth, string> = {
wide: 'auto',
thin: 'thin',
none: 'none',
};
const ScrollbarColors: Record<ScrollbarColor, string> = {
native: 'auto',
custom: 'black transparent',
};
export type ScrollbarGutters = Readonly<{
vertical: number;
horizontal: number;
}>;
const SCROLLBAR_GUTTERS_CACHE = new Map<string, ScrollbarGutters>();
function isValidClientSize(value: number): boolean {
return Number.isInteger(value) && value > 0;
}
export function getScrollbarGutters(
scrollbarWidth: ScrollbarWidth,
scrollbarColor: ScrollbarColor
): ScrollbarGutters {
const cacheKey = `${scrollbarWidth}, ${scrollbarColor}`;
const cached = SCROLLBAR_GUTTERS_CACHE.get(cacheKey);
if (cached != null) {
return cached;
}
const outer = document.createElement('div');
const inner = document.createElement('div');
// Use `all: initial` to avoid other styles affecting the measurement
// This resets elements to their initial value (such as `display: inline`)
outer.style.setProperty('all', 'initial');
outer.style.setProperty('display', 'block');
outer.style.setProperty('visibility', 'hidden');
outer.style.setProperty('overflow', 'auto');
outer.style.setProperty('width', '100px');
outer.style.setProperty('height', '100px');
outer.style.setProperty('scrollbar-width', ScrollbarWidths[scrollbarWidth]);
outer.style.setProperty('scrollbar-color', ScrollbarColors[scrollbarColor]);
inner.style.setProperty('all', 'initial');
inner.style.setProperty('display', 'block');
inner.style.setProperty('width', '101px');
inner.style.setProperty('height', '101px');
outer.append(inner);
// Insert the element into the DOM to get non-zero measurements
document.body.append(outer);
const { offsetWidth, offsetHeight, clientWidth, clientHeight } = outer;
outer.remove();
assert(offsetWidth === 100, 'offsetWidth must be exactly 100px');
assert(offsetHeight === 100, 'offsetHeight must be exactly 100px');
assert(
isValidClientSize(clientWidth),
'clientWidth must be non-zero positive integer'
);
assert(
isValidClientSize(clientHeight),
'clientHeight must be non-zero positive integer'
);
const vertical = offsetWidth - clientWidth;
const horizontal = offsetHeight - clientHeight;
const result: ScrollbarGutters = { vertical, horizontal };
SCROLLBAR_GUTTERS_CACHE.set(cacheKey, result);
return result;
}

6
ts/axo/_styles.css Normal file
View File

@@ -0,0 +1,6 @@
/**
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@import './AxoScrollArea.css';

View File

@@ -1,37 +1,30 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React from 'react';
import type { LocalizerType } from '../../../types/I18N.std.js';
import { ConfirmationDialog } from '../../ConfirmationDialog.dom.js';
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
export function DeleteChatFolderDialog(props: {
i18n: LocalizerType;
title: string;
description: ReactNode;
cancelText: string;
deleteText: string;
onConfirm: () => void;
onClose: () => void;
}): JSX.Element {
const { i18n } = props;
return (
<ConfirmationDialog
i18n={i18n}
dialogName="Preferences__DeleteChatFolderDialog"
title={props.title}
cancelText={props.cancelText}
actions={[
{
text: props.deleteText,
style: 'affirmative',
action: props.onConfirm,
},
]}
onClose={props.onClose}
>
{props.description}
</ConfirmationDialog>
<AxoAlertDialog.Content size="sm" escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>{props.title}</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{props.description}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>{props.cancelText}</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action variant="destructive" onClick={props.onConfirm}>
{props.deleteText}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
);
}

View File

@@ -42,6 +42,7 @@ import {
itemListItemClassName,
ItemTitle,
} from './PreferencesChatFolderItems.dom.js';
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
function moveChatFolders(
chatFolders: ReadonlyArray<CurrentChatFolder>,
@@ -322,9 +323,11 @@ export function PreferencesChatFoldersPage(
contentsRef={props.settingsPaneRef}
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
/>
{confirmDeleteChatFolder != null && (
<AxoAlertDialog.Root
open={confirmDeleteChatFolder != null}
onOpenChange={handleChatFolderDeleteCancel}
>
<DeleteChatFolderDialog
i18n={i18n}
title={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
)}
@@ -334,7 +337,7 @@ export function PreferencesChatFoldersPage(
id="icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description"
components={{
chatFolderTitle: (
<UserText text={confirmDeleteChatFolder.name} />
<UserText text={confirmDeleteChatFolder?.name ?? ''} />
),
}}
/>
@@ -345,10 +348,9 @@ export function PreferencesChatFoldersPage(
cancelText={i18n(
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
)}
onClose={handleChatFolderDeleteCancel}
onConfirm={handleChatFolderDeleteConfirm}
/>
)}
</AxoAlertDialog.Root>
</>
);
}

View File

@@ -47,6 +47,7 @@ import {
ItemTitle,
} from './PreferencesChatFolderItems.dom.js';
import { AxoButton } from '../../../axo/AxoButton.dom.js';
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
export type PreferencesEditChatFolderPageProps = Readonly<{
i18n: LocalizerType;
@@ -93,7 +94,6 @@ export function PreferencesEditChatFolderPage(
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
const normalizedChatFolderParams = useMemo(() => {
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
@@ -211,18 +211,12 @@ export function PreferencesEditChatFolderPage(
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
}, [blocker]);
const handleDeleteInit = useCallback(() => {
setShowDeleteFolderDialog(true);
}, []);
const handleDeleteConfirm = useCallback(() => {
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
onDeleteChatFolder(existingChatFolderId);
setShowDeleteFolderDialog(false);
handleBack();
}, [existingChatFolderId, onDeleteChatFolder, handleBack]);
const handleDeleteClose = useCallback(() => {
setShowDeleteFolderDialog(false);
}, []);
const handleSelectInclusions = useCallback(() => {
setShowInclusionsDialog(true);
}, []);
@@ -465,15 +459,33 @@ export function PreferencesEditChatFolderPage(
{props.existingChatFolderId != null && (
<SettingsRow>
<div className="Preferences__padding">
<button
type="button"
onClick={handleDeleteInit}
className="Preferences__ChatFolders__ChatList__DeleteButton"
>
{i18n(
'icu:Preferences__EditChatFolderPage__DeleteFolderButton'
)}
</button>
<AxoAlertDialog.Root>
<AxoAlertDialog.Trigger>
<button
type="button"
className="Preferences__ChatFolders__ChatList__DeleteButton"
>
{i18n(
'icu:Preferences__EditChatFolderPage__DeleteFolderButton'
)}
</button>
</AxoAlertDialog.Trigger>
<DeleteChatFolderDialog
title={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
)}
description={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
)}
deleteText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
)}
onConfirm={handleDeleteConfirm}
/>
</AxoAlertDialog.Root>
</div>
</SettingsRow>
)}
@@ -517,25 +529,6 @@ export function PreferencesEditChatFolderPage(
showChatTypes={false}
/>
)}
{showDeleteFolderDialog && (
<DeleteChatFolderDialog
i18n={i18n}
title={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
)}
description={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
)}
cancelText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
)}
deleteText={i18n(
'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
)}
onConfirm={handleDeleteConfirm}
onClose={handleDeleteClose}
/>
)}
{blocker.state === 'blocked' && (
<SaveChangesFolderDialog
i18n={i18n}

View File

@@ -243,8 +243,8 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
);
const confirmDeleteBtn = window
.getByTestId('ConfirmationDialog.Preferences__DeleteChatFolderDialog')
.locator('button:has-text("Delete")');
.getByRole('alertdialog', { name: 'Delete this chat folder?' })
.getByRole('button', { name: 'Delete' });
let state = await phone.expectStorageState('initial state');
// wait for initial creation of story distribution list and "all chats" chat folder