Define isChanged() before first snapshot + emit form-changed on control changes

isChanged() compared against a fabricated '{}' baseline before the first
snapshot() call, so the first query on any non-empty form reported true.
Consumers that embed the boolean in their own baseline (settings dirty
guards) froze that false positive in. Now reports false until a baseline
exists; hasSnapshot getter exposes the state.

New: form listens (capture phase, so non-bubbling events are caught) to
the tp-* control change events and re-emits them as 'form-changed'
(detail.changed = isChanged()), scoped to its own registered controls.
Consumers can drop polling.
This commit is contained in:
pk committed 2026-10-22 23:51:12 +02:00
1 parent 354b0ab720
commit 82adb306b6
2 files changed
+65 -3

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@tp/tp-form", "name": "@tp/tp-form",
"version": "1.2.1", "version": "1.3.0",
"description": "", "description": "",
"main": "tp-form.js", "main": "tp-form.js",
"scripts": { "scripts": {
+64 -2
View File
@@ -7,6 +7,22 @@ This program is available under Apache License Version 2.0
import { LitElement, html, css } from 'lit'; import { LitElement, html, css } from 'lit';
import { clone } from '@tp/helpers/clone.js'; import { clone } from '@tp/helpers/clone.js';
// Events that indicate a form control's value changed. Fired either by
// tp-* controls (see the control's source for the exact name) or natively
// (input/change from wrapped native fields). Listened to in the capture
// phase so events that don't bubble (e.g. tp-color-picker's 'change') are
// caught as well.
const CHANGE_EVENTS = [
'input',
'change',
'value-changed',
'selection-changed',
'checked-changed',
'toggled',
'number-changed',
'slider-changed',
];
class TpForm extends LitElement { class TpForm extends LitElement {
static get styles() { static get styles() {
return [ return [
@@ -78,6 +94,7 @@ class TpForm extends LitElement {
super(); super();
this._origValues = []; this._origValues = [];
this._wasSetReadonly = []; this._wasSetReadonly = [];
this._onControlChanged = this._onControlChanged.bind(this);
this._addElement = this._addElement.bind(this); this._addElement = this._addElement.bind(this);
this._removeElement = this._removeElement.bind(this); this._removeElement = this._removeElement.bind(this);
@@ -101,6 +118,11 @@ class TpForm extends LitElement {
this.addEventListener('form-element-register', this._addElement); this.addEventListener('form-element-register', this._addElement);
this.addEventListener('form-element-unregister', this._removeElement); this.addEventListener('form-element-unregister', this._removeElement);
// Notify about control changes via `form-changed` — see CHANGE_EVENTS.
for (const name of CHANGE_EVENTS) {
this.addEventListener(name, this._onControlChanged, true);
}
// Holds all custom elements registered. // Holds all custom elements registered.
this._controls = []; this._controls = [];
} }
@@ -109,6 +131,10 @@ class TpForm extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this.removeEventListener('form-element-register', this._addElement); this.removeEventListener('form-element-register', this._addElement);
this.removeEventListener('form-element-unregister', this._removeElement); this.removeEventListener('form-element-unregister', this._removeElement);
for (const name of CHANGE_EVENTS) {
this.removeEventListener(name, this._onControlChanged, true);
}
} }
_handleSlotChange(e) { _handleSlotChange(e) {
@@ -351,10 +377,46 @@ class TpForm extends LitElement {
} }
/** /**
* Check if some form control's value was changed. * True once snapshot() was called at least once. Dirty checks are only
* meaningful against a baseline.
*/
get hasSnapshot() {
return this._snapshot !== undefined;
}
/**
* Check if some form control's value was changed since the last
* snapshot(). Before the first snapshot() there is no baseline to
* compare against, so this reports false instead of comparing against a
* fabricated empty form.
*/ */
isChanged() { isChanged() {
return JSON.stringify(this.serialize()) !== (this._snapshot || '{}'); if (!this.hasSnapshot) {
return false;
}
return JSON.stringify(this.serialize()) !== this._snapshot;
}
/**
* True if el is a control registered with this form (custom control or
* unwrapped native element). Scopes form-changed to the form's own
* controls when forms are nested.
*/
_isRegisteredControl(el) {
return this._controls.includes(el) || Array.from(this._nativeElements).includes(el);
}
_onControlChanged(e) {
if (!this._isRegisteredControl(e.target)) {
return;
}
this.dispatchEvent(new CustomEvent('form-changed', {
detail: { changed: this.isChanged() },
bubbles: true,
composed: true,
}));
} }
/** /**