Skip to content

Commit

Permalink
[AlertDialog][Dialog][Popover] Fix non-interactive button disabled st…
Browse files Browse the repository at this point in the history
…ate (#1473)
  • Loading branch information
mj12albert authored Feb 25, 2025
1 parent 05e3fd9 commit e619406
Show file tree
Hide file tree
Showing 19 changed files with 425 additions and 28 deletions.
6 changes: 5 additions & 1 deletion docs/reference/generated/alert-dialog-close.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
}
},
"dataAttributes": {},
"dataAttributes": {
"data-disabled": {
"description": "Present when the button is disabled."
}
},
"cssVariables": {}
}
3 changes: 3 additions & 0 deletions docs/reference/generated/alert-dialog-trigger.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"dataAttributes": {
"data-popup-open": {
"description": "Present when the corresponding dialog is open."
},
"data-disabled": {
"description": "Present when the trigger is disabled."
}
},
"cssVariables": {}
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/generated/dialog-close.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
}
},
"dataAttributes": {},
"dataAttributes": {
"data-disabled": {
"description": "Present when the button is disabled."
}
},
"cssVariables": {}
}
3 changes: 3 additions & 0 deletions docs/reference/generated/dialog-trigger.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"dataAttributes": {
"data-popup-open": {
"description": "Present when the corresponding dialog is open."
},
"data-disabled": {
"description": "Present when the trigger is disabled."
}
},
"cssVariables": {}
Expand Down
68 changes: 68 additions & 0 deletions packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { AlertDialog } from '@base-ui-components/react/alert-dialog';
import { screen } from '@mui/internal-test-utils';
import { createRenderer, describeConformance } from '#test-utils';

describe('<AlertDialog.Close />', () => {
Expand All @@ -18,4 +21,69 @@ describe('<AlertDialog.Close />', () => {
);
},
}));

describe('prop: disabled', () => {
it('disables the button', async () => {
const handleOpenChange = spy();

const { user } = await render(
<AlertDialog.Root onOpenChange={handleOpenChange}>
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Popup>
<AlertDialog.Close disabled>Close</AlertDialog.Close>
</AlertDialog.Popup>
</AlertDialog.Portal>
</AlertDialog.Root>,
);

expect(handleOpenChange.callCount).to.equal(0);

const openButton = screen.getByText('Open');
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[0]).to.equal(true);

const closeButton = screen.getByText('Close');
expect(closeButton).to.have.attribute('disabled');
expect(closeButton).to.have.attribute('data-disabled');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(1);
});

it('custom element', async () => {
const handleOpenChange = spy();

const { user } = await render(
<AlertDialog.Root onOpenChange={handleOpenChange}>
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Popup>
<AlertDialog.Close disabled render={<span />}>
Close
</AlertDialog.Close>
</AlertDialog.Popup>
</AlertDialog.Portal>
</AlertDialog.Root>,
);

expect(handleOpenChange.callCount).to.equal(0);

const openButton = screen.getByText('Open');
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[0]).to.equal(true);

const closeButton = screen.getByText('Close');
expect(closeButton).to.not.have.attribute('disabled');
expect(closeButton).to.have.attribute('data-disabled');
expect(closeButton).to.have.attribute('aria-disabled', 'true');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(1);
});
});
});
20 changes: 14 additions & 6 deletions packages/react/src/alert-dialog/close/AlertDialogClose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { useDialogClose } from '../../dialog/close/useDialogClose';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import type { BaseUIComponentProps } from '../../utils/types';

const state = {};

/**
* A button that closes the alert dialog.
* Renders a `<button>` element.
Expand All @@ -18,16 +16,17 @@ const AlertDialogClose = React.forwardRef(function AlertDialogClose(
props: AlertDialogClose.Props,
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const { render, className, ...other } = props;
const { render, className, disabled = false, ...other } = props;
const { open, setOpen } = useAlertDialogRootContext();
const { getRootProps } = useDialogClose({ open, setOpen });
const { getRootProps } = useDialogClose({ disabled, open, setOpen, rootRef: forwardedRef });

const state: AlertDialogClose.State = React.useMemo(() => ({ disabled }), [disabled]);

const { renderElement } = useComponentRenderer({
render: render ?? 'button',
className,
state,
propGetter: getRootProps,
ref: forwardedRef,
extraProps: other,
});

Expand All @@ -37,7 +36,12 @@ const AlertDialogClose = React.forwardRef(function AlertDialogClose(
namespace AlertDialogClose {
export interface Props extends BaseUIComponentProps<'button', State> {}

export interface State {}
export interface State {
/**
* Whether the button is currently disabled.
*/
disabled: boolean;
}
}

AlertDialogClose.propTypes /* remove-proptypes */ = {
Expand All @@ -54,6 +58,10 @@ AlertDialogClose.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
/**
* @ignore
*/
disabled: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum AlertDialogCloseDataAttributes {
/**
* Present when the button is disabled.
*/
disabled = 'data-disabled',
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from 'react';
import { expect } from 'chai';
import { AlertDialog } from '@base-ui-components/react/alert-dialog';
import { screen } from '@mui/internal-test-utils';
import { createRenderer, describeConformance } from '#test-utils';

describe('<AlertDialog.Trigger />', () => {
Expand All @@ -16,4 +18,55 @@ describe('<AlertDialog.Trigger />', () => {
);
},
}));

describe('prop: disabled', () => {
it('disables the dialog', async () => {
const { user } = await render(
<AlertDialog.Root>
<AlertDialog.Trigger disabled />
<AlertDialog.Portal>
<AlertDialog.Backdrop />
<AlertDialog.Popup>
<AlertDialog.Title>title text</AlertDialog.Title>
</AlertDialog.Popup>
</AlertDialog.Portal>
</AlertDialog.Root>,
);

const trigger = screen.getByRole('button');
expect(trigger).to.have.attribute('disabled');
expect(trigger).to.have.attribute('data-disabled');

await user.click(trigger);
expect(screen.queryByText('title text')).to.equal(null);

await user.keyboard('[Tab]');
expect(document.activeElement).to.not.equal(trigger);
});

it('custom element', async () => {
const { user } = await render(
<AlertDialog.Root>
<AlertDialog.Trigger disabled render={<span />} />
<AlertDialog.Portal>
<AlertDialog.Backdrop />
<AlertDialog.Popup>
<AlertDialog.Title>title text</AlertDialog.Title>
</AlertDialog.Popup>
</AlertDialog.Portal>
</AlertDialog.Root>,
);

const trigger = screen.getByRole('button');
expect(trigger).to.not.have.attribute('disabled');
expect(trigger).to.have.attribute('data-disabled');
expect(trigger).to.have.attribute('aria-disabled', 'true');

await user.click(trigger);
expect(screen.queryByText('title text')).to.equal(null);

await user.keyboard('[Tab]');
expect(document.activeElement).to.not.equal(trigger);
});
});
});
23 changes: 20 additions & 3 deletions packages/react/src/alert-dialog/trigger/AlertDialogTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { useAlertDialogRootContext } from '../root/AlertDialogRootContext';
import { useButton } from '../../use-button/useButton';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { useForkRef } from '../../utils/useForkRef';
import type { BaseUIComponentProps } from '../../utils/types';
Expand All @@ -17,18 +18,26 @@ const AlertDialogTrigger = React.forwardRef(function AlertDialogTrigger(
props: AlertDialogTrigger.Props,
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const { render, className, ...other } = props;
const { render, className, disabled = false, ...other } = props;
const { open, setTriggerElement, getTriggerProps } = useAlertDialogRootContext();

const state: AlertDialogTrigger.State = React.useMemo(() => ({ open }), [open]);
const state: AlertDialogTrigger.State = React.useMemo(
() => ({ disabled, open }),
[disabled, open],
);

const mergedRef = useForkRef(forwardedRef, setTriggerElement);

const { getButtonProps } = useButton({
disabled,
buttonRef: mergedRef,
});

const { renderElement } = useComponentRenderer({
render: render ?? 'button',
className,
state,
propGetter: getTriggerProps,
propGetter: (externalProps) => getButtonProps(getTriggerProps(externalProps)),
extraProps: other,
customStyleHookMapping: triggerOpenStateMapping,
ref: mergedRef,
Expand All @@ -41,6 +50,10 @@ namespace AlertDialogTrigger {
export interface Props extends BaseUIComponentProps<'button', State> {}

export interface State {
/**
* Whether the dialog is currently disabled.
*/
disabled: boolean;
/**
* Whether the dialog is currently open.
*/
Expand All @@ -62,6 +75,10 @@ AlertDialogTrigger.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
/**
* @ignore
*/
disabled: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export enum AlertDialogTriggerDataAttributes {
/**
* Present when the trigger is disabled.
*/
disabled = 'data-disabled',
/**
* Present when the corresponding dialog is open.
*/
Expand Down
68 changes: 68 additions & 0 deletions packages/react/src/dialog/close/DialogClose.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { Dialog } from '@base-ui-components/react/dialog';
import { screen } from '@mui/internal-test-utils';
import { createRenderer, describeConformance } from '#test-utils';

describe('<Dialog.Close />', () => {
Expand All @@ -17,4 +20,69 @@ describe('<Dialog.Close />', () => {
);
},
}));

describe('prop: disabled', () => {
it('disables the button', async () => {
const handleOpenChange = spy();

const { user } = await render(
<Dialog.Root onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Close disabled>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>,
);

expect(handleOpenChange.callCount).to.equal(0);

const openButton = screen.getByText('Open');
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[0]).to.equal(true);

const closeButton = screen.getByText('Close');
expect(closeButton).to.have.attribute('disabled');
expect(closeButton).to.have.attribute('data-disabled');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(1);
});

it('custom element', async () => {
const handleOpenChange = spy();

const { user } = await render(
<Dialog.Root onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup>
<Dialog.Close disabled render={<span />}>
Close
</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>,
);

expect(handleOpenChange.callCount).to.equal(0);

const openButton = screen.getByText('Open');
await user.click(openButton);

expect(handleOpenChange.callCount).to.equal(1);
expect(handleOpenChange.firstCall.args[0]).to.equal(true);

const closeButton = screen.getByText('Close');
expect(closeButton).to.not.have.attribute('disabled');
expect(closeButton).to.have.attribute('data-disabled');
expect(closeButton).to.have.attribute('aria-disabled', 'true');
await user.click(closeButton);

expect(handleOpenChange.callCount).to.equal(1);
});
});
});
Loading

0 comments on commit e619406

Please sign in to comment.