# Android TV Sidebar Architecture — Production Standards

## Sources
- Netflix, YouTube, Disney+, Prime Video, Max Android TV apps (UX analysis)
- Material Design 3 Expressive (May 2025): NavigationRail replaces NavigationDrawer
- Google tv-material3: NavigationDrawer API, NavigationDrawerItem
- Android Developers: Focus in Compose, TV Focus System, Build Adaptive Apps for TV
- WCAG 2.1: 4.1.2 (role/state), 1.1.1 (alt text), 2.5.5 (touch target), 2.4.3 (traversal)
- Kotlin Multiplatform: Semantics properties (role, contentDescription, stateDescription)
- Kodeco: Jetpack Compose Accessibility (Tori Gonda)
- Joe Birch (GDE Android): Navigation Drawers for Android TV using Jetpack Compose
- Prahalad Sharma: Android TV D-Pad Navigation Handling (Jetpack Compose) series
- TO THE NEW: Migrating from Leanback to Jetpack Compose in Android TV
- Float Left Interactive: Google TV platform notes (Leanback vs Compose)

---

## Production App Navigation Patterns

| App | Sidebar? | Style | Labels | Focus Indicator |
|-----|----------|-------|--------|-----------------|
| Netflix | Persist icon rail 72dp | Solid dark bg, no glass | Appear on focus | Bright icon only, no box |
| YouTube | Modal overlay (hidden by default) | Full expanded list | Always visible | Bright text on dark bg |
| Disney+ | Persistent rail | Collapsed + expanded states | Always visible | Scale + glow ring |
| Prime Video | No sidebar | Top tab bar | N/A | Tab highlight |
| Max (HBO) | Persistent | Text + icon list | Always visible | Color change only |

**Common pattern across all with sidebars:** Custom implementation, not stock NavigationDrawer.

---

## M3 NavigationRail (May 2025 — current recommendation)

Material Design 3 deprecates NavigationDrawer in favor of NavigationRail with:
- **Collapsed navigation rail** — icon-only, ~72dp width
- **Expanded navigation rail** — icon + text label, modal or non-modal
- Transition animation between collapsed/expanded states

An icon-only rail that shows labels on focus is a valid collapsed rail with auto-expand.

---

## TV Accessibility Checklist (WCAG + Google Guidelines)

| Requirement | Code | Notes |
|---|---|---|
| `role = Role.Button` on clickable items | `semantics { role = Role.Button }` | Without it TalkBack may announce as generic element |
| `stateDescription` for selected state | `stateDescription = if(selected) "Selected" else ""` | Required for WCAG 4.1.2 |
| `mergeDescendants = true` for grouped icon+label | `semantics(mergeDescendants = true) {}` | Prevents TalkBack from navigating into children separately |
| `isTraversalGroup = true` on sidebar container | `semantics { isTraversalGroup = true }` | Makes TalkBack read all sidebar items before moving to content |
| `contentDescription` on icons | `Icon(contentDescription = item.label)` | WCAG 1.1.1 — null only for decorative |
| Min touch target 48dp | `Modifier.defaultMinSize(minHeight = 48.dp)` | WCAG 2.5.5 |
| Disabled items focusable | Built into tv-material3 by design | TV team decided disabled buttons stay focusable on TV |
| `traversalIndex` for custom ordering | `semantics { traversalIndex = ... }` | Low to high, 0f default |

---

## Focus Management Architecture (Production Grade)

### Required State

```
TvFocusState
  activeZone: TvFocusZone
  selectedRoute: String
  sidebarOpen: Boolean
  lastSidebarFocusIndex: Int      // remember scroll position
  lastSidebarFocusKey: String     // remember last focused item
  per-zone state maps
```

### D-Pad Routing Pattern

```
Sidebar item (LazyColumn)
  focusProperties { left = Cancel }
  focusProperties { right = entryRequester }  // → content entry
  focusProperties { up = prevItem ?? lastItem }  // wrap: top → bottom
  focusProperties { down = nextItem ?? firstItem }  // wrap: bottom → top
  // Use `?? Cancel` instead of `?? lastItem/firstItem` for no-wrap behavior

First content card
  focusProperties { left = sidebarRequester } // ← sidebar

Sidebar Column
  onPreviewKeyEvent(Key.DirectionRight) → closeSidebar()
  onPreviewKeyEvent(Key.DirectionLeft)  → Cancel (prevents leaving left edge)

Scaffold level (root Box)
  .onPreviewKeyEvent { event ->
      if (event.type == KeyDown && event.key == Key.Menu) {
          controller.openSidebar()
          true
      } else false
  }
```

### Focus Restoration

Always persist both **scroll position** and **last focused key** when the sidebar closes, and restore both when it reopens. Without this, focus resets to the selected route every time (bad UX, unlike Netflix/Disney+).

```kotlin
// Save on close
LaunchedEffect(sidebarOpen) {
    if (!sidebarOpen) {
        controller.recordFocusForZone(
            TvFocusZone.Sidebar,
            "sidebar_scroll",
            Pair(listState.firstVisibleItemIndex, 0),
        )
    }
}

// Restore on open (with layout guard)
LaunchedEffect(sidebarOpen, selectedRoute) {
    if (sidebarOpen && items.isNotEmpty()) {
        val lastKey = controller.getLastFocusedKey(TvFocusZone.Sidebar)
        val lastScroll = controller.getLastScroll(TvFocusZone.Sidebar)
        val targetIndex = if (lastKey != null) {
            items.indexOfFirst { it.key == lastKey }.coerceAtLeast(0)
        } else {
            selectedIndex  // fallback to selected route
        }
        val scrollIndex = lastScroll?.first?.coerceIn(0, items.lastIndex) ?: targetIndex

        // Guard: wait for layout before scrolling
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.isNotEmpty() }
            .filter { it }.first()
        listState.animateScrollToItem(scrollIndex)
        requesters[items.getOrNull(targetIndex)?.key]?.requestFocusAfterFrames()
    }
}
```

Also add `focusRestorer()` on the sidebar Column so the composable system remembers focus across recompositions:
```kotlin
Column(
    modifier = Modifier
        .focusRestorer { sidebarFocusRequester }
        ...
)

// Requires @file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
```

---

## Haze/Blur Library Integration

Chris Banes' Haze library requires parent + child:

```kotlin
// Parent (background)
Modifier.haze(state = hazeState)

// Child (sidebar overlay)
Modifier.hazeChild(state = hazeState, style = HazeStyle(...))
```

**Without the parent `.haze()` call, `hazeChild` silently does nothing.**

---

## Navigation Dispatch — Single Source of Truth

```kotlin
fun sidebarNavigate(navController: NavController, route: String) {
    when (route) {
        "home" -> navController.navigate(Screen.Home.route) {
            popUpTo(Screen.Home.route) { inclusive = true }
        }
        // ... all routes with consistent popUpTo + launchSingleTop
    }
}
```

All screens call the same function. No per-screen duplicates.

---

## EntryFocusRequester Map — Screen-Specific Only

```kotlin
// HomeScreen — only the home entry matters
val entryFocusRequesterMap = remember { mapOf("home" to catalogEntryRequester) }

// SearchScreen
val entryFocusRequesterMap = remember { mapOf("search" to keyboardFocusRequester) }
```

Sidebar code handles missing keys: `entryFocusRequesterMap[item.route] ?: contentFocusRequester`

---

## Key Pitfalls

- **`rememberInfiniteTransition` burns GPU on all items** — gate with `targetValue = if (focused) 1f else 0f` and a single `InfiniteRepeatableSpec`. Don't create the spec conditionally (type mismatch), just vary the targetValue.
- **`animateScrollToItem` before layout** — guard with `snapshotFlow { visibleItemsInfo.isNotEmpty() }.filter { it }.first()`
- **Hardcoded strings** — all display text must go in `strings.xml`. Store `@StringRes labelResId: Int` in the data model, resolve via `stringResource()` in composable context.
- **Focus lost on composable tree rebuild** — use `focusRestorer()` on root of focusable containers
- **Netflix/Disney+ do NOT use glass blur** — if performance is bad on low-end hardware, cut blur first
- **`focusRestorer` is `@ExperimentalComposeUiApi`** — needs opt-in annotation
- **`mergeDescendants = true` means child contentDescription becomes redundant** — when using mergeDescendants on the parent Column, set `contentDescription = null` on child Icon and Text, because the parent's one merged node provides the description. Double descriptions confuse TalkBack.
