Skip to content

Commit

Permalink
fix: anchor links not scrolling into view (angular#519)
Browse files Browse the repository at this point in the history
* Currently if someone visits a link that contains a fragment that refers to an anchor element, the element won't be scrolled into view properly. This is because the `focusOnNavigation` calls `focus()` after the scroll into view. This causes the focused element to be visible in view.
* Removes an unnecessary workaround for fixing fragment URLs.

Fixes angular#396
  • Loading branch information
devversion authored and jelbourn committed Sep 21, 2018
1 parent e9e73f0 commit 3b161b0
Show file tree
Hide file tree
Showing 8 changed files with 41 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<span class="cdk-visually-hidden" tabindex="-1" #intialFocusTarget>
<span class="cdk-visually-hidden" tabindex="-1" #initialFocusTarget>
API for {{componentViewer.componentDocItem.id}}
</span>

<doc-viewer
documentUrl="/assets/documents/api/{{componentViewer.componentDocItem.packageName}}-{{componentViewer.componentDocItem.id}}.html"
class="docs-component-view-text-content docs-component-api"
(contentLoaded)="onContentLoaded()"></doc-viewer>
documentUrl="/assets/documents/api/{{componentViewer.componentDocItem.packageName}}-{{componentViewer.componentDocItem.id}}.html"
class="docs-component-view-text-content docs-component-api"
(contentRendered)="scrollToSelectedContentSection()">
</doc-viewer>

<table-of-contents #toc
*ngIf="showToc | async"
headerSelectors=".docs-api-h3,.docs-api-h4"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<span class="cdk-visually-hidden" tabindex="-1" #intialFocusTarget>
<span class="cdk-visually-hidden" tabindex="-1" #initialFocusTarget>
Examples for {{componentViewer.componentDocItem.id}}
</span>
<example-viewer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<span class="cdk-visually-hidden" tabindex="-1" #intialFocusTarget>
<span class="cdk-visually-hidden" tabindex="-1" #initialFocusTarget>
Overview for {{componentViewer.componentDocItem.id}}
</span>
<doc-viewer
documentUrl="/assets/documents/overview/{{componentViewer.componentDocItem.packageName}}-{{componentViewer.componentDocItem.id}}.html"
class="docs-component-view-text-content docs-component-overview"
(contentLoaded)="onContentLoaded()">
(contentRendered)="scrollToSelectedContentSection()">
</doc-viewer>
<table-of-contents #toc container=".mat-drawer-content" *ngIf="showToc | async"></table-of-contents>
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class ComponentViewer implements OnDestroy {
encapsulation: ViewEncapsulation.None,
})
export class ComponentOverview implements OnInit {
@ViewChild('intialFocusTarget') focusTarget: ElementRef;
@ViewChild('initialFocusTarget') focusTarget: ElementRef;
@ViewChild('toc') tableOfContents: TableOfContents;
showToc: Observable<boolean>;

Expand All @@ -76,10 +76,10 @@ export class ComponentOverview implements OnInit {

ngOnInit() {
// 100ms timeout is used to allow the page to settle before moving focus for screen readers.
setTimeout(() => this.focusTarget.nativeElement.focus(), 100);
setTimeout(() => this.focusTarget.nativeElement.focus({preventScroll: true}), 100);
}

onContentLoaded() {
scrollToSelectedContentSection() {
if (this.tableOfContents) {
this.tableOfContents.updateScrollPosition();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ <h1>{{guide.name}}</h1>
<div class="docs-guide-wrapper">
<div class="docs-guide-toc-and-content">
<doc-viewer class="docs-guide-content"
(contentLoaded)="toc.updateScrollPosition()"
(contentRendered)="toc.updateScrollPosition()"
[documentUrl]="guide.document"></doc-viewer>
<table-of-contents #toc container="guide-viewer"></table-of-contents>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import {
EventEmitter,
Injector,
Input,
NgZone,
OnDestroy,
Output,
ViewContainerRef,
} from '@angular/core';
import {Router} from '@angular/router';
import {Subscription} from 'rxjs';
import {take} from 'rxjs/operators';
import {ExampleViewer} from '../example-viewer/example-viewer';
import {HeaderLink} from './header-link';

Expand All @@ -31,7 +32,7 @@ export class DocViewer implements OnDestroy {
this._fetchDocument(url);
}

@Output() contentLoaded = new EventEmitter<void>();
@Output() contentRendered = new EventEmitter<void>();

/** The document text. It should not be HTML encoded. */
textContent = '';
Expand All @@ -42,7 +43,8 @@ export class DocViewer implements OnDestroy {
private _http: HttpClient,
private _injector: Injector,
private _viewContainerRef: ViewContainerRef,
private _router: Router) {}
private _ngZone: NgZone) {
}

/** Fetch a document by URL. */
private _fetchDocument(url: string) {
Expand All @@ -66,8 +68,13 @@ export class DocViewer implements OnDestroy {
this.textContent = this._elementRef.nativeElement.textContent;
this._loadComponents('material-docs-example', ExampleViewer);
this._loadComponents('header-link', HeaderLink);
this._fixFragmentUrls();
this.contentLoaded.next();

// Resolving and creating components dynamically in Angular happens synchronously, but since
// we want to emit the output if the components are actually rendered completely, we wait
// until the Angular zone becomes stable.
this._ngZone.onStable
.pipe(take(1))
.subscribe(() => this.contentRendered.next());
}

/** Show an error that occurred when fetching a document. */
Expand All @@ -77,15 +84,6 @@ export class DocViewer implements OnDestroy {
`Failed to load document: ${url}. Error: ${error.statusText}`;
}

releadLiveExamples() {
// When the example viewer is dynamically loaded inside of md-tabs, they somehow end up in
// the wrong place in the DOM after switching tabs. This function is a workaround to
// put the live examples back in the right place.
this._clearLiveExamples();
this._loadComponents('material-docs-example', ExampleViewer);
this._loadComponents('header-link', HeaderLink);
}

/** Instantiate a ExampleViewer for each example. */
private _loadComponents(componentName: string, componentClass: any) {
let exampleElements =
Expand All @@ -108,25 +106,6 @@ export class DocViewer implements OnDestroy {
this._portalHosts = [];
}

/**
* A fragment link is a link that references a specific element on the page that should be
* scrolled into the viewport on page load or click.
*
* By default those links refer to the root page of the documentation and the fragment links
* won't work properly. Those links need to be updated to be relative to the current base URL.
*/
private _fixFragmentUrls() {
const baseUrl = this._router.url.split('#')[0];
const anchorElements =
[].slice.call(this._elementRef.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];

// Update hash links that are referring to the same page and host. Links that are referring
// to a different destination shouldn't be updated. For example the Google Fonts URL.
anchorElements
.filter(anchorEl => anchorEl.hash && anchorEl.host === location.host)
.forEach(anchorEl => anchorEl.href = `${baseUrl}${anchorEl.hash}`);
}

ngOnDestroy() {
this._clearLiveExamples();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, Input, OnInit} from '@angular/core';
import {Component, Input} from '@angular/core';
import {Router} from '@angular/router';

/**
Expand All @@ -19,27 +19,31 @@ import {Router} from '@angular/router';
template: `
<a
title="Link to this heading"
[attr.aria-describedby]="example"
class="docs-markdown-a"
aria-label="Link to this heading"
[href]="url">
class="docs-markdown-a"
[attr.aria-describedby]="example"
[href]="_getFragmentUrl()">
<mat-icon>link</mat-icon>
</a>
`
})
export class HeaderLink implements OnInit {
export class HeaderLink {

/**
* Id of the anchor element. Note that is uses "example" because we instantiate the
* header link components through the ComponentPortal.
*/
@Input() example: string;

url: string;
private _rootUrl: string;
/** Base URL that is used to build an absolute fragment URL. */
private _baseUrl: string;

constructor(router: Router) {
this._rootUrl = router.url.split('#')[0];
this._baseUrl = router.url.split('#')[0];
}

ngOnInit(): void {
this.url = `${this._rootUrl}#${this.example}`;
_getFragmentUrl(): string {
return `${this._baseUrl}#${this.example}`;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class NavigationFocus implements OnInit {
ngOnInit() {
clearTimeout(lastTimeoutId);
// 100ms timeout is used to allow the page to settle before moving focus for screen readers.
lastTimeoutId = setTimeout(() => this.el.nativeElement.focus(), 100);
lastTimeoutId = setTimeout(() => this.el.nativeElement.focus({preventScroll: true}), 100);
}
}

Expand Down

0 comments on commit 3b161b0

Please sign in to comment.