)
+ // Hoist title size + navigation visibility off children onto the root so
+ // styling can use plain attribute selectors instead of `:has()`. We descend
+ // into fragments so this matches the previous DOM-based `:has()` selectors,
+ // which saw through fragment wrappers. `titleVariant` stays undefined when
+ // no TitleArea is rendered so the root doesn't emit title sizing in that case.
+ //
+ // Scope of the walk: only direct children and children nested inside
+ // `React.Fragment` are inspected. Unlike the old `:has()` selectors — which
+ // matched a slot at *any* depth in the rendered subtree — we intentionally do
+ // NOT descend into host elements (`…`) or custom wrapper components. This
+ // is safe because `PageHeader.TitleArea`/`Navigation` are compound-component
+ // slots: they are only supported as direct children (optionally via fragments
+ // or conditional/mapped expressions, which `React.Children.toArray` flattens).
+ // Wrapping a slot in an arbitrary element is not a supported usage — the
+ // component's other slot logic (e.g. the direct-children scan in
+ // `validateInteractiveElementsInTitle` below) already assumes this — so the
+ // narrower traversal preserves supported behavior while avoiding an expensive
+ // deep tree walk on every render.
+ //
+ // We return the hoisted state (rather than mutating closure variables) so
+ // TypeScript's control-flow analysis keeps the correct types at the usage
+ // sites below — a value mutated inside a closure stays narrowed to its
+ // initializer (e.g. `hasNavigation` would otherwise be typed as `false`).
+ type HoistedChildState = {
+ titleVariant?: TitleAreaProps['variant']
+ hasNavigation: boolean
+ navigationHidden?: NavigationProps['hidden']
+ }
+ const hoistChildState = (nodes: React.ReactNode): HoistedChildState => {
+ const result: HoistedChildState = {hasNavigation: false}
+ for (const child of React.Children.toArray(nodes)) {
+ if (!React.isValidElement(child)) continue
+ if (child.type === React.Fragment) {
+ const nested = hoistChildState((child.props as {children?: React.ReactNode}).children)
+ if (nested.titleVariant !== undefined) result.titleVariant = nested.titleVariant
+ if (nested.hasNavigation) {
+ result.hasNavigation = true
+ result.navigationHidden = nested.navigationHidden
+ }
+ } else if (child.type === TitleArea) {
+ result.titleVariant = (child.props as TitleAreaProps).variant ?? 'medium'
+ } else if (child.type === Navigation) {
+ result.hasNavigation = true
+ result.navigationHidden = (child.props as NavigationProps).hidden ?? false
+ }
+ }
+ return result
+ }
+ const {titleVariant, hasNavigation, navigationHidden} = hoistChildState(children)
+
const isInteractive = (element: HTMLElement) => {
return (
['a', 'button'].some(selector => element.matches(selector)) ||
@@ -109,6 +159,9 @@ const Root = React.forwardRef
@@ -393,12 +446,15 @@ const Navigation: React.FC> = ({
// Based on getBreakpointDeclarations, this function will return the
// correct data attribute for the given hidden value for CSS modules.
-function getHiddenDataAttributes(isHidden: boolean | ResponsiveValue): {
- 'data-hidden-all'?: boolean
- 'data-hidden-narrow'?: boolean
- 'data-hidden-regular'?: boolean
- 'data-hidden-wide'?: boolean
-} {
+function getHiddenDataAttributes(
+ isHidden: boolean | ResponsiveValue,
+ prefix: 'hidden' | 'nav-hidden' = 'hidden',
+): Record {
+ 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
@@ -406,28 +462,28 @@ function getHiddenDataAttributes(isHidden: boolean | ResponsiveValue):
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 {
@@ -443,10 +499,15 @@ function getHiddenDataAttributes(isHidden: boolean | ResponsiveValue):
}
} 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 | undefined) {
+ if (isHidden === undefined) return undefined
+ return getHiddenDataAttributes(isHidden, 'nav-hidden')
+}
+
export const PageHeader = Object.assign(Root, {
ContextArea,
ParentLink,