`
* is inert noise. `uiSrefActive`'s `isLinkElement` asks the neighbouring
* *role*-based question for `aria-current`, which `
`
* legitimately takes. The two overlap on `
`/`` and nowhere else — do
* not unify them.
*
* @internal
*/
export function isNativeLink(element: Element): boolean {
const tag = element.localName;
return tag === 'a' || tag === 'area';
}
/**
* Whether the click asked the browser for something other than a plain
* in-place navigation: a new tab/window, a download, or a non-primary button.
* @internal
*/
function isModifiedClick(event: MouseEvent): boolean {
const { button, ctrlKey, metaKey, shiftKey, altKey } = event;
return (
!isNumber(button) || !!button || ctrlKey || metaKey || shiftKey || altKey
);
}
/**
* Whether the element declares that its href leaves this browsing context: a
* `target` other than `_self`, or a `rel` token list containing `external`.
* @internal
*/
function opensOffApp(element: Element): boolean {
const target = element.getAttribute('target');
// browsing-context keywords are ASCII case-insensitive; a name we do not
// recognise is a frame, which is equally not ours. untrimmed on purpose —
// the browser does not trim either, so `" _blank"` really is a frame name
if (target && target.toLowerCase() !== '_self') {
return true;
}
// rel is a token list: `rel="external noopener"` is still external
return (element.getAttribute('rel') ?? '').split(/\s+/).includes('external');
}
/**
* Directive class that creates state-based navigation links.
*
* This directive is used internally by the {@link uiSref} directive function.
* It transforms elements (typically `` tags) into UI-Router navigation links
* by setting the `href` attribute and handling click events.
*
* @see {@link uiSref} for the public API
* @see [[AsyncDirective]]
* @see [[StateService.go]]
*
* @category directives
*/
export class UiSrefDirective extends AsyncDirective {
state: string | null = null;
params: RawParams = {};
options: TransitionOptions = {};
element: UiSrefElement | null = null;
uiRouter: UIRouterLit | undefined;
parentView: UiView | null = null;
/** this directive's own options, stripped from the transition options */
uiSrefOptions: UiSrefOptions = {};
href: string | null = null;
targetState: TargetState | null = null;
/** whether the href currently on the element was written by us */
private _ownsHref = false;
/** @internal */
unsubscribe: (() => void) | undefined;
/** @internal */
constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.ELEMENT) {
throw new Error('The `uiSref` directive must be used as an element');
}
}
getOptions(opts: TransitionOptions = this.options): TransitionOptions {
const defaultOpts: TransitionOptions = {
relative: this.parentView?.viewContext?.name,
inherit: true,
source: 'sref',
};
return extend(defaultOpts, opts || {}) as TransitionOptions;
}
render(
state: string,
params?: RawParams,
options?: TransitionOptions,
): typeof noChange {
if (!this.element) {
return noChange;
}
const { uiRouter: router } = this;
const $state = router?.stateService;
if (!$state) {
return noChange;
}
const targetState = $state.target(state, params, this.getOptions(options));
const targetChanged = !sameTarget(this.targetState, targetState);
this.element.targetState = this.targetState = targetState;
// core returns null from href() for a state whose navigable has no url
this.href = $state.href(state, params, this.getOptions(options));
if (this.shouldAssignHref()) {
if (this.href !== this.element.getAttribute('href')) {
if (this.href) {
this.element.setAttribute('href', this.href);
this._ownsHref = true;
} else {
this.element.removeAttribute('href');
this._ownsHref = false;
}
}
} else if (this._ownsHref) {
// the option was flipped after we wrote one; leave author hrefs alone
this.element.removeAttribute('href');
this._ownsHref = false;
}
// the href is not the target: a url-less state has none, and non-url
// params change the target without changing it
if (targetChanged) {
this.element.dispatchEvent(uiSrefTargetEvent(this.targetState));
}
return noChange;
}
/**
* Whether this render writes the `href`, warning once per element under lit's
* dev build when the 1.x default puts one on something that cannot use it.
* @internal
*/
shouldAssignHref(): boolean {
const element = this.element!;
const { assignHref = true } = this.uiSrefOptions;
if (assignHref === 'auto') {
return isNativeLink(element);
}
if (!assignHref) {
return false;
}
if (
inLitDevMode() &&
this.href !== null &&
!isNativeLink(element) &&
!warnedAssignHref.has(element)
) {
warnedAssignHref.add(element);
console.warn(
`lit-ui-router: uiSref wrote href="${this.href}" to <${element.localName}>, which has no href in HTML. ` +
`Pass { assignHref: 'auto' } to write it only to links; 'auto' becomes the default in 2.0.`,
);
}
return true;
}
/** @internal */
seekRouter(): void {
this.uiRouter = UIRouterLitElement.seekRouter(this.element!);
}
/** @internal */
seekParentView(): void {
this.parentView = UiView.seekParentView(this.element!);
}
/** @internal */
disconnected(): void {
this.element?.removeEventListener('click', this.onClick as EventListener);
this.element = null;
this.targetState = null;
this.href = null;
this._ownsHref = false;
this.unsubscribe?.();
}
onClick = (event: MouseEvent): void => {
const { uiRouter: router, state, params } = this;
const options = this.getOptions();
const $state = router?.stateService;
if (!$state || !this.element?.isConnected || !state) {
return;
}
const element = event.currentTarget as Element;
// author signals, so unscoped: they apply whatever the element is
if (event.defaultPrevented || element.hasAttribute('download')) {
return;
}
// scoped to links: these guards hand the click back to the browser, and a
// non-link has nothing to hand it back to
if (
isNativeLink(element) &&
(isModifiedClick(event) || opensOffApp(element))
) {
return;
}
// fire-and-forget: @uirouter/core handles transition promise rejections
void $state.go(state, params, options);
event.preventDefault();
};
update(
part: ElementPart,
[state, params = {}, options = {}]: [
string,
RawParams?,
UiSrefTransitionOptions?,
],
): typeof noChange {
// split the directive's own options out so they never reach core
const { assignHref, ...transitionOptions } = options;
this.state = state;
this.params = params;
this.options = transitionOptions;
this.uiSrefOptions = { assignHref };
const uiSrefElement = part.element as unknown as UiSrefElement;
if (this.element !== uiSrefElement) {
this.element = uiSrefElement;
this._firstUpdated = false;
setTimeout(() => {
this.firstUpdated();
}, 0);
}
return this.doRender();
}
doRender = (): typeof noChange => {
return this.render(this.state!, this.params, this.options);
};
private _firstUpdated = false;
/**
* @internal
*/
firstUpdated(): void {
if (this._firstUpdated || !this.isConnected) {
return;
}
this.seekRouter();
this.seekParentView();
this.element!.addEventListener('click', this.onClick as EventListener);
this.unsubscribe = this.uiRouter!.stateRegistry.onStatesChanged(
this.doRender,
);
this.doRender();
this._firstUpdated = true;
}
}
/**
* Directive that creates state-based navigation links.
*
* The `uiSref` directive transforms elements (typically `` tags) into
* UI-Router navigation links. It automatically generates the `href` attribute
* based on the target state and handles click events to perform state transitions.
*
* **Arguments:**
* - `state` - The target state name (can be relative like `.child` or `^.sibling`)
* - `params` - Optional state parameters (see [[RawParams]])
* - `options` - Optional transition options (see [[TransitionOptions]]), plus
* this directive's own (see {@link UiSrefOptions})
*
* @example Basic usage
* ```ts
* import { uiSref } from 'lit-ui-router';
* import { html } from 'lit';
*
* html`Go Home`
* ```
*
* @example With parameters
* ```ts
* html`
View User`
* ```
*
* @example With transition options
* ```ts
* html`
Reload Dashboard`
* ```
*
* @example Relative state references
* ```ts
* // Navigate to child state
* html`
Go to Child`
*
* // Navigate to sibling state
* html`
Go to Sibling`
* ```
*
* @example On an element that is not a link
* ```ts
* // `