Public/Add-DhTabs.ps1

function Add-DhTabs {
    <#
    .SYNOPSIS
        Add an inline tabbed content block (v1.5+).
 
    .DESCRIPTION
        Renders a tab strip with one content pane per tab. Distinct from the
        page-level NavGroup mechanism: NavGroup partitions the whole dashboard
        into named panels; Add-DhTabs creates a tabbed pivot inside a single
        block of the dashboard flow.
 
        Use it for "summary / details / settings" style pivots — when several
        related views share the same surface area and the user toggles between
        them locally without navigating away from the current panel.
 
        Each tab is a hashtable carrying:
          - Title (required) — the label shown on the tab strip
          - Content — free-form HTML rendered inside the tab pane, OR
          - HtmlBlockId — an Id of an Add-DhHtmlBlock previously declared
 
        Free-form Content is the simplest path. To embed a richer block
        (sparkline table, status grid, etc.) inside a tab, the easiest pattern
        is still to wrap the block in -NavGroup / -NavSubGroup and use the
        page-level nav; Add-DhTabs is intentionally limited to inline HTML.
 
        SECURITY: per-tab Content is injected into the DOM via innerHTML.
        Never pass untrusted external data (AD attributes, ticket titles, log
        lines, anything supplied by an actor outside your trust boundary) into
        Content without HTML-encoding it first with
        [System.Web.HttpUtility]::HtmlEncode(). Same policy as
        Add-DhHtmlBlock, Add-DhAlertBanner -Message, and Add-DhCollapsible
        -Content.
 
    .PARAMETER Report
        Dashboard object from New-DhDashboard.
 
    .PARAMETER Id
        Unique identifier (alphanumeric, dash, underscore).
 
    .PARAMETER Title
        Optional block heading shown above the tab strip.
 
    .PARAMETER Tabs
        Array of tab definition hashtables:
          @{
              Title = 'Overview' # REQUIRED — tab label
              Content = '<p>...</p>' # REQUIRED — inline HTML
              Icon = 'X' # optional — glyph shown before the label
              Active = $true # optional — selects this tab on load (first by default)
          }
 
    .PARAMETER NavGroup
        Primary nav group label (enables two-tier nav).
 
    .PARAMETER NavSubGroup
        Optional second-level group under NavGroup (enables three-tier nav).
 
    .EXAMPLE
        Add-DhTabs -Report $report -Id 'auth-pivot' -Title 'Authentication detail' `
            -Tabs @(
                @{ Title='Overview';
                   Content='<p>Auth subsystem is operating within SLA.</p>' }
                @{ Title='Errors';
                   Content='<p>3 auth errors in the last hour. <a href="#">View log</a>.</p>' }
                @{ Title='Configuration';
                   Content='<pre>kerberos.maxRetries=3
ntlm.fallback=disabled</pre>' }
            )
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_-]+$')]
        [string] $Id,
        [string] $Title = '',
        [Parameter(Mandatory)] [object[]] $Tabs,

        # v1.6.0 — when $false, each tab's Content is HTML-encoded (rendered as
        # literal text) instead of injected as raw markup. Applies to every tab
        # in this block. v2.0.0: the default is now $false (encoded). Pass
        # -AllowHtml:$true for intentional raw markup.
        [bool]   $AllowHtml   = $false,

        [bool]   $Collapsible = $true,    # wrap the block in a collapsible header (chevron + click-to-toggle)

        [bool]   $DefaultOpen = $true,    # whether the collapsible body starts expanded (only used when -Collapsible is $true)

        [ValidateRange(0,12)]

        [int]    $GridSpan    = 0,   # v1.10.0 - columns (1-12) to span in -report-grid layout; 0 = default flow

        [string] $NavGroup    = '',
        [string] $NavSubGroup = '',
        # ---- v2.0.0 navigation (see readme-dev.md section 15) ----
        [string] $NavPath      = '',   # 'A/B/C' - where this item lives. '//' escapes a literal '/'
        [ValidateSet('','Exact','Subtree','Global')]
        [string] $NavScope     = '',   # Exact (default) | Subtree (this node and below) | Global (everywhere)
        [string] $NavLabel     = '',   # display text for this node, decoupled from the path key
        [int]    $NavOrder     = 0     # explicit sibling ordering; 0 = declaration order
    )

    # v2.0.0 - resolve nav declaration once, in one place. Throws if -NavPath is
    # combined with -NavGroup/-NavSubGroup rather than silently preferring one.
    $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope `
                    -NavGroup $NavGroup -NavSubGroup $NavSubGroup `
                    -ItemKind 'Block' -Context 'Add-DhTabs'
    # Monotonic declaration number, shared by tables and blocks. The exporter
    # emits ALL blocks before ALL tables, so without this the menu would order
    # nodes by section type instead of by the order the author wrote them.
    if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 }
    $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1

    if (-not $Report.Contains('Blocks')) {
        $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
    }
    foreach ($existing in $Report.Blocks) {
        if ($existing.Id -eq $Id) {
            throw "Add-DhTabs: A block with Id '$Id' already exists in this report. Use a unique Id."
        }
    }
    if ($Tabs.Count -lt 1) {
        throw "Add-DhTabs: -Tabs must contain at least one tab."
    }

    $allowHtmlExplicit = $PSBoundParameters.ContainsKey('AllowHtml')
    # @(...) so a single tab still yields an array — otherwise $normTabs[0] below
    # indexes a scalar hashtable and throws (a single-tab block is valid).
    $normTabs = @(foreach ($t in $Tabs) {
        if ($t -isnot [hashtable] -and $t -isnot [System.Collections.Specialized.OrderedDictionary]) {
            throw "Add-DhTabs: each tab must be a hashtable. Got: $($t.GetType().Name)"
        }
        foreach ($req in 'Title','Content') {
            if (-not $t.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$t[$req])) {
                throw "Add-DhTabs: each tab must have a non-empty '$req' key."
            }
        }
        @{
            Title   = [string]$t['Title']
            # v1.6.0 — apply the -AllowHtml policy per tab. Context names the tab
            # so the deprecation warning points at the offending content.
            Content = ConvertTo-DhSafeContent -Content ([string]$t['Content']) -AllowHtml $AllowHtml `
                        -Explicit $allowHtmlExplicit -Context "Add-DhTabs '$Id' tab '$([string]$t['Title'])' Content"
            Icon    = if ($t.Contains('Icon')   -and $null -ne $t['Icon'])   { [string]$t['Icon'] }   else { '' }
            Active  = if ($t.Contains('Active') -and $null -ne $t['Active']) { [bool]$t['Active'] }   else { $false }
        }
    })

    # If no tab is explicitly Active, mark the first one
    $explicit = @($normTabs | Where-Object { $_.Active }).Count
    if ($explicit -eq 0) {
        $normTabs[0].Active = $true
    } elseif ($explicit -gt 1) {
        # Keep only the first explicitly-active tab; reset the rest
        $seen = $false
        foreach ($t in $normTabs) {
            if ($t.Active -and -not $seen) { $seen = $true } else { $t.Active = $false }
        }
    }

    $Report.Blocks.Add([ordered]@{
        BlockType   = 'tabs'
        Id          = $Id
        Title       = $Title
        Tabs        = @($normTabs)
        NavGroup    = $NavGroup
        NavSubGroup = $NavSubGroup
        NavPath     = $NavPath
        NavSegments     = $navDecl.Path
        NavSeqNo     = $navSeqNo
        NavScope    = $navDecl.Scope
        NavLabel    = $NavLabel
        NavOrder    = $NavOrder
        GridSpan    = $GridSpan
        Collapsible = $Collapsible
        DefaultOpen = $DefaultOpen
    })
    Write-Verbose "Add-DhTabs: '$Id' ($($normTabs.Count) tab(s))."
}