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

18
api.php
View File

@@ -229,24 +229,26 @@ switch ($action) {
$name = sanitize($input['name'] ?? ''); $name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium', 50); $container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
$category = sanitize($input['category'] ?? 'other', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false); $outOfStock = (bool)($input['outOfStock'] ?? false);
if (empty($name)) { if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Name required']); echo json_encode(['success' => false, 'error' => 'Name required']);
break; break;
} }
$inventory = loadInventory(); $inventory = loadInventory();
$newItem = [ $newItem = [
'id' => getNextId($inventory), 'id' => getNextId($inventory),
'name' => $name, 'name' => $name,
'container' => $container, 'container' => $container,
'category' => $category,
'outOfStock' => $outOfStock 'outOfStock' => $outOfStock
]; ];
$inventory[] = $newItem; $inventory[] = $newItem;
saveInventory($inventory); saveInventory($inventory);
echo json_encode(['success' => true, 'item' => $newItem]); echo json_encode(['success' => true, 'item' => $newItem]);
break; break;
@@ -259,31 +261,33 @@ switch ($action) {
$id = (int)($input['id'] ?? 0); $id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? ''); $name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? '', 50); $container = sanitize($input['container'] ?? '', 50);
$category = sanitize($input['category'] ?? '', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false); $outOfStock = (bool)($input['outOfStock'] ?? false);
if ($id <= 0) { if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid ID']); echo json_encode(['success' => false, 'error' => 'Invalid ID']);
break; break;
} }
$inventory = loadInventory(); $inventory = loadInventory();
$found = false; $found = false;
foreach ($inventory as &$item) { foreach ($inventory as &$item) {
if ($item['id'] === $id) { if ($item['id'] === $id) {
if (!empty($name)) $item['name'] = $name; if (!empty($name)) $item['name'] = $name;
if (!empty($container)) $item['container'] = $container; if (!empty($container)) $item['container'] = $container;
if (!empty($category)) $item['category'] = $category;
$item['outOfStock'] = $outOfStock; $item['outOfStock'] = $outOfStock;
$found = true; $found = true;
break; break;
} }
} }
if (!$found) { if (!$found) {
echo json_encode(['success' => false, 'error' => 'Item not found']); echo json_encode(['success' => false, 'error' => 'Item not found']);
break; break;
} }
saveInventory($inventory); saveInventory($inventory);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; 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 # Deploy code files
rsync -avz --no-t --no-p --delete \ rsync -avz --no-t --no-p --delete \
--exclude 'data/' \ --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 # Deploy data directory protection
scp data/index.php ${HOST}:${DIR}/data/index.php 2>/dev/null || true scp data/index.php ${HOST}:${DIR}/data/index.php 2>/dev/null || true

View File

@@ -207,6 +207,14 @@
border-radius: 50%; border-radius: 50%;
} }
.category-tag {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
font-weight: 500;
}
.status-badge { .status-badge {
padding: 4px 10px; padding: 4px 10px;
font-size: 0.75rem; font-size: 0.75rem;
@@ -688,13 +696,13 @@
</div> </div>
</div> </div>
<div class="nav-tabs" id="navTabs"> <div class="nav-tabs" id="navTabs" role="tablist" aria-label="Inventory filters">
<button class="nav-tab active" data-filter="all">All</button> <button class="nav-tab active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="nav-tab" data-filter="in-stock">In Stock</button> <button class="nav-tab" data-filter="in-stock" role="tab" aria-selected="false">In Stock</button>
<button class="nav-tab" data-filter="out">Out of Stock</button> <button class="nav-tab" data-filter="out" role="tab" aria-selected="false">Out of Stock</button>
<button class="nav-tab" data-filter="spices">Spices</button> <!-- Category tabs inserted dynamically here -->
<button class="nav-tab" data-filter="recent">Recent</button> <button class="nav-tab" data-filter="recent" role="tab" aria-selected="false">Recent</button>
<button class="nav-tab" data-filter="qr">QR Code</button> <button class="nav-tab" data-filter="qr" role="tab" aria-selected="false">QR Code</button>
</div> </div>
<div class="container-legend" id="containerLegend"></div> <div class="container-legend" id="containerLegend"></div>
@@ -801,6 +809,12 @@
<!-- Options loaded from containers.json --> <!-- Options loaded from containers.json -->
</select> </select>
</div> </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="form-group">
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="itemOutOfStock"> <input type="checkbox" id="itemOutOfStock">
@@ -821,11 +835,15 @@
let containerTypes = {}; let containerTypes = {};
let containerCategories = []; let containerCategories = [];
// Item categories - loaded from categories.json
let itemCategories = {};
let inventory = []; let inventory = [];
let isUnlocked = false; let isUnlocked = false;
let currentPin = null; let currentPin = null;
let currentFilter = 'all'; let currentFilter = 'all';
let containerFilter = null; let containerFilter = null;
let categoryFilter = null;
let hasPinSet = false; let hasPinSet = false;
// API calls // 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 // Initialize
async function init() { async function init() {
try { try {
await loadContainers(); await loadContainers();
await loadCategories();
const result = await apiCall('get'); const result = await apiCall('get');
inventory = result.inventory || []; inventory = result.inventory || [];
@@ -987,14 +1068,14 @@
function renderInventory() { function renderInventory() {
const list = document.getElementById('inventoryList'); const list = document.getElementById('inventoryList');
const searchTerm = document.getElementById('searchInput').value.toLowerCase(); const searchTerm = document.getElementById('searchInput').value.toLowerCase();
let filtered = inventory.filter(item => { let filtered = inventory.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm); const matchesSearch = item.name.toLowerCase().includes(searchTerm);
const matchesFilter = currentFilter === 'all' || const matchesFilter = currentFilter === 'all' ||
currentFilter === 'recent' || currentFilter === 'recent' ||
(currentFilter === 'in-stock' && !item.outOfStock) || (currentFilter === 'in-stock' && !item.outOfStock) ||
(currentFilter === 'out' && item.outOfStock) || (currentFilter === 'out' && item.outOfStock) ||
(currentFilter === 'spices' && item.container.startsWith('spice-')); (currentFilter === 'category' && item.category === categoryFilter);
const matchesContainer = !containerFilter || item.container === containerFilter; const matchesContainer = !containerFilter || item.container === containerFilter;
return matchesSearch && matchesFilter && matchesContainer; return matchesSearch && matchesFilter && matchesContainer;
}); });
@@ -1024,20 +1105,23 @@
return; return;
} }
list.innerHTML = filtered.map(item => ` 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'}"> 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-info">
<div class="item-name">${escapeHtml(item.name)}</div> <div class="item-name">${escapeHtml(item.name)}</div>
<div class="item-container"> <div class="item-container">
<div class="container-dot" style="background: ${containerTypes[item.container]?.color || '#ccc'}" aria-hidden="true"></div> <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'} ${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>
</div> </div>
<span class="status-badge ${item.outOfStock ? 'status-out' : 'status-in-stock'}"> <span class="status-badge ${item.outOfStock ? 'status-out' : 'status-in-stock'}">
${item.outOfStock ? 'Out' : 'In Stock'} ${item.outOfStock ? 'Out' : 'In Stock'}
</span> </span>
</div> </div>
`).join(''); `}).join('');
} }
function escapeHtml(text) { function escapeHtml(text) {
@@ -1113,14 +1197,19 @@
function setupEventListeners() { function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', renderInventory); document.getElementById('searchInput').addEventListener('input', renderInventory);
document.querySelectorAll('.nav-tab').forEach(tab => { document.querySelectorAll('.nav-tab[data-filter]').forEach(tab => {
tab.addEventListener('click', () => { 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.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const filter = tab.dataset.filter; const filter = tab.dataset.filter;
currentFilter = filter; currentFilter = filter;
categoryFilter = null; // Clear category filter when switching to non-category tabs
if (filter === 'qr') { if (filter === 'qr') {
document.getElementById('inventoryList').style.display = 'none'; document.getElementById('inventoryList').style.display = 'none';
document.getElementById('containerLegend').style.display = 'none'; document.getElementById('containerLegend').style.display = 'none';
@@ -1342,10 +1431,11 @@
toggleLock(); toggleLock();
return; return;
} }
document.getElementById('itemModalTitle').textContent = 'Add Item'; document.getElementById('itemModalTitle').textContent = 'Add Item';
document.getElementById('itemName').value = ''; document.getElementById('itemName').value = '';
document.getElementById('itemContainer').selectedIndex = 0; document.getElementById('itemContainer').selectedIndex = 0;
document.getElementById('itemCategory').value = 'other';
document.getElementById('itemOutOfStock').checked = false; document.getElementById('itemOutOfStock').checked = false;
document.getElementById('editingItemId').value = ''; document.getElementById('editingItemId').value = '';
document.getElementById('deleteBtn').style.display = 'none'; document.getElementById('deleteBtn').style.display = 'none';
@@ -1357,13 +1447,14 @@
if (!isUnlocked) { if (!isUnlocked) {
return; // View only mode return; // View only mode
} }
const item = inventory.find(i => i.id === id); const item = inventory.find(i => i.id === id);
if (!item) return; if (!item) return;
document.getElementById('itemModalTitle').textContent = 'Edit Item'; document.getElementById('itemModalTitle').textContent = 'Edit Item';
document.getElementById('itemName').value = item.name; document.getElementById('itemName').value = item.name;
document.getElementById('itemContainer').value = item.container; document.getElementById('itemContainer').value = item.container;
document.getElementById('itemCategory').value = item.category || 'other';
document.getElementById('itemOutOfStock').checked = item.outOfStock; document.getElementById('itemOutOfStock').checked = item.outOfStock;
document.getElementById('editingItemId').value = id; document.getElementById('editingItemId').value = id;
document.getElementById('deleteBtn').style.display = 'block'; document.getElementById('deleteBtn').style.display = 'block';
@@ -1373,9 +1464,10 @@
async function saveItem() { async function saveItem() {
const name = document.getElementById('itemName').value.trim(); const name = document.getElementById('itemName').value.trim();
const container = document.getElementById('itemContainer').value; const container = document.getElementById('itemContainer').value;
const category = document.getElementById('itemCategory').value;
const outOfStock = document.getElementById('itemOutOfStock').checked; const outOfStock = document.getElementById('itemOutOfStock').checked;
const editingId = document.getElementById('editingItemId').value; const editingId = document.getElementById('editingItemId').value;
if (!name) { if (!name) {
showToast('Please enter an item name'); showToast('Please enter an item name');
return; return;
@@ -1401,6 +1493,7 @@
id: parseInt(editingId), id: parseInt(editingId),
name, name,
container, container,
category,
outOfStock, outOfStock,
pin: currentPin pin: currentPin
}); });
@@ -1409,13 +1502,14 @@
if (item) { if (item) {
item.name = name; item.name = name;
item.container = container; item.container = container;
item.category = category;
item.outOfStock = outOfStock; item.outOfStock = outOfStock;
} }
showToast('Item updated'); showToast('Item updated');
} else { } else {
// Add new // 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) { if (result.success && result.item) {
inventory.push(result.item); inventory.push(result.item);
} }