dropdown_widget: Implement dropdown widget using tippy.

This will soon replace DropdownListWidget.
This commit is contained in:
Aman Agrawal
2023-05-07 12:42:54 +00:00
committed by Tim Abbott
parent bb78d9741f
commit a196b949f7
10 changed files with 306 additions and 1 deletions

View File

@@ -83,6 +83,7 @@ EXEMPT_FILES = make_set(
"web/src/dialog_widget.ts",
"web/src/drafts.js",
"web/src/dropdown_list_widget.js",
"web/src/dropdown_widget.js",
"web/src/echo.js",
"web/src/emoji_picker.js",
"web/src/emojisets.js",

154
web/src/dropdown_widget.js Normal file
View File

@@ -0,0 +1,154 @@
import $ from "jquery";
import * as tippy from "tippy.js";
import render_dropdown_list from "../templates/dropdown_list.hbs";
import render_dropdown_list_container from "../templates/dropdown_list_container.hbs";
import * as ListWidget from "./list_widget";
import {default_popover_props} from "./popover_menus";
import {parse_html} from "./ui_util";
/* Sync with height set in zulip.css */
export const DEFAULT_DROPDOWN_HEIGHT = 200;
const noop = () => {};
export function setup(tippy_props, get_options, item_click_callback, dropdown_props = {}) {
// Define all possible `dropdown_props` here so that they are easy to track.
const on_show_callback = dropdown_props.on_show_callback || noop;
const on_exit_with_escape_callback = dropdown_props.on_exit_with_escape_callback || noop;
// Used focus the `target` after dropdown is closed. This is important since the dropdown is
// appended to `body` and hence `body` is focused when the dropdown is closed, which makes
// it hard for the user to get focus back to the `target`.
const focus_target_on_hidden = dropdown_props.focus_target_on_hidden || true;
// Should enter keypress on target show the dropdown.
const show_on_target_enter_keypress = dropdown_props.show_on_target_enter_keypress || false;
if (show_on_target_enter_keypress) {
$("body").on("keypress", tippy_props.target, (e) => {
if (e.key === "Enter") {
$(tippy_props.target).trigger("click");
e.stopPropagation();
e.preventDefault();
}
});
}
tippy.delegate("body", {
...default_popover_props,
// Custom theme defined in popovers.css
theme: "dropdown-widget",
arrow: false,
onShow(instance) {
instance.setContent(parse_html(render_dropdown_list_container()));
const $popper = $(instance.popper);
const $dropdown_list_body = $popper.find(".dropdown-list");
const $search_input = $popper.find(".dropdown-list-search-input");
const list_widget = ListWidget.create($dropdown_list_body, get_options(), {
name: `${CSS.escape(tippy_props.target)}-list-widget`,
modifier(item) {
return render_dropdown_list({item});
},
filter: {
$element: $search_input,
predicate(item, value) {
return item.name.toLowerCase().includes(value);
},
},
$simplebar_container: $popper.find(".dropdown-list-wrapper"),
});
// Keyboard handler
$popper.on("keydown", (e) => {
function trigger_element_focus($element) {
e.preventDefault();
e.stopPropagation();
$element.trigger("focus");
}
const $search_input = $popper.find(".dropdown-list-search-input");
const list_items = list_widget.get_current_list();
if (list_items.length === 0 && !(e.key === "Escape")) {
// Let the browser handle it.
return;
}
function first_item() {
const first_item = list_items[0];
return $popper.find(`.list-item[data-unique-id="${first_item.unique_id}"]`);
}
function last_item() {
const last_item = list_items.at(-1);
return $popper.find(`.list-item[data-unique-id="${last_item.unique_id}"]`);
}
switch (e.key) {
case "Enter":
if (e.target === $search_input.get(0)) {
// Select first item if in search input.
first_item().trigger("click");
} else if (list_items.length !== 0) {
$(e.target).trigger("click");
}
e.stopPropagation();
e.preventDefault();
break;
case "Escape":
instance.hide();
on_exit_with_escape_callback();
e.stopPropagation();
e.preventDefault();
break;
case "Tab":
case "ArrowDown":
switch (e.target) {
case last_item().get(0):
trigger_element_focus($search_input);
break;
case $search_input.get(0):
trigger_element_focus(first_item());
break;
default:
trigger_element_focus($(e.target).next());
}
break;
case "ArrowUp":
switch (e.target) {
case first_item().get(0):
trigger_element_focus($search_input);
break;
case $search_input.get(0):
// Can't focus on the last element since it is
// most likely not rendered.
break;
default:
trigger_element_focus($(e.target).prev());
}
break;
}
});
// Click on item.
$popper.one("click", ".list-item", (event) => {
item_click_callback(event, instance);
});
// Set focus on search input when dropdown opens.
setTimeout(() => {
$(".dropdown-list-search-input").trigger("focus");
});
on_show_callback(instance);
},
onHidden() {
if (focus_target_on_hidden) {
$(tippy_props.target).trigger("focus");
}
},
...tippy_props,
});
}

View File

@@ -137,7 +137,7 @@ function get_popover_items_for_instance(instance) {
return $current_elem.find("li:not(.divider):visible a");
}
const default_popover_props = {
export const default_popover_props = {
delay: 0,
appendTo: () => document.body,
trigger: "click",

View File

@@ -1010,3 +1010,19 @@ div.overlay {
}
}
}
.dropdown-widget-button {
background-color: hsl(0deg 0% 100%);
padding: 4px 6px;
border: 1px solid hsl(0deg 0% 80%);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
.fa-chevron-down {
position: relative;
top: -1px;
}
}

View File

@@ -180,6 +180,27 @@
}
}
.tippy-box[data-theme="dropdown-widget"] {
background-color: hsl(240deg 5% 16%);
border: 1px solid hsl(0deg 0% 0%);
box-shadow: 0 7px 13px hsl(0deg 0% 0% / 35%),
0 5px 8px hsl(0deg 0% 0% / 32%), 0 2px 4px hsl(0deg 0% 0% / 20%);
}
.dropdown-list-container .dropdown-list .list-item {
&:focus {
background-color: hsl(220deg 12% 95.1% / 5%);
}
& > a {
color: hsl(0deg 0% 75%);
&:hover {
background-color: hsl(220deg 12% 95.1% / 5%);
}
}
}
& body,
.app-main,
.header-main,
@@ -466,6 +487,17 @@
color: inherit;
}
.dropdown-list-search .dropdown-list-search-input:focus {
background-color: hsl(225deg 6% 7%);
border: 1px solid hsl(0deg 0% 100% / 50%);
box-shadow: 0 0 5px hsl(0deg 0% 100% / 40%);
}
.dropdown-widget-button {
border-color: hsl(0deg 0% 0% / 60%);
background-color: hsl(0deg 0% 0% / 20%);
}
& select option {
background-color: hsl(212deg 28% 18%);
color: hsl(236deg 33% 90%);

View File

@@ -351,6 +351,7 @@
}
}
.dropdown-widget-button,
.modal_text_input {
width: 206px;
}

View File

@@ -25,6 +25,20 @@
}
}
.tippy-box[data-theme="dropdown-widget"] {
border-radius: 6px;
background-color: hsl(240deg 20% 98%);
border: 1px solid hsl(0deg 0% 0% / 40%);
box-shadow: 0 7px 13px hsl(0deg 0% 0% / 15%),
0 5px 8px hsl(0deg 0% 0% / 12%), 0 2px 4px hsl(0deg 0% 0% / 10%);
.tippy-content {
font-size: 14px;
color: hsl(0deg 0% 75%);
padding: 0;
}
}
.popover {
width: auto;
max-width: 100%;

View File

@@ -68,6 +68,8 @@ body,
--color-background: hsl(0deg 0% 100%);
--color-background-direct-mention: hsl(240deg 52% 95%);
--color-background-group-mention: hsl(180deg 40% 94%);
--color-background-dropdown-input: hsl(0deg 0% 100%);
--color-text-dropdown-input: hsl(0deg 0% 13.33%);
}
%dark-theme {
@@ -83,6 +85,8 @@ body,
--color-background: hsl(212deg 28% 18%);
--color-background-direct-mention: hsl(240deg 13% 20%);
--color-background-group-mention: hsl(180deg 13% 15%);
--color-background-dropdown-input: hsl(225deg 6% 10%);
--color-text-dropdown-input: hsl(0deg 0% 95%);
}
:root.dark-theme {
@@ -3029,3 +3033,65 @@ select.invite-as {
but we don't want it to have an outline when focused anywhere in the app. */
outline: none;
}
.dropdown-list-container {
.dropdown-list-search {
display: flex;
.dropdown-list-search-input {
background: var(--color-background-dropdown-input);
color: var(--color-text-dropdown-input);
width: 100%;
margin: 4px 4px 2px;
&:focus {
background: hsl(0deg 0% 100%);
border: 1px solid hsl(229.09deg 21.57% 10% / 80%);
box-shadow: 0 0 6px hsl(228deg 9.8% 20% / 30%);
}
}
}
.dropdown-list-wrapper {
/* Sync with `height` in dropdown_widget. */
height: 200px;
min-width: 200px;
.dropdown-list {
list-style: none;
margin: 0;
.list-item:focus {
background-color: hsl(220deg 12% 4.9% / 5%);
outline: none;
}
.list-item a {
display: block;
padding: 3px 10px 3px 8px;
clear: both;
font-weight: 400;
line-height: 20px;
color: hsl(0deg 0% 20%);
white-space: normal;
.stream-privacy-type-icon {
font-size: 13px;
width: 13px;
height: 13px;
padding-right: 2px;
}
&:hover {
text-decoration: none;
background-color: hsl(220deg 12% 4.9% / 5%);
}
&:focus {
text-decoration: none;
outline: none;
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
{{#with item}}
<li class="list-item" role="presentation" data-unique-id="{{unique_id}}" data-name="{{name}}" tabindex="0">
<a role="menuitem">
{{#if stream}}
{{> inline_decorated_stream_name stream=stream show_colored_icon=true}}
{{else if is_direct_message}}
<i class="zulip-icon zulip-icon-users stream-privacy-type-icon"></i> {{name}}
{{else}}
{{name}}
{{/if}}
</a>
</li>
{{/with}}

View File

@@ -0,0 +1,8 @@
<div class="dropdown-list-container">
<div class="dropdown-list-search">
<input class="dropdown-list-search-input" type="text" placeholder="{{t 'Filter' }}" autofocus/>
</div>
<div class="dropdown-list-wrapper" data-simplebar>
<ul class="dropdown-list"></ul>
</div>
</div>