├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── ci-and-publish.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ └── stale.yml ├── .gitignore ├── .prettierignore ├── .releaserc.json ├── .size-limit.json ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── assets └── react-headings.png ├── codecov.yml ├── package-lock.json ├── package.json ├── src ├── index.test.tsx └── index.tsx ├── tsconfig.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier" 8 | ], 9 | "env": { 10 | "es6": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci-and-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: ci-and-publish 5 | 6 | on: 7 | push: 8 | branches: [master, next] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x, 16.x, 18.x] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: npm 23 | - run: npm ci 24 | - run: npm run lint 25 | - run: npm test 26 | 27 | publish-npm: 28 | needs: test 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: actions/setup-node@v2 33 | with: 34 | node-version: 18 35 | registry-url: https://registry.npmjs.org/ 36 | cache: npm 37 | - run: npm ci 38 | - run: npm run build 39 | - run: npx semantic-release 40 | env: 41 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 42 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: [master, next] 6 | 7 | jobs: 8 | build-and-test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [12.x, 14.x, 16.x, 18.x] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: npm 20 | - run: npm ci 21 | - run: npm run lint 22 | - run: npm test 23 | - run: npm run build 24 | - name: Upload coverage to Codecov 25 | uses: codecov/codecov-action@v2 26 | size: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: andresz1/size-limit-action@v1 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, next] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: "32 10 * * 3" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: ["javascript"] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v1 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: stale 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * MON" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v3 12 | with: 13 | repo-token: ${{ secrets.GITHUB_TOKEN }} 14 | stale-issue-message: "This issue has been marked as stale." 15 | stale-pr-message: "This PR has been marked as stale." 16 | stale-issue-label: "no-issue-activity" 17 | stale-pr-label: "no-pr-activity" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Builds 5 | dist 6 | types 7 | coverage 8 | 9 | # Misc 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | README.md 5 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master", 4 | { "name": "next", "channel": "next", "prerelease": "next" } 5 | ], 6 | "preset": "conventionalcommits", 7 | "plugins": [ 8 | [ 9 | "@semantic-release/commit-analyzer", 10 | { 11 | "releaseRules": [ 12 | { "type": "docs", "release": "patch" }, 13 | { "type": "refactor", "release": "patch" }, 14 | { "type": "style", "release": "patch" }, 15 | { "type": "test", "release": "patch" }, 16 | { "type": "chore", "release": "patch" } 17 | ], 18 | "parserOpts": { 19 | "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES"] 20 | } 21 | } 22 | ], 23 | [ 24 | "@semantic-release/release-notes-generator", 25 | { 26 | "presetConfig": { 27 | "types": [ 28 | { 29 | "type": "feat", 30 | "section": "Features", 31 | "hidden": false 32 | }, 33 | { 34 | "type": "fix", 35 | "section": "Bug Fixes", 36 | "hidden": false 37 | }, 38 | { 39 | "type": "docs", 40 | "section": "Documentation", 41 | "hidden": false 42 | }, 43 | { 44 | "type": "refactor", 45 | "section": "Code Refactors", 46 | "hidden": false 47 | }, 48 | { 49 | "type": "perf", 50 | "section": "Performance", 51 | "hidden": false 52 | }, 53 | { 54 | "type": "style", 55 | "section": "Styles", 56 | "hidden": false 57 | }, 58 | { 59 | "type": "test", 60 | "section": "Tests", 61 | "hidden": false 62 | }, 63 | { 64 | "type": "chore", 65 | "section": "Chores", 66 | "hidden": false 67 | } 68 | ] 69 | } 70 | } 71 | ], 72 | "@semantic-release/npm", 73 | "@semantic-release/github" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "JS Minified", 4 | "path": "dist/*.js", 5 | "gzip": false, 6 | "limit": "1608B" 7 | }, 8 | { 9 | "name": "JS Compressed", 10 | "path": "dist/*.js", 11 | "limit": "750B" 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Nault 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![React Headings Logo](https://github.com/alexnault/react-headings/raw/master/assets/react-headings.png) 2 | 3 | # React Headings 4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | 20 | > Never worry about using the wrong heading level (`h1`, `h2`, etc.) in complex React apps! 21 | 22 | React-headings maintains the proper hierarchy of headings for improved accessibility and SEO, no matter the component structure, while you keep full control of what's rendered. 23 | 24 | References: 25 | 26 | - [WCAG 2.0 technique H69](https://www.w3.org/TR/WCAG20-TECHS/H69.html) 27 | - [Lighthouse SEO heading order audit](https://web.dev/heading-order/) 28 | 29 | ## Table of contents 30 | 31 | - [Demos](#demos) 32 | - [Highlights](#highlights) 33 | - [Installation](#installation) 34 | - [Examples](#examples) 35 | - [API](#api) 36 | - [Changelog](#changelog) 37 | - [Contributing](#contributing) 38 | 39 | ## Demos 40 | 41 | - [Minimal](https://codesandbox.io/s/react-headings-minimal-4temt?file=/src/Demo.js) 42 | - [Custom component](https://codesandbox.io/s/react-headings-custom-component-l4bjb?file=/src/Demo.js) 43 | - [Advanced structure](https://codesandbox.io/s/react-headings-advanced-structure-uxk4p?file=/src/Demo.js) 44 | 45 | ## Highlights 46 | 47 | - Improves SEO and accessibility 48 | - Supports server-side rendering 49 | - Under 1 kB minified & gzipped 50 | - Typed with TypeScript 51 | - Fully tested 52 | - Works with any CSS solutions (Tailwind, CSS-in-JS, etc.) 53 | - Plays nicely with component libraries (Material UI, etc.) 54 | - Follows [semantic versioning](https://semver.org/) 55 | 56 | ## Installation 57 | 58 | ```bash 59 | npm install react-headings 60 | ``` 61 | 62 | ## Examples 63 | 64 | ### Basic usage 65 | 66 | ```jsx 67 | import React from "react"; 68 | import { H, Section } from "react-headings"; 69 | 70 | function App() { 71 | return ( 72 |
My hx}> 73 |
...
74 |
...
75 |
...
76 |
My hx+1}> 77 |
...
78 |
...
79 |
...
80 |
81 |
82 | ); 83 | } 84 | ``` 85 | 86 | ### Advanced structure 87 | 88 | Child components inherit the current level of their parent: 89 | 90 | ```jsx 91 | import React from "react"; 92 | import { H, Section } from "react-headings"; 93 | 94 | function ParentComponent() { 95 | return ( 96 |
My hx}> 97 |
My hx+1}> 98 |
My hx+2}> 99 | 100 |
101 |
102 |
103 | ); 104 | } 105 | 106 | function ChildComponent() { 107 | return ( 108 |
My hy}> 109 | {/* The following heading would be a
in the current context */} 110 |
My hy+1}> 111 |

...

112 |
113 |
114 | ); 115 | } 116 | ``` 117 | 118 | ### Styling 119 | 120 | A heading can be styled like any ordinary `` element since it accepts all the same props: 121 | 122 | ```jsx 123 | import React from "react"; 124 | import { H, Section } from "react-headings"; 125 | 126 | function App() { 127 | return ( 128 |
My hx}> 129 | ... 130 |
131 | ); 132 | } 133 | ``` 134 | 135 | ### Custom heading 136 | 137 | A heading can be as complex as we want: 138 | 139 | ```jsx 140 | import React from "react"; 141 | import { H, Section } from "react-headings"; 142 | import MyIcon from "./MyIcon"; 143 | 144 | function App() { 145 | return ( 146 |
149 | 150 | My hx 151 | 152 | } 153 | > 154 |
...
155 |
...
156 |
...
157 |
158 | ); 159 | } 160 | ``` 161 | 162 | ### Using component libraries 163 | 164 | Leveraging `Component` and `level` from the context allows the use of component libraries. 165 | Here's an example with [Material UI](https://material-ui.com/api/typography/): 166 | 167 | ```jsx 168 | import React from "react"; 169 | import { useLevel } from "react-headings"; 170 | import { Typography } from "@material-ui/core"; 171 | 172 | function MyHeading(props) { 173 | const { Component } = useLevel(); 174 | 175 | return ; 176 | } 177 | ``` 178 | 179 | ## API 180 | 181 | ### `` component 182 | 183 | Renders a `

`, `

`, `

`, `

`, `

` or `
` depending on the current level. 184 | 185 | #### Props 186 | 187 | | Name | Type | Required | Description | 188 | | ---------- | ---------- | -------- | --------------------------------------------------------------- | 189 | | `render` | `function` | No | Override with a custom heading. Has precedence over `children`. | 190 | | `children` | `node` | No | The content of the heading. Usually the title. | 191 | 192 | Any other props will be passed to the heading element. 193 | 194 | #### Example 195 | 196 | ```jsx 197 | import React from "react"; 198 | import { H } from "react-headings"; 199 | 200 | function Example1() { 201 | return This is my title; 202 | } 203 | 204 | function Example2() { 205 | return ( 206 | My h{level}} /> 207 | ); 208 | } 209 | ``` 210 | 211 | ### `
` component 212 | 213 | Creates a new section (a heading and its level). 214 | 215 | #### Props 216 | 217 | | Name | Type | Required | Description | 218 | | ----------- | ------ | -------- | ------------------------------------------------------------------------------- | 219 | | `component` | `node` | Yes | The heading component. Can be anything but best used in combination with ``. | 220 | | `children` | `node` | No | The content of the new level. | 221 | 222 | #### Example 223 | 224 | ```jsx 225 | import React from "react"; 226 | import { Section, H } from "react-headings"; 227 | 228 | function Example1() { 229 | return ( 230 |
This is my title}> 231 | This is my content 232 |
233 | ); 234 | } 235 | 236 | function Example2() { 237 | return ( 238 |
241 |
242 | This is my title 243 |
244 | 245 | } 246 | > 247 | This is my content 248 |
249 | ); 250 | } 251 | ``` 252 | 253 | ### `useLevel` hook 254 | 255 | Returns an object containing the current `level` and current `Component`. 256 | 257 | #### Arguments 258 | 259 | None 260 | 261 | #### Returns 262 | 263 | | Name | Type | Description | 264 | | ----------- | -------------------------------------------------------- | ------------------------------------- | 265 | | `level` | `1` \| `2` \| `3` \| `4` \| `5` \| `6` | The current level. | 266 | | `Component` | `"h1"` \| `"h2"` \| `"h3"` \| `"h4"` \| `"h5"` \| `"h6"` | The current component. Same as level. | 267 | 268 | #### Example 269 | 270 | ```jsx 271 | import React from "react"; 272 | import { useLevel } from "react-headings"; 273 | 274 | function Example(props) { 275 | const { level, Component } = useLevel(); 276 | 277 | return This is a h{level}; 278 | } 279 | ``` 280 | 281 | ## Changelog 282 | 283 | For a list of changes and releases, see the [changelog](https://github.com/alexnault/react-headings/releases). 284 | 285 | ## Contributing 286 | 287 | Found a bug, have a question or looking to improve react-headings? Open an [issue](https://github.com/alexnault/react-headings/issues/new), start a [discussion](https://github.com/alexnault/react-headings/discussions/new) or submit a [PR](https://github.com/alexnault/react-headings/fork)! 288 | -------------------------------------------------------------------------------- /assets/react-headings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexnault/react-headings/5ab92f31c77769335d6323ada1dbb1d897750dcc/assets/react-headings.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 100% 6 | threshold: 0% 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-headings", 3 | "version": "1.0.0-semantic-release", 4 | "description": "HTML headings with auto-incrementing levels for WCAG compliance.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "author": "Alex Nault", 8 | "keywords": [ 9 | "react", 10 | "heading", 11 | "auto", 12 | "level", 13 | "accessibility", 14 | "a11y", 15 | "wcag", 16 | "h1", 17 | "header", 18 | "title" 19 | ], 20 | "license": "MIT", 21 | "repository": "https://github.com/alexnault/react-headings", 22 | "homepage": "https://github.com/alexnault/react-headings#readme", 23 | "scripts": { 24 | "build": "rm -rf ./dist && tsc", 25 | "format": "prettier ./ --write", 26 | "lint": "eslint ./ --max-warnings=0", 27 | "size": "size-limit", 28 | "test": "vitest run --coverage" 29 | }, 30 | "files": [ 31 | "dist" 32 | ], 33 | "sideEffects": false, 34 | "peerDependencies": { 35 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 36 | }, 37 | "devDependencies": { 38 | "@size-limit/preset-small-lib": "^7.0.8", 39 | "@testing-library/react": "^13.0.1", 40 | "@types/react": "^18.0.3", 41 | "@types/react-dom": "^18.0.0", 42 | "@typescript-eslint/eslint-plugin": "^4.33.0", 43 | "@typescript-eslint/parser": "^4.33.0", 44 | "@vitest/coverage-c8": "^0.30.1", 45 | "conventional-changelog-conventionalcommits": "^4.6.1", 46 | "eslint": "^7.32.0", 47 | "eslint-config-prettier": "^8.3.0", 48 | "jsdom": "^21.1.1", 49 | "prettier": "^2.7.1", 50 | "react": "^18.0.0", 51 | "react-dom": "^18.0.0", 52 | "semantic-release": "^21.0.1", 53 | "size-limit": "^7.0.8", 54 | "typescript": "^4.4.3", 55 | "vitest": "^0.30.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, cleanup } from "@testing-library/react"; 3 | import { describe, it, expect, afterEach } from "vitest"; 4 | 5 | import { H, Section, useLevel } from "./index"; 6 | 7 | afterEach(() => { 8 | cleanup(); 9 | }); 10 | 11 | describe("useLevel hook", () => { 12 | it("should be level 1 by default", () => { 13 | function MyComponent() { 14 | const { level, Component } = useLevel(); 15 | 16 | expect(level).toBe(1); 17 | expect(Component).toBe("h1"); 18 | 19 | return null; 20 | } 21 | 22 | render(); 23 | }); 24 | 25 | it("should be level 2 when 1 level down", () => { 26 | function MyComponent() { 27 | const { level, Component } = useLevel(); 28 | 29 | expect(level).toBe(2); 30 | expect(Component).toBe("h2"); 31 | 32 | return null; 33 | } 34 | 35 | render( 36 |
My H1}> 37 | 38 |
39 | ); 40 | }); 41 | 42 | it("should be level 6 when at level 7 or more", () => { 43 | function MyComponent() { 44 | const { level, Component } = useLevel(); 45 | 46 | expect(level).toBe(6); 47 | expect(Component).toBe("h6"); 48 | 49 | return null; 50 | } 51 | 52 | render( 53 |
My H1}> 54 |
My H2}> 55 |
My H3}> 56 |
My H4}> 57 |
My H5}> 58 |
My H6}> 59 |
My H6-2}> 60 | 61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | ); 69 | }); 70 | }); 71 | 72 | describe("H component", () => { 73 | it("should be level 1 by default", () => { 74 | const { getByText } = render(My H1); 75 | 76 | const headingEl = getByText("My H1"); 77 | 78 | expect(headingEl.tagName).toBe("H1"); 79 | }); 80 | 81 | it("should forward HTML heading props", () => { 82 | const { getByText } = render(My H1); 83 | 84 | const headingEl = getByText("My H1"); 85 | 86 | expect(headingEl.className).toBe("myClass"); 87 | }); 88 | 89 | it("should render a custom component", () => { 90 | const { getByText } = render( My span} />); 91 | 92 | const headingEl = getByText("My span"); 93 | 94 | expect(headingEl.tagName).toBe("SPAN"); 95 | }); 96 | 97 | it("should render a custom component based on arguments", () => { 98 | const { getByText } = render( 99 |
My H1}> 100 |
( 104 | My H{level} 105 | )} 106 | /> 107 | } 108 | >
109 |
110 | ); 111 | 112 | const headingEl = getByText("My H2"); 113 | 114 | expect(headingEl.tagName).toBe("H2"); 115 | }); 116 | 117 | it("should forward ref", () => { 118 | const ref = React.createRef(); 119 | const { container } = render(Heading); 120 | expect(ref.current).toBe(container.firstElementChild); 121 | }); 122 | }); 123 | 124 | describe("Section component", () => { 125 | it("should be level 1 in first section", () => { 126 | const { getByText } = render(
My H1}>
); 127 | 128 | const headingEl = getByText("My H1"); 129 | 130 | expect(headingEl.tagName).toBe("H1"); 131 | }); 132 | 133 | it("should be level 2 in second section", () => { 134 | const { getByText } = render( 135 |
My H1}> 136 |
My H2}>
137 |
138 | ); 139 | 140 | const headingEl = getByText("My H2"); 141 | 142 | expect(headingEl.tagName).toBe("H2"); 143 | }); 144 | 145 | it("should be level 6 in 7th section", () => { 146 | const { getByText } = render( 147 |
My H1}> 148 |
My H2}> 149 |
My H3}> 150 |
My H4}> 151 |
My H5}> 152 |
My H6}> 153 |
My H6-2}>
154 |
155 |
156 |
157 |
158 |
159 |
160 | ); 161 | 162 | const headingEl = getByText("My H6-2"); 163 | 164 | expect(headingEl.tagName).toBe("H6"); 165 | }); 166 | 167 | it("should render a heading and its content", () => { 168 | const { getByText } = render( 169 |
My H1}> 170 |

My content

171 |
172 | ); 173 | 174 | const headingEl = getByText("My H1"); 175 | 176 | expect(headingEl.tagName).toBe("H1"); 177 | 178 | const pEl = getByText("My content"); 179 | 180 | expect(pEl.tagName).toBe("P"); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Level = 1 | 2 | 3 | 4 | 5 | 6; 4 | 5 | type Heading = `h${Level}`; 6 | 7 | type LevelContextValue = { level: Level; Component: Heading }; 8 | 9 | const LevelContext = React.createContext({ 10 | level: 1, 11 | Component: "h1", 12 | }); 13 | 14 | /** 15 | * Returns the current heading and level. 16 | */ 17 | export function useLevel(): LevelContextValue { 18 | return React.useContext(LevelContext); 19 | } 20 | 21 | type HProps = React.DetailedHTMLProps< 22 | React.HTMLAttributes, 23 | HTMLHeadingElement 24 | > & { 25 | render?: (context: LevelContextValue) => React.ReactElement; 26 | }; 27 | 28 | function InternalH( 29 | { render, ...props }: HProps, 30 | forwardedRef: React.ForwardedRef 31 | ): JSX.Element { 32 | const context = useLevel(); 33 | 34 | if (render) { 35 | return render(context); 36 | } 37 | 38 | return ; 39 | } 40 | 41 | /** 42 | * Renders a dynamic HTML heading (h1, h2, etc.) or custom component according to the current level. 43 | */ 44 | export const H = React.forwardRef(InternalH); 45 | 46 | type SectionProps = { 47 | component: React.ReactNode; 48 | children?: React.ReactNode; 49 | }; 50 | 51 | /** 52 | * Renders `component` in the current level and `children` in the next level. 53 | * @param component A component containing a heading 54 | * @param children The children in the next level 55 | */ 56 | export function Section({ component, children }: SectionProps): JSX.Element { 57 | const { level } = useLevel(); 58 | 59 | const nextLevel = Math.min(level + 1, 6) as Level; 60 | 61 | const value = { 62 | level: nextLevel, 63 | Component: `h${nextLevel}` as Heading, 64 | }; 65 | 66 | return ( 67 | <> 68 | {component} 69 | {children} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "isolatedModules": false, 5 | "jsx": "react", 6 | "lib": ["esnext", "dom"], 7 | "outDir": "dist", 8 | "target": "es5", 9 | "declaration": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "skipLibCheck": true, 16 | "strict": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] 20 | } 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | coverage: { 7 | provider: "c8", 8 | reporter: ["lcov", "text"], 9 | branches: 100, 10 | functions: 100, 11 | lines: 100, 12 | statements: 100, 13 | }, 14 | }, 15 | }); 16 | --------------------------------------------------------------------------------