From 16d6282f2e63360b28b6cb6879ba1d9bd2d78795 Mon Sep 17 00:00:00 2001 From: Fedo Hagge-Kubat Date: Thu, 9 Apr 2026 20:59:29 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(a11y):=20WCAG=202.4.1=20=E2=80=94=20add?= =?UTF-8?q?=20SkipLink=20component=20for=20keyboard=20bypass=20blocks=20na?= =?UTF-8?q?vigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Accessibility/SkipLink.tsx | 99 +++++++++++++++++++ .../src/components/Accessibility/index.tsx | 19 ++++ 2 files changed, 118 insertions(+) create mode 100644 superset-frontend/src/components/Accessibility/SkipLink.tsx create mode 100644 superset-frontend/src/components/Accessibility/index.tsx diff --git a/superset-frontend/src/components/Accessibility/SkipLink.tsx b/superset-frontend/src/components/Accessibility/SkipLink.tsx new file mode 100644 index 000000000000..0479013e622d --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.tsx @@ -0,0 +1,99 @@ +/** + * 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'; + +/** + * 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 = '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/index.tsx b/superset-frontend/src/components/Accessibility/index.tsx new file mode 100644 index 000000000000..9fb07415c66e --- /dev/null +++ b/superset-frontend/src/components/Accessibility/index.tsx @@ -0,0 +1,19 @@ +/** + * 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'; From 806c8b2adaceeb11b4849c887214f1d40f7842ed Mon Sep 17 00:00:00 2001 From: Fedo Hagge-Kubat Date: Thu, 9 Apr 2026 21:17:46 +0200 Subject: [PATCH 2/4] fix(a11y): render SkipLink in App shell and add main-content target ID --- superset-frontend/src/views/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 270a344d817f..18a429383930 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 = () => ( + ( }> Date: Sat, 18 Apr 2026 01:08:57 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(a11y):=20WCAG=202.4.1=20=E2=80=94=20tra?= =?UTF-8?q?nslate=20SkipLink=20default=20label=20via=20t()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Bito code review finding: the hardcoded English default 'Skip to main content' bypassed i18n so non-English users saw untranslated link text. Pipe the default through t() from @superset-ui/core so it picks up the user's locale catalog. Notes on the other bot findings for this PR: - 'SkipLink exported but never rendered' (CodeAnt Architect) was already addressed in the follow-up commit that mounted in the App shell and added id='main-content' to Layout.Content. - Default targetId remains 'main-content' rather than 'app' because WCAG 2.4.1 Bypass Blocks specifically asks users to skip *past* the nav, which is what Layout.Content represents; #app is the SPA root above the nav. --- superset-frontend/src/components/Accessibility/SkipLink.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/components/Accessibility/SkipLink.tsx b/superset-frontend/src/components/Accessibility/SkipLink.tsx index 0479013e622d..2692c625be2c 100644 --- a/superset-frontend/src/components/Accessibility/SkipLink.tsx +++ b/superset-frontend/src/components/Accessibility/SkipLink.tsx @@ -18,6 +18,7 @@ */ 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 @@ -60,7 +61,7 @@ interface SkipLinkProps { const SkipLink: FC = ({ targetId = 'main-content', - children = 'Skip to main content', + children = t('Skip to main content'), }) => { const handleClick = (e: MouseEvent) => { e.preventDefault(); From 70c7be1385f7c1dfb74f753ea67b83afb93b2961 Mon Sep 17 00:00:00 2001 From: Kolja Date: Thu, 7 May 2026 17:02:10 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(a11y):=20WCAG=202.4.1=20=E2=80=94=20add?= =?UTF-8?q?=20VisuallyHidden=20component,=20main=20landmark,=20and=20SkipL?= =?UTF-8?q?ink=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review polish points on PR #39240: - Add `
` landmark via role="main" on Layout.Content so the skip target has semantic meaning beyond just an id anchor. - Scope `outline: none` to `[tabindex='-1']:focus` on the same element so the programmatic-focus moment doesn't render a stray browser outline. The rule is intentionally tight: only fires when SkipLink has dynamically attached tabindex='-1', does not affect any other element. - Add `VisuallyHidden` reusable component (with `as` prop for h1, h2, etc.) in the Accessibility folder. This is the shared sr-only primitive that WCAG 2.4.6 (PR #39241) needs to deduplicate across pages. Tests added for both SkipLink and VisuallyHidden covering default behavior, custom props, focus management, and tabindex cleanup on blur. --- .../Accessibility/SkipLink.test.tsx | 77 +++++++++++++++++++ .../Accessibility/VisuallyHidden.test.tsx | 54 +++++++++++++ .../Accessibility/VisuallyHidden.tsx | 60 +++++++++++++++ .../src/components/Accessibility/index.tsx | 4 + superset-frontend/src/views/App.tsx | 4 + 5 files changed, 199 insertions(+) create mode 100644 superset-frontend/src/components/Accessibility/SkipLink.test.tsx create mode 100644 superset-frontend/src/components/Accessibility/VisuallyHidden.test.tsx create mode 100644 superset-frontend/src/components/Accessibility/VisuallyHidden.tsx 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/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 index 9fb07415c66e..673640a8470b 100644 --- a/superset-frontend/src/components/Accessibility/index.tsx +++ b/superset-frontend/src/components/Accessibility/index.tsx @@ -17,3 +17,7 @@ * 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 18a429383930..f27e6b03a91f 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -89,9 +89,13 @@ const App = () => (