-
Notifications
You must be signed in to change notification settings - Fork 17.2k
fix(a11y): WCAG 2.4.1 — add SkipLink component for keyboard bypass blocks #39240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
16d6282
806c8b2
73d91f5
70c7be1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<SkipLink />); | ||
| expect(screen.getByText('Skip to main content')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('renders custom children when provided', () => { | ||
| render(<SkipLink>Jump to content</SkipLink>); | ||
| expect(screen.getByText('Jump to content')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('href targets the default main-content id', () => { | ||
| render(<SkipLink />); | ||
| const link = screen.getByRole('link'); | ||
| expect(link).toHaveAttribute('href', '#main-content'); | ||
| }); | ||
|
|
||
| test('href reflects custom targetId', () => { | ||
| render(<SkipLink targetId="my-target">Skip</SkipLink>); | ||
| 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(<SkipLink targetId="target-area">Skip</SkipLink>); | ||
|
|
||
| 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(<SkipLink targetId="target-area">Skip</SkipLink>); | ||
|
|
||
| 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); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<SkipLinkProps> = ({ | ||||||
| targetId = 'main-content', | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The default anchor target is Severity Level: Major
|
||||||
| targetId = 'main-content', | |
| targetId = 'app', |
Steps of Reproduction ✅
1. Note that the SPA root element is defined in the server template at
`superset/templates/superset/spa.html:10` as `<div id="app" data-bootstrap="{{
bootstrap_data }}">`, and a repo-wide search (`Grep id=["']main-content["']`) shows no
element with `id="main-content"` anywhere in the codebase.
2. The React application mounts into this `#app` container from
`superset-frontend/src/views/index.tsx:6-14`, where `const appMountPoint =
document.getElementById('app');` and `ReactDOM.render(<App />, appMountPoint);` render
`App` into that root.
3. The global layout for all frontend routes is defined in
`superset-frontend/src/views/App.tsx:73-110`, which renders `<Menu />`, `<Layout>`, and
`<Layout.Content>` but does not assign `id="main-content"` (or any other matching ID) to
the main content container; there is currently no matching DOM node for the default
`targetId`.
4. When a developer uses the new `SkipLink` component with its default props anywhere
inside the SPA (for example, adding `<SkipLink />` at the top of the layout in `App.tsx`),
the click handler at `SkipLink.tsx:65-85` executes `document.getElementById(targetId)`
with `targetId = 'main-content'`, receives `null` (because no such element exists), and
falls into the `else` branch which only sets `window.location.hash = '#main-content'`. As
a result, pressing Enter on the "Skip to main content" link does not move focus to any
real main content container, so the skip link silently fails to fulfill its intended
bypass behavior in the current SPA structure unless callers always override `targetId` or
introduce a matching `id="main-content"` element.Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** superset-frontend/src/components/Accessibility/SkipLink.tsx
**Line:** 62:62
**Comment:**
*Logic Error: The default anchor target is `main-content`, but that ID does not exist in the current frontend/templates, so using this component without a custom `targetId` will not move focus to real main content. Point the default to an existing stable container ID so the skip link works out of the box.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Copilot
AI
Apr 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inline comment says removing tabindex is needed to keep the element in the “natural tab order”, but tabindex="-1" is already excluded from sequential tab navigation. Consider rewording this comment to reflect the actual reason for removing the attribute (e.g., avoiding permanently changing the element’s focusability/markup) or keep the attribute permanently if that’s acceptable.
| // then remove it on blur so the element stays in the natural tab order | |
| // then remove it on blur to restore the element's original markup |
Copilot
AI
Apr 20, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new SkipLink component introduces focus management and hash navigation behavior, but there are no accompanying frontend unit tests. Adding tests (e.g., renders hidden until focus; clicking/Enter moves focus to the target and temporarily manages tabindex; falls back to hash when target is missing) will help prevent regressions.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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(<VisuallyHidden>Screen-reader only text</VisuallyHidden>); | ||||||
| expect(screen.getByText('Screen-reader only text')).toBeInTheDocument(); | ||||||
| }); | ||||||
|
|
||||||
| test('renders as span by default', () => { | ||||||
| render(<VisuallyHidden>Default tag</VisuallyHidden>); | ||||||
| const el = screen.getByText('Default tag'); | ||||||
| expect(el.tagName).toBe('SPAN'); | ||||||
| }); | ||||||
|
|
||||||
| test('renders as a custom element when as prop is provided', () => { | ||||||
| render(<VisuallyHidden as="h1">Page heading</VisuallyHidden>); | ||||||
| const el = screen.getByText('Page heading'); | ||||||
| expect(el.tagName).toBe('H1'); | ||||||
| }); | ||||||
|
|
||||||
| test('forwards id and className props', () => { | ||||||
| render( | ||||||
| <VisuallyHidden id="my-id" className="my-class"> | ||||||
| Text | ||||||
| </VisuallyHidden>, | ||||||
| ); | ||||||
| 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(<VisuallyHidden>Hidden</VisuallyHidden>); | ||||||
| const el = screen.getByText('Hidden'); | ||||||
| expect(el).toHaveStyle({ position: 'absolute' }); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test assertion mismatch
The test 'applies clip-rect style for screen-reader-only behavior' claims to verify clip-rect application but only asserts position: 'absolute'. The VisuallyHidden component uses clip: rect(0, 0, 0, 0) as the primary mechanism for visual hiding while maintaining accessibility. Update the assertion to check clip to match the test's intent and prevent potential regressions. Code suggestionCheck the AI-generated fix before applying
Suggested change
Code Review Run #a27561 Should Bito avoid suggestions like this for future reviews? (Manage Rules)
|
||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<span>` 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<VisuallyHiddenProps> = ({ | ||
| as = 'span', | ||
| children, | ||
| ...rest | ||
| }) => ( | ||
| <HiddenElement as={as} {...rest}> | ||
| {children} | ||
| </HiddenElement> | ||
| ); | ||
|
|
||
| export default VisuallyHidden; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Architect Review — HIGH SkipLink is exported but never rendered in any application shell or layout, so the "Skip to main content" link never appears on real pages and keyboard users cannot bypass navigation. Suggestion: Render SkipLink at the top of the global layout(s) or app shell, before navigation, so it is the first tabbable element across SPA and menu-only entrypoints.
|
||
| export { | ||
| default as VisuallyHidden, | ||
| type VisuallyHiddenProps, | ||
| } from './VisuallyHidden'; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The skip link is hidden by positioning it at
top: -100px, which can still leave it partially visible at high zoom / large font sizes (contradicts the requirement that it be invisible until focused). Consider hiding it with a transform-based approach (e.g., translateY(-100%)) or a standard visually-hidden pattern that guarantees it’s fully off-screen regardless of its computed height, then reveal on focus.