import { RawParams, TransitionOptions, TargetState } from '@uirouter/core';
import { noChange, ElementPart } from 'lit';
import { directive, PartInfo, PartType } from 'lit/directive.js';
import type { DirectiveResult } from 'lit/directive.js';
import { AsyncDirective } from 'lit/async-directive.js';
import { UIRouterLit } from './core.js';
import { inLitDevMode, warnMissingRouter } from './dev-warn.js';
import { UIRouterLitElement } from './ui-router.js';
import { UiView } from './ui-view.js';
import {
clickBelongsToBrowser,
isNativeLink,
sameTarget,
srefTransitionOptions,
uiSrefTargetEvent,
uiSrefTargetRemovedEvent,
} from './sref-internals.js';
import type { UiSrefElement } from './sref-internals.js';
// re-export: `inLitDevMode` ships in the public d.ts (#541)
export { inLitDevMode };
export {
UI_SREF_TARGET_EVENT,
UI_SREF_TARGET_REMOVED_EVENT,
} from './sref-internals.js';
export type { UiSrefElement, UiSrefTargetEvent } from './sref-internals.js';
export {
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
clickBelongsToBrowser,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
isNativeLink,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
sameTarget,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
srefEventLink,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
srefTransitionOptions,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
uiSrefTargetEvent,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
uiSrefTargetRemovedEvent,
} from './sref-internals.js';
/**
* Directive options for {@link uiSref}, passed alongside the transition
* options in its third argument. These never reach `@uirouter/core`.
*
* @category types
*/
export interface UiSrefOptions {
/**
* Where the generated `href` is written.
*
* - `true` *(default in 1.x)* — always write it, whatever the element is.
* This is the historical behaviour and the standing answer for a custom
* element that declares its own `href`.
* - `'auto'` — write it only to elements the HTML spec gives an `href`:
* ``, ``, and SVG ``. This is the correct behaviour and
* becomes the default in 2.0.
* - `false` — never write it; the app manages the attribute itself.
*
* Under `true`, a non-link that receives an `href` warns once and names
* `'auto'` as the fix.
*
* This option governs the `href` attribute **only**. Whether the click
* handler defers to native browser behaviour is decided by the element
* itself, never by this setting — see `isNativeLink`.
*/
assignHref?: boolean | 'auto';
}
/**
* The third argument to {@link uiSref}: core's transition options plus this
* directive's own.
*
* @category types
*/
export type UiSrefTransitionOptions = TransitionOptions & UiSrefOptions;
/** elements already warned about, so a re-render does not repeat itself */
const warnedAssignHref = new WeakSet();
/**
* 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 {@link AsyncDirective}
* @see {@link "@uirouter/core"!StateService.go | StateService.go}
*
* @category directives
*/
export class UiSrefDirective extends AsyncDirective {
/** the target state name from the last render */
state: string | null = null;
/** the target state params from the last render */
params: RawParams = {};
/** the transition options from the last render */
options: TransitionOptions = {};
/** @internal */
element: UiSrefElement | null = null;
/** @internal */
uiRouter: UIRouterLit | undefined;
/** @internal */
parentView: UiView | null = null;
/** this directive's own options, stripped from the transition options */
uiSrefOptions: UiSrefOptions = {};
/** the href computed for the target, or null when there is none */
href: string | null = null;
/** the resolved target, or null until the router is found */
targetState: TargetState | null = null;
/** whether the href currently on the element was written by us */
private _ownsHref = false;
/**
* Whether {@link seekRouter} has run. The seek is deferred a task past the
* first render, so `uiRouter` being empty before it means "not looked yet",
* not "not there" — only after it may a bail be reported as a missing
* provider.
*
* @internal
*/
private _seekedRouter = false;
/** @internal */
unsubscribe: (() => void) | undefined;
/**
* Kept across a disconnect so {@link reconnected} can re-arm.
* @internal
*/
private _partElement: UiSrefElement | null = null;
/** @internal */
constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.ELEMENT) {
throw new Error('The `uiSref` directive must be used as an element');
}
}
/** the transition options with `relative` defaulted to the enclosing view */
getOptions(opts: TransitionOptions = this.options): TransitionOptions {
return srefTransitionOptions(this.parentView, opts);
}
/** @internal */
render(
state: string,
params?: RawParams,
options?: TransitionOptions,
): typeof noChange {
if (!this.element) {
return noChange;
}
const { uiRouter: router } = this;
const $state = router?.stateService;
if (!$state) {
if (this._seekedRouter) {
this.warnMissingRouter(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;
}
// DEV folds away in dist/*.js (check:dev-split); inLitDevMode() is the runtime probe.
if (
import.meta.env.DEV &&
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!);
this._seekedRouter = true;
}
/**
* Names this sref in the missing-provider warning. Shared by the two sites
* that observe the no-op — the render that writes no `href` and the click
* that navigates nowhere — so an element that does both still warns once.
*
* @internal
*/
private warnMissingRouter(state: string): void {
const element = this.element!;
warnMissingRouter(
element,
`<${element.localName} uiSref="${state}">`,
'will not navigate',
);
}
/** @internal */
seekParentView(): void {
this.parentView = UiView.seekParentView(this.element!);
}
/** @internal */
disconnected(): void {
this.element?.removeEventListener('click', this.onClick as EventListener);
// lit notifies before it removes the nodes: the container still hears this
this.element?.dispatchEvent(uiSrefTargetRemovedEvent());
this.element = null;
this.targetState = null;
this.href = null;
this._ownsHref = false;
this.unsubscribe?.();
this.unsubscribe = undefined;
// re-arming is what `reconnected` does; without this it would no-op
this._firstUpdated = false;
}
/**
* Re-arms after a detach/re-attach; `update` only re-arms on a NEW element.
* @internal
*/
reconnected(): void {
this.element = this._partElement;
if (this.element) {
this.firstUpdated();
}
}
/** @internal */
onClick = (event: MouseEvent): void => {
const { uiRouter: router, state, params } = this;
const options = this.getOptions();
const $state = router?.stateService;
if (!$state || !this.element?.isConnected || !state) {
if (!$state && state && this.element?.isConnected) {
this.warnMissingRouter(state);
}
return;
}
if (clickBelongsToBrowser(event, event.currentTarget as Element)) {
return;
}
// fire-and-forget: @uirouter/core handles transition promise rejections
void $state.go(state, params, options);
event.preventDefault();
};
/** @internal */
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;
this._partElement = uiSrefElement;
if (this.element !== uiSrefElement) {
this.element = uiSrefElement;
this._firstUpdated = false;
setTimeout(() => {
this.firstUpdated();
}, 0);
}
return this.doRender();
}
/** @internal */
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);
// no router: the subscription is the only step that needs one, and
// `doRender` still has to run for the no-op to report itself
if (this.uiRouter) {
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 {@link RawParams})
* - `options` - Optional transition options (see {@link 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
* // `