refactor(ui): unified bottom-stack container for PillNav, QuickInput, TagStrip

Replace 4 independent position:fixed elements with one flex container that
stacks them naturally from bottom to top. Elements push each other
automatically — no more hardcoded offsets or z-index conflicts.

Stack order (bottom → top):
1. PillNavigation (collapsible)
2. TagStrip (togglable)
3. QuickInputBar + toggle button row

Shared-UI changes:
- PillNavigation: add positioning='fixed'|'static' prop
- QuickInputBar: add positioning='fixed'|'static' prop
- TagStrip: add positioning='fixed'|'static' prop
- All default to 'fixed' for backward compatibility

Layout changes:
- Wrap all bottom elements in .bottom-stack (position:fixed, flex-column)
- Remove hardcoded bottomOffset calculations
- Toggle button is now inline next to QuickInputBar (not separately positioned)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 23:52:40 +02:00
parent bed2060ba5
commit b415567dfa
4 changed files with 154 additions and 85 deletions

View file

@ -375,89 +375,93 @@
{/if}
<div class="min-h-screen bg-background">
<!-- Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="ManaCore"
homeRoute="/"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
loginHref="/login"
primaryColor="#6366f1"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
{spotlightActions}
{contentSearcher}
/>
<!-- Bottom Stack: all fixed-bottom elements in one flex container -->
<div class="bottom-stack">
<!-- QuickInputBar + toggle button row -->
<div class="bottom-stack-row">
<QuickInputBar
onSearch={inputBarAdapter.onSearch}
onSelect={inputBarAdapter.onSelect}
onParseCreate={inputBarAdapter.onParseCreate}
onCreate={inputBarAdapter.onCreate}
onSearchChange={inputBarAdapter.onSearchChange}
placeholder={inputBarAdapter.placeholder}
appIcon={inputBarAdapter.appIcon}
emptyText={inputBarAdapter.emptyText}
createText={inputBarAdapter.createText}
deferSearch={inputBarAdapter.deferSearch}
locale={$locale || 'de'}
defaultOptions={inputBarAdapter.defaultOptions}
selectedDefaultId={inputBarAdapter.selectedDefaultId}
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
onDefaultChange={inputBarAdapter.onDefaultChange}
highlightPatterns={inputBarAdapter.highlightPatterns}
positioning="static"
/>
<button
class="pill-nav-toggle"
onclick={() => handleCollapsedChange(!isCollapsed)}
title={isCollapsed ? 'Navigation einblenden' : 'Navigation ausblenden'}
>
<span class="pill-nav-toggle-icon" class:collapsed={isCollapsed}>▼</span>
</button>
</div>
<!-- QuickInputBar (context-aware per module) -->
<QuickInputBar
onSearch={inputBarAdapter.onSearch}
onSelect={inputBarAdapter.onSelect}
onParseCreate={inputBarAdapter.onParseCreate}
onCreate={inputBarAdapter.onCreate}
onSearchChange={inputBarAdapter.onSearchChange}
placeholder={inputBarAdapter.placeholder}
appIcon={inputBarAdapter.appIcon}
emptyText={inputBarAdapter.emptyText}
createText={inputBarAdapter.createText}
deferSearch={inputBarAdapter.deferSearch}
locale={$locale || 'de'}
defaultOptions={inputBarAdapter.defaultOptions}
selectedDefaultId={inputBarAdapter.selectedDefaultId}
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
onDefaultChange={inputBarAdapter.onDefaultChange}
highlightPatterns={inputBarAdapter.highlightPatterns}
bottomOffset={isCollapsed ? '12px' : '70px'}
/>
<!-- TagStrip (between QuickInputBar and PillNav) -->
{#if isTagStripVisible}
<TagStrip
tags={(allTags.value ?? []).map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
onTagDrop={tagDropHandler ?? undefined}
managementHref="/tags"
loading={allTags.loading}
positioning="static"
/>
{/if}
<!-- PillNav toggle button (next to QuickInputBar) -->
<button
class="pill-nav-toggle"
style:bottom={isCollapsed ? '20px' : '78px'}
onclick={() => handleCollapsedChange(!isCollapsed)}
title={isCollapsed ? 'Navigation einblenden' : 'Navigation ausblenden'}
>
<span class="pill-nav-toggle-icon" class:collapsed={isCollapsed}>▼</span>
</button>
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
tags={(allTags.value ?? []).map((t) => ({
id: t.id,
name: t.name,
color: t.color || '#3b82f6',
}))}
selectedIds={[]}
onToggle={() => {}}
onClear={() => {}}
onTagDrop={tagDropHandler ?? undefined}
managementHref="/tags"
loading={allTags.loading}
<!-- PillNav (bottom of stack) -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="ManaCore"
homeRoute="/"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
loginHref="/login"
primaryColor="#6366f1"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
{spotlightActions}
{contentSearcher}
positioning="static"
/>
{/if}
</div>
<!-- DnD: floating preview -->
<DragPreview />
@ -499,12 +503,44 @@
</AuthGate>
<style>
.pill-nav-toggle {
.bottom-stack {
position: fixed;
right: 16px;
z-index: 91;
bottom: 0;
left: 0;
right: 0;
z-index: 90;
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.bottom-stack > :global(*) {
pointer-events: auto;
}
.bottom-stack-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding-right: 8px;
pointer-events: none;
}
.bottom-stack-row > :global(*) {
pointer-events: auto;
}
.bottom-stack-row > :global(:first-child) {
flex: 1;
}
.pill-nav-toggle {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 20px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.6);
@ -515,9 +551,9 @@
align-items: center;
justify-content: center;
transition:
bottom 0.3s ease,
background 0.2s ease,
color 0.2s ease;
pointer-events: auto;
}
.pill-nav-toggle:hover {

View file

@ -228,6 +228,8 @@
onToggleTheme?: () => void;
/** Whether dark mode is active */
isDark?: boolean;
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
positioning?: 'fixed' | 'static';
/** Whether navigation is collapsed */
isCollapsed?: boolean;
/** Called when collapsed state changes */
@ -318,6 +320,7 @@
onLogout,
onToggleTheme,
isDark = false,
positioning = 'fixed',
isCollapsed: externalCollapsed,
onCollapsedChange,
languageItems = [],
@ -403,6 +406,7 @@
{#if !(externalCollapsed ?? false)}
<nav
class="pill-nav"
class:pill-nav-static={positioning === 'static'}
style="{primaryColor
? `--pill-primary-color: ${primaryColor};`
: ''}--pill-nav-bottom: {bottomOffset}"
@ -828,6 +832,12 @@
container-name: pillnav;
}
.pill-nav-static {
position: relative;
bottom: auto;
z-index: auto;
}
.pill-nav-container {
display: flex;
align-items: center;

View file

@ -34,6 +34,8 @@
createHref?: string;
/** Whether the filter strip below is visible (adjusts bottom position) */
aboveFilterStrip?: boolean;
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
positioning?: 'fixed' | 'static';
}
let {
@ -48,6 +50,7 @@
showCreateButton = true,
createHref,
aboveFilterStrip = false,
positioning = 'fixed',
}: Props = $props();
const resolvedCreateHref = $derived(createHref ?? managementHref + '?new=true');
@ -63,7 +66,11 @@
}
</script>
<div class="tag-strip-wrapper" class:above-filter-strip={aboveFilterStrip}>
<div
class="tag-strip-wrapper"
class:above-filter-strip={aboveFilterStrip}
class:tag-strip-static={positioning === 'static'}
>
<div class="tag-strip-container">
<!-- Clear Filter Button (always rendered to prevent layout shift) -->
<button
@ -140,6 +147,12 @@
left: 0;
right: 0;
z-index: 49;
}
.tag-strip-static {
position: relative;
bottom: auto;
z-index: auto;
display: flex;
flex-direction: column;
align-items: stretch;

View file

@ -79,6 +79,8 @@
highlightPatterns?: HighlightPattern[];
/** Locale for syntax highlighting keywords (e.g., 'de', 'en'). Default: 'de'. */
locale?: string;
/** Use 'static' when inside a flex container (bottom-stack pattern). Default: 'fixed'. */
positioning?: 'fixed' | 'static';
}
let {
@ -107,6 +109,7 @@
leftAction,
highlightPatterns,
locale = 'de',
positioning = 'fixed',
}: Props = $props();
// Use settings for autoFocus
@ -382,6 +385,7 @@
class="quick-input-bar"
class:has-fab-right={hasFabRight}
class:has-fab-left={hasFabLeft}
class:quick-input-static={positioning === 'static'}
style="--bottom-offset: {bottomOffset}"
>
<!-- Results Panel (above input) -->
@ -566,6 +570,12 @@
transition: bottom 0.3s ease;
}
.quick-input-static {
position: relative;
bottom: auto;
z-index: auto;
}
/* Leave space for FAB on mobile */
@media (max-width: 900px) {
.quick-input-bar.has-fab-right {