From f8324142daf3d2417556400ae5e5e5b10f1133d0 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Fri, 9 Oct 2026 10:57:34 +0200 Subject: [PATCH] feat(react,ConfirmationDialog): add new component --- .../ConfirmationDialog.api.mdx | 31 +++++ .../ConfirmationDialog.stories.tsx | 126 ++++++++++++++++++ .../ConfirmationDialog/ConfirmationDialog.tsx | 104 +++++++++++++++ .../ConfirmationDialog.types.ts | 55 ++++++++ .../components/ConfirmationDialog/index.ts | 6 + .../Dialog/DialogActions/DialogActions.tsx | 11 +- .../DialogActions/DialogActions.types.ts | 10 ++ .../Dialog/DialogTitle/DialogTitle.tsx | 13 +- .../Dialog/DialogTitle/DialogTitle.types.ts | 7 + packages/react/src/components/index.ts | 1 + packages/react/src/overrides.ts | 2 + .../lib/components/_confirmation-dialog.scss | 85 ++++++++++++ packages/theme/lib/components/_dialog.scss | 41 +++++- packages/theme/lib/components/_index.scss | 2 + 14 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/components/ConfirmationDialog/ConfirmationDialog.api.mdx create mode 100644 packages/react/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx create mode 100644 packages/react/src/components/ConfirmationDialog/ConfirmationDialog.tsx create mode 100644 packages/react/src/components/ConfirmationDialog/ConfirmationDialog.types.ts create mode 100644 packages/react/src/components/ConfirmationDialog/index.ts create mode 100644 packages/theme/lib/components/_confirmation-dialog.scss diff --git a/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.api.mdx b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.api.mdx new file mode 100644 index 000000000..a61416577 --- /dev/null +++ b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.api.mdx @@ -0,0 +1,31 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import LinkTo from '@storybook/addon-links/react'; +import { TableInterface } from '~storybook/components/TableInterface'; + + + +# ConfirmationDialog API + +```js +import { ConfirmationDialog } from '@esfront/react'; +``` + +## Component name + +The name `ESConfirmationDialog` can be used when providing default props. + +## Props + + + +
+ +## Demos + + diff --git a/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx new file mode 100644 index 000000000..e6b718b4a --- /dev/null +++ b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -0,0 +1,126 @@ +import { ComponentProps } from 'react'; + +import { Meta, StoryContext, StoryObj } from '@storybook/react-vite'; + +import { ConfirmationDialog } from './ConfirmationDialog'; + +import { Button } from '../Button'; +import { useDialogStackV2 } from '../DialogStackV2'; + +const getOpenButtonText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Open dialog window' : 'Открыть диалоговое окно'; +}; + +const getHeadingText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Close without saving?' : 'Закрыть без сохранения?'; +}; + +const getContentText = (context: StoryContext) => { + return context.globals.locale === 'en' + ? 'The changes you have made will not be saved' + : 'Внесённые изменения не сохранятся'; +}; + +const getCancelButtonText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Cancel' : 'Отменить'; +}; + +const getConfirmButtonText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Close' : 'Закрыть'; +}; + +const getCloseText = (context: StoryContext) => { + return context.globals.locale === 'en' ? 'Close' : 'Закрыть'; +}; + +type Args = ComponentProps; + +const meta: Meta = { + tags: ['autodocs'], + component: ConfirmationDialog, + parameters: { + references: ['ConfirmationDialog', 'DialogStack'], + }, + argTypes: { + children: { + table: { + disable: true, + }, + }, + action: { + table: { + disable: true, + }, + }, + close: { + table: { + disable: true, + }, + }, + title: { + table: { + disable: true, + }, + }, + icon: { + table: { + disable: true, + }, + }, + open: { + table: { + disable: true, + }, + }, + BackdropProps: { + table: { + disable: true, + }, + }, + }, + args: { + severity: 'error', + disabled: false, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render(args, context) { + const dialogStack = useDialogStackV2(); + + const onOpen = () => { + dialogStack + .open(({ close }) => ( + + new Promise((resolve) => { + setTimeout(() => resolve(true), 1000); + }) + } + close={close} + labelCancel={getCancelButtonText(context)} + labelClose={getCloseText(context)} + labelConfirm={getConfirmButtonText(context)} + labelEscapeKey="Esc" + title={getHeadingText(context)} + > + {getContentText(context)} + + )) + .afterClosed.then((data) => { + console.info(data); + }); + }; + + return ( + + ); + }, +}; diff --git a/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 000000000..4427e7b6b --- /dev/null +++ b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { RefAttributes, useState } from 'react'; + +import { ConfirmationDialogProps } from './ConfirmationDialog.types'; + +import clsx from 'clsx'; + +import { IconAlertFillW500, IconCloseLineW500, IconInformation2FillW500 } from '../../icons'; +import { useDefaultProps } from '../../theming'; +import { Button, ButtonOwnProps } from '../Button'; +import { Dialog, DialogActions, DialogClose, DialogContent, DialogTitle } from '../Dialog'; +import { LoadingButton } from '../LoadingButton'; + +const defaultIconMapping: Record = { + error: , + primary: , +}; + +/** + * ConfirmationDialog asks the user to approve an operation before it is performed. + */ +export const ConfirmationDialog = ({ ref, ...inProps }: ConfirmationDialogProps & RefAttributes) => { + const { + children, + className, + severity = 'error', + title, + icon, + iconClose = , + iconMapping = defaultIconMapping, + labelConfirm, + labelCancel, + labelClose, + labelEscapeKey, + disabled = false, + action, + close, + fullWidth = true, + maxWidth = '480px', + ...props + } = useDefaultProps({ + props: inProps, + name: 'ESConfirmationDialog', + }); + + const [loading, setLoading] = useState(false); + + const onClose = () => { + if (!loading) { + close(); + } + }; + + const onConfirm = () => { + setLoading(true); + + action() + .then((data) => { + close(data); + }) + .catch((error: unknown) => { + setLoading(false); + return Promise.reject(error); + }); + }; + + return ( + } + className={clsx('es-confirmation-dialog', `es-confirmation-dialog--severity--${severity}`, className)} + fullWidth={fullWidth} + maxWidth={maxWidth} + onClose={onClose} + {...props} + > + + + {title} + + {children} + + + {!!labelConfirm && ( + + {labelConfirm} + + )} + + + ); +}; diff --git a/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.types.ts b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.types.ts new file mode 100644 index 000000000..8e6869775 --- /dev/null +++ b/packages/react/src/components/ConfirmationDialog/ConfirmationDialog.types.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import { ReactNode } from 'react'; + +import { OverridableStringUnion } from '../../types'; +import { DialogProps } from '../Dialog'; + +export interface ConfirmationDialogPropsSeverityOverrides {} + +export type ConfirmationDialogSeverity = OverridableStringUnion< + 'error' | 'primary', + ConfirmationDialogPropsSeverityOverrides +>; + +export interface ConfirmationDialogProps extends Omit { + children?: ReactNode; + /** + * The severity of the operation. It defines the color of the icon and of the confirm button. + * @default 'error' + */ + severity?: ConfirmationDialogSeverity; + + /** + * The operation to perform when the user confirms. The dialog shows a loading state until the returned promise + * settles, then closes with its value. A rejection keeps the dialog open and is rethrown. + */ + action: () => Promise; + /** Callback fired when the dialog requests to be closed, either with the value of `action` or without a value. */ + close: (data?: unknown) => void; + + /** The title text. */ + title: ReactNode; + /** + * If `true`, the confirm button is disabled. + * @default false + */ + disabled?: boolean; + + /** The icon displayed next to the title. Pass `false` to render the title without an icon. */ + icon?: ReactNode | false; + /** The component maps the `severity` prop to an icon. Provide your own mapping to change it. */ + iconMapping?: Partial>; + + /** The icon of the close button rendered inside the dialog on narrow screens. */ + iconClose?: ReactNode; + + /** Text for the confirm button. The button is not rendered without it. */ + labelConfirm?: ReactNode; + /** Text for the cancel button. */ + labelCancel?: ReactNode; + /** Text for the close button aria-label. */ + labelClose?: string; + /** Text for the escape key hint next to the close button. */ + labelEscapeKey?: string; +} diff --git a/packages/react/src/components/ConfirmationDialog/index.ts b/packages/react/src/components/ConfirmationDialog/index.ts new file mode 100644 index 000000000..9e5a9b9c3 --- /dev/null +++ b/packages/react/src/components/ConfirmationDialog/index.ts @@ -0,0 +1,6 @@ +export { ConfirmationDialog } from './ConfirmationDialog'; +export type { + ConfirmationDialogProps, + ConfirmationDialogPropsSeverityOverrides, + ConfirmationDialogSeverity, +} from './ConfirmationDialog.types'; diff --git a/packages/react/src/components/Dialog/DialogActions/DialogActions.tsx b/packages/react/src/components/Dialog/DialogActions/DialogActions.tsx index 44f64acc4..1885aff6c 100644 --- a/packages/react/src/components/Dialog/DialogActions/DialogActions.tsx +++ b/packages/react/src/components/Dialog/DialogActions/DialogActions.tsx @@ -13,7 +13,14 @@ import { useDefaultProps } from '../../../theming'; * @see `Dialog` */ export const DialogActions = ({ ref, ...inProps }: DialogActionsProps & RefAttributes) => { - const { className, style, sticky, children } = useDefaultProps({ + const { + className, + style, + sticky, + direction = 'horizontal', + fullWidth = false, + children, + } = useDefaultProps({ props: inProps, name: 'ESDialogActions', }); @@ -26,6 +33,8 @@ export const DialogActions = ({ ref, ...inProps }: DialogActionsProps & RefAttri ref={ref} className={clsx( 'es-dialog-actions', + `es-dialog-actions--direction--${direction}`, + fullWidth && 'es-dialog-actions--full-width', sticky && 'es-dialog-actions--sticky', stuck && 'es-dialog-actions--stuck', className diff --git a/packages/react/src/components/Dialog/DialogActions/DialogActions.types.ts b/packages/react/src/components/Dialog/DialogActions/DialogActions.types.ts index cb5c44edf..6b3486ce1 100644 --- a/packages/react/src/components/Dialog/DialogActions/DialogActions.types.ts +++ b/packages/react/src/components/Dialog/DialogActions/DialogActions.types.ts @@ -8,4 +8,14 @@ export interface DialogActionsProps { style?: CSSProperties; /** Whether the actions should be sticky. */ sticky?: boolean; + /** + * The direction the actions are laid out in. + * @default 'horizontal' + */ + direction?: 'horizontal' | 'vertical'; + /** + * If `true`, the actions stretch to fill the available space instead of hugging their content. + * @default false + */ + fullWidth?: boolean; } diff --git a/packages/react/src/components/Dialog/DialogTitle/DialogTitle.tsx b/packages/react/src/components/Dialog/DialogTitle/DialogTitle.tsx index 3e3e91ca5..2d35667d9 100644 --- a/packages/react/src/components/Dialog/DialogTitle/DialogTitle.tsx +++ b/packages/react/src/components/Dialog/DialogTitle/DialogTitle.tsx @@ -13,7 +13,14 @@ import { useDefaultProps } from '../../../theming'; * @see `Dialog` */ export const DialogTitle = ({ ref, ...inProps }: DialogTitleProps & RefAttributes) => { - const { className, style, sticky, children } = useDefaultProps({ + const { + className, + style, + sticky, + icon, + align = 'start', + children, + } = useDefaultProps({ props: inProps, name: 'ESDialogTitle', }); @@ -27,6 +34,7 @@ export const DialogTitle = ({ ref, ...inProps }: DialogTitleProps & RefAttribute ref={ref} className={clsx( 'es-dialog-title', + `es-dialog-title--align--${align}`, sticky && 'es-dialog-title--sticky', stuck && 'es-dialog-title--stuck', 'h4', @@ -34,7 +42,8 @@ export const DialogTitle = ({ ref, ...inProps }: DialogTitleProps & RefAttribute )} style={style} > - {children} + {!!icon &&
{icon}
} +
{children}
); diff --git a/packages/react/src/components/Dialog/DialogTitle/DialogTitle.types.ts b/packages/react/src/components/Dialog/DialogTitle/DialogTitle.types.ts index d2d039a28..6816d6c4c 100644 --- a/packages/react/src/components/Dialog/DialogTitle/DialogTitle.types.ts +++ b/packages/react/src/components/Dialog/DialogTitle/DialogTitle.types.ts @@ -9,4 +9,11 @@ export interface DialogTitleProps { style?: CSSProperties; /** Whether the title should be sticky. */ sticky?: boolean; + /** The icon displayed next to the title. */ + icon?: ReactNode; + /** + * The title layout. `center` stacks the icon above the text and centers both. + * @default 'start' + */ + align?: 'start' | 'center'; } diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index b4a0e2c76..8d40caad0 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -21,6 +21,7 @@ export * from './Chips'; export * from './ClearButton'; export * from './ClickAwayListener'; export * from './Collapse'; +export * from './ConfirmationDialog'; export * from './DateAdapter'; export * from './DateInput'; export * from './Dialog'; diff --git a/packages/react/src/overrides.ts b/packages/react/src/overrides.ts index 7983554e5..9dcf009f2 100644 --- a/packages/react/src/overrides.ts +++ b/packages/react/src/overrides.ts @@ -24,6 +24,7 @@ import { ChipsProps } from './components/Chips'; import { ClearButtonProps } from './components/ClearButton'; import { ClickAwayListenerProps } from './components/ClickAwayListener'; import { CollapseProps } from './components/Collapse'; +import { ConfirmationDialogProps } from './components/ConfirmationDialog'; import { DateInputProps } from './components/DateInput'; import { DialogActionsProps, @@ -215,6 +216,7 @@ declare module './theming/DefaultPropsProvider/DefaultPropsProvider.types' { ESClearButton: ClearButtonProps; ESClickAwayListener: ClickAwayListenerProps; ESCollapse: CollapseProps; + ESConfirmationDialog: ConfirmationDialogProps; ESDateInput: DateInputProps; ESDialog: DialogProps; ESDialogActions: DialogActionsProps; diff --git a/packages/theme/lib/components/_confirmation-dialog.scss b/packages/theme/lib/components/_confirmation-dialog.scss new file mode 100644 index 000000000..c3dac8141 --- /dev/null +++ b/packages/theme/lib/components/_confirmation-dialog.scss @@ -0,0 +1,85 @@ +@use '../helpers/breakpoints'; + +@mixin include() { + .es-confirmation-dialog { + &--severity--error .es-dialog-title__icon { + color: var(--es-error-300); + } + + &--severity--primary .es-dialog-title__icon { + color: var(--es-primary-300); + } + + & .es-dialog__paper { + position: relative; + } + + & .es-dialog-close { + display: none; + + @include breakpoints.up('sm') { + display: flex; + } + } + + &__close { + --icon: var(--es-mono-a-a700); + + position: absolute; + right: 8px; + top: 8px; + z-index: 1; + + @include breakpoints.up('sm') { + display: none; + } + } + + & .es-dialog-title { + padding: 32px 16px 12px; + + @include breakpoints.up('sm') { + align-items: center; + flex-direction: row; + gap: 8px; + padding: 22px 24px 14px; + text-align: left; + } + } + + & .es-dialog-title__icon .es-svg-icon { + height: 56px !important; + width: 56px !important; + + @include breakpoints.up('sm') { + height: 24px !important; + width: 24px !important; + } + } + + & .es-dialog-content { + color: var(--es-mono-a-a800); + padding: 0 16px; + text-align: center; + + @include breakpoints.up('sm') { + padding: 0 24px; + text-align: left; + } + } + + & .es-dialog-actions { + padding: 20px 16px 16px; + + @include breakpoints.up('sm') { + padding: 16px 24px 24px; + } + + & > * { + @include breakpoints.up('sm') { + flex: 0 0 auto; + } + } + } + } +} diff --git a/packages/theme/lib/components/_dialog.scss b/packages/theme/lib/components/_dialog.scss index a14bd0563..a28806abb 100644 --- a/packages/theme/lib/components/_dialog.scss +++ b/packages/theme/lib/components/_dialog.scss @@ -123,11 +123,22 @@ .es-dialog-actions { display: flex; + gap: 16px; justify-content: flex-end; padding: 16px 24px 24px; - & > :not(:first-of-type) { - margin-left: 16px; + &--direction--vertical { + flex-direction: column; + } + + &--full-width > * { + flex: 1 0 0; + min-width: 0; + } + + &--direction--vertical.es-dialog-actions--full-width > * { + flex: 0 0 auto; + width: 100%; } &--sticky { @@ -251,9 +262,35 @@ .es-dialog-title { color: var(--es-mono-a-a900); + display: flex; padding: 24px; padding-bottom: 6px; + &--align--start { + align-items: center; + gap: 8px; + + & > .es-dialog-title__text { + flex: 1 0 0; + min-width: 0; + } + } + + &--align--center { + align-items: center; + flex-direction: column; + gap: 12px; + text-align: center; + + & > .es-dialog-title__text { + width: 100%; + } + } + + &__icon { + display: flex; + } + &--sticky { background-color: var(--es-surface-600); border-top-left-radius: 8px; diff --git a/packages/theme/lib/components/_index.scss b/packages/theme/lib/components/_index.scss index 784731912..8bf9d551e 100644 --- a/packages/theme/lib/components/_index.scss +++ b/packages/theme/lib/components/_index.scss @@ -19,6 +19,7 @@ @use './chips'; @use './clear-button'; @use './collapse'; +@use './confirmation-dialog'; @use './date-input'; @use './dialog'; @use './divider'; @@ -102,6 +103,7 @@ @include chips.include; @include clear-button.include; @include collapse.include; + @include confirmation-dialog.include; @include date-input.include; @include dialog.include; @include divider.include;