Files
tatlock-ui/lib/shared/layouts/widgets/nav_panel.dart
T
Jeroen SchweitzerandClaude Opus 4.5 a6c51f757a
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
feat(semantics): add semantic labels for UI automation
- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
  - ProfileDropdown (button, settings, theme options, logout)
  - TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
  - NavPanel items (nav_item_{id})

This enables browser automation tools like Puppeteer and WebDriver to
discover and interact with Flutter widgets via the accessibility tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:21:43 +01:00

271 lines
7.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'panel_header.dart';
/// A navigation item in the NavPanel.
class NavItem {
const NavItem({
required this.id,
required this.label,
required this.icon,
this.section,
this.badge,
});
/// Unique identifier for routing.
final String id;
/// Display label.
final String label;
/// Item icon.
final IconData icon;
/// Optional section grouping (e.g., 'Portainer', 'NPM', 'Authentik').
/// Section headers are shown only when multiple sections exist.
final String? section;
/// Optional badge (e.g., item count).
final String? badge;
}
/// Left-side navigation panel for room-level section navigation.
///
/// Shows a list of items within the current room (e.g., Containers, Networks
/// for Control Room). Items can be grouped into sections with headers.
/// Section headers only appear when multiple sections exist.
///
/// Header content is docked to bottom to accommodate the logo bulge overlay.
///
/// See UI_LAYOUT.md for panel taxonomy and layout specifications.
class NavPanel extends StatelessWidget {
const NavPanel({
super.key,
required this.title,
required this.icon,
required this.items,
required this.selectedId,
required this.onItemSelected,
this.width = 280,
this.trailing,
});
/// Panel title displayed in header.
final String title;
/// Leading icon for the header.
final IconData icon;
/// List of navigation items.
final List<NavItem> items;
/// Currently selected item ID.
final String selectedId;
/// Callback when an item is tapped.
final ValueChanged<String> onItemSelected;
/// Panel width (default 280px per UI_LAYOUT.md spec).
final double width;
/// Optional trailing widget below items (e.g., external links).
final Widget? trailing;
/// Returns true if section headers should be shown.
bool get _showSectionHeaders {
final sections = items.map((i) => i.section).where((s) => s != null).toSet();
return sections.length > 1;
}
/// Groups items by section, preserving order.
List<(String?, List<NavItem>)> get _groupedItems {
final groups = <String?, List<NavItem>>{};
final order = <String?>[];
for (final item in items) {
if (!groups.containsKey(item.section)) {
groups[item.section] = [];
order.add(item.section);
}
groups[item.section]!.add(item);
}
return order.map((section) => (section, groups[section]!)).toList();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PanelHeader(
title: title,
icon: icon,
dockToBottom: true, // Left-side panel
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: _buildItemList(context),
),
),
if (trailing != null) ...[
Divider(height: 1, color: colorScheme.outlineVariant),
trailing!,
],
],
),
);
}
List<Widget> _buildItemList(BuildContext context) {
final widgets = <Widget>[];
final showHeaders = _showSectionHeaders;
for (final (section, sectionItems) in _groupedItems) {
// Add section header if multiple sections exist
if (showHeaders && section != null) {
widgets.add(_SectionHeader(title: section));
}
// Add items
for (final item in sectionItems) {
widgets.add(
_NavTile(
item: item,
isSelected: item.id == selectedId,
onTap: () => onItemSelected(item.id),
),
);
}
}
return widgets;
}
}
/// Section header widget with left accent bar.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
margin: const EdgeInsets.only(top: 8, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
border: Border(
left: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
),
child: Text(
title.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.item,
required this.isSelected,
required this.onTap,
});
final NavItem item;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Semantics(
identifier: NavSemantics.item(item.id),
label: item.label,
button: true,
selected: isSelected,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(
item.icon,
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
item.label,
style: textTheme.bodyMedium?.copyWith(
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : null,
),
),
),
if (item.badge != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
item.badge!,
style: textTheme.labelSmall?.copyWith(
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
),
);
}
}