-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add/update Heading
, Section
and Article
for heading-level management
#304
Conversation
src/components/H/Section.tsx
Outdated
export const Section: React.FC<BoxProps<'section'>> = ({ children, as = 'section', ...rest }) => { | ||
const level = React.useContext(HeadingLevelContext); | ||
|
||
return ( | ||
<HeadingLevelContext.Provider value={level + 1}> | ||
<Box {...rest} as={as}> | ||
{children} | ||
</Box> | ||
</HeadingLevelContext.Provider> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the past I've also called this component HLevel
and had it only increase the level.
I'm not crazy about the name HLevel
, but having this component (whatever it's named) not render any markup might be preferable if we want to introduce it more easily in panther-enterprise
const Section = ({ children }) => {
const level = React.useContext(HeadingLevelContext)
return (
<HeadingLevelContext.Provider value={level + 1}>{children}</HeadingLevelContext.Provider>
)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that remembering to add an <HLevel />
just for the sake of automatic h{1-6}
tags is too much. Having Section
and Article
components might be a more "straighforward" approach which will seem natural to most devs.
Thought process: whenever you need a section
HTML tag, just use <Section />
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I dig that. Section + Article are the 99% use case where you're gonna increase heading levels. And if you wanna do it without the section
/ article
markup, there's always <Section as="div" />
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for that @benjamminj 💯 ! That's a really interesting approach to deal with the Headings levels. We will be adding some context overhead by using a number of nested providers but since the providers only return a static number there won't be any issue!
Deferring to @3nvi for approval
@kouknick Yep exactly! There's a small first-render overhead but it should be negligible compared to things like Emotion and Apollo. Especially since it's static. It's also rare that we'll see nested providers all the way down to |
c6fcd8a
to
896ea73
Compare
@benjamminj Before even reviewing the code, I'd like to thank you for your initiative here. This is the mentality I was envisioning for this lib. Have this "ready for you without too much thought". |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work here. I like the approach, let's nail down the the API and see if we can do something about multiple h1
tags (no h1
siblings should ever exist unless I'm mistaken)
src/components/H/H.tsx
Outdated
export const H: React.FC<HProps> = React.forwardRef<HTMLHeadingElement, HProps>(function H( | ||
{ increase = 0, ...rest }, | ||
ref | ||
) { | ||
const level = React.useContext(HeadingLevelContext); | ||
|
||
// `Math.min` makes sure that the heading level is never above a h6 | ||
const tag = 'h' + Math.min(level + increase, 6); | ||
return <Box ref={ref} {...rest} as={tag as HTag} />; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: why can't we just implement that in the Heading
component itself? The only downside I see is a constant "context read", but, I mean, emotionn does this all the time.
P.S. A couple of years ago I wrote an article on the perf considerations around React Context & CSS-in-JS
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do think that Heading
should be the primary way that people interact with the heading levels. But it won't be the only way.
Heading
comes with styles baked in, and currently its main responsibility is providing those heading styles. However, sometimes what needs to be an h{1-6}
in markup doesn't have Heading
styles. In those cases all we really want / need is the tag itself, without any styles applied.
However, we could expose a hook instead of an H
component if we'd prefer that to be the API? Under the hood, this would be the same thing as H
, but as a hook instead of a component. Something more like this would be the usage outside of Heading
:
const MyCustomComponent = () => {
const hTag = useHeadingLevel()
return <Box as={hTag}> ... </Box>
}
I don't really mind whether we do a hook or a H
component, but I'd hesitate about only offering the heading level management via the Heading
component. Feels like we'd be starting to mix the "concerns" of Heading
rather than composing a few smaller pieces together
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heading comes with styles baked in, and currently its main responsibility is providing those heading styles.
I kind of disagree with that. It has some default styles like the browser's default stylesheet has, but not something that you have to continuously override. The only thing it has as a default is the size. Literally nothing else.
Feels like we'd be starting to mix the "concerns" of Heading rather than composing a few smaller pieces together
I understand. My mindset behind that is "I don't want to have to think if I should use H
or Heading
". Generally, I wouldn't want to have multiple ways of exposing the same thing, and I'd hate to see <H as={Heading} />
since it just feels weird.
Should we chat in a small call?
src/components/H/Section.tsx
Outdated
export const Section: React.FC<BoxProps<'section'>> = ({ children, as = 'section', ...rest }) => { | ||
const level = React.useContext(HeadingLevelContext); | ||
|
||
return ( | ||
<HeadingLevelContext.Provider value={level + 1}> | ||
<Box {...rest} as={as}> | ||
{children} | ||
</Box> | ||
</HeadingLevelContext.Provider> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that remembering to add an <HLevel />
just for the sake of automatic h{1-6}
tags is too much. Having Section
and Article
components might be a more "straighforward" approach which will seem natural to most devs.
Thought process: whenever you need a section
HTML tag, just use <Section />
src/index.tsx
Outdated
@@ -57,6 +57,7 @@ export { default as FormError } from './components/FormError'; | |||
export { default as FormHelperText } from './components/FormHelperText'; | |||
export { default as Grid } from './components/Grid'; | |||
export { default as SimpleGrid } from './components/SimpleGrid'; | |||
export { default as H, Section } from './components/H'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick: I'd envision Section
(and potentially Article
) living in their own folder
src/components/H/H.tsx
Outdated
* also allowing components to create markup in a vacuum. | ||
*/ | ||
export const H: React.FC<HProps> = React.forwardRef<HTMLHeadingElement, HProps>(function H( | ||
{ increase = 0, ...rest }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm.. Not 100% convinced on the name of the increase
prop. I know it conflictss with other stuff, but perhaps level
is the right word here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think naming the prop level
portrays the right mental model—not if we want these tags to automatically manage levels properly. The primary reason being that level
implies the absolute level, rather than the rest of the component operating with a relative awareness of its level.
// Let's assume we're at the h4 level in the ctx.
// I'd expect this to output an `h2` by a simple reading of the component.
<H level={2}>Heading</H>
I wonder if we can axe level
or increase
entirely? The situations where we would need two adjacent heading levels are decently rare, especially since we don't have SEO as a main concern. In the off-chance that we do need adjacent headings, you can always wrap the heading in a new section to increase the level.
<H>Heading 2</H>
<Section as='span'>
<H>Heading 3</H>
</Section>
@3nvi Awesome, thanks for all this feedback! I'll chew on some ways we can improve the API this week, but gonna hit pause on this while we do Q4 planning. I'll plan on resuming this early next week since it isn't urgent 🎉 ✨ |
@3nvi I'm not 100% sure if we need to handle The constraints on I'm almost wondering if we should just have a separate
Another option would be to bake this into our root-level layout in |
8c43e55
to
a41f18a
Compare
@3nvi @kouknick Had some time to circle back around to this, did a round of edits.
And a couple notes on where I think we can go with this:
|
* NOTE: this component is functionally the same as <Section />, but semantically | ||
* it renders an <article> tag instead of a <section> tag. | ||
*/ | ||
export const Article: React.FC<BoxProps<'article'>> = ({ as = 'article', ...rest }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I imagine these need their own example mdx
files too? Will be essentially the same as the H
example
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. every components/{COMPONENT}
folder needs:
- a test file
- an mdx file for docs (even if minimal)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like the overall approach @benjamminj . Things that I need to approve:
- have
Section
andArticle
have a test & mdx file - help me understand why
level
can never beundefined
(as per my comments) - discuss whether
H
can replaceHeading
and provide evidence as to why this would be wrong
Sorry for being pedantic here, I just want to make sure we keep an "easy to understand" API :)
src/components/H/H.test.tsx
Outdated
expect(getByText('h3').tagName).toEqual('H3'); | ||
}); | ||
|
||
it('does not render above an h6', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we move some of these tests to components/Section/Section.test.tsx
and components/Article/Article.test.tsx
accordingly?
* Increases the heading level of all `H` components inside it by 1. | ||
*/ | ||
export const Section: React.FC<BoxProps<'section'>> = ({ children, as = 'section', ...rest }) => { | ||
const level = React.useContext(HeadingLevelContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
q: I see that we have a default context value, but I'm a bit baffled by something. Can't level
never be undefined
if there's no parent provider?
I see that the related tests pass, but I'm failing to understaand why. In my mind useContext returns undefined
if no provider parent exists, but I see that somehow in your tests that's not the case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't level never be undefined if there's no parent provider?
Nope! The default value to createContext
takes care of that!
In my mind useContext returns undefined if no provider parent exists
This is slightly off from the way Context.Consumer
& useContext
works. If the context is given a default value, that will be provided by useContext
in the event that it's rendered without a provider.
The defaultValue argument is only used when a component does not have a matching Provider above it in the tree.
a41f18a
to
0f453f4
Compare
0f453f4
to
63a05b2
Compare
63a05b2
to
65d96dd
Compare
@3nvi I finally got around to another round of edits on this PR while waiting on a
Done! Each has a test / mdx file.
Explanation here, level will never be undefined even if the headings are rendered outside of a provider.
I was able to roll I also went thru all the existing components using headings and verified that there's no visual changes! Screenshots below! |
{currentMonth.format('MMMM YYYY')} | ||
</Box> | ||
<Box as="h4" fontSize="medium" fontWeight="bold" width="50%" textAlign="center"> | ||
<Box as={Heading} fontSize="medium" fontWeight="bold" width="50%" textAlign="center"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -197,7 +198,7 @@ const DateInput: React.FC<DateInputProps & Omit<TextInputProps, 'value' | 'onCha | |||
icon="arrow-back" | |||
aria-label="Go to previous month" | |||
/> | |||
<Box as="h4" fontSize="medium" fontWeight="bold" tabIndex="-1"> | |||
<Box as={Heading} fontSize="medium" fontWeight="bold" tabIndex="-1"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
src/components/Modal/Modal.tsx
Outdated
@@ -69,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({ | |||
<Card minWidth="400px" position="relative" boxShadow="dark200"> | |||
{title && ( | |||
<Box as="header" borderBottom="1px solid" borderColor="navyblue-300" py={6}> | |||
<Heading as="h4" size="x-small" textAlign="center" id={id}> | |||
<Heading as={Heading} size="x-small" textAlign="center" id={id}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heading as heading :P
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😂
We might even be able to remove this prop entirely now...I'll give it a try
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<Heading as={Heading}
seems completely wrong 😄 Can we not remove the as
? It would render an h2
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this was leftover from one of the previous versions, removed it 😂
@@ -77,7 +78,7 @@ const ControlledAlert = React.forwardRef<HTMLDivElement, ControlledAlertProps>( | |||
<Flex as="header" align="center"> | |||
{icon && <Icon type={icon} mr={2} color={iconColor} size="large" />} | |||
<Box | |||
as="h2" | |||
as={Heading} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that we can merge this. Deferring approval to @3nvi since he had some suggestions/comments but i think that all of them are already addressed. Great job there!
really like how it will just take care of it for you. that type of DX makes you WANT to use it. awesome stuff |
@3nvi have a couple seconds to take a peek at this? Hoping to close this one out before I go on leave 😅 |
* Increases the heading level of all `Heading` components inside it by 1. | ||
*/ | ||
export const Section: React.FC<BoxProps<'section'>> = ({ children, as = 'section', ...rest }) => { | ||
const level = React.useContext(HeadingLevelContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There it's!
Also, I don't get why we have to increase the containing headings by 1?
Sections should always have a heading, with very few exceptions.
So an h1
-> h6
will do the job, but why don't we use an h2 over h3?
I think it would be better to default the hTag
to h2
and don't use the context at all!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I agree that it feels weird for the default heading inside a Section to be h3
. An h1
inside a section
is considered a subheading according to MDN, so I feel that we should perhaps entertain the idea of having heading inside sections be h1
by default.
Outside of a <section />
, I agree that h2
would be a sane default (rather than an h1
), exactly as you have it now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel that we should perhaps entertain the idea of having heading inside sections be h1 by default.
I mentioned this in the original PR description — the HTML spec that MDN mentions there is not implemented by any browser, so for assistive technology the best advice is to implement proper heading levels in the document structure itself. The outline algorithm (automatic leveling based on nested h1
tags) was recently removed from the HTML spec because no browser implemented it.
If the browsers implemented it we wouldn't need this PR since we could just use raw h1
and section
tags
I think it would be better to default the hTag to h2 and don't use the context at all!
Looking at this again, I do think we have an edge case where heading levels skip straight from h1
-> h3
if you place section
tags at the top of the document. I've updated the PR to default section
to h2
.
But we do need to increase the heading levels within if you're already inside a group of heading levels. The main reason is nested Section
or Section -> Article
or Section -> <Section as="aside">
, those inner ones should be one level higher than their parent section, this is a "good" document structure for assistive technology.
This way, no matter where you use a Section
, you're at the correct heading level for where you're at in the document.
const { getByText } = renderWithTheme( | ||
<div> | ||
<Heading>h2</Heading> | ||
<Section> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought section
represents a generic standalone section of a document and not that during nested sections we should increase the heading level!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that was the whole point of this PR 😄 That you don't have to think about h1
vs h2
, but that Pounce would do that for you
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but open-source libraries should act autonomous and do this like that... We are using the components, so we have to structure the HTML semantic as we want and not as Pounce
wants!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @benjamminj! I'm ok to approve as long as we change the default heading inside a section :)
const { getByText } = renderWithTheme( | ||
<div> | ||
<Heading>h2</Heading> | ||
<Section> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that was the whole point of this PR 😄 That you don't have to think about h1
vs h2
, but that Pounce would do that for you
src/components/Modal/Modal.tsx
Outdated
@@ -69,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({ | |||
<Card minWidth="400px" position="relative" boxShadow="dark200"> | |||
{title && ( | |||
<Box as="header" borderBottom="1px solid" borderColor="navyblue-300" py={6}> | |||
<Heading as="h4" size="x-small" textAlign="center" id={id}> | |||
<Heading as={Heading} size="x-small" textAlign="center" id={id}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<Heading as={Heading}
seems completely wrong 😄 Can we not remove the as
? It would render an h2
, right?
* Increases the heading level of all `Heading` components inside it by 1. | ||
*/ | ||
export const Section: React.FC<BoxProps<'section'>> = ({ children, as = 'section', ...rest }) => { | ||
const level = React.useContext(HeadingLevelContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I agree that it feels weird for the default heading inside a Section to be h3
. An h1
inside a section
is considered a subheading according to MDN, so I feel that we should perhaps entertain the idea of having heading inside sections be h1
by default.
Outside of a <section />
, I agree that h2
would be a sane default (rather than an h1
), exactly as you have it now
@@ -77,7 +78,7 @@ const ControlledAlert = React.forwardRef<HTMLDivElement, ControlledAlertProps>( | |||
<Flex as="header" align="center"> | |||
{icon && <Icon type={icon} mr={2} color={iconColor} size="large" />} | |||
<Box | |||
as="h2" | |||
as={Heading} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After reading the comments and the last updates, it LGTM 🎉
Sorry for taking that long 🙏
🎉 This PR is included in version 0.120.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Background
One common a11y problem is skipping headings. It's best for a11y (and SEO, but that doesn't affect
panther-enterprise
) to have heading levels increase in a "semantically decreasing order", or almost as an "outline" of the pageI wanted to open this PR mainly as a starting point for a discussion of how we can solve this in our app. We don't have to go this exact route, but wanted to open the conversation 😅
TL;DR — this PR adds the ability to automate heading levels so that components always render the correct
h2-h6
tagScreen.Recording.2021-10-21.at.5.08.20.PM.mov
The Problem
When working with components as the primary styling block, managing heading levels starts to get tricky. Sure, you can go look at a page and see whether the headings decrease semantically, but that doesn't help make building blocks that can be placed anywhere.
Take this example component
If we render this component as the top-level of a page (say, like a user profile), perhaps
h2
is ok. But let's say it's embedded in another section of our pageIn this case,
h2
isn't semantically correct 😱 We'd be more correct renderingUserCard
with ah3
.Many of our components that require heading levels experience this exact issue—the "proper" heading level depends entirely on where in the DOM the component is rendered.
Proposed Solution
Turns out,
React.Context
can save our bacon and automate 90% of this problem away.This PR adds two components,
H
andSection
. Both components work together to automate heading levels so that anH
tag always displays the correct heading level regardless of where it's rendered.These two components allow you to drop
H
in wherever headings makes sense semantically without needing to worry where the component is rendered. And then parent components (or layout components) can set up<Section>
wrappers to make sure that theh1-h6
progression is correct!Believe it or not, this exact behavior was technically in the HTML5 spec for
h
andsection
, but is not recommended bc no browsers / screenreaders fully implemented it!Rollout plan
H
andSection
topounce
. Convert allpounce
components to not manually set heading levels.panther-enterprise
. Some heading levels will change in the underlying markup, but it will be very few (mostly the defaulth4
tags becomingh2
tags)panther-enterprise
and switch any instances ofas="h3"
(and other levels) to useas={H}
. This should also be a simple search-and-replace.Section
components to the page layouts to allow theH
tags to set the proper contextual level. This will be a little bit more case-by-case sinceSection
has underlying markup so we'll need to check that we're not breaking things. IMO this can be done at the same time as Step 3Resources
Changes
H
andSection
componentsas="h4"
(or other levels) toas={H}
so they will get contextual heading levels.Testing
npm test