-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ (@hzdg/sectioning) add sectioning package
- Loading branch information
Showing
9 changed files
with
633 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Change Log | ||
|
||
All notable changes to this project will be documented in this file. | ||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,253 @@ | ||
--- | ||
name: sectioning | ||
menu: UI Components | ||
route: /sectioning | ||
--- | ||
|
||
import {Playground} from 'docz'; | ||
import H from './src/H'; | ||
import {Section, Article} from './src/sectioning'; | ||
import {Body, Blockquote} from './src/sectioningRoot'; | ||
import {useSectionLevel, useNextSectionLevel} from './src/sectionLevel'; | ||
|
||
# Sectioning | ||
|
||
Sectioning components are React [Components] that wrap [sectioning content]. | ||
Sectioning represents a thematically similar section of content, | ||
and establishes or participates in an HTML5 [document outline], | ||
or heading level (h1, h2, etc) hierarchy. | ||
|
||
For a good overview of using HTML5 semantics to section content | ||
(and the many pitfalls), see: | ||
|
||
- [How to Section your HTML] | ||
- [Why You Should Choose HTML5 `article` Over `section`] | ||
- [A Decade of Heading Backwards] | ||
|
||
## Rationale | ||
|
||
One common piece of advice amongst devs who've read the [spec] is to | ||
'just use `<h1>` everywhere'. The spec even says it: | ||
|
||
> …authors are strongly encouraged to either use only h1 elements, | ||
> or to use elements of the appropriate rank for the section's nesting level. | ||
However, in practice, [there is no document outline algorithm]. So, | ||
it turns out that using "elements of the appropriate rank for | ||
the section's nesting level" is the _only viable option_. | ||
|
||
You may be wondering, given the above, why would you want to use | ||
a set of components that purports to conform to that spec? | ||
|
||
**The short answer**: Rendering a specific heading level (e.g., `<h2>`) | ||
implicitly _couples_ a component to a page structure, which limits | ||
composability and reuse, and these components let you break that coupling. | ||
|
||
**The longer answer**: | ||
|
||
Composition is a core feature of React, and a component that renders | ||
a heading level element is inherently _less_ composable than one | ||
that does not, since the browser (or assistive technology) _will not_ | ||
apply document outline semantics to 'fix' your heading levels for you. | ||
|
||
An `<h2>` in the wrong place (say, after an `<h3>`) could be confusing | ||
for assistive technologies and search engines, and explicilty rendering | ||
`<h2>` or `<h3>` in your components makes it very easy to uknowingly | ||
create this problem. | ||
|
||
Instead, you could use the sectioning [H] component anywhere where you | ||
might otherwise use an `<h1>` (or `<h2>`, or `<h3>`...). This way, when | ||
you compose your component with other, more general components | ||
(using [Sectioning components]), the rendered elements will | ||
_automatically_ be "the appropriate rank for the section's nesting level"! | ||
|
||
In other words, these sectioning components let you write code as if | ||
the document outline was a thing, even though it's not. | ||
|
||
## Installation | ||
|
||
```shell | ||
yarn add @hzdg/sectioning | ||
``` | ||
|
||
## Usage | ||
|
||
```jsx | ||
import {Body, Section, Article, Blockquote, H} from '@hzdg/sectioning'; | ||
``` | ||
|
||
<Playground> | ||
<Body> | ||
<H>This H renders an h1 in a Body sectioning root context</H> | ||
<Section> | ||
<H>This H renders an h2 in a Section sectioning content context</H> | ||
<Article> | ||
<H> | ||
This H renders an h3 in a nested Article sectioning content context | ||
</H> | ||
<Blockquote> | ||
<H> | ||
This H renders an h1 in a nested Blockquote sectioning root context | ||
</H> | ||
</Blockquote> | ||
<H level={4}> | ||
This H renders an h4 override (would be h3) in a nested Article | ||
sectioning content context | ||
</H> | ||
</Article> | ||
</Section> | ||
</Body> | ||
</Playground> | ||
|
||
## Sectioning Components | ||
|
||
The [sectioning content] components increase the [heading level] for their | ||
descendants by one for each level of nesting, from 2 to 6. | ||
|
||
The sectioning content components are: | ||
|
||
- `Article` | ||
- `Aside` | ||
- `Nav` | ||
- `Section` | ||
|
||
These components can be used in place of their intrinsic counterparts, | ||
e.g. `<article>`, `<section>`, etc. They will render the corresponding | ||
element (with all props, attributes, ref, etc.), but also wrap the children | ||
in a [Section Level Context]. | ||
|
||
### Sectioning Root Components | ||
|
||
In contrast to the components above, [sectioning root] components _reset_ | ||
the heading level to 1 for their descendants, _regardless_ of the heading level | ||
in which they may be nested. | ||
|
||
The sectioning root components are: | ||
|
||
- `Body` | ||
- `Blockquote` | ||
- `Details` | ||
- `Dialog` | ||
- `Fieldset` | ||
- `Figure` | ||
- `Td` | ||
|
||
> **NOTE:** The document outline [spec] has **never been implemented** | ||
> by any major web browser or assistive technology. This means that | ||
> these sectioning root components may confound [heading level] expectations | ||
> in browsers and screen readers, so use them with caution! | ||
## H | ||
|
||
A component that renders a [heading level] element. It is used like intrinsic | ||
`<h1>`, `<h2>` , etc, but the level is _automatically_ determined by its context. | ||
|
||
See [Sectioning Components] for more. | ||
|
||
```jsx | ||
import {H} from '@hzdg/sectioning'; | ||
``` | ||
|
||
<Playground> | ||
<H>This H renders an h1 with no sectioning context</H> | ||
</Playground> | ||
|
||
### With a `level` prop | ||
|
||
<Playground> | ||
<H level={2}> | ||
This H renders an h2 override (would be h1) with no sectioning context | ||
</H> | ||
</Playground> | ||
|
||
## Section Level Context | ||
|
||
This is not normally used directly (use the [Sectioning Components] and [H] | ||
instead), and are mostly an implementation detail, but they are exported | ||
for convenience and completeness. | ||
|
||
### SectionLevelProvider | ||
|
||
A React [context] provider that establishes a sectioning context for | ||
descendant sectioning and heading content. | ||
|
||
```jsx | ||
import {SectionLevelProvider} from '@hzdg/sectioning'; | ||
|
||
function CustomSectioningRootComponent({children, ...props}) { | ||
return ( | ||
<div {...props}> | ||
<SectionLevelProvider value={1}>{children}</SectionLevelProvider> | ||
</div> | ||
); | ||
} | ||
``` | ||
|
||
### useSectionLevel | ||
|
||
A React [hook] that returns the current sectioning level, as provided by | ||
the nearest [SectionLevelProvider]. | ||
|
||
```jsx | ||
import {useSectionLevel} from '@hzdg/sectioning'; | ||
``` | ||
|
||
<Playground> | ||
{() => { | ||
function SectionLevelUser() { | ||
const level = useSectionLevel(); | ||
return <H>the current section level is {level}</H>; | ||
} | ||
return <SectionLevelUser />; | ||
}} | ||
</Playground> | ||
|
||
### useNextSectionLevel | ||
|
||
A React [hook] that returns the current sectioning level, incremented by 1, | ||
up to the maximum level of 6, as provided by the nearest [SectionLevelProvider]. | ||
|
||
Note that, as the minimum section level is 1, the default | ||
return value will be 2 when no sectioning context has been established. | ||
|
||
```jsx | ||
import {useNextSectionLevel} from '@hzdg/sectioning'; | ||
``` | ||
|
||
<Playground> | ||
{() => { | ||
function NextSectionLevelUser() { | ||
const level = useNextSectionLevel(); | ||
return ( | ||
<React.Fragment> | ||
<H>This section's level is {level - 1} (one less than the next).</H> | ||
<Section> | ||
<H>This nested section's level matches {level}</H> | ||
</Section> | ||
</React.Fragment> | ||
); | ||
} | ||
return <NextSectionLevelUser />; | ||
}} | ||
</Playground> | ||
|
||
[components]: https://reactjs.org/docs/components-and-props.html | ||
[hook]: https://reactjs.org/docs/hooks-intro.html | ||
[context]: https://reactjs.org/docs/context.html | ||
[sectioning content]: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Sectioning_content | ||
[sectioning root]: https://html.spec.whatwg.org/multipage/sections.html#sectioning-root | ||
[heading content]: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Heading_content | ||
[document outline]: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_HTML_sections_and_outlines | ||
[heading level]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements | ||
[how to section your html]: https://css-tricks.com/how-to-section-your-html/ | ||
[why you should choose html5 `article` over `section`]: https://www.smashingmagazine.com/2020/01/html5-article-section/ | ||
[a decade of heading backwards]: https://codepen.io/stevef/post/a-decade-of-heading-backwards | ||
[there is no document outline algorithm]: https://adrianroselli.com/2016/08/there-is-no-document-outline-algorithm.html | ||
[spec]: https://html.spec.whatwg.org/multipage/sections.html#headings-and-sections | ||
[live example]: #nested-in-sectioning-contexts | ||
[sectioning components]: #sectioning-components | ||
[section level context]: #section-level-context | ||
[sectionlevelprovider]: #sectionlevelprovider | ||
[usesectionlevel]: #usesectionlevel | ||
[usenextsectionlevel]: #usenextsectionlevel | ||
[h]: #h |
124 changes: 124 additions & 0 deletions
124
packages/ui-components/sectioning/__tests__/sectioning_test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
/* eslint-env jest, browser */ | ||
import React from 'react'; | ||
import {render, screen} from '@testing-library/react'; | ||
import { | ||
H, | ||
Section, | ||
Article, | ||
Aside, | ||
Nav, | ||
Body, | ||
Blockquote, | ||
Details, | ||
Dialog, | ||
Fieldset, | ||
Figure, | ||
Td, | ||
} from '../src'; | ||
|
||
describe.each` | ||
Component | renders | ||
${Section} | ${'section'} | ||
${Article} | ${'article'} | ||
${Aside} | ${'aside'} | ||
${Nav} | ${'nav'} | ||
`('Sectioning content components', ({Component, renders}) => { | ||
describe(Component.displayName, () => { | ||
it(`renders ${renders}`, () => { | ||
render(<Component data-testid={renders} />); | ||
expect(screen.getByTestId(renders)).toBeInTheDocument(); | ||
}); | ||
|
||
it(`increments heading level`, () => { | ||
render( | ||
<Component> | ||
<H>first</H> | ||
<Component> | ||
<H>second</H> | ||
<Component> | ||
<H>third</H> | ||
</Component> | ||
</Component> | ||
<H>fourth</H> | ||
</Component>, | ||
); | ||
const first = screen.getByText('first'); | ||
expect(first).toBeInTheDocument(); | ||
expect(first.tagName).toBe('H2'); | ||
|
||
const second = screen.getByText('second'); | ||
expect(second).toBeInTheDocument(); | ||
expect(second.tagName).toBe('H3'); | ||
|
||
const third = screen.getByText('third'); | ||
expect(third).toBeInTheDocument(); | ||
expect(third.tagName).toBe('H4'); | ||
|
||
const fourth = screen.getByText('fourth'); | ||
expect(fourth).toBeInTheDocument(); | ||
expect(fourth.tagName).toBe('H2'); | ||
}); | ||
}); | ||
}); | ||
|
||
describe.each` | ||
Component | renders | ||
${Body} | ${'body'} | ||
${Blockquote} | ${'blockquote'} | ||
${Details} | ${'details'} | ||
${Dialog} | ${'dialog'} | ||
${Fieldset} | ${'fieldset'} | ||
${Figure} | ${'figure'} | ||
${Td} | ${'td'} | ||
`('Sectioning root components', ({Component, renders}) => { | ||
describe(Component.displayName, () => { | ||
const originalConsoleError = console.error; | ||
|
||
beforeEach(() => { | ||
// Hijack console.error to surpress warnings about invalid DOM nesting. | ||
console.error = (...args: Parameters<typeof console.error>) => { | ||
const [msg] = args; | ||
if (msg && msg.startsWith('Warning: validateDOMNesting')) return; | ||
originalConsoleError(...args); | ||
}; | ||
}); | ||
|
||
afterEach(() => { | ||
console.error = originalConsoleError; | ||
}); | ||
it(`renders ${renders}`, () => { | ||
render(<Component data-testid={renders} />); | ||
expect(screen.getByTestId(renders)).toBeInTheDocument(); | ||
}); | ||
|
||
it(`resets heading level`, () => { | ||
render( | ||
<Component> | ||
<H>first</H> | ||
<Component> | ||
<H>second</H> | ||
<Component> | ||
<H>third</H> | ||
</Component> | ||
</Component> | ||
<H>fourth</H> | ||
</Component>, | ||
); | ||
const first = screen.getByText('first'); | ||
expect(first).toBeInTheDocument(); | ||
expect(first.tagName).toBe('H1'); | ||
|
||
const second = screen.getByText('second'); | ||
expect(second).toBeInTheDocument(); | ||
expect(second.tagName).toBe('H1'); | ||
|
||
const third = screen.getByText('third'); | ||
expect(third).toBeInTheDocument(); | ||
expect(third.tagName).toBe('H1'); | ||
|
||
const fourth = screen.getByText('fourth'); | ||
expect(fourth).toBeInTheDocument(); | ||
expect(fourth.tagName).toBe('H1'); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.