diff --git a/.yarn/cache/tabbable-npm-6.2.0-5a74c8b4e2-980fa73476.zip b/.yarn/cache/tabbable-npm-6.2.0-5a74c8b4e2-980fa73476.zip new file mode 100644 index 000000000000..5dd7c388a803 Binary files /dev/null and b/.yarn/cache/tabbable-npm-6.2.0-5a74c8b4e2-980fa73476.zip differ diff --git a/packages/feature-flags/feature-flags.yml b/packages/feature-flags/feature-flags.yml index 88a21e6e420a..db06ca3a7908 100644 --- a/packages/feature-flags/feature-flags.yml +++ b/packages/feature-flags/feature-flags.yml @@ -42,3 +42,7 @@ feature-flags: description: > Enable the new TreeView controllable API enabled: false + - name: enable-experimental-focus-wrap-without-sentinels + description: > + Enable the new focus wrap behavior that doesn't use sentinel nodes + enabled: false diff --git a/packages/react/package.json b/packages/react/package.json index 159b0f540058..f98a8b95b854 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -65,6 +65,7 @@ "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", "react-is": "^18.2.0", + "tabbable": "^6.2.0", "use-resize-observer": "^6.0.0", "wicg-inert": "^3.1.1", "window-or-global": "^1.0.1" diff --git a/packages/react/src/components/ComposedModal/ComposedModal.featureflag.mdx b/packages/react/src/components/ComposedModal/ComposedModal.featureflag.mdx new file mode 100644 index 000000000000..b798b2923b25 --- /dev/null +++ b/packages/react/src/components/ComposedModal/ComposedModal.featureflag.mdx @@ -0,0 +1,27 @@ +# ComposedModal + +[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/ComposedModal) + |  +[Usage guidelines](https://www.carbondesignsystem.com/components/modal/usage) + |  +[Accessibility](https://www.carbondesignsystem.com/components/modal/accessibility) + +## Experimental focus wrap without sentinels + +`ComposedModal` supports the `enable-experimental-focus-wrap-without-sentinels` +feature flag. This enables a new approach to the focus wrap behavior that +modifies the DOM to no longer include hidden "sentinel nodes" used to mark the +beginning and end of the wrapped focus. The new behavior looks at all +interactive child nodes and wraps focus based on tabbable order of those nodes. +The focus direction is determined whether `tab` is being pressed (forward) or +`shift`+`tab` is being pressed (backwards). In javascript you can enable this +feature flag to use the new focus wrap behavior. + +```js + + + +``` diff --git a/packages/react/src/components/ComposedModal/ComposedModal.featureflag.stories.js b/packages/react/src/components/ComposedModal/ComposedModal.featureflag.stories.js new file mode 100644 index 000000000000..3d004a4131f8 --- /dev/null +++ b/packages/react/src/components/ComposedModal/ComposedModal.featureflag.stories.js @@ -0,0 +1,430 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import ComposedModal, { ModalBody } from './ComposedModal'; +import { ModalHeader } from './ModalHeader'; +import { ModalFooter } from './ModalFooter'; +import MultiSelect from '../MultiSelect'; +import Dropdown from '../Dropdown'; +import Select from '../Select'; +import SelectItem from '../SelectItem'; +import TextInput from '../TextInput'; +import Button from '../Button'; +import { + StructuredListWrapper, + StructuredListHead, + StructuredListBody, + StructuredListRow, + StructuredListCell, +} from '../StructuredList'; +import mdx from './ComposedModal.featureflag.mdx'; +import { WithFeatureFlags } from '../../../.storybook/templates/WithFeatureFlags'; + +export default { + title: 'Experimental/Feature Flags/ComposedModal', + component: ComposedModal, + subcomponents: { + ModalHeader, + ModalBody, + ModalFooter, + }, + parameters: { + docs: { + page: mdx, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ +
+ + ); +}; + +export const FullWidth = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} isFullWidth> + + + + + + + Column A + + + Column B + + + Column C + + + + + + Row 1 + Row 1 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. + Aenean posuere sem vel euismod dignissim. Nulla ut cursus + dolor. Pellentesque vulputate nisl a porttitor interdum. + + + + Row 2 + Row 2 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. + Aenean posuere sem vel euismod dignissim. Nulla ut cursus + dolor. Pellentesque vulputate nisl a porttitor interdum. + + + + Row 3 + Row 3 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. + Aenean posuere sem vel euismod dignissim. Nulla ut cursus + dolor. Pellentesque vulputate nisl a porttitor interdum. + + + + + + + + + ); +}; + +export const PassiveModal = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)}> + + + + + ); +}; + +export const WithStateManager = () => { + const button = useRef(); + + /** + * Simple state manager for modals. + */ + const ModalStateManager = ({ + renderLauncher: LauncherContent, + children: ModalContent, + }) => { + const [open, setOpen] = useState(false); + return ( + <> + {!ModalContent || typeof document === 'undefined' + ? null + : ReactDOM.createPortal( + , + document.body + )} + {LauncherContent && } + + ); + }; + return ( + ( + + )}> + {({ open, setOpen }) => ( + { + setOpen(false); + }} + launcherButtonRef={button}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a + shared domain, a shared subdomain, or a shared domain and host. +

+ + +
+ +
+ )} +
+ ); +}; + +export const WithScrollingContent = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + eu nibh odio. Nunc a consequat est, id porttitor sapien. Proin vitae + leo vitae orci tincidunt auctor eget eget libero. Ut tincidunt + ultricies fringilla. Aliquam erat volutpat. Aenean arcu odio, + elementum vel vehicula vitae, porttitor ac lorem. Sed viverra elit + ac risus tincidunt fermentum. Ut sollicitudin nibh id risus ornare + ornare. Etiam gravida orci ut lectus dictum, quis ultricies felis + mollis. Mauris nec commodo est, nec faucibus nibh. Nunc commodo ante + quis pretium consectetur. Ut ac nisl vitae mi mattis vulputate a at + elit. Nullam porttitor ex eget mi feugiat mattis. Nunc non sodales + magna. Proin ornare tellus quis hendrerit egestas. Donec pharetra + leo nec molestie sollicitudin.{' '} +

+ +
+ +
+ + (item ? item.text : '')} + /> +
+ +
+ + ); +}; + +export const WithInlineLoading = () => { + const [open, setOpen] = useState(true); + const [status, setStatus] = useState('inactive'); + const [description, setDescription] = useState('Submitting...'); + + const fakePromise = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 2000); + }); + }; + + const submit = async () => { + setStatus('active'); + + await fakePromise(); + + setDescription('Submited!'); + setStatus('finished'); + }; + + const resetStatus = () => { + setStatus('inactive'); + setDescription('Submitting...'); + }; + + return ( + <> + + setOpen(false)}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ +
+ + ); +}; + +export const Playground = (args) => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ +
+ + ); +}; + +Playground.argTypes = { + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, + containerClassName: { + table: { + disable: true, + }, + }, + onClose: { + action: 'onClose', + }, + onKeyDown: { + action: 'onKeyDown', + }, + selectorPrimaryFocus: { + table: { + disable: true, + }, + }, + selectorsFloatingMenus: { + table: { + disable: true, + }, + }, +}; diff --git a/packages/react/src/components/ComposedModal/ComposedModal.tsx b/packages/react/src/components/ComposedModal/ComposedModal.tsx index 883e6eb15601..84edbabd9715 100644 --- a/packages/react/src/components/ComposedModal/ComposedModal.tsx +++ b/packages/react/src/components/ComposedModal/ComposedModal.tsx @@ -19,9 +19,10 @@ import mergeRefs from '../../tools/mergeRefs'; import cx from 'classnames'; import toggleClass from '../../tools/toggleClass'; import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy'; -import wrapFocus from '../../internal/wrapFocus'; +import wrapFocus, { wrapFocusWithoutSentinels } from '../../internal/wrapFocus'; import { usePrefix } from '../../internal/usePrefix'; import { keys, match } from '../../internal/keyboard'; +import { useFeatureFlag } from '../FeatureFlags'; export interface ModalBodyProps extends HTMLAttributes { /** Specify the content to be placed in the ModalBody. */ @@ -241,6 +242,9 @@ const ComposedModal = React.forwardRef( const button = useRef(null); const startSentinel = useRef(null); const endSentinel = useRef(null); + const focusTrapWithoutSentinels = useFeatureFlag( + 'enable-experimental-focus-wrap-without-sentinels' + ); // Keep track of modal open/close state // and propagate it to the document.body @@ -258,13 +262,26 @@ const ComposedModal = React.forwardRef( }; }, []); // eslint-disable-line react-hooks/exhaustive-deps - function handleKeyDown(evt: KeyboardEvent) { - evt.stopPropagation(); - if (match(evt, keys.Escape)) { - closeModal(evt); + function handleKeyDown(event) { + event.stopPropagation(); + if (match(event, keys.Escape)) { + closeModal(event); + } + + if ( + focusTrapWithoutSentinels && + open && + match(event, keys.Tab) && + innerModal.current + ) { + wrapFocusWithoutSentinels({ + containerNode: innerModal.current, + currentActiveNode: event.target, + event: event, + }); } - onKeyDown?.(evt); + onKeyDown?.(event); } function handleMousedown(evt: MouseEvent) { evt.stopPropagation(); @@ -273,6 +290,7 @@ const ComposedModal = React.forwardRef( closeModal(evt); } } + function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode, @@ -393,7 +411,7 @@ const ComposedModal = React.forwardRef( role="presentation" ref={ref} aria-hidden={!open} - onBlur={handleBlur} + onBlur={!focusTrapWithoutSentinels ? handleBlur : () => {}} onMouseDown={handleMousedown} onKeyDown={handleKeyDown} className={modalClass}> @@ -404,24 +422,27 @@ const ComposedModal = React.forwardRef( aria-label={ariaLabel ? ariaLabel : generatedAriaLabel} aria-labelledby={ariaLabelledBy}> {/* Non-translatable: Focus-wrap code makes this ` + {!focusTrapWithoutSentinels && ( + + )}
{normalizedSlug} {childrenWithProps}
{/* Non-translatable: Focus-wrap code makes this ` + {!focusTrapWithoutSentinels && ( + + )} ); diff --git a/packages/react/src/components/FeatureFlags/overview.stories.mdx b/packages/react/src/components/FeatureFlags/overview.stories.mdx index 6a39b5f2c11a..93588e21054a 100644 --- a/packages/react/src/components/FeatureFlags/overview.stories.mdx +++ b/packages/react/src/components/FeatureFlags/overview.stories.mdx @@ -30,15 +30,15 @@ components with all feature flags turned on. ## Current feature flags -| Flag | Description | Default | Javascript flag | Sass flag | -| ----------------------------------- | ------------------------------------------------------------------------ | ------- | --------------- | --------- | -| `enable-v11-release` | Flag enabling the v11 features | `true` | ✅ | ✅ | -| `enable-experimental-tile-contrast` | Enable the improved styling for tiles that provides better contrast | `false` | | ✅ | -| `enable-v12-tile-default-icons` | Enable default icons for Tile components | `false` | ✅ | | -| `enable-v12-overflowmenu` | Enable the use of the v12 OverflowMenu leveraging the Menu subcomponents | `false` | ✅ | | -| `enable-v12-tile-radio-icons` | Enable rendering of default icons in the tile components | `false` | ✅ | ✅ | -| `enable-treeview-controllable` | Enable the new TreeView controllable API | `false` | ✅ | | - +| Flag | Description | Default | Javascript flag | Sass flag | +| -------------------------------------------------- | ------------------------------------------------------------------------ | ------- | --------------- | --------- | +| `enable-v11-release` | Flag enabling the v11 features | `true` | ✅ | ✅ | +| `enable-experimental-tile-contrast` | Enable the improved styling for tiles that provides better contrast | `false` | | ✅ | +| `enable-v12-tile-default-icons` | Enable default icons for Tile components | `false` | ✅ | | +| `enable-v12-overflowmenu` | Enable the use of the v12 OverflowMenu leveraging the Menu subcomponents | `false` | ✅ | | +| `enable-v12-tile-radio-icons` | Enable rendering of default icons in the tile components | `false` | ✅ | ✅ | +| `enable-treeview-controllable` | Enable the new TreeView controllable API | `false` | ✅ | | +| `enable-experimental-focus-wrap-without-sentinels` | Enable the new focus wrap behavior that doesn't use sentinel nodes | `false` | ✅ | | ## Turning on feature flags in Javascript/react @@ -55,7 +55,7 @@ import { unstable_FeatureFlags as FeatureFlags } from '@carbon/react'; 'enable-a-second-feature-flag': true, }}> - +; ``` The `FeatureFlag` component can be placed at any point in your react tree and diff --git a/packages/react/src/components/Modal/Modal.featureflag.mdx b/packages/react/src/components/Modal/Modal.featureflag.mdx new file mode 100644 index 000000000000..db6b333252a3 --- /dev/null +++ b/packages/react/src/components/Modal/Modal.featureflag.mdx @@ -0,0 +1,27 @@ +# Modal + +[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/Modal) + |  +[Usage guidelines](https://www.carbondesignsystem.com/components/modal/usage) + |  +[Accessibility](https://www.carbondesignsystem.com/components/modal/accessibility) + +## Experimental focus wrap without sentinels + +`Modal` supports the `enable-experimental-focus-wrap-without-sentinels` feature +flag. This enables a new approach to the focus wrap behavior that modifies the +DOM to no longer include hidden "sentinel nodes" used to mark the beginning and +end of the wrapped focus. The new behavior looks at all interactive child nodes +and wraps focus based on tabbable order of those nodes. The focus direction is +determined whether `tab` is being pressed (forward) or `shift`+`tab` is being +pressed (backwards). In javascript you can enable this feature flag to use the +new focus wrap behavior. + +```js + + + +``` diff --git a/packages/react/src/components/Modal/Modal.featureflag.stories.js b/packages/react/src/components/Modal/Modal.featureflag.stories.js new file mode 100644 index 000000000000..d4bfcc2ad1f1 --- /dev/null +++ b/packages/react/src/components/Modal/Modal.featureflag.stories.js @@ -0,0 +1,587 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { action } from '@storybook/addon-actions'; +import Modal from './Modal'; +import Button from '../Button'; +import Select from '../Select'; +import MultiSelect from '../MultiSelect'; +import Dropdown from '../Dropdown'; +import SelectItem from '../SelectItem'; +import TextInput from '../TextInput'; +import mdx from './Modal.featureflag.mdx'; +import { WithFeatureFlags } from '../../../.storybook/templates/WithFeatureFlags'; +import { + StructuredListWrapper, + StructuredListHead, + StructuredListBody, + StructuredListRow, + StructuredListCell, +} from '../StructuredList'; + +export default { + title: 'Experimental/Feature Flags/Modal', + component: Modal, + parameters: { + docs: { + page: mdx, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + modalHeading="Add a custom domain" + modalLabel="Account resources" + primaryButtonText="Add" + secondaryButtonText="Cancel"> +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + + + (item ? item.text : '')} + /> +
+ + ); +}; + +export const FullWidth = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + isFullWidth + modalHeading="Full Width Modal" + modalLabel="An example of a modal with no padding" + primaryButtonText="Add" + secondaryButtonText="Cancel"> + + + + + Column A + + + Column B + + + Column C + + + + + + Row 1 + Row 1 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. Aenean + posuere sem vel euismod dignissim. Nulla ut cursus dolor. + Pellentesque vulputate nisl a porttitor interdum. + + + + Row 2 + Row 2 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. Aenean + posuere sem vel euismod dignissim. Nulla ut cursus dolor. + Pellentesque vulputate nisl a porttitor interdum. + + + + Row 3 + Row 3 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + dui magna, finibus id tortor sed, aliquet bibendum augue. Aenean + posuere sem vel euismod dignissim. Nulla ut cursus dolor. + Pellentesque vulputate nisl a porttitor interdum. + + + + + + + ); +}; + +export const DangerModal = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + danger + modalHeading="Are you sure you want to delete this custom domain?" + modalLabel="Account resources" + primaryButtonText="Delete" + secondaryButtonText="Cancel" + /> + + ); +}; + +const buttons = { + 'One (1)': '1', + 'Two (2)': '2', + 'Three (3)': '3', +}; +const modalFooter = (numberOfButtons) => { + const secondaryButtons = () => { + switch (numberOfButtons) { + case '1': + return { + secondaryButtons: [], + }; + case '2': + return { + secondaryButtonText: 'Cancel', + }; + case '3': + return { + secondaryButtons: [ + { + buttonText: 'Keep both', + onClick: action('onClick'), + }, + { + buttonText: 'Rename', + onClick: action('onClick'), + }, + ], + }; + default: + return null; + } + }; + return { + ...secondaryButtons(), + }; +}; + +export const WithScrollingContent = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + hasScrollingContent + modalHeading="Add a custom domain" + modalLabel="Account resources" + primaryButtonText="Add" + secondaryButtonText="Cancel"> +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus eu + nibh odio. Nunc a consequat est, id porttitor sapien. Proin vitae leo + vitae orci tincidunt auctor eget eget libero. Ut tincidunt ultricies + fringilla. Aliquam erat volutpat. Aenean arcu odio, elementum vel + vehicula vitae, porttitor ac lorem. Sed viverra elit ac risus + tincidunt fermentum. Ut sollicitudin nibh id risus ornare ornare. + Etiam gravida orci ut lectus dictum, quis ultricies felis mollis. + Mauris nec commodo est, nec faucibus nibh. Nunc commodo ante quis + pretium consectetur. Ut ac nisl vitae mi mattis vulputate a at elit. + Nullam porttitor ex eget mi feugiat mattis. Nunc non sodales magna. + Proin ornare tellus quis hendrerit egestas. Donec pharetra leo nec + molestie sollicitudin.{' '} +

+ +
+ +
+ + (item ? item.text : '')} + /> +
+ + ); +}; + +export const Playground = ({ numberOfButtons, ...args }) => { + const [open, setOpen] = useState(true); + return ( + <> + + { + action(e); + setOpen(false); + }} + modalHeading="Add a custom domain" + primaryButtonText="Add" + secondaryButtonText="Cancel" + aria-label="Modal content" + open={open} + {...args} + {...modalFooter(numberOfButtons)}> +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + + {args.hasScrollingContent && ( + <> +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

Lorem ipsum

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis + molestie tellus. Quisque consectetur non risus eu rutrum.{' '} +

+ + )} +
+ + ); +}; + +Playground.args = { + numberOfButtons: 'Two (2)', +}; + +Playground.argTypes = { + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, + id: { + table: { + disable: true, + }, + }, + modalHeading: { + control: 'text', + }, + modalLabel: { + control: 'text', + }, + numberOfButtons: { + description: 'Count of Footer Buttons', + options: Object.keys(buttons), + mapping: buttons, + control: { + type: 'inline-radio', + labels: Object.keys(buttons), + }, + }, + onKeyDown: { + action: 'onKeyDown', + }, + onRequestSubmit: { + action: 'onRequestSubmit', + }, + onSecondarySubmit: { + action: 'onSecondarySubmit', + table: { + disable: true, + }, + }, + primaryButtonText: { + control: 'text', + }, + secondaryButtons: { + table: { + disable: true, + }, + }, + secondaryButtonText: { + control: 'text', + table: { + disable: true, + }, + }, + selectorPrimaryFocus: { + table: { + disable: true, + }, + }, + selectorsFloatingMenus: { + table: { + disable: true, + }, + }, +}; + +export const WithStateManager = () => { + /** + * Simple state manager for modals. + */ + const ModalStateManager = ({ + renderLauncher: LauncherContent, + children: ModalContent, + }) => { + const [open, setOpen] = useState(false); + return ( + <> + {!ModalContent || typeof document === 'undefined' + ? null + : ReactDOM.createPortal( + , + document.body + )} + {LauncherContent && } + + ); + }; + + const button = useRef(); + + return ( + ( + + )}> + {({ open, setOpen }) => ( + setOpen(false)}> +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ )} +
+ ); +}; + +export const PassiveModal = () => { + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + passiveModal + modalHeading="You have been successfully signed out" + /> + + ); +}; + +export const WithInlineLoading = () => { + const [status, setStatus] = useState('inactive'); + const [description, setDescription] = useState('Deleting...'); + + const fakePromise = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 2000); + }); + }; + + const submit = async () => { + setStatus('active'); + + await fakePromise(); + + setDescription('Deleted!'); + setStatus('finished'); + }; + + const resetStatus = () => { + setStatus('inactive'); + setDescription('Deleting...'); + }; + + const [open, setOpen] = useState(true); + return ( + <> + + setOpen(false)} + danger + modalHeading="Are you sure you want to delete this custom domain?" + modalLabel="Account resources" + primaryButtonText="Delete" + secondaryButtonText="Cancel" + onRequestSubmit={submit} + loadingStatus={status} + loadingDescription={description} + onLoadingSuccess={resetStatus} + /> + + ); +}; diff --git a/packages/react/src/components/Modal/Modal.tsx b/packages/react/src/components/Modal/Modal.tsx index 8496ed0373b3..2ae2148f43a4 100644 --- a/packages/react/src/components/Modal/Modal.tsx +++ b/packages/react/src/components/Modal/Modal.tsx @@ -15,6 +15,7 @@ import ButtonSet from '../ButtonSet'; import InlineLoading from '../InlineLoading'; import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy'; import wrapFocus, { + wrapFocusWithoutSentinels, elementOrParentIsFloatingMenu, } from '../../internal/wrapFocus'; import debounce from 'lodash.debounce'; @@ -27,6 +28,7 @@ import { noopFn } from '../../internal/noopFn'; import { Text } from '../Text'; import { ReactAttr } from '../../types/common'; import { InlineLoadingStatus } from '../InlineLoading/InlineLoading'; +import { useFeatureFlag } from '../FeatureFlags'; const getInstanceId = setupGetInstanceId(); @@ -268,8 +270,10 @@ const Modal = React.forwardRef(function Modal( const primaryButtonClass = classNames({ [`${prefix}--btn--loading`]: loadingStatus !== 'inactive', }); - const loadingActive = loadingStatus !== 'inactive'; + const focusTrapWithoutSentinels = useFeatureFlag( + 'enable-experimental-focus-wrap-without-sentinels' + ); function isCloseButton(element: Element) { return ( @@ -284,6 +288,7 @@ const Modal = React.forwardRef(function Modal( if (match(evt, keys.Escape)) { onRequestClose(evt); } + if ( match(evt, keys.Enter) && shouldSubmitOnEnter && @@ -291,6 +296,18 @@ const Modal = React.forwardRef(function Modal( ) { onRequestSubmit(evt); } + + if ( + focusTrapWithoutSentinels && + match(evt, keys.Tab) && + innerModal.current + ) { + wrapFocusWithoutSentinels({ + containerNode: innerModal.current, + currentActiveNode: evt.target, + event: evt as any, + }); + } } } @@ -568,27 +585,31 @@ const Modal = React.forwardRef(function Modal( {...rest} onKeyDown={handleKeyDown} onMouseDown={handleMousedown} - onBlur={handleBlur} + onBlur={!focusTrapWithoutSentinels ? handleBlur : () => {}} className={modalClasses} role="presentation" ref={ref}> {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} - - Focus sentinel - + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )} {modalBody} {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} - - Focus sentinel - + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )} ); }); diff --git a/packages/react/src/components/Notification/Notification.featureflag.mdx b/packages/react/src/components/Notification/Notification.featureflag.mdx new file mode 100644 index 000000000000..1d9919cf841c --- /dev/null +++ b/packages/react/src/components/Notification/Notification.featureflag.mdx @@ -0,0 +1,28 @@ +# ActionableNotification + +[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/react/src/components/Notification) + |  +[Usage guidelines](https://www.carbondesignsystem.com/components/notification/usage) + |  +[Accessibility](https://www.carbondesignsystem.com/components/notification/accessibility) + +## Experimental focus wrap without sentinels + +`ActionableNotification` supports the +`enable-experimental-focus-wrap-without-sentinels` feature flag. This enables a +new approach to the focus wrap behavior that modifies the DOM to no longer +include hidden "sentinel nodes" used to mark the beginning and end of the +wrapped focus. The new behavior looks at all interactive child nodes and wraps +focus based on tabbable order of those nodes. The focus direction is determined +whether `tab` is being pressed (forward) or `shift`+`tab` is being pressed +(backwards). In javascript you can enable this feature flag to use the new focus +wrap behavior. + +```js + + + +``` diff --git a/packages/react/src/components/Notification/Notification.tsx b/packages/react/src/components/Notification/Notification.tsx index 3057e9dda54e..6cd398d21fd5 100644 --- a/packages/react/src/components/Notification/Notification.tsx +++ b/packages/react/src/components/Notification/Notification.tsx @@ -36,11 +36,12 @@ import { useNoInteractiveChildren, useInteractiveChildrenNeedDescription, } from '../../internal/useNoInteractiveChildren'; -import { keys, matches } from '../../internal/keyboard'; +import { keys, matches, match } from '../../internal/keyboard'; import { usePrefix } from '../../internal/usePrefix'; import { useId } from '../../internal/useId'; import { noopFn } from '../../internal/noopFn'; -import wrapFocus from '../../internal/wrapFocus'; +import wrapFocus, { wrapFocusWithoutSentinels } from '../../internal/wrapFocus'; +import { useFeatureFlag } from '../FeatureFlags'; /** * Conditionally call a callback when the escape key is pressed @@ -952,6 +953,9 @@ export function ActionableNotification({ const startTrap = useRef(null); const endTrap = useRef(null); const ref = useRef(null); + const focusTrapWithoutSentinels = useFeatureFlag( + 'enable-experimental-focus-wrap-without-sentinels' + ); useIsomorphicEffect(() => { if (hasFocus) { @@ -980,6 +984,16 @@ export function ActionableNotification({ } } + function handleKeyDown(event) { + if (isOpen && match(event, keys.Tab) && ref.current) { + wrapFocusWithoutSentinels({ + containerNode: ref.current, + currentActiveNode: event.target, + event, + }); + } + } + const handleClose = (evt: MouseEvent) => { if (!onClose || onClose(evt) !== false) { setIsOpen(false); @@ -1003,14 +1017,17 @@ export function ActionableNotification({ role={role} className={containerClassName} aria-labelledby={title ? id : subtitleId} - onBlur={handleBlur}> - - Focus sentinel - + onBlur={!focusTrapWithoutSentinels ? handleBlur : () => {}} + onKeyDown={focusTrapWithoutSentinels ? handleKeyDown : () => {}}> + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )}
)}
- - Focus sentinel - + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )} ); } diff --git a/packages/react/src/components/Notification/stories/ActionableNotification.featureflag.stories.js b/packages/react/src/components/Notification/stories/ActionableNotification.featureflag.stories.js new file mode 100644 index 000000000000..77e64b88d70f --- /dev/null +++ b/packages/react/src/components/Notification/stories/ActionableNotification.featureflag.stories.js @@ -0,0 +1,102 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { ActionableNotification } from '../../Notification'; +import { action } from '@storybook/addon-actions'; +import mdx from '../Notification.featureflag.mdx'; +import { WithFeatureFlags } from '../../../../.storybook/templates/WithFeatureFlags'; + +// eslint-disable-next-line storybook/csf-component +export default { + title: 'Experimental/Feature Flags/Notifications/Actionable', + component: ActionableNotification, + parameters: { + docs: { + page: mdx, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + kind: 'error', + lowContrast: false, + hideCloseButton: false, + ['aria-label']: 'closes notification', + statusIconDescription: 'notification', + onClose: action('onClose'), + onCloseButtonClick: action('onCloseButtonClick'), + }, +}; + +export const Default = () => ( + +); + +Default.argTypes = { + hasFocus: { + table: { + disable: true, + }, + }, +}; + +export const Playground = (args) => ; + +Playground.argTypes = { + ['aria-label']: { + table: { + disable: true, + }, + }, + ariaLabel: { + table: { + disable: true, + }, + }, + onActionButtonClick: { + action: 'onActionButtonClick', + }, + onClose: { + action: 'onClose', + }, + onCloseButtonClick: { + action: 'onCloseButtonClick', + }, + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, + hasFocus: { + table: { + disable: true, + }, + }, +}; +Playground.args = { + actionButtonLabel: 'Action', + inline: false, + title: 'Notification title', + subtitle: 'Subtitle text goes here', +}; diff --git a/packages/react/src/internal/FloatingMenu.js b/packages/react/src/internal/FloatingMenu.js index dcfbdcc8a0f7..6c160b086dc3 100644 --- a/packages/react/src/internal/FloatingMenu.js +++ b/packages/react/src/internal/FloatingMenu.js @@ -12,8 +12,10 @@ import window from 'window-or-global'; import OptimizedResize from './OptimizedResize'; import { selectorFocusable, selectorTabbable } from './keyboard/navigation'; import { warning } from './warning'; -import wrapFocus from './wrapFocus'; +import wrapFocus, { wrapFocusWithoutSentinels } from './wrapFocus'; +import { match, keys } from '../internal/keyboard'; import { PrefixContext } from './usePrefix'; +import * as FeatureFlags from '@carbon/feature-flags'; /** * The structure for the position of floating menu. @@ -453,30 +455,56 @@ class FloatingMenu extends React.Component { } }; + /** + * Keydown handler for when focus wrap behavior is enabled + * @param {Event} event + */ + handleKeyDown = (event) => { + if (match(event, keys.Tab) && this._menuBody) { + wrapFocusWithoutSentinels({ + containerNode: this._menuBody, + currentActiveNode: event.target, + event, + }); + } + }; + render() { const { context: prefix } = this; + const focusTrapWithoutSentinels = FeatureFlags.enabled( + 'enable-experimental-focus-wrap-without-sentinels' + ); if (typeof document !== 'undefined') { const { focusTrap, target } = this.props; return ReactDOM.createPortal( -
+ //eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{} + } + onKeyDown={focusTrapWithoutSentinels ? this.handleKeyDown : () => {}}> {/* Non-translatable: Focus management code makes this `` not actually read by screen readers */} - - Focus sentinel - + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )} {this._getChildrenWithProps()} {/* Non-translatable: Focus management code makes this `` not actually read by screen readers */} - - Focus sentinel - + {!focusTrapWithoutSentinels && ( + + Focus sentinel + + )}
, !target ? document.body : target() ); diff --git a/packages/react/src/internal/wrapFocus.js b/packages/react/src/internal/wrapFocus.js index fc51ab409fbf..542554123ccf 100644 --- a/packages/react/src/internal/wrapFocus.js +++ b/packages/react/src/internal/wrapFocus.js @@ -6,11 +6,13 @@ */ import findLast from 'lodash.findlast'; +import { useEffect } from 'react'; import { DOCUMENT_POSITION_BROAD_PRECEDING, DOCUMENT_POSITION_BROAD_FOLLOWING, selectorTabbable, } from './keyboard/navigation'; +import { tabbable } from 'tabbable'; /** * @param {Node} node A DOM node. @@ -86,5 +88,58 @@ function wrapFocus({ } } -export { elementOrParentIsFloatingMenu }; +/** + * Ensures the focus is kept in the given `containerNode`, implementing "focus-wrap" behavior. + * Note: This must be called *before* focus moves using onKeyDown or similar. + * @param {object} options The options. + * @param {Node|null} options.containerNode + * @param {EventTarget} options.currentActiveNode The DOM node that has focus. + * @param {KeyboardEvent} options.event The DOM event + */ +function wrapFocusWithoutSentinels({ + containerNode, + currentActiveNode, + event, +}) { + if ( + ['blur', 'focusout', 'focusin', 'focus'].includes(event.type) && + __DEV__ + ) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + throw new Error( + `Error: wrapFocusWithoutSentinels(...) called in unsupported ${event.type} event.\n\nCall wrapFocusWithoutSentinels(...) from onKeyDown instead.` + ); + }); + } + + // The reason we're using tabbable is because it returns the tabbable + // items *in tab order*, whereas using our `selectorTabbable` only + // returns in DOM order + const tabbables = tabbable(containerNode); + const firstTabbable = tabbables[0]; + const lastTabbable = tabbables[tabbables.length - 1]; + + // console.log(`---------------------------------`); + // console.log(containerNode); + // console.log(tabbables); + // console.log(firstTabbable); + // console.log(lastTabbable); + // console.log(currentActiveNode); + + // The shift key is used to determine if focus is moving forwards or backwards + if (currentActiveNode === lastTabbable && !event.shiftKey) { + // Cancel the current movement of focus because we're going to place it ourselves + event.preventDefault(); + firstTabbable.focus(); + } + + if (currentActiveNode === firstTabbable && event.shiftKey) { + // Cancel the current movement of focus because we're going to place it ourselves + event.preventDefault(); + lastTabbable.focus(); + } +} + +export { elementOrParentIsFloatingMenu, wrapFocusWithoutSentinels }; export default wrapFocus; diff --git a/yarn.lock b/yarn.lock index 5628d43063e9..ab0c3f71e4cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2116,6 +2116,7 @@ __metadata: storybook-addon-accessibility-checker: "npm:^3.1.61-rc.2" stream-browserify: "npm:^3.0.0" style-loader: "npm:^3.3.1" + tabbable: "npm:^6.2.0" typescript-config-carbon: "npm:^0.2.0" use-resize-observer: "npm:^6.0.0" webpack: "npm:^5.65.0" @@ -26198,6 +26199,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.2.0": + version: 6.2.0 + resolution: "tabbable@npm:6.2.0" + checksum: 10/980fa73476026e99dcacfc0d6e000d41d42c8e670faf4682496d30c625495e412c4369694f2a15cf1e5252d22de3c396f2b62edbe8d60b5dadc40d09e3f2dde3 + languageName: node + linkType: hard + "table@npm:^6.8.1": version: 6.8.1 resolution: "table@npm:6.8.1"