mirror of https://github.com/zulip/zulip.git
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).
This commit is contained in:
parent
3f5be23854
commit
c7b12c996c
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = '<ul class="typeahead-menu"></ul>';
|
|||
const ITEM_HTML = "<li><a></a></li>";
|
||||
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<HTMLInputElement>;
|
||||
type: "input";
|
||||
};
|
||||
|
||||
if (this.fixed) {
|
||||
this.$container.css("position", "fixed");
|
||||
class Typeahead<ItemType extends string | object> {
|
||||
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<ItemType>) {
|
||||
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<ItemType> = {
|
||||
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<ItemType extends string | object>(
|
||||
input_element: InputElement,
|
||||
options: TypeaheadOptions<ItemType>,
|
||||
): 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();
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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});
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue