Using third-party web components
Component libraries built as custom elements (Web Awesome, Shoelace, Material Web, Spectrum, or a design system of your own) work inside Gyral views, because to Gyral they're just elements. You render them in the view, turn their events into intents, and style them through the hooks they expose.
Their events as intents
A library element fires its own events, such as wa-change or sl-change. Put
data-intent-on on the element to say which event fires its intent. A component listens for
every event its templates name there, so nothing else is needed.
// src/size-field.ts
import { define, html } from '@gyral/core';
export interface State {
readonly size: string;
}
export type Msg = { readonly _tag: 'SizeChanged'; readonly size: string };
/** Reads `value` from any element that has one (library inputs usually do). */
const valueOf = (el: Element): string | undefined =>
'value' in el && typeof el.value === 'string' ? el.value : undefined;
export const SizeField = define<State, Msg>()('my-size-field', {
init: () => ({ size: 'M' }),
intent: {
SizeChanged: ({ target }) => {
const size = valueOf(target);
return size === undefined ? undefined : { _tag: 'SizeChanged', size };
},
},
update: { SizeChanged: (_s, m) => ({ size: m.size }) },
view: (s, i) => html`
<sl-select
label="Size"
.value=${s.size}
data-intent=${i.SizeChanged}
data-intent-on="sl-change"
>
<sl-option value="S">Small</sl-option>
<sl-option value="M">Medium</sl-option>
<sl-option value="L">Large</sl-option>
</sl-select>
<p>Size: <output>${s.size}</output></p>
`,
});Without data-intent-on, Gyral treats any custom element with a data-intent as a child
component and waits for its gyral-output event, which a library element never sends. When the
event name would be bound (data-intent-on=${…}), write it into the attribute's name instead,
as below, so the component knows to listen for it.
The event can also go in the attribute's name: data-intent-sl-change=${i.SizeChanged} does
the same as the pair above, and leaves data-intent free for another event on the same element
(see Intent). Attribute names are lower case,
so this works for event names without capitals. For a name such as valueChange, use
data-intent-on="valueChange".
What the parser gets
targetis the library element. Read its state from it (value,checked, …), as the example does. Thevaluefield of the intent input is only filled for native<input>,<select>,<textarea>and<button>elements.detailis thedetailof anyCustomEvent, so a library that sends data that way (a map's selected marker, a date picker's range) reaches the parser like a Gyral child's output. Its type isunknown: check it with a type guard or a schema before you use it.eventis the raw event, if you need anything else.
A store finder whose map element dispatches marker-select, with the marker as detail:
// src/store-finder.ts
import { define, html } from '@gyral/core';
export interface Marker {
readonly id: string;
readonly lat: number;
readonly lng: number;
}
const isMarker = (u: unknown): u is Marker =>
typeof u === 'object' &&
u !== null &&
'id' in u &&
typeof u.id === 'string' &&
'lat' in u &&
typeof u.lat === 'number' &&
'lng' in u &&
typeof u.lng === 'number';
export type Msg = { readonly _tag: 'Select'; readonly marker: Marker };
export const StoreFinder = define<{ readonly selected: string }, Msg>()('my-store-finder', {
init: () => ({ selected: '' }),
intent: {
Select: ({ detail }) => (isMarker(detail) ? { _tag: 'Select', marker: detail } : undefined),
},
update: { Select: (_s, m) => ({ selected: m.marker.id }) },
view: (s, i) => html`
<geo-map data-intent-marker-select=${i.Select}></geo-map>
<p>Selected store: <output>${s.selected}</output></p>
`,
});The element's events must bubble, or be dispatched on the element that carries the intent attribute, as here.
Keep the parser the place where library specifics live: the message it returns is plain data,
so update and your tests never know which library you used.
This website's build checks this path: a Gyral component hosts a non-Gyral custom element,
receives its custom event through data-intent-on alone, and reads both detail and the
element. A second component receives the same event through a data-intent-<event> attribute.
Styling them
Style a library element from your component's styles, with the hooks the library documents:
- Custom properties pass through shadow roots, so a library's tokens can be set on the
element or on
:host::host { --sl-input-border-radius-medium: 0; }. ::part()reaches the parts a library exposes:sl-select::part(combobox) { border-color: var(--brand); }.- Ordinary selectors style the element itself (its host box), not its insides.
Gyral's own theming uses the same two mechanisms, so one set of design tokens can drive your components and the library's.
Loading and server rendering
- Load the library in the browser, the way its docs say (usually one import per component, or a loader script). Its elements upgrade when their definitions arrive.
- On the server, don't import browser-only libraries. Gyral's server renderer writes a custom
element that isn't a Gyral component as a plain element, with its attributes and children, so
<sl-select>and its<sl-option>children arrive as plain HTML and the library upgrades them in the browser. Hydration leaves whatever the library renders inside it alone. Property bindings such as.value=${…}are applied in the browser only; pass anything the first paint needs as an attribute. - Until the library loads, its elements aren't interactive. For anything that must work
without JavaScript, such as a form's main fields, use native elements (
<select>,<input>) and style them.
Types
Libraries usually register their tags in HTMLElementTagNameMap when you import their types,
which lets target instanceof checks and querySelector know the element's class. Without
that, read properties defensively, as valueOf does above.