diff --git a/web/e2e-tests/drafts.test.ts b/web/e2e-tests/drafts.test.ts index a1cd12f635..a639cfc2cb 100644 --- a/web/e2e-tests/drafts.test.ts +++ b/web/e2e-tests/drafts.test.ts @@ -46,7 +46,11 @@ async function create_stream_message_draft(page: Page): Promise { async function test_restore_stream_message_draft_by_opening_compose_box(page: Page): Promise { await page.click(".search_icon"); await page.waitForSelector("#search_query", {visible: true}); - await common.select_item_via_typeahead(page, "#search_query", "stream:Denmark topic:tests", ""); + await common.clear_and_type(page, "#search_query", "stream:Denmark topic:tests"); + // Wait for narrow to complete. + const wait_for_change = true; + await common.get_current_msg_list_id(page, wait_for_change); + await page.keyboard.press("Enter"); await page.click("#left_bar_compose_reply_button_big"); await page.waitForSelector("#send_message_form", {visible: true}); diff --git a/web/e2e-tests/lib/common.ts b/web/e2e-tests/lib/common.ts index a3a04bfc72..2244aa503f 100644 --- a/web/e2e-tests/lib/common.ts +++ b/web/e2e-tests/lib/common.ts @@ -34,14 +34,12 @@ export const pm_recipient = { // a flake where the typeahead doesn't show up. await page.type("#private_message_recipient", recipient, {delay: 100}); - // We use [style*="display: block"] here to distinguish - // the visible typeahead menu from the invisible ones - // meant for something else; e.g., the direct message - // input typeahead is different from the topic input - // typeahead but both can be present in the DOM. - const entry = await page.waitForSelector('.typeahead[style*="display: block"] .active a', { + // PM typeaheads always have an image. This ensures we are waiting for the right typeahead to appear. + const entry = await page.waitForSelector(".typeahead .active a .typeahead-image", { visible: false, }); + // log entry in puppeteer logs + console.log(await entry!.evaluate((el) => el.textContent)); await entry!.click(); }, @@ -584,9 +582,7 @@ export async function select_item_via_typeahead( console.log(`Looking in ${field_selector} to select ${str}, ${item}`); await clear_and_type(page, field_selector, str); const entry = await page.waitForSelector( - `xpath///*[${has_class_x( - "typeahead", - )} and contains(@style, "display: block")]//li[contains(normalize-space(), "${item}")]//a`, + `xpath///*[${has_class_x("typeahead")}]//li[contains(normalize-space(), "${item}")]//a`, {visible: true}, ); assert.ok(entry); diff --git a/web/e2e-tests/realm-playground.test.ts b/web/e2e-tests/realm-playground.test.ts index 7e03af2dd4..60beb60a56 100644 --- a/web/e2e-tests/realm-playground.test.ts +++ b/web/e2e-tests/realm-playground.test.ts @@ -16,8 +16,18 @@ async function _add_playground_and_return_status(page: Page, payload: Playground const admin_playground_status_selector = "div#admin-playground-status"; await page.waitForSelector(admin_playground_status_selector, {hidden: true}); + await common.select_item_via_typeahead( + page, + "#playground_pygments_language", + payload.pygments_language, + payload.pygments_language, + ); + // Now we can fill and click the submit button. - await common.fill_form(page, "form.admin-playground-form", payload); + await common.fill_form(page, "form.admin-playground-form", { + playground_name: payload.playground_name, + url_template: payload.url_template, + }); // Not sure why, but page.click() doesn't seem to always click the submit button. // So we resort to using eval with the button ID instead. await page.$eval("button#submit_playground_button", (el) => { diff --git a/web/src/bootstrap_typeahead.ts b/web/src/bootstrap_typeahead.ts index 272371a33f..d99b73ecde 100644 --- a/web/src/bootstrap_typeahead.ts +++ b/web/src/bootstrap_typeahead.ts @@ -130,11 +130,15 @@ * * This allows us to have things like a close button, and be able * to move focus there without the typeahead closing. + * + * 15. To position typeaheads, we use Tippyjs. * ============================================================ */ import $ from "jquery"; import assert from "minimalistic-assert"; import {insertTextIntoField} from "text-field-edit"; +import type {Instance} from "tippy.js"; +import tippy from "tippy.js"; import {get_string_diff} from "./util"; @@ -229,6 +233,7 @@ export class Typeahead { advanceKeyCodes: number[]; parentElement?: string; values: WeakMap; + instance?: Instance; constructor(input_element: TypeaheadInputElement, options: TypeaheadOptions) { this.input_element = input_element; @@ -242,7 +247,10 @@ export class Typeahead { 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.$container = $(CONTAINER_HTML); + if (options.parentElement) { + $(options.parentElement).append(this.$container); + } this.$menu = $(MENU_HTML).appendTo(this.$container); this.$header = $(HEADER_ELEMENT_HTML).appendTo(this.$container); this.source = options.source; @@ -311,6 +319,13 @@ export class Typeahead { } show(): this { + if (this.shown) { + return this; + } + + // Call this early to avoid duplicate calls. + this.shown = true; + const header_text_html = this.header_html(); if (header_text_html) { this.$header.find("span#typeahead-header-text").html(header_text_html); @@ -318,45 +333,48 @@ export class Typeahead { } else { this.$header.hide(); } - - // If a parent element was specified, we shouldn't manually - // position the element, since it's already in the right place. - if (this.parentElement === undefined) { - let pos; - - pos = this.input_element.$element[0].getBoundingClientRect(); - - pos = $.extend({}, pos, { - height: this.input_element.$element[0].offsetHeight, - // Zulip patch: Workaround for iOS safari problems - top: this.input_element.$element.get_offset_to_window().top, - }); - - let top_pos = pos.top + pos.height; - if (this.dropup) { - top_pos = pos.top - this.$container.outerHeight()!; - } - - // Zulip patch: Avoid typeahead going off top of screen. - if (top_pos < 0) { - top_pos = 0; - } - - this.$container.css({ - top: top_pos, - left: pos.left, - }); - } - - this.$container.show(); - this.shown = true; this.mouse_moved_since_typeahead = false; + + if (this.parentElement) { + this.$container.show(); + // We don't need tippy to position typeaheads which already know where they should be. + return this; + } + this.instance = tippy(this.input_element.$element[0], { + // Lets typeahead take the width needed to fit the content + // and wraps it if it overflows the visible container. + maxWidth: "none", + theme: "popover-menu", + placement: this.dropup ? "top-start" : "bottom-start", + interactive: true, + appendTo: () => document.body, + showOnCreate: true, + content: this.$container[0], + // We expect the typeahead creator to handle when to hide / show the typeahead. + trigger: "manual", + arrow: false, + offset: [0, 0], + // We have event handlers to hide the typeahead, so we + // don't want tippy to hide it for us. + hideOnClick: false, + onHidden: () => { + assert(this.instance !== undefined); + this.instance.destroy(); + this.instance = undefined; + }, + }); + return this; } hide(): this { - this.$container.hide(); this.shown = false; + if (this.parentElement) { + this.$container.hide(); + } else { + this.instance?.hide(); + } + if (this.closeInputFieldOnHide !== undefined) { this.closeInputFieldOnHide(); } @@ -378,10 +396,10 @@ export class Typeahead { const items = this.source(this.query, this.input_element); - if (!items && this.shown) { + if (!items.length && this.shown) { this.hide(); } - return items ? this.process(items) : this; + return items.length ? this.process(items) : this; } process(items: ItemType[]): this { @@ -396,7 +414,13 @@ export class Typeahead { this.select(); return this; } - return this.render(final_items.slice(0, this.items), matching_items).show(); + this.render(final_items.slice(0, this.items), matching_items); + + if (!this.shown) { + return this.show(); + } + + return this; } defaultMatcher(item: ItemType, query: string): boolean { @@ -471,10 +495,11 @@ export class Typeahead { } unlisten(): void { + this.hide(); this.$container.remove(); - const events = ["blur", "keydown", "keyup", "keypress", "mousemove"]; - for (const event_ of events) { - $(this.input_element.$element).off(event_); + const events = ["blur", "keydown", "keyup", "keypress", "click"]; + for (const event of events) { + $(this.input_element.$element).off(event); } } @@ -558,6 +583,11 @@ export class Typeahead { } keyup(e: JQuery.KeyUpEvent): void { + // NOTE: Ideally we can ignore meta keyup calls here but + // it's better to just trigger the lookup call to update the list in case + // it did modify the query. For example, `Command + delete` on Mac + // doesn't trigger a keyup event but when `Command` is released, it + // triggers a keyup event which correctly updates the list. const pseudo_keycode = get_pseudo_keycode(e); switch (pseudo_keycode) { diff --git a/web/styles/popovers.css b/web/styles/popovers.css index b450634ffd..b39dd7b137 100644 --- a/web/styles/popovers.css +++ b/web/styles/popovers.css @@ -89,6 +89,7 @@ > .tippy-content { padding: 0; + font-size: 14px; } > .tippy-arrow { diff --git a/web/styles/zulip.css b/web/styles/zulip.css index 2695037375..4272ecefe5 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -1360,11 +1360,14 @@ div.focused-message-list { font-size: 0; } -.typeahead.dropdown-menu a { - color: inherit; -} - .typeahead.dropdown-menu { + /* Use default color until some other typeahead overrides it. */ + color: var(--color-text-default); + + a { + color: inherit; + } + .active { color: hsl(0deg 0% 100%); diff --git a/web/third/bootstrap-typeahead/typeahead.css b/web/third/bootstrap-typeahead/typeahead.css index 6257071a24..dfdb035a83 100644 --- a/web/third/bootstrap-typeahead/typeahead.css +++ b/web/third/bootstrap-typeahead/typeahead.css @@ -1,25 +1,10 @@ /* CSS for Bootstrap typeahead */ .dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: 1000; - display: none; - float: left; - min-width: 160px; padding: 5px 0; - margin: 2px 0 0; + min-width: 160px; list-style: none; background-color: #ffffff; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, 0.2); - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; - -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); -webkit-background-clip: padding-box; -moz-background-clip: padding; background-clip: padding-box; @@ -83,8 +68,4 @@ } .typeahead { z-index: 1051; - margin-top: 2px; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; }