buildFluentSpinButton function

Widget buildFluentSpinButton(
  1. FluentSpinButtonBaseState state,
  2. FluentSpinButtonStyle style,
  3. Set<WidgetState> states
)

Renders a spin button from a resolved state and style.

The third of the three-function recomposition contract. Takes FluentSpinButtonBaseState rather than FluentSpinButtonState on purpose: it never reads appearance or size, so a consumer can supply their own style and still use Fluent's layout, border and stepper column.

states is the live interaction set of the control — hover, press and disabled. Each stepper resolves its own set independently, which is what makes a stepper hold at rest while the field around it is hovered.

Paint order is upstream's: the fill, the content (steppers included), the border over both — upstream draws it on ::before at z-index: 10, so a hovered stepper never covers it — and the focus bar (::after, 20) on top. That ::before is an absolute overlay, so unlike buildFluentInput the border does not inset the content.

The only thing that animates is the focus underline, which grows from the centre — useSpinButtonStyles.styles.ts declares exactly one transition, transform on the root ::after. That bar is FluentInputFocusUnderline, whose fluentInputFocusUnderlineEnter and fluentInputFocusUnderlineExit are durationNormal in and durationUltraFast out, both on CSS ease — upstream's curve tokens sit in transitionDelay, which the browser drops, and the port ports what renders. Reduced motion is handled inside that widget.

Implementation

Widget buildFluentSpinButton(
  FluentSpinButtonBaseState state,
  FluentSpinButtonStyle style,
  Set<WidgetState> states,
) {
  final radius = style.borderRadius?.resolve(states) ?? BorderRadius.zero;
  final borderWidth = style.borderWidth?.resolve(states) ?? FluentStroke.none;
  final borderColor = style.borderColor?.resolve(states);
  final background = style.backgroundColor?.resolve(states);
  final ruleColor = style.bottomRuleColor?.resolve(states);
  final ruleWidth = style.bottomRuleWidth?.resolve(states) ?? FluentStroke.none;
  final focusColor = style.focusUnderlineColor?.resolve(states);
  final focusWidth =
      style.focusUnderlineWidth?.resolve(states) ?? FluentStroke.none;
  final foreground = style.foregroundColor?.resolve(states);
  final textStyle = style.textStyle?.resolve(states);
  final padding = style.padding?.resolve(states) ?? EdgeInsets.zero;
  final contentPadding =
      style.contentPadding?.resolve(states) ?? EdgeInsets.zero;
  final minimumSize = style.minimumSize?.resolve(states) ?? Size.zero;

  // The `<input>` spans the control's full height, and so does its cursor;
  // its text sits in the middle of it. `heightFactor` keeps it at its own
  // height when the parent's is loose, and centres it in a taller tight one.
  Widget field = MouseRegion(
    cursor: style.mouseCursor?.resolve(states) ?? MouseCursor.defer,
    child: Align(
      alignment: AlignmentDirectional.centerStart,
      heightFactor: 1,
      child: Padding(padding: contentPadding, child: state.field),
    ),
  );
  if (textStyle != null || foreground != null) {
    field = DefaultTextStyle.merge(
      style: (textStyle ?? const TextStyle()).copyWith(color: foreground),
      child: field,
    );
  }

  // Upstream's grid: two `1fr` rows the `<input>` spans, a button at the
  // start of each. At the natural 32 / 24 the rows are the buttons, 16 / 12;
  // a taller box leaves each button atop its half, as Chrome does.
  Widget stepper(FluentSpinButtonStepperDirection direction) => Expanded(
    child: Align(
      alignment: Alignment.topCenter,
      child: FluentSpinButtonStepper(
        direction: direction,
        style: style,
        onPressed: direction == FluentSpinButtonStepperDirection.increase
            ? state.onIncrease
            : state.onDecrease,
      ),
    ),
  );

  // The grid's `auto` height is the taller of the field and the two stepper
  // rows. The stepper column's size is known from the style, so the field
  // sizes the box and the column is pinned over its end: no intrinsic pass,
  // which a caller's field (a LayoutBuilder, say) may be unable to answer.
  const rest = <WidgetState>{};
  final stepperBox = style.stepperSize?.resolve(rest) ?? const Size(24, 16);
  final content = ConstrainedBox(
    constraints: BoxConstraints(minHeight: stepperBox.height * 2),
    child: Stack(
      fit: StackFit.passthrough,
      children: <Widget>[
        Padding(
          // Upstream's grid `columnGap: spacingHorizontalXS`, at both sizes.
          padding: EdgeInsetsDirectional.only(
            end: stepperBox.width + FluentSpacing.xs,
          ),
          child: field,
        ),
        // The steppers duplicate the increment and decrement actions the
        // control already publishes on itself, so announcing them again would
        // read the field twice.
        PositionedDirectional(
          end: 0,
          top: 0,
          bottom: 0,
          width: stepperBox.width,
          child: ExcludeSemantics(
            child: Column(
              children: <Widget>[
                stepper(FluentSpinButtonStepperDirection.increase),
                stepper(FluentSpinButtonStepperDirection.decrease),
              ],
            ),
          ),
        ),
      ],
    ),
  );

  // A null colour is no side at all, whatever the width says.
  final side = borderColor == null ? FluentStroke.none : borderWidth;
  // A border with no sides is drawn square: upstream's underline zeroes the
  // radius of `::before` ("corners look strange if rounded") and of the focus
  // bar, and keeps the root's for the fill. Underline is the only appearance
  // with no side border, so that is what tells it apart here.
  // ponytail: keyed on the side width, not a style property of its own; add
  // one if a caller ever needs a rounded bottom-only border.
  final edge = side > 0 ? radius : BorderRadius.zero;

  return Stack(
    // Passthrough, so a parent's tight height stretches the box itself, as a
    // CSS `height` would. A loose Stack laid the box out at its own 24 / 32 and
    // pinned the bar to the bottom of the taller Stack, below it.
    fit: StackFit.passthrough,
    children: <Widget>[
      ConstrainedBox(
        constraints: BoxConstraints(
          minHeight: minimumSize.height,
          minWidth: minimumSize.width,
        ),
        child: DecoratedBox(
          decoration: BoxDecoration(color: background, borderRadius: radius),
          child: CustomPaint(
            foregroundPainter: FluentInputBorderPainter(
              radius: edge,
              borderColor: borderColor,
              borderWidth: side,
              bottomBorderColor: ruleColor,
              bottomBorderWidth: ruleColor == null ? side : ruleWidth,
            ),
            child: Padding(padding: padding, child: content),
          ),
        ),
      ),
      if (focusColor != null && focusWidth > 0)
        Positioned(
          left: 0,
          right: 0,
          bottom: 0,
          height: focusWidth,
          child: FluentInputFocusUnderline(
            focused: state.focused,
            color: focusColor,
            borderRadius: BorderRadius.only(
              bottomLeft: edge.bottomLeft,
              bottomRight: edge.bottomRight,
            ),
            thickness: focusWidth,
          ),
        ),
    ],
  );
}