Skip to content

Commit

Permalink
fix: Return focus to launcher button (carbon-design-system#5017)
Browse files Browse the repository at this point in the history
* fix(Tearsheet): implement focus return property 'launcherButtonRef'

* fix(sidePanel): implement return focus property

* refactor(sidePanel): change in property type

* chore(tearsheetShell): ts-ignore

* chore: implement sidepanel and tearsheet stories

* test(SidePanel): implement test for launch button focus return

* test(tearsheet): implement test for focus return
  • Loading branch information
makafsal committed May 23, 2024
1 parent e9441f5 commit 659126a
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,40 @@ const SlideInTemplate = ({ actions, slug, ...args }) => {
);
};

const ReturnFocusToOpenButtonTemplate = ({
minimalContent,
actions,
slug,
...args
}) => {
const [open, setOpen] = useState(false);
const testRef = useRef();
const buttonRef = useRef();

return (
<>
<Button
ref={buttonRef}
onClick={() => setOpen(!open)}
className={`${prefix}toggle`}
>
{open ? 'Close side panel' : 'Open side panel'}
</Button>
<SidePanel
{...args}
open={open}
onRequestClose={() => setOpen(false)}
actions={actionSets[actions]}
ref={testRef}
slug={slug && sampleSlug}
launcherButtonRef={buttonRef}
>
{!minimalContent && <ChildrenContent />}
</SidePanel>
</>
);
};

export const SlideOver = SlideOverTemplate.bind({});
SlideOver.args = {
includeOverlay: true,
Expand Down Expand Up @@ -550,6 +584,11 @@ SpecifyElementToHaveInitialFocus.args = {
...defaultStoryProps,
};

export const ReturnFocusToOpenButton = ReturnFocusToOpenButtonTemplate.bind({});
ReturnFocusToOpenButton.args = {
...defaultStoryProps,
};

export const WithStaticTitle = SlideOverTemplate.bind({});
WithStaticTitle.args = {
...defaultStoryProps,
Expand Down
42 changes: 41 additions & 1 deletion packages/ibm-products/src/components/SidePanel/SidePanel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event';
import { expectMultipleError } from '../../global/js/utils/test-helper';

import React from 'react';
import { TextInput } from '@carbon/react';
import { Button, TextInput } from '@carbon/react';
import { pkg } from '../../settings';
import uuidv4 from '../../global/js/utils/uuidv4';
import { SidePanel } from '.';
Expand Down Expand Up @@ -475,4 +475,44 @@ describe('SidePanel', () => {
const style = getComputedStyle(pageContent);
expect(style.marginInlineStart).toBe('0');
});

it('should return focus back to launcher button', async () => {
const mockCloseFn = jest.fn();

const DummyComponent = ({ open }) => {
const buttonRef = React.useRef();

return (
<div>
<Button ref={buttonRef}>Open</Button>
<SlideIn
animateTitle={false}
placement="right"
open={open}
actionToolbarButtons={[]}
launcherButtonRef={buttonRef}
onRequestClose={mockCloseFn}
/>
</div>
);
};

const { container, getByText, rerender } = render(
<DummyComponent open={true} />
);

const launchButtonEl = getByText('Open');
expect(launchButtonEl).toBeInTheDocument();

const closeIconButton = container.querySelector(
`.${blockClass}__close-button`
);
await act(() => userEvent.click(closeIconButton));
expect(mockCloseFn).toHaveBeenCalledTimes(1);

rerender(<DummyComponent open={false} />);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(launchButtonEl).toHaveFocus();
});
});
22 changes: 22 additions & 0 deletions packages/ibm-products/src/components/SidePanel/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import React, {
ReactNode,
ForwardedRef,
MutableRefObject,
RefObject,
} from 'react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';

Expand Down Expand Up @@ -105,6 +106,11 @@ type SidePanelBaseProps = {
*/
labelText?: string;

/**
* Provide a ref to return focus to once the side panel is closed.
*/
launcherButtonRef?: RefObject<any>;

/**
* Sets the icon description for the navigation back icon button
*/
Expand Down Expand Up @@ -252,6 +258,7 @@ export let SidePanel = React.forwardRef(
slug,
subtitle,
title,
launcherButtonRef,

// Collect any other property values passed in.
...rest
Expand All @@ -275,6 +282,7 @@ export let SidePanel = React.forwardRef(
const { firstElement, keyDownListener } = useFocus(sidePanelRef);
const panelRefValue = (sidePanelRef as MutableRefObject<HTMLDivElement>)
.current;
const previousOpen = usePreviousValue(open);

const shouldReduceMotion = useReducedMotion();

Expand Down Expand Up @@ -379,6 +387,14 @@ export let SidePanel = React.forwardRef(
}
}, [labelText, title]);

useEffect(() => {
if (previousOpen && !open && launcherButtonRef) {
setTimeout(() => {
launcherButtonRef?.current?.focus();
}, 0);
}
}, [launcherButtonRef, open, previousOpen]);

const checkSetDoAnimateTitle = () => {
let canDoAnimateTitle = false;
if (
Expand Down Expand Up @@ -966,6 +982,12 @@ SidePanel.propTypes = {
*/
labelText: PropTypes.string,

/**
* Provide a ref to return focus to once the modal is closed.
*/
/**@ts-ignore */
launcherButtonRef: PropTypes.any,

/**
* Sets the icon description for the navigation back icon button
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,50 @@ const Template = ({ actions, slug, ...args }) => {
);
};

const ReturnFocusTemplate = ({ actions, slug, ...args }) => {
const [open, setOpen] = useState(false);
const buttonRef = useRef();

const wiredActions =
actions &&
Array.prototype.map.call(actions, (action) => {
if (action.label === 'Cancel') {
const previousClick = action.onClick;
return {
...action,
onClick: (evt) => {
setOpen(false);
previousClick(evt);
},
};
}
return action;
});

const ref = useRef();

return (
<>
<style>{`.${pkg.prefix}--tearsheet { opacity: 0 }`};</style>
<Button ref={buttonRef} onClick={() => setOpen(true)}>
Open Tearsheet
</Button>
<div ref={ref}>
<Tearsheet
{...args}
actions={wiredActions}
open={open}
onClose={() => setOpen(false)}
slug={slug && sampleSlug}
launcherButtonRef={buttonRef}
>
{mainContent}
</Tearsheet>
</div>
</>
);
};

// eslint-disable-next-line react/prop-types
const StackedTemplate = ({ mixedSizes, actions, slug, ...args }) => {
const [open1, setOpen1] = useState(false);
Expand Down Expand Up @@ -429,6 +473,15 @@ withInfluencer.args = {
actions: 7,
};

export const ReturnFocusToOpenButton = ReturnFocusTemplate.bind({});
ReturnFocusToOpenButton.args = {
closeIconDescription,
description,
onClose: action('onClose called'),
title,
actions: 7,
};

export const fullyLoaded = Template.bind({});
fullyLoaded.storyName = 'Tearsheet with all header items and influencer';
fullyLoaded.args = {
Expand Down
69 changes: 68 additions & 1 deletion packages/ibm-products/src/components/Tearsheet/Tearsheet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ import {
import uuidv4 from '../../global/js/utils/uuidv4';
import { pkg, carbon } from '../../settings';

import { Button, ButtonSet, Tab, Tabs, TabList } from '@carbon/react';
import {
Button,
ButtonSet,
Tab,
Tabs,
TabList,
TextInput,
} from '@carbon/react';
import { Tearsheet, TearsheetNarrow } from '.';
import { CreateTearsheetNarrow } from '../CreateTearsheetNarrow';

Expand Down Expand Up @@ -254,6 +261,66 @@ const commonTests = (Ts, name, props, testActions) => {
expect(tearsheet).toHaveClass('is-visible');
expect(onCloseReturnsFalse).toHaveBeenCalledTimes(1);
});

it('should return focus to the launcher button', async () => {
const mainText = 'Main content 1';
const inputId = 'stacked-input-1';

// eslint-disable-next-line react/prop-types
const DummyComponent = ({ open }) => {
const buttonRef = React.useRef();

return (
<>
<Button ref={buttonRef}>Open</Button>
<Ts
{...{ ...props, closeIconDescription }}
{...{
open: open,
}}
hasCloseIcon={true}
onClose={onCloseReturnsTrue}
open={open}
selectorPrimaryFocus={`#${inputId}`}
launcherButtonRef={buttonRef}
>
<div className="tearsheet-stories__dummy-content-block">
{mainText}
<TextInput
id={inputId}
data-testid={inputId}
labelText="Enter an important value here"
/>
</div>
</Ts>
</>
);
};

const { rerender, getByText, getByTestId } = render(
<DummyComponent open={true} />
);

const mainContentEl = getByText(mainText);
const inputEl = getByTestId(inputId);
const closeButton = screen.getByRole('button', {
name: closeIconDescription,
});
const launchButtonEl = getByText('Open');

expect(launchButtonEl).toBeInTheDocument();
expect(mainContentEl).toBeInTheDocument();
expect(closeButton).toBeInTheDocument();
expect(inputEl).toHaveFocus();

await act(() => userEvent.click(closeButton));
expect(onCloseReturnsTrue).toHaveBeenCalledTimes(1);

rerender(<DummyComponent open={false} />);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(launchButtonEl).toHaveFocus();
});
}

it('is visible when open is true', async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/ibm-products/src/components/Tearsheet/Tearsheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@ Tearsheet.propTypes = {
*/
label: PropTypes.node,

/**
* Provide a ref to return focus to once the tearsheet is closed.
*/
launcherButtonRef: PropTypes.any,

/**
* Navigation content, such as a set of tabs, to be displayed at the bottom
* of the header area of the tearsheet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,43 @@ const Template = ({ influencer, open: _open, slug, ...args }, context) => {
);
};

const ReturnFocusTemplate = (
{ influencer, open: _open, slug, ...args },
context
) => {
const ref = useRef();
const [open, setOpen] = useState(context.viewMode !== 'docs' && _open);
const [beenOpen, setBeenOpen] = useState(false);
useEffect(() => setBeenOpen(beenOpen || open), [open, beenOpen]);
const buttonRef = useRef();

return (
<div ref={ref}>
<Button ref={buttonRef} onClick={() => setOpen(true)}>
{beenOpen ? 'Reopen the' : 'Open the'} context.component.componentName
</Button>{' '}
<TearsheetShell
className={className}
{...args}
influencer={
influencer && (
<div className="tearsheet-stories__dummy-content-block">
Influencer
</div>
)
}
open={open}
onClose={() => setOpen(false)}
slug={slug && sampleSlug}
title={'Tearsheet title'}
launcherButtonRef={buttonRef}
>
{dummyContent}
</TearsheetShell>
</div>
);
};

// Stories
export const AllAttributesSet = Template.bind({});
AllAttributesSet.args = {
Expand All @@ -140,3 +177,8 @@ export const NoAttributesSet = Template.bind({});
NoAttributesSet.args = {
size: 'wide',
};

export const ReturnFocusToOpenButton = ReturnFocusTemplate.bind({});
NoAttributesSet.args = {
size: 'wide',
};
Loading

0 comments on commit 659126a

Please sign in to comment.