diff --git a/public/index.html b/public/index.html index dcc37e3..5423e07 100644 --- a/public/index.html +++ b/public/index.html @@ -42,6 +42,7 @@ + diff --git a/public/pages/settings.js b/public/pages/settings.js index eb52b6f..f79ae50 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -9,6 +9,7 @@ import { openModal, closeModal, confirmModal } from '/components/modal.js'; import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, getDateFormat } from '/i18n.js'; import { esc } from '/utils/html.js'; import { renderSettingsSidebar, renderBreadcrumb, getLastActivePage, setActivePage, findSectionAndPage } from '/utils/settings-nav.js'; +import { renderSubTabs } from '/utils/sub-tabs.js'; import '/components/oikos-locale-picker.js'; const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD']; @@ -256,8 +257,6 @@ export async function render(container, { user }) { : (allowedTabs.includes(storedTab) ? storedTab : 'general'); const panelHidden = (id) => id === activeTab ? '' : ' hidden'; - const btnClass = (id) => `settings-tab-btn${id === activeTab ? ' settings-tab-btn--active' : ''}`; - const btnAria = (id) => id === activeTab ? 'true' : 'false'; container.innerHTML = `
@@ -268,18 +267,6 @@ export async function render(container, { user }) { ${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''} ${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''} - -
@@ -878,6 +865,7 @@ docker cp oikos:/data/oikos-backup.db ./oikos-backup.db loadCardDAVAccounts(container, user); } + renderSettingsSubTabs(container, user, activeTab); bindEvents(container, user, users, categories, icsSubscriptions, apiTokens); if (window.lucide) window.lucide.createIcons(); } @@ -1151,12 +1139,56 @@ async function loadCardDAVAccounts(container, user) { } } +// -------------------------------------------------------- +// Sub-Tab-Navigation +// -------------------------------------------------------- + +function buildSettingsTabs(user) { + const tabs = [ + { id: 'general', label: t('settings.tabGeneral'), icon: 'settings' }, + { id: 'meals', label: t('settings.tabMeals'), icon: 'utensils' }, + { id: 'budget', label: t('settings.tabBudget'), icon: 'wallet' }, + { id: 'shopping', label: t('settings.tabShopping'), icon: 'shopping-cart' }, + { id: 'sync', label: t('settings.tabSync'), icon: 'refresh-cw', separatorBefore: true }, + { id: 'account', label: t('settings.tabAccount'), icon: 'user', separatorBefore: true }, + ]; + if (user?.role === 'admin') { + tabs.push( + { id: 'family', label: t('settings.tabFamily'), icon: 'users', separatorBefore: true }, + { id: 'api-tokens', label: t('settings.tabApiTokens'), icon: 'key' }, + { id: 'backup', label: t('settings.tabBackup'), icon: 'database' }, + ); + } + return tabs; +} + +function renderSettingsSubTabs(container, user, activeTab) { + const settingsPage = container.querySelector('.settings-page'); + if (!settingsPage) return; + + const lastBanner = [...settingsPage.querySelectorAll('.settings-banner')].at(-1); + const anchor = lastBanner ?? settingsPage.querySelector('.page__header'); + if (!anchor) return; + + renderSubTabs(anchor, { + tabs: buildSettingsTabs(user), + activeId: activeTab, + storageKey: SETTINGS_TAB_KEY, + ariaLabel: t('settings.tabsAriaLabel'), + insertPosition: 'afterend', + onChange: (tabId) => { + container.querySelectorAll('[data-panel]').forEach((panel) => { + panel.hidden = panel.dataset.panel !== tabId; + }); + }, + }); +} + // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) { - bindTabEvents(container); bindSettingsDateInputs(container); bindCategoryEvents(container); bindIcsEvents(container, user, icsSubscriptions); @@ -1693,33 +1725,6 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok } // -------------------------------------------------------- -// Tab-Navigation -// -------------------------------------------------------- - -function bindTabEvents(container) { - const tabList = container.querySelector('.settings-tabs'); - if (!tabList) return; - - tabList.addEventListener('click', (e) => { - const btn = e.target.closest('[data-tab]'); - if (!btn) return; - const tab = btn.dataset.tab; - - tabList.querySelectorAll('[data-tab]').forEach((b) => { - const active = b.dataset.tab === tab; - b.classList.toggle('settings-tab-btn--active', active); - b.setAttribute('aria-selected', String(active)); - }); - - container.querySelectorAll('[data-panel]').forEach((panel) => { - panel.hidden = panel.dataset.panel !== tab; - }); - - try { sessionStorage.setItem(SETTINGS_TAB_KEY, tab); } catch (_) {} - }); -} - - function bindDeleteButtons(container, user) { container.querySelectorAll('[data-delete-user]').forEach((btn) => { btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden diff --git a/public/styles/kitchen-tabs.css b/public/styles/kitchen-tabs.css index c099b46..638a69b 100644 --- a/public/styles/kitchen-tabs.css +++ b/public/styles/kitchen-tabs.css @@ -1,66 +1,11 @@ /* Modul: Kitchen Tabs Bar - * Sticky Segment-Control für Mahlzeiten/Rezepte/Einkauf + Sub-Modul Layout-Fixes + * Layout-Fixes für Mahlzeiten/Rezepte/Einkauf bei aktiver Sub-Tab-Leiste. + * Tab-Styles kommen aus sub-tabs.css; hier nur Kitchen-spezifische Overrides. */ +/* Höhe auf --kitchen-tabs-height überschreiben (56px statt Standard 52px) */ .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: var(--target-base); - 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; + --sub-tabs-height: var(--kitchen-tabs-height); } /* Mahlzeiten: sticky day-header unterhalb der Tab-Leiste */ diff --git a/public/styles/settings.css b/public/styles/settings.css index ae2f65d..67547ad 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -43,89 +43,12 @@ } /* -------------------------------------------------------- - Tab-Navigation + Sub-Tab-Navigation (via shared sub-tabs.css) -------------------------------------------------------- */ -.settings-tabs { - display: flex; - flex-wrap: nowrap; - gap: 0; - overflow-x: auto; - scrollbar-width: none; - -webkit-overflow-scrolling: touch; - border-bottom: 1px solid var(--color-border); +/* Abstand zwischen Tab-Leiste und erstem Panel-Inhalt */ +.settings-page .sub-tabs-bar { margin-bottom: var(--space-6); - position: sticky; - top: 0; - background: var(--color-bg); - z-index: 10; - padding-top: var(--space-1); -} - -.settings-tabs::-webkit-scrollbar { - display: none; -} - -.settings-tab-btn { - flex-shrink: 0; - padding: var(--space-3) clamp(var(--space-2), 1.3vw, var(--space-4)); - border: none; - border-bottom: 2px solid transparent; - background: transparent; - color: var(--color-text-secondary); - font-size: var(--text-sm); - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: color var(--transition-fast), border-color var(--transition-fast); - min-height: 44px; - white-space: nowrap; - margin-bottom: -1px; -} - -.settings-tab-btn:hover { - color: var(--color-text-primary); -} - -.settings-tab-btn--active { - color: var(--color-accent); - border-bottom-color: var(--color-accent); -} - -/* -------------------------------------------------------- - Visuelle Tab-Gruppierung (Vorschlag B) - -------------------------------------------------------- */ - -/* Trennlinie vor Sync-Gruppe */ -.settings-tab-btn[data-group="sync"]::before { - content: ''; - position: absolute; - left: -8px; - top: 20%; - height: 60%; - width: 2px; - background: var(--color-border); -} - -/* Trennlinie vor Personal-Gruppe */ -.settings-tab-btn[data-group="personal"]::before { - content: ''; - position: absolute; - left: -8px; - top: 20%; - height: 60%; - width: 2px; - background: var(--color-border); -} - -/* Trennlinie vor Admin-Gruppe */ -.settings-tab-btn[data-group="admin"]::before { - content: ''; - position: absolute; - left: -8px; - top: 20%; - height: 60%; - width: 2px; - background: var(--color-border); } /* -------------------------------------------------------- diff --git a/public/styles/sub-tabs.css b/public/styles/sub-tabs.css new file mode 100644 index 0000000..bf1f2d5 --- /dev/null +++ b/public/styles/sub-tabs.css @@ -0,0 +1,75 @@ +/* Sub-Tabs Bar — shared pill-style segment control + * Used by kitchen modules, settings, and any future sub-module navigation. + * Height: --sub-tabs-height (default from tokens.css); override per context. + */ + +.sub-tabs-bar { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-4); + height: var(--sub-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; +} + +.sub-tabs-bar::-webkit-scrollbar { + display: none; +} + +.sub-tab { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-3); + height: var(--target-base); + 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; +} + +.sub-tab:active { + transform: scale(0.96); + transition-duration: 0.06s; +} + +.sub-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)); +} + +.sub-tab__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.sub-tab__label { + line-height: 1; +} + +/* Vertical divider between tab groups */ +.sub-tabs-separator { + width: 1px; + height: 20px; + background: var(--color-border); + flex-shrink: 0; + margin: 0 var(--space-1); +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index 4c0e7b0..cd77eca 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; + --sub-tabs-height: 52px; --kitchen-tabs-height: 56px; /* -------------------------------------------------------- diff --git a/public/utils/kitchen-tabs.js b/public/utils/kitchen-tabs.js index 1005480..14f6dbc 100644 --- a/public/utils/kitchen-tabs.js +++ b/public/utils/kitchen-tabs.js @@ -1,4 +1,5 @@ import { t } from '/i18n.js'; +import { renderSubTabs } from '/utils/sub-tabs.js'; export const KITCHEN_ROUTES = ['/meals', '/recipes', '/shopping']; export const KITCHEN_STORAGE_KEY = 'oikos-kitchen-tab'; @@ -23,46 +24,15 @@ export function isKitchenRoute(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); + renderSubTabs(container, { + tabs: TABS().map(({ route, labelKey, icon }) => ({ id: route, label: t(labelKey), icon })), + activeId: activeRoute, + storageKey: KITCHEN_STORAGE_KEY, + extraClass: 'kitchen-tabs-bar', + ariaLabel: t('nav.kitchen'), + insertPosition: 'afterbegin', + onChange: (route) => window.oikos?.navigate(route), }); - - 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/public/utils/sub-tabs.js b/public/utils/sub-tabs.js new file mode 100644 index 0000000..7dac4bb --- /dev/null +++ b/public/utils/sub-tabs.js @@ -0,0 +1,91 @@ +/** + * Shared sticky sub-tab bar (pill-style). + * Used by kitchen modules and settings; extend to any future sub-module nav. + * + * @param {HTMLElement} anchorEl - element relative to which the bar is inserted + * @param {object} opts + * @param {Array<{id: string, label: string, icon?: string, separatorBefore?: boolean}>} opts.tabs + * @param {string} opts.activeId - initially active tab id + * @param {Function} opts.onChange - called with new id on tab switch + * @param {string} [opts.storageKey] - sessionStorage key for persistence + * @param {string} [opts.extraClass] - additional CSS class on bar element + * @param {string} [opts.ariaLabel] + * @param {InsertPosition} [opts.insertPosition='afterbegin'] + * @returns {HTMLElement} the rendered bar element + */ +export function renderSubTabs(anchorEl, { + tabs, + activeId, + onChange, + storageKey, + extraClass, + ariaLabel, + insertPosition = 'afterbegin', +}) { + let current = activeId; + + if (storageKey) { + try { sessionStorage.setItem(storageKey, current); } catch { /* ignore */ } + } + + const bar = document.createElement('div'); + bar.className = 'sub-tabs-bar' + (extraClass ? ' ' + extraClass : ''); + bar.setAttribute('role', 'tablist'); + if (ariaLabel) bar.setAttribute('aria-label', ariaLabel); + + for (const { id, label, icon, separatorBefore } of tabs) { + if (separatorBefore) { + const sep = document.createElement('span'); + sep.className = 'sub-tabs-separator'; + sep.setAttribute('aria-hidden', 'true'); + bar.appendChild(sep); + } + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sub-tab' + (id === current ? ' sub-tab--active' : ''); + btn.dataset.tabId = id; + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', id === current ? 'true' : 'false'); + + if (icon) { + const i = document.createElement('i'); + i.dataset.lucide = icon; + i.className = 'sub-tab__icon'; + i.setAttribute('aria-hidden', 'true'); + btn.appendChild(i); + } + + const span = document.createElement('span'); + span.className = 'sub-tab__label'; + span.textContent = label; + btn.appendChild(span); + + bar.appendChild(btn); + } + + bar.addEventListener('click', (e) => { + const btn = e.target.closest('[data-tab-id]'); + if (!btn || btn.dataset.tabId === current) return; + + current = btn.dataset.tabId; + + if (storageKey) { + try { sessionStorage.setItem(storageKey, current); } catch { /* ignore */ } + } + + bar.querySelectorAll('[data-tab-id]').forEach((b) => { + const active = b.dataset.tabId === current; + b.classList.toggle('sub-tab--active', active); + b.setAttribute('aria-selected', String(active)); + }); + + onChange(current); + }); + + anchorEl.insertAdjacentElement(insertPosition, bar); + + if (window.lucide) window.lucide.createIcons({ el: bar }); + + return bar; +} diff --git a/test-browser-loader.mjs b/test-browser-loader.mjs index b3e1479..04065f7 100644 --- a/test-browser-loader.mjs +++ b/test-browser-loader.mjs @@ -25,5 +25,10 @@ export async function resolve(specifier, context, nextResolve) { url: `data:text/javascript,${encodeURIComponent(STUBS[specifier])}`, }; } + // Browser-absolute paths (/foo.js, /utils/bar.js) → public/foo.js, public/utils/bar.js + if (specifier.startsWith('/') && !specifier.startsWith('//')) { + const resolved = new URL('public' + specifier, import.meta.url).href; + return nextResolve(resolved, context); + } return nextResolve(specifier, context); }