Source: alpha/uib-theme-changer.js

// @ts-nocheck
/** Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * TODO:
 *   - Compact layout
 *   - Other attribs for controlling scheme, etc
 *   - Settings for accessibility
 *   - Reset button
 *   - Move event handlers to fns and remove them on disconnect
 *   - Add inbound uibuilder msg handler
 *   - Add hidden attrib to allow for just msg handling
 *   - Update documentation
 *
 * version See the class code
 *
 * References:
 *   - https://web.dev/building-a-color-scheme/
 *
 * See https://github.com/runem/web-component-analyzer#-how-to-document-your-components-using-jsdoc on how to document
 * Use `npx web-component-analyzer ./components/button-send.js` to create/update the documentation
 *     or paste into https://runem.github.io/web-component-analyzer/
 * Use `npx web-component-analyzer ./components/*.js --format vscode --outFile ./vscode-descriptors/ti-web-components.html-data.json`
 *     to generate/update vscode custom data files. See https://github.com/microsoft/vscode-custom-data/tree/main/samples/webcomponents
 *
 */
/** 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.
 */

const componentName = 'uib-theme-changer'
const className = 'UibThemeChanger'

const template = document.createElement('template')
template.innerHTML = /*html*/`
    <style>
        :host {
            --size: 2rem;
            --w: 4rem;
            /* display: flex; */
            display: inline-block; /* default is inline */
            contain: content; /* performance boost */
            position: sticky;
            top: 0;
            background-color: var(--surface1);
            border: 1px solid var(--text3);
            border-radius: 0.5rem;
            margin: 0.2rem;
            padding: 0.5rem;
            color: var(--text2);
            background-clip: border-box;
            box-sizing: border-box;
            box-shadow: var(--shadow2);
            width: 100%;
        }
        * {
            vertical-align: top;
        }
        div {
            display: inline-block;
        }
        form {
            margin: .2rem 1rem;
            /* flex: 1 0 auto; */
            display: inline-block;
        }
        form > div {
            display: block;
        }
        .s-and-m {
            aspect-ratio: 1;
            background: none;
            color: var(--text3);
            border: none;
            padding: 0;
            inline-size: var(--size);
            block-size: var(--size);
            
            border-radius: 50%;

            cursor: pointer;
            touch-action: manipulation;
            -webkit-tap-highlight-color: transparent;
            outline-offset: 5px;
        }
        input[type=submit] {
            inline-size: var(--w);
            block-size: var(--size);
            margin-left: 1rem;
        }
        .sun-and-moon {
            inline-size: 100%;
            block-size: 100%;
            stroke-linecap: round;
        }
        .moon, .sun {
            fill: var(--text2);
        }
        .moon:hover, .sun:hover {
            fill: var(--text1);
        }

        /* @media (hover: none) {
            :host {
                --size: 48px;
                --w: 48px;
            }
        } */
    </style>
    <div id="s-and-m" name="color-scheme-toggle" onclick="this.getRootNode().host.evtClickToggle(event)" 
            title="Toggles between light & dark color schemes" 
            aria-label="auto" aria-live="polite">
        <svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
            <svg name="sun" class="sun" viewbox="0 0 20 20">
                <path
                    fill-rule="evenodd"
                    d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
                    clip-rule="evenodd"
                ></path>
            </svg>
            <svg name="moon" class="moon" viewbox="0 0 20 20">
                <path
                    d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
                ></path>
            </svg>
            <svg class="divider" width='100%' height='100%' viewBox='0 0 20 20' preserveAspectRatio='none'>
                <line x1="5" y1="5" x2="15" y2="15" vector-effect="non-scaling-stroke" 
                    stroke-width="10%" stroke="currentColor" />
            </svg>
        </svg>
    </div>
    <input type="submit" value="Reset" onclick="this.getRootNode().host.evtClickReset(event)">
    <form>
        <div name="color-scheme-choose" onclick="this.getRootNode().host.evtClickChooser(event)">
            <b>Color scheme:</b>
            <label><input type="radio" name="input-color-scheme-choose" value="auto">&nbsp;auto</label>
            <label><input type="radio" name="input-color-scheme-choose" value="light">&nbsp;light</label>
            <label><input type="radio" name="input-color-scheme-choose" value="dark">&nbsp;dark</label>
        </div>
        
        <div onclick="this.getRootNode().host.evtClickContrast(event)">
            <b>Contrast:</b>
            <label><input type="radio" name="contrast" value="standard">&nbsp;standard</label>
            <label><input type="radio" name="contrast" value="more">&nbsp;more</label>
            <label><input type="radio" name="contrast" value="less">&nbsp;less</label>
        </div>
    
        <div>
            <label><b>Brand Hue angle:</b>
            <input name="brand-hue" type="range" min="0" max="360" step="1" value="200">
            <output>200</output></label>
        </div>

        <div title="30=Split Complementary, 60=Triadic, 150=Complementary">
            <label><b>Accent offset angle</b>
            <input name="accent-offset" type="range" min="0" max="360" step="1" value="30">
            <output>30</output></label>
        </div>
    </form>
`

/** Namespace
 * @namespace Alpha
 */

/** A uibuilder for Node-RED Theme Changer component
 * @class
 * @augments HTMLElement
 * @description Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * @element component-template
 * @memberOf Alpha
 *
 *
 * @element uib-theme-changer
 *
 * fires uib-theme-changer:construction - Document object event. evt.details contains the data
 * fires uib-theme-changer:connected - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
 * fires uib-theme-changer:disconnected - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
 * fires uib-theme-changer:attribChanged - When a watched attribute changes. `evt.details` contains the details of the change.
 * NOTE that listeners can be attached either to the `document` or to the specific element instance.
 *
 * @property {string} name - Optional. Will be used to synthesize an ID if no ID is provided.
 * attr {string} data-* - Optional. All data-* attributes are returned in the _meta prop as a _meta.data object.
 *
 * slot Container contents
 *
 * @csspart ??? - Uses the uib-styles.css uibuilder master for variables where available.
 */
class UibThemeChanger extends HTMLElement {
    /** Component version */
    static version = '2024-09-21'

    //#region ---- Class Variables ----

    /** Is UIBUILDER for Node-RED loaded? */
    uib = !!window['uibuilder']
    /** Mini jQuery-like shadow dom selector (see constructor)
     * @type {function(string): Element}
     * @param {string} selector - A CSS selector to match the element within the shadow DOM.
     * @returns {Element} The first element that matches the specified selector.
     */
    $
    /** Mini jQuery-like shadow dom multi-selector (see constructor)
     * @type {function(string): NodeList}
     * @param {string} selector - A CSS selector to match the element within the shadow DOM.
     * @returns {NodeList} A STATIC list of all shadow dom elements that match the selector.
     */
    $$
    /** Holds a count of how many instances of this component are on the page that don't have their own id
     * Used to ensure a unique id if needing to add one dynamically
     */
    static _iCount = 0

    /** Runtime configuration settings */
    opts = {}


    /** 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',
            // Other watched attributes:
        ]
    }

    /** Report the current component version string
     *  @returns {string} The component version string
     */
    get version() {
        return UibThemeChanger.version
    }

    /** Holds the uib theme settings for all pages in this domain (from/to localStorage) */
    uibThemeSettings = {}

    /** Standard _ui object to include in msgs */
    _ui = {
        type: componentName,
        event: undefined,
        id: undefined,
        name: undefined,
        data: undefined, // All of the data-* attributes as an object
    }

    /** What is the current scheme? 'light', 'dark' or 'auto' */
    scheme = undefined

    //#endregion ---- ---- ---- ----

    /** NB: Attributes not available here - use connectedCallback to reference */
    constructor() {
        super()

        this.attachShadow({ mode: 'open', delegatesFocus: true, })
            // Only append the template if code and style isolation is needed
            .append(template.content.cloneNode(true))

        // jQuery-like selectors but for the shadow. NB: Returns are STATIC not dynamic lists
        this.$ = this.shadowRoot?.querySelector.bind(this.shadowRoot)
        this.$$ = this.shadowRoot?.querySelectorAll.bind(this.shadowRoot)
    }

    /** Runs when an instance is added to the DOM */
    connectedCallback() {
        // Make sure instance has an ID. Create an id from name or calculation if needed
        if (!this.id) {
            if (!this.name) this.name = this.getAttribute('name')
            if (this.name) this.id = this.name.toLowerCase().replace(/\s/g, '_')
            else this.id = `uib-meta-${++UibThemeChanger._iCount}`
        }

        // Check again if UIBUILDER for Node-RED is loaded
        this.uib = !!window['uibuilder']

        // Apply parent styles from a stylesheet if required - only required if using an applied template
        if (this.hasAttribute('inherit-style')) {
            const styleUrl = this.getAttribute('inherit-style')
            this.doInheritStyles(styleUrl)
        }

        if ( !getComputedStyle(this).getPropertyValue('--uib-css').includes('uib-brand') ) { // eslint-disable-line @stylistic/newline-per-chained-call
            console.warn('[uib-theme-changer] WARNING: It appears that you are not using uibuilder\'s uib-brand.css stylesheet. This component may not work as expected.')
        }

        // Try to retrieve theme settings for this page
        try {
            this.uibThemeSettings = JSON.parse(localStorage.getItem('uibThemeSettings')) || this.uibThemeSettings
        } catch (e) {}
        if ( !this.uibThemeSettings[window.location.pathname] ) this.uibThemeSettings[window.location.pathname] = {}

        const docRoot = document.documentElement

        // TODO: Replace fns with named fns so that listeners can be removed
        this.$('[name=brand-hue]').addEventListener('change', function(evt) {
            docRoot.style.setProperty('--brand-hue', evt.target.value)
        })
        this.$('[name=brand-hue]').addEventListener('input', function(evt) {
            this.nextElementSibling.value = this.value
        })

        this.$('[name=accent-offset]').addEventListener('change', function(evt) {
            docRoot.style.setProperty('--accent-offset', evt.target.value)
        })
        this.$('[name=accent-offset]').addEventListener('input', function(evt) {
            this.nextElementSibling.value = this.value
        })

        //#region --- set contrast ---

        // If contrast is manually set, remove saved setting for this page (not needed)
        if ( docRoot.classList.contains('standard') || docRoot.classList.contains('more')  || docRoot.classList.contains('less')) {
            try {
                delete this.uibThemeSettings[window.location.pathname].contrast
                localStorage.setItem('uibThemeSettings', JSON.stringify(this.uibThemeSettings))
            } catch (e) {}
            this.shadowRoot.querySelector(`input[value=${docRoot.classList[0]}][name=contrast]`).checked = true
        } else if ( this.uibThemeSettings[window.location.pathname] && this.uibThemeSettings[window.location.pathname].contrast ) {
            // not manually set but does have a saved page setting
            this.shadowRoot.querySelector(`input[value=${this.uibThemeSettings[window.location.pathname].contrast}][name=contrast]`).checked = true
            this.evtClickContrast({ target: { name: 'contrast', value: this.uibThemeSettings[window.location.pathname].contrast, }, })
        } else {
            this.shadowRoot.querySelector('input[value=standard][name=contrast]').checked = true
        }

        //#endregion --- --- ---

        //#region --- set theme ---

        // If theme is manually set, remove saved setting for this page (not needed)
        if ( docRoot.classList.contains('light') || docRoot.classList.contains('dark')  || docRoot.classList.contains('auto')) {
            try {
                delete this.uibThemeSettings[window.location.pathname].theme
                localStorage.setItem('uibThemeSettings', JSON.stringify(this.uibThemeSettings))
            } catch (e) {}
            this.shadowRoot.querySelector(`input[value=${docRoot.classList[0]}][name=input-color-scheme-choose]`).checked = true
        } else if ( this.uibThemeSettings[window.location.pathname] && this.uibThemeSettings[window.location.pathname].theme ) {
            // not manually set but does have a saved page setting
            this.shadowRoot.querySelector(`input[value=${this.uibThemeSettings[window.location.pathname].theme}][name=input-color-scheme-choose]`).checked = true
            this.setTheme(this.uibThemeSettings[window.location.pathname].theme)
        } else {
            this.shadowRoot.querySelector('input[value=auto][name=input-color-scheme-choose]').checked = true
        }

        //#endregion --- --- ---

        // OPTIONAL. Listen for a uibuilder msg that is targetted at this instance of the component
        if (this.uib) document.addEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler.bind(this) )

        // Keep at end. Let everyone know that a new instance of the component has been connected
        this.dispatchEvent(new CustomEvent('uib-theme-changer:connected', {
            bubbles: true,
            composed: true,
            detail: {
                id: this.id,
                name: this.name,
            },
        } ) )
    }

    /** Runs when an instance is removed from the DOM */
    disconnectedCallback() {
        // @ts-ignore Remove optional uibuilder event listener
        document.removeEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler )

        // Keep at end. Let everyone know that an instance of the component has been disconnected
        this.dispatchEvent(new CustomEvent('uib-theme-changer:disconnected', {
            bubbles: true,
            composed: true,
            detail: {
                id: this.id,
                name: this.name,
            },
        } ) )
    }

    /** Runs when an observed attribute changes - Note: values are always strings
     * @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) {
        // 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
        document.dispatchEvent(new CustomEvent('uib-theme-changer:attribChanged', {
            bubbles: true,
            composed: true,
            detail: {
                id: this.id,
                name: this.name,
                attribute: attrib,
                newVal: newVal,
                oldVal: oldVal,
            },
        }))
    }

    /** Optionally apply an external linked style sheet (called from connectedCallback)
     * @param {*} url The URL for the linked style sheet
     */
    async doInheritStyles(url) {
        if (!url) url = './index.css'

        const linkEl = document.createElement('link')
        linkEl.setAttribute('type', 'text/css')
        linkEl.setAttribute('rel', 'stylesheet')
        linkEl.setAttribute('href', url)
        // @ts-ignore
        this.shadowRoot.appendChild(linkEl)

        console.info(`[state-timeline] Inherit style requested. Loading: "${url}"`)
    }

    /** OPTIONAL. Update runtime configuration, return complete config
     * @param {object|undefined} config If present, partial or full set of options. If undefined, fn returns the current full option settings
     * @returns {object} The current full option settings
     */
    config(config) {
        // Merge config but ensure that default states always present
        // if (config) this.opts = { ...this.opts, ...config }
        if (config) this.opts = UibThemeChanger.deepAssign(this.opts, config)
        return this.opts
    }

    /** Utility object deep merge fn
     * @param {object} target Merge target object
     * @param  {...object} sources 1 or more source objects to merge
     * @returns {object} Deep merged object
     */
    static deepAssign(target, ...sources) {
        for (let source of sources) { // eslint-disable-line prefer-const
            for (let k in source) { // eslint-disable-line prefer-const
                const vs = source[k]
                const vt = target[k]
                if (Object(vs) == vs && Object(vt) === vt) {
                    target[k] = UibThemeChanger.deepAssign(vt, vs)
                    continue
                }
                target[k] = source[k]
            }
        }
        return target
    }

    /** Handle a `uibuilder:msg:_ui:update:${this.id}` custom event
     * @param {CustomEvent} evt uibuilder `uibuilder:msg:_ui:update:${this.id}` custom event evt.details contains the data
     */
    _uibMsgHandler(evt) {
        // If there is a payload, we want to replace the slot - easiest done from the light DOM
        // if ( evt['detail'].payload ) {
        //     const el = document.getElementById(this.id)
        //     el.innerHTML = evt['detail'].payload
        // }
        // If there is a payload, we want to replace the VALUE
        // if ( evt['detail'].payload ) {
        //     const el = this.shadowRoot.getElementById('value')
        //     el.innerHTML = evt['detail'].payload
        // }
    }

    /**
     *
     * @param {*} theme _
     * @returns {string} _
     */
    setTheme(theme) {
        const $ = this.shadowRoot.querySelector.bind(this.shadowRoot)
        const docRoot = document.documentElement
        switch (theme) {
            case 'dark': {
                this.scheme = 'dark'
                docRoot.classList.remove('light')
                docRoot.classList.add('dark')
                $('.sun').style.opacity = 0
                $('.moon').style.opacity = 1
                $('.divider').style.opacity = 0
                break
            }
            case 'light': {
                this.scheme = 'light'
                docRoot.classList.remove('dark')
                docRoot.classList.add('light')
                $('.sun').style.opacity = 1
                $('.moon').style.opacity = 0
                $('.divider').style.opacity = 0
                break
            }
            case 'auto': {
                this.scheme = 'auto'
                docRoot.classList.remove('light')
                docRoot.classList.remove('dark')
                $('.sun').style.opacity = 1
                $('.moon').style.opacity = 1
                $('.divider').style.opacity = 1
                break
            }
            case 'none':
            default: {
                this.scheme = undefined
                docRoot.classList.remove('light')
                docRoot.classList.remove('dark')
                docRoot.classList.remove('auto')
                $('.sun').style.opacity = 1
                $('.moon').style.opacity = 1
                $('.divider').style.opacity = 1
                break
            }
        }
        return theme
    }

    /** TODO Handle the icon
     * @param {MouseEvent} evt _
     */
    evtClickToggle(evt) {
        console.log('icon click: ', evt.target.tagName)
    }

    /** Handle the light/dark theme chooser. Override contrast css variables and set appropriate class on html
     * @param {MouseEvent} evt _
     */
    evtClickChooser(evt) {
        if (evt.target.name !== 'input-color-scheme-choose') return

        try {
            this.uibThemeSettings[window.location.pathname].theme = evt.target.value
            localStorage.setItem('uibThemeSettings', JSON.stringify(this.uibThemeSettings))
        } catch (e) {}

        // TODO: Consider moving to a getter/setter
        this.setTheme(evt.target.value)
    }

    /** Handle reset button. Override contrast css variables and set appropriate class on html
     * @param {MouseEvent} evt _
     */
    evtClickReset(evt) {
        this.setTheme('none')
        const els = this.shadowRoot.querySelectorAll('input[name=input-color-scheme-choose]')
        for (let i = 0; i < els.length; i++) {
            els[i].checked = false
        }

        this.evtClickContrast({ target: { name: 'contrast', value: 'standard', }, })
        const els1 = this.shadowRoot.querySelectorAll('input[name=contrast]')
        for (let i = 0; i < els1.length; i++) {
            els1[i].checked = false
        }

        delete this.uibThemeSettings[window.location.pathname]
        localStorage.setItem('uibThemeSettings', JSON.stringify(this.uibThemeSettings))
    }

    /** Handle contrast click. Override contrast css variables and set appropriate class on html
     * @param {MouseEvent} evt Click event
     */
    evtClickContrast(evt) {
        if (evt.target.name !== 'contrast') return

        const docRoot = document.documentElement

        if ( evt.target.value === 'more' ) {
            docRoot.style.setProperty('--text-bias', '1')
            docRoot.style.setProperty('--surfaces-bias', '1')
            docRoot.style.setProperty('--saturation-bias', '1')
            docRoot.classList.remove('standard')
            docRoot.classList.remove('less')
            docRoot.classList.add('more')
        } else if ( evt.target.value === 'less' ) {
            docRoot.style.setProperty('--text-bias', '-.1')
            docRoot.style.setProperty('--surfaces-bias', '-.05')
            docRoot.style.setProperty('--saturation-bias', '-.05')
            docRoot.classList.remove('standard')
            docRoot.classList.remove('more')
            docRoot.classList.add('less')
        } else {
            docRoot.style.removeProperty('--text-bias')
            docRoot.style.removeProperty('--surfaces-bias')
            docRoot.style.removeProperty('--saturation-bias')
            docRoot.classList.remove('standard')
            docRoot.classList.remove('more')
            docRoot.classList.remove('less')

        }

        try {
            this.uibThemeSettings[window.location.pathname].contrast = evt.target.value
            localStorage.setItem('uibThemeSettings', JSON.stringify(this.uibThemeSettings))
        } catch (e) {}
    }
} // ---- end of Class ---- //

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

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

// Self-register the HTML tag
customElements.define('uib-theme-changer', UibThemeChanger)