import { RawParams, Transition, TransitionOptions } from '@uirouter/core'; import { noChange, nothing, AttributePart } from 'lit'; import { AttributePartInfo, directive, PartInfo, PartType, } from 'lit/directive.js'; import type { DirectiveResult } from 'lit/directive.js'; import type { ClassInfo } from 'lit/directives/class-map.js'; import { AsyncDirective } from 'lit/async-directive.js'; import { getScopedRouter } from './context.js'; import { UIRouterLit } from './core.js'; import { warnMissingRouter } from './dev-warn.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'; /** * Which state an attribute-part active directive watches. Name a state, or * leave it out to watch the {@link srefHref} links inside the element instead * (container mode, as {@link uiSrefActive} does). * * @category types */ export interface SrefTargetParams { /** The state name to check for active status */ state?: string; /** State parameters to match */ params?: RawParams; /** Transition options; `relative` defaults to the enclosing view's state */ options?: TransitionOptions; } type deregisterFn = () => void; /** * What {@link srefActiveClass} and {@link srefAriaCurrent} share: a target * (named, or gathered from enclosed links), the router subscriptions that * recompute its {@link SrefStatus}, and the push of each new value into the * attribute. * * `render()` is a function of `status` and the params alone. A server renderer * runs it without `update()`, so it seeds `status` from the call-scoped router * first; with no router in reach the value stays `noChange` and the attribute * is left as authored. * * @category directives */ export abstract class SrefStatusDirective< Params extends SrefTargetParams, > extends AsyncDirective { /** @internal */ element: Element | null = null; /** @internal */ uiRouter: UIRouterLit | undefined; /** @internal */ parentView: UiView | null = null; /** the last params `update()` saw */ params: Params | undefined; /** merged status of every target, or `undefined` before there is one */ status: SrefStatus | undefined; /** * the named target, or the enclosed links' targets in container mode * @internal */ protected readonly targets: SrefTargets = new SrefTargets(); private _firstUpdated = false; private _deregister: deregisterFn[] = []; /** bumped on disconnect, so a settlement subscribed before it stays quiet */ private _connection = 0; /** the attribute this directive is bound in, for warnings */ private readonly attributeName: string; /** * @param directiveName the public function's name, for errors and warnings * @internal */ constructor( partInfo: PartInfo, protected readonly directiveName: string, ) { super(partInfo); if (partInfo.type !== PartType.ATTRIBUTE) { throw new Error( `The \`${directiveName}\` directive must be used in an attribute`, ); } this.attributeName = partInfo.name; } /** the value for `status` and `params` as they stand */ abstract render(params: Params): unknown; /** * What `update()` and a status change hand to the part: `render()`, or * `noChange` once the directive keeps the DOM in sync itself. * * @internal */ protected abstract commit(): unknown; /** * The status to render: the one `update()` already computed, or — when * `update()` never ran, as in a server render — one seeded from the router * the enclosing `withRouterSync` scoped, by building the named target and * merging its status. Container mode has no links to merge and stays * `undefined`. * * A directive instance that only ever renders resolves this at most once. * * @internal */ protected getScopedStatus(params: Params): SrefStatus | undefined { if (this.status || this.uiRouter) return this.status; const router = getScopedRouter(); if (!router) return this.status; this.targets.router = router; this.targets.params = params; this.targets.setExplicit(); this.status = this.targets.status(); return this.status; } /** @internal */ update(part: AttributePart, [params]: [Params]): unknown { this.params = params; this.targets.params = params; if (this.element !== part.element) { this.element = part.element; this._firstUpdated = false; // the part's element is not in the document yet; the seek needs it there setTimeout(() => { this.firstUpdated(); }, 0); } else if (this.uiRouter) { // a re-render may name a different state this.targets.setExplicit(); this.refresh(); return noChange; } return this.commit(); } /** @internal */ firstUpdated(): void { if (this._firstUpdated || !this.isConnected) { return; } const element = this.element!; this.uiRouter = UIRouterLitElement.seekRouter(element); this.parentView = UiView.seekParentView(element); this.targets.router = this.uiRouter; this.targets.relative = this.parentView?.viewContext?.name; this.targets.setExplicit(); // listened for in named mode too: a re-render may drop the name element.addEventListener( UI_SREF_TARGET_EVENT, this.onUiSrefTargetEvent as EventListener, ); element.addEventListener( UI_SREF_TARGET_REMOVED_EVENT, this.onUiSrefTargetRemovedEvent, ); this._deregister.push(() => { element.removeEventListener( UI_SREF_TARGET_EVENT, this.onUiSrefTargetEvent as EventListener, ); element.removeEventListener( UI_SREF_TARGET_REMOVED_EVENT, this.onUiSrefTargetRemovedEvent, ); }); const router = this.uiRouter; if (router) { this._deregister.push( router.transitionService.onStart({}, this.onTransitionStart, { priority: -Infinity, }) as deregisterFn, router.stateRegistry.onStatesChanged(this.onStatesChanged), ); } else { warnMissingRouter( element, `<${element.localName} ${this.attributeName}=\${${this.directiveName}(...)}>`, 'will never be marked active', ); } this._firstUpdated = true; this.refresh(); } /** @internal */ onUiSrefTargetEvent = (event: UiSrefTargetEvent): void => { this.targets.onLink(event); this.refresh(); }; /** @internal */ onUiSrefTargetRemovedEvent = (event: Event): void => { if (this.targets.onLinkRemoved(event)) { this.refresh(); } }; /** * A `TargetState` pins its definition when built, so one made before its * state was registered stays non-existent: rebuild every target first. * * @internal */ onStatesChanged = (): void => { this.targets.rebuild(); this.refresh(); }; /** @internal */ onTransitionStart = (trans: Transition): void => { // deregistering stops the next start, not a settlement already subscribed; // past a disconnect the class directive would write a detached element 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'), ); }; /** * Recomputes `status` and pushes the result into the attribute. * * @internal */ refresh(event?: TransEvt): void { this.status = this.targets.status(event); const value = this.commit(); if (value !== noChange && this.isConnected) { this.setValue(value); } } /** @internal */ disconnected(): void { this._connection++; this._deregister.forEach((deregister) => deregister()); this._deregister = []; this._firstUpdated = false; } /** @internal */ reconnected(): void { // lit reconnects while the cached fragment is still detached; seek once // the element is back in the document setTimeout(() => { this.firstUpdated(); }, 0); } } /** * Parameters for {@link srefActiveClass}. * * @category types */ export interface SrefActiveClassParams extends SrefTargetParams { /** 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[]; /** * Other classes to toggle by their value's truthiness, as * {@link https://lit.dev/docs/templates/directives/#classmap | classMap} * takes them. A `class` attribute holds one toggling directive, so this is * where `classMap`'s argument goes when it shares the attribute with ours. * A name listed here and in `activeClasses` applies when either says so. */ classes?: ClassInfo; } /** * The attribute-part sibling of {@link UiSrefActiveDirective}'s class * handling, with the contract of lit's * {@link https://lit.dev/docs/templates/directives/#classmap | classMap}: * bound in `class`, alone or beside static classes, and toggling only the * classes it names. * * The first commit writes the whole class list — statics plus whichever of * ours apply — as `classMap` does, which is also what a server rendering * `render()` alone would emit. Every commit after that toggles names on * `classList`, so classes something else added to the element survive. * * @see {@link srefActiveClass} for the public API * * @category directives */ export class SrefActiveClassDirective extends SrefStatusDirective { /** classes the template wrote around the expression; never toggled */ private _staticClasses: Set | undefined; /** our classes on the element as of the last commit; `undefined` before one */ private _previousClasses: Set | undefined; /** @internal */ constructor(partInfo: PartInfo) { super(partInfo, 'srefActiveClass'); const { name, strings } = partInfo as AttributePartInfo; if (name !== 'class' || (strings?.length ?? 0) > 2) { throw new Error( '`srefActiveClass()` can only be used in the `class` attribute and must be the only expression in it', ); } } /** each named class with whether it applies now */ private classInfo({ activeClasses = [], exactClasses = [], classes = {}, }: SrefActiveClassParams): Record { const info: Record = {}; const { active = false, exact = false } = this.status ?? {}; for (const name in classes) { info[name] = !!classes[name]; } for (const name of activeClasses) { info[name] = info[name] || active; } for (const name of exactClasses) { info[name] = info[name] || exact; } return info; } /** * The classes that apply, space-padded to keep clear of the statics; or * `noChange` before a status exists. */ render(params: SrefActiveClassParams): string | typeof noChange { const status = this.getScopedStatus(params); if (!status) { return noChange; } const info = this.classInfo(params); return ( ' ' + Object.keys(info) .filter((name) => info[name]) .join(' ') + ' ' ); } /** @internal */ update(part: AttributePart, args: [SrefActiveClassParams]): unknown { if (this._staticClasses === undefined && part.strings !== undefined) { this._staticClasses = new Set( part.strings .join(' ') .split(/\s/) .filter((s) => s !== ''), ); } return super.update(part, args); } /** @internal */ protected commit(): unknown { const info = this.classInfo(this.params!); if (this._previousClasses === undefined) { const value = this.render(this.params!); if (value === noChange) { return value; } this._previousClasses = new Set(); for (const name in info) { if (info[name] && !this._staticClasses?.has(name)) { this._previousClasses.add(name); } } return value; } const { classList } = this.element!; for (const name of this._previousClasses) { if (!(name in info)) { classList.remove(name); this._previousClasses.delete(name); } } for (const name in info) { const value = info[name]; if ( value !== this._previousClasses.has(name) && !this._staticClasses?.has(name) ) { if (value) { classList.add(name); this._previousClasses.add(name); } else { classList.remove(name); this._previousClasses.delete(name); } } } return noChange; } } /** * Parameters for {@link srefAriaCurrent}. * * @category types */ export interface SrefAriaCurrentParams extends SrefTargetParams { /** * The token to write while the **exact** state is active — `'page'` by * default — or an object that also names one for an active ancestor: * `{ exact: 'page', active: 'location' }`. See {@link AriaCurrentValues}. */ value?: AriaCurrentValue | AriaCurrentValues; } /** * The attribute-part sibling of {@link UiSrefActiveDirective}'s * `aria-current` handling. * * @see {@link srefAriaCurrent} for the public API * * @category directives */ export class SrefAriaCurrentDirective extends SrefStatusDirective { /** whether a status was ever written, so losing every target clears it */ private _wrote = false; /** @internal */ constructor(partInfo: PartInfo) { super(partInfo, 'srefAriaCurrent'); if ((partInfo as AttributePartInfo).strings !== undefined) { throw new Error( '`srefAriaCurrent()` must be the only expression in its attribute', ); } } /** @internal */ protected commit(): unknown { if (!this.status) { return this._wrote ? nothing : noChange; } this._wrote = true; return this.render(this.params!); } /** * The token for the current status, `nothing` to remove the attribute, or * `noChange` before a status exists. */ render( params: SrefAriaCurrentParams, ): AriaCurrentValue | typeof nothing | typeof noChange { const status = this.getScopedStatus(params); if (!status) { return noChange; } return resolveAriaCurrent(status, params.value); } } /** * Toggles classes in a `class` attribute by the active state: the * attribute-part form of {@link uiSrefActive}'s classes. * * It follows lit's * {@link https://lit.dev/docs/templates/directives/#classmap | classMap}: it * must be bound in `class`, alone or next to static classes, and it only ever * toggles the classes it names. Name the state to watch, or leave `state` out * on a wrapper to watch the {@link srefHref} links inside it. * * It cannot share the attribute with `classMap` — lit rewrites the whole * value when either expression changes, so one directive has to own it. Pass * what `classMap` would have taken as `classes` instead. For a component that * wants plain `classMap` and its own bindings, use * {@link SrefStatusController}. * * Unlike `uiSrefActive`, this writes no `aria-current` — a `class` binding * cannot reach another attribute. Bind {@link srefAriaCurrent} beside it. * * @example * ```ts * import { srefHref, srefActiveClass } from 'lit-ui-router'; * import { html } from 'lit'; * * html` * Users * ` * ``` * * @example In place of classMap * ```ts * html`Users` * ``` * * @example Container mode * ```ts * html`
  • * Users *
  • ` * ``` * * @see {@link uiSrefActive} * @see {@link srefAriaCurrent} * @see {@link SrefStatusController} * * @category directives */ export const srefActiveClass: ( params: SrefActiveClassParams, ) => DirectiveResult = directive( SrefActiveClassDirective, ); /** * Binds `aria-current` to the active state: the attribute-part form of * {@link uiSrefActive}'s `aria-current`. * * Binding the attribute is the opt-in, so there is none of `uiSrefActive`'s * link detection or takeover: the value is `'page'` while the exact state is * active and the attribute is absent otherwise, whatever the element. Pass * `value` for another token, or `{ exact, active }` to mark an ancestor too. * * @example * ```ts * import { srefHref, srefAriaCurrent } from 'lit-ui-router'; * import { html } from 'lit'; * * html` * Payment * ` * ``` * * @see {@link uiSrefActive} * @see {@link srefActiveClass} * @see {@link SrefStatusController} * * @category directives */ export const srefAriaCurrent: ( params: SrefAriaCurrentParams, ) => DirectiveResult = directive( SrefAriaCurrentDirective, );