diff --git a/src/dropdownListModel.ts b/src/dropdownListModel.ts index 60f54402ae..3be103af52 100644 --- a/src/dropdownListModel.ts +++ b/src/dropdownListModel.ts @@ -6,6 +6,7 @@ import { ListModel } from "./list"; import { PopupModel } from "./popup"; import { Question } from "./question"; import { QuestionDropdownModel } from "./question_dropdown"; +import { settings } from "./settings"; import { SurveyModel } from "./survey"; import { CssClassBuilder } from "./utils/cssClassBuilder"; import { IsTouch } from "./utils/devices"; @@ -17,6 +18,7 @@ export class DropdownListModel extends Base { private _markdownMode = false; private _popupModel: PopupModel; + private filteredItems: Array = undefined; @property({ defaultValue: false }) focused: boolean; private get focusFirstInputSelector(): string { return this.getFocusFirstInputSelector(); @@ -159,6 +161,13 @@ export class DropdownListModel extends Base { }; } const res = new ListModel(visibleItems, _onSelectionChanged, false, undefined, this.question.choicesLazyLoadEnabled ? this.listModelFilterStringChanged : undefined, this.listElementId); + res.setOnTextSearchCallback((item: ItemValue, textToSearch: string) => { + if (this.filteredItems) return this.filteredItems.indexOf(item) >= 0; + let textInLow = item.text.toLocaleLowerCase(); + textInLow = settings.comparator.normalizeTextCallback(textInLow, "filter"); + const index = textInLow.indexOf(textToSearch.toLocaleLowerCase()); + return this.question.searchMode == "startsWith" ? index == 0 : index > -1; + }); res.renderElements = false; res.forceShowFilter = true; res.areSameItemsCallback = (item1: IAction, item2: IAction): boolean => { @@ -191,7 +200,12 @@ export class DropdownListModel extends Base { this.resetFilterString(); } protected onSetFilterString(): void { + this.filteredItems = undefined; if (!this.filterString && !this.popupModel.isVisible) return; + const options = { question: this.question, choices: this.getAvailableItems(), filter: this.filterString, filteredChoices: undefined as Array }; + (this.question.survey as SurveyModel).onChoicesSearch.fire(this.question.survey as SurveyModel, options); + this.filteredItems = options.filteredChoices; + if (!!this.filterString && !this.popupModel.isVisible) { this.popupModel.isVisible = true; } diff --git a/src/list.ts b/src/list.ts index 0473f9dd72..223754eb48 100644 --- a/src/list.ts +++ b/src/list.ts @@ -32,6 +32,7 @@ export interface IListModel { allowSelection?: boolean; selectedItem?: IAction; onFilterStringChangedCallback?: (text: string) => void; + onTextSearchCallback?: (text: string, textToSearch: string) => boolean; } export class ListModel extends ActionContainer { private listContainerHtmlElement: HTMLElement; @@ -65,7 +66,9 @@ export class ListModel extends ActionContainer private hasText(item: T, filterStringInLow: string): boolean { if (!filterStringInLow) return true; - let textInLow = (item.title || "").toLocaleLowerCase(); + const text = item.title || ""; + if (this.onTextSearchCallback) return this.onTextSearchCallback(item, filterStringInLow); + let textInLow = text.toLocaleLowerCase(); textInLow = settings.comparator.normalizeTextCallback(textInLow, "filter"); return textInLow.indexOf(filterStringInLow.toLocaleLowerCase()) > -1; } @@ -109,10 +112,13 @@ export class ListModel extends ActionContainer this.setItems(items); this.selectedItem = selectedItem; } - + private onTextSearchCallback: (item: T, textToSearch: string) => boolean; public setOnFilterStringChangedCallback(callback: (text: string) => void) { this.onFilterStringChangedCallback = callback; } + public setOnTextSearchCallback(callback: (item: T, textToSearch: string) => boolean) { + this.onTextSearchCallback = callback; + } public setItems(items: Array, sortByVisibleIndex = true): void { super.setItems(items, sortByVisibleIndex); if(this.elementId) { diff --git a/src/question_dropdown.ts b/src/question_dropdown.ts index 10af4b528a..fa93fa56a1 100644 --- a/src/question_dropdown.ts +++ b/src/question_dropdown.ts @@ -194,6 +194,10 @@ export class QuestionDropdownModel extends QuestionSelectBase { @property() allowClear: boolean; /** * Specifies whether users can enter a value into the input field to filter the drop-down list. + * + * [View Demo](https://surveyjs.io/form-library/examples/create-dropdown-menu-in-javascript/ (linkStyle)) + * @see searchMode + * @see [SurveyModel.onChoicesSearch](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#onChoicesSearch) */ @property({ onSet: (newValue: boolean, target: QuestionDropdownModel) => { @@ -203,6 +207,17 @@ export class QuestionDropdownModel extends QuestionSelectBase { } }) searchEnabled: boolean; + /** + * Specifies a comparison operation used to filter the drop-down list. Applies only if [`searchEnabled`](#searchEnabled) is `true`. + * + * Possible values: + * + * - `"contains"` (default) + * - `"startsWith"` + * @see [SurveyModel.onChoicesSearch](https://surveyjs.io/form-library/documentation/api-reference/survey-data-model#onChoicesSearch) + */ + @property() searchMode: "contains" | "startsWith"; + @property({ defaultValue: false }) inputHasValue: boolean; @property({ defaultValue: "" }) readOnlyText: string; /** @@ -343,6 +358,7 @@ Serializer.addClass( { name: "autocomplete", alternativeName: "autoComplete", choices: settings.questions.dataList, }, { name: "renderAs", default: "default", visible: false }, { name: "searchEnabled:boolean", default: true, visible: false }, + { name: "searchMode", default: "contains", choices: ["contains", "startsWith"], }, { name: "choicesLazyLoadEnabled:boolean", default: false, visible: false }, { name: "choicesLazyLoadPageSize:number", default: 25, visible: false }, { name: "inputFieldComponent", visible: false }, diff --git a/src/survey-events-api.ts b/src/survey-events-api.ts index c69117eac1..5549c12cbe 100644 --- a/src/survey-events-api.ts +++ b/src/survey-events-api.ts @@ -610,6 +610,22 @@ export interface ChoicesLazyLoadEvent extends QuestionEventMixin { */ skip: number; } + +export interface ChoicesSearchEvent extends QuestionEventMixin { + /** + * A search string used to filter choice options. + */ + filter: string; + /** + * An array of all choice options. + */ + choices: Array; + /** + * A filtered array of choice options. Apply `options.filter` to the `options.choices` array and assign the result to this parameter. + */ + filteredChoices: Array; +} + export interface GetChoiceDisplayValueEvent extends QuestionEventMixin { /** * A method that you should call to assign display texts to the question. diff --git a/src/survey.ts b/src/survey.ts index bed299c52d..4285ddd33f 100644 --- a/src/survey.ts +++ b/src/survey.ts @@ -64,7 +64,7 @@ import { MatrixCellValidateEvent, DynamicPanelModifiedEvent, DynamicPanelRemovingEvent, TimerPanelInfoTextEvent, DynamicPanelItemValueChangedEvent, DynamicPanelGetTabTitleEvent, DynamicPanelCurrentIndexChangedEvent, IsAnswerCorrectEvent, DragDropAllowEvent, ScrollingElementToTopEvent, GetQuestionTitleActionsEvent, GetPanelTitleActionsEvent, GetPageTitleActionsEvent, GetPanelFooterActionsEvent, GetMatrixRowActionsEvent, ElementContentVisibilityChangedEvent, GetExpressionDisplayValueEvent, - ServerValidateQuestionsEvent, MultipleTextItemAddedEvent, MatrixColumnAddedEvent, GetQuestionDisplayValueEvent, PopupVisibleChangedEvent + ServerValidateQuestionsEvent, MultipleTextItemAddedEvent, MatrixColumnAddedEvent, GetQuestionDisplayValueEvent, PopupVisibleChangedEvent, ChoicesSearchEvent } from "./survey-events-api"; import { QuestionMatrixDropdownModelBase } from "./question_matrixdropdownbase"; import { QuestionMatrixDynamicModel } from "./question_matrixdynamic"; @@ -611,6 +611,13 @@ export class SurveyModel extends SurveyElementCore */ public onChoicesLazyLoad: EventBase = this.addEvent(); + /** + * An event that is raised each time a search string in a [Dropdown](https://surveyjs.io/form-library/documentation/api-reference/dropdown-menu-model) or [Tag Box](https://surveyjs.io/form-library/documentation/api-reference/dropdown-tag-box-model) question changes. Use this event to implement custom filtering of choice options. + * @see [QuestionDropdownModel.searchEnabled](https://surveyjs.io/form-library/documentation/api-reference/dropdown-menu-model#searchEnabled) + * @see [QuestionDropdownModel.searchMode](https://surveyjs.io/form-library/documentation/api-reference/dropdown-menu-model#searchMode) + */ + public onChoicesSearch: EventBase = this.addEvent(); + /** * Use this event to load a display text for the [default choice item](https://surveyjs.io/form-library/documentation/questiondropdownmodel#defaultValue) in [Dropdown](https://surveyjs.io/form-library/documentation/questiondropdownmodel) and [Tag Box](https://surveyjs.io/form-library/documentation/questiontagboxmodel) questions. * diff --git a/tests/dropdown_list_model_test.ts b/tests/dropdown_list_model_test.ts index cb8a244358..ce3f907b24 100644 --- a/tests/dropdown_list_model_test.ts +++ b/tests/dropdown_list_model_test.ts @@ -916,4 +916,66 @@ QUnit.test("DropdownListModel in panel filterString change callback", (assert) = const dropdownListModel = (question.panels[0].elements[0] as QuestionDropdownModel).dropdownListModel; dropdownListModel["listModel"].filterString = "abc"; assert.equal(dropdownListModel.filterString, "abc"); -}); \ No newline at end of file +}); +QUnit.test("DropdownListModel filter options", (assert) => { + const survey = new SurveyModel({ + questions: [{ + type: "dropdown", + name: "question1", + searchEnabled: true, + choices: [ + "abc", + "abd", + "cab", + "efg" + ] + }] + }); + const question = survey.getAllQuestions()[0]; + const dropdownListModel = question.dropdownListModel; + const list: ListModel = dropdownListModel.popupModel.contentComponentData.model as ListModel; + + dropdownListModel.filterString = "ab"; + const getfilteredItems = () => list.renderedActions.filter(item => list.isItemVisible(item)); + + assert.equal(list.renderedActions.length, 4); + assert.equal(getfilteredItems().length, 3); + + question.searchMode = "startsWith"; + assert.equal(list.renderedActions.length, 4); + assert.equal(getfilteredItems().length, 2); +}); + +QUnit.test("DropdownListModel filter event", (assert) => { + const survey = new SurveyModel({ + questions: [{ + type: "dropdown", + name: "question1", + searchEnabled: true, + choices: [ + "abcd", + "abdd", + "cabd", + "efab" + ] + }] + }); + + survey.onChoicesSearch.add((sender, options) => { + options.filteredChoices = options.choices.filter(item => item.text.indexOf(options.filter) + options.filter.length == item.text.length); + }); + + const question = survey.getAllQuestions()[0]; + const dropdownListModel = question.dropdownListModel; + const list: ListModel = dropdownListModel.popupModel.contentComponentData.model as ListModel; + + dropdownListModel.filterString = "ab"; + const getfilteredItems = () => list.renderedActions.filter(item => list.isItemVisible(item)); + + assert.equal(list.renderedActions.length, 4); + assert.equal(getfilteredItems().length, 1); + + question.searchMode = "startsWith"; + assert.equal(list.renderedActions.length, 4); + assert.equal(getfilteredItems().length, 1); +});