Breadcrumb
Where a page sits, as links to the pages above it, folding long trails.
import 'package:flutter/widgets.dart';
import 'ui/breadcrumb.dart';
class BreadcrumbDemo extends StatelessWidget {
const BreadcrumbDemo({super.key});
@override
Widget build(BuildContext context) => Breadcrumb(
items: [
BreadcrumbItem('Home', onPressed: () {}),
BreadcrumbItem('Components', onPressed: () {}),
const BreadcrumbItem('Breadcrumb'),
],
);
}
Add it
In your Flutter project:
ashlar add breadcrumb
It writes theme.dart, palette.dart, group.dart, pressable.dart, popover.dart, dropdown_menu.dart and breadcrumb.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, dropdown-menu and ashlar_icons, which come with it.
Examples
Other ways to use it, each running live, with its code.
Folded in the middle
A long path, the middle in a menu.
// Folded in the middle: A long path, the middle in a menu.
import 'package:flutter/widgets.dart';
import 'ui/breadcrumb.dart';
class BreadcrumbCollapsedExample extends StatelessWidget {
const BreadcrumbCollapsedExample({super.key});
@override
Widget build(BuildContext context) => Breadcrumb(
maxItems: 3,
items: [
for (final name in ['Home', 'Projects', 'Ashlar', 'Components', 'Breadcrumb'])
BreadcrumbItem(name, onPressed: name == 'Breadcrumb' ? null : () {}),
],
);
}
Own separator
A slash between pages.
// Own separator: A slash between pages.
import 'package:flutter/widgets.dart';
import 'ui/breadcrumb.dart';
class BreadcrumbSeparatorExample extends StatelessWidget {
const BreadcrumbSeparatorExample({super.key});
@override
Widget build(BuildContext context) => Breadcrumb(
separator: const Text('/'),
items: [
BreadcrumbItem('Docs', onPressed: () {}),
BreadcrumbItem('Components', onPressed: () {}),
const BreadcrumbItem('Breadcrumb'),
],
);
}
API
Read from the source above: each parameter with its type, its default, and what it is for.
BreadcrumbItemclass
One step on the way to where you are. The last is the page itself and is not a link.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
labelpositional |
String |
The page's name. | |
onPressed |
VoidCallback? |
Goes to the page. Null for the page you are on. |
Breadcrumbclass
Where a page sits: the pages above it, each a link, then the page itself. Past maxItems the middle steps fold into an ellipsis that opens them as a menu.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
itemsrequired |
List<BreadcrumbItem> |
The pages, from the top down to this one. | |
separator |
Widget? |
Between steps; a chevron unless given: a slash, a dot. | |
maxItems |
int? |
How many steps show before the middle ones fold away: the first, and the last ones. |
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/breadcrumb.dart 157 lines
// Ashlar UI: Breadcrumb. Copied into your app by `ashlar add breadcrumb`; it is yours to
// change.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';
import 'dropdown_menu.dart';
import 'pressable.dart';
import 'theme.dart';
/// One step on the way to where you are. The last is the page itself and is not a link.
@immutable
class BreadcrumbItem {
const BreadcrumbItem(this.label, {this.onPressed});
/// The page's name.
final String label;
/// Goes to the page. Null for the page you are on.
final VoidCallback? onPressed;
}
/// Where a page sits: the pages above it, each a link, then the page itself. Past
/// [maxItems] the middle steps fold into an ellipsis that opens them as a menu.
///
/// ```dart
/// Breadcrumb(maxItems: 3, items: [
/// BreadcrumbItem('Home', onPressed: () => go('/')),
/// BreadcrumbItem('Docs', onPressed: () => go('/docs')),
/// BreadcrumbItem('Components', onPressed: () => go('/docs/components')),
/// BreadcrumbItem('Breadcrumb'),
/// ])
/// ```
class Breadcrumb extends StatelessWidget {
const Breadcrumb({
super.key,
required this.items,
this.separator,
this.maxItems,
});
/// The pages, from the top down to this one.
final List<BreadcrumbItem> items;
/// Between steps; a chevron unless given: a slash, a dot.
final Widget? separator;
/// How many steps show before the middle ones fold away: the first, and the last ones.
final int? maxItems;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final style = t.text.sm;
final fold = maxItems != null && items.length > maxItems! && maxItems! >= 2;
final hidden = fold
? items.sublist(1, items.length - (maxItems! - 1))
: const <BreadcrumbItem>[];
final shown = fold
? [items.first, ...items.sublist(items.length - (maxItems! - 1))]
: items;
Widget step(BreadcrumbItem item, bool last) {
if (last || item.onPressed == null) {
return Semantics(
// The page itself: aria-current="page".
selected: last,
child: Text(
item.label,
style: style.copyWith(
color: last ? c.foreground : c.mutedForeground,
),
),
);
}
return Pressable(
onPressed: item.onPressed,
link: true,
builder: (context, state) => Text(
item.label,
style: style.copyWith(
color: state.hovered || state.focused
? c.foreground
: c.mutedForeground,
decoration: state.focused ? TextDecoration.underline : null,
decorationColor: c.foreground,
),
),
);
}
final between = ExcludeSemantics(
child: IconTheme.merge(
data: IconThemeData(color: c.mutedForeground, size: 14),
child: DefaultTextStyle.merge(
style: style.copyWith(color: c.mutedForeground),
child:
separator ??
AshlarIcon(
isRtl(context)
? AshlarIconsRoundedStroke.chevronLeft
: AshlarIconsRoundedStroke.chevronRight,
),
),
),
);
final parts = <Widget>[];
for (final (i, item) in shown.indexed) {
if (i > 0) parts.add(between);
parts.add(step(item, i == shown.length - 1));
if (fold && i == 0) {
parts
..add(between)
..add(
DropdownMenu(
builder: (context, menu) => Pressable(
onPressed: menu.open,
semanticLabel: 'Show ${hidden.length} more',
builder: (context, state) => Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: state.hovered
? c.accent
: c.accent.withValues(alpha: 0),
borderRadius: BorderRadius.circular(t.radiusSm),
boxShadow: [if (state.focused) ...t.focusRing],
),
child: AshlarIcon(
AshlarIconsRoundedStroke.ellipsis,
size: 16,
color: c.mutedForeground,
),
),
),
items: [
for (final h in hidden)
MenuItem(onPressed: h.onPressed, child: Text(h.label)),
],
),
);
}
}
return Semantics(
container: true,
label: 'Breadcrumb',
explicitChildNodes: true,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: parts,
),
);
}
}