import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, html, unsafeCSS} from 'lit'; import {deepQuerySelectorAll} from "../../utilities/query"; import {property, state} from "lit/decorators.js"; import {Store} from "../../internal/storage"; import {type ZnChangeEvent} from "../../events/zn-change"; import ZincElement from '../../internal/zinc-element'; import type ZnCheckbox from "../checkbox"; import styles from './settings-container.scss'; interface SettingsContainerFilter { attribute: string; checked: boolean; label: string; itemSelector?: string; } /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/settings-container * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnSettingsContainer extends ZincElement { static styles: CSSResultGroup = unsafeCSS(styles); @state() filters: SettingsContainerFilter[] = []; @property() position: 'top-end' | 'top-start' | 'bottom-end' | 'bottom-start' = 'bottom-start'; @property({attribute: 'store-key'}) storeKey: string; @property({attribute: 'no-scroll'}) noScroll: boolean; private _mutationObserver: MutationObserver | null = null; private _updateFiltersScheduled = false; private _store: Store; private _hiddenElements: Set = new Set(); connectedCallback() { super.connectedCallback(); this._store = new Store(window.sessionStorage, this.storeKey); this.recomputeFiltersFromSlot(); // Load saved filter states before applying filters if (this.storeKey) { const savedFilters = this._store.get('filters'); if (savedFilters) { const parsedFilters = JSON.parse(savedFilters) as Record; this.filters = this.filters.map(filter => ({ ...filter, checked: Object.prototype.hasOwnProperty.call(parsedFilters, filter.attribute) ? parsedFilters[filter.attribute] : filter.checked })); } } this.updateFilters(); this._mutationObserver = new MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.type === 'attributes' && mutation.attributeName === 'hidden') { continue; } this.scheduleUpdateFilters(); break; } }); this._mutationObserver.observe(this, {childList: true, subtree: true, attributes: true}); } disconnectedCallback() { super.disconnectedCallback(); if (this._mutationObserver) { this._mutationObserver.disconnect(); this._mutationObserver = null; } } // Debounced scheduling to avoid excessive updates on rapid DOM mutations private scheduleUpdateFilters() { if (this._updateFiltersScheduled) return; this._updateFiltersScheduled = true; Promise.resolve().then(() => { this._updateFiltersScheduled = false; this.updateFilters(); }); } private handleContentSlotChange = () => { this.updateFilters(); }; private handleFiltersSlotChange = () => { this.recomputeFiltersFromSlot(); this.updateFilters(); }; private recomputeFiltersFromSlot() { const existingChecked = new Map(this.filters.map(f => [f.attribute, f.checked])); const slotFilters = this.querySelectorAll('[slot="filter"]'); const nextFilters: SettingsContainerFilter[] = []; slotFilters.forEach(filter => { const attribute = filter.getAttribute('attribute'); if (!attribute) return; const defaultChecked = filter.getAttribute('default') === 'true'; const checked = existingChecked.has(attribute) ? !!existingChecked.get(attribute) : defaultChecked; const label = filter.textContent || 'Unnamed Filter'; const itemSelector = filter.getAttribute('item-selector') || '*'; nextFilters.push({attribute, checked, label, itemSelector}); }); this.filters = nextFilters; } private updateSingleFilter(filter: SettingsContainerFilter, checked: boolean) { let items; const attrAppend = filter.attribute === "*" ? `` : `[${filter.attribute}]`; if (filter.itemSelector && filter.itemSelector.startsWith('>')) { items = deepQuerySelectorAll(filter.itemSelector.substring(1) + attrAppend, this, ""); } else { items = this.querySelectorAll(filter.itemSelector + attrAppend); } items.forEach(item => { if (checked) { const shouldStayHidden = this.filters.some(f => { if (f.attribute === filter.attribute || f.checked) return false; const fAttrAppend = f.attribute === "*" ? `` : `[${f.attribute}]`; let fItems; if (f.itemSelector && f.itemSelector.startsWith('>')) { fItems = deepQuerySelectorAll(f.itemSelector.substring(1) + fAttrAppend, this, ""); } else { fItems = this.querySelectorAll(f.itemSelector + fAttrAppend); } return Array.from(fItems).includes(item); }); if (!shouldStayHidden) { item.removeAttribute('hidden'); this._hiddenElements.delete(item); } } else { item.setAttribute('hidden', 'true'); this._hiddenElements.add(item); } }); } updateFilters() { // reset all items this._hiddenElements.forEach(el => el.removeAttribute('hidden')); this._hiddenElements.clear(); // apply filters this.filters.forEach(filter => { this.updateSingleFilter(filter, filter.checked); }); } updateFilter(e: ZnChangeEvent) { const attribute = (e.target as ZnCheckbox).getAttribute('data-attribute'); const checked = (e.target as ZnCheckbox).isChecked // Find the specific filter that changed const changedFilter = this.filters.find(f => f.attribute === attribute); if (!changedFilter) return; this.filters = this.filters.map(filter => { if (filter.attribute === attribute) { return {...filter, checked}; } return filter; }); // Save filter states if (this.storeKey) { const filterStates = this.filters.reduce((acc: Record, filter) => { acc[filter.attribute] = filter.checked; return acc; }, {} satisfies Record); this._store.set('filters', JSON.stringify(filterStates)); } // Only update elements affected by this specific filter this.updateSingleFilter(changedFilter, checked); } render() { let placement = 'top-start'; switch (this.position) { case 'top-end': placement = 'bottom-end'; break; case 'top-start': placement = 'bottom-start'; break; case 'bottom-end': placement = 'top-end'; break; case 'bottom-start': placement = 'top-start'; break; } return html`
${this.getDropdownContent()}
`; } private getDropdownContent() { const hasDropdownContent = this.querySelector('[slot="dropdown-content"]'); if (hasDropdownContent) { return html` `; } return html`
${this.filters.map(filter => html` ${filter.label} `)}
`; } }