diff --git a/src/scripts/clipperUI/mainController.tsx b/src/scripts/clipperUI/mainController.tsx index ec8c533a..7f487ce8 100644 --- a/src/scripts/clipperUI/mainController.tsx +++ b/src/scripts/clipperUI/mainController.tsx @@ -104,6 +104,25 @@ export class MainControllerClass extends ComponentBase { this.setState({currentPanel: PanelType.None}); }, onBeforeAnimateIn: () => { this.props.clipperState.reset(); }, onAnimateInExpand: () => { this.setState({currentPanel: this.getPanelTypeToShow()}); }, + onAfterAnimateIn: (el: HTMLElement) => { + // Set focus to the clipper when it opens for accessibility (MAS 2.4.3 - Focus Order) + // Find the first tabbable element and focus on it + let tabbables = el.querySelectorAll("[tabindex]"); + let lowestTabIndexElement: HTMLElement; + if (tabbables.length > 0) { + for (let i = 0; i < tabbables.length; i++) { + let tabbable = tabbables[i] as HTMLElement; + if (!lowestTabIndexElement || tabbable.tabIndex < lowestTabIndexElement.tabIndex) { + if (tabbable.tabIndex >= 0) { + lowestTabIndexElement = tabbable; + } + } + } + if (lowestTabIndexElement) { + lowestTabIndexElement.focus(); + } + } + }, onAfterAnimateOut: () => { Clipper.getInjectCommunicator().callRemoteFunction(Constants.FunctionKeys.hideUi); } }); diff --git a/src/scripts/extensions/clipperInject.ts b/src/scripts/extensions/clipperInject.ts index 8a66cd3d..ed83e3b7 100644 --- a/src/scripts/extensions/clipperInject.ts +++ b/src/scripts/extensions/clipperInject.ts @@ -134,6 +134,20 @@ export class ClipperInject extends FrameInjectBase { } } }; + + // Set focus to the iframe on initial invocation to ensure keyboard users can access it + // immediately, regardless of how the extension was invoked (MAS 2.4.3 - Focus Order) + // Use both requestAnimationFrame and setTimeout to handle different browser behaviors + requestAnimationFrame(() => { + // First, try to move focus from browser chrome to the page content + // This is necessary when invoked via keyboard from extensions list + window.focus(); + + setTimeout(() => { + // Then focus the iframe + this.frame.focus(); + }, 50); + }); } catch (e) { this.handleConstructorError(e); throw e; @@ -185,6 +199,20 @@ export class ClipperInject extends FrameInjectBase { this.frame = StyledFrameFactory.getStyledFrame(Frame.WebClipper); this.frame.id = Constants.Ids.clipperUiFrame; this.frame.src = this.options.frameUrl; + // Set tabindex to make iframe focusable for keyboard accessibility (MAS 2.4.3 - Focus Order) + this.frame.tabIndex = -1; + + // Add onload handler to focus the iframe once content is loaded + // This ensures focus transfer works even when invoked from browser chrome + this.frame.onload = () => { + // First, try to move focus from browser chrome to the page content + window.focus(); + + // Small delay to ensure browser chrome has released focus + setTimeout(() => { + this.frame.focus(); + }, 100); + }; } protected handleConstructorError(e: Error) { @@ -375,6 +403,15 @@ export class ClipperInject extends FrameInjectBase { private toggleClipper() { if (this.frame.style.display === "none") { this.frame.style.display = ""; + // Set focus to the iframe when showing it to ensure focus moves from browser chrome + // This enables keyboard users to access the clipper immediately (MAS 2.4.3 - Focus Order) + // First move focus to page, then to iframe + window.focus(); + + // Use setTimeout to ensure the iframe is fully visible before focusing + setTimeout(() => { + this.frame.focus(); + }, 50); } this.uiCommunicator.callRemoteFunction(Constants.FunctionKeys.toggleClipper); }