From d1424313cd138ede6278e978857d06bfe1a935fe Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 6 Mar 2023 15:31:02 -0500 Subject: [PATCH] [Live] Smart Rendering! Mix/match live components with external JavaScript --- src/Autocomplete/assets/dist/controller.d.ts | 16 + src/Autocomplete/assets/dist/controller.js | 148 +- src/Autocomplete/assets/src/controller.ts | 193 +- .../assets/test/controller.test.ts | 422 +++- src/Chartjs/assets/dist/controller.d.ts | 2 + src/Chartjs/assets/dist/controller.js | 23 +- src/Chartjs/assets/src/controller.ts | 29 +- src/Chartjs/assets/test/controller.test.ts | 97 +- .../assets/dist/Component/index.d.ts | 1 + .../dist/Rendering/ChangingItemsTracker.d.ts | 12 + .../assets/dist/Rendering/ElementChanges.d.ts | 26 + .../Rendering/ExternalMutationTracker.d.ts | 25 + .../assets/dist/live_controller.js | 393 +++- src/LiveComponent/assets/dist/morphdom.d.ts | 3 +- .../assets/src/Component/index.ts | 33 +- .../src/Rendering/ChangingItemsTracker.ts | 87 + .../assets/src/Rendering/ElementChanges.ts | 114 + .../src/Rendering/ExternalMutationTracker.ts | 296 +++ src/LiveComponent/assets/src/morphdom.ts | 76 +- .../Rendering/ChangingItemsTracker.test.ts | 88 + .../test/Rendering/ElementChanges.test.ts | 76 + .../Rendering/ExternalMutationTracker.test.ts | 221 ++ .../render-with-external-changes.test.ts | 191 ++ src/LiveComponent/doc/index.rst | 44 +- .../LiveComponentDemoController.php | 8 + ux.symfony.com/src/Form/MealPlannerForm.php | 6 +- .../src/Service/DinoStatsService.php | 94 + .../src/Service/LiveDemoRepository.php | 6 + .../src/Service/data/dino-stats.json | 1856 +++++++++++++++++ .../src/Twig/DinoChartComponent.php | 65 + .../templates/components/dino_chart.html.twig | 53 + .../live_component_demo/chartjs.html.twig | 21 + 32 files changed, 4463 insertions(+), 262 deletions(-) create mode 100644 src/LiveComponent/assets/dist/Rendering/ChangingItemsTracker.d.ts create mode 100644 src/LiveComponent/assets/dist/Rendering/ElementChanges.d.ts create mode 100644 src/LiveComponent/assets/dist/Rendering/ExternalMutationTracker.d.ts create mode 100644 src/LiveComponent/assets/src/Rendering/ChangingItemsTracker.ts create mode 100644 src/LiveComponent/assets/src/Rendering/ElementChanges.ts create mode 100644 src/LiveComponent/assets/src/Rendering/ExternalMutationTracker.ts create mode 100644 src/LiveComponent/assets/test/Rendering/ChangingItemsTracker.test.ts create mode 100644 src/LiveComponent/assets/test/Rendering/ElementChanges.test.ts create mode 100644 src/LiveComponent/assets/test/Rendering/ExternalMutationTracker.test.ts create mode 100644 src/LiveComponent/assets/test/controller/render-with-external-changes.test.ts create mode 100644 ux.symfony.com/src/Service/DinoStatsService.php create mode 100644 ux.symfony.com/src/Service/data/dino-stats.json create mode 100644 ux.symfony.com/src/Twig/DinoChartComponent.php create mode 100644 ux.symfony.com/templates/components/dino_chart.html.twig create mode 100644 ux.symfony.com/templates/live_component_demo/chartjs.html.twig diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts index 8e2d9750b56..67abbcf145b 100644 --- a/src/Autocomplete/assets/dist/controller.d.ts +++ b/src/Autocomplete/assets/dist/controller.d.ts @@ -1,5 +1,12 @@ import { Controller } from '@hotwired/stimulus'; import TomSelect from 'tom-select'; +export interface AutocompletePreConnectOptions { + options: any; +} +export interface AutocompleteConnectOptions { + tomSelect: TomSelect; + options: any; +} export default class extends Controller { #private; static values: { @@ -23,11 +30,20 @@ export default class extends Controller { readonly hasPreloadValue: boolean; readonly preloadValue: string; tomSelect: TomSelect; + private mutationObserver; + private isObserving; initialize(): void; connect(): void; disconnect(): void; + private getMaxOptions; get selectElement(): HTMLSelectElement | null; get formElement(): HTMLInputElement | HTMLSelectElement; private dispatchEvent; get preload(): string | boolean; + private resetTomSelect; + private changeTomSelectDisabledState; + private updateTomSelectPlaceholder; + private startMutationObserver; + private stopMutationObserver; + private onMutations; } diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index 45b6e1fd096..855dee71074 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -27,14 +27,13 @@ class default_1 extends Controller { constructor() { super(...arguments); _default_1_instances.add(this); + this.isObserving = false; } initialize() { - this.element.setAttribute('data-live-ignore', ''); - if (this.element.id) { - const label = document.querySelector(`label[for="${this.element.id}"]`); - if (label) { - label.setAttribute('data-live-ignore', ''); - } + if (!this.mutationObserver) { + this.mutationObserver = new MutationObserver((mutations) => { + this.onMutations(mutations); + }); } } connect() { @@ -47,11 +46,15 @@ class default_1 extends Controller { return; } this.tomSelect = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createAutocomplete).call(this); + this.startMutationObserver(); } disconnect() { - this.tomSelect.revertSettings.innerHTML = this.element.innerHTML; + this.stopMutationObserver(); this.tomSelect.destroy(); } + getMaxOptions() { + return this.selectElement ? this.selectElement.options.length : 50; + } get selectElement() { if (!(this.element instanceof HTMLSelectElement)) { return null; @@ -79,6 +82,123 @@ class default_1 extends Controller { } return this.preloadValue; } + resetTomSelect() { + if (this.tomSelect) { + this.stopMutationObserver(); + this.tomSelect.clearOptions(); + this.tomSelect.settings.maxOptions = this.getMaxOptions(); + this.tomSelect.sync(); + this.startMutationObserver(); + } + } + changeTomSelectDisabledState(isDisabled) { + this.stopMutationObserver(); + if (isDisabled) { + this.tomSelect.disable(); + } + else { + this.tomSelect.enable(); + } + this.startMutationObserver(); + } + updateTomSelectPlaceholder() { + const input = this.element; + let placeholder = input.getAttribute('placeholder') || input.getAttribute('data-placeholder'); + if (!placeholder && !this.tomSelect.allowEmptyOption) { + const option = input.querySelector('option[value=""]'); + if (option) { + placeholder = option.textContent; + } + } + if (placeholder) { + this.stopMutationObserver(); + this.tomSelect.control_input.setAttribute('placeholder', placeholder); + this.startMutationObserver(); + } + } + startMutationObserver() { + if (!this.isObserving) { + this.mutationObserver.observe(this.element, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + this.isObserving = true; + } + } + stopMutationObserver() { + if (this.isObserving) { + this.mutationObserver.disconnect(); + this.isObserving = false; + } + } + onMutations(mutations) { + const addedOptionElements = []; + const removedOptionElements = []; + let hasAnOptionChanged = false; + let changeDisabledState = false; + let changePlaceholder = false; + mutations.forEach((mutation) => { + switch (mutation.type) { + case 'childList': + if (mutation.target instanceof HTMLOptionElement) { + if (mutation.target.value === '') { + changePlaceholder = true; + break; + } + hasAnOptionChanged = true; + break; + } + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLOptionElement) { + if (removedOptionElements.includes(node)) { + removedOptionElements.splice(removedOptionElements.indexOf(node), 1); + return; + } + addedOptionElements.push(node); + } + }); + mutation.removedNodes.forEach((node) => { + if (node instanceof HTMLOptionElement) { + if (addedOptionElements.includes(node)) { + addedOptionElements.splice(addedOptionElements.indexOf(node), 1); + return; + } + removedOptionElements.push(node); + } + }); + break; + case 'attributes': + if (mutation.target instanceof HTMLOptionElement) { + hasAnOptionChanged = true; + break; + } + if (mutation.target === this.element && mutation.attributeName === 'disabled') { + changeDisabledState = true; + break; + } + break; + case 'characterData': + if (mutation.target instanceof Text && mutation.target.parentElement instanceof HTMLOptionElement) { + if (mutation.target.parentElement.value === '') { + changePlaceholder = true; + break; + } + hasAnOptionChanged = true; + } + } + }); + if (hasAnOptionChanged || addedOptionElements.length > 0 || removedOptionElements.length > 0) { + this.resetTomSelect(); + } + if (changeDisabledState) { + this.changeTomSelectDisabledState((this.formElement.disabled)); + } + if (changePlaceholder) { + this.updateTomSelectPlaceholder(); + } + } } _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() { const plugins = {}; @@ -103,10 +223,6 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def onItemAdd: () => { this.tomSelect.setTextboxValue(''); }, - onInitialize: function () { - const tomSelect = this; - tomSelect.wrapper.setAttribute('data-live-ignore', ''); - }, closeAfterSelect: true, }; if (!this.selectElement && !this.urlValue) { @@ -115,12 +231,12 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue); }, _default_1_createAutocomplete = function _default_1_createAutocomplete() { const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { - maxOptions: this.selectElement ? this.selectElement.options.length : 50, + maxOptions: this.getMaxOptions(), }); return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config); }, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() { const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { - maxOptions: this.selectElement ? this.selectElement.options.length : 50, + maxOptions: this.getMaxOptions(), score: (search) => { const scoringFunction = this.tomSelect.getScoreFunction(search); return (item) => { @@ -183,9 +299,11 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def }, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) { return Object.assign(Object.assign({}, object1), object2); }, _default_1_createTomSelect = function _default_1_createTomSelect(options) { - this.dispatchEvent('pre-connect', { options }); + const preConnectPayload = { options }; + this.dispatchEvent('pre-connect', preConnectPayload); const tomSelect = new TomSelect(this.formElement, options); - this.dispatchEvent('connect', { tomSelect, options }); + const connectPayload = { tomSelect, options }; + this.dispatchEvent('connect', connectPayload); return tomSelect; }; default_1.values = { diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index 63c7047f372..c74b895ca74 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -3,6 +3,14 @@ import TomSelect from 'tom-select'; import { TPluginHash } from 'tom-select/dist/types/contrib/microplugin'; import { RecursivePartial, TomSettings, TomTemplates } from 'tom-select/dist/types/types'; +export interface AutocompletePreConnectOptions { + options: any; +} +export interface AutocompleteConnectOptions { + tomSelect: TomSelect; + options: any; +} + export default class extends Controller { static values = { url: String, @@ -24,13 +32,14 @@ export default class extends Controller { declare readonly preloadValue: string; tomSelect: TomSelect; + private mutationObserver: MutationObserver; + private isObserving = false; + initialize() { - this.element.setAttribute('data-live-ignore', ''); - if (this.element.id) { - const label = document.querySelector(`label[for="${this.element.id}"]`); - if (label) { - label.setAttribute('data-live-ignore', ''); - } + if (!this.mutationObserver) { + this.mutationObserver = new MutationObserver((mutations: MutationRecord[]) => { + this.onMutations(mutations); + }); } } @@ -48,11 +57,11 @@ export default class extends Controller { } this.tomSelect = this.#createAutocomplete(); + this.startMutationObserver(); } disconnect() { - // make sure it will "revert" to the latest innerHTML - this.tomSelect.revertSettings.innerHTML = this.element.innerHTML; + this.stopMutationObserver(); this.tomSelect.destroy(); } @@ -86,10 +95,6 @@ export default class extends Controller { onItemAdd: () => { this.tomSelect.setTextboxValue(''); }, - onInitialize: function () { - const tomSelect = this as any; - tomSelect.wrapper.setAttribute('data-live-ignore', ''); - }, closeAfterSelect: true, }; @@ -103,7 +108,7 @@ export default class extends Controller { #createAutocomplete(): TomSelect { const config = this.#mergeObjects(this.#getCommonConfig(), { - maxOptions: this.selectElement ? this.selectElement.options.length : 50, + maxOptions: this.getMaxOptions(), }); return this.#createTomSelect(config); @@ -111,7 +116,7 @@ export default class extends Controller { #createAutocompleteWithHtmlContents(): TomSelect { const config = this.#mergeObjects(this.#getCommonConfig(), { - maxOptions: this.selectElement ? this.selectElement.options.length : 50, + maxOptions: this.getMaxOptions(), score: (search: string) => { const scoringFunction = this.tomSelect.getScoreFunction(search); return (item: any) => { @@ -182,6 +187,10 @@ export default class extends Controller { return this.#createTomSelect(config); } + private getMaxOptions(): number { + return this.selectElement ? this.selectElement.options.length : 50; + } + #stripTags(string: string): string { return string.replace(/(<([^>]+)>)/gi, ''); } @@ -213,9 +222,11 @@ export default class extends Controller { } #createTomSelect(options: RecursivePartial): TomSelect { - this.dispatchEvent('pre-connect', { options }); + const preConnectPayload: AutocompletePreConnectOptions = { options }; + this.dispatchEvent('pre-connect', preConnectPayload); const tomSelect = new TomSelect(this.formElement, options); - this.dispatchEvent('connect', { tomSelect, options }); + const connectPayload: AutocompleteConnectOptions = { tomSelect, options }; + this.dispatchEvent('connect', connectPayload); return tomSelect; } @@ -239,4 +250,154 @@ export default class extends Controller { return this.preloadValue; } + + private resetTomSelect(): void { + if (this.tomSelect) { + this.stopMutationObserver(); + this.tomSelect.clearOptions(); + this.tomSelect.settings.maxOptions = this.getMaxOptions(); + this.tomSelect.sync(); + this.startMutationObserver(); + } + } + + private changeTomSelectDisabledState(isDisabled: boolean): void { + this.stopMutationObserver(); + if (isDisabled) { + this.tomSelect.disable(); + } else { + this.tomSelect.enable(); + } + this.startMutationObserver(); + } + + /** + * TomSelect doesn't give us a way to update the placeholder, so most of + * this code is copied from TomSelect's source code. + * + * @private + */ + private updateTomSelectPlaceholder(): void { + const input = this.element; + let placeholder = input.getAttribute('placeholder') || input.getAttribute('data-placeholder'); + if (!placeholder && !this.tomSelect.allowEmptyOption) { + const option = input.querySelector('option[value=""]'); + + if (option) { + placeholder = option.textContent; + } + } + + if (placeholder) { + this.stopMutationObserver(); + this.tomSelect.control_input.setAttribute('placeholder', placeholder); + this.startMutationObserver(); + } + } + + private startMutationObserver(): void { + if (!this.isObserving) { + this.mutationObserver.observe(this.element, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + this.isObserving = true; + } + } + + private stopMutationObserver(): void { + if (this.isObserving) { + this.mutationObserver.disconnect(); + this.isObserving = false; + } + } + + private onMutations(mutations: MutationRecord[]): void { + const addedOptionElements: HTMLOptionElement[] = []; + const removedOptionElements: HTMLOptionElement[] = []; + let hasAnOptionChanged = false; + let changeDisabledState = false; + let changePlaceholder = false; + + mutations.forEach((mutation) => { + switch (mutation.type) { + case 'childList': + // look for changes to any