Skip to content

Commit 985445a

Browse files
committed
[Live][Autocomplete] Fixing morphing on tomselect autocomplete elements
1 parent 8a22b5c commit 985445a

File tree

5 files changed

+173
-144
lines changed

5 files changed

+173
-144
lines changed

src/Autocomplete/assets/src/controller.ts

Lines changed: 48 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export default class extends Controller {
3838
private mutationObserver: MutationObserver;
3939
private isObserving = false;
4040
private hasLoadedChoicesPreviously = false;
41+
private originalOptions: Array<{ value: string; text: string; group: string | null }> = [];
4142

4243
initialize() {
4344
if (this.requiresLiveIgnore()) {
@@ -65,10 +66,20 @@ export default class extends Controller {
6566
}
6667

6768
connect() {
69+
if (this.selectElement) {
70+
this.originalOptions = this.createOptionsDataStructure(this.selectElement);
71+
}
72+
6873
this.initializeTomSelect();
6974
}
7075

7176
initializeTomSelect() {
77+
// live components support: morphing the options causes issues, due
78+
// to the fact that TomSelect reorders the options when you select them
79+
if (this.selectElement) {
80+
this.selectElement.setAttribute('data-skip-morph', '');
81+
}
82+
7283
if (this.urlValue) {
7384
this.tomSelect = this.#createAutocompleteWithRemoteData(
7485
this.urlValue,
@@ -313,8 +324,17 @@ export default class extends Controller {
313324
private resetTomSelect(): void {
314325
if (this.tomSelect) {
315326
this.stopMutationObserver();
327+
328+
// Grab the current HTML then restore it after destroying TomSelect
329+
// This is needed because TomSelect's destroy revert the element to
330+
// its original HTML.
331+
const currentHtml = this.element.innerHTML;
332+
const currentValue: any = this.tomSelect.getValue();
316333
this.tomSelect.destroy();
334+
this.element.innerHTML = currentHtml;
317335
this.initializeTomSelect();
336+
this.tomSelect.setValue(currentValue);
337+
318338
this.startMutationObserver();
319339
}
320340
}
@@ -329,33 +349,6 @@ export default class extends Controller {
329349
this.startMutationObserver();
330350
}
331351

332-
/**
333-
* TomSelect doesn't give us a way to update the placeholder, so most of
334-
* this code is copied from TomSelect's source code.
335-
*
336-
* @private
337-
*/
338-
private updateTomSelectPlaceholder(): void {
339-
const input = this.element;
340-
let placeholder = input.getAttribute('placeholder') || input.getAttribute('data-placeholder');
341-
if (!placeholder && !this.tomSelect.allowEmptyOption) {
342-
const option = input.querySelector('option[value=""]');
343-
344-
if (option) {
345-
placeholder = option.textContent;
346-
}
347-
}
348-
349-
if (placeholder) {
350-
this.stopMutationObserver();
351-
// override settings so it's used again later
352-
this.tomSelect.settings.placeholder = placeholder;
353-
// and set it right now
354-
this.tomSelect.control_input.setAttribute('placeholder', placeholder);
355-
this.startMutationObserver();
356-
}
357-
}
358-
359352
private startMutationObserver(): void {
360353
if (!this.isObserving && this.mutationObserver) {
361354
this.mutationObserver.observe(this.element, {
@@ -376,93 +369,58 @@ export default class extends Controller {
376369
}
377370

378371
private onMutations(mutations: MutationRecord[]): void {
379-
const addedOptionElements: HTMLOptionElement[] = [];
380-
const removedOptionElements: HTMLOptionElement[] = [];
381-
let hasAnOptionChanged = false;
382372
let changeDisabledState = false;
383-
let changePlaceholder = false;
384373

385374
mutations.forEach((mutation) => {
386375
switch (mutation.type) {
387-
case 'childList':
388-
// look for changes to any <option> elements - e.g. text
389-
if (mutation.target instanceof HTMLOptionElement) {
390-
if (mutation.target.value === '') {
391-
changePlaceholder = true;
392-
393-
break;
394-
}
395-
396-
hasAnOptionChanged = true;
397-
break;
398-
}
399-
400-
// look for new or removed <option> elements
401-
mutation.addedNodes.forEach((node) => {
402-
if (node instanceof HTMLOptionElement) {
403-
// check if a previously-removed is being added back
404-
if (removedOptionElements.includes(node)) {
405-
removedOptionElements.splice(removedOptionElements.indexOf(node), 1);
406-
return;
407-
}
408-
409-
addedOptionElements.push(node);
410-
}
411-
});
412-
mutation.removedNodes.forEach((node) => {
413-
if (node instanceof HTMLOptionElement) {
414-
// check if a previously-added is being removed
415-
if (addedOptionElements.includes(node)) {
416-
addedOptionElements.splice(addedOptionElements.indexOf(node), 1);
417-
return;
418-
}
419-
420-
removedOptionElements.push(node);
421-
}
422-
});
423-
break;
424376
case 'attributes':
425-
// look for changes to any <option> elements (e.g. value attribute)
426-
if (mutation.target instanceof HTMLOptionElement) {
427-
hasAnOptionChanged = true;
428-
break;
429-
}
430-
431377
if (mutation.target === this.element && mutation.attributeName === 'disabled') {
432378
changeDisabledState = true;
433379

434380
break;
435381
}
436382

437383
break;
438-
case 'characterData':
439-
// an alternative way for an option's text to change
440-
if (mutation.target instanceof Text && mutation.target.parentElement instanceof HTMLOptionElement) {
441-
if (mutation.target.parentElement.value === '') {
442-
changePlaceholder = true;
443-
444-
break;
445-
}
446-
447-
hasAnOptionChanged = true;
448-
}
449384
}
450385
});
451386

452-
if (hasAnOptionChanged || addedOptionElements.length > 0 || removedOptionElements.length > 0) {
387+
const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
388+
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
389+
console.log('are options equivalent?', areOptionsEquivalent);
390+
if (!areOptionsEquivalent) {
391+
this.originalOptions = newOptions;
453392
this.resetTomSelect();
454393
}
455394

456395
if (changeDisabledState) {
457396
this.changeTomSelectDisabledState(this.formElement.disabled);
458397
}
459-
460-
if (changePlaceholder) {
461-
this.updateTomSelectPlaceholder();
462-
}
463398
}
464399

465400
private requiresLiveIgnore(): boolean {
466401
return this.element instanceof HTMLSelectElement && this.element.multiple;
467402
}
403+
404+
private createOptionsDataStructure(selectElement: HTMLSelectElement): Array<{ value: string; text: string; group: string | null }> {
405+
return Array.from(selectElement.options).map(option => {
406+
const optgroup = option.closest('optgroup');
407+
return {
408+
value: option.value,
409+
text: option.text,
410+
group: optgroup ? optgroup.label : null
411+
};
412+
});
413+
}
414+
415+
private areOptionsEquivalent(newOptions: Array<{ value: string; text: string; group: string | null }>): boolean {
416+
if (this.originalOptions.length !== newOptions.length) {
417+
return false;
418+
}
419+
420+
const normalizeOption = (option: { value: string; text: string; group: string | null }) => `${option.value}-${option.text}-${option.group}`;
421+
const originalOptionsSet = new Set(this.originalOptions.map(normalizeOption));
422+
const newOptionsSet = new Set(newOptions.map(normalizeOption));
423+
424+
return originalOptionsSet.size === newOptionsSet.size && [...originalOptionsSet].every(option => newOptionsSet.has(option));
425+
}
468426
}

src/Autocomplete/assets/test/controller.test.ts

Lines changed: 74 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -443,13 +443,13 @@ describe('AutocompleteController', () => {
443443
// wait for the MutationObserver to be able to flush
444444
await shortDelay(10);
445445

446-
// something external mutations the elements into a different order
447-
selectElement.children[1].setAttribute('value', '2');
448-
selectElement.children[1].innerHTML = 'dog2';
449-
selectElement.children[2].setAttribute('value', '3');
450-
selectElement.children[2].innerHTML = 'dog3';
451-
selectElement.children[3].setAttribute('value', '1');
452-
selectElement.children[3].innerHTML = 'dog1';
446+
// something external sets new HTML, with a different order
447+
selectElement.innerHTML = `
448+
<option value="">Select a dog</option>
449+
<option value="2">dog2</option>
450+
<option value="3">dog3</option>
451+
<option value="1">dog1</option>
452+
`;
453453

454454
// wait for the MutationObserver to flush these changes
455455
await shortDelay(10);
@@ -488,26 +488,20 @@ describe('AutocompleteController', () => {
488488
await shortDelay(10);
489489

490490
// TomSelect will move the "2" option out of its optgroup and onto the bottom
491-
// let's imitate an Ajax call reversing that
492-
const selectedOption2 = selectElement.children[3];
493-
if (!(selectedOption2 instanceof HTMLOptionElement)) {
494-
throw new Error('cannot find option 3');
495-
}
496-
const smallDogGroup = selectElement.children[1];
497-
if (!(smallDogGroup instanceof HTMLOptGroupElement)) {
498-
throw new Error('cannot find small dog group');
499-
}
500-
501-
// add a new element, which is really just the old dog2
502-
const newOption2 = document.createElement('option');
503-
newOption2.setAttribute('value', '2');
504-
newOption2.innerHTML = 'dog2';
505-
// but the new HTML will correctly mark this as selected
506-
newOption2.setAttribute('selected', '');
507-
smallDogGroup.appendChild(newOption2);
508-
509-
// remove the dog2 element from the bottom
510-
selectElement.removeChild(selectedOption2);
491+
// let's imitate an Ajax call reversing that order
492+
selectElement.innerHTML = `
493+
<option value="">Select a dog</option>
494+
<optgroup label="big dogs">
495+
<option value="4">dog4</option>
496+
<option value="5">dog5</option>
497+
<option value="6">dog6</option>
498+
</optgroup>
499+
<optgroup label="small dogs">
500+
<option value="1">dog1</option>
501+
<option value="2" selected>dog2</option>
502+
<option value="3">dog3</option>
503+
</optgroup>
504+
`;
511505

512506
// TomSelect will still have the correct value
513507
expect(tomSelect.getValue()).toEqual('2');
@@ -529,44 +523,62 @@ describe('AutocompleteController', () => {
529523
</select>
530524
`);
531525

526+
// select 3 to start
527+
tomSelect.addItem('3');
532528
const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
529+
expect(selectElement.value).toBe('3');
533530

534531
// something external changes the set of options, including add a new one
535-
selectElement.children[1].setAttribute('value', '4');
536-
selectElement.children[1].innerHTML = 'dog4';
537-
selectElement.children[2].setAttribute('value', '5');
538-
selectElement.children[2].innerHTML = 'dog5';
539-
selectElement.children[3].setAttribute('value', '6');
540-
selectElement.children[3].innerHTML = 'dog6';
541-
const newOption7 = document.createElement('option');
542-
newOption7.setAttribute('value', '7');
543-
newOption7.innerHTML = 'dog7';
544-
selectElement.appendChild(newOption7);
545-
const newOption8 = document.createElement('option');
546-
newOption8.setAttribute('value', '8');
547-
newOption8.innerHTML = 'dog8';
548-
selectElement.appendChild(newOption8);
532+
selectElement.innerHTML = `
533+
<option value="">Select a dog</option>
534+
<option value="4">dog4</option>
535+
<option value="5">dog5</option>
536+
<option value="6">dog6</option>
537+
<option value="7">dog7</option>
538+
<option value="8">dog8</option>
539+
`;
540+
541+
let newTomSelect: TomSelect|null = null;
542+
container.addEventListener('autocomplete:connect', (event: any) => {
543+
newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect;
544+
});
549545

550546
// wait for the MutationObserver to flush these changes
551547
await shortDelay(10);
552548

553-
const controlInput = tomSelect.control_input;
554-
userEvent.click(controlInput);
549+
// the previously selected option is no longer there
550+
expect(selectElement.value).toBe('');
551+
userEvent.click(container.querySelector('.ts-control') as HTMLElement);
555552
await waitFor(() => {
556553
// make sure all 5 new options are there
557554
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(5);
558555
});
559556

560-
tomSelect.addItem('7');
557+
if (null === newTomSelect) {
558+
throw new Error('Missing TomSelect instance');
559+
}
560+
// @ts-ignore
561+
newTomSelect.addItem('7');
561562
expect(selectElement.value).toBe('7');
562563

563564
// remove an element, the control should update
564565
selectElement.removeChild(selectElement.children[1]);
565566
await shortDelay(10);
566-
userEvent.click(controlInput);
567+
userEvent.click(container.querySelector('.ts-control') as HTMLElement);
567568
await waitFor(() => {
568569
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(4);
569570
});
571+
572+
// change again, but the selected value is still there
573+
selectElement.innerHTML = `
574+
<option value="">Select a dog</option>
575+
<option value="1">dog4</option>
576+
<option value="2">dog5</option>
577+
<option value="3">dog6</option>
578+
<option value="7">dog7</option>
579+
`;
580+
await shortDelay(10);
581+
expect(selectElement.value).toBe('7');
570582
});
571583

572584
it('toggles correctly between disabled and enabled', async () => {
@@ -616,15 +628,25 @@ describe('AutocompleteController', () => {
616628
const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
617629
expect(tomSelect.control_input.placeholder).toBe('Select a dog');
618630

619-
selectElement.children[0].innerHTML = 'Select a cat';
620-
// wait for the MutationObserver
621-
await shortDelay(10);
622-
expect(tomSelect.control_input.placeholder).toBe('Select a cat');
631+
let newTomSelect: TomSelect|null = null;
632+
container.addEventListener('autocomplete:connect', (event: any) => {
633+
newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect;
634+
});
635+
636+
selectElement.innerHTML = `
637+
<option value="">Select a cat</option>
638+
<option value="1">dog1</option>
639+
<option value="2">dog2</option>
640+
<option value="3">dog3</option>
641+
`;
623642

624-
// a different way to change the placeholder
625-
selectElement.children[0].childNodes[0].nodeValue = 'Select a kangaroo';
643+
// wait for the MutationObserver
626644
await shortDelay(10);
627-
expect(tomSelect.control_input.placeholder).toBe('Select a kangaroo');
645+
if (null === newTomSelect) {
646+
throw new Error('Missing TomSelect instance');
647+
}
648+
// @ts-ignore
649+
expect(newTomSelect.control_input.placeholder).toBe('Select a cat');
628650
});
629651

630652
it('group related options', async () => {

src/LiveComponent/assets/src/morphdom.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,17 @@ export function executeMorphdom(
112112
return true;
113113
}
114114

115+
if (fromEl.hasAttribute('data-skip-morph')) {
116+
fromEl.innerHTML = toEl.innerHTML;
117+
118+
return false;
119+
}
120+
115121
// look for data-live-ignore, and don't update
116122
return !fromEl.hasAttribute('data-live-ignore');
117123
},
118124

119-
beforeNodeRemoved(node) {
125+
beforeNodeRemoved(node: Node) {
120126
if (!(node instanceof HTMLElement)) {
121127
// text element
122128
return true;

0 commit comments

Comments
 (0)