mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-05 01:10:49 +00:00
Init AxoAlertDialog
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
142
stylesheets/tailwind-plugins/animate-enter-exit.css
Normal file
142
stylesheets/tailwind-plugins/animate-enter-exit.css
Normal 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,);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
stylesheets/tailwind-plugins/animate-general.css
Normal file
85
stylesheets/tailwind-plugins/animate-general.css
Normal 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;
|
||||
}
|
||||
79
stylesheets/tailwind-plugins/scrollbar.css
Normal file
79
stylesheets/tailwind-plugins/scrollbar.css
Normal 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);
|
||||
}
|
||||
}
|
||||
129
ts/axo/AxoAlertDialog.dom.stories.tsx
Normal file
129
ts/axo/AxoAlertDialog.dom.stories.tsx
Normal 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 />;
|
||||
}
|
||||
252
ts/axo/AxoAlertDialog.dom.tsx
Normal file
252
ts/axo/AxoAlertDialog.dom.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
145
ts/axo/AxoDialog.dom.stories.tsx
Normal file
145
ts/axo/AxoDialog.dom.stories.tsx
Normal 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
434
ts/axo/AxoDialog.dom.tsx
Normal 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
199
ts/axo/AxoScrollArea.css
Normal 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;
|
||||
}
|
||||
}
|
||||
274
ts/axo/AxoScrollArea.dom.stories.tsx
Normal file
274
ts/axo/AxoScrollArea.dom.stories.tsx
Normal 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 />;
|
||||
}
|
||||
464
ts/axo/AxoScrollArea.dom.tsx
Normal file
464
ts/axo/AxoScrollArea.dom.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
102
ts/axo/_internal/AxoBaseDialog.dom.tsx
Normal file
102
ts/axo/_internal/AxoBaseDialog.dom.tsx
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]'
|
||||
|
||||
84
ts/axo/_internal/scrollbars.dom.tsx
Normal file
84
ts/axo/_internal/scrollbars.dom.tsx
Normal 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
6
ts/axo/_styles.css
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@import './AxoScrollArea.css';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user