diff --git a/resources/metronic/app/layouts/base.js b/resources/metronic/app/layouts/base.js new file mode 100644 index 0000000..4dbab0c --- /dev/null +++ b/resources/metronic/app/layouts/base.js @@ -0,0 +1,67 @@ +class KTLayout { + static _isSidebarCollapse() { + return document.body.classList.contains('sidebar-collapse'); + } + + static _handleMegaMenu() { + const megamenuEl = document.querySelector('#megamenu'); + if (!megamenuEl) return; + + const menu = KTMenu.getInstance(megamenuEl); + menu.disable(); + + setTimeout(() => { + menu.enable(); + }, 500); + } + + static _handleSidebar() { + const sidebarToggle = KTToggle.getInstance(this.sidebarToggleEl); + sidebarToggle?.on('toggle', () => { + this.sidebarEl.classList.add('animating'); + + this._handleMegaMenu(); + + KTDom.transitionEnd(this.sidebarEl, () => { + this.sidebarEl.classList.remove('animating'); + }); + }); + } + + static _handleSidebarMenu() { + const menuEl = document.querySelector('#sidebar_menu'); + const scrollableEl = document.querySelector('#sidebar_scrollable'); + const menuActiveItemEl = menuEl.querySelector(".menu-item.active"); + + if (!menuActiveItemEl || KTDom.isVisibleInParent(menuActiveItemEl, scrollableEl)) { + return; + } + + scrollableEl.scroll({ + top: KTDom.getRelativeTopPosition(menuActiveItemEl, scrollableEl) - 100, + behavior: 'instant' + }); + } + + static init() { + this.sidebarEl = document.querySelector('#sidebar'); + this.sidebarWrapperEl = document.querySelector('#sidebar_wrapper'); + this.headerEl = document.querySelector('#header'); + this.sidebarToggleEl = document.querySelector('#sidebar_toggle'); + + if (this.sidebarEl && this.sidebarToggleEl) { + this._handleSidebar(); + this._handleSidebarMenu(); + } + } + + static isSidebarCollapse() { + return this._isSidebarCollapse(); + } +} + +KTDom.ready(() => { + KTLayout.init(); +}); + +export default KTLayout; \ No newline at end of file diff --git a/resources/metronic/core/components/accordion/accordion.ts b/resources/metronic/core/components/accordion/accordion.ts new file mode 100644 index 0000000..5d79cb1 --- /dev/null +++ b/resources/metronic/core/components/accordion/accordion.ts @@ -0,0 +1,171 @@ +import KTData from '../../helpers/data'; +import KTDom from '../../helpers/dom'; +import KTEventHandler from '../../helpers/event-handler'; +import KTComponent from '../component'; +import { KTAccordionInterface, KTAccordionConfigInterface } from './types'; + +export class KTAccordion extends KTComponent implements KTAccordionInterface { + protected override _name: string = 'accordion'; + protected override _defaultConfig: KTAccordionConfigInterface = { + hiddenClass: 'hidden', + activeClass: 'active', + expandAll: false + }; + protected override _config: KTAccordionConfigInterface = this._defaultConfig; + protected _accordionElements: NodeListOf; + + constructor(element: HTMLElement, config?: KTAccordionConfigInterface) { + super(); + + if (KTData.has(element as HTMLElement, this._name)) return; + + this._init(element); + this._buildConfig(config); + this._handlers(); + } + + protected _handlers(): void { + KTEventHandler.on(this._element, '[data-accordion-toggle]', 'click', (event: Event, target: HTMLElement) => { + event.preventDefault(); + const accordionElement = target.closest('[data-accordion-item]') as HTMLElement; + + if (accordionElement) this._toggle(accordionElement); + }); + } + + protected _toggle(accordionElement: HTMLElement): void { + const payload = { cancel: false }; + this._fireEvent('toggle', payload); + this._dispatchEvent('toggle', payload); + if (payload.cancel === true) { + return; + } + + if (accordionElement.classList.contains('active')) { + this._hide(accordionElement); + } else { + this._show(accordionElement); + } + } + + protected _show(accordionElement: HTMLElement): void { + if (accordionElement.hasAttribute('animating') || accordionElement.classList.contains(this._getOption('activeClass') as string)) return; + + const toggleElement = KTDom.child(accordionElement, '[data-accordion-toggle]'); + if (!toggleElement) return; + + const contentElement = KTDom.getElement(toggleElement.getAttribute('data-accordion-toggle')); + if (!contentElement) return; + + const payload = { cancel: false }; + this._fireEvent('show', payload); + this._dispatchEvent('show', payload); + if (payload.cancel === true) { + return; + } + + if (this._getOption('expandAll') as boolean === false) { + this._hideSiblings(accordionElement); + } + + accordionElement.setAttribute('aria-expanded', 'true'); + accordionElement.classList.add(this._getOption('activeClass') as string); + + accordionElement.setAttribute('animating', 'true'); + contentElement.classList.remove(this._getOption('hiddenClass') as string); + contentElement.style.height = `0px`; + KTDom.reflow(contentElement); + contentElement.style.height = `${contentElement.scrollHeight}px`; + + KTDom.transitionEnd(contentElement, () => { + accordionElement.removeAttribute('animating'); + contentElement.style.height = '' + + this._fireEvent('shown'); + this._dispatchEvent('shown'); + }); + } + + protected _hide(accordionElement: HTMLElement): void { + if (accordionElement.hasAttribute('animating') || !accordionElement.classList.contains(this._getOption('activeClass') as string)) return; + + const toggleElement = KTDom.child(accordionElement, '[data-accordion-toggle]'); + if (!toggleElement) return; + + const contentElement = KTDom.getElement(toggleElement.getAttribute('data-accordion-toggle')); + if (!contentElement) return; + + const payload = { cancel: false }; + this._fireEvent('hide', payload); + this._dispatchEvent('hide', payload); + if (payload.cancel === true) { + return; + } + + accordionElement.setAttribute('aria-expanded', 'false'); + + contentElement.style.height = `${contentElement.scrollHeight}px`; + KTDom.reflow(contentElement); + contentElement.style.height = '0px'; + accordionElement.setAttribute('animating', 'true'); + + KTDom.transitionEnd(contentElement, () => { + accordionElement.removeAttribute('animating'); + accordionElement.classList.remove(this._getOption('activeClass') as string); + contentElement.classList.add(this._getOption('hiddenClass') as string); + + this._fireEvent('hidden'); + this._dispatchEvent('hidden'); + }); + } + + protected _hideSiblings(accordionElement: HTMLElement): void { + const siblings = KTDom.siblings(accordionElement); + + siblings?.forEach((sibling) => { + this._hide(sibling as HTMLElement); + }); + } + + public show(accordionElement: HTMLElement): void { + this._show(accordionElement); + } + + public hide(accordionElement: HTMLElement): void { + this._hide(accordionElement); + } + + public toggle(accordionElement: HTMLElement): void { + this._toggle(accordionElement); + } + + public static getInstance(element: HTMLElement): KTAccordion { + if (!element) return null; + + if (KTData.has(element, 'accordion')) { + return KTData.get(element, 'accordion') as KTAccordion; + } + + if (element.getAttribute('data-accordion') === "true") { + return new KTAccordion(element); + } + + return null; + } + + public static getOrCreateInstance(element: HTMLElement, config?: KTAccordionConfigInterface): KTAccordion { + return this.getInstance(element) || new KTAccordion(element, config); + } + + public static createInstances(): void { + const elements = document.querySelectorAll('[data-accordion="true"]'); + + elements.forEach((element) => { + new KTAccordion(element as HTMLElement); + }); + } + + public static init(): void { + KTAccordion.createInstances(); + } +} diff --git a/resources/metronic/core/components/accordion/index.ts b/resources/metronic/core/components/accordion/index.ts new file mode 100644 index 0000000..a1411be --- /dev/null +++ b/resources/metronic/core/components/accordion/index.ts @@ -0,0 +1,2 @@ +export { KTAccordion } from './accordion'; +export type { KTAccordionConfigInterface, KTAccordionInterface } from './types'; diff --git a/resources/metronic/core/components/accordion/types.ts b/resources/metronic/core/components/accordion/types.ts new file mode 100644 index 0000000..1ee4894 --- /dev/null +++ b/resources/metronic/core/components/accordion/types.ts @@ -0,0 +1,11 @@ +export interface KTAccordionConfigInterface { + hiddenClass: string; + activeClass: string + expandAll: boolean; +} + +export interface KTAccordionInterface { + show(accordionElement: HTMLElement): void; + hide(accordionElement: HTMLElement): void; + toggle(accordionElement: HTMLElement): void; +} \ No newline at end of file diff --git a/resources/metronic/core/components/collapse/collapse.ts b/resources/metronic/core/components/collapse/collapse.ts new file mode 100644 index 0000000..8f3e7d4 --- /dev/null +++ b/resources/metronic/core/components/collapse/collapse.ts @@ -0,0 +1,179 @@ +/* eslint-disable max-len */ +/* eslint-disable require-jsdoc */ + +import KTData from '../../helpers/data'; +import KTDom from '../../helpers/dom'; +import KTComponent from '../component'; +import { KTCollapseInterface, KTCollapseConfigInterface } from './types'; + +export class KTCollapse extends KTComponent implements KTCollapseInterface { + protected override _name: string = 'collapse'; + protected override _defaultConfig: KTCollapseConfigInterface = { + hiddenClass: 'hidden', + activeClass: 'active', + target: '' + }; + protected override _config: KTCollapseConfigInterface = this._defaultConfig; + protected _isAnimating: boolean = false; + protected _targetElement: HTMLElement; + + constructor(element: HTMLElement, config?: KTCollapseConfigInterface) { + super(); + + if (KTData.has(element as HTMLElement, this._name)) return; + + this._init(element); + this._buildConfig(config); + + this._targetElement = this._getTargetElement(); + if (!this._targetElement) { + return; + } + + this._handlers(); + } + + private _getTargetElement(): HTMLElement | null { + return ( + KTDom.getElement(this._element.getAttribute('data-collapse') as string) || + KTDom.getElement(this._getOption('target') as string) + ); + } + + protected _isOpen(): boolean { + return this._targetElement.classList.contains(this._getOption('activeClass') as string); + } + + protected _handlers(): void { + this._element.addEventListener('click', (event: Event) => { + event.preventDefault(); + + this._toggle(); + }); + } + + protected _expand(): void { + if (this._isAnimating || this._isOpen()) { + return; + } + + const payload = { cancel: false }; + this._fireEvent('expand', payload); + this._dispatchEvent('expand', payload); + if (payload.cancel === true) { + return; + } + + if (this._element) { + this._element.setAttribute('aria-expanded', 'true'); + this._element.classList.add(this._getOption('activeClass') as string); + } + this._targetElement.classList.remove(this._getOption('hiddenClass') as string); + this._targetElement.classList.add(this._getOption('activeClass') as string); + + this._targetElement.style.height = '0px'; + this._targetElement.style.overflow = 'hidden'; + KTDom.reflow(this._targetElement); + this._targetElement.style.height = `${this._targetElement.scrollHeight}px`; + this._isAnimating = true; + + KTDom.transitionEnd(this._targetElement, () => { + this._isAnimating = false; + this._targetElement.style.height = ''; + this._targetElement.style.overflow = ''; + + this._fireEvent('expanded'); + this._dispatchEvent('expanded'); + }); + } + + protected _collapse(): void { + if (this._isAnimating || !this._isOpen()) { + return; + } + + const payload = { cancel: false }; + this._fireEvent('collapse', payload); + this._dispatchEvent('collapse', payload); + if (payload.cancel === true) { + return; + } + + if (!this._element) return; + this._element.setAttribute('aria-expanded', 'false'); + this._element.classList.remove(this._getOption('activeClass') as string); + this._targetElement.classList.remove(this._getOption('activeClass') as string); + + this._targetElement.style.height = `${this._targetElement.scrollHeight}px`; + KTDom.reflow(this._targetElement); + this._targetElement.style.height = `0px`; + this._targetElement.style.overflow = 'hidden'; + this._isAnimating = true; + + KTDom.transitionEnd(this._targetElement, () => { + this._isAnimating = false; + this._targetElement.classList.add(this._getOption('hiddenClass') as string); + this._targetElement.style.overflow = ''; + + this._fireEvent('collapsed'); + this._dispatchEvent('collapsed'); + }); + } + + protected _toggle(): void { + const payload = { cancel: false }; + this._fireEvent('toggle', payload); + this._dispatchEvent('toggle', payload); + if (payload.cancel === true) { + return; + } + + if (this._isOpen()) { + this._collapse(); + } else { + this._expand(); + } + } + + public expand(): void { + return this._expand(); + } + + public collapse(): void { + return this._collapse(); + } + + public isOpen(): boolean { + return this._isOpen(); + } + + public static getInstance(element: HTMLElement): KTCollapse { + if (!element) return null; + + if (KTData.has(element, 'collapse')) { + return KTData.get(element, 'collapse') as KTCollapse; + } + + if (element.getAttribute('data-collapse') !== "false") { + return new KTCollapse(element); + } + + return null; + } + + public static getOrCreateInstance(element: HTMLElement, config?: KTCollapseConfigInterface): KTCollapse { + return this.getInstance(element) || new KTCollapse(element, config); + } + + public static createInstances(): void { + const elements = document.querySelectorAll('[data-collapse]:not([data-collapse="false"]'); + + elements.forEach((element) => { + new KTCollapse(element as HTMLElement); + }); + } + + public static init(): void { + KTCollapse.createInstances(); + } +} diff --git a/resources/metronic/core/components/collapse/index.ts b/resources/metronic/core/components/collapse/index.ts new file mode 100644 index 0000000..d102a25 --- /dev/null +++ b/resources/metronic/core/components/collapse/index.ts @@ -0,0 +1,2 @@ +export { KTCollapse } from './collapse'; +export type { KTCollapseConfigInterface, KTCollapseInterface } from './types'; diff --git a/resources/metronic/core/components/collapse/types.ts b/resources/metronic/core/components/collapse/types.ts new file mode 100644 index 0000000..c57213e --- /dev/null +++ b/resources/metronic/core/components/collapse/types.ts @@ -0,0 +1,11 @@ +export interface KTCollapseConfigInterface { + hiddenClass: string; + activeClass: string; + target: string; +} + +export interface KTCollapseInterface { + collapse(): void; + expand(): void; + isOpen(): boolean; +} \ No newline at end of file diff --git a/resources/metronic/core/components/component.ts b/resources/metronic/core/components/component.ts new file mode 100644 index 0000000..f30d7ab --- /dev/null +++ b/resources/metronic/core/components/component.ts @@ -0,0 +1,147 @@ +/* eslint-disable guard-for-in */ +/* eslint-disable max-len */ +/* eslint-disable require-jsdoc */ + +declare global { + interface Window { + KTGlobalComponentsConfig: object; + } +} + +import KTData from '../helpers/data'; +import KTDom from '../helpers/dom'; +import KTUtils from '../helpers/utils'; +import KTGlobalComponentsConfig from './config'; +import {KTBreakpointType, KTOptionType} from '../types'; + +export default class KTComponent { + protected _name: string; + protected _defaultConfig: object; + protected _config: object; + protected _events: Map>; + protected _uid: string | null = null; + protected _element: HTMLElement | null = null; + + protected _init(element: HTMLElement | null) { + element = KTDom.getElement(element); + + if (!element) { + return; + } + + this._element = element; + this._events = new Map(); + this._uid = KTUtils.geUID(this._name); + + KTData.set(this._element, this._name, this); + } + + protected _fireEvent(eventType: string, payload: object = null): void { + this._events.get(eventType)?.forEach((callable) => { + callable(payload); + }); + } + + protected _dispatchEvent(eventType: string, payload: object = null): void { + const event = new CustomEvent(eventType, { + detail: { payload }, + bubbles: true, + cancelable: true, + composed: false, + }); + + if (!this._element) return; + this._element.dispatchEvent(event); + } + + protected _getOption(name: string): KTOptionType { + const value = this._config[name as keyof object]; + + if (value && (typeof value) === 'string') { + return this._getResponsiveOption(value); + } else { + return value; + } + } + + protected _getResponsiveOption(value: string): KTOptionType { + let result = null; + const width = KTDom.getViewPort().width; + const parts = String(value).split('|'); + + if (parts.length > 1) { + parts.every((part) => { + if (part.includes(':')) { + const [breakpointKey, breakpointValue] = part.split(':'); + const breakpoint = KTUtils.getBreakpoint(breakpointKey as KTBreakpointType); + + if (breakpoint <= width) { + result = breakpointValue; + return false; + } + } else { + result = part; + } + + return true; + }); + } else { + result = value; + } + + result = KTUtils.parseDataAttribute(result); + + return result; + } + + protected _getGlobalConfig(): object { + if (window.KTGlobalComponentsConfig && (window.KTGlobalComponentsConfig as object)[this._name as keyof object]) { + return (window.KTGlobalComponentsConfig as object)[this._name as keyof object] as object; + } else if (KTGlobalComponentsConfig && (KTGlobalComponentsConfig as object)[this._name as keyof object]) { + return (KTGlobalComponentsConfig as object)[this._name as keyof object] as object; + } else { + return {}; + } + } + + protected _buildConfig(config: object = {}): void { + if (!this._element) return; + + this._config = { + ...this._defaultConfig, + ...this._getGlobalConfig(), + ...KTDom.getDataAttributes(this._element, this._name), + ...config, + }; + } + + public dispose(): void { + if (!this._element) return; + KTData.remove(this._element, this._name); + } + + public on(eventType: string, callback: CallableFunction): string { + const eventId = KTUtils.geUID(); + + if (!this._events.get(eventType)) { + this._events.set(eventType, new Map()); + } + + this._events.get(eventType).set(eventId, callback); + + return eventId; + } + + public off(eventType: string, eventId: string): void { + this._events.get(eventType)?.delete(eventId); + } + + public getOption(name: string): KTOptionType { + return this._getOption(name as keyof object); + } + + public getElement(): HTMLElement { + if (!this._element) return null; + return this._element; + } +} diff --git a/resources/metronic/core/components/config.ts b/resources/metronic/core/components/config.ts new file mode 100644 index 0000000..1ee2016 --- /dev/null +++ b/resources/metronic/core/components/config.ts @@ -0,0 +1,25 @@ +/* eslint-disable max-len */ +const KTGlobalComponentsConfig = { + modal: { + backdropClass: 'transition-all duration-300', + }, + drawer: { + backdropClass: 'transition-all duration-300', + hiddenClass: 'hidden' + }, + collapse: { + hiddenClass: 'hidden', + }, + dismiss: { + hiddenClass: 'hidden', + }, + tabs: { + hiddenClass: 'hidden', + }, + accordion: { + hiddenClass: 'hidden', + } +}; + +export default KTGlobalComponentsConfig; + diff --git a/resources/metronic/core/components/config.umd.js b/resources/metronic/core/components/config.umd.js new file mode 100644 index 0000000..fe913c8 --- /dev/null +++ b/resources/metronic/core/components/config.umd.js @@ -0,0 +1,23 @@ +/* eslint-disable max-len */ +window.KTGlobalComponentsConfig = { + modal: { + backdropClass: 'transition-all duration-300', + }, + drawer: { + backdropClass: 'transition-all duration-300', + hiddenClass: 'hidden' + }, + collapse: { + hiddenClass: 'hidden', + }, + dismiss: { + hiddenClass: 'hidden', + }, + tabs: { + hiddenClass: 'hidden', + }, + accordion: { + hiddenClass: 'hidden', + } +}; + diff --git a/resources/metronic/core/components/constants.ts b/resources/metronic/core/components/constants.ts new file mode 100644 index 0000000..70b72cb --- /dev/null +++ b/resources/metronic/core/components/constants.ts @@ -0,0 +1,11 @@ +export const KT_ACCESSIBILITY_KEYS = [ + 'ArrowUp', + 'ArrowLeft', + 'ArrowDown', + 'ArrowRight', + 'Home', + 'End', + 'Escape', + 'Enter', + 'Tab', +]; \ No newline at end of file diff --git a/resources/metronic/core/components/datatable/datatable.ts b/resources/metronic/core/components/datatable/datatable.ts new file mode 100644 index 0000000..96b5e88 --- /dev/null +++ b/resources/metronic/core/components/datatable/datatable.ts @@ -0,0 +1,1580 @@ +import KTComponent from '../component'; +import { KTDataTableDataInterface, KTDataTableInterface, KTDataTableConfigInterface as KTDataTableConfigInterface, KTDataTableSortOrderInterface, KTDataTableStateInterface, KTDataTableColumnFilterInterface, KTDataTableAttributeInterface } from './types'; +import KTEventHandler from '../../helpers/event-handler'; +import { KTDatatableCheckChangePayloadInterface } from './types'; +import KTUtils from '../../helpers/utils'; +import KTComponents from '../../index'; +import KTData from '../../helpers/data'; + +/** + * Custom DataTable plugin class with server-side API, pagination, and sorting + * @classdesc A custom KTComponent class that integrates server-side API, pagination, and sorting functionality into a table. + * It supports fetching data from a server-side API, pagination, and sorting of the fetched data. + * @class + * @extends {KTComponent} + * @param {HTMLElement} element The table element + * @param {KTDataTableConfigInterface} [config] Additional configuration options + */ +export class KTDataTable extends KTComponent implements KTDataTableInterface { + protected override _name: string = 'datatable'; + protected override _config: KTDataTableConfigInterface; + protected override _defaultConfig: KTDataTableConfigInterface; + + private _tableElement: HTMLTableElement; + private _tbodyElement: HTMLTableSectionElement; + private _theadElement: HTMLTableSectionElement; + + private _infoElement: HTMLElement; + private _sizeElement: HTMLSelectElement; + private _paginationElement: HTMLElement; + + // Checkbox properties + private _headerChecked: boolean; + private _headerCheckElement: HTMLInputElement; + private _targetElements: NodeListOf; + private _checkboxListener = (event: MouseEvent) => { + this._checkboxToggle(event); // Toggle row checkbox state + }; + + // private _searchListener: (value: string) => void; + + private _data: T[] = []; + + constructor(element: HTMLElement, config?: KTDataTableConfigInterface) { + super(); + + if (KTData.has(element as HTMLElement, this._name)) return; + + this._defaultConfig = this._initDefaultConfig(config); + + this._init(element); + this._buildConfig(); + + // Store the instance directly on the element + (element as any).instance = this; + + this._initElements(); + + if (this._config.stateSave === false) { + this._deleteState(); + } + + if (this._config.stateSave) { + this._loadState(); + } + + this._initTableHeader(); + + this._updateData(); + + this._fireEvent('init'); + this._dispatchEvent('init'); + } + + /** + * Initialize default configuration for the datatable + * @param config User-provided configuration options + * @returns Default configuration merged with user-provided options + */ + private _initDefaultConfig(config?: KTDataTableConfigInterface): KTDataTableConfigInterface { + return { + /** + * HTTP method for server-side API call + */ + requestMethod: 'GET', + /** + * Custom HTTP headers for the API request + */ + requestHeaders: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + /** + * Pagination info template + */ + info: '{start}-{end} of {total}', + /** + * Info text when there is no data + */ + infoEmpty: 'No records found', + /** + * Available page sizes + */ + pageSizes: [5, 10, 20, 30, 50], + /** + * Default page size + */ + pageSize: 10, + /** + * Enable or disable pagination more button + */ + pageMore: true, + /** + * Maximum number of pages before enabling pagination more button + */ + pageMoreLimit: 3, + /** + * Pagination button templates + */ + pagination: { + number: { + /** + * CSS classes to be added to the pagination button + */ + class: 'btn', + /** + * Text to be displayed in the pagination button + */ + text: '{page}', + }, + previous: { + /** + * CSS classes to be added to the previous pagination button + */ + class: 'btn', + /** + * Text to be displayed in the previous pagination button + */ + text: '', + }, + next: { + /** + * CSS classes to be added to the next pagination button + */ + class: 'btn', + /** + * Text to be displayed in the next pagination button + */ + text: '', + }, + more: { + /** + * CSS classes to be added to the pagination more button + */ + class: 'btn', + /** + * Text to be displayed in the pagination more button + */ + text: '...', + } + }, + /** + * Sorting options + */ + sort: { + /** + * CSS classes to be added to the sortable headers + */ + classes: { + base: 'sort', + asc: 'asc', + desc: 'desc', + }, + /** + * Local sorting callback function + * Sorts the data array based on the sort field and order + * @param data Data array to be sorted + * @param sortField Property name of the data object to be sorted by + * @param sortOrder Sorting order (ascending or descending) + * @returns Sorted data array + */ + callback: (data: T[], sortField: keyof T | number, sortOrder: KTDataTableSortOrderInterface): T[] => { + /** + * Compares two values by converting them to strings and removing any HTML tags or white spaces + * @param a First value to be compared + * @param b Second value to be compared + * @returns 1 if a > b, -1 if a < b, 0 if a === b + */ + const compareValues = (a: unknown, b: unknown): number => { + const aText = String(a).replace(/<[^>]*>| /g, ''); + const bText = String(b).replace(/<[^>]*>| /g, ''); + return aText > bText ? (sortOrder === 'asc' ? 1 : -1) : (aText < bText ? (sortOrder === 'asc' ? -1 : 1) : 0); + }; + + return data.sort((a, b) => { + const aValue = a[sortField] as unknown; + const bValue = b[sortField] as unknown; + return compareValues(aValue, bValue); + }); + }, + }, + search: { + /** + * Delay in milliseconds before the search function is applied to the data array + * @default 500 + */ + delay: 500, // ms + /** + * Local search callback function + * Filters the data array based on the search string + * @param data Data array to be filtered + * @param search Search string used to filter the data array + * @returns Filtered data array + */ + callback: (data: T[], search: string): T[] => { + if (!data || !search) { + return []; + } + + return data.filter((item: T) => { + if (!item) { + return false; + } + + return Object.values(item).some((value: string | number | boolean) => { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return false; + } + + const valueText = String(value).replace(/<[^>]*>| /g, '').toLowerCase(); + return valueText.includes(search.toLowerCase()); + }); + }); + } + }, + /** + * Loading spinner options + */ + loading: { + /** + * Template to be displayed during data fetching process + */ + template: ` +
+
+ + + + + {content} +
+
+ `, + /** + * Loading text to be displayed in the template + */ + content: 'Loading...' + }, + /** + * Selectors of the elements to be targeted + */ + attributes: { + /** + * Data table element + */ + table: 'table[data-datatable-table="true"]', + /** + * Pagination info element + */ + info: '[data-datatable-info="true"]', + /** + * Page size dropdown element + */ + size: '[data-datatable-size="true"]', + /** + * Pagination element + */ + pagination: '[data-datatable-pagination="true"]', + /** + * Spinner element + */ + spinner: '[data-datatable-spinner="true"]', + /** + * Checkbox element + */ + check: '[data-datatable-check="true"]', + checkbox: '[data-datatable-row-check="true"]' + }, + /** + * Enable or disable state saving + */ + stateSave: true, + checkbox: { + checkedClass: 'checked' + }, + /** + * Private properties + */ + _state: {} as KTDataTableStateInterface, + + ...config + + } as KTDataTableConfigInterface; + } + + /** + * Initialize table, tbody, thead, info, size, and pagination elements + * @returns {void} + */ + private _initElements(): void { + /** + * Data table element + */ + this._tableElement = this._element.querySelector(this._config.attributes.table)!; + /** + * Table body element + */ + this._tbodyElement = this._tableElement.tBodies[0] || this._tableElement.createTBody(); + /** + * Table head element + */ + this._theadElement = this._tableElement.tHead!; + /** + * Pagination info element + */ + this._infoElement = this._element.querySelector(this._config.attributes.info)!; + /** + * Page size dropdown element + */ + this._sizeElement = this._element.querySelector(this._config.attributes.size)!; + /** + * Pagination element + */ + this._paginationElement = this._element.querySelector(this._config.attributes.pagination)!; + } + + /** + * Fetch data from the server or from the DOM if `apiEndpoint` is not defined. + * @returns {Promise} Promise which is resolved after data has been fetched and checkbox plugin initialized. + */ + private async _updateData(): Promise { + this._showSpinner(); // Show spinner before fetching data + + // Fetch data from the DOM and initialize the checkbox plugin + return (typeof this._config.apiEndpoint === 'undefined') + ? this._fetchDataFromLocal().then(this._finalize.bind(this) as () => Promise) + : this._fetchDataFromServer().then(this._finalize.bind(this) as () => Promise); + } + + /** + * Finalize data table after data has been fetched + * @returns {void} + */ + private _finalize(): void { + this._element.classList.add('datatable-initialized'); + + const headerCheckElement = this._element.querySelector(this._config.attributes.check); + if (headerCheckElement) { + this._initChecbox(headerCheckElement); + } + + this._attachSearchEvent(); + + if (typeof KTComponents !== "undefined") { + KTComponents.init(); + } + + /** + * Hide spinner + */ + this._hideSpinner(); + } + + /** + * Attach search event to the search input element + * @returns {void} + */ + private _attachSearchEvent(): void { + const tableId: string = this._tableId(); + const searchElement: HTMLInputElement | null = document.querySelector(`[data-datatable-search="#${tableId}"]`); + + if (searchElement) { + // Check if a debounced search function already exists + if ((searchElement as any)._debouncedSearch) { + // Remove the existing debounced event listener + searchElement.removeEventListener('keyup', (searchElement as any)._debouncedSearch); + } + + // Create a new debounced search function + const debouncedSearch = this._debounce(() => { + this.search(searchElement.value); + }, this._config.search.delay); + + // Store the new debounced function as a property of the element + (searchElement as any)._debouncedSearch = debouncedSearch; + + // Add the new debounced event listener + searchElement.addEventListener('keyup', debouncedSearch); + } + } + + /** + * Initialize the checkbox plugin + * @param {HTMLInputElement} headerCheckElement - The header checkbox element + * @returns {void} + */ + private _initChecbox(headerCheckElement: HTMLInputElement): void { + this._headerCheckElement = headerCheckElement; + this._headerChecked = headerCheckElement.checked; + + this._targetElements = this._element.querySelectorAll(this._config.attributes.checkbox) as NodeListOf; + + this._checkboxHandler(); + } + + /** + * Fetch data from the DOM + * Fetch data from the table element and save it to the `originalData` state property. + * This method is used when the data is not fetched from the server via an API endpoint. + */ + private async _fetchDataFromLocal(): Promise { + const { sortField, sortOrder, page, pageSize, search } = this.getState(); + let { originalData } = this.getState(); + + // If the table element or the original data is not defined, bail + if (!this._tableElement || originalData === undefined || this._tableConfigInvalidate() || this._localTableHeaderInvalidate() || this._localTableContentInvalidate()) { + this._deleteState(); + + const { originalData, originalDataAttributes } = this._localExtractTableContent(); + + this._config._state.originalData = originalData; + this._config._state.originalDataAttributes = originalDataAttributes; + } + + // Update the original data variable + originalData = this.getState().originalData; + + // Clone the original data + let _temp = this._data = [...originalData] as T[]; + + if (search) { + _temp = this._data = this._config.search.callback.call(this, this._data, search) as T[]; + } + + // If sorting is defined, sort the data + if (sortField !== undefined && sortOrder !== undefined && sortOrder !== '') { + if (typeof this._config.sort.callback === 'function') { + this._data = this._config.sort.callback.call(this, this._data, sortField as string, sortOrder) as T[]; + } + } + + // If there is data, slice it to the current page size + if (this._data?.length > 0) { + // Calculate the start and end indices for the current page + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + + this._data = this._data.slice(startIndex, endIndex) as T[]; + } + + // Determine number of total rows + this._config._state.totalItems = _temp.length; + + // Draw the data + await this._draw(); + } + + /** + * Checks if the table content has been invalidated by comparing the current checksum of the table body + * with the stored checksum in the state. If the checksums are different, the state is updated with the + * new checksum and `true` is returned. Otherwise, `false` is returned. + * + * @returns {boolean} `true` if the table content has been invalidated, `false` otherwise. + */ + private _localTableContentInvalidate(): boolean { + const checksum: string = KTUtils.checksum(JSON.stringify(this._tbodyElement.innerHTML)); + if (this.getState()._contentChecksum !== checksum) { + this._config._state._contentChecksum = checksum; + return true; + } + return false; + } + + private _tableConfigInvalidate(): boolean { + // Remove _data and _state from config + const { _data, _state, ...restConfig } = this._config; + const checksum: string = KTUtils.checksum(JSON.stringify(restConfig)); + if (_state._configChecksum !== checksum) { + this._config._state._configChecksum = checksum; + return true; + } + return false; + } + + /** + * Extracts the table content and returns it as an object containing an array of original data and an array of original data attributes. + * + * @returns {{originalData: T[], originalDataAttributes: KTDataTableAttributeInterface[]}} - An object containing an array of original data and an array of original data attributes. + */ + private _localExtractTableContent(): { originalData: T[]; originalDataAttributes: KTDataTableAttributeInterface[]; } { + const originalData: T[] = []; + const originalDataAttributes: KTDataTableAttributeInterface[] = []; + + const rows = this._tbodyElement.querySelectorAll('tr'); + + rows.forEach((row: HTMLTableRowElement) => { + const dataRow: T = {} as T; + const dataRowAttribute: KTDataTableAttributeInterface = {} as KTDataTableAttributeInterface; + + // Loop through each cell in the row + row.querySelectorAll('td').forEach((td: HTMLTableCellElement, index: number) => { + const attributes: { [key: string]: string; } = {}; + + // Copy all attributes to the cell data + Array.from(td.attributes).forEach((attr: Attr) => { + attributes[attr.name] = attr.value; + }); + + // Set the data for the current row and cell + dataRow[index as keyof T] = td.innerHTML?.trim() as T[keyof T]; + dataRowAttribute[index] = attributes; + }); + + // If the row has any data, add it to the original data array + if (Object.keys(dataRow).length > 0) { + originalData.push(dataRow); + originalDataAttributes.push(dataRowAttribute); + } + }); + + return { originalData, originalDataAttributes }; + } + + /** + * Check if the table header is invalidated + * @returns {boolean} - Returns true if the table header is invalidated, false otherwise + */ + private _localTableHeaderInvalidate(): boolean { + const { originalData } = this.getState(); + const currentTableHeaders = this._getTableHeaders()?.length || 0; + const totalColumns = originalData.length ? Object.keys(originalData[0]).length : 0; + + return currentTableHeaders !== totalColumns; + } + + /** + * Fetch data from the server + */ + private async _fetchDataFromServer(): Promise { + this._fireEvent('fetch'); + this._dispatchEvent('fetch'); + + const queryParams = this._getQueryParamsForFetchRequest(); + const response = await this._performFetchRequest(queryParams); + + let responseData = null; + + try { + responseData = await response.json(); + } catch (error) { + this._noticeOnTable('Error parsing API response as JSON: ' + String(error)); + return; + } + + this._fireEvent('fetched', { response: responseData }); + this._dispatchEvent('fetched', { response: responseData }); + + // Use the mapResponse function to transform the data if provided + if (typeof this._config.mapResponse === 'function') { + responseData = this._config.mapResponse.call(this, responseData); + } + + this._data = responseData.data; + + this._config._state.totalItems = responseData.totalCount; + + await this._draw(); + } + + /** + * Get the query params for a fetch request + * @returns The query params for the fetch request + */ + private _getQueryParamsForFetchRequest(): URLSearchParams { + // Get the current state of the datatable + const { page, pageSize, sortField, sortOrder, filters, search } = this.getState(); + + // Create a new URLSearchParams object to store the query params + let queryParams = new URLSearchParams(); + + // Add the current page number and page size to the query params + queryParams.set('page', String(page)); + queryParams.set('size', String(pageSize)); + + // If there is a sort order and field set, add them to the query params + if (sortOrder !== undefined) { + queryParams.set('sortOrder', String(sortOrder)); + } + + if (sortField !== undefined) { + queryParams.set('sortField', String(sortField)); + } + + // If there are any filters set, add them to the query params + if (Array.isArray(filters) && filters.length) { + queryParams.set('filters', JSON.stringify(filters.map((filter: KTDataTableColumnFilterInterface) => ({ + // Map the filter object to a simpler object with just the necessary properties + column: filter.column, + type: filter.type, + value: filter.value, + })))); + } + + if (search) { + queryParams.set('search', typeof search === 'object' ? JSON.stringify(search) : search); + } + + // If a mapRequest function is provided, call it with the query params object + if (typeof this._config.mapRequest === 'function') { + queryParams = this._config.mapRequest.call(this, queryParams); + } + + // Return the query params object + return queryParams; + } + + + private async _performFetchRequest(queryParams: URLSearchParams): Promise { + let requestMethod: RequestInit['method'] = this._config.requestMethod; + let requestBody: RequestInit['body'] | undefined = undefined; + + // If the request method is POST, send the query params as the request body + if (requestMethod === 'POST') { + requestBody = queryParams; + } else if (requestMethod === 'GET') { + // If the request method is GET, append the query params to the API endpoint + const apiEndpointWithQueryParams = new URL(this._config.apiEndpoint); + apiEndpointWithQueryParams.search = queryParams.toString(); + this._config.apiEndpoint = apiEndpointWithQueryParams.toString(); + } + + return fetch(this._config.apiEndpoint, { + method: requestMethod, + body: requestBody, + headers: this._config.requestHeaders + }).catch(error => { + this._noticeOnTable('Error performing fetch request: ' + String(error)); + throw error; + }); + } + + /** + * Update the table and pagination controls with new data + * @returns {Promise} A promise that resolves when the table and pagination controls are updated + */ + private async _draw(): Promise { + this._config._state.totalPages = Math.ceil(this.getState().totalItems / this.getState().pageSize) || 0; + + this._fireEvent('draw'); + this._dispatchEvent('draw'); + + this._dispose(); + + // Update the table and pagination controls + if (this._theadElement && this._tbodyElement) { + this._updateTable(); + } + + if (this._infoElement && this._paginationElement) { + this._updatePagination(); + } + + this._fireEvent('drew'); + this._dispatchEvent('drew'); + + this._hideSpinner(); // Hide spinner after data is fetched + + if (this._config.stateSave) { + this._saveState(); + } + } + + /** + * Update the HTML table with new data + * @returns {HTMLTableSectionElement} The new table body element + */ + private _updateTable(): HTMLTableSectionElement { + // Clear the existing table contents using a more efficient method + while (this._tableElement.tBodies.length) { + this._tableElement.removeChild(this._tableElement.tBodies[0]); + } + + // Create the table body with the new data + const tbodyElement = this._tableElement.createTBody() as HTMLTableSectionElement; + + this._updateTableContent(tbodyElement); + + return tbodyElement; + } + + /** + * Initialize the table header + * Add sort event listener to all sortable columns + */ + private _initTableHeader(): void { + if (!this._theadElement) { + return; + } + + // Set the initial sort icon + this._setSortIcon(this.getState().sortField, this.getState().sortOrder); + + // Get all the table headers + const headers = this._getTableHeaders(); + + // Loop through each table header + headers.forEach(header => { + // If the sort class is not found, it's not a sortable column + if (!header.querySelector(`.${this._config.sort.classes.base}`)) { + return; + } + + const sortAttribute = header.getAttribute('data-datatable-column-sort') || header.getAttribute('data-datatable-column'); + const sortField = sortAttribute ? (sortAttribute as keyof T) : header.cellIndex as keyof T; + + // Add click event listener to the header + header.addEventListener('click', () => { + const sortOrder = this._toggleSortOrder(sortField); + + this._setSortIcon(sortField, sortOrder); + this._sort(sortField); + }); + }); + } + + /** + * Returns an array of table headers as HTMLTableCellElement. + * @returns {HTMLTableCellElement[]} An array of table headers. + */ + private _getTableHeaders(): HTMLTableCellElement[] { + if (!this._theadElement) { + return []; + } + return Array.from(this._theadElement.querySelectorAll('th')) as HTMLTableCellElement[]; + } + + /** + * Sets the sort icon in the table header + * @param sortField The field to set the sort icon for + * @param sortOrder The sort order (ascending or descending) + */ + private _setSortIcon(sortField: keyof T, sortOrder: KTDataTableSortOrderInterface): void { + + const sortClass = sortOrder ? (sortOrder === 'asc' ? this._config.sort.classes.asc : this._config.sort.classes.desc) : ''; + + // Get the appropriate table header element + const th = typeof sortField === 'number' + ? this._theadElement.querySelectorAll('th')[sortField] + : this._theadElement.querySelector(`th[data-datatable-column="${String(sortField)}"], th[data-datatable-column-sort="${String(sortField)}"]`) as HTMLElement; + + if (th) { + const sortElement = th.querySelector(`.${this._config.sort.classes.base}`) as HTMLElement; + + if (sortElement) { + sortElement.className = `${this._config.sort.classes.base} ${sortClass}`.trim(); + } + } + } + + /** + * Toggles the sort order of a column + * @param sortField The field to toggle the sort order for + * @returns The new sort order (ascending, descending or unsorted) + */ + private _toggleSortOrder(sortField: keyof T | number): KTDataTableSortOrderInterface { + // If the sort field is the same as the current sort field, + // toggle the sort order. Otherwise, set the sort order to ascending. + return (() => { + if (this.getState().sortField === sortField) { + switch (this.getState().sortOrder) { + case 'asc': + return 'desc'; // Descending + case 'desc': + return ''; // Unsorted + default: + return 'asc'; // Ascending + } + } + return 'asc'; // Ascending + })(); + } + + /** + * Update the table content + * @param tbodyElement The table body element + * @returns {HTMLTableSectionElement} The updated table body element + */ + private _updateTableContent(tbodyElement: HTMLTableSectionElement): HTMLTableSectionElement { + const fragment = document.createDocumentFragment(); + + tbodyElement.textContent = ''; // Clear the tbody element + + if (this._data.length === 0) { + this._noticeOnTable(this._config.infoEmpty || ''); + return tbodyElement; + } + + this._data.forEach((item: T, rowIndex: number) => { + const row = document.createElement('tr'); + + if (!this._config.columns) { + const dataRowAttributes = this.getState().originalDataAttributes + ? this.getState().originalDataAttributes[rowIndex] + : null; + + Object.keys(item).forEach((key: keyof T | number, colIndex: number) => { + const td = document.createElement('td'); + td.innerHTML = item[key] as string; + + if (dataRowAttributes) { + for (const attr in dataRowAttributes[colIndex]) { + td.setAttribute(attr, dataRowAttributes[colIndex][attr]); + } + } + + row.appendChild(td); + }); + } else { + Object.keys(this._config.columns).forEach((key: keyof T) => { + const td = document.createElement('td'); + const columnDef = this._config.columns[key as string]; + + if (typeof columnDef.render === 'function') { + td.innerHTML = columnDef.render.call(this, item[key] as string, item, this) as string; + } else { + td.textContent = item[key] as string; + } + + if (typeof columnDef.createdCell === 'function') { + columnDef.createdCell.call(this, td, item[key], item, row); + } + + row.appendChild(td); + }); + } + + fragment.appendChild(row); + }); + + tbodyElement.appendChild(fragment); + return tbodyElement; + } + + /** + * Show a notice on the table + * @param message The message to show. If empty, the message will be removed + * @returns {void} + */ + private _noticeOnTable(message: string = ''): void { + const row = this._tbodyElement.insertRow(); + const cell = row.insertCell(); + cell.colSpan = this._getTableHeaders()?.length || 0; + cell.innerHTML = message; + } + + private _updatePagination(): void { + this._removeChildElements(this._sizeElement); + this._createPageSizeControls(this._sizeElement); + + this._removeChildElements(this._paginationElement); + this._createPaginationControls(this._infoElement, this._paginationElement); + } + + /** + * Removes all child elements from the given container element. + * @param container The container element to remove the child elements from. + */ + private _removeChildElements(container: HTMLElement): void { + if (!container) { + return; + } + + // Loop through all child elements of the container and remove them one by one + while (container.firstChild) { + // Remove the first child element (which is the first element in the list of child elements) + container.removeChild(container.firstChild); + } + } + + /** + * Creates a container element for the items per page selector. + * @param _sizeElement The element to create the page size controls in. + * @returns The container element. + */ + private _createPageSizeControls(_sizeElement: HTMLSelectElement): HTMLSelectElement { + // If no element is provided, return early + if (!_sizeElement) { + return _sizeElement; + } + + // Create