Fix category filter bug and add tab accessibility

The category tabs were not filtering because the generic tab event handler
was overwriting currentFilter with undefined (category tabs use data-category
not data-filter). Fixed by targeting only [data-filter] tabs and clearing
categoryFilter when switching to non-category tabs.

Added proper ARIA attributes for screen reader accessibility:
- role="tablist" on nav-tabs container
- role="tab" and aria-selected on all tab buttons
- Dynamic aria-selected updates on tab clicks

Also includes API support for category field and deploy script update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2025-12-24 12:26:35 -05:00
parent 39b1ff7bc0
commit 459878f045
4 changed files with 154 additions and 30 deletions

View File

@@ -229,6 +229,7 @@ switch ($action) {
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
$category = sanitize($input['category'] ?? 'other', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if (empty($name)) {
@@ -241,6 +242,7 @@ switch ($action) {
'id' => getNextId($inventory),
'name' => $name,
'container' => $container,
'category' => $category,
'outOfStock' => $outOfStock
];
@@ -259,6 +261,7 @@ switch ($action) {
$id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? '', 50);
$category = sanitize($input['category'] ?? '', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if ($id <= 0) {
@@ -273,6 +276,7 @@ switch ($action) {
if ($item['id'] === $id) {
if (!empty($name)) $item['name'] = $name;
if (!empty($container)) $item['container'] = $container;
if (!empty($category)) $item['category'] = $category;
$item['outOfStock'] = $outOfStock;
$found = true;
break;

26
categories.json Normal file
View File

@@ -0,0 +1,26 @@
{
"flours": {
"name": "Flours & Starches",
"color": "#8B4513"
},
"spices": {
"name": "Spices & Seasonings",
"color": "#CD853F"
},
"pasta": {
"name": "Pasta & Grains",
"color": "#DAA520"
},
"baking": {
"name": "Baking",
"color": "#D2691E"
},
"produce": {
"name": "Beans & Dried Produce",
"color": "#556B2F"
},
"other": {
"name": "Other",
"color": "#708090"
}
}

2
deploy
View File

@@ -6,7 +6,7 @@ DIR=/var/www/my_webapp__2/www
# Deploy code files
rsync -avz --no-t --no-p --delete \
--exclude 'data/' \
index.html api.php containers.json og-image.png ${HOST}:${DIR}
index.html api.php containers.json categories.json og-image.png ${HOST}:${DIR}
# Deploy data directory protection
scp data/index.php ${HOST}:${DIR}/data/index.php 2>/dev/null || true

View File

@@ -207,6 +207,14 @@
border-radius: 50%;
}
.category-tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
font-weight: 500;
}
.status-badge {
padding: 4px 10px;
font-size: 0.75rem;
@@ -688,13 +696,13 @@
</div>
</div>
<div class="nav-tabs" id="navTabs">
<button class="nav-tab active" data-filter="all">All</button>
<button class="nav-tab" data-filter="in-stock">In Stock</button>
<button class="nav-tab" data-filter="out">Out of Stock</button>
<button class="nav-tab" data-filter="spices">Spices</button>
<button class="nav-tab" data-filter="recent">Recent</button>
<button class="nav-tab" data-filter="qr">QR Code</button>
<div class="nav-tabs" id="navTabs" role="tablist" aria-label="Inventory filters">
<button class="nav-tab active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="nav-tab" data-filter="in-stock" role="tab" aria-selected="false">In Stock</button>
<button class="nav-tab" data-filter="out" role="tab" aria-selected="false">Out of Stock</button>
<!-- Category tabs inserted dynamically here -->
<button class="nav-tab" data-filter="recent" role="tab" aria-selected="false">Recent</button>
<button class="nav-tab" data-filter="qr" role="tab" aria-selected="false">QR Code</button>
</div>
<div class="container-legend" id="containerLegend"></div>
@@ -801,6 +809,12 @@
<!-- Options loaded from containers.json -->
</select>
</div>
<div class="form-group">
<label class="form-label" for="itemCategory">Category</label>
<select class="form-select" id="itemCategory">
<!-- Options loaded from categories.json -->
</select>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="itemOutOfStock">
@@ -821,11 +835,15 @@
let containerTypes = {};
let containerCategories = [];
// Item categories - loaded from categories.json
let itemCategories = {};
let inventory = [];
let isUnlocked = false;
let currentPin = null;
let currentFilter = 'all';
let containerFilter = null;
let categoryFilter = null;
let hasPinSet = false;
// API calls
@@ -909,10 +927,73 @@
});
}
// Load item categories from JSON file
async function loadCategories() {
try {
const response = await fetch('categories.json');
itemCategories = await response.json();
buildCategoryDropdown();
buildCategoryTabs();
} catch (error) {
console.error('Error loading categories:', error);
}
}
// Build the category dropdown
function buildCategoryDropdown() {
const select = document.getElementById('itemCategory');
select.replaceChildren();
Object.entries(itemCategories).forEach(([key, cat]) => {
const option = document.createElement('option');
option.value = key;
option.textContent = cat.name;
select.appendChild(option);
});
}
// Build category filter tabs
function buildCategoryTabs() {
const navTabs = document.getElementById('navTabs');
// Remove old category tabs if they exist
navTabs.querySelectorAll('.nav-tab[data-category]').forEach(tab => tab.remove());
// Add category tabs after "Out of Stock" tab, sorted alphabetically
const outTab = navTabs.querySelector('[data-filter="out"]');
const sortedCategories = Object.entries(itemCategories)
.sort((a, b) => a[1].name.localeCompare(b[1].name))
.reverse(); // Reverse so insertAdjacentElement builds correct order
sortedCategories.forEach(([key, cat]) => {
const tab = document.createElement('button');
tab.className = 'nav-tab';
tab.dataset.category = key;
tab.textContent = cat.name;
tab.style.borderLeft = `3px solid ${cat.color}`;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.addEventListener('click', () => {
// Clear other active states and aria-selected
navTabs.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
currentFilter = 'category';
categoryFilter = key;
document.getElementById('inventoryList').style.display = 'block';
document.getElementById('containerLegend').style.display = 'grid';
document.getElementById('qrSection').style.display = 'none';
renderInventory();
});
outTab.insertAdjacentElement('afterend', tab);
});
}
// Initialize
async function init() {
try {
await loadContainers();
await loadCategories();
const result = await apiCall('get');
inventory = result.inventory || [];
@@ -994,7 +1075,7 @@
currentFilter === 'recent' ||
(currentFilter === 'in-stock' && !item.outOfStock) ||
(currentFilter === 'out' && item.outOfStock) ||
(currentFilter === 'spices' && item.container.startsWith('spice-'));
(currentFilter === 'category' && item.category === categoryFilter);
const matchesContainer = !containerFilter || item.container === containerFilter;
return matchesSearch && matchesFilter && matchesContainer;
});
@@ -1024,20 +1105,23 @@
return;
}
list.innerHTML = filtered.map(item => `
<div class="inventory-item ${item.outOfStock ? 'out-of-stock' : ''}" onclick="openEditModal(${item.id})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openEditModal(${item.id});}" tabindex="0" role="button" aria-label="${escapeHtml(item.name)}, ${item.outOfStock ? 'out of stock' : 'in stock'}">
list.innerHTML = filtered.map(item => {
const cat = itemCategories[item.category];
return `
<div class="inventory-item ${item.outOfStock ? 'out-of-stock' : ''}" onclick="openEditModal(${item.id})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openEditModal(${item.id});}" tabindex="0" role="button" aria-label="${escapeHtml(item.name)}, ${cat ? cat.name : 'uncategorized'}, ${item.outOfStock ? 'out of stock' : 'in stock'}">
<div class="item-info">
<div class="item-name">${escapeHtml(item.name)}</div>
<div class="item-container">
<div class="container-dot" style="background: ${containerTypes[item.container]?.color || '#ccc'}" aria-hidden="true"></div>
${containerTypes[item.container] ? `${containerTypes[item.container].category}: ${containerTypes[item.container].name}` : 'Unknown'}
${cat ? `<span class="category-tag" style="background: ${cat.color}20; color: ${cat.color}; border: 1px solid ${cat.color}40;">${cat.name}</span>` : ''}
</div>
</div>
<span class="status-badge ${item.outOfStock ? 'status-out' : 'status-in-stock'}">
${item.outOfStock ? 'Out' : 'In Stock'}
</span>
</div>
`).join('');
`}).join('');
}
function escapeHtml(text) {
@@ -1113,13 +1197,18 @@
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', renderInventory);
document.querySelectorAll('.nav-tab').forEach(tab => {
document.querySelectorAll('.nav-tab[data-filter]').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const filter = tab.dataset.filter;
currentFilter = filter;
categoryFilter = null; // Clear category filter when switching to non-category tabs
if (filter === 'qr') {
document.getElementById('inventoryList').style.display = 'none';
@@ -1346,6 +1435,7 @@
document.getElementById('itemModalTitle').textContent = 'Add Item';
document.getElementById('itemName').value = '';
document.getElementById('itemContainer').selectedIndex = 0;
document.getElementById('itemCategory').value = 'other';
document.getElementById('itemOutOfStock').checked = false;
document.getElementById('editingItemId').value = '';
document.getElementById('deleteBtn').style.display = 'none';
@@ -1364,6 +1454,7 @@
document.getElementById('itemModalTitle').textContent = 'Edit Item';
document.getElementById('itemName').value = item.name;
document.getElementById('itemContainer').value = item.container;
document.getElementById('itemCategory').value = item.category || 'other';
document.getElementById('itemOutOfStock').checked = item.outOfStock;
document.getElementById('editingItemId').value = id;
document.getElementById('deleteBtn').style.display = 'block';
@@ -1373,6 +1464,7 @@
async function saveItem() {
const name = document.getElementById('itemName').value.trim();
const container = document.getElementById('itemContainer').value;
const category = document.getElementById('itemCategory').value;
const outOfStock = document.getElementById('itemOutOfStock').checked;
const editingId = document.getElementById('editingItemId').value;
@@ -1401,6 +1493,7 @@
id: parseInt(editingId),
name,
container,
category,
outOfStock,
pin: currentPin
});
@@ -1409,12 +1502,13 @@
if (item) {
item.name = name;
item.container = container;
item.category = category;
item.outOfStock = outOfStock;
}
showToast('Item updated');
} else {
// Add new
const result = await apiCall('add', { name, container, outOfStock, pin: currentPin });
const result = await apiCall('add', { name, container, category, outOfStock, pin: currentPin });
if (result.success && result.item) {
inventory.push(result.item);