{closeButtonToRender}
diff --git a/src/scripts/clipperUI/panels/regionSelectingPanel.tsx b/src/scripts/clipperUI/panels/regionSelectingPanel.tsx index 25fe30fd..b5c5dee9 100644 --- a/src/scripts/clipperUI/panels/regionSelectingPanel.tsx +++ b/src/scripts/clipperUI/panels/regionSelectingPanel.tsx @@ -4,6 +4,10 @@ import {ClipperStateProp} from "../clipperState"; import {ComponentBase} from "../componentBase"; class RegionSelectingPanelClass extends ComponentBase<{}, ClipperStateProp> { + initiallySetFocus(element: HTMLElement) { + element.focus(); + } + handleCancelButton() { this.props.clipperState.setState({ focusOnRender: Constants.Ids.regionButton @@ -28,6 +32,7 @@ class RegionSelectingPanelClass extends ComponentBase<{}, ClipperStateProp> {
{Localization.getLocalizedString("WebClipper.Action.BackToHome")} diff --git a/src/scripts/clipperUI/panels/successPanel.tsx b/src/scripts/clipperUI/panels/successPanel.tsx index be79716f..ba08ed2a 100644 --- a/src/scripts/clipperUI/panels/successPanel.tsx +++ b/src/scripts/clipperUI/panels/successPanel.tsx @@ -14,6 +14,10 @@ import {Clipper} from "../frontEndGlobals"; import {SpriteAnimation} from "../components/spriteAnimation"; class SuccessPanelClass extends ComponentBase<{ }, ClipperStateProp> { + initiallySetFocus(element: HTMLElement) { + element.focus(); + } + public onLaunchOneNoteButton() { Clipper.logger.logUserFunnel(Log.Funnel.Label.ViewInWac); let data = this.props.clipperState.oneNoteApiResult.data as OneNoteApi.Page; @@ -38,6 +42,7 @@ class SuccessPanelClass extends ComponentBase<{ }, ClipperStateProp> {
{Localization.getLocalizedString("WebClipper.Action.ViewInOneNote")} diff --git a/src/scripts/extensions/chrome/manifest.json b/src/scripts/extensions/chrome/manifest.json index 64a48fe0..c57ba943 100644 --- a/src/scripts/extensions/chrome/manifest.json +++ b/src/scripts/extensions/chrome/manifest.json @@ -61,7 +61,7 @@ "256": "icons/icon-256.png" }, "action": { - "default_title": "Clip to OneNote", + "default_title": "OneNote Web Clipper", "default_icon": { "19": "icons/icon-19.png", "38": "icons/icon-38.png" diff --git a/src/scripts/extensions/clipperInject.ts b/src/scripts/extensions/clipperInject.ts index 8a66cd3d..3a5a8799 100644 --- a/src/scripts/extensions/clipperInject.ts +++ b/src/scripts/extensions/clipperInject.ts @@ -84,6 +84,12 @@ export class ClipperInject extends FrameInjectBase { this.updateUiSizeAttributes(); this.overrideTransformStyles(document.documentElement); + // Hide page body from assistive technologies (Voice Access) while clipper is open + if (document.body) { + document.body.setAttribute("aria-hidden", "true"); + document.body.setAttribute("inert", ""); + } + this.logger = new CommunicatorLoggerPure(this.uiCommunicator); this.updatePageInfo(); @@ -316,6 +322,10 @@ export class ClipperInject extends FrameInjectBase { this.uiCommunicator.registerFunction(Constants.FunctionKeys.hideUi, () => { this.frame.style.display = "none"; + if (document.body) { + document.body.removeAttribute("aria-hidden"); + document.body.removeAttribute("inert"); + } }); this.uiCommunicator.registerFunction(Constants.FunctionKeys.refreshPage, () => { @@ -375,6 +385,10 @@ export class ClipperInject extends FrameInjectBase { private toggleClipper() { if (this.frame.style.display === "none") { this.frame.style.display = ""; + if (document.body) { + document.body.setAttribute("aria-hidden", "true"); + document.body.setAttribute("inert", ""); + } } this.uiCommunicator.callRemoteFunction(Constants.FunctionKeys.toggleClipper); } diff --git a/src/scripts/extensions/edge/manifest.json b/src/scripts/extensions/edge/manifest.json index 96123579..0cb140d1 100644 --- a/src/scripts/extensions/edge/manifest.json +++ b/src/scripts/extensions/edge/manifest.json @@ -60,7 +60,7 @@ }, "action": { - "default_title": "Clip to OneNote", + "default_title": "OneNote Web Clipper", "default_icon": { "19": "icons/icon-19.png", "38": "icons/icon-38.png" diff --git a/src/scripts/extensions/firefox/manifest.json b/src/scripts/extensions/firefox/manifest.json index 227859f3..34da500b 100644 --- a/src/scripts/extensions/firefox/manifest.json +++ b/src/scripts/extensions/firefox/manifest.json @@ -40,7 +40,7 @@ "content_security_policy": "script-src 'self'; object-src 'self'", "browser_action": { - "default_title": "Clip to OneNote", + "default_title": "OneNote Web Clipper", "default_icon": { "19": "icons/icon-19.png", "38": "icons/icon-38.png" diff --git a/src/styles/clipper.less b/src/styles/clipper.less index bbc2bb74..f7be6942 100644 --- a/src/styles/clipper.less +++ b/src/styles/clipper.less @@ -267,7 +267,15 @@ forced-color-adjust: none; filter: drop-shadow(0 0 1px CanvasText) drop-shadow(0 0 1px CanvasText); } - + + // Ensure selected state of font radio buttons is visible in high contrast themes (Aquatic, Desert, etc.) + .control-button.active { + forced-color-adjust: none; + background-color: Highlight !important; + color: HighlightText !important; + border: 2px solid HighlightText !important; + } + } @media screen and (-ms-high-contrast: black-on-white) { diff --git a/src/tests/clipperUI/components/sectionPicker_tests.tsx b/src/tests/clipperUI/components/sectionPicker_tests.tsx index 00a67e55..bb3f40e3 100644 --- a/src/tests/clipperUI/components/sectionPicker_tests.tsx +++ b/src/tests/clipperUI/components/sectionPicker_tests.tsx @@ -272,6 +272,243 @@ export class SectionPickerTests extends TestModule { let actual = SectionPickerClass.formatSectionInfoForStorage([]); strictEqual(actual, undefined, "The section info should be formatted correctly"); }); + + test("onPopupToggle should focus the currently selected section element when the popup opens and a curSection is set", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + let mockNotebooks = MockProps.getMockNotebooks(); + let mockSection = { + section: mockNotebooks[0].sections[0], + path: "Clipper Test > Full Page", + parentId: mockNotebooks[0].id + }; + initializeClipperStorage(JSON.stringify(mockNotebooks), JSON.stringify(mockSection)); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Create a fake section element in the DOM that matches the selected section id + let sectionElement = document.createElement("li"); + sectionElement.id = mockSection.section.id; + sectionElement.tabIndex = 70; + let focusCalled = false; + sectionElement.focus = () => { focusCalled = true; }; + document.body.appendChild(sectionElement); + + controllerInstance.onPopupToggle(true); + clock.tick(0); + + ok(focusCalled, "The selected section element should have been focused when the popup opens"); + + document.body.removeChild(sectionElement); + clock.restore(); + done(); + }); + + test("onPopupToggle should focus the first focusable item in the picker popup when the popup opens and no curSection is set", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + initializeClipperStorage(undefined, undefined); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Create a fake popup container and a focusable item inside it + let sectionPickerPopup = document.createElement("div"); + sectionPickerPopup.id = "sectionPickerContainer"; + let firstItem = document.createElement("li"); + firstItem.tabIndex = 70; + let focusCalled = false; + firstItem.focus = () => { focusCalled = true; }; + sectionPickerPopup.appendChild(firstItem); + document.body.appendChild(sectionPickerPopup); + + controllerInstance.onPopupToggle(true); + clock.tick(0); + + ok(focusCalled, "The first focusable item in the picker popup should have been focused when no section is selected"); + + document.body.removeChild(sectionPickerPopup); + clock.restore(); + done(); + }); + + test("onPopupToggle should not change focus when the popup closes", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + let mockNotebooks = MockProps.getMockNotebooks(); + let mockSection = { + section: mockNotebooks[0].sections[0], + path: "Clipper Test > Full Page", + parentId: mockNotebooks[0].id + }; + initializeClipperStorage(JSON.stringify(mockNotebooks), JSON.stringify(mockSection)); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Create a fake section element to catch any unexpected focus calls + let sectionElement = document.createElement("li"); + sectionElement.id = mockSection.section.id; + sectionElement.tabIndex = 70; + let focusCalled = false; + sectionElement.focus = () => { focusCalled = true; }; + document.body.appendChild(sectionElement); + + controllerInstance.onPopupToggle(false); + clock.tick(0); + + ok(!focusCalled, "No focus change should occur when the popup closes"); + + document.body.removeChild(sectionElement); + clock.restore(); + done(); + }); + + test("onPopupToggle should enable Down arrow key to move focus to the next item in the popup", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + initializeClipperStorage(undefined, undefined); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Create a fake popup with two items + let sectionPickerPopup = document.createElement("div"); + sectionPickerPopup.id = "sectionPickerContainer"; + let firstItem = document.createElement("li"); + firstItem.tabIndex = 70; + let secondItemFocusCalled = false; + let secondItem = document.createElement("li"); + secondItem.tabIndex = 70; + secondItem.focus = () => { secondItemFocusCalled = true; }; + sectionPickerPopup.appendChild(firstItem); + sectionPickerPopup.appendChild(secondItem); + document.body.appendChild(sectionPickerPopup); + + controllerInstance.onPopupToggle(true); + clock.tick(0); + + // Simulate focus on first item and press Down arrow + firstItem.focus(); + let downKeyEvent = document.createEvent("KeyboardEvent"); + downKeyEvent.initEvent("keydown", true, true); + Object.defineProperty(downKeyEvent, "which", { value: 40 }); + sectionPickerPopup.dispatchEvent(downKeyEvent); + + ok(secondItemFocusCalled, "Down arrow key should move focus to the next item in the popup"); + + document.body.removeChild(sectionPickerPopup); + clock.restore(); + done(); + }); + + test("onPopupToggle should enable Up arrow key to move focus to the previous item in the popup", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + initializeClipperStorage(undefined, undefined); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Create a fake popup with two items + let sectionPickerPopup = document.createElement("div"); + sectionPickerPopup.id = "sectionPickerContainer"; + let firstItemFocusCalled = false; + let firstItem = document.createElement("li"); + firstItem.tabIndex = 70; + firstItem.focus = () => { firstItemFocusCalled = true; }; + let secondItem = document.createElement("li"); + secondItem.tabIndex = 70; + sectionPickerPopup.appendChild(firstItem); + sectionPickerPopup.appendChild(secondItem); + document.body.appendChild(sectionPickerPopup); + + controllerInstance.onPopupToggle(true); + clock.tick(0); + + // Simulate focus on second item and press Up arrow + secondItem.focus(); + let upKeyEvent = document.createEvent("KeyboardEvent"); + upKeyEvent.initEvent("keydown", true, true); + Object.defineProperty(upKeyEvent, "which", { value: 38 }); + sectionPickerPopup.dispatchEvent(upKeyEvent); + + ok(firstItemFocusCalled, "Up arrow key should move focus to the previous item in the popup"); + + document.body.removeChild(sectionPickerPopup); + clock.restore(); + done(); + }); + + test("onPopupToggle should skip hidden items inside closed notebooks when navigating with Down arrow", (assert: QUnitAssert) => { + let done = assert.async(); + let clock = sinon.useFakeTimers(); + + let clipperState = MockProps.getMockClipperState(); + initializeClipperStorage(undefined, undefined); + + let component = {}} clipperState={clipperState} />; + let controllerInstance = MithrilUtils.mountToFixture(component); + + // Build a structure that mirrors the OneNotePicker DOM: + // sectionPickerContainer + // li.Notebook.Closed (notebook1, tabindex) + // ul + // li.Section (hiddenSection, tabindex) -- inside closed notebook + // li.Notebook.Opened (notebook2, tabindex) + let sectionPickerPopup = document.createElement("div"); + sectionPickerPopup.id = "sectionPickerContainer"; + + let notebook1 = document.createElement("li"); + notebook1.className = "Notebook Closed"; + notebook1.tabIndex = 70; + let closedChildList = document.createElement("ul"); + let hiddenSection = document.createElement("li"); + hiddenSection.className = "Section"; + hiddenSection.tabIndex = 70; + let hiddenSectionFocusCalled = false; + hiddenSection.focus = () => { hiddenSectionFocusCalled = true; }; + closedChildList.appendChild(hiddenSection); + notebook1.appendChild(closedChildList); + + let notebook2FocusCalled = false; + let notebook2 = document.createElement("li"); + notebook2.className = "Notebook Opened"; + notebook2.tabIndex = 70; + notebook2.focus = () => { notebook2FocusCalled = true; }; + + sectionPickerPopup.appendChild(notebook1); + sectionPickerPopup.appendChild(notebook2); + document.body.appendChild(sectionPickerPopup); + + controllerInstance.onPopupToggle(true); + clock.tick(0); + + // Press Down arrow from notebook1 — should jump to notebook2, skipping the hidden section inside notebook1 + notebook1.focus(); + let downKeyEvent = document.createEvent("KeyboardEvent"); + downKeyEvent.initEvent("keydown", true, true); + Object.defineProperty(downKeyEvent, "which", { value: 40 }); + sectionPickerPopup.dispatchEvent(downKeyEvent); + + ok(!hiddenSectionFocusCalled, "Hidden section inside a closed notebook should not receive focus"); + ok(notebook2FocusCalled, "Down arrow from a closed notebook should move focus to the next visible notebook"); + + document.body.removeChild(sectionPickerPopup); + clock.restore(); + done(); + }); } } diff --git a/src/tests/clipperUI/mainController_tests.tsx b/src/tests/clipperUI/mainController_tests.tsx index 3ba143f1..45d02d0c 100644 --- a/src/tests/clipperUI/mainController_tests.tsx +++ b/src/tests/clipperUI/mainController_tests.tsx @@ -44,7 +44,8 @@ export class MainControllerTests extends TestModule { onSignInInvoked={this.mockMainControllerProps.onSignInInvoked} onSignOutInvoked={this.mockMainControllerProps.onSignOutInvoked} updateFrameHeight={this.mockMainControllerProps.updateFrameHeight} - onStartClip={this.mockMainControllerProps.onStartClip}/>; + onStartClip={this.mockMainControllerProps.onStartClip} + clearKeepAlive={this.mockMainControllerProps.clearKeepAlive}/>; } protected tests() { @@ -149,6 +150,50 @@ export class MainControllerTests extends TestModule { Assert.equalTabIndexes(dialogButtons); }); + test("On the region instructions panel, focus traps between cancel button and close button on Tab", () => { + let controllerInstance = MithrilUtils.mountToFixture(this.defaultComponent); + + MithrilUtils.simulateAction(() => { + controllerInstance.state.currentPanel = PanelType.RegionInstructions; + }); + + let cancelButton = document.getElementById(Constants.Ids.regionClipCancelButton); + let closeButton = document.getElementById(Constants.Ids.closeButton); + + // Focus on cancel button and tab - should move to close button + cancelButton.focus(); + let tabEvent = new KeyboardEvent("keydown", { keyCode: Constants.KeyCodes.tab, bubbles: true } as any); + document.dispatchEvent(tabEvent); + strictEqual(document.activeElement, closeButton, "Tab from cancel button should focus close button"); + + // Tab again - should wrap to cancel button + tabEvent = new KeyboardEvent("keydown", { keyCode: Constants.KeyCodes.tab, bubbles: true } as any); + document.dispatchEvent(tabEvent); + strictEqual(document.activeElement, cancelButton, "Tab from close button should wrap to cancel button"); + }); + + test("On the success panel, focus traps between launch button and close button on Tab", () => { + let controllerInstance = MithrilUtils.mountToFixture(this.defaultComponent); + + MithrilUtils.simulateAction(() => { + controllerInstance.state.currentPanel = PanelType.ClippingSuccess; + }); + + let launchButton = document.getElementById(Constants.Ids.launchOneNoteButton); + let closeButton = document.getElementById(Constants.Ids.closeButton); + + // Focus on launch button and tab - should move to close button + launchButton.focus(); + let tabEvent = new KeyboardEvent("keydown", { keyCode: Constants.KeyCodes.tab, bubbles: true } as any); + document.dispatchEvent(tabEvent); + strictEqual(document.activeElement, closeButton, "Tab from launch button should focus close button"); + + // Tab again - should wrap to launch button + tabEvent = new KeyboardEvent("keydown", { keyCode: Constants.KeyCodes.tab, bubbles: true } as any); + document.dispatchEvent(tabEvent); + strictEqual(document.activeElement, launchButton, "Tab from close button should wrap to launch button"); + }); + test("On the clip failure panel, the right message is displayed for a particular API error code", () => { let controllerInstance = MithrilUtils.mountToFixture(this.defaultComponent); @@ -467,6 +512,17 @@ export class MainControllerTests extends TestModule { "The close button should render when the clipper is not clipping to OneNote API"); } }); + + test("The main controller should have role='dialog' and aria-modal='true' for accessibility", () => { + MithrilUtils.mountToFixture(this.defaultComponent); + + let mainController = document.getElementById(Constants.Ids.mainController); + ok(mainController, "The main controller element should exist"); + strictEqual(mainController.getAttribute("role"), "dialog", + "The main controller should have role='dialog' to prevent Voice Access from numbering background controls"); + strictEqual(mainController.getAttribute("aria-modal"), "true", + "The main controller should have aria-modal='true' to mark it as a modal dialog"); + }); } private getMockRequestError(): OneNoteApi.RequestError {