diff --git a/apps/web/app/entry.tsx b/apps/web/app/entry.tsx index 4756cee869..125f204354 100644 --- a/apps/web/app/entry.tsx +++ b/apps/web/app/entry.tsx @@ -26,6 +26,7 @@ import { MobileBrowseDrawer } from '~/partials/browse-sidebar/mobile-browse-draw import { EntityCommentsPanelHost } from '~/partials/comments/entity-comments-panel-host'; import { CreateSpaceDialog } from '~/partials/create-space/create-space-dialog'; import { EntitySidePanel } from '~/partials/entity-page/entity-side-panel'; +import { EntityStickyHeaderHost } from '~/partials/entity-page/entity-sticky-header-host'; import { PersonalProfileCreatePostSidePanelSync } from '~/partials/entity-page/personal-profile-create-post-side-panel-sync'; import { FeatureFlagsDialog } from '~/partials/feature-flags/feature-flags-dialog'; import { GovernanceReopenEditLoadingBar } from '~/partials/governance/governance-reopen-edit-loading-bar'; @@ -151,6 +152,11 @@ export function App({ children }: { children: React.ReactNode }) { triggerRef={mobileBrowseButtonRef} /> setOpen(false)} /> + {/* Directly under the navbar and above the page: a zero-height dock the entity route + portals its sticky header into. See `EntityStickyHeaderHost`. The collapsed sidebar + leaves a vertical rail across this column with nothing holding the space — the same + condition that draws it below. */} +
{children}
diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx index 0a4cd52c86..1c81504f76 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx @@ -26,6 +26,7 @@ import { EntityPageContentContainer } from '~/partials/entity-page/entity-page-c import { EntityPageCover } from '~/partials/entity-page/entity-page-cover'; import { EntityPageInlineDescription } from '~/partials/entity-page/entity-page-inline-description'; import { EntityPageMetadataHeader } from '~/partials/entity-page/entity-page-metadata-header'; +import { EntityStickyHeader } from '~/partials/entity-page/entity-sticky-header'; import { EntityTabs } from '~/partials/entity-page/entity-tabs'; import { PersonalProfileSuggestedCard } from '~/partials/entity-page/personal-profile-suggested-card'; import { PersonalProfileSuggestedTaskSync } from '~/partials/entity-page/personal-profile-suggested-task-sync'; @@ -80,14 +81,25 @@ export default async function ProfileLayout(props: Props) { const result = await cachedFetchEntityPage(entityId, spaceId); const entityTypes = result?.entity?.types ?? []; + // Mounted here rather than per page: every entity surface below this — the generic page, a + // claim, a topic, a profile, and each of the type-owned record tabs — hangs off this one layout, + // and the bar has no business being drawn four times with four ideas of what it shows. + const stickyHeader = ; + if (entityBrowseViewFromTypes(entityTypes) !== 'person') { - return <>{children}; + return ( + <> + {stickyHeader} + {children} + + ); } const profile = await getProfilePage(entityId, spaceId); return ( + {stickyHeader} ({ heading: null as Record | null, storeProvider: null as Record | null, metadataHeader: null as Record | null, + stickyHeader: null as Record | null, entityTypes: [] as { id: string }[], })); @@ -83,6 +84,16 @@ vi.mock('~/partials/entity-page/entity-page-metadata-header', () => ({ }, })); +// Stubbed like every other child here: the real one reads the sync engine, which this suite mounts +// no provider for. Its props are captured because they are this file's subject — the header votes +// on and names an entity, so it has to be handed the route's id like the actions and the title. +vi.mock('~/partials/entity-page/entity-sticky-header', () => ({ + EntityStickyHeader: (props: Record) => { + mocks.stickyHeader = props; + return
; + }, +})); + vi.mock('~/partials/entity-page/entity-page-cover', () => ({ EntityPageCover: () => null })); vi.mock('~/partials/entity-page/entity-page-content-container', () => ({ EntityPageContentContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -104,6 +115,7 @@ beforeEach(() => { mocks.heading = null; mocks.storeProvider = null; mocks.metadataHeader = null; + mocks.stickyHeader = null; }); afterEach(cleanup); @@ -145,6 +157,8 @@ describe('profile ProfileLayout', () => { // The metadata header takes its id from the store rather than a prop, so pinning the provider // above is what covers it — the space it scopes by is the one assertable here. expect(mocks.metadataHeader).toMatchObject({ spaceId: SPACE_ID }); + expect(mocks.stickyHeader).toMatchObject({ entityId: ROUTE_ENTITY_ID, spaceId: SPACE_ID }); + expect(mocks.stickyHeader?.entityId).not.toBe(FETCHED_PERSON_ID); }); it('does not wrap a mixed Claim and Person entity in the profile shell', async () => { @@ -155,5 +169,9 @@ describe('profile ProfileLayout', () => { expect(view.getByTestId('page')).toBeInTheDocument(); expect(mocks.storeProvider).toBeNull(); expect(mocks.actions).toBeNull(); + // The bar is not part of the profile shell: it belongs to every entity route, so the branch that + // draws no shell still draws it. + expect(view.getByTestId('sticky-header')).toBeInTheDocument(); + expect(mocks.stickyHeader).toMatchObject({ entityId: ROUTE_ENTITY_ID, spaceId: SPACE_ID }); }); }); diff --git a/apps/web/atoms/index.ts b/apps/web/atoms/index.ts index 3b6e36cf42..2de9ed9e39 100644 --- a/apps/web/atoms/index.ts +++ b/apps/web/atoms/index.ts @@ -18,6 +18,17 @@ export const entitySidePanelAtom = atom(null); export const entitySidePanelHostElementAtom = atom(null); +/** + * Where the sticky entity header draws itself: a zero-height element docked under the navbar by the + * app shell. + * + * The bar has to span the content column and sit under the navbar, and the entity route that knows + * *which* entity is on screen renders deep inside a width-capped, transform-animated `
` — + * neither a full-bleed `sticky` nor a `fixed` element behaves there. Registering a host once in the + * shell and portalling into it keeps the positioning in the one place that can express it. + */ +export const entityStickyHeaderHostElementAtom = atom(null); + /** * The comments panel's own element, for the same reason the side panel registers one: a slide-up * locks scrolling everywhere but its own subtree, and a panel portalled to the body is outside it. diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 8ea3f2eb9c..60f2fc7276 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -34,6 +34,7 @@ import { Text } from '~/design-system/text'; import { CommentSection } from '~/partials/comments/comments-section'; import { Editor } from '~/partials/editor/editor'; import { EditableHeading } from '~/partials/entity-page/editable-entity-header'; +import { ENTITY_PAGE_CONTENT_ANCHOR, entityPageTitleAnchor } from '~/partials/entity-page/entity-page-anchors'; import { ENTITY_DESCRIPTION_MAX_LINES, EntityPageInlineDescription, @@ -217,6 +218,7 @@ export function ClaimPageView({ return (
@@ -262,7 +264,10 @@ export function ClaimPageView({ {isEditing ? ( ) : ( -

+

{entity.name ?? entity.id}

)} diff --git a/apps/web/core/hooks/use-mirrored-content-column.test.tsx b/apps/web/core/hooks/use-mirrored-content-column.test.tsx new file mode 100644 index 0000000000..dc598e38af --- /dev/null +++ b/apps/web/core/hooks/use-mirrored-content-column.test.tsx @@ -0,0 +1,308 @@ +import { act, cleanup, render, renderHook } from '@testing-library/react'; + +import * as React from 'react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMirroredContentColumn } from './use-mirrored-content-column'; + +const ATTRIBUTE = 'data-entity-page-content'; + +/** + * jsdom lays nothing out, so every box in this file is stated rather than measured. + * + * Spelled out field by field rather than spread from a `DOMRect`: its properties are prototype + * accessors, so a spread copies none of them and every measurement comes back `NaN`. + */ +function withBox(element: T, box: { left: number; width: number }): T { + const rect: DOMRect = { + x: box.left, + y: 0, + left: box.left, + right: box.left + box.width, + top: 0, + bottom: 40, + width: box.width, + height: 40, + toJSON: () => ({}), + }; + element.getBoundingClientRect = () => rect; + return element; +} + +/** + * A host, a tagged column with padding, and an anchor inside the column — the shape the sticky + * header sees: it is portalled into the host and tracks the page's title. + */ +function buildPage({ + host: hostBox = { left: 200, width: 1240 }, + column: columnBox = { left: 400, width: 840 }, + padding = '20px', + tagColumn = true, +}: { + host?: { left: number; width: number }; + column?: { left: number; width: number }; + padding?: string; + tagColumn?: boolean; +} = {}) { + const host = withBox(document.createElement('div'), hostBox); + const column = withBox(document.createElement('div'), columnBox); + if (tagColumn) column.setAttribute(ATTRIBUTE, ''); + column.style.paddingLeft = padding; + column.style.paddingRight = padding; + + const anchor = document.createElement('h1'); + column.append(anchor); + document.body.append(host, column); + + return { host, column, anchor }; +} + +beforeEach(() => { + // Observed but never fired: these cases are about the first measurement. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +describe('useMirroredContentColumn', () => { + it('reports the column’s content box in the host’s coordinates', () => { + const { host, anchor } = buildPage(); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + // Column at 400 with 20px of padding, host starting at 200: text starts 220 into the host and + // runs for the 800 the padding leaves. + expect(result.current).toEqual({ left: 220, width: 800 }); + }); + + /** + * The gutters are the page's, not the bar's. Mirroring the border box would put the bar's text a + * gutter outside the text it lines up with, and then the bar's own padding would double it. + */ + it('takes the column’s own padding off rather than passing it on', () => { + const { host, anchor } = buildPage({ padding: '0px' }); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toEqual({ left: 200, width: 840 }); + }); + + it('follows whichever column the anchor is in, so a narrower view is matched', () => { + const { host, anchor } = buildPage({ column: { left: 460, width: 720 }, padding: '16px' }); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toEqual({ left: 276, width: 688 }); + }); + + /** + * The nearest tagged ancestor, not the first in the document: a side panel open over the page has + * a column of its own, and a document query would be a coin toss between them. + */ + it('reads the column the anchor sits in and ignores others', () => { + const { host, anchor } = buildPage({ column: { left: 400, width: 840 }, padding: '20px' }); + const otherColumn = withBox(document.createElement('div'), { left: 0, width: 300 }); + otherColumn.setAttribute(ATTRIBUTE, ''); + document.body.prepend(otherColumn); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toEqual({ left: 220, width: 800 }); + }); + + it('reports nothing when the anchor is in no tagged column', () => { + const { host, anchor } = buildPage({ tagColumn: false }); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toBeNull(); + }); + + /** + * A detached or hidden column measures all zeros, which is an absence rather than a width — + * passing it on would have the caller draw a blank strip instead of falling back to its own. + */ + it('reports nothing for a column with no width', () => { + const { host, column, anchor } = buildPage(); + withBox(column, { left: 0, width: 0 }); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toBeNull(); + }); + + it('reports nothing without an anchor or a host', () => { + const { host, anchor } = buildPage(); + + expect(renderHook(() => useMirroredContentColumn(null, host, ATTRIBUTE)).result.current).toBeNull(); + expect(renderHook(() => useMirroredContentColumn(anchor, null, ATTRIBUTE)).result.current).toBeNull(); + }); + + it('measures once and then watches both the column and the host', () => { + const observed: Element[] = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe(element: Element) { + observed.push(element); + } + unobserve() {} + disconnect() {} + } + ); + + const { host, column, anchor } = buildPage(); + renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + // The host too: collapsing the browse sidebar widens the column under a bar that has not + // otherwise changed. + expect(observed).toEqual([column, host]); + }); + + /** + * The whole point of the observer. A column's width is not fixed for the life of the page — it + * changes when the window resizes, when a rail appears beside it, and whenever somebody edits the + * constant it is set from — and none of those re-render the caller. + */ + it('re-measures when the column changes size', () => { + let fire = () => {}; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + fire = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + ); + + const { host, column, anchor } = buildPage(); + const { result, rerender } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + expect(result.current).toEqual({ left: 220, width: 800 }); + + withBox(column, { left: 300, width: 1040 }); + act(() => fire()); + rerender(); + + expect(result.current).toEqual({ left: 120, width: 1000 }); + }); + + /** + * The host is watched as well as the column, for the case the column alone would miss: a column at + * its max width does not resize when the window does, it only moves. + */ + it('re-measures when the host moves under an unchanged column', () => { + let fire = () => {}; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + fire = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + ); + + const { host, anchor } = buildPage(); + const { result, rerender } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + expect(result.current).toEqual({ left: 220, width: 800 }); + + withBox(host, { left: 24, width: 1416 }); + act(() => fire()); + rerender(); + + expect(result.current).toEqual({ left: 396, width: 800 }); + }); + + it('still measures where ResizeObserver is missing', () => { + vi.stubGlobal('ResizeObserver', undefined); + const { host, anchor } = buildPage(); + + const { result } = renderHook(() => useMirroredContentColumn(anchor, host, ATTRIBUTE)); + + expect(result.current).toEqual({ left: 220, width: 800 }); + }); +}); + +/** + * *When* the measurement lands, not just what it says. + * + * A passive effect runs after the browser paints, so the commit that first sees a new anchor would + * paint with the previous column's geometry — or, on a title mounting late, with the caller's + * fallback width. That is the wrong width on screen for a frame, which is a different thing from a + * decoration arriving a frame late. + * + * Phase is asserted by ordering rather than by reading React internals: a parent's layout effect + * runs *after* its children's, so a measurement taken in the hook's layout effect is recorded before + * the parent's marker, and one taken in a passive effect after it. + */ +describe('when the column is measured', () => { + function renderWithPhaseProbe() { + const log: string[] = []; + + const host = withBox(document.createElement('div'), { left: 200, width: 1240 }); + const column = document.createElement('div'); + column.setAttribute(ATTRIBUTE, ''); + column.getBoundingClientRect = () => { + log.push('measure'); + return { + x: 400, + y: 0, + left: 400, + right: 1240, + top: 0, + bottom: 40, + width: 840, + height: 40, + toJSON: () => ({}), + } as DOMRect; + }; + const anchor = document.createElement('h1'); + column.append(anchor); + document.body.append(host, column); + + function Child() { + useMirroredContentColumn(anchor, host, ATTRIBUTE); + return null; + } + + function Parent() { + React.useLayoutEffect(() => { + log.push('parent-layout'); + }); + React.useEffect(() => { + log.push('parent-passive'); + }); + return ; + } + + render(); + return log; + } + + it('is measured before paint, not after it', () => { + const log = renderWithPhaseProbe(); + + expect(log).toContain('measure'); + expect(log).toContain('parent-layout'); + // Ahead of the parent's layout marker means it happened in the layout phase — before the browser + // is given a chance to paint the frame that commit belongs to. + expect(log.indexOf('measure')).toBeLessThan(log.indexOf('parent-layout')); + }); +}); diff --git a/apps/web/core/hooks/use-mirrored-content-column.ts b/apps/web/core/hooks/use-mirrored-content-column.ts new file mode 100644 index 0000000000..2773e6d917 --- /dev/null +++ b/apps/web/core/hooks/use-mirrored-content-column.ts @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; + +export type ContentColumnBox = { + /** Offset from `host`'s left edge to where the column's content starts. */ + left: number; + /** The column's content width, its own horizontal padding already taken off. */ + width: number; +}; + +function measure(content: Element, host: Element): ContentColumnBox | null { + const contentRect = content.getBoundingClientRect(); + const hostRect = host.getBoundingClientRect(); + + // A detached or hidden column measures all zeros, which is not a width — it is an absence, and + // reporting it as `{ left: -hostLeft, width: 0 }` would have the caller draw a blank strip rather + // than fall back to a width of its own. + if (contentRect.width === 0) return null; + + // The *content* box, not the border box. Every page but the generic one pads its column + // (`px-4`/`px-5`), and mirroring the border box would put the bar's text a gutter's width outside + // the text it is supposed to line up with — then the bar's own padding would double it. + const styles = getComputedStyle(content); + const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0; + const paddingRight = Number.parseFloat(styles.paddingRight) || 0; + + return { + left: contentRect.left + paddingLeft - hostRect.left, + width: Math.max(0, contentRect.width - paddingLeft - paddingRight), + }; +} + +/** + * Where a page's content column sits, in `host`'s coordinates. + * + * For lining a fixed or portalled element up with a column it is not inside. There is no width to + * hard-code: the generic entity page is 900 and unpadded, a claim 840 at `px-4`/`px-5`, a page with + * a rail 1142 — and a view added later will have its own. A topic was 720 until #2580 moved it to + * the generic width, which is the point: nothing here needed changing when it did. Reading the column the caller + * is actually tracking means a new one is matched by tagging it, with nothing to keep in sync here. + * + * `anchor` is any element inside that column; the column itself is the nearest ancestor carrying + * `attribute`. Anchoring rather than querying the document keeps the answer unambiguous when a side + * panel is open over the page with a column of its own. + * + * Returns null when there is nothing to mirror, which is the caller's cue to fall back to a width of + * its own rather than collapse to zero. + * + * Measured in a layout effect, which is the one place it can be measured. It cannot be derived during + * render — the DOM this reads is the DOM being rendered, so the element is not committed yet and + * `closest` would walk the previous tree. A passive effect is the other way to get it wrong: it runs + * after the browser paints, so the commit that first sees a new anchor paints with the *previous* + * column's geometry, or with the caller's fallback width when a title mounts late. That is the wrong + * width on screen for a frame — a different and worse thing than a decoration arriving a frame late, + * which is why `useScrolledPastElement` is content to stay passive and this is not. + */ +export function useMirroredContentColumn( + anchor: Element | null, + host: Element | null, + attribute: string +): ContentColumnBox | null { + const [box, setBox] = React.useState(null); + + React.useLayoutEffect(() => { + if (!anchor || !host) { + setBox(null); + return; + } + + const content = anchor.closest(`[${attribute}]`); + if (!content) { + setBox(null); + return; + } + + const sync = () => { + const next = measure(content, host); + // Compared before storing: a `ResizeObserver` fires for changes on both axes, and a bar that + // re-rendered every time the page below it grew a row would be paying for nothing — the + // horizontal geometry is all this reports. + setBox(previous => + previous && next && previous.left === next.left && previous.width === next.width ? previous : next + ); + }; + + sync(); + + if (typeof ResizeObserver === 'undefined') return; + + // Both: the column moves when the window resizes, and also when the host does — collapsing the + // browse sidebar widens the column under a bar that has not otherwise changed. + const observer = new ResizeObserver(sync); + observer.observe(content); + observer.observe(host); + + return () => observer.disconnect(); + }, [anchor, host, attribute]); + + return box; +} diff --git a/apps/web/core/hooks/use-scrolled-past-element.test.tsx b/apps/web/core/hooks/use-scrolled-past-element.test.tsx new file mode 100644 index 0000000000..0bf5c9f728 --- /dev/null +++ b/apps/web/core/hooks/use-scrolled-past-element.test.tsx @@ -0,0 +1,410 @@ +import { act, cleanup, render as renderComponent, renderHook, waitFor } from '@testing-library/react'; + +import * as React from 'react'; + +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useScrolledPastElement } from './use-scrolled-past-element'; + +type ObserverRecord = { + callback: IntersectionObserverCallback; + disconnect: Mock<() => void>; + observed: Element[]; + options?: IntersectionObserverInit; + observer: IntersectionObserver; +}; + +let observers: ObserverRecord[] = []; + +const SELECTOR = '[data-entity-page-title="entity-1"]'; +const TOP_OFFSET = 44; + +beforeEach(() => { + observers = []; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin = '0px'; + readonly scrollMargin = '0px'; + readonly thresholds = [0]; + private readonly record: ObserverRecord; + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.record = { callback, disconnect: vi.fn(), observed: [], options, observer: this }; + observers.push(this.record); + } + + observe(element: Element) { + this.record.observed.push(element); + } + + unobserve(element: Element) { + this.record.observed = this.record.observed.filter(observed => observed !== element); + } + + disconnect() { + this.record.disconnect(); + this.record.observed = []; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); +}); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +function addTitle(id = 'entity-1') { + const title = document.createElement('h1'); + title.setAttribute('data-entity-page-title', id); + document.body.append(title); + return title; +} + +/** jsdom lays nothing out, so a title's position on screen has to be stated. */ +function atViewportBottom(element: Element, bottom: number) { + element.getBoundingClientRect = () => + ({ + x: 0, + y: bottom - 30, + left: 0, + right: 300, + top: bottom - 30, + bottom, + width: 300, + height: 30, + toJSON: () => ({}), + }) as DOMRect; + return element; +} + +function latestObserver() { + const record = observers.at(-1); + if (!record) throw new Error('Expected an IntersectionObserver to have been created'); + return record; +} + +/** One notification, with the geometry that separates "above the fold" from "below it". */ +function notify(record: ObserverRecord, target: Element, ...entries: { isIntersecting: boolean; bottom: number }[]) { + act(() => { + record.callback( + entries.map( + ({ isIntersecting, bottom }, index) => + ({ + target, + time: index, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + boundingClientRect: { bottom } as DOMRectReadOnly, + }) as IntersectionObserverEntry + ), + record.observer + ); + }); +} + +function render(enabled = true) { + return renderHook(() => useScrolledPastElement({ selector: SELECTOR, topOffset: TOP_OFFSET, enabled })); +} + +function renderForSelector(selector: string) { + return renderHook(({ selector }) => useScrolledPastElement({ selector, topOffset: TOP_OFFSET }), { + initialProps: { selector }, + }); +} + +describe('useScrolledPastElement', () => { + it('observes the matching element below the docked offset', () => { + const title = addTitle(); + render(); + + const record = latestObserver(); + expect(record.observed).toEqual([title]); + expect(record.options).toEqual({ root: null, rootMargin: `-${TOP_OFFSET}px 0px 0px 0px`, threshold: 0 }); + }); + + it('is true once the element has left the viewport upwards', () => { + const title = addTitle(); + const { result } = render(); + + expect(result.current.scrolledPast).toBe(false); + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current.scrolledPast).toBe(true); + }); + + it('stays false for an element that is merely out of view below the fold', () => { + const title = addTitle(); + const { result } = render(); + + // Not intersecting, but still ahead of the reader — raising the bar here would announce a + // title nobody has scrolled to yet. + notify(latestObserver(), title, { isIntersecting: false, bottom: 2400 }); + expect(result.current.scrolledPast).toBe(false); + }); + + it('goes back to false when the element scrolls into view again', () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current.scrolledPast).toBe(true); + + notify(latestObserver(), title, { isIntersecting: true, bottom: 200 }); + expect(result.current.scrolledPast).toBe(false); + }); + + it('follows the last transition when several are batched', () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }, { isIntersecting: true, bottom: 200 }); + expect(result.current.scrolledPast).toBe(false); + }); + + it('picks up a title that mounts after it starts watching', async () => { + const { result } = render(); + expect(latestObserver().observed).toEqual([]); + + const title = addTitle(); + await waitFor(() => expect(latestObserver().observed).toEqual([title])); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current.scrolledPast).toBe(true); + }); + + it('clears when the watched title is removed from the document', async () => { + const title = addTitle(); + const { result } = render(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current.scrolledPast).toBe(true); + + act(() => title.remove()); + await waitFor(() => expect(result.current.scrolledPast).toBe(false)); + }); + + it('ignores a title belonging to some other entity', () => { + addTitle('entity-2'); + render(); + + expect(latestObserver().observed).toEqual([]); + }); + + it('watches nothing while disabled', () => { + addTitle(); + const { result } = render(false); + + expect(observers).toHaveLength(0); + expect(result.current.scrolledPast).toBe(false); + }); + + /** + * Moving between two entities. The next one's title is not in the document at the moment the + * selector changes, and `next === watched` is true when both are null — so without an explicit + * reset the previous entity's answer stood, opening the new page with the bar already up and the + * old, detached title still being handed out for measuring. + */ + it('forgets the previous entity when the selector changes', async () => { + const title = addTitle('entity-1'); + const { result, rerender } = renderForSelector('[data-entity-page-title="entity-1"]'); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(result.current.scrolledPast).toBe(true); + + // The old title goes with the old page; the new one has not mounted yet. + act(() => title.remove()); + rerender({ selector: '[data-entity-page-title="entity-2"]' }); + + await waitFor(() => expect(result.current.scrolledPast).toBe(false)); + expect(result.current.target).toBeNull(); + }); + + /** + * One lookup a frame. While a title is mounted `sync` is a connectedness test, but a route that + * draws none — the debates feed — would otherwise query the document on every mutation batch, and + * that feed mutates continuously. + */ + it('coalesces document lookups when there is no title to find', async () => { + const querySelector = vi.spyOn(document, 'querySelector'); + render(); + const initialLookups = querySelector.mock.calls.length; + + act(() => { + for (let index = 0; index < 20; index++) { + document.body.append(document.createElement('div')); + } + }); + + await waitFor(() => expect(querySelector.mock.calls.length).toBeGreaterThan(initialLookups)); + // One frame's worth, not one per mutation batch. + expect(querySelector.mock.calls.length - initialLookups).toBeLessThanOrEqual(2); + querySelector.mockRestore(); + }); + + /** + * What each *commit* carried, not what settled afterwards and not every render call. + * + * Recorded in a layout effect on purpose. Reading `result.current` after `rerender` cannot see a + * value that was painted and then corrected, which is the value in question — but recording during + * render would over-report the other way, since a state adjustment made during render makes React + * re-run the component and throw the first pass away without ever committing it. A layout effect + * runs once per commit, before the browser paints, so this is exactly the set of values a reader + * could have seen. + */ + function captureCommits() { + const commits: { selector: string; scrolledPast: boolean; hasTarget: boolean }[] = []; + + function Probe({ selector }: { selector: string }) { + const { scrolledPast, target } = useScrolledPastElement({ selector, topOffset: TOP_OFFSET }); + React.useLayoutEffect(() => { + commits.push({ selector, scrolledPast, hasTarget: target !== null }); + }); + return null; + } + + return { commits, Probe }; + } + + /** + * A reset in a passive effect runs *after* the browser has painted, so the render that first sees + * the new selector still returns the previous entity's answer. On a client-side navigation to an + * already-hydrated entity that paints its bar for a frame, over a page whose title has never been + * observed. Derived during render instead, so there is no frame to paint. + */ + it('never returns the previous entity’s answer under the new selector', async () => { + const title = addTitle('entity-1'); + const { commits, Probe } = captureCommits(); + const view = renderComponent(); + + notify(latestObserver(), title, { isIntersecting: false, bottom: -120 }); + expect(commits.at(-1)).toMatchObject({ scrolledPast: true }); + + act(() => title.remove()); + view.rerender(); + + const underNewSelector = commits.filter(entry => entry.selector === '[data-entity-page-title="entity-2"]'); + expect(underNewSelector.length).toBeGreaterThan(0); + expect(underNewSelector.every(entry => entry.scrolledPast === false)).toBe(true); + expect(underNewSelector.every(entry => entry.hasTarget === false)).toBe(true); + }); + + /** + * A tab swaps the title for another element belonging to the same entity, so the selector never + * changes and the render-time reset above never runs. The old element's answer is not about the + * new one, and `IntersectionObserver` does not report on a freshly observed element until a task + * later — long enough to paint the bar over a title sitting at the top of a newly opened tab. + */ + it('does not carry a scrolled answer onto a title swapped in under the same selector', async () => { + const first = atViewportBottom(addTitle(), -200); + const { result } = render(); + + notify(latestObserver(), first, { isIntersecting: false, bottom: -200 }); + expect(result.current.scrolledPast).toBe(true); + + // The tab swaps the title, and the new one sits in view. No notification yet. + act(() => first.remove()); + const second = atViewportBottom(addTitle(), 300); + await waitFor(() => expect(result.current.target).toBe(second)); + + expect(result.current.scrolledPast).toBe(false); + }); + + /** + * And the other direction, which a blind clear would get wrong: the element swapped in is already + * above the fold, so the answer is `true` from the first frame rather than after the observer + * catches up. Measuring the new target is what covers both. + */ + it('reports a title swapped in already above the fold without waiting for the observer', async () => { + const first = atViewportBottom(addTitle(), 300); + const { result } = render(); + + notify(latestObserver(), first, { isIntersecting: true, bottom: 300 }); + expect(result.current.scrolledPast).toBe(false); + + act(() => first.remove()); + const second = atViewportBottom(addTitle(), -120); + await waitFor(() => expect(result.current.target).toBe(second)); + + expect(result.current.scrolledPast).toBe(true); + }); + + /** + * `unobserve` stops future records; it does not purge ones already queued. So a notification about + * the title a tab just replaced can land after the swap, and taking the batch's last entry without + * asking what it describes let that stale record overwrite the measurement taken for the new title + * — undoing, for a frame, the very thing measuring at the swap was added to fix. + */ + it('ignores a queued notification about the title it has already dropped', async () => { + const first = atViewportBottom(addTitle(), -200); + const { result } = render(); + + notify(latestObserver(), first, { isIntersecting: false, bottom: -200 }); + expect(result.current.scrolledPast).toBe(true); + + act(() => first.remove()); + const second = atViewportBottom(addTitle(), 300); + await waitFor(() => expect(result.current.target).toBe(second)); + expect(result.current.scrolledPast).toBe(false); + + // The old element's record, queued before it was unobserved, arriving late. + notify(latestObserver(), first, { isIntersecting: false, bottom: -200 }); + + expect(result.current.scrolledPast).toBe(false); + }); + + /** + * A retired observer's callback can still run. `disconnect()` empties `[[ObservationTargets]]`; the + * spec does not empty `[[QueuedEntries]]`, and "notify intersection observers" invokes the callback + * of any observer whose queue is non-empty. That closure keeps the *old* `watched`, so the + * target filter waves the record straight through and it lands on top of the render-time reset the + * new selector just performed. + */ + it('ignores a callback from an observer it has already disconnected', async () => { + const first = atViewportBottom(addTitle('entity-1'), -200); + const { result, rerender } = renderForSelector('[data-entity-page-title="entity-1"]'); + + const retired = latestObserver(); + notify(retired, first, { isIntersecting: false, bottom: -200 }); + expect(result.current.scrolledPast).toBe(true); + + // A new entity: the effect tears the old observer down and starts another. + act(() => first.remove()); + rerender({ selector: '[data-entity-page-title="entity-2"]' }); + await waitFor(() => expect(result.current.scrolledPast).toBe(false)); + expect(retired.disconnect).toHaveBeenCalled(); + + // The retired observer's queued record, arriving after its own teardown. + notify(retired, first, { isIntersecting: false, bottom: -200 }); + + expect(result.current.scrolledPast).toBe(false); + }); + + it('stays false when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined); + addTitle(); + + expect(render().result.current.scrolledPast).toBe(false); + }); + + /** + * Handed back as well as watched, so the sticky header can measure the content column around it + * without mounting a second `MutationObserver` over the body to find the same element again. + */ + it('hands back the element it is watching', async () => { + const { result } = render(); + expect(result.current.target).toBeNull(); + + const title = addTitle(); + await waitFor(() => expect(result.current.target).toBe(title)); + + act(() => title.remove()); + await waitFor(() => expect(result.current.target).toBeNull()); + }); +}); diff --git a/apps/web/core/hooks/use-scrolled-past-element.ts b/apps/web/core/hooks/use-scrolled-past-element.ts new file mode 100644 index 0000000000..926e802671 --- /dev/null +++ b/apps/web/core/hooks/use-scrolled-past-element.ts @@ -0,0 +1,182 @@ +'use client'; + +import * as React from 'react'; + +type Options = { + /** CSS selector for the element to watch. Re-queried whenever it leaves the document. */ + selector: string; + /** How far down the viewport counts as "still visible" — the height of whatever is docked above. */ + topOffset: number; + enabled?: boolean; +}; + +type Result = { + /** Whether the watched element has scrolled up out of view. */ + scrolledPast: boolean; + /** The element being watched, so a caller can measure it or what it sits inside. */ + target: Element | null; +}; + +/** + * Whether the element matching `selector` has scrolled up out of view, above `topOffset`. + * + * Deliberately selector-based rather than ref-based. The thing being watched — an entity's title — + * is drawn by four unrelated branches (the generic header, the claim hero, the topic hero, the + * profile layout), and the thing that wants the answer is mounted once, above all of them, so there + * is no ref to hand between the two without threading a context through every branch. + * + * `isIntersecting` alone cannot answer this: an element below the fold and an element scrolled off + * the top both read false. The sign of `boundingClientRect.bottom` is what separates them, and only + * "above" counts — a title the reader has not reached yet must not raise the bar. + * + * The target arrives late and can be replaced: this app paints nothing until hydration, and moving + * between an entity's tabs swaps the subtree. A `MutationObserver` covers both, guarded on + * `isConnected` so the usual case is a boolean test rather than a document query per mutation. + * + * Answers `false` where `IntersectionObserver` is missing (jsdom, older browsers). The safe failure + * for a decoration is to stay out of the way. + */ +export function useScrolledPastElement({ selector, topOffset, enabled = true }: Options): Result { + const [scrolledPast, setScrolledPast] = React.useState(false); + // Handed back as well as watched. The one element this hook goes to the trouble of finding is also + // the only handle a caller has on the page it belongs to — the sticky header measures the content + // column around it — and finding it twice would mean a second `MutationObserver` over the body. + const [target, setTarget] = React.useState(null); + const [trackedSelector, setTrackedSelector] = React.useState(selector); + + /* + * A new selector is a new entity, and its title is not in the document yet — so neither of these + * answers is about the page now being asked for. + * + * Adjusted during render rather than in an effect. `sync`'s early return — `next === watched` is + * true when both are null — used to leave the previous entity's answer standing until an effect + * cleared it, and a passive effect runs *after* the browser paints: on a client-side navigation to + * an entity that is already hydrated, the render that first saw the new selector handed back + * `scrolledPast: true` and the old, detached title, and the bar painted for a frame over a page + * whose own title had never been observed. + * + * This is React's documented way to reset state when a prop changes: setting state during render + * of the same component makes React re-run it immediately, before committing or painting, so there + * is no intervening frame to get wrong. `useLayoutEffect` would also beat the paint, but only by + * committing once and correcting itself — this never emits the wrong value at all. + */ + if (trackedSelector !== selector) { + setTrackedSelector(selector); + setScrolledPast(false); + setTarget(null); + } + + React.useEffect(() => { + if (!enabled) { + setScrolledPast(false); + setTarget(null); + return; + } + + if (typeof document === 'undefined' || typeof IntersectionObserver === 'undefined') return; + + let watched: Element | null = null; + + /* + * A retired observer can still call back. `disconnect()` empties `[[ObservationTargets]]`; the + * spec does not empty `[[QueuedEntries]]`, and "notify intersection observers" invokes the + * callback of any observer whose queue is non-empty. That closure keeps this effect's `watched`, + * so the target filter below waves the record straight through — and it lands on top of the + * render-time reset the new selector just performed. + * + * `ResizeObserver` and `MutationObserver` need no equivalent: both specs clear their pending work + * on `disconnect` (`activeTargets`, and the record queue respectively). This one is alone in not. + */ + let disposed = false; + + const observer = new IntersectionObserver( + entries => { + if (disposed) return; + // Entries are queued chronologically and several transitions can arrive in one batch, so a + // reversible state has to follow the last of them rather than any earlier one. + // + // Filtered by target first. `unobserve` stops future records; it does not purge ones already + // queued, so a notification about the title a tab just replaced can land after the swap. Read + // blindly, that stale record overwrote the measurement taken for the new title — undoing, for + // a frame, the very thing measuring at the swap exists to fix. + const latest = entries.filter(entry => entry.target === watched).at(-1); + if (!latest) return; + setScrolledPast(!latest.isIntersecting && latest.boundingClientRect.bottom <= topOffset); + }, + // Shrinking the root's top edge by the docked height makes "visible" mean "visible below the + // bar", which is what the reader actually sees. + { root: null, rootMargin: `-${topOffset}px 0px 0px 0px`, threshold: 0 } + ); + + const sync = () => { + if (watched?.isConnected) return; + + const next = document.querySelector(selector); + if (next === watched) return; + + if (watched) observer.unobserve(watched); + watched = next; + setTarget(next); + + if (watched) { + // Measured here rather than left to the observer's first notification, which does not arrive + // until a task later. A tab swapping the title for another element of the *same* entity + // leaves `selector` untouched, so the render-time reset above never runs, and without this + // the element that just left carried its answer onto the one that replaced it — long enough + // to paint the bar over a title sitting at the top of a newly opened tab. + // + // Measured rather than simply cleared, which would only move the wrong frame to the other + // direction: a tab opened already scrolled past its title would blink the bar off and back + // on. This is the predicate the observer itself applies — `isIntersecting` is redundant + // against a root shrunk by `topOffset`, since anything whose bottom is above that line + // cannot be intersecting it. + const rect = watched.getBoundingClientRect(); + const isRendered = rect.width > 0 || rect.height > 0; + setScrolledPast(isRendered && rect.bottom <= topOffset); + + observer.observe(watched); + } else { + // Nothing to watch means nothing to be past. Without this the bar would stay up after the + // title it belongs to was unmounted. + setScrolledPast(false); + } + }; + + sync(); + + if (typeof MutationObserver === 'undefined') { + return () => { + disposed = true; + observer.disconnect(); + }; + } + + // Coalesced to one lookup a frame. `sync` is cheap while the title is mounted — a connectedness + // test — but a route that draws none, the debates feed, leaves it querying the document on every + // batch, and that feed mutates continuously. + let queued = 0; + const scheduleSync = () => { + if (queued) return; + queued = requestAnimationFrame(() => { + queued = 0; + sync(); + }); + }; + + const mutations = new MutationObserver(scheduleSync); + mutations.observe(document.body, { childList: true, subtree: true }); + + return () => { + disposed = true; + if (queued) cancelAnimationFrame(queued); + mutations.disconnect(); + observer.disconnect(); + }; + }, [selector, topOffset, enabled]); + + // `enabled` gets the same treatment for the same reason: its effect branch clears these, and that + // clearing is a paint too late. + return enabled ? { scrolledPast, target } : DISABLED; +} + +const DISABLED: Result = { scrolledPast: false, target: null }; diff --git a/apps/web/core/topics/browse/topic-page-view.tsx b/apps/web/core/topics/browse/topic-page-view.tsx index 5b9d545b35..915b9eb979 100644 --- a/apps/web/core/topics/browse/topic-page-view.tsx +++ b/apps/web/core/topics/browse/topic-page-view.tsx @@ -29,6 +29,7 @@ import { Editor } from '~/partials/editor/editor'; import { EditableHeading } from '~/partials/entity-page/editable-entity-header'; import { RelationsGroup as EditableRelationsGroup } from '~/partials/entity-page/editable-entity-page'; import { EntityPageActions } from '~/partials/entity-page/entity-page-actions'; +import { ENTITY_PAGE_CONTENT_ANCHOR, entityPageTitleAnchor } from '~/partials/entity-page/entity-page-anchors'; import { ENTITY_DESCRIPTION_MAX_LINES, EntityPageInlineDescription, @@ -267,6 +268,7 @@ export function TopicPageView({ return (
@@ -304,7 +306,13 @@ export function TopicPageView({ {isEditing ? ( ) : ( - + {entity.name ?? entity.id} )} diff --git a/apps/web/partials/entity-page/editable-entity-header.tsx b/apps/web/partials/entity-page/editable-entity-header.tsx index edf563ae6a..ca5a70a7a3 100644 --- a/apps/web/partials/entity-page/editable-entity-header.tsx +++ b/apps/web/partials/entity-page/editable-entity-header.tsx @@ -37,6 +37,7 @@ export function EditableHeading({ return ( `, which is where a route's own title is drawn. */ +function routeTitle(entityId = ENTITY) { + const main = document.createElement('main'); + const title = document.createElement('h1'); + title.setAttribute(ENTITY_PAGE_TITLE_ATTRIBUTE, entityId); + main.append(title); + document.body.append(main); + return title; +} + +/** + * The entity side panel, which portals to `document.body` in both of its branches — and draws its + * title through the very same components the route does. + */ +function panelTitle(entityId = ENTITY) { + const panel = document.createElement('aside'); + panel.setAttribute('data-entity-side-panel', ''); + const title = document.createElement('h1'); + title.setAttribute(ENTITY_PAGE_TITLE_ATTRIBUTE, entityId); + panel.append(title); + document.body.append(panel); + return title; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('entityPageTitleSelector', () => { + it('finds the route’s own title', () => { + const title = routeTitle(); + + expect(document.querySelector(entityPageTitleSelector(ENTITY))).toBe(title); + }); + + /** + * The case document order alone does not cover. A Debate route draws the live feed instead of a + * title, so with the panel open on that same entity its heading was the only match — and the + * panel scrolls in a container of its own, so scrolling it raised the route's bar over a page + * whose title had never moved. + */ + it('ignores a side panel’s title when the route draws none', () => { + panelTitle(); + document.body.prepend(document.createElement('main')); + + expect(document.querySelector(entityPageTitleSelector(ENTITY))).toBeNull(); + }); + + it('prefers the route’s title over a panel open on the same entity', () => { + const panel = panelTitle(); + const route = routeTitle(); + + const found = document.querySelector(entityPageTitleSelector(ENTITY)); + expect(found).toBe(route); + expect(found).not.toBe(panel); + }); + + it('does not match another entity’s title', () => { + routeTitle('c3d4e5f6a7b8429c0d1e2f3a4b5c6d7e'); + + expect(document.querySelector(entityPageTitleSelector(ENTITY))).toBeNull(); + }); +}); + +describe('the anchors themselves', () => { + it('mark a title with the entity it names', () => { + expect(entityPageTitleAnchor(ENTITY)).toEqual({ [ENTITY_PAGE_TITLE_ATTRIBUTE]: ENTITY }); + }); + + /** Spread onto an element, so it has to be a present-but-empty attribute rather than a flag. */ + it('mark a content column with a valueless attribute', () => { + expect(ENTITY_PAGE_CONTENT_ANCHOR).toEqual({ [ENTITY_PAGE_CONTENT_ATTRIBUTE]: '' }); + + const column = document.createElement('div'); + column.setAttribute(ENTITY_PAGE_CONTENT_ATTRIBUTE, ''); + expect(column.matches(`[${ENTITY_PAGE_CONTENT_ATTRIBUTE}]`)).toBe(true); + }); +}); diff --git a/apps/web/partials/entity-page/entity-page-anchors.ts b/apps/web/partials/entity-page/entity-page-anchors.ts new file mode 100644 index 0000000000..c68bc930bc --- /dev/null +++ b/apps/web/partials/entity-page/entity-page-anchors.ts @@ -0,0 +1,64 @@ +/** + * The two marks an entity page leaves in the DOM for the sticky header to find it by. + * + * Its own module, with no `'use client'`, because the `(entity)` layout is a Server Component: every + * export of a client module is a client reference on the server, so a constant living beside a + * component would reach the flight payload as a reference rather than a string. `space-tabs-anchor` + * is here for the same reason and is the pattern this follows. + * + * Attribute and selector are one contract with two halves, and the point of naming each once is that + * the two cannot drift — four files were spelling `data-entity-page-title` out by hand. + * + * Why the DOM at all: the header is mounted once in the layout and portalled into the app shell, + * above every branch that draws a page, while the title and the column it has to find are drawn by + * four unrelated components with only one of them mounted at a time. There is no ref to hand between + * the two without threading a context through every branch. + */ + +/** Marks an entity page's title, valued with the entity it names so a side panel cannot be mistaken for it. */ +export const ENTITY_PAGE_TITLE_ATTRIBUTE = 'data-entity-page-title'; + +/** Marks the element holding a page's content column, for anything lining up with it from outside. */ +export const ENTITY_PAGE_CONTENT_ATTRIBUTE = 'data-entity-page-content'; + +/** Spread onto whichever element draws the title. */ +export function entityPageTitleAnchor(entityId: string | undefined) { + return { [ENTITY_PAGE_TITLE_ATTRIBUTE]: entityId }; +} + +/** + * Spread onto a page's content column. + * + * There is no single width to hand the header instead: the generic page is 900 and unpadded, a claim + * 840 at `px-4`/`px-5`, a topic the generic width at that gutter, and a page with a rail 1142. It + * measures whichever + * of these carries this, so a view with a width of its own is matched by saying so rather than by + * teaching the header about it. + */ +export const ENTITY_PAGE_CONTENT_ANCHOR = { [ENTITY_PAGE_CONTENT_ATTRIBUTE]: '' }; + +/** + * The selector matching one entity's title **on the route**, not in an overlay. + * + * Scoped to `
`, which holds the routed page and nothing else: both the entity side panel's + * branches portal to `document.body`, as does every slide-up. Those surfaces draw their own titles + * through the same components — `EditableHeading`, `ClaimPageView`, `TopicPageView` all serve the + * panel too — and a panel opened on the entity the route is already showing puts a second matching + * title in the document. Document order hides that while the route has a title of its own, and stops + * hiding it on a route that draws none: a Debate renders the live feed instead, so the panel's title + * became the only match and scrolling the panel's own container raised the route's bar. + * + * An allowlist rather than excluding the panel by name, so the next surface portalled out of the + * page is excluded by construction rather than by somebody remembering to add it here. + */ +export function entityPageTitleSelector(entityId: string) { + return `main [${ENTITY_PAGE_TITLE_ATTRIBUTE}="${escapeAttributeValue(entityId)}"]`; +} + +/** + * Entity ids are uuids, so this is belt and braces — but the value comes from a route param, and + * `CSS.escape` is missing in jsdom, so the fallback has to be the identity rather than a throw. + */ +function escapeAttributeValue(value: string) { + return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value; +} diff --git a/apps/web/partials/entity-page/entity-page-content-container.tsx b/apps/web/partials/entity-page/entity-page-content-container.tsx index 390507324c..7615cc2d3a 100644 --- a/apps/web/partials/entity-page/entity-page-content-container.tsx +++ b/apps/web/partials/entity-page/entity-page-content-container.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import cx from 'classnames'; +import { ENTITY_PAGE_CONTENT_ANCHOR } from './entity-page-anchors'; import { ENTITY_PAGE_WIDTH_VARIABLES, type EntityPageContentVariant } from './entity-page-layout'; type Props = { @@ -22,6 +23,7 @@ export function EntityPageContentContainer({ children, variant = 'content' }: Pr return (
diff --git a/apps/web/partials/entity-page/entity-page-title.tsx b/apps/web/partials/entity-page/entity-page-title.tsx index 485d0baee5..6a0d637a8c 100644 --- a/apps/web/partials/entity-page/entity-page-title.tsx +++ b/apps/web/partials/entity-page/entity-page-title.tsx @@ -9,6 +9,8 @@ import { ZERO_WIDTH_SPACE } from '~/core/constants'; import { Spacer } from '~/design-system/spacer'; +import { entityPageTitleAnchor } from '~/partials/entity-page/entity-page-anchors'; + /** Entity-title token (`text-entityTitle`), including the narrow-viewport steps in `styles.css`. */ const titleTypographyClassName = 'text-entityTitle'; @@ -20,6 +22,11 @@ type EntityPageTitleProps = { className?: string; /** Rendered inline directly after the title in browse mode, e.g. a verification badge. */ accessory?: React.ReactNode; + /** + * The entity this title names, marked in the DOM for the sticky header — see + * `entity-page-anchors`, which says why it is an attribute rather than a ref. + */ + entityId?: string; }; /** @@ -42,10 +49,11 @@ export function EntityPageTitle({ placeholder = 'Entity name...', className, accessory, + entityId, }: EntityPageTitleProps) { if (isEditing) { return ( -
+