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:
18
api.php
18
api.php
@@ -229,24 +229,26 @@ 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)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Name required']);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
$inventory = loadInventory();
|
||||
$newItem = [
|
||||
'id' => getNextId($inventory),
|
||||
'name' => $name,
|
||||
'container' => $container,
|
||||
'category' => $category,
|
||||
'outOfStock' => $outOfStock
|
||||
];
|
||||
|
||||
|
||||
$inventory[] = $newItem;
|
||||
saveInventory($inventory);
|
||||
|
||||
|
||||
echo json_encode(['success' => true, 'item' => $newItem]);
|
||||
break;
|
||||
|
||||
@@ -259,31 +261,33 @@ 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) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
$inventory = loadInventory();
|
||||
$found = false;
|
||||
|
||||
|
||||
foreach ($inventory as &$item) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!$found) {
|
||||
echo json_encode(['success' => false, 'error' => 'Item not found']);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
saveInventory($inventory);
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
26
categories.json
Normal file
26
categories.json
Normal 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
2
deploy
@@ -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
|
||||
|
||||
138
index.html
138
index.html
@@ -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 || [];
|
||||
@@ -987,14 +1068,14 @@
|
||||
function renderInventory() {
|
||||
const list = document.getElementById('inventoryList');
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
|
||||
let filtered = inventory.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm);
|
||||
const matchesFilter = currentFilter === 'all' ||
|
||||
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,14 +1197,19 @@
|
||||
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';
|
||||
document.getElementById('containerLegend').style.display = 'none';
|
||||
@@ -1342,10 +1431,11 @@
|
||||
toggleLock();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
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';
|
||||
@@ -1357,13 +1447,14 @@
|
||||
if (!isUnlocked) {
|
||||
return; // View only mode
|
||||
}
|
||||
|
||||
|
||||
const item = inventory.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
|
||||
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,9 +1464,10 @@
|
||||
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;
|
||||
|
||||
|
||||
if (!name) {
|
||||
showToast('Please enter an item name');
|
||||
return;
|
||||
@@ -1401,6 +1493,7 @@
|
||||
id: parseInt(editingId),
|
||||
name,
|
||||
container,
|
||||
category,
|
||||
outOfStock,
|
||||
pin: currentPin
|
||||
});
|
||||
@@ -1409,13 +1502,14 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user