Merge pull request #31371 from backstage/bui-dialog-component

BUI - New Dialog component
This commit is contained in:
Patrik Oldsberg
2025-10-13 09:58:21 +02:00
committed by GitHub
16 changed files with 1074 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Adding a new Dialog component to Backstage UI.
File diff suppressed because one or more lines are too long
+107
View File
@@ -0,0 +1,107 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { DialogSnippet } from '@/snippets/stories-snippets';
import {
dialogPropDefs,
dialogTriggerPropDefs,
dialogHeaderPropDefs,
dialogBodyPropDefs,
dialogFooterPropDefs,
dialogClosePropDefs,
dialogUsageSnippet,
dialogDefaultSnippet,
dialogFixedWidthAndHeightSnippet,
dialogWithFormSnippet,
dialogWithNoTriggerSnippet,
dialogCloseSnippet,
} from './dialog.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
<PageTitle
title="Dialog"
description="A modal dialog component that displays content in an overlay window."
/>
<Snippet
align="center"
py={4}
preview={<DialogSnippet story="Default" />}
code={dialogDefaultSnippet}
/>
## Usage
<CodeBlock code={dialogUsageSnippet} />
## API reference
### DialogTrigger
Wraps a trigger element and the dialog content to handle open/close state.
<PropsTable data={dialogTriggerPropDefs} />
### Dialog
The main dialog container that renders as a modal overlay.
<PropsTable data={dialogPropDefs} />
### DialogHeader
Displays the dialog title with a built-in close button.
<PropsTable data={dialogHeaderPropDefs} />
### DialogBody
The main content area of the dialog with optional scrolling.
<PropsTable data={dialogBodyPropDefs} />
### DialogFooter
Contains action buttons or other footer content.
<PropsTable data={dialogFooterPropDefs} />
If you want to close the dialog while pressing a button, you can use the `slot="close"` prop on the button.
<CodeBlock code={dialogCloseSnippet} />
## Examples
### Fixed Width and Height
Dialog with a fixed height body that scrolls when content overflows.
<Snippet
align="center"
py={4}
preview={<DialogSnippet story="PreviewFixedWidthAndHeight" />}
code={dialogFixedWidthAndHeightSnippet}
/>
### Dialog with Form
Dialog containing form elements for user input.
<Snippet
align="center"
py={4}
preview={<DialogSnippet story="PreviewWithForm" />}
code={dialogWithFormSnippet}
/>
### Dialog with no trigger and controlled by props
You can also control the dialog using your own states.
<CodeBlock code={dialogWithNoTriggerSnippet} />
<Theming component="Dialog" />
<ChangelogComponent component="dialog" />
@@ -0,0 +1,172 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const dialogTriggerPropDefs: Record<string, PropDef> = {
children: { type: 'enum', values: ['ReactNode'], responsive: false },
isOpen: {
type: 'boolean',
description: 'Whether the overlay is open by default (controlled).',
},
defaultOpen: {
type: 'boolean',
description: 'Whether the overlay is open by default (uncontrolled).',
},
onOpenChange: {
type: 'enum',
values: ['(isOpen: boolean) => void'],
description:
"Handler that is called when the overlay's open state changes.",
},
};
export const dialogPropDefs: Record<string, PropDef> = {
children: { type: 'enum', values: ['ReactNode'], responsive: false },
isOpen: {
type: 'boolean',
description: 'Whether the overlay is open by default (controlled).',
},
defaultOpen: {
type: 'boolean',
description: 'Whether the overlay is open by default (uncontrolled).',
},
onOpenChange: {
type: 'enum',
values: ['(isOpen: boolean) => void'],
description:
"Handler that is called when the overlay's open state changes.",
},
width: {
type: 'enum',
values: ['number', 'string'],
responsive: false,
},
height: {
type: 'enum',
values: ['number', 'string'],
responsive: false,
},
...classNamePropDefs,
...stylePropDefs,
};
export const dialogHeaderPropDefs: Record<string, PropDef> = {
children: { type: 'enum', values: ['ReactNode'], responsive: false },
...classNamePropDefs,
...stylePropDefs,
};
export const dialogBodyPropDefs: Record<string, PropDef> = {
children: { type: 'enum', values: ['ReactNode'], responsive: false },
height: {
type: 'enum',
values: ['number', 'string'],
responsive: false,
},
...classNamePropDefs,
...stylePropDefs,
};
export const dialogFooterPropDefs: Record<string, PropDef> = {
children: { type: 'enum', values: ['ReactNode'], responsive: false },
...classNamePropDefs,
...stylePropDefs,
};
export const dialogClosePropDefs: Record<string, PropDef> = {
variant: {
type: 'enum',
values: ['primary', 'secondary', 'tertiary'],
default: 'secondary',
responsive: false,
},
children: { type: 'enum', values: ['ReactNode'], responsive: false },
...classNamePropDefs,
...stylePropDefs,
};
export const dialogUsageSnippet = `import {
Dialog,
DialogTrigger,
DialogHeader,
DialogBody,
DialogFooter,
} from '@backstage/ui';
<DialogTrigger>
<Button>Open Dialog</Button>
<Dialog>
<DialogHeader>Title</DialogHeader>
<DialogBody>Content</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">Close</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>`;
export const dialogDefaultSnippet = `<DialogTrigger>
<Button variant="secondary">Open Dialog</Button>
<Dialog>
<DialogHeader>Example Dialog</DialogHeader>
<DialogBody>
<Text>This is a basic dialog example.</Text>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">Close</Button>
<Button variant="primary" slot="close">Save</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>`;
export const dialogFixedWidthAndHeightSnippet = `<DialogTrigger>
<Button variant="secondary">Scrollable Dialog</Button>
<Dialog>
<DialogHeader>Long Content Dialog</DialogHeader>
<DialogBody width={600} height={400}>
...
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">Cancel</Button>
<Button variant="primary" slot="close">Accept</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>`;
export const dialogWithFormSnippet = `<DialogTrigger>
<Button variant="secondary">Create User</Button>
<Dialog>
<DialogHeader>Create New User</DialogHeader>
<DialogBody>
<Flex direction="column" gap="3">
<TextField label="Name" placeholder="Enter full name" />
<TextField label="Email" placeholder="Enter email address" />
<Select label="Role">
<SelectItem>Admin</SelectItem>
<SelectItem>User</SelectItem>
<SelectItem>Viewer</SelectItem>
</Select>
</Flex>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">Cancel</Button>
<Button variant="primary" slot="close">Create User</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>`;
export const dialogWithNoTriggerSnippet = `const [isOpen, setIsOpen] = useState(false);
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogHeader>Create New User</DialogHeader>
<DialogBody>
Your content
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">Cancel</Button>
<Button variant="primary" slot="close">Create User</Button>
</DialogFooter>
</Dialog>`;
export const dialogCloseSnippet = `<Button slot="close">Close</Button>`;
@@ -17,6 +17,7 @@ import * as MenuStories from '../../../packages/ui/src/components/Menu/Menu.stor
import * as LinkStories from '../../../packages/ui/src/components/Link/Link.stories';
import * as AvatarStories from '../../../packages/ui/src/components/Avatar/Avatar.stories';
import * as CollapsibleStories from '../../../packages/ui/src/components/Collapsible/Collapsible.stories';
import * as DialogStories from '../../../packages/ui/src/components/Dialog/Dialog.stories';
import * as RadioGroupStories from '../../../packages/ui/src/components/RadioGroup/RadioGroup.stories';
import * as TabsStories from '../../../packages/ui/src/components/Tabs/Tabs.stories';
import * as SwitchStories from '../../../packages/ui/src/components/Switch/Switch.stories';
@@ -63,6 +64,7 @@ export const MenuSnippet = createSnippetComponent(MenuStories);
export const LinkSnippet = createSnippetComponent(LinkStories);
export const AvatarSnippet = createSnippetComponent(AvatarStories);
export const CollapsibleSnippet = createSnippetComponent(CollapsibleStories);
export const DialogSnippet = createSnippetComponent(DialogStories);
export const RadioGroupSnippet = createSnippetComponent(RadioGroupStories);
export const TabsSnippet = createSnippetComponent(TabsStories);
export const SwitchSnippet = createSnippetComponent(SwitchStories);
+5
View File
@@ -101,6 +101,11 @@ export const components: Page[] = [
slug: 'collapsible',
status: 'alpha',
},
{
title: 'Dialog',
slug: 'dialog',
status: 'alpha',
},
{
title: 'Header',
slug: 'header',
+123
View File
@@ -10838,6 +10838,129 @@
@layer legacy;
.bui-DialogOverlay {
z-index: 1000;
background: #e8e8e8cc;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
display: flex;
position: fixed;
top: 0;
left: 0;
}
[data-theme="dark"] .bui-Dialog {
background: #00000080;
}
.bui-DialogOverlay[data-entering] {
animation: .2s ease-out forwards fade-in;
}
.bui-DialogOverlay[data-exiting] {
animation: .15s ease-out forwards fade-out;
}
.bui-Dialog {
background: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
color: var(--bui-fg-primary);
width: min(var(--bui-dialog-min-width, 400px), calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem));
border-radius: .5rem;
outline: none;
flex-direction: column;
max-height: calc(100vh - 3rem);
display: flex;
position: relative;
}
.bui-DialogOverlay[data-entering] .bui-Dialog {
animation: .15s ease-out forwards dialog-enter;
}
.bui-DialogOverlay[data-exiting] .bui-Dialog {
animation: .15s ease-out forwards dialog-exit;
}
.bui-DialogHeader {
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
border-bottom: 1px solid var(--bui-border);
justify-content: space-between;
align-items: center;
display: flex;
}
.bui-DialogHeaderTitle {
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-bold);
margin: 0;
}
.bui-DialogFooter {
justify-content: end;
align-items: center;
gap: var(--bui-space-2);
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-3);
border-top: 1px solid var(--bui-border);
display: flex;
}
.bui-DialogBody {
padding: var(--bui-space-3);
flex: 1;
overflow-y: auto;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-enter {
from {
opacity: .5;
transform: scale(.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-exit {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(.95);
}
}
.bui-MenuPopover {
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-2);
+73
View File
@@ -11,8 +11,12 @@ import { ColumnProps } from 'react-aria-components';
import { ComponentProps } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { Context } from 'react';
import { DetailedHTMLProps } from 'react';
import type { DialogTriggerProps as DialogTriggerProps_2 } from 'react-aria-components';
import type { ElementType } from 'react';
import { ForwardRefExoticComponent } from 'react';
import type { HeadingProps } from 'react-aria-components';
import { HTMLAttributes } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LinkProps as LinkProps_2 } from 'react-aria-components';
import type { ListBoxItemProps } from 'react-aria-components';
@@ -21,6 +25,7 @@ import type { MenuItemProps as MenuItemProps_2 } from 'react-aria-components';
import type { MenuProps as MenuProps_2 } from 'react-aria-components';
import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components';
import type { MenuTriggerProps as MenuTriggerProps_2 } from 'react-aria-components';
import type { ModalOverlayProps } from 'react-aria-components';
import type { PopoverProps } from 'react-aria-components';
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
@@ -436,6 +441,16 @@ export const componentDefinitions: {
readonly root: 'bui-Container';
};
};
readonly Dialog: {
readonly classNames: {
readonly overlay: 'bui-DialogOverlay';
readonly dialog: 'bui-Dialog';
readonly header: 'bui-DialogHeader';
readonly headerTitle: 'bui-DialogHeaderTitle';
readonly body: 'bui-DialogBody';
readonly footer: 'bui-DialogFooter';
};
};
readonly FieldLabel: {
readonly classNames: {
readonly root: 'bui-FieldLabelWrapper';
@@ -698,6 +713,64 @@ export type DataAttributesMap = Record<string, DataAttributeValues>;
// @public
export type DataAttributeValues = readonly (string | number | boolean)[];
// @public (undocumented)
export const Dialog: ForwardRefExoticComponent<
DialogProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export const DialogBody: ForwardRefExoticComponent<
DialogBodyProps & RefAttributes<HTMLDivElement>
>;
// @public
export interface DialogBodyProps {
// (undocumented)
children?: React.ReactNode;
// (undocumented)
className?: string;
}
// @public (undocumented)
export const DialogFooter: ForwardRefExoticComponent<
Omit<
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
'ref'
> &
RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export const DialogHeader: ForwardRefExoticComponent<
DialogHeaderProps & RefAttributes<HTMLDivElement>
>;
// @public
export interface DialogHeaderProps extends HeadingProps {
// (undocumented)
children?: React.ReactNode;
// (undocumented)
className?: string;
}
// @public
export interface DialogProps extends ModalOverlayProps {
// (undocumented)
children?: React.ReactNode;
// (undocumented)
className?: string;
// (undocumented)
height?: number | string;
// (undocumented)
width?: number | string;
}
// @public (undocumented)
export const DialogTrigger: (props: DialogTriggerProps) => JSX_2.Element;
// @public
export interface DialogTriggerProps extends DialogTriggerProps_2 {}
// @public (undocumented)
export type Display = 'none' | 'flex' | 'block' | 'inline';
@@ -0,0 +1,254 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
Dialog,
DialogTrigger,
DialogHeader,
DialogBody,
DialogFooter,
} from './Dialog';
import { Button, Flex, Text, TextField, Select } from '@backstage/ui';
import { useArgs } from 'storybook/preview-api';
const meta = {
title: 'Backstage UI/Dialog',
component: Dialog,
args: {
isOpen: undefined,
defaultOpen: undefined,
},
argTypes: {
isOpen: { control: 'boolean' },
defaultOpen: { control: 'boolean' },
},
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: args => {
return (
<DialogTrigger>
<Button variant="secondary">Open Dialog</Button>
<Dialog {...args}>
<DialogHeader>Example Dialog</DialogHeader>
<DialogBody>
<Text>This is a basic dialog example.</Text>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">
Close
</Button>
<Button variant="primary" slot="close">
Save
</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>
);
},
};
export const Open: Story = {
args: {
...Default.args,
defaultOpen: true,
},
render: Default.render,
};
export const NoTrigger: Story = {
args: {
isOpen: true,
},
render: args => {
const [{ isOpen }, updateArgs] = useArgs();
return (
<Dialog
{...args}
isOpen={isOpen}
onOpenChange={value => updateArgs({ isOpen: value })}
>
<DialogHeader>Example Dialog</DialogHeader>
<DialogBody>
<Text>This is a basic dialog example.</Text>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">
Close
</Button>
<Button variant="primary" slot="close">
Save
</Button>
</DialogFooter>
</Dialog>
);
},
};
export const FixedWidth: Story = {
args: {
defaultOpen: true,
width: 600,
},
render: args => (
<DialogTrigger>
<Button variant="secondary">Open Dialog</Button>
<Dialog {...args}>
<DialogHeader>Long Content Dialog</DialogHeader>
<DialogBody>
<Flex direction="column" gap="3">
<Text>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</Text>
<Text>
Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit
anim id est laborum.
</Text>
<Text>
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium, totam rem aperiam, eaque ipsa
quae ab illo inventore veritatis et quasi architecto beatae vitae
dicta sunt explicabo.
</Text>
</Flex>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">
Cancel
</Button>
<Button variant="primary" slot="close">
Accept
</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>
),
};
export const FixedHeight: Story = {
args: {
defaultOpen: true,
height: 500,
},
render: FixedWidth.render,
};
export const FixedWidthAndHeight: Story = {
args: {
defaultOpen: true,
width: 600,
height: 400,
},
render: FixedWidth.render,
};
export const FullWidthAndHeight: Story = {
args: {
defaultOpen: true,
width: '100%',
height: '100%',
},
render: FixedWidth.render,
};
export const Confirmation: Story = {
args: {
isOpen: true,
},
render: args => (
<DialogTrigger {...args}>
<Button variant="secondary">Delete Item</Button>
<Dialog>
<DialogHeader>Confirm Delete</DialogHeader>
<DialogBody>
<Text>
Are you sure you want to delete this item? This action cannot be
undone.
</Text>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">
Cancel
</Button>
<Button variant="primary" slot="close">
Delete
</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>
),
};
export const WithForm: Story = {
args: {
isOpen: true,
},
render: args => (
<DialogTrigger {...args}>
<Button variant="secondary">Create User</Button>
<Dialog>
<DialogHeader>Create New User</DialogHeader>
<DialogBody>
<Flex direction="column" gap="3">
<TextField label="Name" placeholder="Enter full name" />
<TextField label="Email" placeholder="Enter email address" />
<Select
label="Role"
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
</Flex>
</DialogBody>
<DialogFooter>
<Button variant="secondary" slot="close">
Cancel
</Button>
<Button variant="primary" slot="close">
Create User
</Button>
</DialogFooter>
</Dialog>
</DialogTrigger>
),
};
export const PreviewFixedWidthAndHeight: Story = {
args: {
defaultOpen: undefined,
width: 600,
height: 400,
},
render: FixedWidth.render,
};
export const PreviewWithForm: Story = {
args: {
defaultOpen: undefined,
},
render: WithForm.render,
};
@@ -0,0 +1,122 @@
/* Backdrop */
.bui-DialogOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(232, 232, 232, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
[data-theme='dark'] .bui-Dialog {
background: rgba(0, 0, 0, 0.5);
}
.bui-DialogOverlay[data-entering] {
animation: fade-in 200ms ease-out forwards;
}
.bui-DialogOverlay[data-exiting] {
animation: fade-out 150ms ease-out forwards;
}
.bui-Dialog {
background: var(--bui-bg-surface-1);
border-radius: 0.5rem;
border: 1px solid var(--bui-border);
color: var(--bui-fg-primary);
position: relative;
width: min(var(--bui-dialog-min-width, 400px), calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem));
max-height: calc(100vh - 3rem);
display: flex;
flex-direction: column;
outline: none;
}
/* Dialog entering animation */
.bui-DialogOverlay[data-entering] .bui-Dialog {
animation: dialog-enter 150ms ease-out forwards;
}
/* Dialog exiting animation */
.bui-DialogOverlay[data-exiting] .bui-Dialog {
animation: dialog-exit 150ms ease-out forwards;
}
.bui-DialogHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
border-bottom: 1px solid var(--bui-border);
}
.bui-DialogHeaderTitle {
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-bold);
margin: 0;
}
.bui-DialogFooter {
display: flex;
align-items: center;
justify-content: end;
gap: var(--bui-space-2);
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-3);
border-top: 1px solid var(--bui-border);
}
.bui-DialogBody {
padding: var(--bui-space-3);
flex: 1;
overflow-y: auto;
}
/* Keyframe animations */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-enter {
from {
opacity: 0.5;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-exit {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@@ -0,0 +1,125 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import {
Dialog as RADialog,
DialogTrigger as RADialogTrigger,
Modal,
Heading,
} from 'react-aria-components';
import clsx from 'clsx';
import type {
DialogTriggerProps,
DialogHeaderProps,
DialogProps,
DialogBodyProps,
} from './types';
import './Dialog.styles.css';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '../Button';
import { useStyles } from '../../hooks/useStyles';
import { Flex } from '../Flex';
/** @public */
export const DialogTrigger = (props: DialogTriggerProps) => {
return <RADialogTrigger {...props} />;
};
/** @public */
export const Dialog = forwardRef<React.ElementRef<typeof Modal>, DialogProps>(
({ className, children, width, height, style, ...props }, ref) => {
const { classNames } = useStyles('Dialog');
return (
<Modal
ref={ref}
className={clsx(classNames.overlay)}
isDismissable
isKeyboardDismissDisabled={false}
{...props}
>
<RADialog
className={clsx(classNames.dialog, className)}
style={{
['--bui-dialog-min-width' as keyof React.CSSProperties]:
typeof width === 'number' ? `${width}px` : width || '400px',
['--bui-dialog-min-height' as keyof React.CSSProperties]: height
? typeof height === 'number'
? `${height}px`
: height
: 'auto',
...style,
}}
>
{children}
</RADialog>
</Modal>
);
},
);
Dialog.displayName = 'Dialog';
/** @public */
export const DialogHeader = forwardRef<
React.ElementRef<'div'>,
DialogHeaderProps
>(({ className, children, ...props }, ref) => {
const { classNames } = useStyles('Dialog');
return (
<Flex ref={ref} className={clsx(classNames.header, className)} {...props}>
<Heading slot="title" className={classNames.headerTitle}>
{children}
</Heading>
<Button name="close" aria-label="Close" variant="tertiary" slot="close">
<RiCloseLine />
</Button>
</Flex>
);
});
DialogHeader.displayName = 'DialogHeader';
/** @public */
export const DialogBody = forwardRef<React.ElementRef<'div'>, DialogBodyProps>(
({ className, children, ...props }, ref) => {
const { classNames } = useStyles('Dialog');
return (
<div className={clsx(classNames.body, className)} ref={ref} {...props}>
{children}
</div>
);
},
);
DialogBody.displayName = 'DialogBody';
/** @public */
export const DialogFooter = forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, children, ...props }, ref) => {
const { classNames } = useStyles('Dialog');
return (
<div ref={ref} className={clsx(classNames.footer, className)} {...props}>
{children}
</div>
);
});
DialogFooter.displayName = 'DialogFooter';
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Dialog';
export * from './types';
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
DialogTriggerProps as RADialogTriggerProps,
ModalOverlayProps as RAModalProps,
HeadingProps as RAHeadingProps,
} from 'react-aria-components';
/**
* Props for the DialogTrigger component.
* @public
*/
export interface DialogTriggerProps extends RADialogTriggerProps {}
/**
* Props for the Dialog component.
* @public
*/
export interface DialogProps extends RAModalProps {
className?: string;
children?: React.ReactNode;
width?: number | string;
height?: number | string;
}
/**
* Props for the DialogHeader component.
* @public
*/
export interface DialogHeaderProps extends RAHeadingProps {
children?: React.ReactNode;
className?: string;
}
/**
* Props for the DialogBody component.
* @public
*/
export interface DialogBodyProps {
children?: React.ReactNode;
className?: string;
}
+1
View File
@@ -32,6 +32,7 @@
@import '../components/Checkbox/styles.css';
@import '../components/Collapsible/Collapsible.styles.css';
@import '../components/Container/styles.css';
@import '../components/Dialog/Dialog.styles.css';
@import '../components/FieldError/FieldError.styles.css';
@import '../components/FieldLabel/FieldLabel.styles.css';
@import '../components/Flex/styles.css';
+1
View File
@@ -34,6 +34,7 @@ export * from './components/Avatar';
export * from './components/Button';
export * from './components/Card';
export * from './components/Collapsible';
export * from './components/Dialog';
export * from './components/FieldLabel';
export * from './components/Header';
export * from './components/HeaderPage';
@@ -85,6 +85,16 @@ export const componentDefinitions = {
root: 'bui-Container',
},
},
Dialog: {
classNames: {
overlay: 'bui-DialogOverlay',
dialog: 'bui-Dialog',
header: 'bui-DialogHeader',
headerTitle: 'bui-DialogHeaderTitle',
body: 'bui-DialogBody',
footer: 'bui-DialogFooter',
},
},
FieldLabel: {
classNames: {
root: 'bui-FieldLabelWrapper',