Repository navigation
18 Async sections
Four components hold content that may not exist yet: a tab panel, a wizard step, a dialog body, a drawer body. Rather than each inventing its own loading callback, they share one protocol. Entering a section fires an event; you answer it by putting content in the section; the component handles the waiting, the spinner, the failure and the staleness.
Nothing is required. A component whose sections are already filled by the server never uses the protocol at all, and an activation nobody listens for costs nothing.
Entering a section fires section:requested on the component, and it bubbles, so a page-level listener works too. The target is always the component itself, never the section, which means e.target.localName tells you which family fired and e.target === e.currentTarget distinguishes a host's own sections from a nested component's.
Three types are fired for the same entry, so you can listen at whichever grain suits you.
| type | fired by | example |
|---|---|---|
section:requested |
always | the generic catch-all |
section:requested:#<index> |
tabs, wizard | section:requested:#1 |
section:requested:<name> |
wizard sections carrying data-step
|
section:requested:dettagli |
The # marks the index form, so a positional listener can never be confused with a name. Dialogs and drawers have one body and no names, so they fire the generic type alone.
Answer through exactly one of the three. All three reach the same listener set, so registering on both the generic type and the name loads the section twice.
Listeners are registered with AsyncEvents.asyncOn, which is what makes the component able to await them. One added with addEventListener still runs, but the component does not wait for it: the section shows at once, without the loading ring, and a failed delivery paints nothing.
const http = wizard.component('http-client');
ful.AsyncEvents.asyncOn(wizard, 'section:requested:dettagli', async (e) => {
if (!e.detail.first) {
return;
}
const html = await http.get(`/steps/${e.detail.name}`).fetchText();
e.detail.section.prepend(ftl.Fragments.fromHtml(html));
});The detail carries four things:
| field | meaning |
|---|---|
section |
the element to put content in |
name |
the section's data-step, where it has one |
index |
its zero-based position |
first |
whether this is the section's first answered entry |
Delivery is yours. The component never assigns the content: you append, prepend or replace inside detail.section however you like, which is what lets a delivery keep server-rendered markup that is already there.
sequenceDiagram
participant U as the user
participant C as the component
participant L as your listener
U->>C: opens a tab, moves a step
C->>L: section:requested
Note over C: a frame later, the loading ring
L-->>C: resolved
Note over C: ring off, section painted
C-->>U: the move resolves
The component awaits the union of every answer, and handles four things you would otherwise write yourself.
The spinner arrives a frame late. An answer that resolves synchronously never pends, so it never flashes a ring.
A failure paints, and rejects where someone is waiting. The section shows the problems the rejection carried. wizard.move() rejects too, since it has a caller; a tab click and a dialog open have none, so the chrome reports and that is the end of it.
A superseded delivery owns no chrome. Activate the same section again while the first answer is still in flight and the older one no longer touches the loading state, aria-busy or the error. Its content is another matter: whatever the older listener writes into the section still lands, so a listener that can be overtaken guards its own writes, by checking it is still the latest request before writing.
first is spent only by a listened activation. An activation nobody answered does not consume it, so a listener attached later still sees first: true on its first real entry.
While an answer pends the section carries the loading attribute and aria-busy="true", which the stylesheet draws as the ring, and a failure prepends a .ful-section-error with the alert role to the section, holding the problems' reasons one per line. Those are the hooks for css of your own.
refresh() fires the same event again, with first false from then on. The wizard takes a data-step name or the section itself, the tabs an index or the panel itself; a dialog and a drawer take no argument, having one body each.
await tabs.refresh(1);
await wizard.refresh('dettagli');
await drawer.refresh();Its failures paint the section's problems and stop there. move() and update() are the calls that reject, so those are the ones to await when the outcome matters to your code.
The same dispatch is available for events of your own, on the ful global.
asyncOn(el, type, fn) registers a listener the dispatcher can await and returns it, which is what asyncOff(el, type, listener) takes to remove it. fireAsync(el, evt, { mode }) fires one and waits, in one of three modes:
| mode | listeners | resolves with |
|---|---|---|
broadcast |
all of them, awaited together | an array of their values |
pipeline |
at most one | that listener's value, which flows on |
delegate |
exactly one, or it throws | that listener's value |
ful-form uses pipeline for submit:requested, which is how a local form's listener returns the response. The section protocol uses broadcast.
mixInto(...classes) installs fireAsync, asyncOn and asyncOff as methods on classes of your own, so a component can fire and register without reaching for the namespace.
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project