Skip to content

Commit 701f287

Browse files
feat(modal): refactor to use native dialog element (#18553)
* refactor(modal): add dialog feature flag * refactor(modal): begin dialog exploration * feat(modal): unify conditional exports under wrapper component * revert(modal): changes from initial dialog refactor * refactor(modal): remove unecessary close button dom location condition * fix(modal): add dialog refactor scss feature flag * fix: named export * feat(dialog): break children elements out into composable components * feat(dialog): improve stories, documentation, and Modal refactor * feat(modal): document dialog feature flag * fix(composedmodal): refactor to use dialog * refactor(modal): remove conditional export, move enableDialogElement into existing Modal source file * Update packages/react/src/components/Dialog/Dialog.stories.js Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> * Update packages/react/scss/components/dialog/_index.scss Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> * Update packages/react/scss/components/dialog/_dialog.scss Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> * fix(dialog): incorporate feedback --------- Co-authored-by: “heloiselui” <helolui27@gmail.com> Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com>
1 parent 4cbf8f4 commit 701f287

File tree

23 files changed

+857
-1129
lines changed

23 files changed

+857
-1129
lines changed

packages/feature-flags/feature-flags.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ feature-flags:
4545
description: >
4646
Enable the new focus wrap behavior that doesn't use sentinel nodes
4747
enabled: false
48+
- name: enable-dialog-element
49+
description: >
50+
Enable components to utilize the native dialog element
51+
enabled: false
4852
- name: enable-v12-dynamic-floating-styles
4953
description: >
5054
Enable dynamic setting of floating styles for components like Popover,

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9933,6 +9933,9 @@ Map {
99339933
"children": Object {
99349934
"type": "node",
99359935
},
9936+
"enableDialogElement": Object {
9937+
"type": "bool",
9938+
},
99369939
"enableExperimentalFocusWrapWithoutSentinels": Object {
99379940
"type": "bool",
99389941
},
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Code generated by @carbon/react. DO NOT EDIT.
2+
//
3+
// Copyright IBM Corp. 2018, 2025
4+
//
5+
// This source code is licensed under the Apache-2.0 license found in the
6+
// LICENSE file in the root directory of this source tree.
7+
//
8+
9+
@forward '@carbon/styles/scss/components/dialog/dialog';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Code generated by @carbon/react. DO NOT EDIT.
2+
//
3+
// Copyright IBM Corp. 2018, 2025
4+
//
5+
// This source code is licensed under the Apache-2.0 license found in the
6+
// LICENSE file in the root directory of this source tree.
7+
//
8+
9+
@forward '@carbon/styles/scss/components/dialog';

packages/react/src/components/ComposedModal/ComposedModal.featureflag.mdx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,26 @@ import { Meta } from '@storybook/blocks';
1010
&nbsp;|&nbsp;
1111
[Accessibility](https://www.carbondesignsystem.com/components/modal/accessibility)
1212

13-
## Experimental focus wrap without sentinels
13+
## `enable-dialog-element`
1414

15-
`ComposedModal` supports the `enable-experimental-focus-wrap-without-sentinels`
16-
feature flag. This enables a new approach to the focus wrap behavior that
17-
modifies the DOM to no longer include hidden "sentinel nodes" used to mark the
18-
beginning and end of the wrapped focus. The new behavior looks at all
19-
interactive child nodes and wraps focus based on tabbable order of those nodes.
20-
The focus direction is determined whether `tab` is being pressed (forward) or
21-
`shift`+`tab` is being pressed (backwards). In javascript you can enable this
22-
feature flag to use the new focus wrap behavior.
15+
`ComposedModal` supports the `enable-dialog-element` feature flag. This enables
16+
a new approach internal to the ComposedModal that uses the native `<dialog>`
17+
element. With this, the browser natively controls the focus wrap behavior. This
18+
means that the DOM no longer includes the "sentinel nodes" previously needed for
19+
the ComposedModal to manage focus wrap.
20+
21+
`ComposedModal` also supports the
22+
`enable-experimental-focus-wrap-without-sentinels` feature flag that was
23+
implemented previous to the dialog element refactor. In ComposedModal, this flag
24+
is an alias for the `enable-dialog-element` flag. They do the same thing and you
25+
only need to use one, preferably `enable-dialog-element`.
26+
27+
## Enabling the flag
28+
29+
To enable the flag, use the `FeatureFlags` component and set the prop:
2330

2431
```js
25-
<FeatureFlags
26-
flags={{
27-
'enable-experimental-focus-wrap-without-sentinels': true,
28-
}}>
29-
<ComposedModal />
32+
<FeatureFlags enableDialogElement>
33+
<ComposedModal ... />
3034
</FeatureFlags>
3135
```

packages/react/src/components/ComposedModal/ComposedModal.featureflag.stories.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default {
4545
],
4646
};
4747

48-
export const FocusWrapWithoutSentinels = (args) => {
48+
export const EnableDialogElement = (args) => {
4949
const [open, setOpen] = useState(true);
5050
return (
5151
<>
@@ -84,7 +84,7 @@ export const FocusWrapWithoutSentinels = (args) => {
8484
);
8585
};
8686

87-
FocusWrapWithoutSentinels.argTypes = {
87+
EnableDialogElement.argTypes = {
8888
children: {
8989
table: {
9090
disable: true,

packages/react/src/components/ComposedModal/ComposedModal.tsx

Lines changed: 104 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ import toggleClass from '../../tools/toggleClass';
2525
import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy';
2626
import wrapFocus, {
2727
elementOrParentIsFloatingMenu,
28-
wrapFocusWithoutSentinels,
2928
} from '../../internal/wrapFocus';
3029
import { usePrefix } from '../../internal/usePrefix';
3130
import { keys, match } from '../../internal/keyboard';
3231
import { useFeatureFlag } from '../FeatureFlags';
3332
import { composeEventHandlers } from '../../tools/events';
3433
import deprecate from '../../prop-types/deprecate';
34+
import { unstable__Dialog as Dialog } from '../Dialog/index';
3535

3636
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
3737
/** Specify the content to be placed in the ModalBody. */
@@ -260,43 +260,34 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
260260
const endSentinel = useRef<HTMLButtonElement>(null);
261261
const onMouseDownTarget: MutableRefObject<Node | null> =
262262
useRef<Node | null>(null);
263-
const focusTrapWithoutSentinels = useFeatureFlag(
264-
'enable-experimental-focus-wrap-without-sentinels'
265-
);
263+
const enableDialogElement =
264+
// useFeatureFlag('enable-experimental-focus-wrap-without-sentinels') ||
265+
useFeatureFlag('enable-dialog-element');
266266

267267
// Keep track of modal open/close state
268268
// and propagate it to the document.body
269269
useEffect(() => {
270-
if (open !== wasOpen) {
270+
if (!enableDialogElement && open !== wasOpen) {
271271
setIsOpen(!!open);
272272
setWasOpen(!!open);
273273
toggleClass(document.body, `${prefix}--body--with-modal-open`, !!open);
274274
}
275275
}, [open, wasOpen, prefix]);
276276
// Remove the document.body className on unmount
277277
useEffect(() => {
278-
return () => {
279-
toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
280-
};
278+
if (!enableDialogElement) {
279+
return () => {
280+
toggleClass(document.body, `${prefix}--body--with-modal-open`, false);
281+
};
282+
}
281283
}, []); // eslint-disable-line react-hooks/exhaustive-deps
282284

283285
function handleKeyDown(event) {
284-
event.stopPropagation();
285-
if (match(event, keys.Escape)) {
286-
closeModal(event);
287-
}
288-
289-
if (
290-
focusTrapWithoutSentinels &&
291-
open &&
292-
match(event, keys.Tab) &&
293-
innerModal.current
294-
) {
295-
wrapFocusWithoutSentinels({
296-
containerNode: innerModal.current,
297-
currentActiveNode: event.target,
298-
event: event,
299-
});
286+
if (!enableDialogElement) {
287+
event.stopPropagation();
288+
if (match(event, keys.Escape)) {
289+
closeModal(event);
290+
}
300291
}
301292

302293
onKeyDown?.(event);
@@ -400,45 +391,47 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
400391
});
401392

402393
useEffect(() => {
403-
if (!open && launcherButtonRef) {
394+
if (!enableDialogElement && !open && launcherButtonRef) {
404395
setTimeout(() => {
405396
launcherButtonRef?.current?.focus();
406397
});
407398
}
408399
}, [open, launcherButtonRef]);
409400

410401
useEffect(() => {
411-
const initialFocus = (focusContainerElement) => {
412-
const containerElement = focusContainerElement || innerModal.current;
413-
const primaryFocusElement = containerElement
414-
? containerElement.querySelector(
415-
danger ? `.${prefix}--btn--secondary` : selectorPrimaryFocus
416-
)
417-
: null;
418-
419-
if (primaryFocusElement) {
420-
return primaryFocusElement;
421-
}
422-
423-
return button && button.current;
424-
};
425-
426-
const focusButton = (focusContainerElement) => {
427-
const target = initialFocus(focusContainerElement);
402+
if (!enableDialogElement) {
403+
const initialFocus = (focusContainerElement) => {
404+
const containerElement = focusContainerElement || innerModal.current;
405+
const primaryFocusElement = containerElement
406+
? containerElement.querySelector(
407+
danger ? `.${prefix}--btn--secondary` : selectorPrimaryFocus
408+
)
409+
: null;
410+
411+
if (primaryFocusElement) {
412+
return primaryFocusElement;
413+
}
414+
415+
return button && button.current;
416+
};
417+
418+
const focusButton = (focusContainerElement) => {
419+
const target = initialFocus(focusContainerElement);
420+
421+
const closeButton = focusContainerElement.querySelector(
422+
`.${prefix}--modal-close`
423+
);
428424

429-
const closeButton = focusContainerElement.querySelector(
430-
`.${prefix}--modal-close`
431-
);
425+
if (target) {
426+
target.focus();
427+
} else if (!target && closeButton) {
428+
closeButton?.focus();
429+
}
430+
};
432431

433-
if (target) {
434-
target.focus();
435-
} else if (!target && closeButton) {
436-
closeButton?.focus();
432+
if (open && isOpen) {
433+
focusButton(innerModal.current);
437434
}
438-
};
439-
440-
if (open && isOpen) {
441-
focusButton(innerModal.current);
442435
}
443436
}, [open, selectorPrimaryFocus, isOpen]);
444437

@@ -458,58 +451,78 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
458451
);
459452
}
460453

454+
const modalBody = enableDialogElement ? (
455+
<Dialog
456+
open={open}
457+
modal
458+
className={containerClass}
459+
aria-label={ariaLabel ? ariaLabel : generatedAriaLabel}
460+
aria-labelledby={ariaLabelledBy}>
461+
<div ref={innerModal} className={`${prefix}--modal-container-body`}>
462+
{slug ? (
463+
normalizedDecorator
464+
) : decorator ? (
465+
<div className={`${prefix}--modal--inner__decorator`}>
466+
{normalizedDecorator}
467+
</div>
468+
) : (
469+
''
470+
)}
471+
{childrenWithProps}
472+
</div>
473+
</Dialog>
474+
) : (
475+
<div
476+
className={containerClass}
477+
role="dialog"
478+
aria-modal="true"
479+
aria-label={ariaLabel ? ariaLabel : generatedAriaLabel}
480+
aria-labelledby={ariaLabelledBy}>
481+
{/* Non-translatable: Focus-wrap code makes this `<button>` not actually read by screen readers */}
482+
<button
483+
type="button"
484+
ref={startSentinel}
485+
className={`${prefix}--visually-hidden`}>
486+
Focus sentinel
487+
</button>
488+
<div ref={innerModal} className={`${prefix}--modal-container-body`}>
489+
{slug ? (
490+
normalizedDecorator
491+
) : decorator ? (
492+
<div className={`${prefix}--modal--inner__decorator`}>
493+
{normalizedDecorator}
494+
</div>
495+
) : (
496+
''
497+
)}
498+
{childrenWithProps}
499+
</div>
500+
{/* Non-translatable: Focus-wrap code makes this `<button>` not actually read by screen readers */}
501+
<button
502+
type="button"
503+
ref={endSentinel}
504+
className={`${prefix}--visually-hidden`}>
505+
Focus sentinel
506+
</button>
507+
</div>
508+
);
509+
461510
return (
462511
<Layer
463512
{...rest}
464513
level={0}
465514
role="presentation"
466515
ref={ref}
467516
aria-hidden={!open}
468-
onBlur={!focusTrapWithoutSentinels ? handleBlur : () => {}}
517+
onBlur={!enableDialogElement ? handleBlur : () => {}}
469518
onClick={composeEventHandlers([rest?.onClick, handleOnClick])}
470519
onMouseDown={composeEventHandlers([
471520
rest?.onMouseDown,
472521
handleOnMouseDown,
473522
])}
474523
onKeyDown={handleKeyDown}
475524
className={modalClass}>
476-
<div
477-
className={containerClass}
478-
role="dialog"
479-
aria-modal="true"
480-
aria-label={ariaLabel ? ariaLabel : generatedAriaLabel}
481-
aria-labelledby={ariaLabelledBy}>
482-
{/* Non-translatable: Focus-wrap code makes this `<button>` not actually read by screen readers */}
483-
{!focusTrapWithoutSentinels && (
484-
<button
485-
type="button"
486-
ref={startSentinel}
487-
className={`${prefix}--visually-hidden`}>
488-
Focus sentinel
489-
</button>
490-
)}
491-
<div ref={innerModal} className={`${prefix}--modal-container-body`}>
492-
{slug ? (
493-
normalizedDecorator
494-
) : decorator ? (
495-
<div className={`${prefix}--modal--inner__decorator`}>
496-
{normalizedDecorator}
497-
</div>
498-
) : (
499-
''
500-
)}
501-
{childrenWithProps}
502-
</div>
503-
{/* Non-translatable: Focus-wrap code makes this `<button>` not actually read by screen readers */}
504-
{!focusTrapWithoutSentinels && (
505-
<button
506-
type="button"
507-
ref={endSentinel}
508-
className={`${prefix}--visually-hidden`}>
509-
Focus sentinel
510-
</button>
511-
)}
512-
</div>
525+
{modalBody}
513526
</Layer>
514527
);
515528
}

0 commit comments

Comments
 (0)