Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions example/src/Examples/DialogExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from 'react-native-paper';

import {
DialogWithCustomColors,
DialogWithDeclarativeApi,
DialogWithDismissableBackButton,
DialogWithIcon,
DialogWithLoadingIndicator,
Expand Down Expand Up @@ -79,6 +80,13 @@ const DialogExample = () => {
Dismissable back button
</Button>
)}
<Button
mode="outlined"
onPress={_toggleDialog('dialog8')}
style={styles.button}
>
Declarative API
</Button>
<DialogWithLongText
visible={_getVisible('dialog1')}
close={_toggleDialog('dialog1')}
Expand Down Expand Up @@ -107,6 +115,10 @@ const DialogExample = () => {
visible={_getVisible('dialog7')}
close={_toggleDialog('dialog7')}
/>
<DialogWithDeclarativeApi
visible={_getVisible('dialog8')}
close={_toggleDialog('dialog8')}
/>
</ScreenWrapper>
);
};
Expand Down
27 changes: 27 additions & 0 deletions example/src/Examples/Dialogs/DialogWithDeclarativeApi.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Portal, Dialog } from 'react-native-paper';

const DialogWithDeclarativeApi = ({
visible,
close,
}: {
visible: boolean;
close: () => void;
}) => {
return (
<Portal>
<Dialog
onDismiss={close}
visible={visible}
icon="alert"
title="Delete item"
content="Are you sure you want to delete this item? This action cannot be undone."
actions={[
{ label: 'Cancel', onPress: close },
{ label: 'Delete', onPress: close, mode: 'contained' },
]}
/>
</Portal>
);
};

export default DialogWithDeclarativeApi;
1 change: 1 addition & 0 deletions example/src/Examples/Dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { default as DialogWithRadioBtns } from './DialogWithRadioBtns';
export { default as UndismissableDialog } from './UndismissableDialog';
export { default as DialogWithIcon } from './DialogWithIcon';
export { default as DialogWithDismissableBackButton } from './DialogWithDismissableBackButton';
export { default as DialogWithDeclarativeApi } from './DialogWithDeclarativeApi';
142 changes: 138 additions & 4 deletions src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import DialogContent from './DialogContent';
import DialogIcon from './DialogIcon';
import DialogScrollArea from './DialogScrollArea';
import DialogTitle from './DialogTitle';
import type { DialogAction, DialogChildProps } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { Theme, ThemeProp } from '../../types';
import Button from '../Button/Button';
import type { IconSource } from '../Icon';
import Modal from '../Modal';
import type { DialogChildProps } from './utils';
import Text from '../Typography/Text';

export type Props = {
/**
Expand All @@ -32,9 +35,31 @@ export type Props = {
*/
visible: boolean;
/**
* Content of the `Dialog`.
* Icon to display at the top of the dialog, above the title (MD3).
* Only used by the declarative API; ignored when `children` are provided.
*/
children: React.ReactNode;
icon?: IconSource;
/**
* Title of the dialog. When provided (and `children` are omitted), it is
* rendered inside a `Dialog.Title`. Accepts a string or any React node.
*/
title?: React.ReactNode;
/**
* Supporting text of the dialog. When provided (and `children` are omitted),
* it is rendered inside a `Dialog.Content`. A string is wrapped in a themed
* `Text`; any other node is rendered as-is.
*/
content?: React.ReactNode;
/**
* List of action buttons rendered inside a `Dialog.Actions` row. Only used by
* the declarative API; ignored when `children` are provided.
*/
actions?: DialogAction[];
/**
* Content of the `Dialog`. When provided, the declarative `icon` / `title` /
* `content` / `actions` props are ignored and the composition API is used.
*/
children?: React.ReactNode;
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* @optional
Expand Down Expand Up @@ -87,9 +112,31 @@ const DIALOG_ELEVATION: number = 24;
*
* export default MyComponent;
* ```
*
* Alternatively, the dialog can be described declaratively via the `icon`,
* `title`, `content` and `actions` props instead of composing children:
*
* ```js
* <Portal>
* <Dialog
* visible={visible}
* onDismiss={hideDialog}
* title="Delete item"
* content="Are you sure? This action cannot be undone."
* actions={[
* { label: 'Cancel', onPress: hideDialog },
* { label: 'Delete', onPress: onConfirmDelete },
* ]}
* />
* </Portal>
* ```
*/
const Dialog = ({
children,
icon,
title,
content,
actions,
dismissable = true,
dismissableBackButton = dismissable,
onDismiss,
Expand All @@ -104,6 +151,87 @@ const Dialog = ({

const backgroundColor = theme.colors.surfaceContainerHigh;

const hasDeclarativeProps =
icon != null || title != null || content != null || actions != null;

if (__DEV__ && children != null && hasDeclarativeProps) {
console.warn(
'Dialog: `children` was provided together with the declarative ' +
'`icon`/`title`/`content`/`actions` props. `children` take precedence ' +
'and the declarative props are ignored. Use one API or the other.'
);
}

// Build the composition tree from the declarative props when no explicit
// `children` are passed. This keeps the existing children-based API fully
// backward compatible while offering a simpler declarative alternative.
const dialogChildren = React.useMemo(() => {
if (children != null) {
return children;
}

const centered = icon != null;
const items: React.ReactNode[] = [];

if (icon != null) {
items.push(<DialogIcon key="icon" icon={icon} />);
}

if (title != null) {
items.push(
<DialogTitle
key="title"
style={centered ? styles.centeredTitle : undefined}
>
{title}
</DialogTitle>
);
}

if (content != null) {
items.push(
<DialogContent key="content">
{typeof content === 'string' ? (
<Text
variant="bodyMedium"
style={[
{ color: theme.colors.onSurfaceVariant },
centered && styles.centeredContent,
]}
>
{content}
</Text>
) : (
content
)}
</DialogContent>
);
}

if (actions != null && actions.length > 0) {
items.push(
<DialogActions key="actions">
{actions.map((action, i) => (
<Button
key={action.key ?? i}
mode={action.mode ?? 'text'}
onPress={action.onPress}
icon={action.icon}
loading={action.loading}
disabled={action.disabled}
labelStyle={action.labelStyle}
testID={action.testID}
>
{action.label}
</Button>
))}
</DialogActions>
);
}

return items;
}, [children, icon, title, content, actions, theme.colors.onSurfaceVariant]);

return (
<Modal
dismissable={dismissable}
Expand All @@ -122,7 +250,7 @@ const Dialog = ({
theme={theme}
testID={testID}
>
{React.Children.toArray(children)
{React.Children.toArray(dialogChildren)
.filter((child) => child != null && typeof child !== 'boolean')
.map((child, i) => {
if (i === 0 && React.isValidElement<DialogChildProps>(child)) {
Expand Down Expand Up @@ -161,6 +289,12 @@ const styles = StyleSheet.create({
elevation: DIALOG_ELEVATION,
justifyContent: 'flex-start',
},
centeredTitle: {
textAlign: 'center',
},
centeredContent: {
textAlign: 'center',
},
});

export default Dialog;
52 changes: 51 additions & 1 deletion src/components/Dialog/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { StyleProp, ViewStyle } from 'react-native';
import type {
GestureResponderEvent,
StyleProp,
TextStyle,
ViewStyle,
} from 'react-native';

import type { IconSource } from '../Icon';

export type DialogChildProps = {
style?: StyleProp<ViewStyle>;
Expand All @@ -8,3 +15,46 @@ export type DialogActionChildProps = DialogChildProps & {
compact?: boolean;
uppercase?: boolean;
};

/**
* Declarative description of a single action button rendered in the
* `Dialog.Actions` row when the `actions` prop is used.
*/
export type DialogAction = {
/**
* Text displayed inside the action button.
*/
label: string;
/**
* Called when the action button is pressed.
*/
onPress?: (e: GestureResponderEvent) => void;
/**
* Button mode. Defaults to `'text'`, matching Material Design 3 dialog buttons.
*/
mode?: 'text' | 'outlined' | 'contained' | 'elevated' | 'contained-tonal';
/**
* Icon to display on the button.
*/
icon?: IconSource;
/**
* Whether the button shows a loading indicator.
*/
loading?: boolean;
/**
* Whether the button is disabled.
*/
disabled?: boolean;
/**
* Style applied to the button label.
*/
labelStyle?: StyleProp<TextStyle>;
/**
* Optional key to stabilize the rendered list of action buttons.
*/
key?: string | number;
/**
* testID used for the action button.
*/
testID?: string;
};
68 changes: 68 additions & 0 deletions src/components/__tests__/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,74 @@ describe('Dialog', () => {
});
});

describe('Dialog declarative API', () => {
it('should render title and string content passed via props', async () => {
await render(
<Dialog
visible
testID="dialog"
title="Delete item"
content="Are you sure?"
/>
);

expect(screen.getByText('Delete item')).toBeOnTheScreen();
expect(screen.getByText('Are you sure?')).toBeOnTheScreen();
});

it('should render action buttons passed via the actions prop', async () => {
const onCancel = jest.fn();
const onConfirm = jest.fn();

await render(
<Dialog
visible
testID="dialog"
title="Delete item"
actions={[
{ label: 'Cancel', onPress: onCancel, testID: 'action-cancel' },
{ label: 'Delete', onPress: onConfirm, testID: 'action-delete' },
]}
/>
);

expect(screen.getByTestId('action-cancel')).toBeOnTheScreen();
expect(screen.getByTestId('action-delete')).toBeOnTheScreen();

await userEvent.press(screen.getByTestId('action-delete'));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(0);
});

it('should accept a React node as content', async () => {
await render(
<Dialog visible testID="dialog" title="Title">
<Dialog.Content>
<Text testID="custom-content">Custom node</Text>
</Dialog.Content>
</Dialog>
);

expect(screen.getByTestId('custom-content')).toBeOnTheScreen();
});

it('should prefer children over declarative props and warn in dev', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});

await render(
<Dialog visible testID="dialog" title="From prop">
<Text testID="from-children">From children</Text>
</Dialog>
);

expect(screen.getByTestId('dialog')).toHaveTextContent('From children');
expect(screen.getByTestId('dialog')).not.toHaveTextContent('From prop');
expect(warn).toHaveBeenCalled();

warn.mockRestore();
});
});

describe('DialogActions', () => {
it('should render passed children', async () => {
await render(
Expand Down