Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add accessibility props to Heading #4516

Merged
merged 3 commits into from
Nov 19, 2024
Merged
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
8 changes: 6 additions & 2 deletions docs/src/__examples__/Heading/DEFAULT.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import React from "react";
import { Heading } from "@kiwicom/orbit-components";

export default {
Example: () => <Heading>Orbit components</Heading>,
Example: () => (
sarkaaa marked this conversation as resolved.
Show resolved Hide resolved
<Heading role="heading" level={1}>
Orbit components
</Heading>
),
exampleKnobs: [
{
component: "Heading",
Expand All @@ -26,7 +30,7 @@ export default {
"title5",
"title6",
],
defaultValue: "div",
defaultValue: "title1",
},
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/orbit-components/.storybook/orbitDecorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)}
</>
);
}
28 changes: 14 additions & 14 deletions packages/orbit-components/src/Card/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/orbit-components/src/Card/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const Header = ({
<Heading
type={isSection ? "title4" : "title3"}
as={titleAs}
role={undefined} // To avoid requiring the `level` prop
dataA11ySection={dataA11ySection}
>
{title}
Expand Down
34 changes: 18 additions & 16 deletions packages/orbit-components/src/Heading/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
Expand Down
5 changes: 4 additions & 1 deletion packages/orbit-components/src/Heading/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const Heading = ({
type = TYPE_OPTIONS.TITLE0,
align = ALIGN.START,
as: Component = ELEMENT_OPTIONS.DIV,
level,
role,
dataTest,
inverted = false,
spaceAfter,
Expand Down Expand Up @@ -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}
sarkaaa marked this conversation as resolved.
Show resolved Hide resolved
data-a11y-section={dataA11ySection}
className={cx(
"orbit-heading font-base m-0",
Expand Down
23 changes: 20 additions & 3 deletions packages/orbit-components/src/Heading/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,3 +54,5 @@ export interface Props extends Common.Globals, Common.SpaceAfter {
readonly desktop?: MediaQuery;
readonly largeDesktop?: MediaQuery;
}

export type Props = BaseProps & LevelProps;
Loading