mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
dropdown_widget: Implement dropdown widget using tippy.
This will soon replace DropdownListWidget.
This commit is contained in:
@@ -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
154
web/src/dropdown_widget.js
Normal 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,
|
||||
});
|
||||
}
|
@@ -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",
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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%);
|
||||
|
@@ -351,6 +351,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-widget-button,
|
||||
.modal_text_input {
|
||||
width: 206px;
|
||||
}
|
||||
|
@@ -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%;
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
13
web/templates/dropdown_list.hbs
Normal file
13
web/templates/dropdown_list.hbs
Normal 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}}
|
8
web/templates/dropdown_list_container.hbs
Normal file
8
web/templates/dropdown_list_container.hbs
Normal 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>
|
Reference in New Issue
Block a user