Standalone Components
Vue-style Custom Elements with reactive data, computed properties, lifecycle hooks, and surgical DOM binding - built on Web Components with Shadow DOM.
Standalone Components
What are Domma Components?
Domma Components unify the framework's primitives into a single declarative definition: a reactive Model for state, a Mustache template for markup, computed properties for derived values, and lifecycle hooks for side effects. Each component is a real Custom Element backed by Shadow DOM.
True style encapsulation - component CSS never leaks out, theme variables still flow in.
Simple {{field}} bindings update only that text node. Block changes ({{#if}}) trigger targeted re-renders.
JS object definition works at runtime. Optional .domma SFC format for build pipelines.
Reactive count field, a doubled computed property, methods,
and lifecycle logging. The count display updates surgically (only that text node changes);
the colour badge re-renders when the condition flips.
Lifecycle Log
Waiting for events…
Domma.component('counter-demo', {
// Inline template (use templateUrl for real projects)
template: `
<div class="counter-wrap">
<p class="count">Count: {{count}}</p>
<p class="doubled">Doubled: {{doubled}}</p>
{{#if high}}
<span class="badge-high">High!</span>
{{/if}}
<div class="actions">
<button class="dm-inc">+</button>
<button class="dm-dec">−</button>
<button class="dm-rst">Reset</button>
</div>
</div>
`,
data() { return { count: 0 }; },
computed: {
doubled() { return this.data.count * 2; },
high() { return this.data.count >= 10; }
},
methods: {
increment() { this.set({ count: this.data.count + 1 }); },
decrement() { this.set({ count: this.data.count - 1 }); },
reset() { this.set({ count: 0 }); }
},
onBeforeMount() { log('beforeMount'); },
onMount() {
log('mount');
// $() can't pierce Shadow DOM - wrap root.querySelector() in $()
$(this.root.querySelector('.dm-inc')).on('click', () => this.increment());
$(this.root.querySelector('.dm-dec')).on('click', () => this.decrement());
$(this.root.querySelector('.dm-rst')).on('click', () => this.reset());
},
onUpdated() { log('updated - count=' + this.data.count); },
onUnmount() { log('unmount'); },
style: `
.counter-wrap { text-align: center; padding: 1.5rem; }
.count { font-size: 1.5rem; font-weight: 700; margin: 0 0 .25rem; }
.doubled { color: #6b7280; font-size: 0.9rem; margin: 0 0 .75rem; }
.badge-high { display: inline-block; background: #dc2626; color: #fff;
padding: .2em .6em; border-radius: 9999px;
font-size: .75rem; font-weight: 600; margin-bottom: .75rem; }
.actions { display: flex; gap: .5rem; justify-content: center; }
button { padding: .4rem 1rem; border: none; border-radius: 6px;
background: var(--dm-primary, #6495ED); color: #fff;
cursor: pointer; font-size: 1rem; }
button:last-child { background: #6b7280; }
`
});
Demonstrates props (type coercion, defaults), simulated async data loading
with {{#if loading}} conditional rendering, and a computed
initials property.
Domma.component('user-card-demo', {
props: {
userId: { type: M.types.number, required: true },
showAvatar: { type: M.types.boolean, default: true }
},
data() {
return { name: '', role: '', loading: true };
},
computed: {
initials() {
return this.data.name
.split(' ').map(n => n[0] || '').join('').toUpperCase();
}
},
methods: {
async fetchUser() {
this.set({ loading: true });
// Simulate API call (replace with: H.get('/api/users/' + this.props.userId))
await new Promise(r => setTimeout(r, 600));
const users = {
42: { name: 'Alice Nguyen', role: 'Senior Engineer' },
7: { name: 'Bob Okafor', role: 'Product Manager' }
};
const user = users[this.props.userId]
?? { name: 'Unknown User', role: '-' };
this.set({ name: user.name, role: user.role, loading: false });
}
},
onMount() { this.fetchUser(); },
onPropsChanged(name) {
if (name === 'userId') this.fetchUser();
},
template: `
<div class="card">
{{#if loading}}
<div class="skeleton-wrap">
<div class="skeleton avatar"></div>
<div class="skeleton-lines">
<div class="skeleton line"></div>
<div class="skeleton line short"></div>
</div>
</div>
{{/if}}
{{#unless loading}}
<div class="user-row">
{{#if showAvatar}}
<div class="avatar">{{initials}}</div>
{{/if}}
<div>
<p class="name">{{name}}</p>
<p class="role">{{role}}</p>
</div>
</div>
{{/unless}}
</div>
`,
style: `/* … component-scoped CSS … */`
});
Use a component as a route view. Props are derived from URL parameters.
// Route definition - props come from URL params
R.route({
path: '/users/:id',
component: 'user-card-demo', // Custom element tag name
props: (params) => ({ // Function receives route params
userId: parseInt(params.id, 10)
})
});
When this route activates, the router creates <user-card-demo user-id="…">
and mounts it in the route container - no extra glue code needed.
For build-pipeline projects, the optional Rollup plugin lets you co-locate
template, script, and styles in a single .domma file (similar to Vue's
.vue format). The filename becomes the tag name.
<template>
<div class="card">
<div class="avatar">{{initials}}</div>
<p class="name">{{name}}</p>
<p class="role">{{role}}</p>
</div>
</template>
<script>
export default {
props: {
userId: { type: M.types.number, required: true }
},
data() {
return { name: '', role: '', loading: true };
},
computed: {
initials() {
return this.data.name.split(' ').map(n => n[0]).join('');
}
},
methods: {
async fetchUser() {
const user = await H.get('/api/users/' + this.props.userId);
this.set({ name: user.name, role: user.role, loading: false });
}
},
onMount() { this.fetchUser(); }
};
</script>
<style>
.card { padding: var(--spacing-md); text-align: center; }
.avatar { width: 3rem; height: 3rem; border-radius: 50%;
background: var(--dm-primary); color: #fff;
display: flex; align-items: center;
justify-content: center; margin: 0 auto .75rem; }
.name { font-weight: 700; margin: 0; }
.role { color: #6b7280; font-size: .875rem; margin: 0; }
</style>
import { dommaPlugin } from './src/plugins/rollup-plugin-domma.js';
export default {
input: 'src/main.js',
plugins: [
dommaPlugin(), // Handles .domma files
// … other plugins
]
};
How it works
- Plugin detects
.dommafile extension - Extracts
<template>,<script>,<style>sections - Derives tag name from filename (
user-card.domma → user-card) - Wraps
export defaultdefinition into aDomma.component()call - Standard Rollup bundling handles the rest
Definition Options
| Option | Type | Description |
|---|---|---|
template | string | Inline Mustache template |
templateUrl | string | URL to external template file |
props | object | Declared HTML attributes with type/default |
data() | function | Returns initial reactive state |
computed | object | Derived values (re-evaluated each render) |
methods | object | Functions bound to the component context |
style | string | Scoped CSS injected into Shadow DOM |
Lifecycle Hooks
| Hook | When |
|---|---|
onBeforeMount() | Before template is rendered |
onMount() | After template rendered + bindings established |
onUpdated() | After any data change, once per flush - including fields no binding uses |
onBeforeUnmount() | Before element is removed from DOM |
onUnmount() | After element removed and cleaned up |
onPropsChanged(name, old, new) | When an observed attribute changes |
Context (this) inside methods & hooks
this.data // Reactive data snapshot (toJSON)
this.props // Resolved props object
this.root // shadowRoot reference
this.el // The host <custom-element> itself
this.set({ field: value }) // Batch-update reactive data
// (triggers bindings + onUpdated)
// Methods are called via this.methodName()
// Computed values appear as this.computedName
// Accessing props in methods:
const id = this.props.userId;
// Updating state:
this.set({ loading: false, name: 'Alice' });
<!-- ── Concept overview ─────────────────────────────────────────────── -->
<div class="card card-accent-primary mb-6">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<div data-icon="code" class="text-primary" style="font-size: 2rem; flex-shrink: 0;"></div>
<h3 class="text-xl font-semibold mb-0">What are Domma Components?</h3>
</div>
<p class="text-muted mb-4">Domma Components unify the framework's primitives into a single
declarative definition: a reactive <strong>Model</strong> for state, a
<strong>Mustache template</strong> for markup, <strong>computed properties</strong>
for derived values, and <strong>lifecycle hooks</strong> for side effects.
Each component is a real Custom Element backed by Shadow DOM.</p>
<div class="row g-3">
<div class="col-4">
<div class="badge badge-primary mb-2">Shadow DOM</div>
<p class="text-sm mb-0">True style encapsulation - component CSS never leaks out, theme variables still flow in.</p>
</div>
<div class="col-4">
<div class="badge badge-success mb-2">Surgical Updates</div>
<p class="text-sm mb-0">Simple <code>{{field}}</code> bindings update only that text node. Block changes (<code>{{#if}}</code>) trigger targeted re-renders.</p>
</div>
<div class="col-4">
<div class="badge badge-warning mb-2">Zero Build Step</div>
<p class="text-sm mb-0">JS object definition works at runtime. Optional <code>.domma</code> SFC format for build pipelines.</p>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
EXAMPLE 1: Counter Demo
══════════════════════════════════════════════════════════════════════ -->
<div class="card mb-6" id="card-counter" data-section="counter">
<div class="card-header">
<div class="card-header-content">
<span class="font-bold">Example 1 - Counter</span>
<span class="badge badge-secondary ms-3">reactive · computed · lifecycle</span>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-5">
Reactive <code>count</code> field, a <code>doubled</code> computed property, methods,
and lifecycle logging. The count display updates surgically (only that text node changes);
the colour badge re-renders when the condition flips.
</p>
<div class="row g-4">
<div class="col-5">
<div class="component-demo-wrapper mb-4">
<div class="component-demo-header">
<span class="demo-dot red"></span>
<span class="demo-dot yellow"></span>
<span class="demo-dot green"></span>
<span class="ms-2"><counter-demo></span>
</div>
<div class="component-demo-body">
<counter-demo></counter-demo>
</div>
</div>
<h4 class="font-semibold mb-2">Lifecycle Log</h4>
<div class="lifecycle-log" id="counter-log">
<p class="text-muted" data-if="entries.value.length === 0">Waiting for events…</p>
<div data-each="entries.value key=id">
<p><span class="log-time" data-bind-text="time"></span><span data-bind-class="'log-' + type" data-bind-text="msg"></span></p>
</div>
</div>
</div>
<div class="col-7">
<div class="code-file-tab">counter-demo.js</div>
<pre class="code-block code-block-scroll" style="font-size: 0.78rem; line-height: 1.5;"><code>Domma.component('counter-demo', {
// Inline template (use templateUrl for real projects)
template: `
<div class="counter-wrap">
<p class="count">Count: {{count}}</p>
<p class="doubled">Doubled: {{doubled}}</p>
{{#if high}}
<span class="badge-high">High!</span>
{{/if}}
<div class="actions">
<button class="dm-inc">+</button>
<button class="dm-dec">−</button>
<button class="dm-rst">Reset</button>
</div>
</div>
`,
data() { return { count: 0 }; },
computed: {
doubled() { return this.data.count * 2; },
high() { return this.data.count >= 10; }
},
methods: {
increment() { this.set({ count: this.data.count + 1 }); },
decrement() { this.set({ count: this.data.count - 1 }); },
reset() { this.set({ count: 0 }); }
},
onBeforeMount() { log('beforeMount'); },
onMount() {
log('mount');
// $() can't pierce Shadow DOM - wrap root.querySelector() in $()
$(this.root.querySelector('.dm-inc')).on('click', () => this.increment());
$(this.root.querySelector('.dm-dec')).on('click', () => this.decrement());
$(this.root.querySelector('.dm-rst')).on('click', () => this.reset());
},
onUpdated() { log('updated - count=' + this.data.count); },
onUnmount() { log('unmount'); },
style: `
.counter-wrap { text-align: center; padding: 1.5rem; }
.count { font-size: 1.5rem; font-weight: 700; margin: 0 0 .25rem; }
.doubled { color: #6b7280; font-size: 0.9rem; margin: 0 0 .75rem; }
.badge-high { display: inline-block; background: #dc2626; color: #fff;
padding: .2em .6em; border-radius: 9999px;
font-size: .75rem; font-weight: 600; margin-bottom: .75rem; }
.actions { display: flex; gap: .5rem; justify-content: center; }
button { padding: .4rem 1rem; border: none; border-radius: 6px;
background: var(--dm-primary, #6495ED); color: #fff;
cursor: pointer; font-size: 1rem; }
button:last-child { background: #6b7280; }
`
});</code></pre>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
EXAMPLE 2: User Card with Props
══════════════════════════════════════════════════════════════════════ -->
<div class="card mb-6" id="card-user-card" data-section="user-card">
<div class="card-header">
<div class="card-header-content">
<span class="font-bold">Example 2 - User Card with Props</span>
<span class="badge badge-secondary ms-3">props · async · conditional</span>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-5">
Demonstrates <strong>props</strong> (type coercion, defaults), simulated async data loading
with <code>{{#if loading}}</code> conditional rendering, and a computed
<code>initials</code> property.
</p>
<div class="row g-4">
<div class="col-4">
<div class="component-demo-wrapper mb-4">
<div class="component-demo-header">
<span class="demo-dot red"></span>
<span class="demo-dot yellow"></span>
<span class="demo-dot green"></span>
<span class="ms-2"><user-card-demo user-id="42"></span>
</div>
<div class="component-demo-body">
<user-card-demo user-id="42" show-avatar=""></user-card-demo>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-primary" id="btn-user-42">User #42</button>
<button type="button" class="btn btn-sm btn-secondary" id="btn-user-7">User #7</button>
<button type="button" class="btn btn-sm btn-outline" id="btn-toggle-avatar">Toggle Avatar</button>
</div>
</div>
<div class="col-8">
<div class="code-file-tab">user-card-demo.js</div>
<pre class="code-block code-block-scroll" style="font-size: 0.76rem; line-height: 1.5;"><code>Domma.component('user-card-demo', {
props: {
userId: { type: M.types.number, required: true },
showAvatar: { type: M.types.boolean, default: true }
},
data() {
return { name: '', role: '', loading: true };
},
computed: {
initials() {
return this.data.name
.split(' ').map(n => n[0] || '').join('').toUpperCase();
}
},
methods: {
async fetchUser() {
this.set({ loading: true });
// Simulate API call (replace with: H.get('/api/users/' + this.props.userId))
await new Promise(r => setTimeout(r, 600));
const users = {
42: { name: 'Alice Nguyen', role: 'Senior Engineer' },
7: { name: 'Bob Okafor', role: 'Product Manager' }
};
const user = users[this.props.userId]
?? { name: 'Unknown User', role: '-' };
this.set({ name: user.name, role: user.role, loading: false });
}
},
onMount() { this.fetchUser(); },
onPropsChanged(name) {
if (name === 'userId') this.fetchUser();
},
template: `
<div class="card">
{{#if loading}}
<div class="skeleton-wrap">
<div class="skeleton avatar"></div>
<div class="skeleton-lines">
<div class="skeleton line"></div>
<div class="skeleton line short"></div>
</div>
</div>
{{/if}}
{{#unless loading}}
<div class="user-row">
{{#if showAvatar}}
<div class="avatar">{{initials}}</div>
{{/if}}
<div>
<p class="name">{{name}}</p>
<p class="role">{{role}}</p>
</div>
</div>
{{/unless}}
</div>
`,
style: `/* … component-scoped CSS … */`
});</code></pre>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
EXAMPLE 3: Router Integration
══════════════════════════════════════════════════════════════════════ -->
<div class="card mb-6" id="card-router" data-section="router">
<div class="card-header">
<div class="card-header-content">
<span class="font-bold">Example 3 - Router Integration</span>
<span class="badge badge-secondary ms-3">R.route · props from URL</span>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-4">
Use a component as a route view. Props are derived from URL parameters.
</p>
<pre class="code-block mb-4"><code>// Route definition - props come from URL params
R.route({
path: '/users/:id',
component: 'user-card-demo', // Custom element tag name
props: (params) => ({ // Function receives route params
userId: parseInt(params.id, 10)
})
});</code></pre>
<p class="text-sm text-muted mb-0">
When this route activates, the router creates <code><user-card-demo user-id="…"></code>
and mounts it in the route container - no extra glue code needed.
</p>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
EXAMPLE 4: .domma Single-File Format
══════════════════════════════════════════════════════════════════════ -->
<div class="card mb-6" id="card-sfc" data-section="sfc">
<div class="card-header">
<div class="card-header-content">
<span class="font-bold">Example 4 - .domma Single-File Format</span>
<span class="badge badge-secondary ms-3">optional · build pipeline</span>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-4">
For build-pipeline projects, the optional Rollup plugin lets you co-locate
template, script, and styles in a single <code>.domma</code> file (similar to Vue's
<code>.vue</code> format). The filename becomes the tag name.
</p>
<div class="row g-4">
<div class="col-6">
<div class="code-file-tab">components/user-card.domma</div>
<pre class="code-block" style="font-size: 0.78rem;"><code><template>
<div class="card">
<div class="avatar">{{initials}}</div>
<p class="name">{{name}}</p>
<p class="role">{{role}}</p>
</div>
</template>
<script>
export default {
props: {
userId: { type: M.types.number, required: true }
},
data() {
return { name: '', role: '', loading: true };
},
computed: {
initials() {
return this.data.name.split(' ').map(n => n[0]).join('');
}
},
methods: {
async fetchUser() {
const user = await H.get('/api/users/' + this.props.userId);
this.set({ name: user.name, role: user.role, loading: false });
}
},
onMount() { this.fetchUser(); }
};
</script>
<style>
.card { padding: var(--spacing-md); text-align: center; }
.avatar { width: 3rem; height: 3rem; border-radius: 50%;
background: var(--dm-primary); color: #fff;
display: flex; align-items: center;
justify-content: center; margin: 0 auto .75rem; }
.name { font-weight: 700; margin: 0; }
.role { color: #6b7280; font-size: .875rem; margin: 0; }
</style></code></pre>
</div>
<div class="col-6">
<div class="code-file-tab">rollup.config.js</div>
<pre class="code-block mb-4" style="font-size: 0.78rem;"><code>import { dommaPlugin } from './src/plugins/rollup-plugin-domma.js';
export default {
input: 'src/main.js',
plugins: [
dommaPlugin(), // Handles .domma files
// … other plugins
]
};</code></pre>
<div class="card card-accent-success">
<div class="card-body">
<h5 class="font-semibold mb-2">How it works</h5>
<ol class="ps-4 text-sm lh-lg mb-0">
<li>Plugin detects <code>.domma</code> file extension</li>
<li>Extracts <code><template></code>, <code><script></code>, <code><style></code> sections</li>
<li>Derives tag name from filename (<code>user-card.domma → user-card</code>)</li>
<li>Wraps <code>export default</code> definition into a <code>Domma.component()</code> call</li>
<li>Standard Rollup bundling handles the rest</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
API REFERENCE
══════════════════════════════════════════════════════════════════════ -->
<div class="card mb-6" id="card-api" data-section="api">
<div class="card-header">
<div class="card-header-content">
<span class="font-bold">API Reference</span>
</div>
</div>
<div class="card-body">
<div class="grid grid-cols-2 gap-4">
<div class="card h-full">
<div class="card-body">
<h4 class="font-semibold mb-3">Definition Options</h4>
<table class="table table-sm">
<thead><tr><th>Option</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>template</code></td><td>string</td><td>Inline Mustache template</td></tr>
<tr><td><code>templateUrl</code></td><td>string</td><td>URL to external template file</td></tr>
<tr><td><code>props</code></td><td>object</td><td>Declared HTML attributes with type/default</td></tr>
<tr><td><code>data()</code></td><td>function</td><td>Returns initial reactive state</td></tr>
<tr><td><code>computed</code></td><td>object</td><td>Derived values (re-evaluated each render)</td></tr>
<tr><td><code>methods</code></td><td>object</td><td>Functions bound to the component context</td></tr>
<tr><td><code>style</code></td><td>string</td><td>Scoped CSS injected into Shadow DOM</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card h-full">
<div class="card-body">
<h4 class="font-semibold mb-3">Lifecycle Hooks</h4>
<table class="table table-sm">
<thead><tr><th>Hook</th><th>When</th></tr></thead>
<tbody>
<tr><td><code>onBeforeMount()</code></td><td>Before template is rendered</td></tr>
<tr><td><code>onMount()</code></td><td>After template rendered + bindings established</td></tr>
<tr><td><code>onUpdated()</code></td><td>After any data change, once per flush - including fields no binding uses</td></tr>
<tr><td><code>onBeforeUnmount()</code></td><td>Before element is removed from DOM</td></tr>
<tr><td><code>onUnmount()</code></td><td>After element removed and cleaned up</td></tr>
<tr><td><code>onPropsChanged(name, old, new)</code></td><td>When an observed attribute changes</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-span-full">
<div class="card">
<div class="card-body">
<h4 class="font-semibold mb-3">Context (<code>this</code>) inside methods & hooks</h4>
<div class="grid grid-cols-2 gap-3">
<pre class="code-block" style="font-size: 0.8rem;"><code>this.data // Reactive data snapshot (toJSON)
this.props // Resolved props object
this.root // shadowRoot reference
this.el // The host <custom-element> itself
this.set({ field: value }) // Batch-update reactive data
// (triggers bindings + onUpdated)</code></pre>
<pre class="code-block" style="font-size: 0.8rem;"><code>// Methods are called via this.methodName()
// Computed values appear as this.computedName
// Accessing props in methods:
const id = this.props.userId;
// Updating state:
this.set({ loading: false, name: 'Alice' });</code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>/**
* Standalone Components - from public/showcase/components.
*/
export function mount(root) {
// ── Collapsible example cards ────────────────────────────────────────────
['card-counter', 'card-user-card', 'card-router', 'card-sfc', 'card-api'].forEach(id => {
E.card(root.querySelector(`#${id}`), { collapsible: true });
});
// ── Lifecycle log (bound list) ──────────────────────────────────────────
// Each entry is a row in an observable list; #counter-log renders it with
// data-each, so the log is data rather than appended HTML strings.
const $log = $(root).find('#counter-log');
const entries = M.observable([]);
let seq = 0;
let scrollFrame = 0;
const logHandle = M.applyBindings({entries}, $log[0]);
function counterLog(msg, type = 'update') {
entries.value = [...entries.value, {id: ++seq, time: D().format('HH:mm:ss'), msg, type}];
// Keep the newest entry in view once the list has rendered
cancelAnimationFrame(scrollFrame);
scrollFrame = requestAnimationFrame(() => $log.scrollTop($log[0].scrollHeight));
}
// ── Example 1: Counter ──────────────────────────────────────────────────
Domma.component('counter-demo', {
template: `
<div class="counter-wrap">
<p class="count">Count: <span class="num">{{count}}</span></p>
<p class="doubled">Doubled: {{doubled}}</p>
{{#if high}}
<div class="badge-high">🔥 High! (≥10)</div>
{{/if}}
<div class="actions">
<button class="btn-dec">−</button>
<button class="btn-inc">+</button>
<button class="btn-rst">Reset</button>
</div>
</div>
`,
data() { return { count: 0 }; },
computed: {
doubled() { return this.data.count * 2; },
high() { return this.data.count >= 10; }
},
methods: {
increment() { this.set({ count: this.data.count + 1 }); },
decrement() { this.set({ count: this.data.count - 1 }); },
reset() { this.set({ count: 0 }); }
},
onBeforeMount() { counterLog('onBeforeMount fired', 'mount'); },
onMount() {
counterLog('onMount - component ready', 'mount');
// $ can't pierce Shadow DOM - wrap root.querySelector() in $() to use Domma events
$(this.root.querySelector('.btn-inc')).on('click', () => this.increment());
$(this.root.querySelector('.btn-dec')).on('click', () => this.decrement());
$(this.root.querySelector('.btn-rst')).on('click', () => this.reset());
},
onUpdated() {
counterLog(`onUpdated - count=${this.data.count}, doubled=${this.data.count * 2}`, 'update');
},
onUnmount() { counterLog('onUnmount fired', 'unmount'); },
style: `
.counter-wrap {
text-align: center;
padding: 1.5rem 2rem;
font-family: system-ui, sans-serif;
}
.count {
font-size: 1.6rem;
font-weight: 700;
margin: 0 0 0.25rem;
}
.num { color: var(--dm-primary, #6495ED); }
.doubled {
color: #6b7280;
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.badge-high {
display: inline-block;
background: #dc2626;
color: #fff;
padding: .2em .75em;
border-radius: 9999px;
font-size: .8rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.actions {
display: flex;
gap: .5rem;
justify-content: center;
}
button {
padding: .5rem 1.25rem;
border: none;
border-radius: 8px;
background: var(--dm-primary, #6495ED);
color: #fff;
cursor: pointer;
font-size: 1.1rem;
font-weight: 600;
transition: opacity .15s;
}
button:hover { opacity: 0.85; }
.btn-dec { background: var(--dm-danger, #dc2626); }
.btn-rst { background: #6b7280; font-size: 0.85rem; }
`
});
// ── Example 2: User Card ────────────────────────────────────────────────
Domma.component('user-card-demo', {
props: {
userId: { type: 'number', required: true },
showAvatar: { type: 'boolean', default: true }
},
data() {
return { name: '', role: '', department: '', loading: true };
},
computed: {
initials() {
if (!this.data.name) return '?';
return this.data.name.split(' ')
.map(n => n[0] || '')
.join('')
.toUpperCase()
.slice(0, 2);
}
},
methods: {
async fetchUser() {
this.set({ loading: true });
// Simulated API call - replace with H.get('/api/users/' + this.props.userId)
await new Promise(r => setTimeout(r, 700));
const db = {
42: { name: 'Alice Nguyen', role: 'Senior Engineer', department: 'Platform' },
7: { name: 'Bob Okafor', role: 'Product Manager', department: 'Product' },
99: { name: 'Carol Lindström', role: 'Design Lead', department: 'UX' }
};
const user = db[this.props.userId] ?? { name: 'Unknown User', role: '-', department: '-' };
this.set({ name: user.name, role: user.role, department: user.department, loading: false });
}
},
onMount() { this.fetchUser(); },
onPropsChanged(name) {
if (name === 'userId') this.fetchUser();
},
template: `
<div class="uc">
{{#if loading}}
<div class="skeleton-wrap">
<div class="sk sk-avatar"></div>
<div class="flex-fill">
<div class="sk sk-line"></div>
<div class="sk sk-line sk-short"></div>
</div>
</div>
{{/if}}
{{#unless loading}}
<div class="user-row">
{{#if showAvatar}}
<div class="avatar">{{initials}}</div>
{{/if}}
<div class="user-info">
<p class="name">{{name}}</p>
<p class="role">{{role}}</p>
<p class="dept">{{department}}</p>
</div>
</div>
{{/unless}}
</div>
`,
style: `
.uc {
font-family: system-ui, sans-serif;
padding: 1.5rem;
min-width: 220px;
}
.user-row { display: flex; align-items: center; gap: 1rem; }
.avatar {
width: 3.5rem; height: 3.5rem;
border-radius: 50%;
background: var(--dm-primary, #6495ED);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; font-weight: 700;
flex-shrink: 0;
}
.name { font-weight: 700; font-size: 1.05rem; margin: 0 0 .2rem; }
.role { color: #6b7280; font-size: .85rem; margin: 0 0 .15rem; }
.dept { font-size: .75rem; color: #9ca3af; margin: 0;
text-transform: uppercase; letter-spacing: .05em; }
/* Skeleton loader */
.skeleton-wrap { display: flex; align-items: center; gap: 1rem; }
.sk {
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.sk-avatar { width: 3.5rem; height: 3.5rem; border-radius: 50%; flex-shrink: 0; }
.sk-line { height: 14px; margin-bottom: 8px; width: 120px; }
.sk-short { width: 80px; margin-bottom: 0; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`
});
// ── User Card controls ──────────────────────────────────────────────────
const userCardEl = root.querySelector('user-card-demo');
let avatarVisible = true;
$(root).find('#btn-user-42').on('click', () => {
userCardEl.setAttribute('user-id', '42');
});
$(root).find('#btn-user-7').on('click', () => {
userCardEl.setAttribute('user-id', '7');
});
$(root).find('#btn-toggle-avatar').on('click', () => {
avatarVisible = !avatarVisible;
if (avatarVisible) {
userCardEl.setAttribute('show-avatar', '');
} else {
userCardEl.removeAttribute('show-avatar');
}
});
return () => {
cancelAnimationFrame(scrollFrame);
logHandle.dispose();
};
}