URL Help System
Espalier's help system is a progressive-disclosure ladder:
- A form item's
hintanswers the common question without interaction. - A help affordance identifies fields or features with deeper guidance.
- A URL-authored document opens in the page flyout with the requested topic first and the surrounding document one action away.
The content stays application-owned. A documentation team can author Markdown, a CMS can own the source, or the application can publish static pages; the URL consumed by Espalier must ultimately return HTML. One document can cover a whole form or feature as long as its topic headings have stable ids.
Try The Live Demo
The example below is live on this page. Activate either information button to fetch the docs-hosted account document and open the matching heading in the flyout. Activate the pressed button again to close that help surface without disturbing other open flyouts.
For viewport, footer, preview, navigation, and resize testing, use the full-page workspace lab instead of this bounded guide example.
This guide is itself inside an esp-page, so the live example demonstrates the default nesting behavior: its help opens in the outer documentation page's flyout rather than being trapped inside the example frame. In an application where the example page is the outermost page, the same markup opens that page's flyout.
Place one <esp-help-provider> inside the application's <esp-root>, alongside the page's shared <esp-flyout>. The example uses match-surface so the panel adopts the page surface's outward-facing elevation without casting a shadow across the main content. Declare help-src once on the nearest useful ancestor:
<style>
esp-page.help-system-demo::part(wrapper) {
min-height: auto;
}
</style>
<esp-root>
<esp-help-provider></esp-help-provider>
<esp-page class="help-system-demo" help-src="/examples/help/account/">
<esp-flyout slot="flyout" match-surface></esp-flyout>
<esp-form-item
label="Display name"
field-name="display-name"
hint="Use the name other people recognize.">
<esp-input></esp-input>
</esp-form-item>
<esp-form-item label="Email address" field-name="email">
<esp-input input-type="email"></esp-input>
</esp-form-item>
</esp-page>
</esp-root>
Both form items inherit /examples/help/account/. Their help affordances appear automatically because each has a resolvable document and a field-name; activating one requests #display-name or #email from that shared document. The contextual flyout aligns with and points at the field control, while closing it restores focus to the adjacent help button that opened it.
Form-item help uses the visible field label as the flyout title by default. Add help-title-source="document" to a form item when the matched heading in the help document should supply the title instead.
The provider is intentionally headless. It owns fetching, caching, normalization, and latest-request-wins cancellation, then passes ordinary content to the content-agnostic flyout. Use one provider per application surface; multiple providers would all service the same global bus request.
Flyout Scope
Anchored showFlyout() and help requests use the outermost esp-page containing the trigger by default. This makes application-level presentation the stable default even when a control lives inside a nested page, component preview, or embedded tool.
To deliberately keep requests inside a nested page, set scope="nearest" on that page's flyout. The flyout must be a light-DOM descendant of the esp-page whose requests it owns so the page can discover it. The explicit nearest flyout takes precedence over the outer flyout for triggers in its page and nested descendants:
<style>
esp-page.help-scope-demo::part(wrapper) {
min-height: auto;
}
</style>
<esp-page class="help-scope-demo" help-src="/examples/help/billing/">
<esp-flyout slot="flyout" scope="nearest" match-surface></esp-flyout>
<h3>
Nested help surface
<esp-help-button
anchor="billing-contacts"
label="Help inside nested page">
</esp-help-button>
</h3>
</esp-page>
standalone remains the full bus opt-out for a flyout controlled only through its show(), close(), or toggle() methods. Programmatic bus requests without an anchor or returnFocusTo have no nested-page context, so they are handled only by outermost page flyouts.
Source And Anchor Resolution
Resolution happens when the trigger is activated, walking the composed DOM tree so it can cross slots and shadow boundaries.
| Input | Precedence and behavior |
|---|---|
help-url with a fragment |
Replaces inherited help-src; its fragment is the topic. |
help-url without a fragment |
Replaces inherited help-src; the trigger's anchor or form item's field-name supplies the topic. |
nearest non-empty help-src |
Supplies the shared document URL. |
esp-help-button[anchor] |
Selects a heading in the inherited document. |
esp-form-item[field-name] |
Becomes the topic id when no help-url fragment overrides it. |
esp-form-item[help-title-source="document"] |
Uses the matched document heading as the flyout title instead of the form item's visible label. |
Relative URLs are resolved against the consuming page before the request is published. The document URL and topic are carried separately on the bus.
Override One Field
Use help-url when one field belongs to another document or its heading id cannot mirror the field name:
<section help-src="/examples/help/account/">
<esp-form-item
label="Contact email"
field-name="email"
help-url="/examples/help/contact/#contact-email">
<esp-input input-type="email"></esp-input>
</esp-form-item>
</section>
The explicit fragment contact-email wins over the derived email anchor.
Standalone Help Triggers
Use <esp-help-button> for page, feature, section, or dialog topics. Its icon-only control is keyboard reachable and needs a specific accessible label:
<section help-src="/examples/help/billing/">
<h2>
Billing contacts
<esp-help-button
anchor="billing-contacts"
label="Help for billing contacts">
</esp-help-button>
</h2>
</section>
For an exception, set a complete help-url. Set title only when the flyout heading should differ from the topic heading or document title:
<esp-help-button
help-url="/examples/help/exporting/#csv"
label="Help with CSV exports"
title="Export help">
</esp-help-button>
While the exact source and anchor are showing, the underlying button exposes aria-pressed="true" and the component reflects active for styling or inspection.
Programmatic Requests
requestHelp() publishes the same contract as the components:
import { requestHelp } from "@taprootio/espalier";
const trigger = document.querySelector<HTMLElement>("#account-help");
const field = document.querySelector<HTMLElement>("#display-name");
requestHelp({
src: "/help/account.html",
anchor: "display-name",
title: "Account help",
placementTarget: field ?? undefined,
trigger: trigger ?? undefined,
});
Pass trigger whenever a user control initiated the request; it receives focus when an overlay closes. Add placementTarget when the flyout should align with, point at, and derive page context from another element, such as the field controlled by an adjacent help button. When omitted, trigger supplies both placement context and focus restoration.
The public bus event is request-help with { src, anchor?, title?, placementTarget?, trigger? }. Applications can replace <esp-help-provider> with their own subscriber when they need a different renderer. A pressed trigger publishes close-help for its exact { src, anchor? }, allowing the provider to close only the active help flyout. Providers publish help-state-changed with { src, anchor?, open } so trigger state stays synchronized.
Authoring Help Documents
Topic ids belong on headings and should remain stable. For form help, mirror the field-name exactly:
<!doctype html>
<html lang="en">
<head>
<title>Account help</title>
</head>
<body>
<h1 id="account">Account</h1>
<p>Manage the identity and contact information shown to your team.</p>
<h2 id="display-name">Display name</h2>
<p>Use the name colleagues will recognize in assignments and activity.</p>
<h3 id="display-name-examples">Examples</h3>
<ul>
<li>Jordan Lee</li>
<li>Warehouse receiving</li>
</ul>
<h2 id="email">Email address</h2>
<p>This address receives account and recovery messages.</p>
</body>
</html>
For #display-name, the matched h2 supplies the topic title but is omitted from the contextual excerpt, which contains its paragraph and nested h3 section. The excerpt stops before the next heading of equal or higher rank (#email). Form-item help uses the form item's visible label as the flyout title by default; set help-title-source="document" to use the matched heading text instead. The contextual flyout is only as tall as that field's help, points at the field control, and remains in the field's page scroll flow; it may shift upward to remain visible without ever lifting its bottom above the field's bottom. “View full document” keeps that field anchor for topic state and focus restoration but transfers placement to a sticky viewport-height reading surface. It remains visible while the application scrolls through the bottom of the page, changes the flyout heading to the fetched document's normalized <title>, restores the complete normalized document including the matched section heading, and scrolls it into view inside the flyout without moving the application page. When the document begins with an h1 matching that title, only the embedded copy omits the redundant h1; the standalone help document remains unchanged.
If an id is missing, the provider shows the full document instead of an empty panel and logs one warning for that source-and-anchor pair. Treat the warning as anchor-drift feedback and restore the stable id.
Same-document links such as <a href="#email"> expand the full document when necessary and scroll to the linked topic. Relative links and image sources are rebased against the fetched document URL, not the application's current route. Non-fragment links open in a new, opener-isolated tab so help navigation does not replace the host application.
Strip-To-Bare Contract
Fetched markup is normalized into an internal shadow view so application and CMS classes cannot fight the Espalier theme.
The following elements are retained:
| Family | Elements |
|---|---|
| Headings and text | h1–h6, p, br, hr, blockquote |
| Emphasis and inline semantics | abbr, b, del, em, i, mark, q, s, small, strong, sub, sup, u |
| Code | code, kbd, pre, samp, var |
| Lists | ul, ol, li, dl, dt, dd |
| Figures and disclosure | figure, figcaption, details, summary |
| Tables | table, thead, tbody, tfoot, tr, th, td |
| Navigation and media | a, img |
Everything else is unwrapped while its retained descendants continue through normalization, except script, style, template, and noscript, whose entire contents are dropped. Comments are dropped as well.
Only these attributes survive:
| Element | Attributes |
|---|---|
| Every retained element | id |
a |
href |
img |
src, alt, width, height |
ol |
start, reversed |
li |
value |
td |
colspan, rowspan |
th |
colspan, rowspan, scope |
details |
open |
Classes, inline styles, event handlers, ARIA attributes, framework data attributes, link targets, and all other attributes are removed. URL attributes retain fragments plus http:, https:, mailto:, and tel: destinations; attributes using active-content or unsupported protocols are dropped. Put essential semantics into the retained HTML structure and text rather than an application-specific class or script.
Trust And Security Boundary
Help documents are owned and trusted by the consuming application. Espalier's normalization exists for visual consistency; it is not an HTML sanitizer or security boundary. It removes common active-content mechanisms incidentally, but applications must not use it to render untrusted user HTML.
Cross-origin documents work only when the serving host permits the application origin through CORS. A Markdown repository or CMS is a valid authoring source when its publishing pipeline exposes the resulting document as fetchable HTML.
Placement And Focus
Help uses the normal flyout placement ladder:
- In gutter and docked modes, opening help does not move focus. A contextual field topic sizes to its content and aligns with the field control; if it would overflow, it shifts upward without lifting its bottom above the field's bottom. Its leading-edge marker points at the control, and the compact surface scrolls with that field. When a persistent preview is also visible, the caret stays at the main/preview edge and a non-interactive 40%-opacity dotted line crosses preview to a short, thick, caret-height marker on the preview/help seam. Full-document help retains the originating field for topic and focus state but uses viewport-owned placement: it fills the visible viewport, remains sticky above the page footer, scrolls independently, and omits the field marker and bridge. Expanding or following a same-document topic never scrolls the application page.
- In overlay-drawer mode, the flyout is modal and moves focus into the panel. Closing restores focus to the originating help control.
- Requesting a second topic swaps the existing flyout content; help panels never stack.
Fallbacks And Failures
The fallback depends on which service is missing:
- If no help provider subscribes to
request-help,requestHelp()opens the resolved URL in a new, opener-isolated tab. - If a provider is present but no shared flyout services presentation, the provider uses the same new-tab fallback without starting a fetch.
- If the provider and flyout are present but fetching fails (network, HTTP, or CORS), the flyout stays open with an error and an explicit “Open help document” button. It does not surprise the user by opening a tab after an asynchronous failure.
This makes a standalone form's help affordance useful without requiring the full page shell, while keeping application-specific replacement straightforward.