Skip to content

Commit

Permalink
[Live][Autocomplete] Fixing morphing on tomselect autocomplete elements
Browse files Browse the repository at this point in the history
  • Loading branch information
weaverryan committed Jan 21, 2024
1 parent 8a22b5c commit 985445a
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 144 deletions.
138 changes: 48 additions & 90 deletions src/Autocomplete/assets/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default class extends Controller {
private mutationObserver: MutationObserver;
private isObserving = false;
private hasLoadedChoicesPreviously = false;
private originalOptions: Array<{ value: string; text: string; group: string | null }> = [];

initialize() {
if (this.requiresLiveIgnore()) {
Expand Down Expand Up @@ -65,10 +66,20 @@ export default class extends Controller {
}

connect() {
if (this.selectElement) {
this.originalOptions = this.createOptionsDataStructure(this.selectElement);
}

this.initializeTomSelect();
}

initializeTomSelect() {
// live components support: morphing the options causes issues, due
// to the fact that TomSelect reorders the options when you select them
if (this.selectElement) {
this.selectElement.setAttribute('data-skip-morph', '');
}

if (this.urlValue) {
this.tomSelect = this.#createAutocompleteWithRemoteData(
this.urlValue,
Expand Down Expand Up @@ -313,8 +324,17 @@ export default class extends Controller {
private resetTomSelect(): void {
if (this.tomSelect) {
this.stopMutationObserver();

// Grab the current HTML then restore it after destroying TomSelect
// This is needed because TomSelect's destroy revert the element to
// its original HTML.
const currentHtml = this.element.innerHTML;
const currentValue: any = this.tomSelect.getValue();
this.tomSelect.destroy();
this.element.innerHTML = currentHtml;
this.initializeTomSelect();
this.tomSelect.setValue(currentValue);

this.startMutationObserver();
}
}
Expand All @@ -329,33 +349,6 @@ export default class extends Controller {
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();
// override settings so it's used again later
this.tomSelect.settings.placeholder = placeholder;
// and set it right now
this.tomSelect.control_input.setAttribute('placeholder', placeholder);
this.startMutationObserver();
}
}

private startMutationObserver(): void {
if (!this.isObserving && this.mutationObserver) {
this.mutationObserver.observe(this.element, {
Expand All @@ -376,93 +369,58 @@ export default class extends Controller {
}

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 <option> elements - e.g. text
if (mutation.target instanceof HTMLOptionElement) {
if (mutation.target.value === '') {
changePlaceholder = true;

break;
}

hasAnOptionChanged = true;
break;
}

// look for new or removed <option> elements
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLOptionElement) {
// check if a previously-removed is being added back
if (removedOptionElements.includes(node)) {
removedOptionElements.splice(removedOptionElements.indexOf(node), 1);
return;
}

addedOptionElements.push(node);
}
});
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLOptionElement) {
// check if a previously-added is being removed
if (addedOptionElements.includes(node)) {
addedOptionElements.splice(addedOptionElements.indexOf(node), 1);
return;
}

removedOptionElements.push(node);
}
});
break;
case 'attributes':
// look for changes to any <option> elements (e.g. value attribute)
if (mutation.target instanceof HTMLOptionElement) {
hasAnOptionChanged = true;
break;
}

if (mutation.target === this.element && mutation.attributeName === 'disabled') {
changeDisabledState = true;

break;
}

break;
case 'characterData':
// an alternative way for an option's text to change
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) {
const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
console.log('are options equivalent?', areOptionsEquivalent);
if (!areOptionsEquivalent) {
this.originalOptions = newOptions;
this.resetTomSelect();
}

if (changeDisabledState) {
this.changeTomSelectDisabledState(this.formElement.disabled);
}

if (changePlaceholder) {
this.updateTomSelectPlaceholder();
}
}

private requiresLiveIgnore(): boolean {
return this.element instanceof HTMLSelectElement && this.element.multiple;
}

private createOptionsDataStructure(selectElement: HTMLSelectElement): Array<{ value: string; text: string; group: string | null }> {
return Array.from(selectElement.options).map(option => {
const optgroup = option.closest('optgroup');
return {
value: option.value,
text: option.text,
group: optgroup ? optgroup.label : null
};
});
}

private areOptionsEquivalent(newOptions: Array<{ value: string; text: string; group: string | null }>): boolean {
if (this.originalOptions.length !== newOptions.length) {
return false;
}

const normalizeOption = (option: { value: string; text: string; group: string | null }) => `${option.value}-${option.text}-${option.group}`;
const originalOptionsSet = new Set(this.originalOptions.map(normalizeOption));
const newOptionsSet = new Set(newOptions.map(normalizeOption));

return originalOptionsSet.size === newOptionsSet.size && [...originalOptionsSet].every(option => newOptionsSet.has(option));
}
}
126 changes: 74 additions & 52 deletions src/Autocomplete/assets/test/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,13 +443,13 @@ describe('AutocompleteController', () => {
// wait for the MutationObserver to be able to flush
await shortDelay(10);

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

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

// TomSelect will move the "2" option out of its optgroup and onto the bottom
// let's imitate an Ajax call reversing that
const selectedOption2 = selectElement.children[3];
if (!(selectedOption2 instanceof HTMLOptionElement)) {
throw new Error('cannot find option 3');
}
const smallDogGroup = selectElement.children[1];
if (!(smallDogGroup instanceof HTMLOptGroupElement)) {
throw new Error('cannot find small dog group');
}

// add a new element, which is really just the old dog2
const newOption2 = document.createElement('option');
newOption2.setAttribute('value', '2');
newOption2.innerHTML = 'dog2';
// but the new HTML will correctly mark this as selected
newOption2.setAttribute('selected', '');
smallDogGroup.appendChild(newOption2);

// remove the dog2 element from the bottom
selectElement.removeChild(selectedOption2);
// let's imitate an Ajax call reversing that order
selectElement.innerHTML = `
<option value="">Select a dog</option>
<optgroup label="big dogs">
<option value="4">dog4</option>
<option value="5">dog5</option>
<option value="6">dog6</option>
</optgroup>
<optgroup label="small dogs">
<option value="1">dog1</option>
<option value="2" selected>dog2</option>
<option value="3">dog3</option>
</optgroup>
`;

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

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

// something external changes the set of options, including add a new one
selectElement.children[1].setAttribute('value', '4');
selectElement.children[1].innerHTML = 'dog4';
selectElement.children[2].setAttribute('value', '5');
selectElement.children[2].innerHTML = 'dog5';
selectElement.children[3].setAttribute('value', '6');
selectElement.children[3].innerHTML = 'dog6';
const newOption7 = document.createElement('option');
newOption7.setAttribute('value', '7');
newOption7.innerHTML = 'dog7';
selectElement.appendChild(newOption7);
const newOption8 = document.createElement('option');
newOption8.setAttribute('value', '8');
newOption8.innerHTML = 'dog8';
selectElement.appendChild(newOption8);
selectElement.innerHTML = `
<option value="">Select a dog</option>
<option value="4">dog4</option>
<option value="5">dog5</option>
<option value="6">dog6</option>
<option value="7">dog7</option>
<option value="8">dog8</option>
`;

let newTomSelect: TomSelect|null = null;
container.addEventListener('autocomplete:connect', (event: any) => {
newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect;
});

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

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

tomSelect.addItem('7');
if (null === newTomSelect) {
throw new Error('Missing TomSelect instance');
}
// @ts-ignore
newTomSelect.addItem('7');
expect(selectElement.value).toBe('7');

// remove an element, the control should update
selectElement.removeChild(selectElement.children[1]);
await shortDelay(10);
userEvent.click(controlInput);
userEvent.click(container.querySelector('.ts-control') as HTMLElement);
await waitFor(() => {
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(4);
});

// change again, but the selected value is still there
selectElement.innerHTML = `
<option value="">Select a dog</option>
<option value="1">dog4</option>
<option value="2">dog5</option>
<option value="3">dog6</option>
<option value="7">dog7</option>
`;
await shortDelay(10);
expect(selectElement.value).toBe('7');
});

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

selectElement.children[0].innerHTML = 'Select a cat';
// wait for the MutationObserver
await shortDelay(10);
expect(tomSelect.control_input.placeholder).toBe('Select a cat');
let newTomSelect: TomSelect|null = null;
container.addEventListener('autocomplete:connect', (event: any) => {
newTomSelect = (event.detail as AutocompleteConnectOptions).tomSelect;
});

selectElement.innerHTML = `
<option value="">Select a cat</option>
<option value="1">dog1</option>
<option value="2">dog2</option>
<option value="3">dog3</option>
`;

// a different way to change the placeholder
selectElement.children[0].childNodes[0].nodeValue = 'Select a kangaroo';
// wait for the MutationObserver
await shortDelay(10);
expect(tomSelect.control_input.placeholder).toBe('Select a kangaroo');
if (null === newTomSelect) {
throw new Error('Missing TomSelect instance');
}
// @ts-ignore
expect(newTomSelect.control_input.placeholder).toBe('Select a cat');
});

it('group related options', async () => {
Expand Down
8 changes: 7 additions & 1 deletion src/LiveComponent/assets/src/morphdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,17 @@ export function executeMorphdom(
return true;
}

if (fromEl.hasAttribute('data-skip-morph')) {
fromEl.innerHTML = toEl.innerHTML;

return false;
}

// look for data-live-ignore, and don't update
return !fromEl.hasAttribute('data-live-ignore');
},

beforeNodeRemoved(node) {
beforeNodeRemoved(node: Node) {
if (!(node instanceof HTMLElement)) {
// text element
return true;
Expand Down
Loading

0 comments on commit 985445a

Please sign in to comment.