Repository: symfony/ux (php)

Issue:
When specfying `tom_select_options` on `AsEntityAutocompleteField`: errors `Uncaught TypeError: this.getUrl is not a function`
Hi,

If you define an `AsEntityAutocompleteField` as per [the docs](https://symfony.com/bundles/ux-autocomplete/current/index.html#usage-in-a-form-with-ajax), but add some `tom_select_options`, 

<details>
<summary>FoodAutocompleteField.php</summary>

```php
<?php

// src/Form/FoodAutocompleteField.php
// ...

use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\ParentEntityAutocompleteType;

#[AsEntityAutocompleteField]
class FoodAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'class' => Food::class,
            'placeholder' => 'What should we eat?',
            'tom_select_options' => [
                'plugins' => ['dropdown_input'],
            ],
    }

    public function getParent(): string
    {
        return ParentEntityAutocompleteType::class;
    }
}
```

</details>

you will receive a javascript error when the ajax calls is made to load items:
```
Uncaught TypeError: this.getUrl is not a function
```
which comes from here:
https://github.com/symfony/ux-autocomplete/blob/6a1851b8ed5e51c0a285728b0e51522e62ae6fdd/assets/dist/controller.js#L286

This comment is suspicious:
https://github.com/symfony/ux-autocomplete/blob/2.x/assets/src/controller.ts#L178-L180

Thanks,
Ben

[UX Autocomplete] setting "tom_select_options" overrides some default values set by ux which breaks plugin
Tested on 2.10.

When creating `FormType`:
1. Set `autocomplete_url` to some custom url.
2. Set some custom plugin you want to add via `tom_select_options`

```php
$builder->add('field', ChoiceType::class, [
                'choices' => [
                    'Select something' => '',
                ],
                'autocomplete' => true,
                'autocomplete_url' => 'custom_url,
                'tom_select_options' => [
                    'plugins' => ['dropdown_input'],
                ]
            ]);
```

3. On site open select to choose something and you will get an error in console from ux-autocomplete controller

`Uncaught TypeError: this.getUrl is not a function`


The error is caused because of that ux-autocomplete controller adds `virtual_scroll` as a plugin by default when `url` option is set. However providing `plugins` option directly overrides it. Knowing that fact I can set of course it manually

```php
'tom_select_options' => [
    'plugins' => ['dropdown_input', 'virtual_scroll'],
]
```



It would be nice if some required default options were merged instead of replacing them. Or at least there may be some information in doc what kind of options should be set to not override those created by ux-autocomplete controller.


Pull request description:
[Autocomplete] Ensure default plugins are nicely merged with user-defined plugins
| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Docs?         | no <!-- required for new features -->
| Issues        | Fix #1128, Fix #2002 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT

The issue happens because when merging the default configuration and the configuration defined by the user, plugins defined by the users erase plugins defined in the default configuration:

At the moment, when the user didn't define `tom_select_options.plugins`, everything works fine:

<details>
<summary>Screenshot</summary>

<img width="1800" alt="Capture d’écran 2025-06-13 à 14 22 37" src="https://github.com/user-attachments/assets/744ebb1c-b006-4e73-b90a-af08c32cea3c" />

</details>

But when configuring `tom_select_options.plugins`, some necessary plugins are missing:

<details>
<summary>Screenshot</summary>

<img width="1800" alt="Capture d’écran 2025-06-13 à 14 22 55" src="https://github.com/user-attachments/assets/a9731b2d-5c5c-40f1-a19b-e24524765c05" /> 

</details>

With this PR, `tom_select_options.plugins` from the default configuration and the configuration defined by the user are nicely merged:

<details>
<summary>Screenshots</summary>

<img width="1800" alt="Capture d’écran 2025-06-13 à 14 23 30" src="https://github.com/user-attachments/assets/a07f47a9-1acb-44a7-90b6-45fcc42c8da4" />
<img width="1800" alt="Capture d’écran 2025-06-13 à 14 23 53" src="https://github.com/user-attachments/assets/1e1d5064-2425-400c-95f3-3ca24f7246ed" />

</details>


## TODO:
- [x] Update JS tests to prevent regressions 

Patch:
diff --git a/link b/link
index 36580089..52fbd1d2 100755
--- a/link
+++ b/link
@@ -38,7 +38,7 @@ if (!is_dir("$pathToProject/vendor/symfony")) {
 $sfPackages = array();
 
 $filesystem = new Filesystem();
-$directories = [...glob(__DIR__.'/src/*', GLOB_ONLYDIR | GLOB_NOSORT), ...glob(__DIR__.'/src/*/src/Bridge/*', GLOB_ONLYDIR | GLOB_NOSORT)];
+$directories = glob(__DIR__.'/src/*', GLOB_ONLYDIR | GLOB_NOSORT);
 
 foreach ($directories as $dir) {
     if ($filesystem->exists($composer = "$dir/composer.json")) {
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index b9712824..7670659e 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -29,7 +29,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
     return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
 };
 
-var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect;
+var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeConfigs, _default_1_normalizePluginsToHash, _default_1_createTomSelect;
 class default_1 extends Controller {
     constructor() {
         super(...arguments);
@@ -37,6 +37,20 @@ class default_1 extends Controller {
         this.isObserving = false;
         this.hasLoadedChoicesPreviously = false;
         this.originalOptions = [];
+        _default_1_normalizePluginsToHash.set(this, (plugins) => {
+            if (Array.isArray(plugins)) {
+                return plugins.reduce((acc, plugin) => {
+                    if (typeof plugin === 'string') {
+                        acc[plugin] = {};
+                    }
+                    if (typeof plugin === 'object' && plugin.name) {
+                        acc[plugin.name] = plugin.options || {};
+                    }
+                    return acc;
+                }, {});
+            }
+            return plugins;
+        });
     }
     initialize() {
         if (!this.mutationObserver) {
@@ -223,7 +237,7 @@ class default_1 extends Controller {
             [...originalOptionsSet].every((option) => newOptionsSet.has(option)));
     }
 }
-_default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
+_default_1_normalizePluginsToHash = new WeakMap(), _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _default_1_getCommonConfig() {
     const plugins = {};
     const isMultiple = !this.selectElement || this.selectElement.multiple;
     if (!this.formElement.disabled && !isMultiple) {
@@ -288,16 +302,16 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
     if (!this.selectElement && !this.urlValue) {
         config.shouldLoad = () => false;
     }
-    return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, config, this.tomSelectOptionsValue);
+    return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).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), {
+    const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), {
         maxOptions: this.getMaxOptions(),
     });
     return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
 }, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() {
     const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
     const labelField = commonConfig.labelField ?? 'text';
-    const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
+    const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, {
         maxOptions: this.getMaxOptions(),
         score: (search) => {
             const scoringFunction = this.tomSelect.getScoreFunction(search);
@@ -314,7 +328,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
 }, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) {
     const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this);
     const labelField = commonConfig.labelField ?? 'text';
-    const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, {
+    const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeConfigs).call(this, commonConfig, {
         firstUrl: (query) => {
             const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
             return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`;
@@ -364,8 +378,15 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def
     return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config);
 }, _default_1_stripTags = function _default_1_stripTags(string) {
     return string.replace(/(<([^>]+)>)/gi, '');
-}, _default_1_mergeObjects = function _default_1_mergeObjects(object1, object2) {
-    return { ...object1, ...object2 };
+}, _default_1_mergeConfigs = function _default_1_mergeConfigs(config1, config2) {
+    return {
+        ...config1,
+        ...config2,
+        plugins: {
+            ...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config1.plugins || {}),
+            ...__classPrivateFieldGet(this, _default_1_normalizePluginsToHash, "f").call(this, config2.plugins || {}),
+        },
+    };
 }, _default_1_createTomSelect = function _default_1_createTomSelect(options) {
     const preConnectPayload = { options };
     this.dispatchEvent('pre-connect', preConnectPayload);
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index f89b2914..d0b7db82 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -214,11 +214,11 @@ export default class extends Controller {
             config.shouldLoad = () => false;
         }
 
-        return this.#mergeObjects(config, this.tomSelectOptionsValue);
+        return this.#mergeConfigs(config, this.tomSelectOptionsValue);
     }
 
     #createAutocomplete(): TomSelect {
-        const config = this.#mergeObjects(this.#getCommonConfig(), {
+        const config = this.#mergeConfigs(this.#getCommonConfig(), {
             maxOptions: this.getMaxOptions(),
         });
 
@@ -229,7 +229,7 @@ export default class extends Controller {
         const commonConfig = this.#getCommonConfig();
         const labelField = commonConfig.labelField ?? 'text';
 
-        const config = this.#mergeObjects(commonConfig, {
+        const config = this.#mergeConfigs(commonConfig, {
             maxOptions: this.getMaxOptions(),
             score: (search: string) => {
                 const scoringFunction = this.tomSelect.getScoreFunction(search);
@@ -251,7 +251,7 @@ export default class extends Controller {
         const commonConfig = this.#getCommonConfig();
         const labelField = commonConfig.labelField ?? 'text';
 
-        const config: RecursivePartial<TomSettings> = this.#mergeObjects(commonConfig, {
+        const config: RecursivePartial<TomSettings> = this.#mergeConfigs(commonConfig, {
             firstUrl: (query: string) => {
                 const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?';
 
@@ -325,10 +325,39 @@ export default class extends Controller {
         return string.replace(/(<([^>]+)>)/gi, '');
     }
 
-    #mergeObjects(object1: any, object2: any): any {
-        return { ...object1, ...object2 };
+    #mergeConfigs(config1: any, config2: any): any {
+        return {
+            ...config1,
+            ...config2,
+            // Plugins from both configs should be merged together.
+            plugins: {
+                ...this.#normalizePluginsToHash(config1.plugins || {}),
+                ...this.#normalizePluginsToHash(config2.plugins || {}),
+            },
+        };
     }
 
+    /**
+     * Normalizes the plugins to a hash, so that we can merge them easily.
+     */
+    #normalizePluginsToHash = (plugins: TomSettings['plugins']): TPluginHash => {
+        if (Array.isArray(plugins)) {
+            return plugins.reduce((acc, plugin) => {
+                if (typeof plugin === 'string') {
+                    acc[plugin] = {};
+                }
+
+                if (typeof plugin === 'object' && plugin.name) {
+                    acc[plugin.name] = plugin.options || {};
+                }
+
+                return acc;
+            }, {} as TPluginHash);
+        }
+
+        return plugins;
+    };
+
     /**
      * Returns the element, but only if it's a select element.
      */
diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst
index 6779d78c..cdeed061 100644
--- a/src/Map/doc/index.rst
+++ b/src/Map/doc/index.rst
@@ -288,9 +288,9 @@ Alternatively, you can use the ``<twig:ux:map />`` component.
 .. code-block:: html+twig
 
     <twig:ux:map
-        :center="[51.5074, 0.1278]"
+        center="[51.5074, 0.1278]"
         zoom="3"
-        :markers='[
+        markers='[
             {"position": [51.5074, 0.1278], "title": "London"},
             {"position": [48.8566, 2.3522], "title": "Paris"},
             {
diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php
index bd402914..82f9a5cd 100644
--- a/src/TwigComponent/src/ComponentAttributes.php
+++ b/src/TwigComponent/src/ComponentAttributes.php
@@ -80,7 +80,7 @@ final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Cou
             //      xml:*, xmlns:*,
             // - special syntax names (Vue.js, Svelte, Alpine.js, ...)
             //      v-*, x-*, @*, :*
-            if (!ctype_alnum(str_replace(['-', '_', ':', '@', '.'], '', $key))) {
+            if (!ctype_alpha(str_replace(['-', '_', ':', '@', '.'], '', $key))) {
                 $key = (string) $this->escaper->escape($key, 'html_attr');
             }
 


Test patch:
diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts
index 5b72e7fb..e6a6d6d3 100644
--- a/src/Autocomplete/assets/test/controller.test.ts
+++ b/src/Autocomplete/assets/test/controller.test.ts
@@ -138,6 +138,67 @@ describe('AutocompleteController', () => {
         expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo');
     });
 
+    it('connect with ajax URL on a select element, even when the user define custom TomSelect plugins', async () => {
+        const { container, tomSelect } = await startAutocompleteTest(`
+            <label for="the-select">Items</label>
+            <select
+                id="the-select"
+                data-testid="main-element"
+                data-controller="autocomplete"
+                data-autocomplete-url-value="/path/to/autocomplete"
+                data-autocomplete-tom-select-options-value="{&quot;plugins&quot;:[&quot;input_autogrow&quot;]}"
+            ></select>
+        `);
+
+        // initial Ajax request on focus
+        fetchMock.mockResponseOnce(
+            JSON.stringify({
+                results: [
+                    {
+                        value: 3,
+                        text: 'salad',
+                    },
+                ],
+            })
+        );
+
+        fetchMock.mockResponseOnce(
+            JSON.stringify({
+                results: [
+                    {
+                        value: 1,
+                        text: 'pizza',
+                    },
+                    {
+                        value: 2,
+                        text: 'popcorn',
+                    },
+                ],
+            })
+        );
+
+        const controlInput = tomSelect.control_input;
+
+        // wait for the initial Ajax request to finish
+        userEvent.click(controlInput);
+        await waitFor(() => {
+            expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1);
+        });
+
+        // typing was not properly triggering, for some reason
+        //userEvent.type(controlInput, 'foo');
+        controlInput.value = 'foo';
+        controlInput.dispatchEvent(new Event('input'));
+
+        await waitFor(() => {
+            expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2);
+        });
+
+        expect(fetchMock.requests().length).toEqual(2);
+        expect(fetchMock.requests()[0].url).toEqual('/path/to/autocomplete?query=');
+        expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo');
+    });
+
     it('resets when ajax URL attribute on a select element changes', async () => {
         const { container, tomSelect } = await startAutocompleteTest(`
             <label for="the-select">Items</label>
diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
index 255364ca..377822c3 100644
--- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
+++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
@@ -324,7 +324,6 @@ final class ComponentAttributesTest extends TestCase
         yield ['@click'];
         // Alpine.js
         yield ['x-on:click'];
-        yield ['@input.debounce.500ms'];
     }
 
     public function testThrowsTypeErrorWithoutEscaperRuntime(): void