I'm trying to implement something similar to the Windows Forms TabControl but in a ASP.net core MVC web application. I want to be able to use this control on multiple pages in my application, but with each page rendering different content inside the tabbed regions. The number of tabs and tab text is the same on every page, only the content of the tabbed regions varies from one page to the next.
I've created the project using the asp.NET Core Web App project template in Visual Studio 2022, which adds version 6.0.9 of Microsoft.AspNetCore.App and Microsoft.NETCore.App, and version 5.1 of Bootstrap to the project.
Here is a .cshtml page which implements the tabbed behaviour I'm after. The "Hello from..." text represents the content which I want to vary from one page to the next.
<div class="row">
<div class="col-2">
Some kind of vertical menu with options specific to this page
</div>
<div class="col-10">
<p>Some content which appears before the TabControl</p>
@* Start of tabs *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab1-tab" data-bs-toggle="tab" data-bs-target="#tab1-tab-pane" href="#">
Tab 1
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab2-tab" data-bs-toggle="tab" data-bs-target="#tab2-tab-pane" href="#">
Tab 2
</a>
</li>
</ul>
@* End of tabs *@
<div class="tab-content">
@* Content of tab1 *@
<div class="tab-pane fade show active" id="tab1-tab-pane" role="tabpanel" aria-labelledby="tab1-tab" tabindex="0">
Hello from tab 1 of the Home/Index view!
</div>
@* Content of tab2 *@
<div class="tab-pane fade" id="tab2-tab-pane" role="tabpanel" aria-labelledby="tab2-tab" tabindex="0">
Hello from tab 2 of the Home/Index view!
</div>
</div>
<p>Some content which appears after the TabControl</p>
</div>
</div>
This works beautifully, but I want to reproduce that same tabbed behaviour on multiple pages without copy/pasting the markup with all the Bootstrap classes into each page. This feels like a use case for refactoring out that markup into a .... (what? user control? partial view? view component? some other term?) which I can reference from my pages, passing in the content of each of the tabbed regions as some kind of parameter.
This attempt uses a model with string properties to hold the content of the tabbed regions, and that model is passed to a partial view which acts as the TabControl. The model is populated in the controller.
public class TabControlVMWithStringProperties
{
public string Tab1Content { get; set; }
public string Tab2Content { get; set; }
}
This is a partial view which I can include in multiple pages.
@model TabControlVMWithStringProperties
<div>
@* Start of tabs *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab1-tab" data-bs-toggle="tab" data-bs-target="#tab1-tab-pane" href="#">
Tab 1
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab2-tab" data-bs-toggle="tab" data-bs-target="#tab2-tab-pane" href="#">
Tab 2
</a>
</li>
</ul>
@* End of tabs *@
<div class="tab-content">
@* Content of tab1 *@
<div class="tab-pane fade show active" id="tab1-tab-pane" role="tabpanel" aria-labelledby="tab1-tab" tabindex="0">
@Model.Tab1Content
</div>
@* Content of tab2 *@
<div class="tab-pane fade" id="tab2-tab-pane" role="tabpanel" aria-labelledby="tab2-tab" tabindex="0">
@Model.Tab2Content
</div>
</div>
</div>
<div class="row">
<div class="col-2">
Some kind of vertical menu with options specific to this page
</div>
<div class="col-10">
<p>Some content which appears before the TabControl</p>
<partial name="TabControlAttempt1" />
<p>Some content which appears after the TabControl</p>
</div>
</div>
public class Attempt1Controller : Controller
{
public IActionResult Index()
{
var model = new TabControlVMWithStringProperties
{
Tab1Content = "Hello from tab 1 of Attempt1/Index view!",
Tab2Content = "Hello from tab 2 of Attempt1/Index view!",
};
return this.View(model);
}
}
This works, but is only really viable if Tab1Content and Tab2Content are simple strings. If I want to render complex markup in the tabbed regions then this approach quickly becomes unwieldy, and I don't really want to be working with markup in the controller.
This attempt is similar to attempt 1 and uses the same partial view and model, but this time the model's properties are set in the view rather than in the controller:
<div class="row">
<div class="col-2">
Some kind of vertical menu with options specific to this page
</div>
<div class="col-10">
<p>Some content which appears before the TabControl</p>
@{
var tabControlVM = new TabControlVMWithStringProperties
{
Tab1Content = await Tab1Content(),
Tab2Content = await Tab2Content(),
};
}
<partial name="TabControlAttempt2" model="tabControlVM" />
<p>Some content which appears after the TabControl</p>
</div>
</div>
@functions
{
async Task<string> Tab1Content()
{
return "<div class='text-center'>Hello from tab 1 of the Attempt2/Index view!</div>";
}
async Task<string> Tab2Content()
{
return "<div class='text-center'>Hello from tab 2 of the Attempt2/Index view!</div>";
}
}
Similar to attempt 1, working with markup as strings is still unwieldy, and the more complex content highlights that the markup is treated as literal strings rather than as markup, which isn't what I want.
This time I've changed the model properties from string to IHtmlContent.
public class TabControlVMWithIHtmlContentProperties
{
public IHtmlContent Tab1Content { get; set; }
public IHtmlContent Tab2Content { get; set; }
}
This uses Html.Raw to convert the strings containing markup into something which actually behaves like markup.
@using Microsoft.AspNetCore.Html
<div class="row">
<div class="col-2">
Some kind of vertical menu with options specific to this page
</div>
<div class="col-10">
<p>Some content which appears before the TabControl</p>
@{
var tabControlVM = new TabControlVMWithIHtmlContentProperties
{
Tab1Content = await Tab1Content(),
Tab2Content = await Tab2Content(),
};
}
<partial name="TabControlAttempt3" model="tabControlVM" />
<p>Some content which appears after the TabControl</p>
</div>
</div>
@functions
{
async Task<IHtmlContent> Tab1Content() // IHtmlContent is in namespace Microsoft.AspNetCore.Html
{
return Html.Raw("<div class='text-center'>Hello from tab 1 of the Attempt3/Index view!</div>");
}
async Task<IHtmlContent> Tab2Content()
{
return Html.Raw("<div class='text-center'>Hello from tab 2 of the Attempt3/Index view!</div>");
}
}
This renders the markup as markup rather than literal strings, but doesn't solve the problem that building the markup as a string is less than ideal, as it can't take advantage of Visual Studio productivity features such as highlighting badly-formed markup and autocomplete suggesting attribute names and values.
Having used JSX syntax with React.js, it feels like I ought to be able to set the value of a variable or property to a block of markup in the .cshtml file, a bit like this
@using Microsoft.AspNetCore.Html
<div class="row">
<div class="col-2">
Some kind of vertical menu with options specific to this page
</div>
<div class="col-10">
<p>Some content which appears before the TabControl</p>
@{
var tabControlVM = new TabControlVMWithIHtmlContentProperties
{
Tab1Content = (<div class='text-center'>Hello from tab 1 of the Attempt4/Index view!</div>),
// ^
// CS1525 Invalid expression term ')'
Tab2Content = (<div class='text-center'>Hello from tab 2 of the Attempt4/Index view!</div>),
};
}
<partial name="TabControlAttempt3" model="tabControlVM" />
<p>Some content which appears after the TabControl</p>
</div>
</div>
But this doesn't compile - if this is possible then I don't have the right syntax. Is there a way to work with markup as markup in the .cshtml file, assign its value to a variable or property and pass it around as a parameter value? Or is a partial view completely the wrong approach for this use case?
I haven't managed to create a single component which replicates the TabControl functionality I was after, but I have a sort of solution.
ClientNavTabStrip.cshtml represents the tabs in a tab control.
@model ClientNavTabStripVM
<ul class="nav nav-tabs" role="tablist">
@foreach (var tab in Model.Tabs)
{
<partial name="ClientNavTab" model="@tab" />
}
</ul>
And it renders ClientNavTab.cshtml for each tab
@model ClientNavTabVM
<li class="nav-item">
<a class="nav-link@(Model.IsActive ? " active" : string.Empty)"
id="@(Model.UniqueName)-tab"
data-bs-toggle="tab"
data-bs-target="#@(Model.UniqueName)-tabpanel"
href="#">
@Model.TabText
</a>
</li>
The view model for ClientNavTab contains some logic to create a unique name for each tab, based on the text to display on the tab but with no characters which wouldn't be allowed in a HTML attribute values:
using System.Text;
/// <summary>
/// View model for one tab in a strip of tabs which forms part of a tab control.
/// </summary>
public class ClientNavTabVM
{
/// <summary>
/// Unique identifier which will form part of the id attribute of both the
/// tab and the tab panel that it activates.
/// </summary>
private readonly string tabGuid = Guid.NewGuid().ToString().Replace("-", string.Empty);
/// <summary>
/// Array of characters which aren't safe to include in a HTML element's
/// attribute value.
/// </summary>
/// <remarks>Warning: might not be a comprehensive list.</remarks>
private readonly string[] unsafeCharacters = new string[]
{
" ", "`", "¬", "!", "\"", "£", "$", "%", "^", "&", "*", "(", ")", "=", "+", "[", "{",
"]", "}", ";", ":", "'", "@", "#", "~", "\\", "|", ",", "<", ".", ">", "/", "?", "-",
};
/// <summary>
/// Gets or sets a value indicating whether the current tab is the active
/// one when the page first loads.
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// Gets or sets the text to display on the tab.
/// </summary>
/// <remarks>
/// A sanitised version of this name will be included in the id attributes
/// of both the tab and the corresponding tab panel.
/// </remarks>
public string TabText { get; set; } = string.Empty;
/// <summary>
/// Gets the tab's display text, with any unsafe characters removed.
/// </summary>
public string SafeName => this.MakeSafe(this.TabText);
/// <summary>
/// Gets a unique name which will form part of the id attributes of both
/// the tab and the corresponding tab panel.
/// </summary>
public string UniqueName => $"{this.SafeName}_{this.tabGuid}";
/// <summary>
/// Replaces characters which aren't safe to include in a HTML element's
/// attribute value with underscores.
/// </summary>
/// <param name="unsafeName">The tab's display text.</param>
/// <returns>
/// A version of the display text which is safe to use as an attribute value.
/// </returns>
private string MakeSafe(string unsafeName)
{
var sb = new StringBuilder(unsafeName);
foreach (var unsafeChar in this.unsafeCharacters)
{
sb.Replace(unsafeChar, "_");
}
// If the safe name starts with a number, put an underscore
// before it to make it a valid element ID.
if (sb[0] >= '0' && sb[0] <= '9')
{
sb.Insert(0, "_");
}
return sb.ToString();
}
}
And the view model for ClientNavTabStrip can be used to add tabs to the tab control:
/// <summary>
/// View model for a strip of tabs which forms part of a tab control.
/// </summary>
public class ClientNavTabStripVM
{
/// <summary>
/// Gets the tabs on the tab strip.
/// </summary>
public List<ClientNavTabVM> Tabs { get; private set; } = new List<ClientNavTabVM>();
/// <summary>
/// Adds a tab to the tab strip.
/// </summary>
/// <param name="tabText">Text to display on the tab.</param>
/// <param name="isActive">
/// True if the tab is to be the active tab when the page first loads.
/// </param>
public void AddTab(string tabText, bool isActive = false)
{
this.Tabs.Add(new ClientNavTabVM { IsActive = isActive, TabText = tabText });
}
}
This builder class allows a ClientNavTabStripVM
to be created, whilst ensuring that exactly one of the tabs is selected as the one to be active when the page first loads:
/// <summary>
/// Builds a view model for a ClientNavTabStrip partial view.
/// </summary>
public class ClientNavTabStripVMBuilder
{
private readonly ClientNavTabStripVM stripVM = new ClientNavTabStripVM();
/// <summary>
/// Adds a tab with the supplied text to the tab strip.
/// </summary>
/// <param name="tabText">Text to display on the tab.</param>
/// <returns>The current ClientNavTabStripVMBuilder.</returns>
public ClientNavTabStripVMBuilder AddTab(string tabText)
{
this.stripVM.AddTab(tabText);
return this;
}
/// <summary>
/// Adds a tab with the supplied text to the tab strip as the active tab.
/// </summary>
/// <param name="tabText">Text to display on the tab.</param>
/// <returns>The current ClientNavTabStripVMBuilder.</returns>
/// <remarks>
/// If an active tab has previously been added, that tab is no longer active,
/// instead the tab added by this method call becomes the active tab.
/// </remarks>
public ClientNavTabStripVMBuilder AddActiveTab(string tabText)
{
foreach (var tab in this.stripVM.Tabs.Where(t => t.IsActive).ToList())
{
tab.IsActive = false;
}
this.stripVM.AddTab(tabText, true);
return this;
}
/// <summary>
/// Builds the view model.
/// </summary>
/// <returns>A view model for a ClientNavTabStrip partial view.</returns>
/// <exception cref="InvalidOperationException">
/// The view model contains no tabs.
/// </exception>
public ClientNavTabStripVM Build()
{
if (!this.stripVM.Tabs.Any())
{
var msg = "You haven't added any tabs!";
throw new InvalidOperationException(msg);
}
if (!this.stripVM.Tabs.Any(t => t.IsActive))
{
this.stripVM.Tabs.First().IsActive = true;
}
return this.stripVM;
}
}
This JavaScript is driven by the tabs in the ClientNavTabStrip and for each tab, attempts to find an element with a role="tabpanel"
attribute containing the content to be displayed when the tab is selected. It adds the remaining required attributes to both the tab and the tab panel, including ensuring that the tab panel is aria-labelledby
the corresponding tab.
$(document).ready(initialiseTabControls);
/**
* Initialises all the tab controls on the page.
*/
function initialiseTabControls() {
var tabControls = $('.tabcontrol');
// Now we need to find the tabs and tab panels which are part of this tab control,
// but not those which are part of any tab controls which are embedded inside this
// tab control.
// We need to do this one tab control at a time rather than for the whole page at
// once in order to support embedding one tab control inside another, otherwise
// the tab panels aren't returned in the same order as the corresponding tabs.
tabControls.each(function (i) {
// Each tab is a child of a .nav-item,
// which is a child of a .nav .nav-tabs,
// which is a child of a .tabcontrol
var tabs = $(this).children().children().children('[data-bs-toggle=tab]');
// Each tab panel is a child of a .tab-content,
// which is a child of a .tabcontrol
var tabPanels = $(this).children().children('[role=tabpanel]');
try {
reconcileTabCounts(tabs, tabPanels, true);
} catch (error) {
$(this).prepend('<div class="text-danger">' + error + "</div>");
}
// Reduce the number of attributes needed in the tab panels' markup
tabPanels.addClass('tab-pane');
tabPanels.addClass('fade');
initialiseTabs(tabs, tabPanels);
});
}
/**
* Checks that the number of tabs on the page matches the number of tab panels on the page.
* @param {any} tabs jQuery elements with the data-bs-toggle="tab" attribute.
* @param {any} tabPanels jQuery elements with the role="tabpanel" attribute.
* @param {boolean} throwIfNotEqual True to throw an error if the counts aren't equal.
*/
function reconcileTabCounts(tabs, tabPanels, throwIfNotEqual) {
if (tabs.length != tabPanels.length) {
var msg = 'Found ' + tabs.length + ' tabs but ' + tabPanels.length + ' tab panels.';
console.error(msg);
if (throwIfNotEqual) {
throw (msg);
}
}
}
/**
* Iterates through each of the tabs.
* Sets the tab's data-bs-target attribute to the ID of the tab panel that it activates,
* Sets the corresponding tab panel's id attribute to the same ID,
* Set the tab panel's aria-labelledby attribute to the ID of the tab.
* Sets any other attributes on both which are required for Bootstrap tab behaviour.
* The tab's id attribute will be [meaningful name]_[unique id]-tab.
* The tab panel's id attribute will be [meaningful name]_[unique id]-tabpanel.
* @param {any} tabs jQuery elements with the attribute data-bs-tobble="tab".
* @param {any} tabPanels jQuery elements with the attribute role="tabpanel".
*/
function initialiseTabs(tabs, tabPanels) {
tabs.each(function (i) {
var tab = $(this);
var tabId = tab.attr('id');
var tabPanelId = tabId.split('-')[0] + '-tabpanel';
var tabPanel = $(tabPanels[i]);
setTabPanelAttributes(tab, tabPanel, tabPanelId);
});
}
/**
* Sets the attributes of an element which acts as a tab panel.
* @param {any} tab jQuery element representing the tab which activates the tab panel.
* @param {any} tabPanel jQuery element representing the tab panel.
* @param {string} tabPanelId The id attribute of the tab panel will be set to this.
*/
function setTabPanelAttributes(tab, tabPanel, tabPanelId) {
// Tab panels need these two attributes, set them here so that the only
// attribute the tab panel requires in markup is role="tabpanel".
tabPanel.attr('id', tabPanelId);
tabPanel.attr('aria-labelledby', tab.attr('id'));
// If the current tab is active then make the corresponding tab panel active
if (tab.hasClass('active')) {
tabPanel.addClass('active');
tabPanel.addClass('show');
}
}
This solution has a number of advantages:
It has one key disadvantage:
ClientNavTabStripVM
, and in the same order. If the number of tabs doesn't match the number of tab panels then an error message is displayed at the top of the tab control, but it's by no means a foolproof solution.This view has 3 of these tab controls, one of which is embedded in the first tab of the first tab control. Key points to note:
tabcontrol
classClientNavTabStrip
partial viewClientNavTabStrip
partial view needs to be followed by an element with the tab-content
class, which contains all of the tab panels for that tab controlrole="tabpanel"
attribute@model Attempt5VM
<div>Hello from the attempt 5 view!</div>
<hr />
<div class="tabcontrol">
<partial name="ClientNavTabStrip" model="@Model.TabStrip1VM" />
<div class="tab-content">
<div role="tabpanel">
Hello from tab 1 - we can embed one tab control inside another
<div class="tabcontrol">
<partial name="ClientNavTabStrip" model="@Model.TabStrip1SubTabStripVM" />
<div class="tab-content">
<div role="tabpanel">Hello from tab 1's sub tab 1</div>
<div role="tabpanel">Hello from tab 1's sub tab 2</div>
</div>
</div>
</div>
<div role="tabpanel">Hello from tab 2</div>
@* uncomment the next line to see what happens if there are more tabpanels than tabs *@
@*<div role="tabpanel">Panel with no tab</div>*@
</div>
</div>
<hr />
<div class="tabcontrol">
<partial name="ClientNavTabStrip" model="@Model.TabStrip2VM" />
<div class="tab-content">
<div role="tabpanel">Hello from tab 1 (the other one)</div>
<div role="tabpanel">Hello from tab B</div>
<div role="tabpanel">Hello from tab C</div>
</div>
</div>
The view model for this view doesn't have any properties other than the view models for each of the 3 tab controls. A view model for a real view would of course have other properties too.
public class Attempt5VM
{
public ClientNavTabStripVM TabStrip1VM { get; set; } = new ClientNavTabStripVM();
public ClientNavTabStripVM TabStrip1SubTabStripVM { get; set; } = new ClientNavTabStripVM();
public ClientNavTabStripVM TabStrip2VM { get; set; } = new ClientNavTabStripVM();
}
And this is how the controller creates those view models, and therefore defines the tabs which appear on each tab control:
public IActionResult Index()
{
var model = new Attempt5VM();
// No active tab specified - first tab will be active
model.TabStrip1VM = new ClientNavTabStripVMBuilder()
.AddTab("Tab 1")
.AddTab("Tab 2")
.Build();
// Second tab explicitly set to active
model.TabStrip2VM = new ClientNavTabStripVMBuilder()
.AddTab("Tab 1")
.AddActiveTab("Tab B")
.AddTab("Tab C")
.Build();
// More than one active tab specified - last one takes precedence
model.TabStrip1SubTabStripVM = new ClientNavTabStripVMBuilder()
.AddActiveTab("Sub tab 1")
.AddActiveTab("Sub tab 2")
.Build();
return this.View(model);
}
If anyone has an alternative approach to this, please feel free to post another answer, or please comment if you can suggest any improvements to this solution.