import { TargetState, Transition, UIRouter } from '@uirouter/core';
import { nothing, ReactiveController, ReactiveControllerHost } from 'lit';
import { warnMissingRouter } from './dev-warn.js';
import type { SrefTargetParams } from './sref-active.js';
import { resolveAriaCurrent, SrefTargets } from './sref-status.js';
import { UIRouterLitElement } from './ui-router.js';
import {
UI_SREF_TARGET_EVENT,
UI_SREF_TARGET_REMOVED_EVENT,
UiSrefTargetEvent,
} from './ui-sref.js';
import {
AriaCurrentValue,
AriaCurrentValues,
SrefStatus,
TransEvt,
} from './ui-sref-active.js';
import { UiView } from './ui-view.js';
/** @internal */
type DeregisterFn = () => void;
/**
* Options for {@link SrefStatusController}: which state to watch, plus the
* router to watch it in.
*
* @category controllers
*/
export interface SrefStatusControllerOptions extends SrefTargetParams {
/**
* The {@link UIRouter} instance to observe.
*
* When omitted, the controller discovers the router from an ancestor
* <ui-router> (or <ui-view>) via the
* `ui-router-context` event when the host connects.
*
* Passing it explicitly also moves the first status computation into the
* constructor, so the status is there for the very first render and needs
* no DOM — the hand-off an element-less environment has no other way to
* make.
*/
router?: UIRouter;
}
/**
* Whether a host would render the same thing from both: the four flags and
* the very targets they are about. Targets are held until a retarget, a link
* change or a rebuild replaces them, so identity is the exact test.
*/
const sameShape = (
a: SrefStatus | undefined,
b: SrefStatus | undefined,
): boolean =>
a === b ||
(!!a &&
!!b &&
a.active === b.active &&
a.exact === b.exact &&
a.entering === b.entering &&
a.exiting === b.exiting &&
a.targetStates.length === b.targetStates.length &&
a.targetStates.every((target, i) => target === b.targetStates[i]));
/**
* A Lit
* {@link https://lit.dev/docs/composition/controllers/ | ReactiveController}
* that exposes a state's {@link SrefStatus} — `active`, `exact`, `entering`,
* `exiting` — to its host, so the host's own template decides what to do
* with it.
*
* This is the composition path {@link srefActiveClass} cannot offer: a
* `class` attribute holds one toggling directive, so `srefActiveClass` and
* {@link https://lit.dev/docs/templates/directives/#classmap | classMap}
* cannot share it. Read the flags here instead and pass them to `classMap`,
* to `aria-current`, to a `?disabled`, to anything.
*
* The controller registers its hooks when the host connects and deregisters
* them on `hostDisconnected`, so nothing leaks when hosts come and go. It
* calls `host.requestUpdate()` only when one of the four flags or the watched
* targets actually changed, so transitions that leave the link alone cost no
* render.
*
* @example Composing with classMap and aria-current
* ```ts
* import { html, LitElement } from 'lit';
* import { classMap } from 'lit/directives/class-map.js';
* import { srefHref, SrefStatusController } from 'lit-ui-router';
*
* class NavLink extends LitElement {
* private users = new SrefStatusController(this, { state: 'users' });
*
* render() {
* return html`Users`;
* }
* }
* ```
*
* @example With an explicit router instance
* ```ts
* // status is computed in the constructor, before the host ever connects
* const status = new SrefStatusController(host, { state: 'users', router });
* ```
*
* @example Re-target from a property setter
* ```ts
* class NavLink extends LitElement {
* private status = new SrefStatusController(this);
*
* @property() set state(state: string) {
* this.status.retarget({ state });
* }
* }
* ```
*
* @example Container mode: watch the links in the host's template
* ```ts
* class NavSection extends LitElement {
* // no `state`: every srefHref link below feeds this controller
* private section = new SrefStatusController(this);
*
* render() {
* return html`
<ui-router> on connect.
*/
get router(): UIRouter | undefined {
return this.targets.router;
}
/**
* The merged {@link SrefStatus} of every watched target, or `undefined` while
* there is no router or no target.
*/
get status(): SrefStatus | undefined {
return this._status;
}
/** The target state, or a child of it, is active. */
get active(): boolean {
return this._status?.active ?? false;
}
/** The target state is itself the active state. */
get exact(): boolean {
return this._status?.exact ?? false;
}
/** A transition in flight is entering the target state. */
get entering(): boolean {
return this._status?.entering ?? false;
}
/** A transition in flight is exiting the target state. */
get exiting(): boolean {
return this._status?.exiting ?? false;
}
/** The states being watched: the named one, or the enclosed links'. */
get targetStates(): TargetState[] {
return this._status?.targetStates ?? [];
}
/**
* Points the controller at another state — for a host that takes the state
* as a property. Replaces `state`, `params` and `options` wholesale, keeps
* the router, and refreshes.
*/
retarget(params: SrefTargetParams): void {
this.options = { router: this.options.router, ...params };
this.targets.params = this.options;
this.targets.setExplicit();
this.refresh();
}
/**
* The `aria-current` token for the current status, or `nothing` to leave
* the attribute off — {@link srefAriaCurrent}'s rules, as a value to bind:
* `'page'` while the exact state is active, nothing otherwise.
*
* @param value another token, or `{ exact, active }` to mark an active
* ancestor too. See {@link AriaCurrentValues}.
*/
ariaCurrent(
value?: AriaCurrentValue | AriaCurrentValues,
): AriaCurrentValue | typeof nothing {
return this._status ? resolveAriaCurrent(this._status, value) : nothing;
}
/** @internal */
hostConnected(): void {
const { host } = this;
this.targets.router ??=
this.options.router ?? UIRouterLitElement.seekRouter(host);
this.targets.relative = UiView.seekParentView(host)?.viewContext?.name;
this.targets.setExplicit();
// links announce themselves from inside the render root, if there is one;
// listened for in named mode too, since `retarget({})` can drop the name
const scope: EventTarget =
(host as { renderRoot?: EventTarget }).renderRoot ?? host;
scope.addEventListener(
UI_SREF_TARGET_EVENT,
this.onUiSrefTargetEvent as EventListener,
);
scope.addEventListener(
UI_SREF_TARGET_REMOVED_EVENT,
this.onUiSrefTargetRemovedEvent,
);
this.deregisterFns.push(() => {
scope.removeEventListener(
UI_SREF_TARGET_EVENT,
this.onUiSrefTargetEvent as EventListener,
);
scope.removeEventListener(
UI_SREF_TARGET_REMOVED_EVENT,
this.onUiSrefTargetRemovedEvent,
);
});
const router = this.targets.router;
if (router) {
this.deregisterFns.push(
router.transitionService.onStart({}, this.onTransitionStart, {
priority: -Infinity,
}) as DeregisterFn,
router.stateRegistry.onStatesChanged(this.onStatesChanged),
);
} else {
warnMissingRouter(
host,
`new SrefStatusController() on <${host.localName}>`,
'will never be marked active',
);
}
this.refresh();
}
/** @internal */
hostDisconnected(): void {
this._connection++;
while (this.deregisterFns.length) {
this.deregisterFns.shift()?.();
}
if (!this.options.router) {
// found through context: the host may reconnect under another router
this.targets.router = undefined;
}
}
/**
* Recomputes the status and re-renders the host, but only if a flag or a
* watched target moved.
*
* @internal
*/
refresh(event?: TransEvt): void {
if (this.compute(event)) {
this.host.requestUpdate();
}
}
/** the new status, and whether the host needs to see it */
private compute(event?: TransEvt): boolean {
const before = this._status;
this._status = this.targets.status(event);
const first = !this.computed;
this.computed = true;
return first || !sameShape(before, this._status);
}
private readonly onUiSrefTargetEvent = (event: UiSrefTargetEvent): void => {
this.targets.onLink(event);
this.refresh();
};
private readonly onUiSrefTargetRemovedEvent = (event: Event): void => {
if (this.targets.onLinkRemoved(event)) {
this.refresh();
}
};
private readonly onStatesChanged = (): void => {
this.targets.rebuild();
this.refresh();
};
private readonly onTransitionStart = (trans: Transition): void => {
// deregistering stops the next start, not a settlement already subscribed
const connection = this._connection;
const settled = (evt: TransEvt['evt']): void => {
if (connection === this._connection) {
this.refresh({ evt, trans });
}
};
this.refresh({ evt: 'start', trans });
trans.promise.then(
() => settled('success'),
() => settled('error'),
);
};
}