mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Init AxoAlertDialog
This commit is contained in:
@@ -269,8 +269,10 @@ function withFunProvider(Story, context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withAxoProvider(Story, context) {
|
function withAxoProvider(Story, context) {
|
||||||
|
const globalValue = context.globals.direction ?? 'ltr';
|
||||||
|
const dir = globalValue === 'auto' ? 'ltr' : globalValue;
|
||||||
return (
|
return (
|
||||||
<AxoProvider dir={context.globals.direction ?? 'ltr'}>
|
<AxoProvider dir={dir}>
|
||||||
<Story {...context} />
|
<Story {...context} />
|
||||||
</AxoProvider>
|
</AxoProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
@import 'tailwindcss' source(none);
|
@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 "../ts";
|
||||||
@source "../test";
|
@source "../test";
|
||||||
@@ -23,6 +28,7 @@
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
@theme {
|
@theme {
|
||||||
--color-*: initial; /* reset defaults */
|
--color-*: initial; /* reset defaults */
|
||||||
|
--color-transparent: transparent;
|
||||||
|
|
||||||
/* Colors/Labels */
|
/* Colors/Labels */
|
||||||
--color-label-primary: light-dark(--alpha(#000 / 85%), --alpha(#FFF / 85%));
|
--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);
|
--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
|
* Animations
|
||||||
* ----------------------------------------------------------------------------
|
* ----------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
|
--default-animation-duration: 120ms;
|
||||||
|
--default-animation-timing-function: var(--ease-out-cubic);
|
||||||
|
|
||||||
--animate-*: initial; /* reset defaults */
|
--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-rotate: animate-spinner-v2-rotate 2s linear infinite;
|
||||||
--animate-spinner-v2-dash: animate-spinner-v2-dash 1.5s ease-in-out infinite;
|
--animate-spinner-v2-dash: animate-spinner-v2-dash 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@keyframes animate-fade-out {
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes animate-spinner-v2-rotate {
|
@keyframes animate-spinner-v2-rotate {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(-180deg);
|
transform: rotate(-180deg);
|
||||||
@@ -416,3 +428,12 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: transparent;
|
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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { Meta } from '@storybook/react';
|
import type { Meta } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
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 Namespace = 'AxoButton';
|
||||||
|
|
||||||
const baseAxoButtonStyles = tw(
|
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]',
|
'outline-0 outline-border-focused focused:outline-[2.5px]',
|
||||||
'forced-colors:border'
|
'forced-colors:border'
|
||||||
);
|
);
|
||||||
@@ -204,10 +204,22 @@ export namespace AxoButton {
|
|||||||
export type Variant = AxoButtonVariant;
|
export type Variant = AxoButtonVariant;
|
||||||
export type Size = AxoButtonSize;
|
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 &
|
export type RootProps = BaseButtonAttrs &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
variant: AxoButtonVariant;
|
variant: AxoButtonVariant;
|
||||||
size: AxoButtonSize;
|
size: AxoButtonSize;
|
||||||
|
width?: Width;
|
||||||
symbol?: AxoSymbol.InlineGlyphName;
|
symbol?: AxoSymbol.InlineGlyphName;
|
||||||
arrow?: boolean;
|
arrow?: boolean;
|
||||||
experimentalSpinner?: { 'aria-label': string } | null;
|
experimentalSpinner?: { 'aria-label': string } | null;
|
||||||
@@ -219,6 +231,7 @@ export namespace AxoButton {
|
|||||||
const {
|
const {
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
|
width,
|
||||||
symbol,
|
symbol,
|
||||||
arrow,
|
arrow,
|
||||||
experimentalSpinner,
|
experimentalSpinner,
|
||||||
@@ -233,24 +246,27 @@ export namespace AxoButton {
|
|||||||
AxoButtonSizes[size],
|
AxoButtonSizes[size],
|
||||||
`${Namespace}: Invalid size ${size}`
|
`${Namespace}: Invalid size ${size}`
|
||||||
);
|
);
|
||||||
|
const widthStyles = Widths[width ?? 'fit'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
className={tw(variantStyles, sizeStyles)}
|
className={tw(variantStyles, sizeStyles, widthStyles)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={tw(
|
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
|
experimentalSpinner != null ? 'opacity-0' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{symbol != null && (
|
{symbol != null && (
|
||||||
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
<AxoSymbol.InlineGlyph symbol={symbol} label={null} />
|
||||||
)}
|
)}
|
||||||
{children}
|
<span className={tw('min-w-0 shrink grow truncate')}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
{arrow && (
|
{arrow && (
|
||||||
<AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
|
<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(
|
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 => {
|
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]',
|
'max-w-[300px] min-w-[200px]',
|
||||||
'select-none',
|
'select-none',
|
||||||
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
|
'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:border',
|
||||||
'forced-colors:bg-[Canvas]',
|
'forced-colors:bg-[Canvas]',
|
||||||
'forced-colors:text-[CanvasText]'
|
'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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { LocalizerType } from '../../../types/I18N.std.js';
|
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
|
||||||
import { ConfirmationDialog } from '../../ConfirmationDialog.dom.js';
|
|
||||||
|
|
||||||
export function DeleteChatFolderDialog(props: {
|
export function DeleteChatFolderDialog(props: {
|
||||||
i18n: LocalizerType;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
cancelText: string;
|
cancelText: string;
|
||||||
deleteText: string;
|
deleteText: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onClose: () => void;
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const { i18n } = props;
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationDialog
|
<AxoAlertDialog.Content size="sm" escape="cancel-is-noop">
|
||||||
i18n={i18n}
|
<AxoAlertDialog.Body>
|
||||||
dialogName="Preferences__DeleteChatFolderDialog"
|
<AxoAlertDialog.Title>{props.title}</AxoAlertDialog.Title>
|
||||||
title={props.title}
|
<AxoAlertDialog.Description>
|
||||||
cancelText={props.cancelText}
|
{props.description}
|
||||||
actions={[
|
</AxoAlertDialog.Description>
|
||||||
{
|
</AxoAlertDialog.Body>
|
||||||
text: props.deleteText,
|
<AxoAlertDialog.Footer>
|
||||||
style: 'affirmative',
|
<AxoAlertDialog.Cancel>{props.cancelText}</AxoAlertDialog.Cancel>
|
||||||
action: props.onConfirm,
|
<AxoAlertDialog.Action variant="destructive" onClick={props.onConfirm}>
|
||||||
},
|
{props.deleteText}
|
||||||
]}
|
</AxoAlertDialog.Action>
|
||||||
onClose={props.onClose}
|
</AxoAlertDialog.Footer>
|
||||||
>
|
</AxoAlertDialog.Content>
|
||||||
{props.description}
|
|
||||||
</ConfirmationDialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
itemListItemClassName,
|
itemListItemClassName,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from './PreferencesChatFolderItems.dom.js';
|
} from './PreferencesChatFolderItems.dom.js';
|
||||||
|
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
|
||||||
|
|
||||||
function moveChatFolders(
|
function moveChatFolders(
|
||||||
chatFolders: ReadonlyArray<CurrentChatFolder>,
|
chatFolders: ReadonlyArray<CurrentChatFolder>,
|
||||||
@@ -322,9 +323,11 @@ export function PreferencesChatFoldersPage(
|
|||||||
contentsRef={props.settingsPaneRef}
|
contentsRef={props.settingsPaneRef}
|
||||||
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
|
title={i18n('icu:Preferences__ChatFoldersPage__Title')}
|
||||||
/>
|
/>
|
||||||
{confirmDeleteChatFolder != null && (
|
<AxoAlertDialog.Root
|
||||||
|
open={confirmDeleteChatFolder != null}
|
||||||
|
onOpenChange={handleChatFolderDeleteCancel}
|
||||||
|
>
|
||||||
<DeleteChatFolderDialog
|
<DeleteChatFolderDialog
|
||||||
i18n={i18n}
|
|
||||||
title={i18n(
|
title={i18n(
|
||||||
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
|
||||||
)}
|
)}
|
||||||
@@ -334,7 +337,7 @@ export function PreferencesChatFoldersPage(
|
|||||||
id="icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description"
|
id="icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description"
|
||||||
components={{
|
components={{
|
||||||
chatFolderTitle: (
|
chatFolderTitle: (
|
||||||
<UserText text={confirmDeleteChatFolder.name} />
|
<UserText text={confirmDeleteChatFolder?.name ?? ''} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -345,10 +348,9 @@ export function PreferencesChatFoldersPage(
|
|||||||
cancelText={i18n(
|
cancelText={i18n(
|
||||||
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
|
'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
|
||||||
)}
|
)}
|
||||||
onClose={handleChatFolderDeleteCancel}
|
|
||||||
onConfirm={handleChatFolderDeleteConfirm}
|
onConfirm={handleChatFolderDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
</AxoAlertDialog.Root>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from './PreferencesChatFolderItems.dom.js';
|
} from './PreferencesChatFolderItems.dom.js';
|
||||||
import { AxoButton } from '../../../axo/AxoButton.dom.js';
|
import { AxoButton } from '../../../axo/AxoButton.dom.js';
|
||||||
|
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
|
||||||
|
|
||||||
export type PreferencesEditChatFolderPageProps = Readonly<{
|
export type PreferencesEditChatFolderPageProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
@@ -93,7 +94,6 @@ export function PreferencesEditChatFolderPage(
|
|||||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||||
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
|
const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
|
||||||
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
|
const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
|
||||||
const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
|
|
||||||
|
|
||||||
const normalizedChatFolderParams = useMemo(() => {
|
const normalizedChatFolderParams = useMemo(() => {
|
||||||
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
|
return parseStrict(ChatFolderParamsSchema, chatFolderParams);
|
||||||
@@ -211,18 +211,12 @@ export function PreferencesEditChatFolderPage(
|
|||||||
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
|
blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
|
||||||
}, [blocker]);
|
}, [blocker]);
|
||||||
|
|
||||||
const handleDeleteInit = useCallback(() => {
|
|
||||||
setShowDeleteFolderDialog(true);
|
|
||||||
}, []);
|
|
||||||
const handleDeleteConfirm = useCallback(() => {
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
|
strictAssert(existingChatFolderId, 'Missing existing chat folder id');
|
||||||
onDeleteChatFolder(existingChatFolderId);
|
onDeleteChatFolder(existingChatFolderId);
|
||||||
setShowDeleteFolderDialog(false);
|
|
||||||
handleBack();
|
handleBack();
|
||||||
}, [existingChatFolderId, onDeleteChatFolder, handleBack]);
|
}, [existingChatFolderId, onDeleteChatFolder, handleBack]);
|
||||||
const handleDeleteClose = useCallback(() => {
|
|
||||||
setShowDeleteFolderDialog(false);
|
|
||||||
}, []);
|
|
||||||
const handleSelectInclusions = useCallback(() => {
|
const handleSelectInclusions = useCallback(() => {
|
||||||
setShowInclusionsDialog(true);
|
setShowInclusionsDialog(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -465,15 +459,33 @@ export function PreferencesEditChatFolderPage(
|
|||||||
{props.existingChatFolderId != null && (
|
{props.existingChatFolderId != null && (
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
<div className="Preferences__padding">
|
<div className="Preferences__padding">
|
||||||
<button
|
<AxoAlertDialog.Root>
|
||||||
type="button"
|
<AxoAlertDialog.Trigger>
|
||||||
onClick={handleDeleteInit}
|
<button
|
||||||
className="Preferences__ChatFolders__ChatList__DeleteButton"
|
type="button"
|
||||||
>
|
className="Preferences__ChatFolders__ChatList__DeleteButton"
|
||||||
{i18n(
|
>
|
||||||
'icu:Preferences__EditChatFolderPage__DeleteFolderButton'
|
{i18n(
|
||||||
)}
|
'icu:Preferences__EditChatFolderPage__DeleteFolderButton'
|
||||||
</button>
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
)}
|
)}
|
||||||
@@ -517,25 +529,6 @@ export function PreferencesEditChatFolderPage(
|
|||||||
showChatTypes={false}
|
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' && (
|
{blocker.state === 'blocked' && (
|
||||||
<SaveChangesFolderDialog
|
<SaveChangesFolderDialog
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|||||||
@@ -243,8 +243,8 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const confirmDeleteBtn = window
|
const confirmDeleteBtn = window
|
||||||
.getByTestId('ConfirmationDialog.Preferences__DeleteChatFolderDialog')
|
.getByRole('alertdialog', { name: 'Delete this chat folder?' })
|
||||||
.locator('button:has-text("Delete")');
|
.getByRole('button', { name: 'Delete' });
|
||||||
|
|
||||||
let state = await phone.expectStorageState('initial state');
|
let state = await phone.expectStorageState('initial state');
|
||||||
// wait for initial creation of story distribution list and "all chats" chat folder
|
// wait for initial creation of story distribution list and "all chats" chat folder
|
||||||
|
|||||||
Reference in New Issue
Block a user