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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ These encode hard-won decisions. Violating any of them is a review failure.
- **`flutterwindcss` (engine) public types are prefixed `Fw`** (`FwStyle`, `FwColors`, `FwTokens`, `FwBreakpoint`), and so are the routing/structure **library** base types that ship as engine-style code (`FwRoute`, `FwRoutePattern`, `FwPresentation`, `FwStatusBar`). **flutterbits *components* are UNprefixed** — `Button`, `Card`, `Screen`, `Layout` — because they are copy-paste source the developer owns (shadcn-style) and need no namespace. (Material name clashes, only possible in the rare Material-interop case, are resolved by namespacing the barrel `import '.../ui/ui.dart' as ui;`, not by prefixing — see the charter §5.) When you see `Fw` inside a component, that is the engine showing through.
- Prefer `const` constructors wherever the analyzer allows; leaf widgets that never change should be `const`.
- Variants are **typed enums + exhaustive `switch`** (the cva equivalent). No stringly-typed variant maps. The `switch` must be exhaustive so the compiler catches a missing case — do not add a `default:` that papers over new enum values.
- Where a shadcn name is a **Dart reserved word**, deviate minimally and document it at the call site: the `default` button **variant** → `primary`, the `default` **size** → `md` (`default` cannot be an enum constant). Mirror every other shadcn name verbatim.
- Every file passes `dart format` (100-col) and `flutter analyze` with **zero** warnings before you call a task done.
- Doc-comment every public member with `///`. Explain *why*, not just *what*, when a choice is non-obvious.
- One component per file in `registry/`. No barrel that re-exports registry components (they are copied individually).
Expand Down
6 changes: 6 additions & 0 deletions apps/gallery/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml

analyzer:
language:
strict-casts: true
strict-raw-types: true
192 changes: 192 additions & 0 deletions apps/gallery/lib/components/ui/button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';

/// shadcn's button variants. `primary` is shadcn's `default` (`default` is a
/// Dart reserved word, so it cannot be an enum constant).
enum ButtonVariant { primary, secondary, destructive, outline, ghost, link }

/// shadcn's button sizes. `md` is shadcn's `default` size (same reserved-word
/// reason as above).
enum ButtonSize { sm, md, lg, icon }

/// A Material-free, themeable button — shadcn parity. Copy-paste source you own.
///
/// Sources its own interaction states (hover/focus/pressed/disabled) via a
/// [FocusableActionDetector] and wires keyboard activation (Enter / Space) to
/// [ActivateIntent] → [onPressed]. Visual styling of all variant/size/state
/// combinations is applied through a single `.tw` chain in [_ButtonState._styled].
class Button extends StatefulWidget {
const Button({
super.key,
required this.child,
this.onPressed,
this.variant = ButtonVariant.primary,
this.size = ButtonSize.md,
this.semanticLabel,
this.focusNode,
});

/// The button's content (a `Text`, an icon widget, or a row of both).
final Widget child;

/// Tapped/activated callback. `null` disables the button.
final VoidCallback? onPressed;

final ButtonVariant variant;
final ButtonSize size;

/// Optional accessibility label (defaults to the child's own semantics).
final String? semanticLabel;

/// Optional external [FocusNode]. When provided, the caller controls focus.
/// Useful in tests and for programmatic focus management.
final FocusNode? focusNode;

/// Whether the button is interactive.
bool get enabled => onPressed != null;

@override
State<Button> createState() => _ButtonState();
}

class _ButtonState extends State<Button> {
// Interaction-state booleans — set by FocusableActionDetector callbacks and
// GestureDetector tap events. Read by [_styled] to drive visual styling.
bool _hovered = false;
bool _focused = false;
bool _pressed = false;

/// The single styled box: resolves (variant, size, states) → one `.tw` chain.
Widget _styled(BuildContext context) {
final c = context.fw.colors;
final enabled = widget.enabled;

// Base treatment per variant (transparent fill uses the one allowed literal,
// Color(0x00000000), per AGENTS.md §3.1).
const transparent = Color(0x00000000);
late Color baseBg;
late Color baseFg;
Color? borderColor;
final isLink = widget.variant == ButtonVariant.link;
switch (widget.variant) {
case ButtonVariant.primary:
baseBg = c.primary;
baseFg = c.primaryForeground;
case ButtonVariant.secondary:
baseBg = c.secondary;
baseFg = c.secondaryForeground;
case ButtonVariant.destructive:
baseBg = c.destructive;
baseFg = c.destructiveForeground;
case ButtonVariant.outline:
baseBg = transparent;
baseFg = c.foreground;
borderColor = c.border;
case ButtonVariant.ghost:
baseBg = transparent;
baseFg = c.foreground;
case ButtonVariant.link:
baseBg = transparent;
baseFg = c.primary;
}

// Hover/press treatment (shadcn: filled → /90 (secondary /80); outline/ghost
// → accent; link → underline).
// Note: the `/90` alpha dimming is faithful to shadcn's modifier but is
// translucent — place the button on a `background`-coloured surface, or the
// surface beneath (card, popover, etc.) will bleed through on hover.
var bg = baseBg;
var fg = baseFg;
final interacting = enabled && (_hovered || _pressed);
if (interacting) {
switch (widget.variant) {
case ButtonVariant.primary:
case ButtonVariant.destructive:
bg = baseBg.withValues(alpha: 0.9);
case ButtonVariant.secondary:
bg = baseBg.withValues(alpha: 0.8);
case ButtonVariant.outline:
case ButtonVariant.ghost:
bg = c.accent;
fg = c.accentForeground;
case ButtonVariant.link:
break; // underline handled below
}
}
final underlineNow = isLink && enabled && (_hovered || _focused);

// Content: shrink-wrap width, center vertically within the fixed height.
// widthFactor: null (icon) = fill available width; 1.0 = shrink-wrap to child.
final inner = Center(
widthFactor: widget.size == ButtonSize.icon ? null : 1.0,
child: widget.child,
);

var box =
inner.tw.bg(bg).text(fg).textSize(FwFontSize.sm.px).weight(FwFontWeight.medium).roundedMd;

box = switch (widget.size) {
ButtonSize.sm => box.h(9).px(3),
ButtonSize.md => box.h(10).px(4),
ButtonSize.lg => box.h(11).px(8),
ButtonSize.icon => box.size(10),
};

if (borderColor != null) box = box.border(1, color: borderColor);
if (underlineNow) box = box.underline;
// `_focused` comes from FocusableActionDetector.onShowFocusHighlight, which
// internally gates on _canShowHighlight (false for FocusHighlightMode.touch,
// true for traditional/keyboard). This is already focus-visible semantics —
// no additional highlightMode check is needed or correct here.
if (_focused && enabled) {
box = box.ring(2, color: c.ring, offset: 2, offsetColor: c.background);
}
if (!enabled) box = box.opacity(0.5);

return box;
}

void _set(VoidCallback f) {
if (mounted) setState(f);
}

@override
Widget build(BuildContext context) {
final enabled = widget.enabled;
return Semantics(
button: true,
enabled: enabled,
label: widget.semanticLabel,
child: FocusableActionDetector(
enabled: enabled,
focusNode: widget.focusNode,
mouseCursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onShowHoverHighlight: (h) => _set(() => _hovered = h),
onShowFocusHighlight: (f) => _set(() => _focused = f),
// Map Enter + Space to ActivateIntent so keyboard users can activate
// the button just like they can in every browser-native button.
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
widget.onPressed?.call();
return null;
},
),
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onPressed,
onTapDown: enabled ? (_) => _set(() => _pressed = true) : null,
onTapUp: enabled ? (_) => _set(() => _pressed = false) : null,
onTapCancel: enabled ? () => _set(() => _pressed = false) : null,
child: _styled(context),
),
),
);
}
}
64 changes: 64 additions & 0 deletions apps/gallery/lib/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';
import 'components/ui/button.dart';

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

/// TEMPORARY bootstrap root for the gallery. The structure plan REPLACES this
/// raw `WidgetsApp` with the flutterbits `Layout` (the gallery's intended root,
/// which it then demos). Do not entrench this — it is throwaway dev scaffolding.
class GalleryApp extends StatelessWidget {
const GalleryApp({super.key});

@override
Widget build(BuildContext context) {
return FwTheme(
tokens: FwTokens.light,
child: WidgetsApp(
title: 'flutterbits gallery',
color: const Color(0xFF2563EB),
debugShowCheckedModeBanner: false,
pageRouteBuilder: <T extends Object?>(RouteSettings settings, WidgetBuilder builder) {
return PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, _, _) => builder(context),
);
},
home: Builder(
builder:
(context) => ColoredBox(
color: context.fw.colors.background,
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final v in ButtonVariant.values)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final s in ButtonSize.values)
Button(
variant: v,
size: s,
onPressed: () {},
child: s == ButtonSize.icon ? const Text('+') : Text(v.name),
),
],
),
),
],
),
),
),
),
),
),
);
}
}
30 changes: 30 additions & 0 deletions apps/gallery/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: flutterbits_gallery
description: >-
flutterbits COMPONENT gallery — a Material-free Flutter app showcasing the
copy-paste components, and the golden-test + compile target for the registry.
publish_to: none
version: 1.0.0+1

# Joins the repo's pub workspace (root pubspec.yaml `workspace:`).
resolution: workspace

environment:
# Match the toolchain floor (AGENTS.md §2): Flutter 3.29 / Dart 3.7.
sdk: '>=3.7.0 <4.0.0'
flutter: '>=3.29.0'

dependencies:
flutter:
sdk: flutter
# The styling engine every component styles through.
flutterwindcss:
path: ../../packages/flutterwindcss

dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0

flutter:
# Material-free: components run on the pure path (WidgetsApp + FwTheme).
uses-material-design: false
80 changes: 80 additions & 0 deletions apps/gallery/test/button_behavior_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:ui' show Tristate;

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutterwindcss/flutterwindcss.dart';
import 'package:flutterbits_gallery/components/ui/button.dart';

Widget _host(Widget child) => FwTheme(
tokens: FwTokens.light,
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(data: const MediaQueryData(), child: Center(child: child)),
),
);

void main() {
testWidgets('renders its label and reports button semantics', (t) async {
await t.pumpWidget(_host(Button(onPressed: () {}, child: const Text('Save'))));
expect(find.text('Save'), findsOneWidget);

final semanticsNode = t.getSemantics(find.text('Save'));
final flags = semanticsNode.getSemanticsData().flagsCollection;
expect(flags.isButton, isTrue);
expect(flags.isEnabled, Tristate.isTrue);
});

testWidgets('fires onPressed when tapped', (t) async {
var taps = 0;
await t.pumpWidget(_host(Button(onPressed: () => taps++, child: const Text('Go'))));
await t.tap(find.text('Go'));
expect(taps, 1);
});

testWidgets('disabled (onPressed null) does not fire and reports disabled', (t) async {
await t.pumpWidget(_host(const Button(onPressed: null, child: Text('Nope'))));
await t.tap(find.text('Nope'), warnIfMissed: false);
final flags = t.getSemantics(find.text('Nope')).getSemanticsData().flagsCollection;
expect(flags.isEnabled, Tristate.isFalse);
});

// The Button must handle ActivateIntent (mapped to Enter + Space) when it
// owns focus. We give the Button its own FocusNode, request focus directly,
// then send Enter — this is the most reliable harness sequence because widget
// tests don't always simulate a full tab-traversal chain consistently.
testWidgets('activates via keyboard (Enter) when focused', (t) async {
var taps = 0;
final focus = FocusNode();
addTearDown(focus.dispose);
await t.pumpWidget(
_host(Button(focusNode: focus, onPressed: () => taps++, child: const Text('K'))),
);
focus.requestFocus();
await t.pump();
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pump();
expect(taps, 1, reason: 'Enter while focused should fire onPressed exactly once');
});

testWidgets('activates via keyboard (Space) when focused', (t) async {
var taps = 0;
final focus = FocusNode();
addTearDown(focus.dispose);
await t.pumpWidget(
_host(Button(focusNode: focus, onPressed: () => taps++, child: const Text('K2'))),
);
focus.requestFocus();
await t.pump();
await t.sendKeyEvent(LogicalKeyboardKey.space);
await t.pump();
expect(taps, 1, reason: 'Space while focused should fire onPressed exactly once');
});

testWidgets('renders a single styled box (FwStyled) per variant', (t) async {
for (final v in ButtonVariant.values) {
await t.pumpWidget(_host(Button(variant: v, onPressed: () {}, child: const Text('x'))));
expect(find.byType(FwStyled), findsOneWidget, reason: 'variant $v');
}
});
}
Loading
Loading