typeahead: Use tippy to position typeaheads without a specified parent.

Except for search typeaheads which have a specific parent container,
we position typeaheads using tippy.
This commit is contained in:
Aman Agrawal 2024-04-29 13:10:05 +00:00 committed by Tim Abbott
parent 689489573a
commit 4e87f35c7d
7 changed files with 100 additions and 75 deletions

View File

@ -46,7 +46,11 @@ async function create_stream_message_draft(page: Page): Promise<void> {
async function test_restore_stream_message_draft_by_opening_compose_box(page: Page): Promise<void> { async function test_restore_stream_message_draft_by_opening_compose_box(page: Page): Promise<void> {
await page.click(".search_icon"); await page.click(".search_icon");
await page.waitForSelector("#search_query", {visible: true}); 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.click("#left_bar_compose_reply_button_big");
await page.waitForSelector("#send_message_form", {visible: true}); await page.waitForSelector("#send_message_form", {visible: true});

View File

@ -34,14 +34,12 @@ export const pm_recipient = {
// a flake where the typeahead doesn't show up. // a flake where the typeahead doesn't show up.
await page.type("#private_message_recipient", recipient, {delay: 100}); await page.type("#private_message_recipient", recipient, {delay: 100});
// We use [style*="display: block"] here to distinguish // PM typeaheads always have an image. This ensures we are waiting for the right typeahead to appear.
// the visible typeahead menu from the invisible ones const entry = await page.waitForSelector(".typeahead .active a .typeahead-image", {
// 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', {
visible: false, visible: false,
}); });
// log entry in puppeteer logs
console.log(await entry!.evaluate((el) => el.textContent));
await entry!.click(); await entry!.click();
}, },
@ -584,9 +582,7 @@ export async function select_item_via_typeahead(
console.log(`Looking in ${field_selector} to select ${str}, ${item}`); console.log(`Looking in ${field_selector} to select ${str}, ${item}`);
await clear_and_type(page, field_selector, str); await clear_and_type(page, field_selector, str);
const entry = await page.waitForSelector( const entry = await page.waitForSelector(
`xpath///*[${has_class_x( `xpath///*[${has_class_x("typeahead")}]//li[contains(normalize-space(), "${item}")]//a`,
"typeahead",
)} and contains(@style, "display: block")]//li[contains(normalize-space(), "${item}")]//a`,
{visible: true}, {visible: true},
); );
assert.ok(entry); assert.ok(entry);

View File

@ -16,8 +16,18 @@ async function _add_playground_and_return_status(page: Page, payload: Playground
const admin_playground_status_selector = "div#admin-playground-status"; const admin_playground_status_selector = "div#admin-playground-status";
await page.waitForSelector(admin_playground_status_selector, {hidden: true}); 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. // 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. // 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. // So we resort to using eval with the button ID instead.
await page.$eval("button#submit_playground_button", (el) => { await page.$eval("button#submit_playground_button", (el) => {

View File

@ -130,11 +130,15 @@
* *
* This allows us to have things like a close button, and be able * This allows us to have things like a close button, and be able
* to move focus there without the typeahead closing. * to move focus there without the typeahead closing.
*
* 15. To position typeaheads, we use Tippyjs.
* ============================================================ */ * ============================================================ */
import $ from "jquery"; import $ from "jquery";
import assert from "minimalistic-assert"; import assert from "minimalistic-assert";
import {insertTextIntoField} from "text-field-edit"; import {insertTextIntoField} from "text-field-edit";
import type {Instance} from "tippy.js";
import tippy from "tippy.js";
import {get_string_diff} from "./util"; import {get_string_diff} from "./util";
@ -229,6 +233,7 @@ export class Typeahead<ItemType extends string | object> {
advanceKeyCodes: number[]; advanceKeyCodes: number[];
parentElement?: string; parentElement?: string;
values: WeakMap<HTMLElement, ItemType>; values: WeakMap<HTMLElement, ItemType>;
instance?: Instance;
constructor(input_element: TypeaheadInputElement, options: TypeaheadOptions<ItemType>) { constructor(input_element: TypeaheadInputElement, options: TypeaheadOptions<ItemType>) {
this.input_element = input_element; this.input_element = input_element;
@ -242,7 +247,10 @@ export class Typeahead<ItemType extends string | object> {
this.sorter = options.sorter; this.sorter = options.sorter;
this.highlighter_html = options.highlighter_html; this.highlighter_html = options.highlighter_html;
this.updater = options.updater ?? ((items) => this.defaultUpdater(items)); 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.$menu = $(MENU_HTML).appendTo(this.$container);
this.$header = $(HEADER_ELEMENT_HTML).appendTo(this.$container); this.$header = $(HEADER_ELEMENT_HTML).appendTo(this.$container);
this.source = options.source; this.source = options.source;
@ -311,6 +319,13 @@ export class Typeahead<ItemType extends string | object> {
} }
show(): this { show(): this {
if (this.shown) {
return this;
}
// Call this early to avoid duplicate calls.
this.shown = true;
const header_text_html = this.header_html(); const header_text_html = this.header_html();
if (header_text_html) { if (header_text_html) {
this.$header.find("span#typeahead-header-text").html(header_text_html); this.$header.find("span#typeahead-header-text").html(header_text_html);
@ -318,45 +333,48 @@ export class Typeahead<ItemType extends string | object> {
} else { } else {
this.$header.hide(); 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; 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; return this;
} }
hide(): this { hide(): this {
this.$container.hide();
this.shown = false; this.shown = false;
if (this.parentElement) {
this.$container.hide();
} else {
this.instance?.hide();
}
if (this.closeInputFieldOnHide !== undefined) { if (this.closeInputFieldOnHide !== undefined) {
this.closeInputFieldOnHide(); this.closeInputFieldOnHide();
} }
@ -378,10 +396,10 @@ export class Typeahead<ItemType extends string | object> {
const items = this.source(this.query, this.input_element); const items = this.source(this.query, this.input_element);
if (!items && this.shown) { if (!items.length && this.shown) {
this.hide(); this.hide();
} }
return items ? this.process(items) : this; return items.length ? this.process(items) : this;
} }
process(items: ItemType[]): this { process(items: ItemType[]): this {
@ -396,7 +414,13 @@ export class Typeahead<ItemType extends string | object> {
this.select(); this.select();
return this; 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 { defaultMatcher(item: ItemType, query: string): boolean {
@ -471,10 +495,11 @@ export class Typeahead<ItemType extends string | object> {
} }
unlisten(): void { unlisten(): void {
this.hide();
this.$container.remove(); this.$container.remove();
const events = ["blur", "keydown", "keyup", "keypress", "mousemove"]; const events = ["blur", "keydown", "keyup", "keypress", "click"];
for (const event_ of events) { for (const event of events) {
$(this.input_element.$element).off(event_); $(this.input_element.$element).off(event);
} }
} }
@ -558,6 +583,11 @@ export class Typeahead<ItemType extends string | object> {
} }
keyup(e: JQuery.KeyUpEvent): void { 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); const pseudo_keycode = get_pseudo_keycode(e);
switch (pseudo_keycode) { switch (pseudo_keycode) {

View File

@ -89,6 +89,7 @@
> .tippy-content { > .tippy-content {
padding: 0; padding: 0;
font-size: 14px;
} }
> .tippy-arrow { > .tippy-arrow {

View File

@ -1360,11 +1360,14 @@ div.focused-message-list {
font-size: 0; font-size: 0;
} }
.typeahead.dropdown-menu a { .typeahead.dropdown-menu {
/* Use default color until some other typeahead overrides it. */
color: var(--color-text-default);
a {
color: inherit; color: inherit;
} }
.typeahead.dropdown-menu {
.active { .active {
color: hsl(0deg 0% 100%); color: hsl(0deg 0% 100%);

View File

@ -1,25 +1,10 @@
/* CSS for Bootstrap typeahead */ /* CSS for Bootstrap typeahead */
.dropdown-menu { .dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0; padding: 5px 0;
margin: 2px 0 0; min-width: 160px;
list-style: none; list-style: none;
background-color: #ffffff; 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; -webkit-background-clip: padding-box;
-moz-background-clip: padding; -moz-background-clip: padding;
background-clip: padding-box; background-clip: padding-box;
@ -83,8 +68,4 @@
} }
.typeahead { .typeahead {
z-index: 1051; z-index: 1051;
margin-top: 2px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
} }