Skip to content

Commit

Permalink
Additional keyboard navigation support
Browse files Browse the repository at this point in the history
* When a bulk action customization table checkbox is focused:
  * 'Enter' checks/unchecks the focused checkbox.
  * Up/Down arrow moves to the next checkbox.
    * Ctrl+Up/Down moves to the first/last checkbox.
    * Shift+Up/Down selects the currently focused checkbox and the
      newly focused checkbox.
  * 's' selects/deselects the currently focused checkbox row.
  * 'c' checks/unchecks the currently selected rows.
    * Shift+C checks/unchecks all rows.

* When a non-movie/episode row is selected:
  * In addition to "main" rows being navigable with up/down arrows, arrows can
    now also navigate header rows (bulk actions, 'go back', section options).
  * Ctrl+Shift+Up/Down goes to the first/last navigable item.
  * If in a "main" row (show/season):
    * Ctrl+Up/Down goes to the first/last main row.
    * If the first main row is already focused, Ctrl+Up acts like Ctrl+Shift+Up.
  * If a non-main item is selected:
    * Ctrl+Up goes to the first non-main item available.
    * Ctrl+Down goes to the first main row.
* When a movie/episode row is selected:
  * [Ctrl+]Left/Right shows/hides marker tables, as before.
  * Ctrl+Up/Down goes to the first/last main row, as before.
  * Ctrl+Shift+Up/Down goes to the first/last navigable item.
  * Up/Down goes to the next available navigable item, whether it's a marker
    table input, bulk action, or another movie/episode row.
  * Shift+Up/Down goes to the next row, regardless of visible marker tables.
  * Alt+Up/Down goes to the next visible marker table (if any).
* When a marker table input is selected:
  * Left/Right goes to the next input in the row, if any.
  * Ctrl+Left/Right goes to the first/last input in the row.
  * Up/Down goes to the next marker row, or the next main row if we're at the
    top/bottom row of the marker table.
  * Ctrl+Up/Down goes to the first/last row of the marker table.
    * If a <select> is currently focused, moves to the next marker row.
  * Shift+Up/Down goes to the next marker table, if any.
  * Alt+Up/Down goes to the next main row, if any.
  * Ctrl+Alt+Shift+Up/Down goes to the first/last visible marker table.

* For all of the above, ensure any required scrolling is smooth.
* Standard tab/shift+tab navigation works, as before.

Tangential changes:
* For any non-standard attributes, ensure they follow the "data-XYZ" pattern,
  and make them constants for easy reference/refactoring.
* Don't try to get sectionId "-1" when it's selected from the dropdown.
* Set focus to the first main item after 'clicking here to load all items'.
  • Loading branch information
danrahn committed Apr 15, 2024
1 parent f8ecb93 commit 24b1b60
Show file tree
Hide file tree
Showing 24 changed files with 1,343 additions and 136 deletions.
5 changes: 3 additions & 2 deletions Client/Script/AnimationHelpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ConsoleLog, ContextualLog } from '/Shared/ConsoleLog.js';
import { $$ } from './Common.js';
import { Attributes } from './DataAttributes.js';

const Log = new ContextualLog('Animate');

Expand Down Expand Up @@ -106,7 +107,7 @@ export function animateOpacity(ele, start, end, options, callback) {
* @param {string} prop */
function checkProp(ele, prop) {
const sav = ele.style[prop];
prop = `data-${prop}-reset`;
prop = Attributes.PropReset(prop);
const isAnimating = ele.getAttribute(prop);
if (isAnimating !== null) {
return isAnimating;
Expand All @@ -121,7 +122,7 @@ function checkProp(ele, prop) {
* @param {HTMLElement} ele
* @param {string} prop */
function removeProp(ele, prop) {
ele.removeAttribute(`data-${prop}-reset`);
ele.removeAttribute(Attributes.PropReset(prop));
}

/**
Expand Down
144 changes: 127 additions & 17 deletions Client/Script/BulkActionCommon.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $, $$, appendChildren, buildNode } from './Common.js';
import { $, $$, appendChildren, buildNode, clickOnEnterCallback, scrollAndFocus } from './Common.js';
import { ContextualLog } from '/Shared/ConsoleLog.js';

import { customCheckbox } from './CommonUI.js';
Expand Down Expand Up @@ -59,7 +59,7 @@ class BulkActionRow {
}

// Otherwise, hand it off to the parent for multi-select handling
this.parent.onRowClicked(e, this);
this.parent.onRowClicked(e, this, false /*fFromKeyboard*/);
}

/**
Expand Down Expand Up @@ -102,6 +102,37 @@ class BulkActionRow {
selected ? this.row.classList.add('selectedRow') : this.row.classList.remove('selectedRow');
}

/**
* @param {HTMLInputElement} checkbox
* @param {KeyboardEvent} e */
onCheckboxKeydown(checkbox, e) {
switch (e.key) {
case 'Enter':
if (!e.ctrlKey && !e.shiftKey && !e.altKey && (e.target instanceof HTMLInputElement)) {
checkbox.checked = !checkbox.checked;
this.update();
}
break;
case 's':
this.parent.onRowClicked(new MouseEvent('click', { ctrlKey : true }), this, true /*fromKeyboard*/);
break;
case 'c':
this.parent.toggleAllSelected(this.selected ? !this.enabled : undefined);
break;
case 'C':
{
const mainCheck = $('#selAllCheck', this.table);
mainCheck.checked = !mainCheck.checked;
BulkActionCommon.selectUnselectAll();
break;
}
case 'ArrowUp':
case 'ArrowDown':
this.parent.onCheckboxNav(e, this);
break;
}
}

/** Build the table row. To be implemented by the concrete class. */
build() { Log.error('BulkActionRow.build should be overridden.'); }
/** Updates the contents/style of the table row. To be implemented by the concrete class. */
Expand All @@ -110,24 +141,22 @@ class BulkActionRow {
/**
* Create a marker table checkbox
* @param {boolean} checked
* @param {number} mid Marker id
* @param {number} eid Episode id
* @param {number} identifier Unique identifier for this checkbox
* @param {*} attributes Dictionary of extra attributes to apply to the checkbox. */
createCheckbox(checked, mid, eid, attributes={}) {
createCheckbox(checked, identifier, attributes={}) {
this.enabled = checked;
const checkboxName = `mid_check_${mid || eid}`;
const checkboxName = `mid_check_${identifier}`;
const checkbox = customCheckbox({
id : checkboxName,
mid : mid,
eid : eid,
...attributes,
checked : checked,
},
{ change : this.onChecked },
{ change : this.onChecked,
keydown : this.onCheckboxKeydown },
{},
{ thisArg : this });

return appendChildren(checkbox, buildNode('label', { for : checkboxName, class : 'hidden' }, `Marker ${mid} Checkbox`));
return appendChildren(checkbox, buildNode('label', { for : checkboxName, class : 'hidden' }, `Marker ${identifier} Checkbox`));
}
}

Expand All @@ -154,6 +183,10 @@ class BulkActionTable {
* Whether the last row selection was a select or deselect operation, which can affect Ctrl and Ctrl+Shift actions.
* @type {boolean} */
#lastSelectedWasDeselect = false;
/**
* Whether our last selection was initiated from keyboard input, not a mouse click.
* @type {boolean} */
#inKeyboardSelection = false;
/**
* Holds the bulk check/uncheck checkboxes and label.
* @type {HTMLDivElement} */
Expand Down Expand Up @@ -236,12 +269,23 @@ class BulkActionTable {
id : 'selAllCheck',
checked : 'checked'
},
{ change : BulkActionCommon.selectUnselectAll });
{ change : BulkActionCommon.selectUnselectAll,
keydown : [ clickOnEnterCallback, this.#onMainCheckboxKeydown.bind(this) ] });

this.#html.appendChild(appendChildren(buildNode('thead'), TableElements.rawTableRow(mainCheckbox, ...columns)));
this.#html.appendChild(
appendChildren(buildNode('thead'), TableElements.rawTableRow(mainCheckbox, ...columns)));
this.#tbody = buildNode('tbody');
}

#onMainCheckboxKeydown(e) {
if (e.key !== 'ArrowDown') {
return;
}

const target = (e.ctrlKey && e.shiftKey) ? this.#rows[this.#rows.length - 1] : this.#rows[0];
scrollAndFocus(e, $$('input[type="checkbox"]', target.row));
}

/**
* Add the given row to the table.
* @param {BulkActionRow} row */
Expand Down Expand Up @@ -287,9 +331,18 @@ class BulkActionTable {
* @param {MouseEvent} e */
#onMultiSelectClick(checkbox, e) {
e.preventDefault(); // Don't change the check state
const select = checkbox.id === 'multiSelectSelect';
this.toggleAllSelected(checkbox.id === 'multiSelectSelect');
}

/**
* Check/Uncheck all selected markers. If check is undefined,
* toggle based on the first selected item's checked state.
* @param {boolean?} check */
toggleAllSelected(check) {
let setChecked = check;
for (const row of this.#selected.values()) {
row.setChecked(select);
setChecked ??= !row.enabled;
row.setChecked(setChecked);
}
}

Expand Down Expand Up @@ -361,8 +414,10 @@ class BulkActionTable {
* Process a row being clicked, selecting/deselecting all relevant rows
* based on what modifier keys were used.
* @param {MouseEvent} e
* @param {BulkActionRow} toggledRow */
onRowClicked(e, toggledRow) {
* @param {BulkActionRow} toggledRow
* @param {boolean} fromKeyboard */
onRowClicked(e, toggledRow, fromKeyboard) {
this.#inKeyboardSelection = fromKeyboard;
// The following should match the behavior of Windows Explorer bulk-selection
if (!e.ctrlKey && !e.shiftKey) {
// If this is the only row that's currently selected, deselect with a plain click.
Expand Down Expand Up @@ -427,6 +482,61 @@ class BulkActionTable {

this.#repositionMultiSelectCheckboxes();
}

/**
* @param {KeyboardEvent} e
* @param {BulkActionRow} currentRow */
onCheckboxNav(e, currentRow) {
if (e.altKey) {
return;
}

const up = e.key === 'ArrowUp';
const thisIndex = this.#rowMap[currentRow.id].rowIndex;
if (up && thisIndex === 0) {
// Just set focus to the table head checkbox and return.
const thead = $$('#bulkActionCustomizeTable thead');
if (thead) {
scrollAndFocus(e, thead, $$('input[type="checkbox"]', thead));
}

return;
}

let nextIndex = 0;
if (e.ctrlKey) {
nextIndex = up ? 0 : this.#rows.length - 1;
} else {
nextIndex = Math.max(0, Math.min(thisIndex + (up ? -1 : 1), this.#rows.length - 1));
}

const nextRow = this.#rows[nextIndex];

// Shift creates a selection
if (e.shiftKey) {
if (this.#inKeyboardSelection) {
// If we're already in the middle of keyboard selection,
// treat the calling row as a Ctrl+Click if it's not already
// selected, and treat the new row as a Ctrl+Shift+Click.
if (!currentRow.selected) {
this.onRowClicked(new MouseEvent('click', { ctrlKey : true }), currentRow, true /*fromKeyboard*/);
}

this.onRowClicked(new MouseEvent('click', { ctrlKey : true, shiftKey : true }), nextRow, true /*fromKeyboard*/);
} else {
// Fresh keyboard selection. Simulate a click of the initial row unless it's
// already the only item selected, and Shift+Click the next row.
if (this.#selected.size !== 1 || !currentRow.selected) {
this.onRowClicked(new MouseEvent('click'), currentRow);
}

this.onRowClicked(new MouseEvent('click', { shiftKey : true }), nextRow, true /*fromKeyboard*/);
}
}

// Now set focus.
scrollAndFocus(e, nextRow.row, $$('input[type="checkbox"]', nextRow.row));
}
}

/**
Expand Down Expand Up @@ -454,7 +564,7 @@ class BulkActionCommon {

/**
* Bulk check/uncheck all items in the given table based on the checkbox state.
* @param {Event} e */
* @param {Event} _e */
static selectUnselectAll(_e) {
const table = $(`#bulkActionCustomizeTable`);
if (!table) {
Expand Down
13 changes: 7 additions & 6 deletions Client/Script/BulkAddOverlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { BulkActionCommon, BulkActionRow, BulkActionTable, BulkActionType } from './BulkActionCommon.js';
import { BulkMarkerResolveType, MarkerData } from '/Shared/PlexTypes.js';
import { errorResponseOverlay, errorToast } from './ErrorHandling.js';
import { Attributes } from './DataAttributes.js';
import { BulkAddStickySettings } from 'StickySettings';
import ButtonCreator from './ButtonCreator.js';
import { ContextualLog } from '/Shared/ConsoleLog.js';
Expand Down Expand Up @@ -274,8 +275,8 @@ class BulkAddOverlay {

const startChapter = $('#addStartChapter');
const endChapter = $('#addEndChapter');
startChapter.setAttribute('data-switching-episode', 1); // Don't fire a bunch of change events when reorganizing.
endChapter.setAttribute('data-switching-episode', 1);
startChapter.setAttribute(Attributes.BulkAddUpdating, 1); // Don't fire a bunch of change events when reorganizing.
endChapter.setAttribute(Attributes.BulkAddUpdating, 1);
clearEle(startChapter);
clearEle(endChapter);
const displayTitle = (name, index, timestamp) => `${name || 'Chapter ' + (parseInt(index) + 1)} (${msToHms(timestamp)})`;
Expand All @@ -290,8 +291,8 @@ class BulkAddOverlay {
startChapter.title = startChapter.options[0].innerText;
endChapter.title = endChapter.options[0].innerText;

startChapter.removeAttribute('data-switching-episode');
endChapter.removeAttribute('data-switching-episode');
startChapter.removeAttribute(Attributes.BulkAddUpdating);
endChapter.removeAttribute(Attributes.BulkAddUpdating);

this.#updateTableStats();
}
Expand All @@ -300,7 +301,7 @@ class BulkAddOverlay {
* Update the customization table when the start/end chapter baseline is changed.
* @param {Event} e */
#onChapterChanged(e) {
if (e.target.getAttribute('data-switching-episode')) {
if (e.target.getAttribute(Attributes.BulkAddUpdating)) {
// We're repopulating the chapter list due to a baseline episode change. Don't do anything yet.
return;
}
Expand Down Expand Up @@ -712,7 +713,7 @@ class BulkAddRow extends BulkActionRow {
const startTime = this.#parent.startTime();
const endTime = this.#parent.endTime();
this.buildRow(
this.createCheckbox(true, null /*mid*/, this.#episodeInfo.metadataId),
this.createCheckbox(true, this.#episodeInfo.metadataId),
`S${pad0(this.#episodeInfo.seasonIndex, 2)}E${pad0(this.#episodeInfo.index, 2)}`,
this.#episodeInfo.title,
isNaN(startTime) ? '-' : TableElements.timeData(endTime),
Expand Down
2 changes: 1 addition & 1 deletion Client/Script/BulkDeleteOverlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ class BulkDeleteRow extends BulkActionRow {
/** Construct the table row. */
build() {
const row = this.buildRow(
this.createCheckbox(true /*checked*/, this.#marker.id, this.#marker.parentId),
this.createCheckbox(true /*checked*/, this.#marker.id),
`S${pad0(this.#episode.seasonIndex, 2)}E${pad0(this.#episode.index, 2)}`,
this.#marker.markerType[0].toUpperCase() + this.#marker.markerType.substring(1),
TableElements.customClassColumn(this.#episode.title, 'bulkActionEpisodeColumn'),
Expand Down
7 changes: 4 additions & 3 deletions Client/Script/BulkShiftOverlay.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { $, appendChildren, buildNode, msToHms, pad0, timeInputShortcutHandler, timeToMs } from './Common.js';

import { BulkActionCommon, BulkActionRow, BulkActionTable, BulkActionType } from './BulkActionCommon.js';
import { Attributes } from './DataAttributes.js';
import { BulkShiftStickySettings } from 'StickySettings';
import ButtonCreator from './ButtonCreator.js';
import { ContextualLog } from '/Shared/ConsoleLog.js';
Expand Down Expand Up @@ -244,7 +245,7 @@ class BulkShiftOverlay {
message = 'The shift could not be applied, please try again later.';
}

const attributes = { id : 'resolveShiftMessage', resolveMessage : messageType };
const attributes = { id : 'resolveShiftMessage', [Attributes.BulkShiftResolveMessage] : messageType };
let node;
if (addForceButton) {
node = appendChildren(buildNode('div', attributes),
Expand Down Expand Up @@ -284,7 +285,7 @@ class BulkShiftOverlay {
return false;
}

return message.getAttribute('resolveMessage');
return message.getAttribute(Attributes.BulkShiftResolveMessage);
}

/**
Expand Down Expand Up @@ -651,7 +652,7 @@ class BulkShiftRow extends BulkActionRow {
/** Build and return the marker row. */
build() {
const row = this.buildRow(
this.createCheckbox(!this.#linked, this.#marker.id, this.#marker.parentId, { linked : this.#linked ? 1 : 0 }),
this.createCheckbox(!this.#linked, this.#marker.id),
`S${pad0(this.#episode.seasonIndex, 2)}E${pad0(this.#episode.index, 2)}`,
TableElements.timeData(this.#marker.start),
TableElements.timeData(this.#marker.end),
Expand Down
17 changes: 15 additions & 2 deletions Client/Script/ButtonCreator.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { $, $$, appendChildren, buildNode } from './Common.js';
import { ContextualLog } from '/Shared/ConsoleLog.js';

import { addWindowResizedListener, isSmallScreen } from './WindowResizeEventHandler.js';
import { Attributes } from './DataAttributes.js';
import { getSvgIcon } from './SVGHelper.js';
import Icons from './Icons.js';
import { ThemeColors } from './ThemeColors.js';
Expand Down Expand Up @@ -29,7 +30,7 @@ class ButtonCreator {
buttonText.classList[small ? 'add' : 'remove']('hidden');

// Don't override the tooltip if it was user-set.
if (!button.hasAttribute('data-default-tooltip')) {
if (!button.hasAttribute(Attributes.UseDefaultTooltip)) {
return;
}

Expand Down Expand Up @@ -73,7 +74,7 @@ class ButtonCreator {

const button = ButtonCreator.fullButton(text, icon, color, clickHandler, attributes);
if (!attributes.tooltip) {
button.setAttribute('data-default-tooltip', 1);
button.setAttribute(Attributes.UseDefaultTooltip, 1);
}

if (isSmallScreen()) {
Expand Down Expand Up @@ -149,6 +150,18 @@ class ButtonCreator {
svg.replaceWith(getSvgIcon(newIcon, theme));
}

/**
* Return the button, or undefined if the element is not part of a button.
* @param {HTMLElement} element */
static getButton(element) {
let current = element;
while (current && !current.classList.contains('button')) {
current = current.parentElement;
}

return current;
}

/**
* Returns an empty button with the given class
* @param {string} className The class name to give this button.
Expand Down

0 comments on commit 24b1b60

Please sign in to comment.