diff --git a/src/components/ApplicationLayout/ApplicationLayout.tsx b/src/components/ApplicationLayout/ApplicationLayout.tsx index 857667dc..920180dd 100644 --- a/src/components/ApplicationLayout/ApplicationLayout.tsx +++ b/src/components/ApplicationLayout/ApplicationLayout.tsx @@ -20,6 +20,7 @@ import AppStatus from "./AppStatus"; import Application from "./Application"; import Button from "components/Button"; import Icon from "components/Icon"; +import SkipLink from "components/SkipLink"; export type BaseProps< NI = SideNavigationLinkDefaultElement, @@ -86,6 +87,10 @@ export type BaseProps< * Classes to apply to the status area. */ statusClassName?: string; + /** + * Id to apply to the main area. Used for the "Skip to main content" link. + */ + mainId?: string; }, HTMLProps >; @@ -134,6 +139,7 @@ const ApplicationLayout = < sideNavigation, status, statusClassName, + mainId = "main-content", ...props }: Props) => { const [internalMenuPinned, setInternalMenuPinned] = useState(false); @@ -145,6 +151,7 @@ const ApplicationLayout = < return ( + {(navItems || sideNavigation) && ( <> @@ -222,7 +229,9 @@ const ApplicationLayout = < )} - {children} + + {children} + {aside} {status && {status}} diff --git a/src/components/SkipLink/SkipLink.stories.tsx b/src/components/SkipLink/SkipLink.stories.tsx new file mode 100644 index 00000000..2fe6a544 --- /dev/null +++ b/src/components/SkipLink/SkipLink.stories.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; + +import SkipLink from "./SkipLink"; + +const meta: Meta = { + component: SkipLink, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +

+ Click inside this example box, then hit the "Tab" key to make the skip + link focused and visible. +

+
+ ), + + name: "Default", +}; diff --git a/src/components/SkipLink/SkipLink.test.tsx b/src/components/SkipLink/SkipLink.test.tsx new file mode 100644 index 00000000..a5761bea --- /dev/null +++ b/src/components/SkipLink/SkipLink.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import SkipLink from "./SkipLink"; + +describe("", () => { + it("renders and is found in the DOM", () => { + render(); + + expect(screen.getByText("Skip to main content")).toBeInTheDocument(); + }); + + it("gets focused only after a TAB press", async () => { + render(); + + const skipLink = screen.getByText("Skip to main content"); + expect(skipLink).not.toHaveFocus(); + + await userEvent.tab(); + expect(skipLink).toHaveFocus(); + }); + + it("redirects focus to the main content", async () => { + render( +
+ +
+ +
+
, + ); + + const input = screen.getByRole("textbox"); + expect(input).not.toHaveFocus(); + + await userEvent.click(screen.getByText("Skip to main content")); + expect(window.location.hash).toBe("#main-element"); + + await userEvent.tab(); + expect(input).toHaveFocus(); + }); +}); diff --git a/src/components/SkipLink/SkipLink.tsx b/src/components/SkipLink/SkipLink.tsx new file mode 100644 index 00000000..634359a9 --- /dev/null +++ b/src/components/SkipLink/SkipLink.tsx @@ -0,0 +1,21 @@ +import React, { HTMLProps } from "react"; + +export type Props = { + /** + * Id of the main content area to skip to. + */ + mainId?: string; +} & HTMLProps; + +/** + * This is a [React](https://reactjs.org/) component for the Vanilla [Skip link](https://vanillaframework.io/docs/patterns/links#skip-link) component. + */ +export const SkipLink = ({ mainId = "main-content" }: Props): JSX.Element => { + return ( + + Skip to main content + + ); +}; + +export default SkipLink; diff --git a/src/components/SkipLink/index.ts b/src/components/SkipLink/index.ts new file mode 100644 index 00000000..d4845831 --- /dev/null +++ b/src/components/SkipLink/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SkipLink"; +export type { Props as SkipLinkProps } from "./SkipLink"; diff --git a/src/index.ts b/src/index.ts index c5a4bdb7..ef648588 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,7 @@ export { default as SideNavigation } from "./components/SideNavigation"; export { default as SideNavigationItem } from "./components/SideNavigation/SideNavigationItem"; export { default as SideNavigationLink } from "./components/SideNavigation/SideNavigationLink"; export { default as SideNavigationText } from "./components/SideNavigation/SideNavigationText"; +export { default as SkipLink } from "./components/SkipLink"; export { default as Slider } from "./components/Slider"; export { default as Switch } from "./components/Switch"; export { default as Spinner } from "./components/Spinner"; @@ -153,6 +154,7 @@ export type { SideNavigationProps } from "./components/SideNavigation"; export type { SideNavigationItemProps } from "./components/SideNavigation/SideNavigationItem"; export type { SideNavigationLinkProps } from "./components/SideNavigation/SideNavigationLink"; export type { SideNavigationTextProps } from "./components/SideNavigation/SideNavigationText"; +export type { SkipLinkProps } from "./components/SkipLink"; export type { SliderProps } from "./components/Slider"; export type { SpinnerProps } from "./components/Spinner"; export type { StatusLabelProps } from "./components/StatusLabel";