Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function LinkIcon({

navigator.clipboard.writeText(redirectUrl ?? href);

history.replaceState({}, '', hash);
// Preserve the existing entry's state: Astro's ClientRouter keeps its scroll and history
// index in there, and replacing it wholesale breaks its back/forward handling.
history.replaceState(history.state, '', hash);

setLinkCopied(true);
setlinkActive(true);
Expand Down
6 changes: 3 additions & 3 deletions packages/ag-charts-website/e2e/api-ref-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ test.describe('api-ref-page', () => {
await selectSearchOption(page, 'series type bar');
await page.keyboard.press('Enter');

const url = page.url();
expect(url).toContain('/options/series/bar/');
expect(url).toContain('#reference-AgBarSeriesOptions-type');
// Selecting a result hands off to Astro's router, which swaps in the target document, so
// the address bar catches up a tick after the keypress rather than during it.
await page.waitForURL(/\/options\/series\/bar\/#reference-AgBarSeriesOptions-type$/);
await expect(page.locator('header h1')).toContainText("type = 'bar'");
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { navigate } from 'astro:transitions/client';
import { useEffect, useState } from 'react';

import type { NavigationData } from './apiReferenceHelpers';

/*
Astro's ClientRouter owns `history.state` — it reads `index` to work out popstate direction and
`scrollX`/`scrollY` to restore scroll. It spreads any `state` passed to `navigate()` alongside
those keys, so nesting the reference selection under a single key lets both live in one entry
without either overwriting the other.
*/
const SELECTION_STATE_KEY = 'apiReferenceSelection';

interface ApiReferenceLocation {
pathname: string;
/** Includes the leading `#`, matching `window.location.hash`. */
hash: string;
}

const inBrowser = () => typeof window !== 'undefined';

function currentLocation(): ApiReferenceLocation | null {
return inBrowser() ? { pathname: window.location.pathname, hash: window.location.hash } : null;
}

function hrefFor({ pathname, hash }: NavigationData) {
return hash ? `${pathname}#${hash}` : pathname;
}

export function readSelection(): NavigationData | undefined {
if (!inBrowser()) return undefined;
return (history.state as Record<string, unknown> | null)?.[SELECTION_STATE_KEY] as NavigationData | undefined;
}

/**
* Attach a selection to the current entry. Entries created by a full page load or by arriving from
* a non-reference page carry no selection of their own, so a later back/forward has nothing to
* restore without this.
*/
export function seedSelection(selection: NavigationData) {
// Astro's router seeds `index`/`scrollX`/`scrollY` when its module loads; merging onto a state
// it has not written yet would produce an entry it can no longer track.
if (!inBrowser() || !history.state || readSelection()) return;
history.replaceState({ ...history.state, [SELECTION_STATE_KEY]: selection }, '');
}

export function navigateToSelection(selection: NavigationData) {
return navigate(hrefFor(selection), { state: { [SELECTION_STATE_KEY]: selection } });
}

/**
* Current location, tracked through every route Astro can change it by: `astro:page-load` after a
* document swap or full load, `popstate` for back/forward, and `hashchange` for the hash-only path
* (which Astro handles by assigning `location.href`, so no swap event fires).
*/
export function useApiReferenceLocation(): ApiReferenceLocation | null {
const [location, setLocation] = useState(currentLocation);

useEffect(() => {
const update = () => {
const next = { pathname: window.location.pathname, hash: window.location.hash };
setLocation((prev) => (prev?.pathname === next.pathname && prev?.hash === next.hash ? prev : next));
};

document.addEventListener('astro:page-load', update);
window.addEventListener('popstate', update);
window.addEventListener('hashchange', update);

return () => {
document.removeEventListener('astro:page-load', update);
window.removeEventListener('popstate', update);
window.removeEventListener('hashchange', update);
};
}, []);

return location;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Code from '@ag-website-shared/components/code/Code';
import { Icon } from '@ag-website-shared/components/icon/Icon';
import styles from '@ag-website-shared/components/reference-documentation/ApiReference.module.scss';
import { navigate, scrollIntoViewById, useLocation } from '@ag-website-shared/utils/navigation';
import { scrollIntoViewById } from '@ag-website-shared/utils/navigation';
import type {
InterfaceNode,
MemberNode,
Expand Down Expand Up @@ -48,6 +48,7 @@ import {
resolveAliasedUnion,
resolveReferenceType,
} from '../apiReferenceHelpers';
import { navigateToSelection, useApiReferenceLocation } from '../apiReferenceRouting';
import { SelectionContext } from './OptionsNavigation';
import { type CollapsibleType, PropertyTitle, PropertyType } from './Properties';

Expand Down Expand Up @@ -191,7 +192,7 @@ function UnionVariantNode({
}) {
const [isExpanded, toggleExpanded, setExpanded] = useToggle();
const config = useContext(ApiReferenceConfigContext);
const location = useLocation();
const location = useApiReferenceLocation();
const docs = parseJsDocs(variant.node.docs);
const { discriminator } = variant;
const displayName = discriminator ? `[${discriminator.key}='${discriminator.value}']` : variant.anchorSegment;
Expand Down Expand Up @@ -279,7 +280,7 @@ export function ApiReference({
const reference = useContext(ApiReferenceContext);
const config = useContext(ApiReferenceConfigContext);
const interfaceRef = reference?.get(id);
const location = useLocation();
const location = useApiReferenceLocation();

// include / exclude / prioritise scope the top-level interface only; nested interfaces
// and union variants must render their full member set.
Expand Down Expand Up @@ -335,7 +336,7 @@ function NodeFactory({ member, anchorId, genericsMap, prefixPath = [], ...props
const [isSignatureExpanded, toggleSignature, setSignatureExpanded] = useToggle();
const interfaceRef = useMemberAdditionalDetails(member);
const config = useContext(ApiReferenceConfigContext);
const location = useLocation();
const location = useApiReferenceLocation();

const hasMembers = hasMembersNode(interfaceRef);
const hasNestedPages = config.specialTypes?.[getMemberType(member)] === 'NestedPage';
Expand Down Expand Up @@ -497,7 +498,7 @@ function ApiReferenceRow({
pageTitle: { name: memberName },
};
selection?.setSelection(selectionState);
navigate(selectionState, { state: selectionState });
navigateToSelection(selectionState);
}}
>
See property details <Icon name="arrowRight" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
import type { PageTitle } from '@components/api-documentation/apiReferenceHelpers';
import {
cleanupName,
normalizeType,
parseJsDocs,
processMembers,
} from '@components/api-documentation/apiReferenceHelpers';
import { getInterfacesReference } from '@utils/server/getInterfacesReference';
import styles from './ApiReferenceFallback.module.scss';

interface Props {
rootInterface: string;
pageInterface?: string;
pageTitle?: PageTitle;
}

const { rootInterface, pageInterface, pageTitle } = Astro.props;

const reference = getInterfacesReference();
const id = pageInterface ?? rootInterface;
const pageRef = reference.get(id);
const interfaceRef = pageRef?.kind === 'interface' ? pageRef : undefined;

const title = pageTitle ?? { name: id };
const description = interfaceRef && parseJsDocs(interfaceRef.docs);
const members = interfaceRef ? processMembers(interfaceRef, {}) : [];
---

{
/*
Server-rendered reference content for crawlers that don't execute JavaScript. Astro shows this
until the interactive <ApiReferencePage> island hydrates and replaces it. Deliberately flat:
top-level properties only, so the served HTML stays small (SE-53). Nested children and deep type
signatures remain client-rendered.
*/
}
<div class="api-reference-fallback layout-grid">
{
/* Empty column reserving the left-nav width, so the property content lands at the same
x-position as the hydrated layout and the navigation tree fills into reserved space
without shifting the page horizontally on hydration. */
}
<div class={styles.navPlaceholder} aria-hidden="true"></div>

<div class={styles.referenceContent}>
<header>
<h1 class="text-3xl">
{
title.type ? (
<>
{title.name}
{title.name === 'axes' && <span class={styles.recordAlias}>.key</span>}[type='
<span class={styles.unionDiscriminator}>{title.type}</span>']
</>
) : (
title.name
)
}
</h1>
{description && <p class="text-secondary">{description}</p>}
</header>

<dl class={styles.referenceList}>
{
members.map((member) => {
const memberDocs = parseJsDocs(member.docs);
return (
<div class={styles.referenceRow} id={`reference-${id}-${member.name}`}>
<dt>
<span class={styles.propertyName}>{cleanupName(member.name)}</span>
{!member.optional && <span class={styles.required}>required</span>}
<code class={styles.propertyType}>{normalizeType(member.type)}</code>
{member.defaultValue != null && (
<span class={styles.propertyDefault}>default: {member.defaultValue}</span>
)}
</dt>
{/* Always render the <dd> so every definition-list group has both a
term and a description, even for undocumented members. */}
<dd class="text-secondary">{memberDocs}</dd>
</div>
);
})
}
</dl>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
@use 'design-system' as *;

/*
Mirror the two-column geometry the hydrated <ApiReferencePage> produces (its
`.container.layout-grid` + `.objectViewOuter` + `.referenceOuter`) so the swap from the
fallback to the React app does not shift the page horizontally.
*/

.navPlaceholder {
width: calc(100% + var(--layout-horizontal-margins) * 2);
margin-left: calc(var(--layout-horizontal-margins) * -1);

@media screen and (max-width: $breakpoint-options-medium) {
/*
Below this breakpoint the navigation tree is hidden and the nav stacks above the
content as a single search box. Reserve its height so the content does not drop on
hydration: the nav's top padding ($spacing-size-5) plus the search box (its text
line-box + vertical padding of $spacing-size-3 + 1px border top and bottom).
*/
min-height: calc(#{$spacing-size-5} + var(--text-fs-base) * var(--text-lh-base) + #{$spacing-size-3} + 2px);
}

@media screen and (min-width: $breakpoint-options-medium) {
width: calc(var(--layout-width-4-12) + var(--layout-horizontal-margins));
box-shadow: -1px 0 0 0 var(--color-border-primary);
border-right: 1px solid var(--color-border-primary);
}

@media screen and (min-width: $breakpoint-options-large) {
width: calc(var(--layout-width-3-12) + var(--layout-horizontal-margins));
}
}

.referenceContent {
width: calc(100% + var(--layout-gap) * 2);
margin-left: calc(var(--layout-gap) * -1);

@media screen and (max-width: $breakpoint-options-medium) {
/* Below this breakpoint the content spans full width with no negative bleed, matching
the hydrated `.referenceOuter`; without this the body sits ~32px too far left (and
off-screen) until hydration snaps it back. */
margin-left: 0;
}

@media screen and (min-width: $breakpoint-options-medium) {
width: var(--layout-width-8-12);
margin-left: 0;
}

@media screen and (min-width: $breakpoint-options-large) {
width: var(--layout-width-9-12);
}

header {
padding-top: $spacing-size-8;
padding-bottom: $spacing-size-6;
margin-bottom: $spacing-size-4;
border-bottom: 1px solid var(--color-border-secondary);
}
}

.referenceList {
margin: 0;
}

.referenceRow {
padding: $spacing-size-4 0;
border-top: 1px solid var(--color-border-secondary);

dt {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: $spacing-size-2;
}

dd {
margin: $spacing-size-2 0 0;

&:empty {
margin: 0;
}
}
}

.propertyName {
font-weight: var(--text-bold);
}

.propertyType {
color: var(--color-fg-code);
}

.propertyDefault {
color: var(--color-fg-secondary);
font-size: var(--text-fs-sm);
}

.required {
color: red;
font-size: var(--text-fs-sm);
}

.recordAlias,
.unionDiscriminator {
color: var(--color-fg-code);
}
Loading
Loading