ashlarui

Input

A text field with selection handles and a cut, copy and paste menu.

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

import 'ui/input.dart';

class InputDemo extends StatelessWidget {
  const InputDemo({super.key});

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 320,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Input(
          placeholder: 'Email',
          keyboardType: TextInputType.emailAddress,
          leading: AshlarIcon(AshlarIconsRoundedStroke.mail),
        ),
        SizedBox(height: 12),
        Input(placeholder: 'Password', obscureText: true, error: true),
        SizedBox(height: 12),
        Input(placeholder: 'Disabled', enabled: false),
      ],
    ),
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Input with controls, as it looks in an app Input with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add input

It writes theme.dart, palette.dart, selection.dart and input.dart into lib/ui, keeping any you already have. It needs selection, which come with it.

Examples

Other ways to use it, each running live, with its code.

With icons

An icon before, a button after.

With icons, as it looks in an app With icons, as it looks in an app, dark
// With icons: An icon before, a button after.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/input.dart';

class InputIconsExample extends StatelessWidget {
  const InputIconsExample({super.key});

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 340,
    child: Input(
      placeholder: 'Search',
      semanticLabel: 'Search',
      leading: const AshlarIcon(AshlarIconsRoundedStroke.search),
      trailing: Button(
        variant: ButtonVariant.ghost,
        size: ButtonSize.iconXs,
        semanticLabel: 'Clear',
        onPressed: () {},
        child: const AshlarIcon(AshlarIconsRoundedStroke.x),
      ),
    ),
  );
}

States

Disabled, read only, and wrong.

States, as it looks in an app States, as it looks in an app, dark
// States: Disabled, read only, and wrong.
import 'package:flutter/widgets.dart';

import 'ui/input.dart';

class InputStatesExample extends StatelessWidget {
  const InputStatesExample({super.key});

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 340,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Input(placeholder: 'Disabled', enabled: false),
        SizedBox(height: 12),
        Input(placeholder: 'Read only', readOnly: true),
        SizedBox(height: 12),
        Input(placeholder: 'Wrong', error: true),
      ],
    ),
  );
}

Password

Hidden as it is typed.

Password, as it looks in an app Password, as it looks in an app, dark
// Password: Hidden as it is typed.
import 'package:flutter/widgets.dart';

import 'ui/input.dart';

class InputPasswordExample extends StatelessWidget {
  const InputPasswordExample({super.key});

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 340,
    child: Input(placeholder: 'Password', obscureText: true, semanticLabel: 'Password'),
  );
}

API

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

Inputclass

A one-line text field (or more, with maxLines), with a placeholder, focus ring and error state, and selection, handles and a cut, copy and paste menu on every platform.

Parameter Type Default What it is for
controller TextEditingController? Its text, to read and set from outside; one is made when not given.
focusNode FocusNode? Its focus node, to move focus to it or follow it from outside; one is made when not given.
placeholder String? Faint text shown while it is empty.
leading Widget? An icon or text before the text, inside the field.
trailing Widget? An icon or button after the text, inside the field.
obscureText bool false Hides what is typed, as for a password.
enabled bool true False fades it and stops it taking focus or typing.
readOnly bool false Shows its text, selectable, but takes no typing.
error bool false Draws the field as invalid.
autofocus bool false Takes the keyboard's focus when it first appears.
maxLines int 1 More than 1 makes a text area that grows to that many lines.
minLines int? How tall a text area starts, in lines. Defaults to maxLines, up to 3.
keyboardType TextInputType? The on-screen keyboard to show: numbers, an email address, a phone number.
textInputAction TextInputAction? What the on-screen keyboard's action key says and does: next, done, search.
autofillHints Iterable<String>? What the platform may fill in here from saved details: an email, a password.
inputFormatters List<TextInputFormatter>? Rules for what can be typed, such as digits only.
onChanged ValueChanged<String>? Told the text as it changes.
onSubmitted ValueChanged<String>? Told the text when the keyboard's action key is pressed.
semanticLabel String? Read out by screen readers; defaults to the placeholder.
decorated bool true False draws the bare text, with no border, fill or ring: for an InputGroup, which draws those round the field and its addons together.

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/input.dart 308 lines
// Ashlar UI: Input. Copied into your app by `ashlar add input`; it is yours to change.
//
// The widgets layer gives EditableText and no field around it: no selection handles, no
// cut and paste menu, no tap to place the cursor. This file and selection.dart supply them.

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

import 'selection.dart';
import 'theme.dart';

/// A one-line text field (or more, with [maxLines]), with a placeholder, focus ring and
/// error state, and selection, handles and a cut, copy and paste menu on every platform.
///
/// ```dart
/// Input(placeholder: 'Email', keyboardType: TextInputType.emailAddress, onChanged: (v) => email = v)
/// Input(controller: password, placeholder: 'Password', obscureText: true, error: wrong)
/// ```
class Input extends StatefulWidget {
  const Input({
    super.key,
    this.controller,
    this.focusNode,
    this.placeholder,
    this.leading,
    this.trailing,
    this.obscureText = false,
    this.enabled = true,
    this.readOnly = false,
    this.error = false,
    this.autofocus = false,
    this.maxLines = 1,
    this.minLines,
    this.keyboardType,
    this.textInputAction,
    this.autofillHints,
    this.inputFormatters,
    this.onChanged,
    this.onSubmitted,
    this.semanticLabel,
    this.decorated = true,
  });

  /// Its text, to read and set from outside; one is made when not given.
  final TextEditingController? controller;

  /// Its focus node, to move focus to it or follow it from outside; one is made when not
  /// given.
  final FocusNode? focusNode;

  /// Faint text shown while it is empty.
  final String? placeholder;

  /// An icon or text before the text, inside the field.
  final Widget? leading;

  /// An icon or button after the text, inside the field.
  final Widget? trailing;

  /// Hides what is typed, as for a password.
  final bool obscureText;

  /// False fades it and stops it taking focus or typing.
  final bool enabled;

  /// Shows its text, selectable, but takes no typing.
  final bool readOnly;

  /// Draws the field as invalid.
  final bool error;

  /// Takes the keyboard's focus when it first appears.
  final bool autofocus;

  /// More than 1 makes a text area that grows to that many lines.
  final int maxLines;

  /// How tall a text area starts, in lines. Defaults to [maxLines], up to 3.
  final int? minLines;

  /// The on-screen keyboard to show: numbers, an email address, a phone number.
  final TextInputType? keyboardType;

  /// What the on-screen keyboard's action key says and does: next, done, search.
  final TextInputAction? textInputAction;

  /// What the platform may fill in here from saved details: an email, a password.
  final Iterable<String>? autofillHints;

  /// Rules for what can be typed, such as digits only.
  final List<TextInputFormatter>? inputFormatters;

  /// Told the text as it changes.
  final ValueChanged<String>? onChanged;

  /// Told the text when the keyboard's action key is pressed.
  final ValueChanged<String>? onSubmitted;

  /// Read out by screen readers; defaults to the placeholder.
  final String? semanticLabel;

  /// False draws the bare text, with no border, fill or ring: for an InputGroup, which
  /// draws those round the field and its addons together.
  final bool decorated;

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

class _InputState extends State<Input>
    implements TextSelectionGestureDetectorBuilderDelegate {
  TextEditingController? _ownController;
  FocusNode? _ownFocus;
  TextEditingController get _controller =>
      widget.controller ?? (_ownController ??= TextEditingController());
  FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
  late final _gestures = TextSelectionGestureDetectorBuilder(delegate: this);
  bool _hovered = false;

  @override
  final GlobalKey<EditableTextState> editableTextKey =
      GlobalKey<EditableTextState>();

  @override
  bool get forcePressEnabled => false;

  @override
  bool get selectionEnabled => widget.enabled;

  @override
  void initState() {
    super.initState();
    _focus.addListener(_refresh);
    _controller.addListener(_refresh);
  }

  void _refresh() => setState(() {});

  @override
  void dispose() {
    _focus.removeListener(_refresh);
    _controller.removeListener(_refresh);
    _ownController?.dispose();
    _ownFocus?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final focused = _focus.hasFocus;
    final style = t.text.sm.copyWith(color: c.foreground);
    final multiline = widget.maxLines > 1;
    final editable = EditableText(
      key: editableTextKey,
      controller: _controller,
      focusNode: _focus,
      readOnly: widget.readOnly || !widget.enabled,
      obscureText: widget.obscureText,
      autofocus: widget.autofocus,
      style: style,
      cursorColor: c.foreground,
      backgroundCursorColor: c.mutedForeground,
      cursorWidth: 1.5,
      cursorRadius: const Radius.circular(1),
      selectionColor: c.ring.withValues(alpha: .35),
      selectionControls: AshlarSelectionControls(c.foreground),
      contextMenuBuilder: (context, state) => AshlarSelectionMenu(
        anchors: state.contextMenuAnchors,
        items: state.contextMenuButtonItems,
      ),
      maxLines: widget.maxLines,
      minLines: multiline
          ? (widget.minLines ?? widget.maxLines.clamp(1, 3))
          : null,
      keyboardType:
          widget.keyboardType ??
          (multiline ? TextInputType.multiline : TextInputType.text),
      textInputAction: widget.textInputAction,
      autofillHints: widget.autofillHints,
      inputFormatters: widget.inputFormatters,
      onChanged: widget.onChanged,
      onSubmitted: widget.onSubmitted,
      rendererIgnoresPointer: true,
      enableInteractiveSelection: widget.enabled,
      mouseCursor: widget.enabled
          ? SystemMouseCursors.text
          : SystemMouseCursors.basic,
    );
    final ringColor = widget.error ? c.destructive : c.ring;
    return Opacity(
      opacity: widget.enabled ? 1 : .5,
      child: MouseRegion(
        cursor: widget.enabled
            ? SystemMouseCursors.text
            : SystemMouseCursors.forbidden,
        onEnter: (_) => setState(() => _hovered = true),
        onExit: (_) => setState(() => _hovered = false),
        child: TextFieldTapRegion(
          child: _gestures.buildGestureDetector(
            behavior: HitTestBehavior.translucent,
            child: AnimatedContainer(
              duration: t.duration,
              constraints: BoxConstraints(
                minHeight: multiline ? 64 : t.control(36),
              ),
              padding: EdgeInsets.symmetric(
                horizontal: widget.decorated ? 12 : 0,
                vertical: multiline ? 8 : 0,
              ),
              decoration: !widget.decorated
                  ? null
                  : BoxDecoration(
                      // Opaque, so the shadow beneath does not show through.
                      color: t.brightness == Brightness.dark
                          ? Color.alphaBlend(
                              c.input.withValues(alpha: c.input.a * .3),
                              c.background,
                            )
                          : c.background,
                      borderRadius: BorderRadius.circular(t.radiusMd),
                      border: Border.all(
                        color: widget.error
                            ? c.destructive
                            : (focused
                                  ? c.ring
                                  : (_hovered
                                        ? c.ring.withValues(alpha: .6)
                                        : c.input)),
                      ),
                      boxShadow: [
                        if (focused)
                          BoxShadow(
                            color: ringColor.withValues(
                              alpha: widget.error ? .2 : .5,
                            ),
                            spreadRadius: 3,
                          ),
                        ...t.shadowXs,
                      ],
                    ),
              child: IconTheme.merge(
                data: IconThemeData(color: c.mutedForeground, size: 16),
                child: Row(
                  crossAxisAlignment: multiline
                      ? CrossAxisAlignment.start
                      : CrossAxisAlignment.center,
                  children: [
                    if (widget.leading != null) ...[
                      widget.leading!,
                      const SizedBox(width: 8),
                    ],
                    Expanded(
                      child: Stack(
                        alignment: multiline
                            ? AlignmentDirectional.topStart
                            : AlignmentDirectional.centerStart,
                        children: [
                          if (_controller.text.isEmpty &&
                              widget.placeholder != null)
                            // The field announces the placeholder as its name, once.
                            ExcludeSemantics(
                              child: IgnorePointer(
                                child: Text(
                                  widget.placeholder!,
                                  style: style.copyWith(
                                    color: c.mutedForeground,
                                  ),
                                  maxLines: widget.maxLines,
                                  overflow: TextOverflow.ellipsis,
                                ),
                              ),
                            ),
                          // The name goes on the node a screen reader focuses: the text itself.
                          MergeSemantics(
                            child: Semantics(
                              label: widget.semanticLabel ?? widget.placeholder,
                              enabled: widget.enabled,
                              // A screen reader opens a field by tapping it: then it takes
                              // focus and the keyboard comes up, as Flutter's own field does.
                              onTap: widget.enabled && !widget.readOnly
                                  ? () {
                                      _focus.requestFocus();
                                      editableTextKey.currentState
                                          ?.requestKeyboard();
                                    }
                                  : null,
                              child: editable,
                            ),
                          ),
                        ],
                      ),
                    ),
                    if (widget.trailing != null) ...[
                      const SizedBox(width: 8),
                      widget.trailing!,
                    ],
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}