Skip to content

Commit

Permalink
feat: enhance focus control for Drawer, Modal, and Tooltip comp…
Browse files Browse the repository at this point in the history
…onents (#748)

* feat: add `closeOnPointerDown` and `openOnFocus` props to Tooltip

* feat: add `returnFocusOnClose` prop to Modal

* feat: add `returnFocusOnClose` prop to Drawer

* docs: rename the deprecated `isInvalid` prop to `error`

* docs: add the `returnFocusOnClose` option to the examples of Drawer and Modal

* feat: add `aria-label="Close"` attribute to the close button for accessibility

* feat: add `aria-modal="true"` attribute to the modal content

* test: improve Modal component test coverage

* test: improve Drawer component test coverage

* docs: update description for the focus control props (autoFocus, ensureFocus, initialFocusRef, finalFocusRef)

* test: improve Menu component test coverage

* test: improve Tooltip component test coverage
  • Loading branch information
cheton authored Apr 29, 2023
1 parent a54cfa8 commit 4c8ee2c
Show file tree
Hide file tree
Showing 23 changed files with 1,223 additions and 106 deletions.
2 changes: 1 addition & 1 deletion packages/react-docs/components/InputTag/InputTag.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const InputTag = forwardRef((props, ref) => {
key={id}
mr="2x"
title={value}
isInvalid={!!error}
error={!!error}
onChange={handleTagChange(id)}
onClose={handleTagClose(id)}
>
Expand Down
30 changes: 23 additions & 7 deletions packages/react-docs/pages/components/drawer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function Example() {
const [closeOnOutsideClick, toggleCloseOnOutsideClick] = useToggle(true);
const [ensureFocus, toggleEnsureFocus] = useToggle(true);
const [isClosable, toggleIsClosable] = useToggle(true);
const [returnFocusOnClose, toggleReturnFocusOnClose] = useToggle(true);
const [isOverlayVisible, toggleIsOverlayVisible] = useToggle(true);
const [isHeaderVisible, toggleIsHeaderVisible] = useToggle(true);
const [isBodyVisible, toggleIsBodyVisible] = useToggle(true);
Expand All @@ -98,9 +99,11 @@ function Example() {
return (
<>
<Box>
<Button onClick={() => toggleDrawer(true)}>
Launch drawer
</Button>
<Tooltip label="Click to launch drawer" openOnFocus={false}>
<Button onClick={() => toggleDrawer(true)}>
Launch drawer
</Button>
</Tooltip>
</Box>
<Divider my="4x" />
<Box mb="4x">
Expand Down Expand Up @@ -232,6 +235,17 @@ function Example() {
<Text fontFamily="mono" whiteSpace="nowrap">isClosable</Text>
</TextLabel>
</FormGroup>
<FormGroup>
<TextLabel display="flex" alignItems="center">
<Checkbox
checked={returnFocusOnClose}
disabled={!ensureFocus}
onChange={() => toggleReturnFocusOnClose()}
/>
<Space width="2x" />
<Text fontFamily="mono" whiteSpace="nowrap">returnFocusOnClose</Text>
</TextLabel>
</FormGroup>
<Divider my="4x" />
<Box mb="4x">
<Text fontSize="lg" lineHeight="lg">
Expand Down Expand Up @@ -312,6 +326,7 @@ function Example() {
isOpen={isOpen}
onClose={() => toggleDrawer(false)}
placement={placement}
returnFocusOnClose={returnFocusOnClose}
size={size}
>
{enableBodyScrollLock && (
Expand Down Expand Up @@ -445,17 +460,18 @@ Besides the default functionality of the `DrawerCloseButton`, you can also pass

| Name | Type | Default | Description |
| :--- | :--- | :------ | :---------- |
| autoFocus | boolean | false | If `true` and `ensureFocus` is `true` and `initialFocusRef` is not set, it will automatically set focus on the first focusable element. |
| autoFocus | boolean | false | If `true`, the drawer will automatically set focus on the first focusable element inside the drawer when it is opened.<br />⚠️ This only works if `initialFocusRef` is not defined and `ensureFocus` is set to `true`. |
| backdrop | boolean | false | If `true`, it will wrap components with a backdrop to provide a click area for dismissing when clicking outside the drawer. |
| children | ReactNode \| `(context) => ReactNode` | | A function child can be used intead of a React element. This function is called with the context object. |
| closeOnEsc | boolean | false | If `true`, close the drawer when the `esc` key is pressed. |
| closeOnOutsideClick | boolean | false | If `true`, close the drawer when click outside of the drawer. Note that this value will not have any effect when `backdrop` is set to `true`. |
| ensureFocus | boolean | false | If `true`, it will always bring the focus back to the `Drawer` descendants, which does not allow the focus to escape while open. |
| finalFocusRef | RefObject | | The `ref` of element to receive focus when the drawer closes. |
| initialFocusRef | RefObject | | The `ref` of the element to receive focus when the drawer opens. |
| ensureFocus | boolean | false | If `true`, it ensures that the user's focus remains within the drawer when it is open, preventing them from interacting with elements outside the drawer. |
| finalFocusRef | RefObject | | The `ref` of the element that should receive focus when the drawer closes.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| initialFocusRef | RefObject | | The `ref` of the element that should receive focus when the drawer opens.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| isClosable | boolean | false | If `true`, a close button will appear on the right side. |
| isOpen | boolean | false | If `true`, the drawer is shown. |
| onClose | function | | Callback fired when the drawer closes. |
| returnFocusOnClose | boolean | true | If `true`, the focus will be restored to the element that was focused on when the drawer was initially opened.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| placement | string | 'right' | Change the placement of the drawer. One of: 'left', 'right', 'top', 'bottom' |
| size | string | 'auto' | Change the size of the drawer. One of: 'auto', 'sm', 'md', 'lg', 'full' |

Expand Down
32 changes: 24 additions & 8 deletions packages/react-docs/pages/components/modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ function Example() {
const [closeOnOutsideClick, toggleCloseOnOutsideClick] = useToggle(true);
const [ensureFocus, toggleEnsureFocus] = useToggle(true);
const [isClosable, toggleIsCloseButtonVisible] = useToggle(true);
const [returnFocusOnClose, toggleReturnFocusOnClose] = useToggle(true);
const [isOverlayVisible, toggleIsOverlayVisible] = useToggle(true);
const [isHeaderVisible, toggleIsHeaderVisible] = useToggle(true);
const [isBodyVisible, toggleIsBodyVisible] = useToggle(true);
Expand All @@ -172,9 +173,11 @@ function Example() {
return (
<>
<Box>
<Button onClick={() => toggleModal(true)}>
Launch modal
</Button>
<Tooltip label="Click to launch modal" openOnFocus={false}>
<Button onClick={() => toggleModal(true)}>
Launch modal
</Button>
</Tooltip>
</Box>
<Divider my="4x" />
<Box mb="4x">
Expand Down Expand Up @@ -306,6 +309,17 @@ function Example() {
<Text fontFamily="mono" whiteSpace="nowrap">isClosable</Text>
</TextLabel>
</FormGroup>
<FormGroup>
<TextLabel display="flex" alignItems="center">
<Checkbox
checked={returnFocusOnClose}
disabled={!ensureFocus}
onChange={() => toggleReturnFocusOnClose()}
/>
<Space width="2x" />
<Text fontFamily="mono" whiteSpace="nowrap">returnFocusOnClose</Text>
</TextLabel>
</FormGroup>
<Divider my="4x" />
<Box mb="4x">
<Text fontSize="lg" lineHeight="lg">
Expand Down Expand Up @@ -444,13 +458,14 @@ function Example() {
<Modal
TransitionComponent={null}
autoFocus={autoFocus}
ensureFocus={ensureFocus}
closeOnEsc={closeOnEsc}
closeOnOutsideClick={closeOnOutsideClick}
ensureFocus={ensureFocus}
initialFocusRef={initialFocusRef}
isClosable={isClosable}
isOpen={isOpen}
onClose={() => toggleModal(false)}
returnFocusOnClose={returnFocusOnClose}
scrollBehavior={scrollBehavior}
size={size}
{...modalStyleProps}
Expand Down Expand Up @@ -669,16 +684,17 @@ function Example() {

| Name | Type | Default | Description |
| :--- | :--- | :------ | :---------- |
| autoFocus | boolean | false | If `true` and `ensureFocus` is `true` and `initialFocusRef` is not set, it will automatically set focus on the first focusable element. |
| autoFocus | boolean | false | If `true`, the modal will automatically set focus on the first focusable element inside the modal when it is opened.<br />⚠️ This only works if `initialFocusRef` is not defined and `ensureFocus` is set to `true`. |
| children | ReactNode \| `(context) => ReactNode` | | A function child can be used intead of a React element. This function is called with the context object. |
| closeOnEsc | boolean | false | If `true`, close the modal when the `esc` key is pressed. |
| closeOnOutsideClick | boolean | false | If `true`, close the modal when click outside of the modal. |
| ensureFocus | boolean | false | If `true`, it will always bring the focus back to the `Modal` descendants, which does not allow the focus to escape while open. |
| finalFocusRef | RefObject | | The `ref` of element to receive focus when the modal closes. |
| initialFocusRef | RefObject | | The `ref` of the element to receive focus when the modal opens. |
| ensureFocus | boolean | false | If `true`, it ensures that the user's focus remains within the modal when it is open, preventing them from interacting with elements outside the modal. |
| finalFocusRef | RefObject | | The `ref` of the element that should receive focus when the modal closes.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| initialFocusRef | RefObject | | The `ref` of the element that should receive focus when the modal opens.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| isClosable | boolean | false | If `true`, a close button will appear on the right side. |
| isOpen | boolean | false | If `true`, the modal is shown. |
| onClose | function | | Callback fired when the modal closes. |
| returnFocusOnClose | boolean | true | If `true`, the focus will be restored to the element that was focused on when the modal was initially opened.<br />⚠️ This only works if `ensureFocus` is set to `true`. |
| size | string | 'auto' | Change the size of the modal. One of: 'auto', 'xs', 'sm', 'md', 'lg', 'xl', 'full' |
| scrollBehavior | string | 'inside' | Control the scroll behavior of the modal if the content overflows. One of: 'inside', 'outside' |

Expand Down
7 changes: 4 additions & 3 deletions packages/react-docs/pages/components/tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,9 @@ To mitigate this issue, you can pass `PopperProps={{ usePortal: true }}` to `Too
| TransitionProps.appear | boolean | true | |
| arrow | boolean | true | If `true`, adds an arrow to the tooltip. |
| children | ReactNode \| `(context) => ReactNode` | | |
| closeOnClick | boolean | true | If `true`, close the tooltip on click. |
| closeOnEsc | boolean | true | If `true`, close the tooltip when pressing the escape key. |
| closeOnMouseDown | boolean | false | If `true`, close the tooltip while the mouse is down. |
| closeOnClick | boolean | true | If `true`, the tooltip will close upon clicking. |
| closeOnEsc | boolean | true | If `true`, the tooltip will close upon pressing the escape key. |
| closeOnPointerDown | boolean | true | If `true`, the tooltip will close while the pointer is pressed down. |
| defaultIsOpen | boolean | false | Whether the tooltip will be open by default. |
| disabled | boolean | | If `true`, the tooltip will not display. |
| enterDelay | number | 100 | The delay in milliseconds before the tooltip appears. |
Expand All @@ -418,5 +418,6 @@ To mitigate this issue, you can pass `PopperProps={{ usePortal: true }}` to `Too
| offset | [skidding, distance] | [0, 8] | The skidding and distance of the tooltip. |
| onClose| function | | Callback fired when the tooltip is closed. |
| onOpen | function | | Callback fired when the tooltip is opened. |
| openOnFocus | boolean | true | If `true`, the tooltip will open upon receiving focus. |
| placement | PopperJS.Placement | 'bottom' | Position the tooltip relative to the trigger element as well as surrounding elements. One of: 'top', 'bottom', 'right', 'left', 'top-start', 'top-end', 'bottom-start', 'bottom-end', 'right-start', 'right-end', 'left-start', 'left-end' |
| shouldWrapChildren | boolean | false | If `true`, the tooltip will be wrapped in a `Box` component. Otherwise, you have to ensure tooltip has only one child node. |
1 change: 1 addition & 0 deletions packages/react/src/alert/AlertCloseButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const AlertCloseButton = forwardRef((

return (
<ButtonBase
aria-label="Close"
ref={ref}
onClick={callEventHandlers(onClickProp, onClose)}
{...styleProps}
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/drawer/Drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const Drawer = forwardRef((
onClose,
placement = defaultPlacement,
portalProps,
returnFocusOnClose = true,
size = defaultSize,
...rest
},
Expand Down Expand Up @@ -69,7 +70,7 @@ const Drawer = forwardRef((
scrollBehavior: 'inside', // internal use only (only 'inside' is supported by Drawer)
});

const returnFocus = !finalFocusRef;
const returnFocus = returnFocusOnClose && !finalFocusRef;
const onFocusLockActivation = useCallback(() => {
if (initialFocusRef && initialFocusRef.current) {
const el = initialFocusRef.current;
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/drawer/DrawerCloseButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const DrawerCloseButton = forwardRef((

return (
<ButtonBase
aria-label="Close"
ref={ref}
onClick={callEventHandlers(onClickProp, onClose)}
{...styleProps}
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/drawer/DrawerContent.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMergeRefs } from '@tonic-ui/react-hooks';
import { callAll } from '@tonic-ui/utils';
import { ariaAttr, callAll } from '@tonic-ui/utils';
import React, { forwardRef } from 'react';
import { Slide } from '../transitions';
import { useAnimatePresence } from '../utils/animate-presence';
Expand Down Expand Up @@ -33,6 +33,7 @@ const DrawerContent = forwardRef((
const tabIndex = -1;
const styleProps = useDrawerContentStyle({ placement, size, tabIndex });
const contentProps = {
'aria-modal': ariaAttr(true),
ref: combinedRef,
role: 'dialog',
tabIndex,
Expand Down
Loading

0 comments on commit 4c8ee2c

Please sign in to comment.