inbox: Simplify keyboard navigation.

This makes use of `data-col-index` attribute on the elements
to know which column to focus based on user input.
This commit is contained in:
Aman Agrawal
2025-07-17 15:52:44 +05:30
committed by Tim Abbott
parent 35c0de27fe
commit 2ba72101a2
4 changed files with 45 additions and 100 deletions

View File

@@ -195,15 +195,14 @@ let filters_dropdown_widget;
let channel_view_topic_widget: InboxTopicListWidget | undefined; let channel_view_topic_widget: InboxTopicListWidget | undefined;
const COLUMNS = { const COLUMNS = {
COLLAPSE_BUTTON: 0, FULL_ROW: 0,
RECIPIENT: 1, UNREAD_COUNT: 1,
UNREAD_COUNT: 2, TOPIC_VISIBILITY: 2,
TOPIC_VISIBILITY: 3, ACTION_MENU: 3,
ACTION_MENU: 4,
}; };
const DEFAULT_ROW_FOCUS = 0; const DEFAULT_ROW_FOCUS = 0;
const DEFAULT_COL_FOCUS = COLUMNS.COLLAPSE_BUTTON; const DEFAULT_COL_FOCUS = COLUMNS.FULL_ROW;
const channel_view_navigation_state = { const channel_view_navigation_state = {
channel_id: -1, channel_id: -1,
@@ -298,13 +297,6 @@ export function show(filter?: Filter): void {
assert(hide_other_views_callback !== undefined); assert(hide_other_views_callback !== undefined);
hide_other_views_callback(); hide_other_views_callback();
const was_inbox_already_visible = inbox_util.is_visible(); const was_inbox_already_visible = inbox_util.is_visible();
// Avoid setting col_focus to recipient when moving to inbox from other narrows.
// We prefer to focus entire row instead of stream name for inbox-header.
// Since inbox-row doesn't has a collapse button, focus on COLUMNS.COLLAPSE_BUTTON
// is same as focus on COLUMNS.RECIPIENT. See `set_list_focus` for details.
if (!inbox_util.is_visible() && col_focus === COLUMNS.RECIPIENT) {
col_focus = COLUMNS.COLLAPSE_BUTTON;
}
// Check if we are already narrowed to the same channel view. // Check if we are already narrowed to the same channel view.
const was_inbox_channel_view = inbox_util.is_channel_view(); const was_inbox_channel_view = inbox_util.is_channel_view();
@@ -1489,10 +1481,6 @@ function set_list_focus(input_key?: string): void {
// This function is used for both revive_current_focus and // This function is used for both revive_current_focus and
// setting focus after we modify col_focus and row_focus as per // setting focus after we modify col_focus and row_focus as per
// hotkey pressed by user. // hotkey pressed by user.
//
// When to focus on entire row?
// For `inbox-header`, when focus on COLUMNS.COLLAPSE_BUTTON
// For `inbox-row`, when focus on COLUMNS.COLLAPSE_BUTTON (fake) or COLUMNS.RECIPIENT
const $all_rows = get_all_rows(); const $all_rows = get_all_rows();
const max_row_focus = $all_rows.length - 1; const max_row_focus = $all_rows.length - 1;
@@ -1510,82 +1498,48 @@ function set_list_focus(input_key?: string): void {
const row_to_focus = $all_rows.get(row_focus); const row_to_focus = $all_rows.get(row_focus);
assert(row_to_focus !== undefined); assert(row_to_focus !== undefined);
const $row_to_focus = $(row_to_focus); const $row_to_focus = $(row_to_focus);
// This includes a fake collapse button for `inbox-row` and a fake topic visibility
// button for `inbox-header`. The fake buttons help simplify code here and
// `$($cols_to_focus[col_focus]).trigger("focus");` at the end of this function.
const cols_to_focus = [row_to_focus, ...$row_to_focus.find("[tabindex=0]")];
const total_cols = cols_to_focus.length;
current_focus_id = $row_to_focus.attr("id"); current_focus_id = $row_to_focus.attr("id");
const is_header_row = is_row_a_header($row_to_focus); const is_header_row = is_row_a_header($row_to_focus);
update_closed_compose_text($row_to_focus, is_header_row); update_closed_compose_text($row_to_focus, is_header_row);
if (col_focus > COLUMNS.ACTION_MENU) {
// Loop through columns. col_focus = COLUMNS.FULL_ROW;
if (col_focus > total_cols - 1) { $row_to_focus.trigger("focus");
col_focus = 0; return;
} else if (col_focus < 0) {
col_focus = total_cols - 1;
} }
// Since header rows always have a collapse button, other rows have one less element to focus. const cols_to_focus = [row_to_focus, ...$row_to_focus.find("[tabindex=0]")];
if (col_focus === COLUMNS.COLLAPSE_BUTTON) { // We assumes that the last column has the highest index is the rightmost column.
if (!is_header_row && input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) { const last_col_index = Number($(cols_to_focus.at(-1)!).attr("data-col-index")!);
// In `inbox-row` user pressed left on COLUMNS.RECIPIENT, so
// go to the last column. if (col_focus < 0) {
col_focus = total_cols - 1; col_focus = last_col_index;
} $(cols_to_focus.at(-1)!).trigger("focus");
} else if (!is_header_row && col_focus === COLUMNS.RECIPIENT) { return;
if (input_key !== undefined && RIGHT_NAVIGATION_KEYS.includes(input_key)) { }
// In `inbox-row` user pressed right on COLUMNS.COLLAPSE_BUTTON.
// Since `inbox-row` has no collapse button, user wants to go // This assumes that the last column has the highest index.
// to the unread count button. if (col_focus > last_col_index) {
col_focus = COLUMNS.UNREAD_COUNT; col_focus = 0;
} else if (input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) { $(cols_to_focus[0]!).trigger("focus");
// In `inbox-row` user pressed left on COLUMNS.UNREAD_COUNT, return;
// we move focus to COLUMNS.COLLAPSE_BUTTON so that moving }
// up or down to `inbox-header` keeps the entire row focused for the
// `inbox-header` too. // Find the closest column to focus based on the input key.
col_focus = COLUMNS.COLLAPSE_BUTTON; let equal = (a: number, b: number): boolean => b >= a;
} else { if (input_key && LEFT_NAVIGATION_KEYS.includes(input_key)) {
// up / down arrow equal = (a: number, b: number): boolean => a >= b;
// For `inbox-row`, we focus entire row for COLUMNS.RECIPIENT. cols_to_focus.reverse();
$row_to_focus.trigger("focus"); }
for (const col of cols_to_focus) {
const col_index = Number($(col).attr("data-col-index"));
if (equal(col_focus, col_index)) {
col_focus = col_index;
$(col).trigger("focus");
return; return;
} }
} else if (is_header_row && col_focus === COLUMNS.TOPIC_VISIBILITY) {
// `inbox-header` doesn't have a topic visibility indicator, so focus on
// button around it instead.
if (input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) {
col_focus = COLUMNS.UNREAD_COUNT;
} else {
col_focus = COLUMNS.ACTION_MENU;
}
} }
// Focus on appropriate button if the is row has no unreads but
// `col_focus` is set to `UNREAD_COUNT` column.
if (!is_header_row && col_focus === COLUMNS.UNREAD_COUNT) {
const row_has_unreads = cols_to_focus.some((col) =>
col.classList.contains("unread-count-focus-outline"),
);
if (!row_has_unreads) {
if (input_key !== undefined && RIGHT_NAVIGATION_KEYS.includes(input_key)) {
col_focus = COLUMNS.TOPIC_VISIBILITY;
} else if (input_key !== undefined && LEFT_NAVIGATION_KEYS.includes(input_key)) {
// Focus on entire row.
col_focus = COLUMNS.COLLAPSE_BUTTON;
} else {
// up / down arrow
// Focus on the entire row without changing `col_focus` so that
// we can focus on unread count button if the next row has one.
$row_to_focus.trigger("focus");
return;
}
}
}
const col_to_focus = cols_to_focus[col_focus];
assert(col_to_focus !== undefined);
$(col_to_focus).trigger("focus");
} }
function focus_filters_dropdown(): void { function focus_filters_dropdown(): void {
@@ -2019,10 +1973,6 @@ function get_focus_class_for_header(): string {
let focus_class = ".collapsible-button"; let focus_class = ".collapsible-button";
switch (col_focus) { switch (col_focus) {
case COLUMNS.RECIPIENT: {
focus_class = ".inbox-header-name a";
break;
}
case COLUMNS.UNREAD_COUNT: { case COLUMNS.UNREAD_COUNT: {
focus_class = ".unread_count"; focus_class = ".unread_count";
break; break;
@@ -2181,7 +2131,7 @@ export function initialize({hide_other_views}: {hide_other_views: () => void}):
const $elt = $(this); const $elt = $(this);
const container_id = $elt.parents(".inbox-header").attr("id"); const container_id = $elt.parents(".inbox-header").attr("id");
assert(container_id !== undefined); assert(container_id !== undefined);
col_focus = COLUMNS.COLLAPSE_BUTTON; col_focus = COLUMNS.FULL_ROW;
focus_clicked_list_element($elt); focus_clicked_list_element($elt);
collapse_or_expand(container_id); collapse_or_expand(container_id);
e.stopPropagation(); e.stopPropagation();
@@ -2209,12 +2159,11 @@ export function initialize({hide_other_views}: {hide_other_views: () => void}):
let $elt = $(this); let $elt = $(this);
const href = $elt.find("a").attr("href"); const href = $elt.find("a").attr("href");
col_focus = COLUMNS.FULL_ROW;
if (href !== undefined) { if (href !== undefined) {
col_focus = COLUMNS.RECIPIENT;
window.location.href = href; window.location.href = href;
} else { } else {
$elt = $elt.closest(".inbox-header"); $elt = $elt.closest(".inbox-header");
col_focus = COLUMNS.COLLAPSE_BUTTON;
collapse_or_expand($elt.attr("id")!); collapse_or_expand($elt.attr("id")!);
} }
focus_clicked_list_element($elt); focus_clicked_list_element($elt);

View File

@@ -1,4 +1,5 @@
<div id="{{ header_id }}" tabindex="0" class="inbox-header inbox-folder {{#unless is_header_visible}}hidden_by_filters{{/unless}} {{#if is_collapsed}}inbox-collapsed-state{{/if}}"> {{!-- col-index `0` is COLUMNS.FULL_ROW --}}
<div id="{{ header_id }}" tabindex="0" class="inbox-header inbox-folder {{#unless is_header_visible}}hidden_by_filters{{/unless}} {{#if is_collapsed}}inbox-collapsed-state{{/if}}" data-col-index="0">
<div class="inbox-focus-border"> <div class="inbox-focus-border">
<div class="inbox-left-part-wrapper"> <div class="inbox-left-part-wrapper">
<div class="inbox-left-part"> <div class="inbox-left-part">

View File

@@ -1,7 +1,7 @@
{{#if is_stream}} {{#if is_stream}}
{{> inbox_stream_header_row .}} {{> inbox_stream_header_row .}}
{{else}} {{else}}
<div id="inbox-row-conversation-{{conversation_key}}" class="inbox-row {{#if is_hidden}}hidden_by_filters{{/if}}" tabindex="0" data-col-index="{{ column_indexes.RECIPIENT }}"> <div id="inbox-row-conversation-{{conversation_key}}" class="inbox-row {{#if is_hidden}}hidden_by_filters{{/if}}" tabindex="0" data-col-index="{{ column_indexes.FULL_ROW }}">
<div class="inbox-focus-border"> <div class="inbox-focus-border">
<div class="inbox-left-part-wrapper"> <div class="inbox-left-part-wrapper">
<div class="inbox-left-part"> <div class="inbox-left-part">
@@ -18,7 +18,6 @@
<span class="recipients_name">{{{rendered_dm_with}}}</span> <span class="recipients_name">{{{rendered_dm_with}}}</span>
</span> </span>
</a> </a>
<div class="hide fake-collapse-button" tabindex="0" data-col-index="{{ column_indexes.COLLAPSE_BUTTON }}"></div>
<span class="unread_mention_info tippy-zulip-tooltip <span class="unread_mention_info tippy-zulip-tooltip
{{#unless has_unread_mention}}hidden{{/unless}}" {{#unless has_unread_mention}}hidden{{/unless}}"
data-tippy-content="{{t 'You have unread mentions' }}">@</span> data-tippy-content="{{t 'You have unread mentions' }}">@</span>
@@ -31,7 +30,6 @@
<div class="inbox-topic-name"> <div class="inbox-topic-name">
<a tabindex="-1" href="{{topic_url}}" {{#if is_empty_string_topic}}class="empty-topic-display"{{/if}}>{{topic_display_name}}</a> <a tabindex="-1" href="{{topic_url}}" {{#if is_empty_string_topic}}class="empty-topic-display"{{/if}}>{{topic_display_name}}</a>
</div> </div>
<div class="hide fake-collapse-button" tabindex="0" data-col-index="{{ column_indexes.COLLAPSE_BUTTON }}"></div>
<span class="unread_mention_info tippy-zulip-tooltip <span class="unread_mention_info tippy-zulip-tooltip
{{#unless mention_in_unread}}hidden{{/unless}}" {{#unless mention_in_unread}}hidden{{/unless}}"
data-tippy-content="{{t 'You have unread mentions'}}">@</span> data-tippy-content="{{t 'You have unread mentions'}}">@</span>
@@ -43,8 +41,6 @@
{{unread_count}} {{unread_count}}
</span> </span>
</div> </div>
{{else}}
<div class="hide fake-unread-count" tabindex="0" data-col-index="{{ column_indexes.UNREAD_COUNT }}"></div>
{{/if}} {{/if}}
{{/if}} {{/if}}
</div> </div>

View File

@@ -1,4 +1,4 @@
<div id="inbox-stream-header-{{stream_id}}" class="inbox-header {{#if is_hidden}}hidden_by_filters{{/if}} {{#if is_collapsed}}inbox-collapsed-state{{/if}}" data-col-index="{{ column_indexes.COLLAPSE_BUTTON }}" tabindex="0" data-stream-id="{{stream_id}}" style="background: {{stream_header_color}};"> <div id="inbox-stream-header-{{stream_id}}" class="inbox-header {{#if is_hidden}}hidden_by_filters{{/if}} {{#if is_collapsed}}inbox-collapsed-state{{/if}}" data-col-index="{{ column_indexes.FULL_ROW }}" tabindex="0" data-stream-id="{{stream_id}}" style="background: {{stream_header_color}};">
<div class="inbox-focus-border"> <div class="inbox-focus-border">
<div class="inbox-left-part-wrapper"> <div class="inbox-left-part-wrapper">
<div class="inbox-left-part"> <div class="inbox-left-part">
@@ -22,7 +22,6 @@
data-stream-id="{{stream_id}}" data-tippy-content="{{t 'Mark as read' }}" data-stream-id="{{stream_id}}" data-tippy-content="{{t 'Mark as read' }}"
aria-label="{{t 'Mark as read' }}">{{unread_count}}</span> aria-label="{{t 'Mark as read' }}">{{unread_count}}</span>
</div> </div>
<div class="topic-visibility-indicator invisible" tabindex="0"></div>
</div> </div>
</div> </div>
<div class="inbox-right-part-wrapper"> <div class="inbox-right-part-wrapper">