ember-link
Version:
Link primitive to pass around self-contained route references
424 lines (380 loc) • 12.6 kB
JavaScript
import { tracked } from '@glimmer/tracking';
import { addListener, removeListener } from '@ember/object/events';
import * as services from '@ember/service';
import services__default from '@ember/service';
import { macroCondition, isDevelopingApp, dependencySatisfies, importSync } from '@embroider/macros';
import { assert } from '@ember/debug';
import { action } from '@ember/object';
import { g, i, n } from 'decorator-transforms/runtime-esm';
function isQueryParams(maybeQueryParam) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return maybeQueryParam?.isQueryParams !== undefined && typeof maybeQueryParam.values === 'object';
}
function freezeParams(params) {
if (macroCondition(isDevelopingApp())) {
if (params.models) Object.freeze(params.models);
if (params.query) Object.freeze(params.query);
return Object.freeze(params);
}
return params;
}
const BEHAVIOR = Symbol('prevent');
const MAIN_BUTTON = 0;
function isUnmodifiedLeftClick(event) {
return event.button === MAIN_BUTTON && !event.ctrlKey && !event.metaKey;
}
function isMouseEvent(event) {
return typeof event === 'object' && event !== null && 'button' in event;
}
function isRegularOpening(event) {
return isMouseEvent(event) && !isUnmodifiedLeftClick(event);
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
function preventDefault(event) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof event?.preventDefault === 'function') {
event.preventDefault();
}
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
function prevent(event) {
if (isRegularOpening(event)) {
return true;
}
preventDefault(event);
return false;
}
const owner = macroCondition(dependencySatisfies('ember-source', '>=4.10')) ? importSync('@ember/owner') : importSync('@ember/application');
const {
getOwner,
setOwner
} = owner;
class Link {
static {
g(this.prototype, "_params", [tracked]);
}
#_params = (i(this, "_params"), void 0);
_linkManager;
constructor(linkManager, params) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setOwner(this, getOwner(linkManager));
this._linkManager = linkManager;
this._params = freezeParams(params);
}
get _routeArgs() {
const {
routeName,
models,
queryParams
} = this;
if (queryParams) {
return [routeName, ...models,
// Cloning `queryParams` is necessary, since we freeze it, but Ember
// wants to mutate it.
{
queryParams: {
...queryParams
}
}];
}
return [routeName, ...models];
}
get behavior() {
return {
...this._linkManager[BEHAVIOR],
...this._params.behavior
};
}
/**
* Whether this route is currently active, including potentially supplied
* models and query params.
*/
get isActive() {
if (!this._linkManager.isRouterInitialized) return false;
this._linkManager.currentTransitionStack; // eslint-disable-line @typescript-eslint/no-unused-expressions
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this._linkManager.router.isActive(...this._routeArgs);
}
/**
* Whether this route is currently active, including potentially supplied
* models, but ignoring query params.
*/
get isActiveWithoutQueryParams() {
if (!this._linkManager.isRouterInitialized) return false;
this._linkManager.currentTransitionStack; // eslint-disable-line @typescript-eslint/no-unused-expressions
return this._linkManager.router.isActive(this.routeName,
// Unfortunately TypeScript is not clever enough to support "rest"
// parameters in the middle.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...this.models);
}
/**
* Whether this route is currently active, but ignoring models and query
* params.
*/
get isActiveWithoutModels() {
if (!this._linkManager.isRouterInitialized) return false;
this._linkManager.currentTransitionStack; // eslint-disable-line @typescript-eslint/no-unused-expressions
return this._linkManager.router.isActive(this.routeName);
}
/**
* Whether this route is currently being transitioned into / entered.
*/
get isEntering() {
return this._isTransitioning('to');
}
/**
* Whether this route is currently being transitioned out of / exited.
*/
get isExiting() {
return this._isTransitioning('from');
}
/**
* Whether this link leaves Ember
*/
get isExternal() {
return this._params.isExternal ?? false;
}
/**
* The URL for this link that you can pass to an `<a>` tag as the `href`
* attribute.
*/
get url() {
if (this.isExternal) {
return this._params.route;
}
if (!this._linkManager.isRouterInitialized) return '';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this._linkManager.router.urlFor(...this._routeArgs);
}
/**
* Alias for `url`.
*
* Allows for more ergonomic composition as query parameters.
*
* ```hbs
* {{link "foo" query=(hash bar=(link "bar"))}}
* ```
*/
toString() {
return this.url;
}
/**
* The `RouteInfo` object for the target route.
*/
// get route(): RouteInfo {
// return this._linkManager.router.recognize(this.url);
// }
/**
* The target route name of this link.
*/
get routeName() {
return this._params.route;
}
/**
* The fully qualified target route name of this link.
*/
get qualifiedRouteName() {
// Ignore `Property 'recognize' does not exist on type 'RouterService'.`
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const routeInfo = this._linkManager.router.recognize(this.url);
return routeInfo.name;
}
/**
* The route models passed in this link.
*/
get models() {
return this._params.models ?? [];
}
/**
* The query params for this link, if specified.
*/
get queryParams() {
return this._params.query;
}
_isTransitioning(direction) {
return this._linkManager.currentTransitionStack?.some(transition => {
return transition[direction]?.name === this.qualifiedRouteName;
}) ?? false;
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
canOpen(event) {
if (this.isExternal) {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.behavior.prevent?.(event, this)) {
return false;
}
assert('You can only call `open`, when the router is initialized, e.g. when using `setupApplicationTest`.', this._linkManager.isRouterInitialized);
return true;
}
/**
* Transition into the target route.
*/
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
transitionTo(event) {
if (!this.canOpen(event)) {
return;
}
this._params.onTransitionTo?.();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this._linkManager.router.transitionTo(...this._routeArgs);
}
/**
* Transition into the target route while replacing the current URL, if
* possible.
*/
static {
n(this.prototype, "transitionTo", [action]);
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
replaceWith(event) {
if (!this.canOpen(event)) {
return;
}
this._params.onReplaceWith?.();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return this._linkManager.router.replaceWith(...this._routeArgs);
}
static {
n(this.prototype, "replaceWith", [action]);
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
open(event) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const method = this.behavior.open ?? 'transition';
if (method === 'replace') {
return this.replaceWith(event);
}
return this.transitionTo(event);
}
static {
n(this.prototype, "open", [action]);
}
}
// ember 3.28 has no exported `service` but `inject`
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-deprecated
const service = services.service ?? services.inject;
class LinkManagerService extends services__default {
static {
g(this.prototype, "internalCurrentTransitionStack", [tracked]);
}
#internalCurrentTransitionStack = (i(this, "internalCurrentTransitionStack"), void 0);
static {
g(this.prototype, "router", [service('router')]);
}
#router = (i(this, "router"), void 0);
/**
* The `RouterService` instance to be used by the generated `Link` instances.
*/
[BEHAVIOR] = {
open: 'transition',
prevent
};
/**
* Configure the default behavior of _all_ links.
*
* This can be overwritten at a particular link instance
*/
configureBehavior(behavior) {
this[BEHAVIOR] = {
...this[BEHAVIOR],
...behavior
};
}
/**
* Whether the router has been initialized.
* This will be `false` in render tests.
*
* @see https://github.com/buschtoens/ember-link/issues/126
*/
get isRouterInitialized() {
// Ideally we would use the public `router` service here, but accessing
// the `currentURL` on that service automatically starts the routing layer
// as a side-effect, which is not our intention here. Once or if Ember.js
// provides a flag on the `router` service to figure out if routing was
// already initialized we should switch back to the public service instead.
//
// Inspiration for this workaround was taken from the `currentURL()` test
// helper (see https://github.com/emberjs/ember-test-helpers/blob/v2.1.4/addon-test-support/@ember/test-helpers/setup-application-context.ts#L180)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line ember/no-private-routing-service
return Boolean(getOwner(this).lookup('router:main').currentURL);
}
/**
* The currently active `Transition` objects.
*/
get currentTransitionStack() {
return this.internalCurrentTransitionStack;
}
/**
* Creates a `Link` instance.
*/
createLink(linkParams) {
return new Link(this, linkParams);
}
/**
* Deserializes the `LinkParams` to be passed to `createLink` / `createUILink`
* from a URL.
*
* If the URL cannot be recognized by the router, an error is thrown.
*/
getLinkParamsFromURL(url) {
const routeInfo = this.router.recognize(url);
return LinkManagerService.getLinkParamsFromRouteInfo(routeInfo);
}
/**
* Converts a `RouteInfo` object into `LinkParams`.
*/
static getLinkParamsFromRouteInfo(routeInfo) {
const models = [];
let currentRoute = routeInfo;
do {
models.unshift(
// eslint-disable-next-line @typescript-eslint/no-loop-func
...currentRoute.paramNames.map(name => currentRoute.params?.[name]));
currentRoute = currentRoute.parent;
} while (currentRoute.name !== 'application');
return {
route: routeInfo.name,
query: routeInfo.queryParams,
models
};
}
constructor(owner) {
super(owner);
// Ignore `Argument of type '"routeWillChange"' is not assignable to parameter of type ...`
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addListener(this.router, 'routeWillChange', this.handleRouteWillChange);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addListener(this.router, 'routeDidChange', this.handleRouteDidChange);
}
willDestroy() {
super.willDestroy();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
removeListener(this.router, 'routeWillChange', this.handleRouteWillChange);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
removeListener(this.router, 'routeDidChange', this.handleRouteDidChange);
}
handleRouteWillChange = transition => {
this.internalCurrentTransitionStack = [...(this.internalCurrentTransitionStack ?? []), transition];
};
handleRouteDidChange = () => {
this.internalCurrentTransitionStack = undefined;
};
}
export { Link as L, LinkManagerService as a, isQueryParams as i, prevent as p };
//# sourceMappingURL=link-manager-DRbwO37N.js.map