From c7b12c996ccab07e4557d7d17f017b48720b0f66 Mon Sep 17 00:00:00 2001 From: evykassirer Date: Sun, 17 Mar 2024 22:14:23 -0700 Subject: [PATCH] typeahead: Convert module to typescript. This contains two more complex changes: - The default versions of sorter and matcher assume that ItemType is a string. But the Typeahead class works on a generic ItemType and I'm not aware of a way to assert that this function is only called for typeaheads with string items. For `matcher`, we can assert that the items are strings. `sorter` is now a required option instead of an optional one that could fall back to the default. - `element` can be either an `input` element or a `contenteditable` `div`. We distinguish between them using `.is("[contenteditable]"))` but TypeScript doesn't understand that. So we replaced `this.$element` with `this.input_element` where `input_element` is an object with the `$element` and also a `type` specifying which type of element it is (input or contenteditable). --- docs/THIRDPARTY | 2 +- tools/test-js-with-node | 2 +- ...ap_typeahead.js => bootstrap_typeahead.ts} | 380 +++++++++++------- web/src/composebox_typeahead.js | 38 +- web/src/custom_profile_fields_ui.js | 9 +- web/src/pill_typeahead.js | 6 +- web/src/search.js | 6 +- web/src/settings_playgrounds.js | 10 +- web/tests/composebox_typeahead.test.js | 28 +- web/tests/pill_typeahead.test.js | 4 +- web/tests/search.test.js | 4 +- 11 files changed, 315 insertions(+), 174 deletions(-) rename web/src/{bootstrap_typeahead.js => bootstrap_typeahead.ts} (65%) diff --git a/docs/THIRDPARTY b/docs/THIRDPARTY index 8c6baf0277..fdb712bde7 100644 --- a/docs/THIRDPARTY +++ b/docs/THIRDPARTY @@ -277,7 +277,7 @@ Files: web/third/bootstrap/css/bootstrap-btn.css Copyright: 2011-2014 Twitter, Inc. License: Expat -Files: web/src/bootstrap_typeahead.js +Files: web/src/bootstrap_typeahead.ts Copyright: 2012 Twitter, Inc. License: Apache-2.0 Comment: Bootstrap typeahead. The software has been modified. diff --git a/tools/test-js-with-node b/tools/test-js-with-node index b654b087ab..414b649285 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -61,7 +61,7 @@ EXEMPT_FILES = make_set( "web/src/billing/helpers.ts", "web/src/blueslip.ts", "web/src/blueslip_stacktrace.ts", - "web/src/bootstrap_typeahead.js", + "web/src/bootstrap_typeahead.ts", "web/src/browser_history.ts", "web/src/buddy_list.ts", "web/src/click_handlers.js", diff --git a/web/src/bootstrap_typeahead.js b/web/src/bootstrap_typeahead.ts similarity index 65% rename from web/src/bootstrap_typeahead.js rename to web/src/bootstrap_typeahead.ts index 252d55109d..718dbec2ac 100644 --- a/web/src/bootstrap_typeahead.js +++ b/web/src/bootstrap_typeahead.ts @@ -133,12 +133,15 @@ * ============================================================ */ import $ from "jquery"; +import assert from "minimalistic-assert"; import {insertTextIntoField} from "text-field-edit"; import {get_string_diff} from "./util"; -function get_pseudo_keycode(event) { - const isComposing = (event.originalEvent && event.originalEvent.isComposing) || false; +function get_pseudo_keycode( + event: JQuery.KeyDownEvent | JQuery.KeyUpEvent | JQuery.KeyPressEvent, +): number { + const isComposing = event.originalEvent?.isComposing ?? false; /* We treat IME compose enter keypresses as a separate -13 key. */ if (event.keyCode === 13 && isComposing) { return -13; @@ -146,6 +149,25 @@ function get_pseudo_keycode(event) { return event.keyCode; } +export function defaultSorter(items: string[], query: string): string[] { + const beginswith = []; + const caseSensitive = []; + const caseInsensitive = []; + let item; + + while ((item = items.shift())) { + if (item.toLowerCase().startsWith(query.toLowerCase())) { + beginswith.push(item); + } else if (item.includes(query)) { + caseSensitive.push(item); + } else { + caseInsensitive.push(item); + } + } + + return [...beginswith, ...caseSensitive, ...caseInsensitive]; +} + /* TYPEAHEAD PUBLIC CLASS DEFINITION * ================================= */ @@ -156,83 +178,131 @@ const MENU_HTML = ''; const ITEM_HTML = "
  • "; const MIN_LENGTH = 1; -const Typeahead = function (element, options) { - this.$element = $(element); - this.items = options.items ?? 8; - this.matcher = options.matcher ?? this.matcher; - this.sorter = options.sorter ?? this.sorter; - this.highlighter_html = options.highlighter_html; - this.updater = options.updater ?? this.updater; - this.$container = $(CONTAINER_HTML).appendTo(options.parentElement ?? "body"); - this.$menu = $(MENU_HTML).appendTo(this.$container); - this.$header = $(HEADER_ELEMENT_HTML).appendTo(this.$container); - this.source = options.source; - this.shown = false; - this.mouse_moved_since_typeahead = false; - this.dropup = options.dropup ?? false; - this.fixed = options.fixed ?? false; - this.automated = options.automated ?? (() => false); - this.trigger_selection = options.trigger_selection ?? (() => false); - this.on_escape = options.on_escape; - // return a string to show in typeahead header or false. - this.header_html = options.header_html ?? (() => false); - // return a string to show in typeahead items or false. - this.option_label = options.option_label ?? (() => false); - this.stopAdvance = options.stopAdvance ?? false; - this.advanceKeyCodes = options.advanceKeyCodes ?? []; - this.openInputFieldOnKeyUp = options.openInputFieldOnKeyUp; - this.closeInputFieldOnHide = options.closeInputFieldOnHide; - this.tabIsEnter = options.tabIsEnter ?? true; - this.helpOnEmptyStrings = options.helpOnEmptyStrings ?? false; - this.naturalSearch = options.naturalSearch ?? false; - this.parentElement = options.parentElement; +type InputElement = + | { + $element: JQuery; + type: "contenteditable"; + } + | { + $element: JQuery; + type: "input"; + }; - if (this.fixed) { - this.$container.css("position", "fixed"); +class Typeahead { + input_element: InputElement; + items: number; + matcher: (item: ItemType) => boolean; + sorter: (items: ItemType[]) => ItemType[]; + highlighter_html: (item: ItemType) => string | undefined; + updater: ( + item: ItemType, + event?: JQuery.ClickEvent | JQuery.KeyUpEvent | JQuery.KeyDownEvent, + ) => string | undefined; + $container: JQuery; + $menu: JQuery; + $header: JQuery; + source: (query: string) => ItemType[]; + dropup: boolean; + fixed: boolean; + automated: () => boolean; + trigger_selection: (event: JQuery.KeyDownEvent) => boolean; + on_escape?: () => void; + // returns a string to show in typeahead header or false. + header_html: () => string | false; + // returns a string to show in typeahead items or false. + option_label: (matching_items: ItemType[], item: ItemType) => string | false; + suppressKeyPressRepeat = false; + query = ""; + mouse_moved_since_typeahead = false; + shown = false; + openInputFieldOnKeyUp?: () => void; + closeInputFieldOnHide?: () => void; + helpOnEmptyStrings: boolean; + tabIsEnter: boolean; + naturalSearch: boolean; + stopAdvance: boolean; + advanceKeyCodes: number[]; + parentElement?: string; + + constructor(input_element: InputElement, options: TypeaheadOptions) { + this.input_element = input_element; + if (this.input_element.type === "contenteditable") { + assert(this.input_element.$element.is("[contenteditable]")); + } else { + assert(!this.input_element.$element.is("[contenteditable]")); + } + this.items = options.items ?? 8; + this.matcher = options.matcher ?? ((item) => this.defaultMatcher(item)); + this.sorter = options.sorter; + this.highlighter_html = options.highlighter_html; + this.updater = options.updater ?? ((items) => this.defaultUpdater(items)); + this.$container = $(CONTAINER_HTML).appendTo(options.parentElement ?? "body"); + this.$menu = $(MENU_HTML).appendTo(this.$container); + this.$header = $(HEADER_ELEMENT_HTML).appendTo(this.$container); + this.source = options.source; + this.dropup = options.dropup ?? false; + this.fixed = options.fixed ?? false; + this.automated = options.automated ?? (() => false); + this.trigger_selection = options.trigger_selection ?? (() => false); + this.on_escape = options.on_escape; + // return a string to show in typeahead header or false. + this.header_html = options.header_html ?? (() => false); + // return a string to show in typeahead items or false. + this.option_label = options.option_label ?? (() => false); + this.stopAdvance = options.stopAdvance ?? false; + this.advanceKeyCodes = options.advanceKeyCodes ?? []; + this.openInputFieldOnKeyUp = options.openInputFieldOnKeyUp; + this.closeInputFieldOnHide = options.closeInputFieldOnHide; + this.tabIsEnter = options.tabIsEnter ?? true; + this.helpOnEmptyStrings = options.helpOnEmptyStrings ?? false; + this.naturalSearch = options.naturalSearch ?? false; + this.parentElement = options.parentElement; + + if (this.fixed) { + this.$container.css("position", "fixed"); + } + // The naturalSearch option causes arrow keys to immediately + // update the search box with the underlying values from the + // search suggestions. + this.listen(); } - // The naturalSearch option causes arrow keys to immediately - // update the search box with the underlying values from the - // search suggestions. - this.listen(); -}; -Typeahead.prototype = { - constructor: Typeahead, - - select(e) { + select(e?: JQuery.ClickEvent | JQuery.KeyUpEvent | JQuery.KeyDownEvent): this { const val = this.$menu.find(".active").data("typeahead-value"); - if (this.$element.is("[contenteditable]")) { - this.$element.text(this.updater(val, e)).trigger("change"); + if (this.input_element.type === "contenteditable") { + this.input_element.$element.text(this.updater(val, e) ?? "").trigger("change"); // Empty text after the change event handler // converts the input text to html elements. - this.$element.text(""); + this.input_element.$element.text(""); } else { - const after_text = this.updater(val, e); - const [from, to_before, to_after] = get_string_diff(this.$element.val(), after_text); + const after_text = this.updater(val, e) ?? ""; + const element_val = this.input_element.$element.val(); + assert(element_val !== undefined); + const [from, to_before, to_after] = get_string_diff(element_val, after_text); const replacement = after_text.slice(from, to_after); // select / highlight the minimal text to be replaced - this.$element[0].setSelectionRange(from, to_before); - insertTextIntoField(this.$element[0], replacement); - this.$element.trigger("change"); + this.input_element.$element[0].setSelectionRange(from, to_before); + insertTextIntoField(this.input_element.$element[0], replacement); + this.input_element.$element.trigger("change"); } return this.hide(); - }, + } - set_value() { + set_value(): void { const val = this.$menu.find(".active").data("typeahead-value"); - if (this.$element.is("[contenteditable]")) { - this.$element.text(val); + if (this.input_element.type === "contenteditable") { + this.input_element.$element.text(val); } else { - this.$element.val(val); + this.input_element.$element.val(val); } - }, + } - updater(item) { + defaultUpdater(item: ItemType): ItemType { return item; - }, + } - show() { + show(): this { const header_text_html = this.header_html(); if (header_text_html) { this.$header.find("span#typeahead-header-text").html(header_text_html); @@ -243,26 +313,25 @@ Typeahead.prototype = { // If a parent element was specified, we shouldn't manually // position the element, since it's already in the right place. - if (!this.parentElement) { + if (this.parentElement === undefined) { let pos; if (this.fixed) { // Relative to screen instead of to page - pos = this.$element[0].getBoundingClientRect(); + pos = this.input_element.$element[0].getBoundingClientRect(); } else { - pos = this.$element.offset(); + pos = this.input_element.$element.offset(); } pos = $.extend({}, pos, { - height: this.$element[0].offsetHeight, + height: this.input_element.$element[0].offsetHeight, + // Zulip patch: Workaround for iOS safari problems + top: this.input_element.$element.get_offset_to_window().top, }); - // Zulip patch: Workaround for iOS safari problems - pos.top = this.$element.get_offset_to_window().top; - let top_pos = pos.top + pos.height; if (this.dropup) { - top_pos = pos.top - this.$container.outerHeight(); + top_pos = pos.top - this.$container.outerHeight()!; } // Zulip patch: Avoid typeahead going off top of screen. @@ -280,21 +349,22 @@ Typeahead.prototype = { this.shown = true; this.mouse_moved_since_typeahead = false; return this; - }, + } - hide() { + hide(): this { this.$container.hide(); this.shown = false; if (this.closeInputFieldOnHide !== undefined) { this.closeInputFieldOnHide(); } return this; - }, + } - lookup(hideOnEmpty) { - this.query = this.$element.is("[contenteditable]") - ? this.$element.text() - : this.$element.val(); + lookup(hideOnEmpty: boolean): this { + this.query = + this.input_element.type === "contenteditable" + ? this.input_element.$element.text() + : this.input_element.$element.val() ?? ""; if ( (!this.helpOnEmptyStrings || hideOnEmpty) && @@ -309,9 +379,9 @@ Typeahead.prototype = { this.hide(); } return items ? this.process(items) : this; - }, + } - process(items) { + process(items: ItemType[]): this { const matching_items = $.grep(items, (item) => this.matcher(item)); const final_items = this.sorter(matching_items); @@ -324,35 +394,17 @@ Typeahead.prototype = { return this; } return this.render(final_items.slice(0, this.items), matching_items).show(); - }, + } - matcher(item) { + defaultMatcher(item: ItemType): boolean { + assert(typeof item === "string"); return item.toLowerCase().includes(this.query.toLowerCase()); - }, + } - sorter(items) { - const beginswith = []; - const caseSensitive = []; - const caseInsensitive = []; - let item; - - while ((item = items.shift())) { - if (item.toLowerCase().startsWith(this.query.toLowerCase())) { - beginswith.push(item); - } else if (item.includes(this.query)) { - caseSensitive.push(item); - } else { - caseInsensitive.push(item); - } - } - - return [...beginswith, ...caseSensitive, ...caseInsensitive]; - }, - - render(final_items, matching_items) { - const $items = final_items.map((item) => { + render(final_items: ItemType[], matching_items: ItemType[]): this { + const $items: JQuery[] = final_items.map((item) => { const $i = $(ITEM_HTML).data("typeahead-value", item); - const item_html = this.highlighter_html(item); + const item_html = this.highlighter_html(item) ?? ""; const $item_html = $i.find("a").html(item_html); const option_label_html = this.option_label(matching_items, item); @@ -366,9 +418,9 @@ Typeahead.prototype = { $items[0].addClass("active"); this.$menu.empty().append($items); return this; - }, + } - next() { + next(): void { const $active = this.$menu.find(".active").removeClass("active"); let $next = $active.next(); @@ -381,9 +433,9 @@ Typeahead.prototype = { if (this.naturalSearch) { this.set_value(); } - }, + } - prev() { + prev(): void { const $active = this.$menu.find(".active").removeClass("active"); let $prev = $active.prev(); @@ -396,10 +448,10 @@ Typeahead.prototype = { if (this.naturalSearch) { this.set_value(); } - }, + } - listen() { - this.$element + listen(): void { + $(this.input_element.$element) .on("blur", this.blur.bind(this)) .on("keypress", this.keypress.bind(this)) .on("keyup", this.keyup.bind(this)) @@ -412,24 +464,24 @@ Typeahead.prototype = { .on("mousemove", "li", this.mousemove.bind(this)); $(window).on("resize", this.resizeHandler.bind(this)); - }, + } - unlisten() { + unlisten(): void { this.$container.remove(); const events = ["blur", "keydown", "keyup", "keypress", "mousemove"]; for (const event_ of events) { - this.$element.off(event_); + $(this.input_element.$element).off(event_); } - this.$element.removeData("typeahead"); - }, + this.input_element.$element.removeData("typeahead"); + } - resizeHandler() { + resizeHandler(): void { if (this.shown) { this.show(); } - }, + } - maybeStopAdvance(e) { + maybeStopAdvance(e: JQuery.KeyPressEvent | JQuery.KeyUpEvent | JQuery.KeyDownEvent): void { const pseudo_keycode = get_pseudo_keycode(e); if ( (this.stopAdvance || (pseudo_keycode !== 9 && pseudo_keycode !== 13)) && @@ -437,9 +489,9 @@ Typeahead.prototype = { ) { e.stopPropagation(); } - }, + } - move(e) { + move(e: JQuery.KeyDownEvent | JQuery.KeyPressEvent): void { if (!this.shown) { return; } @@ -470,18 +522,18 @@ Typeahead.prototype = { } this.maybeStopAdvance(e); - }, + } - mousemove(e) { + mousemove(e: JQuery.MouseMoveEvent): void { if (!this.mouse_moved_since_typeahead) { /* Undo cursor disabling in mouseenter handler. */ $(e.currentTarget).find("a").css("cursor", ""); this.mouse_moved_since_typeahead = true; this.mouseenter(e); } - }, + } - keydown(e) { + keydown(e: JQuery.KeyDownEvent): void { const pseudo_keycode = get_pseudo_keycode(e); if (this.trigger_selection(e)) { if (!this.shown) { @@ -492,17 +544,17 @@ Typeahead.prototype = { } this.suppressKeyPressRepeat = ![40, 38, 9, 13, 27].includes(pseudo_keycode); this.move(e); - }, + } - keypress(e) { + keypress(e: JQuery.KeyPressEvent): void { if (!this.suppressKeyPressRepeat) { this.move(e); return; } this.maybeStopAdvance(e); - }, + } - keyup(e) { + keyup(e: JQuery.KeyUpEvent): void { const pseudo_keycode = get_pseudo_keycode(e); switch (pseudo_keycode) { @@ -519,10 +571,11 @@ Typeahead.prototype = { this.select(e); if (e.currentTarget.id === "stream_message_recipient_topic") { + assert(this.input_element.type === "input"); // Move the cursor to the end of the topic - const topic_length = this.$element.val().length; - this.$element[0].selectionStart = topic_length; - this.$element[0].selectionEnd = topic_length; + const topic_length = this.input_element.$element.val()!.length; + this.input_element.$element[0].selectionStart = topic_length; + this.input_element.$element[0].selectionEnd = topic_length; } break; @@ -566,16 +619,20 @@ Typeahead.prototype = { this.maybeStopAdvance(e); e.preventDefault(); - }, + } - blur(e) { + blur(e: JQuery.BlurEvent): void { // Blurs that move focus to elsewhere within the parent element shouldn't // hide the typeahead. - if (this.parentElement && $(e.relatedTarget).parents(this.parentElement).length > 0) { + if ( + this.parentElement !== undefined && + e.relatedTarget && + $(e.relatedTarget).parents(this.parentElement).length > 0 + ) { return; } setTimeout(() => { - if (!this.$container.is(":hover") && !this.$element.is(":focus")) { + if (!this.$container.is(":hover") && !this.input_element.$element.is(":focus")) { // We do not hide the typeahead in case it is being hovered over, // or if the focus is immediately back in the input field (likely // when using compose formatting buttons). @@ -584,18 +641,18 @@ Typeahead.prototype = { // refocus the input if the user clicked on the typeahead // so that clicking elsewhere registers as a blur and hides // the typeahead. - this.$element.trigger("focus"); + this.input_element.$element.trigger("focus"); } }, 150); - }, + } - element_click() { + element_click(): void { // update / hide the typeahead menu if the user clicks anywhere // inside the typing area, to avoid misplaced typeahead insertion. this.lookup(false); - }, + } - click(e) { + click(e: JQuery.ClickEvent): void { e.stopPropagation(); e.preventDefault(); // The original bootstrap code expected `mouseenter` to be called @@ -607,9 +664,9 @@ Typeahead.prototype = { // handler here. this.mouseenter(e); this.select(e); - }, + } - mouseenter(e) { + mouseenter(e: JQuery.MouseEnterEvent | JQuery.ClickEvent | JQuery.MouseMoveEvent): void { if (!this.mouse_moved_since_typeahead) { // Prevent the annoying interaction where your mouse happens // to be in the space where typeahead will open. (This would @@ -624,17 +681,48 @@ Typeahead.prototype = { } this.$menu.find(".active").removeClass("active"); $(e.currentTarget).addClass("active"); - }, -}; + } +} /* TYPEAHEAD PLUGIN DEFINITION * =========================== */ -export function create($element, options) { - $element.data("typeahead", new Typeahead($element, options)); +type TypeaheadOptions = { + highlighter_html: (item: ItemType) => string | undefined; + items: number; + source: (query: string) => ItemType[]; + // optional options + advanceKeyCodes?: number[]; + automated?: () => boolean; + closeInputFieldOnHide?: () => void; + dropup?: boolean; + fixed?: boolean; + header_html?: () => string | false; + helpOnEmptyStrings?: boolean; + matcher?: (item: ItemType) => boolean; + naturalSearch?: boolean; + on_escape?: () => void; + openInputFieldOnKeyUp?: () => void; + option_label?: (matching_items: ItemType[], item: ItemType) => string | false; + parentElement?: string; + sorter: (items: ItemType[]) => ItemType[]; + stopAdvance?: boolean; + tabIsEnter?: boolean; + trigger_selection?: (event: JQuery.KeyDownEvent) => boolean; + updater: ( + item: ItemType, + event?: JQuery.ClickEvent | JQuery.KeyUpEvent | JQuery.KeyDownEvent, + ) => string | undefined; +}; + +export function create( + input_element: InputElement, + options: TypeaheadOptions, +): void { + input_element.$element.data("typeahead", new Typeahead(input_element, options)); } -export function lookup($element) { +export function lookup($element: JQuery): void { const typeahead = $element.data("typeahead"); typeahead.lookup(); } diff --git a/web/src/composebox_typeahead.js b/web/src/composebox_typeahead.js index be2b6b0915..9407e75be1 100644 --- a/web/src/composebox_typeahead.js +++ b/web/src/composebox_typeahead.js @@ -585,7 +585,7 @@ export function get_person_suggestions(query, opts) { export function get_stream_topic_data(hacky_this) { const opts = {}; - const $message_row = hacky_this.$element.closest(".message_row"); + const $message_row = hacky_this.input_element.$element.closest(".message_row"); if ($message_row.length === 1) { // we are editing a message so we try to use its keys. const msg = message_store.get(rows.id($message_row)); @@ -667,7 +667,7 @@ const ALLOWED_MARKDOWN_FEATURES = { }; export function get_candidates(query) { - const split = split_at_cursor(query, this.$element); + const split = split_at_cursor(query, this.input_element.$element); let current_token = tokenize_compose_str(split[0]); if (current_token === "") { return false; @@ -861,10 +861,10 @@ export function content_highlighter_html(item) { } export function content_typeahead_selected(item, event) { - const pieces = split_at_cursor(this.query, this.$element); + const pieces = split_at_cursor(this.query, this.input_element.$element); let beginning = pieces[0]; let rest = pieces[1]; - const $textbox = this.$element; + const $textbox = this.input_element.$element; // Accepting some typeahead selections, like polls, will generate // placeholder text that is selected, in order to clarify for the // user what a given parameter is for. This object stores the @@ -1008,7 +1008,11 @@ export function content_typeahead_selected(item, event) { $textbox.caret(beginning.length, beginning.length); compose_ui.autosize_textarea($textbox); }; - flatpickr.show_flatpickr(this.$element[0], on_timestamp_selection, timestamp); + flatpickr.show_flatpickr( + this.input_element.$element[0], + on_timestamp_selection, + timestamp, + ); return beginning + rest; } } @@ -1094,6 +1098,10 @@ export function compose_trigger_selection(event) { } export function initialize_topic_edit_typeahead(form_field, stream_name, dropup) { + const bootstrap_typeahead_input = { + $element: form_field, + type: "input", + }; const options = { fixed: true, dropup, @@ -1113,7 +1121,7 @@ export function initialize_topic_edit_typeahead(form_field, stream_name, dropup) }, items: 5, }; - bootstrap_typeahead.create(form_field, options); + bootstrap_typeahead.create(bootstrap_typeahead_input, options); } function get_header_html() { @@ -1141,7 +1149,11 @@ function get_header_html() { } export function initialize_compose_typeahead(selector) { - bootstrap_typeahead.create($(selector), { + const bootstrap_typeahead_input = { + $element: $(selector), + type: "input", + }; + bootstrap_typeahead.create(bootstrap_typeahead_input, { items: max_num_items, dropup: true, fixed: true, @@ -1172,7 +1184,11 @@ export function initialize({on_enter_send}) { $("form#send_message_form").on("keydown", (e) => handle_keydown(e, {on_enter_send})); $("form#send_message_form").on("keyup", handle_keyup); - bootstrap_typeahead.create($("input#stream_message_recipient_topic"), { + const stream_message_typeahead_input = { + $element: $("input#stream_message_recipient_topic"), + type: "input", + }; + bootstrap_typeahead.create(stream_message_typeahead_input, { source() { return topics_seen_for(compose_state.stream_id()); }, @@ -1197,7 +1213,11 @@ export function initialize({on_enter_send}) { header_html: render_topic_typeahead_hint, }); - bootstrap_typeahead.create($("#private_message_recipient"), { + const private_message_typeahead_input = { + $element: $("#private_message_recipient"), + type: "contenteditable", + }; + bootstrap_typeahead.create(private_message_typeahead_input, { source: get_pm_people, items: max_num_items, dropup: true, diff --git a/web/src/custom_profile_fields_ui.js b/web/src/custom_profile_fields_ui.js index 9bd1ed821b..b81d9b5d5a 100644 --- a/web/src/custom_profile_fields_ui.js +++ b/web/src/custom_profile_fields_ui.js @@ -165,13 +165,20 @@ export function initialize_custom_pronouns_type_fields(element_id) { $t({defaultMessage: "she/her"}), $t({defaultMessage: "they/them"}), ]; - bootstrap_typeahead.create($(element_id).find(".pronouns_type_field"), { + const bootstrap_typeahead_input = { + $element: $(element_id).find(".pronouns_type_field"), + type: "input", + }; + bootstrap_typeahead.create(bootstrap_typeahead_input, { items: 3, fixed: true, helpOnEmptyStrings: true, source() { return commonly_used_pronouns; }, + sorter(items) { + return bootstrap_typeahead.defaultSorter(items, this.query); + }, highlighter_html(item) { return typeahead_helper.render_typeahead_item({primary: item}); }, diff --git a/web/src/pill_typeahead.js b/web/src/pill_typeahead.js index 5e24703ed3..37d52de0d9 100644 --- a/web/src/pill_typeahead.js +++ b/web/src/pill_typeahead.js @@ -32,7 +32,11 @@ export function set_up($input, pills, opts) { const include_users = opts.user; const exclude_bots = opts.exclude_bots; - bootstrap_typeahead.create($input, { + const bootstrap_typeahead_input = { + $element: $input, + type: "contenteditable", + }; + bootstrap_typeahead.create(bootstrap_typeahead_input, { items: 5, fixed: true, dropup: true, diff --git a/web/src/search.js b/web/src/search.js index ab24af0bb5..73fb6e3813 100644 --- a/web/src/search.js +++ b/web/src/search.js @@ -57,7 +57,11 @@ export function initialize({on_narrow_search}) { // just represents the key of the hash, so it's redundant.) let search_map = new Map(); - bootstrap_typeahead.create($search_query_box, { + const bootstrap_typeahead_input = { + $element: $search_query_box, + type: "input", + }; + bootstrap_typeahead.create(bootstrap_typeahead_input, { source(query) { const suggestions = search_suggestion.get_suggestions(query); // Update our global search_map hash diff --git a/web/src/settings_playgrounds.js b/web/src/settings_playgrounds.js index efbbd87131..125208f237 100644 --- a/web/src/settings_playgrounds.js +++ b/web/src/settings_playgrounds.js @@ -152,7 +152,12 @@ function build_page() { const $search_pygments_box = $("#playground_pygments_language"); let language_labels = new Map(); - bootstrap_typeahead.create($search_pygments_box, { + const bootstrap_typeahead_input = { + $element: $search_pygments_box, + type: "input", + }; + + bootstrap_typeahead.create(bootstrap_typeahead_input, { source(query) { language_labels = realm_playground.get_pygments_typeahead_list_for_settings(query); return [...language_labels.keys()]; @@ -165,6 +170,9 @@ function build_page() { const q = this.query.trim().toLowerCase(); return item.toLowerCase().startsWith(q); }, + sorter(items) { + return bootstrap_typeahead.defaultSorter(items, this.query); + }, }); $search_pygments_box.on("click", (e) => { diff --git a/web/tests/composebox_typeahead.test.js b/web/tests/composebox_typeahead.test.js index d60a41860e..cb293a7b60 100644 --- a/web/tests/composebox_typeahead.test.js +++ b/web/tests/composebox_typeahead.test.js @@ -431,11 +431,14 @@ test("topics_seen_for", ({override, override_rewire}) => { test("content_typeahead_selected", ({override}) => { const fake_this = { query: "", - $element: {}, + input_element: { + $element: {}, + type: "input", + }, }; let caret_called1 = false; let caret_called2 = false; - fake_this.$element.caret = function (...args) { + fake_this.input_element.$element.caret = function (...args) { if (args.length === 0) { // .caret() used in split_at_cursor caret_called1 = true; @@ -448,7 +451,7 @@ test("content_typeahead_selected", ({override}) => { return this; }; let range_called = false; - fake_this.$element.range = function (...args) { + fake_this.input_element.$element.range = function (...args) { const [arg1, arg2] = args; // .range() used in setTimeout assert.ok(arg2 > arg1); @@ -690,7 +693,7 @@ test("content_typeahead_selected", ({override}) => { // Test special case to not close code blocks if there is text afterward fake_this.query = "```p\nsome existing code"; fake_this.token = "p"; - fake_this.$element.caret = () => 4; // Put cursor right after ```p + fake_this.input_element.$element.caret = () => 4; // Put cursor right after ```p actual_value = ct.content_typeahead_selected.call(fake_this, "python"); expected_value = "```python\nsome existing code"; assert.equal(actual_value, expected_value); @@ -757,8 +760,8 @@ test("initialize", ({override, override_rewire, mock_template}) => { let topic_typeahead_called = false; let pm_recipient_typeahead_called = false; let compose_textarea_typeahead_called = false; - override(bootstrap_typeahead, "create", ($element, options) => { - switch ($element) { + override(bootstrap_typeahead, "create", (input_element, options) => { + switch (input_element.$element) { case $("input#stream_message_recipient_topic"): { override_rewire(stream_topic_history, "get_recent_topic_names", (stream_id) => { assert.equal(stream_id, sweden_stream.stream_id); @@ -987,14 +990,17 @@ test("initialize", ({override, override_rewire, mock_template}) => { // properly set as the .source(). All its features are tested later on // in test_begins_typeahead(). let fake_this = { - $element: {}, + input_element: { + $element: {}, + type: "input", + }, }; let caret_called = false; - fake_this.$element.caret = () => { + fake_this.input_element.$element.caret = () => { caret_called = true; return 7; }; - fake_this.$element.closest = () => []; + fake_this.input_element.$element.closest = () => []; let actual_value = options.source.call(fake_this, "test #s"); assert.deepEqual(sorted_names_from(actual_value), ["Sweden", "The Netherlands"]); assert.ok(caret_called); @@ -1318,6 +1324,10 @@ test("begins_typeahead", ({override, override_rewire}) => { override(stream_topic_history_util, "get_server_history", noop); const begin_typehead_this = { + input_element: { + $element: {}, + type: "input", + }, options: { completions: { emoji: true, diff --git a/web/tests/pill_typeahead.test.js b/web/tests/pill_typeahead.test.js index b27e14f815..af709fbf0c 100644 --- a/web/tests/pill_typeahead.test.js +++ b/web/tests/pill_typeahead.test.js @@ -132,8 +132,8 @@ run_test("set_up", ({mock_template, override}) => { } let opts = {}; - override(bootstrap_typeahead, "create", ($element, config) => { - assert.equal($element, $fake_input); + override(bootstrap_typeahead, "create", (input_element, config) => { + assert.equal(input_element.$element, $fake_input); assert.equal(config.items, 5); assert.ok(config.fixed); assert.ok(config.dropup); diff --git a/web/tests/search.test.js b/web/tests/search.test.js index ee4a4b983c..b50a31d917 100644 --- a/web/tests/search.test.js +++ b/web/tests/search.test.js @@ -36,8 +36,8 @@ run_test("initialize", ({override, override_rewire, mock_template}) => { search_suggestion.max_num_of_search_results = 999; let terms; - override(bootstrap_typeahead, "create", ($element, opts) => { - assert.equal($element, $search_query_box); + override(bootstrap_typeahead, "create", (input_element, opts) => { + assert.equal(input_element.$element, $search_query_box); assert.equal(opts.items, 999); assert.equal(opts.naturalSearch, true); assert.equal(opts.helpOnEmptyStrings, true);