diff --git a/.changeset/khaki-horses-chew.md b/.changeset/khaki-horses-chew.md new file mode 100644 index 0000000000..b9f5dfda5e --- /dev/null +++ b/.changeset/khaki-horses-chew.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs-module-addons-contrib': patch +'@backstage/plugin-techdocs': patch +--- + +Improved Keyboard accessibility in techdocs. diff --git a/plugins/techdocs-module-addons-contrib/src/ExpandableNavigation/ExpandableNavigation.tsx b/plugins/techdocs-module-addons-contrib/src/ExpandableNavigation/ExpandableNavigation.tsx index 5e5bd939fc..83e34f411a 100644 --- a/plugins/techdocs-module-addons-contrib/src/ExpandableNavigation/ExpandableNavigation.tsx +++ b/plugins/techdocs-module-addons-contrib/src/ExpandableNavigation/ExpandableNavigation.tsx @@ -92,16 +92,52 @@ export const ExpandableNavigationAddon = () => { }, [expanded], ); - + const handleKeyPass = ( + event: React.KeyboardEvent, + toggleAction: () => void, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleAction(); + } + }; useEffect(() => { // There is no nested navs if (!checkboxToggles?.length) return; setHasNavSubLevels(true); checkboxToggles.forEach(item => { - if (shouldToggle(item)) item.click(); + item.tabIndex = 0; + const toggleAction = () => { + if (shouldToggle(item)) { + item.click(); + } + }; + // Add keyboard event listener + const keydownHandler = (event: KeyboardEvent) => { + handleKeyPass( + event as unknown as React.KeyboardEvent, + toggleAction, + ); + }; + item.addEventListener('keydown', keydownHandler); + item.addEventListener('click', toggleAction); + + // Clean up event listener or unmount + return () => { + item.removeEventListener('keydown', keydownHandler); + item.removeEventListener('click', toggleAction); + }; }); - }, [expanded, shouldToggle, checkboxToggles]); + }, [checkboxToggles, shouldToggle]); + useEffect(() => { + if (!checkboxToggles?.length) return; + checkboxToggles.forEach(item => { + if (shouldToggle(item)) { + item.click(); + } + }); + }, [expanded, checkboxToggles, shouldToggle]); const handleState = () => { setExpanded(prevState => ({ @@ -115,6 +151,9 @@ export const ExpandableNavigationAddon = () => { handleKeyPass(event, handleState)} + tabIndex={0} // Ensuring keyboard focus + aria-expanded={expanded?.expandAllNestedNavs} // Accessibility aria-label={ expanded?.expandAllNestedNavs ? 'collapse-nav' : 'expand-nav' } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index 10143aa99c..0c4d6dee3e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -51,6 +51,7 @@ import { useSanitizerTransformer, useStylesTransformer, handleMetaRedirects, + addNavLinkKeyboardToggle, } from '../../transformers'; import { useNavigateUrl } from './useNavigateUrl'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; @@ -293,6 +294,7 @@ export const useTechDocsReaderDom = ( }, onLoaded: () => {}, }), + addNavLinkKeyboardToggle(), ]), [theme, navigate, analytics, entityRef.name, configApi], ); diff --git a/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.test.ts b/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.test.ts new file mode 100644 index 0000000000..3af6151164 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { addNavLinkKeyboardToggle } from './addNavLinkKeyboardToggle'; +import { createTestShadowDom } from '../../test-utils'; +import { act } from '@testing-library/react'; + +describe('addNavLinkKeyboardToggle', () => { + it('adds tabindex and toggles checkbox on Enter/Space', async () => { + let shadowDom: ShadowRoot; + await act(async () => { + shadowDom = await createTestShadowDom( + ` + + + `, + { + preTransformers: [], + postTransformers: [addNavLinkKeyboardToggle()], + }, + ); + }); + const label = shadowDom!.querySelector('label.md-nav__link')!; + const checkbox = shadowDom!.querySelector('#cb1') as HTMLInputElement; + expect(label.getAttribute('tabIndex')).toBe('0'); + + // Simulate keydown: Enter + await act(async () => { + label.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + }); + expect(checkbox.checked).toBe(true); + + // Simulate keydown: space + await act(async () => { + label.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }), + ); + }); + expect(checkbox.checked).toBe(false); + }); + + it('does nothing if no for attribute or checkbox', async () => { + let shadowDom: ShadowRoot; + await act(async () => { + shadowDom = await createTestShadowDom( + ``, + { + preTransformers: [], + postTransformers: [addNavLinkKeyboardToggle()], + }, + ); + }); + const label = shadowDom!.querySelector('label.md-nav__link')!; + await act(async () => { + expect(() => + label.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ), + ).not.toThrow(); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.ts b/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.ts new file mode 100644 index 0000000000..9f29df1576 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addNavLinkKeyboardToggle.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function addNavLinkKeyboardToggle() { + return (element: Element) => { + const navLabels = element.querySelectorAll('label.md-nav__link[for]'); + navLabels.forEach(label => { + label.setAttribute('tabIndex', '0'); + label.addEventListener('keydown', event => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') { + const forId = label.getAttribute('for'); + if (!forId) return; + const checkbox = element.querySelector( + `#${forId}`, + ) as HTMLInputElement | null; + if (checkbox && checkbox.type === 'checkbox') { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + event.preventDefault(); + event.stopPropagation(); + } + } + }); + }); + return element; + }; +} diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 7fdf47b009..571725ed25 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -28,3 +28,4 @@ export * from './onCssReady'; export * from './scrollIntoNavigation'; export * from './transformer'; export * from './handleMetaRedirects'; +export * from './addNavLinkKeyboardToggle';