diff --git a/src/accounting/static/js/account-form.js b/src/accounting/static/js/account-form.js index cb831ab..c3d4efc 100644 --- a/src/accounting/static/js/account-form.js +++ b/src/accounting/static/js/account-form.js @@ -225,15 +225,16 @@ class AccountForm { /** * The base account selector. * + * @extends {BaseCombobox} * @private */ -class BaseAccountSelector { +class BaseAccountSelector extends BaseCombobox { /** * The account form * @type {AccountForm} */ - form; + #form; /** * The selector modal @@ -241,12 +242,6 @@ class BaseAccountSelector { */ #modal; - /** - * The query input - * @type {HTMLInputElement} - */ - #query; - /** * The error message when the query has no result * @type {HTMLParagraphElement} @@ -259,12 +254,6 @@ class BaseAccountSelector { */ #optionList; - /** - * The options - * @type {BaseAccountOption[]} - */ - #options; - /** * The button to clear the base account value * @type {HTMLButtonElement} @@ -277,29 +266,32 @@ class BaseAccountSelector { * @param form {AccountForm} the form */ constructor(form) { - this.form = form; const prefix = "accounting-base-selector"; + const query = document.getElementById(`${prefix}-query`); + const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(element, form.saveBaseAccount.bind(form))); + super(query, options); + this.#form = form; this.#modal = document.getElementById(`${prefix}-modal`); - this.#query = document.getElementById(`${prefix}-query`); + this.#modal.addEventListener("hidden.bs.modal", () => this.#form.onBaseAccountSelectorClosed()); this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`); this.#optionList = document.getElementById(`${prefix}-option-list`); - this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element)); - this.#clearButton = document.getElementById(`${prefix}-clear`); - this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed()); - this.#query.oninput = () => this.#filterOptions(); - this.#clearButton.onclick = () => this.form.clearBaseAccount(); + this.#clearButton = document.getElementById(`${prefix}-clear`); + this.#clearButton.onclick = () => this.#form.clearBaseAccount(); } /** * Filters the options. * + * @override */ - #filterOptions() { + filterOptions() { + this.shownOptions = []; let isAnyMatched = false; - for (const option of this.#options) { - if (option.isMatched(this.#query.value)) { + for (const option of this.options) { + if (option.isMatched(this.query.value)) { option.setShown(true); + this.shownOptions.push(option); isAnyMatched = true; } else { option.setShown(false); @@ -319,12 +311,11 @@ class BaseAccountSelector { * */ onOpen() { - this.#query.value = ""; - this.#filterOptions(); - for (const option of this.#options) { - option.setActive(option.code === this.form.baseCode); - } - if (this.form.baseCode === null) { + this.query.value = ""; + this.filterOptions(); + this.query.removeAttribute("aria-activedescendant"); + this.selectOption(this.shownOptions.find((option) => option.code === this.#form.baseCode)); + if (this.#form.baseCode === null) { this.#clearButton.classList.add("btn-secondary") this.#clearButton.classList.remove("btn-danger"); this.#clearButton.disabled = true; @@ -341,13 +332,7 @@ class BaseAccountSelector { * * @private */ -class BaseAccountOption { - - /** - * The element - * @type {HTMLLIElement} - */ - #element; +class BaseAccountOption extends BaseOption { /** * The account code @@ -370,16 +355,16 @@ class BaseAccountOption { /** * Constructs the account in the base account selector. * - * @param selector {BaseAccountSelector} the base account selector * @param element {HTMLLIElement} the element + * @param save {function(BaseAccountOption): void} the callback to save the option */ - constructor(selector, element) { - this.#element = element; + constructor(element, save) { + super(element); this.code = element.dataset.code; this.text = element.dataset.text; this.#queryValues = JSON.parse(element.dataset.queryValues); - this.#element.onclick = () => selector.form.saveBaseAccount(this); + element.onclick = () => save(this); } /** @@ -399,30 +384,4 @@ class BaseAccountOption { } return false; } - - /** - * Sets whether the option is shown. - * - * @param isShown {boolean} true to show, or false otherwise - */ - setShown(isShown) { - if (isShown) { - this.#element.classList.remove("d-none"); - } else { - this.#element.classList.add("d-none"); - } - } - - /** - * Sets whether the option is active. - * - * @param isActive {boolean} true if active, or false otherwise - */ - setActive(isActive) { - if (isActive) { - this.#element.classList.add("active"); - } else { - this.#element.classList.remove("active"); - } - } } diff --git a/src/accounting/static/js/base-combobox.js b/src/accounting/static/js/base-combobox.js new file mode 100644 index 0000000..8c4a458 --- /dev/null +++ b/src/accounting/static/js/base-combobox.js @@ -0,0 +1,237 @@ +/* The Mia! Accounting Project + * base-combobox.js: The JavaScript for the base abstract combobox + */ + +/* 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 combobox. + * + * @abstract + * @template {BaseOption} T + */ +class BaseCombobox { + + /** + * The query input + * @type {HTMLInputElement} + */ + query; + + /** + * The options + * @type {T[]} + */ + options; + + /** + * The options that are shown + * @type {T[]} + */ + shownOptions; + + /** + * Constructs a base abstract combobox. + * + * @param query {HTMLInputElement} the query input + * @param options {T[]} the options + */ + constructor(query, options) { + this.query = query; + this.query.oninput = () => this.filterOptions(); + this.query.onkeydown = this.onQueryKeyDown.bind(this); + this.options = options; + this.shownOptions = []; + } + + /** + * Actions when keys are pressed on the query input. + * + * @param event {KeyboardEvent} the key event + */ + onQueryKeyDown(event) { + if (this.shownOptions.length === 0) { + return; + } + const currentID = this.query.getAttribute("aria-activedescendant"); + const currentIndex = this.shownOptions.findIndex((option) => option.elementID === currentID); + + let newIndex; + switch (event.key) { + case "ArrowUp": + if (currentIndex === -1) { + newIndex = this.shownOptions.length - 1; + } else { + newIndex = (currentIndex - 1 + this.shownOptions.length) % this.shownOptions.length; + } + break; + case "ArrowDown": + if (currentIndex === -1) { + newIndex = 0; + } else { + newIndex = (currentIndex + 1) % this.shownOptions.length; + } + break; + case "Home": + if (this.query.value !== "") { + return; + } + newIndex = 0; + break; + case "End": + if (this.query.value !== "") { + return; + } + newIndex = this.shownOptions.length - 1; + break; + case "PageUp": + if (currentIndex === -1) { + newIndex = this.shownOptions.length - 1; + } else { + newIndex = Math.max(currentIndex - 10, 0); + } + break; + case "PageDown": + if (currentIndex === -1) { + newIndex = 0; + } else { + newIndex = Math.min(currentIndex + 10, this.shownOptions.length - 1); + } + break; + case "Enter": + event.preventDefault(); + if (currentIndex !== -1) { + this.shownOptions[currentIndex].click(); + } + return; + case "Escape": + if (this.query.value !== "") { + event.preventDefault(); + event.stopPropagation(); + this.query.value = ""; + this.filterOptions(); + } + return; + default: + return; + } + event.preventDefault(); + this.selectOption(this.shownOptions[newIndex]); + } + + /** + * Filters the options. + * + * @abstract + */ + filterOptions() { + throw new Error("Method not implemented"); + } + + /** + * Selects an option. + * + * @param option {T|undefined} the option. + */ + selectOption(option) { + this.options.forEach((opt) => opt.setActive(false)); + if (option === undefined) { + return; + } + option.setActive(true); + this.query.setAttribute("aria-activedescendant", option.elementID); + option.scrollIntoView(); + } +} + +/** + * The base abstract option + * + * @abstract + */ +class BaseOption { + + /** + * The element + * @type {HTMLLIElement} + */ + #element; + + /** + * The element ID + * @type {string} + */ + elementID; + + /** + * Constructs the base abstract option. + * + * @param element {HTMLLIElement} the element + */ + constructor(element) { + this.#element = element; + this.elementID = element.id; + } + + /** + * Sets whether the option is shown. + * + * @param isShown {boolean} true to show, or false otherwise + */ + setShown(isShown) { + if (isShown) { + this.#element.classList.remove("d-none"); + } else { + this.#element.classList.add("d-none"); + } + } + + /** + * Sets whether the option is active. + * + * @param isActive {boolean} true if active, or false otherwise + */ + setActive(isActive) { + if (isActive) { + this.#element.classList.add("active"); + this.#element.ariaSelected = "true"; + } else { + this.#element.classList.remove("active"); + this.#element.ariaSelected = "false"; + } + } + + /** + * Clicks the option. + * + */ + click() { + this.#element.click(); + } + + /** + * Scrolls the option into view. + * + */ + scrollIntoView() { + this.#element.scrollIntoView({block: "nearest"}); + } +} diff --git a/src/accounting/static/js/journal-entry-account-selector.js b/src/accounting/static/js/journal-entry-account-selector.js index 4f43ea6..f387733 100644 --- a/src/accounting/static/js/journal-entry-account-selector.js +++ b/src/accounting/static/js/journal-entry-account-selector.js @@ -25,15 +25,16 @@ /** * The account selector. * + * @extends {BaseCombobox} * @private */ -class JournalEntryAccountSelector { +class JournalEntryAccountSelector extends BaseCombobox { /** * The line item editor * @type {JournalEntryLineItemEditor} */ - lineItemEditor; + #lineItemEditor; /** * Either "debit" or "credit" @@ -47,12 +48,6 @@ class JournalEntryAccountSelector { */ #clearButton - /** - * The query input - * @type {HTMLInputElement} - */ - #query; - /** * The error message when the query has no result * @type {HTMLParagraphElement} @@ -65,15 +60,9 @@ class JournalEntryAccountSelector { */ #optionList; - /** - * The options - * @type {JournalEntryAccountOption[]} - */ - #options; - /** * The more item to show all accounts - * @type {HTMLLIElement} + * @type {MoreItems} */ #more; @@ -90,40 +79,47 @@ class JournalEntryAccountSelector { * @param debitCredit {string} either "debit" or "credit" */ constructor(lineItemEditor, debitCredit) { - this.lineItemEditor = lineItemEditor; - this.#debitCredit = debitCredit; const prefix = `accounting-account-selector-${debitCredit}`; - this.#query = document.getElementById(`${prefix}-query`); + const query = document.getElementById(`${prefix}-query`); + const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(element, lineItemEditor.saveAccount.bind(lineItemEditor))); + super(query, options); + this.#lineItemEditor = lineItemEditor; + this.#debitCredit = debitCredit; this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`); this.#optionList = document.getElementById(`${prefix}-option-list`); - this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element)); - this.#more = document.getElementById(`${prefix}-more`); - this.#clearButton = document.getElementById(`${prefix}-btn-clear`); - - this.#more.onclick = () => { + const moreElement = document.getElementById(`${prefix}-more`); + this.#more = new MoreItems(moreElement); + moreElement.onclick = () => { this.#isShowMore = true; - this.#more.classList.add("d-none"); - this.#filterOptions(); + this.#more.setShown(false); + this.filterOptions(); }; - this.#query.oninput = () => this.#filterOptions(); - this.#clearButton.onclick = () => this.lineItemEditor.clearAccount(); + + this.#clearButton = document.getElementById(`${prefix}-btn-clear`); + this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount(); } /** * Filters the options. * + * @override */ - #filterOptions() { + filterOptions() { + this.shownOptions = []; const codesInUse = this.#getCodesUsedInForm(); let isAnyMatched = false; - for (const option of this.#options) { - if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) { + for (const option of this.options) { + if (option.isMatched(this.#isShowMore, codesInUse, this.query.value)) { option.setShown(true); + this.shownOptions.push(option); isAnyMatched = true; } else { option.setShown(false); } } + if (!this.#isShowMore) { + this.shownOptions.push(this.#more); + } if (!isAnyMatched && this.#isShowMore) { this.#optionList.classList.add("d-none"); this.#queryNoResult.classList.remove("d-none"); @@ -139,9 +135,9 @@ class JournalEntryAccountSelector { * @return {string[]} the account codes that are used in the form */ #getCodesUsedInForm() { - const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit); - if (this.lineItemEditor.account !== null) { - inUse.push(this.lineItemEditor.account.code); + const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit); + if (this.#lineItemEditor.account !== null) { + inUse.push(this.#lineItemEditor.account.code); } return inUse } @@ -151,14 +147,13 @@ class JournalEntryAccountSelector { * */ onOpen() { - this.#query.value = ""; + this.query.value = ""; this.#isShowMore = false; - this.#more.classList.remove("d-none"); - this.#filterOptions(); - for (const option of this.#options) { - option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code); - } - if (this.lineItemEditor.account === null) { + this.#more.setShown(true); + this.filterOptions(); + this.query.removeAttribute("aria-activedescendant"); + this.selectOption(this.shownOptions.find((option) => this.#lineItemEditor.account !== null && option.code === this.#lineItemEditor.account.code)); + if (this.#lineItemEditor.account === null) { this.#clearButton.classList.add("btn-secondary"); this.#clearButton.classList.remove("btn-danger"); this.#clearButton.disabled = true; @@ -169,6 +164,17 @@ class JournalEntryAccountSelector { } } + /** + * Selects an option. + * + * @param option {BaseJournalEntryAccountOption|undefined} the option. + * @override + */ + selectOption(option) { + this.#more.setActive(false); + super.selectOption(option); + } + /** * Returns the account selector instances. * @@ -186,23 +192,37 @@ class JournalEntryAccountSelector { } /** - * An account option + * The base abstract account option * * @private */ -class JournalEntryAccountOption { - - /** - * The element - * @type {HTMLLIElement} - */ - #element; +class BaseJournalEntryAccountOption extends BaseOption { /** * The account code * @type {string} */ - code; + code = ""; + + /** + * Returns whether the account matches the query. + * + * @param isShowMore {boolean} true to show all accounts, or false to show only those in use + * @param codesInUse {string[]} the account codes that are used in the form + * @param query {string} the query term + * @return {boolean} true if the option matches, or false otherwise + */ + isMatched(isShowMore, codesInUse, query) { + return false; + } +} + +/** + * An account option + * + * @private + */ +class JournalEntryAccountOption extends BaseJournalEntryAccountOption { /** * The account title @@ -237,11 +257,11 @@ class JournalEntryAccountOption { /** * Constructs the account in the account selector. * - * @param selector {JournalEntryAccountSelector} the account selector * @param element {HTMLLIElement} the element + * @param save {function(JournalEntryAccountOption): void} the callback to save the option */ - constructor(selector, element) { - this.#element = element; + constructor(element, save) { + super(element); this.code = element.dataset.code; this.title = element.dataset.title; this.text = element.dataset.text; @@ -249,7 +269,7 @@ class JournalEntryAccountOption { this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset"); this.#queryValues = JSON.parse(element.dataset.queryValues); - this.#element.onclick = () => selector.lineItemEditor.saveAccount(this); + element.onclick = () => save(this); } /** @@ -259,6 +279,7 @@ class JournalEntryAccountOption { * @param codesInUse {string[]} the account codes that are used in the form * @param query {string} the query term * @return {boolean} true if the option matches, or false otherwise + * @override */ isMatched(isShowMore, codesInUse, query) { return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query); @@ -292,30 +313,12 @@ class JournalEntryAccountOption { } return false; } - - /** - * Sets whether the option is shown. - * - * @param isShown {boolean} true to show, or false otherwise - */ - setShown(isShown) { - if (isShown) { - this.#element.classList.remove("d-none"); - } else { - this.#element.classList.add("d-none"); - } - } - - /** - * Sets whether the option is active. - * - * @param isActive {boolean} true if active, or false otherwise - */ - setActive(isActive) { - if (isActive) { - this.#element.classList.add("active"); - } else { - this.#element.classList.remove("active"); - } - } +} + +/** + * The more item to show all accounts. + * + * @private + */ +class MoreItems extends BaseJournalEntryAccountOption { } diff --git a/src/accounting/static/js/option-form.js b/src/accounting/static/js/option-form.js index 58ef041..ef17850 100644 --- a/src/accounting/static/js/option-form.js +++ b/src/accounting/static/js/option-form.js @@ -843,15 +843,16 @@ class RecurringItemEditor { /** * The account selector for the recurring item editor. * + * @extends {BaseCombobox} * @private */ -class RecurringAccountSelector { +class RecurringAccountSelector extends BaseCombobox { /** * The recurring item editor * @type {RecurringItemEditor} */ - editor; + #editor; /** * Either "expense" or "income" @@ -859,12 +860,6 @@ class RecurringAccountSelector { */ #expenseIncome; - /** - * The query input - * @type {HTMLInputElement} - */ - #query; - /** * The error message when the query has no result * @type {HTMLParagraphElement} @@ -877,12 +872,6 @@ class RecurringAccountSelector { */ #optionList; - /** - * The account options - * @type {RecurringAccount[]} - */ - #options; - /** * The button to clear the account * @type {HTMLButtonElement} @@ -895,28 +884,31 @@ class RecurringAccountSelector { * @param editor {RecurringItemEditor} the recurring item editor */ constructor(editor) { - this.editor = editor; - this.#expenseIncome = editor.expenseIncome; const prefix = `accounting-recurring-accounting-selector-${editor.expenseIncome}`; - this.#query = document.getElementById(`${prefix}-query`); + const query = document.getElementById(`${prefix}-query`); + const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(element, editor.saveAccount.bind(editor))); + super(query, options); + this.#editor = editor; + this.#expenseIncome = editor.expenseIncome; this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`); this.#optionList = document.getElementById(`${prefix}-option-list`); - this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(this, element)); - this.#clearButton = document.getElementById(`${prefix}-clear`); - this.#query.oninput = () => this.#filterOptions(); - this.#clearButton.onclick = () => this.editor.clearAccount(); + this.#clearButton = document.getElementById(`${prefix}-clear`); + this.#clearButton.onclick = () => this.#editor.clearAccount(); } /** * Filters the options. * + * @override */ - #filterOptions() { + filterOptions() { + this.shownOptions = []; let isAnyMatched = false; - for (const option of this.#options) { - if (option.isMatched(this.#query.value)) { + for (const option of this.options) { + if (option.isMatched(this.query.value)) { option.setShown(true); + this.shownOptions.push(option); isAnyMatched = true; } else { option.setShown(false); @@ -936,12 +928,11 @@ class RecurringAccountSelector { * */ onOpen() { - this.#query.value = ""; - this.#filterOptions(); - for (const option of this.#options) { - option.setActive(option.code === this.editor.accountCode); - } - if (this.editor.accountCode === null) { + this.query.value = ""; + this.filterOptions(); + this.query.removeAttribute("aria-activedescendant"); + this.selectOption(this.shownOptions.find((option) => option.code === this.#editor.accountCode)); + if (this.#editor.accountCode === null) { this.#clearButton.classList.add("btn-secondary"); this.#clearButton.classList.remove("btn-danger"); this.#clearButton.disabled = true; @@ -958,13 +949,7 @@ class RecurringAccountSelector { * * @private */ -class RecurringAccount { - - /** - * The element - * @type {HTMLLIElement} - */ - #element; +class RecurringAccount extends BaseOption { /** * The account code @@ -987,16 +972,16 @@ class RecurringAccount { /** * Constructs the account in the account selector for the recurring item editor. * - * @param selector {RecurringAccountSelector} the account selector * @param element {HTMLLIElement} the element + * @param save {function(RecurringAccount): void} the callback to save the option */ - constructor(selector, element) { - this.#element = element; + constructor(element, save) { + super(element); this.code = element.dataset.code; this.text = element.dataset.text; this.#queryValues = JSON.parse(element.dataset.queryValues); - this.#element.onclick = () => selector.editor.saveAccount(this); + element.onclick = () => save(this); } /** @@ -1016,30 +1001,4 @@ class RecurringAccount { } return false; } - - /** - * Sets whether the option is shown. - * - * @param isShown {boolean} true to show, or false otherwise - */ - setShown(isShown) { - if (isShown) { - this.#element.classList.remove("d-none"); - } else { - this.#element.classList.add("d-none"); - } - } - - /** - * Sets whether the option is active. - * - * @param isActive {boolean} true if active, or false otherwise - */ - setActive(isActive) { - if (isActive) { - this.#element.classList.add("active"); - } else { - this.#element.classList.remove("active"); - } - } } diff --git a/src/accounting/static/js/original-line-item-selector.js b/src/accounting/static/js/original-line-item-selector.js index d93701b..a31a597 100644 --- a/src/accounting/static/js/original-line-item-selector.js +++ b/src/accounting/static/js/original-line-item-selector.js @@ -25,27 +25,16 @@ /** * The original line item selector. * + * @extends {BaseCombobox} * @private */ -class OriginalLineItemSelector { +class OriginalLineItemSelector extends BaseCombobox { /** * The line item editor * @type {JournalEntryLineItemEditor} */ - lineItemEditor; - - /** - * The prefix of the HTML ID and class names - * @type {string} - */ - #prefix = "accounting-original-line-item-selector"; - - /** - * The query input - * @type {HTMLInputElement} - */ - #query; + #lineItemEditor; /** * The error message when the query has no result @@ -59,12 +48,6 @@ class OriginalLineItemSelector { */ #optionList; - /** - * The options - * @type {OriginalLineItem[]} - */ - #options; - /** * The options by their ID * @type {Object.} @@ -88,16 +71,17 @@ class OriginalLineItemSelector { * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor */ constructor(lineItemEditor) { - this.lineItemEditor = lineItemEditor; - this.#query = document.getElementById(`${this.#prefix}-query`); - this.#queryNoResult = document.getElementById(`${this.#prefix}-option-no-result`); - this.#optionList = document.getElementById(`${this.#prefix}-option-list`); - this.#options = Array.from(document.getElementsByClassName(`${this.#prefix}-option`)).map((element) => new OriginalLineItem(this, element)); + const prefix = "accounting-original-line-item-selector"; + const query = document.getElementById(`${prefix}-query`); + const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new OriginalLineItem(element, lineItemEditor.saveOriginalLineItem.bind(lineItemEditor), lineItemEditor.form)); + super(query, options); + this.#lineItemEditor = lineItemEditor; + this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`); + this.#optionList = document.getElementById(`${prefix}-option-list`); this.#optionById = {}; - for (const option of this.#options) { + for (const option of this.options) { this.#optionById[option.id] = option; } - this.#query.oninput = () => this.#filterOptions(); } /** @@ -127,7 +111,7 @@ class OriginalLineItemSelector { * */ #updateNetBalances() { - const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem); + const otherLineItems = this.#lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.#lineItemEditor.lineItem); const otherOffsets = {} for (const otherLineItem of otherLineItems) { const otherOriginalLineItemId = otherLineItem.originalLineItemId; @@ -140,7 +124,7 @@ class OriginalLineItemSelector { } otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount); } - for (const option of this.#options) { + for (const option of this.options) { if (option.id in otherOffsets) { option.updateNetBalance(otherOffsets[option.id]); } else { @@ -152,12 +136,15 @@ class OriginalLineItemSelector { /** * Filters the options. * + * @override */ - #filterOptions() { + filterOptions() { + this.shownOptions = []; let isAnyMatched = false; - for (const option of this.#options) { - if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) { + for (const option of this.options) { + if (option.isMatched(this.#debitCredit, this.#currencyCode, this.query.value)) { option.setShown(true); + this.shownOptions.push(option); isAnyMatched = true; } else { option.setShown(false); @@ -177,14 +164,13 @@ class OriginalLineItemSelector { * */ onOpen() { - this.#currencyCode = this.lineItemEditor.currencyCode; - this.#debitCredit = this.lineItemEditor.debitCredit; - for (const option of this.#options) { - option.setActive(option.id === this.lineItemEditor.originalLineItemId); - } - this.#query.value = ""; + this.#currencyCode = this.#lineItemEditor.currencyCode; + this.#debitCredit = this.#lineItemEditor.debitCredit; + this.query.value = ""; this.#updateNetBalances(); - this.#filterOptions(); + this.filterOptions(); + this.query.removeAttribute("aria-activedescendant"); + this.selectOption(this.shownOptions.find((option) => option.id === this.#lineItemEditor.originalLineItemId)); } } @@ -193,7 +179,7 @@ class OriginalLineItemSelector { * * @private */ -class OriginalLineItem { +class OriginalLineItem extends BaseOption { /** * The journal entry form @@ -201,12 +187,6 @@ class OriginalLineItem { */ #form; - /** - * The element - * @type {HTMLLIElement} - */ - #element; - /** * The ID * @type {string} @@ -276,12 +256,13 @@ class OriginalLineItem { /** * Constructs an original line item. * - * @param selector {OriginalLineItemSelector} the original line item selector * @param element {HTMLLIElement} the element + * @param save {function(OriginalLineItem): void} the callback to save the option + * @param form {JournalEntryForm} the journal entry form */ - constructor(selector, element) { - this.#form = selector.lineItemEditor.form; - this.#element = element; + constructor(element, save, form) { + super(element); + this.#form = form; this.id = element.dataset.id; this.date = element.dataset.date; this.#debitCredit = element.dataset.debitCredit; @@ -293,7 +274,8 @@ class OriginalLineItem { this.netBalanceText = document.getElementById(`accounting-original-line-item-selector-option-${this.id}-net-balance`); this.text = element.dataset.text; this.#queryValues = JSON.parse(element.dataset.queryValues); - this.#element.onclick = () => selector.lineItemEditor.saveOriginalLineItem(this); + + element.onclick = () => save(this); } /** @@ -382,30 +364,4 @@ class OriginalLineItem { const whole = Number(this.netBalance.minus(frac)); return String(whole) + String(frac).substring(1); } - - /** - * Sets whether the option is shown. - * - * @param isShown {boolean} true to show, or false otherwise - */ - setShown(isShown) { - if (isShown) { - this.#element.classList.remove("d-none"); - } else { - this.#element.classList.add("d-none"); - } - } - - /** - * Sets whether the option is active. - * - * @param isActive {boolean} true if active, or false otherwise - */ - setActive(isActive) { - if (isActive) { - this.#element.classList.add("active"); - } else { - this.#element.classList.remove("active"); - } - } } diff --git a/src/accounting/templates/accounting/account/include/form.html b/src/accounting/templates/accounting/account/include/form.html index 711301d..eb6de4d 100644 --- a/src/accounting/templates/accounting/account/include/form.html +++ b/src/accounting/templates/accounting/account/include/form.html @@ -22,6 +22,7 @@ First written: 2023/2/1 {% extends "accounting/base.html" %} {% block accounting_scripts %} + {% endblock %} @@ -92,16 +93,16 @@ First written: 2023/2/1 diff --git a/src/accounting/templates/accounting/journal-entry/include/form.html b/src/accounting/templates/accounting/journal-entry/include/form.html index 9a6d635..1b4422c 100644 --- a/src/accounting/templates/accounting/journal-entry/include/form.html +++ b/src/accounting/templates/accounting/journal-entry/include/form.html @@ -23,6 +23,7 @@ First written: 2023/2/26 {% block accounting_scripts %} + diff --git a/src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html b/src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html index b35ed7c..8e75a5d 100644 --- a/src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html +++ b/src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html @@ -28,16 +28,16 @@ First written: 2023/2/25