From 7e137d1c21f54479883d7bce056962d375a80ad3 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Wed, 29 Apr 2026 19:55:28 +0200 Subject: [PATCH] feat: add kitchen-tabs utility, CSS, token and test --- package.json | 3 +- public/styles/kitchen-tabs.css | 86 ++++++++++++++++++++++++++++++++++ public/styles/tokens.css | 1 + public/utils/kitchen-tabs.js | 68 +++++++++++++++++++++++++++ test-kitchen-tabs.js | 63 +++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 public/styles/kitchen-tabs.css create mode 100644 public/utils/kitchen-tabs.js create mode 100644 test-kitchen-tabs.js diff --git a/package.json b/package.json index 1f39977..98fc3e3 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,13 @@ "test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js", "test:ux-utils": "node test-ux-utils.js", "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", + "test:kitchen-tabs": "node --loader ./test-browser-loader.mjs test-kitchen-tabs.js", "test:reminders": "node --experimental-sqlite test-reminders.js", "test:api": "node test-api.js", "test:setup": "node test-setup.js", "test:ics-parser": "node test-ics-parser.js", "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup" + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/public/styles/kitchen-tabs.css b/public/styles/kitchen-tabs.css new file mode 100644 index 0000000..3529844 --- /dev/null +++ b/public/styles/kitchen-tabs.css @@ -0,0 +1,86 @@ +/* Modul: Kitchen Tabs Bar + * Sticky Segment-Control für Mahlzeiten/Rezepte/Einkauf + Sub-Modul Layout-Fixes + */ + +.kitchen-tabs-bar { + display: flex; + gap: var(--space-1); + padding: var(--space-2) var(--space-4); + height: var(--kitchen-tabs-height); + box-sizing: border-box; + position: sticky; + top: 0; + z-index: var(--z-sticky); + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-border-subtle); + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + flex-shrink: 0; +} + +.kitchen-tabs-bar::-webkit-scrollbar { + display: none; +} + +.kitchen-tab { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-3); + height: 36px; + border-radius: var(--radius-full); + border: none; + background: transparent; + color: var(--color-text-secondary); + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background-color var(--transition-fast), color var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} + +.kitchen-tab:active { + transform: scale(0.96); + transition-duration: 0.06s; +} + +.kitchen-tab--active { + background-color: color-mix(in srgb, var(--active-module-accent, var(--color-accent)) 14%, transparent); + color: var(--active-module-accent, var(--color-accent)); +} + +.kitchen-tab__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.kitchen-tab__label { + line-height: 1; +} + +/* Mahlzeiten: sticky day-header unterhalb der Tab-Leiste */ +.has-kitchen-tabs .day-header { + top: var(--kitchen-tabs-height); +} + +/* Einkauf: Viewport-Höhe um Tab-Leiste reduzieren (Mobile) */ +.has-kitchen-tabs .shopping-page { + height: calc( + 100dvh + - var(--nav-bottom-height) + - var(--safe-area-inset-bottom) + - var(--kitchen-tabs-height) + ); +} + +/* Einkauf: Viewport-Höhe (Desktop) */ +@media (min-width: 1024px) { + .has-kitchen-tabs .shopping-page { + height: calc(100dvh - var(--kitchen-tabs-height)); + } +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index 4e9f0f4..74a45a2 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -351,6 +351,7 @@ --content-max-width: 1280px; --content-max-width-narrow: 720px; --cal-hour-height: 56px; + --kitchen-tabs-height: 56px; /* -------------------------------------------------------- * 13. Sidebar diff --git a/public/utils/kitchen-tabs.js b/public/utils/kitchen-tabs.js new file mode 100644 index 0000000..1005480 --- /dev/null +++ b/public/utils/kitchen-tabs.js @@ -0,0 +1,68 @@ +import { t } from '/i18n.js'; + +export const KITCHEN_ROUTES = ['/meals', '/recipes', '/shopping']; +export const KITCHEN_STORAGE_KEY = 'oikos-kitchen-tab'; + +const TABS = () => [ + { route: '/meals', labelKey: 'nav.meals', icon: 'utensils' }, + { route: '/recipes', labelKey: 'nav.recipes', icon: 'book-text' }, + { route: '/shopping', labelKey: 'nav.shopping', icon: 'shopping-cart' }, +]; + +export function getLastKitchenRoute() { + try { + const stored = sessionStorage.getItem(KITCHEN_STORAGE_KEY); + return KITCHEN_ROUTES.includes(stored) ? stored : '/meals'; + } catch { + return '/meals'; + } +} + +export function isKitchenRoute(path) { + return KITCHEN_ROUTES.includes(path); +} + +export function renderKitchenTabsBar(container, activeRoute) { + try { + sessionStorage.setItem(KITCHEN_STORAGE_KEY, activeRoute); + } catch { /* ignore */ } + + container.classList.add('has-kitchen-tabs'); + + const bar = document.createElement('div'); + bar.className = 'kitchen-tabs-bar'; + bar.setAttribute('role', 'tablist'); + bar.setAttribute('aria-label', t('nav.kitchen')); + + TABS().forEach(({ route, labelKey, icon }) => { + const btn = document.createElement('button'); + btn.className = 'kitchen-tab' + (route === activeRoute ? ' kitchen-tab--active' : ''); + btn.dataset.route = route; + btn.type = 'button'; + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', route === activeRoute ? 'true' : 'false'); + + const i = document.createElement('i'); + i.dataset.lucide = icon; + i.className = 'kitchen-tab__icon'; + i.setAttribute('aria-hidden', 'true'); + + const span = document.createElement('span'); + span.className = 'kitchen-tab__label'; + span.textContent = t(labelKey); + + btn.appendChild(i); + btn.appendChild(span); + bar.appendChild(btn); + }); + + bar.addEventListener('click', (e) => { + const btn = e.target.closest('[data-route]'); + if (!btn || btn.dataset.route === activeRoute) return; + window.oikos?.navigate(btn.dataset.route); + }); + + container.insertAdjacentElement('afterbegin', bar); + + if (window.lucide) window.lucide.createIcons({ el: bar }); +} diff --git a/test-kitchen-tabs.js b/test-kitchen-tabs.js new file mode 100644 index 0000000..796def8 --- /dev/null +++ b/test-kitchen-tabs.js @@ -0,0 +1,63 @@ +/** + * Tests: Kitchen-Tabs Utility (pure functions) + * Läuft mit: node --loader ./test-browser-loader.mjs test-kitchen-tabs.js + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { KITCHEN_ROUTES, KITCHEN_STORAGE_KEY, getLastKitchenRoute, isKitchenRoute } = await (async () => { + global.window = { oikos: null }; + global.document = { + createElement: () => ({ + className: '', dataset: {}, style: {}, + setAttribute() {}, appendChild() {}, + classList: { add() {}, toggle() {} }, + insertAdjacentElement() {}, + addEventListener() {}, + }), + }; + const storage = { + _d: {}, + getItem(k) { return this._d[k] ?? null; }, + setItem(k, v) { this._d[k] = v; }, + }; + global.sessionStorage = storage; + global.t = (k) => k; + return import('./public/utils/kitchen-tabs.js'); +})(); + +test('KITCHEN_ROUTES enthält alle drei Sub-Routen', () => { + assert.deepEqual(KITCHEN_ROUTES, ['/meals', '/recipes', '/shopping']); +}); + +test('KITCHEN_STORAGE_KEY ist korrekt', () => { + assert.equal(KITCHEN_STORAGE_KEY, 'oikos-kitchen-tab'); +}); + +test('getLastKitchenRoute: Standardwert /meals wenn kein Storage-Eintrag', () => { + global.sessionStorage._d = {}; + assert.equal(getLastKitchenRoute(), '/meals'); +}); + +test('getLastKitchenRoute: gibt gespeicherte Route zurück', () => { + global.sessionStorage._d = { 'oikos-kitchen-tab': '/recipes' }; + assert.equal(getLastKitchenRoute(), '/recipes'); +}); + +test('getLastKitchenRoute: ignoriert ungültige gespeicherte Route', () => { + global.sessionStorage._d = { 'oikos-kitchen-tab': '/admin' }; + assert.equal(getLastKitchenRoute(), '/meals'); +}); + +test('isKitchenRoute: erkennt Kitchen-Routen', () => { + assert.equal(isKitchenRoute('/meals'), true); + assert.equal(isKitchenRoute('/recipes'), true); + assert.equal(isKitchenRoute('/shopping'), true); +}); + +test('isKitchenRoute: lehnt Nicht-Kitchen-Routen ab', () => { + assert.equal(isKitchenRoute('/tasks'), false); + assert.equal(isKitchenRoute('/'), false); + assert.equal(isKitchenRoute('/calendar'), false); + assert.equal(isKitchenRoute(''), false); +});