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
247 changes: 247 additions & 0 deletions apps/gallery/lib/components/ui/input.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';

/// A Material-free, themeable single-line text field — shadcn parity. Copy-paste
/// source you own.
///
/// **Intention-revealing:** `Input(controller: c, placeholder: 'Email')` reads as
/// the artifact. It is a styled [EditableText] (the `widgets.dart` text-editing
/// primitive — `TextField` is Material and off-limits) wrapped in a single `.tw`
/// box. Fill/border/ring/text are all `flutterwindcss` semantic tokens (`input`,
/// `ring`, `destructive`, `foreground`, `mutedForeground`, `primary`), so a pasted
/// theme reskins it. Layout is faithful to shadcn v4 `h-9 w-full rounded-md border
/// border-input bg-transparent px-3 text-base`.
///
/// **States** (faithful to shadcn): default `border-input`; focused
/// `border-ring` + a 3px `ring/50` focus ring; [invalid] `border-destructive`
/// (+ `destructive/20` ring when focused); disabled ([enabled] = false)
/// `opacity-50` + non-interactive. For a text field, focus == focus-visible (you
/// focus a field to type), so the ring shows on any focus.
///
/// **Managed controller/focusNode.** Pass your own [controller]/[focusNode], or
/// omit them and the widget creates (and disposes) its own — the standard Flutter
/// pattern.
///
/// **Block-level** (`w-full`): the field fills — and requires — a bounded-width
/// parent (place it in a constrained column/`SizedBox`/`Expanded`). Single-line
/// (`maxLines: 1`); a multi-line `Textarea` is a separate primitive.
///
/// **Accessibility:** [EditableText] reports text-field semantics + the current
/// value; pass [semanticLabel] to name the field (e.g. "Email") — unlike Button's
/// `semanticLabel`, this does NOT replace the value (a screen reader announces
/// both the name and the typed text, which is correct for an input).
///
/// **Faithful deviations (documented, not bugs):** `shadow-xs`, `md:text-sm`, and
/// `dark:bg-input/30` are dropped (an input's depth is its border+ring; 16px is
/// the mobile-first default; the dark fill needs a brightness-only token knob we
/// don't have — same precedent as Badge's dropped `dark:bg-destructive/60`).
/// Selected text keeps its `foreground` color (EditableText can't recolor text on
/// selection). **Not yet built (feasible, AGENTS.md §11b):** a custom selection
/// toolbar/handles (`TextSelectionControls`) — v1 ships keyboard + basic selection.
class Input extends StatefulWidget {
const Input({
super.key,
this.controller,
this.focusNode,
this.placeholder,
this.enabled = true,
this.invalid = false,
this.obscureText = false,
this.keyboardType,
this.textInputAction,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.semanticLabel,
}) : assert(
!autofocus || enabled,
'Input: autofocus requires enabled: true — a disabled field must not steal focus.',
);

/// External text controller. When null, the field manages its own.
final TextEditingController? controller;

/// External focus control. When null, the field manages its own.
final FocusNode? focusNode;

/// Hint shown in `mutedForeground` while the field is empty (shadcn
/// `placeholder:`).
final String? placeholder;

/// Whether the field is interactive. `false` ⇒ `opacity-50`, read-only, and
/// non-focusable (shadcn `disabled:`).
final bool enabled;

/// Whether to render the error treatment (shadcn `aria-invalid:` — a
/// `destructive` border, and a `destructive/20` ring when focused). The caller
/// owns validation; this is the visual flag.
final bool invalid;

/// Obscures input (passwords). Maps to [EditableText.obscureText].
final bool obscureText;

/// The soft-keyboard type (e.g. `TextInputType.emailAddress`).
final TextInputType? keyboardType;

/// The IME action button (e.g. `TextInputAction.next`).
final TextInputAction? textInputAction;

/// Called on every edit with the new text.
final ValueChanged<String>? onChanged;

/// Called when the user submits (IME action / Enter).
final ValueChanged<String>? onSubmitted;

/// Whether to focus the field on first build.
final bool autofocus;

/// Screen-reader name for the field (e.g. "Email"). Added alongside — not in
/// place of — the field's value.
final String? semanticLabel;

@override
State<Input> createState() => _InputState();
}

class _InputState extends State<Input> {
TextEditingController? _internalController;
FocusNode? _internalFocusNode;
TextEditingController get _controller =>
widget.controller ?? (_internalController ??= TextEditingController());
FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode());

@override
void initState() {
super.initState();
_controller.addListener(_onChangedInternal);
_focusNode.addListener(_onFocusChanged);
}

@override
void didUpdateWidget(Input old) {
super.didUpdateWidget(old);
if (widget.controller != old.controller) {
(old.controller ?? _internalController)?.removeListener(_onChangedInternal);
if (old.controller != null && widget.controller == null) {
_internalController = TextEditingController(text: old.controller!.text);
} else if (widget.controller != null) {
_internalController?.dispose();
_internalController = null;
}
_controller.addListener(_onChangedInternal);
}
if (widget.focusNode != old.focusNode) {
(old.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged);
if (old.focusNode != null && widget.focusNode == null) {
_internalFocusNode = FocusNode();
} else if (widget.focusNode != null) {
_internalFocusNode?.dispose();
_internalFocusNode = null;
}
_focusNode.addListener(_onFocusChanged);
}
}

@override
void dispose() {
(widget.controller ?? _internalController)?.removeListener(_onChangedInternal);
(widget.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged);
_internalController?.dispose();
_internalFocusNode?.dispose();
super.dispose();
}

void _onChangedInternal() {
// Only the placeholder depends on the text, so skip the rebuild when there is
// none (avoids a full rebuild on every keystroke in a placeholder-less field).
if (mounted && widget.placeholder != null) setState(() {});
}

void _onFocusChanged() {
if (mounted) setState(() {});
}

@override
Widget build(BuildContext context) {
final c = context.fw.colors;
final focused = _focusNode.hasFocus && widget.enabled;

final Color borderColor = widget.invalid ? c.destructive : (focused ? c.ring : c.input);
final Color? ringColor =
focused
? (widget.invalid ? c.destructive : c.ring).withValues(
alpha: widget.invalid ? 0.2 : 0.5,
)
: null;

final textStyle = TextStyle(fontSize: FwFontSize.base.px, color: c.foreground);

final editable = EditableText(
controller: _controller,
focusNode: _focusNode,
readOnly: !widget.enabled,
obscureText: widget.obscureText,
keyboardType:
widget.keyboardType ??
(widget.obscureText ? TextInputType.visiblePassword : TextInputType.text),
textInputAction: widget.textInputAction,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
autofocus: widget.autofocus,
maxLines: 1,
style: textStyle,
cursorColor: c.foreground,
backgroundCursorColor: c.mutedForeground,
// shadcn `selection:bg-primary`, softened to 30% alpha so the `foreground`
// text stays readable through the highlight.
selectionColor: c.primary.withValues(alpha: 0.3),
cursorWidth: 2,
);

final showPlaceholder = widget.placeholder != null && _controller.text.isEmpty;
final content = Stack(
alignment: AlignmentDirectional.centerStart,
children: <Widget>[
if (showPlaceholder)
Text(
widget.placeholder!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: FwFontSize.base.px, color: c.mutedForeground),
),
editable,
],
);

FwStyled box =
content.tw
.bg(const Color(0x00000000))
.border(1, color: borderColor)
.roundedMd
.px(3)
.py(2)
.minH(9)
.wFull;
if (ringColor != null) box = box.ring(3, color: ringColor);
if (!widget.enabled) box = box.opacity(0.5);

Widget result = box;
if (!widget.enabled) {
// `IgnorePointer` blocks taps; `ExcludeFocus` removes the field from keyboard
// traversal (and denies programmatic focus) so a disabled input is truly
// non-focusable — without mutating a caller-owned [focusNode]. Faithful to
// shadcn `disabled:pointer-events-none` + the disabled-tabindex behavior.
result = ExcludeFocus(child: IgnorePointer(child: result));
}
if (widget.semanticLabel != null) {
// Merge the name ONTO the field's own node (which already carries the
// text-field role + the current value) so a screen reader announces one
// "name, edit text, value" — not a separate empty-valued label node beside
// an unnamed field. (A bare `Semantics(textField: true, label:)` wrapper
// would create that competing second node; `MergeSemantics` collapses them.)
result = MergeSemantics(child: Semantics(label: widget.semanticLabel, child: result));
}
return result;
}
}
17 changes: 17 additions & 0 deletions apps/gallery/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutterwindcss/flutterwindcss.dart';
import 'components/ui/button.dart';
import 'components/ui/card.dart';
import 'components/ui/badge.dart';
import 'components/ui/input.dart';

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

Expand Down Expand Up @@ -128,6 +129,22 @@ class GalleryApp extends StatelessWidget {
Badge(leading: _Dot(), child: Text('Verified')),
],
),
const SizedBox(height: 24),
Text('Inputs').tw.textSize(20).weight(FwFontWeight.bold),
const SizedBox(height: 12),
const SizedBox(
width: 280,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Input(placeholder: 'Email'),
SizedBox(height: 12),
Input(invalid: true, placeholder: 'Invalid'),
SizedBox(height: 12),
Input(enabled: false, placeholder: 'Disabled'),
],
),
),
],
),
),
Expand Down
1 change: 1 addition & 0 deletions apps/gallery/test/gallery_smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ void main() {
expect(find.text('primary'), findsWidgets);
expect(find.text('Create project'), findsWidgets);
expect(find.text('Badges'), findsWidgets);
expect(find.text('Inputs'), findsWidgets);
});
}
Binary file added apps/gallery/test/goldens/input_grid_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/gallery/test/goldens/input_grid_light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/gallery/test/goldens/input_grid_rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading