2023-05-15 07:25:44 +02:00
|
|
|
import $ from "jquery";
|
2023-10-25 02:29:30 +02:00
|
|
|
import assert from "minimalistic-assert";
|
2023-07-10 11:30:52 +02:00
|
|
|
|
2024-03-01 15:05:41 +01:00
|
|
|
export type Context = {
|
2023-07-10 11:30:52 +02:00
|
|
|
items_container_selector: string;
|
|
|
|
items_list_selector: string;
|
|
|
|
row_item_selector: string;
|
|
|
|
box_item_selector: string;
|
|
|
|
id_attribute_name: string;
|
2023-12-27 01:34:59 +01:00
|
|
|
get_items_ids: () => number[];
|
|
|
|
on_enter: () => void;
|
|
|
|
on_delete: () => void;
|
2023-07-10 11:30:52 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
export function row_with_focus(context: Context): JQuery {
|
2024-05-30 19:07:59 +02:00
|
|
|
const $focused_item = $(`.${CSS.escape(context.box_item_selector)}:focus`);
|
|
|
|
return $focused_item.parent(`.${CSS.escape(context.row_item_selector)}`);
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
2023-09-08 23:19:39 +02:00
|
|
|
export function activate_element(elem: HTMLElement, context: Context): void {
|
2023-05-15 07:25:44 +02:00
|
|
|
$(`.${CSS.escape(context.box_item_selector)}`).removeClass("active");
|
2023-09-08 23:19:39 +02:00
|
|
|
elem.classList.add("active");
|
2023-05-15 07:25:44 +02:00
|
|
|
elem.focus();
|
|
|
|
}
|
|
|
|
|
2024-05-07 19:20:43 +02:00
|
|
|
export function get_focused_element_id(context: Context): string | undefined {
|
|
|
|
return row_with_focus(context).attr(context.id_attribute_name);
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
export function focus_on_sibling_element(context: Context): void {
|
2023-05-15 07:25:44 +02:00
|
|
|
const $next_row = row_after_focus(context);
|
|
|
|
const $prev_row = row_before_focus(context);
|
2023-07-10 11:30:52 +02:00
|
|
|
let elem_to_be_focused_id: string | undefined;
|
2023-05-15 07:25:44 +02:00
|
|
|
|
|
|
|
// Try to get the next item in the list and 'focus' on it.
|
|
|
|
// Use previous item as a fallback.
|
2023-07-15 09:24:21 +02:00
|
|
|
if ($next_row.length > 0) {
|
2023-05-15 07:25:44 +02:00
|
|
|
elem_to_be_focused_id = $next_row.attr(context.id_attribute_name);
|
2023-07-15 09:24:21 +02:00
|
|
|
} else if ($prev_row.length > 0) {
|
2023-05-15 07:25:44 +02:00
|
|
|
elem_to_be_focused_id = $prev_row.attr(context.id_attribute_name);
|
|
|
|
}
|
|
|
|
|
2023-09-08 23:19:39 +02:00
|
|
|
const $new_focus_element = get_element_by_id(elem_to_be_focused_id ?? "", context);
|
2024-05-30 19:07:59 +02:00
|
|
|
if ($new_focus_element[0] !== undefined) {
|
2023-10-25 02:29:30 +02:00
|
|
|
assert($new_focus_element[0].children[0] instanceof HTMLElement);
|
|
|
|
activate_element($new_focus_element[0].children[0], context);
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
export function modals_handle_events(event_key: string, context: Context): void {
|
2023-05-15 07:25:44 +02:00
|
|
|
initialize_focus(event_key, context);
|
|
|
|
|
|
|
|
// This detects up arrow key presses when the overlay
|
|
|
|
// is open and scrolls through.
|
|
|
|
if (event_key === "up_arrow" || event_key === "vim_up") {
|
|
|
|
scroll_to_element(row_before_focus(context), context);
|
|
|
|
}
|
|
|
|
|
|
|
|
// This detects down arrow key presses when the overlay
|
|
|
|
// is open and scrolls through.
|
|
|
|
if (event_key === "down_arrow" || event_key === "vim_down") {
|
|
|
|
scroll_to_element(row_after_focus(context), context);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event_key === "backspace" || event_key === "delete") {
|
|
|
|
context.on_delete();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event_key === "enter") {
|
|
|
|
context.on_enter();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
export function set_initial_element(element_id: string, context: Context): void {
|
2023-05-15 07:25:44 +02:00
|
|
|
if (element_id) {
|
2023-09-08 23:19:39 +02:00
|
|
|
const $current_element = get_element_by_id(element_id, context);
|
2024-05-24 00:02:38 +02:00
|
|
|
const focus_element = $current_element[0]!.children[0];
|
2023-10-25 02:29:30 +02:00
|
|
|
assert(focus_element instanceof HTMLElement);
|
2023-05-15 07:25:44 +02:00
|
|
|
activate_element(focus_element, context);
|
2024-05-24 00:02:38 +02:00
|
|
|
$(`.${CSS.escape(context.items_list_selector)}`)[0]!.scrollTop = 0;
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
function row_before_focus(context: Context): JQuery {
|
2023-05-15 07:25:44 +02:00
|
|
|
const $focused_row = row_with_focus(context);
|
|
|
|
const $prev_row = $focused_row.prev(`.${CSS.escape(context.row_item_selector)}:visible`);
|
|
|
|
// The draft modal can have two sub-sections. This handles the edge case
|
|
|
|
// when the user moves from the second "Other drafts" section to the first
|
|
|
|
// section which contains drafts from a particular narrow.
|
|
|
|
if (
|
|
|
|
$prev_row.length === 0 &&
|
|
|
|
$focused_row.parent().attr("id") === "other-drafts" &&
|
|
|
|
$("#drafts-from-conversation").is(":visible")
|
|
|
|
) {
|
2023-06-09 21:53:24 +02:00
|
|
|
return $($("#drafts-from-conversation").children(".overlay-message-row:visible").last());
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return $prev_row;
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
function row_after_focus(context: Context): JQuery {
|
2023-05-15 07:25:44 +02:00
|
|
|
const $focused_row = row_with_focus(context);
|
|
|
|
const $next_row = $focused_row.next(`.${CSS.escape(context.row_item_selector)}:visible`);
|
|
|
|
// The draft modal can have two sub-sections. This handles the edge case
|
|
|
|
// when the user moves from the first section (drafts from a particular
|
|
|
|
// narrow) to the second section which contains the rest of the drafts.
|
|
|
|
if (
|
|
|
|
$next_row.length === 0 &&
|
|
|
|
$focused_row.parent().attr("id") === "drafts-from-conversation" &&
|
|
|
|
$("#other-drafts").is(":visible")
|
|
|
|
) {
|
2023-06-09 21:53:24 +02:00
|
|
|
return $("#other-drafts").children(".overlay-message-row:visible").first();
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
return $next_row;
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
function initialize_focus(event_name: string, context: Context): void {
|
2023-05-15 07:25:44 +02:00
|
|
|
// If an item is not focused in modal, then focus the last item
|
|
|
|
// if up_arrow is clicked or the first item if down_arrow is clicked.
|
|
|
|
if (
|
|
|
|
(event_name !== "up_arrow" && event_name !== "down_arrow") ||
|
2023-07-15 09:24:21 +02:00
|
|
|
$(`.${CSS.escape(context.box_item_selector)}:focus`).length > 0
|
2023-05-15 07:25:44 +02:00
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const modal_items_ids = context.get_items_ids();
|
2024-05-30 19:07:59 +02:00
|
|
|
const id = modal_items_ids.at(event_name === "up_arrow" ? -1 : 0);
|
|
|
|
if (id === undefined) {
|
2023-05-15 07:25:44 +02:00
|
|
|
// modal is empty
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-05-30 19:07:59 +02:00
|
|
|
const $element = get_element_by_id(id, context);
|
2024-05-24 00:02:38 +02:00
|
|
|
const focus_element = $element[0]!.children[0];
|
2023-10-25 02:29:30 +02:00
|
|
|
assert(focus_element instanceof HTMLElement);
|
2023-05-15 07:25:44 +02:00
|
|
|
activate_element(focus_element, context);
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
function scroll_to_element($element: JQuery, context: Context): void {
|
2024-05-30 19:07:59 +02:00
|
|
|
if ($element[0] === undefined) {
|
2023-05-15 07:25:44 +02:00
|
|
|
return;
|
|
|
|
}
|
2024-05-30 19:07:59 +02:00
|
|
|
if ($element[0].children[0] === undefined) {
|
2023-05-15 07:25:44 +02:00
|
|
|
return;
|
|
|
|
}
|
2023-10-25 02:29:30 +02:00
|
|
|
assert($element[0].children[0] instanceof HTMLElement);
|
|
|
|
activate_element($element[0].children[0], context);
|
2023-05-15 07:25:44 +02:00
|
|
|
|
|
|
|
const $items_list = $(`.${CSS.escape(context.items_list_selector)}`);
|
|
|
|
const $items_container = $(`.${CSS.escape(context.items_container_selector)}`);
|
|
|
|
const $box_item = $(`.${CSS.escape(context.box_item_selector)}`);
|
|
|
|
|
|
|
|
// If focused element is first, scroll to the top.
|
2024-05-24 00:02:38 +02:00
|
|
|
if ($box_item.first()[0]!.parentElement === $element[0]) {
|
|
|
|
$items_list[0]!.scrollTop = 0;
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// If focused element is last, scroll to the bottom.
|
2024-05-24 00:02:38 +02:00
|
|
|
if ($box_item.last()[0]!.parentElement === $element[0]) {
|
|
|
|
$items_list[0]!.scrollTop = $items_list[0]!.scrollHeight - ($items_list.height() ?? 0);
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// If focused element is cut off from the top, scroll up halfway in modal.
|
|
|
|
if ($element.position().top < 55) {
|
|
|
|
// 55 is the minimum distance from the top that will require extra scrolling.
|
2024-05-24 00:02:38 +02:00
|
|
|
$items_list[0]!.scrollTop -= $items_list[0]!.clientHeight / 2;
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// If focused element is cut off from the bottom, scroll down halfway in modal.
|
|
|
|
const dist_from_top = $element.position().top;
|
|
|
|
const total_dist = dist_from_top + $element[0].clientHeight;
|
2024-05-24 00:02:38 +02:00
|
|
|
const dist_from_bottom = $items_container[0]!.clientHeight - total_dist;
|
2023-05-15 07:25:44 +02:00
|
|
|
if (dist_from_bottom < -4) {
|
|
|
|
// -4 is the min dist from the bottom that will require extra scrolling.
|
2024-05-24 00:02:38 +02:00
|
|
|
$items_list[0]!.scrollTop += $items_list[0]!.clientHeight / 2;
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-10 11:30:52 +02:00
|
|
|
function get_element_by_id(id: number | string, context: Context): JQuery {
|
2023-09-08 23:11:56 +02:00
|
|
|
return $(`[${CSS.escape(context.id_attribute_name)}='${CSS.escape(id.toString())}']`);
|
2023-05-15 07:25:44 +02:00
|
|
|
}
|