Skip to content
Merged
6 changes: 6 additions & 0 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Wed May 7 13:46:05 UTC 2025 - David Diaz <[email protected]>

- Add "Skip to content" link for easing navigation for keyboard-only
users (gh#agama-project/agama#1521, gh#agama-project/agama#2337).

-------------------------------------------------------------------
Wed May 7 07:07:10 UTC 2025 - Ladislav Slezák <[email protected]>

Expand Down
18 changes: 18 additions & 0 deletions web/src/assets/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,17 @@ label.pf-m-disabled + .pf-v6-c-check__description {
gap: inherit;
}

.pf-v6-c-skip-to-content {
--pf-v6-c-skip-to-content--focus--InsetInlineStart: var(
--pf-t--global--spacer--inset--page-chrome
);

a.pf-m-primary {
background: var(--agm-t--color--waterhole);
font-size: var(--pf-t--global--font--size--md);
}
}

// Some utilities not found at PF
.w-14ch {
inline-size: 14ch;
Expand All @@ -433,3 +444,10 @@ input[type="checkbox"] {
inline-size: var(--pf-t--global--font--size--md);
block-size: var(--pf-t--global--font--size--md);
}

// Custom style for :focus-visible appearance
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--agm-t--color--waterhole);
outline-offset: 3px;
}
6 changes: 5 additions & 1 deletion web/src/components/core/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ const Page = ({
children,
...pageGroupProps
}: React.PropsWithChildren<PageGroupProps>): React.ReactNode => {
return <PageGroup {...pageGroupProps}>{children}</PageGroup>;
return (
<PageGroup {...pageGroupProps} tabIndex={-1} id="main-content">
{children}
</PageGroup>
);
};

Page.displayName = "agama/core/Page";
Expand Down
98 changes: 98 additions & 0 deletions web/src/components/core/SkipTo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (c) [2025] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 of the License, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { screen } from "@testing-library/react";
import { plainRender } from "~/test-utils";
import SkipTo from "./SkipTo";

const scrollIntoViewMock = jest.fn();

describe("SkipTo", () => {
beforeAll(() => {
// .scrollIntoView is not yet implemented at jsdom, https://github.com/jsdom/jsdom/issues/1695
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
});

afterAll(() => {
HTMLElement.prototype.scrollIntoView = undefined;
});

it("renders with default label and contentId", () => {
plainRender(<SkipTo />);
const link = screen.getByRole("link", { name: "Skip to content" });
expect(link).toHaveAttribute("href", "#main-content");
});

it("renders with custom label and contentId", () => {
plainRender(<SkipTo contentId="navigation">Skip to navigation</SkipTo>);
const skipToNavigationLink = screen.getByRole("link", { name: "Skip to navigation" });
expect(skipToNavigationLink).toHaveAttribute("href", "#navigation");
});

it("focuses and scrolls to target element on [Enter]", async () => {
const { user } = plainRender(
<>
<SkipTo />
<a href="https://agama-project.github.io/docs">Agama documentation</a>
<a href="#fake-anchor">Link to elsewhere</a>
<div id="main-content" tabIndex={-1}>
Main content
</div>
</>,
);

const skipToContentLink = screen.getByRole("link", { name: "Skip to content" });
const mainContent = screen.getByText("Main content");
expect(skipToContentLink).not.toHaveFocus();
expect(mainContent).not.toHaveFocus();
await user.tab();
expect(skipToContentLink).toHaveFocus();
expect(mainContent).not.toHaveFocus();
await user.keyboard("[Enter]");
expect(scrollIntoViewMock).toHaveBeenCalled();
expect(skipToContentLink).not.toHaveFocus();
expect(mainContent).toHaveFocus();
});

it("focuses and scrolls to target element on click", async () => {
const { user } = plainRender(
<>
<SkipTo />
<a href="https://agama-project.github.io/docs">Agama documentation</a>
<a href="#fake-anchor">Link to elsewhere</a>
<div id="main-content" tabIndex={-1}>
Main content
</div>
</>,
);

const skipToContentLink = screen.getByRole("link", { name: "Skip to content" });
const mainContent = screen.getByText("Main content");
expect(skipToContentLink).not.toHaveFocus();
expect(mainContent).not.toHaveFocus();
await user.click(skipToContentLink);
expect(scrollIntoViewMock).toHaveBeenCalled();
expect(skipToContentLink).not.toHaveFocus();
expect(mainContent).toHaveFocus();
});
});
75 changes: 75 additions & 0 deletions web/src/components/core/SkipTo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (c) [2025] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 of the License, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { SkipToContent, SkipToContentProps } from "@patternfly/react-core";
import { _ } from "~/i18n";

type SkipToProps = Omit<SkipToContentProps, "href" | "onClick"> & {
/**
* The ID (without the "#" prefix) of the element to jump to when the link is activated.
*
* The target element must be focusable — either natively (like a <button> or <a>)
* or by adding `tabIndex={-1}`. Avoid using `tabIndex={0}` to prevent creating an
* unwanted tab stop in the page’s focus order.
*
* Learn more about `tabIndex`:
* - https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
* - https://allyant.com/blog/mastering-the-aria-tabindex-attribute-for-enhanced-web-accessibility/
*/
contentId?: string;
};

/**
* A wrapper around PatternFly's SkipToContent component.
*
* Provides an accessible link that helps screen reader and keyboard-only users
* bypass navigation and other non-essential focusable elements, allowing them
* to jump directly to the main content of the page by default.
*
* The target section and link text can be customized to support navigation to
* different parts of the page.
*
* The link is only visible when the user agent applies the `:focus-visible`
* pseudo-class, ensuring it appears during keyboard navigation or when
* visible focus is explicitly required.
*/
export default function SkipToContentLink({
children,
contentId = "main-content",
...props
}: SkipToProps) {
const onClick = (e) => {
e.preventDefault();

const element = document.getElementById(contentId);
if (element) {
element.focus();
element.scrollIntoView();
}
};
return (
<SkipToContent href={`#${contentId}`} onClick={onClick} {...props}>
{children || _("Skip to content")}
</SkipToContent>
);
}
1 change: 1 addition & 0 deletions web/src/components/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ export { default as NestedContent } from "./NestedContent";
export { default as SubtleContent } from "./SubtleContent";
export { default as MenuHeader } from "./MenuHeader";
export { default as SplitButton } from "./SplitButton";
export { default as SkipTo } from "./SkipTo";
10 changes: 10 additions & 0 deletions web/src/components/layout/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ describe("Header", () => {
screen.getByText("Install Button Mock");
});

it("renders skip to content link", async () => {
installerRender(<Header />);
screen.getByRole("link", { name: "Skip to content" });
});

it("does not render skip to content link when showSkipToContent is false", async () => {
installerRender(<Header showSkipToContent={false} />);
expect(screen.queryByRole("link", { name: "Skip to content" })).toBeNull();
});

it("renders an options dropdown", async () => {
const { user } = installerRender(<Header />);
expect(screen.queryByRole("menu")).toBeNull();
Expand Down
8 changes: 6 additions & 2 deletions web/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,21 @@ import {
} from "@patternfly/react-core";
import { Icon } from "~/components/layout";
import { useProduct } from "~/queries/software";
import { _ } from "~/i18n";
import { InstallationPhase } from "~/types/status";
import { useInstallerStatus } from "~/queries/status";
import { Route } from "~/types/routes";
import { ChangeProductOption, InstallButton, InstallerOptions } from "~/components/core";
import { ChangeProductOption, InstallButton, InstallerOptions, SkipTo } from "~/components/core";
import { useLocation, useMatches } from "react-router-dom";
import { ROOT } from "~/routes/paths";
import { _ } from "~/i18n";

export type HeaderProps = {
/** Whether the application sidebar should be mounted or not */
showSidebarToggle?: boolean;
/** Whether the selected product name should be shown */
showProductName?: boolean;
/** Whether the "Skip to content" link should be mounted */
showSkipToContent?: boolean;
/** Whether the installer options link should be mounted */
showInstallerOptions?: boolean;
/** Callback to be triggered for toggling the IssuesDrawer visibility */
Expand Down Expand Up @@ -125,6 +127,7 @@ const OptionsDropdown = ({ showInstallerOptions }) => {
export default function Header({
showSidebarToggle = true,
showProductName = true,
showSkipToContent = true,
toggleIssuesDrawer,
isSidebarOpen,
toggleSidebar,
Expand All @@ -145,6 +148,7 @@ export default function Header({
return (
<Masthead>
<MastheadMain>
{showSkipToContent && <SkipTo />}
{showSidebarToggle && (
<MastheadToggle>
<PageToggleButton
Expand Down
15 changes: 15 additions & 0 deletions web/src/components/layout/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ describe("Layout", () => {
expect(screen.queryByText("Header Mock")).toBeNull();
});

it("renders the skip to content when mountHeader=false and mountSkipToContent is not given", () => {
installerRender(<Layout mountHeader={false} />);
screen.getByRole("link", { name: "Skip to content" });
});

it("renders the skip to content when mountHeader=false and mountSkipToContent is true", () => {
installerRender(<Layout mountHeader={false} mountSkipToContent />);
screen.getByRole("link", { name: "Skip to content" });
});

it("does not render the skip to content when mountHeader=false and mountSkipToContent=false", () => {
installerRender(<Layout mountHeader={false} mountSkipToContent={false} />);
expect(screen.queryByRole("link", { name: "Skip to content" })).toBeNull();
});

it("does not render the sidebar when mountSidebar=false", () => {
installerRender(<Layout mountSidebar={false} />);
expect(screen.queryByText("Sidebar Mock")).toBeNull();
Expand Down
9 changes: 6 additions & 3 deletions web/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ import { Masthead, Page, PageProps } from "@patternfly/react-core";
import { Questions } from "~/components/questions";
import Header, { HeaderProps } from "~/components/layout/Header";
import { Loading, Sidebar } from "~/components/layout";
import { IssuesDrawer } from "~/components/core";
import { IssuesDrawer, SkipTo } from "~/components/core";
import { ROOT } from "~/routes/paths";
import { agamaWidthBreakpoints, getBreakpoint } from "~/utils";

export type LayoutProps = React.PropsWithChildren<{
className?: string;
mountHeader?: boolean;
mountSidebar?: boolean;
mountSkipToContent?: boolean;
headerOptions?: HeaderProps;
}>;

Expand All @@ -56,6 +57,7 @@ const focusDrawer = (drawer: HTMLElement | null) => {
const Layout = ({
mountHeader = true,
mountSidebar = true,
mountSkipToContent = true,
headerOptions = {},
children,
...props
Expand Down Expand Up @@ -86,6 +88,7 @@ const Layout = ({
toggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
showSidebarToggle={mountSidebar}
toggleIssuesDrawer={toggleIssuesDrawer}
showSkipToContent={mountSkipToContent}
{...headerOptions}
/>
);
Expand All @@ -95,9 +98,9 @@ const Layout = ({
pageProps.isNotificationDrawerExpanded = issuesDrawerVisible;
} else {
// FIXME: render an empty Masthead instead of nothing, in order to have
// everything working as designed by PatternfFly (there are some CSS rules
// everything working as designed by PatternFly (there are some CSS rules
// that expect the masthead to be there :shrug:)
pageProps.masthead = <Masthead />;
pageProps.masthead = <Masthead>{mountSkipToContent && <SkipTo />}</Masthead>;
}

return (
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const MainNavigation = (): React.ReactNode => {

export default function Sidebar(props: PageSidebarProps): React.ReactNode {
return (
<PageSidebar id="agama-sidebar" {...props}>
<PageSidebar id="main-navigation" tabIndex={-1} {...props}>
<PageSidebarBody isFilled>
<MainNavigation />
</PageSidebarBody>
Expand Down
2 changes: 1 addition & 1 deletion web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const router = () =>
{
path: PATHS.login,
element: (
<PlainLayout mountHeader={false}>
<PlainLayout mountHeader={false} mountSkipToContent={false}>
<LoginPage />
</PlainLayout>
),
Expand Down