From 6a56f0bfdbfd73b57130db0106bb1c446b0f2f06 Mon Sep 17 00:00:00 2001 From: Imad Zairig Date: Mon, 30 Mar 2026 18:31:31 +0100 Subject: [PATCH 1/3] add option to clear_on_focus --- src/Autocomplete/assets/src/controller.ts | 12 ++++++++++++ .../src/Form/AutocompleteChoiceTypeExtension.php | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index 646e084bb1e..e6ed86cae59 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -33,6 +33,7 @@ export default class extends Controller { minCharacters: Number, tomSelectOptions: Object, preload: String, + resetOnFocus: Boolean, }; declare readonly urlValue: string; @@ -46,6 +47,7 @@ export default class extends Controller { declare readonly tomSelectOptionsValue: object; declare readonly hasPreloadValue: boolean; declare readonly preloadValue: string; + declare readonly resetOnFocusValue: boolean; tomSelect: TomSelect | undefined; private mutationObserver: MutationObserver; @@ -321,6 +323,16 @@ export default class extends Controller { return `
${this.createOptionTextValue.replace('%placeholder%', `${escapeData(data.input)}`)}
`; }, }, + onFocus: () => { + if (this.resetOnFocusValue && this.tomSelect) { + const query = this.tomSelect.control_input.value.trim(); + if (query === '') { + this.tomSelect.clearOptions(); + (this.tomSelect as any).lastQuery = null; + this.tomSelect.load(''); + } + } + }, preload: this.preload, }); diff --git a/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php index 711ef2e401d..5a97af2059a 100644 --- a/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php +++ b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php @@ -94,6 +94,10 @@ public function finishView(FormView $view, FormInterface $form, array $options): $values['create-option-text'] = $this->trans($options['create_option_text']); $values['preload'] = $options['preload']; + if ($options['reset_on_focus']) { + $values['reset-on-focus'] = ''; + } + foreach ($values as $name => $value) { $attr['data-'.$controllerName.'-'.$name.'-value'] = $value; } @@ -152,6 +156,7 @@ public function configureOptions(OptionsResolver $resolver): void 'min_characters' => null, 'max_results' => 10, 'preload' => 'focus', + 'reset_on_focus' => false, 'extra_options' => [], ]); From 88e569c6cc7b19141fa27a058f9062c76ae783da Mon Sep 17 00:00:00 2001 From: Imad Zairig Date: Mon, 30 Mar 2026 18:40:27 +0100 Subject: [PATCH 2/3] Add tests --- .../assets/test/unit/controller.test.ts | 97 +++++++++++++++++++ .../tests/Fixtures/Form/ProductType.php | 10 ++ .../AutocompleteFormRenderingTest.php | 3 + 3 files changed, 110 insertions(+) diff --git a/src/Autocomplete/assets/test/unit/controller.test.ts b/src/Autocomplete/assets/test/unit/controller.test.ts index ccc4f0fd71c..7b75aba7e89 100644 --- a/src/Autocomplete/assets/test/unit/controller.test.ts +++ b/src/Autocomplete/assets/test/unit/controller.test.ts @@ -1206,4 +1206,101 @@ describe('AutocompleteController', () => { // but the absence of "already initialized" error is the key indicator) expect(newSelect).toBeInTheDocument(); }); + + it('reloads options on focus when resetOnFocus is enabled', async () => { + const { container, tomSelect } = await startAutocompleteTest(` + + + `); + + // first focus: initial load + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { value: 1, text: 'pizza' }, + { value: 2, text: 'popcorn' }, + ], + }) + ); + + const controlInput = tomSelect.control_input; + + userEvent.click(controlInput); + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2); + }); + + expect(fetchMock.requests().length).toEqual(1); + expect(fetchMock.requests()[0].url).toEqual('/path/to/autocomplete?query='); + + // simulate blur then re-focus: should reload + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { value: 1, text: 'pizza' }, + { value: 2, text: 'popcorn' }, + { value: 3, text: 'salad' }, + ], + }) + ); + + tomSelect.blur(); + await shortDelay(10); + + userEvent.click(controlInput); + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(3); + }); + + // a second request was made on re-focus + expect(fetchMock.requests().length).toEqual(2); + expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query='); + }); + + it('does not reload options on focus when resetOnFocus is not set', async () => { + const { container, tomSelect } = await startAutocompleteTest(` + + + `); + + // first focus: initial load (preload: 'focus' is the default) + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { value: 1, text: 'pizza' }, + { value: 2, text: 'popcorn' }, + ], + }) + ); + + const controlInput = tomSelect.control_input; + + userEvent.click(controlInput); + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2); + }); + + expect(fetchMock.requests().length).toEqual(1); + + // blur and re-focus: should NOT make a new request + tomSelect.blur(); + await shortDelay(10); + + userEvent.click(controlInput); + await shortDelay(50); + + // still only 1 request — no reload on re-focus + expect(fetchMock.requests().length).toEqual(1); + }); }); diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductType.php b/src/Autocomplete/tests/Fixtures/Form/ProductType.php index c2c5b35bf18..a34c046481f 100644 --- a/src/Autocomplete/tests/Fixtures/Form/ProductType.php +++ b/src/Autocomplete/tests/Fixtures/Form/ProductType.php @@ -54,6 +54,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'createOnBlur' => true, ], ]) + ->add('portionSizeResetOnFocus', ChoiceType::class, [ + 'choices' => [ + 'small' => 's', + 'medium' => 'm', + 'large' => 'l', + ], + 'autocomplete' => true, + 'reset_on_focus' => true, + 'mapped' => false, + ]) ; } diff --git a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php index 126ac7280b4..0d5294975e5 100644 --- a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php +++ b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php @@ -38,6 +38,9 @@ public function testFieldsRenderWithStimulusController() ->assertElementAttributeContains('#product_portionSize', 'data-controller', 'symfony--ux-autocomplete--autocomplete') ->assertElementAttributeContains('#product_tags', 'data-controller', 'symfony--ux-autocomplete--autocomplete') ->assertElementAttributeContains('#product_tags', 'data-symfony--ux-autocomplete--autocomplete-tom-select-options-value', 'createOnBlur') + + ->assertElementAttributeContains('#product_portionSizeResetOnFocus', 'data-controller', 'symfony--ux-autocomplete--autocomplete') + ->assertElementAttributeContains('#product_portionSizeResetOnFocus', 'data-symfony--ux-autocomplete--autocomplete-reset-on-focus-value', '') ; } From 814ab69d2c7430bde0724351be588f8f0d6ba861 Mon Sep 17 00:00:00 2001 From: Imad Zairig Date: Mon, 30 Mar 2026 18:52:46 +0100 Subject: [PATCH 3/3] fix ci dist --- src/Autocomplete/assets/dist/controller.d.ts | 2 ++ src/Autocomplete/assets/dist/controller.js | 13 ++++++++++++- src/Autocomplete/assets/src/controller.ts | 5 ++++- .../assets/test/unit/controller.test.ts | 16 +++++++++------- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts index a60f3e94880..587407f8237 100644 --- a/src/Autocomplete/assets/dist/controller.d.ts +++ b/src/Autocomplete/assets/dist/controller.d.ts @@ -19,6 +19,7 @@ declare class export_default extends Controller { minCharacters: NumberConstructor; tomSelectOptions: ObjectConstructor; preload: StringConstructor; + resetOnFocus: BooleanConstructor; }; readonly urlValue: string; readonly optionsAsHtmlValue: boolean; @@ -31,6 +32,7 @@ declare class export_default extends Controller { readonly tomSelectOptionsValue: object; readonly hasPreloadValue: boolean; readonly preloadValue: string; + readonly resetOnFocusValue: boolean; tomSelect: TomSelect | undefined; private mutationObserver; private isObserving; diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index 514e73dcf9f..264b2b3208b 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -290,6 +290,16 @@ function _createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacter return `
${this.createOptionTextValue.replace("%placeholder%", `${escapeData(data.input)}`)}
`; } }, + onFocus: () => { + if (this.resetOnFocusValue && this.tomSelect) { + if (this.tomSelect.control_input.value.trim() === "") { + this.tomSelect.clearOptions(); + this.tomSelect.loadedSearches = {}; + if (typeof this.tomSelect.clearPagination === "function") this.tomSelect.clearPagination(); + this.tomSelect.load(""); + } + } + }, preload: this.preload }); return _assertClassBrand(_Class_brand, this, _createTomSelect).call(this, config); @@ -333,6 +343,7 @@ _Class.values = { createOptionText: String, minCharacters: Number, tomSelectOptions: Object, - preload: String + preload: String, + resetOnFocus: Boolean }; export { _Class as default }; diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index e6ed86cae59..d0f04d067b1 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -328,7 +328,10 @@ export default class extends Controller { const query = this.tomSelect.control_input.value.trim(); if (query === '') { this.tomSelect.clearOptions(); - (this.tomSelect as any).lastQuery = null; + (this.tomSelect as any).loadedSearches = {}; + if (typeof (this.tomSelect as any).clearPagination === 'function') { + (this.tomSelect as any).clearPagination(); + } this.tomSelect.load(''); } } diff --git a/src/Autocomplete/assets/test/unit/controller.test.ts b/src/Autocomplete/assets/test/unit/controller.test.ts index 7b75aba7e89..554c85f42e0 100644 --- a/src/Autocomplete/assets/test/unit/controller.test.ts +++ b/src/Autocomplete/assets/test/unit/controller.test.ts @@ -1250,17 +1250,19 @@ describe('AutocompleteController', () => { }) ); - tomSelect.blur(); - await shortDelay(10); - - userEvent.click(controlInput); + // trigger TomSelect's focus event which calls our onFocus callback + tomSelect.trigger('focus'); await waitFor(() => { - expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(3); + expect(fetchMock.requests().length).toEqual(2); }); - // a second request was made on re-focus - expect(fetchMock.requests().length).toEqual(2); expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query='); + + // open the dropdown to render the new options + tomSelect.open(); + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(3); + }); }); it('does not reload options on focus when resetOnFocus is not set', async () => {