Appearance
Counter
A minimal two-way-bound counter. Demonstrates <props> with model: true, <data> for local reactive state, $computed, and @event handlers in <template>.
Live demo
The Counter below is the actual examples/Counter.rozie file from the monorepo, compiled by @rozie/unplugin/vite at build time into a Vue SFC and rendered inline. Click the buttons; the value is two-way-bound to local state on this page.
Current value: 2
Source — Counter.rozie
rozie
<!--
Counter.rozie
Demonstrates the basics:
- <props> with `model: true` for two-way binding
- <data> with local reactive state
- <script> with $props, $data, $computed, methods as plain functions
- <template> with @event, :prop, {{ }} interpolation
- Always-scoped <style>
No lifecycle, no refs, no listeners — see Dropdown.rozie for those.
-->
<rozie name="Counter">
<props>
{
value: { type: Number, default: 0, model: true },
step: { type: Number, default: 1 },
min: { type: Number, default: -Infinity },
max: { type: Number, default: Infinity },
}
</props>
<data>
{
hovering: false,
}
</data>
<script>
console.log("hello from rozie")
const canIncrement = $computed(() => $props.value + $props.step <= $props.max)
const canDecrement = $computed(() => $props.value - $props.step >= $props.min)
const increment = () => { if (canIncrement) $model.value += $props.step }
const decrement = () => { if (canDecrement) $model.value -= $props.step }
</script>
<template>
<div
class="counter"
:class="{ hovering: $data.hovering }"
@mouseenter="$data.hovering = true"
@mouseleave="$data.hovering = false"
>
<button :disabled="!canDecrement" @click="decrement" aria-label="Decrement">−</button>
<span class="value">{{ $props.value }}</span>
<button :disabled="!canIncrement" @click="increment" aria-label="Increment">+</button>
</div>
</template>
<style>
.counter { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering { background: rgba(0, 0, 0, 0.04); }
.value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button { padding: 0.25rem 0.5rem; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</rozie>Compiled output
vue
<template>
<div :class="['counter', { hovering: hovering }]" v-bind="$attrs" @mouseenter="hovering = true" @mouseleave="hovering = false">
<button :disabled="!canDecrement" aria-label="Decrement" @click="decrement">−</button>
<span class="value">{{ value }}</span>
<button :disabled="!canIncrement" aria-label="Increment" @click="increment">+</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{ step?: number; min?: number; max?: number }>(),
{ step: 1, min: -Infinity, max: Infinity }
);
const value = defineModel<number>('value', { default: 0 });
const hovering = ref(false);
const canIncrement = computed(() => value.value + props.step <= props.max);
const canDecrement = computed(() => value.value - props.step >= props.min);
console.log("hello from rozie");
const increment = () => {
if (canIncrement.value) value.value += props.step;
};
const decrement = () => {
if (canDecrement.value) value.value -= props.step;
};
</script>
<style scoped>
.counter { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering { background: rgba(0, 0, 0, 0.04); }
.value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button { padding: 0.25rem 0.5rem; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>tsx
import { useCallback, useMemo, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './Counter.css';
interface CounterProps {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
step?: number;
min?: number;
max?: number;
}
export default function Counter(_props: CounterProps): JSX.Element {
const props: Omit<CounterProps, 'step' | 'min' | 'max'> & { step: number; min: number; max: number } = {
..._props,
step: _props.step ?? 1,
min: _props.min ?? -Infinity,
max: _props.max ?? Infinity,
};
const attrs: Record<string, unknown> = (() => {
const { value, step, min, max, defaultValue, onValueChange, ...rest } = _props as CounterProps & Record<string, unknown>;
void value; void step; void min; void max; void defaultValue; void onValueChange;
return rest;
})();
const [value, setValue] = useControllableState({
value: props.value,
defaultValue: props.defaultValue ?? 0,
onValueChange: props.onValueChange,
});
const [hovering, setHovering] = useState(false);
const canIncrement = useMemo(() => value + props.step <= props.max, [props.max, props.step, value]);
const canDecrement = useMemo(() => value - props.step >= props.min, [props.min, props.step, value]);
console.log("hello from rozie");
const increment = useCallback(() => {
if (canIncrement) setValue(prev => prev + props.step);
}, [canIncrement, props.step, setValue]);
const decrement = useCallback(() => {
if (canDecrement) setValue(prev => prev - props.step);
}, [canDecrement, props.step, setValue]);
return (
<>
<div {...attrs} className={clsx(clsx("counter", { hovering: hovering }), (attrs.className as string | undefined))} onMouseEnter={($event) => { setHovering(true); }} onMouseLeave={($event) => { setHovering(false); }} data-rozie-s-c72e01d0="">
<button disabled={!canDecrement} aria-label="Decrement" onClick={decrement} data-rozie-s-c72e01d0="">−</button>
<span className={"value"} data-rozie-s-c72e01d0="">{value}</span>
<button disabled={!canIncrement} aria-label="Increment" onClick={increment} data-rozie-s-c72e01d0="">+</button>
</div>
</>
);
}svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
interface Props {
value?: number;
step?: number;
min?: number;
max?: number;
[key: string]: unknown;
}
let {
value = $bindable(0),
step = 1,
min = -Infinity,
max = Infinity,
...__rozieAttrs
}: Props = $props();
let hovering = $state(false);
console.log("hello from rozie");
const increment = () => {
if (canIncrement) value += step;
};
const decrement = () => {
if (canDecrement) value -= step;
};
const canIncrement = $derived(value + step <= max);
const canDecrement = $derived(value - step >= min);
</script>
<div {...__rozieAttrs} class={["counter", { hovering: hovering }, (__rozieAttrs)?.class]} onmouseenter={($event) => { hovering = true; }} onmouseleave={($event) => { hovering = false; }} use:applyListeners={__rozieAttrs} data-rozie-s-c72e01d0><button disabled={!canDecrement} aria-label="Decrement" onclick={decrement} data-rozie-s-c72e01d0>−</button><span class="value" data-rozie-s-c72e01d0>{value}</span><button disabled={!canIncrement} aria-label="Increment" onclick={increment} data-rozie-s-c72e01d0>+</button></div>
<style>
:global {
.counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
.value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, signal, viewChild } from '@angular/core';
import { NgClass } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'rozie-counter',
standalone: true,
imports: [NgClass],
template: `
<div class="counter" [ngClass]="{ hovering: hovering() }" #rozieSpread_0 (mouseenter)="hovering.set(true)" (mouseleave)="hovering.set(false)" #rozieListenersTarget_1>
<button [disabled]="!canDecrement()" aria-label="Decrement" (click)="decrement()">−</button>
<span class="value">{{ value() }}</span>
<button [disabled]="!canIncrement()" aria-label="Increment" (click)="increment()">+</button>
</div>
`,
styles: [`
.counter { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering { background: rgba(0, 0, 0, 0.04); }
.value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button { padding: 0.25rem 0.5rem; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Counter),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Counter {
value = model<number>(0);
step = input<number>(1);
min = input<number>(-Infinity);
max = input<number>(Infinity);
hovering = signal(false);
constructor() {
console.log("hello from rozie");
}
canIncrement = computed(() => this.value() + this.step() <= this.max());
canDecrement = computed(() => this.value() - this.step() >= this.min());
increment = () => {
if (this.canIncrement()) this.value.set(this.value() + this.step()), this.__rozieCvaOnChange(this.value() + this.step());
};
decrement = () => {
if (this.canDecrement()) this.value.set(this.value() - this.step()), this.__rozieCvaOnChange(this.value() - this.step());
};
private __rozieCvaOnChange: (v: number) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: number | null): void {
this.value.set(v ?? 0);
}
registerOnChange(fn: (v: number) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
}
export default Counter;tsx
import type { JSX } from 'solid-js';
import { createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, mergeListeners, rozieClass } from '@rozie/runtime-solid';
__rozieInjectStyle('Counter-c72e01d0', `.counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
.value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }`);
interface CounterProps {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
step?: number;
min?: number;
max?: number;
}
export default function Counter(_props: CounterProps): JSX.Element {
const _merged = mergeProps({ step: 1, min: -Infinity, max: Infinity }, _props);
const [local, attrs] = splitProps(_merged, ['value', 'step', 'min', 'max']);
const [value, setValue] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'value', 0);
const [hovering, setHovering] = createSignal(false);
const canIncrement = createMemo(() => value() + local.step <= local.max);
const canDecrement = createMemo(() => value() - local.step >= local.min);
console.log("hello from rozie");
function increment() {
if (canIncrement()) setValue(value() + local.step);
}
function decrement() {
if (canDecrement()) setValue(value() - local.step);
}
return (
<>
<div {...attrs} class={"counter" + " " + rozieClass({ hovering: hovering() }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onMouseEnter: ($event) => { setHovering(true); }, onMouseLeave: ($event) => { setHovering(false); } }, attrs)} data-rozie-s-c72e01d0="">
<button aria-label="Decrement" disabled={!canDecrement()} onClick={decrement} data-rozie-s-c72e01d0="">−</button>
<span class={"value"} data-rozie-s-c72e01d0="">{value()}</span>
<button aria-label="Increment" disabled={!canIncrement()} onClick={increment} data-rozie-s-c72e01d0="">+</button>
</div>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
@customElement('rozie-counter')
export default class Counter extends SignalWatcher(LitElement) {
static styles = css`
.counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
.value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }
`;
@property({ type: Number, attribute: 'value' }) _value_attr: number = 0;
private _valueControllable = createLitControllableProperty<number>({ host: this, eventName: 'value-change', defaultValue: 0, initialControlledValue: undefined });
@property({ type: Number, reflect: true }) step: number = 1;
@property({ type: Number, reflect: true }) min: number = -Infinity;
@property({ type: Number, reflect: true }) max: number = Infinity;
private _hovering = signal(false);
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
firstUpdated(): void {
console.log("hello from rozie");
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'value') this._valueControllable.notifyAttributeChange(value === null ? 0 : Number(value));
}
render() {
return html`
<div class="${Object.entries({ "counter": true, hovering: this._hovering.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} @mouseenter=${($event: Event) => { this._hovering.value = true; }} @mouseleave=${($event: Event) => { this._hovering.value = false; }} ${rozieListeners(this.$listeners)} data-rozie-s-c72e01d0>
<button ?disabled=${!this.canDecrement} aria-label="Decrement" @click=${this.decrement} data-rozie-s-c72e01d0>−</button>
<span class="value" data-rozie-s-c72e01d0>${this.value}</span>
<button ?disabled=${!this.canIncrement} aria-label="Increment" @click=${this.increment} data-rozie-s-c72e01d0>+</button>
</div>
`;
}
get canIncrement() { return this.value + this.step <= this.max; }
get canDecrement() { return this.value - this.step >= this.min; }
increment = () => {
if (this.canIncrement) this._valueControllable.write(prev => prev + this.step);
};
decrement = () => {
if (this.canDecrement) this._valueControllable.write(prev => prev - this.step);
};
get value(): number { return this._valueControllable.read(); }
set value(v: number) { this._valueControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['value', 'step', 'min', 'max']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}