diff --git a/superset-frontend/src/components/Accessibility/SkipLink.test.tsx b/superset-frontend/src/components/Accessibility/SkipLink.test.tsx new file mode 100644 index 000000000000..8951904b97f9 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.test.tsx @@ -0,0 +1,77 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 userEvent from '@testing-library/user-event'; +import { render, screen } from 'spec/helpers/testing-library'; +import SkipLink from './SkipLink'; + +const createTarget = (id: string) => { + const target = document.createElement('div'); + target.id = id; + document.body.appendChild(target); + return target; +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +test('renders the default label', () => { + render(); + expect(screen.getByText('Skip to main content')).toBeInTheDocument(); +}); + +test('renders custom children when provided', () => { + render(Jump to content); + expect(screen.getByText('Jump to content')).toBeInTheDocument(); +}); + +test('href targets the default main-content id', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#main-content'); +}); + +test('href reflects custom targetId', () => { + render(Skip); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#my-target'); +}); + +test('focuses the target element on click', async () => { + const target = createTarget('target-area'); + render(Skip); + + const link = screen.getByRole('link'); + await userEvent.click(link); + + expect(document.activeElement).toBe(target); +}); + +test('does not leave a permanent tabindex on the target after blur', async () => { + const target = createTarget('target-area'); + render(Skip); + + const link = screen.getByRole('link'); + await userEvent.click(link); + + expect(target).toHaveAttribute('tabindex', '-1'); + + target.dispatchEvent(new FocusEvent('blur')); + expect(target.hasAttribute('tabindex')).toBe(false); +}); diff --git a/superset-frontend/src/components/Accessibility/SkipLink.tsx b/superset-frontend/src/components/Accessibility/SkipLink.tsx new file mode 100644 index 000000000000..2692c625be2c --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.tsx @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { FC, MouseEvent } from 'react'; +import { styled } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; + +/** + * SkipLink - WCAG 2.4.1 Bypass Blocks + * Allows keyboard users to skip navigation and jump directly to main content. + * The link is visually hidden but becomes visible when focused. + */ + +const StyledSkipLink = styled.a` + position: absolute; + top: -100px; + left: 0; + background: ${({ theme }) => theme.colorPrimary}; + color: ${({ theme }) => theme.colorWhite}; + padding: ${({ theme }) => theme.sizeUnit * 3}px + ${({ theme }) => theme.sizeUnit * 4}px; + z-index: 10000; + text-decoration: none; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + border-radius: 0 0 ${({ theme }) => theme.borderRadius}px + ${({ theme }) => theme.borderRadius}px; + transition: top 0.2s ease-in-out; + + &:focus, + &:focus-visible { + top: 0 !important; + outline: 3px solid ${({ theme }) => theme.colorPrimaryBorderHover}; + outline-offset: 2px; + } + + &:hover { + background: ${({ theme }) => theme.colorPrimaryHover}; + } +`; + +interface SkipLinkProps { + targetId?: string; + children?: React.ReactNode; +} + +const SkipLink: FC = ({ + targetId = 'main-content', + children = t('Skip to main content'), +}) => { + const handleClick = (e: MouseEvent) => { + e.preventDefault(); + const el = document.getElementById(targetId); + if (el) { + // Temporarily set tabindex to allow programmatic focus, + // then remove it on blur so the element stays in the natural tab order + const hadTabindex = el.hasAttribute('tabindex'); + if (!hadTabindex) { + el.setAttribute('tabindex', '-1'); + } + el.focus(); + if (!hadTabindex) { + el.addEventListener( + 'blur', + () => el.removeAttribute('tabindex'), + { once: true }, + ); + } + } else { + window.location.hash = `#${targetId}`; + } + }; + + return ( + + {children} + + ); +}; + +export default SkipLink; diff --git a/superset-frontend/src/components/Accessibility/VisuallyHidden.test.tsx b/superset-frontend/src/components/Accessibility/VisuallyHidden.test.tsx new file mode 100644 index 000000000000..a5708e7fd1b4 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/VisuallyHidden.test.tsx @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { render, screen } from 'spec/helpers/testing-library'; +import VisuallyHidden from './VisuallyHidden'; + +test('renders children', () => { + render(Screen-reader only text); + expect(screen.getByText('Screen-reader only text')).toBeInTheDocument(); +}); + +test('renders as span by default', () => { + render(Default tag); + const el = screen.getByText('Default tag'); + expect(el.tagName).toBe('SPAN'); +}); + +test('renders as a custom element when as prop is provided', () => { + render(Page heading); + const el = screen.getByText('Page heading'); + expect(el.tagName).toBe('H1'); +}); + +test('forwards id and className props', () => { + render( + + Text + , + ); + const el = screen.getByText('Text'); + expect(el).toHaveAttribute('id', 'my-id'); + expect(el).toHaveClass('my-class'); +}); + +test('applies clip-rect style for screen-reader-only behavior', () => { + render(Hidden); + const el = screen.getByText('Hidden'); + expect(el).toHaveStyle({ position: 'absolute' }); +}); diff --git a/superset-frontend/src/components/Accessibility/VisuallyHidden.tsx b/superset-frontend/src/components/Accessibility/VisuallyHidden.tsx new file mode 100644 index 000000000000..c827bb9dbce5 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/VisuallyHidden.tsx @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { ElementType, FC, ReactNode } from 'react'; +import { styled } from '@apache-superset/core/theme'; + +/** + * VisuallyHidden — content that is available to assistive technology but not + * visually rendered. Use for screen-reader-only headings, labels, and live + * regions where a duplicate visible element would be redundant. + * + * Renders a `` by default. Pass `as` to render a different element + * (e.g. `as="h1"` for an sr-only page heading). + */ +const HiddenElement = styled.span` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +`; + +export interface VisuallyHiddenProps { + /** Element type to render. Defaults to `'span'`. */ + as?: ElementType; + children?: ReactNode; + id?: string; + className?: string; +} + +const VisuallyHidden: FC = ({ + as = 'span', + children, + ...rest +}) => ( + + {children} + +); + +export default VisuallyHidden; diff --git a/superset-frontend/src/components/Accessibility/index.tsx b/superset-frontend/src/components/Accessibility/index.tsx new file mode 100644 index 000000000000..673640a8470b --- /dev/null +++ b/superset-frontend/src/components/Accessibility/index.tsx @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { default as SkipLink } from './SkipLink'; +export { + default as VisuallyHidden, + type VisuallyHiddenProps, +} from './VisuallyHidden'; diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 270a344d817f..f27e6b03a91f 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -28,6 +28,7 @@ import { css } from '@apache-superset/core/theme'; import { Layout, Loading } from '@superset-ui/core/components'; import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact'; import { ErrorBoundary } from 'src/components'; +import { SkipLink } from 'src/components/Accessibility'; import Menu from 'src/features/home/Menu'; import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData'; import ToastContainer from 'src/components/MessageToasts/ToastContainer'; @@ -75,6 +76,7 @@ const App = () => ( + ( }>