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"> | ||||
|     <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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user