typeahead: Make menu scrollable with up to 50 options.

To increase the number of options available for the user to pick from,
we increase the limit of options shown to 50 for all typeaheads, and
make the menu scrollable. The max height is set to original height of
the composebox typeahead menu, which fit 8 options or to 95% of the
window height, whichever is smaller.

Fixes: #20620.
This commit is contained in:
N-Shar-ma 2024-06-27 13:51:46 +05:30 committed by Tim Abbott
parent 4cb925a9c8
commit 82c2da8aae
13 changed files with 29 additions and 29 deletions

View File

@ -165,6 +165,7 @@ import {insertTextIntoField} from "text-field-edit";
import getCaretCoordinates from "textarea-caret"; import getCaretCoordinates from "textarea-caret";
import * as tippy from "tippy.js"; import * as tippy from "tippy.js";
import * as scroll_util from "./scroll_util";
import {get_string_diff} from "./util"; import {get_string_diff} from "./util";
function get_pseudo_keycode( function get_pseudo_keycode(
@ -197,13 +198,15 @@ export function defaultSorter(items: string[], query: string): string[] {
return [...beginswith, ...caseSensitive, ...caseInsensitive]; return [...beginswith, ...caseSensitive, ...caseInsensitive];
} }
export const MAX_ITEMS = 50;
/* TYPEAHEAD PUBLIC CLASS DEFINITION /* TYPEAHEAD PUBLIC CLASS DEFINITION
* ================================= */ * ================================= */
const HEADER_ELEMENT_HTML = const HEADER_ELEMENT_HTML =
'<p class="typeahead-header"><span id="typeahead-header-text"></span></p>'; '<p class="typeahead-header"><span id="typeahead-header-text"></span></p>';
const CONTAINER_HTML = '<div class="typeahead dropdown-menu"></div>'; const CONTAINER_HTML = '<div class="typeahead dropdown-menu"></div>';
const MENU_HTML = '<ul class="typeahead-menu"></ul>'; const MENU_HTML = '<ul class="typeahead-menu" data-simplebar></ul>';
const ITEM_HTML = "<li><a></a></li>"; const ITEM_HTML = "<li><a></a></li>";
const MIN_LENGTH = 1; const MIN_LENGTH = 1;
@ -275,7 +278,7 @@ export class Typeahead<ItemType extends string | object> {
} else { } else {
assert(!this.input_element.$element.is("[contenteditable]")); assert(!this.input_element.$element.is("[contenteditable]"));
} }
this.items = options.items ?? 8; this.items = options.items ?? MAX_ITEMS;
this.matcher = options.matcher ?? ((item, query) => this.defaultMatcher(item, query)); this.matcher = options.matcher ?? ((item, query) => this.defaultMatcher(item, query));
this.sorter = options.sorter; this.sorter = options.sorter;
this.highlighter_html = options.highlighter_html; this.highlighter_html = options.highlighter_html;
@ -538,7 +541,10 @@ export class Typeahead<ItemType extends string | object> {
if (this.requireHighlight || this.shouldHighlightFirstResult()) { if (this.requireHighlight || this.shouldHighlightFirstResult()) {
$items[0]!.addClass("active"); $items[0]!.addClass("active");
} }
this.$menu.empty().append($items); // Getting scroll element ensures simplebar has processed the element
// before we render it.
scroll_util.get_scroll_element(this.$menu);
scroll_util.get_content_element(this.$menu).empty().append($items);
return this; return this;
} }
@ -558,6 +564,7 @@ export class Typeahead<ItemType extends string | object> {
} }
$next.addClass("active"); $next.addClass("active");
scroll_util.scroll_element_into_container($next, this.$menu);
} }
prev(): void { prev(): void {
@ -576,6 +583,7 @@ export class Typeahead<ItemType extends string | object> {
} }
$prev.addClass("active"); $prev.addClass("active");
scroll_util.scroll_element_into_container($prev, this.$menu);
} }
listen(): void { listen(): void {
@ -831,7 +839,7 @@ export class Typeahead<ItemType extends string | object> {
type TypeaheadOptions<ItemType> = { type TypeaheadOptions<ItemType> = {
highlighter_html: (item: ItemType, query: string) => string | undefined; highlighter_html: (item: ItemType, query: string) => string | undefined;
items: number; items?: number;
source: (query: string, input_element: TypeaheadInputElement) => ItemType[]; source: (query: string, input_element: TypeaheadInputElement) => ItemType[];
// optional options // optional options
advanceKeyCodes?: number[]; advanceKeyCodes?: number[];

View File

@ -6,7 +6,7 @@ import * as typeahead from "../shared/src/typeahead";
import type {Emoji, EmojiSuggestion} from "../shared/src/typeahead"; import type {Emoji, EmojiSuggestion} from "../shared/src/typeahead";
import render_topic_typeahead_hint from "../templates/topic_typeahead_hint.hbs"; import render_topic_typeahead_hint from "../templates/topic_typeahead_hint.hbs";
import {Typeahead} from "./bootstrap_typeahead"; import {MAX_ITEMS, Typeahead} from "./bootstrap_typeahead";
import type {TypeaheadInputElement} from "./bootstrap_typeahead"; import type {TypeaheadInputElement} from "./bootstrap_typeahead";
import * as bulleted_numbered_list_util from "./bulleted_numbered_list_util"; import * as bulleted_numbered_list_util from "./bulleted_numbered_list_util";
import * as compose_pm_pill from "./compose_pm_pill"; import * as compose_pm_pill from "./compose_pm_pill";
@ -103,9 +103,8 @@ export type TypeaheadSuggestion =
| EmojiSuggestion | EmojiSuggestion
| SlashCommandSuggestion; | SlashCommandSuggestion;
// This is what we use for direct message/compose typeaheads.
// We export it to allow tests to mock it. // We export it to allow tests to mock it.
export const max_num_items = 8; export const max_num_items = MAX_ITEMS;
export let emoji_collection: Emoji[] = []; export let emoji_collection: Emoji[] = [];
@ -1239,7 +1238,7 @@ export function initialize_topic_edit_typeahead(
const stream_id = stream_data.get_stream_id(stream_name); const stream_id = stream_data.get_stream_id(stream_name);
return topics_seen_for(stream_id); return topics_seen_for(stream_id);
}, },
items: 5, items: max_num_items,
}); });
} }
@ -1317,7 +1316,7 @@ export function initialize({
source(): string[] { source(): string[] {
return topics_seen_for(compose_state.stream_id()); return topics_seen_for(compose_state.stream_id());
}, },
items: 3, items: max_num_items,
highlighter_html(item: string): string { highlighter_html(item: string): string {
return typeahead_helper.render_typeahead_item({primary: item}); return typeahead_helper.render_typeahead_item({primary: item});
}, },

View File

@ -202,7 +202,6 @@ export function initialize_custom_pronouns_type_fields(element_id: string): void
type: "input" as const, type: "input" as const,
}; };
new Typeahead(bootstrap_typeahead_input, { new Typeahead(bootstrap_typeahead_input, {
items: 3,
helpOnEmptyStrings: true, helpOnEmptyStrings: true,
source() { source() {
return commonly_used_pronouns; return commonly_used_pronouns;

View File

@ -41,7 +41,6 @@ export function set_up_user(
type: "contenteditable", type: "contenteditable",
}; };
new Typeahead(bootstrap_typeahead_input, { new Typeahead(bootstrap_typeahead_input, {
items: 5,
dropup: true, dropup: true,
source(_query: string): UserPillData[] { source(_query: string): UserPillData[] {
return user_pill.typeahead_source(pills, exclude_bots); return user_pill.typeahead_source(pills, exclude_bots);
@ -87,7 +86,6 @@ export function set_up_stream(
}; };
opts.help_on_empty_strings ||= false; opts.help_on_empty_strings ||= false;
new Typeahead(bootstrap_typeahead_input, { new Typeahead(bootstrap_typeahead_input, {
items: 12,
dropup: true, dropup: true,
helpOnEmptyStrings: true, helpOnEmptyStrings: true,
source(_query: string): StreamPillData[] { source(_query: string): StreamPillData[] {
@ -153,7 +151,6 @@ export function set_up_combined(
type: "contenteditable", type: "contenteditable",
}; };
new Typeahead(bootstrap_typeahead_input, { new Typeahead(bootstrap_typeahead_input, {
items: 5,
dropup: true, dropup: true,
source(query: string): TypeaheadItem[] { source(query: string): TypeaheadItem[] {
let source: TypeaheadItem[] = []; let source: TypeaheadItem[] = [];

View File

@ -3,6 +3,7 @@ import assert from "minimalistic-assert";
import render_user_pill from "../templates/user_pill.hbs"; import render_user_pill from "../templates/user_pill.hbs";
import {MAX_ITEMS} from "./bootstrap_typeahead";
import * as common from "./common"; import * as common from "./common";
import * as direct_message_group_data from "./direct_message_group_data"; import * as direct_message_group_data from "./direct_message_group_data";
import {Filter, create_user_pill_context} from "./filter"; import {Filter, create_user_pill_context} from "./filter";
@ -41,7 +42,7 @@ export type Suggestion = {
} }
); );
export const max_num_of_search_results = 12; export const max_num_of_search_results = MAX_ITEMS;
function channel_matches_query(channel_name: string, q: string): boolean { function channel_matches_query(channel_name: string, q: string): boolean {
return common.phrase_match(q, channel_name); return common.phrase_match(q, channel_name);

View File

@ -169,7 +169,6 @@ function build_page(): void {
language_labels = realm_playground.get_pygments_typeahead_list_for_settings(query); language_labels = realm_playground.get_pygments_typeahead_list_for_settings(query);
return [...language_labels.keys()]; return [...language_labels.keys()];
}, },
items: 5,
helpOnEmptyStrings: true, helpOnEmptyStrings: true,
highlighter_html: (item: string): string => highlighter_html: (item: string): string =>
render_typeahead_item({primary: language_labels.get(item)}), render_typeahead_item({primary: language_labels.get(item)}),

View File

@ -6,6 +6,7 @@ import * as typeahead from "../shared/src/typeahead";
import type {EmojiSuggestion} from "../shared/src/typeahead"; import type {EmojiSuggestion} from "../shared/src/typeahead";
import render_typeahead_list_item from "../templates/typeahead_list_item.hbs"; import render_typeahead_list_item from "../templates/typeahead_list_item.hbs";
import {MAX_ITEMS} from "./bootstrap_typeahead";
import * as buddy_data from "./buddy_data"; import * as buddy_data from "./buddy_data";
import * as compose_state from "./compose_state"; import * as compose_state from "./compose_state";
import type {LanguageSuggestion, SlashCommandSuggestion} from "./composebox_typeahead"; import type {LanguageSuggestion, SlashCommandSuggestion} from "./composebox_typeahead";
@ -416,7 +417,7 @@ export function sort_recipients<UserType extends UserOrMentionPillData | UserPil
current_stream_id, current_stream_id,
current_topic, current_topic,
groups = [], groups = [],
max_num_items = 20, max_num_items = MAX_ITEMS,
}: { }: {
users: UserType[]; users: UserType[];
query: string; query: string;

View File

@ -1404,6 +1404,12 @@ textarea.new_message_textarea {
.typeahead-menu { .typeahead-menu {
list-style: none; list-style: none;
margin: 4px 0; margin: 4px 0;
max-height: min(248px, 95vh);
overflow-y: auto;
.simplebar-content {
min-width: max-content;
}
} }
.typeahead-header { .typeahead-header {

View File

@ -166,7 +166,7 @@
color: var(--color-text-search-hover); color: var(--color-text-search-hover);
} }
.typeahead-menu > li > a { .typeahead-menu .simplebar-content > li > a {
max-width: none; max-width: none;
} }
} }
@ -331,7 +331,7 @@
} }
} }
.typeahead-menu > li > a { .typeahead-menu .simplebar-content > li > a {
padding: 3px 30px; padding: 3px 30px;
/* Override white-space: nowrap from zulip.css */ /* Override white-space: nowrap from zulip.css */
white-space: normal; white-space: normal;

View File

@ -14,7 +14,7 @@
z-index: 1051; z-index: 1051;
} }
.typeahead.dropdown-menu .typeahead-menu { .typeahead.dropdown-menu .typeahead-menu .simplebar-content {
& > li { & > li {
word-break: break-word; word-break: break-word;

View File

@ -59,11 +59,6 @@ const settings_config = zrequire("settings_config");
const ct = composebox_typeahead; const ct = composebox_typeahead;
// Use a slightly larger value than what's user-facing
// to facilitate testing different combinations of
// broadcast-mentions/persons/groups.
ct.__Rewire__("max_num_items", 15);
function user_item(user) { function user_item(user) {
return {type: "user", user}; return {type: "user", user};
} }

View File

@ -162,7 +162,6 @@ run_test("set_up_user", ({mock_template, override, override_rewire}) => {
override(bootstrap_typeahead, "Typeahead", (input_element, config) => { override(bootstrap_typeahead, "Typeahead", (input_element, config) => {
assert.equal(input_element.$element, $fake_input); assert.equal(input_element.$element, $fake_input);
assert.equal(config.items, 5);
assert.ok(config.dropup); assert.ok(config.dropup);
assert.ok(config.stopAdvance); assert.ok(config.stopAdvance);
@ -255,7 +254,6 @@ run_test("set_up_stream", ({mock_template, override, override_rewire}) => {
override(bootstrap_typeahead, "Typeahead", (input_element, config) => { override(bootstrap_typeahead, "Typeahead", (input_element, config) => {
assert.equal(input_element.$element, $fake_input); assert.equal(input_element.$element, $fake_input);
assert.equal(config.items, 12);
assert.ok(config.dropup); assert.ok(config.dropup);
assert.ok(config.stopAdvance); assert.ok(config.stopAdvance);
@ -345,7 +343,6 @@ run_test("set_up_combined", ({mock_template, override, override_rewire}) => {
let opts = {}; let opts = {};
override(bootstrap_typeahead, "Typeahead", (input_element, config) => { override(bootstrap_typeahead, "Typeahead", (input_element, config) => {
assert.equal(input_element.$element, $fake_input); assert.equal(input_element.$element, $fake_input);
assert.equal(config.items, 5);
assert.ok(config.dropup); assert.ok(config.dropup);
assert.ok(config.stopAdvance); assert.ok(config.stopAdvance);

View File

@ -16,8 +16,6 @@ const stream_topic_history = zrequire("stream_topic_history");
const people = zrequire("people"); const people = zrequire("people");
const search = zrequire("search_suggestion"); const search = zrequire("search_suggestion");
search.__Rewire__("max_num_of_search_results", 15);
const me = { const me = {
email: "myself@zulip.com", email: "myself@zulip.com",
full_name: "Me Myself", full_name: "Me Myself",