Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ pubspec.lock
node_modules/
.next/

# Golden-test failure artifacts (auto-written by `flutter test` on a golden diff)
test/**/failures/
# Golden-test failure artifacts (auto-written by `flutter test` on a golden diff).
# Depth-agnostic so it covers every package/app test tree (apps/gallery,
# apps/example, packages/flutterwindcss), not just a root-level `test/`.
**/test/**/failures/

# Local Claude Code settings (per-machine)
.claude/settings.local.json
132 changes: 132 additions & 0 deletions apps/gallery/lib/components/ui/badge.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';

/// shadcn's badge variants. `primary` is shadcn's `default` (`default` is a Dart
/// reserved word, so it cannot be an enum constant). shadcn's Badge has a single
/// size, so there is no size enum.
enum BadgeVariant { primary, secondary, destructive, outline }

/// A Material-free, themeable badge — shadcn parity. Copy-paste source you own.
///
/// **Intention-revealing:** `Badge(child: Text('New'))` reads as the artifact. A
/// badge is a tiny pill label whose fill/foreground/border come entirely from
/// `flutterwindcss` semantic tokens (`context.fw.colors.*`), so any pasted theme
/// reskins it. Layout is faithful to shadcn v4 `rounded-full border px-2 py-0.5
/// text-xs font-medium gap-1`.
///
/// **Variant pattern (mirrors [Button], without the state machine).** The variant
/// branches the *palette* in Dart via an exhaustive `switch` over [BadgeVariant]
/// (the cva equivalent), then a single flat `.tw` chain renders one styled box.
/// Because a Badge owns no action, it is a plain [StatelessWidget] — no
/// hover/focus/pressed, no `FocusableActionDetector`.
///
/// **Non-interactive** (shadcn's default badge is a `<span>`; the `[a&]:hover:`
/// states apply only to a link badge). For a clickable badge, wrap it (e.g. in a
/// `GestureDetector`/`Button`); clickability is intentionally not baked in here.
///
/// **Sizes to its content** (`w-fit`): a bare `Text` child shrink-wraps; with a
/// [leading]/[trailing] icon the row is `MainAxisSize.min`. In a *stretch* parent
/// (e.g. a `CrossAxisAlignment.stretch` column) the box would stretch — place a
/// Badge inline (`FwRow`/`Wrap`) or wrap it in an `Align`.
///
/// **Faithful-token deviations (engine, not bugs):** shadcn v4's destructive badge
/// uses a literal `text-white`; this uses the `destructiveForeground` token
/// (≈ white in the stock theme, and it reskins) per AGENTS.md §3.1, matching
/// [Button]. shadcn dims dark-mode destructive to `bg-destructive/60`; this uses
/// the flat `destructive` token in both brightnesses, also matching [Button].
class Badge extends StatelessWidget {
const Badge({
super.key,
required this.child,
this.variant = BadgeVariant.primary,
this.leading,
this.trailing,
this.semanticLabel,
});

/// The badge's label (typically a `Text`). For an icon-only badge, pass the
/// icon as [child] **and** a [semanticLabel].
final Widget child;

/// The badge's visual style (fill, foreground, border). Defaults to
/// [BadgeVariant.primary] (shadcn's `default`).
final BadgeVariant variant;

/// Optional icon before the label (composed with a `gap-1`). Icons render at
/// 12px (shadcn `[&>svg]:size-3`).
final Widget? leading;

/// Optional icon after the label. See [leading].
final Widget? trailing;

/// Screen-reader label. When provided it **replaces** the child's semantics
/// (like `aria-label`); omit it to announce the child's own text. **Required in
/// practice for icon-only badges**, whose child carries no text.
final String? semanticLabel;

/// Resting treatment per variant. Transparent fill/border uses the one allowed
/// color literal (`Color(0x00000000)`, AGENTS.md §3.1). The filled variants
/// (primary/secondary/destructive) keep an **explicit transparent border** —
/// not `null` — so every variant occupies identical geometry (the 1px border
/// slot is always present, just invisible unless `outline` colors it). This is
/// shadcn's `border border-transparent` → `border-border`.
({Color bg, Color fg, Color border}) _palette(FwColors c) {
const transparent = Color(0x00000000);
return switch (variant) {
BadgeVariant.primary => (bg: c.primary, fg: c.primaryForeground, border: transparent),
BadgeVariant.secondary => (bg: c.secondary, fg: c.secondaryForeground, border: transparent),
BadgeVariant.destructive => (
bg: c.destructive,
fg: c.destructiveForeground,
border: transparent,
),
BadgeVariant.outline => (bg: transparent, fg: c.foreground, border: c.border),
};
}

@override
Widget build(BuildContext context) {
final p = _palette(context.fw.colors);

// Compose the content: icon+label row (shadcn `gap-1`) when a leading/trailing
// slot is present; otherwise the bare label. Icons render at 12px (shadcn
// `[&>svg]:size-3`); their color flows from `.text(fg)` via the engine's
// IconTheme.
Widget content = child;
if (leading != null || trailing != null) {
content = FwRow(
gap: 1,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[if (leading != null) leading!, child, if (trailing != null) trailing!],
);
}
// Pin icons to 12px (shadcn `[&>svg]:size-3`) *independently of the text
// size* — faithful to shadcn, where icon size is decoupled from `text-xs`.
// (Color is intentionally not set here so it inherits the `.text(fg)` color
// the engine threads through its own `IconTheme`.)
content = IconTheme.merge(data: const IconThemeData(size: 12), child: content);

Widget badge =
content.tw
.bg(p.bg)
.text(p.fg)
.textSize(FwFontSize.xs.px)
.weight(FwFontWeight.medium)
.px(2)
.py(0.5)
.border(1, color: p.border)
.roundedFull
.nowrap
.clip();

if (semanticLabel != null) {
// `excludeSemantics: true` makes the label genuinely *replace* the child's
// semantics (like `aria-label`); without it the child's own text leaks and
// a screen reader announces both (verified). Icon-only badges therefore get
// exactly the supplied name, and a text badge with an override gets only it.
badge = Semantics(label: semanticLabel, excludeSemantics: true, child: badge);
}
return badge;
}
}
16 changes: 16 additions & 0 deletions apps/gallery/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';
import 'components/ui/button.dart';
import 'components/ui/card.dart';
import 'components/ui/badge.dart';

void main() => runApp(const GalleryApp());

Expand Down Expand Up @@ -112,6 +113,21 @@ class GalleryApp extends StatelessWidget {
],
),
),
const SizedBox(height: 24),
Text('Badges').tw.textSize(20).weight(FwFontWeight.bold),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: const [
Badge(child: Text('Primary')),
Badge(variant: BadgeVariant.secondary, child: Text('Secondary')),
Badge(variant: BadgeVariant.destructive, child: Text('Destructive')),
Badge(variant: BadgeVariant.outline, child: Text('Outline')),
Badge(leading: _Dot(), child: Text('Verified')),
],
),
],
),
),
Expand Down
214 changes: 214 additions & 0 deletions apps/gallery/test/badge_behavior_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutterwindcss/flutterwindcss.dart';
import 'package:flutterbits_gallery/components/ui/badge.dart';

/// Theme frame. Badges are content-sized, so a topStart Align with no width
/// constraint lets the badge shrink-wrap its label (proving `w-fit`).
Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme(
tokens: tokens,
child: Directionality(
textDirection: dir,
child: MediaQuery(
data: const MediaQueryData(),
child: ColoredBox(
color: tokens.colors.background,
child: Align(alignment: AlignmentDirectional.topStart, child: child),
),
),
),
);

void main() {
testWidgets('primary Badge paints the primary token and renders its label', (t) async {
await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Badge(child: Text('New'))));
expect(t.takeException(), isNull);
expect(find.text('New'), findsOneWidget);
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == FwTokens.light.colors.primary,
),
findsOneWidget,
);
});

testWidgets('secondary/destructive Badges paint their fill tokens', (t) async {
for (final (variant, token) in <(BadgeVariant, Color)>[
(BadgeVariant.secondary, FwTokens.light.colors.secondary),
(BadgeVariant.destructive, FwTokens.light.colors.destructive),
]) {
await t.pumpWidget(
_frame(FwTokens.light, TextDirection.ltr, Badge(variant: variant, child: const Text('x'))),
);
expect(t.takeException(), isNull);
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == token,
),
findsOneWidget,
reason: 'variant $variant should fill with its token',
);
}
});

testWidgets('outline Badge has a transparent fill and a border-token border', (t) async {
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.ltr,
const Badge(variant: BadgeVariant.outline, child: Text('Draft')),
),
);
expect(t.takeException(), isNull);
expect(
find.byWidgetPredicate((w) {
if (w is! DecoratedBox) return false;
final d = w.decoration;
if (d is! BoxDecoration) return false;
final b = d.border;
return d.color == const Color(0x00000000) &&
b is Border &&
b.top.width > 0 &&
b.top.color != const Color(0x00000000) && // a visible border must appear
b.top.color == FwTokens.light.colors.border;
}),
findsOneWidget,
);
});

testWidgets('foreground uses the variant token (destructive → destructiveForeground)', (t) async {
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.ltr,
const Badge(variant: BadgeVariant.destructive, child: Text('x')),
),
);
expect(t.takeException(), isNull);
// The `.text(fg)` engine setter resolves to a DefaultTextStyle carrying the
// variant's foreground token — proving destructive maps to the
// `destructiveForeground` token (NOT a literal `text-white`).
expect(
find.byWidgetPredicate(
(w) =>
w is DefaultTextStyle && w.style.color == FwTokens.light.colors.destructiveForeground,
),
findsWidgets,
);
});

testWidgets('leading/trailing slots compose a gap-1 row around the label', (t) async {
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.ltr,
const Badge(
leading: SizedBox(width: 12, height: 12, key: ValueKey('lead')),
trailing: SizedBox(width: 12, height: 12, key: ValueKey('trail')),
child: Text('Verified'),
),
),
);
expect(t.takeException(), isNull);
expect(find.text('Verified'), findsOneWidget);
expect(find.byKey(const ValueKey('lead')), findsOneWidget);
expect(find.byKey(const ValueKey('trail')), findsOneWidget);
// FwRow lowers to a horizontal Flex (not Row); gap:1 → spacing = fwSpace(1) =
// 4.0 px. Pin direction + mainAxisSize so the predicate matches exactly the
// `FwRow(gap:1, mainAxisSize:.min)` the badge builds, not an incidental Flex.
expect(
find.byWidgetPredicate(
(w) =>
w is Flex &&
w.direction == Axis.horizontal &&
w.mainAxisSize == MainAxisSize.min &&
w.spacing == 4.0,
),
findsOneWidget,
reason: 'gap-1 => spacing 4 on the icon+label Flex',
);
});

testWidgets('semanticLabel replaces the child semantics (icon-only badge)', (t) async {
final handle = t.ensureSemantics();
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.ltr,
const Badge(semanticLabel: 'online', child: SizedBox(width: 12, height: 12)),
),
);
expect(t.takeException(), isNull);
expect(find.bySemanticsLabel('online'), findsOneWidget);
handle.dispose();
});

testWidgets('semanticLabel replaces a TEXT child label (no double-announce)', (t) async {
final handle = t.ensureSemantics();
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.ltr,
const Badge(semanticLabel: 'override', child: Text('hidden-child')),
),
);
expect(t.takeException(), isNull);
// The supplied label is the node's exact accessible name…
expect(find.bySemanticsLabel('override'), findsOneWidget);
// …and the child's own text does NOT leak into the semantics (excludeSemantics).
expect(find.bySemanticsLabel(RegExp('hidden-child')), findsNothing);
handle.dispose();
});

testWidgets('renders under RTL with no overflow/exception', (t) async {
await t.binding.setSurfaceSize(const Size(400, 200));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(
_frame(
FwTokens.light,
TextDirection.rtl,
const Badge(
leading: SizedBox(width: 12, height: 12),
variant: BadgeVariant.outline,
child: Text('مسودة'),
),
),
);
expect(t.takeException(), isNull);
expect(find.text('مسودة'), findsOneWidget);
});

testWidgets('reskins with the active theme (dark primary token)', (t) async {
await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Badge(child: Text('x'))));
expect(t.takeException(), isNull);
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == FwTokens.dark.colors.primary,
),
findsOneWidget,
);
// The light-token-absent half is only meaningful when the two themes differ
// (they do in the stock theme, so this runs); the guard avoids a vacuous
// assertion if a custom theme were ever wired with equal light/dark primaries.
if (FwTokens.light.colors.primary != FwTokens.dark.colors.primary) {
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == FwTokens.light.colors.primary,
),
findsNothing,
);
}
});
}
Loading
Loading