diff --git a/docs/src/__examples__/Heading/DEFAULT.tsx b/docs/src/__examples__/Heading/DEFAULT.tsx index 7475340972..c9c4fb7e0e 100644 --- a/docs/src/__examples__/Heading/DEFAULT.tsx +++ b/docs/src/__examples__/Heading/DEFAULT.tsx @@ -2,7 +2,11 @@ import React from "react"; import { Heading } from "@kiwicom/orbit-components"; export default { - Example: () => <Heading>Orbit components</Heading>, + Example: () => ( + <Heading role="heading" level={1}> + Orbit components + </Heading> + ), exampleKnobs: [ { component: "Heading", @@ -26,7 +30,7 @@ export default { "title5", "title6", ], - defaultValue: "div", + defaultValue: "title1", }, ], }, diff --git a/docs/src/documentation/03-components/09-text/heading/03-accessibility.mdx b/docs/src/documentation/03-components/09-text/heading/03-accessibility.mdx new file mode 100644 index 0000000000..3892c4851d --- /dev/null +++ b/docs/src/documentation/03-components/09-text/heading/03-accessibility.mdx @@ -0,0 +1,68 @@ +--- +title: Accessibility +redirect_from: + - /components/heading/accessibility/ +--- + +# Accessibility + +## Heading + +The Heading component has been designed with accessibility in mind. + +The component offers flexibility in terms of the HTML element used for the root node, the `role` attribute, and the `level` attribute. +These properties allow for the creation of semantic and accessible headings. + +| Name | Type | Description | +| :---- | :------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------- | +| as | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6" \| "div"` | Defines the HTML element to be rendered. | +| role | `"heading"` | Can only be used if `as="div"`. If defined, sets the role of the element to be "heading". | +| level | `number` | Can only be used if `as="div"` and "`role="heading`". Defines the `aria-level` of the rendered `div` with the heading role. | + +All the props above are optional by default. + +If they are not provided, the component will render a `div` element with no role or aria-level defined. +It is not semantically wrong but won't tell screen readers that the element is a heading. This should be used only for decorative purposes. + +```jsx +<Heading>Hello World!</Heading> +``` + +renders: + +```html +<div>Hello World!</div> +``` + +If the `as` prop is set to `"div"` (or undefined), the `role` prop is optional, but only accepts one possible value (if not `undefined`): `"heading"`. +If the `role` prop is set to `"heading"`, the `level` prop must be defined as well. It will tell assistive technologies the level of the heading. +The `level` prop must be a number between 1 and 6 and cannot be used if the `role` prop is not set to `"heading"`. + +```jsx +<Heading as="div" role="heading" level={1}> + Hello World! +</Heading> +``` + +renders: + +```html +<div role="heading" aria-level="1">Hello World!</div> +``` + +If the `as` prop is set to `"h1"`, `"h2"`, `"h3"`, `"h4"`, `"h5"`, or `"h6"`, the component will render the corresponding HTML element. +In that case, the `role` and `level` props are not needed, since assistive technologies will recognize the element as a heading and its correct level automatically. + +```jsx +<Heading as="h1">Hello World!</Heading> +``` + +renders: + +```html +<h1>Hello World!</h1> +``` + +### Compatibility with SkipNavigation + +The `dataA11ySection` prop can be used to link the Heading to a `SkipNavigation` component. diff --git a/packages/orbit-components/.storybook/orbitDecorator.tsx b/packages/orbit-components/.storybook/orbitDecorator.tsx index f92323bb25..26439f5a0f 100644 --- a/packages/orbit-components/.storybook/orbitDecorator.tsx +++ b/packages/orbit-components/.storybook/orbitDecorator.tsx @@ -45,7 +45,7 @@ const OrbitDecorator: Decorator = (storyFn, context) => { return ( <OrbitProvider useId={React.useId} theme={{ ...defaultTheme }}> <div style={{ padding: "20px" }}> - <Heading spaceAfter="medium" inverted={inverted}> + <Heading as="h1" spaceAfter="medium" inverted={inverted}> {context.kind} </Heading> <Text spaceAfter="largest" type={inverted ? "white" : "primary"}> diff --git a/packages/orbit-components/cypress/integration/pages/heading-media-query-props.tsx b/packages/orbit-components/cypress/integration/pages/heading-media-query-props.tsx index 31ca891c86..ee8372bd6b 100644 --- a/packages/orbit-components/cypress/integration/pages/heading-media-query-props.tsx +++ b/packages/orbit-components/cypress/integration/pages/heading-media-query-props.tsx @@ -4,6 +4,8 @@ import { Heading } from "@kiwicom/orbit-components"; export default function HeadingMediaProps() { return ( <Heading + role="heading" + level={1} type="title0" mediumMobile={{ type: "display" }} largeMobile={{ spaceAfter: "small", type: "title2" }} diff --git a/packages/orbit-components/cypress/integration/pages/media-queries.tsx b/packages/orbit-components/cypress/integration/pages/media-queries.tsx index 019cdeed6d..c819ff33b5 100644 --- a/packages/orbit-components/cypress/integration/pages/media-queries.tsx +++ b/packages/orbit-components/cypress/integration/pages/media-queries.tsx @@ -5,12 +5,36 @@ export default function MediaQueries() { const query = useMediaQuery(); return ( <> - {query.isMediumMobile && <Heading type="title0">Medium mobile</Heading>} - {query.isLargeMobile && <Heading type="title0">Large mobile</Heading>} - {query.isTablet && <Heading type="title0">Tablet</Heading>} - {query.isDesktop && <Heading type="title0">Desktop</Heading>} - {query.isLargeDesktop && <Heading type="title0">Large desktop</Heading>} - {query.prefersReducedMotion && <Heading type="title0">Reduced motion</Heading>} + {query.isMediumMobile && ( + <Heading role="heading" level={1} type="title0"> + Medium mobile + </Heading> + )} + {query.isLargeMobile && ( + <Heading role="heading" level={1} type="title0"> + Large mobile + </Heading> + )} + {query.isTablet && ( + <Heading role="heading" level={1} type="title0"> + Tablet + </Heading> + )} + {query.isDesktop && ( + <Heading role="heading" level={1} type="title0"> + Desktop + </Heading> + )} + {query.isLargeDesktop && ( + <Heading role="heading" level={1} type="title0"> + Large desktop + </Heading> + )} + {query.prefersReducedMotion && ( + <Heading role="heading" level={1} type="title0"> + Reduced motion + </Heading> + )} </> ); } diff --git a/packages/orbit-components/src/Card/README.md b/packages/orbit-components/src/Card/README.md index 8fc1336191..804d4eff57 100644 --- a/packages/orbit-components/src/Card/README.md +++ b/packages/orbit-components/src/Card/README.md @@ -18,20 +18,20 @@ After adding import into your project you can use it simply like: Table below contains all types of the props available in the Card component. -| Name | Type | Default | Description | -| :---------- | :--------------------------- | :------ | :--------------------------------------------------------------------------------------------------------------------- | -| actions | `React.Node` | | Optional prop for Action components in header of Card | -| children | `React.Node` | | The content of the Card. You can use only [CardSection](#cardsection) | -| dataTest | `string` | | Optional prop for testing purposes | -| id | `string` | | Set `id` for `Card` | -| description | `React.Node` | | The description of the Card | -| header | `React.Node` | | The header of the Card. Useful when you need a different layout than the combination of e.g. `title` and `description` | -| loading | `boolean` | | If `true` `Loading` will be rendered | -| onClose | `() => void \| Promise` | | Callback that is triggered when Card is closing | -| title | `React.Node` | | The title of the Card | -| titleAs | [`enum`](#enum) | `"h2"` | The element used for the root node of the title of Card. | -| margin | `string \| number \| Object` | | Utility prop to set margin. | -| labelClose | `string` | `Close` | Property for passing translation string to close Button. | +| Name | Type | Default | Description | +| :---------- | :--------------------------- | :------ | :---------------------------------------------------------------------------------------------------------------------- | +| actions | `React.Node` | | Optional prop for Action components in header of Card. | +| children | `React.Node` | | The content of the Card. You can use only [CardSection](#cardsection). | +| dataTest | `string` | | Optional prop for testing purposes. | +| id | `string` | | Set `id` for `Card`. | +| description | `React.Node` | | The description of the Card. | +| header | `React.Node` | | The header of the Card. Useful when you need a different layout than the combination of e.g. `title` and `description`. | +| loading | `boolean` | | If `true`, a loading animation will be rendered. | +| onClose | `() => void \| Promise` | | Callback that is triggered when Card is closing. | +| title | `React.Node` | | The title of the Card. | +| titleAs | [`enum`](#enum) | `"h2"` | The element used for the root node of the title of Card. It **does not** impact the visual style of the title. | +| margin | `string \| number \| Object` | | Utility prop to set margin. | +| labelClose | `string` | `Close` | Property for passing translation string to close Button. | ### CardSection diff --git a/packages/orbit-components/src/Card/components/Header.tsx b/packages/orbit-components/src/Card/components/Header.tsx index 1a816544af..58e299d25f 100644 --- a/packages/orbit-components/src/Card/components/Header.tsx +++ b/packages/orbit-components/src/Card/components/Header.tsx @@ -51,6 +51,7 @@ const Header = ({ <Heading type={isSection ? "title4" : "title3"} as={titleAs} + role={undefined} // To avoid requiring the `level` prop dataA11ySection={dataA11ySection} > {title} diff --git a/packages/orbit-components/src/Heading/README.md b/packages/orbit-components/src/Heading/README.md index f3fd5d843f..769feb92a5 100644 --- a/packages/orbit-components/src/Heading/README.md +++ b/packages/orbit-components/src/Heading/README.md @@ -16,22 +16,24 @@ After adding import to your project you can use it simply like: The table below contains all types of props available in the Heading component. -| Name | Type | Default | Description | -| :-------------- | :------------------------- | :--------- | :-------------------------------------------------------------------------------------------------- | -| as | [`enum`](#enum) | `"div"` | The element used for the root node. | -| children | `React.Node` | | The content of the Heading. | -| dataTest | `string` | | Optional prop for testing purposes. | -| align | [`enum`](#enum) | `left` | `text-align` of `Heading` component | -| dataA11ySection | `string` | | ID for a `<SkipNavigation>` component. | -| id | `string` | | Adds `id` HTML attribute to an element. Expects a unique ID. | -| inverted | `boolean` | | The `true`, the Heading color will be white. | -| spaceAfter | `enum` | | Additional `margin-bottom` after component. | -| **type** | [`enum`](#enum) | `"title1"` | The size type of Heading. | -| mediumMobile | [`Object`](#media-queries) | | Object for setting up properties for the mediumMobile viewport. [See Media queries](#media-queries) | -| largeMobile | [`Object`](#media-queries) | | Object for setting up properties for the largeMobile viewport. [See Media queries](#media-queries) | -| tablet | [`Object`](#media-queries) | | Object for setting up properties for the tablet viewport. [See Media queries](#media-queries) | -| desktop | [`Object`](#media-queries) | | Object for setting up properties for the desktop viewport. [See Media queries](#media-queries) | -| largeDesktop | [`Object`](#media-queries) | | Object for setting up properties for the largeDesktop viewport. [See Media queries](#media-queries) | +| Name | Type | Default | Description | +| :-------------- | :------------------------- | :--------- | :--------------------------------------------------------------------------------------------------------- | +| as | [`enum`](#enum) | `"div"` | The element used for the root node. | +| role | `"heading"` | | The role attribute of the element. Can only be defined if `as="div"`. If defined, `level` must be defined. | +| level | `number` | | The level of the Heading. Required if `role` is defined as `"heading"`. | +| children | `React.Node` | | The content of the Heading. | +| dataTest | `string` | | Optional prop for testing purposes. | +| align | [`enum`](#enum) | `left` | `text-align` of `Heading` component. | +| dataA11ySection | `string` | | ID for a `<SkipNavigation>` component. | +| id | `string` | | Adds `id` HTML attribute to an element. Expects a unique ID. | +| inverted | `boolean` | | The `true`, the Heading color will be white. | +| spaceAfter | `enum` | | Additional `margin-bottom` after component. | +| **type** | [`enum`](#enum) | `"title1"` | The size type of Heading. | +| mediumMobile | [`Object`](#media-queries) | | Object for setting up properties for the mediumMobile viewport. [See Media queries](#media-queries) | +| largeMobile | [`Object`](#media-queries) | | Object for setting up properties for the largeMobile viewport. [See Media queries](#media-queries) | +| tablet | [`Object`](#media-queries) | | Object for setting up properties for the tablet viewport. [See Media queries](#media-queries) | +| desktop | [`Object`](#media-queries) | | Object for setting up properties for the desktop viewport. [See Media queries](#media-queries) | +| largeDesktop | [`Object`](#media-queries) | | Object for setting up properties for the largeDesktop viewport. [See Media queries](#media-queries) | ### enum diff --git a/packages/orbit-components/src/Heading/__tests__/index.test.tsx b/packages/orbit-components/src/Heading/__tests__/index.test.tsx index 8bce02b824..a754a75559 100644 --- a/packages/orbit-components/src/Heading/__tests__/index.test.tsx +++ b/packages/orbit-components/src/Heading/__tests__/index.test.tsx @@ -18,6 +18,15 @@ describe("Heading", () => { expect(heading).toHaveAttribute("id", "id"); }); + it("renders correct aria-level", () => { + render( + <Heading role="heading" level={2}> + Title + </Heading>, + ); + expect(screen.getByRole("heading")).toHaveAttribute("aria-level", "2"); + }); + it.each(Object.values(ALIGN))("should have expected styles from align %s", align => { render( <Heading dataTest={align} align={align}> diff --git a/packages/orbit-components/src/Heading/index.tsx b/packages/orbit-components/src/Heading/index.tsx index 36a423ed08..e44f36b9d9 100644 --- a/packages/orbit-components/src/Heading/index.tsx +++ b/packages/orbit-components/src/Heading/index.tsx @@ -15,6 +15,8 @@ const Heading = ({ type = TYPE_OPTIONS.TITLE0, align = ALIGN.START, as: Component = ELEMENT_OPTIONS.DIV, + level, + role, dataTest, inverted = false, spaceAfter, @@ -44,9 +46,10 @@ const Heading = ({ return ( <Component + aria-level={Component === "div" ? level : undefined} id={id} data-test={dataTest} - role={Component === "div" ? "heading" : undefined} + role={Component === "div" ? role : undefined} data-a11y-section={dataA11ySection} className={cx( "orbit-heading font-base m-0", diff --git a/packages/orbit-components/src/Heading/types.d.ts b/packages/orbit-components/src/Heading/types.d.ts index 5663e8bf80..01a3d9e525 100644 --- a/packages/orbit-components/src/Heading/types.d.ts +++ b/packages/orbit-components/src/Heading/types.d.ts @@ -20,13 +20,28 @@ export type As = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"; type Align = "start" | "center" | "end" | "justify"; +type LevelProps = + | { + as: Exclude<As, "div">; + role?: never; + level?: never; + } + | { + as?: "div"; + role: "heading"; + level: number; + } + | { + as?: "div"; + role?: undefined; + level?: never; + }; + interface MediaQuery extends Common.SpaceAfter { readonly type?: Type; readonly align?: Align; } - -export interface Props extends Common.Globals, Common.SpaceAfter { - readonly as?: As; +export interface BaseProps extends Common.Globals, Common.SpaceAfter { readonly type?: Type; readonly align?: Align; readonly children: React.ReactNode; @@ -39,3 +54,5 @@ export interface Props extends Common.Globals, Common.SpaceAfter { readonly desktop?: MediaQuery; readonly largeDesktop?: MediaQuery; } + +export type Props = BaseProps & LevelProps;