Skip to content

Workspace sidebar splits into two columns on short viewports — should scroll instead #146

Description

@DeepDiver1975

Context

The left navigation sidebar (AUFBAU, ETB, AUFGABEN, FUNKTIONEN, KRÄFTE, ATEMSCHUTZ, CO-MESSUNG, DATEIEN, LINKS, ABBAU) is a native Avalonia TabControl with TabStripPlacement="Left" (src/LageBuch.App.Shared/Theme/Styles.axaml:283-286). When the window is too short to fit all 11 tab items in one column (confirmed on a phone-height viewport), it currently splits into two side-by-side columns ("AUFBAU | LINKS", "ETB | ABBAU") instead of scrolling — confusing, and on a narrow window the second column can run off the right edge entirely.

Root cause, confirmed against the real upstream template: TabStripPlacement="Left" makes Avalonia's Fluent theme give the tab strip a vertical WrapPanel (its own built-in default ItemsPanel, plus a ControlTheme-level rule that flips the WrapPanel to vertical for Left/Right placement). A WrapPanel starts a new column whenever it runs out of space in the space it's given — and today that ItemsPresenter sits directly in a DockPanel with no scrollable region around it, so it's bounded by whatever height is left in the window and wraps instead of overflowing/scrolling. This app doesn't declare that WrapPanel itself (nothing to edit in IncidentWorkspaceView.axamlTabControl/TabItem are plain native controls, confirmed via IncidentWorkspaceView.axaml.cs having no selection/panel code at all) — it comes from Avalonia's own default TabControl control template, so the fix has to override that template.

TabControl is used nowhere else in this codebase (grep -rl "<TabControl" src/LageBuch.App.Shared/Views/*.axaml → only IncidentWorkspaceView.axaml), so touching the type-wide Style Selector="TabControl" in Styles.axaml is safe — there's only one consumer to affect.

Fix

Add a Setter Property="Template" to the existing <Style Selector="TabControl"> block in src/LageBuch.App.Shared/Theme/Styles.axaml (currently lines 283-286). The replacement ControlTemplate is the real upstream Fluent ControlTheme template for TabControl (fetched from AvaloniaUI/Avalonia at tag 12.1.1, matching this repo's pinned Avalonia version — see Directory.Packages.props), with exactly one change: PART_ItemsPresenter is wrapped in a ScrollViewer that takes over the DockPanel.Dock="{TemplateBinding TabStripPlacement}" role:

<Setter Property="Template">
  <ControlTemplate>
    <Border BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}"
            CornerRadius="{TemplateBinding CornerRadius}"
            Background="{TemplateBinding Background}"
            HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
            VerticalAlignment="{TemplateBinding VerticalAlignment}">
      <DockPanel>
        <ScrollViewer DockPanel.Dock="{TemplateBinding TabStripPlacement}"
                      HorizontalScrollBarVisibility="Disabled"
                      VerticalScrollBarVisibility="Auto">
          <ItemsPresenter Name="PART_ItemsPresenter"
                          ItemsPanel="{TemplateBinding ItemsPanel}" />
        </ScrollViewer>
        <Panel ClipToBounds="True">
          <ContentPresenter Name="PART_SelectedContentHost2"
                            Margin="{TemplateBinding Padding}"
                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
                            IsVisible="False" />
          <ContentPresenter Name="PART_SelectedContentHost"
                            Margin="{TemplateBinding Padding}"
                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
        </Panel>
      </DockPanel>
    </Border>
  </ControlTemplate>
</Setter>

Why this is sufficient by itself (no need to also swap the WrapPanel for a StackPanel, and no need to touch TabControl.ItemsPanel): the part name (PART_ItemsPresenter) and its generated child (the default WrapPanel, still produced by TabControl's own ItemsPanel default — this template only binds to it via {TemplateBinding ItemsPanel}, it doesn't change it) are unchanged, so the theme's own nested rule (Style Selector="^[TabStripPlacement=Left] /template/ ItemsPresenter#PART_ItemsPresenter > WrapPanel"Orientation="Vertical") still finds and applies to it exactly as before. What changes is that the WrapPanel is now measured inside a ScrollViewer, which offers it effectively unbounded height in its wrap direction — so it never runs out of vertical space to wrap from, and instead the ScrollViewer grows a scrollbar once content exceeds the viewport. The existing app rule Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter"Margin="0" (Styles.axaml:287-289) is untouched and keeps applying to the same named part.

Everything else in the template (Border, DockPanel, the two ContentPresenters including the pre-caching PART_SelectedContentHost2) is copied verbatim from the real upstream template, so tab-content switching, transitions and existing TabItem/hover/selected styles (Styles.axaml:291-336) are unaffected — none of them target the DockPanel/Border structure being replaced.

Files to change

  • src/LageBuch.App.Shared/Theme/Styles.axaml — the Template setter above, added to the existing <Style Selector="TabControl"> block (~line 283).

No changes needed to IncidentWorkspaceView.axaml, IncidentWorkspaceView.axaml.cs, or any ViewModel — this is purely a control-template fix.

Verification

  • dotnet build src/LageBuch.App.Shared first, to catch any XAML/template syntax error immediately (a malformed ControlTemplate fails at compile time, not silently at runtime).
  • Add an acceptance test (e.g. tests/LageBuch.Acceptance.Tests/ModuleTabsScrollingTests.cs, following the AvaloniaFact + GetVisualDescendants() conventions already used in LayoutAlignmentTests.cs/ScbaControlBarReachabilityTests.cs) that renders IncidentWorkspaceView in a deliberately short window (e.g. Height=400, short enough to force wrap regardless of which header banners happen to be visible) and asserts:
    • All 11 TabItems are still present under ModuleTabs (nothing lost).
    • Every TabItem's horizontal position (Bounds.X, or TranslatePoint relative to ModuleTabs) is identical — proving a single column, not two.
    • A ScrollViewer is present in the tab-strip's ancestry with Extent.Height > Viewport.Height in this short-window state, proving the content actually overflows into a scrollable region rather than being silently clipped.
  • Re-run the full existing acceptance suite (dotnet test tests/LageBuch.Acceptance.Tests) to confirm nothing else in IncidentWorkspaceView (banners, tab content, the two checklist-dot TabItem headers) regresses.
  • Capture a before/after screenshot pair at a short window height via the existing headless render harness (Window.CaptureRenderedFrame(), RENDER_OUT convention already used by ScbaTabRenderTests/ScbaControlBarReachabilityTests) for the PR body, per this repo's UI-PR screenshot convention.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions