diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 3a4fd88cdf..b8fa6972b7 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed May 7 13:46:05 UTC 2025 - David Diaz + +- 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 diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 34951881fe..a486e6f2e9 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -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; @@ -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; +} diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 6929f194b5..11504282f1 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -320,7 +320,11 @@ const Page = ({ children, ...pageGroupProps }: React.PropsWithChildren): React.ReactNode => { - return {children}; + return ( + + {children} + + ); }; Page.displayName = "agama/core/Page"; diff --git a/web/src/components/core/SkipTo.test.tsx b/web/src/components/core/SkipTo.test.tsx new file mode 100644 index 0000000000..352019046e --- /dev/null +++ b/web/src/components/core/SkipTo.test.tsx @@ -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(); + const link = screen.getByRole("link", { name: "Skip to content" }); + expect(link).toHaveAttribute("href", "#main-content"); + }); + + it("renders with custom label and contentId", () => { + plainRender(Skip to navigation); + 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( + <> + + Agama documentation + Link to elsewhere +
+ Main content +
+ , + ); + + 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( + <> + + Agama documentation + Link to elsewhere +
+ Main content +
+ , + ); + + 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(); + }); +}); diff --git a/web/src/components/core/SkipTo.tsx b/web/src/components/core/SkipTo.tsx new file mode 100644 index 0000000000..90fdd70e44 --- /dev/null +++ b/web/src/components/core/SkipTo.tsx @@ -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 & { + /** + * 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