Compare commits

..

2 Commits

Author SHA1 Message Date
thekiwismarthome 9a5a0bbcaf Bump version to 1.3.0 in manifest.json 2026-02-06 21:51:21 +13:00
thekiwismarthome 40d29aee3a Improve setConfig method for better validation
Enhance configuration handling with validation and normalization.
2026-02-06 21:50:23 +13:00
2 changed files with 276 additions and 41 deletions
@@ -1,5 +1,8 @@
/** /**
* Shopping List Manager - Custom Lovelace Card * Shopping List Manager - Custom Lovelace Card
*
* Uses proper Home Assistant WebSocket API
* Version: 2.1.0 - Fixed syntax errors
*/ */
// Category definitions // Category definitions
@@ -1144,17 +1147,15 @@ class ShoppingListCard extends HTMLElement {
* Fuzzy search - matches even with typos or partial matches * Fuzzy search - matches even with typos or partial matches
*/ */
_fuzzyMatch(text, query) { _fuzzyMatch(text, query) {
if (!text || !query) return false; text = text.toLowerCase();
query = query.toLowerCase();
text = String(text).toLowerCase();
query = String(query).toLowerCase();
// Exact match or substring // Exact match or substring
if (text.includes(query)) { if (text.includes(query)) {
return true; return true;
} }
// Fuzzy match - characters in order // Fuzzy match - allows for missing characters
let queryIndex = 0; let queryIndex = 0;
for (let i = 0; i < text.length && queryIndex < query.length; i++) { for (let i = 0; i < text.length && queryIndex < query.length; i++) {
if (text[i] === query[queryIndex]) { if (text[i] === query[queryIndex]) {
@@ -1164,7 +1165,6 @@ class ShoppingListCard extends HTMLElement {
return queryIndex === query.length; return queryIndex === query.length;
} }
/** /**
* Filter products by search and split into active/inactive * Filter products by search and split into active/inactive
*/ */
@@ -1175,8 +1175,7 @@ class ShoppingListCard extends HTMLElement {
if (this._searchQuery) { if (this._searchQuery) {
const query = this._searchQuery.toLowerCase(); const query = this._searchQuery.toLowerCase();
products = products.filter(product => products = products.filter(product =>
this._fuzzyMatch(product.name, query) || this._fuzzyMatch(product.name, query)
this._fuzzyMatch(product.category, query)
); );
} }
@@ -1223,8 +1222,6 @@ class ShoppingListCard extends HTMLElement {
*/ */
_getInactiveProducts() { _getInactiveProducts() {
const filtered = this._getFilteredProducts(); const filtered = this._getFilteredProducts();
// 🔑 When searching, show ALL inactive matches (including recently used)
return filtered.filter(product => { return filtered.filter(product => {
const qty = this._activeList[product.key]?.qty || 0; const qty = this._activeList[product.key]?.qty || 0;
return qty === 0; return qty === 0;
@@ -1700,35 +1697,21 @@ class ShoppingListCard extends HTMLElement {
} }
// Show inactive products in "Recently Used" section (unless hidden) // Show inactive products in "Recently Used" section (unless hidden)
if (inactiveProducts.length > 0 && !this._settings.hideCompleted) { if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) {
if (this._searchQuery) { html += `
// During search: just show matches, no header <div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--divider-color);">
const containerClass = <div style="font-size: 12px; color: var(--secondary-text-color); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500;">
this._settings.layout === 'list' ? 'product-list' : 'product-grid'; Recently Used
html += `
<div class="${containerClass}">
${inactiveProducts.map(p => this._renderProductTile(p)).join('')}
</div> </div>
`; ${this._sortBy === 'category' ?
} else { this._renderProductsByCategory(inactiveProducts, true) :
// Normal Recently Used section `<div class="${this._settings.layout === 'list' ? 'product-list' : 'product-grid'}">
html += ` ${inactiveProducts.map(product => this._renderProductTile(product)).join('')}
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--divider-color);"> </div>`
<div style="font-size: 12px; color: var(--secondary-text-color); margin-bottom: 12px; text-transform: uppercase;"> }
Recently Used </div>
</div> `;
${this._sortBy === 'category'
? this._renderProductsByCategory(inactiveProducts, true)
: `<div class="${this._settings.layout === 'list' ? 'product-list' : 'product-grid'}">
${inactiveProducts.map(p => this._renderProductTile(p)).join('')}
</div>`
}
</div>
`;
}
} }
// Empty state // Empty state
if (activeProducts.length === 0 && inactiveProducts.length === 0) { if (activeProducts.length === 0 && inactiveProducts.length === 0) {
@@ -1994,7 +1977,22 @@ class ShoppingListCard extends HTMLElement {
} }
} }
// Temporarily disabled GUI editor - use YAML configuration
static getConfigElement() {
return document.createElement('shopping-list-card-editor');
}
static getStubConfig() {
return {
type: 'custom:shopping-list-card',
title: 'Shopping List',
products_per_row: 'auto',
layout: 'grid',
haptics: 'medium',
hide_completed: false,
compact_headers: false
};
}
getCardSize() { getCardSize() {
return 3; return 3;
@@ -2002,6 +2000,243 @@ class ShoppingListCard extends HTMLElement {
} }
// ── 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 = `
<style>
.row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
ha-text-field, ha-select {
display: block;
flex: 1;
min-width: 200px;
}
.toggle-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 0;
gap: 12px;
}
.toggle-row + .toggle-row {
border-top: 1px solid var(--divider-color);
}
.toggle-text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toggle-label {
font-size: 14px;
color: var(--primary-text-color);
font-weight: 400;
}
.toggle-hint {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 2px;
}
.section-divider {
border: none;
border-top: 1px solid var(--divider-color);
margin: 8px 0 16px 0;
}
.code-editor-link {
display: block;
margin-top: 16px;
padding: 12px 0 0 0;
border-top: 1px solid var(--divider-color);
font-size: 14px;
color: var(--primary-color);
cursor: pointer;
background: none;
border-left: none;
border-right: none;
border-bottom: none;
text-align: left;
width: 100%;
}
.code-editor-link:hover { text-decoration: underline; }
.yaml-block {
background: var(--card-background-color);
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 12px;
margin-top: 8px;
font-family: monospace;
font-size: 13px;
color: var(--primary-text-color);
white-space: pre;
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
}
</style>
<!-- Title -->
<div class="row">
<ha-text-field id="ed-title" label="Title" placeholder="Shopping List"></ha-text-field>
</div>
<!-- Products Per Row -->
<div class="row">
<ha-select id="ed-perrow" label="Products Per Row">
<mwc-list-item value="auto">Auto</mwc-list-item>
<mwc-list-item value="2">2</mwc-list-item>
<mwc-list-item value="3">3</mwc-list-item>
<mwc-list-item value="4">4</mwc-list-item>
<mwc-list-item value="5">5</mwc-list-item>
</ha-select>
<!-- Layout -->
<ha-select id="ed-layout" label="Layout">
<mwc-list-item value="grid">Grid</mwc-list-item>
<mwc-list-item value="list">List</mwc-list-item>
</ha-select>
</div>
<!-- Haptic Feedback -->
<div class="row">
<ha-select id="ed-haptics" label="Haptic Feedback">
<mwc-list-item value="off">Off</mwc-list-item>
<mwc-list-item value="low">Low</mwc-list-item>
<mwc-list-item value="medium">Medium</mwc-list-item>
<mwc-list-item value="high">High</mwc-list-item>
</ha-select>
</div>
<hr class="section-divider">
<!-- Toggle: Hide completed items -->
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-label">Hide completed items</span>
<span class="toggle-hint">Hides the "Recently Used" section showing products not currently on the list.</span>
</div>
<ha-switch id="ed-hide-completed"></ha-switch>
</div>
<!-- Toggle: Compact headers -->
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-label">Compact headers</span>
<span class="toggle-hint">Show category names only (no emoji)</span>
</div>
<ha-switch id="ed-hide-headers"></ha-switch>
</div>
<!-- Show/hide YAML preview -->
<button class="code-editor-link" id="ed-code-toggle">
${this._showCodeEditor ? 'Hide code editor' : 'Show code editor'}
</button>
${this._showCodeEditor ? `<div class="yaml-block">${this._toYaml()}</div>` : ''}
`;
// ── 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); customElements.define('shopping-list-card', ShoppingListCard);
window.customCards = window.customCards || []; window.customCards = window.customCards || [];
@@ -1,7 +1,7 @@
{ {
"domain": "shopping_list_manager", "domain": "shopping_list_manager",
"name": "Shopping List Manager", "name": "Shopping List Manager",
"version": "1.4.0", "version": "1.3.0",
"documentation": "https://github.com/thekiwismarthome/shopping-list-manager", "documentation": "https://github.com/thekiwismarthome/shopping-list-manager",
"issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues", "issue_tracker": "https://github.com/thekiwismarthome/shopping-list-manager/issues",
"requirements": [], "requirements": [],