feat(aio): add Table of Contents (toc) component. (#16078)

This commit is contained in:
Ward Bell 2017-04-27 15:32:46 -07:00 committed by Miško Hevery
parent 2a7f63650c
commit 3f46645f5f
29 changed files with 938 additions and 171 deletions

View File

@ -1,17 +1,12 @@
@title
Animations
@intro
A guide to Angular's animation system.
@description
# Animations
Motion is an important aspect in the design of modern web applications. Good
user interfaces transition smoothly between states with engaging animations
that call attention where it's needed. Well-designed animations can make a UI not only
more fun but also easier to use.
## Overview
Angular's animation system lets you build animations that run with the same kind of native
performance found in pure CSS animations. You can also tightly integrate your
animation logic with the rest of your application code, for ease of control.
@ -33,7 +28,7 @@ add it to your page.
</div>
<!--
# Contents
* [Example: Transitioning between two states](guide/animations#example-transitioning-between-states).
@ -46,7 +41,7 @@ add it to your page.
* [Multi-step animations with keyframes](guide/animations#multi-step-animations-with-keyframes).
* [Parallel animation groups](guide/animations#parallel-animation-groups).
* [Animation callbacks](guide/animations#animation-callbacks).
-->
<div class="l-sub-section">

View File

@ -1,11 +1,4 @@
@title
NgModules
@intro
Define application modules with @NgModule.
@description
<h1 class="no-toc">NgModules</h1>
**NgModules** help organize an application into cohesive blocks of functionality.
@ -25,32 +18,23 @@ of creating and maintaining a single root `AppModule` for the entire application
This page covers NgModules in greater depth.
## Table of Contents
<!-- CF: The titling for tables of contents in the advanced chapters is inconsistent:
* some are titled "Contents" while others are titled "Table of Contents" (should probably be sentence case as it's an H2
* some headings are H2, some are H3
* some pages don't have tables of contents
I didn't make changes here as I'm not sure what the correct style is.
-->
<!-- CF: See my comment in the "Resolve directive conflicts" section below proposing renaming or reorganizing that section. -->
* [Angular modularity](guide/ngmodule#angular-modularity "Add structure to the app with NgModule")
* [The application root module](guide/ngmodule#root-module "The startup module that every app requires")
* [Bootstrap](guide/ngmodule#bootstrap "Launch the app in a browser with the root module as the entry point") the root module
* [Bootstrap the root module](guide/ngmodule#bootstrap "Launch the app in a browser with the root module as the entry point")
* [Declarations](guide/ngmodule#declarations "Declare the components, directives, and pipes that belong to a module")
* [Providers](guide/ngmodule#providers "Extend the app with additional services")
* [Imports](guide/ngmodule#imports "Import components, directives, and pipes for use in component templates")
* [Resolve conflicts](guide/ngmodule#resolve-conflicts "When two directives have the same selector")
<!-- CF: See my comment in the "Resolve diretive conflicts" section below proposing renaming or reorganizing that section. -->
* [Feature modules](guide/ngmodule#feature-modules "Partition the app into feature modules")
* [Lazy loaded modules](guide/ngmodule#lazy-load "Load modules asynchronously") with the router
* [Lazy loaded modules with the router](guide/ngmodule#lazy-load "Load modules asynchronously")
* [Shared modules](guide/ngmodule#shared-module "Create modules for commonly used components, directives, and pipes")
* [The Core module](guide/ngmodule#core-module "Create a core module with app-wide singleton services and single-use components")
* [Configure core services with _forRoot_](guide/ngmodule#core-for-root "Configure providers during module import")
* [Prevent reimport of the _CoreModule_](guide/ngmodule#prevent-reimport "because bad things happen if a lazy loaded module imports Core")
* [Prevent re-import of the _CoreModule_](guide/ngmodule#prevent-reimport "because bad things happen if a lazy loaded module imports Core")
* [NgModule metadata properties](guide/ngmodule#ngmodule-properties "A technical summary of the @NgModule metadata properties")
<!-- CF: This link goes to the top of this page. I would expect it to go to an "NgModule metadata properties"
section at the end of this page, but that section doesn't exist. -->

View File

@ -1,12 +1,7 @@
@title
QuickStart
@description
<h1 class="no-toc">QuickStart</h1>
Angular applications are made up of _components_.
A _component_ is the combination of an HTML template and a component class that controls a portion of the screen. Here is an example of a component that displays a simple string:
A _component_ is the combination of an HTML template and a component class that controls a portion of the screen. Here is an example of a component that displays a simple string:
<code-example path="quickstart/src/app/app.component.ts" title="src/app/app.component.ts" linenums="false">

View File

@ -1,8 +1,8 @@
<h1 class="title center">Angular Contributors</h1>
<h1 class="title center no-toc">Angular Contributors</h1>
<h2>Building For the Future</h2>
<p>Angular is built by a team of engineers who share a passion for
making web development feel effortless. We believe that writing
beautiful apps should be joyful and fun. We're building a
platform for the future.</p>
<aio-contributor-list></aio-contributor-list>
<aio-contributor-list></aio-contributor-list>

View File

@ -1,2 +1,2 @@
<h1>API List</h1>
<h1 class="no-toc">API List</h1>
<aio-api-list></aio-api-list>

View File

@ -1,10 +1,5 @@
@title
Contribute
# Contribute to Angular
@intro
Contribute to Angular
@description
Help us build the framework of the future!
## Angular Projects

View File

@ -1,4 +1,5 @@
<h1>Events</h1>
<h1 class="no-toc">Events</h1>
<h3>Where we'll be presenting:</h3>
<article class="l-content ">
<table class="is-full-width">

View File

@ -1,4 +1,5 @@
<h1>Features &amp; Benefits</h1>
<h1 class="no-toc">Features &amp; Benefit</h1>
<article class="l-content ">
<div class="flex-center">
<div><h2 class="text-headline">Cross Platform</h2>
@ -20,7 +21,7 @@
<p class="text-body">Create desktop-installed apps across Mac, Windows, and Linux using the same Angular methods you've learned for the web plus the ability to access native OS APIs.</p>
</div>
</div>
<h2 class="text-headline">Speed and Performance</h2>
<div class="feature-row">
@ -43,7 +44,7 @@
<h2 class="text-headline">Productivity</h2>
<div class="feature-row">
<div class="feature">
<h5>Templates</h5>
<p class="text-body">Quickly create UI views with simple and powerful template syntax.</p>
@ -53,7 +54,7 @@
<h5>Angular CLI</h5>
<p class="text-body">Command line tools: start building fast, add components and tests, then instantly deploy.</p>
</div>
<div class="feature">
<h5>IDEs</h5>
<p class="text-body">Get intelligent code completion, instant errors, and other feedback in popular editors and IDEs.</p>
@ -81,7 +82,7 @@
</div>
</div>
</div>
<div class="cta-bar"><a href="guide/quickstart" md-button="md-button" class="button button-large button-shield mat-raised mat-primary">Get Started</a></div>
</article>

View File

@ -3,7 +3,7 @@
<div class="hero background-superhero-paper is-large">
<img src="assets/images/logos/angular/angular.svg" class="hero-logo"/>
<h1 class="text-headline">One framework.<br>Mobile &amp; desktop.</h1>
<h1 class="text-headline no-toc">One framework.<br>Mobile &amp; desktop.</h1>
<a href="guide/quickstart" md-button="md-button" class="hero-cta mat-raised button button-large button-plain">Get Started</a>
<announcement-bar class="announcement-bar">
@ -13,7 +13,7 @@
<a href="http://angularjs.blogspot.com/2017/03/angular-400-now-available.html" target="_blank" class="button mat-button">Learn More</a>
</div>
</announcement-bar>
</div>
</header>
@ -27,7 +27,7 @@
<img src="assets/images/home/responsive-framework.svg" alt="responsive framework">
</div>
</div>
<div class="text-container">
<div class="text-block promo-1-desc l-pad-top-2">
<h2 class="text-headline">Develop Across All Platforms</h2>

View File

@ -1,4 +1,5 @@
<header class="hero background-sky"><h1 class="hero-title ">News</h1>
<header class="hero background-sky">
<h1 class="hero-title no-toc">News</h1>
<div class="clear"></div>
</header>
<article class="l-content ">

View File

@ -10,19 +10,19 @@ import { of } from 'rxjs/observable/of';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { MockLocationService } from 'testing/location.service';
import { MockSearchService } from 'testing/search.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { SearchService } from 'app/search/search.service';
import { MockSearchService } from 'testing/search.service';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
describe('AppComponent', () => {
let component: AppComponent;
@ -245,7 +245,7 @@ describe('AppComponent', () => {
it('should display a marketing page', () => {
locationService.go('features');
fixture.detectChanges();
expect(docViewer.innerText).toMatch(/Features Doc/i);
expect(docViewer.innerText).toMatch(/Features/i);
});
it('should update the document title', () => {
@ -443,7 +443,11 @@ class TestHttp {
{
"url": "features",
"title": "Features"
}
},
{
"url": "no-title",
"title": "No Title"
},
],
"SideNav": [
{
@ -459,7 +463,7 @@ class TestHttp {
"url": "guide/bags",
"title": "Bags",
"tooltip": "Pack your bags for a code adventure."
},
}
]
},
{
@ -493,12 +497,11 @@ class TestHttp {
} else {
const match = /content\/docs\/(.+)\.json/.exec(url);
const id = match[1];
// Make up a title for test purposes
const title = id.split('/').pop().replace(/^([a-z])/, (_, letter) => letter.toUpperCase());
const contents = `<h1>${title} Doc</h1><h2 id="#somewhere">Some heading</h2>`;
data = { id, title, contents };
if (id === 'no-title') {
data.title = '';
}
const h1 = (id === 'no-title') ? '' : `<h1>${title}</h1>`;
const contents = `${h1}<h2 id="#somewhere">Some heading</h2>`;
data = { id, contents };
}
return of({ json: () => data });
}

View File

@ -1,7 +1,6 @@
import { Component, ElementRef, HostListener, OnInit,
QueryList, ViewChild, ViewChildren } from '@angular/core';
import { MdSidenav } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CurrentNode, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
@ -62,8 +61,7 @@ export class AppComponent implements OnInit {
private documentService: DocumentService,
private locationService: LocationService,
private navigationService: NavigationService,
private swUpdateNotifications: SwUpdateNotificationsService,
private titleService: Title
private swUpdateNotifications: SwUpdateNotificationsService
) { }
ngOnInit() {
@ -73,7 +71,6 @@ export class AppComponent implements OnInit {
this.documentService.currentDocument.subscribe(doc => {
this.currentDocument = doc;
this.setDocumentTitle(doc.title);
this.setPageId(doc.id);
});
@ -155,14 +152,6 @@ export class AppComponent implements OnInit {
this.sidenav.toggle(value);
}
setDocumentTitle(title: string) {
if (title.trim()) {
this.titleService.setTitle(`Angular - ${title}`);
} else {
this.titleService.setTitle('Angular');
}
}
setPageId(id: string) {
// Special case the home page
this.pageId = (id === 'index') ? 'home' : id.replace('/', '-');

View File

@ -16,6 +16,8 @@ import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
import { EmbeddedModule } from 'app/embedded/embedded.module';
@ -31,8 +33,7 @@ import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
import { TocService } from 'app/shared/toc.service';
// These are the hardcoded inline svg sources to be used by the `<md-icon>` component
export const svgIconProviders = [
@ -83,18 +84,19 @@ export const svgIconProviders = [
],
providers: [
ApiService,
AutoScrollService,
DocumentService,
GaService,
Logger,
Location,
{ provide: LocationStrategy, useClass: PathLocationStrategy },
LocationService,
{ provide: MdIconRegistry, useClass: CustomMdIconRegistry },
NavigationService,
DocumentService,
SearchService,
Platform,
AutoScrollService,
{ provide: MdIconRegistry, useClass: CustomMdIconRegistry },
svgIconProviders
svgIconProviders,
TocService
],
bootstrap: [AppComponent]
})

View File

@ -1,8 +1,6 @@
export interface DocumentContents {
/** The unique identifier for this document */
id: string;
/** The string to display in the browser tab when this document is being viewed */
title: string;
/** The HTML to display in the doc viewer */
contents: string;
}

View File

@ -10,7 +10,8 @@ import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { DocumentService, DocumentContents } from './document.service';
import { DocumentService, DocumentContents,
FETCHING_ERROR_ID, FILE_NOT_FOUND_ID } from './document.service';
const CONTENT_URL_PREFIX = 'content/docs/';
@ -61,8 +62,8 @@ describe('DocumentService', () => {
it('should emit a document each time the location changes', () => {
let latestDocument: DocumentContents;
const doc0 = { title: 'doc 0', id: 'initial/doc' };
const doc1 = { title: 'doc 1', id: 'new/doc' };
const doc0 = { contents: 'doc 0', id: 'initial/doc' };
const doc1 = { contents: 'doc 1', id: 'new/doc' };
const { docService, backend, locationService } = getServices('initial/doc');
const connections = backend.connectionsArray;
@ -86,16 +87,16 @@ describe('DocumentService', () => {
connections[0].mockError(new Response(new ResponseOptions({ status: 404, statusText: 'NOT FOUND'})) as any);
expect(connections.length).toEqual(2);
expect(connections[1].request.url).toEqual(CONTENT_URL_PREFIX + 'file-not-found.json');
const fileNotFoundDoc = { id: 'file-not-found', title: 'Page Not Found', contents: '<h1>Page Not Found</h1>' };
const fileNotFoundDoc = { id: FILE_NOT_FOUND_ID, contents: '<h1>Page Not Found</h1>' };
connections[1].mockRespond(createResponse(fileNotFoundDoc));
expect(currentDocument).toEqual(fileNotFoundDoc);
});
it('should emit a hard-coded not-found document if the not-found document is not found on the server', () => {
let currentDocument: DocumentContents;
const notFoundDoc: DocumentContents = { title: 'Not Found', contents: 'Document not found', id: 'file-not-found' };
const nextDoc = { title: 'Next Doc', id: 'new/doc' };
const { docService, backend, locationService } = getServices('file-not-found');
const notFoundDoc: DocumentContents = { contents: 'Document not found', id: FILE_NOT_FOUND_ID };
const nextDoc = { contents: 'Next Doc', id: 'new/doc' };
const { docService, backend, locationService } = getServices(FILE_NOT_FOUND_ID);
const connections = backend.connectionsArray;
docService.currentDocument.subscribe(doc => currentDocument = doc);
@ -117,9 +118,9 @@ describe('DocumentService', () => {
docService.currentDocument.subscribe(doc => latestDocument = doc);
connections[0].mockRespond(new Response(new ResponseOptions({ body: 'this is invalid JSON' })));
expect(latestDocument.title).toMatch('Document retrieval error');
expect(latestDocument.id).toEqual(FETCHING_ERROR_ID);
const doc1 = { title: 'doc 1' };
const doc1 = { contents: 'doc 1' };
locationService.go('new/doc');
connections[1].mockRespond(createResponse(doc1));
expect(latestDocument).toEqual(jasmine.objectContaining(doc1));
@ -129,8 +130,8 @@ describe('DocumentService', () => {
let latestDocument: DocumentContents;
let subscription: Subscription;
const doc0 = { title: 'doc 0' };
const doc1 = { title: 'doc 1' };
const doc0 = { contents: 'doc 0' };
const doc1 = { contents: 'doc 1' };
const { docService, backend, locationService} = getServices('url/0');
const connections = backend.connectionsArray;
@ -141,7 +142,7 @@ describe('DocumentService', () => {
subscription.unsubscribe();
// modify the response so we can check that future subscriptions do not trigger another request
connections[0].response.next(createResponse({ title: 'error 0' }));
connections[0].response.next(createResponse({ contents: 'error 0' }));
subscription = docService.currentDocument.subscribe(doc => latestDocument = doc);
locationService.go('url/1');
@ -151,7 +152,7 @@ describe('DocumentService', () => {
subscription.unsubscribe();
// modify the response so we can check that future subscriptions do not trigger another request
connections[1].response.next(createResponse({ title: 'error 1' }));
connections[1].response.next(createResponse({ contents: 'error 1' }));
subscription = docService.currentDocument.subscribe(doc => latestDocument = doc);
locationService.go('url/0');

View File

@ -14,9 +14,10 @@ export { DocumentContents } from './document-contents';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
export const FILE_NOT_FOUND_ID = 'file-not-found';
export const FETCHING_ERROR_ID = 'fetching-error';
const CONTENT_URL_PREFIX = 'content/docs/';
const FILE_NOT_FOUND_ID = 'file-not-found';
const FETCHING_ERROR_ID = 'fetching-error';
const FETCHING_ERROR_CONTENTS = `
<div class="nf-container l-flex-wrap flex-center">
<div class="nf-icon material-icons">error_outline</div>
@ -72,9 +73,8 @@ export class DocumentService {
return this.getDocument(FILE_NOT_FOUND_ID);
} else {
return of({
title: 'Not Found',
contents: 'Document not found',
id: FILE_NOT_FOUND_ID
id: FILE_NOT_FOUND_ID,
contents: 'Document not found'
});
}
}
@ -83,9 +83,8 @@ export class DocumentService {
this.logger.error('Error fetching document', error);
this.cache.delete(id);
return Observable.of({
title: 'Document retrieval error',
contents: FETCHING_ERROR_CONTENTS,
id: FETCHING_ERROR_ID
id: FETCHING_ERROR_ID,
contents: FETCHING_ERROR_CONTENTS
});
}
}

View File

@ -19,7 +19,7 @@ import { Component, ElementRef, OnInit } from '@angular/core';
<aio-code [ngClass]="{'headed-code':title, 'simple-code':!title}" [code]="code" [language]="language" [linenums]="linenums"></aio-code>
`
})
export class CodeExampleComponent implements OnInit { // implements AfterViewInit {
export class CodeExampleComponent implements OnInit {
code: string;
language: string;

View File

@ -1,15 +0,0 @@
/* tslint:disable component-selector */
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { DocumentService } from 'app/documents/document.service';
@Component({
selector: 'doc-title',
template: '<h1 class="docs-primary-header">{{title | async}}</h1>'
})
export class DocTitleComponent {
title: Observable<string>;
constructor(docs: DocumentService) {
this.title = docs.currentDocument.map(doc => doc.title);
}
}

View File

@ -18,18 +18,18 @@ import { CodeExampleComponent } from './code/code-example.component';
import { CodeTabsComponent } from './code/code-tabs.component';
import { ContributorListComponent } from './contributor/contributor-list.component';
import { ContributorComponent } from './contributor/contributor.component';
import { DocTitleComponent } from './doc-title.component';
import { CurrentLocationComponent } from './current-location.component';
import { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component';
import { ResourceListComponent } from './resource/resource-list.component';
import { ResourceService } from './resource/resource.service';
import { TocComponent } from './toc/toc.component';
/** Components that can be embedded in docs
* such as CodeExampleComponent, LiveExampleComponent,...
*/
export const embeddedComponents: any[] = [
ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent,
CurrentLocationComponent, DocTitleComponent, LiveExampleComponent, ResourceListComponent
CurrentLocationComponent, LiveExampleComponent, ResourceListComponent, TocComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */

View File

@ -26,17 +26,4 @@
</div>
</div>
</div>
<!--</div>-->
<!--<div class="c3">-->
<div class="c-resource-nav shadow-1 l-flex--column h-affix" [ngClass]="{ 'affix-top': scrollPos > 200 }">
<div class="category" *ngFor="let category of categories">
<a class="category-link h-capitalize" [href]="href(category)">{{category.title}}</a>
<div class="subcategory" *ngFor="let subCategory of category.subCategories">
<a class="subcategory-link" [href]="href(subCategory)">{{subCategory.title}}</a>
</div>
</div>
</div>
<!--</div>-->
</div>

View File

@ -0,0 +1,24 @@
<div *ngIf="hasToc" [class.closed]="isClosed">
<div *ngIf="!hasSecondary"class="toc-heading">Contents</div>
<div *ngIf="hasSecondary" class="toc-heading secondary"
(click)="toggle()"
title="Expand/collapse contents"
aria-label="Expand/collapse contents">
Contents
<button type="button"
class="toc-show-all material-icons" [class.closed]="isClosed">
</button>
</div>
<ul class="toc-list">
<li *ngFor="let toc of tocList" title="{{toc.title}}" class="{{toc.level}}" [class.secondary]="toc.isSecondary">
<a [href]="toc.href" [innerHTML]="toc.content"></a>
</li>
</ul>
<button type="button" (click)="toggle()" *ngIf="hasSecondary"
class="toc-more-items material-icons" [class.closed]="isClosed"
title="Expand/collapse contents"
aria-label="Expand/collapse contents">
</button>
</div>

View File

@ -0,0 +1,232 @@
import { Component, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By, DOCUMENT } from '@angular/platform-browser';
import { TocComponent } from './toc.component';
import { TocItem, TocService } from 'app/shared/toc.service';
describe('TocComponent', () => {
let tocComponentDe: DebugElement;
let tocComponent: TocComponent;
let tocService: TestTocService;
let page: {
listItems: DebugElement[];
tocHeading: DebugElement;
tocHeadingButton: DebugElement;
tocMoreButton: DebugElement;
};
function setPage(): typeof page {
return {
listItems: tocComponentDe.queryAll(By.css('ul.toc-list>li')),
tocHeading: tocComponentDe.query(By.css('.toc-heading')),
tocHeadingButton: tocComponentDe.query(By.css('.toc-heading button')),
tocMoreButton: tocComponentDe.query(By.css('button.toc-more-items')),
};
}
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ HostEmbeddedTocComponent, HostNotEmbeddedTocComponent, TocComponent ],
providers: [
{ provide: TocService, useClass: TestTocService }
]
})
.compileComponents();
}));
describe('when embedded in doc body', () => {
let fixture: ComponentFixture<HostEmbeddedTocComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(HostEmbeddedTocComponent);
tocComponentDe = fixture.debugElement.children[0];
tocComponent = tocComponentDe.componentInstance;
tocService = TestBed.get(TocService);
});
it('should create tocComponent', () => {
expect(tocComponent).toBeTruthy();
});
it('should be in embedded state', () => {
expect(tocComponent.isEmbedded).toEqual(true);
});
it('should not display anything when no TocItems', () => {
tocService.tocList = [];
fixture.detectChanges();
expect(tocComponentDe.children.length).toEqual(0);
});
describe('when four TocItems', () => {
beforeEach(() => {
tocService.tocList.length = 4;
fixture.detectChanges();
page = setPage();
});
it('should have four displayed items', () => {
expect(page.listItems.length).toEqual(4);
});
it('should not have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
const aSecond = page.listItems.find(item => item.classes.secondary);
expect(aSecond).toBeFalsy('should not find a secondary');
});
it('should not display expando buttons', () => {
expect(page.tocHeadingButton).toBeFalsy('top expand/collapse button');
expect(page.tocMoreButton).toBeFalsy('bottom more button');
});
});
describe('when many TocItems', () => {
beforeEach(() => {
fixture.detectChanges();
page = setPage();
});
it('should have more than 4 displayed items', () => {
expect(page.listItems.length).toBeGreaterThan(4);
expect(page.listItems.length).toEqual(tocService.tocList.length);
});
it('should be in "closed" (not expanded) state at the start', () => {
expect(tocComponent.isClosed).toBeTruthy();
});
it('should have "closed" class at the start', () => {
expect(tocComponentDe.children[0].classes.closed).toEqual(true);
});
it('should display expando buttons', () => {
expect(page.tocHeadingButton).toBeTruthy('top expand/collapse button');
expect(page.tocMoreButton).toBeTruthy('bottom more button');
});
it('should have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(true, 'hasSecondary flag');
});
// CSS should hide items with the secondary class when closed
it('should have secondary item with a secondary class', () => {
const aSecondary = page.listItems.find(item => item.classes.secondary);
expect(aSecondary).toBeTruthy('should find a secondary');
expect(aSecondary.classes.secondary).toEqual(true, 'has secondary class');
});
describe('after click expando button', () => {
beforeEach(() => {
page.tocHeadingButton.nativeElement.click();
fixture.detectChanges();
});
it('should not be "closed"', () => {
expect(tocComponent.isClosed).toEqual(false);
});
it('should not have "closed" class', () => {
expect(tocComponentDe.children[0].classes.closed).toBeFalsy();
});
});
});
});
describe('when in side panel (not embedded))', () => {
let fixture: ComponentFixture<HostNotEmbeddedTocComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(HostNotEmbeddedTocComponent);
tocComponentDe = fixture.debugElement.children[0];
tocComponent = tocComponentDe.componentInstance;
fixture.detectChanges();
page = setPage();
});
it('should not be in embedded state', () => {
expect(tocComponent.isEmbedded).toEqual(false);
});
it('should display all items', () => {
expect(page.listItems.length).toEqual(tocService.tocList.length);
});
it('should not have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
const aSecond = page.listItems.find(item => item.classes.secondary);
expect(aSecond).toBeFalsy('should not find a secondary');
});
it('should not display expando buttons', () => {
expect(page.tocHeadingButton).toBeFalsy('top expand/collapse button');
expect(page.tocMoreButton).toBeFalsy('bottom more button');
});
});
});
//// helpers ////
@Component({
selector: 'aio-embedded-host',
template: '<aio-toc class="embedded"></aio-toc>'
})
class HostEmbeddedTocComponent {}
@Component({
selector: 'aio-not-embedded-host',
template: '<aio-toc></aio-toc>'
})
class HostNotEmbeddedTocComponent {}
class TestTocService {
tocList: TocItem[] = getTestTocList();
}
// tslint:disable:quotemark
function getTestTocList() {
return [
{
"content": "Heading one",
"href": "fizz/buzz#heading-one-special-id",
"level": "h2",
"title": "Heading one"
},
{
"content": "H2 Two",
"href": "fizz/buzz#h2-two",
"level": "h2",
"title": "H2 Two"
},
{
"content": "H2 <b>Three</b>",
"href": "fizz/buzz#h2-three",
"level": "h2",
"title": "H2 Three"
},
{
"content": "H3 3a",
"href": "fizz/buzz#h3-3a",
"level": "h3",
"title": "H3 3a"
},
{
"content": "H3 3b",
"href": "fizz/buzz#h3-3b",
"level": "h3",
"title": "H3 3b"
},
{
"content": "<i>H2 <b>four</b></i>",
"href": "fizz/buzz#h2-four",
"level": "h2",
"title": "H2 4"
}
];
}

View File

@ -0,0 +1,42 @@
import { Component, ElementRef, OnInit } from '@angular/core';
import { TocItem, TocService } from 'app/shared/toc.service';
@Component({
selector: 'aio-toc',
templateUrl: 'toc.component.html',
styles: []
})
export class TocComponent implements OnInit {
hasSecondary = false;
hasToc = true;
isClosed = true;
isEmbedded = false;
private primaryMax = 4;
tocList: TocItem[];
constructor(
private elementRef: ElementRef,
private tocService: TocService) {
const hostElement = this.elementRef.nativeElement;
this.isEmbedded = hostElement.className.indexOf('embedded') !== -1;
}
ngOnInit() {
const tocList = this.tocList = this.tocService.tocList;
const count = tocList.length;
this.hasToc = count > 0;
if (this.isEmbedded && this.hasToc) {
// If TOC is embedded in doc, mark secondary (sometimes hidden) items
this.hasSecondary = tocList.length > this.primaryMax;
for (let i = this.primaryMax; i < count; i++) {
tocList[i].isSecondary = true;
}
}
}
toggle() {
this.isClosed = !this.isClosed;
}
}

View File

@ -1,10 +1,13 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFactoryResolver, ElementRef, Injector, NgModule, OnInit, ViewChild, Component, DebugElement } from '@angular/core';
import {
Component, ComponentFactoryResolver, DebugElement,
ElementRef, Injector, NgModule, OnInit, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
import { DocViewerComponent } from './doc-viewer.component';
import { DocumentContents } from 'app/documents/document.service';
import { EmbeddedModule, embeddedComponents, EmbeddedComponents } from 'app/embedded/embedded.module';
import { Title } from '@angular/platform-browser';
import { TocService } from 'app/shared/toc.service';
/// Embedded Test Components ///
@ -86,6 +89,17 @@ class TestComponent {
@ViewChild(DocViewerComponent) docViewer: DocViewerComponent;
}
//// Test Services ////
class TestTitleService {
setTitle = jasmine.createSpy('reset');
}
class TestTocService {
reset = jasmine.createSpy('reset');
genToc = jasmine.createSpy('genToc');
}
//////// Tests //////////////
describe('DocViewerComponent', () => {
@ -94,6 +108,10 @@ describe('DocViewerComponent', () => {
let docViewerEl: HTMLElement;
let fixture: ComponentFixture<TestComponent>;
function setCurrentDoc(contents = '', id = 'fizz/buzz') {
component.currentDoc = { contents, id };
}
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ TestModule ],
@ -103,7 +121,9 @@ describe('DocViewerComponent', () => {
embeddedTestComponents
],
providers: [
{provide: EmbeddedComponents, useValue: {components: embeddedTestComponents}}
{ provide: EmbeddedComponents, useValue: {components: embeddedTestComponents} },
{ provide: Title, useClass: TestTitleService },
{ provide: TocService, useClass: TestTocService }
]
})
.compileComponents();
@ -122,23 +142,23 @@ describe('DocViewerComponent', () => {
});
it(('should display nothing when set currentDoc has no content'), () => {
component.currentDoc = { title: 'fake title', contents: '', id: 'a/b' };
setCurrentDoc();
fixture.detectChanges();
expect(docViewerEl.innerHTML).toBe('');
});
it(('should display simple static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual(contents);
});
it(('should display nothing after reset static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
component.currentDoc = { title: 'fake title', contents: '', id: 'a/c' };
component.currentDoc = { contents: '', id: 'a/c' };
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual('');
});
@ -149,7 +169,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Below Foo</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML;
expect(fooHtml).toContain('Foo Component');
@ -165,7 +185,7 @@ describe('DocViewerComponent', () => {
</div>
<p>Below Foo</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2);
@ -177,7 +197,7 @@ describe('DocViewerComponent', () => {
<aio-bar></aio-bar>
<p>Below Bar</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('Bar Component');
@ -189,7 +209,7 @@ describe('DocViewerComponent', () => {
<aio-bar>###bar content###</aio-bar>
<p>Below Bar</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger projection within ngOnInit
fixture.detectChanges();
@ -207,7 +227,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -230,7 +250,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo><p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -254,7 +274,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -282,7 +302,7 @@ describe('DocViewerComponent', () => {
<p><aio-baz>---More baz--</aio-baz></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -298,4 +318,86 @@ describe('DocViewerComponent', () => {
'expected 2nd Baz template content');
});
describe('Title', () => {
let titleService: TestTitleService;
beforeEach(() => {
titleService = TestBed.get(Title);
});
it('should set the default empty title when no <h1>', () => {
setCurrentDoc('Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
it('should set the expected title when has <h1>', () => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the expected title with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
});
describe('TOC', () => {
let tocService: TestTocService;
function getAioToc(): HTMLElement {
return fixture.debugElement.nativeElement.querySelector('aio-toc');
}
beforeEach(() => {
tocService = TestBed.get(TocService);
});
describe('if no <h1> title', () => {
beforeEach(() => {
setCurrentDoc('Some content');
fixture.detectChanges();
});
it('should not have an <aio-toc>', () => {
expect(getAioToc()).toBeFalsy();
});
it('should reset Toc Service', () => {
expect(tocService.reset).toHaveBeenCalled();
});
it('should not call Toc Service genToc()', () => {
expect(tocService.genToc).not.toHaveBeenCalled();
});
});
it('should not have an <aio-toc> with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(getAioToc()).toBeFalsy();
});
describe('when has an <h1> (title)', () => {
beforeEach(() => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
});
it('should add <aio-toc>', () => {
expect(getAioToc()).toBeTruthy();
});
it('should have <aio-toc> with "embedded" class', () => {
expect(getAioToc().classList.contains('embedded')).toEqual(true);
});
it('should call Toc Service genToc()', () => {
expect(tocService.genToc).toHaveBeenCalled();
});
});
});
});

View File

@ -6,6 +6,8 @@ import {
import { EmbeddedComponents } from 'app/embedded/embedded.module';
import { DocumentContents } from 'app/documents/document.service';
import { Title } from '@angular/platform-browser';
import { TocService } from 'app/shared/toc.service';
interface EmbeddedComponentFactory {
contentPropertyName: string;
@ -18,13 +20,7 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen
@Component({
selector: 'aio-doc-viewer',
template: '',
styles: [ `
:host >>> doc-title.not-found h1 {
color: white;
background-color: red;
}
`]
template: ''
// TODO(robwormald): shadow DOM and emulated don't work here (?!)
// encapsulation: ViewEncapsulation.Native
})
@ -41,7 +37,9 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
embeddedComponents: EmbeddedComponents,
private injector: Injector
private injector: Injector,
private titleService: Title,
private tocService: TocService
) {
this.hostElement = elementRef.nativeElement;
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
@ -77,6 +75,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
if (!doc.contents) { return; }
this.addTitleAndToc(doc.id);
// TODO(i): why can't I use for-of? why doesn't typescript like Map#value() iterators?
this.embeddedComponentFactories.forEach(({ contentPropertyName, factory }, selector) => {
const embeddedComponentElements = this.hostElement.querySelectorAll(selector);
@ -92,8 +92,27 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
});
}
private addTitleAndToc(docId: string) {
this.tocService.reset();
let title = '';
const titleEl = this.hostElement.querySelector('h1');
// Only create TOC for docs with an <h1> title
// If you don't want a TOC, don't have an <h1>
if (titleEl) {
title = titleEl.innerText.trim();
if (!/(no-toc|notoc)/i.test(titleEl.className)) {
this.tocService.genToc(this.hostElement, docId);
titleEl.insertAdjacentHTML('afterend', '<aio-toc class="embedded"></aio-toc>');
}
}
this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular');
}
ngDoCheck() {
if (this.displayedDoc) { this.displayedDoc.detectChanges(); }
// TODO: make sure this isn't called too often on the same doc
if (this.displayedDoc) {
this.displayedDoc.detectChanges();
}
}
ngOnDestroy() {

View File

@ -0,0 +1,227 @@
import { ReflectiveInjector, SecurityContext } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TocItem, TocService } from './toc.service';
describe('TocService', () => {
let injector: ReflectiveInjector;
let tocService: TocService;
// call TocService.genToc
function callGenToc(html = '', docId = 'fizz/buzz'): HTMLDivElement {
const el = document.createElement('div');
el.innerHTML = html;
tocService.genToc(el, docId);
return el;
}
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
{ provide: DomSanitizer, useClass: TestDomSanitizer },
{ provide: DOCUMENT, useValue: document },
TocService,
]);
tocService = injector.get(TocService);
});
it('should be creatable', () => {
expect(tocService).toBeTruthy();
});
describe('should clear tocList', () => {
// Start w/ dummy data from previous usage
beforeEach(() => tocService.tocList = [{}, {}] as TocItem[]);
it('when reset()', () => {
tocService.reset();
expect(tocService.tocList.length).toEqual(0);
});
it('when given undefined doc element', () => {
tocService.genToc(undefined);
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ no headings', () => {
callGenToc('<p>This</p><p>and</p><p>that</p>');
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ headings other than h2 & h3', () => {
callGenToc('<h1>This</h1><h4>and</h4><h5>that</h5>');
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ no-toc headings', () => {
// tolerates different spellings/casing of the no-toc class
callGenToc(`
<h2 class="no-toc">one</h2><p>some one</p>
<h2 class="notoc">two</h2><p>some two</p>
<h2 class="no-Toc">three</h2><p>some three</p>
<h2 class="noToc">four</h2><p>some four</p>
`);
expect(tocService.tocList.length).toEqual(0);
});
});
describe('when given many headings', () => {
let docId: string;
let docEl: HTMLDivElement;
let tocList: TocItem[];
let headings: NodeListOf<HTMLHeadingElement>;
beforeEach(() => {
docId = 'fizz/buzz';
docEl = callGenToc(`
<h1>Fun with TOC</h1>
<h2 id="heading-one-special-id">Heading one</h2>
<p>h2 toc 0</p>
<h2>H2 Two</h2>
<p>h2 toc 1</p>
<h2>H2 <b>Three</b></h2>
<p>h2 toc 2</p>
<h3 id="h3-3a">H3 3a</h3> <p>h3 toc 3</p>
<h3 id="h3-3b">H3 3b</h3> <p>h3 toc 4</p>
<!-- h4 shouldn't be in TOC -->
<h4 id="h4-3b">H4 of h3-3b</h4> <p>an h4</p>
<h2><i>H2 4 <b>repeat</b></i></h2>
<p>h2 toc 5</p>
<h2><b>H2 4 <i>repeat</i></b></h2>
<p>h2 toc 6</p>
<h2 class="no-toc" id="skippy">Skippy</h2>
<p>Skip this header</p>
<h2 id="h2-6">H2 6</h2>
<p>h2 toc 7</p>
<h3 id="h3-6a">H3 6a</h3> <p>h3 toc 8</p>
`, docId);
tocList = tocService.tocList;
headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf<HTMLHeadingElement>;
});
it('should have tocList with expect number of TocItems', () => {
// should ignore h1, h4, and the no-toc h2
expect(tocList.length).toEqual(headings.length - 3);
});
it('should have href with docId and heading\'s id', () => {
const tocItem = tocList[0];
expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`);
});
it('should have level "h2" for an <h2>', () => {
const tocItem = tocList[0];
expect(tocItem.level).toEqual('h2');
});
it('should have level "h3" for an <h3>', () => {
const tocItem = tocList[3];
expect(tocItem.level).toEqual('h3');
});
it('should have title which is heading\'s innerText ', () => {
const heading = headings[3];
const tocItem = tocList[2];
expect(heading.innerText).toEqual(tocItem.title);
});
it('should have "SafeHtml" content which is heading\'s innerHTML ', () => {
const heading = headings[3];
const content = tocList[2].content;
expect((<TestSafeHtml>content).changingThisBreaksApplicationSecurity)
.toEqual(heading.innerHTML);
});
it('should calculate and set id of heading without an id', () => {
const id = headings[2].getAttribute('id');
expect(id).toEqual('h2-two');
});
it('should have href with docId and calculated heading id', () => {
const tocItem = tocList[1];
expect(tocItem.href).toEqual(`${docId}#h2-two`);
});
it('should ignore HTML in heading when calculating id', () => {
const id = headings[3].getAttribute('id');
const tocItem = tocList[2];
expect(id).toEqual('h2-three', 'heading id');
expect(tocItem.href).toEqual(`${docId}#h2-three`, 'tocItem href');
});
it('should avoid repeating an id when calculating', () => {
const tocItem4a = tocList[5];
const tocItem4b = tocList[6];
expect(tocItem4a.href).toEqual(`${docId}#h2-4-repeat`, 'first');
expect(tocItem4b.href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
});
});
describe('TocItem for an h2 with anchor link and extra whitespace', () => {
let docId: string;
let docEl: HTMLDivElement;
let tocItem: TocItem;
let expectedTocContent: string;
beforeEach(() => {
docId = 'fizz/buzz/';
expectedTocContent = 'Setup to develop <i>locally</i>.';
// An almost-actual <h2> ... with extra whitespace
docEl = callGenToc(`
<h2 id="setup-to-develop-locally">
<a href="tutorial/toh-pt1#setup-to-develop-locally" aria-hidden="true">
<span class="icon icon-link"></span>
</a>
${expectedTocContent}
</h2>
`, docId);
tocItem = tocService.tocList[0];
});
it('should have expected href', () => {
expect(tocItem.href).toEqual(`${docId}#setup-to-develop-locally`);
});
it('should have expected title', () => {
expect(tocItem.title).toEqual('Setup to develop locally.');
});
it('should have removed anchor link from tocItem html content', () => {
expect((<TestSafeHtml>tocItem.content)
.changingThisBreaksApplicationSecurity)
.toEqual('Setup to develop <i>locally</i>.');
});
it('should have bypassed HTML sanitizing of heading\'s innerHTML ', () => {
const domSanitizer: TestDomSanitizer = injector.get(DomSanitizer);
expect(domSanitizer.bypassSecurityTrustHtml)
.toHaveBeenCalledWith(expectedTocContent);
});
});
});
interface TestSafeHtml extends SafeHtml {
changingThisBreaksApplicationSecurity: string;
getTypeName: () => string;
}
class TestDomSanitizer {
bypassSecurityTrustHtml = jasmine.createSpy('bypassSecurityTrustHtml')
.and.callFake(html => {
return {
changingThisBreaksApplicationSecurity: html,
getTypeName: () => 'HTML',
} as TestSafeHtml;
});
}

View File

@ -0,0 +1,81 @@
import { Inject, Injectable } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { DocumentContents } from 'app/documents/document.service';
export interface TocItem {
content: SafeHtml;
href: string;
isSecondary?: boolean;
level: string;
title: string;
}
@Injectable()
export class TocService {
tocList: TocItem[];
constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer) { }
genToc(docElement: Element, docId = '') {
const tocList = this.tocList = [];
if (!docElement) { return; }
const headings = docElement.querySelectorAll('h2,h3');
const idMap = new Map<string, number>();
for (let i = 0; i < headings.length; i++) {
const heading = headings[i] as HTMLHeadingElement;
// skip if heading class is 'no-toc'
if (/(no-toc|notoc)/i.test(heading.className)) { continue; }
const id = this.getId(heading, idMap);
const toc: TocItem = {
content: this.extractHeadingSafeHtml(heading),
href: `${docId}#${id}`,
level: heading.tagName.toLowerCase(),
title: heading.innerText.trim(),
};
tocList.push(toc);
}
}
reset() {
this.tocList = [];
}
// This bad boy exists only to strip off the anchor link attached to a heading
private extractHeadingSafeHtml(heading: HTMLHeadingElement) {
const a = this.document.createElement('a') as HTMLAnchorElement;
a.innerHTML = heading.innerHTML;
const anchorLink = a.querySelector('a');
if (anchorLink) {
a.removeChild(anchorLink);
}
// security: the document element which provides this heading content
// is always authored by the documentation team and is considered to be safe
return this.domSanitizer.bypassSecurityTrustHtml(a.innerHTML.trim());
}
// Extract the id from the heading; create one if necessary
// Is it possible for a heading to lack an id?
private getId(h: HTMLHeadingElement, idMap: Map<string, number>) {
let id = h.id;
if (id) {
addToMap(id);
} else {
id = h.innerText.toLowerCase().replace(/\W+/g, '-');
id = addToMap(id);
h.id = id;
}
return id;
// Map guards against duplicate id creation.
function addToMap(key: string) {
const count = idMap[key] = idMap[key] ? idMap[key] + 1 : 1;
return count === 1 ? key : `${key}-${count}`;
}
}
}

View File

@ -25,4 +25,5 @@
@import 'resources';
@import 'edit-page-cta';
@import 'heading-anchors';
@import 'api-info-bar';
@import 'api-info-bar';
@import 'toc';

View File

@ -0,0 +1,103 @@
aio-toc > div {
font-size: 13px;
border-left: 10px solid #4285f4;
overflow-y: visible;
padding: 4px 0 0 10px;
.toc-heading {
font-size: 22px;
font-weight: 500;
margin-left: 8px;
padding-bottom: 8px;
}
.toc-heading.secondary {
padding-bottom: 0;
position: relative;
top: -8px;
&:hover {
color: $accentblue;
}
}
button.toc-show-all,
button.toc-more-items {
display: inline-block;
position: relative;
background: 0;
background-color: transparent;
border: none;
box-shadow: none;
color: $mediumgray;
padding: 0;
&:hover {
color: $accentblue;
}
&:focus {
outline: none;
}
}
button.toc-show-all {
min-width: 34px;
}
button.toc-show-all::after {
content: 'expand_less';
}
button.toc-show-all.closed::after {
content: 'expand_more';
}
button.toc-more-items {
top: 10px;
}
button.toc-more-items::after {
content: 'expand_less';
}
button.toc-more-items.closed::after {
content: 'more_horiz';
}
ul.toc-list {
list-style-type: none;
margin: 0;
padding: 0 8px;
}
ul.toc-list li {
line-height: 16px;
margin: 0;
a {
color: $mediumgray;
display:inline-block;
overflow: visible;
&:hover {
color: $accentblue;
}
}
}
ul.toc-list li.h3 {
margin-left: 15px;
}
}
aio-toc > div.closed li.secondary {
display: none;
}
@media screen and (max-width: 1200px) {
aio-toc.embedded:not(:empty) {
display: block;
margin: 20px 0 24px;
}
}