Skip to content

refactor(dialog): MD3 updates, updated Dialog API - #5072

Open
kachmashk wants to merge 18 commits into
callstack:mainfrom
kachmashk:refactor/dialog-improvements
Open

kachmashk wants to merge 18 commits into
callstack:mainfrom
kachmashk:refactor/dialog-improvements

Conversation

@kachmashk

@kachmashk kachmashk commented Aug 25, 2026 •

Copy link
Copy Markdown

Motivation

The goal of this PR is to address Material Design 3 guidelines for the Dialog component based on the official documentation as well as this issue. This PR also provides ability to avoid using previous compound components implementation in favor of props directly passed to the Dialog component.

Changes

Material Design 3:

  • Dialog.ScrollArea divider border color uses outline,
  • Dialog has now restricted minimum and maximum width set to 280dp and 560dp,
  • Fixed an issue when DialogIcon is the first component in the Dialog and it had a total top margin of 48dp instead of 24dp,
  • Dialog now detects if Dialog.Icon and Dialog.Title are present and if so enforcing Dialog.Title to be centered.

Dialog:

  • Dialog component now supports icon, title, content, actions, scrollable props instead of using compound components. Dialog still uses compound components underneath to properly comply with Material Design specs as these components enforce proper styling,
  • Added new test cases for Dialog to ensure the new approach works as intended.

Example:

  • Updated Dialog screen within Example app with the same dialogs as before but using new API to make sure they look and work as expected.

Related issue

Test plan

  • lint
  • typecheck
  • test

Reviewers can through new Dialogs added to the Example app. They are duplicated as previous dialogs but they use new approach.

Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/DialogScrollArea.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread docs/src/data/componentDocs6x.json
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx
Comment thread docs/src/data/componentDocs6x.json
Comment thread src/components/Dialog/DialogActions.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread src/components/Dialog/DialogTitle.tsx Outdated
Comment thread src/components/Modal.tsx
Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread docs/src/data/componentDocs6x.json
@kachmashk
kachmashk requested a review from JKobrynski September 1, 2026 08:40
@kachmashk
kachmashk force-pushed the refactor/dialog-improvements branch from 8562995 to 7be180f Compare September 2, 2026 08:36
@JKobrynski

JKobrynski commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

The MD3 corrections here should all land - divider colour, the 280/560 width clamp, the icon margin, the centred headline. What I'd like to reconsider is replacing children with title/content/actions props. #5076 solves the same problem while keeping the sub-components, which matches Card, List and DataTable. Could you drop the API change and keep the MD3 fixes? #5076 will need to land first either way, since the two touch the same files. @kachmashk

Comment thread src/components/Dialog/Dialog.tsx Outdated
Comment thread docs/6.x/docs/guides/migration.md
Comment thread docs/6.x/docs/guides/migration.md Outdated
Comment thread src/components/Modal.tsx
@JKobrynski

Copy link
Copy Markdown
Collaborator

Also @kachmashk could you resolve the comments that've been addressed? 🙏

@kachmashk
kachmashk force-pushed the refactor/dialog-improvements branch from e2db9f1 to a0fc3b7 Compare September 7, 2026 07:39
@kachmashk

Copy link
Copy Markdown
Author

Waiting for a #5076 to be closed.

@JKobrynski

Copy link
Copy Markdown
Collaborator

#5094 removes docs from the repo, can you remove them from this PR? 🙏

@JKobrynski

Copy link
Copy Markdown
Collaborator

@kachmashk ☝️ and we've got conflicts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@k0ndee

k0ndee commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Heads up - #5125 is reworking Modal's accessibility props: it replaces overlayAccessibilityLabel with aria-label, and adds a new configurable role prop (defaulting to dialog) directly on Modal's Surface, rather than a hardcoded value. This PR already forwards aria-label from Dialog to Modal, but its Modal usage here still targets the old shape (overlayAccessibilityLabel + hardcoded role="dialog"), so it'll need a rebase once #5125 merges.

konstmar pushed a commit to konstmar/react-native-paper that referenced this pull request Sep 17, 2026
The surface stays a dialog, but the prop lands together with the Dialog
wiring in callstack#5072 rather than half-done here.
@satya164

Copy link
Copy Markdown
Member

Review by Claude

The new maxWidth: 560 keeps the dialog stretched from the start edge, so on any screen wider than about 612 points the dialog sits against one side instead of the center. Phones in portrait are not affected.

Web

Steps: open the example app at 1280px width, go to Dialog, press "With icon".
What happens: the dialog is 560px wide and starts at x=26, so it hugs the left edge.
What should happen: the dialog is centered horizontally. (via Claude, verify if it makes sense)

Web, 1280px wide: dialog pinned to the left edge

iOS

Steps: rotate the device to landscape, go to Dialog, press "With icon".
What happens: the dialog starts at the left safe area inset and leaves a wide gap on the right.
What should happen: the dialog is centered horizontally. (via Claude, verify if it makes sense)

iOS, landscape: dialog pinned to the left edge

Android

Steps: turn on RTL in the drawer, rotate the device to landscape, go to Dialog, press "With icon".
What happens: the dialog sticks to the right edge, which is the start edge in RTL.
What should happen: the dialog is centered horizontally. (via Claude, verify if it makes sense)

Android, landscape, RTL: dialog pinned to the right edge

@satya164 satya164 left a comment

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.

Review by Claude

See the inline comments. Also, no test covers the new icon prop or the centered title when an icon is present. A test that renders icon with title and checks textAlign: 'center' on the title would catch a regression. (via Claude, verify if it makes sense)

Comment on lines +242 to +243
minWidth: 280,
maxWidth: 560,

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.

maxWidth: 560 caps the width, but the Modal wrapper still stretches the surface from the start edge. On screens wider than 612 points (web, tablets, phones in landscape) the dialog sits against the left edge, or the right edge in RTL. At 320 points wide the 280 point dialog is also off center (26 on the left, 14 on the right).

Centering the surface fixes both. I checked alignSelf: 'center' in the browser at 1280 and 320 points. Short dialogs then size to their content between 280 and 560 instead of filling the phone width.

Suggested change
minWidth: 280,
maxWidth: 560,
alignSelf: 'center',
minWidth: 280,
maxWidth: 560,

Web, 1280px wide: dialog pinned to the left edge

iOS, landscape: dialog pinned to the left edge

Steps and the Android screenshot are in the PR comment above. (via Claude, verify if it makes sense)

]}
theme={theme}
testID={testID}
aria-label={ariaLabel ?? (typeof title === 'string' ? title : undefined)}

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 only gets an accessible name when title is a string. DialogWithCustomColors.tsx passes a <Text> element as title, and that dialog has no name on any platform: web renders role="dialog" with no aria-label, and the iOS and Android accessibility trees show the surface with only its test ID. Any title that is not a plain string has the same problem.

Main now gives Dialog.Title an id from React.useId() and passes aria-labelledby to Modal when aria-label is not set (#5076). Use the same approach here so an element title still names the dialog on web, and pass aria-label="Alert" in DialogWithCustomColors.tsx so it also has a name on iOS and Android. (via Claude, verify if it makes sense)

const backgroundColor = theme.colors.surfaceContainerHigh;

const contentNode =
typeof content === '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.

content is a ReactNode, so content={count} type checks. A number skips the Text wrapper and renders directly inside the View of Dialog.Content, which throws "Text strings must be rendered within a component." I set content={42} in UndismissableDialog.tsx and got this error on iOS.

Suggested change
typeof content === 'string' ? (
typeof content === 'string' || typeof content === 'number' ? (

(via Claude, verify if it makes sense)

Comment on lines +57 to 74
const actions = React.Children.toArray(children).filter((child) =>
React.isValidElement<{ style?: StyleProp<ViewStyle> }>(child)
);

return (
<View {...props} style={[styles.v3Container, props.style]}>
{React.Children.map(props.children, (child, i) =>
React.isValidElement<DialogActionChildProps>(child)
? React.cloneElement(child, {
compact: true,
uppercase: false,
style: [
{
marginRight: i + 1 === actionsLength ? 0 : 8,
},
child.props.style,
],
})
: child
)}
<View {...rest} style={[styles.v3Container, style]}>
{actions.map((child, index) => (
<React.Fragment
key={
React.isValidElement(child) && child.key != null ? child.key : index
}
>
{index > 0 && <View style={styles.spacer} />}
{child}
</React.Fragment>
))}
</View>
);

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 adds React.Children.toArray, an isValidElement filter and a child.key read on the actions. Inspecting children like this is fragile. It only sees the top level, so a fragment or a custom component that renders two buttons counts as one action and gets no gap between its buttons. It also hides the data flow, because the spacing depends on how the caller shapes children.

Main already solved the spacing in #5076 with columnGap: 8 on the container and plain {children}, so this can match it:

Suggested change
const actions = React.Children.toArray(children).filter((child) =>
React.isValidElement<{ style?: StyleProp<ViewStyle> }>(child)
);
return (
<View {...props} style={[styles.v3Container, props.style]}>
{React.Children.map(props.children, (child, i) =>
React.isValidElement<DialogActionChildProps>(child)
? React.cloneElement(child, {
compact: true,
uppercase: false,
style: [
{
marginRight: i + 1 === actionsLength ? 0 : 8,
},
child.props.style,
],
})
: child
)}
<View {...rest} style={[styles.v3Container, style]}>
{actions.map((child, index) => (
<React.Fragment
key={
React.isValidElement(child) && child.key != null ? child.key : index
}
>
{index > 0 && <View style={styles.spacer} />}
{child}
</React.Fragment>
))}
</View>
);
return (
<View {...rest} style={[styles.v3Container, style]}>
{children}
</View>
);

Then add columnGap: 8 to v3Container and remove spacer. The "should apply default styles" test also needs a change, because it counts the spacer views instead of checking the visible gap. (via Claude, verify if it makes sense)

Comment on lines +35 to +46
* <Dialog
* visible={visible}
* onDismiss={hideDialog}
* actions={[
* <Button key="disagree-btn" onPress={close} textColor={Palette.error50}>
* Disagree
* </Button>,
* <Button key="agree-btn" onPress={close}>
* Agree
* </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.

The usage example calls close and Palette.error50, but neither is defined or imported in it. The example defines hideDialog. These lines also mix tabs into the space indentation. Dialog.tsx lines 117 to 119 have the same tabs.

Suggested change
* <Dialog
* visible={visible}
* onDismiss={hideDialog}
* actions={[
* <Button key="disagree-btn" onPress={close} textColor={Palette.error50}>
* Disagree
* </Button>,
* <Button key="agree-btn" onPress={close}>
* Agree
* </Button>,
* ]}
* />
* <Dialog
* visible={visible}
* onDismiss={hideDialog}
* actions={[
* <Button key="cancel" onPress={hideDialog}>Cancel</Button>,
* <Button key="ok" onPress={hideDialog}>Ok</Button>,
* ]}
* />

(via Claude, verify if it makes sense)

<Dialog
visible={visible}
onDismiss={hideDialog}
icon="alert"

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 "Before" snippet has no Dialog.Icon, but the "After" snippet adds icon="alert". Readers can take the icon as part of the migration. Remove it so both snippets show the same dialog.

Suggested change
icon="alert"

(via Claude, verify if it makes sense)

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants