Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions superset-frontend/src/components/Accessibility/SkipLink.test.tsx
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);
});
100 changes: 100 additions & 0 deletions superset-frontend/src/components/Accessibility/SkipLink.tsx
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};
Comment on lines +29 to +34
Copy link

Copilot AI Apr 20, 2026

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.

Copilot uses AI. Check for mistakes.
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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: 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. [logic error]

Severity Level: Major ⚠️
- ⚠️ Global "Skip to main content" link focus stays on link.
- ⚠️ WCAG 2.4.1 bypass blocks unmet in SPA layout.
- ⚠️ Keyboard users cannot reliably jump past navigation.
Suggested change
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.
👍 | 👎

children = t('Skip to main content'),
}) => {
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
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
Copy link

Copilot AI Apr 20, 2026

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.

Suggested change
// 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 uses AI. Check for mistakes.
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}`;
}
};
Comment on lines +62 to +87
Copy link

Copilot AI Apr 20, 2026

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.

Copilot uses AI. Check for mistakes.

return (
<StyledSkipLink
href={`#${targetId}`}
className="a11y-skip-link"
onClick={handleClick}
>
{children}
</StyledSkipLink>
);
};

export default SkipLink;
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' });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 suggestion
Check the AI-generated fix before applying
Suggested change
expect(el).toHaveStyle({ position: 'absolute' });
expect(el).toHaveStyle({ clip: 'rect(0, 0, 0, 0)' });

Code Review Run #a27561


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

});
60 changes: 60 additions & 0 deletions superset-frontend/src/components/Accessibility/VisuallyHidden.tsx
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;
23 changes: 23 additions & 0 deletions superset-frontend/src/components/Accessibility/index.tsx
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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/components/Accessibility/index.tsx only re-exports a symbol and doesn’t contain JSX. In this codebase, barrel files without JSX are typically .ts (e.g., src/components/ListView/index.ts), so renaming this to index.ts would better match existing conventions and avoid implying JSX content.

Copilot uses AI. Check for mistakes.
export {
default as VisuallyHidden,
type VisuallyHiddenProps,
} from './VisuallyHidden';
7 changes: 7 additions & 0 deletions superset-frontend/src/views/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,6 +76,7 @@ const App = () => (
<ScrollToTop />
<LocationPathnameLogger />
<RootContextProviders>
<SkipLink />
<Menu
data={bootstrapData.common.menu_data}
isFrontendRoute={isFrontendRoute}
Expand All @@ -86,9 +88,14 @@ const App = () => (
<Suspense fallback={<Fallback />}>
<Layout>
<Layout.Content
id="main-content"
role="main"
css={css`
display: flex;
flex-direction: column;
&[tabindex='-1']:focus {
outline: none;
}
`}
>
<ErrorBoundary
Expand Down
Loading