Components
A component is a class that extends Base, a name in the registry, and a data-component attribute in the markup. All three are needed, and nothing else is.
Declaring a component
import { Base, registerComponent } from '@studiometa/js-toolkit';
class Counter extends Base {
static config = {
name: 'Counter',
refs: ['output'],
options: { step: { type: Number, default: 1 } },
};
count = 0;
onClick() {
this.count += this.$options.step;
this.$refs.output.textContent = String(this.count);
}
}
registerComponent(Counter);<button data-component="Counter" data-option-step="5">
clicked
<span data-ref="output">0</span>
times
</button>config.name is the name the registry uses and the token the markup writes. It is also what gives each instance its $id.
Registering
registerComponent(ComponentClass) puts one name in the registry and scans the document for it. registerComponents(...classes) does the same for several:
import { Base, registerComponents } from '@studiometa/js-toolkit';
class Accordion extends Base {
static config = { name: 'Accordion' };
}
class Slider extends Base {
static config = { name: 'Slider' };
}
registerComponents(Accordion, Slider);There is no createApp()
v4 has no root component and no application object. A component is registered, not mounted by a parent, so nothing has to own the page. Register the components a page uses and the registry does the rest.
One name gives one entry, as with customElements.define(). A second registration under a name already taken gives a registry.conflict warning and is ignored.
Several components on one element
data-component holds a whitespace-separated token list, and each token gets its own instance:
<a href="/next" data-component="Action Analytics Prefetch">Next</a>The three instances are independent. They share the element and nothing else.
Nesting
Children are not constructed by their parent. A nested data-component is discovered by the same registry, on its own:
<div data-component="Accordion">
<div data-component="AccordionItem">…</div>
<div data-component="AccordionItem">…</div>
</div>What the parent declares in config.components is a family, and it does two jobs:
- registering that family when the parent registers, so one
registerComponent()call covers a whole tree; - giving the name set that
on<Child><Event>resolution needs.
import { Base, registerComponent } from '@studiometa/js-toolkit';
class AccordionItem extends Base {
static config = { name: 'AccordionItem' };
}
class Accordion extends Base {
static config = {
name: 'Accordion',
components: { AccordionItem },
};
}
// Registers `Accordion` *and* `AccordionItem`.
registerComponent(Accordion);A value can also be a thunk, which makes the child its own chunk:
static config = {
name: 'Accordion',
components: {
AccordionItem: () => import('./AccordionItem.js'),
},
};The key supplies the name, so the registry knows AccordionItem with nothing downloaded. See Autoloading.
config.components is not ownership
Declaring a child does not make the parent construct it, does not make the parent's unmount unmount it, and does not restrict where the child may appear. Nesting is DOM ancestry, always.
Finding other components
There is no $parent and no $children. A component that needs to reach another one has four channels, in order of preference:
| Direction | Use |
|---|---|
| parent → children | $watchChildren() or $query(name) |
| child → parent | $emit() and the parent's on<Child><Event> |
| ancestor lookup, ad hoc | $closest(name) — guard the result |
| shared state either way | provide/inject |
import { Base } from '@studiometa/js-toolkit';
class Accordion extends Base {
static config = { name: 'Accordion', components: {} };
// A live collection, in document order, kept for the life of this instance.
items = this.$watchChildren('AccordionItem');
closeAll() {
for (const item of this.items) item.$el.removeAttribute('data-option-open');
}
}Page-wide lookups
Outside a component, four functions answer from the DOM. They keep no registry of instances:
import {
getInstance,
getInstances,
getMountedInstances,
getUnmountedInstances,
} from '@studiometa/js-toolkit';
const section = document.querySelector('section')!;
getMountedInstances('Dialog'); // the live ones — safe to call a method on
getInstances('Dialog', section); // every one built in a region
getUnmountedInstances('Dialog'); // built, then stood down
getInstance(section, 'Dialog'); // the one on this element, mounted or not
getInstances(section); // everything on one elementSee the Registry reference for the full contract of each.
Extending a component
There is no withExtraConfig(). To extend a component with a different config, declare a class. $config walks the prototype chain, so refs, options and components merge:
import { Base, registerComponent } from '@studiometa/js-toolkit';
class AbstractControl extends Base {
static config = {
name: 'AbstractControl',
refs: ['button'],
};
}
class NavigationControl extends AbstractControl {
static config = {
name: 'NavigationControl',
// `refs` merges: ['button', 'compass']
refs: ['compass'],
options: { showCompass: Boolean },
};
}
registerComponent(NavigationControl);To extend a class you cannot edit, do it in expression position:
registerComponent(
class extends Vendor {
static config = { name: 'CompactVendor', options: { compact: Boolean } };
},
);When a component mounts
By default an instance mounts as soon as its element enters the document. config.mountStrategy and the data-mount attribute change that:
<div data-component="Map" data-mount="visible"></div>
<div data-component="Chat" data-mount="interaction:page"></div>A component that waits has no instance: it is invisible to $query(), $closest(), $watchChildren() and getInstances(), and it announces nothing. See Mount strategies.
Responsive declarations
data-component takes one breakpoint-scoped companion, so a component can exist at some widths and not others:
<div
data-component="Action Analytics"
data-component:xxs="MobileMenu"
data-component:m="DesktopMenu"></div>The unconditional set is always active. The scoped set is resolved by walking from the widest active suffix down to the first attribute present, and it replaces rather than merges. A name that stops being declared is unmounted and dropped from the element; a crossing back builds a new instance. See data-component.