import {
extend,
RawParams,
TargetState,
Transition,
TransitionOptions,
} 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 { UIRouterLitElement } from './ui-router.js';
import { inLitDevMode, warnMissingRouter } from './dev-warn.js';
import {
isNativeLink,
mergeSrefStatus,
srefStatus,
UiSrefElement,
UiSrefTargetEvent,
UI_SREF_TARGET_EVENT,
} from './sref-internals.js';
import { UiView } from './ui-view.js';
export {
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
mergeSrefStatus,
/**
* @internal
* @deprecated Directive plumbing, not a supported import.
*/
srefStatus,
} from './sref-internals.js';
/** @internal */
export interface TransEvt {
evt: string;
trans: Transition;
status?: SrefStatus;
}
/**
* Event name dispatched when a transition state changes.
* @internal
*/
export const TRANSITION_STATE_CHANGE_EVENT = 'transitionStateChange';
/**
* Enum representing the different stages of a transition.
* @internal
*/
export enum TransitionStateChange {
/** Transition has started */
start = 'start',
/** Transition completed successfully */
success = 'success',
/** Transition failed with an error */
error = 'error',
}
/**
* Status object representing the active state of a uiSref link.
*
* This interface describes the relationship between a link (or container
* with links) and the current router state.
*
* @see {@link uiSrefActive}
* @see {@link TargetState}
*
* @category types
*/
export interface SrefStatus {
/** The sref's target state (or one of its children) is currently active */
active: boolean;
/** The sref's target state is currently active */
exact: boolean;
/** A transition is entering the sref's target state */
entering: boolean;
/** A transition is exiting the sref's target state */
exiting: boolean;
/** The enclosed sref(s) target state(s) */
targetStates: TargetState[];
}
/**
* Valid `aria-current` token values.
*
* @see {@link UiSrefActiveParams.ariaCurrentValue}
* @see [WAI-ARIA `aria-current`](https://www.w3.org/TR/wai-aria-1.2/#aria-current)
*
* @category types
*/
export type AriaCurrentValue =
| 'page'
| 'step'
| 'location'
| 'date'
| 'time'
| 'true';
/**
* Per-state `aria-current` values, for the rare nav that wants to mark an
* ancestor as well as the current page.
*
* Unlike `activeClasses` and `exactClasses` — which both land in `class` when a
* link is exactly active — `aria-current` is one attribute with one value, so
* these do not combine: on an exactly-active element `exact` wins and `active`
* is not consulted. Each key falls back to its own default when omitted.
*
* @see {@link UiSrefActiveParams.ariaCurrentValue}
*
* @category types
*/
export interface AriaCurrentValues {
/** Applied when the exact state is active. Defaults to `'page'` on links. */
exact?: AriaCurrentValue | false;
/**
* Applied when a child state is active but this one is not the exact match —
* `'location'` is the token meant for this. Defaults to `false`.
*/
active?: AriaCurrentValue | false;
}
/**
* `aria-current` defaults on for link elements only; other elements must opt in
* explicitly, since `aria-current` on a wrapper (`
`, ``) is rarely intended.
*
* This is `uiSref`'s tag check widened by role. `aria-current` is a property of
* the role, so `` takes it; `href` is a property of the tag,
* so the same element must never take one. Sharing the tag half keeps the
* overlap exact — see `isNativeLink` for the other side.
*
* @internal
*/
const isLinkElement = (element: Element): boolean =>
isNativeLink(element) || element.matches('[role~="link"]');
/**
* Widens the shorthand forms to the per-state shape. A token, `false`, or
* nothing at all is an `exact` value; only the object form sets `active`.
*
* @internal
*/
const toAriaCurrentValues = (
ariaCurrentValue: UiSrefActiveParams['ariaCurrentValue'],
): AriaCurrentValues =>
typeof ariaCurrentValue === 'object'
? ariaCurrentValue
: { exact: ariaCurrentValue };
/**
* Parameters for the uiSrefActive directive.
*
* @see {@link uiSrefActive}
*
* @category types
*/
export interface UiSrefActiveParams {
/** CSS classes to add when the state (or a child state) is active */
activeClasses: string[];
/** CSS classes to add only when the exact state is active */
exactClasses: string[];
/**
* The `aria-current` value to set when the **exact** state is active — the
* same binding Vue Router's `ariaCurrentValue` uses.
*
* Defaults to `'page'` on link elements (`
`, ` `, `[role="link"]`).
* Pass a value explicitly to apply it to any element; pass `false` to leave
* `aria-current` untouched.
*
* Pass an object to also mark ancestors, which is otherwise off:
* `{ exact: 'page', active: 'location' }`. See {@link AriaCurrentValues} —
* the two do not combine the way `activeClasses` and `exactClasses` do.
*
* The directive only removes an `aria-current` it set itself, so a value
* authored in the template survives until the directive first writes one of
* its own — after that it owns the attribute and clears it when inactive,
* warning once. Use `false` to keep an authored value for good.
*/
ariaCurrentValue?: AriaCurrentValue | false | AriaCurrentValues;
/** The state name to check for active status */
state: string;
/** State parameters to match */
params?: RawParams;
/** Transition options */
options?: TransitionOptions;
/** Target states from nested uiSref directives */
targetStates: TargetState[];
}
/** @internal */
let _first: UiSrefActiveDirective | null = null;
type deregisterFn = () => void;
/**
* Directive class that adds CSS classes based on active state.
*
* This directive is used internally by the {@link uiSrefActive} directive function.
* It watches the current router state and applies CSS classes to elements
* when their associated states are active.
*
* The directive can operate in two modes:
* 1. **Explicit state**: Provide a state name to watch
* 2. **Container mode**: Automatically watch nested uiSref directives
*
* @see {@link uiSrefActive} for the public API
* @see {@link AsyncDirective}
* @see {@link SrefStatus}
*
* @category directives
*/
export class UiSrefActiveDirective extends AsyncDirective {
/** @internal */
element: Element | null = null;
/** @internal */
uiRouter: UIRouterLit | undefined;
/** @internal */
seekRouter(): void {
this.uiRouter = UIRouterLitElement.seekRouter(this.element!);
}
/** @internal */
parentView: UiView | null = null;
/** @internal */
seekParentView(): void {
this.parentView = UiView.seekParentView(this.element!);
}
/** classes applied while any target is active */
activeClasses: string[] = [];
/** classes applied while any target is exactly active */
exactClasses: string[] = [];
/** undefined = default (on for link elements) */
ariaCurrentValue: AriaCurrentValue | false | AriaCurrentValues | undefined;
/**
* Whether the `aria-current` currently on the element was written by this
* directive. Guards against clearing one authored in the template.
*
* @internal
*/
private ownsAriaCurrent = false;
/**
* Whether the takeover warning has already been emitted. Instance state
* rather than a module-level element registry: the directive instance already
* lives as long as its part, so this costs nothing extra and pins nothing.
*
* @internal
*/
private warnedAriaCurrentTakeover = false;
/** the explicit target state name, or undefined in container mode */
state: string | undefined;
/** the explicit target state params */
params: RawParams = {};
/** the explicit target transition options */
options: TransitionOptions = {};
/** whether any target is active, or undefined before the first status */
active: boolean | undefined;
/** whether any target is exactly active, or undefined before the first status */
exact: boolean | undefined;
/** whether a running transition enters a target */
entering: boolean | undefined;
/** whether a running transition exits a target */
exiting: boolean | undefined;
/** every target this directive watches: the explicit one, or the enclosed links' */
targetStates: Set = new Set();
/** @internal */
uiSrefs: WeakMap = new WeakMap<
TargetState,
UiSrefElement
>();
/**
* The reverse of {@link uiSrefs}, so a re-targeting link retires its old one.
* @internal
*/
private readonly _linkTargets = new WeakMap();
/** @internal */
_deregisterOnStart: deregisterFn | undefined;
/** @internal */
_deregisterOnStatesChanged: deregisterFn | undefined;
/**
* Kept across a disconnect so {@link reconnected} can re-arm.
* @internal
*/
private _partElement: Element | null = null;
/**
* Replayed by {@link reconnected} so it re-arms the way `firstUpdated` did.
* @internal
*/
private _lastTargetStates: TargetState[] | undefined = undefined;
/**
* Bumped on disconnect; `reconnected` re-subscribes to what is in flight.
* @internal
*/
private _connection = 0;
/** @internal */
constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.ELEMENT) {
throw new Error(
'The `uiSrefActive` directive must be used as an element',
);
}
_first = _first || this;
}
/** @internal */
render({
activeClasses,
exactClasses,
ariaCurrentValue,
}: Partial): typeof noChange {
if (!this._firstUpdated) {
return noChange;
}
activeClasses?.forEach((className) => {
if (this.active) {
this.element!.classList.add(className);
} else {
this.element!.classList.remove(className);
}
});
exactClasses?.forEach((className) => {
if (this.exact) {
this.element!.classList.add(className);
} else {
this.element!.classList.remove(className);
}
});
this.applyAriaCurrent(ariaCurrentValue);
return noChange;
}
/**
* Resolves one `aria-current` value for the element's current state, or
* `false` for none.
*
* `exact` and `active` are branches of a single decision here, not the union
* `classList` gets: an exactly-active element takes the `exact` value and
* never falls through to `active`.
*
* @internal
*/
private resolveAriaCurrent(
values: AriaCurrentValues,
): AriaCurrentValue | false {
if (this.exact) {
return values.exact ?? (isLinkElement(this.element!) && 'page');
}
if (this.active) {
return values.active ?? false;
}
return false;
}
/**
* Writes, rewrites, or clears `aria-current`.
*
* Resolving to a single value first is what keeps "no opinion", "explicitly
* off" and "not applicable" from each needing their own branch here.
*
* @internal
*/
private applyAriaCurrent(
ariaCurrentValue: UiSrefActiveParams['ariaCurrentValue'],
): void {
const resolved = this.resolveAriaCurrent(
toAriaCurrentValues(ariaCurrentValue),
);
if (resolved) {
if (!this.ownsAriaCurrent) {
this.warnAriaCurrentTakeover();
}
this.element!.setAttribute('aria-current', resolved);
this.ownsAriaCurrent = true;
} else if (this.ownsAriaCurrent) {
this.element!.removeAttribute('aria-current');
this.ownsAriaCurrent = false;
}
}
/**
* Warns the first time this directive takes over an `aria-current` it did not
* write. Taking over is deliberate — restoring the previous value when the
* state goes inactive would leave an inactive link asserting
* `aria-current="page"` — but it is silent, and the loss only surfaces a
* navigation later, so it is worth naming once.
*
* Development builds only, like `uiSref`'s `assignHref` warning.
*
* @internal
*/
private warnAriaCurrentTakeover(): void {
// DEV folds the whole body out of dist/*.js (check:dev-split).
if (!import.meta.env.DEV) return;
const existing = this.element!.getAttribute('aria-current');
if (
!inLitDevMode() ||
existing === null ||
this.warnedAriaCurrentTakeover
) {
return;
}
this.warnedAriaCurrentTakeover = true;
console.warn(
`lit-ui-router: uiSrefActive is taking over an existing aria-current="${existing}" that it did not set; ` +
'the attribute will be removed when the state goes inactive. ' +
'Pass ariaCurrentValue: false to keep the attribute under your own control.',
this.element,
);
}
/** @internal */
getOptions(): TransitionOptions {
const defaultOpts: TransitionOptions = {
relative: this.parentView?.viewContext?.name,
};
return extend(defaultOpts, this.options || {}) as TransitionOptions;
}
/**
* Given a TransEvt (Transition event: started, success, error)
* and a UISref Target State, return a SrefStatus object
* which represents the current status of that Sref:
* active, activeEq (exact match), entering, exiting
*
* @internal
*/
getSrefStatus(
event: TransEvt | undefined,
srefTarget: TargetState,
): SrefStatus {
return srefStatus(this.uiRouter!, event, srefTarget);
}
/** @internal */
async update(
part: ElementPart,
[
{
activeClasses,
exactClasses,
ariaCurrentValue,
state,
params = {},
options = {},
targetStates,
},
]: [UiSrefActiveParams],
): Promise {
this.activeClasses = activeClasses;
this.exactClasses = exactClasses;
this.ariaCurrentValue = ariaCurrentValue;
this.state = state;
this.params = params;
this.options = options;
const { element } = part;
this._partElement = element;
this._lastTargetStates = targetStates && Array.from(targetStates);
if (this.element !== element) {
this.element = element;
this._firstUpdated = false;
// defer a microtask so the part's element is settled before first render
await Promise.resolve();
this.firstUpdated({ targetStates });
}
if (this.uiRouter && this._firstUpdated) {
this.doRender();
} else if (!this.uiRouter) {
// reached only past the awaited `firstUpdated` above, so the seek has
// run: the classes this update would have applied are never coming
warnMissingRouter(
element,
`<${element.localName} uiSrefActive>`,
'will never be marked active',
);
}
}
/** @internal */
doRender = (): typeof noChange => {
return this.render({
activeClasses: this.activeClasses,
exactClasses: this.exactClasses,
ariaCurrentValue: this.ariaCurrentValue,
});
};
/** @internal */
_firstUpdated = false;
/** @internal */
firstUpdated({ targetStates }: Partial): void {
if (this._firstUpdated || !this.isConnected) {
return;
}
this.seekRouter();
this.seekParentView();
this.targetStates.clear();
if (targetStates) {
Array.prototype.forEach.call(targetStates, (targetState) => {
this.targetStates.add(targetState as TargetState);
});
} else if (this.state) {
// no router: no target to resolve, and the update that follows reports it
if (this.uiRouter) {
this.targetStates.add(
this.uiRouter.stateService.target(
this.state,
this.params,
this.getOptions(),
),
);
}
} else {
this.element!.addEventListener(
UI_SREF_TARGET_EVENT,
this.onUiSrefTargetEvent as EventListener,
);
}
this.element!.addEventListener(
TRANSITION_STATE_CHANGE_EVENT,
this.onTransitionStateChange,
);
// no router: nothing to subscribe to, and `_firstUpdated` still has to be
// reached so the next update can report the no-op
if (this.uiRouter) {
this._deregisterOnStart = this.uiRouter.transitionService.onStart(
{},
this.onTransitionStart,
) as deregisterFn;
this._deregisterOnStatesChanged =
this.uiRouter.stateRegistry.onStatesChanged(this.onStatesChanged);
}
setTimeout(() => {
if (this.targetStates.size) {
const { active, exact } = this.getStatus() || {};
this.active = active;
this.exact = exact;
this.doRender();
}
}, 0);
this._firstUpdated = true;
}
/** @internal */
disconnected(): void {
// re-arming is what `reconnected` does; without this it would no-op
this._firstUpdated = false;
this._connection++;
if (!this.element) {
return;
}
this.element.removeEventListener(
UI_SREF_TARGET_EVENT,
this.onUiSrefTargetEvent as EventListener,
);
this.element.removeEventListener(
TRANSITION_STATE_CHANGE_EVENT,
this.onTransitionStateChange,
);
this.element = null;
this._deregisterOnStart?.();
this._deregisterOnStart = undefined;
this._deregisterOnStatesChanged?.();
this._deregisterOnStatesChanged = undefined;
}
/**
* 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) {
return;
}
// a same-element uiSref reconnects first; hold its target across the re-arm
const listening = !this._lastTargetStates && !this.state;
const retained = listening ? [...this.targetStates] : [];
this.firstUpdated({ targetStates: this._lastTargetStates });
retained.forEach((targetState) => this.targetStates.add(targetState));
// whatever is in flight either started, or was let go, while disconnected
const inFlight = this.uiRouter?.globals.transition;
if (inFlight) {
this.onTransitionStart(inFlight);
}
}
/** @internal */
createTransitionStateChangeEvent(
evt: TransitionStateChange,
trans: Transition,
): CustomEvent {
const detail: TransEvt = {
evt,
trans,
status: undefined,
};
detail.status = this.getStatus(detail);
return new CustomEvent(TRANSITION_STATE_CHANGE_EVENT, {
detail,
});
}
/** @internal */
onUiSrefTargetEvent = (event: UiSrefTargetEvent): void => {
const { targetState } = event.detail;
const previous = this._linkTargets.get(event.target);
if (previous) {
this.targetStates.delete(previous);
this.uiSrefs.delete(previous);
}
this.targetStates.add(targetState);
this.uiSrefs.set(targetState, event.target);
this._linkTargets.set(event.target, targetState);
if (this._firstUpdated) {
this.onStatesChanged();
}
};
/** @internal */
onTransitionStateChange = (e: Event): void => {
const event = e as unknown as CustomEvent;
const status = this.getStatus(event.detail);
if (!status) {
return;
}
const { active, exact, entering, exiting } = status;
this.active = active;
this.exact = exact;
this.entering = entering;
this.exiting = exiting;
this.doRender();
};
/** @internal */
getStatus(transEvt?: TransEvt): SrefStatus | undefined {
const { targetStates } = this;
if (!targetStates.size) {
return undefined;
}
const statuses: SrefStatus[] = [];
for (const target of targetStates) {
statuses.push(this.getSrefStatus(transEvt, target));
}
return statuses.reduce(mergeSrefStatus);
}
/** @internal */
onTransitionStart = (trans: Transition): void => {
// a settlement subscribed to before a disconnect stays quiet
const connection = this._connection;
const dispatch = (evt: TransitionStateChange): void => {
if (connection !== this._connection) {
return;
}
this.element?.dispatchEvent(
this.createTransitionStateChangeEvent(evt, trans),
);
};
dispatch(TransitionStateChange.start);
trans.promise.then(
() => dispatch(TransitionStateChange.success),
() => dispatch(TransitionStateChange.error),
);
};
/** @internal */
onStatesChanged = (): void => {
const { active, exact } = this.getStatus() || {};
this.active = active;
this.exact = exact;
this.doRender();
};
}
/**
* Directive that adds CSS classes based on active router state.
*
* The `uiSrefActive` directive watches the current router state and applies
* CSS classes to elements when their associated states are active. It supports
* both "active" classes (applied when the state or any child state is active)
* and "exact" classes (applied only when the exact state is active).
*
* On link elements (``, ` `, `[role="link"]`) it also sets
* `aria-current="page"` while the *exact* state is active, and removes the
* attribute otherwise, so assistive technology gets the same "you are here"
* signal as the active CSS class. Other elements opt in by passing
* `ariaCurrentValue` explicitly.
*
* **Arguments:**
* - `params` - Configuration object (see {@link UiSrefActiveParams}) with activeClasses, exactClasses, ariaCurrentValue, and optional state/params
*
* @example Basic usage with nested uiSref
* ```ts
* import { uiSref, uiSrefActive } from 'lit-ui-router';
* import { html } from 'lit';
*
* html`
*
* Home
*
* `
* ```
*
* @example With exact matching
* ```ts
* html`
*
* Users
*
* `
* ```
*
* @example Container mode (watches nested uiSref directives)
* ```ts
* html`
*
* Users
* List
* Create
*
* `
* ```
*
* @example Customizing or disabling aria-current
* ```ts
* html`
*
*
* Payment
*
*
*
*
*
*
*
*
* Home
*
*
*
*
* Users
*
*
*
*
* Users
*
* `
* ```
*
* Only an `aria-current` this directive wrote is removed again. A template-authored
* value therefore survives right up until the directive first writes one of its own —
* from that point the directive owns the attribute and will clear it on the next
* inactive render. Pair a template-authored value with `ariaCurrentValue: false` to
* keep it for good.
*
* @example Explicit state (without nested uiSref)
* ```ts
* html`
*
* Dashboard content
*
* `
* ```
*
* @see {@link SrefStatus}
* @see {@link UiSrefActiveParams}
* @see {@link DirectiveResult}
*
* @category directives
*/
export const uiSrefActive: (
params: Partial,
) => DirectiveResult = directive(
UiSrefActiveDirective,
);