docs(docs-infra): drop tabs layout from the API reference details page (#59068)

Drop the tabs in favor of a single page separated by sections.

PR Close #59068
This commit is contained in:
hawkgs 2024-11-12 09:35:24 +02:00 committed by Jessica Janiuk
parent e0c33814fd
commit b6733eeea4
37 changed files with 834 additions and 1068 deletions

View file

@ -6,26 +6,26 @@
<ul class="docs-faceted-list">
<!-- TODO: Hide li elements with class docs-toc-item-h3 for laptop, table and phone screen resolutions -->
@for (item of tableOfContentItems(); track item.id) {
<li
class="docs-faceted-list-item"
[class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3"
>
<a
routerLink="."
[fragment]="item.id"
[class.docs-faceted-list-item-active]="item.id === activeItemId()"
<li
class="docs-faceted-list-item"
[class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3"
>
{{ item.title }}
</a>
</li>
<!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
<a
[href]="location.path() + '#' + item.id"
[class.docs-faceted-list-item-active]="item.id === activeItemId()"
>
{{ item.title }}
</a>
</li>
}
</ul>
</nav>
@if (shouldDisplayScrollToTop()) {
<button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top
</button>
<button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top
</button>
}
</aside>

View file

@ -14,7 +14,7 @@ import {
computed,
inject,
} from '@angular/core';
import {RouterLink} from '@angular/router';
import {Location} from '@angular/common';
import {TableOfContentsLevel} from '../../interfaces/index';
import {TableOfContentsLoader} from '../../services/table-of-contents-loader.service';
import {TableOfContentsScrollSpy} from '../../services/table-of-contents-scroll-spy.service';
@ -25,11 +25,12 @@ import {IconComponent} from '../icon/icon.component';
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './table-of-contents.component.html',
styleUrls: ['./table-of-contents.component.scss'],
imports: [RouterLink, IconComponent],
imports: [IconComponent],
})
export class TableOfContents {
// Element that contains the content from which the Table of Contents is built
readonly contentSourceElement = input.required<HTMLElement>();
readonly location = inject(Location);
private readonly scrollSpy = inject(TableOfContentsScrollSpy);
private readonly tableOfContentsLoader = inject(TableOfContentsLoader);

View file

@ -343,7 +343,12 @@ export class DocViewer implements OnChanges {
relativeUrl = hrefAttr;
}
handleHrefClickEventWithRouter(e, this.router, relativeUrl);
// Unless this is a link to an element within the same page, use the Angular router.
// https://github.com/angular/angular/issues/30139
const scrollToElementExists = relativeUrl.startsWith(this.location.path() + '#');
if (!scrollToElementExists) {
handleHrefClickEventWithRouter(e, this.router, relativeUrl);
}
});
});
}

View file

@ -8,13 +8,13 @@
// TODO(jelbourn): all of these CSS classes should use the `docs-` prefix.
export const API_REFERENCE_CONTAINER = 'docs-api';
export const PARAM_KEYWORD_CLASS_NAME = 'docs-param-keyword';
export const PARAM_GROUP_CLASS_NAME = 'docs-param-group';
export const REFERENCE_HEADER = 'docs-reference-header';
export const REFERENCE_MEMBERS = 'docs-reference-members';
export const REFERENCE_DEPRECATED = 'docs-reference-deprecated';
export const REFERENCE_MEMBERS_CONTAINER = 'docs-reference-members-container';
export const REFERENCE_MEMBER_CARD = 'docs-reference-member-card';
export const REFERENCE_MEMBER_CARD_HEADER = 'docs-reference-card-header';
export const REFERENCE_MEMBER_CARD_BODY = 'docs-reference-card-body';
@ -24,3 +24,6 @@ export const HEADER_CLASS_NAME = 'docs-reference-header';
export const HEADER_ENTRY_CATEGORY = 'docs-reference-category';
export const HEADER_ENTRY_TITLE = 'docs-reference-title';
export const HEADER_ENTRY_LABEL = 'docs-api-item-label';
export const SECTION_CONTAINER = 'docs-reference-section';
export const SECTION_HEADING = 'docs-reference-section-heading';

View file

@ -13,7 +13,7 @@ import {
isPropertyEntry,
isSetterEntry,
} from '../entities/categorization';
import {MemberEntryRenderable} from '../entities/renderables';
import {MemberEntryRenderable, MethodEntryRenderable} from '../entities/renderables';
import {
REFERENCE_MEMBER_CARD,
REFERENCE_MEMBER_CARD_BODY,
@ -27,20 +27,24 @@ import {getFunctionMetadataRenderable} from '../transforms/function-transforms';
import {CodeSymbol} from './code-symbols';
export function ClassMember(props: {member: MemberEntryRenderable}) {
const member = props.member;
const renderMethod = (method: MethodEntryRenderable) => {
const signature = method.signatures.length ? method.signatures : [method.implementation];
return signature.map((sig) => {
const renderableMember = getFunctionMetadataRenderable(sig);
return <ClassMethodInfo entry={renderableMember} options={{showUsageNotes: true}} />;
});
};
const body = (
<div className={REFERENCE_MEMBER_CARD_BODY}>
{isClassMethodEntry(props.member) ? (
(props.member.signatures.length
? props.member.signatures
: [props.member.implementation]
).map((sig) => {
const renderableMember = getFunctionMetadataRenderable(sig);
return <ClassMethodInfo entry={renderableMember} options={{showUsageNotes: true}} />;
})
) : props.member.htmlDescription || props.member.deprecationMessage ? (
{isClassMethodEntry(member) ? (
renderMethod(member)
) : member.htmlDescription || member.deprecationMessage ? (
<div className={REFERENCE_MEMBER_CARD_ITEM}>
<DeprecatedLabel entry={props.member} />
<RawHtml value={props.member.htmlDescription} />
<DeprecatedLabel entry={member} />
<RawHtml value={member.htmlDescription} />
</div>
) : (
<></>
@ -48,23 +52,19 @@ export function ClassMember(props: {member: MemberEntryRenderable}) {
</div>
);
const memberName = props.member.name;
const returnType = getMemberType(props.member);
const memberName = member.name;
const returnType = getMemberType(member);
return (
<div id={memberName} className={REFERENCE_MEMBER_CARD} tabIndex={-1}>
<header>
<div className={REFERENCE_MEMBER_CARD_HEADER}>
<h3>{memberName}</h3>
<div>
{isClassMethodEntry(props.member) && props.member.signatures.length > 1 ? (
<span>{props.member.signatures.length} overloads</span>
) : returnType ? (
<CodeSymbol code={returnType} />
) : (
<></>
)}
</div>
</div>
<div id={memberName} tabIndex={-1} className={REFERENCE_MEMBER_CARD}>
<header className={REFERENCE_MEMBER_CARD_HEADER}>
<h3>{memberName}</h3>
{isClassMethodEntry(member) && member.signatures.length > 1 ? (
<span>{member.signatures.length} overloads</span>
) : returnType ? (
<CodeSymbol code={returnType} />
) : (
<></>
)}
</header>
{body}
</div>

View file

@ -10,26 +10,26 @@ import {Fragment, h} from 'preact';
import {ClassEntryRenderable, DecoratorEntryRenderable} from '../entities/renderables';
import {ClassMemberList} from './class-member-list';
import {HeaderApi} from './header-api';
import {REFERENCE_MEMBERS_CONTAINER} from '../styling/css-classes';
import {TabDescription} from './tab-description';
import {TabUsageNotes} from './tab-usage-notes';
import {TabApi} from './tab-api';
import {API_REFERENCE_CONTAINER, REFERENCE_MEMBERS} from '../styling/css-classes';
import {SectionDescription} from './section-description';
import {SectionUsageNotes} from './section-usage-notes';
import {SectionApi} from './section-api';
/** Component to render a class API reference document. */
export function ClassReference(entry: ClassEntryRenderable | DecoratorEntryRenderable) {
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabApi entry={entry} />
<TabDescription entry={entry} />
<TabUsageNotes entry={entry} />
<SectionApi entry={entry} />
{entry.members.length > 0 ? (
<div class={REFERENCE_MEMBERS_CONTAINER}>
<div class={REFERENCE_MEMBERS}>
<ClassMemberList members={entry.members} />
</div>
) : (
<></>
)}
<SectionDescription entry={entry} />
<SectionUsageNotes entry={entry} />
</div>
);
}

View file

@ -9,17 +9,12 @@
import {Fragment, h} from 'preact';
import {CliCardRenderable} from '../entities/renderables';
import {DeprecatedLabel} from './deprecated-label';
import { REFERENCE_MEMBER_CARD, REFERENCE_MEMBER_CARD_HEADER } from '../styling/css-classes';
import {REFERENCE_MEMBER_CARD, REFERENCE_MEMBER_CARD_BODY} from '../styling/css-classes';
export function CliCard(props: {card: CliCardRenderable}) {
return (
<div id={props.card.type} class={REFERENCE_MEMBER_CARD} tabIndex={-1}>
<header>
<div class={REFERENCE_MEMBER_CARD_HEADER}>
<h3>{props.card.type}</h3>
</div>
</header>
<div class="docs-reference-card-body">
<div class={REFERENCE_MEMBER_CARD}>
<div className={REFERENCE_MEMBER_CARD_BODY}>
{props.card.items.map((item) => (
<div class="docs-ref-content">
{item.deprecated ? <DeprecatedLabel entry={item} /> : <></>}

View file

@ -6,55 +6,74 @@
* found in the LICENSE file at https://angular.dev/license
*/
import { Fragment, h } from 'preact';
import { CliCommandRenderable } from '../entities/renderables';
import { REFERENCE_MEMBERS, REFERENCE_MEMBERS_CONTAINER } from '../styling/css-classes';
import { CliCard } from './cli-card';
import { HeaderCli } from './header-cli';
import { RawHtml } from './raw-html';
import {Fragment, h} from 'preact';
import {CliCommandRenderable} from '../entities/renderables';
import {REFERENCE_MEMBERS} from '../styling/css-classes';
import {CliCard} from './cli-card';
import {HeaderCli} from './header-cli';
import {RawHtml} from './raw-html';
import {SectionHeading} from './section-heading';
/** Component to render a CLI command reference document. */
export function CliCommandReference(entry: CliCommandRenderable) {
return (
<div className="cli">
<div className="docs-cli">
<div className="docs-reference-cli-content">
<HeaderCli command={entry} />
{[entry.name, ...entry.aliases].map((command) =>
{[entry.name, ...entry.aliases].map((command) => (
<div class="docs-code docs-reference-cli-toc">
<pre class="docs-mini-scroll-track">
<code>
<div className={'shiki line cli'}>
ng {commandName(entry, command)}
{entry.argumentsLabel ? <button member-id={'Arguments'} className="shiki-ln-line-argument">{entry.argumentsLabel}</button> : <></>}
{entry.hasOptions ? <button member-id={'Options'} className="shiki-ln-line-option">[options]</button> : <></>}
{entry.argumentsLabel ? (
<button member-id={'Arguments'} className="shiki-ln-line-argument">
{entry.argumentsLabel}
</button>
) : (
<></>
)}
{entry.hasOptions ? (
<button member-id={'Options'} className="shiki-ln-line-option">
[options]
</button>
) : (
<></>
)}
</div>
</code>
</pre>
</div>
))}
<RawHtml value={entry.htmlDescription} />
{entry.subcommands && entry.subcommands?.length > 0 ? (
<>
<h3>Sub-commands</h3>
<p>This command has the following sub-commands</p>
<ul>
{entry.subcommands.map((subcommand) => (
<li>
<a href={`cli/${entry.name}/${subcommand.name}`}>{subcommand.name}</a>
</li>
))}
</ul>
</>
) : (
<></>
)}
<RawHtml value={entry.htmlDescription}/>
{entry.subcommands && entry.subcommands?.length > 0 ? <>
<h3>Sub-commands</h3>
<p>This command has the following sub-commands</p>
<ul>
{entry.subcommands.map((subcommand) =>
<li>
<a href={`cli/${entry.name}/${subcommand.name}`}>{subcommand.name}</a>
</li>
)}
</ul>
</> : <></>}
</div>
<div className={REFERENCE_MEMBERS_CONTAINER}>
<div className={REFERENCE_MEMBERS}>
{entry.cards.map((card) => <CliCard card={card} />)}
</div>
<div className={REFERENCE_MEMBERS}>
{entry.cards.map((card) => (
<>
<SectionHeading name={card.type} />
<CliCard card={card} />
</>
))}
</div>
</div>
);
}
function commandName(entry: CliCommandRenderable, command: string) {
if (entry.parentCommand?.name) {
return `${entry.parentCommand?.name} ${command}`;

View file

@ -9,18 +9,19 @@
import {h} from 'preact';
import {ConstantEntryRenderable} from '../entities/renderables';
import {HeaderApi} from './header-api';
import {TabDescription} from './tab-description';
import {TabUsageNotes} from './tab-usage-notes';
import {TabApi} from './tab-api';
import {SectionDescription} from './section-description';
import {SectionUsageNotes} from './section-usage-notes';
import {SectionApi} from './section-api';
import {API_REFERENCE_CONTAINER} from '../styling/css-classes';
/** Component to render a constant API reference document. */
export function ConstantReference(entry: ConstantEntryRenderable) {
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabApi entry={entry} />
<TabDescription entry={entry} />
<TabUsageNotes entry={entry} />
<SectionApi entry={entry} />
<SectionDescription entry={entry} />
<SectionUsageNotes entry={entry} />
</div>
);
}

View file

@ -9,14 +9,15 @@
import {h} from 'preact';
import {DocEntryRenderable} from '../entities/renderables';
import {HeaderApi} from './header-api';
import {TabDescription} from './tab-description';
import {SectionDescription} from './section-description';
import {API_REFERENCE_CONTAINER} from '../styling/css-classes';
/** Component to render a block or element API reference document. */
export function DocsReference(entry: DocEntryRenderable) {
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabDescription entry={entry} />
<SectionDescription entry={entry} />
</div>
);
}

View file

@ -9,29 +9,27 @@
import {h, Fragment} from 'preact';
import {EnumEntryRenderable, MemberEntryRenderable} from '../entities/renderables';
import {HeaderApi} from './header-api';
import {TabDescription} from './tab-description';
import {TabApi} from './tab-api';
import {REFERENCE_MEMBERS, REFERENCE_MEMBERS_CONTAINER} from '../styling/css-classes';
import {SectionDescription} from './section-description';
import {SectionApi} from './section-api';
import {API_REFERENCE_CONTAINER, REFERENCE_MEMBERS} from '../styling/css-classes';
import {ClassMember} from './class-member';
/** Component to render a enum API reference document. */
export function EnumReference(entry: EnumEntryRenderable) {
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabApi entry={entry} />
<TabDescription entry={entry} />
{
entry.members.length > 0
? (
<div class={REFERENCE_MEMBERS_CONTAINER}>
<div class={REFERENCE_MEMBERS}>
{entry.members.map((member: MemberEntryRenderable) => (<ClassMember member={member}/>))}
</div>
</div>
)
: (<></>)
}
<SectionApi entry={entry} />
{entry.members.length > 0 ? (
<div class={REFERENCE_MEMBERS}>
{entry.members.map((member: MemberEntryRenderable) => (
<ClassMember member={member} />
))}
</div>
) : (
<></>
)}
<SectionDescription entry={entry} />
</div>
);
}

View file

@ -6,20 +6,23 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {h} from 'preact';
import {FunctionEntryRenderable, FunctionSignatureMetadataRenderable} from '../entities/renderables';
import {h, Fragment} from 'preact';
import {
FunctionEntryRenderable,
FunctionSignatureMetadataRenderable,
} from '../entities/renderables';
import {
API_REFERENCE_CONTAINER,
REFERENCE_MEMBERS,
REFERENCE_MEMBERS_CONTAINER,
REFERENCE_MEMBER_CARD,
REFERENCE_MEMBER_CARD_BODY,
REFERENCE_MEMBER_CARD_HEADER,
} from '../styling/css-classes';
import {ClassMethodInfo} from './class-method-info';
import {HeaderApi} from './header-api';
import {TabApi} from './tab-api';
import {TabDescription} from './tab-description';
import {TabUsageNotes} from './tab-usage-notes';
import {SectionApi} from './section-api';
import {SectionDescription} from './section-description';
import {SectionUsageNotes} from './section-usage-notes';
import {HighlightTypeScript} from './highlight-ts';
import {printInitializerFunctionSignatureLine} from '../transforms/code-transforms';
import {getFunctionMetadataRenderable} from '../transforms/function-transforms';
@ -32,8 +35,8 @@ export const signatureCard = (
printSignaturesAsHeader: boolean,
) => {
return (
<div class={REFERENCE_MEMBER_CARD} id={opts.id} tabIndex={-1}>
<header>
<div id={opts.id} tabIndex={-1} class={REFERENCE_MEMBER_CARD}>
<header class={REFERENCE_MEMBER_CARD_HEADER}>
{printSignaturesAsHeader ? (
<code>
<HighlightTypeScript
@ -46,12 +49,12 @@ export const signatureCard = (
/>
</code>
) : (
<div className={REFERENCE_MEMBER_CARD_HEADER}>
<>
<h3>{name}</h3>
<div>
<CodeSymbol code={signature.returnType} />
</div>
</div>
</>
)}
</header>
<div class={REFERENCE_MEMBER_CARD_BODY}>
@ -67,25 +70,24 @@ export function FunctionReference(entry: FunctionEntryRenderable) {
const printSignaturesAsHeader = entry.signatures.length > 1;
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabApi entry={entry} />
<TabDescription entry={entry} />
<TabUsageNotes entry={entry} />
<div className={REFERENCE_MEMBERS_CONTAINER}>
<div className={REFERENCE_MEMBERS}>
{entry.signatures.map((s, i) =>
signatureCard(
s.name,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${s.name}_${i}`,
},
printSignaturesAsHeader,
),
)}
</div>
<SectionApi entry={entry} />
<div className={REFERENCE_MEMBERS}>
{entry.signatures.map((s, i) =>
signatureCard(
s.name,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${s.name}_${i}`,
},
printSignaturesAsHeader,
),
)}
</div>
<SectionDescription entry={entry} />
<SectionUsageNotes entry={entry} />
</div>
);
}

View file

@ -9,9 +9,9 @@
import {h, JSX} from 'preact';
import {InitializerApiFunctionRenderable} from '../entities/renderables';
import {HeaderApi} from './header-api';
import {TabApi} from './tab-api';
import {TabUsageNotes} from './tab-usage-notes';
import {REFERENCE_MEMBERS, REFERENCE_MEMBERS_CONTAINER} from '../styling/css-classes';
import {SectionApi} from './section-api';
import {SectionUsageNotes} from './section-usage-notes';
import {API_REFERENCE_CONTAINER, REFERENCE_MEMBERS} from '../styling/css-classes';
import {getFunctionMetadataRenderable} from '../transforms/function-transforms';
import {signatureCard} from './function-reference';
@ -34,42 +34,41 @@ export function InitializerApiFunction(entry: InitializerApiFunctionRenderable)
}
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} showFullDescription={true} />
<TabApi entry={entry} />
<TabUsageNotes entry={entry} />
<SectionApi entry={entry} />
<div class={REFERENCE_MEMBERS_CONTAINER}>
<div class={REFERENCE_MEMBERS}>
{entry.callFunction.signatures.map((s, i) =>
signatureCard(
s.name,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${s.name}_${i}`,
},
printSignaturesAsHeader,
),
)}
<div class={REFERENCE_MEMBERS}>
{entry.callFunction.signatures.map((s, i) =>
signatureCard(
s.name,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${s.name}_${i}`,
},
printSignaturesAsHeader,
),
)}
{entry.subFunctions.reduce(
(elements, subFunction) => [
...elements,
...subFunction.signatures.map((s, i) =>
signatureCard(
`${entry.name}.${s.name}`,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${entry.name}_${s.name}_${i}`,
},
printSignaturesAsHeader,
),
{entry.subFunctions.reduce(
(elements, subFunction) => [
...elements,
...subFunction.signatures.map((s, i) =>
signatureCard(
`${entry.name}.${s.name}`,
getFunctionMetadataRenderable(s, entry.moduleName),
{
id: `${entry.name}_${s.name}_${i}`,
},
printSignaturesAsHeader,
),
],
[] as JSX.Element[],
)}
</div>
),
],
[] as JSX.Element[],
)}
</div>
<SectionUsageNotes entry={entry} />
</div>
);
}

View file

@ -0,0 +1,26 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {h} from 'preact';
import {DocEntryRenderable} from '../entities/renderables';
import {HasRenderableToc} from '../entities/traits';
import {CodeTableOfContents} from './code-table-of-contents';
import {SECTION_CONTAINER} from '../styling/css-classes';
import {SectionHeading} from './section-heading';
const API_SECTION_NAME = 'API';
/** Component to render the API section. */
export function SectionApi(props: {entry: DocEntryRenderable & HasRenderableToc}) {
return (
<div className={SECTION_CONTAINER + ' docs-reference-api-section'}>
<SectionHeading name={API_SECTION_NAME} />
<CodeTableOfContents entry={props.entry} />
</div>
);
}

View file

@ -8,14 +8,15 @@
import {Fragment, h} from 'preact';
import {DocEntryRenderable} from '../entities/renderables';
import {normalizeTabUrl} from '../transforms/url-transforms';
import {RawHtml} from './raw-html';
import {CodeSymbol} from './code-symbols';
import {SECTION_CONTAINER} from '../styling/css-classes';
import {SectionHeading} from './section-heading';
const DESCRIPTION_TAB_NAME = 'Description';
const DESCRIPTION_SECTION_NAME = 'Description';
/** Component to render the description tab. */
export function TabDescription(props: {entry: DocEntryRenderable}) {
/** Component to render the description section. */
export function SectionDescription(props: {entry: DocEntryRenderable}) {
const exportedBy = props.entry.jsdocTags.filter((t) => t.name === 'ngModule');
if (
(!props.entry.htmlDescription ||
@ -26,7 +27,8 @@ export function TabDescription(props: {entry: DocEntryRenderable}) {
}
return (
<div data-tab={DESCRIPTION_TAB_NAME} data-tab-url={normalizeTabUrl(DESCRIPTION_TAB_NAME)}>
<div className={SECTION_CONTAINER}>
<SectionHeading name={DESCRIPTION_SECTION_NAME} />
<RawHtml value={props.entry.htmlDescription} />
{exportedBy.length ? (

View file

@ -0,0 +1,25 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {h} from 'preact';
import {convertSectionNameToId} from '../transforms/reference-section-id';
import {SECTION_HEADING} from '../styling/css-classes';
/** Component to render the API section. */
export function SectionHeading(props: {name: string}) {
const id = convertSectionNameToId(props.name);
const label = 'Link to ' + props.name + ' section';
return (
<h2 id={id} class={SECTION_HEADING}>
<a href={'#' + id} aria-label={label} tabIndex={-1}>
{props.name}
</a>
</h2>
);
}

View file

@ -8,19 +8,21 @@
import {Fragment, h} from 'preact';
import {DocEntryRenderable} from '../entities/renderables';
import {normalizeTabUrl} from '../transforms/url-transforms';
import {RawHtml} from './raw-html';
import {SECTION_CONTAINER} from '../styling/css-classes';
import {SectionHeading} from './section-heading';
const USAGE_NOTES_TAB_NAME = 'Usage Notes';
const USAGE_NOTES_SECTION_NAME = 'Usage Notes';
/** Component to render the usage notes tab. */
export function TabUsageNotes(props: {entry: DocEntryRenderable}) {
/** Component to render the usage notes section. */
export function SectionUsageNotes(props: {entry: DocEntryRenderable}) {
if (!props.entry.htmlUsageNotes) {
return (<></>);
return <></>;
}
return (
<div data-tab={USAGE_NOTES_TAB_NAME} data-tab-url={normalizeTabUrl(USAGE_NOTES_TAB_NAME)}>
<div className={SECTION_CONTAINER}>
<SectionHeading name={USAGE_NOTES_SECTION_NAME} />
<RawHtml value={props.entry.htmlUsageNotes} />
</div>
);

View file

@ -1,26 +0,0 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {h} from 'preact';
import {DocEntryRenderable} from '../entities/renderables';
import {HasRenderableToc} from '../entities/traits';
import {normalizeTabUrl} from '../transforms/url-transforms';
import {CodeTableOfContents} from './code-table-of-contents';
const API_TAB_NAME = 'API';
/** Component to render the API tab. */
export function TabApi(props: {entry: DocEntryRenderable & HasRenderableToc}) {
return (
<div data-tab={API_TAB_NAME} data-tab-url={normalizeTabUrl(API_TAB_NAME)}>
<div class={'docs-reference-api-tab'}>
<CodeTableOfContents entry={props.entry} />
</div>
</div>
);
}

View file

@ -9,18 +9,19 @@
import {h} from 'preact';
import {TypeAliasEntryRenderable} from '../entities/renderables';
import {HeaderApi} from './header-api';
import {TabDescription} from './tab-description';
import {TabUsageNotes} from './tab-usage-notes';
import {TabApi} from './tab-api';
import {SectionDescription} from './section-description';
import {SectionUsageNotes} from './section-usage-notes';
import {SectionApi} from './section-api';
import {API_REFERENCE_CONTAINER} from '../styling/css-classes';
/** Component to render a type alias API reference document. */
export function TypeAliasReference(entry: TypeAliasEntryRenderable) {
return (
<div class="api">
<div className={API_REFERENCE_CONTAINER}>
<HeaderApi entry={entry} />
<TabApi entry={entry} />
<TabDescription entry={entry} />
<TabUsageNotes entry={entry} />
<SectionApi entry={entry} />
<SectionDescription entry={entry} />
<SectionUsageNotes entry={entry} />
</div>
);
}

View file

@ -0,0 +1,14 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
export const convertSectionNameToId = (sectionName: string): string => {
return sectionName
.toLowerCase()
.replace(/\s|\//g, '-') // remove spaces and slashes
.replace(/[^0-9a-z\-]/g, ''); // only keep letters, digits & dashes
};

View file

@ -19,15 +19,3 @@ export const normalizePath = (path: string): string => {
}
return path;
};
export const normalizeTabUrl = (tabName: string): string => {
return tabName
.toLowerCase()
.replace(/<code>(.*?)<\/code>/g, '$1') // remove <code>
.replace(/<strong>(.*?)<\/strong>/g, '$1') // remove <strong>
.replace(/<em>(.*?)<\/em>/g, '$1') // remove <em>
.replace(/\s|\//g, '-') // remove spaces and slashes
.replace(/gt;|lt;/g, '') // remove escaped < and >
.replace(/&#\d+;/g, '') // remove HTML entities
.replace(/[^0-9a-zA-Z\-]/g, ''); // only keep letters, digits & dashes
};

View file

@ -7,7 +7,7 @@
*/
import {Token, Tokens, RendererThis, TokenizerThis} from 'marked';
import {formatHeading, headingRender} from '../../tranformations/heading';
import {formatHeading} from '../../tranformations/heading';
interface DocsStepToken extends Tokens.Generic {
type: 'docs-step';

View file

@ -49,9 +49,7 @@ export class TableOfContentsLoader {
const updatedTopValues = new Map<string, number>();
for (const heading of headings) {
const parentTop = heading.parentElement?.offsetTop ?? 0;
const top = Math.floor(parentTop + heading.offsetTop - this.toleranceThreshold);
updatedTopValues.set(heading.id, top);
updatedTopValues.set(heading.id, this.calculateTop(heading));
}
this.tableOfContentItems.update((oldItems) => {

View file

@ -0,0 +1,410 @@
@use './anchor' as anchor;
/* Common styles for the API & CLI references */
@mixin reference-common() {
.docs-code {
pre {
margin-block: 0;
}
}
.docs-reference-header {
// deprecated markers beside header
& ~ .docs-deprecated {
margin-block-start: 0.5rem;
}
& > p {
color: var(--secondary-contrast);
margin-block-start: 0;
margin-block-end: 1.5rem;
}
.docs-reference-title {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding-block-end: 0;
gap: 0.5rem;
> div {
margin-block: 0.67em;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
h1 {
margin-block: 0;
}
}
a {
fill: var(--quinary-contrast);
transition: fill 0.3s ease;
&:hover {
fill: var(--primary-contrast);
}
}
}
.docs-reference-category {
color: var(--gray-400);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
}
.docs-code {
margin-block-end: 1.5rem;
}
}
.docs-reference-section-heading {
padding-block-start: 3rem;
a {
@include anchor.docs-anchor();
color: inherit;
}
}
.docs-reference-members {
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&:not(:first-child) {
margin-top: 1rem;
}
.docs-reference-member-card {
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
position: relative;
transition: border 0.3s ease;
pointer-events: none;
&::before {
content: '';
inset: -1px;
position: absolute;
background: transparent;
border-radius: 0.35rem;
z-index: 0;
}
&:focus {
box-shadow: 10px 4px 40px 0 rgba(0, 0, 0, 0.01);
&::before {
background: var(--red-to-pink-to-purple-horizontal-gradient);
}
}
> p {
padding-inline: 1.25rem;
margin-block-end: 0;
}
a {
pointer-events: initial;
}
.docs-reference-card-header {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 0.25rem 0.25rem 0 0;
background-color: var(--octonary-contrast);
position: relative;
z-index: 10;
padding: 0.7rem 1rem;
cursor: pointer;
gap: 0.5rem;
flex-wrap: wrap;
transition:
background-color 0.3s ease,
border 0.3s ease;
&:focus {
outline: none;
}
&:has(+ .docs-reference-card-body:empty) {
border-radius: 0.25rem;
}
code {
font-size: 0.875rem;
&:has(pre) {
padding: 0;
}
&:not(pre *) {
padding: 0 0.3rem;
}
}
pre {
margin: 0;
/* Do we have a better alternative ? */
overflow: auto;
}
h3 {
display: inline-block;
font-family: var(--code-font);
font-size: 1rem;
letter-spacing: -0.025rem;
margin: 0;
}
span {
font-size: 0.875rem;
}
}
.docs-reference-card-body {
padding: 0.25rem 1.25rem;
background: var(--septenary-contrast);
transition: background-color 0.3s ease;
color: var(--quaternary-contrast);
border-radius: 0 0 0.25rem 0.25rem;
position: relative;
z-index: 10;
&:empty {
display: none;
}
&:first-child {
border-radius: 0.25rem;
}
hr {
margin-block: 2rem;
}
.docs-code {
margin-block-end: 1rem;
}
.docs-deprecation-message {
border-block-end: 1px solid var(--senary-contrast);
.docs-deprecated {
color: var(--page-background);
background-color: var(--quaternary-contrast);
width: max-content;
border-radius: 0.25rem;
padding: 0.1rem 0.25rem;
margin-block-start: 1rem;
}
}
}
}
}
}
/* API reference styles */
@mixin api-reference {
// API section styles
.docs-reference-api-section {
.docs-code {
box-sizing: border-box;
width: 100%;
overflow: hidden;
padding: 0;
button {
transition: background-color 0.3s ease;
&.shiki-ln-line-highlighted {
background-color: var(--senary-contrast);
}
&:hover {
background-color: var(--septenary-contrast);
}
&:focus {
background-color: var(--senary-contrast);
span {
background-color: inherit;
}
}
}
// Hide copy source code button
button[docs-copy-source-code] {
display: none;
}
}
code {
margin-block: 0;
}
pre {
white-space: pre;
overflow-x: auto;
margin: 0;
}
}
// "API member card"-specific styles
.docs-reference-member-card {
.docs-reference-card-item {
// When it's not the only card ...
&:has(~ .docs-reference-card-item) {
border: 1px solid var(--senary-contrast);
margin-block: 1rem;
border-radius: 0.25rem;
padding-inline: 1rem;
}
// & the last card
&:last-child:not(:first-of-type) {
border: 1px solid var(--senary-contrast);
margin-block: 1rem;
border-radius: 0.25rem;
padding-inline: 1rem;
}
span {
display: inline-block;
font-size: 0.875rem;
}
code {
font-size: 0.875rem;
}
.docs-function-definition:has(*) {
border-block-end: 1px solid var(--senary-contrast);
}
.docs-param-group {
margin-block-start: 1rem;
// If it's the only param group...
&:not(:has(~ .docs-param-group)) {
margin-block: 1rem;
}
.docs-param-name {
color: var(--vivid-pink);
font-family: var(--code-font);
margin-inline-end: 0.25rem;
&::after {
content: ':';
}
}
.docs-parameter-description {
p:first-child {
margin-block-start: 0;
}
}
}
.docs-param-keyword {
color: var(--primary-contrast);
font-family: var(--code-font);
margin-inline-end: 0.5rem;
}
.docs-return-type {
padding-block: 1rem;
// & does not follow a function definition
&:not(.docs-function-definition + .docs-return-type) {
border-block-start: 1px solid var(--senary-contrast);
}
}
}
}
}
/* CLI reference styles */
@mixin cli-reference {
// CLI TOC
.docs-reference-cli-toc {
margin-bottom: 1rem;
.shiki-ln-line-argument,
.shiki-ln-line-option {
padding: 0.1rem 0.2rem 0.2rem;
margin-inline: 0.1rem;
color: var(--quaternary-contrast);
background: transparent;
border-radius: 0.25rem;
position: relative;
transition:
color 0.3s ease,
background 0.3s ease,
border 0.3s ease;
&:hover {
color: var(--primary-contrast);
background: var(--septenary-contrast);
}
&.shiki-ln-line-highlighted {
color: var(--primary-contrast);
background: var(--senary-contrast);
}
}
.shiki-ln-line-argument {
margin-inline-start: 0.2rem;
}
}
.docs-reference-members {
.docs-reference-section-heading {
margin: 0;
}
// "CLI member card"-specific styles
.docs-reference-member-card {
.docs-ref-content {
padding: 1rem 0;
&:not(:first-child) {
border-block-start: 1px solid var(--senary-contrast);
}
.docs-reference-type-and-default {
width: 4.375rem;
flex-shrink: 0;
span {
display: block;
font-size: 0.875rem;
margin-block-end: 0.2rem;
white-space: nowrap;
&:not(:first-child) {
margin-block-start: 1rem;
}
}
code {
font-size: 0.775rem;
}
}
}
}
}
}

View file

@ -29,7 +29,7 @@
<span class="adev-item-title" [attr.title]="apiItem.title">{{ apiItem.title }}</span>
</a>
@if (apiItem.isDeprecated) {
<span class="docs-deprecated"> &lt;!&gt; </span>
<span class="adev-deprecated"> &lt;!&gt; </span>
}
</li>
}

View file

@ -79,7 +79,7 @@
gap: 1em;
}
.docs-deprecated {
.adev-deprecated {
font-family: var(--code-font);
background-color: var(--senary-contrast);
color: var(--tertiary-contrast);

View file

@ -10,7 +10,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import ApiItemsSection from './api-items-section.component';
import {ApiItemsGroup} from '../interfaces/api-items-group';
import {ApiReferenceManager} from '../api-reference-list/api-reference-manager.service';
import {ApiItemType} from '../interfaces/api-item-type';
import {provideRouter} from '@angular/router';
import {By} from '@angular/platform-browser';
@ -60,7 +59,7 @@ describe('ApiItemsSection', () => {
fixture.detectChanges();
const deprecatedApiIcons = fixture.debugElement.queryAll(
By.css('.adev-api-items-section-grid li .docs-deprecated'),
By.css('.adev-api-items-section-grid li .adev-deprecated'),
);
const deprecatedApiTitle = deprecatedApiIcons[0].parent?.query(By.css('.adev-item-title'));

View file

@ -1,30 +1,9 @@
<div class="adev-header-and-tabs">
<docs-viewer [docContent]="parsedDocContent().header" class="docs-reference-header"></docs-viewer>
<mat-tab-group
class="docs-reference-tabs"
animationDuration="0ms"
mat-stretch-tabs="false"
[selectedIndex]="selectedTabIndex()"
(selectedIndexChange)="tabChange($event)"
>
@for (tab of tabs(); track tab.url) {
<mat-tab [label]="tab.title">
<div class="adev-reference-tab-body">
<docs-viewer [docContent]="tab.content"></docs-viewer>
</div>
</mat-tab>
}
</mat-tab-group>
</div>
@if (isApiTabActive()) {
@if (docContent(); as docContent) {
<docs-viewer
class="docs-reference-members-container"
[docContent]="parsedDocContent().members"
(contentLoaded)="membersCardsLoaded()"
>
</docs-viewer>
[docContent]="docContent.contents"
[hasToc]="true"
(contentLoaded)="onContentLoaded()"
/>
}
<div id="jump-msg" class="cdk-visually-hidden">Jump to details</div>

View file

@ -1,11 +1,23 @@
@use '@angular/docs/styles/media-queries' as mq;
@use '@angular/docs/styles/reference' as ref;
:host {
display: flex;
gap: 1rem;
display: block;
width: 100%;
max-width: var(--page-width);
padding: var(--layout-padding) 0 1rem var(--layout-padding);
box-sizing: border-box;
flex-direction: column;
@include mq.for-desktop-down {
padding: var(--layout-padding);
max-width: none;
}
&::-webkit-scrollbar-thumb {
background-color: var(--septenary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
h1 {
font-size: 1.5rem;
@ -27,391 +39,7 @@
}
}
// stylelint-disable-next-line
::ng-deep {
.adev-header-and-tabs {
padding: var(--layout-padding) 0 1rem var(--layout-padding);
box-sizing: border-box;
width: 100%;
max-width: var(--page-width);
@include mq.for-desktop-down {
padding: var(--layout-padding);
max-width: none;
}
&::-webkit-scrollbar-thumb {
background-color: var(--septenary-contrast);
border-radius: 10px;
transition: background-color 0.3s ease;
}
}
.docs-code {
pre {
margin-block: 0;
}
}
.docs-reference-header {
> p {
color: var(--secondary-contrast);
margin-block-start: 0;
margin-block-end: 1.5rem;
}
.docs-code {
margin-block-end: 1.5rem;
}
}
.adev-reference-tab-body {
margin-block-start: 1.5rem;
docs-viewer > div {
:first-child {
margin-top: 0;
}
}
}
.docs-reference-api-tab {
display: flex;
gap: 1.81rem;
align-items: flex-start;
margin-bottom: 1px;
@include mq.for-desktop-down {
flex-direction: column;
}
& > .docs-code {
box-sizing: border-box;
width: 100%;
overflow: hidden;
padding: 0;
@include mq.for-desktop-down {
width: 100%;
position: static;
}
button {
transition: background-color 0.3s ease;
&.shiki-ln-line-highlighted {
background-color: var(--senary-contrast);
}
&:hover {
background-color: var(--septenary-contrast);
}
&:focus {
background-color: var(--senary-contrast);
}
}
// Hide copy source code button
button[docs-copy-source-code] {
display: none;
}
}
code {
margin-block: 0;
}
pre {
white-space: pre;
overflow-x: auto;
margin: 0;
}
}
.docs-reference-cli-toc {
margin-bottom: 1rem;
}
.adev-reference-tab {
min-width: 50ch;
margin-block-start: 2.5rem;
}
.docs-reference-members-container {
width: 40%;
box-sizing: border-box;
width: 100%;
max-width: var(--page-width);
padding: 0 0 1rem var(--layout-padding);
@include mq.for-desktop-down {
padding: var(--layout-padding);
padding-top: 0;
max-width: none;
}
}
// Sidebar
.docs-reference-members {
display: flex;
flex-direction: column;
gap: 20px;
@include mq.for-desktop-down {
width: 100%;
}
}
.docs-reference-title {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding-block-end: 0;
gap: 0.5rem;
> div {
margin-block: 0.67em;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
h1 {
margin-block: 0;
}
}
a {
fill: var(--quinary-contrast);
transition: fill 0.3s ease;
&:hover {
fill: var(--primary-contrast);
}
}
}
.adev-reference-labels {
display: flex;
gap: 0.5rem;
}
.docs-reference-category {
color: var(--gray-400);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
}
.docs-reference-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.7rem 1rem;
code:not(pre *) {
padding: 0 0.3rem;
}
}
.docs-reference-member-card {
border: 1px solid var(--senary-contrast);
border-radius: 0.25rem;
position: relative;
transition: border 0.3s ease;
&::before {
content: '';
inset: -1px;
position: absolute;
background: transparent;
border-radius: 0.35rem;
z-index: 0;
}
&:focus {
box-shadow: 10px 4px 40px 0 rgba(0, 0, 0, 0.01);
&::before {
background: var(--red-to-pink-to-purple-horizontal-gradient);
}
}
header {
display: flex;
flex-direction: column;
border-radius: 0.25rem 0.25rem 0 0;
background-color: var(--octonary-contrast);
position: relative;
z-index: 10;
cursor: pointer;
transition:
background-color 0.3s ease,
border 0.3s ease;
& > code {
max-width: 100%;
}
code:has(pre) {
padding: 0;
}
pre {
margin: 0;
/* Do we have a better alternative ? */
overflow: auto;
}
}
.docs-reference-card-header {
h3 {
display: inline-block;
font-family: var(--code-font);
font-size: 1rem;
letter-spacing: -0.025rem;
margin: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
code,
span {
font-size: 0.875rem;
}
}
> p {
padding-inline: 1.25rem;
margin-block-end: 0;
}
}
.docs-reference-card-body {
padding: 0.25rem 1.25rem;
background: var(--septenary-contrast);
transition: background-color 0.3s ease;
color: var(--quaternary-contrast);
border-radius: 0 0 0.25rem 0.25rem;
position: relative;
z-index: 10;
hr {
margin-block: 2rem;
}
.docs-code {
margin-block-end: 1rem;
}
&:empty {
display: none;
}
}
// when it's not the only card...
.docs-reference-card-item:has(~ .docs-reference-card-item) {
border: 1px solid var(--senary-contrast);
margin-block: 1rem;
border-radius: 0.25rem;
padding-inline: 1rem;
}
// & the last card
.docs-reference-card-item:last-child {
&:not(:first-of-type) {
border: 1px solid var(--senary-contrast);
margin-block: 1rem;
border-radius: 0.25rem;
padding-inline: 1rem;
}
}
.docs-reference-card-item {
span {
display: inline-block;
font-size: 0.875rem;
}
code {
font-size: 0.875rem;
}
}
.docs-function-definition {
&:has(*) {
border-block-end: 1px solid var(--senary-contrast);
}
}
.docs-deprecation-message {
border-block-end: 1px solid var(--senary-contrast);
}
.docs-param-group {
margin-block-start: 1rem;
}
// If it's the only param group...
.docs-param-group:not(:has(~ .docs-param-group)) {
margin-block: 1rem;
}
.docs-return-type {
padding-block: 1rem;
// & does not follow a function definition
&:not(.docs-function-definition + .docs-return-type) {
border-block-start: 1px solid var(--senary-contrast);
}
}
.docs-param-keyword {
color: var(--primary-contrast);
font-family: var(--code-font);
margin-inline-end: 0.5rem;
}
.docs-param-name {
color: var(--vivid-pink);
font-family: var(--code-font);
margin-inline-end: 0.25rem;
&::after {
content: ':';
}
}
.docs-deprecated {
color: var(--page-background);
background-color: var(--quaternary-contrast);
width: max-content;
border-radius: 0.25rem;
padding: 0.1rem 0.25rem;
margin-block-start: 1rem;
}
// deprecated markers beside header
.docs-reference-header ~ .docs-deprecated {
margin-block-start: 0.5rem;
}
.docs-parameter-description {
p:first-child {
margin-block-start: 0;
}
}
.docs-ref-content {
padding: 1rem 0;
&:not(:first-child) {
border-block-start: 1px solid var(--senary-contrast);
}
.docs-param-keyword {
display: block;
margin: 0 0 0.5rem 0;
}
}
@include ref.reference-common();
@include ref.api-reference();
}

View file

@ -6,37 +6,29 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {TestBed} from '@angular/core/testing';
import {MatTabGroupHarness} from '@angular/material/tabs/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {ReferenceScrollHandler} from '../services/reference-scroll-handler.service';
import {signal} from '@angular/core';
import {provideRouter, withComponentInputBinding} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import ApiReferenceDetailsPage from './api-reference-details-page.component';
import {By} from '@angular/platform-browser';
describe('ApiReferenceDetailsPage', () => {
let component: ApiReferenceDetailsPage;
let loader: HarnessLoader;
let harness: RouterTestingHarness;
let fixture: ComponentFixture<unknown>;
let fakeApiReferenceScrollHandler = {
setupListeners: () => {},
membersMarginTopInPx: signal(10),
updateMembersMarginTop: () => {},
};
const SAMPLE_CONTENT_WITH_TABS = `<div class="docs-reference-tabs">
<div data-tab="API" data-tab-url="api" class="adev-reference-tab"></div>
<div data-tab="Description" data-tab-url="description" class="adev-reference-tab"></div>
<div data-tab="Examples" data-tab-url="examples" class="adev-reference-tab"></div>
<div data-tab="Usage Notes" data-tab-url="usage-notes" class="adev-reference-tab"></div>
<div class="docs-reference-members-container"></div>
</div>`;
const SAMPLE_CONTENT_WITH_SECTIONS = `<div class="docs-api">
<div class="docs-reference-section">API</div>
<div class="docs-reference-members"></div>
<div class="docs-reference-section">Description</div>
<div class="docs-reference-section">Examples</div>
<div class="docs-reference-section">Usage Notes</div>
</div>`;
beforeEach(async () => {
TestBed.configureTestingModule({
@ -51,7 +43,7 @@ describe('ApiReferenceDetailsPage', () => {
data: {
'docContent': {
id: 'id',
contents: SAMPLE_CONTENT_WITH_TABS,
contents: SAMPLE_CONTENT_WITH_SECTIONS,
},
},
},
@ -61,10 +53,9 @@ describe('ApiReferenceDetailsPage', () => {
],
});
TestBed.overrideProvider(ReferenceScrollHandler, {useValue: fakeApiReferenceScrollHandler});
harness = await RouterTestingHarness.create();
const {fixture} = harness;
const harness = await RouterTestingHarness.create();
fixture = harness.fixture;
component = await harness.navigateByUrl('/', ApiReferenceDetailsPage);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
@ -72,39 +63,10 @@ describe('ApiReferenceDetailsPage', () => {
expect(component).toBeTruthy();
});
it('should render tabs for all elements with tab attribute', async () => {
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
it('should load the doc content', () => {
expect(component.docContent()?.contents).toBeTruthy();
const tabs = await matTabGroup.getTabs();
expect(tabs.length).toBe(4);
});
it('should display members cards when API tab is active', async () => {
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
const tabs = await matTabGroup.getTabs();
let membersCard = harness.fixture.debugElement.query(
By.css('.docs-reference-members-container'),
);
expect(membersCard).toBeTruthy();
await matTabGroup.selectTab({label: await tabs[1].getLabel()});
membersCard = harness.fixture.debugElement.query(By.css('.docs-reference-members-container'));
expect(membersCard).toBeFalsy();
await matTabGroup.selectTab({label: await tabs[0].getLabel()});
membersCard = harness.fixture.debugElement.query(By.css('.docs-reference-members-container'));
expect(membersCard).toBeTruthy();
});
it('should setup scroll listeners when API members are loaded', () => {
const setupListenersSpy = spyOn(fakeApiReferenceScrollHandler, 'setupListeners');
component.membersCardsLoaded();
expect(setupListenersSpy).toHaveBeenCalled();
const docsViewer = fixture.nativeElement.querySelector('docs-viewer');
expect(docsViewer).toBeTruthy();
});
});

View file

@ -6,103 +6,50 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, inject, input, computed} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {MatTabsModule} from '@angular/material/tabs';
import {ChangeDetectionStrategy, Component, inject, input} from '@angular/core';
import {DocContent, DocViewer} from '@angular/docs';
import {ActivatedRoute, Router} from '@angular/router';
import {ApiItemType} from './../interfaces/api-item-type';
import {ActivatedRoute} from '@angular/router';
import {DOCUMENT} from '@angular/common';
import {ReferenceScrollHandler} from '../services/reference-scroll-handler.service';
import {
API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME,
API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME,
API_REFERENCE_TAB_ATTRIBUTE,
API_REFERENCE_TAB_API_LABEL,
API_TAB_CLASS_NAME,
API_REFERENCE_TAB_URL_ATTRIBUTE,
} from '../constants/api-reference-prerender.constants';
import {API_SECTION_CLASS_NAME} from '../constants/api-reference-prerender.constants';
@Component({
selector: 'adev-reference-page',
imports: [DocViewer, MatTabsModule],
standalone: true,
imports: [DocViewer],
templateUrl: './api-reference-details-page.component.html',
styleUrls: ['./api-reference-details-page.component.scss'],
providers: [ReferenceScrollHandler],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class ApiReferenceDetailsPage {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly referenceScrollHandler = inject(ReferenceScrollHandler);
private readonly route = inject(ActivatedRoute);
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly scrollHandler = inject(ReferenceScrollHandler);
docContent = input<DocContent | undefined>();
tab = input<string | undefined>();
// aliases
ApiItemType = ApiItemType;
// computed state
parsedDocContent = computed(() => {
// TODO: pull this logic outside of a computed where it can be tested etc.
const docContent = this.docContent();
if (docContent === undefined) {
return {
header: undefined,
members: undefined,
tabs: [],
};
}
const element = this.document.createElement('div');
element.innerHTML = docContent.contents;
// Get the innerHTML of the header element from received document.
const header = element.querySelector(API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME);
// Get the innerHTML of the card elements from received document.
const members = element.querySelector(API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME);
// Get the tab elements from received document.
// We're expecting that tab element will contain `tab` attribute.
const tabs = Array.from(element.querySelectorAll(`[${API_REFERENCE_TAB_ATTRIBUTE}]`)).map(
(tab) => ({
url: tab.getAttribute(API_REFERENCE_TAB_URL_ATTRIBUTE)!,
title: tab.getAttribute(API_REFERENCE_TAB_ATTRIBUTE)!,
content: tab.innerHTML,
}),
);
element.remove();
return {
header: header?.innerHTML,
members: members?.innerHTML,
tabs,
};
});
tabs = () => this.parsedDocContent().tabs;
selectedTabIndex = computed(() => {
const existingTabIdx = this.tabs().findIndex((tab) => tab.url === this.tab());
return Math.max(existingTabIdx, 0);
});
isApiTabActive = computed(() => {
const activeTabTitle = this.tabs()[this.selectedTabIndex()]?.title;
return activeTabTitle === API_REFERENCE_TAB_API_LABEL || activeTabTitle === 'CLI';
});
membersCardsLoaded(): void {
this.scrollHandler.setupListeners(API_TAB_CLASS_NAME);
onContentLoaded() {
this.referenceScrollHandler.setupListeners(API_SECTION_CLASS_NAME);
this.scrollToSectionLegacy();
}
tabChange(tabIndex: number) {
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {tab: this.tabs()[tabIndex].url},
queryParamsHandling: 'merge',
});
/** Handle legacy URLs with a `tab` query param from the old tab layout */
private scrollToSectionLegacy() {
const params = this.route.snapshot.queryParams;
const tab = params['tab'] as string | undefined;
if (tab) {
const section = this.document.getElementById(tab);
if (section) {
// `scrollIntoView` is ignored even, if the element exists.
// It seems that it's related to: https://issues.chromium.org/issues/40715316
// Hence, the usage of `setTimeout`.
setTimeout(() => {
section.scrollIntoView({behavior: 'smooth'});
}, 100);
}
}
}
}

View file

@ -1,11 +1,5 @@
<div class="adev-header-and-tabs adev-cli-content docs-scroll-track-transparent">
<docs-viewer [docContent]="mainContentInnerHtml()" />
</div>
<docs-viewer
class="adev-cli-members-container"
[docContent]="cardsInnerHtml()"
(contentLoaded)="contentLoaded()"
/>
@if (docContent(); as docContent) {
<docs-viewer [docContent]="docContent.contents" [hasToc]="true" />
}
<div id="jump-msg" class="cdk-visually-hidden">Jump to details</div>

View file

@ -1,123 +1,21 @@
@use '@angular/docs/styles/media-queries' as mq;
// Note: cli-reference-details-page is receiving page styles
// from api-reference-details-page.component.scss
@use '@angular/docs/styles/reference' as ref;
:host {
display: block;
width: 100%;
max-width: var(--page-width);
padding: var(--layout-padding) 0 1rem var(--layout-padding);
box-sizing: border-box;
@include mq.for-desktop-down {
padding: var(--layout-padding);
max-width: none;
}
}
// stylelint-disable-next-line
::ng-deep {
.adev-ref-content {
display: flex;
padding-block: 1rem;
gap: 1rem;
&:not(:last-of-type) {
border-block-end: 1px solid var(--senary-contrast);
}
}
.adev-header-and-tabs {
&.adev-cli-content {
width: 100%;
max-width: var(--page-width);
@include mq.for-desktop-down {
max-width: none;
}
}
}
.adev-cli-members-container {
padding: 0 0 var(--layout-padding) var(--layout-padding);
padding-bottom: 1rem;
box-sizing: border-box;
max-width: var(--page-width);
@include mq.for-desktop-down {
width: 100%;
padding: var(--layout-padding);
padding-top: 0;
max-width: none;
}
}
.adev-ref-option-and-description {
flex-grow: 1;
max-width: calc(100% - 80px);
p {
margin-block-end: 0;
}
}
.docs-reference-type-and-default {
width: 4.375rem;
flex-shrink: 0;
span {
display: block;
font-size: 0.875rem;
margin-block-end: 0.2rem;
white-space: nowrap;
&:not(:first-child) {
margin-block-start: 1rem;
}
}
code {
font-size: 0.775rem;
}
}
.adev-reference-cli-toc {
border: 1px solid var(--senary-contrast);
border-radius: 0.3rem;
position: relative;
transition: border 0.3s ease;
&::before {
content: '';
inset: -1px;
position: absolute;
background: transparent;
border-radius: 0.35rem;
z-index: 0;
}
&:has(.shiki-ln-line-highlighted) {
&::before {
background: var(--red-to-pink-to-purple-horizontal-gradient);
}
}
pre {
border-radius: 0.25rem;
position: relative;
z-index: 100;
background: var(--octonary-contrast);
}
}
.shiki-ln-line-argument,
.shiki-ln-line-option {
padding: 0.1rem 0.2rem 0.2rem;
margin-inline: 0.1rem;
color: var(--quaternary-contrast);
background: transparent;
border-radius: 0.25rem;
position: relative;
transition:
color 0.3s ease,
background 0.3s ease,
border 0.3s ease;
&:hover {
color: var(--primary-contrast);
background: var(--septenary-contrast);
}
&.shiki-ln-line-highlighted {
color: var(--primary-contrast);
background: var(--senary-contrast);
}
}
.shiki-ln-line-argument {
margin-inline-start: 0.2rem;
}
@include ref.reference-common();
@include ref.cli-reference();
}

View file

@ -6,23 +6,20 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {TestBed} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import CliReferenceDetailsPage from './cli-reference-details-page.component';
import {RouterTestingHarness, RouterTestingModule} from '@angular/router/testing';
import {signal} from '@angular/core';
import {RouterTestingHarness} from '@angular/router/testing';
import {ReferenceScrollHandler} from '../services/reference-scroll-handler.service';
import {provideRouter} from '@angular/router';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {provideRouter, withComponentInputBinding} from '@angular/router';
import {provideNoopAnimations} from '@angular/platform-browser/animations';
describe('CliReferenceDetailsPage', () => {
let component: CliReferenceDetailsPage;
let harness: RouterTestingHarness;
let fixture: ComponentFixture<unknown>;
let fakeApiReferenceScrollHandler = {
setupListeners: () => {},
membersMarginTopInPx: signal(0),
updateMembersMarginTop: () => {},
};
const SAMPLE_CONTENT = `
@ -34,33 +31,42 @@ describe('CliReferenceDetailsPage', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [CliReferenceDetailsPage, RouterTestingModule],
imports: [CliReferenceDetailsPage],
providers: [
provideRouter([
{
path: '**',
component: CliReferenceDetailsPage,
data: {
'docContent': {
id: 'id',
contents: SAMPLE_CONTENT,
provideNoopAnimations(),
provideRouter(
[
{
path: '**',
component: CliReferenceDetailsPage,
data: {
'docContent': {
id: 'id',
contents: SAMPLE_CONTENT,
},
},
},
},
]),
],
withComponentInputBinding(),
),
],
});
TestBed.overrideProvider(ReferenceScrollHandler, {useValue: fakeApiReferenceScrollHandler});
harness = await RouterTestingHarness.create();
const {fixture} = harness;
const harness = await RouterTestingHarness.create();
fixture = harness.fixture;
component = await harness.navigateByUrl('/', CliReferenceDetailsPage);
TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('should set content on init', () => {
expect(component.mainContentInnerHtml()).toBe('First column content');
expect(component.cardsInnerHtml()).toBe('Members content');
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load the doc content', () => {
expect(component.docContent()?.contents).toBeTruthy();
const docsViewer = fixture.nativeElement.querySelector('docs-viewer');
expect(docsViewer).toBeTruthy();
});
});

View file

@ -6,81 +6,16 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT} from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnInit,
inject,
signal,
} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {DocContent, DocViewer} from '@angular/docs';
import {ActivatedRoute} from '@angular/router';
import {map} from 'rxjs/operators';
import {ReferenceScrollHandler} from '../services/reference-scroll-handler.service';
import {API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME} from '../constants/api-reference-prerender.constants';
export const CLI_MAIN_CONTENT_SELECTOR = '.docs-reference-cli-content';
export const CLI_TOC = '.adev-reference-cli-toc';
@Component({
selector: 'adev-cli-reference-page',
imports: [DocViewer],
templateUrl: './cli-reference-details-page.component.html',
styleUrls: [
'./cli-reference-details-page.component.scss',
'../api-reference-details-page/api-reference-details-page.component.scss',
],
providers: [ReferenceScrollHandler],
styleUrls: ['./cli-reference-details-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class CliReferenceDetailsPage implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly scrollHandler = inject(ReferenceScrollHandler);
cardsInnerHtml = signal<string>('');
mainContentInnerHtml = signal<string>('');
ngOnInit(): void {
this.setPageContent();
}
contentLoaded(): void {
this.scrollHandler.setupListeners(CLI_TOC);
}
// Fetch the content for CLI Reference page based on the active route.
private setPageContent(): void {
this.activatedRoute.data
.pipe(
map((data) => data['docContent']),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((doc: DocContent | undefined) => {
this.setContentForPageSections(doc);
});
}
private setContentForPageSections(doc: DocContent | undefined) {
const element = this.document.createElement('div');
element.innerHTML = doc?.contents!;
// Get the innerHTML of the main content from received document.
const mainContent = element.querySelector(CLI_MAIN_CONTENT_SELECTOR);
if (mainContent) {
this.mainContentInnerHtml.set(mainContent.innerHTML);
}
// Get the innerHTML of the cards from received document.
const cards = element.querySelector(API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME);
if (cards) {
this.cardsInnerHtml.set(cards.innerHTML);
}
element.remove();
}
export default class CliReferenceDetailsPage {
docContent = input<DocContent | undefined>();
}

View file

@ -6,10 +6,5 @@
* found in the LICENSE file at https://angular.dev/license
*/
export const API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME = '.docs-reference-header';
export const API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME = '.docs-reference-members-container';
export const API_REFERENCE_TAB_ATTRIBUTE = 'data-tab';
export const API_REFERENCE_TAB_URL_ATTRIBUTE = 'data-tab-url';
export const API_REFERENCE_TAB_API_LABEL = 'API';
export const API_TAB_CLASS_NAME = '.docs-reference-api-tab';
export const API_SECTION_CLASS_NAME = 'docs-reference-api-section';
export const MEMBER_ID_ATTRIBUTE = 'member-id';

View file

@ -7,55 +7,29 @@
*/
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
import {DestroyRef, Injectable, Injector, PLATFORM_ID, inject} from '@angular/core';
import {DestroyRef, Injectable, PLATFORM_ID, inject} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {fromEvent} from 'rxjs';
import {MEMBER_ID_ATTRIBUTE} from '../constants/api-reference-prerender.constants';
import {WINDOW} from '@angular/docs';
import {Router} from '@angular/router';
import {AppScroller} from '../../../app-scroller';
// Adds some space/margin between the top of the target element and the top of viewport.
const SCROLL_MARGIN_TOP = 100;
@Injectable()
export class ReferenceScrollHandler {
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly injector = inject(Injector);
private readonly window = inject(WINDOW);
private readonly router = inject(Router);
private readonly appScroller = inject(AppScroller);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
setupListeners(tocSelector: string): void {
setupListeners(tocClass: string): void {
if (!this.isBrowser) {
return;
}
this.setupCodeToCListeners(tocSelector);
this.setupFragmentChangeListener();
this.setupCodeToCListeners(tocClass);
}
private setupFragmentChangeListener() {
this.router.routerState.root.fragment
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((fragment) => {
// If there is no fragment or the scroll event has a position (traversing through history),
// allow the scroller to handle scrolling instead of going to the fragment
if (!fragment || this.appScroller.lastScrollEvent?.position) {
this.appScroller.scroll(this.injector);
return;
}
const card = this.document.getElementById(fragment) as HTMLDivElement | null;
card?.focus();
this.scrollToCard(card);
});
}
private setupCodeToCListeners(tocSelector: string): void {
const tocContainer = this.document.querySelector<HTMLDivElement>(tocSelector);
private setupCodeToCListeners(tocClass: string): void {
const tocContainer = this.document.querySelector<HTMLDivElement>(`.${tocClass}`);
if (!tocContainer) {
return;
@ -82,21 +56,6 @@ export class ReferenceScrollHandler {
});
}
private scrollToCard(card: HTMLDivElement | null): void {
if (!card) {
return;
}
if (card !== <HTMLElement>document.activeElement) {
(<HTMLElement>document.activeElement).blur();
}
this.window.scrollTo({
top: card!.offsetTop - SCROLL_MARGIN_TOP,
behavior: 'smooth',
});
}
private getMemberId(lineButton: HTMLButtonElement | null): string | undefined {
if (!lineButton) {
return undefined;