aio: add h1 title to floating table of contents (#16959)
* refactor(aio): use explicit CSS class for TOC container This makes the styling less fragile to changes in the HTML * fix(aio): schedule TocComponent.activeIndex updates via AsapScheduler We use the `asap` scheduler because updates to `activeItemIndex` are triggered by DOM changes, which, in turn, are caused by the rendering that happened due to a ChangeDetection. Without asap, we would be updating the model while still in a ChangeDetection handler, which is disallowed by Angular. * refactor(aio): do not instantiate floating ToC if not displayed * feat(aio): display the h1 at the top of the floating TOC Closes #16900 * refactor(aio): combine the TOC booleans flags into a "type" state * refactor(aio): remove unnecessary `hostElement` property * fix(aio): ensure that transition works on TOC * fix(aio): use strict equality in ToC template
This commit is contained in:
parent
b0c5018c70
commit
966eb2fbd0
|
@ -35,9 +35,7 @@
|
|||
|
||||
</md-sidenav-container>
|
||||
|
||||
|
||||
|
||||
<div class="toc-container" [style.max-height.px]="tocMaxHeight">
|
||||
<div *ngIf="showFloatingToc" class="toc-container" [style.max-height.px]="tocMaxHeight">
|
||||
<aio-toc></aio-toc>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -75,6 +75,13 @@ describe('AppComponent', () => {
|
|||
component.onResize(500);
|
||||
expect(component.isSideBySide).toBe(false);
|
||||
});
|
||||
|
||||
it('should update `showFloatingToc` accordingly', () => {
|
||||
component.onResize(801);
|
||||
expect(component.showFloatingToc).toBe(true);
|
||||
component.onResize(800);
|
||||
expect(component.showFloatingToc).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onScroll', () => {
|
||||
|
|
|
@ -65,8 +65,12 @@ export class AppComponent implements OnInit {
|
|||
sideNavNodes: NavigationNode[];
|
||||
topMenuNodes: NavigationNode[];
|
||||
topMenuNarrowNodes: NavigationNode[];
|
||||
|
||||
showFloatingToc = false;
|
||||
showFloatingTocWidth = 800;
|
||||
tocMaxHeight: string;
|
||||
private tocMaxHeightOffset = 0;
|
||||
|
||||
versionInfo: VersionInfo;
|
||||
|
||||
get homeImageUrl() {
|
||||
|
@ -198,6 +202,7 @@ export class AppComponent implements OnInit {
|
|||
@HostListener('window:resize', ['$event.target.innerWidth'])
|
||||
onResize(width) {
|
||||
this.isSideBySide = width > this.sideBySideWidth;
|
||||
this.showFloatingToc = width > this.showFloatingTocWidth;
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<div *ngIf="hasToc" [class.collapsed]="isCollapsed">
|
||||
<button *ngIf="!isEmbedded" type="button" class="toc-heading"
|
||||
(click)="toTop()" title="Top of page">Contents</button>
|
||||
<div *ngIf="type !== 'None'" class="toc-inner" [class.collapsed]="isCollapsed">
|
||||
|
||||
<div *ngIf="!hasSecondary && isEmbedded" class="toc-heading embedded">Contents</div>
|
||||
<div *ngIf="type === 'EmbeddedSimple'" class="toc-heading embedded">
|
||||
Contents
|
||||
</div>
|
||||
|
||||
<button *ngIf="hasSecondary" type="button" class="toc-heading embedded secondary"
|
||||
(click)="toggle(false)"
|
||||
<button *ngIf="type === 'EmbeddedExpandable'" type="button" (click)="toggle(false)"
|
||||
class="toc-heading embedded secondary"
|
||||
title="Expand/collapse contents"
|
||||
aria-label="Expand/collapse contents"
|
||||
[attr.aria-pressed]="!isCollapsed">
|
||||
|
@ -14,13 +14,15 @@
|
|||
</button>
|
||||
|
||||
<ul class="toc-list">
|
||||
<li #tocItem *ngFor="let toc of tocList; let i = index" title="{{toc.title}}"
|
||||
class="{{toc.level}}" [class.secondary]="toc.isSecondary" [class.active]="i === activeIndex">
|
||||
<a [href]="toc.href" [innerHTML]="toc.content"></a>
|
||||
</li>
|
||||
<ng-container *ngFor="let toc of tocList; let i = index">
|
||||
<li #tocItem title="{{toc.title}}" *ngIf="type === 'Floating' || toc.level !== 'h1'"
|
||||
class="{{toc.level}}" [class.secondary]="type === 'EmbeddedExpandable' && i >= primaryMax" [class.active]="i === activeIndex">
|
||||
<a [href]="toc.href" [innerHTML]="toc.content"></a>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ul>
|
||||
|
||||
<button type="button" (click)="toggle()" *ngIf="hasSecondary"
|
||||
<button *ngIf="type === 'EmbeddedExpandable'" type="button" (click)="toggle()"
|
||||
class="toc-more-items embedded material-icons" [class.collapsed]="isCollapsed"
|
||||
title="Expand/collapse contents"
|
||||
aria-label="Expand/collapse contents"
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By, DOCUMENT } from '@angular/platform-browser';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { asap } from 'rxjs/scheduler/asap';
|
||||
|
||||
import { ScrollService } from 'app/shared/scroll.service';
|
||||
import { TocComponent } from './toc.component';
|
||||
|
@ -16,7 +17,7 @@ describe('TocComponent', () => {
|
|||
listItems: DebugElement[];
|
||||
tocHeading: DebugElement;
|
||||
tocHeadingButtonEmbedded: DebugElement;
|
||||
tocHeadingButtonSide: DebugElement;
|
||||
tocH1Heading: DebugElement;
|
||||
tocMoreButton: DebugElement;
|
||||
};
|
||||
|
||||
|
@ -25,7 +26,7 @@ describe('TocComponent', () => {
|
|||
listItems: tocComponentDe.queryAll(By.css('ul.toc-list>li')),
|
||||
tocHeading: tocComponentDe.query(By.css('.toc-heading')),
|
||||
tocHeadingButtonEmbedded: tocComponentDe.query(By.css('button.toc-heading.embedded')),
|
||||
tocHeadingButtonSide: tocComponentDe.query(By.css('button.toc-heading:not(.embedded)')),
|
||||
tocH1Heading: tocComponentDe.query(By.css('.h1')),
|
||||
tocMoreButton: tocComponentDe.query(By.css('button.toc-more-items')),
|
||||
};
|
||||
}
|
||||
|
@ -60,40 +61,51 @@ describe('TocComponent', () => {
|
|||
});
|
||||
|
||||
it('should not display a ToC initially', () => {
|
||||
expect(tocComponent.hasToc).toBe(false);
|
||||
expect(tocComponent.type).toEqual('None');
|
||||
});
|
||||
|
||||
it('should not display anything when no TocItems', () => {
|
||||
tocService.tocList.next([]);
|
||||
it('should not display anything when no h2 or h3 TocItems', () => {
|
||||
tocService.tocList.next([tocItem('H1', 'h1')]);
|
||||
fixture.detectChanges();
|
||||
expect(tocComponentDe.children.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should update when the TocItems are updated', () => {
|
||||
tocService.tocList.next([{}] as TocItem[]);
|
||||
tocService.tocList.next([tocItem('Heading A')]);
|
||||
fixture.detectChanges();
|
||||
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||
|
||||
tocService.tocList.next([{}, {}, {}] as TocItem[]);
|
||||
tocService.tocList.next([tocItem('Heading A'), tocItem('Heading B'), tocItem('Heading C')]);
|
||||
fixture.detectChanges();
|
||||
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(3);
|
||||
});
|
||||
|
||||
it('should only display H2 and H3 TocItems', () => {
|
||||
tocService.tocList.next([tocItem('Heading A', 'h1'), tocItem('Heading B'), tocItem('Heading C', 'h3')]);
|
||||
fixture.detectChanges();
|
||||
const items = tocComponentDe.queryAllNodes(By.css('li'));
|
||||
expect(items.length).toBe(2);
|
||||
expect(items.find(item => item.nativeNode.innerText === 'Heading A')).toBeFalsy();
|
||||
expect(items.find(item => item.nativeNode.innerText === 'Heading B')).toBeTruthy();
|
||||
expect(items.find(item => item.nativeNode.innerText === 'Heading C')).toBeTruthy();
|
||||
expect(setPage().tocH1Heading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should stop listening for TocItems once destroyed', () => {
|
||||
tocService.tocList.next([{}] as TocItem[]);
|
||||
tocService.tocList.next([tocItem('Heading A')]);
|
||||
fixture.detectChanges();
|
||||
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||
|
||||
tocComponent.ngOnDestroy();
|
||||
tocService.tocList.next([{}, {}, {}] as TocItem[]);
|
||||
tocService.tocList.next([tocItem('Heading A', 'h1'), tocItem('Heading B'), tocItem('Heading C')]);
|
||||
fixture.detectChanges();
|
||||
expect(tocComponentDe.queryAllNodes(By.css('li')).length).toBe(1);
|
||||
});
|
||||
|
||||
describe('when four TocItems', () => {
|
||||
describe('when fewer than `maxPrimary` TocItems', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tocService.tocList.next([{}, {}, {}, {}] as TocItem[]);
|
||||
tocService.tocList.next([tocItem('Heading A'), tocItem('Heading B'), tocItem('Heading C'), tocItem('Heading D')]);
|
||||
fixture.detectChanges();
|
||||
page = setPage();
|
||||
});
|
||||
|
@ -103,7 +115,7 @@ describe('TocComponent', () => {
|
|||
});
|
||||
|
||||
it('should not have secondary items', () => {
|
||||
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
|
||||
expect(tocComponent.type).toEqual('EmbeddedSimple');
|
||||
const aSecond = page.listItems.find(item => item.classes.secondary);
|
||||
expect(aSecond).toBeFalsy('should not find a secondary');
|
||||
});
|
||||
|
@ -128,7 +140,10 @@ describe('TocComponent', () => {
|
|||
tocService.tocList.subscribe(v => tocList = v);
|
||||
|
||||
expect(page.listItems.length).toBeGreaterThan(4);
|
||||
expect(page.listItems.length).toEqual(tocList.length);
|
||||
});
|
||||
|
||||
it('should not display the h1 item', () => {
|
||||
expect(page.listItems.find(item => item.classes.h1)).toBeFalsy('should not find h1 item');
|
||||
});
|
||||
|
||||
it('should be in "collapsed" (not expanded) state at the start', () => {
|
||||
|
@ -145,14 +160,13 @@ describe('TocComponent', () => {
|
|||
});
|
||||
|
||||
it('should have secondary items', () => {
|
||||
expect(tocComponent.hasSecondary).toEqual(true, 'hasSecondary flag');
|
||||
expect(tocComponent.type).toEqual('EmbeddedExpandable');
|
||||
});
|
||||
|
||||
// CSS should hide items with the secondary class when collapsed
|
||||
// CSS will hide items with the secondary class when collapsed
|
||||
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 tocHeading button', () => {
|
||||
|
@ -245,17 +259,15 @@ describe('TocComponent', () => {
|
|||
|
||||
it('should not be in embedded state', () => {
|
||||
expect(tocComponent.isEmbedded).toEqual(false);
|
||||
expect(tocComponent.type).toEqual('Floating');
|
||||
});
|
||||
|
||||
it('should display all items', () => {
|
||||
let tocList: TocItem[];
|
||||
tocService.tocList.subscribe(v => tocList = v);
|
||||
|
||||
expect(page.listItems.length).toEqual(tocList.length);
|
||||
it('should display all items (including h1s)', () => {
|
||||
expect(page.listItems.length).toEqual(getTestTocList().length);
|
||||
});
|
||||
|
||||
it('should not have secondary items', () => {
|
||||
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
|
||||
expect(tocComponent.type).toEqual('Floating');
|
||||
const aSecond = page.listItems.find(item => item.classes.secondary);
|
||||
expect(aSecond).toBeFalsy('should not find a secondary');
|
||||
});
|
||||
|
@ -265,37 +277,31 @@ describe('TocComponent', () => {
|
|||
expect(page.tocMoreButton).toBeFalsy('bottom more button');
|
||||
});
|
||||
|
||||
it('should display "Contents" button', () => {
|
||||
expect(page.tocHeadingButtonSide).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should scroll to top when "Contents" button clicked', () => {
|
||||
page.tocHeadingButtonSide.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
expect(scrollToTopSpy).toHaveBeenCalled();
|
||||
it('should display H1 title', () => {
|
||||
expect(page.tocH1Heading).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('#activeIndex', () => {
|
||||
it('should keep track of `TocService`\'s `activeItemIndex`', () => {
|
||||
expect(tocComponent.activeIndex).toBeNull();
|
||||
|
||||
tocService.activeItemIndex.next(42);
|
||||
tocService.setActiveIndex(42);
|
||||
expect(tocComponent.activeIndex).toBe(42);
|
||||
|
||||
tocService.activeItemIndex.next(null);
|
||||
tocService.setActiveIndex(null);
|
||||
expect(tocComponent.activeIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('should stop tracking `activeItemIndex` once destroyed', () => {
|
||||
tocService.activeItemIndex.next(42);
|
||||
tocService.setActiveIndex(42);
|
||||
expect(tocComponent.activeIndex).toBe(42);
|
||||
|
||||
tocComponent.ngOnDestroy();
|
||||
|
||||
tocService.activeItemIndex.next(43);
|
||||
tocService.setActiveIndex(43);
|
||||
expect(tocComponent.activeIndex).toBe(42);
|
||||
|
||||
tocService.activeItemIndex.next(null);
|
||||
tocService.setActiveIndex(null);
|
||||
expect(tocComponent.activeIndex).toBe(42);
|
||||
});
|
||||
|
||||
|
@ -334,19 +340,19 @@ describe('TocComponent', () => {
|
|||
|
||||
tocComponent.activeIndex = 1;
|
||||
fixture.detectChanges();
|
||||
expect(getActiveTextContent()).toBe('H2 Two');
|
||||
expect(getActiveTextContent()).toBe('Heading one');
|
||||
|
||||
tocComponent.tocList = [{content: 'New 1'}, {content: 'New 2'}] as any as TocItem[];
|
||||
tocComponent.tocList = [tocItem('New 1'), tocItem('New 2')];
|
||||
fixture.detectChanges();
|
||||
page = setPage();
|
||||
expect(getActiveTextContent()).toBe('New 2');
|
||||
|
||||
tocComponent.tocList.unshift({content: 'New 0'} as any as TocItem);
|
||||
tocComponent.tocList.unshift(tocItem('New 0'));
|
||||
fixture.detectChanges();
|
||||
page = setPage();
|
||||
expect(getActiveTextContent()).toBe('New 1');
|
||||
|
||||
tocComponent.tocList = [{content: 'Very New 1'}] as any as TocItem[];
|
||||
tocComponent.tocList = [tocItem('Very New 1')];
|
||||
fixture.detectChanges();
|
||||
page = setPage();
|
||||
expect(page.listItems.findIndex(By.css('.active'))).toBe(-1);
|
||||
|
@ -373,17 +379,17 @@ describe('TocComponent', () => {
|
|||
});
|
||||
|
||||
it('when the `activeIndex` changes', () => {
|
||||
tocService.activeItemIndex.next(0);
|
||||
tocService.setActiveIndex(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentScrollTop).toBe(0);
|
||||
|
||||
tocService.activeItemIndex.next(1);
|
||||
tocService.setActiveIndex(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentScrollTop).toBe(0);
|
||||
|
||||
tocService.activeItemIndex.next(page.listItems.length - 1);
|
||||
tocService.setActiveIndex(page.listItems.length - 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentScrollTop).toBeGreaterThan(0);
|
||||
|
@ -397,7 +403,7 @@ describe('TocComponent', () => {
|
|||
|
||||
expect(parentScrollTop).toBe(0);
|
||||
|
||||
tocService.activeItemIndex.next(tocList.length - 1);
|
||||
tocService.setActiveIndex(tocList.length - 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentScrollTop).toBe(0);
|
||||
|
@ -412,7 +418,7 @@ describe('TocComponent', () => {
|
|||
const tocList = tocComponent.tocList;
|
||||
tocComponent.ngOnDestroy();
|
||||
|
||||
tocService.activeItemIndex.next(page.listItems.length - 1);
|
||||
tocService.setActiveIndex(page.listItems.length - 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(parentScrollTop).toBe(0);
|
||||
|
@ -453,46 +459,26 @@ class TestScrollService {
|
|||
class TestTocService {
|
||||
tocList = new BehaviorSubject<TocItem[]>(getTestTocList());
|
||||
activeItemIndex = new BehaviorSubject<number | null>(null);
|
||||
setActiveIndex(index) {
|
||||
this.activeItemIndex.next(index);
|
||||
if (asap.scheduled) {
|
||||
asap.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tocItem(title: string, level = 'h2', href = '', content = title) {
|
||||
return { title, href, level, content };
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
tocItem('Title', 'h1', 'fizz/buzz#title', 'Title'),
|
||||
tocItem('Heading one', 'h2', 'fizz/buzz#heading-one-special-id', 'Heading one'),
|
||||
tocItem('H2 Two', 'h2', 'fizz/buzz#h2-two', 'H2 Two'),
|
||||
tocItem('H2 Three', 'h2', 'fizz/buzz#h2-three', 'H2 <b>Three</b>'),
|
||||
tocItem('H3 3a', 'h3', 'fizz/buzz#h3-3a', 'H3 3a'),
|
||||
tocItem('H3 3b', 'h3', 'fizz/buzz#h3-3b', 'H3 3b'),
|
||||
tocItem('H2 4', 'h2', 'fizz/buzz#h2-four', '<i>H2 <b>four</b></i>'),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { asap } from 'rxjs/scheduler/asap';
|
||||
import 'rxjs/add/observable/combineLatest';
|
||||
import 'rxjs/add/operator/subscribeOn';
|
||||
import 'rxjs/add/operator/takeUntil';
|
||||
|
||||
import { ScrollService } from 'app/shared/scroll.service';
|
||||
import { TocItem, TocService } from 'app/shared/toc.service';
|
||||
|
||||
type TocType = 'None' | 'Floating' | 'EmbeddedSimple' | 'EmbeddedExpandable';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-toc',
|
||||
templateUrl: 'toc.component.html',
|
||||
|
@ -15,9 +19,7 @@ import { TocItem, TocService } from 'app/shared/toc.service';
|
|||
export class TocComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
activeIndex: number | null = null;
|
||||
hasSecondary = false;
|
||||
hasToc = false;
|
||||
hostElement: HTMLElement;
|
||||
type: TocType = 'None';
|
||||
isCollapsed = true;
|
||||
isEmbedded = false;
|
||||
@ViewChildren('tocItem') private items: QueryList<ElementRef>;
|
||||
|
@ -29,37 +31,35 @@ export class TocComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
private scrollService: ScrollService,
|
||||
elementRef: ElementRef,
|
||||
private tocService: TocService) {
|
||||
this.hostElement = elementRef.nativeElement;
|
||||
this.isEmbedded = this.hostElement.className.indexOf('embedded') !== -1;
|
||||
this.isEmbedded = elementRef.nativeElement.className.indexOf('embedded') !== -1;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.tocService.tocList
|
||||
.takeUntil(this.onDestroy)
|
||||
.subscribe(tocList => {
|
||||
const count = tocList.length;
|
||||
|
||||
this.hasToc = count > 0;
|
||||
this.hasSecondary = this.isEmbedded && this.hasToc && (count > this.primaryMax);
|
||||
this.tocList = tocList;
|
||||
const itemCount = count(this.tocList, item => item.level !== 'h1');
|
||||
|
||||
if (this.hasSecondary) {
|
||||
for (let i = this.primaryMax; i < count; i++) {
|
||||
tocList[i].isSecondary = true;
|
||||
}
|
||||
}
|
||||
this.type = (itemCount > 0) ?
|
||||
this.isEmbedded ?
|
||||
(itemCount > this.primaryMax) ?
|
||||
'EmbeddedExpandable' :
|
||||
'EmbeddedSimple' :
|
||||
'Floating' :
|
||||
'None';
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
if (!this.isEmbedded) {
|
||||
this.tocService.activeItemIndex
|
||||
.takeUntil(this.onDestroy)
|
||||
.subscribe(index => this.activeIndex = index);
|
||||
|
||||
Observable.combineLatest(this.tocService.activeItemIndex, this.items.changes.startWith(this.items))
|
||||
// We use the `asap` scheduler because updates to `activeItemIndex` are triggered by DOM changes,
|
||||
// which, in turn, are caused by the rendering that happened due to a ChangeDetection.
|
||||
// Without asap, we would be updating the model while still in a ChangeDetection handler, which is disallowed by Angular.
|
||||
Observable.combineLatest(this.tocService.activeItemIndex.subscribeOn(asap), this.items.changes.startWith(this.items))
|
||||
.takeUntil(this.onDestroy)
|
||||
.subscribe(([index, items]) => {
|
||||
this.activeIndex = index;
|
||||
if (index === null || index >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
@ -92,3 +92,7 @@ export class TocComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.scrollService.scrollToTop();
|
||||
}
|
||||
}
|
||||
|
||||
function count<T>(array: T[], fn: (T) => boolean) {
|
||||
return array.reduce((count, item) => fn(item) ? count + 1 : count, 0);
|
||||
}
|
||||
|
|
|
@ -37,28 +37,28 @@ describe('TocService', () => {
|
|||
|
||||
describe('tocList', () => {
|
||||
it('should emit the latest value to new subscribers', () => {
|
||||
const expectedValue1 = {} as TocItem;
|
||||
const expectedValue2 = {} as TocItem;
|
||||
const expectedValue1 = tocItem('Heading A');
|
||||
const expectedValue2 = tocItem('Heading B');
|
||||
let value1: TocItem[];
|
||||
let value2: TocItem[];
|
||||
|
||||
tocService.tocList.next([] as TocItem[]);
|
||||
tocService.tocList.next([]);
|
||||
tocService.tocList.subscribe(v => value1 = v);
|
||||
expect(value1).toEqual([]);
|
||||
|
||||
tocService.tocList.next([expectedValue1, expectedValue2] as TocItem[]);
|
||||
tocService.tocList.next([expectedValue1, expectedValue2]);
|
||||
tocService.tocList.subscribe(v => value2 = v);
|
||||
expect(value2).toEqual([expectedValue1, expectedValue2]);
|
||||
});
|
||||
|
||||
it('should emit the same values to all subscribers', () => {
|
||||
const expectedValue1 = {} as TocItem;
|
||||
const expectedValue2 = {} as TocItem;
|
||||
const expectedValue1 = tocItem('Heading A');
|
||||
const expectedValue2 = tocItem('Heading B');
|
||||
const emittedValues: TocItem[][] = [];
|
||||
|
||||
tocService.tocList.subscribe(v => emittedValues.push(v));
|
||||
tocService.tocList.subscribe(v => emittedValues.push(v));
|
||||
tocService.tocList.next([expectedValue1, expectedValue2] as TocItem[]);
|
||||
tocService.tocList.next([expectedValue1, expectedValue2]);
|
||||
|
||||
expect(emittedValues).toEqual([
|
||||
[expectedValue1, expectedValue2],
|
||||
|
@ -153,7 +153,9 @@ describe('TocService', () => {
|
|||
describe('should clear tocList', () => {
|
||||
beforeEach(() => {
|
||||
// Start w/ dummy data from previous usage
|
||||
tocService.tocList.next([{}, {}] as TocItem[]);
|
||||
const expectedValue1 = tocItem('Heading A');
|
||||
const expectedValue2 = tocItem('Heading B');
|
||||
tocService.tocList.next([expectedValue1, expectedValue2]);
|
||||
expect(lastTocList).not.toEqual([]);
|
||||
});
|
||||
|
||||
|
@ -172,8 +174,8 @@ describe('TocService', () => {
|
|||
expect(lastTocList).toEqual([]);
|
||||
});
|
||||
|
||||
it('when given doc element w/ headings other than h2 & h3', () => {
|
||||
callGenToc('<h1>This</h1><h4>and</h4><h5>that</h5>');
|
||||
it('when given doc element w/ headings other than h1, h2 & h3', () => {
|
||||
callGenToc('<h4>and</h4><h5>that</h5>');
|
||||
expect(lastTocList).toEqual([]);
|
||||
});
|
||||
|
||||
|
@ -232,60 +234,60 @@ describe('TocService', () => {
|
|||
});
|
||||
|
||||
it('should have tocList with expect number of TocItems', () => {
|
||||
// should ignore h1, h4, and the no-toc h2
|
||||
expect(lastTocList.length).toEqual(headings.length - 3);
|
||||
// should ignore h4, and the no-toc h2
|
||||
expect(lastTocList.length).toEqual(headings.length - 2);
|
||||
});
|
||||
|
||||
it('should have href with docId and heading\'s id', () => {
|
||||
const tocItem = lastTocList[0];
|
||||
const tocItem = lastTocList.find(item => item.title === 'Heading one');
|
||||
expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`);
|
||||
});
|
||||
|
||||
it('should have level "h2" for an <h2>', () => {
|
||||
const tocItem = lastTocList[0];
|
||||
const tocItem = lastTocList.find(item => item.title === 'Heading one');
|
||||
expect(tocItem.level).toEqual('h2');
|
||||
});
|
||||
|
||||
it('should have level "h3" for an <h3>', () => {
|
||||
const tocItem = lastTocList[3];
|
||||
const tocItem = lastTocList.find(item => item.title === 'H3 3a');
|
||||
expect(tocItem.level).toEqual('h3');
|
||||
});
|
||||
|
||||
it('should have title which is heading\'s innerText ', () => {
|
||||
const heading = headings[3];
|
||||
const tocItem = lastTocList[2];
|
||||
const tocItem = lastTocList[3];
|
||||
expect(heading.innerText).toEqual(tocItem.title);
|
||||
});
|
||||
|
||||
it('should have "SafeHtml" content which is heading\'s innerHTML ', () => {
|
||||
const heading = headings[3];
|
||||
const content = lastTocList[2].content;
|
||||
const content = lastTocList[3].content;
|
||||
expect((<TestSafeHtml>content).changingThisBreaksApplicationSecurity)
|
||||
.toEqual(heading.innerHTML);
|
||||
});
|
||||
|
||||
it('should calculate and set id of heading without an id', () => {
|
||||
const tocItem = lastTocList.find(item => item.title === 'H2 Two');
|
||||
const id = headings[2].getAttribute('id');
|
||||
expect(id).toEqual('h2-two');
|
||||
});
|
||||
|
||||
it('should have href with docId and calculated heading id', () => {
|
||||
const tocItem = lastTocList[1];
|
||||
const tocItem = lastTocList.find(item => item.title === 'H2 Two');
|
||||
expect(tocItem.href).toEqual(`${docId}#h2-two`);
|
||||
});
|
||||
|
||||
it('should ignore HTML in heading when calculating id', () => {
|
||||
const id = headings[3].getAttribute('id');
|
||||
const tocItem = lastTocList[2];
|
||||
const tocItem = lastTocList[3];
|
||||
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 = lastTocList[5];
|
||||
const tocItem4b = lastTocList[6];
|
||||
expect(tocItem4a.href).toEqual(`${docId}#h2-4-repeat`, 'first');
|
||||
expect(tocItem4b.href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
|
||||
const tocItems = lastTocList.filter(item => item.title === 'H2 4 repeat');
|
||||
expect(tocItems[0].href).toEqual(`${docId}#h2-4-repeat`, 'first');
|
||||
expect(tocItems[1].href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -363,3 +365,7 @@ class MockScrollSpyService {
|
|||
}
|
||||
}
|
||||
|
||||
function tocItem(title: string, level = 'h2', href = '', content = title) {
|
||||
return { title, href, level, content };
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Inject, Injectable } from '@angular/core';
|
||||
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
||||
|
||||
import { ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
|
||||
|
||||
|
||||
|
@ -66,7 +65,7 @@ export class TocService {
|
|||
}
|
||||
|
||||
private findTocHeadings(docElement: Element): HTMLHeadingElement[] {
|
||||
const headings = docElement.querySelectorAll('h2,h3');
|
||||
const headings = docElement.querySelectorAll('h1,h2,h3');
|
||||
const skipNoTocHeadings = (heading: HTMLHeadingElement) => !/(?:no-toc|notoc)/i.test(heading.className);
|
||||
|
||||
return Array.prototype.filter.call(headings, skipNoTocHeadings);
|
||||
|
|
|
@ -6,11 +6,6 @@
|
|||
bottom: 32px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
aio-toc {
|
||||
|
@ -19,16 +14,9 @@ aio-toc {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.embedded) {
|
||||
@media (max-width: 800px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
aio-toc > div {
|
||||
.toc-inner {
|
||||
border-left: 4px solid #4285f4;
|
||||
font-size: 13px;
|
||||
overflow-y: visible;
|
||||
|
@ -59,6 +47,7 @@ aio-toc > div {
|
|||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
|
||||
&:focus.embedded {
|
||||
outline: none;
|
||||
|
@ -67,7 +56,8 @@ aio-toc > div {
|
|||
}
|
||||
|
||||
button.toc-heading,
|
||||
div.toc-heading {
|
||||
div.toc-heading,
|
||||
ul.toc-list li.h1 {
|
||||
font-size: 115%;
|
||||
}
|
||||
|
||||
|
@ -131,10 +121,10 @@ aio-toc > div {
|
|||
margin: 6px 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: all 0.3 ease-in-out;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
a {
|
||||
font-size: 12px;
|
||||
font-size: inherit;
|
||||
color: lighten($darkgray, 10);
|
||||
display:table-cell;
|
||||
overflow: visible;
|
||||
|
@ -154,6 +144,17 @@ aio-toc > div {
|
|||
ul.toc-list li.h3 {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
ul.toc-list li.h1:after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 40%;
|
||||
margin: 24px 0px 10px;
|
||||
background: #DBDBDB;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
aio-toc.embedded > div.collapsed li.secondary {
|
||||
|
|
Loading…
Reference in New Issue