Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/perf-pageheader-has-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

PageHeader: hoist title-size and navigation-visibility state from descendants onto the root and replace 18 `:has()` selectors with plain attribute selectors. Same rendered output; the engine no longer re-evaluates subtree-scoped `:has()` selectors on every DOM mutation inside a `PageHeader`.
40 changes: 21 additions & 19 deletions packages/react/src/PageHeader/PageHeader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,28 @@
line-height is calculated with calc(height/font-size) and the below numbers are from @primer/primitives.
--custom-font-size, --custom-line-height, --custom-font-weight are custom properties that can be used to override the below values.
We don't want these values to be overridden but still want to allow consumers to override them if needed.

Size + nav-visibility data attributes are hoisted to the root by the React
component to keep selectors as plain attribute matches (cheap) rather than
relying on `:has()` (per-descendant invalidation).
*/
/* stylelint-disable selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-component='TitleArea'][data-size-variant='large']) {
&[data-title-size-variant='large'] {
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5)); /* calc(48/32) */

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant='medium']) {
&[data-title-size-variant='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6)); /* calc(32/20) */

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant='subtitle']) {
&[data-title-size-variant='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6)); /* calc(32/20) */
Expand All @@ -61,23 +64,23 @@

/* Responsive size variants */
@media (--viewportRange-narrow) {
&:has([data-component='TitleArea'][data-size-variant-narrow='large']) {
&[data-title-size-variant-narrow='large'] {
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-narrow='medium']) {
&[data-title-size-variant-narrow='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-narrow='subtitle']) {
&[data-title-size-variant-narrow='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -87,23 +90,23 @@
}

@media (--viewportRange-regular) {
&:has([data-component='TitleArea'][data-size-variant-regular='large']) {
&[data-title-size-variant-regular='large'] {
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-regular='medium']) {
&[data-title-size-variant-regular='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-regular='subtitle']) {
&[data-title-size-variant-regular='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -113,23 +116,23 @@
}

@media (--viewportRange-wide) {
&:has([data-component='TitleArea'][data-size-variant-wide='large']) {
&[data-title-size-variant-wide='large'] {
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-large, 1.5));
}

&:has([data-component='TitleArea'][data-size-variant-wide='medium']) {
&[data-title-size-variant-wide='medium'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-semibold, 600));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));

--title-line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
}

&:has([data-component='TitleArea'][data-size-variant-wide='subtitle']) {
&[data-title-size-variant-wide='subtitle'] {
font-size: var(--custom-font-size, var(--text-title-size-medium, 1.25rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
line-height: var(--custom-line-height, var(--text-title-lineHeight-medium, 1.6));
Expand All @@ -138,33 +141,32 @@
}
}

&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-all]),
&[data-has-border='true']:not(:has([data-component='PH_Navigation'])) {
&[data-has-border='true'][data-nav-hidden-all],
&[data-has-border='true']:not([data-has-nav]) {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}

@media (--viewportRange-narrow) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-narrow]) {
&[data-has-border='true'][data-nav-hidden-narrow] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}

@media (--viewportRange-regular) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-regular]) {
&[data-has-border='true'][data-nav-hidden-regular] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}

@media (--viewportRange-wide) {
&[data-has-border='true']:has([data-component='PH_Navigation'][data-hidden-wide]) {
&[data-has-border='true'][data-nav-hidden-wide] {
border-block-end: var(--borderWidth-thin) solid var(--borderColor-default);
padding-block-end: var(--base-size-8);
}
}
/* stylelint-enable selector-pseudo-class-disallowed-list */

& [data-component='PH_LeadingAction'],
& [data-component='PH_TrailingAction'],
Expand Down
48 changes: 37 additions & 11 deletions packages/react/src/PageHeader/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ const Root = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageHeader
({children, className, as: BaseComponent = 'div', 'aria-label': ariaLabel, role, hasBorder}, forwardedRef) => {
const rootRef = useProvidedRefOrCreate<HTMLDivElement>(forwardedRef as React.RefObject<HTMLDivElement>)

// Hoist title size + navigation visibility off direct children onto the
// root so styling can use plain attribute selectors instead of `:has()`.
let titleVariant: TitleAreaProps['variant'] = 'medium'
let hasNavigation = false
let navigationHidden: NavigationProps['hidden'] | undefined
for (const child of React.Children.toArray(children)) {
if (!React.isValidElement(child)) continue
if (child.type === TitleArea) {
titleVariant = (child.props as TitleAreaProps).variant ?? 'medium'
} else if (child.type === Navigation) {
hasNavigation = true
navigationHidden = (child.props as NavigationProps).hidden ?? false
}
}
Comment on lines +59 to +67

const isInteractive = (element: HTMLElement) => {
return (
['a', 'button'].some(selector => element.matches(selector)) ||
Expand Down Expand Up @@ -108,6 +123,9 @@ const Root = React.forwardRef<HTMLDivElement, React.PropsWithChildren<PageHeader
ref={rootRef}
className={clsx(classes.PageHeader, className)}
data-has-border={hasBorder ? 'true' : undefined}
data-has-nav={hasNavigation ? '' : undefined}
{...getResponsiveAttributes('title-size-variant', titleVariant)}
{...getNavHiddenDataAttributes(navigationHidden)}
aria-label={ariaLabel}
role={role}
>
Expand Down Expand Up @@ -374,41 +392,44 @@ const Navigation: React.FC<React.PropsWithChildren<NavigationProps>> = ({

// Based on getBreakpointDeclarations, this function will return the
// correct data attribute for the given hidden value for CSS modules.
function getHiddenDataAttributes(isHidden: boolean | ResponsiveValue<boolean>): {
'data-hidden-all'?: boolean
'data-hidden-narrow'?: boolean
'data-hidden-regular'?: boolean
'data-hidden-wide'?: boolean
} {
function getHiddenDataAttributes(
isHidden: boolean | ResponsiveValue<boolean>,
prefix: 'hidden' | 'nav-hidden' = 'hidden',
): Record<string, boolean | undefined> {
const all = `data-${prefix}-all`
const narrow = `data-${prefix}-narrow`
const regular = `data-${prefix}-regular`
const wide = `data-${prefix}-wide`

if (isResponsiveValue(isHidden)) {
const responsiveValue = isHidden

// Build media queries with the giving cssProperty and mapFn
const narrowMediaQuery =
'narrow' in responsiveValue
? {
'data-hidden-narrow': responsiveValue.narrow || undefined,
[narrow]: responsiveValue.narrow || undefined,
}
: {}

const regularMediaQuery =
'regular' in responsiveValue
? {
'data-hidden-regular': responsiveValue.regular || undefined,
[regular]: responsiveValue.regular || undefined,
}
: {}

const wideMediaQuery =
'wide' in responsiveValue
? {
'data-hidden-wide': responsiveValue.wide || undefined,
[wide]: responsiveValue.wide || undefined,
}
: {}

// check if all values are the same - this is not a recommended practice but we still should check for it
if (areAllValuesTheSame(responsiveValue)) {
// if all the values are the same, we can just use one of the value to determine the CSS property's value
return {'data-hidden-all': responsiveValue.narrow || undefined}
return {[all]: responsiveValue.narrow || undefined}
// check if regular and wide have the same value, if so we can just return the narrow and regular media queries
} else if (haveRegularAndWideSameValue(responsiveValue)) {
return {
Expand All @@ -424,10 +445,15 @@ function getHiddenDataAttributes(isHidden: boolean | ResponsiveValue<boolean>):
}
} else {
// If the given value is not a responsive value
return {'data-hidden-all': isHidden || undefined}
return {[all]: isHidden || undefined}
}
}

function getNavHiddenDataAttributes(isHidden: boolean | ResponsiveValue<boolean> | undefined) {
if (isHidden === undefined) return undefined
return getHiddenDataAttributes(isHidden, 'nav-hidden')
}

export const PageHeader = Object.assign(Root, {
ContextArea,
ParentLink,
Expand Down
Loading