From 99388b683afe05e866b44451dc29529b431ee6bb Mon Sep 17 00:00:00 2001 From: beeps Date: Fri, 14 Jun 2024 12:47:42 +0100 Subject: [PATCH 1/2] Add helper function to observe JS property changes --- .../observe-element-property.jsdom.test.mjs | 31 +++++++++++++++ .../govuk/common/observe-element-property.mjs | 39 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 packages/govuk-frontend/src/govuk/common/observe-element-property.jsdom.test.mjs create mode 100644 packages/govuk-frontend/src/govuk/common/observe-element-property.mjs diff --git a/packages/govuk-frontend/src/govuk/common/observe-element-property.jsdom.test.mjs b/packages/govuk-frontend/src/govuk/common/observe-element-property.jsdom.test.mjs new file mode 100644 index 0000000000..8cf3a9cb7a --- /dev/null +++ b/packages/govuk-frontend/src/govuk/common/observe-element-property.jsdom.test.mjs @@ -0,0 +1,31 @@ +import { observeElementProperty } from './observe-element-property.mjs' + +describe('observeElementProperty', () => { + it('fires a callback function when a property value is updated', () => { + const $element = document.createElement('button') + let callbackCalled = false + + observeElementProperty($element, 'disabled', () => (callbackCalled = true)) + $element.disabled = true + + expect(callbackCalled).toBeTruthy() + }) + + it('returns the values of properties unaltered', () => { + // This test is not directly related to the function, but as we have to + // reimplement the native getter function as part of it, we should make + // sure it still works. + const testString = ' hello $ world! 3' + + // Unobserved input element + const $unobservedInput = document.createElement('input') + $unobservedInput.value = testString + + // Observed input element + const $observedInput = document.createElement('input') + observeElementProperty($observedInput, 'value', function () {}) + $observedInput.value = testString + + expect($observedInput.value).toStrictEqual($unobservedInput.value) + }) +}) diff --git a/packages/govuk-frontend/src/govuk/common/observe-element-property.mjs b/packages/govuk-frontend/src/govuk/common/observe-element-property.mjs new file mode 100644 index 0000000000..e5efdf393b --- /dev/null +++ b/packages/govuk-frontend/src/govuk/common/observe-element-property.mjs @@ -0,0 +1,39 @@ +/** + * Observe a property on an HTMLElement and fire a callback if the + * value of that property changes. + * + * This only works for directly manipulating properties. It will not detect + * passive changes to properties — e.g. using `setAttribute` to disable an input + * will change the value of the `disabled` property, but not trigger a callback. + * + * For changes to HTML attributes, you can use a MutationObserver instead. + * + * @internal + * @param {HTMLElement} element - The element to observe + * @param {string} property - The property of that element to observe + * @param {Function} callback - The callback to fire when the value of that property is changed + */ +export function observeElementProperty(element, property, callback) { + // If the element has had an observer previously applied to this + // property then it will be accessible from the element root. + // Otherwise, we need to extract it from the constructor. + const nativeProperty = + Object.getOwnPropertyDescriptor(element, property) ?? + Object.getOwnPropertyDescriptor(element.constructor.prototype, property) + + // Overwrite the native property descriptor with our own getter/setter + Object.defineProperty(element, property, { + set(value) { + // Still call and return the native setter function so that + // everything it does still happens (e.g. updating the visible value) + nativeProperty?.set?.call(this, value) + + // Call our custom callback function afterwards + callback.call(this, value) + }, + get() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return nativeProperty?.get?.call(this) + } + }) +} From 918908b99be9be9462333a09fe8924f4c50520c8 Mon Sep 17 00:00:00 2001 From: beeps Date: Fri, 14 Jun 2024 12:48:00 +0100 Subject: [PATCH 2/2] Add observer to character count value Add an observer to the character count component's `value` property and call the usual counter update functions if it gets changed programatically. --- .../govuk/components/character-count/character-count.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs index 6c434b4dcf..1fdd0bd1b2 100644 --- a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs +++ b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs @@ -1,6 +1,7 @@ import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' import { mergeConfigs, validateConfig } from '../../common/index.mjs' import { normaliseDataset } from '../../common/normalise-dataset.mjs' +import { observeElementProperty } from '../../common/observe-element-property.mjs' import { ConfigError, ElementError } from '../../errors/index.mjs' import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' import { I18n } from '../../i18n.mjs' @@ -191,6 +192,14 @@ export class CharacterCount extends GOVUKFrontendComponent { // could be called after those events have fired, for example if they are // added to the page dynamically, so update now too. this.updateCountMessage() + + // Lastly, add an observer to the textarea's value property so that we can + // update the counter in response to programmatic value changes too. + observeElementProperty( + this.$textarea, + 'value', + this.updateCountMessage.bind(this) + ) } /**