From 742ed1781e80589aaebeec45900dbc7f9957f8c3 Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:18:19 +1000 Subject: [PATCH 1/3] feat!: move the app header into flow and replace the measured layout offsets with css (#998) the header was position: fixed, so every page pushed its content down by a js-measured header height. that height was 0 in the server render and only corrected on hydration (a ~57px layout shift on main), and wrapping announcements could leave page titles under the header. - the app bar is position: sticky at every width, in flow, so content follows it at its real height in the server render. announcements stay inside it. - applayout is a grid (header auto / main 1fr / footer auto) with min-height 100vh: the page grows, so the header stays pinned, and height: 100% below main still fills the space between header and footer. - header and footer measure themselves with uselayoutheight, which writes --header-height and --footer-height on html from the resizeobserver, before paint. css reads them with fallbacks (57px / 56px). - the header renders a global html scroll-padding-top of the header height, so hash links, focus and scrollintoview clear it on every page. focusable elements inside pinned regions (header, dictionary title and filters) get scroll-margin-top: -100vh so focusing them never jumps the page. - main, contentlayout, error, indexview and the research drawer no longer offset by the header height. sticky elements pin at top: var(--header-height) through a shared pinned() style. - the explore view, permanent sidebar and research drawer use contain: size to fill the main row without growing it. - the data dictionary is a grid of title / outline / filters / entities areas with a fixed 74px title row (long titles truncate; #1134 adds anchor positioning). entities follow the filters with no measured padding, scroll-padding is server-rendered, an empty filters band collapses, and the outline keeps its footer compensation via var(--footer-height). breaking changes: - removed layoutdimensionsprovider, uselayoutdimensions and the providers/layoutdimensions module (dimensions.header/footer.height, headerref, footerref). - removed uselayoutspacing and the layoutspacing type. - removed header-offset props from styled components: headerheight on contentgrid, navigationgrid, outlinegrid and positioner; offset on errorlayout; spacing props on styledgridentityview, the research drawer and the data dictionary layouts. - removed mainwithoffset, and renamed the styled export in main/main.styles from main to styledmain. - the header is in flow, so any offset a consumer adds from the header height now doubles. migration steps are in #998. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../EntitiesLayout/entitiesLayout.styles.ts | 9 +- .../EntitiesLayout/entitiesLayout.tsx | 7 +- .../Layout/components/EntitiesLayout/types.ts | 2 - .../FiltersLayout/filtersLayout.styles.ts | 41 ++-- .../FiltersLayout/filtersLayout.tsx | 2 +- .../Layout/components/FiltersLayout/types.ts | 3 +- .../OutlineLayout/outlineLayout.styles.ts | 25 ++- .../OutlineLayout/outlineLayout.tsx | 2 +- .../Layout/components/OutlineLayout/types.ts | 3 +- .../TitleLayout/titleLayout.styles.ts | 35 ++-- .../components/TitleLayout/titleLayout.tsx | 2 +- .../Layout/components/TitleLayout/types.ts | 3 +- .../components/Layout/constants.ts | 16 ++ .../DataDictionary/dataDictionary.styles.ts | 48 ++++- .../DataDictionary/dataDictionary.tsx | 69 +++---- .../hooks/UseMeasureFilters/hook.ts | 31 ---- .../hooks/UseMeasureFilters/types.ts | 9 - src/components/Error/error.styles.ts | 9 +- src/components/Error/error.tsx | 4 +- src/components/Index/indexView.styles.ts | 16 +- src/components/Index/indexView.tsx | 4 +- .../components/AppLayout/appLayout.styles.ts | 24 ++- .../ContentLayout/components/Main/main.tsx | 2 +- .../ContentLayout/contentLayout.styles.ts | 30 +-- .../ContentLayout/contentLayout.tsx | 19 +- .../components/Footer/common/constants.ts | 10 + .../Layout/components/Footer/footer.styles.ts | 3 +- .../Layout/components/Footer/footer.tsx | 5 +- .../components/Header/common/constants.ts | 15 +- .../NavigationMenu/navigationMenu.styles.ts | 5 +- .../Layout/components/Header/header.styles.ts | 15 +- .../Layout/components/Header/header.tsx | 175 +++++++++--------- .../Layout/components/Main/main.styles.ts | 15 +- .../Layout/components/Main/main.tsx | 12 +- .../sidebarPositioner.styles.ts | 15 +- .../SidebarPositioner/sidebarPositioner.tsx | 13 +- .../components/Sidebar/sidebar.styles.ts | 1 + .../Layout/hooks/UseLayoutHeight/hook.ts | 26 +++ .../Layout/hooks/UseLayoutHeight/types.ts | 9 + .../Layout/hooks/UseLayoutHeight/utils.ts | 29 +++ src/hooks/UseLayoutSpacing/hook.ts | 17 -- src/hooks/UseLayoutSpacing/types.ts | 14 -- src/hooks/useHtmlStyle/constants.ts | 10 +- src/hooks/useHtmlStyle/types.ts | 5 +- src/providers/layoutDimensions/constants.ts | 6 - src/providers/layoutDimensions/context.tsx | 10 - src/providers/layoutDimensions/hook.ts | 11 -- src/providers/layoutDimensions/provider.tsx | 35 ---- src/providers/layoutDimensions/types.ts | 26 --- src/styles/common/mixins/breakpoints.ts | 9 + src/styles/common/mixins/pinned.ts | 27 +++ .../components/Drawer/drawer.styles.ts | 15 +- .../assistant/components/Drawer/drawer.tsx | 3 - tests/headerPosition.test.tsx | 32 ++++ tests/useLayoutHeight.test.tsx | 97 ++++++++++ 55 files changed, 606 insertions(+), 474 deletions(-) delete mode 100644 src/components/DataDictionary/hooks/UseMeasureFilters/hook.ts delete mode 100644 src/components/DataDictionary/hooks/UseMeasureFilters/types.ts create mode 100644 src/components/Layout/components/Footer/common/constants.ts create mode 100644 src/components/Layout/hooks/UseLayoutHeight/hook.ts create mode 100644 src/components/Layout/hooks/UseLayoutHeight/types.ts create mode 100644 src/components/Layout/hooks/UseLayoutHeight/utils.ts delete mode 100644 src/hooks/UseLayoutSpacing/hook.ts delete mode 100644 src/hooks/UseLayoutSpacing/types.ts delete mode 100644 src/providers/layoutDimensions/constants.ts delete mode 100644 src/providers/layoutDimensions/context.tsx delete mode 100644 src/providers/layoutDimensions/hook.ts delete mode 100644 src/providers/layoutDimensions/provider.tsx delete mode 100644 src/providers/layoutDimensions/types.ts create mode 100644 src/styles/common/mixins/pinned.ts create mode 100644 tests/headerPosition.test.tsx create mode 100644 tests/useLayoutHeight.test.tsx diff --git a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.styles.ts b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.styles.ts index 224c745d4..8cc9a761b 100644 --- a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.styles.ts +++ b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.styles.ts @@ -1,23 +1,18 @@ import styled from "@emotion/styled"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; import { bpDown1024 } from "../../../../../../styles/common/mixins/breakpoints"; import { LAYOUT_SPACING } from "../../constants"; const PB = LAYOUT_SPACING.CONTENT_PADDING_BOTTOM; /* bottom padding */ const PT = LAYOUT_SPACING.CONTENT_PADDING_TOP; /* top padding */ -export const Layout = styled("div")` +export const Layout = styled("div")` display: grid; gap: 16px; - grid-column: 2; - grid-row: 1; + grid-area: entities; /* follows the filters, so its position needs no measurement */ padding-bottom: ${PB}px; - padding-top: ${({ top }) => top}px; z-index: 1; /* not required, but helpful in that the entities are always on top */ ${bpDown1024} { - grid-column: 1; - grid-row: auto; padding-top: ${PT}px; } `; diff --git a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.tsx b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.tsx index 776f32419..9b0e7904a 100644 --- a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.tsx +++ b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/entitiesLayout.tsx @@ -3,20 +3,19 @@ import { Layout } from "./entitiesLayout.styles"; import { EntitiesLayoutProps } from "./types"; /** - * Layout for the data dictionary entities, padded at the top to sit below the header and the sticky filters. + * Layout for the data dictionary entities, in the grid row below the sticky filters. * @param props - Component props. * @param props.children - Entities content. - * @param props.spacing - Layout spacing. * @param ref - Ref forwarded to the layout element. * @returns The entities layout. */ export const EntitiesLayout = forwardRef( function EntitiesLayout( - { children, spacing, ...props }: EntitiesLayoutProps, + { children, ...props }: EntitiesLayoutProps, ref, ): JSX.Element { return ( - + {children} ); diff --git a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/types.ts b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/types.ts index 799310255..11fc07ff6 100644 --- a/src/components/DataDictionary/components/Layout/components/EntitiesLayout/types.ts +++ b/src/components/DataDictionary/components/Layout/components/EntitiesLayout/types.ts @@ -1,11 +1,9 @@ import { CSSProperties, ReactNode } from "react"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; /** * Props for the EntitiesLayout component. */ export interface EntitiesLayoutProps { children: ReactNode; - spacing: LayoutSpacing; style?: CSSProperties; // Required for Fade component. See https://mui.com/material-ui/transitions/#child-requirement. } diff --git a/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.styles.ts b/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.styles.ts index 9b435db83..0c564e14e 100644 --- a/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.styles.ts +++ b/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.styles.ts @@ -1,34 +1,41 @@ import styled from "@emotion/styled"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; import { PALETTE } from "../../../../../../styles/common/constants/palette"; import { - bpDown1024, bpDownSm, + bpUp1024, } from "../../../../../../styles/common/mixins/breakpoints"; -import { LAYOUT_SPACING } from "../../constants"; +import { + pinned, + pinnedFocus, +} from "../../../../../../styles/common/mixins/pinned"; +import { LAYOUT_SPACING, TITLE_BOTTOM } from "../../constants"; const PB = LAYOUT_SPACING.FILTERS_PADDING_BOTTOM; /* bottom padding */ const PT = LAYOUT_SPACING.FILTERS_PADDING_TOP; /* top padding */ -const TITLE_HEIGHT = LAYOUT_SPACING.TITLE_HEIGHT; /* title height */ -export const Layout = styled("div")` - align-self: flex-start; +export const Layout = styled("div")` background-color: ${PALETTE.BACKGROUND_DEFAULT}; display: grid; gap: 12px; - grid-column: 2; - grid-row: 1; - padding-bottom: ${PB}px; - padding-top: ${({ top }) => top + TITLE_HEIGHT + PT}px; - position: sticky; - top: 0; + grid-area: filters; z-index: 2; /* required, filters should be on top of entities */ - ${bpDown1024} { - grid-column: 1; - grid-row: auto; - padding-top: ${PT}px; - position: relative; + /* Spacing sits on the children, so a dictionary with no filters collapses the band to nothing. */ + > :empty { + display: none; + } + + > :first-child { + margin-top: ${PT}px; + } + + > :last-child { + margin-bottom: ${PB}px; + } + + ${bpUp1024} { + ${pinned(TITLE_BOTTOM)}; /* below the sticky title */ + ${pinnedFocus}; } ${bpDownSm} { diff --git a/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.tsx b/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.tsx index 4389dd7d2..4b3a45d5f 100644 --- a/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.tsx +++ b/src/components/DataDictionary/components/Layout/components/FiltersLayout/filtersLayout.tsx @@ -4,7 +4,7 @@ import { FiltersLayoutProps } from "./types"; /** * Layout for the data dictionary filters, sticky below the header and title; at narrow widths it is not sticky. - * @param props - Component props, the layout spacing plus children. + * @param props - Component props. * @param props.children - Filters content. * @param ref - Ref forwarded to the layout element. * @returns The filters layout. diff --git a/src/components/DataDictionary/components/Layout/components/FiltersLayout/types.ts b/src/components/DataDictionary/components/Layout/components/FiltersLayout/types.ts index 73555bcc2..ea968e537 100644 --- a/src/components/DataDictionary/components/Layout/components/FiltersLayout/types.ts +++ b/src/components/DataDictionary/components/Layout/components/FiltersLayout/types.ts @@ -1,9 +1,8 @@ import { ReactNode } from "react"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; /** * Props for the FiltersLayout component. */ -export interface FiltersLayoutProps extends LayoutSpacing { +export interface FiltersLayoutProps { children: ReactNode; } diff --git a/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.styles.ts b/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.styles.ts index 3e0d27fb8..2af199824 100644 --- a/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.styles.ts +++ b/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.styles.ts @@ -1,23 +1,22 @@ import styled from "@emotion/styled"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; import { bpDown1024 } from "../../../../../../styles/common/mixins/breakpoints"; -import { LAYOUT_SPACING } from "../../constants"; +import { pinned } from "../../../../../../styles/common/mixins/pinned"; +import { FOOTER_HEIGHT_VAR } from "../../../../../Layout/components/Footer/common/constants"; +import { LAYOUT_SPACING, TITLE_BOTTOM } from "../../constants"; const PB = LAYOUT_SPACING.CONTENT_PADDING_BOTTOM; /* bottom padding */ const PT = LAYOUT_SPACING.OUTLINE_PADDING_TOP; /* top padding */ -const TITLE_HEIGHT = LAYOUT_SPACING.TITLE_HEIGHT; /* title height */ -export const Layout = styled("div")` - grid-column: 1; - grid-row: 1; - margin-bottom: ${({ bottom }) => - -bottom}px; /* required; prevents sticky element from scrolling when footer scrolls into viewport */ - max-height: 100vh; +export const Layout = styled("div")` + grid-area: outline; + margin-bottom: calc( + -1 * ${FOOTER_HEIGHT_VAR} + ); /* with the matching padding, keeps the outline pinned whole while the footer scrolls into view */ + max-height: calc(100vh - ${TITLE_BOTTOM}); overflow: hidden; - padding-bottom: ${({ bottom }) => bottom}px; /* footer height */ - padding-top: ${({ top }) => top + TITLE_HEIGHT + PT}px; - position: sticky; - top: 0; + padding-bottom: ${FOOTER_HEIGHT_VAR}; + padding-top: ${PT}px; + ${pinned(TITLE_BOTTOM)}; /* below the sticky title */ ${bpDown1024} { display: none; diff --git a/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.tsx b/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.tsx index 702c31954..12727cc03 100644 --- a/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.tsx +++ b/src/components/DataDictionary/components/Layout/components/OutlineLayout/outlineLayout.tsx @@ -4,7 +4,7 @@ import { OutlineLayoutProps } from "./types"; /** * Scrollable layout for the data dictionary outline, sticky below the header and title and clear of the footer; hidden at narrow widths. - * @param props - Component props, the layout spacing plus children. + * @param props - Component props. * @param props.children - Outline content. * @returns The outline layout. */ diff --git a/src/components/DataDictionary/components/Layout/components/OutlineLayout/types.ts b/src/components/DataDictionary/components/Layout/components/OutlineLayout/types.ts index c3a7c3af2..f465b7f8d 100644 --- a/src/components/DataDictionary/components/Layout/components/OutlineLayout/types.ts +++ b/src/components/DataDictionary/components/Layout/components/OutlineLayout/types.ts @@ -1,9 +1,8 @@ import { ReactNode } from "react"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; /** * Props for the OutlineLayout component. */ -export interface OutlineLayoutProps extends LayoutSpacing { +export interface OutlineLayoutProps { children: ReactNode; } diff --git a/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.styles.ts b/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.styles.ts index 49130c028..a509fd043 100644 --- a/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.styles.ts +++ b/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.styles.ts @@ -1,23 +1,32 @@ import styled from "@emotion/styled"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; +import { PALETTE } from "../../../../../../styles/common/constants/palette"; import { - bpDown1024, bpDownSm, + bpUp1024, } from "../../../../../../styles/common/mixins/breakpoints"; +import { + pinned, + pinnedFocus, +} from "../../../../../../styles/common/mixins/pinned"; +import { HEADER_HEIGHT_VAR } from "../../../../../Layout/components/Header/common/constants"; -export const Layout = styled("div")` - grid-column: 1 / -1; - grid-row: 1; - height: fit-content; - padding-top: ${({ top }) => top}px; /* header height */ - position: sticky; - top: 0; +export const Layout = styled("div")` + background-color: ${PALETTE.BACKGROUND_DEFAULT}; + grid-area: title; + min-width: 0; z-index: 4; - ${bpDown1024} { - grid-column: 1; - grid-row: auto; - position: relative; + /* From 1024px up the title fills a fixed-height grid row, so it pins below the header and a long title truncates rather than wrapping under the filters. */ + ${bpUp1024} { + ${pinned(HEADER_HEIGHT_VAR)}; + ${pinnedFocus}; + overflow: hidden; /* a custom title taller than the row is clipped, never painted over the filters */ + + > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } ${bpDownSm} { diff --git a/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.tsx b/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.tsx index ffb28ad1e..a6970b649 100644 --- a/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.tsx +++ b/src/components/DataDictionary/components/Layout/components/TitleLayout/titleLayout.tsx @@ -4,7 +4,7 @@ import { TitleLayoutProps } from "./types"; /** * Layout for the data dictionary title, sticky below the header; at narrow widths it is not sticky. - * @param props - Component props, the layout spacing plus children. + * @param props - Component props. * @param props.children - Title content. * @returns The title layout. */ diff --git a/src/components/DataDictionary/components/Layout/components/TitleLayout/types.ts b/src/components/DataDictionary/components/Layout/components/TitleLayout/types.ts index fc5bbb98e..76d7d5bc9 100644 --- a/src/components/DataDictionary/components/Layout/components/TitleLayout/types.ts +++ b/src/components/DataDictionary/components/Layout/components/TitleLayout/types.ts @@ -1,9 +1,8 @@ import { ReactNode } from "react"; -import { LayoutSpacing } from "../../../../../../hooks/UseLayoutSpacing/types"; /** * Props for the TitleLayout component. */ -export interface TitleLayoutProps extends LayoutSpacing { +export interface TitleLayoutProps { children: ReactNode; } diff --git a/src/components/DataDictionary/components/Layout/constants.ts b/src/components/DataDictionary/components/Layout/constants.ts index 402ba35cd..0a506feca 100644 --- a/src/components/DataDictionary/components/Layout/constants.ts +++ b/src/components/DataDictionary/components/Layout/constants.ts @@ -1,3 +1,5 @@ +import { HEADER_HEIGHT_VAR } from "../../../Layout/components/Header/common/constants"; + export const LAYOUT_SPACING = { CONTENT_PADDING_BOTTOM: 24, CONTENT_PADDING_TOP: 16, @@ -7,3 +9,17 @@ export const LAYOUT_SPACING = { OUTLINE_PADDING_TOP: 16, TITLE_HEIGHT: 74, }; + +/** + * Height of the filters with no filter tags, which targets scroll to clear + * before the filters are measured. + */ +export const FILTERS_FALLBACK_HEIGHT = + LAYOUT_SPACING.FILTERS_PADDING_TOP + + LAYOUT_SPACING.FILTERS_HEIGHT + + LAYOUT_SPACING.FILTERS_PADDING_BOTTOM; + +/** + * Where the filters and outline pin: below the sticky header and the title row. + */ +export const TITLE_BOTTOM = `calc(${HEADER_HEIGHT_VAR} + ${LAYOUT_SPACING.TITLE_HEIGHT}px)`; diff --git a/src/components/DataDictionary/dataDictionary.styles.ts b/src/components/DataDictionary/dataDictionary.styles.ts index 336fd5d1f..a39e85860 100644 --- a/src/components/DataDictionary/dataDictionary.styles.ts +++ b/src/components/DataDictionary/dataDictionary.styles.ts @@ -1,16 +1,62 @@ +import type { SerializedStyles, Theme } from "@emotion/react"; +import { css } from "@emotion/react"; import styled from "@emotion/styled"; -import { bpDown1024, bpDownSm } from "../../styles/common/mixins/breakpoints"; +import { PROPERTY } from "../../hooks/useHtmlStyle/constants"; +import { + bpDown1024, + bpDownSm, + bpUp1024, +} from "../../styles/common/mixins/breakpoints"; +import { + FILTERS_FALLBACK_HEIGHT, + LAYOUT_SPACING, + TITLE_BOTTOM, +} from "./components/Layout/constants"; + +/** + * Server-rendered scroll-padding-top, so from 1024px up a deep link clears the + * sticky header, title and filters on first load. Until they are measured, the + * header falls back to its height without announcements and the filters to + * their height without tags. Below 1024px the title and filters scroll with the + * page, so the header's own scroll-padding-top applies. `:root` outranks the + * header's `html` rule. + * @param theme - MUI theme. + * @returns Global scroll-padding-top styles. + */ +export const scrollPadding = (theme: Theme): SerializedStyles => css` + ${bpUp1024({ theme })} { + :root { + scroll-padding-top: calc( + ${TITLE_BOTTOM} + + var( + ${PROPERTY.DATA_DICTIONARY_FILTERS_HEIGHT}, + ${FILTERS_FALLBACK_HEIGHT}px + ) + ); + } + } +`; export const View = styled("div")` column-gap: 24px; display: grid; flex: 1; + grid-template-areas: + "title title" + "outline filters" + "outline entities"; grid-template-columns: 242px 1fr; + grid-template-rows: ${LAYOUT_SPACING.TITLE_HEIGHT}px auto 1fr; margin: 0 24px; position: relative; ${bpDown1024} { + grid-template-areas: + "title" + "filters" + "entities"; grid-template-columns: 1fr; + grid-template-rows: none; margin: 0 16px; } diff --git a/src/components/DataDictionary/dataDictionary.tsx b/src/components/DataDictionary/dataDictionary.tsx index 6c5c07b98..6ae3ad1d3 100644 --- a/src/components/DataDictionary/dataDictionary.tsx +++ b/src/components/DataDictionary/dataDictionary.tsx @@ -1,3 +1,4 @@ +import { Global } from "@emotion/react"; import { Fade } from "@mui/material"; import { RowData } from "@tanstack/react-table"; import { JSX, useMemo } from "react"; @@ -5,9 +6,8 @@ import { Attribute } from "../../common/entities"; import { isColumnFilter } from "../../common/filters/adapters/tanstack/typeGuards"; import { useValidateFilterKeys } from "../../common/filters/hooks/UseValidateFilterKeys/hook"; import { PROPERTY } from "../../hooks/useHtmlStyle/constants"; -import { useHtmlStyle } from "../../hooks/useHtmlStyle/hook"; -import { useLayoutSpacing } from "../../hooks/UseLayoutSpacing/hook"; import { DATA_DICTIONARY_URL_PARAMS } from "../../providers/dataDictionaryState/dictionaries/constants"; +import { useLayoutHeight } from "../Layout/hooks/UseLayoutHeight/hook"; import { Description } from "./components/Description/description"; import { Entities } from "./components/Entities/entities"; import { ColumnFilterTags } from "./components/Filters/components/ColumnFilterTags/columnFilterTags"; @@ -20,17 +20,17 @@ import { Outline as DefaultOutline } from "./components/Outline/outline"; import { buildClassesOutline } from "./components/Outline/utils"; import { useTable } from "./components/Table/hook"; import { Title as DefaultTitle } from "./components/Title/title"; -import { View } from "./dataDictionary.styles"; +import { scrollPadding, View } from "./dataDictionary.styles"; import { useDataDictionaryConfig } from "./hooks/UseDataDictionaryConfig/hook"; -import { useMeasureFilters } from "./hooks/UseMeasureFilters/hook"; import { DataDictionaryProps } from "./types"; import { extractColumnId, getValidColumnIds } from "./utils"; /** * Data dictionary view: the title, the outline of classes, the filters with their tags, and the description and entities. * Throws during render when no configured data dictionary has a matching path, or when the column filters URL param is malformed or names an unknown column. - * Sets the HTML scroll-padding-top to the filters height. - * Fades in once the header is measured, and the entities fade in once the filters are measured. + * From 1024px up, a grid of title, outline, filters and entities areas: the title pins below the header in a fixed-height row (a long title truncates), and the outline and filters pin below the title. + * The filters are measured once mounted, for the HTML scroll-padding-top, with a server-rendered fallback until then. + * The entities fade in once the filters are measured, after hydration, because the server render of the table differs from the client. * @param props - Component props. * @param props.className - Class name applied to the view. * @param props.dictionary - Data dictionary path, matched against the configured data dictionaries. @@ -56,13 +56,10 @@ export const DataDictionary = ({ const { classes, description, tableOptions, title } = useDataDictionaryConfig(dictionary); - // Layout measurements. - // Get header and footer dimensions. - const { spacing } = useLayoutSpacing(); - // Measure filters dimensions. - const { dimensions, filtersRef } = useMeasureFilters(); - // Update entities' spacing with filters dimensions. - const entitiesSpacing = { ...spacing, top: dimensions.height }; + // Measure the filters; their height clears deep links below the sticky filters. + const { isMeasured, ref: filtersRef } = useLayoutHeight( + PROPERTY.DATA_DICTIONARY_FILTERS_HEIGHT, + ); // Table instance. const table = useTable(dictionary, classes, tableOptions); @@ -80,32 +77,26 @@ export const DataDictionary = ({ // Dictionary outline. const outline = buildClassesOutline(table); - // Update scroll-padding-top on the HTML element. - // Scroll-snaps table rows to below the sticky filters. - useHtmlStyle(PROPERTY.SCROLL_PADDING_TOP, `${dimensions.height}px`); - return ( - 0}> - {/* Fade in when header is measured. */} - - - {title} - - - - - - - - - 0}> - {/* Fade in entities when filters are measured. */} - - - - - - - + + + + {title} + + + + + + + + + + {/* Fade in entities after hydration, once the filters are measured. */} + + + + + + ); }; diff --git a/src/components/DataDictionary/hooks/UseMeasureFilters/hook.ts b/src/components/DataDictionary/hooks/UseMeasureFilters/hook.ts deleted file mode 100644 index 966940c65..000000000 --- a/src/components/DataDictionary/hooks/UseMeasureFilters/hook.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useRef } from "react"; -import { - getBorderBoxSizeHeight, - useResizeObserver, -} from "../../../../hooks/useResizeObserver"; -import { useLayoutDimensions } from "../../../../providers/layoutDimensions/hook"; -import { UseMeasureFilters } from "./types"; - -/** - * Measures the height of the data dictionary filters, once the header has been measured. - * @returns The filters ref and height; the height is 0 until measured. - */ -export const useMeasureFilters = (): UseMeasureFilters => { - // Get header dimensions. - const { dimensions } = useLayoutDimensions(); - const { header } = dimensions; - - // Measure filters dimensions. - const filtersRef = useRef(null); - const filtersRect = useResizeObserver( - filtersRef, - getBorderBoxSizeHeight, - header.height > 0, // Only measure filters height, when header measurement is available. - ); - const { height = 0 } = filtersRect || {}; - - return { - dimensions: { height }, - filtersRef, - }; -}; diff --git a/src/components/DataDictionary/hooks/UseMeasureFilters/types.ts b/src/components/DataDictionary/hooks/UseMeasureFilters/types.ts deleted file mode 100644 index ca1eeaff5..000000000 --- a/src/components/DataDictionary/hooks/UseMeasureFilters/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RefObject } from "react"; - -/** - * Return type for the useMeasureFilters hook. - */ -export interface UseMeasureFilters { - dimensions: { height: number }; - filtersRef: RefObject; -} diff --git a/src/components/Error/error.styles.ts b/src/components/Error/error.styles.ts index 19ae3ee0c..13fa383fd 100644 --- a/src/components/Error/error.styles.ts +++ b/src/components/Error/error.styles.ts @@ -3,15 +3,8 @@ import { FONT } from "../../styles/common/constants/font"; import { bpUpSm } from "../../styles/common/mixins/breakpoints"; import { Section, sectionMarginXsm } from "../common/Section/section.styles"; -interface Props { - offset: number; -} - -export const ErrorLayout = styled("div", { - shouldForwardProp: (prop) => prop !== "offset", -})` +export const ErrorLayout = styled.div` flex: 1; - margin-top: ${({ offset }) => offset}px; `; export const Error = styled.div` diff --git a/src/components/Error/error.tsx b/src/components/Error/error.tsx index 8072aecee..fa6fcec16 100644 --- a/src/components/Error/error.tsx +++ b/src/components/Error/error.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { JSX } from "react"; import { useExploreState } from "../../hooks/useExploreState"; import { ExploreActionKind } from "../../providers/exploreState"; -import { useLayoutDimensions } from "../../providers/layoutDimensions/hook"; import { TYPOGRAPHY_PROPS } from "../../styles/common/mui/typography"; import { ButtonPrimary } from "../common/Button/components/ButtonPrimary/buttonPrimary"; import { AlertIcon } from "../common/CustomIcon/components/AlertIcon/alertIcon"; @@ -78,7 +77,6 @@ export const Error = ({ rootPath, }: ErrorProps): JSX.Element => { const { exploreDispatch } = useExploreState(); - const { dimensions } = useLayoutDimensions(); const handleToHomePageClicked = (): void => { // Explicitly clear the caught error so recovery also works when "To @@ -94,7 +92,7 @@ export const Error = ({ }; return ( - + diff --git a/src/components/Index/indexView.styles.ts b/src/components/Index/indexView.styles.ts index 103acf3fd..1ee864227 100644 --- a/src/components/Index/indexView.styles.ts +++ b/src/components/Index/indexView.styles.ts @@ -1,21 +1,23 @@ import styled from "@emotion/styled"; import { Grid } from "@mui/material"; -import { LayoutSpacing } from "../../hooks/UseLayoutSpacing/types"; import { PALETTE } from "../../styles/common/constants/palette"; -import { bpDownSm } from "../../styles/common/mixins/breakpoints"; +import { bpDownMd, bpDownSm } from "../../styles/common/mixins/breakpoints"; import { FluidPaper } from "../common/Paper/components/FluidPaper/fluidPaper"; +import { HEADER_HEIGHT_VAR } from "../Layout/components/Header/common/constants"; -export const StyledGridEntityView = styled(Grid, { - shouldForwardProp: (prop) => prop !== "bottom" && prop !== "top", -})` +export const StyledGridEntityView = styled(Grid)` align-content: flex-start; display: grid; flex: 1; gap: 0; + contain: size; /* fills the main row without growing it, so the page fits the window */ height: 100%; - max-height: 100vh; overflow: hidden; - padding-top: ${({ top }) => top}px; + + /* Below md the footer is too tall to keep on screen: fill the window below the header and let the footer follow. */ + ${bpDownMd} { + height: calc(100vh - ${HEADER_HEIGHT_VAR}); + } `; export const StyledGridEntityLayout = styled(Grid)` diff --git a/src/components/Index/indexView.tsx b/src/components/Index/indexView.tsx index cab631a1a..3f154ef5f 100644 --- a/src/components/Index/indexView.tsx +++ b/src/components/Index/indexView.tsx @@ -1,6 +1,5 @@ import { Grid } from "@mui/material"; import { JSX } from "react"; -import { useLayoutSpacing } from "../../hooks/UseLayoutSpacing/hook"; import { ActionButton } from "./components/EntityView/components/controls/ActionButton/actionButton"; import { ExportButton } from "./components/EntityView/components/controls/ExportButton/exportButton"; import { FilterButton } from "./components/EntityView/components/controls/FilterButton/filterButton"; @@ -42,10 +41,9 @@ export const Index = ({ loading, }: IndexProps): JSX.Element => { const { onChange, viewMode, viewStatus } = useViewToggle(); - const { spacing } = useLayoutSpacing(); const { table } = useTable({ entityListType }); return ( - + {/* Entity view alerts or other */} {/* Entity layout */} diff --git a/src/components/Layout/components/AppLayout/appLayout.styles.ts b/src/components/Layout/components/AppLayout/appLayout.styles.ts index c1722ab88..83674f9ab 100644 --- a/src/components/Layout/components/AppLayout/appLayout.styles.ts +++ b/src/components/Layout/components/AppLayout/appLayout.styles.ts @@ -1,8 +1,26 @@ import styled from "@emotion/styled"; +/** + * Grows with the page, so the sticky header stays pinned on long pages. A grid + * rather than a flex column so the main row's height is definite: percentage + * heights below it (e.g. `height: 100%` views) still fill the space between + * the header and footer. + */ export const AppLayout = styled.div` - display: flex; - flex-direction: column; - height: 100vh; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 1fr auto; min-height: 100vh; + + > header { + grid-row: 1; + } + + > main { + grid-row: 2; + } + + > footer { + grid-row: 3; + } `; diff --git a/src/components/Layout/components/ContentLayout/components/Main/main.tsx b/src/components/Layout/components/ContentLayout/components/Main/main.tsx index eb81203a8..16519b86f 100644 --- a/src/components/Layout/components/ContentLayout/components/Main/main.tsx +++ b/src/components/Layout/components/ContentLayout/components/Main/main.tsx @@ -1,5 +1,5 @@ import { JSX, ReactNode } from "react"; -import { Main as ContentMain } from "../../../Main/main.styles"; +import { StyledMain as ContentMain } from "../../../Main/main.styles"; /** * Props for the Main component. diff --git a/src/components/Layout/components/ContentLayout/contentLayout.styles.ts b/src/components/Layout/components/ContentLayout/contentLayout.styles.ts index e5889b30a..2a1cef2fd 100644 --- a/src/components/Layout/components/ContentLayout/contentLayout.styles.ts +++ b/src/components/Layout/components/ContentLayout/contentLayout.styles.ts @@ -6,6 +6,8 @@ import { bpUp1366, bpUpMd, } from "../../../../styles/common/mixins/breakpoints"; +import { pinned } from "../../../../styles/common/mixins/pinned"; +import { HEADER_HEIGHT_VAR } from "../Header/common/constants"; import { PanelBackgroundColor } from "./common/entities"; const CONTENT_MAX_WIDTH = 756; @@ -26,7 +28,6 @@ interface LayoutProps { } interface GridProps { - headerHeight: number; panelColor?: PanelBackgroundColor; } @@ -63,25 +64,12 @@ export const ContentLayout = styled.div` } `; -const content = ({ headerHeight, panelColor }: GridProps) => css` +const panel = ({ panelColor }: GridProps) => css` background-color: ${getPanelBackgroundColor(panelColor)}; - padding-top: ${headerHeight}px; -`; - -const navigation = ({ panelColor }: GridProps) => css` - background-color: ${getPanelBackgroundColor(panelColor)}; -`; - -const positioner = ({ headerHeight }: GridProps) => css` - max-height: 100vh; - overflow: auto; - padding-top: ${headerHeight}px; - position: sticky; - top: 0; `; export const NavigationGrid = styled.div` - ${navigation}; + ${panel}; box-shadow: inset -1px 0 ${PALETTE.SMOKE_MAIN}; display: none; grid-area: navigation; @@ -92,13 +80,13 @@ export const NavigationGrid = styled.div` `; export const ContentGrid = styled.div` - ${content}; + ${panel}; grid-area: content; min-width: 0; `; export const OutlineGrid = styled("div")` - ${navigation}; + ${panel}; display: none; grid-area: outline; @@ -107,8 +95,10 @@ export const OutlineGrid = styled("div")` } `; -export const Positioner = styled.div` - ${positioner}; +export const Positioner = styled.div` + max-height: calc(100vh - ${HEADER_HEIGHT_VAR}); + overflow: auto; + ${pinned(HEADER_HEIGHT_VAR)}; `; export const Navigation = styled.div` diff --git a/src/components/Layout/components/ContentLayout/contentLayout.tsx b/src/components/Layout/components/ContentLayout/contentLayout.tsx index eb9154c28..09677c27e 100644 --- a/src/components/Layout/components/ContentLayout/contentLayout.tsx +++ b/src/components/Layout/components/ContentLayout/contentLayout.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; import { JSX, ReactNode } from "react"; -import { useLayoutDimensions } from "../../../../providers/layoutDimensions/hook"; import { BaseComponentProps } from "../../../types"; import { LayoutStyle } from "./common/entities"; import { @@ -26,7 +25,7 @@ export interface ContentLayoutProps { /** * Content page layout, rendering the content between optional navigation and outline panels. - * The navigation and outline panels are positioned below the header, and the outline remounts when the path (excluding the hash) changes. + * The navigation and outline panels pin below the sticky header, and the outline remounts when the path (excluding the hash) changes. * @param props - Component props. * @param props.className - Class name applied to the layout. * @param props.content - Page content. @@ -43,7 +42,6 @@ export const ContentLayout = ({ outline, }: BaseComponentProps & ContentLayoutProps): JSX.Element => { const { asPath } = useRouter(); - const { dimensions } = useLayoutDimensions(); return ( {navigation && ( - - + + {navigation} )} - + {content} {outline && ( - + {outline} diff --git a/src/components/Layout/components/Footer/common/constants.ts b/src/components/Layout/components/Footer/common/constants.ts new file mode 100644 index 000000000..d13efa159 --- /dev/null +++ b/src/components/Layout/components/Footer/common/constants.ts @@ -0,0 +1,10 @@ +import { PROPERTY } from "../../../../../hooks/useHtmlStyle/constants"; + +export const FOOTER_HEIGHT = 56; + +/** + * Footer height as a CSS value. Reads the measured height from the + * `--footer-height` custom property, falling back to the footer's toolbar + * height (from the "sm" breakpoint up) until the footer is measured. + */ +export const FOOTER_HEIGHT_VAR = `var(${PROPERTY.FOOTER_HEIGHT}, ${FOOTER_HEIGHT}px)`; diff --git a/src/components/Layout/components/Footer/footer.styles.ts b/src/components/Layout/components/Footer/footer.styles.ts index d07c3feee..f268d112d 100644 --- a/src/components/Layout/components/Footer/footer.styles.ts +++ b/src/components/Layout/components/Footer/footer.styles.ts @@ -5,6 +5,7 @@ import { PALETTE } from "../../../../styles/common/constants/palette"; import { bpUpSm } from "../../../../styles/common/mixins/breakpoints"; import { Socials as DXSocials } from "../../../common/Socials/socials"; import { Link as DXLink } from "../../../Links/components/Link/link"; +import { FOOTER_HEIGHT } from "./common/constants"; export const AppBar = styled(MAppBar)` padding: 16px 0; @@ -26,7 +27,7 @@ export const AppBar = styled(MAppBar)` align-items: center; flex-direction: row; gap: unset; - min-height: 56px; + min-height: ${FOOTER_HEIGHT}px; } } ` as typeof MAppBar; diff --git a/src/components/Layout/components/Footer/footer.tsx b/src/components/Layout/components/Footer/footer.tsx index f150b9bd6..2f65de378 100644 --- a/src/components/Layout/components/Footer/footer.tsx +++ b/src/components/Layout/components/Footer/footer.tsx @@ -1,11 +1,12 @@ import { Toolbar } from "@mui/material"; import { JSX, ReactNode } from "react"; import { SELECTOR } from "../../../../common/selectors"; -import { useLayoutDimensions } from "../../../../providers/layoutDimensions/hook"; +import { PROPERTY } from "../../../../hooks/useHtmlStyle/constants"; import { Social } from "../../../common/Socials/socials"; import { StaticImageProps } from "../../../common/StaticImage/staticImage"; import { ANCHOR_TARGET } from "../../../Links/common/entities"; import { isNodeBoolean } from "../../../utils"; +import { useLayoutHeight } from "../../hooks/UseLayoutHeight/hook"; import { NavLinkItem } from "../Header/components/Content/components/Navigation/navigation"; import { PoweredByCleverCanary } from "./components/PoweredByCleverCanary/poweredByCleverCanary"; import { VersionInfo } from "./components/VersionInfo/versionInfo"; @@ -43,7 +44,7 @@ export const Footer = ({ socials, versionInfo, }: FooterProps): JSX.Element => { - const { footerRef } = useLayoutDimensions(); + const { ref: footerRef } = useLayoutHeight(PROPERTY.FOOTER_HEIGHT); return ( = { elevation: 1, - position: "fixed", + position: "sticky", }; export const FADE_TRANSITION_PROPS = { diff --git a/src/components/Layout/components/Header/components/Content/components/Navigation/components/NavigationMenu/navigationMenu.styles.ts b/src/components/Layout/components/Header/components/Content/components/Navigation/components/NavigationMenu/navigationMenu.styles.ts index b9c536d0d..1f08aea47 100644 --- a/src/components/Layout/components/Header/components/Content/components/Navigation/components/NavigationMenu/navigationMenu.styles.ts +++ b/src/components/Layout/components/Header/components/Content/components/Navigation/components/NavigationMenu/navigationMenu.styles.ts @@ -3,6 +3,7 @@ import styled from "@emotion/styled"; import { MenuItem as MMenuItem, Popper as MPopper } from "@mui/material"; import { PALETTE } from "../../../../../../../../../../styles/common/constants/palette"; import { Button as DXButton } from "../../../../../../../../../common/Button/button"; +import { HEADER_HEIGHT_VAR } from "../../../../../../common/constants"; interface Props { isActive: boolean; @@ -14,7 +15,9 @@ export const StyledPopper = styled(MPopper)` .MuiPaper-menu { border-color: ${PALETTE.SMOKE_MAIN}; margin: 4px 0; - max-height: calc(100vh - 96px); + max-height: calc( + 100vh - ${HEADER_HEIGHT_VAR} - 40px + ); /* below the header, with room above the window edge */ max-width: 324px; min-width: 204px; overflow-y: auto; diff --git a/src/components/Layout/components/Header/header.styles.ts b/src/components/Layout/components/Header/header.styles.ts index 2562436fa..b52d7ab47 100644 --- a/src/components/Layout/components/Header/header.styles.ts +++ b/src/components/Layout/components/Header/header.styles.ts @@ -2,16 +2,29 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { AppBar as MAppBar } from "@mui/material"; import { PALETTE } from "../../../../styles/common/constants/palette"; -import { HEADER_HEIGHT } from "./common/constants"; +import { pinnedFocus } from "../../../../styles/common/mixins/pinned"; +import { HEADER_HEIGHT, HEADER_HEIGHT_VAR } from "./common/constants"; // See https://github.com/emotion-js/emotion/issues/1105. // See https://github.com/emotion-js/emotion/releases/tag/%40emotion%2Fcache%4011.10.2. const ignoreSsrWarning = "/* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */"; +/** + * Clears the sticky header when the page scrolls to a target: hash links, + * focus and scrollIntoView. Server-rendered, so it applies on first load. + */ +export const scrollPadding = css` + html { + scroll-padding-top: ${HEADER_HEIGHT_VAR}; + } +`; + export const AppBar = styled(MAppBar)` border-bottom: 1px solid ${PALETTE.SMOKE_MAIN}; + ${pinnedFocus}; + &.MuiPaper-elevation0 { border-bottom: 1px solid transparent; } diff --git a/src/components/Layout/components/Header/header.tsx b/src/components/Layout/components/Header/header.tsx index ab979e6a4..c85535161 100644 --- a/src/components/Layout/components/Header/header.tsx +++ b/src/components/Layout/components/Header/header.tsx @@ -1,9 +1,11 @@ +import { Global } from "@emotion/react"; import { Fade, Toolbar } from "@mui/material"; import { usePathname } from "next/navigation"; import { JSX, ReactNode } from "react"; import { SELECTOR } from "../../../../common/selectors"; import { ComponentsConfig } from "../../../../config/entities"; -import { useLayoutDimensions } from "../../../../providers/layoutDimensions/hook"; +import { PROPERTY } from "../../../../hooks/useHtmlStyle/constants"; +import { useLayoutHeight } from "../../hooks/UseLayoutHeight/hook"; import { APP_BAR_PROPS, FADE_TRANSITION_PROPS, @@ -20,7 +22,7 @@ import { Navigation as DXNavigation } from "./components/Content/components/Navi import { Slogan } from "./components/Content/components/Slogan/slogan"; import { Divider } from "./components/Content/components/Slogan/slogan.styles"; import { Socials } from "./components/Content/components/Socials/socials.styles"; -import { AppBar, Center, Left, Right } from "./header.styles"; +import { AppBar, Center, Left, Right, scrollPadding } from "./header.styles"; import { useHeaderNavigation } from "./hooks/useHeaderNavigation"; import { useHeaderVisibility } from "./hooks/useHeaderVisibility"; import { useMenu } from "./hooks/useMenu"; @@ -59,7 +61,7 @@ export interface HeaderProps { export const Header = ({ ...headerProps }: HeaderProps): JSX.Element => { const { navigation } = useHeaderNavigation(headerProps); const { isIn } = useHeaderVisibility({ ...headerProps, navigation }); - const { headerRef } = useLayoutDimensions(); + const { ref: headerRef } = useLayoutHeight(PROPERTY.HEADER_HEIGHT); const { onClose, onOpen, open } = useMenu(); const pathname = usePathname() ?? ""; const { @@ -80,89 +82,92 @@ export const Header = ({ ...headerProps }: HeaderProps): JSX.Element => { }; return ( - - {/* Announcements */} - - {/* Toolbar */} - - {/* Left group */} - - - {/* Logo */} - {logo} - {/* Divider */} - {isIn.isSloganIn && } - {/* Slogan */} - {isIn.isSloganIn && } - {/* Left navigation */} - {isIn.isLeftNavigationIn && ( - - )} - - - {/* Center group */} - -
- {/* Center navigation */} - {isIn.isCenterNavigationIn && ( - - )} -
-
- {/* Right group */} - - - {/* Navigation */} - {isIn.isRightNavigationIn && ( - - )} - {/* Socials */} - {isIn.isSocialsIn && ( - - )} - {/* Actions */} - {isIn.isActionsIn && ( - - {/* Search */} - - {/* Authentication */} - - {/* Additional actions i.e. call-to-action button */} - {actions} - {/* Menu */} - + + + {/* Announcements */} + + {/* Toolbar */} + + {/* Left group */} + + + {/* Logo */} + {logo} + {/* Divider */} + {isIn.isSloganIn && } + {/* Slogan */} + {isIn.isSloganIn && } + {/* Left navigation */} + {isIn.isLeftNavigationIn && ( + + )} + + + {/* Center group */} + +
+ {/* Center navigation */} + {isIn.isCenterNavigationIn && ( + + )} +
+
+ {/* Right group */} + + + {/* Navigation */} + {isIn.isRightNavigationIn && ( + + )} + {/* Socials */} + {isIn.isSocialsIn && ( + - - )} - - -
-
+ )} + {/* Actions */} + {isIn.isActionsIn && ( + + {/* Search */} + + {/* Authentication */} + + {/* Additional actions i.e. call-to-action button */} + {actions} + {/* Menu */} + + + )} + + + + + ); }; diff --git a/src/components/Layout/components/Main/main.styles.ts b/src/components/Layout/components/Main/main.styles.ts index acb9ea69b..cad0e828e 100644 --- a/src/components/Layout/components/Main/main.styles.ts +++ b/src/components/Layout/components/Main/main.styles.ts @@ -1,20 +1,7 @@ -import { css } from "@emotion/react"; import styled from "@emotion/styled"; -interface Props { - offset: number; -} - -const main = css` +export const StyledMain = styled("main")` align-items: flex-start; display: flex; flex: 1; `; - -export const Main = styled("main")` - ${main}; -`; - -export const MainWithOffset = styled(Main)` - margin-top: ${({ offset }) => offset}px; -`; diff --git a/src/components/Layout/components/Main/main.tsx b/src/components/Layout/components/Main/main.tsx index b81a508d4..6f02e601a 100644 --- a/src/components/Layout/components/Main/main.tsx +++ b/src/components/Layout/components/Main/main.tsx @@ -1,6 +1,5 @@ import { JSX, ReactNode } from "react"; -import { useLayoutDimensions } from "../../../../providers/layoutDimensions/hook"; -import { MainWithOffset } from "./main.styles"; +import { StyledMain } from "./main.styles"; /** * Props for the Main component. @@ -11,17 +10,12 @@ export interface MainProps { } /** - * Main content, offset by the header height. + * Main content. Follows the in-flow header, so it carries no header offset. * @param props - Component props. * @param props.children - Main content. * @param props.className - Class name applied to the main element. * @returns The main content. */ export const Main = ({ children, className }: MainProps): JSX.Element => { - const { dimensions } = useLayoutDimensions(); - return ( - - {children} - - ); + return {children}; }; diff --git a/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.styles.ts b/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.styles.ts index 6d9ae0561..544590e95 100644 --- a/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.styles.ts +++ b/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.styles.ts @@ -1,21 +1,18 @@ import styled from "@emotion/styled"; import { bpUpMd } from "../../../../../../styles/common/mixins/breakpoints"; +import { pinned } from "../../../../../../styles/common/mixins/pinned"; +import { HEADER_HEIGHT_VAR } from "../../../Header/common/constants"; -interface Props { - headerHeight: number; -} - -export const SidebarPositioner = styled("div")` +export const SidebarPositioner = styled("div")` box-sizing: border-box; height: 100%; overflow: visible; padding: 16px 0; ${bpUpMd} { - max-height: 100vh; + max-height: calc(100vh - ${HEADER_HEIGHT_VAR}); overflow: auto; - padding: ${({ headerHeight }) => headerHeight}px 0 0; - position: sticky; - top: 0; + padding: 0; + ${pinned(HEADER_HEIGHT_VAR)}; } `; diff --git a/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.tsx b/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.tsx index 466630552..3b0203ec7 100644 --- a/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.tsx +++ b/src/components/Layout/components/Sidebar/components/SidebarPositioner/sidebarPositioner.tsx @@ -1,6 +1,5 @@ import { JSX, ReactNode } from "react"; import { SELECTOR } from "../../../../../../common/selectors"; -import { useLayoutDimensions } from "../../../../../../providers/layoutDimensions/hook"; import { SidebarPositioner as Positioner } from "./sidebarPositioner.styles"; /** @@ -11,7 +10,7 @@ export interface SidebarPositionerProps { } /** - * Positions the sidebar content; from the "md" breakpoint up, it is sticky and offset by the header height. + * Positions the sidebar content; from the "md" breakpoint up, it pins below the sticky header. * @param props - Component props. * @param props.children - Sidebar content. * @returns The sidebar positioner. @@ -19,13 +18,5 @@ export interface SidebarPositionerProps { export const SidebarPositioner = ({ children, }: SidebarPositionerProps): JSX.Element => { - const { dimensions } = useLayoutDimensions(); - return ( - - {children} - - ); + return {children}; }; diff --git a/src/components/Layout/components/Sidebar/sidebar.styles.ts b/src/components/Layout/components/Sidebar/sidebar.styles.ts index 8f785f357..ca276fd15 100644 --- a/src/components/Layout/components/Sidebar/sidebar.styles.ts +++ b/src/components/Layout/components/Sidebar/sidebar.styles.ts @@ -5,5 +5,6 @@ export const Sidebar = styled.div` align-self: stretch; border-right: 1px solid ${PALETTE.SMOKE_MAIN}; box-sizing: content-box; + contain: size; /* sized by the row it stretches into, not by its filters */ width: 264px; `; diff --git a/src/components/Layout/hooks/UseLayoutHeight/hook.ts b/src/components/Layout/hooks/UseLayoutHeight/hook.ts new file mode 100644 index 000000000..634d60f2d --- /dev/null +++ b/src/components/Layout/hooks/UseLayoutHeight/hook.ts @@ -0,0 +1,26 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import type { CSSPropHyphen } from "../../../../hooks/useHtmlStyle/types"; +import type { UseLayoutHeight } from "./types"; +import { observeHeight } from "./utils"; + +/** + * Measures an element's height and sets it on the HTML element as a CSS custom + * property, for styles that size or pin around it. The property is written + * from the resize observer, before paint and without re-rendering, and is + * removed on unmount so styles fall back to their default. + * @param property - Custom property to set, e.g. `--header-height`. + * @returns The ref to attach to the measured element, and whether it has been measured. + */ +export const useLayoutHeight = ( + property: CSSPropHyphen, +): UseLayoutHeight => { + const ref = useRef(null); + const [isMeasured, setIsMeasured] = useState(false); + + useLayoutEffect( + () => observeHeight(ref.current, property, setIsMeasured), + [property], + ); + + return { isMeasured, ref }; +}; diff --git a/src/components/Layout/hooks/UseLayoutHeight/types.ts b/src/components/Layout/hooks/UseLayoutHeight/types.ts new file mode 100644 index 000000000..99c6bb27b --- /dev/null +++ b/src/components/Layout/hooks/UseLayoutHeight/types.ts @@ -0,0 +1,9 @@ +import type { RefObject } from "react"; + +/** + * Return value of the useLayoutHeight hook. + */ +export interface UseLayoutHeight { + isMeasured: boolean; + ref: RefObject; +} diff --git a/src/components/Layout/hooks/UseLayoutHeight/utils.ts b/src/components/Layout/hooks/UseLayoutHeight/utils.ts new file mode 100644 index 000000000..111f0d25c --- /dev/null +++ b/src/components/Layout/hooks/UseLayoutHeight/utils.ts @@ -0,0 +1,29 @@ +import type { CSSPropHyphen } from "../../../../hooks/useHtmlStyle/types"; +import { getBorderBoxSizeHeight } from "../../../../hooks/useResizeObserver"; + +/** + * Observes an element's border-box height and writes it to a custom property on + * the HTML element on every resize. + * @param element - Element to measure; nothing is observed when null, or where ResizeObserver is unavailable. + * @param property - Custom property to set. + * @param onMeasure - Called with true once the element has been measured. + * @returns Cleanup that stops observing and removes the property. + */ +export function observeHeight( + element: HTMLElement | null, + property: CSSPropHyphen, + onMeasure: (isMeasured: boolean) => void, +): (() => void) | undefined { + if (!element || typeof ResizeObserver === "undefined") return; + const root = document.documentElement; + const observer = new ResizeObserver((entries) => { + const { height = 0 } = getBorderBoxSizeHeight(entries); + root.style.setProperty(property, `${height}px`); + onMeasure(true); + }); + observer.observe(element); + return (): void => { + observer.disconnect(); + root.style.removeProperty(property); + }; +} diff --git a/src/hooks/UseLayoutSpacing/hook.ts b/src/hooks/UseLayoutSpacing/hook.ts deleted file mode 100644 index fac41ab51..000000000 --- a/src/hooks/UseLayoutSpacing/hook.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useLayoutDimensions } from "../../providers/layoutDimensions/hook"; -import { UseLayoutSpacing } from "./types"; - -/** - * Returns the space taken by the layout's header and footer, from the measured - * layout dimensions. - * @returns Spacing with the footer height at the bottom and the header height at the top. - */ -export const useLayoutSpacing = (): UseLayoutSpacing => { - const { dimensions } = useLayoutDimensions(); - return { - spacing: { - bottom: dimensions.footer.height, - top: dimensions.header.height, - }, - }; -}; diff --git a/src/hooks/UseLayoutSpacing/types.ts b/src/hooks/UseLayoutSpacing/types.ts deleted file mode 100644 index a5a4f1681..000000000 --- a/src/hooks/UseLayoutSpacing/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Space, in pixels, taken at the top and bottom of the layout. - */ -export interface LayoutSpacing { - bottom: number; - top: number; -} - -/** - * Return value of the useLayoutSpacing hook. - */ -export interface UseLayoutSpacing { - spacing: LayoutSpacing; -} diff --git a/src/hooks/useHtmlStyle/constants.ts b/src/hooks/useHtmlStyle/constants.ts index a08c235b2..b2a442aee 100644 --- a/src/hooks/useHtmlStyle/constants.ts +++ b/src/hooks/useHtmlStyle/constants.ts @@ -1,5 +1,7 @@ -import { CSSPropHyphen } from "./types"; +import type { CSSPropHyphen } from "./types"; -export const PROPERTY: Record = { - SCROLL_PADDING_TOP: "scroll-padding-top", -}; +export const PROPERTY = { + DATA_DICTIONARY_FILTERS_HEIGHT: "--data-dictionary-filters-height", + FOOTER_HEIGHT: "--footer-height", + HEADER_HEIGHT: "--header-height", +} as const satisfies Record; diff --git a/src/hooks/useHtmlStyle/types.ts b/src/hooks/useHtmlStyle/types.ts index f5a7e729e..982412cc4 100644 --- a/src/hooks/useHtmlStyle/types.ts +++ b/src/hooks/useHtmlStyle/types.ts @@ -1,3 +1,6 @@ import type * as CSS from "csstype"; -export type CSSPropHyphen = keyof CSS.PropertiesHyphen; +/** + * Hyphenated CSS property name, or a custom property. + */ +export type CSSPropHyphen = keyof CSS.PropertiesHyphen | `--${string}`; diff --git a/src/providers/layoutDimensions/constants.ts b/src/providers/layoutDimensions/constants.ts deleted file mode 100644 index d328cc756..000000000 --- a/src/providers/layoutDimensions/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { LayoutDimensions } from "./types"; - -export const DEFAULT_LAYOUT_DIMENSIONS: LayoutDimensions = { - footer: { height: 0 }, - header: { height: 0 }, -}; diff --git a/src/providers/layoutDimensions/context.tsx b/src/providers/layoutDimensions/context.tsx deleted file mode 100644 index 0a52087eb..000000000 --- a/src/providers/layoutDimensions/context.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext } from "react"; -import { DEFAULT_LAYOUT_DIMENSIONS } from "./constants"; -import { LayoutDimensionsContextProps } from "./types"; - -export const LayoutDimensionsContext = - createContext({ - dimensions: DEFAULT_LAYOUT_DIMENSIONS, - footerRef: { current: null }, - headerRef: { current: null }, - }); diff --git a/src/providers/layoutDimensions/hook.ts b/src/providers/layoutDimensions/hook.ts deleted file mode 100644 index fff63cfb5..000000000 --- a/src/providers/layoutDimensions/hook.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -import { LayoutDimensionsContext } from "./context"; -import { LayoutDimensionsContextProps } from "./types"; - -/** - * Returns the layout dimensions context. - * @returns The layout dimensions context. - */ -export const useLayoutDimensions = (): LayoutDimensionsContextProps => { - return useContext(LayoutDimensionsContext); -}; diff --git a/src/providers/layoutDimensions/provider.tsx b/src/providers/layoutDimensions/provider.tsx deleted file mode 100644 index b3a43f749..000000000 --- a/src/providers/layoutDimensions/provider.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { JSX, useRef } from "react"; -import { - getBorderBoxSizeHeight, - useResizeObserver, -} from "../../hooks/useResizeObserver"; -import { LayoutDimensionsContext } from "./context"; -import { LayoutDimensions, LayoutDimensionsProviderProps } from "./types"; - -/** - * Provides the header and footer heights through LayoutDimensionsContext, measured with a resize observer on the header and footer refs. - * @param props - Component props. - * @param props.children - Children. - * @returns The layout dimensions provider. - */ -export const LayoutDimensionsProvider = ({ - children, -}: LayoutDimensionsProviderProps): JSX.Element => { - const footerRef = useRef(null); - const headerRef = useRef(null); - const footerRect = useResizeObserver(footerRef, getBorderBoxSizeHeight); - const headerRect = useResizeObserver(headerRef, getBorderBoxSizeHeight); - - const dimensions: LayoutDimensions = { - footer: { height: footerRect?.height ?? 0 }, - header: { height: headerRect?.height ?? 0 }, - }; - - return ( - - {children} - - ); -}; diff --git a/src/providers/layoutDimensions/types.ts b/src/providers/layoutDimensions/types.ts deleted file mode 100644 index 974aa7d5e..000000000 --- a/src/providers/layoutDimensions/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ReactNode, RefObject } from "react"; -import { ElementRect } from "../../hooks/useResizeObserver"; - -/** - * Header and footer heights. - */ -export interface LayoutDimensions { - footer: Pick; - header: Pick; -} - -/** - * Layout dimensions context. - */ -export interface LayoutDimensionsContextProps { - dimensions: LayoutDimensions; - footerRef: RefObject; - headerRef: RefObject; -} - -/** - * Props for the LayoutDimensionsProvider component. - */ -export interface LayoutDimensionsProviderProps { - children: ReactNode | ReactNode[]; -} diff --git a/src/styles/common/mixins/breakpoints.ts b/src/styles/common/mixins/breakpoints.ts index 6df05150c..f772f32a1 100644 --- a/src/styles/common/mixins/breakpoints.ts +++ b/src/styles/common/mixins/breakpoints.ts @@ -18,6 +18,15 @@ export const bpDown820 = ({ theme }: ThemeProps): string => export const bpDown1024 = ({ theme }: ThemeProps): string => theme.breakpoints.down(1024); +/** + * Media query for viewports at or wider than 1024px. + * @param props - Theme props. + * @param props.theme - MUI theme. + * @returns The media query string. + */ +export const bpUp1024 = ({ theme }: ThemeProps): string => + theme.breakpoints.up(1024); + /** * Media query for viewports at or wider than 1366px. * @param props - Theme props. diff --git a/src/styles/common/mixins/pinned.ts b/src/styles/common/mixins/pinned.ts new file mode 100644 index 000000000..476266945 --- /dev/null +++ b/src/styles/common/mixins/pinned.ts @@ -0,0 +1,27 @@ +import type { SerializedStyles } from "@emotion/react"; +import { css } from "@emotion/react"; + +/** + * For regions that pin inside the HTML scroll-padding-top (the header, the data + * dictionary title and filters): their focusable elements are always in view, so + * focusing one (e.g. a menu restoring focus to its trigger) must not scroll the + * page to clear the padding. Not for regions that scroll internally, where the + * margin would also stop them scrolling a focused element into view. + */ +export const pinnedFocus = css` + & :is(a, button, input, select, textarea, [tabindex]) { + scroll-margin-top: -100vh; + } +`; + +/** + * Pins an element at the given offset from the top as the page scrolls. + * @param top - Top offset, e.g. the header height variable. + * @returns Pinned styles. + */ +export function pinned(top: string): SerializedStyles { + return css` + position: sticky; + top: ${top}; + `; +} diff --git a/src/views/ResearchView/assistant/components/Drawer/drawer.styles.ts b/src/views/ResearchView/assistant/components/Drawer/drawer.styles.ts index e2ff7ea2d..c93ea52f1 100644 --- a/src/views/ResearchView/assistant/components/Drawer/drawer.styles.ts +++ b/src/views/ResearchView/assistant/components/Drawer/drawer.styles.ts @@ -1,23 +1,20 @@ import styled from "@emotion/styled"; import { Drawer } from "@mui/material"; -import { ComponentProps } from "react"; -import { LayoutSpacing } from "../../../../../hooks/UseLayoutSpacing/types"; +import { HEADER_HEIGHT_VAR } from "../../../../../components/Layout/components/Header/common/constants"; import { PALETTE } from "../../../../../styles/common/constants/palette"; -export const StyledDrawer = styled(Drawer, { - shouldForwardProp(prop) { - return prop !== "bottom" && prop !== "top"; - }, -}) & LayoutSpacing>` +export const StyledDrawer = styled(Drawer)` + contain: size; /* fills the main row without growing it, so a long conversation scrolls inside */ height: 100%; - max-height: 100dvh; + max-height: calc( + 100dvh - ${HEADER_HEIGHT_VAR} + ); /* never taller than the window, even beside content that grows the row */ width: 412px; z-index: 0; > .MuiPaper-root { background-color: ${PALETTE.COMMON_WHITE}; border-right: 1px solid ${PALETTE.SMOKE_MAIN}; - padding-top: ${({ top }) => top}px; position: relative; width: inherit; } diff --git a/src/views/ResearchView/assistant/components/Drawer/drawer.tsx b/src/views/ResearchView/assistant/components/Drawer/drawer.tsx index 25638d2ec..57b2d923b 100644 --- a/src/views/ResearchView/assistant/components/Drawer/drawer.tsx +++ b/src/views/ResearchView/assistant/components/Drawer/drawer.tsx @@ -1,5 +1,4 @@ import { JSX } from "react"; -import { useLayoutSpacing } from "../../../../../hooks/UseLayoutSpacing/hook"; import { DRAWER_PROPS } from "../../../../../styles/common/mui/drawer"; import { TEST_IDS } from "../../../../../tests/testIds"; import { StyledDrawer } from "./drawer.styles"; @@ -12,13 +11,11 @@ import { DrawerProps } from "./types"; * @returns The drawer component. */ export const Drawer = ({ children }: DrawerProps): JSX.Element => { - const { spacing } = useLayoutSpacing(); return ( {children} diff --git a/tests/headerPosition.test.tsx b/tests/headerPosition.test.tsx new file mode 100644 index 000000000..bfc8d3f77 --- /dev/null +++ b/tests/headerPosition.test.tsx @@ -0,0 +1,32 @@ +import { jest } from "@jest/globals"; +import { ThemeProvider } from "@mui/material"; +import { render, screen } from "@testing-library/react"; + +jest.unstable_mockModule("next/navigation", () => ({ + ...jest.requireActual("next/navigation"), + usePathname: jest.fn(() => "/"), +})); +jest.unstable_mockModule("next/router", () => ({ + ...jest.requireActual("next/router"), + default: { push: jest.fn() }, + useRouter: jest.fn(() => ({ asPath: "/", push: jest.fn() })), +})); + +const { Header } = + await import("../src/components/Layout/components/Header/header"); +const { createAppTheme } = await import("../src/theme/theme"); + +describe("Header", () => { + // The header is in flow, so content follows it at its real height; a fixed + // header would need every page to be offset by a measured height again. + it("should render the app bar sticky, not fixed", () => { + render( + +
+ , + ); + const header = screen.getByRole("banner"); + expect(header.classList.contains("MuiAppBar-positionSticky")).toBe(true); + expect(header.classList.contains("MuiAppBar-positionFixed")).toBe(false); + }); +}); diff --git a/tests/useLayoutHeight.test.tsx b/tests/useLayoutHeight.test.tsx new file mode 100644 index 000000000..2aee35d18 --- /dev/null +++ b/tests/useLayoutHeight.test.tsx @@ -0,0 +1,97 @@ +import { jest } from "@jest/globals"; +import { act, render, screen } from "@testing-library/react"; +import type { JSX } from "react"; +import { useLayoutHeight } from "../src/components/Layout/hooks/UseLayoutHeight/hook"; +import { PROPERTY } from "../src/hooks/useHtmlStyle/constants"; + +const PROPERTY_NAME = PROPERTY.HEADER_HEIGHT; + +let resize: ((entries: ResizeObserverEntry[]) => void) | undefined; +const originalResizeObserver = global.ResizeObserver; + +/** + * Returns the custom property's value on the HTML element. + * @returns Property value, or an empty string when unset. + */ +function getValue(): string { + return document.documentElement.style.getPropertyValue(PROPERTY_NAME); +} + +/** + * Renders an element measured by useLayoutHeight. + * @returns Unmount function. + */ +function renderMeasured(): () => void { + /** + * Element whose height is written to the custom property. + * @returns The measured element. + */ + const Measured = (): JSX.Element => { + const { isMeasured, ref } = useLayoutHeight(PROPERTY_NAME); + return
; + }; + return render().unmount; +} + +/** + * Reports a new border box height to the observer. + * @param blockSize - Height in pixels. + */ +function reportHeight(blockSize: number): void { + act(() => { + resize?.([ + { borderBoxSize: [{ blockSize, inlineSize: 0 }] }, + ] as unknown as ResizeObserverEntry[]); + }); +} + +describe("useLayoutHeight", () => { + beforeEach(() => { + resize = undefined; + global.ResizeObserver = jest.fn( + (callback: (entries: ResizeObserverEntry[]) => void) => { + resize = callback; + return { + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + }; + }, + ) as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + global.ResizeObserver = originalResizeObserver; + document.documentElement.style.removeProperty(PROPERTY_NAME); + }); + + it("should leave the property unset until the element is measured", () => { + renderMeasured(); + expect(getValue()).toBe(""); + }); + + it("should set the measured height, and update it on resize", () => { + renderMeasured(); + expect(screen.getByTestId("measured").dataset.measured).toBe("false"); + reportHeight(57); + expect(getValue()).toBe("57px"); + expect(screen.getByTestId("measured").dataset.measured).toBe("true"); + reportHeight(105); + expect(getValue()).toBe("105px"); + }); + + // A measured height of 0 (e.g. an empty footer) must not fall back to the + // CSS default, so it is written rather than treated as "not measured". + it("should set a measured height of 0", () => { + renderMeasured(); + reportHeight(0); + expect(getValue()).toBe("0px"); + }); + + it("should remove the property on unmount", () => { + const unmount = renderMeasured(); + reportHeight(57); + unmount(); + expect(getValue()).toBe(""); + }); +}); From f4842e11cdd394b87ff2d8705f042dd828dd582d Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:42:01 +1000 Subject: [PATCH 2/3] fix: target the dictionary filters grid by class instead of :first-child (#998) with emotion's default inline server rendering, a