From 3f46645f5f64bf6b496e711599d754e2dab66ea7 Mon Sep 17 00:00:00 2001 From: Ward Bell Date: Thu, 27 Apr 2017 15:32:46 -0700 Subject: [PATCH] feat(aio): add Table of Contents (toc) component. (#16078) --- aio/content/guide/animations.md | 15 +- aio/content/guide/ngmodule.md | 28 +-- aio/content/guide/quickstart.md | 9 +- aio/content/marketing/about.html | 4 +- aio/content/marketing/api.html | 2 +- aio/content/marketing/contribute.md | 7 +- aio/content/marketing/events.html | 3 +- aio/content/marketing/features.html | 11 +- aio/content/marketing/index.html | 6 +- aio/content/marketing/news.html | 3 +- aio/src/app/app.component.spec.ts | 35 +-- aio/src/app/app.component.ts | 13 +- aio/src/app/app.module.ts | 14 +- aio/src/app/documents/document-contents.ts | 2 - .../app/documents/document.service.spec.ts | 27 +- aio/src/app/documents/document.service.ts | 15 +- .../embedded/code/code-example.component.ts | 2 +- aio/src/app/embedded/doc-title.component.ts | 15 -- aio/src/app/embedded/embedded.module.ts | 4 +- .../resource/resource-list.component.html | 13 - aio/src/app/embedded/toc/toc.component.html | 24 ++ .../app/embedded/toc/toc.component.spec.ts | 232 ++++++++++++++++++ aio/src/app/embedded/toc/toc.component.ts | 42 ++++ .../doc-viewer/doc-viewer.component.spec.ts | 132 ++++++++-- .../layout/doc-viewer/doc-viewer.component.ts | 37 ++- aio/src/app/shared/toc.service.spec.ts | 227 +++++++++++++++++ aio/src/app/shared/toc.service.ts | 81 ++++++ aio/src/styles/2-modules/_modules-dir.scss | 3 +- aio/src/styles/2-modules/_toc.scss | 103 ++++++++ 29 files changed, 938 insertions(+), 171 deletions(-) delete mode 100644 aio/src/app/embedded/doc-title.component.ts create mode 100644 aio/src/app/embedded/toc/toc.component.html create mode 100644 aio/src/app/embedded/toc/toc.component.spec.ts create mode 100644 aio/src/app/embedded/toc/toc.component.ts create mode 100644 aio/src/app/shared/toc.service.spec.ts create mode 100644 aio/src/app/shared/toc.service.ts create mode 100644 aio/src/styles/2-modules/_toc.scss diff --git a/aio/content/guide/animations.md b/aio/content/guide/animations.md index 1928446ae0..5f8a75b0a9 100644 --- a/aio/content/guide/animations.md +++ b/aio/content/guide/animations.md @@ -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. - +
diff --git a/aio/content/guide/ngmodule.md b/aio/content/guide/ngmodule.md index a3607d662f..d961c58340 100644 --- a/aio/content/guide/ngmodule.md +++ b/aio/content/guide/ngmodule.md @@ -1,11 +1,4 @@ -@title -NgModules - -@intro -Define application modules with @NgModule. - -@description - +

NgModules

**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 - - + * [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") - * [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") + diff --git a/aio/content/guide/quickstart.md b/aio/content/guide/quickstart.md index 3350ea057e..27e8feef6b 100644 --- a/aio/content/guide/quickstart.md +++ b/aio/content/guide/quickstart.md @@ -1,12 +1,7 @@ -@title -QuickStart - -@description - - +

QuickStart

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: diff --git a/aio/content/marketing/about.html b/aio/content/marketing/about.html index 8e15f8dd02..4039734d1a 100644 --- a/aio/content/marketing/about.html +++ b/aio/content/marketing/about.html @@ -1,8 +1,8 @@ -

Angular Contributors

+

Angular Contributors

Building For the Future

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.

- \ No newline at end of file + diff --git a/aio/content/marketing/api.html b/aio/content/marketing/api.html index 83743a1a58..c2508b53b3 100755 --- a/aio/content/marketing/api.html +++ b/aio/content/marketing/api.html @@ -1,2 +1,2 @@ -

API List

+

API List

diff --git a/aio/content/marketing/contribute.md b/aio/content/marketing/contribute.md index 589f045f0d..9e9d1c05e6 100644 --- a/aio/content/marketing/contribute.md +++ b/aio/content/marketing/contribute.md @@ -1,10 +1,5 @@ -@title -Contribute +# Contribute to Angular -@intro -Contribute to Angular - -@description Help us build the framework of the future! ## Angular Projects diff --git a/aio/content/marketing/events.html b/aio/content/marketing/events.html index 0214c01d4c..b491a33fd4 100755 --- a/aio/content/marketing/events.html +++ b/aio/content/marketing/events.html @@ -1,4 +1,5 @@ -

Events

+

Events

+

Where we'll be presenting:

diff --git a/aio/content/marketing/features.html b/aio/content/marketing/features.html index 212e9a5108..c36c7b78d0 100755 --- a/aio/content/marketing/features.html +++ b/aio/content/marketing/features.html @@ -1,4 +1,5 @@ -

Features & Benefits

+

Features & Benefit

+

Cross Platform

@@ -20,7 +21,7 @@

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.

- +

Speed and Performance

@@ -43,7 +44,7 @@

Productivity

- +
Templates

Quickly create UI views with simple and powerful template syntax.

@@ -53,7 +54,7 @@
Angular CLI

Command line tools: start building fast, add components and tests, then instantly deploy.

- +
IDEs

Get intelligent code completion, instant errors, and other feedback in popular editors and IDEs.

@@ -81,7 +82,7 @@
- +
diff --git a/aio/content/marketing/index.html b/aio/content/marketing/index.html index 9faa4b07f6..b48c9dfb62 100755 --- a/aio/content/marketing/index.html +++ b/aio/content/marketing/index.html @@ -3,7 +3,7 @@
-

One framework.
Mobile & desktop.

+

One framework.
Mobile & desktop.

Get Started @@ -13,7 +13,7 @@ Learn More
- + @@ -27,7 +27,7 @@ responsive framework - +

Develop Across All Platforms

diff --git a/aio/content/marketing/news.html b/aio/content/marketing/news.html index 52f4ec8f20..3c150614c8 100755 --- a/aio/content/marketing/news.html +++ b/aio/content/marketing/news.html @@ -1,4 +1,5 @@ -

News

+
+

News

diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index c58d53dcf2..67c0cfabac 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -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 = `

${title} Doc

Some heading

`; - data = { id, title, contents }; - if (id === 'no-title') { - data.title = ''; - } + const h1 = (id === 'no-title') ? '' : `

${title}

`; + const contents = `${h1}

Some heading

`; + data = { id, contents }; } return of({ json: () => data }); } diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 886177ae28..36a0ec0dff 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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('/', '-'); diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 707712daea..a846a7f966 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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 `` 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] }) diff --git a/aio/src/app/documents/document-contents.ts b/aio/src/app/documents/document-contents.ts index a4ac90e008..a625759f0f 100644 --- a/aio/src/app/documents/document-contents.ts +++ b/aio/src/app/documents/document-contents.ts @@ -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; } diff --git a/aio/src/app/documents/document.service.spec.ts b/aio/src/app/documents/document.service.spec.ts index 8028ac22c7..4663a01b3e 100644 --- a/aio/src/app/documents/document.service.spec.ts +++ b/aio/src/app/documents/document.service.spec.ts @@ -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: '

Page Not Found

' }; + const fileNotFoundDoc = { id: FILE_NOT_FOUND_ID, contents: '

Page Not Found

' }; 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'); diff --git a/aio/src/app/documents/document.service.ts b/aio/src/app/documents/document.service.ts index b4e520a8ef..34e81cd151 100644 --- a/aio/src/app/documents/document.service.ts +++ b/aio/src/app/documents/document.service.ts @@ -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 = `
error_outline
@@ -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 }); } } diff --git a/aio/src/app/embedded/code/code-example.component.ts b/aio/src/app/embedded/code/code-example.component.ts index baf9900c57..b84f240d65 100644 --- a/aio/src/app/embedded/code/code-example.component.ts +++ b/aio/src/app/embedded/code/code-example.component.ts @@ -19,7 +19,7 @@ import { Component, ElementRef, OnInit } from '@angular/core'; ` }) -export class CodeExampleComponent implements OnInit { // implements AfterViewInit { +export class CodeExampleComponent implements OnInit { code: string; language: string; diff --git a/aio/src/app/embedded/doc-title.component.ts b/aio/src/app/embedded/doc-title.component.ts deleted file mode 100644 index 2417f995b8..0000000000 --- a/aio/src/app/embedded/doc-title.component.ts +++ /dev/null @@ -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: '

{{title | async}}

' -}) -export class DocTitleComponent { - title: Observable; - constructor(docs: DocumentService) { - this.title = docs.currentDocument.map(doc => doc.title); - } -} diff --git a/aio/src/app/embedded/embedded.module.ts b/aio/src/app/embedded/embedded.module.ts index 561930f916..184ed5ea78 100644 --- a/aio/src/app/embedded/embedded.module.ts +++ b/aio/src/app/embedded/embedded.module.ts @@ -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 */ diff --git a/aio/src/app/embedded/resource/resource-list.component.html b/aio/src/app/embedded/resource/resource-list.component.html index 7fbca8425c..5e01b2e1af 100644 --- a/aio/src/app/embedded/resource/resource-list.component.html +++ b/aio/src/app/embedded/resource/resource-list.component.html @@ -26,17 +26,4 @@
- - - - - - diff --git a/aio/src/app/embedded/toc/toc.component.html b/aio/src/app/embedded/toc/toc.component.html new file mode 100644 index 0000000000..4255257827 --- /dev/null +++ b/aio/src/app/embedded/toc/toc.component.html @@ -0,0 +1,24 @@ +
+
Contents
+
+ Contents + +
+ +
    +
  • + +
  • +
+ + +
diff --git a/aio/src/app/embedded/toc/toc.component.spec.ts b/aio/src/app/embedded/toc/toc.component.spec.ts new file mode 100644 index 0000000000..f82864c07b --- /dev/null +++ b/aio/src/app/embedded/toc/toc.component.spec.ts @@ -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; + + 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; + + 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: '' +}) +class HostEmbeddedTocComponent {} + +@Component({ + selector: 'aio-not-embedded-host', + template: '' +}) +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 Three", + "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": "H2 four", + "href": "fizz/buzz#h2-four", + "level": "h2", + "title": "H2 4" + } + ]; +} diff --git a/aio/src/app/embedded/toc/toc.component.ts b/aio/src/app/embedded/toc/toc.component.ts new file mode 100644 index 0000000000..5785136181 --- /dev/null +++ b/aio/src/app/embedded/toc/toc.component.ts @@ -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; + } +} diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index 4e8bf3031f..43cd6a0172 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -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; + 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 = '

Howdy, doc viewer

'; - 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 = '

Howdy, doc viewer

'; - 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', () => {

Below Foo

`; - 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', () => {

Below Foo

`; - 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', () => {

Below Bar

`; - 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', () => { ###bar content###

Below Bar

`; - 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', () => {

Bottom

`; - 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', () => {

Bottom

`; - 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', () => {

Bottom

`; - 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', () => {

---More baz--

Bottom

`; - 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

', () => { + setCurrentDoc('Some content'); + fixture.detectChanges(); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + }); + + it('should set the expected title when has

', () => { + setCurrentDoc('

Features

Some content'); + fixture.detectChanges(); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + }); + + it('should set the expected title with a no-toc

', () => { + setCurrentDoc('

Features

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

title', () => { + beforeEach(() => { + setCurrentDoc('Some content'); + fixture.detectChanges(); + }); + + it('should not have an ', () => { + 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 with a no-toc

', () => { + setCurrentDoc('

Features

Some content'); + fixture.detectChanges(); + expect(getAioToc()).toBeFalsy(); + }); + + describe('when has an

(title)', () => { + beforeEach(() => { + setCurrentDoc('

Features

Some content'); + fixture.detectChanges(); + }); + + it('should add ', () => { + expect(getAioToc()).toBeTruthy(); + }); + + it('should have with "embedded" class', () => { + expect(getAioToc().classList.contains('embedded')).toEqual(true); + }); + + it('should call Toc Service genToc()', () => { + expect(tocService.genToc).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index 14a8ae53e6..504f3c3648 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -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

title + // If you don't want a TOC, don't have an

+ if (titleEl) { + title = titleEl.innerText.trim(); + if (!/(no-toc|notoc)/i.test(titleEl.className)) { + this.tocService.genToc(this.hostElement, docId); + titleEl.insertAdjacentHTML('afterend', ''); + } + } + 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() { diff --git a/aio/src/app/shared/toc.service.spec.ts b/aio/src/app/shared/toc.service.spec.ts new file mode 100644 index 0000000000..780243f6df --- /dev/null +++ b/aio/src/app/shared/toc.service.spec.ts @@ -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('

This

and

that

'); + expect(tocService.tocList.length).toEqual(0); + }); + + it('when given doc element w/ headings other than h2 & h3', () => { + callGenToc('

This

and

that
'); + 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(` +

one

some one

+

two

some two

+

three

some three

+

four

some four

+ `); + expect(tocService.tocList.length).toEqual(0); + }); + }); + + describe('when given many headings', () => { + let docId: string; + let docEl: HTMLDivElement; + let tocList: TocItem[]; + let headings: NodeListOf; + + beforeEach(() => { + docId = 'fizz/buzz'; + + docEl = callGenToc(` +

Fun with TOC

+ +

Heading one

+

h2 toc 0

+ +

H2 Two

+

h2 toc 1

+ +

H2 Three

+

h2 toc 2

+

H3 3a

h3 toc 3

+

H3 3b

h3 toc 4

+ + +

H4 of h3-3b

an h4

+ +

H2 4 repeat

+

h2 toc 5

+ +

H2 4 repeat

+

h2 toc 6

+ +

Skippy

+

Skip this header

+ +

H2 6

+

h2 toc 7

+

H3 6a

h3 toc 8

+ `, docId); + + tocList = tocService.tocList; + headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf; + }); + + 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

', () => { + const tocItem = tocList[0]; + expect(tocItem.level).toEqual('h2'); + }); + + it('should have level "h3" for an

', () => { + 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((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 locally.'; + + // An almost-actual

... with extra whitespace + docEl = callGenToc(` +

+ + ${expectedTocContent} +

+ `, 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((tocItem.content) + .changingThisBreaksApplicationSecurity) + .toEqual('Setup to develop locally.'); + }); + + 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; + }); +} diff --git a/aio/src/app/shared/toc.service.ts b/aio/src/app/shared/toc.service.ts new file mode 100644 index 0000000000..b429ea9471 --- /dev/null +++ b/aio/src/app/shared/toc.service.ts @@ -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(); + + 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) { + 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}`; + } + } +} diff --git a/aio/src/styles/2-modules/_modules-dir.scss b/aio/src/styles/2-modules/_modules-dir.scss index b2fd9a382a..bde8be7ece 100644 --- a/aio/src/styles/2-modules/_modules-dir.scss +++ b/aio/src/styles/2-modules/_modules-dir.scss @@ -25,4 +25,5 @@ @import 'resources'; @import 'edit-page-cta'; @import 'heading-anchors'; - @import 'api-info-bar'; \ No newline at end of file + @import 'api-info-bar'; + @import 'toc'; diff --git a/aio/src/styles/2-modules/_toc.scss b/aio/src/styles/2-modules/_toc.scss new file mode 100644 index 0000000000..4a091bf6dd --- /dev/null +++ b/aio/src/styles/2-modules/_toc.scss @@ -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; + } +}