Client

custom-elements

  • mmo.client
  • agent skill
  • mmoframework-skill://mmo.client/mmo-client/references/custom-elements.md
# Writing the `<script>` class Source of truth: `src/JHSCompiler.php` (`compileText`, the regex that finds your class and splices it), `src/js/elements/{CustomElement,CustomShadowElement,Widget,CustomAPIElement}.js`. ## What the compiler does with your `<script>` Given: ```html <mmo-progress value> ...markup... <script> class Progress { get value() {return this.view.content.get('value');} set value(value) {/* ... */} } </script> </mmo-progress> ``` the compiler: 1. Finds `class Progress` (or `class Progress extends SomeBase`) by regex against the tag name in both kebab (`progress`... actually the raw tag minus `mmo-`) and PascalCase form. 2. Rewrites it to `MMO.define('mmo-progress', class MMO_Progress_JHS extends CustomShadowElement(HTMLElement) { ... }` where `CustomShadowElement` is whatever you wrote after `extends` (default: `MMO.elements.CustomShadowElement`) and `HTMLElement` is whatever `is="..."` maps to (default: plain `HTMLElement`). 3. Injects, as the **first** members of the class body: `static defaultAttributes()` (from the root tag's own attributes) and `render($view) { ...compiled markup...; ...compiled <style>...; }`. 4. Splices in the rest of your class body (constructor, getters/setters, methods) verbatim after that. 5. Appends an auto-generated `documentation() { return {...}; }` built from JSDoc comments found in your script (see below) — this feeds the framework's own doc tooling (`mmo-inspector` element, `DocGenerator.php`), so writing `@attr`/`@css`/`@param`/`@return` comments is not optional decoration, it's how this element becomes discoverable/documented at runtime. **Consequence:** never define a method literally named `render` in your `<script>` — the compiler already generates one and yours would collide (last-definition-wins in JS class bodies, so your logic would silently be dead or override the generated markup, depending on splice order — don't rely on either, just don't name a method `render`). ## Base classes — pick the right one All are **mixins**: `SomeBase(HTMLElement)`, not `extends SomeBase`. You normally don't write the mixin call yourself — the compiler does it based on your `extends` clause and `is=`. | Base (in `MMO.elements.*`) | Adds | Use when | |---|---|---| | `CustomElement` | Web Component lifecycle wired to `this.view` (a light-DOM `View`): `connectedCallback`→`init()`→`render()`, `attributeChangedCallback` auto-synced to `view.content`, `promise`/`domReady()`/`ready()` promises, `onMutation()` helper. **No shadow DOM.** | You explicitly don't want style/DOM encapsulation (rare — most elements want shadow DOM). | | `CustomShadowElement` (**default**, what the compiler assumes) | `CustomElement` + `attachShadow({mode:'open'})`, `queryShadow()`/`queryShadowAll()`. This is what `<style>` in your `.jhs` file actually scopes to. | The default for every normal `<mmo-xxx>` element. | | `Widget` | `CustomShadowElement` + a `url`/`fields`/`data`/`schema` convention for elements that load a *list* of entities from the framework's JSON:API-style backend and want a fields/schema configuration UI (`load()`, `updateFields()`, `reload()`). Marked `@todo remove not used` in source — check if still current before basing new work on it. | A grid/list-style widget bound to a repository collection. | | `CustomAPIElement` | `CustomShadowElement` + a single-entity API convention: `url` setter accepts either a plain URL string or a `'{"collection":...}'` JSON string, wraps it in `MMO.core.APIRequester`, auto-`reload()`s when visible, exposes `data`/`schema`. | An element bound to one entity/record via the API rather than a data prop passed in. | ## Lifecycle - **`constructor()`**: runs before the element is in the DOM. `this.view` already exists (empty). If you override it, call `super()` first, as usual. Set up bindings that don't touch the DOM yet. - **`init()`**: called once, right after `connectedCallback`, before the first `render()`. `CustomShadowElement.init()` just calls `this.render(this.view)`. Override to run setup that needs the (now-existing) shadow DOM/view, calling `super.init()` at the right point — if you need to wait for the full document to be ready first: `init() {this.domReady(() => super.init());}`. - **attribute → property sync**: for every name in `static get observedAttributes()` (default: `Object.keys(this.constructor.defaultAttributes)`, which the compiler fills from your root tag's attributes), the base constructor defines `get`/`set` on `this` that read/write `this.view.get(key)`/`this.view.set(key, value)` **unless you already defined that getter/setter yourself** (`if (!(key in this))`) — write your own `get value()`/`set value(value)` whenever the raw attribute string needs coercion (number, boolean, JSON) or side effects (updating the DOM directly, dispatching a `change` event), exactly as `mmo-progress.jhs`/`mmo-radio.jhs` do. - Attribute names are **kebab-case in HTML, camelCase in JS** — `key-var` → `keyVar`, automatically, both in `attributeChangedCallback` and the property-definition loop. - **`remove()`/`replaceWith()`** are overridden to clean up `this.view` first — don't bypass them by manipulating the DOM node directly if the element owns view state. ## `documentation()` JSDoc conventions (feeds the auto-generated docs) Inside the class's leading `/** ... */` comment (right above `class Foo {`): ```js /** * One-line, then optional longer description. * @css classname - what this modifier class does * @css {color} - a templated tag (matches usage like @css success) * @attr name - what this attribute controls */ class Foo { ... } ``` Above a getter: ```js /** * Get the value * @return {number} description of what's returned */ get value() { ... } ``` Above a method: ```js /** * What this method does * @param {type} name - description * @return {type} description */ someMethod(name) { ... } ``` Setter JSDoc is parsed but intentionally discarded by `extractJSDoc` in `src/JHSCompiler.php` — document the property on the **getter**, not the setter. ## Attribute declaration shape Root-tag attributes → `defaultAttributes`. Two forms: ```html <mmo-radio readonly value required> <!-- bare: boolean-ish, default null --> <mmo-progress value="0"> <!-- with default --> ``` `static get defaultAttributes() {return {...};}` is generated for you from these — don't hand-write it unless you need attributes that *aren't* on the root tag for some reason (uncommon; if you do, you also then own `static get observedAttributes()`, since the default implementation just mirrors `defaultAttributes`' keys). ## Cross-file dependency errors The compiler resolves your class body with a plain regex, not a real JS parser — a stray `{` / `}` inside a string or comment in your `<script>` that isn't balanced against `findMatching()`'s tracked contexts (`"`, `'`, `//`, `/* */`) will produce "cannot find class matching brackets" at compile time. Keep braces balanced and prefer template literals over manual string concatenation with embedded `{`/`}` where it matters. Worse than an error, a mismatch can compile silently: the class then ends early, the rest of the script lands after it, and the bundle fails with `Unexpected token '{'` in the browser (`node --check` on `compileText()`'s output shows it at once). Found writing `mmo-chart-config.jhs`: - an escaped quote (`'a chart\'s'`) is not understood — the string ends at `\'`. Use the other quote: `"a chart's"`; - a `"` or `'` inside a **regex literal** opens a string (`/"/g`): write `\x22` / `\x27`, as `mmo-chart.jhs` does; - `//` inside a regex (`/<\//g`) starts a "comment" to the end of the line — harmless only if that line holds no brace. The class is also **renamed** (`class Foo` becomes `MMO_Foo_JHS`), so `Foo.STATIC` inside it is a `ReferenceError` at runtime: reach statics through `this.constructor.STATIC` (in a static method, `this.STATIC`). And every `View` watch — so every `<if>`, `<for>` and `{path}` update — runs on the next animation frame (`MMO.window.run`), not when `set()` returns: a node inside an `<if>` whose test just turned true is not there yet, and a hidden browser tab runs no frame at all. Fill such a node from a `watch()` on the path that shows it.