eslint-plugin-lit-ui-router
eslint-plugin-lit-ui-router is a small set of ESLint rules that understand lit-ui-router's directives.
A lit-ui-router anchor carries no static href — the uiSref element part assigns one at runtime — so stock accessibility rules report every correct call site. The usual escape is to disable the rule, which costs its real coverage. This plugin instead ships its own copy of the base rule, taught that the directive counts as the href it assigns, and reports everything the base rule would otherwise still catch.
That vendored rule is the origin story rather than the whole package. The others cover the mistakes the directives make possible, rather than the ones they mask: an href written to an element that has none, an aria-current the directive silently takes over, an aria-current nothing writes at all, and a directive used outside the part type it accepts.
Installation
pnpm add -D eslint-plugin-lit-ui-routeror
npm install --save-dev eslint-plugin-lit-ui-routereslint (^9.0.0 || ^10.0.0) is the only peer dependency. anchor-is-valid is vendored from eslint-plugin-lit-a11y rather than wrapped around it, so lit-a11y is an optional sibling you may also run for the rest of its rules, never a requirement.
The package is published as ESM only. Flat config loads ESM natively; a CommonJS config can require() it on Node ^20.19.0 or >=22.12.0; the call returns the module namespace, so read the plugin from its .default.
Usage
Flat config (eslint.config.js), with lit-a11y alongside:
import litA11y from 'eslint-plugin-lit-a11y';
import litUiRouter from 'eslint-plugin-lit-ui-router';
export default [
litA11y.configs.recommended,
...litUiRouter.configs.recommended,
];Order matters: configs.recommended must come after lit-a11y's own config, because it turns lit-a11y/anchor-is-valid off in favor of ours. It deliberately does not register the lit-a11y plugin key itself — the host's instance owns it. Without lit-a11y installed that off line is simply inert, so the ordering rule costs nothing either way.
The rules
| Rule | What it catches |
|---|---|
anchor-is-valid | an anchor with no href — where a uiSref element part counts as one |
sref-assign-href | an inert href written to a <button>, <tr> or <div>; fix adds 'auto' |
sref-active-aria-current | an authored aria-current the directive silently takes over and later removes |
sref-active-class-aria-current | a link srefActiveClass paints active with no aria-current beside it; fix binds srefAriaCurrent |
sref-status-aria-current | a link whose classes read a SrefStatusController with no aria-current beside it; fix binds ariaCurrent() |
directive-position | a directive outside the part it accepts — uiSref and uiSrefActive on the element, srefHref and srefAriaCurrent as a whole attribute value, srefActiveClass in class; each throws on first render anywhere else |
All six are in configs.recommended at error. The last five are the directives' own runtime development warnings, gaps and throws, said statically: they report at author time, across the whole codebase, and in a production build — where the runtime says nothing at all.
Three of them are one concern seen from three angles. A link can learn it is active from the uiSrefActive element part, from a srefActiveClass attribute part, or from a SrefStatusController the host composes with classMap — and only the first writes aria-current itself. sref-active-aria-current protects the authored attribute from the element part's takeover, sref-active-class-aria-current asks for the attribute the attribute part never writes, and sref-status-aria-current asks for it where the status never reaches the template as a directive call at all.
Every rule is syntax-only, with no type information, so they also load into oxlint as JS plugins.
What anchor-is-valid does
lit-ui-router/anchor-is-valid is lit-a11y's anchor-is-valid, vendored from eslint-plugin-lit-a11y@5.1.1 and extended so a uiSref element part counts as the href it assigns at runtime. lit-a11y is an optional sibling you may also run, not a dependency — our recommended turns its copy off so only one of them reports. The extension suppresses exactly those reports and nothing else:
import { html } from 'lit';
import { uiSref } from 'lit-ui-router';
// Fine: the directive assigns the href.
html`<a ${uiSref('home')}>Home</a>`;
// Still reported, exactly as the base rule would.
html`<a>Home</a>`;
html`<a @click=${() => {}}>Home</a>`;
html`<a ${uiSref('home', undefined, { assignHref: false })}>Home</a>`;An anchor counts as navigable when its uiSref is imported from lit-ui-router — a foreign package's uiSref proves nothing — and the call doesn't opt out of href assignment. Only a literal assignHref: false is a definite no: 'auto' assigns on a native <a>, and a non-literal option is unknowable, so both stay suppressed rather than guessed. The base rule's options (allowHash, aspects) are carried over untouched.
The generated rule documentation is the reference: every example above, the option table, and the configuration the rule ships in.
Example
examples/lint-eslint is the consumer wiring, installed from npm exactly as a consumer would: eslint.config.js spreads litA11y.configs.recommended first and ...litUiRouter.configs.recommended after, and a local Vite plugin runs ESLint's Node API at build time and renders the result in the page — a custom panel and an iframe of ESLint's own html formatter, over the same results.
src/violations.ts is a gallery with one deliberate violation per recommended rule, so the report is not empty: the embed below is that built page, and its report is static (7 problems — one per rule, plus the settings.linkElements pair on <sp-link>, each rule id linked to its docs). Open it on StackBlitz to edit the sources and watch the report re-run.
For the positive control, delete ${uiSref('eslint-html')} from the second tab in src/main.ts: lit-ui-router/anchor-is-valid now reports against the app itself rather than the gallery, as an error — and the tab stops navigating, which is the whole reason the rule exists.
The example is the npm-installed end of a check this repository runs on itself: oxlint loads the same six rules as JS plugins and holds the whole repo to them at error, sample apps included, where every navigation anchor is a uiSref call site.
Semver policy
Following ESLint core's own policy: a change that makes recommended or an existing rule stricter — new reports on code that previously passed — ships as a major.
New router surface is the exception. lit-ui-router keeps adding directives, and a rule that can only fire on one of them reports on nothing anybody could already have written — the API it matches did not exist yet. Those rules are additive, so they ship as a minor. The attribute-part tier is the current example: sref-active-class-aria-current, sref-status-aria-current and directive-position's attribute rows match only srefHref, srefActiveClass, srefAriaCurrent and SrefStatusController imported from lit-ui-router, all new in lit-ui-router@1.14. A codebase on an earlier router cannot trip them, which is why they arrive in 1.1 rather than 2.0.
What bounds the exception is the surface a rule can reach, not how rare a report would be in practice. A new report reachable through uiSref or uiSrefActive — shipped since 1.0 — is a major however unlikely it is, as is a tightened option default, and so is a rule for router API that shipped but went undocumented: the line is that the router did not export it, not that nobody used it.
The option-aware tier widened recommended during the 1.0.0 release candidates, where a widening cost nobody a major; the remaining roadmap (a state-aware tier) is the same trajectory, so expect majors rather than silent tightening, and pin accordingly.
Further reading
- Design System Links — what
uiSrefdoes to an anchor, and why the href only exists at runtime uiSrefAPI — the directive itself, includingassignHref