Source: alpha/labeled-input.mjs

// @ts-nocheck
/** Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * NOTE: This file contains two components: labeledInput and InputGroup
 *       They are designed to work together but can be used separately.
 *
 * labeledInput provides a standardised labeled input field with built-in support for various
 *   input types and error messaging.
 * InputGroup serves as a container for grouping multiple input elements together.
 *
 * For better formatting of HTML in template strings, use VSCode's "ES6 String HTML" extension
 *
 * Version: See the class code
 *
 */
/** Copyright (c) 2025-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'

const version = '2025-09-19'

/** Only use this if using Light DOM but want scoped styles */
const styles = /*css*/`
    input-group {
        display: block;
        
        /* Optional classes to change layout - pass down to children */
        &.above {
            --labeled-input-areas: "label" "input" "error";
            --labeled-input-columns: 1fr;
        }
        
        &.left {
            --labeled-input-areas: "label input" "error error";
            --labeled-input-justify: start;
            --labeled-input-columns: minmax(0, 1fr) 1.5fr;
        }
        
        &.right {
            --labeled-input-areas: "input label" "error error";
            --labeled-input-columns: minmax(0, 0.25fr) 2fr;
        }
        
        &.below {
            --labeled-input-areas: "input" "label" "error";
            --labeled-input-columns: 1fr;
        }
    }

    labeled-input {
        /* Default grid layout */
        display: grid;
        grid-template-areas: var(--labeled-input-areas, "label" "input" "error");
        align-items: center;
        row-gap: var(--input-grid-row-gap, var(--input-grid-gap, 0.5em));
        column-gap: var(--input-grid-column-gap, var(--input-grid-gap, 2em));
        justify-content: var(--labeled-input-justify, normal);
        grid-template-columns: var(--labeled-input-columns, 1fr);

        /* Optional classes to change layout when standalone */
        &.above {
            --labeled-input-areas: "label" "input" "error";
            --labeled-input-columns: 1fr;
        }
        &.left {
            --labeled-input-areas: "label input" "error error";
            --labeled-input-justify: start;
            --labeled-input-columns: minmax(0, 1fr) 1.5fr;
        }
        &.right {
            --labeled-input-areas: "input label" "error error";
            --labeled-input-columns: minmax(0, 0.25fr) 2fr;
        }
        &.below {
            --labeled-input-areas: "input" "label" "error";
            --labeled-input-columns: 1fr;
        }

        /* Element styles */
        & label {
            grid-area: label;
            font-weight: bold;
            line-height: 1.4;
            word-wrap: break-word;
            hyphens: auto;

            &:before {
                content: var(--label-before-content, '');
            }
            &:after {
                content: var(--label-after-content, '');
            }
        }
        
        & input {
            grid-area: input;
            border: 1px solid var(--text3);
            
            /* Better alignment for left/right layouts */
            /* :is(.left, .right) & {
                margin-top: 2px;
            } */
        }
        
        & input[type="checkbox"], & input[type="radio"] {
            justify-self: start;
            :is(.left) & {
                --labeled-input-columns: 2em auto;
            }
            :is(.right) & {
                justify-self: end;
                --labeled-input-columns: auto 2em;
            }
        }
        
        & error-message {
            display: none;
            grid-area: error;
            color: red;
            font-size: 0.9em;
        }
    }
`

/** Namespace
 * @namespace PreAlpha
 */

/**
 * @class
 * @augments TiBaseComponent
 * @description Define a new zero dependency custom web component ECMA module that can be used as an HTML tag.
 *   Provides a standardised labeled input field with built-in support for various input types and error messaging.
 *
 * @element labeled-input
 * @memberOf PreAlpha
 * @license Apache-2.0

 * 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:
  * None

 * CUSTOM EVENTS:
  * "labeled-input:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
  * "labeled-input:ready" - Alias for connected. The instance can handle property & attribute changes
  * "labeled-input:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
  * "labeled-input: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:
  * None

 * 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

 NB: properties marked with 💫 are dynamic and have getters/setters.

 * @slot Container contents

 * @example
  * <labeled-input name="myComponent" inherit-style="./myComponent.css"></labeled-input>

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

    /** Reference to the component's input element */
    elInput
    /** Reference to the component's label element */
    elLabel
    /** Reference to the component's error message element */
    elErrorMessage
    /** Holds the previous values of the component's properties */
    oldValues = {}

    /** 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:
            'type', 'value', 'placeholder', 'disabled', 'readonly', 'required',
            'min', 'max', 'minlength', 'maxlength', 'size', 'autocomplete',
            'autofocus', 'form', 'list', 'pattern', 'step', 'multiple',
        ]
    }

    /** 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))
        // Otherwise, if component styles are needed, use the following instead:
        this.prependStylesheet(styles, 0)

        // this.#elInput = this.shadowRoot?.querySelector('input')

        // Move any existing children into the template's slot, replacing slot content
        const existingChildren = Array.from(this.childNodes)

        // Light DOM html
        this.innerHTML = /*html*/`
            <label for="input"></label>
            <input type="text" />
            <error-message></error-message>
        `

        // Retain a reference to the input element
        this.elInput = this.querySelector('input')
        this.elInput.id = `${this.id}-input`
        this.elInput.setAttribute('name', `${this.id}-input`)

        this.elLabel = this.getElementsByTagName('LABEL')[0]
        this.elLabel.setAttribute('for', this.elInput.id)
        if (existingChildren.length > 0) {
            this.elLabel.innerHTML = ''
            this.elLabel.append(...existingChildren)
        }

        this.elErrorMessage = this.querySelector('error-message')
    }

    /** Runs when an instance is added to the DOM
     * Runs AFTER the initial attributeChangedCallback's
     * @private
     */
    connectedCallback() {
        this._connect() // Keep at start.

        // if this has a <input-group> ancestor, use it's ID for the input name
        const parentGroup = this.closest('input-group')
        if (parentGroup && (parentGroup.id || parentGroup.name)) {
            this.elInput.setAttribute('name', parentGroup.id || parentGroup.name)
        }

        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
     * @private
     */
    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
     * @private
     */
    attributeChangedCallback(attrib, oldVal, 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
        // @ts-ignore Create a property from the value - WARN: Be careful with name clashes
        this[attrib] = newVal
        this.oldValues[attrib] = oldVal

        switch (attrib) {
            case 'inherit-style': {
                // Ignore here - handled in base class
                break
            }

            case 'type': {
                // TODO: Add special handling for buttons. Maybe radios and checkboxes too
                this._updateType(newVal)
                break
            }

            // Pass anything else through to the input element
            default: {
                this.elInput?.setAttribute(attrib, newVal)
                break
            }
        }

        // 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 type attribute for the input element
     * @param {string} type The type attribute for the input element
     * @private
     */
    _updateType(type) {
        this.elInput?.setAttribute('type', type || 'text')
    }
} // ---- end of Class ---- //

/**
 * @class
 * @augments TiBaseComponent
 * @description Define a new zero dependency custom web component ECMA module that can be used as an HTML tag.
 *   Serves as a container for grouping multiple input elements together.
 *
 * @element input-group
 * @memberOf PreAlpha
 * @license Apache-2.0

 * 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:
  * None

 * CUSTOM EVENTS:
  * "input-group:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
  * "input-group:ready" - Alias for connected. The instance can handle property & attribute changes
  * "input-group:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
  * "input-group: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:
  * None

 * 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

 NB: properties marked with 💫 are dynamic and have getters/setters.

 * @slot Container contents

 * @example
  * <input-group name="myComponent" inherit-style="./myComponent.css"></input-group>

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

    /** 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:
            'type', 'title', 'frame-title', 'form-title',
        ]
    }

    /** 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))
        // Otherwise, if component styles are needed, use the following instead:
        // this.prependStylesheet(styles, 0) // <= Done in the labeled-input component
    }

    /** Runs when an instance is added to the DOM
     * Runs AFTER the initial attributeChangedCallback's
     * @private
     */
    connectedCallback() {
        this._connect() // Keep at start.

        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
     * @private
     */
    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
     * @private
     */
    attributeChangedCallback(attrib, oldVal, 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

        switch (attrib) {
            case 'type': {
                this._updateType(newVal)
                break
            }

            case 'title':
            case 'frame-title':
            case 'form-title': {
                this._updateTitle(newVal)
                break
            }

            default: {
                // Nothing to do here yet
                break
            }
        }

        // 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, })
    }

    _updateType(type) {
        if (!type) return

        // Move any existing children into the template's slot, replacing slot content
        const existingChildren = Array.from(this.childNodes)

        if (['fieldset', 'frame'].includes(type)) {
            // Light DOM html
            this.innerHTML = /*html*/`
                <fieldset title="">
                    <legend></legend>
                </fieldset>
            `
            this.elFrame = this.querySelector('fieldset')
        } else if (type === 'form') {
            this.innerHTML = /*html*/`
                <form title="">
                    <div></div>
                </form>
            `
            this.elFrame = this.querySelector('form')
        }

        if (this.elFrame && existingChildren.length > 0) {
            this.elFrame.append(...existingChildren)
        }
    }

    _updateTitle(title) {
        if (!title || !this.elFrame) return
        this.title = title
        const el = this.elFrame.querySelector('div') || this.elFrame.querySelector('legend')
        // const el = this.elFrame.querySelector('legend, div')
        console.log('el', el, this.elFrame)
        el.innerText = title
    }
} // ---- end of Class ---- //

// Make the class the default export so it can be used elsewhere
export default labeledInput
export { labeledInput, InputGroup }

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

// Self-register the HTML tag
customElements.define('labeled-input', labeledInput)
customElements.define('input-group', InputGroup)