diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc552b61e..2136675490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixes [#4295](https://github.com/microsoft/BotFramework-WebChat/issues/4295). Screen reader should not read suggested actions twice when message arrive in live region, by [@compulim](https://github.com/compulim), in PR [#4323](https://github.com/microsoft/BotFramework-WebChat/issues/4323) - Fixes [#4325](https://github.com/microsoft/BotFramework-WebChat/issues/4325). `aria-keyshortcuts` should use modifier keys according to `KeyboardEvent` key values spec, by [@compulim](https://github.com/compulim), in PR [#4323](https://github.com/microsoft/BotFramework-WebChat/issues/4323) - Fixes [#4327](https://github.com/microsoft/BotFramework-WebChat/issues/4327). In Adaptive Cards, `TextBlock` with `style="heading"` should have `aria-level` set, by [@compulim](https://github.com/compulim), in PR [#4329](https://github.com/microsoft/BotFramework-WebChat/issues/4329) +- Fixes [#3949](https://github.com/microsoft/BotFramework-WebChat/issues/3949). For accessibility reasons, buttons in Adaptive Cards should be `role="button"` instead of `role="menubar"`/`role="menuitem"`, by [@compulim](https://github.com/compulim), in PR [#4263](https://github.com/microsoft/BotFramework-WebChat/issues/4263) ## Changes diff --git a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-1-snap.png deleted file mode 100644 index 21f4d5279d..0000000000 Binary files a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-1-snap.png and /dev/null differ diff --git a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-2-snap.png b/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-2-snap.png deleted file mode 100644 index ab80b073aa..0000000000 Binary files a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-2-snap.png and /dev/null differ diff --git a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-3-snap.png b/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-3-snap.png deleted file mode 100644 index 613d00a959..0000000000 Binary files a/__tests__/__image_snapshots__/chrome-docker/adaptive-cards-js-disable-card-inputs-3-snap.png and /dev/null differ diff --git a/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-1-snap.png new file mode 100644 index 0000000000..057441db1d Binary files /dev/null and b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-2-snap.png b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-2-snap.png new file mode 100644 index 0000000000..4d63a1a427 Binary files /dev/null and b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-focus-with-show-card-js-accessibility-requirement-disabling-adaptive-card-with-action-show-card-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-1-snap.png new file mode 100644 index 0000000000..d934f4936f Binary files /dev/null and b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-2-snap.png b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-2-snap.png new file mode 100644 index 0000000000..09670600aa Binary files /dev/null and b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-3-snap.png b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-3-snap.png new file mode 100644 index 0000000000..8cae3ceb11 Binary files /dev/null and b/__tests__/__image_snapshots__/html/accessibility-adaptive-card-disabled-inputs-js-accessibility-requirement-disabling-adaptive-card-with-inputs-field-3-snap.png differ diff --git a/__tests__/__image_snapshots__/html/focus-management-disable-adaptive-card-manual-js-focus-management-focus-should-not-move-after-adaptive-card-is-disable-after-manually-disabled-3-snap.png b/__tests__/__image_snapshots__/html/focus-management-disable-adaptive-card-manual-js-focus-management-focus-should-not-move-after-adaptive-card-is-disable-after-manually-disabled-3-snap.png index e061e5ffdc..92a97747b6 100644 Binary files a/__tests__/__image_snapshots__/html/focus-management-disable-adaptive-card-manual-js-focus-management-focus-should-not-move-after-adaptive-card-is-disable-after-manually-disabled-3-snap.png and b/__tests__/__image_snapshots__/html/focus-management-disable-adaptive-card-manual-js-focus-management-focus-should-not-move-after-adaptive-card-is-disable-after-manually-disabled-3-snap.png differ diff --git a/__tests__/adaptiveCards.js b/__tests__/adaptiveCards.js index 9397d3145f..9d6ce55744 100644 --- a/__tests__/adaptiveCards.js +++ b/__tests__/adaptiveCards.js @@ -97,69 +97,6 @@ test('breakfast card with custom style options', async () => { expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); }); -test('disable card inputs', async () => { - const { driver, pageObjects } = await setupWebDriver(); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('card inputs', { waitForSend: true }); - - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await driver.wait(allImagesLoaded(), timeouts.fetchImage); - await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); - - await driver.executeScript(() => { - document.querySelector('.ac-adaptiveCard input[type="checkbox"]').checked = true; - document.querySelector('.ac-adaptiveCard input[type="date"]').value = '2019-11-01'; - document.querySelector('.ac-adaptiveCard input[type="radio"]').checked = true; - document.querySelector('.ac-adaptiveCard input[type="text"]').value = 'William'; - document.querySelector('.ac-adaptiveCard input[type="time"]').value = '12:34'; - document.querySelector('.ac-adaptiveCard input[type="number"]').value = '1'; - document.querySelector('.ac-adaptiveCard select').value = '1'; - document.querySelector('.ac-adaptiveCard textarea').value = 'One Redmond Way, Redmond, WA'; - }); - - expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ disabled: true }); - await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); - - // Click "Submit" button should have no effect - await driver.executeScript(() => { - document.querySelector('.ac-actionSet button:nth-of-type(2)').click(); - }); - - //@todo change to use scrollStabilizer after release - await new Promise(resolve => setTimeout(resolve, 1000)); - - expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ disabled: false }); - - // Wait until render after update props - await driver.wait( - () => - driver.executeScript(() => { - const button = document.querySelector('.ac-actionSet button:nth-of-type(2)'); - - return button && !button.disabled; - }), - timeouts.ui - ); - - // Click "Submit" button should send values to the bot - await driver.executeScript(() => { - document.querySelector('.ac-actionSet button:nth-of-type(2)').click(); - }); - - //@todo change to use scrollStabilizer after release - await new Promise(resolve => setTimeout(resolve, 1000)); - - await driver.wait(minNumActivitiesShown(3), timeouts.directLine); - await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); - - expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); -}); - test('broken card of invalid version', async () => { const { driver, pageObjects } = await setupWebDriver(); diff --git a/__tests__/html/accessibility.adaptiveCard.ariaPushed.html b/__tests__/html/accessibility.adaptiveCard.ariaPushed.html index da7337b4f7..5f5938b142 100644 --- a/__tests__/html/accessibility.adaptiveCard.ariaPushed.html +++ b/__tests__/html/accessibility.adaptiveCard.ariaPushed.html @@ -34,26 +34,6 @@ return false; } - /** Checks if the page is conform to a subset of WCAG. */ - // TODO: We should use axe-core to validate WAI-ARIA conformity. - function expectWCAGConformity() { - // EXPECT: Conform to WAI-ARIA, all "menubar" should have 1 or more descendants of "menuitem". - [...document.querySelectorAll('[role="menubar"]')].forEach(menuBar => - expect(menuBar.querySelectorAll('[role="menuitem"]').length).toBeTruthy() - ); - - // EXPECT: Conform to WAI-ARIA, all "menuitem" should be a descendant of "menu" or "menubar". - [...document.querySelectorAll('[role="menuitem"]')].forEach(menuItem => { - expect( - hasAncestor(menuItem, ancestor => { - const ancestorRole = ancestor.getAttribute('role'); - - return ancestorRole === 'menu' || ancestorRole === 'menubar'; - }) - ).toBeTruthy(); - }); - } - run(async function () { const directLine = await testHelpers.createDirectLineWithTranscript([ { @@ -127,34 +107,20 @@ await pageConditions.numActivitiesShown(1); await pageConditions.scrollToBottomCompleted(); - // GIVEN: There should be 2 set of `ac-actionSet` containers. - expect(document.querySelectorAll('.ac-actionSet')).toHaveLength(2); + // SETUP: Focus on the send box. + await pageObjects.focusSendBoxTextBox(); - // GIVEN: All `ac-actionSet` should have `[role="menubar"]`. - Array.from(document.querySelectorAll('.ac-actionSet')).every(actionSet => - expect(actionSet.getAttribute('role')).toBe('menubar') - ); + // SETUP: There should be 2 set of `ac-actionSet` containers. + expect(document.querySelectorAll('.ac-actionSet')).toHaveLength(2); - // GIVEN: All 'ac-pushButton' should not have "aria-pressed". + // SETUP: All 'ac-pushButton' should not have `aria-pressed="true"`. Array.from(document.querySelectorAll('.ac-pushButton')).every(pushButton => - expect(pushButton.hasAttribute('aria-pressed')).toBe(false) + expect(pushButton.hasAttribute('aria-pressed')).not.toBe('true') ); - // GIVEN: The page should conform to WCAG. - expectWCAGConformity(); - // WHEN: Clicking on the card action button ("Submit card"). - await host.click( - Array.from(document.getElementsByClassName('ac-pushButton')).find( - pushButton => pushButton.innerText === 'Submit card' - ) - ); - - // THEN: The first `ac-actionSet` should have `[role="menubar"]` untouched. - expect(document.querySelectorAll('.ac-actionSet')[0].getAttribute('role')).toBe('menubar'); - - // THEN: The second `ac-actionSet` should have `[role="menubar"]` removed. - expect(document.querySelectorAll('.ac-actionSet')[1].hasAttribute('role')).toBe(false); + await host.sendShiftTab(3); + await host.sendKeys('ENTER', 'TAB', 'TAB', 'ENTER'); // THEN: Selected `ac-pushButton` should have `aria-pressed` set to `true`. Array.from(document.querySelectorAll('.ac-pushButton')) @@ -171,20 +137,11 @@ // THEN: Non-selection should not have `aria-pressed` set. Array.from(document.querySelectorAll('.ac-pushButton')) .filter(pushButton => pushButton.innerText !== 'Submit card') - .forEach(pushButton => expect(pushButton.hasAttribute('aria-pressed')).toBeFalsy()); - - // THEN: The page should conform to WCAG. - expectWCAGConformity(); + .forEach(pushButton => expect(pushButton.getAttribute('aria-pressed')).not.toBe('true')); // WHEN: Click on the first action set button ("Yes") - await host.click( - Array.from(document.getElementsByClassName('ac-pushButton')).find( - pushButton => pushButton.innerText === 'Yes' - ) - ); - - // THEN: The first `ac-actionSet` should have `[role="menubar"]` removed. - expect(document.querySelectorAll('.ac-actionSet')[0].hasAttribute('role')).toBe(false); + await host.sendShiftTab(2); + await host.sendKeys('ENTER'); // THEN: Selected `ac-pushButton` should have `aria-pressed` set to `true`. Array.from(document.querySelectorAll('.ac-pushButton')) @@ -197,9 +154,6 @@ pushButton => pushButton.getAttribute('aria-pressed') === 'true' ) ).toHaveLength(2); - - // THEN: The page should conform to WCAG. - expectWCAGConformity(); }); diff --git a/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.html b/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.html new file mode 100644 index 0000000000..86050b030a --- /dev/null +++ b/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.html @@ -0,0 +1,198 @@ + + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.js b/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.js new file mode 100644 index 0000000000..a04b129436 --- /dev/null +++ b/__tests__/html/accessibility.adaptiveCard.disabled.focus.withShowCard.js @@ -0,0 +1,6 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility requirement', () => { + test('disabling Adaptive Card with "Action.ShowCard"', () => + runHTML('accessibility.adaptiveCard.disabled.focus.withShowCard.html')); +}); diff --git a/__tests__/html/accessibility.adaptiveCard.disabled.inputs.html b/__tests__/html/accessibility.adaptiveCard.disabled.inputs.html new file mode 100644 index 0000000000..39148d4921 --- /dev/null +++ b/__tests__/html/accessibility.adaptiveCard.disabled.inputs.html @@ -0,0 +1,170 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.adaptiveCard.disabled.inputs.js b/__tests__/html/accessibility.adaptiveCard.disabled.inputs.js new file mode 100644 index 0000000000..765898914b --- /dev/null +++ b/__tests__/html/accessibility.adaptiveCard.disabled.inputs.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility requirement', () => { + test('disabling Adaptive Card with inputs field', () => runHTML('accessibility.adaptiveCard.disabled.inputs.html')); +}); diff --git a/__tests__/html/focusManagement.disableHeroCard.obsolete.html b/__tests__/html/focusManagement.disableHeroCard.obsolete.html index 2b858bb928..f5813a3cfb 100644 --- a/__tests__/html/focusManagement.disableHeroCard.obsolete.html +++ b/__tests__/html/focusManagement.disableHeroCard.obsolete.html @@ -25,24 +25,27 @@ WebChat.renderWebChat( { - attachmentMiddleware: () => next => (...args) => { - const [{ activity, attachment }] = args; - const { activities } = store.getState(); - const messageActivities = activities.filter(activity => activity.type === 'message'); - const mostRecent = messageActivities.pop() === activity; - - if (attachment.contentType === 'application/vnd.microsoft.card.hero') { - return ( - - ); - } - - return next(...args); - }, + attachmentMiddleware: + () => + next => + (...args) => { + const [{ activity, attachment }] = args; + const { activities } = store.getState(); + const messageActivities = activities.filter(activity => activity.type === 'message'); + const mostRecent = messageActivities.pop() === activity; + + if (attachment.contentType === 'application/vnd.microsoft.card.hero') { + return ( + + ); + } + + return next(...args); + }, directLine, store }, @@ -72,7 +75,7 @@ await pageConditions.scrollToBottomCompleted(); // THEN: The second button should be disabled. - expect(secondButton.hasAttribute('disabled')).toBe(true); + expect(secondButton.getAttribute('aria-disabled')).toBe('true'); // THEN: The second button should be grayed out. await host.snapshot(); diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/closest.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/closest.ts new file mode 100644 index 0000000000..23cde3a513 --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/closest.ts @@ -0,0 +1,17 @@ +// Ponyfill `HTMLElement.closest`. +// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest +export default function closest(element: HTMLElement, selector: string): HTMLElement | undefined { + if (typeof element.closest === 'function') { + return element.closest(selector); + } + + let current: HTMLElement | null = element; + + while (current) { + if (current.matches(selector)) { + return current; + } + + current = current.parentElement; + } +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/findDOMNodeOwner.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/findDOMNodeOwner.ts new file mode 100644 index 0000000000..3981618034 --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/findDOMNodeOwner.ts @@ -0,0 +1,25 @@ +import type { AdaptiveCard, CardObject, ShowCardAction } from 'adaptivecards'; + +// TODO: [P2] Remove this when Adaptive Card fixed their bug #7606. +// https://github.com/microsoft/AdaptiveCards/issues/7606 +// Currently, their findDOMNodeOwner() returns bad result when passing an Action attached to the card. +export default function findDOMNodeOwner(adaptiveCard: AdaptiveCard, element: HTMLElement): CardObject | undefined { + for (let count = adaptiveCard.getActionCount(), index = 0; index < count; index++) { + const action = adaptiveCard.getActionAt(index); + + if (action.renderedElement === element) { + return action; + } + + if (action.getJsonTypeName() === 'Action.ShowCard') { + const { card } = action as ShowCardAction; + const cardObject = card && findDOMNodeOwner(card, element); + + if (cardObject) { + return cardObject; + } + } + } + + return adaptiveCard.findDOMNodeOwner(element); +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useAdaptiveCardModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useAdaptiveCardModEffect.ts new file mode 100644 index 0000000000..1997e5026f --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useAdaptiveCardModEffect.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import useLazyRef from './useLazyRef'; +import useValueRef from './useValueRef'; + +import type { AdaptiveCard } from 'adaptivecards'; + +type ModFunction = ( + adaptiveCard: AdaptiveCard, + cardElement: HTMLElement, + ...args: TArgs +) => () => void; + +class Mod { + constructor(mod: ModFunction) { + this.#mod = mod; + } + + // @ts-ignore We are using Babel to transpile and it will transpile private modifier. + #mod: ModFunction; + // @ts-ignore We are using Babel to transpile and it will transpile private modifier. + #undo: (() => void) | undefined; + + apply(adaptiveCard: AdaptiveCard | undefined, cardElement: HTMLElement | undefined, ...args: TArgs) { + this.#undo?.(); + this.#undo = adaptiveCard && cardElement && this.#mod(adaptiveCard, cardElement, ...args); + } + + undo() { + this.#undo?.(); + this.#undo = undefined; + } +} + +/** + * Creates a mod effect for Adaptive Card. + * + * When this hook is executed, it will return two functions for applying and undo the mod. + * It will also monitor the DOM tree and undo-then-reapply if mutation occurred. + * + * The first function must be called right after DOM is mounted. The second function must be called right before re-render. + * + * @return {[function, function]} Two functions, the first one to apply the mod, the second one to undo the mod. + */ +export default function useAdaptiveCardModEffect( + modder: (adaptiveCard: AdaptiveCard, cardElement: HTMLElement, ...args: TArgs) => () => void, + adaptiveCard: AdaptiveCard +): readonly [(cardElement: HTMLElement) => void, () => void] { + const adaptiveCardRef = useValueRef(adaptiveCard); + const mod = useMemo(() => new Mod(modder), [modder]); + const reapplyRef = useRef<() => void>(); + + const observerRef = useLazyRef( + () => + new MutationObserver(() => { + reapplyRef.current?.(); + }) + ); + + useEffect( + () => () => { + observerRef.current.disconnect(); + }, + [observerRef] + ); + + const handleApply = useCallback( + (cardElement: HTMLElement, ...args: TArgs) => { + if (adaptiveCardRef.current && cardElement) { + // Apply the mod immediately, then assign the function to reapply() so we can call later when mutation happens. + (reapplyRef.current = () => mod.apply(adaptiveCardRef.current, cardElement, ...args))(); + } + + const { current: observer } = observerRef; + + observer.disconnect(); + observer.observe(cardElement, { childList: true, subtree: true }); + }, + [adaptiveCardRef, observerRef, mod] + ); + + const handleUndo = useCallback(() => { + mod.undo(); + + // If we have undo-ed the mod, calling reapply() through MutationObserver should be no-op. + reapplyRef.current = undefined; + }, [mod, reapplyRef]); + + return useMemo( + () => Object.freeze([handleApply, handleUndo]) as readonly [(cardElement: HTMLElement) => void, () => void], + [handleApply, handleUndo] + ); +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useLazyRef.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useLazyRef.ts new file mode 100644 index 0000000000..cf296cc03d --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useLazyRef.ts @@ -0,0 +1,15 @@ +import { useRef } from 'react'; + +import type { MutableRefObject } from 'react'; + +const UNINITIALIZED = Symbol(); + +export default function useLazyRef(refInit: () => T): MutableRefObject { + const ref = useRef(UNINITIALIZED); + + if (ref.current === UNINITIALIZED) { + ref.current = refInit(); + } + + return ref as MutableRefObject; +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/usePrevious.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/usePrevious.ts new file mode 100644 index 0000000000..5939e99473 --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/usePrevious.ts @@ -0,0 +1,12 @@ +// TODO: [P0] #4133 Don't copy. +import { useEffect, useRef } from 'react'; + +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useValueRef.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useValueRef.ts new file mode 100644 index 0000000000..b22542ce1f --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/private/useValueRef.ts @@ -0,0 +1,11 @@ +import { useRef } from 'react'; + +import type { RefObject } from 'react'; + +export default function useValueRef(value: T): RefObject { + const ref = useRef(value); + + ref.current = value; + + return ref; +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionSetShouldNotBeMenuBarModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionSetShouldNotBeMenuBarModEffect.ts new file mode 100644 index 0000000000..21d742721a --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionSetShouldNotBeMenuBarModEffect.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; + +import bunchUndos from '../../DOMManipulationWithUndo/bunchUndos'; +import setOrRemoveAttributeIfFalseWithUndo from '../../DOMManipulationWithUndo/setOrRemoveAttributeIfFalseWithUndo'; +import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect'; + +import type { AdaptiveCard } from 'adaptivecards'; + +/** + * Accessibility: ActionSet should not be menu bar. + * + * Menu bar is not accessible through screen reader keyboard shortcut keys: + * + * - B will jump to next button, which the end-user can quickly the chosen action; + * - F will jump to next form field, which is very similar but also jump to text fields; + * - There are no keyboard shortcut keys for menu. + * + * Marking action button as menu item in a menu bar hurts accessibility. End-user will not be able to jump to buttons quickly. + * + * Thus, ActionSet should not be menu bar. + */ +export default function useActionShouldBePushButtonModEffect(adaptiveCard: AdaptiveCard) { + const modder = useMemo( + () => (_: AdaptiveCard, cardElement: HTMLElement) => { + const actionSetElements = Array.from( + cardElement.querySelectorAll('.ac-actionSet[role="menubar"]') as NodeListOf + ); + + const undoStack = actionSetElements.map(actionSetElement => + setOrRemoveAttributeIfFalseWithUndo(actionSetElement, 'role', false) + ); + + return () => bunchUndos(undoStack)(); + }, + [] + ); + + return useAdaptiveCardModEffect(modder, adaptiveCard); +} diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts new file mode 100644 index 0000000000..f9284a9b6c --- /dev/null +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useActionShouldBePushButtonModEffect.ts @@ -0,0 +1,105 @@ +import { useMemo, useRef } from 'react'; + +import addEventListenerWithUndo from '../../DOMManipulationWithUndo/addEventListenerWithUndo'; +import bunchUndos from '../../DOMManipulationWithUndo/bunchUndos'; +import closest from './private/closest'; +import durableAddClassWithUndo from '../../DOMManipulationWithUndo/durableAddClassWithUndo'; +import findDOMNodeOwner from './private/findDOMNodeOwner'; +import setOrRemoveAttributeIfFalseWithUndo from '../../DOMManipulationWithUndo/setOrRemoveAttributeIfFalseWithUndo'; +import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect'; +import usePrevious from './private/usePrevious'; + +import type { AdaptiveCard, CardObject } from 'adaptivecards'; +import type { UndoFunction } from '../../DOMManipulationWithUndo/types/UndoFunction'; + +/** + * Accessibility: Action in ActionSet/CardElement should be push button. + * + * Pressing the action button is a decision-making process. The decision made by the end-user need to be read by the screen reader. + * Thus, we need to indicate what decision the end-user made. + * + * Since action buttons are button, the intuitive way to indicate selection of a button is marking it as pressed. + * + * One exception is the `Action.ShowUrl` action. This button represents expand/collapse header of an accordion. + * Thus, their state is indicated by `aria-expanded`, instead of `aria-pressed`. + * However, we still need to remove other unnecessary ARIA fields. + */ +export default function useActionShouldBePushButtonModEffect( + adaptiveCard: AdaptiveCard +): readonly [(cardElement: HTMLElement, actionPerformedClassName?: string) => void, () => void] { + const prevAdaptiveCard = usePrevious(adaptiveCard); + const pushedCardObjectsRef = useRef>(new Set()); + + prevAdaptiveCard === adaptiveCard || pushedCardObjectsRef.current.clear(); + + const modder = useMemo( + () => (adaptiveCard: AdaptiveCard, cardElement: HTMLElement, actionPerformedClassName?: string) => { + const undoStack: UndoFunction[] = []; + + Array.from(cardElement.querySelectorAll('button.ac-pushButton') as NodeListOf).forEach( + actionElement => { + const cardObject = findDOMNodeOwner(adaptiveCard, actionElement); + + if (!actionElement.hasAttribute('aria-expanded')) { + if (pushedCardObjectsRef.current.has(cardObject)) { + actionPerformedClassName && + undoStack.push(durableAddClassWithUndo(actionElement, actionPerformedClassName)); + + undoStack.push(setOrRemoveAttributeIfFalseWithUndo(actionElement, 'aria-pressed', 'true')); + } else { + undoStack.push(setOrRemoveAttributeIfFalseWithUndo(actionElement, 'aria-pressed', 'false')); + } + } + + undoStack.push( + setOrRemoveAttributeIfFalseWithUndo(actionElement, 'aria-posinset', false), + setOrRemoveAttributeIfFalseWithUndo(actionElement, 'aria-setsize', false), + setOrRemoveAttributeIfFalseWithUndo(actionElement, 'role', false) + ); + } + ); + + undoStack.push( + addEventListenerWithUndo( + cardElement, + 'click', + ({ target }) => { + // Depends on click location, `target` could be the
inside the