Skip to content

feat: Add dialog component - #4904

Open
amanabiy wants to merge 1 commit into
mainfrom
dev-v3-amanabiy-dialog
Open

feat: Add dialog component#4904
amanabiy wants to merge 1 commit into
mainfrom
dev-v3-amanabiy-dialog

Conversation

@amanabiy

@amanabiy amanabiy commented Aug 13, 2026

Copy link
Copy Markdown
Member

Description

Adds an inline, non-modal Dialog component for in-context prompts, authorizations, and feedback. Dialog supports header actions, content, and footer slots, keeps the surrounding page interactive, manages initial and restored focus, and supports dismissal through the close button and Escape.

Related links, issue #, if available: 6f6tUHjL27wB - doc

How has this been tested?

Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.67%. Comparing base (1562d7b) to head (d4727e1).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4904   +/-   ##
=======================================
  Coverage   97.66%   97.67%           
=======================================
  Files         959      963    +4     
  Lines       31345    31421   +76     
  Branches    11579    11605   +26     
=======================================
+ Hits        30614    30690   +76     
+ Misses        724      685   -39     
- Partials        7       46   +39     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch from 4fb0a5e to f87b9bc Compare August 27, 2026 12:35
@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch 2 times, most recently from f87b9bc to 5f24213 Compare August 27, 2026 12:46
@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch from 5f24213 to 2133113 Compare September 1, 2026 09:12
@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch from 2133113 to e62996c Compare September 1, 2026 11:42
@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch from e62996c to 4cd8d21 Compare September 1, 2026 11:54
@amanabiy
amanabiy force-pushed the dev-v3-amanabiy-dialog branch from 4cd8d21 to 97f9d8a Compare September 4, 2026 12:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The updated Style API docs snapshot indicates an empty generated docs set (likely breaking snapshot tests) and the Dialog dismiss button can render without an accessible label when dismissAriaLabel is omitted.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new Dialog component to the Cloudscape React component library, including styling backed by design tokens, testing utilities, and example pages used for visual/integ validation.

Changes:

  • Introduces Dialog (public component + internal implementation), styles, test classes, and unit tests.
  • Extends design tokens (spacing/borders/colors/shadows + metadata + token name unions) to support the Dialog surface and layout.
  • Adds test-utils DOM wrapper support for Dialog and updates snapshots/examples pages accordingly.
File summaries
File Description
style-dictionary/visual-refresh/spacing.ts Adds Dialog spacing tokens mapped to existing spacing primitives.
style-dictionary/visual-refresh/shadows.ts Adds shadowDialog token.
style-dictionary/visual-refresh/metadata/spacing.ts Adds metadata entries for new Dialog spacing tokens.
style-dictionary/visual-refresh/metadata/shadows.ts Adds metadata for shadowDialog.
style-dictionary/visual-refresh/metadata/colors.ts Updates Dialog color token descriptions; adds colorBorderDialogTop metadata.
style-dictionary/visual-refresh/metadata/borders.ts Adds metadata for Dialog border radius/width and top border width.
style-dictionary/visual-refresh/colors.ts Updates Dialog background/border mappings; adds colorBorderDialogTop.
style-dictionary/visual-refresh/borders.ts Adds Dialog border radius/width tokens and top border width token.
style-dictionary/utils/token-names.ts Extends token name unions for new Dialog tokens.
src/test-utils/dom/dialog/index.ts Adds DialogWrapper for DOM test-utils.
src/dialog/test-classes/styles.scss Adds test class hooks for Dialog sub-elements.
src/dialog/styles.scss Implements Dialog styling using design tokens (padding, borders, separators).
src/dialog/internal.tsx Implements Dialog behavior (focus management, Escape handling, dismiss button).
src/dialog/interfaces.ts Defines public Dialog props and event detail types.
src/dialog/index.tsx Exports public Dialog component wiring base-component metadata.
src/dialog/tests/dialog.test.tsx Adds unit tests for rendering, accessibility labeling, dismiss behavior, and focus restoration.
src/tests/snapshot-tests/snapshots/test-utils-wrappers.test.tsx.snap Updates test-utils wrappers snapshot to include Dialog wrapper APIs.
src/tests/snapshot-tests/snapshots/test-utils-selectors.test.tsx.snap Updates test-utils selectors snapshot to include Dialog selectors.
src/tests/snapshot-tests/snapshots/style-docs.test.ts.snap Updates Style API docs snapshot output (currently shows empty list).
src/tests/snapshot-tests/snapshots/documenter.test.ts.snap Adds generated component docs snapshot for Dialog + wrapper docs snapshot.
src/tests/snapshot-tests/snapshots/design-tokens.test.ts.snap Updates design token artifact snapshots reflecting new/changed Dialog tokens.
src/integ/snapshots/themes.test.ts.snap Updates theme custom-properties snapshots to include new Dialog tokens/values.
pages/dialog/simple.page.tsx Adds a basic in-flow Dialog demo page.
pages/dialog/permutations.page.tsx Adds a permutations page to exercise layout combinations.
pages/dialog/inline-feedback.page.tsx Adds an inline-feedback use-case demo for Dialog.
pages/dialog/follow-up-questions.page.tsx Adds a follow-up questions (pagination in header actions) demo.
pages/dialog/common.tsx Adds shared layout wrapper for Dialog demo pages.
pages/dialog/authorization.page.tsx Adds an authorization flow demo for Dialog.
build-tools/utils/pluralize.js Adds pluralization mapping for “Dialog” to “Dialogs”.
Review details

Suppressed comments (1)

src/tests/snapshot-tests/snapshots/style-docs.test.ts.snap:4

  • This snapshot update indicates that the generated Style API docs directory contained no component docs ([]). That contradicts the test behavior, which throws when no Style API docs are present, and would also drop existing snapshots (alert/badge/button). This looks like an accidental snapshot regeneration without running the Style API docs build step; please regenerate after gulp styleDocs (and ensure the component list is non-empty) or revert this snapshot change.
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Style API docs list of components with a Style API doc matches the snapshot 1`] = `[]`;

  • Files reviewed: 29/29 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/dialog/internal.tsx

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are blocking issues affecting API typing, i18n completeness for non-English locales (accessibility impact), and an inconsistent Style API docs snapshot update that likely breaks snapshot testing.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 33/33 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread src/__tests__/snapshot-tests/__snapshots__/style-docs.test.ts.snap Outdated
Comment thread src/dialog/interfaces.ts
Comment on lines +185 to +187
"dialog": {
"i18nStrings.dismissAriaLabel": "Close dialog"
},
chore: clean up

fix: add dismissAriaLabel
</SpaceBetween>
);

// The close button is always present. The closed playground state renders no Dialog,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's remove this comment - this looks to be an agentic ai response to a prompt.

{open && (
<Dialog
header="What's your main goal?"
i18nStrings={{ dismissAriaLabel: 'Close' }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: passing i18n={} to the SimplePage helper injects IntlProvider - which should populate all i18nStrings.


// Minimal conditional-rendering example. Opening Dialog moves focus from the
// trigger into Dialog. Closing Dialog restores focus to the trigger.
export default function DialogSimplePage() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend to include a setting or a separate page to imitate a case when the trigger is no longer available after dialog opens (or it opens programmatically, so there is no trigger in the first place). The page should show how to handle focus manually. I'd be nice to secure both cases with an integration test.

}

describe('Dialog', () => {
describe('root element', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd recommend to make the structure of tests more flat: are those little groups like "root element" or "header" really needed?

expect(wrapper.getElement()).toHaveTextContent('content');
});

test('does not set aria-modal', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this test? It seems random: if we were to capture everything that the dialog components does not set - it'd be a long list.

);
});

test('does not render content when children are not provided', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd question the practicality of this test, as it is hard to imagine a real use case when the dialog without content is needed.

Comment thread src/dialog/interfaces.ts

export namespace DialogProps {
export interface DismissDetail {
reason: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we call this field "reason" in some other components? E.g. in the Drawer a similar prop is called "method".

Why does it have a string type and not a union of possible values?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also take a look at how we name the values in other components - e.g. should we use "closeButton" or "close-button" (I'd argue we also need to call it "dismiss-button" to match the terminology used in the component).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking some more about dialog dismissal - is there any good reason to prevent dialog from closing on-escape, while keeping the dismiss button? If we don't have any use cases - let's remove the property, too.

If in the future we allow hiding the dismiss button - then we should disable the on-escape dismissal, too - relying on consumers to provide it if needed.

expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ detail: { reason: 'closeButton' } }));
});

test('fires onDismiss with the keyboard reason and restores focus when Escape is pressed', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we use reason="keyboard" and not reason="escape"? This can be misleading, as one might think it means that the user activated the dismiss button by focusing it with the keyboard and then pressing Space or Enter.

expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ detail: { reason: 'closeButton' } }));
});

test('fires onDismiss with the keyboard reason and restores focus when Escape is pressed', async () => {

@pan-kot pan-kot Sep 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we combine focus management and event details check in one test? The focus management should not depend on the dismiss method - we should ensure it happens in both cases: when the user presses Escape or clicks on the dismiss button. We should either modify the prev test to also include focus check, or create separate tests to capture all four behaviours.

});

expect(document.activeElement).toBe(activeElement);
expect(wrapper.findDismissButton()!.getElement()).not.toHaveFocus();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this second assertion?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this test, was it created for test coverage?

When the dialog is rendered - it is expected to obtain focus. When it is not rendered - then there is no concern. In this test we render the dialog while breaking one of its internal dependencies - this is not a good proxy for any real use case.

expect(document.activeElement).toBe(wrapper.findDismissButton()!.getElement());
});

test('moves focus to the first headerActions control when present', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing that?

Can we instead consistently move focus e.g. to the dialog body - so that it announces its role and the header, instead of announcing either the dismiss or first action label, depending on its configuration?

We use this approach for standalone drawers: https://cloudscape.design/components/drawer/?tabId=playground&example=fixed-position-with-backdrop-and-close-action

const { container, unmount } = render(
<Dialog
header="Title"
headerActions={<button autoFocus={true}>Action</button>}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the previous test it is clear that the first header action is getting focused by the dialog in either case - so I'd recommend experimenting with a second one.

I would also be nice to see how this behaviour works in different browsers, as there can be some race conditions at play.


unmount();
await new Promise(resolve => setTimeout(resolve, 0));
expect(trigger).not.toHaveFocus();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the motivation for not restoring focus? As the dialog closes - the element to restore focus to will no longer be available, but that is also true for cases when the dialog is opened programmatically, or when the trigger is unmounted after click. Should we make the behaviour more predicable by always restoring focus if the prev focused element is still in DOM? When it is not - it is then consumer responsibility to forward it anyways.

trigger.remove();
});

test('moves focus in on mount and restores focus on unmount', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are already tests that assert initial focus transition and recovery - what is unique about this one?

trigger.focus();
expect(document.activeElement).toBe(trigger);

rerender(view(true));

@pan-kot pan-kot Sep 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we instead do the following check:

  1. Open the dialog
  2. Ensure the focus is on the dismiss button
  3. Move focus to another element
  4. Re-render the dialog and ensure the focus stays on another element (the dialog should not steal focus at that point)

});

test('restores the original trigger when the consumer moves focus while dismissing', async () => {
function Harness() {

@pan-kot pan-kot Sep 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we create some renderStatefulDialog function to include the trigger and additional focus target - so that we don't have to create custom components in tests?

Like this:

function renderStatefulDialog({ open, ...dialogProps }) {
  const [isOpen, setIsOpen] = useState(open);
  const { container } = render(
    <>
      <button data-testid="trigger" />
      <button data-testid="focus-target" />
      {isOpen ? <Dialog {...dialogProps} /> : null}
    <>
  );
  return { container, dialog: createWrapper().findDialog() }
}

(note that we don't have to use focusTargetRef as the required element can be found by testid on the fly).

Comment thread src/dialog/index.tsx
props: {},
metadata: {
hasHeaderActions: Boolean(props.headerActions),
hasContent: Boolean(props.children),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need hasContent - do we anticipate use cases where content is not present?

Comment thread src/dialog/interfaces.ts

/**
* Actions displayed in the header row, before the always-present close button.
* Use this for controls related to the dialog, such as a compact `Pagination`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do we really need to mention the compact pagination here? It is one of our use cases, which will be surfaced in the component guidelines anyways. This line in the api docs feels random.

Comment thread src/dialog/interfaces.ts
headerActions?: React.ReactNode;

/**
* Content of the dialog. The dialog is a shell: the interactive content it hosts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The dialog is a shell" - what does it mean? Do we use this terminology in any other similar components, e.g. modal or container?

Comment thread src/dialog/interfaces.ts

export interface I18nStrings {
/**
* Specifies the ARIA label for the dismiss button.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description repeats the one we gave for i18nStrings, and it is not parsed by the Documenter. Let's remove it.

Comment thread src/dialog/internal.tsx
...restProps
}: InternalDialogProps) {
const baseProps = getBaseProps(restProps);
const isRefresh = useVisualRefresh();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visual refresh is our default theme - we should not use this util for new components or features.

Comment thread src/dialog/internal.tsx
const activeElement = dialog.ownerDocument.activeElement;
const HTMLElementConstructor = dialog.ownerDocument.defaultView?.HTMLElement;
const restoreTarget =
HTMLElementConstructor && activeElement instanceof HTMLElementConstructor && !dialog.contains(activeElement)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is HTMLElementConstructor and why are we doing this?

Can we use the same logic to restore focus as we use in the modal? If we cannot use the focus-lock component - we can at least use the same code, by either copying or extracting the relevant parts.

Comment thread src/dialog/internal.tsx
: null;

// Focus the first headerActions control when present, otherwise the
// always-present close button. The heading is not a focus target.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description is technically incorrect: the dialog's header type is ReactNode - which means it can include interactive elements, too. I don't think this line requires a description - but most importantly I don't think that this way of handling focus is good (see my suggestion above on focusing the dialog body instead).

Comment thread src/dialog/internal.tsx
if (restoreTarget?.isConnected) {
// Cleanup can run synchronously while onDismiss is still executing. Defer
// restoration so focus reliably returns to the original trigger after the handler finishes.
dialog.ownerDocument.defaultView?.setTimeout(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we call setTimeout from dialog.ownerDocument.defaultView?

public: false,
themeable: true,
},
borderDialogTopWidth: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no outside visual refresh. We should remove this var.

colorBackgroundAlertSuccess: '{colorBackgroundStatusSuccess}',
colorBackgroundAlertWarning: '{colorBackgroundStatusWarning}',
colorBackgroundDialog: '{colorBackgroundStatusInfo}',
colorBackgroundDialog: '{colorBackgroundItemCard}',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we refer to the item card here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants