Add BaseTablist base class with keyboard navigation

This commit is contained in:
2026-04-18 02:18:45 +08:00
parent 454ff8bb5f
commit 800832d15e
12 changed files with 420 additions and 323 deletions
+177
View File
@@ -0,0 +1,177 @@
/* The Mia! Accounting Project
* base-tablist.js: The JavaScript for base abstract tablist
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract tablist.
*
* @abstract
* @template {BaseTab} T
*/
class BaseTablist {
/**
* The tabs.
* @type {T[]}
*/
tabs;
/**
* The current tab.
* @type {T}
*/
currentTab;
/**
* Constructs a new base abstract tablist.
*
* @param tablist {HTMLUListElement} the tab list
*/
constructor(tablist) {
tablist.onkeydown = this.onTabKeyDown.bind(this);
}
/**
* Actions when keys are pressed on the tabs.
*
* @param event {KeyboardEvent} the key event
*/
onTabKeyDown(event) {
const currentIndex = this.tabs.indexOf(this.currentTab);
if (currentIndex === -1) {
return;
}
let newIndex = currentIndex;
switch (event.key) {
case "ArrowRight":
newIndex = (newIndex + 1) % this.tabs.length;
break;
case "ArrowLeft":
newIndex = (newIndex - 1 + this.tabs.length) % this.tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = this.tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
this.tabs[newIndex].focus();
this.onTabFocus(this.tabs[newIndex]);
}
/**
* Actions when a tab is focused.
*
* @param tab {T} the tab
*/
onTabFocus(tab) { /* Do nothing */ }
/**
* Switches to a tab.
*
* @param tab {T} the tab
*/
switchTo(tab) {
this.tabs.forEach(t => t.setActive(t === tab));
this.currentTab = tab;
this.currentTab.onActivated();
}
}
/**
* The base abstract tab.
*
* @abstract
*/
class BaseTab {
/**
* The tab element.
* @type {HTMLButtonElement}
*/
#tab;
/**
* The panel element.
* @type {HTMLDivElement}
*/
#panel;
/**
* Constructs a new base abstract tab.
*
* @param tab {HTMLButtonElement} The tab element.
* @param panel {HTMLDivElement} The panel element.
* @param switchTo {function(BaseTab): void} The function to switch to the tab.
*/
constructor(tab, panel, switchTo) {
this.#tab = tab;
this.#panel = panel;
this.#tab.onclick = () => switchTo(this);
}
/**
* Sets the active state of the tab.
*
* @param isActive {boolean} true if the tab is active, false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#tab.classList.add("active");
this.#tab.tabIndex = 0;
this.#tab.ariaSelected = "true";
this.#panel.classList.remove("d-none");
} else {
this.#tab.classList.remove("active");
this.#tab.tabIndex = -1;
this.#tab.ariaSelected = "false";
this.#panel.classList.add("d-none");
}
}
/**
* Returns whether the tab is active.
*
* @returns {boolean} true if the tab is active, false otherwise
*/
isActive() {
return this.#tab.classList.contains("active");
}
/**
* Actions when the tab is activated.
*/
onActivated() { /* Do nothing */ }
/**
* Focuses the tab.
*/
focus() {
this.#tab.focus();
}
}
+159 -190
View File
@@ -25,8 +25,9 @@
/**
* A description editor.
*
* @extends {BaseTablist<BaseDescriptionEditorTab>}
*/
class DescriptionEditor {
class DescriptionEditor extends BaseTablist {
/**
* The line item editor
@@ -58,12 +59,6 @@ class DescriptionEditor {
*/
debitCredit;
/**
* The current tab
* @type {DescriptionEditorTabPlane}
*/
currentTab;
/**
* The description input
* @type {HTMLInputElement}
@@ -125,10 +120,10 @@ class DescriptionEditor {
selectedAccount = null;
/**
* The tab planes
* @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}}
* The tabs by their ID.
* @type {DescriptionEditorTabFactory}
*/
tabPlanes = {};
#tabsByID;
/**
* Constructs a description editor.
@@ -137,23 +132,23 @@ class DescriptionEditor {
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
const prefix = `accounting-description-editor-${debitCredit}`;
super(document.getElementById(`${prefix}-tab-list`));
this.prefix = prefix;
this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit;
this.prefix = `accounting-description-editor-${debitCredit}`;
this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(`${this.prefix}-modal`);
this.#descriptionInput = document.getElementById(`${this.prefix}-description`);
this.#offsetButton = document.getElementById(`${this.prefix}-offset`);
this.number = document.getElementById(`${this.prefix}-annotation-number`);
this.note = document.getElementById(`${this.prefix}-annotation-note`);
this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`));
this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
this.#form = document.getElementById(prefix);
this.#modal = document.getElementById(`${prefix}-modal`);
this.#descriptionInput = document.getElementById(`${prefix}-description`);
this.#offsetButton = document.getElementById(`${prefix}-offset`);
this.number = document.getElementById(`${prefix}-annotation-number`);
this.note = document.getElementById(`${prefix}-annotation-note`);
this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${prefix}-account-confirmed`));
this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
}
this.currentTab = this.tabPlanes.general;
this.#tabsByID = new DescriptionEditorTabFactory(this);
this.tabs = [this.#tabsByID.general, this.#tabsByID.travel, this.#tabsByID.bus, this.#tabsByID.recurring, this.#tabsByID.annotation];
this.currentTab = this.tabs[0];
this.#descriptionInput.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => {
@@ -199,26 +194,26 @@ class DescriptionEditor {
*
*/
#onDescriptionChange() {
this.#resetTabPlanes();
this.#resetTabs();
this.selectedAccount = null;
this.description = this.description.trim();
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) {
for (const tab of [this.#tabsByID.recurring, this.#tabsByID.bus, this.#tabsByID.travel, this.#tabsByID.general]) {
if (tab.populate()) {
break;
}
}
this.tabPlanes.annotation.populate();
this.#tabsByID.annotation.populate();
}
/**
* Resets the tab planes.
* Resets the tabs.
*
*/
#resetTabPlanes() {
for (const tabPlane of Object.values(this.tabPlanes)) {
tabPlane.reset();
#resetTabs() {
for (const tab of this.tabs) {
tab.reset();
}
this.tabPlanes.general.switchToMe();
this.switchTo(this.tabs[0]);
}
/**
@@ -463,12 +458,63 @@ class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
}
/**
* A tab plane.
* The tab factory.
*
* @private
*/
class DescriptionEditorTabFactory {
/**
* The general tag tab
* @type {GeneralTagTab}
*/
general;
/**
* The general trip tab
* @type {GeneralTripTab}
*/
travel;
/**
* The bus trip tab
* @type {BusTripTab}
*/
bus;
/**
* The recurring transactions tab
* @type {RecurringTab}
*/
recurring;
/**
* The annotation tab
* @type {AnnotationTab}
*/
annotation;
/**
* Constructs the tab factory
*
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
this.general = new GeneralTagTab(editor);
this.travel = new GeneralTripTab(editor);
this.bus = new BusTripTab(editor);
this.recurring = new RecurringTab(editor);
this.annotation = new AnnotationTab(editor);
}
}
/**
* The base abstract tab in the description editor.
*
* @abstract
* @private
*/
class DescriptionEditorTabPlane {
class BaseDescriptionEditorTab extends BaseTab {
/**
* The parent description editor
@@ -483,47 +529,29 @@ class DescriptionEditorTabPlane {
prefix;
/**
* The tab
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* Constructs a base abstract tab in the description editor.
*
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
constructor(tabID, editor) {
const prefix = `${editor.prefix}-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, editor.switchTo.bind(editor));
this.editor = editor;
this.prefix = `${this.editor.prefix}-${this.tabId()}`;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.switchToMe();
this.prefix = prefix;
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @abstract
*/
reset() { throw new Error("Method not implemented."); }
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @abstract
@@ -531,39 +559,21 @@ class DescriptionEditorTabPlane {
populate() { throw new Error("Method not implemented."); }
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @abstract
*/
validate() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
*
*/
switchToMe() {
for (const tabPlane of Object.values(this.editor.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
this.editor.currentTab = this;
}
}
/**
* A tag plane with selectable tags.
* The base abstract tab with selectable tags.
*
* @abstract
* @private
*/
class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
class BaseTagTab extends BaseDescriptionEditorTab {
/**
* The tag input
@@ -581,20 +591,21 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
* The tag buttons
* @type {HTMLButtonElement[]}
*/
tagButtons;
#tagButtons;
/**
* Constructs a tab plane.
* Constructs a base abstract tab with selectable tags.
*
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
constructor(tabID, editor) {
super(tabID, editor);
this.tag = document.getElementById(`${this.prefix}-tag`);
this.tagError = document.getElementById(`${this.prefix}-tag-error`);
// noinspection JSValidateTypes
this.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
this.#tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
this.initializeTagButtons();
this.tag.onchange = () => {
this.onTagChange();
@@ -609,7 +620,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
onTagChange() {
this.tag.value = this.tag.value.trim();
let isMatched = false;
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
@@ -627,19 +638,18 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @abstract
*/
updateDescription() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
*
* @inheritDoc
* @override
*/
switchToMe() {
super.switchToMe();
for (const tagButton of this.tagButtons) {
onActivated() {
for (const tagButton of this.#tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
this.editor.updateCurrentSuggestedAccounts(tagButton);
return;
@@ -653,9 +663,9 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
*
*/
initializeTagButtons() {
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
tagButton.onclick = () => {
for (const otherButton of this.tagButtons) {
for (const otherButton of this.#tagButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
@@ -701,7 +711,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -709,7 +719,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
this.tag.value = "";
this.tag.classList.remove("is-invalid");
this.tagError.innerText = "";
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
@@ -717,24 +727,24 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* The general tag tab plane.
* The general tag tab.
*
* @private
*/
class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
class GeneralTagTab extends BaseTagTab {
/**
* The tab ID
* Constructs a general tag tab.
*
* @return {string}
* @abstract
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
tabId() {
return "general";
};
constructor(editor) {
super("general", editor);
}
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -749,7 +759,7 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -763,12 +773,12 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
this.tag.value = found[1];
this.onTagChange();
}
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
*/
@@ -778,11 +788,11 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
}
/**
* The general trip tab plane.
* The general trip tab.
*
* @private
*/
class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
class GeneralTripTab extends BaseTagTab {
/**
* The origin
@@ -815,13 +825,13 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
#directionButtons;
/**
* Constructs a tab plane.
* Constructs a general trip tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("travel", editor);
this.#from = document.getElementById(`${this.prefix}-from`);
this.#fromError = document.getElementById(`${this.prefix}-from-error`);
this.#to = document.getElementById(`${this.prefix}-to`);
@@ -852,17 +862,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "travel";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -878,7 +878,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -902,7 +902,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -927,12 +927,12 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
}
this.#to.value = found[4];
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
@@ -977,11 +977,11 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The bus trip tab plane.
* The bus trip tab.
*
* @private
*/
class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
class BusTripTab extends BaseTagTab {
/**
* The route
@@ -1020,13 +1020,13 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
#toError;
/**
* Constructs a tab plane.
* Constructs a bus trip tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("bus", editor);
this.#route = document.getElementById(`${this.prefix}-route`);
this.#routeError = document.getElementById(`${this.prefix}-route-error`);
this.#from = document.getElementById(`${this.prefix}-from`);
@@ -1051,17 +1051,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "bus";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -1070,7 +1060,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1088,7 +1078,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1105,12 +1095,12 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
this.#route.value = found[2];
this.#from.value = found[3];
this.#to.value = found[4];
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
*/
@@ -1165,11 +1155,11 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The recurring transaction tab plane.
* The recurring transaction tab.
*
* @private
*/
class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
class RecurringTab extends BaseDescriptionEditorTab {
/**
* The month names
@@ -1184,13 +1174,13 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
#itemButtons;
/**
* Constructs a tab plane.
* Constructs a recurring transaction tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("recurring", editor);
this.#monthNames = [
"",
A_("January"), A_("February"), A_("March"), A_("April"),
@@ -1232,17 +1222,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "recurring";
};
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1254,7 +1234,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1264,7 +1244,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
if (this.#getDescription(itemButton) === this.editor.description) {
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.switchToMe();
this.editor.switchTo(this);
return true;
}
}
@@ -1272,11 +1252,10 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Switches to the tab plane.
*
* @inheritDoc
* @override
*/
switchToMe() {
super.switchToMe();
onActivated() {
for (const itemButton of this.#itemButtons) {
if (itemButton.classList.contains("btn-primary")) {
this.editor.updateCurrentSuggestedAccounts(itemButton);
@@ -1287,7 +1266,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
@@ -1298,20 +1277,20 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* The annotation tab plane.
* The annotation tab.
*
* @private
*/
class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
class AnnotationTab extends BaseDescriptionEditorTab {
/**
* Constructs a tab plane.
* Constructs an annotation tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("annotation", editor);
this.editor.number.onchange = () => this.updateDescription();
this.editor.note.onchange = () => {
this.editor.note.value = this.editor.note.value.trim();
@@ -1320,17 +1299,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "annotation";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -1348,7 +1317,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1358,7 +1327,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1382,7 +1351,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
+51 -107
View File
@@ -30,21 +30,16 @@ document.addEventListener("DOMContentLoaded", () => {
/**
* The period chooser.
*
* @extends {BaseTablist<BasePeriodTab>}
* @private
*/
class PeriodChooser {
class PeriodChooser extends BaseTablist {
/**
* The modal of the period chooser
* @type {HTMLDivElement}
* The URL template for different periods.
* @type {string}
*/
modal;
/**
* The tab planes
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
*/
tabPlanes = {};
urlTemplate;
/**
* Constructs the period chooser.
@@ -52,12 +47,23 @@ class PeriodChooser {
*/
constructor() {
const prefix = "accounting-period-chooser";
this.modal = document.getElementById(`${prefix}-modal`);
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
super(document.getElementById(`${prefix}-tab-list`));
this.tabs = [new MonthTab(this), new YearTab(this), new DayTab(this), new CustomTab(this)];
const modal = document.getElementById(`${prefix}-modal`);
this.urlTemplate = modal.dataset.urlTemplate;
for (const tab of this.tabs) {
if (tab.isActive()) {
this.currentTab = tab;
break;
}
}
}
/**
* @inheritDoc
* @override
*/
onTabFocus(tab) { this.switchTo(tab); }
/**
* The period chooser.
@@ -75,12 +81,12 @@ class PeriodChooser {
}
/**
* A tab plane.
* A base abstract period tab.
*
* @abstract
* @private
*/
class TabPlane {
class BasePeriodTab extends BaseTab {
/**
* The period chooser
@@ -95,62 +101,27 @@ class TabPlane {
prefix;
/**
* The tab
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* Constructs a base abstract period tab.
*
* @param tabID {string} the tab ID
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
constructor(tabID, chooser) {
const prefix = `accounting-period-chooser-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, chooser.switchTo.bind(chooser));
this.chooser = chooser;
this.prefix = `accounting-period-chooser-${this.tabId()}`;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.#switchToMe();
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Switches to the tab plane.
*
*/
#switchToMe() {
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
this.prefix = prefix;
}
}
/**
* The month tab plane.
* The month tab.
*
* @private
*/
class MonthTab extends TabPlane {
class MonthTab extends BasePeriodTab {
/**
* The month chooser.
@@ -159,12 +130,12 @@ class MonthTab extends TabPlane {
#monthChooser
/**
* Constructs a tab plane.
* Constructs a month tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("month", chooser);
const monthChooser = document.getElementById(`${this.prefix}-chooser`);
if (monthChooser !== null) {
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
@@ -184,45 +155,36 @@ class MonthTab extends TabPlane {
const date = e.detail.date;
const zeroPaddedMonth = `0${date.month + 1}`.slice(-2)
const period = `${date.year}-${zeroPaddedMonth}`;
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", period);
});
}
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "month";
}
}
/**
* The year tab plane.
* The year tab.
*
* @private
*/
class YearTab extends TabPlane {
class YearTab extends BasePeriodTab {
/**
* The tab ID
* Constructs a year tab.
*
* @return {string}
* @param chooser {PeriodChooser} the period chooser
*/
tabId() {
return "year";
constructor(chooser) {
super("year", chooser);
}
}
/**
* The day tab plane.
* The day tab.
*
* @private
*/
class DayTab extends TabPlane {
class DayTab extends BasePeriodTab {
/**
* The day input
@@ -237,18 +199,18 @@ class DayTab extends TabPlane {
#dateError;
/**
* Constructs a tab plane.
* Constructs a day tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("day", chooser);
this.#date = document.getElementById(`${this.prefix}-date`);
this.#dateError = document.getElementById(`${this.prefix}-date-error`);
if (this.#date !== null) {
this.#date.onchange = () => {
if (this.#validateDate()) {
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", this.#date.value);
}
};
@@ -275,23 +237,14 @@ class DayTab extends TabPlane {
this.#dateError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "day";
}
}
/**
* The custom tab plane.
* The custom tab.
*
* @private
*/
class CustomTab extends TabPlane {
class CustomTab extends BasePeriodTab {
/**
* The start of the period
@@ -324,12 +277,12 @@ class CustomTab extends TabPlane {
#confirm;
/**
* Constructs a tab plane.
* Constructs a custom tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("custom", chooser);
this.#start = document.getElementById(`${this.prefix}-start`);
this.#startError = document.getElementById(`${this.prefix}-start-error`);
this.#end = document.getElementById(`${this.prefix}-end`);
@@ -351,7 +304,7 @@ class CustomTab extends TabPlane {
isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid;
if (isValid) {
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`);
}
};
@@ -407,13 +360,4 @@ class CustomTab extends TabPlane {
this.#endError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "custom";
}
}
@@ -39,36 +39,36 @@ First written: 2023/2/28
</div>
{# Tab navigation #}
<ul class="nav nav-tabs mb-2" role="tablist">
<ul id="accounting-description-editor-{{ description_editor.debit_credit }}-tab-list" class="nav nav-tabs mb-2" role="tablist" aria-label="{{ A_("Description Type") }}">
<li class="nav-item">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" type="button" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-selected="true" aria-current="page">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" type="button" tabindex="0" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" aria-selected="true">
{{ A_("General") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" type="button" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" aria-selected="false" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" aria-selected="false">
{{ A_("Travel") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" type="button" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" aria-selected="false" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" aria-selected="false">
{{ A_("Bus") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" type="button" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" aria-selected="false" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" aria-selected="false">
{{ A_("Recurring") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" type="button" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" aria-selected="false" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" aria-selected="false">
{{ A_("Annotation") }}
</button>
</li>
</ul>
{# A general description with a tag #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" role="tabpanel" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
@@ -85,7 +85,7 @@ First written: 2023/2/28
</div>
{# A general trip with the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" role="tabpanel" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
@@ -119,7 +119,7 @@ First written: 2023/2/28
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" role="tabpanel" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
@@ -156,7 +156,7 @@ First written: 2023/2/28
</div>
{# A recurring transaction #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" role="tabpanel" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
@@ -167,7 +167,7 @@ First written: 2023/2/28
</div>
{# The annotation #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" role="tabpanel" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
@@ -27,6 +27,7 @@ First written: 2023/2/26
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
{% endblock %}
@@ -23,6 +23,7 @@ First written: 2023/3/7
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
@@ -28,31 +28,31 @@ First written: 2023/3/4
</div>
<div class="modal-body">
{# Tab navigation #}
<ul class="nav nav-tabs mb-2" role="tablist">
<ul id="accounting-period-chooser-tab-list" class="nav nav-tabs mb-2" role="tablist" aria-label="{{ A_("Period Type") }}">
<li class="nav-item">
<button id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" type="button" role="tab" aria-controls="accounting-period-chooser-month-page" aria-selected="true" aria-current="{% if report.period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
<button id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" type="button" tabindex="{% if report.period.is_type_month %}0{% else %}-1{% endif %}" role="tab" aria-controls="accounting-period-chooser-month-panel" aria-selected="{% if report.period.is_type_month %}true{% else %}false{% endif %}">
{{ A_("Month") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" type="button" role="tab" aria-controls="accounting-period-chooser-year-page" aria-selected="false" aria-current="{% if report.period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
<button id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" type="button" tabindex="{% if report.period.is_a_year %}0{% else %}-1{% endif %}" role="tab" aria-controls="accounting-period-chooser-year-panel" aria-selected="{% if report.period.is_a_year %}true{% else %}false{% endif %}">
{{ A_("Year") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" type="button" role="tab" aria-controls="accounting-period-chooser-day-page" aria-selected="false" aria-current="{% if report.period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
<button id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" type="button" tabindex="{% if report.period.is_a_day %}0{% else %}-1{% endif %}" role="tab" aria-controls="accounting-period-chooser-day-panel" aria-selected="{% if report.period.is_a_day %}true{% else %}false{% endif %}">
{{ A_("Day") }}
</button>
</li>
<li class="nav-item">
<button id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" type="button" role="tab" aria-controls="accounting-period-chooser-custom-page" aria-selected="false" aria-current="{% if report.period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
<button id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" type="button" tabindex="{% if report.period.is_type_arbitrary %}0{% else %}-1{% endif %}" role="tab" aria-controls="accounting-period-chooser-custom-panel" aria-selected="{% if report.period.is_type_arbitrary %}true{% else %}false{% endif %}">
{{ A_("Custom") }}
</button>
</li>
</ul>
{# The month periods #}
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" role="tabpanel" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
<div id="accounting-period-chooser-month-panel" {% if not report.period.is_type_month %} class="d-none" {% endif %} role="tabpanel" aria-labelledby="accounting-period-chooser-month-tab">
<div>
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
{{ A_("This Month") }}
@@ -72,7 +72,7 @@ First written: 2023/3/4
</div>
{# The year periods #}
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" role="tabpanel" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
<div id="accounting-period-chooser-year-panel" {% if not report.period.is_a_year %} class="d-none" {% endif %} role="tabpanel" aria-labelledby="accounting-period-chooser-year-tab">
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
{{ A_("This Year") }}
</a>
@@ -93,7 +93,7 @@ First written: 2023/3/4
</div>
{# The day periods #}
<div id="accounting-period-chooser-day-page" {% if report.period.is_a_day %} aria-current="page" {% else %} class="d-none" role="tabpanel" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
<div id="accounting-period-chooser-day-panel" {% if not report.period.is_a_day %} class="d-none" {% endif %} role="tabpanel" aria-labelledby="accounting-period-chooser-day-tab">
<div>
<a class="btn {% if report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
{{ A_("Today") }}
@@ -116,7 +116,7 @@ First written: 2023/3/4
</div>
{# The custom periods #}
<div id="accounting-period-chooser-custom-page" {% if report.period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" role="tabpanel" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
<div id="accounting-period-chooser-custom-panel" {% if not report.period.is_type_arbitrary %} class="d-none" {% endif %} role="tabpanel" aria-labelledby="accounting-period-chooser-custom-tab">
<div>
<a class="btn {% if report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
{{ A_("All") }}
@@ -23,6 +23,7 @@ First written: 2023/3/5
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
@@ -23,6 +23,7 @@ First written: 2023/3/7
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
@@ -23,6 +23,7 @@ First written: 2023/3/4
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
@@ -23,6 +23,7 @@ First written: 2023/3/5
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
@@ -23,6 +23,7 @@ First written: 2023/3/5
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}