feat(aio): add Table of Contents (toc) component. (#16078)
This commit is contained in:
parent
2a7f63650c
commit
3f46645f5f
|
@ -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">
|
||||
|
||||
|
|
|
@ -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. -->
|
||||
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
@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:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<h1>API List</h1>
|
||||
<h1 class="no-toc">API List</h1>
|
||||
<aio-api-list></aio-api-list>
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
@title
|
||||
Contribute
|
||||
# Contribute to Angular
|
||||
|
||||
@intro
|
||||
Contribute to Angular
|
||||
|
||||
@description
|
||||
Help us build the framework of the future!
|
||||
|
||||
## Angular Projects
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<h1>Features & Benefits</h1>
|
||||
<h1 class="no-toc">Features & Benefit</h1>
|
||||
|
||||
<article class="l-content ">
|
||||
<div class="flex-center">
|
||||
<div><h2 class="text-headline">Cross Platform</h2>
|
||||
|
|
|
@ -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 & desktop.</h1>
|
||||
<h1 class="text-headline no-toc">One framework.<br>Mobile & 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">
|
||||
|
|
|
@ -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 ">
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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('/', '-');
|
||||
|
|
|
@ -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]
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,3 +26,4 @@
|
|||
@import 'edit-page-cta';
|
||||
@import 'heading-anchors';
|
||||
@import 'api-info-bar';
|
||||
@import 'toc';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue