Skip to content

Commit 1d07901

Browse files
committed
bug #787 [Autocomplete] Limiting fancy updating to non-multiple selects (weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- [Autocomplete] Limiting fancy updating to non-multiple selects | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fix #785 | License | MIT Autocomplete uses TomSelect, which is great. Unfortunately, it does a lot of weird things internally. For example, when you select an item, it actually reorders the `option` elements. Also, if you change the options in a `<select>`, it is very difficult to get TomSelect to "reset" itself and update to show those new options. In an earlier PR, I actually DID (through a lot of pain), make Autocomplete smart enough so that, if *anything* external changes the `<option>` elements inside of the original `<select>`, the `TomSelect` instance would reinitialize to show the new options. This is especially challenging since TomSelect itself is rearranging and changing elements in an odd way. However, when it comes to `<select multiple>`, its behavior seems WAY too complex to "fix". Thus, if you have a `<select multiple` and you change some of its `<option>` elements, you will get the default TomSelect behavior: it will NOT update it show the new options. This is all more relevant when using TomSelect with LiveComponents. tl;dr * Autocomplete + LiveComponents "should" (thank to an earlier PR) work great together... except for `multiple` selects * For `multiple` selects, I've waved the white flag. To make it work in 95% of the cases, however, we add a `data-live-ignore` so that LiveComponents doesn't touch the TomSelect widget after it's initialized. Commits ------- 669ed87 [Autocomplete] Limiting fancy updating to non-multiple selects
2 parents c141baa + 669ed87 commit 1d07901

File tree

7 files changed

+105
-17
lines changed

7 files changed

+105
-17
lines changed

src/Autocomplete/CHANGELOG.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
## 2.8.0
44

5-
6-
- The autocomplete will now update if any of the `option` elements inside of
5+
- The autocomplete now watches for update to any `option` elements inside of
76
it change, including the empty / placeholder element. Additionally, if the
87
`select` or `input` element's `disabled` attribute changes, the autocomplete
9-
instance will update accordingly. This makes Autocomplete work perfectly inside
10-
of a LiveComponent.
8+
instance will update accordingly. This makes Autocomplete work correctly inside
9+
of a LiveComponent. This functionality does _not_ work for `multiple` selects.
1110

1211
- Added support for using [OptionGroups](https://tom-select.js.org/examples/optgroups/).
1312

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ export default class extends Controller {
4646
private startMutationObserver;
4747
private stopMutationObserver;
4848
private onMutations;
49+
private requiresLiveIgnore;
4950
}

src/Autocomplete/assets/dist/controller.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,21 @@ class default_1 extends Controller {
3030
this.isObserving = false;
3131
}
3232
initialize() {
33-
if (!this.mutationObserver) {
34-
this.mutationObserver = new MutationObserver((mutations) => {
35-
this.onMutations(mutations);
36-
});
33+
if (this.requiresLiveIgnore()) {
34+
this.element.setAttribute('data-live-ignore', '');
35+
if (this.element.id) {
36+
const label = document.querySelector(`label[for="${this.element.id}"]`);
37+
if (label) {
38+
label.setAttribute('data-live-ignore', '');
39+
}
40+
}
41+
}
42+
else {
43+
if (!this.mutationObserver) {
44+
this.mutationObserver = new MutationObserver((mutations) => {
45+
this.onMutations(mutations);
46+
});
47+
}
3748
}
3849
}
3950
connect() {
@@ -118,7 +129,7 @@ class default_1 extends Controller {
118129
}
119130
}
120131
startMutationObserver() {
121-
if (!this.isObserving) {
132+
if (!this.isObserving && this.mutationObserver) {
122133
this.mutationObserver.observe(this.element, {
123134
childList: true,
124135
subtree: true,
@@ -129,7 +140,7 @@ class default_1 extends Controller {
129140
}
130141
}
131142
stopMutationObserver() {
132-
if (this.isObserving) {
143+
if (this.isObserving && this.mutationObserver) {
133144
this.mutationObserver.disconnect();
134145
this.isObserving = false;
135146
}
@@ -200,6 +211,9 @@ class default_1 extends Controller {
200211
this.updateTomSelectPlaceholder();
201212
}
202213
}
214+
requiresLiveIgnore() {
215+
return this.element instanceof HTMLSelectElement && this.element.multiple;
216+
}
203217
}
204218
_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
205219
const plugins = {};
@@ -218,12 +232,19 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
218232
return `<div class="no-results">${this.noResultsFoundTextValue}</div>`;
219233
},
220234
};
235+
const requiresLiveIgnore = this.requiresLiveIgnore();
221236
const config = {
222237
render,
223238
plugins,
224239
onItemAdd: () => {
225240
this.tomSelect.setTextboxValue('');
226241
},
242+
onInitialize: function () {
243+
if (requiresLiveIgnore) {
244+
const tomSelect = this;
245+
tomSelect.wrapper.setAttribute('data-live-ignore', '');
246+
}
247+
},
227248
closeAfterSelect: true,
228249
};
229250
if (!this.selectElement && !this.urlValue) {

src/Autocomplete/assets/src/controller.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,27 @@ export default class extends Controller {
3636
private isObserving = false;
3737

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

@@ -88,13 +105,22 @@ export default class extends Controller {
88105
},
89106
};
90107

108+
const requiresLiveIgnore = this.requiresLiveIgnore();
109+
91110
const config: RecursivePartial<TomSettings> = {
92111
render,
93112
plugins,
94113
// clear the text input after selecting a value
95114
onItemAdd: () => {
96115
this.tomSelect.setTextboxValue('');
97116
},
117+
// see initialize() method for explanation
118+
onInitialize: function () {
119+
if (requiresLiveIgnore) {
120+
const tomSelect = this as any;
121+
tomSelect.wrapper.setAttribute('data-live-ignore', '');
122+
}
123+
},
98124
closeAfterSelect: true,
99125
};
100126

@@ -300,7 +326,7 @@ export default class extends Controller {
300326
}
301327

302328
private startMutationObserver(): void {
303-
if (!this.isObserving) {
329+
if (!this.isObserving && this.mutationObserver) {
304330
this.mutationObserver.observe(this.element, {
305331
childList: true,
306332
subtree: true,
@@ -312,7 +338,7 @@ export default class extends Controller {
312338
}
313339

314340
private stopMutationObserver(): void {
315-
if (this.isObserving) {
341+
if (this.isObserving && this.mutationObserver) {
316342
this.mutationObserver.disconnect();
317343
this.isObserving = false;
318344
}
@@ -404,4 +430,8 @@ export default class extends Controller {
404430
this.updateTomSelectPlaceholder();
405431
}
406432
}
433+
434+
private requiresLiveIgnore(): boolean {
435+
return this.element instanceof HTMLSelectElement && this.element.multiple;
436+
}
407437
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,29 @@ describe('AutocompleteController', () => {
227227
expect(tomSelect.settings.shouldLoad('')).toBeTruthy()
228228
})
229229

230+
it('adds work-around for live-component & multiple select', async () => {
231+
const { container } = await startAutocompleteTest(`
232+
<div>
233+
<label for="the-select" data-testid="main-element-label">Select something</label>
234+
<select
235+
id="the-select"
236+
data-testid="main-element"
237+
data-controller="check autocomplete"
238+
multiple
239+
></select>
240+
</div>
241+
`);
242+
243+
expect(getByTestId(container, 'main-element')).toHaveAttribute('data-live-ignore');
244+
expect(getByTestId(container, 'main-element-label')).toHaveAttribute('data-live-ignore');
245+
const tsDropdown = container.querySelector('.ts-wrapper');
246+
247+
await waitFor(() => {
248+
expect(tsDropdown).not.toBeNull();
249+
});
250+
expect(tsDropdown).toHaveAttribute('data-live-ignore');
251+
});
252+
230253
it('loads new pages on scroll', async () => {
231254
document.addEventListener('autocomplete:pre-connect', (event: any) => {
232255
const options = (event.detail as AutocompletePreConnectOptions).options;

src/Autocomplete/doc/index.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,19 @@ consider registering the needed type extension ``AutocompleteChoiceTypeExtension
560560
// ... your tests
561561
}
562562

563+
Known Issue when using with Live Component
564+
------------------------------------------
565+
566+
You *can* use autocomplete inside of a `Live Component`_: the autocomplete JavaScript
567+
widget should work normally and even update if your element changes (e.g. if you
568+
add or change ``<option>`` elements. Internally, a ``MutationObserver`` inside
569+
the UX autocomplete controller detects these changes and forwards them to TomSelect.
570+
571+
However, if you use the ``multiple`` option, due to complexities in TomSelect, the
572+
autocomplete widget *will* work, but it will not update if you change any options.
573+
For example, if your change the "options" for a ``select`` during re-render, those
574+
will not update on the frontend.
575+
563576
Backward Compatibility promise
564577
------------------------------
565578

@@ -572,3 +585,4 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html
572585
.. _`controller.ts`: https://github.com/symfony/ux/blob/2.x/src/Autocomplete/assets/src/controller.ts
573586
.. _`Tom Select Render Templates`: https://tom-select.js.org/docs/#render-templates
574587
.. _`Tom Select Option Group`: https://tom-select.js.org/examples/optgroups/
588+
.. _`Live Component`: https://symfony.com/bundles/ux-live-component/current/index.html

ux.symfony.com/src/Form/MealPlannerForm.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function configureOptions(OptionsResolver $resolver)
6868
private function getAvailableFoodChoices(string $meal): array
6969
{
7070
$foods = match ($meal) {
71-
self::MEAL_BREAKFAST => ['Eggs 🍳', 'Bacon 🥓', 'Strawberries 🍓', 'Croissant 🥐', 'Other', 'AnOther'],
71+
self::MEAL_BREAKFAST => ['Eggs 🍳', 'Bacon 🥓', 'Strawberries 🍓', 'Croissant 🥐'],
7272
self::MEAL_SECOND_BREAKFAST => ['Bagel 🥯', 'Kiwi 🥝', 'Avocado 🥑', 'Waffles 🧇'],
7373
self::MEAL_ELEVENSES => ['Pancakes 🥞', 'Salad 🥙', 'Tea ☕️'],
7474
self::MEAL_LUNCH => ['Sandwich 🥪', 'Cheese 🧀', 'Sushi 🍱'],

0 commit comments

Comments
 (0)