Fliin
On this page

Components / forms

Checkbox group

Ticks several options under one label, description and error, such as interests or alert channels, read by screen readers as one named group.

16 of 16 variants pass
Checkbox group, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add checkbox-groupAlso installs: core, field, control-field, checkbox

Examples

Example

Several independent choices under one label: value is the set of checked options. Here a required group stays invalid until one is checked.

Checkbox group, example state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupExample extends StatefulWidget {
  const CheckboxGroupExample({super.key});

  @override
  State<CheckboxGroupExample> createState() => _CheckboxGroupExampleState();
}

class _CheckboxGroupExampleState extends State<CheckboxGroupExample> {
  var _chosen = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      required: true,
      invalid: _chosen.isEmpty,
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Enabled

A checkbox group with one option checked; an option can carry its own description.

Checkbox group, enabled state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupEnabledExample extends StatefulWidget {
  const CheckboxGroupEnabledExample({super.key});

  @override
  State<CheckboxGroupEnabledExample> createState() => _CheckboxGroupEnabledExampleState();
}

class _CheckboxGroupEnabledExampleState extends State<CheckboxGroupEnabledExample> {
  var _chosen = <String>{'coding'};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Selected

Any number of options can be checked at once.

Checkbox group, selected state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupSelectedExample extends StatefulWidget {
  const CheckboxGroupSelectedExample({super.key});

  @override
  State<CheckboxGroupSelectedExample> createState() => _CheckboxGroupSelectedExampleState();
}

class _CheckboxGroupSelectedExampleState extends State<CheckboxGroupSelectedExample> {
  var _chosen = <String>{'coding', 'design'};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Unselected

Nothing checked yet.

Checkbox group, unselected state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupUnselectedExample extends StatefulWidget {
  const CheckboxGroupUnselectedExample({super.key});

  @override
  State<CheckboxGroupUnselectedExample> createState() => _CheckboxGroupUnselectedExampleState();
}

class _CheckboxGroupUnselectedExampleState extends State<CheckboxGroupUnselectedExample> {
  var _chosen = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Disabled

disabled locks every option and keeps the checked ones visible.

Checkbox group, disabled state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupDisabledExample extends StatefulWidget {
  const CheckboxGroupDisabledExample({super.key});

  @override
  State<CheckboxGroupDisabledExample> createState() => _CheckboxGroupDisabledExampleState();
}

class _CheckboxGroupDisabledExampleState extends State<CheckboxGroupDisabledExample> {
  var _chosen = <String>{'coding'};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      disabled: true,
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Invalid

invalid shows the error under the options.

Checkbox group, invalid state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupInvalidExample extends StatefulWidget {
  const CheckboxGroupInvalidExample({super.key});

  @override
  State<CheckboxGroupInvalidExample> createState() => _CheckboxGroupInvalidExampleState();
}

class _CheckboxGroupInvalidExampleState extends State<CheckboxGroupInvalidExample> {
  var _chosen = <String>{};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Select your interests',
      description: 'Choose all that apply',
      error: 'Choose at least one',
      required: true,
      invalid: true,
      value: _chosen,
      onChanged: (value) => setState(() => _chosen = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Select all

selectAllLabel adds a box that checks them all, mixed while only some are checked; the others are indented under it.

Checkbox group, select-all state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupSelectAllExample extends StatefulWidget {
  const CheckboxGroupSelectAllExample({super.key});

  @override
  State<CheckboxGroupSelectAllExample> createState() => _CheckboxGroupSelectAllExampleState();
}

class _CheckboxGroupSelectAllExampleState extends State<CheckboxGroupSelectAllExample> {
  var _interests = {'coding'};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Interests',
      selectAllLabel: 'Select all',
      value: _interests,
      onChanged: (value) => setState(() => _interests = value),
      options: const [
        FlCheckboxGroupOption(value: 'coding', label: 'Coding'),
        FlCheckboxGroupOption(value: 'design', label: 'Design'),
        FlCheckboxGroupOption(value: 'writing', label: 'Writing'),
      ],
    );
  }
}

Framed

framed puts each box on its own card: add-ons to pick, each with its description.

Checkbox group, framed state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupFramedExample extends StatefulWidget {
  const CheckboxGroupFramedExample({super.key});

  @override
  State<CheckboxGroupFramedExample> createState() => _CheckboxGroupFramedExampleState();
}

class _CheckboxGroupFramedExampleState extends State<CheckboxGroupFramedExample> {
  var _channels = {'email'};

  @override
  Widget build(BuildContext context) {
    return FlCheckboxGroup<String>(
      label: 'Notification preferences',
      description: 'Choose how you want to receive updates',
      framed: true,
      value: _channels,
      onChanged: (value) => setState(() => _channels = value),
      options: const [
        FlCheckboxGroupOption(value: 'email', label: 'Email notifications', description: 'Receive updates by email'),
        FlCheckboxGroupOption(value: 'sms', label: 'SMS alerts', description: 'Get instant text messages'),
        FlCheckboxGroupOption(value: 'push', label: 'Push notifications', description: 'Alerts on this device'),
      ],
    );
  }
}

On surface

On a surface, a sheet or a dialog, FlControlFieldVariant.secondary gives the boxes the background that stands out from it.

Checkbox group, on-surface state
import 'package:your_app/ui/fliin/components/checkbox_group/fl_checkbox_group.dart';
import 'package:your_app/ui/fliin/components/control_field/fl_control_field.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:flutter/widgets.dart';

class CheckboxGroupOnSurfaceExample extends StatefulWidget {
  const CheckboxGroupOnSurfaceExample({super.key});

  @override
  State<CheckboxGroupOnSurfaceExample> createState() => _CheckboxGroupOnSurfaceExampleState();
}

class _CheckboxGroupOnSurfaceExampleState extends State<CheckboxGroupOnSurfaceExample> {
  var _interests = {'design'};

  @override
  Widget build(BuildContext context) {
    return FlSurface(
      child: FlCheckboxGroup<String>(
        label: 'Select your interests',
        description: 'Choose all that apply',
        variant: FlControlFieldVariant.secondary,
        value: _interests,
        onChanged: (value) => setState(() => _interests = value),
        options: const [
          FlCheckboxGroupOption(value: 'coding', label: 'Coding', description: 'Love building software'),
          FlCheckboxGroupOption(value: 'design', label: 'Design', description: 'Enjoy creating interfaces'),
          FlCheckboxGroupOption(value: 'writing', label: 'Writing', description: 'Passionate about content'),
        ],
      ),
    );
  }
}

Usage

The code the reference images render:

Localizations(locale: Locale'en', delegates: const [DefaultWidgetsLocalizations.delegate], child: Directionality(textDirection: variant.dir, child: ColoredBox(color: FlScope.of(context).colors.surface, child: Padding(padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.xl, children: [FlCheckboxGroup<String>(label: 'Interests', description: 'Choose everything you like', value: const {'cuisine'}, onChanged: (_) {}, options: [FlCheckboxGroupOption(value: 'cuisine', label: 'Cooking'), FlCheckboxGroupOption(value: 'voyage', label: 'Travel', description: 'Stays and deals'), FlCheckboxGroupOption(value: 'sport', label: 'Sports', disabled: true)]), FlCheckboxGroup<String>(label: 'Alerts', required: true, invalid: true, error: 'Choose at least one channel', value: const {}, onChanged: (_) {}, options: [FlCheckboxGroupOption(value: 'mail', label: 'Email'), FlCheckboxGroupOption(value: 'sms', label: 'SMS')])])))))

API

FlCheckboxGroupOption: class<T>
FlCheckboxGroupOption.new: const new({required T value, required String label, String? description, bool disabled})
FlCheckboxGroupOption.value: get T
FlCheckboxGroupOption.label: get String
FlCheckboxGroupOption.description: get String?
FlCheckboxGroupOption.disabled: get bool
FlCheckboxGroup: class<T> extends StatefulWidget
FlCheckboxGroup.new: const new({super.key, required String label, required Set<T> value, required ValueChanged<Set<T>> onChanged, required List<FlCheckboxGroupOption<T>> options, String? description, String? error, bool invalid, bool required, bool disabled, FlControlFieldVariant variant, bool framed, String? selectAllLabel})
FlCheckboxGroup.label: get String
FlCheckboxGroup.variant: get FlControlFieldVariant
FlCheckboxGroup.framed: get bool
FlCheckboxGroup.selectAllLabel: get String?
FlCheckboxGroup.value: get Set<T>
FlCheckboxGroup.onChanged: get ValueChanged<Set<T>>
FlCheckboxGroup.options: get List<FlCheckboxGroupOption<T>>
FlCheckboxGroup.description: get String?
FlCheckboxGroup.error: get String?
FlCheckboxGroup.invalid: get bool
FlCheckboxGroup.required: get bool
FlCheckboxGroup.disabled: get bool
FlCheckboxGroup.createState: State<FlCheckboxGroup<T>>()

Reference renders

The 16 images published with version 0.2.2, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify checkbox-group compares your copy to them.

iOS · Light
Checkbox group, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Checkbox group, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Checkbox group, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Checkbox group, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Checkbox group, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Checkbox group, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Checkbox group, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Checkbox group, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Checkbox group, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Checkbox group, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Checkbox group, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Checkbox group, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Checkbox group, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Checkbox group, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Checkbox group, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Checkbox group, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • select several options under one label
  • pick your interests from a list
  • show that at least one option must be ticked
  • show one shared error for several checkboxes
  • ask which notification channels to use
Not for
  • a single option among several: use radio buttons (radio)
  • a single box with its label: use a labeled control (control-field)
  • grouped switches: use a switch group (switch-group)
States
  • Enabled
  • Selected
  • Unselected
  • Disabled
  • Invalid

Source

The files fliin add checkbox-group copies into your app, before your prefix and imports are applied.

checkbox_group/fl_checkbox_group.dart

// Modified from the CheckboxGroup of HeroUI React v3, Copyright 2020 Next UI
// Inc., licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the ControlField of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0.
import 'dart:ui' show SemanticsValidationResult;

import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/checkbox/fl_checkbox.dart';
import 'package:fliin_ui/components/control_field/fl_control_field.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/core/fliin.dart';

/// A box of a [FlCheckboxGroup]: the value it adds to the selection, its
/// label and its help text.
class FlCheckboxGroupOption<T> {
  /// The box [value], named [label].
  const FlCheckboxGroupOption({required this.value, required this.label, this.description, this.disabled = false});

  /// The value the box adds to the selection when checked.
  final T value;

  /// The row's label, also read by the screen reader.
  final String label;

  /// A help text under the label.
  final String? description;

  /// True: this box alone does not respond.
  final bool disabled;
}

/// A group of checkboxes: a shared label
/// and description, one `FlControlField` row per box, then the group's
/// error message. The selection is the set [value] of checked values, held
/// by the parent.
///
/// The screen reader reads the group as a node named by [label] and
/// described by [description], then each box; the error is announced when
/// it appears. [invalid] outlines each box in `danger`, [disabled] disables
/// them all. A minimum or maximum number of boxes is validated by the
/// caller, through [invalid] and [error]: [required] only
/// sets the asterisk and the required state.
class FlCheckboxGroup<T> extends StatefulWidget {
  /// A group [label] whose [options] boxes are checked according to [value].
  const FlCheckboxGroup({
    super.key,
    required this.label,
    required this.value,
    required this.onChanged,
    required this.options,
    this.description,
    this.error,
    this.invalid = false,
    this.required = false,
    this.disabled = false,
    this.variant = FlControlFieldVariant.primary,
    this.framed = false,
    this.selectAllLabel,
  });

  /// The group's label, in the `label` role, which names it for the screen
  /// reader.
  final String label;

  /// The background of the boxes: `primary` on the page by default,
  /// `secondary` on a surface, a sheet or a dialog.
  final FlControlFieldVariant variant;

  /// True: each box sits on its own `surfaceRaised` card, `space.sm` apart,
  /// a choice of plans or add-ons rather than a plain list.
  final bool framed;

  /// Non-null: a box of that name ("Select all") above the others, which
  /// are indented under it. It is checked when every box is, mixed when
  /// some are; tapping it checks every enabled box, or clears them all
  /// once they are all checked.
  final String? selectAllLabel;

  /// The values of the checked boxes.
  final Set<T> value;

  /// Receives the new set when a box changes; the parent passes it back in
  /// [value]. Two boxes tapped before the parent passes back the first add
  /// up: the second set starts from the first, not from [value].
  final ValueChanged<Set<T>> onChanged;

  /// The boxes, top to bottom.
  final List<FlCheckboxGroupOption<T>> options;

  /// A help text under the label, in `textSm`.
  final String? description;

  /// The error message under the boxes, shown when [invalid] is true.
  final String? error;

  /// True: the selection is rejected; the boxes take a `danger` outline, and
  /// [error] appears.
  final bool invalid;

  /// True: the label carries an asterisk and the group announces itself required.
  final bool required;

  /// True: no box responds, and the whole group fades.
  final bool disabled;

  @override
  State<FlCheckboxGroup<T>> createState() => _FlCheckboxGroupState<T>();
}

class _FlCheckboxGroupState<T> extends State<FlCheckboxGroup<T>> {
  /// The last set sent, or the parent's value when it passes one back.
  late Set<T> _latest = widget.value;

  @override
  void didUpdateWidget(FlCheckboxGroup<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    _latest = widget.value;
  }

  void _toggle(T option, bool on) {
    _latest = on ? {..._latest, option} : ({..._latest}..remove(option));
    widget.onChanged(_latest);
  }

  /// Checks every enabled box, or clears them once all are checked; a
  /// disabled box keeps its state.
  void _toggleAll() {
    final enabled = {
      for (final option in widget.options)
        if (!option.disabled) option.value,
    };
    final all = widget.options.every((option) => _latest.contains(option.value));
    _latest = all ? ({..._latest}..removeAll(enabled)) : {..._latest, ...enabled};
    widget.onChanged(_latest);
  }

  /// The box that checks them all, mixed while only some are checked: the
  /// same row as the others, its checkbox carrying the semantics.
  Widget _selectAll(FlTheme theme, String label) {
    final options = widget.options;
    final checked = options.where((option) => widget.value.contains(option.value)).length;
    final bool? state = checked == 0 ? false : (checked == options.length ? true : null);
    final enabled = !widget.disabled && options.any((option) => !option.disabled);
    final family = DefaultTextStyle.of(context).style.fontFamily;
    final text = Text(
      label,
      style: theme.typography
          .resolve(FlTextRole.textBase)
          .copyWith(color: theme.colors.onSurface, fontWeight: theme.tokens.typography.weight.normal),
      strutStyle: theme.typography.strut(FlTextRole.textBase, inheritedFamily: family),
    );
    return ConstrainedBox(
      constraints: BoxConstraints(minHeight: theme.size.controlCompact),
      child: Row(
        spacing: theme.space.md,
        children: [
          FlCheckbox(
            value: state,
            tristate: true,
            label: label,
            invalid: widget.invalid,
            variant: widget.variant == FlControlFieldVariant.secondary
                ? FlCheckboxVariant.secondary
                : FlCheckboxVariant.primary,
            onChanged: enabled ? (_) => _toggleAll() : null,
          ),
          Expanded(
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              excludeFromSemantics: true,
              onTap: enabled ? _toggleAll : null,
              child: ExcludeSemantics(
                child: enabled ? text : Opacity(opacity: theme.size.disabledOpacity, child: text),
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final FlCheckboxGroup(:label, :description, :error, :invalid, :required, :disabled, :options, :value) = widget;
    final selectAll = widget.selectAllLabel;
    // Indented under "Select all": each box under its label.
    final indent = selectAll == null ? 0.0 : theme.size.toggle + theme.space.md;
    return FlFieldScope(
      invalid: invalid,
      disabled: disabled,
      required: required,
      child: Semantics(
        container: true,
        explicitChildNodes: true,
        label: label,
        hint: description,
        isRequired: required ? true : null,
        validationResult: invalid ? SemanticsValidationResult.invalid : SemanticsValidationResult.none,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ExcludeSemantics(
              child: _header(context, label: label, description: description, required: required, disabled: disabled),
            ),
            if (selectAll != null)
              Padding(
                padding: EdgeInsetsDirectional.only(top: theme.space.sm),
                child: _selectAll(theme, selectAll),
              ),
            for (final option in options)
              Padding(
                padding: EdgeInsetsDirectional.only(top: theme.space.sm, start: indent),
                child: FlControlField(
                  value: value.contains(option.value),
                  onChanged: disabled || option.disabled ? null : (on) => _toggle(option.value, on),
                  label: option.label,
                  description: option.description,
                  indicator: FlControlFieldIndicator.checkbox,
                  variant: widget.variant,
                  invalid: invalid,
                  framed: widget.framed,
                ),
              ),
            if (error != null)
              Padding(
                padding: EdgeInsetsDirectional.only(top: invalid ? theme.space.sm : 0),
                child: FlFieldError(error),
              ),
          ],
        ),
      ),
    );
  }
}

/// A group's header: [label] in the `label` role, `onSurface`, with a
/// `danger` asterisk when [required]; [description] in `textSm`
/// `onSurfaceMuted` under it. Neither turns `danger`: the outlines and the
/// error carry the invalid state. Disabled, it fades.
Widget _header(
  BuildContext context, {
  required String label,
  String? description,
  required bool required,
  required bool disabled,
}) {
  final theme = FlScope.of(context);
  final colors = theme.colors;
  final scale = theme.typography;
  final family = DefaultTextStyle.of(context).style.fontFamily;
  final header = Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    spacing: theme.space.xs / 2,
    children: [
      Text.rich(
        TextSpan(
          text: label,
          children: [
            if (required)
              TextSpan(
                text: ' *',
                semanticsLabel: '',
                style: TextStyle(color: disabled ? colors.onSurfaceMuted : colors.danger),
              ),
          ],
        ),
        style: scale.resolve(FlTextRole.label).copyWith(color: colors.onSurface),
        strutStyle: scale.strut(FlTextRole.label, inheritedFamily: family),
      ),
      if (description != null)
        Text(
          description,
          style: scale.resolve(FlTextRole.textSm).copyWith(color: colors.onSurfaceMuted),
          strutStyle: scale.strut(FlTextRole.textSm, inheritedFamily: family),
        ),
    ],
  );
  return disabled ? Opacity(opacity: theme.size.disabledOpacity, child: header) : header;
}

Arrows to move, Enter to open, Escape to close.