ashlarui

Input Group

A text field with text, buttons or a toolbar inside its border.

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

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

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 380,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const InputGroup(
          start: Text('https://'),
          end: Text('.co.ke'),
          child: Input(decorated: false, placeholder: 'yourshop'),
        ),
        const SizedBox(height: 16),
        InputGroup(
          start: const AshlarIcon(AshlarIconsRoundedStroke.search),
          end: Button(variant: ButtonVariant.ghost, size: ButtonSize.sm, onPressed: () {}, child: const Text('Search')),
          child: const Input(decorated: false, placeholder: 'Search orders'),
        ),
        const SizedBox(height: 16),
        InputGroup(
          bottom: Row(
            children: [
              const Text('280 characters left'),
              const Spacer(),
              Button(size: ButtonSize.sm, onPressed: () {}, child: const Text('Send')),
            ],
          ),
          child: const Textarea(decorated: false, placeholder: 'Write a reply', minLines: 2),
        ),
      ],
    ),
  );
}

Try its options

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

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

Add it

In your Flutter project:

$ ashlar add input-group

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

Examples

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

Before and after

Words fixed at either end.

Before and after, as it looks in an app Before and after, as it looks in an app, dark
// Before and after: Words fixed at either end.
import 'package:flutter/widgets.dart';

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

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

  @override
  Widget build(BuildContext context) => const SizedBox(
    width: 360,
    child: InputGroup(
      start: Text('https://'),
      end: Text('.co.ke'),
      child: Input(placeholder: 'solutech', decorated: false, semanticLabel: 'Address'),
    ),
  );
}

With a row below

A count and a button under the field.

With a row below, as it looks in an app With a row below, as it looks in an app, dark
// With a row below: A count and a button under the field.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/input_group.dart';
import 'ui/textarea.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 360,
    child: InputGroup(
      bottom: Row(
        children: [
          const Text('0 / 280'),
          const Spacer(),
          Button(size: ButtonSize.sm, onPressed: () {}, child: const Text('Post')),
        ],
      ),
      child: const Textarea(placeholder: 'What is happening?', decorated: false, semanticLabel: 'Post'),
    ),
  );
}

API

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

InputGroupclass

A text field and the things that belong with it inside one border: text before it ("https://"), a button after it, a toolbar under a text area. Give it an Input or a Textarea with decorated: false; the group draws the border, the fill and the focus ring round them all.

Parameter Type Default What it is for
childrequired Widget The field: an Input with its decoration off, or a Textarea.
start Widget? Before the field and after it, on its line.
end Widget? Before the field and after it, on its line.
top Widget? Above the field and under it, across the whole width.
bottom Widget? Above the field and under it, across the whole width.
error bool false Draws the group as invalid.

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_group.dart 135 lines
// Ashlar UI: Input Group. Copied into your app by `ashlar add input-group`; it is yours to
// change.

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// A text field and the things that belong with it inside one border: text before it
/// ("https://"), a button after it, a toolbar under a text area. Give it an Input or a
/// Textarea with `decorated: false`; the group draws the border, the fill and the focus
/// ring round them all.
///
/// ```dart
/// InputGroup(
///   start: Text('https://'),
///   end: Text('.co.ke'),
///   child: Input(decorated: false, placeholder: 'example'),
/// )
/// InputGroup(
///   bottom: Row(children: [Text('120 characters left'), Spacer(), Button(size: ButtonSize.sm, onPressed: send, child: Text('Send'))]),
///   child: Textarea(decorated: false, placeholder: 'Write a reply'),
/// )
/// ```
class InputGroup extends StatefulWidget {
  const InputGroup({
    super.key,
    required this.child,
    this.start,
    this.end,
    this.top,
    this.bottom,
    this.error = false,
  });

  /// The field: an Input with its decoration off, or a Textarea.
  final Widget child;

  /// Before the field and after it, on its line.
  final Widget? start, end;

  /// Above the field and under it, across the whole width.
  final Widget? top, bottom;

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

  @override
  State<InputGroup> createState() => _InputGroupState();
}

class _InputGroupState extends State<InputGroup> {
  bool _focused = false;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    Widget addon(Widget w, EdgeInsetsGeometry padding) => Padding(
      padding: padding,
      child: DefaultTextStyle.merge(
        style: t.text.sm.copyWith(color: c.mutedForeground),
        child: IconTheme.merge(
          data: IconThemeData(color: c.mutedForeground, size: 16),
          child: w,
        ),
      ),
    );
    final ring = widget.error ? c.destructive : c.ring;
    return Focus(
      canRequestFocus: false,
      skipTraversal: true,
      // The ring shows while the field, or anything in the group, has focus.
      onFocusChange: (f) => setState(() => _focused = f),
      child: AnimatedContainer(
        duration: t.duration,
        decoration: BoxDecoration(
          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 : c.input),
          ),
          boxShadow: [
            if (_focused)
              BoxShadow(
                color: ring.withValues(alpha: widget.error ? .2 : .5),
                spreadRadius: 3,
              ),
            ...t.shadowXs,
          ],
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            if (widget.top != null)
              addon(widget.top!, const EdgeInsets.fromLTRB(12, 10, 12, 0)),
            Row(
              children: [
                if (widget.start != null)
                  addon(
                    widget.start!,
                    const EdgeInsetsDirectional.only(start: 12, end: 2),
                  ),
                Expanded(
                  child: Padding(
                    padding: EdgeInsetsDirectional.only(
                      start: widget.start == null ? 12 : 4,
                      end: widget.end == null ? 12 : 4,
                    ),
                    child: widget.child,
                  ),
                ),
                if (widget.end != null)
                  addon(
                    widget.end!,
                    const EdgeInsetsDirectional.only(start: 2, end: 8),
                  ),
              ],
            ),
            if (widget.bottom != null)
              addon(
                widget.bottom!,
                const EdgeInsetsDirectional.fromSTEB(12, 0, 8, 8),
              ),
          ],
        ),
      ),
    );
  }
}