ashlarui

Sidebar

An app's navigation down the side: variants, submenus, actions, a rail.

Sidebar, as it looks in an app Sidebar, as it looks in an app, dark
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/avatar.dart';
import 'ui/sidebar.dart';
import 'ui/theme.dart';

class SidebarDemo extends StatefulWidget {
  const SidebarDemo({super.key});

  @override
  State<SidebarDemo> createState() => _SidebarDemoState();
}

class _SidebarDemoState extends State<SidebarDemo> {
  String page = 'Home';

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    SidebarItem item(String label, IconData icon, {String? badge}) => SidebarItem(
      label,
      icon: icon,
      badge: badge,
      active: page == label,
      onPressed: () => setState(() => page = label),
    );
    return Container(
      width: 800,
      height: 480,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        border: Border.all(color: t.colors.border),
        borderRadius: BorderRadius.circular(t.radiusLg),
      ),
      child: SidebarLayout(
        width: 220,
        narrow: 500,
        header: Text('Acme Shop', style: t.text.sm.copyWith(fontWeight: FontWeight.w600)),
        footer: const Row(
          children: [
            Avatar(fallback: 'WM', size: 28),
            SizedBox(width: 8),
            Expanded(child: Text('Wanjiru Mwangi', maxLines: 1, overflow: TextOverflow.ellipsis)),
          ],
        ),
        groups: [
          SidebarGroup(label: 'Shop', [
            item('Home', AshlarIconsRoundedStroke.house),
            item('Orders', AshlarIconsRoundedStroke.inbox, badge: '12'),
            item('Products', AshlarIconsRoundedStroke.folder),
            item('Reports', AshlarIconsRoundedStroke.chartColumn),
          ]),
          SidebarGroup(label: 'Account', [
            item('Team', AshlarIconsRoundedStroke.users),
            item('Settings', AshlarIconsRoundedStroke.settings),
          ]),
        ],
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(8),
              child: Row(
                children: [
                  const SidebarTrigger(),
                  const SizedBox(width: 8),
                  Text(page, style: t.text.sm.copyWith(fontWeight: FontWeight.w500)),
                ],
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                child: Container(
                  decoration: BoxDecoration(color: t.colors.muted, borderRadius: BorderRadius.circular(t.radiusLg)),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Add it

In your Flutter project:

$ ashlar add sidebar

It writes theme.dart, palette.dart, group.dart, pressable.dart, button.dart, sheet.dart, tooltip.dart, skeleton.dart and sidebar.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, button, sheet, tooltip, skeleton and ashlar_icons, which come with it.

API

Read from the source above: each parameter with its type, its default, and what it is for.

SidebarActionclass

A small button at the end of an item or a group's label: "more", "add".

Parameter Type Default What it is for
iconrequired IconData Its icon.
labelrequired String What it does, for screen readers and its tooltip.
onPressedrequired VoidCallback Called when it is pressed.

SidebarItemclass

A place in the app, on the sidebar. With children it opens a list of its own.

Parameter Type Default What it is for
labelpositional String Its name; shown in a tooltip when the sidebar is collapsed to icons.
iconrequired IconData? Its icon, shown alone when the sidebar is collapsed.
onPressed VoidCallback? Called when it is chosen.
active bool false The page being shown.
badge String? A count or a word at the end of the row: '12'.
action SidebarAction? A button at the end of the row, shown under the pointer or keyboard.
children List<SidebarItem> const [] Pages under this one, shown when it is opened.
initiallyOpen bool false Whether its list starts open.

SidebarGroupclass

A titled run of SidebarItems.

SidebarGroup(…)

Parameter Type Default What it is for
itemspositional List<SidebarItem> The group's items.
labelString?The group's title.
action SidebarAction? A small button at the end of the group's label: add, more.
atEnd bool false Kept at the bottom of the sidebar, above the footer, where help and settings go.

SidebarGroup.loading(…)

Parameter Type Default What it is for
labelString?The group's title.
rows int 5 How many placeholder rows to show while it loads.

SidebarGroup.custom(…)

Parameter Type Default What it is for
childpositional Widget Anything else the sidebar holds: a calendar, a file tree, a card.
labelString?The group's title.
action SidebarAction? A small button at the end of the group's label: add, more.
atEnd bool false Kept at the bottom of the sidebar, above the footer, where help and settings go.

SidebarCollapseenum

How a sidebar gets out of the way.

ValueWhat it is for
iconsDown to a strip of icons, each with its label in a tooltip.
offcanvasOff the side of the screen entirely.
none It stays where it is beside the page: no rail, no shortcut. On a narrow screen it is still hidden, and a SidebarTrigger slides it in.

SidebarVariantenum

How a sidebar sits beside the page.

ValueWhat it is for
sidebarEdge to edge, with a line between it and the page.
floatingA rounded panel floating a little in from the edge.
insetThe page as a rounded card set into the sidebar's colour.

SidebarSideenum

Which side of the screen the sidebar is on; it follows the reading direction.

ValueWhat it is for
start
end

SidebarLayoutclass

An app's main navigation down one side, beside child. It collapses with a SidebarTrigger, its rail, or ⌘B (Ctrl+B), to icons or out of sight; on a narrow screen it is hidden, and the trigger slides it in over the page. Give open and onOpenChanged to hold its state yourself.

Parameter Type Default What it is for
groupsrequired List<SidebarGroup> The navigation, in titled groups.
childrequired Widget The page beside the sidebar.
header Widget? Above the groups: the app's name, a team switcher, a search field.
footer Widget? Below the groups: who is signed in.
collapse SidebarCollapse SidebarCollapse.icons How it gets out of the way: to icons, out of sight, or not at all.
variant SidebarVariant SidebarVariant.sidebar Edge to edge, floating, or with the page inset as a card.
side SidebarSide SidebarSide.start Which side of the screen it is on; it follows the reading direction.
initiallyOpen bool true Whether it starts open, when nothing holds its state.
open bool? Held by the app when given, with onOpenChanged told of each toggle.
onOpenChanged ValueChanged<bool>? Told when it opens or closes.
rail bool true A thin strip along the sidebar's inner edge that toggles it on a click.
width double 256 How wide it is when open.
narrow double 768 Below this width the sidebar is hidden, and slides in over the page.

SidebarLayoutStateclass

SidebarTriggerclass

The button that opens and closes the SidebarLayout above it.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/sidebar.dart 820 lines
// Ashlar UI: Sidebar. Copied into your app by `ashlar add sidebar`; it is yours to change.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'button.dart';
import 'pressable.dart';
import 'sheet.dart';
import 'skeleton.dart';
import 'theme.dart';
import 'tooltip.dart';

/// A small button at the end of an item or a group's label: "more", "add".
@immutable
class SidebarAction {
  const SidebarAction({
    required this.icon,
    required this.label,
    required this.onPressed,
  });

  /// Its icon.
  final IconData icon;

  /// What it does, for screen readers and its tooltip.
  final String label;

  /// Called when it is pressed.
  final VoidCallback onPressed;
}

/// A place in the app, on the sidebar. With [children] it opens a list of its own.
@immutable
class SidebarItem {
  const SidebarItem(
    this.label, {
    required this.icon,
    this.onPressed,
    this.active = false,
    this.badge,
    this.action,
    this.children = const [],
    this.initiallyOpen = false,
  });

  /// Its name; shown in a tooltip when the sidebar is collapsed to icons.
  final String label;

  /// Its icon, shown alone when the sidebar is collapsed.
  final IconData? icon;

  /// Called when it is chosen.
  final VoidCallback? onPressed;

  /// The page being shown.
  final bool active;

  /// A count or a word at the end of the row: '12'.
  final String? badge;

  /// A button at the end of the row, shown under the pointer or keyboard.
  final SidebarAction? action;

  /// Pages under this one, shown when it is opened.
  final List<SidebarItem> children;

  /// Whether its list starts open.
  final bool initiallyOpen;
}

/// A titled run of [SidebarItem]s.
@immutable
class SidebarGroup {
  const SidebarGroup(this.items, {this.label, this.action, this.atEnd = false})
    : loading = 0,
      child = null;

  /// Rows still loading: [rows] placeholders in their place.
  const SidebarGroup.loading({this.label, int rows = 5})
    : items = const [],
      action = null,
      atEnd = false,
      child = null,
      loading = rows;

  /// Anything else the sidebar holds: a calendar, a file tree, a card. Hidden while the
  /// sidebar is collapsed to icons.
  const SidebarGroup.custom(
    Widget this.child, {
    this.label,
    this.action,
    this.atEnd = false,
  }) : items = const [],
       loading = 0;

  /// The group's title.
  final String? label;

  /// The group's items.
  final List<SidebarItem> items;

  /// A small button at the end of the group's label: add, more.
  final SidebarAction? action;
  final int loading;

  /// Anything else the sidebar holds: a calendar, a file tree, a card.
  final Widget? child;

  /// Kept at the bottom of the sidebar, above the footer, where help and settings go.
  final bool atEnd;
}

/// How a sidebar gets out of the way.
enum SidebarCollapse {
  /// Down to a strip of icons, each with its label in a tooltip.
  icons,

  /// Off the side of the screen entirely.
  offcanvas,

  /// It stays where it is beside the page: no rail, no shortcut. On a narrow screen it
  /// is still hidden, and a [SidebarTrigger] slides it in.
  none,
}

/// How a sidebar sits beside the page.
enum SidebarVariant {
  /// Edge to edge, with a line between it and the page.
  sidebar,

  /// A rounded panel floating a little in from the edge.
  floating,

  /// The page as a rounded card set into the sidebar's colour.
  inset,
}

/// Which side of the screen the sidebar is on; it follows the reading direction.
enum SidebarSide { start, end }

/// An app's main navigation down one side, beside [child]. It collapses with a
/// [SidebarTrigger], its rail, or ⌘B (Ctrl+B), to icons or out of sight; on a narrow screen
/// it is hidden, and the trigger slides it in over the page. Give [open] and
/// [onOpenChanged] to hold its state yourself.
///
/// ```dart
/// SidebarLayout(
///   header: Text('Acme'),
///   groups: [SidebarGroup(label: 'App', [
///     SidebarItem('Home', icon: AshlarIconsRoundedStroke.house, active: true, onPressed: () => go('/')),
///     SidebarItem('Projects', icon: AshlarIconsRoundedStroke.folder, children: [SidebarItem('Ashlar', icon: null, onPressed: open)]),
///   ])],
///   child: Column(children: [Row(children: [SidebarTrigger()]), Expanded(child: page)]),
/// )
/// ```
class SidebarLayout extends StatefulWidget {
  const SidebarLayout({
    super.key,
    required this.groups,
    required this.child,
    this.header,
    this.footer,
    this.collapse = SidebarCollapse.icons,
    this.variant = SidebarVariant.sidebar,
    this.side = SidebarSide.start,
    this.initiallyOpen = true,
    this.open,
    this.onOpenChanged,
    this.rail = true,
    this.width = 256,
    this.narrow = 768,
  });

  /// The navigation, in titled groups.
  final List<SidebarGroup> groups;

  /// The page beside the sidebar.
  final Widget child;

  /// Above the groups: the app's name, a team switcher, a search field.
  final Widget? header;

  /// Below the groups: who is signed in.
  final Widget? footer;

  /// How it gets out of the way: to icons, out of sight, or not at all.
  final SidebarCollapse collapse;

  /// Edge to edge, floating, or with the page inset as a card.
  final SidebarVariant variant;

  /// Which side of the screen it is on; it follows the reading direction.
  final SidebarSide side;

  /// Whether it starts open, when nothing holds its state.
  final bool initiallyOpen;

  /// Held by the app when given, with [onOpenChanged] told of each toggle.
  final bool? open;

  /// Told when it opens or closes.
  final ValueChanged<bool>? onOpenChanged;

  /// A thin strip along the sidebar's inner edge that toggles it on a click.
  final bool rail;

  /// How wide it is when open.
  final double width;

  /// Below this width the sidebar is hidden, and slides in over the page.
  final double narrow;

  /// The layout above [context]: whether it is open, and the way to toggle it. Past one
  /// that stays put ([SidebarCollapse.none]) to one that moves, when there is one, so a
  /// trigger beside a fixed sidebar works the one that moves.
  static SidebarLayoutState of(BuildContext context) {
    SidebarLayoutState? first, movable;
    context.visitAncestorElements((e) {
      if (e is StatefulElement && e.state is SidebarLayoutState) {
        final s = e.state as SidebarLayoutState;
        first ??= s;
        if (s.widget.collapse != SidebarCollapse.none) {
          movable = s;
          return false;
        }
      }
      return true;
    });
    return (movable ?? first)!;
  }

  @override
  State<SidebarLayout> createState() => SidebarLayoutState();
}

class SidebarLayoutState extends State<SidebarLayout> {
  late bool _own = widget.initiallyOpen;
  bool _narrow = false;

  bool get open =>
      widget.collapse == SidebarCollapse.none || (widget.open ?? _own);

  /// Opens or closes it; on a narrow screen, slides it in over the page.
  void toggle() {
    if (_narrow) {
      final theme = AshlarTheme.of(context);
      final end = widget.side == SidebarSide.end;
      showSheet<void>(
        context: context,
        side: (Directionality.of(context) == TextDirection.rtl) != end
            ? SheetSide.right
            : SheetSide.left,
        builder: (context) => Align(
          alignment: end
              ? AlignmentDirectional.centerEnd
              : AlignmentDirectional.centerStart,
          child: AshlarTheme(
            data: theme,
            child: SizedBox(
              width: 288,
              child: _Panel(
                layout: widget,
                collapsed: false,
                onPicked: () => Navigator.pop(context),
              ),
            ),
          ),
        ),
      );
      return;
    }
    if (widget.collapse == SidebarCollapse.none) return;
    if (widget.open == null) setState(() => _own = !_own);
    widget.onOpenChanged?.call(!open);
  }

  @override
  Widget build(BuildContext context) => LayoutBuilder(
    builder: (context, box) {
      _narrow = box.maxWidth < widget.narrow;
      final t = AshlarTheme.of(context);
      final c = t.colors;
      final collapsed = !open && widget.collapse == SidebarCollapse.icons;
      final floating = widget.variant != SidebarVariant.sidebar;
      // Floating and inset sidebars sit 8 in from the edge; their icon strip is wider by
      // the same.
      final strip = floating ? 64.0 : 48.0;
      final width = _narrow
          ? 0.0
          : (open ? widget.width : (collapsed ? strip : 0.0));
      final panel = AnimatedContainer(
        duration: t.duration * 1.5,
        curve: Curves.easeOutCubic,
        width: width,
        child: ClipRect(
          child: OverflowBox(
            alignment: widget.side == SidebarSide.start
                ? AlignmentDirectional.centerStart
                : AlignmentDirectional.centerEnd,
            minWidth: collapsed ? strip : widget.width,
            maxWidth: collapsed ? strip : widget.width,
            // Out of sight is out of the Tab order and of what screen readers read.
            child: width == 0
                ? const SizedBox()
                : _Panel(layout: widget, collapsed: collapsed),
          ),
        ),
      );
      final page = widget.variant == SidebarVariant.inset && !_narrow
          ? Container(
              margin: EdgeInsetsDirectional.only(
                top: 8,
                bottom: 8,
                start: widget.side == SidebarSide.start ? 0 : 8,
                end: widget.side == SidebarSide.end ? 0 : 8,
              ),
              clipBehavior: Clip.antiAlias,
              decoration: BoxDecoration(
                color: c.background,
                borderRadius: BorderRadius.circular(t.radiusXl),
                boxShadow: t.shadowSm,
              ),
              child: widget.child,
            )
          : widget.child;
      final fixed = widget.collapse == SidebarCollapse.none;
      final rail = widget.rail && !_narrow && !fixed
          ? _Rail(onPressed: toggle, start: widget.side == SidebarSide.start)
          : null;
      final row = [
        Stack(clipBehavior: Clip.none, children: [panel, ?rail]),
        Expanded(child: page),
      ];
      return CallbackShortcuts(
        bindings: {
          if (!fixed) ...{
            const SingleActivator(LogicalKeyboardKey.keyB, meta: true): toggle,
            const SingleActivator(LogicalKeyboardKey.keyB, control: true):
                toggle,
          },
        },
        child: ColoredBox(
          color: widget.variant == SidebarVariant.inset
              ? c.sidebar
              : c.sidebar.withValues(alpha: 0),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: widget.side == SidebarSide.start
                ? row
                : row.reversed.toList(),
          ),
        ),
      );
    },
  );
}

/// The strip along the inner edge: a line under the pointer, and a click toggles.
class _Rail extends StatefulWidget {
  const _Rail({required this.onPressed, required this.start});

  final VoidCallback onPressed;
  final bool start;

  @override
  State<_Rail> createState() => _RailState();
}

class _RailState extends State<_Rail> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final c = AshlarTheme.of(context).colors;
    return PositionedDirectional(
      top: 0,
      bottom: 0,
      end: widget.start ? -8 : null,
      start: widget.start ? null : -8,
      width: 16,
      child: Semantics(
        container: true,
        button: true,
        label: 'Toggle sidebar',
        onTap: widget.onPressed,
        child: MouseRegion(
          cursor: SystemMouseCursors.resizeColumn,
          onEnter: (_) => setState(() => _hovered = true),
          onExit: (_) => setState(() => _hovered = false),
          child: GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: widget.onPressed,
            child: Center(
              child: Container(
                width: 2,
                color: _hovered
                    ? c.sidebarBorder
                    : c.sidebarBorder.withValues(alpha: 0),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Panel extends StatelessWidget {
  const _Panel({required this.layout, required this.collapsed, this.onPicked});

  final SidebarLayout layout;
  final bool collapsed;

  /// On a narrow screen the panel closes once something is picked.
  final VoidCallback? onPicked;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final start = layout.side == SidebarSide.start;
    final floating =
        layout.variant == SidebarVariant.floating && onPicked == null;
    final inset = layout.variant == SidebarVariant.inset && onPicked == null;
    final line = BorderSide(color: c.sidebarBorder);
    return Semantics(
      container: true,
      label: 'Sidebar',
      explicitChildNodes: true,
      child: Container(
        margin: floating ? const EdgeInsets.all(8) : EdgeInsets.zero,
        decoration: BoxDecoration(
          color: c.sidebar,
          borderRadius: floating ? BorderRadius.circular(t.radiusLg) : null,
          border: floating
              ? Border.all(color: c.sidebarBorder)
              : (inset || onPicked != null
                    ? null
                    : BorderDirectional(
                        end: start ? line : BorderSide.none,
                        start: start ? BorderSide.none : line,
                      )),
          boxShadow: floating ? t.shadowSm : null,
        ),
        child: SafeArea(
          child: DefaultTextStyle.merge(
            style: t.text.sm.copyWith(color: c.sidebarForeground),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                if (layout.header != null && !collapsed)
                  Padding(
                    padding: const EdgeInsets.all(8),
                    child: layout.header,
                  ),
                Expanded(
                  child: SingleChildScrollView(
                    padding: const EdgeInsets.all(8),
                    child: _groups(
                      layout.groups.where((g) => !g.atEnd),
                      collapsed,
                    ),
                  ),
                ),
                if (layout.groups.any((g) => g.atEnd))
                  Padding(
                    padding: const EdgeInsets.all(8),
                    child: _groups(
                      layout.groups.where((g) => g.atEnd),
                      collapsed,
                    ),
                  ),
                if (layout.footer != null && !collapsed)
                  Padding(
                    padding: const EdgeInsets.all(8),
                    child: layout.footer,
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

extension on _Panel {
  Widget _groups(Iterable<SidebarGroup> groups, bool collapsed) => Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      for (final (gi, g) in groups.indexed)
        if (g.child == null || !collapsed) ...[
          if (gi > 0) const SizedBox(height: 16),
          if (g.label != null && !collapsed) _GroupLabel(group: g),
          if (g.child case final child?)
            child
          else if (g.loading > 0)
            for (var i = 0; i < g.loading; i++)
              _SkeletonRow(index: i, collapsed: collapsed)
          else
            for (final item in g.items)
              _Item(item: item, collapsed: collapsed, onPicked: onPicked),
        ],
    ],
  );
}

class _GroupLabel extends StatelessWidget {
  const _GroupLabel({required this.group});

  final SidebarGroup group;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    // A height at least: larger text makes it taller rather than being cut off.
    return ConstrainedBox(
      constraints: const BoxConstraints(minHeight: 32),
      child: Padding(
        padding: const EdgeInsetsDirectional.only(start: 8),
        child: Row(
          children: [
            Expanded(
              child: Semantics(
                header: true,
                child: Text(
                  group.label!,
                  style: t.text.xs.copyWith(
                    color: c.sidebarForeground.withValues(alpha: .7),
                    fontWeight: FontWeight.w500,
                  ),
                ),
              ),
            ),
            if (group.action case final a?) _ActionButton(action: a),
          ],
        ),
      ),
    );
  }
}

/// A grey row where an item will be, while the group loads.
class _SkeletonRow extends StatelessWidget {
  const _SkeletonRow({required this.index, required this.collapsed});

  final int index;
  final bool collapsed;

  @override
  Widget build(BuildContext context) => SizedBox(
    // As tall as it is drawn, or taller when larger text needs it.
    height: grownHeight(context, 32),
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      child: Row(
        children: [
          const Skeleton(width: 16, height: 16),
          if (!collapsed) ...[
            const SizedBox(width: 8),
            // Varied lengths, so it reads as a list of names.
            Skeleton(width: 80.0 + (index * 37) % 70, height: 14),
          ],
        ],
      ),
    ),
  );
}

class _Item extends StatefulWidget {
  const _Item({
    required this.item,
    required this.collapsed,
    this.onPicked,
    this.sub = false,
  });

  final SidebarItem item;
  final bool collapsed, sub;
  final VoidCallback? onPicked;

  @override
  State<_Item> createState() => _ItemState();
}

class _ItemState extends State<_Item> {
  late bool _open =
      widget.item.initiallyOpen || widget.item.children.any((c) => c.active);
  bool _hovered = false, _focused = false;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final item = widget.item;
    final parent = item.children.isNotEmpty;
    final row = Pressable(
      selected: item.active,
      expanded: parent ? _open : null,
      semanticLabel: widget.collapsed ? item.label : null,
      onPressed: () {
        if (parent) {
          // Collapsed, a parent opens the sidebar to show what is under it.
          if (widget.collapsed) return SidebarLayout.of(context).toggle();
          setState(() => _open = !_open);
          return;
        }
        widget.onPicked?.call();
        item.onPressed?.call();
      },
      builder: (context, state) {
        final lit = item.active || state.hovered;
        final fg = lit ? c.sidebarAccentForeground : c.sidebarForeground;
        return AnimatedContainer(
          duration: t.duration,
          // As tall as it is drawn, or taller when larger text needs it.
          height: grownHeight(context, widget.sub ? 28 : 32),
          margin: const EdgeInsets.only(bottom: 2),
          padding: EdgeInsets.symmetric(horizontal: widget.collapsed ? 0 : 8),
          alignment: widget.collapsed
              ? Alignment.center
              : AlignmentDirectional.centerStart,
          decoration: BoxDecoration(
            color: lit ? c.sidebarAccent : c.sidebarAccent.withValues(alpha: 0),
            borderRadius: BorderRadius.circular(t.radiusMd),
            boxShadow: [
              if (state.focused)
                BoxShadow(color: c.sidebarRing, spreadRadius: 2),
            ],
          ),
          child: widget.collapsed
              ? AshlarIcon(
                  item.icon ?? AshlarIconsRoundedStroke.dot,
                  size: 16,
                  color: fg,
                )
              : Row(
                  children: [
                    if (item.icon != null) ...[
                      AshlarIcon(item.icon!, size: 16, color: fg),
                      const SizedBox(width: 8),
                    ],
                    Expanded(
                      child: Text(
                        item.label,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                        style:
                            (widget.sub
                                    ? t.text.xs.copyWith(fontSize: 13)
                                    : t.text.sm)
                                .copyWith(
                                  color: fg,
                                  fontWeight: item.active
                                      ? FontWeight.w500
                                      : null,
                                ),
                      ),
                    ),
                    if (item.badge != null &&
                        !(item.action != null && (_hovered || state.focused)))
                      Text(
                        item.badge!,
                        style: t.text.xs.copyWith(
                          color: fg,
                          fontWeight: FontWeight.w500,
                          fontFeatures: const [FontFeature.tabularFigures()],
                        ),
                      ),
                    if (parent)
                      AnimatedRotation(
                        turns: _open ? .25 : 0,
                        duration: t.duration,
                        child: AshlarIcon(
                          isRtl(context)
                              ? AshlarIconsRoundedStroke.chevronLeft
                              : AshlarIconsRoundedStroke.chevronRight,
                          size: 16,
                          color: fg,
                        ),
                      ),
                  ],
                ),
        );
      },
    );
    final withAction = item.action == null || widget.collapsed
        ? row
        : MouseRegion(
            onEnter: (_) => setState(() => _hovered = true),
            onExit: (_) => setState(() => _hovered = false),
            child: Stack(
              alignment: AlignmentDirectional.centerEnd,
              children: [
                row,
                // Shown under the pointer, and always reachable from the keyboard.
                Padding(
                  padding: const EdgeInsetsDirectional.only(end: 4, bottom: 2),
                  child: Focus(
                    canRequestFocus: false,
                    skipTraversal: true,
                    onFocusChange: (f) => setState(() => _focused = f),
                    child: Opacity(
                      opacity: _hovered || _focused ? 1 : 0,
                      // Unseen until hovered or focused, but always there for screen
                      // readers and Tab.
                      alwaysIncludeSemantics: true,
                      child: _ActionButton(action: item.action!),
                    ),
                  ),
                ),
              ],
            ),
          );
    final shown = widget.collapsed
        ? Tooltip(
            message: item.label,
            side: TooltipSide.right,
            child: withAction,
          )
        : withAction;
    if (!parent || widget.collapsed) return shown;
    final c2 = c;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        shown,
        AnimatedSize(
          duration: t.duration,
          alignment: Alignment.topCenter,
          child: _open
              ? Container(
                  margin: const EdgeInsetsDirectional.only(
                    start: 15,
                    bottom: 4,
                  ),
                  padding: const EdgeInsetsDirectional.only(start: 8),
                  decoration: BoxDecoration(
                    border: BorderDirectional(
                      start: BorderSide(color: c2.sidebarBorder),
                    ),
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      for (final child in item.children)
                        _Item(
                          item: child,
                          collapsed: false,
                          onPicked: widget.onPicked,
                          sub: true,
                        ),
                    ],
                  ),
                )
              : const SizedBox(width: double.infinity),
        ),
      ],
    );
  }
}

class _ActionButton extends StatelessWidget {
  const _ActionButton({required this.action});

  final SidebarAction action;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Tooltip(
      message: action.label,
      child: Pressable(
        onPressed: action.onPressed,
        semanticLabel: action.label,
        builder: (context, state) => Container(
          width: 20,
          height: 20,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: state.hovered
                ? c.sidebarAccent
                : c.sidebarAccent.withValues(alpha: 0),
            borderRadius: BorderRadius.circular(t.radiusSm),
            boxShadow: [
              if (state.focused)
                BoxShadow(color: c.sidebarRing, spreadRadius: 2),
            ],
          ),
          child: AshlarIcon(action.icon, size: 14, color: c.sidebarForeground),
        ),
      ),
    );
  }
}

/// The button that opens and closes the [SidebarLayout] above it.
class SidebarTrigger extends StatelessWidget {
  const SidebarTrigger({super.key});

  @override
  Widget build(BuildContext context) {
    // The panel icon points to the side the sidebar is on.
    final end = SidebarLayout.of(context).widget.side == SidebarSide.end;
    return Button(
      variant: ButtonVariant.ghost,
      size: ButtonSize.icon,
      semanticLabel: 'Toggle sidebar',
      onPressed: () => SidebarLayout.of(context).toggle(),
      child: AshlarIcon(
        end != isRtl(context)
            ? AshlarIconsRoundedStroke.panelRight
            : AshlarIconsRoundedStroke.panelLeft,
      ),
    );
  }
}