Source: data-list.js

/** Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * Version: See the class code
 *
 */
/** Copyright (c) 2022-2025 Julian Knight (Totally Information)
 * https://it.knightnet.org.uk, https://github.com/TotallyInformation
 *
 * 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.
 */

import TiBaseComponent from '../libs/ti-base-component'

/**
 * TODO: Add optional footer ?
 *       topic (uib)
 */

const styles = `
        data-list {
            --list-style: disc; /* Default list style type */
            --nested-indent: 40px; /* Default nested list indent - all browsers use 40px by default */
        }
        data-list ul {
            list-style: var(--list-style);
        }
        /* Nested list indentation */
        data-list ul ul {
            padding-inline-start: var(--nested-indent);
            margin-inline-start: 0;
        }
        data-list .nested-container {
            /* No list bullet on an li containing a nested lists unless the key text is shown */
            list-style: none;
        }
`
/** Only use a template if you want to isolate the code and CSS */
// const template = document.createElement('template')
// template.innerHTML = /*html*/`
//     <style>
//         :host {
//             display: block;   /* default is inline */
//             contain: content; /* performance boost */
//             --list-style: disc; /* Default list style type */
//             --nested-indent: 40px; /* Default nested list indent - all browsers use 40px by default */
//         }
//         ul {
//             list-style: var(--list-style);
//         }
//         /* Nested list indentation */
//         ul ul {
//             padding-inline-start: var(--nested-indent);
//             margin-inline-start: 0;
//         }
//         .nested-container {
//             /* No list bullet on an li containing a nested lists unless the key text is shown */
//             list-style: none;
//         }
//     </style>
//     <slot></slot>
//     <!-- <ul></ul> -->
// `

/** Namespace
 * @namespace Live
 */

/**
 * @class
 * @augments TiBaseComponent
 * @description Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * @element data-list
 * @memberOf Live

 * METHODS FROM BASE: (see TiBaseComponent)
 * STANDARD METHODS:
  * @function attributeChangedCallback Called when an attribute is added, removed, updated or replaced
  * @function connectedCallback Called when the element is added to a document
  * @function constructor Construct the component
  * @function disconnectedCallback Called when the element is removed from a document

 * OTHER METHODS:
  * @function entry Dynamically ament/add a single entry. The list is rebuilt.

 * CUSTOM EVENTS:
  * "data-list:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
  * "data-list:ready" - Alias for connected. The instance can handle property & attribute changes
  * "data-list:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
  * "data-list:attribChanged" - When a watched attribute changes. `evt.details.data` contains the details of the change.
  * NOTE that listeners can be attached either to the `document` or to the specific element instance.

 * Standard watched attributes (common across all my components):
  * @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load.
  * @property {string} name - Optional. HTML name attribute. Included in output _meta prop.

 * Other watched attributes:
  * @property {string} type - 💫 Optional. The type of list to use, ul or ol. Default is ul.
  * @property {string} listvar - 💫 Optional. The global variable name to use for the list data. If not set, set the data property directly from JS.
  * @property {string} keyvalueseparator - Optional. The separator to use between key and value in the list items when input is an object. Default is ' :: '. Set to "NULL" to disable key display.
  * @property {string} liststyle - 💫 Optional. The style type to use for the list. Default is `disc` for `ul` and `decimal` for `ol`. May contain any valid CSS list-style string value.

 * PROPS FROM BASE: (see TiBaseComponent)
 * OTHER STANDARD PROPS:
  * @property {string} componentVersion Static. The component version string (date updated). Also has a getter that returns component and base version strings.

 * Other props:
  * By default, all attributes are also created as properties
  * @property {object} data - 💫 The data to use for the list. Either set directly or via the `listvar` attribute. If an object, the key/value separator is used to separate the key and value in the list items.

 NB: properties marked with 💫 are dynamic and have getters/setters. They will cause the list to rebuild.

 * @slot Container contents

 * @example
  * <data-list id="myComponent" type="ol" inherit-style="./myComponent.css"></data-list>

 * See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
 */
class DataList extends TiBaseComponent {
    /** Component version */
    static componentVersion = '2025-05-31'

    /** The top-level list element - this is created in the shadow DOM */
    #list = null
    /** The list style type to use for the list - this is set as a CSS variable */
    #listStyle = null
    /** The data to use for the list - this is set via the `data` property */
    #entries = null
    /** The type of list to use, ul or ol @type {"ol"|"ul"} */
    #type = 'ul'

    // Optional global variable name to use for the list data
    listvar = null
    // The separator to use between key and value in the list items
    keyvalueseparator = ' :: '

    /** Makes HTML attribute change watched
     * @returns {Array<string>} List of all of the html attribs (props) listened to
     */
    static get observedAttributes() {
        return [
            // Standard watched attributes:
            'inherit-style', 'name',
            // Other watched attributes:
            'keyvalueseparator', 'liststyle', 'listvar', 'type', 'topic',
        ]
    }

    /** NB: Attributes not available here - use connectedCallback to reference */
    constructor() {
        super()
        // Only attach the shadow dom if code and style isolation is needed - comment out if shadow dom not required
        // if (template && template.content) this._construct(template.content.cloneNode(true))

        this.prependStylesheet(styles)
    }

    /** Runs when an instance is added to the DOM
     * Runs AFTER the initial attributeChangedCallback's
     */
    connectedCallback() {
        // console.info(`[DataList] ${this.id} connectedCallback (${this.tagName})`)
        this._connect() // Keep at start.

        if ( this.listvar && window[this.listvar] ) {
            if ( window[this.listvar] ) {
                this.data = window[this.listvar]
                // window.dataList.instances[this.id] = window[newVal]
                // console.log('>> listvar change >>', this.id, this.#entries, window.dataList.instances[this.id])
                //window[newVal] = new Proxy()
            } else {
                console.warn(`[DataList] ${this.id} window.${this.listvar} does not exist, ignoring.`)
            }

            return
        }

        this._ready() // Keep at end. Let everyone know that a new instance of the component has been connected & is ready
    }

    /** Runs when an instance is removed from the DOM */
    disconnectedCallback() {
        this._disconnect() // Keep at end.
    }

    /** Runs when an observed attribute changes - Note: values are always strings
     * NOTE: On initial startup, this is called for each watched attrib set in HTML.
     *       and BEFORE connectedCallback is called.
     * @param {string} attrib Name of watched attribute that has changed
     * @param {string} oldVal The previous attribute value
     * @param {string} newVal The new attribute value
     */
    attributeChangedCallback(attrib, oldVal, newVal) {
        // console.info(`[DataList] ${this.id} attributeChangedCallback: ${attrib} changed from "${oldVal}" to "${newVal}"`)
        /** Optionally ignore attrib changes until instance is fully connected
         * Otherwise this can fire BEFORE everthing is fully connected.
         */
        // if (!this.connected) return

        // Don't bother if the new value same as old
        if ( oldVal === newVal ) return
        // Create a property from the value - WARN: Be careful with name clashes
        this[attrib] = newVal

        // Add other dynamic attribute processing here.
        // If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle

        // Keep at end. Let everyone know that an attribute has changed for this instance of the component
        this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal, })
    }

    /** Updates the list attributes based on the current type and style
     * @param {HTMLElement} listEl The list element to update
     * @param {"ol"|"ul"} listType The type of list to use, either 'ol' for ordered or 'ul' for unordered
     */
    updateListAttributes(listEl, listType) {
        if (listEl) {
            if (listEl) {
                // Update CSS classes/attributes instead of recreating element
                listEl.setAttribute('data-list-type', listType)

                // Update ARIA attributes for accessibility
                if (listType === 'ol') {
                    this.listStyle = 'decimal'
                    listEl.setAttribute('aria-label', 'Ordered list')
                } else {
                    this.listStyle = 'disc'
                    listEl.setAttribute('aria-label', 'Unordered list')
                }
            }
            if (this.#listStyle) {
                // Update the CSS variable for the list style
                listEl.style.setProperty('--list-style', this.#listStyle)
            }
        }
    }

    // Getter/setter for the `type` public property - maps to this.#type
    set type(val) {
        if (!['ul', 'ol'].includes(val)) {
            console.warn(`[DataList] ${this.id} Invalid type "${val}" specified, defaulting to "ul".`)
            val = 'ul'
        }

        this.#type = val

        this.updateListAttributes(this.#list, this.#type)
    }
    get type() {
        return this.#type
    }

    // Getter/setter for the `listStyle` public property - maps to this.#listStyle
    set liststyle(val) {
        this.#listStyle = val
        this.updateListAttributes(this.#list, this.#type)
    }
    get liststyle() {
        return this.#listStyle
    }

    // Getter/setter for the `data` public property - maps to this.#entries
    set data(val) {
        this.#entries = val

        // If the list already exists, delete it
        if (this.#list) this.#list.remove()

        // Dynamically build the content of the ul/ol
        // Object.keys(this.#entries).forEach( this.buildListItem.bind(this))
        this.buildList(this.#entries, this, this.#type, 1)

        this.#list = this.querySelector('ul')
        this.updateListAttributes(this.#list, this.#type)

        // Issue a custom event to notify that the data has changed
        this._event('dataChanged', this.#entries)
    }
    get data() {
        return this.#entries
    }

    /** Dynamically change/add list entries - rebuilds the list DOM element
     * @param {string|number} key Object key or array index to change/add
     * @param {string} val Updates/new list text (can be HTML)
     * @example
     * // Add a new entry to the list
     * dataList.entry('newKey', 'New list item text')
     * // Update an existing entry in the list
     * dataList.entry('existingKey', 'Updated list item text')
     */
    entry(key, val) {
        this.#entries[key] = val
        this.data = this.#entries
    }

    /** Builds a list from input data
     * @param {Array|object} listData Source data for the list, can be an array or an object
     * @param {HTMLElement|ShadowRoot} parentEl The parent element to append the list to
     * @param {"ol"|"ul"} type  The type of list to create, either 'ol' for ordered or 'ul' for unordered
     * @param {number} depth  Recursion depth. Defaults to 1. Used to limit recursion depth for nested objects/arrays.
     */
    buildList(listData, parentEl, type, depth) {
        if (depth === undefined) depth = 1
        // console.log(`[DataList] ${this.id} buildList: listData=${listData}, listEl=${parentEl}, depth=${depth}`)

        // Create a new list element (all lists are created as ul, numbering is done via CSS)
        const listEl = document.createElement('ul')
        if (depth > 1) {
            // If this is a nested list, add a class to the list element
            listEl.classList.add('nested-list')
            // and add a data-depth attribute to the list element
            listEl.setAttribute('data-depth', depth.toString())
        }

        Object.entries(listData).forEach(([key, value], i) => {
            // Is the key a number? Keep a record
            const keyIsNumeric = !isNaN(parseInt(key))

            // Is the value an object or array?
            if (typeof value === 'object' && value !== null) {
                if (depth > 3) {
                    // If depth is too high, just show the key and value as a string
                    listEl.insertAdjacentHTML(
                        'beforeend',
                        this.buildLIhtml({ arrayType: Array.isArray(listData), key, value: JSON.stringify(value), i, })
                    )
                    console.warn(`[DataList] ${this.id} buildList: Depth limit reached for key "${key}", showing as string.`)
                    return
                }

                // Create a new list item for the key
                const li = document.createElement('li')

                // If key is not numeric and if this.keyvalueseparator is not 'NULL', add key as text
                if (!keyIsNumeric && this.keyvalueseparator !== 'NULL') {
                    const keySpan = document.createElement('span')
                    keySpan.textContent = `${key}${this.keyvalueseparator}`
                    // keySpan.classList.add('nested-key')
                    li.appendChild(keySpan)
                } else {
                    li.classList.add('nested-container')
                }

                // recurse to create a nested list
                this.buildList(value, li, type, ++depth)
                this.updateListAttributes(li, type)
                // add the new list item to its parent element
                listEl.appendChild(li)
            } else {
                // Otherwise, just create a list item with the key and value
                // console.log(`[DataList] ${this.id} buildList: key=${key}, value=${value}, i=${i}`)
                listEl.insertAdjacentHTML(
                    'beforeend',
                    this.buildLIhtml({ arrayType: Array.isArray(listData), key, value, i, })
                )
            }
        })

        // Attach the list as the last child of the parent element
        parentEl.appendChild(listEl)
    }

    /** Builds a list item HTML string based on the provided options.
     * @param {object} options Object containing options for building the list item
     * @param {boolean} options.arrayType True if the list is an array, false if it is an object
     * @param {string} options.key The key for the list item, used as the id and data-index
     * @param {string} options.value The value for the list item, displayed as the text
     * @param {number} options.i The index of the item in the list, used for data-index attribute
     * @returns {string} The HTML string for the list item
     */
    buildLIhtml({ arrayType, key, value, i, }) {
        // check if this.#entries is an array
        if (arrayType) {
            return `<li id="${this.id}-${key}" data-index="${i}">${value}</li>`
        }
        // If the keyvalueseparator is 'NULL', then don't show the key
        // otherwise, show the key followed by the separator
        return `<li id="${this.id}-${key}" data-index="${i}">${this.keyvalueseparator === 'NULL' ? '' : `${key}${this.keyvalueseparator}`}${value}</li>`
    }
} // ---- end of Class ---- //

// Make the class the default export so it can be used elsewhere
export default DataList

/** Self register the class to global
 * Enables new data lists to be dynamically added via JS
 * and lets the static methods be called
 */
window['DataList'] = DataList

// Self-register the HTML tag
customElements.define('data-list', DataList)