From 3205383613e470499593babf597f0a5a6b7b621d Mon Sep 17 00:00:00 2001 From: Matt Gallo Date: Tue, 4 Jun 2024 09:26:44 -0400 Subject: [PATCH] fix(CreateTearsheet): add focus trap behavior (#5329) --- cspell.json | 1 + .../WebTerminal/WebTerminal-test.avt.e2e.js | 4 +- .../CreateTearsheet/CreateTearsheetStep.tsx | 114 ++++++++++++------ 3 files changed, 79 insertions(+), 40 deletions(-) diff --git a/cspell.json b/cspell.json index 02db2646db..77792354c8 100644 --- a/cspell.json +++ b/cspell.json @@ -100,6 +100,7 @@ "exportmodal", "expressivecard", "fieldsets", + "focusable", "fullpageerror", "gridcell", "guidebanner", diff --git a/e2e/components/WebTerminal/WebTerminal-test.avt.e2e.js b/e2e/components/WebTerminal/WebTerminal-test.avt.e2e.js index 775bdb746b..f1cee6ccf5 100644 --- a/e2e/components/WebTerminal/WebTerminal-test.avt.e2e.js +++ b/e2e/components/WebTerminal/WebTerminal-test.avt.e2e.js @@ -22,9 +22,7 @@ test.describe('WebTerminal @avt', () => { }); await page.getByLabel('Web terminal').click(); - const modalElement = page.locator( - `.${pkg.prefix}--web-terminal` - ); + const modalElement = page.locator(`.${pkg.prefix}--web-terminal`); await modalElement.evaluate((element) => Promise.all( element.getAnimations().map((animation) => animation.finished) diff --git a/packages/ibm-products/src/components/CreateTearsheet/CreateTearsheetStep.tsx b/packages/ibm-products/src/components/CreateTearsheet/CreateTearsheetStep.tsx index 5d86b8c132..a54d445b15 100644 --- a/packages/ibm-products/src/components/CreateTearsheet/CreateTearsheetStep.tsx +++ b/packages/ibm-products/src/components/CreateTearsheet/CreateTearsheetStep.tsx @@ -12,6 +12,9 @@ import React, { useState, isValidElement, PropsWithChildren, + useRef, + MutableRefObject, + RefObject, } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; @@ -162,6 +165,9 @@ export let CreateTearsheetStep = forwardRef( }: CreateTearsheetStepProps, ref ) => { + const localRef = useRef(null); + const stepRef = ref || localRef; + const stepRefValue = (stepRef as MutableRefObject).current; const stepsContext = useContext(StepsContext); const stepNumber = useContext(StepNumberContext); const [shouldIncludeStep, setShouldIncludeStep] = @@ -196,15 +202,48 @@ export let CreateTearsheetStep = forwardRef( setShouldIncludeStep(includeStep); }, [includeStep, stepsContext, title]); + const setFocusChildrenTabIndex = ( + childInputs: NodeListOf, + value: number + ) => { + if (childInputs?.length) { + childInputs.forEach((child) => { + (child as HTMLElement).tabIndex = value; + }); + } + }; + // Whenever we are the current step, supply our disableSubmit and onNext values to the // steps container context so that it can manage the 'Next' button appropriately. useEffect(() => { + const focusElementQuery = `button, input, select, textarea, a`; + if (stepNumber !== stepsContext?.currentStep) { + // Specify tab-index -1 for focusable elements not contained + // in the current step so that the useFocus hook can exclude + // from the focus trap + const childInputs = stepRefValue?.querySelectorAll(focusElementQuery); + setFocusChildrenTabIndex(childInputs, -1); + } if (stepNumber === stepsContext?.currentStep) { + // Specify tab-index 0 for current step focusable elements + // for the useFocus hook to know which elements to include + // in focus trap + const childInputs = stepRefValue?.querySelectorAll(focusElementQuery); + setFocusChildrenTabIndex(childInputs, 0); + stepsContext.setIsDisabled(!!disableSubmit); stepsContext?.setOnNext(onNext); // needs to be updated here otherwise there could be stale state values from only initially setting onNext stepsContext?.setOnPrevious(onPrevious); } - }, [stepsContext, stepNumber, disableSubmit, onNext, onPrevious]); + }, [ + stepsContext, + stepNumber, + disableSubmit, + onNext, + onPrevious, + stepRef, + stepRefValue, + ]); const renderDescription = () => { if (description) { @@ -221,42 +260,43 @@ export let CreateTearsheetStep = forwardRef( }; return stepsContext ? ( - - -

{title}

- - {subtitle && ( -
{subtitle}
- )} - - {renderDescription()} -
- - - {hasFieldset ? ( - - {children} - - ) : ( - children - )} - -
+
}> + + +

{title}

+ + {subtitle && ( +
{subtitle}
+ )} + + {renderDescription()} +
+ + + {hasFieldset ? ( + + {children} + + ) : ( + children + )} + +
+
) : ( pconsole.warn( `You have tried using a ${componentName} component outside of a CreateTearsheet. This is not allowed. ${componentName}s should always be children of the CreateTearsheet`