-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
BREAKING CHANGE: initial release prep (actually implement the behavior)
- Loading branch information
1 parent
c498241
commit 86cad0c
Showing
16 changed files
with
740 additions
and
141 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,38 +1,63 @@ | ||
ember-url-hash-polyfill | ||
============================================================================== | ||
|
||
[Short description of the addon.] | ||
Navigating to URLs with `#hash-targets` in them is not supported by | ||
most single-page-app frameworks due to the async rendering nature of | ||
modern web apps -- the browser can't scroll to a `#hash-target` on | ||
page load / transition because the element hasn't rendered yet. | ||
There is an issue about this for Ember | ||
[here on the RFCs repo](https://github.com/emberjs/rfcs/issues/709). | ||
|
||
This addon provides a way to support the behavior that is in normally | ||
native to browsers where an anchor tag with `href="#some-id-or-name"` | ||
would scroll down the page when clicked. | ||
|
||
Compatibility | ||
------------------------------------------------------------------------------ | ||
## Installation | ||
|
||
* Ember.js v3.16 or above | ||
* Ember CLI v2.13 or above | ||
* Node.js v10 or above | ||
``` | ||
yarn add ember-url-hash-polyfill | ||
# or | ||
npm install ember-url-hash-polyfill | ||
# or | ||
ember install ember-url-hash-polyfill | ||
``` | ||
|
||
## Compatibility | ||
|
||
Installation | ||
------------------------------------------------------------------------------ | ||
* Ember.js v3.25 or above | ||
* Node.js v12 or above | ||
|
||
``` | ||
ember install ember-url-hash-polyfill | ||
## Usage | ||
|
||
To handle `/some-url/#hash-targets` on page load and after normal route transitions, | ||
```diff | ||
// app/router.js | ||
|
||
import { withHashSupport } from 'ember-url-hash-polyfill'; | ||
|
||
+ @withHashSupport | ||
export default class Router extends EmberRouter { | ||
location = config.locationType; | ||
rootURL = config.rootURL; | ||
} | ||
``` | ||
|
||
Additionally, there is a `scrollToHash` helper if manual invocation is desired. | ||
|
||
Usage | ||
------------------------------------------------------------------------------ | ||
```js | ||
import { scrollToHash } from 'ember-url-hash-polyfill'; | ||
|
||
[Longer description of how to use the addon in apps.] | ||
// ... | ||
|
||
scrollToHash('some-element-id-or-name'); | ||
``` | ||
|
||
|
||
Contributing | ||
------------------------------------------------------------------------------ | ||
## Contributing | ||
|
||
See the [Contributing](CONTRIBUTING.md) guide for details. | ||
|
||
|
||
License | ||
------------------------------------------------------------------------------ | ||
## License | ||
|
||
This project is licensed under the [MIT License](LICENSE.md). |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
import { getOwner } from '@ember/application'; | ||
import { warn } from '@ember/debug'; | ||
import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable'; | ||
import { schedule } from '@ember/runloop'; | ||
import { waitForPromise } from '@ember/test-waiters'; | ||
|
||
import type ApplicationInstance from '@ember/application/instance'; | ||
import type { Route } from '@ember/routing'; | ||
import type EmberRouter from '@ember/routing/router'; | ||
import type RouterService from '@ember/routing/router-service'; | ||
|
||
type Transition = Parameters<Route['beforeModel']>[0]; | ||
type TransitionWithPrivateAPIs = Transition & { | ||
intent: { | ||
url: string; | ||
}; | ||
}; | ||
|
||
export function withHashSupport(AppRouter: typeof EmberRouter) { | ||
return class RouterWithHashSupport extends AppRouter { | ||
constructor(...args: RouterArgs) { | ||
super(...args); | ||
|
||
setupHashSupport(this); | ||
} | ||
}; | ||
} | ||
|
||
export function scrollToHash(hash: string) { | ||
let selector = `[name="${hash}"]`; | ||
let element = document.getElementById(hash) || document.querySelector(selector); | ||
|
||
if (!element) { | ||
warn(`Tried to scroll to element with id or name "${hash}", but it was not found`, { | ||
id: 'no-hash-target', | ||
}); | ||
|
||
return; | ||
} | ||
|
||
/** | ||
* NOTE: the ember router does not support hashes in the URL | ||
* https://github.com/emberjs/rfcs/issues/709 | ||
* | ||
* this means that when testing hash changes in the URL, | ||
* we have to assert against the window.location, rather than | ||
* the self-container currentURL helper | ||
* | ||
* NOTE: other ways of changing the URL, but without the smoothness: | ||
* - window[.top].location.replace | ||
*/ | ||
|
||
element.scrollIntoView({ behavior: 'smooth' }); | ||
|
||
if (hash !== window.location.hash) { | ||
let withoutHash = location.href.split('#')[0]; | ||
let nextUrl = `${withoutHash}#${hash}`; | ||
// most browsers ignore the title param of pushState | ||
let titleWithoutHash = document.title.split(' | #')[0]; | ||
let nextTitle = `${titleWithoutHash} | #${hash}`; | ||
|
||
history.pushState({}, nextTitle, nextUrl); | ||
document.title = nextTitle; | ||
} | ||
} | ||
|
||
async function setupHashSupport(router: EmberRouter) { | ||
let initialURL: string | undefined; | ||
let owner = getOwner(router) as ApplicationInstance; | ||
|
||
await new Promise((resolve) => { | ||
let interval = setInterval(() => { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
let { currentURL } = router as any; /* Private API */ | ||
|
||
if (currentURL) { | ||
clearInterval(interval); | ||
initialURL = currentURL; | ||
resolve(null); | ||
} | ||
}, 100); | ||
}); | ||
|
||
if (isDestroyed(owner) || isDestroying(owner)) { | ||
return; | ||
} | ||
|
||
/** | ||
* This handles the initial Page Load, which is not imperceptible through | ||
* route{Did,Will}Change | ||
* | ||
*/ | ||
requestAnimationFrame(() => { | ||
eventuallyTryScrollingTo(owner, initialURL); | ||
}); | ||
|
||
let routerService = owner.lookup('service:router') as RouterService; | ||
|
||
function handleHashIntent(transition: TransitionWithPrivateAPIs) { | ||
let { url } = transition.intent; | ||
|
||
eventuallyTryScrollingTo(owner, url); | ||
} | ||
|
||
routerService.on('routeDidChange', handleHashIntent); | ||
|
||
registerDestructor(router, () => { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
(routerService as any) /* type def missing "off" */ | ||
.off('routeDidChange', handleHashIntent); | ||
}); | ||
} | ||
|
||
const CACHE = new WeakMap<ApplicationInstance, MutationObserver>(); | ||
|
||
async function eventuallyTryScrollingTo(owner: ApplicationInstance, url?: string) { | ||
// Prevent quick / rapid transitions from continuing to observer beyond their URL-scope | ||
CACHE.get(owner)?.disconnect(); | ||
|
||
if (!url) return; | ||
|
||
let [, hash] = url.split('#'); | ||
|
||
if (!hash) return; | ||
|
||
await waitForPromise(uiSettled(owner)); | ||
|
||
if (isDestroyed(owner) || isDestroying(owner)) { | ||
return; | ||
} | ||
|
||
scrollToHash(hash); | ||
} | ||
|
||
const TIME_SINCE_LAST_MUTATION = 500; // ms | ||
const MAX_TIMEOUT = 2000; // ms | ||
|
||
// exported for testing | ||
export async function uiSettled(owner: ApplicationInstance) { | ||
let timeStarted = new Date().getTime(); | ||
let lastMutationAt = Infinity; | ||
let totalTimeWaited = 0; | ||
|
||
let observer = new MutationObserver(() => { | ||
lastMutationAt = new Date().getTime(); | ||
}); | ||
|
||
CACHE.set(owner, observer); | ||
|
||
observer.observe(document.body, { childList: true, subtree: true }); | ||
|
||
/** | ||
* Wait for DOM mutations to stop until MAX_TIMEOUT | ||
*/ | ||
await new Promise((resolve) => { | ||
let frame: number; | ||
|
||
function requestTimeCheck() { | ||
if (frame) cancelAnimationFrame(frame); | ||
|
||
if (isDestroyed(owner) || isDestroying(owner)) { | ||
return; | ||
} | ||
|
||
frame = requestAnimationFrame(() => { | ||
totalTimeWaited = new Date().getTime() - timeStarted; | ||
|
||
let timeSinceLastMutation = new Date().getTime() - lastMutationAt; | ||
|
||
if (totalTimeWaited >= MAX_TIMEOUT) { | ||
return resolve(totalTimeWaited); | ||
} | ||
|
||
if (timeSinceLastMutation >= TIME_SINCE_LAST_MUTATION) { | ||
return resolve(totalTimeWaited); | ||
} | ||
|
||
schedule('afterRender', requestTimeCheck); | ||
}); | ||
} | ||
|
||
schedule('afterRender', requestTimeCheck); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Oops, something went wrong.