From 82adb306b6b803fdea4cd64d211f8f44d05d0d07 Mon Sep 17 00:00:00 2001 From: pk Date: Thu, 22 Oct 2026 23:51:12 +0200 Subject: [PATCH] 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. --- package.json | 2 +- tp-form.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2f6e6d9..87c7a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tp/tp-form", - "version": "1.2.1", + "version": "1.3.0", "description": "", "main": "tp-form.js", "scripts": { diff --git a/tp-form.js b/tp-form.js index e5c67f1..8e1f6a6 100644 --- a/tp-form.js +++ b/tp-form.js @@ -7,6 +7,22 @@ This program is available under Apache License Version 2.0 import { LitElement, html, css } from 'lit'; 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 { static get styles() { return [ @@ -78,6 +94,7 @@ class TpForm extends LitElement { super(); this._origValues = []; this._wasSetReadonly = []; + this._onControlChanged = this._onControlChanged.bind(this); this._addElement = this._addElement.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-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. this._controls = []; } @@ -109,6 +131,10 @@ class TpForm extends LitElement { super.disconnectedCallback(); this.removeEventListener('form-element-register', this._addElement); this.removeEventListener('form-element-unregister', this._removeElement); + + for (const name of CHANGE_EVENTS) { + this.removeEventListener(name, this._onControlChanged, true); + } } _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() { - 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, + })); } /**