Skip to content

Commit a95a26a

Browse files
committed
[Live] Tweaking data-ignore-morph to still morph outer element
Also resetting TomSelect on a "multiple" attribute change and removing old hacks.
1 parent 1af6093 commit a95a26a

File tree

9 files changed

+80
-102
lines changed

9 files changed

+80
-102
lines changed

src/Autocomplete/assets/dist/controller.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export default class extends Controller {
4848
private startMutationObserver;
4949
private stopMutationObserver;
5050
private onMutations;
51-
private requiresLiveIgnore;
5251
private createOptionsDataStructure;
5352
private areOptionsEquivalent;
5453
}

src/Autocomplete/assets/dist/controller.js

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,10 @@ class default_1 extends Controller {
3232
this.originalOptions = [];
3333
}
3434
initialize() {
35-
if (this.requiresLiveIgnore()) {
36-
this.element.setAttribute('data-live-ignore', '');
37-
if (this.element.id) {
38-
const label = document.querySelector(`label[for="${this.element.id}"]`);
39-
if (label) {
40-
label.setAttribute('data-live-ignore', '');
41-
}
42-
}
43-
}
44-
else {
45-
if (!this.mutationObserver) {
46-
this.mutationObserver = new MutationObserver((mutations) => {
47-
this.onMutations(mutations);
48-
});
49-
}
35+
if (!this.mutationObserver) {
36+
this.mutationObserver = new MutationObserver((mutations) => {
37+
this.onMutations(mutations);
38+
});
5039
}
5140
}
5241
connect() {
@@ -152,23 +141,23 @@ class default_1 extends Controller {
152141
changeDisabledState = true;
153142
break;
154143
}
144+
if (mutation.target === this.element && mutation.attributeName === 'multiple') {
145+
changeDisabledState = true;
146+
break;
147+
}
155148
break;
156149
}
157150
});
158151
const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
159152
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
160-
console.log('are options equivalent?', areOptionsEquivalent);
161-
if (!areOptionsEquivalent) {
153+
if (!areOptionsEquivalent || changeDisabledState) {
162154
this.originalOptions = newOptions;
163155
this.resetTomSelect();
164156
}
165157
if (changeDisabledState) {
166158
this.changeTomSelectDisabledState(this.formElement.disabled);
167159
}
168160
}
169-
requiresLiveIgnore() {
170-
return this.element instanceof HTMLSelectElement && this.element.multiple;
171-
}
172161
createOptionsDataStructure(selectElement) {
173162
return Array.from(selectElement.options).map((option) => {
174163
const optgroup = option.closest('optgroup');
@@ -207,19 +196,12 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
207196
return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
208197
},
209198
};
210-
const requiresLiveIgnore = this.requiresLiveIgnore();
211199
const config = {
212200
render,
213201
plugins,
214202
onItemAdd: () => {
215203
this.tomSelect.setTextboxValue('');
216204
},
217-
onInitialize: function () {
218-
if (requiresLiveIgnore) {
219-
const tomSelect = this;
220-
tomSelect.wrapper.setAttribute('data-live-ignore', '');
221-
}
222-
},
223205
closeAfterSelect: true,
224206
};
225207
if (!this.selectElement && !this.urlValue) {

src/Autocomplete/assets/src/controller.ts

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,10 @@ export default class extends Controller {
4141
private originalOptions: Array<{ value: string; text: string; group: string | null }> = [];
4242

4343
initialize() {
44-
if (this.requiresLiveIgnore()) {
45-
// unfortunately, TomSelect does enough weird things that, for a
46-
// multi select, if the HTML in the `<select>` element changes,
47-
// we can't reliably update TomSelect to see those changes. So,
48-
// as a workaround, we tell LiveComponents to entirely ignore trying
49-
// to update this item
50-
this.element.setAttribute('data-live-ignore', '');
51-
if (this.element.id) {
52-
const label = document.querySelector(`label[for="${this.element.id}"]`);
53-
if (label) {
54-
label.setAttribute('data-live-ignore', '');
55-
}
56-
}
57-
} else {
58-
// for non-multiple selects, we use a MutationObserver to update
59-
// the TomSelect instance if the options themselves change
60-
if (!this.mutationObserver) {
61-
this.mutationObserver = new MutationObserver((mutations: MutationRecord[]) => {
62-
this.onMutations(mutations);
63-
});
64-
}
44+
if (!this.mutationObserver) {
45+
this.mutationObserver = new MutationObserver((mutations: MutationRecord[]) => {
46+
this.onMutations(mutations);
47+
});
6548
}
6649
}
6750

@@ -127,22 +110,13 @@ export default class extends Controller {
127110
},
128111
};
129112

130-
const requiresLiveIgnore = this.requiresLiveIgnore();
131-
132113
const config: RecursivePartial<TomSettings> = {
133114
render,
134115
plugins,
135116
// clear the text input after selecting a value
136117
onItemAdd: () => {
137118
this.tomSelect.setTextboxValue('');
138119
},
139-
// see initialize() method for explanation
140-
onInitialize: function () {
141-
if (requiresLiveIgnore) {
142-
const tomSelect = this as any;
143-
tomSelect.wrapper.setAttribute('data-live-ignore', '');
144-
}
145-
},
146120
closeAfterSelect: true,
147121
};
148122

@@ -370,6 +344,7 @@ export default class extends Controller {
370344

371345
private onMutations(mutations: MutationRecord[]): void {
372346
let changeDisabledState = false;
347+
let requireReset = false;
373348

374349
mutations.forEach((mutation) => {
375350
switch (mutation.type) {
@@ -380,14 +355,19 @@ export default class extends Controller {
380355
break;
381356
}
382357

358+
if (mutation.target === this.element && mutation.attributeName === 'multiple') {
359+
changeDisabledState = true;
360+
361+
break;
362+
}
363+
383364
break;
384365
}
385366
});
386367

387368
const newOptions = this.selectElement ? this.createOptionsDataStructure(this.selectElement) : [];
388369
const areOptionsEquivalent = this.areOptionsEquivalent(newOptions);
389-
console.log('are options equivalent?', areOptionsEquivalent);
390-
if (!areOptionsEquivalent) {
370+
if (!areOptionsEquivalent || changeDisabledState) {
391371
this.originalOptions = newOptions;
392372
this.resetTomSelect();
393373
}
@@ -397,10 +377,6 @@ export default class extends Controller {
397377
}
398378
}
399379

400-
private requiresLiveIgnore(): boolean {
401-
return this.element instanceof HTMLSelectElement && this.element.multiple;
402-
}
403-
404380
private createOptionsDataStructure(
405381
selectElement: HTMLSelectElement
406382
): Array<{ value: string; text: string; group: string | null }> {

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

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -329,29 +329,6 @@ describe('AutocompleteController', () => {
329329
expect(fetchMock.requests()[2].url).toEqual('/path/to/autocomplete?query=fo');
330330
});
331331

332-
it('adds work-around for live-component & multiple select', async () => {
333-
const { container } = await startAutocompleteTest(`
334-
<div>
335-
<label for="the-select" data-testid="main-element-label">Select something</label>
336-
<select
337-
id="the-select"
338-
data-testid="main-element"
339-
data-controller="check autocomplete"
340-
multiple
341-
></select>
342-
</div>
343-
`);
344-
345-
expect(getByTestId(container, 'main-element')).toHaveAttribute('data-live-ignore');
346-
expect(getByTestId(container, 'main-element-label')).toHaveAttribute('data-live-ignore');
347-
const tsDropdown = container.querySelector('.ts-wrapper');
348-
349-
await waitFor(() => {
350-
expect(tsDropdown).not.toBeNull();
351-
});
352-
expect(tsDropdown).toHaveAttribute('data-live-ignore');
353-
});
354-
355332
it('loads new pages on scroll', async () => {
356333
document.addEventListener('autocomplete:pre-connect', (event: any) => {
357334
const options = (event.detail as AutocompletePreConnectOptions).options;
@@ -581,6 +558,47 @@ describe('AutocompleteController', () => {
581558
expect(selectElement.value).toBe('7');
582559
});
583560

561+
it('updates properly if options on a multiple select change', async () => {
562+
const { container, tomSelect } = await startAutocompleteTest(`
563+
<select multiple data-testid='main-element' data-controller='autocomplete'>
564+
<option value=''>Select dogs</option>
565+
<option value='1'>dog1</option>
566+
<option value='2'>dog2</option>
567+
<option value='3'>dog3</option>
568+
</select>
569+
`);
570+
571+
tomSelect.addItem('3');
572+
tomSelect.addItem('2');
573+
const getSelectedValues = () => {
574+
return Array.from(selectElement.selectedOptions).map((option) => option.value).sort();
575+
}
576+
const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement;
577+
expect(getSelectedValues()).toEqual(['2', '3']);
578+
579+
// something external changes the set of options, including add new ones
580+
selectElement.innerHTML = `
581+
<option value=''>Select a dog</option>
582+
<option value='2'>dog2</option>
583+
<option value='4'>dog4</option>
584+
<option value='5'>dog5</option>
585+
<option value='6'>dog6</option>
586+
<option value='7'>dog7</option>
587+
`;
588+
589+
// wait for the MutationObserver to flush these changes
590+
await shortDelay(10);
591+
592+
// only the "2" option from before is still there
593+
expect(getSelectedValues()).toEqual(['2']);
594+
userEvent.click(container.querySelector('.ts-control') as HTMLElement);
595+
await waitFor(() => {
596+
// make sure that, out of the 5 total options, 4 are still selectable
597+
// (the "2" option is not selectable because it's already selected)
598+
expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(4);
599+
});
600+
});
601+
584602
it('toggles correctly between disabled and enabled', async () => {
585603
const { container, tomSelect } = await startAutocompleteTest(`
586604
<select data-testid="main-element" data-controller="autocomplete">

src/Autocomplete/doc/index.rst

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -574,19 +574,6 @@ consider registering the needed type extension ``AutocompleteChoiceTypeExtension
574574
// ... your tests
575575
}
576576

577-
Known Issue when using with Live Component
578-
------------------------------------------
579-
580-
You *can* use autocomplete inside of a `Live Component`_: the autocomplete JavaScript
581-
widget should work normally and even update if your element changes (e.g. if you
582-
add or change ``<option>`` elements. Internally, a ``MutationObserver`` inside
583-
the UX autocomplete controller detects these changes and forwards them to TomSelect.
584-
585-
However, if you use the ``multiple`` option, due to complexities in TomSelect, the
586-
autocomplete widget *will* work, but it will not update if you change any options.
587-
For example, if your change the "options" for a ``select`` during re-render, those
588-
will not update on the frontend.
589-
590577
Backward Compatibility promise
591578
------------------------------
592579

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,6 +1266,9 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements,
12661266
}
12671267
if (fromEl.hasAttribute('data-skip-morph')) {
12681268
fromEl.innerHTML = toEl.innerHTML;
1269+
return true;
1270+
}
1271+
if (fromEl.parentElement && fromEl.parentElement.hasAttribute('data-skip-morph')) {
12691272
return false;
12701273
}
12711274
return !fromEl.hasAttribute('data-live-ignore');

src/LiveComponent/assets/src/morphdom.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export function executeMorphdom(
115115
if (fromEl.hasAttribute('data-skip-morph')) {
116116
fromEl.innerHTML = toEl.innerHTML;
117117

118+
return true;
119+
}
120+
121+
if (fromEl.parentElement && fromEl.parentElement.hasAttribute('data-skip-morph')) {
118122
return false;
119123
}
120124

src/LiveComponent/assets/test/controller/render.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('LiveController rendering Tests', () => {
211211
it('overwrites HTML instead of morph with data-skip-morph', async () => {
212212
const test = await createTest({ firstName: 'Ryan' }, (data: any) => `
213213
<div ${initComponent(data)}>
214-
<div data-skip-morph>Inside Skip Name: <span data-testid="inside-skip-morph">${data.firstName}</span></div>
214+
<div data-skip-morph data-name="${data.firstName}">Inside Skip Name: <span data-testid="inside-skip-morph">${data.firstName}</span></div>
215215
216216
Outside Skip Name: ${data.firstName}
217217
@@ -231,6 +231,12 @@ describe('LiveController rendering Tests', () => {
231231
getByText(test.element, 'Reload').click();
232232

233233
await waitFor(() => expect(test.element).toHaveTextContent('Outside Skip Name: Kevin'));
234+
// make sure the outer element is still updated
235+
const skipElement = test.element.querySelector('div[data-skip-morph]');
236+
if (!(skipElement instanceof HTMLElement)) {
237+
throw new Error('skipElement is not an HTMLElement');
238+
}
239+
expect(skipElement.dataset.name).toEqual('Kevin');
234240
const spanAfter = getByTestId(test.element, 'inside-skip-morph');
235241
expect(spanAfter).toHaveTextContent('Kevin');
236242
// but it is not just a mutation of the original element

src/LiveComponent/doc/index.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3248,7 +3248,7 @@ Overwrite HTML Instead of Morphing
32483248

32493249
Normally, when a component re-renders, the new HTML is "morphed" onto the existing
32503250
elements on the page. In some rare cases, you may want to simply overwrite the existing
3251-
HTML with the new HTML instead of morphing it. This can be done by adding a
3251+
inner HTML of an element with the new HTML instead of morphing it. This can be done by adding a
32523252
``data-skip-morph`` attribute:
32533253

32543254
.. code-block:: html
@@ -3257,6 +3257,9 @@ HTML with the new HTML instead of morphing it. This can be done by adding a
32573257
<option>...</option>
32583258
</select>
32593259

3260+
In this case, any changes to the ``<select>`` element attributes will still be
3261+
"morphed" onto the existing element, but the inner HTML will be overwritten.
3262+
32603263
Define another route for your Component
32613264
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
32623265

0 commit comments

Comments
 (0)