diff --git a/custom_components/shopping_list_manager/frontend/shopping_list_card.js b/custom_components/shopping_list_manager/frontend/shopping_list_card.js index 49bae09..9b9f12d 100644 --- a/custom_components/shopping_list_manager/frontend/shopping_list_card.js +++ b/custom_components/shopping_list_manager/frontend/shopping_list_card.js @@ -1,8 +1,5 @@ /** * Shopping List Manager - Custom Lovelace Card - * - * Uses proper Home Assistant WebSocket API - * Version: 2.1.0 - Fixed syntax errors */ // Category definitions @@ -112,16 +109,53 @@ class ShoppingListCard extends HTMLElement { } setConfig(config) { - this._config = { ...config }; // shallow-copy: HA 2026+ freezes the original - // Derive a unique settings key for THIS card instance so each card on - // the dashboard keeps its own independent settings in localStorage. - // Set `card_id` in YAML for an explicit stable label; otherwise title is used. - const id = (config.card_id || config.title || 'shopping_list').toString().trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'); - this._settingsKey = `shopping_list_settings_${id}`; - // Re-load settings now that we have the correct key - this._settings = this._loadSettings(); + if (!config || typeof config !== 'object') { + throw new Error('Invalid configuration'); + } + + // Normalize + freeze config shape here + this._config = { + title: config.title ?? 'Shopping List', + card_id: config.card_id, + products_per_row: config.products_per_row ?? 'auto', + layout: config.layout ?? 'grid', + haptics: config.haptics ?? 'medium', + hide_completed: !!config.hide_completed, + compact_headers: !!config.compact_headers + }; + + // Derive a stable per-card storage key + const idSource = + this._config.card_id || + this._config.title || + 'shopping_list'; + + const id = idSource + .toString() + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_'); + + const newSettingsKey = `shopping_list_settings_${id}`; + + // If the card_id / title changed, reload settings + if (this._settingsKey !== newSettingsKey) { + this._settingsKey = newSettingsKey; + this._settings = this._loadSettings(); + } + + // First-time init + if (!this._settings) { + this._settings = this._loadSettings(); + } + + // Trigger a re-render if already attached + if (this.isConnected) { + this._render?.(); + } } + /** * Load data using Home Assistant's connection.sendMessagePromise */ @@ -1110,15 +1144,17 @@ class ShoppingListCard extends HTMLElement { * Fuzzy search - matches even with typos or partial matches */ _fuzzyMatch(text, query) { - text = text.toLowerCase(); - query = query.toLowerCase(); - + if (!text || !query) return false; + + text = String(text).toLowerCase(); + query = String(query).toLowerCase(); + // Exact match or substring if (text.includes(query)) { return true; } - - // Fuzzy match - allows for missing characters + + // Fuzzy match - characters in order let queryIndex = 0; for (let i = 0; i < text.length && queryIndex < query.length; i++) { if (text[i] === query[queryIndex]) { @@ -1128,6 +1164,7 @@ class ShoppingListCard extends HTMLElement { return queryIndex === query.length; } + /** * Filter products by search and split into active/inactive */ @@ -1138,7 +1175,8 @@ class ShoppingListCard extends HTMLElement { if (this._searchQuery) { const query = this._searchQuery.toLowerCase(); products = products.filter(product => - this._fuzzyMatch(product.name, query) + this._fuzzyMatch(product.name, query) || + this._fuzzyMatch(product.category, query) ); } @@ -1185,6 +1223,8 @@ class ShoppingListCard extends HTMLElement { */ _getInactiveProducts() { const filtered = this._getFilteredProducts(); + + // 🔑 When searching, show ALL inactive matches (including recently used) return filtered.filter(product => { const qty = this._activeList[product.key]?.qty || 0; return qty === 0; @@ -1660,21 +1700,35 @@ class ShoppingListCard extends HTMLElement { } // Show inactive products in "Recently Used" section (unless hidden) - if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) { - html += ` -
-
- Recently Used + if (inactiveProducts.length > 0 && !this._settings.hideCompleted) { + if (this._searchQuery) { + // During search: just show matches, no header + const containerClass = + this._settings.layout === 'list' ? 'product-list' : 'product-grid'; + + html += ` +
+ ${inactiveProducts.map(p => this._renderProductTile(p)).join('')}
- ${this._sortBy === 'category' ? - this._renderProductsByCategory(inactiveProducts, true) : - `
- ${inactiveProducts.map(product => this._renderProductTile(product)).join('')} -
` - } -
- `; + `; + } else { + // Normal Recently Used section + html += ` +
+
+ Recently Used +
+ ${this._sortBy === 'category' + ? this._renderProductsByCategory(inactiveProducts, true) + : `
+ ${inactiveProducts.map(p => this._renderProductTile(p)).join('')} +
` + } +
+ `; + } } + // Empty state if (activeProducts.length === 0 && inactiveProducts.length === 0) { @@ -1940,257 +1994,14 @@ class ShoppingListCard extends HTMLElement { } } - // Temporarily disabled GUI editor - use YAML configuration - // static getConfigElement() { - // return document.createElement('shopping-list-card-editor'); - // } - - static getStubConfig() { - return { title: 'Shopping List' }; - } + getCardSize() { return 3; } + } -// ── GUI Config Editor ───────────────────────────────────────── -// This is the standard HA card-editor pattern. HA renders this -// element inside its "configure card" panel automatically when the -// card exposes a static getConfigElement() method. -class ShoppingListCardEditor extends HTMLElement { - setConfig(config) { - console.log('[Editor] setConfig called, _rendered:', this._rendered, 'config:', config); - this._config = { ...(config || {}) }; // shallow-copy: HA 2026+ freezes the original - if (!this._rendered) { - console.log('[Editor] First render, calling _render()'); - this._render(); // only render once, then STOP - } else { - console.log('[Editor] Already rendered, ignoring setConfig'); - } - // After first render, ignore setConfig calls - the updateConfig closure - // already handles live updates by mutating this._config directly - } - - set hass(hass) { - console.log('[Editor] hass setter called, _rendered:', this._rendered); - this._hass = hass; - if (!this._rendered) this._render(); - } - - _render() { - if (!this._config) return; // nothing to render yet - - // Attach shadow DOM once, reuse on subsequent renders - if (!this.shadowRoot) { - this.attachShadow({ mode: 'open' }); - } - - const title = this._config.title || ''; - const productsPerRow = String(this._config.products_per_row || '3'); - const layout = this._config.layout || 'grid'; - const haptics = this._config.haptics || 'medium'; - const hideCompleted = !!this._config.hide_completed; - const compactHeaders = !!this._config.compact_headers; - - this.shadowRoot.innerHTML = ` - - - -
- -
- - -
- - Auto - 2 - 3 - 4 - 5 - - - - - Grid - List - -
- - -
- - Off - Low - Medium - High - -
- -
- - -
-
- Hide completed items - Hides the "Recently Used" section showing products not currently on the list. -
- -
- - -
-
- Compact headers - Show category names only (no emoji) -
- -
- - - - ${this._showCodeEditor ? `
${this._toYaml()}
` : ''} - `; - - // ── Set values imperatively (reliable with shadow DOM + HA components) ── - const titleEl = this.shadowRoot.querySelector('#ed-title'); - const perrowEl = this.shadowRoot.querySelector('#ed-perrow'); - const layoutEl = this.shadowRoot.querySelector('#ed-layout'); - const hapticsEl = this.shadowRoot.querySelector('#ed-haptics'); - const hideCompletedEl = this.shadowRoot.querySelector('#ed-hide-completed'); - const hideHeadersEl = this.shadowRoot.querySelector('#ed-hide-headers'); - - titleEl.value = title; - perrowEl.value = productsPerRow; - layoutEl.value = layout; - hapticsEl.value = haptics; - hideCompletedEl.checked = hideCompleted; - hideHeadersEl.checked = compactHeaders; - - // ── Single updateConfig closure — mirrors your working pattern ── - const updateConfig = () => { - console.log('[Editor] updateConfig called'); - this._config = { - ...this._config, - title: titleEl.value || 'Shopping List', - products_per_row: perrowEl.value, - layout: layoutEl.value, - haptics: hapticsEl.value, - hide_completed: hideCompletedEl.checked, - compact_headers: hideHeadersEl.checked - }; - console.log('[Editor] Dispatching config-changed:', this._config); - this.dispatchEvent(new CustomEvent('config-changed', { - detail: { config: this._config }, - bubbles: true, - composed: true - })); - }; - - titleEl.addEventListener('input', updateConfig); - perrowEl.addEventListener('value-changed', updateConfig); - layoutEl.addEventListener('value-changed', updateConfig); - hapticsEl.addEventListener('value-changed', updateConfig); - hideCompletedEl.addEventListener('checked-changed', updateConfig); - hideHeadersEl.addEventListener('checked-changed', updateConfig); - - this.shadowRoot.querySelector('#ed-code-toggle').addEventListener('click', () => { - this._showCodeEditor = !this._showCodeEditor; - this._render(); - }); - - this._rendered = true; - } - - _toYaml() { - const lines = ['type: custom:shopping-list-card']; - if (this._config.title) lines.push('title: ' + this._config.title); - if (this._config.products_per_row) lines.push('products_per_row: ' + this._config.products_per_row); - if (this._config.layout) lines.push('layout: ' + this._config.layout); - if (this._config.haptics) lines.push('haptics: ' + this._config.haptics); - if (this._config.hide_completed) lines.push('hide_completed: true'); - if (this._config.compact_headers) lines.push('compact_headers: true'); - return lines.join('\n'); - } -} - -customElements.define('shopping-list-card-editor', ShoppingListCardEditor); customElements.define('shopping-list-card', ShoppingListCard); window.customCards = window.customCards || [];