mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	drafts: Refactor keyboard functions for reusability.
This is a preparatory commit to implement keyboard navigation in the message scheduling modal. The main goal is to make the `modals_handle_events` function reusable. To achieve this, we have extracted all the context-related variables and replaced all mentions of "draft" with the more neutral term "item". The `modals_handle_events` function now also has a context parameter, which contains all the necessary methods and properties to work in different modal contexts.
This commit is contained in:
		
				
					committed by
					
						
						Tim Abbott
					
				
			
			
				
	
			
			
			
						parent
						
							0117d751c2
						
					
				
				
					commit
					35c23d0269
				
			@@ -415,14 +415,14 @@ export function format_draft(draft) {
 | 
			
		||||
    return formatted;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function row_with_focus() {
 | 
			
		||||
    const focused_draft = $(".draft-info-box:focus")[0];
 | 
			
		||||
    return $(focused_draft).parent(".draft-row");
 | 
			
		||||
function row_with_focus(context) {
 | 
			
		||||
    const focused_item = $(`.${CSS.escape(context.box_item_selector)}:focus`)[0];
 | 
			
		||||
    return $(focused_item).parent(`.${CSS.escape(context.row_item_selector)}`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function row_before_focus() {
 | 
			
		||||
    const $focused_row = row_with_focus();
 | 
			
		||||
    const $prev_row = $focused_row.prev(".draft-row:visible");
 | 
			
		||||
function row_before_focus(context) {
 | 
			
		||||
    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.
 | 
			
		||||
@@ -437,9 +437,9 @@ function row_before_focus() {
 | 
			
		||||
    return $prev_row;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function row_after_focus() {
 | 
			
		||||
    const $focused_row = row_with_focus();
 | 
			
		||||
    const $next_row = $focused_row.next(".draft-row:visible");
 | 
			
		||||
function row_after_focus(context) {
 | 
			
		||||
    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.
 | 
			
		||||
@@ -556,6 +556,44 @@ function filter_drafts_by_compose_box_and_recipient(drafts) {
 | 
			
		||||
    return _.pick(drafts, narrow_drafts_ids);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const keyboard_handling_context = {
 | 
			
		||||
    get_items_ids() {
 | 
			
		||||
        const draft_arrow = draft_model.get();
 | 
			
		||||
        return Object.getOwnPropertyNames(draft_arrow);
 | 
			
		||||
    },
 | 
			
		||||
    on_enter() {
 | 
			
		||||
        // This handles when pressing Enter while looking at drafts.
 | 
			
		||||
        // It restores draft that is focused.
 | 
			
		||||
        const draft_id_arrow = this.get_items_ids();
 | 
			
		||||
        const focused_draft_id = get_focused_element_id(this);
 | 
			
		||||
        if (Object.hasOwn(document.activeElement.parentElement.dataset, "draftId")) {
 | 
			
		||||
            restore_draft(focused_draft_id);
 | 
			
		||||
        } else {
 | 
			
		||||
            const first_draft = draft_id_arrow.at(-1);
 | 
			
		||||
            restore_draft(first_draft);
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    on_delete() {
 | 
			
		||||
        // Allows user to delete drafts with Backspace
 | 
			
		||||
        const focused_element_id = get_focused_element_id(this);
 | 
			
		||||
        if (focused_element_id === undefined) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const $focused_row = row_with_focus(this);
 | 
			
		||||
        focus_on_sibling_element(this);
 | 
			
		||||
        remove_draft($focused_row);
 | 
			
		||||
    },
 | 
			
		||||
    items_container_selector: "drafts-container",
 | 
			
		||||
    items_list_selector: "drafts-list",
 | 
			
		||||
    row_item_selector: "draft-row",
 | 
			
		||||
    box_item_selector: "draft-info-box",
 | 
			
		||||
    id_attribute_name: "data-draft-id",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function handle_keyboard_events(e, event_key) {
 | 
			
		||||
    modals_handle_events(e, event_key, keyboard_handling_context);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function launch() {
 | 
			
		||||
    function format_drafts(data) {
 | 
			
		||||
        for (const [id, draft] of Object.entries(data)) {
 | 
			
		||||
@@ -651,138 +689,145 @@ export function launch() {
 | 
			
		||||
    $("#draft_overlay").css("opacity");
 | 
			
		||||
 | 
			
		||||
    open_overlay();
 | 
			
		||||
    set_initial_element([...formatted_narrow_drafts, ...formatted_other_drafts]);
 | 
			
		||||
    const first_element_id = [...formatted_narrow_drafts, ...formatted_other_drafts][0]?.draft_id;
 | 
			
		||||
    set_initial_element(first_element_id, keyboard_handling_context);
 | 
			
		||||
    setup_event_handlers();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function activate_element(elem) {
 | 
			
		||||
    $(".draft-info-box").removeClass("active");
 | 
			
		||||
function activate_element(elem, context) {
 | 
			
		||||
    $(`.${CSS.escape(context.box_item_selector)}`).removeClass("active");
 | 
			
		||||
    $(elem).expectOne().addClass("active");
 | 
			
		||||
    elem.focus();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function drafts_initialize_focus(event_name) {
 | 
			
		||||
    // If a draft is not focused in draft modal, then focus the last draft
 | 
			
		||||
    // if up_arrow is clicked or the first draft if down_arrow is clicked.
 | 
			
		||||
function initialize_focus(event_name, context) {
 | 
			
		||||
    // 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") ||
 | 
			
		||||
        $(".draft-info-box:focus")[0]
 | 
			
		||||
        $(`.${CSS.escape(context.box_item_selector)}:focus`)[0]
 | 
			
		||||
    ) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const draft_arrow = draft_model.get();
 | 
			
		||||
    const draft_id_arrow = Object.getOwnPropertyNames(draft_arrow);
 | 
			
		||||
    if (draft_id_arrow.length === 0) {
 | 
			
		||||
        // empty drafts modal
 | 
			
		||||
    const modal_items_ids = context.get_items_ids();
 | 
			
		||||
    if (modal_items_ids.length === 0) {
 | 
			
		||||
        // modal is empty
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let draft_element;
 | 
			
		||||
    if (event_name === "up_arrow") {
 | 
			
		||||
        draft_element = document.querySelectorAll(
 | 
			
		||||
            '[data-draft-id="' + draft_id_arrow.at(-1) + '"]',
 | 
			
		||||
        );
 | 
			
		||||
    } else if (event_name === "down_arrow") {
 | 
			
		||||
        draft_element = document.querySelectorAll('[data-draft-id="' + draft_id_arrow[0] + '"]');
 | 
			
		||||
    }
 | 
			
		||||
    const focus_element = draft_element[0].children[0];
 | 
			
		||||
    let element;
 | 
			
		||||
 | 
			
		||||
    activate_element(focus_element);
 | 
			
		||||
    function get_last_element() {
 | 
			
		||||
        const last_id = modal_items_ids.at(-1);
 | 
			
		||||
        return get_element_by_id(last_id, context);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function get_first_element() {
 | 
			
		||||
        const first_id = modal_items_ids[0];
 | 
			
		||||
        return get_element_by_id(first_id, context);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (event_name === "up_arrow") {
 | 
			
		||||
        element = get_last_element();
 | 
			
		||||
    } else if (event_name === "down_arrow") {
 | 
			
		||||
        element = get_first_element();
 | 
			
		||||
    }
 | 
			
		||||
    const focus_element = element[0].children[0];
 | 
			
		||||
    activate_element(focus_element, context);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function drafts_scroll($next_focus_draft_row) {
 | 
			
		||||
    if ($next_focus_draft_row[0] === undefined) {
 | 
			
		||||
function scroll_to_element($element, context) {
 | 
			
		||||
    if ($element[0] === undefined) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    if ($next_focus_draft_row[0].children[0] === undefined) {
 | 
			
		||||
    if ($element[0].children[0] === undefined) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    activate_element($next_focus_draft_row[0].children[0]);
 | 
			
		||||
    activate_element($element[0].children[0], context);
 | 
			
		||||
 | 
			
		||||
    // If focused draft is first draft, scroll to the top.
 | 
			
		||||
    if ($(".draft-info-box").first()[0].parentElement === $next_focus_draft_row[0]) {
 | 
			
		||||
        $(".drafts-list")[0].scrollTop = 0;
 | 
			
		||||
    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.
 | 
			
		||||
    if ($box_item.first()[0].parentElement === $element[0]) {
 | 
			
		||||
        $items_list[0].scrollTop = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If focused draft is the last draft, scroll to the bottom.
 | 
			
		||||
    if ($(".draft-info-box").last()[0].parentElement === $next_focus_draft_row[0]) {
 | 
			
		||||
        $(".drafts-list")[0].scrollTop =
 | 
			
		||||
            $(".drafts-list")[0].scrollHeight - $(".drafts-list").height();
 | 
			
		||||
    // If focused element is last, scroll to the bottom.
 | 
			
		||||
    if ($box_item.last()[0].parentElement === $element[0]) {
 | 
			
		||||
        $items_list[0].scrollTop = $items_list[0].scrollHeight - $items_list.height();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If focused draft is cut off from the top, scroll up halfway in draft modal.
 | 
			
		||||
    if ($next_focus_draft_row.position().top < 55) {
 | 
			
		||||
    // 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.
 | 
			
		||||
        $(".drafts-list")[0].scrollTop -= $(".drafts-list")[0].clientHeight / 2;
 | 
			
		||||
        $items_list[0].scrollTop -= $items_list[0].clientHeight / 2;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If focused draft is cut off from the bottom, scroll down halfway in draft modal.
 | 
			
		||||
    const dist_from_top = $next_focus_draft_row.position().top;
 | 
			
		||||
    const total_dist = dist_from_top + $next_focus_draft_row[0].clientHeight;
 | 
			
		||||
    const dist_from_bottom = $(".drafts-container")[0].clientHeight - total_dist;
 | 
			
		||||
    // 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;
 | 
			
		||||
    const dist_from_bottom = $items_container[0].clientHeight - total_dist;
 | 
			
		||||
    if (dist_from_bottom < -4) {
 | 
			
		||||
        // -4 is the min dist from the bottom that will require extra scrolling.
 | 
			
		||||
        $(".drafts-list")[0].scrollTop += $(".drafts-list")[0].clientHeight / 2;
 | 
			
		||||
        $items_list[0].scrollTop += $items_list[0].clientHeight / 2;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function drafts_handle_events(e, event_key) {
 | 
			
		||||
    const draft_arrow = draft_model.get();
 | 
			
		||||
    const draft_id_arrow = Object.getOwnPropertyNames(draft_arrow);
 | 
			
		||||
    drafts_initialize_focus(event_key);
 | 
			
		||||
function get_element_by_id(id, context) {
 | 
			
		||||
    return $(`[${context.id_attribute_name}='${id}']`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    // This detects up arrow key presses when the draft overlay
 | 
			
		||||
    // is open and scrolls through the drafts.
 | 
			
		||||
    if (event_key === "up_arrow" || event_key === "vim_up") {
 | 
			
		||||
        drafts_scroll(row_before_focus());
 | 
			
		||||
function get_focused_element_id(context) {
 | 
			
		||||
    return row_with_focus(context).attr(context.id_attribute_name);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function focus_on_sibling_element(context) {
 | 
			
		||||
    const $next_row = row_after_focus(context);
 | 
			
		||||
    const $prev_row = row_before_focus(context);
 | 
			
		||||
    let elem_to_be_focused_id;
 | 
			
		||||
 | 
			
		||||
    // Try to get the next item in the list and 'focus' on it.
 | 
			
		||||
    // Use previous item as a fallback.
 | 
			
		||||
    if ($next_row[0] !== undefined) {
 | 
			
		||||
        elem_to_be_focused_id = $next_row.attr(context.id_attribute_name);
 | 
			
		||||
    } else if ($prev_row[0] !== undefined) {
 | 
			
		||||
        elem_to_be_focused_id = $prev_row.attr(context.id_attribute_name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // This detects down arrow key presses when the draft overlay
 | 
			
		||||
    // is open and scrolls through the drafts.
 | 
			
		||||
    if (event_key === "down_arrow" || event_key === "vim_down") {
 | 
			
		||||
        drafts_scroll(row_after_focus());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const focused_draft_id = row_with_focus().data("draft-id");
 | 
			
		||||
    // Allows user to delete drafts with Backspace
 | 
			
		||||
    if ((event_key === "backspace" || event_key === "delete") && focused_draft_id !== undefined) {
 | 
			
		||||
        const $draft_row = row_with_focus();
 | 
			
		||||
        const $next_draft_row = row_after_focus();
 | 
			
		||||
        const $prev_draft_row = row_before_focus();
 | 
			
		||||
        let draft_to_be_focused_id;
 | 
			
		||||
 | 
			
		||||
        // Try to get the next draft in the list and 'focus' it
 | 
			
		||||
        // Use previous draft as a fallback
 | 
			
		||||
        if ($next_draft_row[0] !== undefined) {
 | 
			
		||||
            draft_to_be_focused_id = $next_draft_row.data("draft-id");
 | 
			
		||||
        } else if ($prev_draft_row[0] !== undefined) {
 | 
			
		||||
            draft_to_be_focused_id = $prev_draft_row.data("draft-id");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const new_focus_element = document.querySelectorAll(
 | 
			
		||||
            '[data-draft-id="' + draft_to_be_focused_id + '"]',
 | 
			
		||||
        );
 | 
			
		||||
    const new_focus_element = get_element_by_id(elem_to_be_focused_id, context);
 | 
			
		||||
    if (new_focus_element[0] !== undefined) {
 | 
			
		||||
            activate_element(new_focus_element[0].children[0]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        remove_draft($draft_row);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // This handles when pressing Enter while looking at drafts.
 | 
			
		||||
    // It restores draft that is focused.
 | 
			
		||||
    if (event_key === "enter") {
 | 
			
		||||
        if (Object.hasOwn(document.activeElement.parentElement.dataset, "draftId")) {
 | 
			
		||||
            restore_draft(focused_draft_id);
 | 
			
		||||
        } else {
 | 
			
		||||
            const first_draft = draft_id_arrow.at(-1);
 | 
			
		||||
            restore_draft(first_draft);
 | 
			
		||||
        }
 | 
			
		||||
        activate_element(new_focus_element[0].children[0], context);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function open_overlay() {
 | 
			
		||||
function modals_handle_events(e, event_key, context) {
 | 
			
		||||
    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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function open_overlay() {
 | 
			
		||||
    overlays.open_overlay({
 | 
			
		||||
        name: "drafts",
 | 
			
		||||
        $overlay: $("#draft_overlay"),
 | 
			
		||||
@@ -792,14 +837,12 @@ export function open_overlay() {
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function set_initial_element(drafts) {
 | 
			
		||||
    if (drafts.length > 0) {
 | 
			
		||||
        const curr_draft_id = drafts[0].draft_id;
 | 
			
		||||
        const selector = '[data-draft-id="' + curr_draft_id + '"]';
 | 
			
		||||
        const curr_draft_element = document.querySelectorAll(selector);
 | 
			
		||||
        const focus_element = curr_draft_element[0].children[0];
 | 
			
		||||
        activate_element(focus_element);
 | 
			
		||||
        $(".drafts-list")[0].scrollTop = 0;
 | 
			
		||||
function set_initial_element(element_id, context) {
 | 
			
		||||
    if (element_id) {
 | 
			
		||||
        const current_element = get_element_by_id(element_id, context);
 | 
			
		||||
        const focus_element = current_element[0].children[0];
 | 
			
		||||
        activate_element(focus_element, keyboard_handling_context);
 | 
			
		||||
        $(`.${CSS.escape(context.items_list_selector)}`)[0].scrollTop = 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -817,6 +860,6 @@ export function initialize() {
 | 
			
		||||
    set_count(Object.keys(draft_model.get()).length);
 | 
			
		||||
 | 
			
		||||
    $("body").on("focus", ".draft-info-box", (e) => {
 | 
			
		||||
        activate_element(e.target);
 | 
			
		||||
        activate_element(e.target, keyboard_handling_context);
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -443,7 +443,7 @@ export function process_enter_key(e) {
 | 
			
		||||
    // This handles when pressing Enter while looking at drafts.
 | 
			
		||||
    // It restores draft that is focused.
 | 
			
		||||
    if (overlays.drafts_open()) {
 | 
			
		||||
        drafts.drafts_handle_events(e, "enter");
 | 
			
		||||
        drafts.handle_keyboard_events(e, "enter");
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -650,7 +650,7 @@ export function process_hotkey(e, hotkey) {
 | 
			
		||||
        case "backspace":
 | 
			
		||||
        case "delete":
 | 
			
		||||
            if (overlays.drafts_open()) {
 | 
			
		||||
                drafts.drafts_handle_events(e, event_name);
 | 
			
		||||
                drafts.handle_keyboard_events(e, event_name);
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -524,8 +524,8 @@ run_test("motion_keys", () => {
 | 
			
		||||
 | 
			
		||||
    delete overlays.is_active;
 | 
			
		||||
    overlays.drafts_open = () => true;
 | 
			
		||||
    assert_mapping("up_arrow", drafts, "drafts_handle_events");
 | 
			
		||||
    assert_mapping("down_arrow", drafts, "drafts_handle_events");
 | 
			
		||||
    assert_mapping("up_arrow", drafts, "handle_keyboard_events");
 | 
			
		||||
    assert_mapping("down_arrow", drafts, "handle_keyboard_events");
 | 
			
		||||
    delete overlays.is_active;
 | 
			
		||||
    delete overlays.drafts_open;
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user