Web components
@fluixi/web-components builds standard custom elements with Fluixi's reactivity inside. The
element works in any page and any framework, React, Vue, Svelte or none, and in a Fluixi app it
renders on the server and hydrates like any other part of the page.
import { defineCustomElement, prop, typedElement } from '@fluixi/web-components';
import { html } from '@fluixi/dom';
import { signal } from '@fluixi/reactive/signal';
const counterProps = {
step: prop.number(1), // step="5" or el.step = 5
label: prop.string('Count'),
};
export class FxCounter extends typedElement(counterProps) {
#count = signal(0);
protected override render() {
return html`
<span>${() => this.label}</span>
<output>${() => this.#count()}</output>
<button @click=${() => this.increment()}>+${() => this.step}</button>
`;
}
get count() {
return this.#count();
}
increment() {
this.#count.set(this.#count() + this.step);
this.emit('change', { value: this.#count() });
}
}
defineCustomElement('fx-counter', FxCounter);
render runs once. Each binding in it updates the one node it belongs to when a prop or a
signal changes; nothing re-renders. JSX works as well as html ``.
Props
A prop is a property and the attribute that mirrors it, kept on one signal: el.step = 5 and
step="5" land in the same place, and a framework binding and server-rendered HTML agree.
| Declaration | Attribute |
|---|---|
prop.string(initial) |
as written |
prop.number(initial) |
parsed; a value that is not a number keeps initial |
prop.boolean(initial) |
present or absent |
prop.object(initial) |
JSON |
prop.custom(initial, { fromAttribute, toAttribute }) |
your conversion |
prop.any(initial) |
none: a value only a property can carry, such as a function |
Options: attribute renames the attribute (the property name lowercased by default) or turns it
off with false; reflect: true writes the property back to the attribute on every change;
equals decides when a new value is a change.
typedElement(props) gives the class those props as typed fields. A subclass extends it like any
class, spreading the parent's props:
const boundedProps = { ...counterProps, max: prop.number(10) };
export class FxBoundedCounter extends FxCounter {
static override props = boundedProps;
declare max: number;
}
Events
this.emit(type, detail) dispatches a CustomEvent that bubbles out of the element. List them in
an interface so consumers get typed handlers:
export interface CounterEvents {
change: { value: number };
}
Using it in JSX
Register the element's types once, and JSX checks its attributes and events like any tag:
declare global {
interface HTMLElementTagNameMap {
'fx-counter': FxCounter;
}
interface JSXCustomElements {
'fx-counter': ElementAttributes<typeof counterProps, CounterEvents>;
}
}
<fx-counter step={5} label="Points" onChange={(e) => save(e.detail.value)} />
A function, an object or an array passed as a prop is assigned as a property, never stringified into an attribute.
Shadow DOM and styles
Elements render into the light DOM by default, so page CSS reaches them and server-rendered
children stay where they are. Set static shadow = 'open' (or attachShadow options such as
{ mode: 'open', delegatesFocus: true }) for a shadow root, and static styles for its CSS:
export class FxTab extends typedElement(tabProps) {
static override shadow = 'open' as const;
static override styles = `:host { display: contents; } [role=tab] { font: inherit; }`;
}
Each distinct stylesheet is constructed once and shared by every instance and subclass. A parent
meant to be extended declares static styles: ElementStyles, and a subclass adds to it with
[super.styles, '...'].
Context between elements
Elements share values the way components do. One provides, any element inside it consumes, and the context objects are those of Context, so an element also reads what a Provider in the app gives:
const Tabs = createContext<TabsState | null>(null, 'tabs');
class FxTabs extends typedElement(tabsProps) {
protected override render() {
this.provide(Tabs, { selected, select });
return html`<slot></slot>`;
}
}
class FxTab extends typedElement(tabProps) {
protected override render() {
const tabs = this.consume(Tabs); // an accessor; follows the provider when it arrives
// ...
}
}
It follows the community context protocol, so it interoperates with @lit/context in both
directions. An element defined before its provider picks the value up when the provider
announces itself.
Forms and focus
FormAssociated makes an element a form control the way <input> is: it submits a value, reports
validity, follows a disabled <fieldset>, resets and restores.
class FxRating extends FormAssociated(typedElement(ratingProps)) {
select(value: number) {
this.value = value;
this.setFormValue(String(value));
this.setValidity(value ? {} : { valueMissing: true }, 'Pick a rating.');
}
}
Page code treats it like any input: el.form, el.checkValidity(), new FormData(form). For
keyboard handling, rovingFocus moves focus between items with the arrow keys, Home and End,
trapFocus keeps it inside a dialog, and tabbables and deepActiveElement find focusable
elements across shadow roots.
A function instead of a class
defineElement defines an element from a setup function, a subclass underneath:
defineElement('fx-greeting', ({ props }) => html`<p>Hello, ${() => props.name()}</p>`, {
props: { name: prop.string('world') },
});
The setup receives props as accessors, set, dispatch, provide, consume, targets and
children. Use the class when the element has public methods or will be extended.
Server rendering
In a Fluixi app, an element used in a page renders on the server like the page around it: its props as attributes, objects as plain JSON, its content in the light DOM. In the browser it adopts that markup instead of building it again, and waits for the app to hydrate so it reads the app's context. Declarative shadow DOM is not emitted yet, so a shadow-root element renders its content on the client.
Loading and registration
defineCustomElement(name, constructor) registers through the browser's registry and skips a name
already defined, which matters when standalone bundles each include a shared parent class.
defineWhenUsed(name, load) registers only once the tag appears in the document, so a page that
never uses an expensive element never loads it.