# TabbedShell

> An app shell whose primary navigation is a horizontal strip of tabs under the brand row, and a bottom tab bar within thumb reach on a phone. It takes the same AppChrome and the same six slots as every other arrangement, so switching to it from another shell is one word.

<!-- id: ui-shell-tabbed · area: ui · stability: stable · html: https://osysharp.com/reference/ui/shell-tabbed/ -->

## Summary        {#summary}

`TabbedShell` puts the app's primary sections across the top on a pointer, and along the bottom edge on a phone.
Reach for it when the sections are **peers** — a handful of them, no deep hierarchy — and when a good phone
experience matters, because a bottom bar is one thumb-reach from every section where a drawer is a tap away from
even seeing them.

It reads exactly the same declaration as every other arrangement, so switching is one word:

```osy title="the same chrome, arranged as tabs" test app=ui-shell-tabbed
[Layout]
[AllowAnonymous]
component TabbedLayout() {
  action Palette() { }
  action Inbox() { }

  render {
    TabbedShell(new AppChrome {
      Product = "Expensely",
      Tagline = "Finance operations",
      Home = "/",
      User = new ShellUser { Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR" },
      Nav = [
        new NavItem { Label = "Overview", To = "/", Icon = Icons.Home, Exact = true },
        new NavItem { Label = "Approvals", To = "/approvals", Icon = Icons.CheckCircle, Badge = "12", BadgeTone = Tone.Warning },
        new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
          new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
        ] },
        new NavItem { Kind = NavKind.Section, Label = "Administration", Children = [
          new NavItem { Label = "Audit log", To = "/audit", Icon = Icons.File },
        ] },
      ],
    }) {
      Outlet(retain: 8);
      slot search { ShellSearch("Search requests, people or departments", onPress: Palette); }
      slot actions { ShellCountButton("Notifications", 3, onPress: Inbox) { Icon(Icons.Bell, size: 18); } }
    }
  }
}

[Page("/")] [Layout(TabbedLayout)] [Title("Overview")] [Render(CSR)] [AllowAnonymous]
component TOverview() { render { PageHead("Overview"); Card("This month") { Text("Nothing needs you."); } } }

[Page("/approvals")] [Layout(TabbedLayout)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component TApprovals() { render { PageHead("Approvals"); } }

[Page("/settings")] [Layout(TabbedLayout)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component TSettings() { render { PageHead("Settings"); } }

[Page("/settings/people")] [Layout(TabbedLayout)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component TPeople() { render { PageHead("People"); } }

[Page("/audit")] [Layout(TabbedLayout)] [Title("Audit log")] [Render(CSR)] [AllowAnonymous]
component TAudit() { render { PageHead("Audit log"); } }
```

## Signature      {#signature}

```osy syntax
TabbedShell(AppChrome chrome) {
  Outlet(retain: 8);          // the routed page
  slot search   { … }         // the brand row — `ShellSearch(…)` is shaped for it
  slot actions  { … }         // the brand row, right of search
  slot railFoot { … }         // no rail here: it sits under the page as a help card
  slot aside    { … }         // beside the page at 1100+, under it below that
  slot primary  { … }         // a task's commit bar — `ShellTaskBar`. See [FocusedShell](https://osysharp.com/reference/ui/shell-focused/)
}
```

Everything in `AppChrome`, `NavItem`, `ShellUser` and `MenuAction` is the shared contract — see [App shells](https://osysharp.com/reference/ui/shell/).

## Description    {#description}

### What sits where, at each width   {#bands}

| band | brand row | navigation |
|---|---|---|
| compact `< 768` | mark · page title · actions · identity | a **bottom tab bar**: up to five entries, plus "More" |
| pointer `768+` | brand · search · actions · identity | a horizontal **tab strip** on its own row, plus a **secondary strip** while you are inside a section that has sub-areas |
| wide `1100+` | the same | the same, and the page sits beside its `aside` |

**Two rows on a pointer, not one.** A single row has to divide one line between the brand, the tabs, the search
box, the page's actions and the identity chip — so the tabs, which are the point of the arrangement, get whatever
is left and begin overflowing at about eight entries on a 1280 screen. Two rows give the strip the full width. It
costs 52px of height on a surface that has plenty.

### What happens when the tabs stop fitting?   {#overflow}

**The strip scrolls.** It never wraps to a ragged second row, and there is no "More" menu on a pointer.

A measured priority-plus menu — count what fits, move the rest into an overflow — is the richer answer, and the
platform cannot honestly build it today. It needs a **per-element** width, and the only measurement a component can
read is `Layout.Width`, its own container's. Deriving the cut from label lengths instead was considered and
rejected for a specific reason: `Layout.Width` is null on the first render and arrives after it, so every page load
would paint a full strip and then jump tabs into an overflow that was not there a frame earlier — in the **common**
case, where everything fits, not the rare one. A layout that moves under the reader on every navigation is a worse
failure than a strip they have to scroll.

Scrolling costs nothing when the tabs fit, hides nothing permanently, and keyboard focus scrolls a tab into view
natively. The scrollbar itself is suppressed, because a horizontal bar under a tab row reads as a rendering fault.

### How does a phone reach the sections that did not fit?   {#more}

The bottom bar holds up to **five** entries — a count, not a measurement, and deliberately so: iOS and Android both
cap a tab bar at five by convention, so a deterministic cut *is* the platform behaviour rather than an
approximation of it. If the tail would be a single entry the bar simply shows all of them, because "four and a More
holding one" is worse than five.

Everything else lives in the **"More" sheet**, and the sheet holds the **whole nav tree** rather than only the tail.
That matters: a sheet holding only the overflow would leave a barred entry's *children* with nowhere to live, so a
phone reader could reach "Settings" and never "Settings → Billing". Showing everything is also what a "More" screen
is on both native platforms.

### What does a tab with children do?   {#groups}

**It navigates. That is all a tab ever does.** A tab is a *destination*, and one press does one thing.

A `NavItem` with `Children` goes to its own `To` when it has one, and otherwise to the first route beneath it
(`NavItem.FirstRoute`). An entry whose whole subtree holds no route at all is drawn as plain text rather than as a
link, because a link to nowhere accepts the press and does nothing.

Its children are not hidden behind that press. Once you are **inside** the section, they appear in a **secondary
strip** beneath the tab bar, carrying that section's areas and no others — the shape GitHub's repository tabs,
Stripe's dashboard and the Azure portal all converge on. Anything nested deeper than the strip's own level is
flattened into it rather than dropped.

⚑ **Why not a dropdown?** Because a tab that both navigated *and* opened a menu did two things on one press, which
is a **menu bar** (File / Edit / View) rather than a tab strip — a different interaction model, and one this shell
is not for. The strip also shows a section's areas *without* a press instead of hiding them behind one.

The strip appears only when the section you are in actually has sub-areas. A section with none costs nothing: no
empty rule, no reserved height, so the page does not shift as you move between the two kinds of section.

⚠ **The strip's landmark is the section's own name** — `"Settings sections"`, not a second `"Main navigation"`. Two
landmarks with the same name make a nav unnavigable by keyboard, and would make every `within:` in your tests
ambiguous.

The phone gets the same strip, under the slim top bar, for the same reason and from the same declaration. The
"More" sheet still holds the whole tree, so it remains the exhaustive index rather than the only way in.

⚠ **A top-level `Kind = NavKind.Section` becomes its children.** A bar has nowhere to put a heading and nothing to
do when one is pressed, so `Primary()` replaces the section with the entries under it. A rail has the room to draw
the heading; a strip does not. Both readings are right for their own arrangement.

### Reading the current tab   {#current}

Exactly one tab is ever lit: the shell asks `AppChrome.CurrentRoute` for the **longest** matching route in the whole
tree, so `/settings` and `/settings/people` do not both light up on the child's page. A route no tab covers — a
record detail, a wizard step — leaves no tab current, and the strip names the page on its trailing edge instead of
leaving it anonymous.

⚠ **Give a non-`/` home `Exact = true`.** The prefix rule claims every route under an entry and only `/` is exempt
from it, so an "Overview" at `/t` would otherwise light on every page beneath it.

## Examples       {#examples}

```osy title="a tab whose children become a secondary strip" syntax
new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
  new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
] },
```

The primary strip is the `nav` landmark named "Main navigation", the same name every arrangement uses; the
secondary one is named after its section:

```osy title="driving the tabs and the section strip" syntax
Ui.Click("Settings");                              // ONE act — the tab navigates, and nothing else happens
Assert.OnPage("/settings");
Assert.Visible("People", within: "Settings sections");
Assert.Below("Settings sections", "Main navigation");   // under the bar, above the page
Ui.Click("Invoices");                              // a level deeper, flattened into the same strip
Assert.OnPage("/settings/billing/invoices");
```

## Notes          {#notes}

⚠ **The identity chip is in the brand row, not at the strip's end.** The strip needs its full width for tabs, which
is the reason the arrangement has two rows at all.

⚠ **`railFoot` has no rail here.** It is rendered under the page as a bounded help card rather than dropped — a slot
an app filled and a shell discarded is lost content, not lost layout.

⚠ **The secondary strip does not stick.** The tab bar pins because it is the app's orientation and true on every
page; the section strip belongs to the section's *content* and scrolls with it. Three pinned bars would eat a third
of a laptop's viewport before the page began.

⚑ **A shell holds no nav state on a pointer.** Which section you are in is read from the **route**, never from a
panel the reader had to open — so what the navigation shows and where you actually are cannot disagree.

## See also       {#see-also}

- [App shells](https://osysharp.com/reference/ui/shell/) — the shared `AppChrome` contract every arrangement reads
- [RailShell](https://osysharp.com/reference/ui/shell-rail/) — a permanent icon rail, when the canvas is the product
- [FocusedShell](https://osysharp.com/reference/ui/shell-focused/) — one task, no navigation at all. A focused route can live **inside** a tabbed app: `[Layout(…)]` is per-page, so a wizard is a route rather than an application
- [Navigation](https://osysharp.com/reference/ui/navigation/) — `Navigation.Routes`, `[Title]`, and what a shell reads from them
- [accessibility](https://osysharp.com/reference/ui/accessibility/) — landmarks, `role:`, `current:` and the naming props
