diff --git a/docs.json b/docs.json index d3fd6a2d..22b5ea24 100644 --- a/docs.json +++ b/docs.json @@ -141,6 +141,10 @@ "title": "Radio", "href": "/components/radio" }, + { + "title": "Segmented Control", + "href": "/components/segmented_control" + }, { "title": "Select", "href": "/components/select" diff --git a/docs/components/segmented_control.mdx b/docs/components/segmented_control.mdx new file mode 100644 index 00000000..3443db73 --- /dev/null +++ b/docs/components/segmented_control.mdx @@ -0,0 +1,211 @@ +--- +title: Segmented Control +description: An equal-segment, controlled single-select control with roving keyboard focus +keywords: [flutter, remix, segmented control, single select, roving focus, keyboard] +--- + +A segmented control switches between a small set of mutually exclusive views +or modes. It has a persistent track, equal segment extents, and a selected item +surface. Selection is controlled by `selectedValue`; activating the selected +item does not clear it or emit another change. + +The value type must be non-nullable (`T extends Object`). `null` is reserved for +`selectedValue` to represent no selection; item values themselves cannot be +null, and `onChanged` only emits non-null item values. + + + No Fortal preset ships for this component yet, so there is no `FortalSegmentedControl` and no themed default. An unstyled `RemixSegmentedControl` renders as bare text with no track, selected surface, or focus ring. Every example below passes an explicit style, and your app must do the same. + + +## Basic implementation + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +class ReportingPeriodControl extends StatefulWidget { + const ReportingPeriodControl({super.key}); + + @override + State createState() => + _ReportingPeriodControlState(); +} + +class _ReportingPeriodControlState extends State { + String _value = 'week'; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final disabledForeground = colors.onSurface.withValues(alpha: 0.35); + final itemStyle = SegmentedControlItemStyler() + .paddingX(12) + .paddingY(8) + .spacing(6) + .borderRadius(BorderRadiusGeometryMix.circular(7)) + .labelColor(colors.onSurfaceVariant) + .iconColor(colors.onSurfaceVariant) + .onSelected( + .color(colors.surface) + .labelColor(colors.onSurface) + .iconColor(colors.onSurface) + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: colors.shadow.withValues(alpha: 0.18), + offset: const Offset(0, 1), + blurRadius: 3, + ), + ], + ), + ), + ), + ) + .onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: colors.primary, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ) + .onDisabled( + .label(TextStyler().color(disabledForeground)) + .iconColor(disabledForeground), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + RemixSegmentedControl( + semanticLabel: 'Reporting period', + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'This week'), + RemixSegmentedControlItem(value: 'month', label: 'Month'), + RemixSegmentedControlItem( + value: 'year', + label: 'Year', + enabled: false, + ), + ], + selectedValue: _value, + onChanged: (value) => setState(() => _value = value), + style: SegmentedControlStyler() + .paddingAll(4) + .borderRadius(BorderRadiusGeometryMix.circular(10)) + .color(colors.surfaceContainerHighest) + .item(itemStyle), + ), + const SizedBox(height: 8), + Text('Selected: $_value'), + ], + ); + } +} +``` + + +## Icon-only items + +Icon-only segments must provide a nonblank `semanticLabel`. Labels and semantic +labels containing only whitespace are rejected in debug builds. Naked owns the +single accessible button node; the visual icon and text are excluded beneath +it, so names and selected state are announced exactly once. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +Widget layoutSelector({ + required String value, + required ValueChanged onChanged, + // Required, not optional: there is no themed preset to fall back on. + required SegmentedControlStyler style, +}) { + return RemixSegmentedControl( + semanticLabel: 'Layout', + items: const [ + RemixSegmentedControlItem( + value: 'list', + icon: Icons.view_list, + semanticLabel: 'List view', + ), + RemixSegmentedControlItem( + value: 'grid', + icon: Icons.grid_view, + semanticLabel: 'Grid view', + ), + ], + selectedValue: value, + onChanged: onChanged, + style: style, + ); +} +``` + + +## Vertical orientation + +Set `orientation: Axis.vertical` to equalize item heights and use Up/Down arrow +navigation. Orientation and per-item disabled state are intentional Flutter +extensions to the Radix model. + +## Keyboard behavior + +| Key | Behavior | +| --- | --- | +| `Tab` | Enters on the selected or first enabled segment; the next Tab exits | +| `Arrow Left` / `Arrow Right` | Moves horizontal focus, following LTR/RTL direction | +| `Arrow Up` / `Arrow Down` | Moves vertical focus | +| `Home` / `End` | Moves to the first / last enabled segment | +| `Space` / `Enter` | Selects the focused inactive segment | + +Arrow movement changes focus only. `loop: true` wraps at the ends; set it to +false to clamp. A null `onChanged` or `enabled: false` disables the track and +all items. + +Horizontal visual order, keyboard navigation, and semantics all follow the +nearest `Directionality`. Track styling cannot override that direction; use +`mainAxisSize` and `spacing` for track layout customization. + +## Sizing and wrapping + +The default track is intrinsic width (or intrinsic height vertically). Every +item receives the largest item's main-axis extent. Explicit track constraints +divide the requested extent equally. In narrow parents, labels wrap inside +equal segments; keep labels short and use a visible output label when the +selection needs more explanation. + +## Styling anatomy + +`SegmentedControlStyler` owns the persistent track. Its nested `item` style is +the default for every segment, and each `RemixSegmentedControlItem.style` merges +after it. Put selected, disabled, hover, focus, and press variants on +`SegmentedControlItemStyler`. `containerEffects` supports inset/shadow stacks +and offset focus outlines without changing geometry. A raw `styleSpec` is +authoritative and bypasses fluent group and per-item styles. + +Both stylers start empty. Nothing supplies a track background, segment padding, +selected surface, or focus ring unless you do, so treat `style` as required +rather than optional. + +## v1 visual scope + +No Fortal preset ships for this component in v1. There is no +`FortalSegmentedControl` widget and no `fortalSegmentedControlStyle` recipe, so +a Fortal-themed application must hand the control a `SegmentedControlStyler` +built from `FortalTokens` (or from its own design tokens). A Radix-parity preset +is a follow-up, not a hidden default: callers own the visual layer today. + +The selected surface is static in v1. A sliding indicator and separator +treatment are intentionally not reserved or animated; those remain parity +follow-ups rather than hidden layout layers in this component. diff --git a/packages/playground/lib/registry/component_registry.dart b/packages/playground/lib/registry/component_registry.dart index bf7b218d..f6c222e8 100644 --- a/packages/playground/lib/registry/component_registry.dart +++ b/packages/playground/lib/registry/component_registry.dart @@ -15,6 +15,7 @@ import 'entries/divider_entry.dart'; import 'entries/menu_entry.dart'; import 'entries/progress_entry.dart'; import 'entries/radio_entry.dart'; +import 'entries/segmented_control_entry.dart'; import 'entries/select_entry.dart'; import 'entries/skeleton_entry.dart'; import 'entries/slider_entry.dart'; @@ -54,6 +55,10 @@ final Map components = { brightness: Theme.of(context).brightness, child: PreviewShell(child: buildSelectExample()), ), + 'segmented-control': (context) => FortalScope( + brightness: Theme.of(context).brightness, + child: PreviewShell(child: buildSegmentedControlExample()), + ), 'switch': (context) => FortalScope( brightness: Theme.of(context).brightness, child: PreviewShell(child: buildSwitchExample()), diff --git a/packages/playground/lib/registry/entries/segmented_control_entry.dart b/packages/playground/lib/registry/entries/segmented_control_entry.dart new file mode 100644 index 00000000..41c07e69 --- /dev/null +++ b/packages/playground/lib/registry/entries/segmented_control_entry.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +Widget buildSegmentedControlExample() { + return const SizedBox(width: 340, child: _RemixSegmentedControlPreview()); +} + +class _RemixSegmentedControlPreview extends StatefulWidget { + const _RemixSegmentedControlPreview(); + + @override + State<_RemixSegmentedControlPreview> createState() => + _RemixSegmentedControlPreviewState(); +} + +class _RemixSegmentedControlPreviewState + extends State<_RemixSegmentedControlPreview> { + String _period = 'week'; + String _view = 'grid'; + String _density = 'comfortable'; + + // Deliberate: this preview hand-rolls a style instead of reaching for a + // themed preset. No Fortal recipe ships for the segmented control in v1, so + // an unstyled RemixSegmentedControl would render as bare text. Replace this + // with the Fortal preset once one exists. + SegmentedControlStyler _style(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final disabledForeground = colors.onSurface.withValues(alpha: 0.35); + final selectedShadow = RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: colors.shadow.withValues(alpha: 0.18), + offset: const Offset(0, 1), + blurRadius: 3, + ), + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: colors.outlineVariant, + spreadRadius: 1, + ), + ], + ), + ); + + return SegmentedControlStyler() + .paddingAll(4) + .borderRadius(BorderRadiusGeometryMix.circular(10)) + .color(colors.surfaceContainerHighest) + .item( + SegmentedControlItemStyler() + .paddingX(12) + .paddingY(8) + .spacing(6) + .borderRadius(BorderRadiusGeometryMix.circular(7)) + .labelColor(colors.onSurfaceVariant) + .iconColor(colors.onSurfaceVariant) + .onSelected( + .color(colors.surface) + .labelColor(colors.onSurface) + .iconColor(colors.onSurface) + .containerEffects(selectedShadow), + ) + .onHovered(.color(colors.onSurface.withValues(alpha: 0.06))) + .onPressed(.color(colors.onSurface.withValues(alpha: 0.1))) + .onFocused( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: colors.primary, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ) + .onDisabled( + .label( + TextStyler().color(disabledForeground), + ).iconColor(disabledForeground), + ), + ); + } + + @override + Widget build(BuildContext context) { + final style = _style(context); + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + RemixSegmentedControl( + semanticLabel: 'Reporting period', + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'This week'), + RemixSegmentedControlItem(value: 'month', label: 'Month'), + RemixSegmentedControlItem( + value: 'year', + label: 'Year', + enabled: false, + ), + ], + selectedValue: _period, + onChanged: (value) => setState(() => _period = value), + style: style, + ), + const SizedBox(height: 8), + Text('Selected: $_period'), + const SizedBox(height: 20), + RemixSegmentedControl( + semanticLabel: 'Layout', + items: const [ + RemixSegmentedControlItem( + value: 'list', + icon: Icons.view_list, + semanticLabel: 'List view', + ), + RemixSegmentedControlItem( + value: 'grid', + icon: Icons.grid_view, + semanticLabel: 'Grid view', + ), + RemixSegmentedControlItem( + value: 'board', + icon: Icons.view_kanban, + semanticLabel: 'Board view', + ), + ], + selectedValue: _view, + onChanged: (value) => setState(() => _view = value), + style: style, + ), + const SizedBox(height: 20), + RemixSegmentedControl( + semanticLabel: 'Density', + orientation: Axis.vertical, + items: const [ + RemixSegmentedControlItem(value: 'compact', label: 'Compact'), + RemixSegmentedControlItem( + value: 'comfortable', + label: 'Comfortable', + ), + RemixSegmentedControlItem(value: 'spacious', label: 'Spacious'), + ], + selectedValue: _density, + onChanged: (value) => setState(() => _density = value), + style: style, + ), + ], + ); + } +} diff --git a/packages/playground/lib/routes/all_components.dart b/packages/playground/lib/routes/all_components.dart index 4ef32d01..0f2c0e4b 100644 --- a/packages/playground/lib/routes/all_components.dart +++ b/packages/playground/lib/routes/all_components.dart @@ -10,6 +10,7 @@ import '../registry/entries/data_list_entry.dart'; import '../registry/entries/divider_entry.dart'; import '../registry/entries/progress_entry.dart'; import '../registry/entries/radio_entry.dart'; +import '../registry/entries/segmented_control_entry.dart'; import '../registry/entries/select_entry.dart'; import '../registry/entries/slider_entry.dart'; import '../registry/entries/spinner_entry.dart'; @@ -52,6 +53,7 @@ class AllComponentsPage extends StatelessWidget { _section('Divider', buildDividerExample()), _section('Progress', buildProgressExample()), _section('Radio', buildRadioExample()), + _section('Segmented Control', buildSegmentedControlExample()), _section('Select', buildSelectExample()), _section('Slider', buildSliderExample()), _section('Spinner', buildSpinnerExample()), diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index 04b7fc98..cb4b6ad9 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,5 +1,16 @@ ## Unreleased +- **FEAT**: Add `RemixSegmentedControl`, an equal-segment single-select control + mapped from Radix Segmented Control. A custom render object sizes every + segment to the largest one and divides an explicit track extent equally, + reporting intrinsics that stay consistent with layout so intrinsic-sizing + parents wrap labels instead of overflowing. Selection is controlled and + never cleared by reactivating the selected segment; `T extends Object` keeps + `null` reserved as the no-selection sentinel and `onChanged` non-null. + Roving keyboard focus, `Home`/`End`, optional looping, RTL-aware arrow + navigation, per-item disabled state, and vertical orientation are supported, + and each segment exposes one merged selected-button semantics node. No + Fortal preset ships in v1, so callers own the visual layer. - **FEAT**: Add `RemixTextArea`, a constructor-only multiline facade over `RemixTextField` with two-line auto-growing defaults and the canonical `TextFieldStyler` / `TextFieldSpec` styling surface. diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index d82fcad9..27a43f77 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -17,6 +17,7 @@ export 'src/components/menu/menu.dart'; export 'src/components/popover/popover.dart'; export 'src/components/progress/progress.dart'; export 'src/components/radio/radio.dart'; +export 'src/components/segmented_control/segmented_control.dart'; export 'src/components/select/select.dart'; export 'src/components/skeleton/skeleton.dart'; export 'src/components/slider/slider.dart'; diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control.dart b/packages/remix/lib/src/components/segmented_control/segmented_control.dart new file mode 100644 index 00000000..d6f552ca --- /dev/null +++ b/packages/remix/lib/src/components/segmented_control/segmented_control.dart @@ -0,0 +1,19 @@ +library remix_segmented_control; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:mix/mix.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:naked_ui/naked_ui.dart'; + +import '../../rendering/remix_box_effects.dart'; +import '../../style/style.dart'; +import '../../utilities/remix_style.dart'; + +part 'segmented_control_spec.dart'; +part 'segmented_control_style.dart'; +part 'segmented_control_widget.dart'; +part 'segmented_control.g.dart'; diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart b/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart new file mode 100644 index 00000000..600ff004 --- /dev/null +++ b/packages/remix/lib/src/components/segmented_control/segmented_control.g.dart @@ -0,0 +1,1488 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'segmented_control.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$SegmentedControlSpec + implements Spec, Diagnosticable { + StyleSpec get container; + MainAxisSize? get mainAxisSize; + double? get spacing; + StyleSpec get item; + + @override + Type get type => SegmentedControlSpec; + + @override + SegmentedControlSpec copyWith({ + StyleSpec? container, + MainAxisSize? mainAxisSize, + double? spacing, + StyleSpec? item, + }) { + return SegmentedControlSpec( + container: container ?? this.container, + mainAxisSize: mainAxisSize ?? this.mainAxisSize, + spacing: spacing ?? this.spacing, + item: item ?? this.item, + ); + } + + @override + SegmentedControlSpec lerp(SegmentedControlSpec? other, double t) { + return SegmentedControlSpec( + container: container.lerp(other?.container, t), + mainAxisSize: MixOps.lerpSnap(mainAxisSize, other?.mainAxisSize, t), + spacing: MixOps.lerp(spacing, other?.spacing, t), + item: item.lerp(other?.item, t), + ); + } + + @override + List get props => [container, mainAxisSize, spacing, item]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is SegmentedControlSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('container', container)) + ..add(EnumProperty('mainAxisSize', mainAxisSize)) + ..add(DoubleProperty('spacing', spacing)) + ..add(DiagnosticsProperty('item', item)); + } +} + +@Deprecated( + 'Rename to `_\$SegmentedControlSpec` and migrate the class declaration to `class SegmentedControlSpec with _\$SegmentedControlSpec`. The `_\$SegmentedControlSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$SegmentedControlSpecMethods = _$SegmentedControlSpec; // ignore: unused_element + +mixin _$SegmentedControlItemSpec + implements Spec, Diagnosticable { + StyleSpec get container; + double? get spacing; + StyleSpec get label; + StyleSpec get icon; + RemixBoxEffectsSpec? get containerEffects; + + @override + Type get type => SegmentedControlItemSpec; + + @override + SegmentedControlItemSpec copyWith({ + StyleSpec? container, + double? spacing, + StyleSpec? label, + StyleSpec? icon, + RemixBoxEffectsSpec? containerEffects, + }) { + return SegmentedControlItemSpec( + container: container ?? this.container, + spacing: spacing ?? this.spacing, + label: label ?? this.label, + icon: icon ?? this.icon, + containerEffects: containerEffects ?? this.containerEffects, + ); + } + + @override + SegmentedControlItemSpec lerp(SegmentedControlItemSpec? other, double t) { + return SegmentedControlItemSpec( + container: container.lerp(other?.container, t), + spacing: MixOps.lerp(spacing, other?.spacing, t), + label: label.lerp(other?.label, t), + icon: icon.lerp(other?.icon, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), + ); + } + + @override + List get props => [ + container, + spacing, + label, + icon, + containerEffects, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is SegmentedControlItemSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('container', container)) + ..add(DoubleProperty('spacing', spacing)) + ..add(DiagnosticsProperty('label', label)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)); + } +} + +@Deprecated( + 'Rename to `_\$SegmentedControlItemSpec` and migrate the class declaration to `class SegmentedControlItemSpec with _\$SegmentedControlItemSpec`. The `_\$SegmentedControlItemSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$SegmentedControlItemSpecMethods = _$SegmentedControlItemSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class SegmentedControlStyler + extends MixStyler + with RemixBoxStylerMixin { + final Prop>? $container; + final Prop? $mainAxisSize; + final Prop? $spacing; + final Prop>? $item; + + const SegmentedControlStyler.create({ + Prop>? container, + Prop? mainAxisSize, + Prop? spacing, + Prop>? item, + super.variants, + super.modifier, + super.animation, + }) : $container = container, + $mainAxisSize = mainAxisSize, + $spacing = spacing, + $item = item; + + SegmentedControlStyler({ + BoxStyler? container, + MainAxisSize? mainAxisSize, + double? spacing, + SegmentedControlItemStyler? item, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + container: Prop.maybeMix(container), + mainAxisSize: Prop.maybe(mainAxisSize), + spacing: Prop.maybe(spacing), + item: Prop.maybeMix(item), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory SegmentedControlStyler.container(BoxStyler value) => + SegmentedControlStyler().container(value); + factory SegmentedControlStyler.mainAxisSize(MainAxisSize value) => + SegmentedControlStyler().mainAxisSize(value); + factory SegmentedControlStyler.spacing(double value) => + SegmentedControlStyler().spacing(value); + factory SegmentedControlStyler.item(SegmentedControlItemStyler value) => + SegmentedControlStyler().item(value); + factory SegmentedControlStyler.alignment(AlignmentGeometry value) => + SegmentedControlStyler().alignment(value); + factory SegmentedControlStyler.padding(EdgeInsetsGeometryMix value) => + SegmentedControlStyler().padding(value); + factory SegmentedControlStyler.margin(EdgeInsetsGeometryMix value) => + SegmentedControlStyler().margin(value); + factory SegmentedControlStyler.constraints(BoxConstraintsMix value) => + SegmentedControlStyler().constraints(value); + factory SegmentedControlStyler.decoration(DecorationMix value) => + SegmentedControlStyler().decoration(value); + factory SegmentedControlStyler.foregroundDecoration(DecorationMix value) => + SegmentedControlStyler().foregroundDecoration(value); + factory SegmentedControlStyler.clipBehavior(Clip value) => + SegmentedControlStyler().clipBehavior(value); + factory SegmentedControlStyler.color(Color value) => + SegmentedControlStyler().color(value); + factory SegmentedControlStyler.gradient(GradientMix value) => + SegmentedControlStyler().gradient(value); + factory SegmentedControlStyler.border(BoxBorderMix value) => + SegmentedControlStyler().border(value); + factory SegmentedControlStyler.borderRadius(BorderRadiusGeometryMix value) => + SegmentedControlStyler().borderRadius(value); + factory SegmentedControlStyler.elevation(ElevationShadow value) => + SegmentedControlStyler().elevation(value); + factory SegmentedControlStyler.shadow(BoxShadowMix value) => + SegmentedControlStyler().shadow(value); + factory SegmentedControlStyler.shadows(List value) => + SegmentedControlStyler().shadows(value); + factory SegmentedControlStyler.width(double value) => + SegmentedControlStyler().width(value); + factory SegmentedControlStyler.height(double value) => + SegmentedControlStyler().height(value); + factory SegmentedControlStyler.size(double width, double height) => + SegmentedControlStyler().size(width, height); + factory SegmentedControlStyler.minWidth(double value) => + SegmentedControlStyler().minWidth(value); + factory SegmentedControlStyler.maxWidth(double value) => + SegmentedControlStyler().maxWidth(value); + factory SegmentedControlStyler.minHeight(double value) => + SegmentedControlStyler().minHeight(value); + factory SegmentedControlStyler.maxHeight(double value) => + SegmentedControlStyler().maxHeight(value); + factory SegmentedControlStyler.scale( + double scale, { + Alignment alignment = .center, + }) => SegmentedControlStyler().scale(scale, alignment: alignment); + factory SegmentedControlStyler.rotate( + double radians, { + Alignment alignment = .center, + }) => SegmentedControlStyler().rotate(radians, alignment: alignment); + factory SegmentedControlStyler.translate( + double x, + double y, [ + double z = 0.0, + ]) => SegmentedControlStyler().translate(x, y, z); + factory SegmentedControlStyler.skew(double skewX, double skewY) => + SegmentedControlStyler().skew(skewX, skewY); + factory SegmentedControlStyler.textStyle(TextStyler value) => + SegmentedControlStyler().textStyle(value); + factory SegmentedControlStyler.image(DecorationImageMix value) => + SegmentedControlStyler().image(value); + factory SegmentedControlStyler.shape(ShapeBorderMix value) => + SegmentedControlStyler().shape(value); + factory SegmentedControlStyler.backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlStyler.backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlStyler.backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlStyler.linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => SegmentedControlStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory SegmentedControlStyler.radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => SegmentedControlStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory SegmentedControlStyler.sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => SegmentedControlStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory SegmentedControlStyler.foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => SegmentedControlStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory SegmentedControlStyler.foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => SegmentedControlStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory SegmentedControlStyler.foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => SegmentedControlStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory SegmentedControlStyler.transform( + Matrix4 value, { + Alignment alignment = .center, + }) => SegmentedControlStyler().transform(value, alignment: alignment); + + SegmentedControlStyler alignment(AlignmentGeometry value) { + return container(BoxStyler().alignment(value)); + } + + SegmentedControlStyler padding(EdgeInsetsGeometryMix value) { + return container(BoxStyler().padding(value)); + } + + SegmentedControlStyler margin(EdgeInsetsGeometryMix value) { + return container(BoxStyler().margin(value)); + } + + SegmentedControlStyler constraints(BoxConstraintsMix value) { + return container(BoxStyler().constraints(value)); + } + + SegmentedControlStyler decoration(DecorationMix value) { + return container(BoxStyler().decoration(value)); + } + + SegmentedControlStyler foregroundDecoration(DecorationMix value) { + return container(BoxStyler().foregroundDecoration(value)); + } + + SegmentedControlStyler clipBehavior(Clip value) { + return container(BoxStyler().clipBehavior(value)); + } + + SegmentedControlStyler color(Color value) { + return container(BoxStyler().color(value)); + } + + SegmentedControlStyler gradient(GradientMix value) { + return container(BoxStyler().gradient(value)); + } + + SegmentedControlStyler border(BoxBorderMix value) { + return container(BoxStyler().border(value)); + } + + SegmentedControlStyler borderRadius(BorderRadiusGeometryMix value) { + return container(BoxStyler().borderRadius(value)); + } + + SegmentedControlStyler elevation(ElevationShadow value) { + return container(BoxStyler().elevation(value)); + } + + SegmentedControlStyler shadow(BoxShadowMix value) { + return container(BoxStyler().shadow(value)); + } + + SegmentedControlStyler shadows(List value) { + return container(BoxStyler().shadows(value)); + } + + SegmentedControlStyler width(double value) { + return container(BoxStyler().width(value)); + } + + SegmentedControlStyler height(double value) { + return container(BoxStyler().height(value)); + } + + SegmentedControlStyler size(double width, double height) { + return container(BoxStyler().size(width, height)); + } + + SegmentedControlStyler minWidth(double value) { + return container(BoxStyler().minWidth(value)); + } + + SegmentedControlStyler maxWidth(double value) { + return container(BoxStyler().maxWidth(value)); + } + + SegmentedControlStyler minHeight(double value) { + return container(BoxStyler().minHeight(value)); + } + + SegmentedControlStyler maxHeight(double value) { + return container(BoxStyler().maxHeight(value)); + } + + SegmentedControlStyler scale(double scale, {Alignment alignment = .center}) { + return container(BoxStyler().scale(scale, alignment: alignment)); + } + + SegmentedControlStyler rotate( + double radians, { + Alignment alignment = .center, + }) { + return container(BoxStyler().rotate(radians, alignment: alignment)); + } + + SegmentedControlStyler translate(double x, double y, [double z = 0.0]) { + return container(BoxStyler().translate(x, y, z)); + } + + SegmentedControlStyler skew(double skewX, double skewY) { + return container(BoxStyler().skew(skewX, skewY)); + } + + SegmentedControlStyler textStyle(TextStyler value) { + return container(BoxStyler().textStyle(value)); + } + + SegmentedControlStyler image(DecorationImageMix value) { + return container(BoxStyler().image(value)); + } + + SegmentedControlStyler shape(ShapeBorderMix value) { + return container(BoxStyler().shape(value)); + } + + SegmentedControlStyler backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlStyler backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlStyler backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlStyler linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + SegmentedControlStyler transform( + Matrix4 value, { + Alignment alignment = .center, + }) { + return container(BoxStyler().transform(value, alignment: alignment)); + } + + /// Sets the container. + SegmentedControlStyler container(BoxStyler value) { + return merge(SegmentedControlStyler(container: value)); + } + + /// Sets the mainAxisSize. + SegmentedControlStyler mainAxisSize(MainAxisSize value) { + return merge(SegmentedControlStyler(mainAxisSize: value)); + } + + /// Sets the spacing. + SegmentedControlStyler spacing(double value) { + return merge(SegmentedControlStyler(spacing: value)); + } + + /// Sets the item. + SegmentedControlStyler item(SegmentedControlItemStyler value) { + return merge(SegmentedControlStyler(item: value)); + } + + /// Sets the animation configuration. + @override + SegmentedControlStyler animate(AnimationConfig value) { + return merge(SegmentedControlStyler(animation: value)); + } + + /// Sets the style variants. + @override + SegmentedControlStyler variants( + List> value, + ) { + return merge(SegmentedControlStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + SegmentedControlStyler wrap(WidgetModifierConfig value) { + return merge(SegmentedControlStyler(modifier: value)); + } + + /// Sets the widget modifier. + SegmentedControlStyler modifier(WidgetModifierConfig value) { + return merge(SegmentedControlStyler(modifier: value)); + } + + /// Merges with another [SegmentedControlStyler]. + @override + SegmentedControlStyler merge(SegmentedControlStyler? other) { + return SegmentedControlStyler.create( + container: MixOps.merge($container, other?.$container), + mainAxisSize: MixOps.merge($mainAxisSize, other?.$mainAxisSize), + spacing: MixOps.merge($spacing, other?.$spacing), + item: MixOps.merge($item, other?.$item), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = SegmentedControlSpec( + container: MixOps.resolve(context, $container), + mainAxisSize: MixOps.resolve(context, $mainAxisSize), + spacing: MixOps.resolve(context, $spacing), + item: MixOps.resolve(context, $item), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('mainAxisSize', $mainAxisSize)) + ..add(DiagnosticsProperty('spacing', $spacing)) + ..add(DiagnosticsProperty('item', $item)); + } + + @override + List get props => [ + $container, + $mainAxisSize, + $spacing, + $item, + $animation, + $modifier, + $variants, + ]; +} + +class SegmentedControlItemStyler + extends MixStyler + with + RemixBoxStylerMixin, + LabelStyleMixin, + IconStyleMixin { + final Prop>? $container; + final Prop? $spacing; + final Prop>? $label; + final Prop>? $icon; + final Prop? $containerEffects; + + const SegmentedControlItemStyler.create({ + Prop>? container, + Prop? spacing, + Prop>? label, + Prop>? icon, + Prop? containerEffects, + super.variants, + super.modifier, + super.animation, + }) : $container = container, + $spacing = spacing, + $label = label, + $icon = icon, + $containerEffects = containerEffects; + + SegmentedControlItemStyler({ + BoxStyler? container, + double? spacing, + TextStyler? label, + IconStyler? icon, + RemixBoxEffectsMix? containerEffects, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + container: Prop.maybeMix(container), + spacing: Prop.maybe(spacing), + label: Prop.maybeMix(label), + icon: Prop.maybeMix(icon), + containerEffects: Prop.maybeMix(containerEffects), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory SegmentedControlItemStyler.container(BoxStyler value) => + SegmentedControlItemStyler().container(value); + factory SegmentedControlItemStyler.spacing(double value) => + SegmentedControlItemStyler().spacing(value); + factory SegmentedControlItemStyler.label(TextStyler value) => + SegmentedControlItemStyler().label(value); + factory SegmentedControlItemStyler.icon(IconStyler value) => + SegmentedControlItemStyler().icon(value); + factory SegmentedControlItemStyler.containerEffects( + RemixBoxEffectsMix value, + ) => SegmentedControlItemStyler().containerEffects(value); + factory SegmentedControlItemStyler.alignment(AlignmentGeometry value) => + SegmentedControlItemStyler().alignment(value); + factory SegmentedControlItemStyler.padding(EdgeInsetsGeometryMix value) => + SegmentedControlItemStyler().padding(value); + factory SegmentedControlItemStyler.margin(EdgeInsetsGeometryMix value) => + SegmentedControlItemStyler().margin(value); + factory SegmentedControlItemStyler.constraints(BoxConstraintsMix value) => + SegmentedControlItemStyler().constraints(value); + factory SegmentedControlItemStyler.decoration(DecorationMix value) => + SegmentedControlItemStyler().decoration(value); + factory SegmentedControlItemStyler.foregroundDecoration( + DecorationMix value, + ) => SegmentedControlItemStyler().foregroundDecoration(value); + factory SegmentedControlItemStyler.clipBehavior(Clip value) => + SegmentedControlItemStyler().clipBehavior(value); + factory SegmentedControlItemStyler.color(Color value) => + SegmentedControlItemStyler().color(value); + factory SegmentedControlItemStyler.gradient(GradientMix value) => + SegmentedControlItemStyler().gradient(value); + factory SegmentedControlItemStyler.border(BoxBorderMix value) => + SegmentedControlItemStyler().border(value); + factory SegmentedControlItemStyler.borderRadius( + BorderRadiusGeometryMix value, + ) => SegmentedControlItemStyler().borderRadius(value); + factory SegmentedControlItemStyler.elevation(ElevationShadow value) => + SegmentedControlItemStyler().elevation(value); + factory SegmentedControlItemStyler.shadow(BoxShadowMix value) => + SegmentedControlItemStyler().shadow(value); + factory SegmentedControlItemStyler.shadows(List value) => + SegmentedControlItemStyler().shadows(value); + factory SegmentedControlItemStyler.width(double value) => + SegmentedControlItemStyler().width(value); + factory SegmentedControlItemStyler.height(double value) => + SegmentedControlItemStyler().height(value); + factory SegmentedControlItemStyler.size(double width, double height) => + SegmentedControlItemStyler().size(width, height); + factory SegmentedControlItemStyler.minWidth(double value) => + SegmentedControlItemStyler().minWidth(value); + factory SegmentedControlItemStyler.maxWidth(double value) => + SegmentedControlItemStyler().maxWidth(value); + factory SegmentedControlItemStyler.minHeight(double value) => + SegmentedControlItemStyler().minHeight(value); + factory SegmentedControlItemStyler.maxHeight(double value) => + SegmentedControlItemStyler().maxHeight(value); + factory SegmentedControlItemStyler.scale( + double scale, { + Alignment alignment = .center, + }) => SegmentedControlItemStyler().scale(scale, alignment: alignment); + factory SegmentedControlItemStyler.rotate( + double radians, { + Alignment alignment = .center, + }) => SegmentedControlItemStyler().rotate(radians, alignment: alignment); + factory SegmentedControlItemStyler.translate( + double x, + double y, [ + double z = 0.0, + ]) => SegmentedControlItemStyler().translate(x, y, z); + factory SegmentedControlItemStyler.skew(double skewX, double skewY) => + SegmentedControlItemStyler().skew(skewX, skewY); + factory SegmentedControlItemStyler.textStyle(TextStyler value) => + SegmentedControlItemStyler().textStyle(value); + factory SegmentedControlItemStyler.image(DecorationImageMix value) => + SegmentedControlItemStyler().image(value); + factory SegmentedControlItemStyler.shape(ShapeBorderMix value) => + SegmentedControlItemStyler().shape(value); + factory SegmentedControlItemStyler.backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlItemStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlItemStyler.backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlItemStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlItemStyler.backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) => SegmentedControlItemStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ); + factory SegmentedControlItemStyler.linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => SegmentedControlItemStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => SegmentedControlItemStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => SegmentedControlItemStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) => SegmentedControlItemStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) => SegmentedControlItemStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) => SegmentedControlItemStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ); + factory SegmentedControlItemStyler.transform( + Matrix4 value, { + Alignment alignment = .center, + }) => SegmentedControlItemStyler().transform(value, alignment: alignment); + + SegmentedControlItemStyler alignment(AlignmentGeometry value) { + return container(BoxStyler().alignment(value)); + } + + SegmentedControlItemStyler padding(EdgeInsetsGeometryMix value) { + return container(BoxStyler().padding(value)); + } + + SegmentedControlItemStyler margin(EdgeInsetsGeometryMix value) { + return container(BoxStyler().margin(value)); + } + + SegmentedControlItemStyler constraints(BoxConstraintsMix value) { + return container(BoxStyler().constraints(value)); + } + + SegmentedControlItemStyler decoration(DecorationMix value) { + return container(BoxStyler().decoration(value)); + } + + SegmentedControlItemStyler foregroundDecoration(DecorationMix value) { + return container(BoxStyler().foregroundDecoration(value)); + } + + SegmentedControlItemStyler clipBehavior(Clip value) { + return container(BoxStyler().clipBehavior(value)); + } + + SegmentedControlItemStyler color(Color value) { + return container(BoxStyler().color(value)); + } + + SegmentedControlItemStyler gradient(GradientMix value) { + return container(BoxStyler().gradient(value)); + } + + SegmentedControlItemStyler border(BoxBorderMix value) { + return container(BoxStyler().border(value)); + } + + SegmentedControlItemStyler borderRadius(BorderRadiusGeometryMix value) { + return container(BoxStyler().borderRadius(value)); + } + + SegmentedControlItemStyler elevation(ElevationShadow value) { + return container(BoxStyler().elevation(value)); + } + + SegmentedControlItemStyler shadow(BoxShadowMix value) { + return container(BoxStyler().shadow(value)); + } + + SegmentedControlItemStyler shadows(List value) { + return container(BoxStyler().shadows(value)); + } + + SegmentedControlItemStyler width(double value) { + return container(BoxStyler().width(value)); + } + + SegmentedControlItemStyler height(double value) { + return container(BoxStyler().height(value)); + } + + SegmentedControlItemStyler size(double width, double height) { + return container(BoxStyler().size(width, height)); + } + + SegmentedControlItemStyler minWidth(double value) { + return container(BoxStyler().minWidth(value)); + } + + SegmentedControlItemStyler maxWidth(double value) { + return container(BoxStyler().maxWidth(value)); + } + + SegmentedControlItemStyler minHeight(double value) { + return container(BoxStyler().minHeight(value)); + } + + SegmentedControlItemStyler maxHeight(double value) { + return container(BoxStyler().maxHeight(value)); + } + + SegmentedControlItemStyler scale( + double scale, { + Alignment alignment = .center, + }) { + return container(BoxStyler().scale(scale, alignment: alignment)); + } + + SegmentedControlItemStyler rotate( + double radians, { + Alignment alignment = .center, + }) { + return container(BoxStyler().rotate(radians, alignment: alignment)); + } + + SegmentedControlItemStyler translate(double x, double y, [double z = 0.0]) { + return container(BoxStyler().translate(x, y, z)); + } + + SegmentedControlItemStyler skew(double skewX, double skewY) { + return container(BoxStyler().skew(skewX, skewY)); + } + + SegmentedControlItemStyler textStyle(TextStyler value) { + return container(BoxStyler().textStyle(value)); + } + + SegmentedControlItemStyler image(DecorationImageMix value) { + return container(BoxStyler().image(value)); + } + + SegmentedControlItemStyler shape(ShapeBorderMix value) { + return container(BoxStyler().shape(value)); + } + + SegmentedControlItemStyler backgroundImage( + ImageProvider image, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImage( + image, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlItemStyler backgroundImageUrl( + String url, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageUrl( + url, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlItemStyler backgroundImageAsset( + String path, { + BoxFit? fit, + AlignmentGeometry? alignment, + ImageRepeat repeat = .noRepeat, + }) { + return container( + BoxStyler().backgroundImageAsset( + path, + fit: fit, + alignment: alignment, + repeat: repeat, + ), + ); + } + + SegmentedControlItemStyler linearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().linearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler radialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().radialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler sweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().sweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler foregroundLinearGradient({ + required List colors, + List? stops, + AlignmentGeometry? begin, + AlignmentGeometry? end, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundLinearGradient( + colors: colors, + stops: stops, + begin: begin, + end: end, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler foregroundRadialGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? radius, + AlignmentGeometry? focal, + double? focalRadius, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundRadialGradient( + colors: colors, + stops: stops, + center: center, + radius: radius, + focal: focal, + focalRadius: focalRadius, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler foregroundSweepGradient({ + required List colors, + List? stops, + AlignmentGeometry? center, + double? startAngle, + double? endAngle, + TileMode? tileMode, + }) { + return container( + BoxStyler().foregroundSweepGradient( + colors: colors, + stops: stops, + center: center, + startAngle: startAngle, + endAngle: endAngle, + tileMode: tileMode, + ), + ); + } + + SegmentedControlItemStyler transform( + Matrix4 value, { + Alignment alignment = .center, + }) { + return container(BoxStyler().transform(value, alignment: alignment)); + } + + /// Sets the container. + SegmentedControlItemStyler container(BoxStyler value) { + return merge(SegmentedControlItemStyler(container: value)); + } + + /// Sets the spacing. + SegmentedControlItemStyler spacing(double value) { + return merge(SegmentedControlItemStyler(spacing: value)); + } + + /// Sets the label. + @override + SegmentedControlItemStyler label(TextStyler value) { + return merge(SegmentedControlItemStyler(label: value)); + } + + /// Sets the icon. + @override + SegmentedControlItemStyler icon(IconStyler value) { + return merge(SegmentedControlItemStyler(icon: value)); + } + + /// Sets the containerEffects. + SegmentedControlItemStyler containerEffects(RemixBoxEffectsMix value) { + return merge(SegmentedControlItemStyler(containerEffects: value)); + } + + /// Sets the animation configuration. + @override + SegmentedControlItemStyler animate(AnimationConfig value) { + return merge(SegmentedControlItemStyler(animation: value)); + } + + /// Sets the style variants. + @override + SegmentedControlItemStyler variants( + List> value, + ) { + return merge(SegmentedControlItemStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + SegmentedControlItemStyler wrap(WidgetModifierConfig value) { + return merge(SegmentedControlItemStyler(modifier: value)); + } + + /// Sets the widget modifier. + SegmentedControlItemStyler modifier(WidgetModifierConfig value) { + return merge(SegmentedControlItemStyler(modifier: value)); + } + + /// Merges with another [SegmentedControlItemStyler]. + @override + SegmentedControlItemStyler merge(SegmentedControlItemStyler? other) { + return SegmentedControlItemStyler.create( + container: MixOps.merge($container, other?.$container), + spacing: MixOps.merge($spacing, other?.$spacing), + label: MixOps.merge($label, other?.$label), + icon: MixOps.merge($icon, other?.$icon), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = SegmentedControlItemSpec( + container: MixOps.resolve(context, $container), + spacing: MixOps.resolve(context, $spacing), + label: MixOps.resolve(context, $label), + icon: MixOps.resolve(context, $icon), + containerEffects: MixOps.resolve(context, $containerEffects), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('spacing', $spacing)) + ..add(DiagnosticsProperty('label', $label)) + ..add(DiagnosticsProperty('icon', $icon)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)); + } + + @override + List get props => [ + $container, + $spacing, + $label, + $icon, + $containerEffects, + $animation, + $modifier, + $variants, + ]; +} diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control_spec.dart b/packages/remix/lib/src/components/segmented_control/segmented_control_spec.dart new file mode 100644 index 00000000..8123abf2 --- /dev/null +++ b/packages/remix/lib/src/components/segmented_control/segmented_control_spec.dart @@ -0,0 +1,83 @@ +part of 'segmented_control.dart'; + +/// Resolved visual properties for a [RemixSegmentedControl]. +@MixableSpec(extraStylerMixins: [RemixBoxStylerMixin]) +class SegmentedControlSpec with _$SegmentedControlSpec { + /// Layout and decoration for the persistent control track. + @override + @MixableField(forwardStyler: true) + final StyleSpec container; + + /// Main-axis sizing for the equal-segment track layout. + @override + final MainAxisSize? mainAxisSize; + + /// Space between adjacent segments. + @override + final double? spacing; + + /// Default visual style for every segment. + @override + final StyleSpec item; + + const SegmentedControlSpec({ + StyleSpec? container, + this.mainAxisSize, + this.spacing, + StyleSpec? item, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: SegmentedControlItemSpec()); +} + +/// Resolved visual properties for one segment. +@MixableSpec( + extraStylerMixins: [RemixBoxStylerMixin, LabelStyleMixin, IconStyleMixin], +) +class SegmentedControlItemSpec with _$SegmentedControlItemSpec { + /// Decoration and box layout for the segment surface. + @override + @MixableField(forwardStyler: true) + final StyleSpec container; + + /// Space between the optional icon and label. + @override + final double? spacing; + + /// Text style for the optional label. + @override + final StyleSpec label; + + /// Icon style for the optional icon. + @override + final StyleSpec icon; + + /// Paint-only effects for the segment surface. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + + const SegmentedControlItemSpec({ + StyleSpec? container, + this.spacing, + StyleSpec? label, + StyleSpec? icon, + this.containerEffects, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + label = label ?? const StyleSpec(spec: TextSpec()), + icon = icon ?? const StyleSpec(spec: IconSpec()); + + // Deliberate: route effects through lerpNullable so shadows/blends animate; + // the generator's default snap-lerps unrecognized spec types. + @override + SegmentedControlItemSpec lerp(SegmentedControlItemSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } +} diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control_style.dart b/packages/remix/lib/src/components/segmented_control/segmented_control_style.dart new file mode 100644 index 00000000..6014b2d8 --- /dev/null +++ b/packages/remix/lib/src/components/segmented_control/segmented_control_style.dart @@ -0,0 +1,34 @@ +part of 'segmented_control.dart'; + +/// Style helpers for [RemixSegmentedControl]. +/// +/// Hand-written rather than generated: `@MixableSpec(target:)` cannot express a +/// generic widget, so generic components carry their own `call` the same way +/// [RemixToggleGroup] and [RemixRadio] do. +extension RemixSegmentedControlStylerRemixHelpers on SegmentedControlStyler { + /// Creates a [RemixSegmentedControl] with this style applied. + RemixSegmentedControl call({ + Key? key, + required List> items, + required T? selectedValue, + ValueChanged? onChanged, + bool enabled = true, + Axis orientation = .horizontal, + bool loop = true, + String? semanticLabel, + bool excludeSemantics = false, + }) { + return RemixSegmentedControl( + key: key, + items: items, + selectedValue: selectedValue, + onChanged: onChanged, + enabled: enabled, + orientation: orientation, + loop: loop, + semanticLabel: semanticLabel, + excludeSemantics: excludeSemantics, + style: this, + ); + } +} diff --git a/packages/remix/lib/src/components/segmented_control/segmented_control_widget.dart b/packages/remix/lib/src/components/segmented_control/segmented_control_widget.dart new file mode 100644 index 00000000..69ebd3ee --- /dev/null +++ b/packages/remix/lib/src/components/segmented_control/segmented_control_widget.dart @@ -0,0 +1,741 @@ +part of 'segmented_control.dart'; + +/// Declarative data for one option in a [RemixSegmentedControl]. +/// +/// [T] must be non-nullable because `null` is reserved by the control as the +/// no-selection sentinel. +class RemixSegmentedControlItem { + /// The value selected when this segment is activated. + /// + /// Values must be unique and keep stable equality and hash-code behavior for + /// the lifetime of the rendered item. + final T value; + + /// Optional text shown in the segment. + /// + /// When provided, it must contain a non-whitespace character. + final String? label; + + /// Optional icon shown before [label]. + final IconData? icon; + + /// Accessibility label for this segment. + /// + /// Falls back to [label] when omitted. Icon-only items must provide a value + /// containing at least one non-whitespace character. + final String? semanticLabel; + + /// Whether this segment can receive focus and be activated. + final bool enabled; + + /// Optional caller-owned focus node. + final FocusNode? focusNode; + + /// Whether this segment requests initial focus when the group mounts. + final bool autofocus; + + /// Per-item style merged after the control's default item style. + /// + /// An authoritative raw `styleSpec` on the owning control bypasses this + /// fluent style. + final SegmentedControlItemStyler style; + + const RemixSegmentedControlItem({ + required this.value, + this.label, + this.icon, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.style = const SegmentedControlItemStyler.create(), + }) : assert( + label != null || icon != null, + 'At least one of label or icon must be provided', + ), + assert( + label != null || semanticLabel != null, + 'Icon-only segmented control items require a semanticLabel', + ); +} + +/// An equal-segment, single-select control with roving keyboard focus. +/// +/// This deliberately parallels [RemixToggleGroup] instead of wrapping it. +/// Both controls compose Naked's headless toggle primitives, but a segmented +/// control owns a persistent track, equal segment layout, and effects-aware +/// item surfaces as a distinct public visual anatomy. +/// +/// [orientation] and per-item disabled state are Flutter extensions to the +/// Radix Segmented Control model. Activating the selected segment never clears +/// the controlled selection. +/// +/// [T] must be non-nullable. A null [selectedValue] represents no selection. +class RemixSegmentedControl extends StatelessWidget { + const RemixSegmentedControl({ + super.key, + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const SegmentedControlStyler.create(), + this.styleSpec, + }); + + /// Items rendered in visual and focus-traversal order. + /// + /// Item values are non-null by type and must be unique. The list may be empty + /// when [selectedValue] is null. Do not mutate the list during a build; + /// rebuild with a new list when its contents or order change. + final List> items; + + /// The currently selected item value, or null when no item is selected. + final T? selectedValue; + + /// Called with the non-null value of an activated inactive segment. + /// + /// The control never emits null or clears the selection through this + /// callback; null is reserved for the controlled [selectedValue] sentinel. + /// + /// When null, the entire control is disabled, including focus, activation, + /// semantics actions, and disabled track/item styling. + final ValueChanged? onChanged; + + /// Whether the entire control is interactive. + final bool enabled; + + /// Axis used for layout and arrow-key navigation. + final Axis orientation; + + /// Whether arrow navigation wraps at the ends. + final bool loop; + + /// Accessibility label for the control. + /// + /// When provided, it must contain a non-whitespace character. + final String? semanticLabel; + + /// Whether the control and all segments are hidden from semantics. + final bool excludeSemantics; + + /// Fluent visual style for the track and its default item style. + final SegmentedControlStyler style; + + /// Optional raw style spec that bypasses fluent style resolution. + final SegmentedControlSpec? styleSpec; + + static final styleFrom = SegmentedControlStyler.new; + + bool _debugConfigurationIsValid(List> snapshot) { + final controlSemanticLabel = semanticLabel; + assert( + controlSemanticLabel == null || controlSemanticLabel.trim().isNotEmpty, + 'RemixSegmentedControl semanticLabel must not be blank.', + ); + + final values = {}; + var autofocusCount = 0; + + for (final item in snapshot) { + final label = item.label; + final semanticLabel = item.semanticLabel; + assert( + label == null || label.trim().isNotEmpty, + 'RemixSegmentedControl item labels must not be blank.', + ); + assert( + semanticLabel == null || semanticLabel.trim().isNotEmpty, + 'RemixSegmentedControl item semantic labels must not be blank.', + ); + if (!values.add(item.value)) { + throw FlutterError( + 'RemixSegmentedControl item values must be unique. ' + 'Duplicate value: ${item.value}.', + ); + } + if (item.autofocus) autofocusCount += 1; + } + + if (selectedValue != null && !values.contains(selectedValue)) { + throw FlutterError( + 'RemixSegmentedControl selectedValue must match one item. ' + 'No item has value: $selectedValue.', + ); + } + + if (autofocusCount > 1) { + throw FlutterError( + 'Only one item may autofocus in a RemixSegmentedControl.', + ); + } + + return true; + } + + @override + Widget build(BuildContext context) { + final snapshot = List>.unmodifiable(items); + assert(_debugConfigurationIsValid(snapshot)); + + final handleChanged = onChanged; + final groupDisabled = !enabled || handleChanged == null; + + return NakedToggleGroup( + selectedValue: selectedValue, + onChanged: handleChanged == null + ? null + : (value) { + if (value != null) handleChanged(value); + }, + enabled: enabled, + orientation: orientation, + loop: loop, + semanticLabel: semanticLabel, + excludeSemantics: excludeSemantics, + child: WidgetStateProvider( + states: groupDisabled ? const {WidgetState.disabled} : const {}, + child: RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + final textDirection = Directionality.of(context); + return StyleSpecBuilder( + key: const ValueKey('RemixSegmentedControl.track'), + styleSpec: spec.container, + builder: (context, trackSpec) { + final fillMainAxis = spec.mainAxisSize == MainAxisSize.max; + final track = Box( + styleSpec: StyleSpec(spec: trackSpec), + child: _EqualSegmentLayout( + orientation: orientation, + spacing: spec.spacing ?? 0, + textDirection: textDirection, + fillMainAxis: fillMainAxis, + children: [ + for (final item in snapshot) + KeyedSubtree( + key: ValueKey(item.value), + child: _RemixSegmentedControlItemWidget( + data: item, + defaultStyle: styleSpec == null ? style : null, + defaultStyleSpec: styleSpec == null + ? null + : spec.item, + ), + ), + ], + ), + ); + return Align( + alignment: AlignmentDirectional.topStart, + widthFactor: orientation == Axis.horizontal && fillMainAxis + ? null + : 1, + heightFactor: orientation == Axis.vertical && fillMainAxis + ? null + : 1, + child: track, + ); + }, + ); + }, + ), + ), + ); + } +} + +class _RemixSegmentedControlItemWidget + extends StatelessWidget { + const _RemixSegmentedControlItemWidget({ + super.key, + required this.data, + this.defaultStyle, + this.defaultStyleSpec, + }); + + final RemixSegmentedControlItem data; + final SegmentedControlStyler? defaultStyle; + final StyleSpec? defaultStyleSpec; + + StyleSpec _resolveStyle(BuildContext context) { + final rawDefault = defaultStyleSpec; + if (rawDefault != null) return rawDefault; + + final compositeStyle = defaultStyle!.merge( + SegmentedControlStyler(item: data.style), + ); + + return compositeStyle.build(context).spec.item; + } + + @override + Widget build(BuildContext context) { + // Naked emits focus and option properties on nested semantics nodes. + // Collapse them into one named, focusable option node. + return MergeSemantics( + child: NakedToggleOption( + value: data.value, + enabled: data.enabled, + focusNode: data.focusNode, + autofocus: data.autofocus, + semanticLabel: data.semanticLabel ?? data.label, + builder: (context, state, _) { + return WidgetStateProvider( + states: state.states, + child: Builder( + builder: (context) { + return ExcludeSemantics( + child: StyleSpecBuilder( + styleSpec: _resolveStyle(context), + builder: (context, spec) { + return RemixBoxWithEffects( + styleSpec: spec.container, + containerEffects: spec.containerEffects, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: spec.spacing ?? 0, + children: [ + if (data.icon != null) + StyledIcon( + icon: data.icon!, + styleSpec: spec.icon, + ), + if (data.label != null) + Flexible( + child: StyledText( + data.label!, + styleSpec: spec.label, + ), + ), + ], + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class _EqualSegmentLayout extends MultiChildRenderObjectWidget { + const _EqualSegmentLayout({ + required this.orientation, + required this.spacing, + required this.textDirection, + required this.fillMainAxis, + required super.children, + }); + + final Axis orientation; + final double spacing; + final TextDirection textDirection; + final bool fillMainAxis; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderEqualSegmentLayout( + orientation: orientation, + spacing: spacing, + textDirection: textDirection, + fillMainAxis: fillMainAxis, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderEqualSegmentLayout renderObject, + ) { + renderObject + ..orientation = orientation + ..spacing = spacing + ..textDirection = textDirection + ..fillMainAxis = fillMainAxis; + } +} + +class _EqualSegmentParentData extends ContainerBoxParentData {} + +class _RenderEqualSegmentLayout extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderEqualSegmentLayout({ + required Axis orientation, + required double spacing, + required TextDirection textDirection, + required bool fillMainAxis, + }) : assert(spacing.isFinite && spacing >= 0.0), + _orientation = orientation, + _spacing = spacing, + _textDirection = textDirection, + _fillMainAxis = fillMainAxis; + + Axis _orientation; + double _spacing; + TextDirection _textDirection; + bool _fillMainAxis; + + Axis get orientation => _orientation; + set orientation(Axis value) { + if (_orientation == value) return; + _orientation = value; + markNeedsLayout(); + } + + double get spacing => _spacing; + set spacing(double value) { + assert(value.isFinite && value >= 0.0); + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + bool get fillMainAxis => _fillMainAxis; + set fillMainAxis(bool value) { + if (_fillMainAxis == value) return; + _fillMainAxis = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _EqualSegmentParentData) { + child.parentData = _EqualSegmentParentData(); + } + } + + double _saturatingProduct(double value, int factor) { + if (value <= 0.0 || factor <= 0) return 0.0; + if (!value.isFinite || value > double.maxFinite / factor) { + return double.maxFinite; + } + return value * factor; + } + + double _saturatingSum(double first, double second) { + if (!first.isFinite || + !second.isFinite || + first > double.maxFinite - second) { + return double.maxFinite; + } + return first + second; + } + + int get _gapCount => math.max(0, childCount - 1); + + double get _totalSpacing => _saturatingProduct(spacing, _gapCount); + + /// The gap actually used once [mainExtent] is known, clamped so the gaps can + /// never consume the whole track and starve every segment. + /// + /// Layout and the intrinsic queries must agree on this, so it lives in one + /// place: reporting an intrinsic extent derived from the raw [spacing] makes + /// an `IntrinsicWidth`/`IntrinsicHeight` parent size the track to zero while + /// real layout would have given each segment a positive share. + double _effectiveSpacingFor(double mainExtent) { + final gapCount = _gapCount; + if (gapCount == 0 || spacing < mainExtent / gapCount) return spacing; + + return math.min(spacing, mainExtent / childCount); + } + + /// Each segment's share of [mainExtent], used by the cross-axis intrinsic + /// overrides to ask children what they need at their real segment size. + double _intrinsicChildMainExtent(double mainExtent) { + if (childCount == 0) return mainExtent; + final totalSpacing = _saturatingProduct( + _effectiveSpacingFor(mainExtent), + _gapCount, + ); + + return math.max(0.0, (mainExtent - totalSpacing) / childCount); + } + + /// Main-axis extent of [childCount] equal segments plus the gaps between + /// them, each segment sized from the largest child. + /// + /// [minimum] selects each child's minimum intrinsic extent, which is what the + /// `computeMin*` overrides must report; layout and the `computeMax*` + /// overrides ask for the maximum. Deliberate: conflating the two makes an + /// intrinsic-sizing parent believe the track cannot shrink, so labels + /// overflow instead of wrapping. + double _desiredMainExtent(double crossExtent, {required bool minimum}) => + _saturatingSum( + _saturatingProduct( + _largestIntrinsicMainExtent(crossExtent, minimum: minimum), + childCount, + ), + _totalSpacing, + ); + + double _largestIntrinsicMainExtent( + double crossExtent, { + required bool minimum, + }) { + var largest = 0.0; + var child = firstChild; + while (child != null) { + var extent = _childMainExtent(child, crossExtent, minimum: minimum); + // An unbounded maximum falls back to that child's minimum so a single + // child cannot push the whole track to infinity. A minimum has no + // smaller fallback; the saturating helpers clamp it downstream. + if (!minimum && !extent.isFinite) { + extent = _childMainExtent(child, crossExtent, minimum: true); + } + largest = math.max(largest, extent); + child = childAfter(child); + } + return largest; + } + + double _childMainExtent( + RenderBox child, + double crossExtent, { + required bool minimum, + }) { + return switch ((orientation, minimum)) { + (Axis.horizontal, true) => child.getMinIntrinsicWidth(crossExtent), + (Axis.horizontal, false) => child.getMaxIntrinsicWidth(crossExtent), + (Axis.vertical, true) => child.getMinIntrinsicHeight(crossExtent), + (Axis.vertical, false) => child.getMaxIntrinsicHeight(crossExtent), + }; + } + + ({double mainExtent, double effectiveSpacing}) _mainAxisLayout( + BoxConstraints constraints, + ) { + final count = childCount; + if (count == 0) { + return (mainExtent: 0, effectiveSpacing: spacing); + } + final crossExtent = orientation == Axis.horizontal + ? constraints.maxHeight + : constraints.maxWidth; + // Layout wants the natural size, which is the maximum intrinsic extent. + final desiredMainExtent = _desiredMainExtent(crossExtent, minimum: false); + final maxMainExtent = orientation == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + final minMainExtent = orientation == Axis.horizontal + ? constraints.minWidth + : constraints.minHeight; + + final mainExtent = switch (maxMainExtent) { + final extent + when extent.isFinite && (fillMainAxis || minMainExtent == extent) => + extent, + final extent when extent.isFinite => desiredMainExtent.clamp( + minMainExtent, + extent, + ), + _ => math.max(desiredMainExtent, minMainExtent), + }; + return ( + mainExtent: mainExtent, + effectiveSpacing: _effectiveSpacingFor(mainExtent), + ); + } + + BoxConstraints _childConstraints( + BoxConstraints constraints, + double childMainExtent, { + double? childCrossExtent, + }) { + return switch (orientation) { + Axis.horizontal => BoxConstraints( + minWidth: childMainExtent, + maxWidth: childMainExtent, + minHeight: childCrossExtent ?? 0, + maxHeight: childCrossExtent ?? constraints.maxHeight, + ), + Axis.vertical => BoxConstraints( + minWidth: childCrossExtent ?? 0, + maxWidth: childCrossExtent ?? constraints.maxWidth, + minHeight: childMainExtent, + maxHeight: childMainExtent, + ), + }; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final count = childCount; + if (count == 0) return constraints.constrain(Size.zero); + + final layout = _mainAxisLayout(constraints); + final mainExtent = layout.mainExtent; + final totalSpacing = _saturatingProduct( + layout.effectiveSpacing, + math.max(0, count - 1), + ); + final childMainExtent = math.max(0.0, (mainExtent - totalSpacing) / count); + final childConstraints = _childConstraints(constraints, childMainExtent); + var largestCrossExtent = 0.0; + var child = firstChild; + while (child != null) { + final childSize = child.getDryLayout(childConstraints); + largestCrossExtent = math.max( + largestCrossExtent, + orientation == Axis.horizontal ? childSize.height : childSize.width, + ); + child = childAfter(child); + } + + return constraints.constrain( + orientation == Axis.horizontal + ? Size(mainExtent, largestCrossExtent) + : Size(largestCrossExtent, mainExtent), + ); + } + + @override + void performLayout() { + final count = childCount; + if (count == 0) { + size = constraints.constrain(Size.zero); + return; + } + + final layout = _mainAxisLayout(constraints); + final mainExtent = layout.mainExtent; + final effectiveSpacing = layout.effectiveSpacing; + final totalSpacing = _saturatingProduct( + effectiveSpacing, + math.max(0, count - 1), + ); + final childMainExtent = math.max(0.0, (mainExtent - totalSpacing) / count); + final childConstraints = _childConstraints(constraints, childMainExtent); + var largestCrossExtent = 0.0; + var child = firstChild; + while (child != null) { + child.layout(childConstraints, parentUsesSize: true); + largestCrossExtent = math.max( + largestCrossExtent, + orientation == Axis.horizontal ? child.size.height : child.size.width, + ); + child = childAfter(child); + } + + size = constraints.constrain( + orientation == Axis.horizontal + ? Size(mainExtent, largestCrossExtent) + : Size(largestCrossExtent, mainExtent), + ); + + final childCrossExtent = orientation == Axis.horizontal + ? size.height + : size.width; + final fillConstraints = _childConstraints( + constraints, + childMainExtent, + childCrossExtent: childCrossExtent, + ); + var offset = orientation == Axis.horizontal && textDirection == .rtl + ? size.width + : 0.0; + child = firstChild; + while (child != null) { + child.layout(fillConstraints, parentUsesSize: true); + final parentData = child.parentData! as _EqualSegmentParentData; + if (orientation == Axis.horizontal) { + if (textDirection == .rtl) offset -= child.size.width; + parentData.offset = Offset(offset, 0); + offset += textDirection == .rtl + ? -effectiveSpacing + : child.size.width + effectiveSpacing; + } else { + parentData.offset = Offset(0, offset); + offset += child.size.height + effectiveSpacing; + } + child = childAfter(child); + } + } + + @override + double computeMinIntrinsicWidth(double height) { + if (orientation == Axis.horizontal) { + return _desiredMainExtent(height, minimum: true); + } + var largest = 0.0; + var child = firstChild; + final childHeight = _intrinsicChildMainExtent(height); + while (child != null) { + largest = math.max(largest, child.getMinIntrinsicWidth(childHeight)); + child = childAfter(child); + } + return largest; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (orientation == Axis.horizontal) { + return _desiredMainExtent(height, minimum: false); + } + var largest = 0.0; + var child = firstChild; + final childHeight = _intrinsicChildMainExtent(height); + while (child != null) { + largest = math.max(largest, child.getMaxIntrinsicWidth(childHeight)); + child = childAfter(child); + } + return largest; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (orientation == Axis.vertical) { + return _desiredMainExtent(width, minimum: true); + } + var largest = 0.0; + var child = firstChild; + final childWidth = _intrinsicChildMainExtent(width); + while (child != null) { + largest = math.max(largest, child.getMinIntrinsicHeight(childWidth)); + child = childAfter(child); + } + return largest; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (orientation == Axis.vertical) { + return _desiredMainExtent(width, minimum: false); + } + var largest = 0.0; + var child = firstChild; + final childWidth = _intrinsicChildMainExtent(width); + while (child != null) { + largest = math.max(largest, child.getMaxIntrinsicHeight(childWidth)); + child = childAfter(child); + } + return largest; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } +} diff --git a/packages/remix/test/components/segmented_control/segmented_control_spec_test.dart b/packages/remix/test/components/segmented_control/segmented_control_spec_test.dart new file mode 100644 index 00000000..588bb091 --- /dev/null +++ b/packages/remix/test/components/segmented_control/segmented_control_spec_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + group('SegmentedControlSpec', () { + test('creates default track and item specs', () { + const spec = SegmentedControlSpec(); + + expect(spec.container, isA>()); + expect(spec.mainAxisSize, isNull); + expect(spec.spacing, isNull); + expect(spec.item, isA>()); + expect(spec.props, hasLength(4)); + }); + + test('copyWith replaces selected properties', () { + const original = SegmentedControlSpec(); + final container = StyleSpec(spec: BoxSpec()); + + final copy = original.copyWith( + container: container, + mainAxisSize: MainAxisSize.max, + spacing: 6, + ); + + expect(copy, isNot(same(original))); + expect(copy.container, container); + expect(copy.mainAxisSize, MainAxisSize.max); + expect(copy.spacing, 6); + expect(copy.item, original.item); + }); + + test('supports lerp endpoints, null, and diagnostics', () { + const first = SegmentedControlSpec(); + const second = SegmentedControlSpec(); + final builder = DiagnosticPropertiesBuilder(); + + expect(first.lerp(null, 0.5), first); + expect(first.lerp(second, 0), first); + expect(first.lerp(second, 1), second); + expect(() => first.debugFillProperties(builder), returnsNormally); + expect(builder.properties.map((property) => property.name), [ + 'container', + 'mainAxisSize', + 'spacing', + 'item', + ]); + }); + }); + + group('SegmentedControlItemSpec', () { + test('creates default anatomy and value semantics', () { + const spec = SegmentedControlItemSpec(); + + expect(spec.container, isA>()); + expect(spec.spacing, isNull); + expect(spec.label, isA>()); + expect(spec.icon, isA>()); + expect(spec.containerEffects, isNull); + expect(spec.props, hasLength(5)); + expect(const SegmentedControlItemSpec(), spec); + expect(const SegmentedControlItemSpec().hashCode, spec.hashCode); + }); + + test('copyWith preserves effects and replaces child specs', () { + const effects = RemixBoxEffectsSpec(outlineOffset: 6); + const original = SegmentedControlItemSpec(containerEffects: effects); + final label = StyleSpec(spec: TextSpec()); + + final copy = original.copyWith(label: label, spacing: 6); + + expect(copy.label, label); + expect(copy.container, original.container); + expect(copy.spacing, 6); + expect(copy.containerEffects, effects); + }); + + test('lerp interpolates nullable item effects explicitly', () { + const first = SegmentedControlItemSpec( + containerEffects: RemixBoxEffectsSpec(outlineOffset: 2), + ); + const second = SegmentedControlItemSpec( + containerEffects: RemixBoxEffectsSpec(outlineOffset: 6), + ); + + final middle = first.lerp(second, 0.5); + + expect(middle.containerEffects?.outlineOffset, 4); + expect(first.lerp(null, 0.5).containerEffects, isNull); + }); + }); +} diff --git a/packages/remix/test/components/segmented_control/segmented_control_style_test.dart b/packages/remix/test/components/segmented_control/segmented_control_style_test.dart new file mode 100644 index 00000000..3977afa5 --- /dev/null +++ b/packages/remix/test/components/segmented_control/segmented_control_style_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; +import '../../helpers/test_methods.dart'; + +void main() { + group('SegmentedControlStyler', () { + test('constructors retain independent track and item styles', () { + final container = BoxStyler(); + final item = SegmentedControlItemStyler(); + final style = SegmentedControlStyler(container: container, item: item); + + expect(style.$container, Prop.maybeMix(container)); + expect(style.$item, Prop.maybeMix(item)); + }); + + styleMethodTest( + 'sets the track background color', + initial: SegmentedControlStyler(), + modify: (style) => style.color(Colors.blue), + expect: (style) { + expect(style, SegmentedControlStyler.color(Colors.blue)); + }, + ); + + test('resolves the supported track layout controls', () { + final spec = SegmentedControlStyler() + .mainAxisSize(.max) + .spacing(6) + .build(MockBuildContext()) + .spec; + + expect(spec.mainAxisSize, MainAxisSize.max); + expect(spec.spacing, 6); + }); + + styleMethodTest( + 'sets the default item style', + initial: SegmentedControlStyler(), + modify: (style) => style.item(SegmentedControlItemStyler()), + expect: (style) { + expect(style.$item, Prop.maybeMix(SegmentedControlItemStyler())); + }, + ); + }); + + group('SegmentedControlItemStyler', () { + test('exposes a box surface and explicit content spacing', () { + final container = BoxStyler().paddingAll(4); + final style = SegmentedControlItemStyler( + container: container, + spacing: 6, + ); + + expect(style.$container, Prop.maybeMix(container)); + expect(style.$spacing, Prop.maybe(6.0)); + + final spec = style.build(MockBuildContext()).spec; + final StyleSpec resolvedContainer = spec.container; + expect(resolvedContainer.spec.padding, isNotNull); + expect(spec.spacing, 6); + }); + + styleMethodTest( + 'sets foreground color on label and icon', + initial: SegmentedControlItemStyler(), + modify: (style) => style.labelColor(Colors.red).iconColor(Colors.red), + expect: (style) { + expect(style.$label, isNotNull); + expect(style.$icon, isNotNull); + }, + ); + + styleMethodTest( + 'adds selected, disabled, hovered, focused, and pressed variants', + initial: SegmentedControlItemStyler(), + modify: (style) => style + .onSelected(SegmentedControlItemStyler().color(Colors.blue)) + .onDisabled(SegmentedControlItemStyler().color(Colors.grey)) + .onHovered(SegmentedControlItemStyler().color(Colors.green)) + .onFocused(SegmentedControlItemStyler().color(Colors.orange)) + .onPressed(SegmentedControlItemStyler().color(Colors.purple)), + expect: (style) { + expect(style.$variants, hasLength(5)); + }, + ); + + test('container effects merge through the generated item styler', () { + final merged = SegmentedControlItemStyler() + .containerEffects(RemixBoxEffectsMix(outlineOffset: 2)) + .merge( + SegmentedControlItemStyler().containerEffects( + RemixBoxEffectsMix(backdropBlur: 3), + ), + ); + + final spec = merged.build(MockBuildContext()).spec; + + expect(spec.containerEffects?.outlineOffset, 2); + expect(spec.containerEffects?.backdropBlur, 3); + }); + }); +} diff --git a/packages/remix/test/components/segmented_control/segmented_control_widget_test.dart b/packages/remix/test/components/segmented_control/segmented_control_widget_test.dart new file mode 100644 index 00000000..c090e95f --- /dev/null +++ b/packages/remix/test/components/segmented_control/segmented_control_widget_test.dart @@ -0,0 +1,2586 @@ +import 'dart:math' as math; +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart'; +import 'package:remix/remix.dart'; + +import '../../helpers/test_helpers.dart'; +import '../../helpers/test_methods.dart'; + +List> _items( + List nodes, { + bool disableMiddle = false, +}) { + return [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + icon: Icons.view_list, + focusNode: nodes[0], + ), + RemixSegmentedControlItem( + value: 'grid', + label: 'Grid', + icon: Icons.grid_view, + enabled: !disableMiddle, + focusNode: nodes[1], + ), + RemixSegmentedControlItem( + value: 'board', + label: 'Board', + icon: Icons.view_kanban, + focusNode: nodes[2], + ), + ]; +} + +List _focusNodes() => List.generate(3, (index) => FocusNode()); + +void _disposeNodes(List nodes) { + for (final node in nodes) { + node.dispose(); + } +} + +Finder _trackBox() => find + .descendant( + of: find.byKey(const ValueKey('RemixSegmentedControl.track')), + matching: find.byType(Box), + ) + .first; + +/// Labels long enough to wrap, so each segment's minimum intrinsic width +/// (its longest word) is strictly smaller than its maximum (a single line). +const _wrappableItems = >[ + RemixSegmentedControlItem(value: 'a', label: 'Wrappable long label'), + RemixSegmentedControlItem(value: 'b', label: 'Another long one'), +]; + +double _largestSegmentExtent( + WidgetTester tester, + double Function(RenderBox segment) measure, +) { + final options = find.byType(NakedToggleOption); + var largest = 0.0; + for (var index = 0; index < options.evaluate().length; index++) { + largest = math.max( + largest, + measure(tester.renderObject(options.at(index))), + ); + } + + return largest; +} + +void _expectContained(Rect inner, Rect outer) { + expect(inner.left, greaterThanOrEqualTo(outer.left)); + expect(inner.top, greaterThanOrEqualTo(outer.top)); + expect(inner.right, lessThanOrEqualTo(outer.right)); + expect(inner.bottom, lessThanOrEqualTo(outer.bottom)); +} + +void main() { + group('RemixSegmentedControlItem contract', () { + test('requires an accessible name for icon-only items', () { + expect( + () => RemixSegmentedControlItem( + value: 'grid', + icon: Icons.grid_view, + ), + throwsAssertionError, + ); + }); + + testWidgets('rejects blank labels and semantic labels', (tester) async { + for (final blank in ['', ' ', '\t\n']) { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: [RemixSegmentedControlItem(value: 'grid', label: blank)], + selectedValue: 'grid', + ), + ); + expect(tester.takeException(), isA()); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: [ + RemixSegmentedControlItem( + value: 'grid', + icon: Icons.grid_view, + semanticLabel: blank, + ), + ], + selectedValue: 'grid', + ), + ); + expect(tester.takeException(), isA()); + } + }); + }); + + group('RemixSegmentedControl', () { + testWidgets('rejects a blank control semantic label', (tester) async { + for (final blank in ['', ' ', '\t\n']) { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: null, + semanticLabel: blank, + ), + ); + + expect(tester.takeException(), isA()); + } + }); + + testWidgets('renders all items', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(NakedToggleGroup), findsOneWidget); + expect(find.byType(NakedToggleOption), findsNWidgets(3)); + expect(find.text('List'), findsOneWidget); + expect(find.text('Grid'), findsOneWidget); + expect(find.text('Board'), findsOneWidget); + expect(find.byIcon(Icons.grid_view), findsOneWidget); + }); + + testWidgets('shrink-wraps the segmented-control container', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + final size = tester.getSize(_trackBox()); + expect(size.width, lessThan(400)); + expect(size.height, lessThan(100)); + }); + + testWidgets( + 'horizontal root and semantics shrink vertically and only fill width', + (tester) async { + final semantics = tester.ensureSemantics(); + const parentKey = ValueKey('horizontal-bounds'); + + Widget build({required bool fillMainAxis}) { + return SizedBox( + key: parentKey, + width: 320, + height: 120, + child: Center( + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + semanticLabel: 'Horizontal periods', + style: fillMainAxis + ? SegmentedControlStyler().mainAxisSize(.max) + : SegmentedControlStyler(), + ), + ), + ); + } + + for (final fillMainAxis in [false, true]) { + await tester.pumpRemixApp(build(fillMainAxis: fillMainAxis)); + await tester.pumpAndSettle(); + + final parentRect = tester.getRect(find.byKey(parentKey)); + final rootRect = tester.getRect( + find.byType(RemixSegmentedControl), + ); + final trackRect = tester.getRect(_trackBox()); + final semanticsRect = tester.getRect( + find.bySemanticsLabel('Horizontal periods'), + ); + + expect(rootRect, trackRect); + expect(semanticsRect, trackRect); + expect(trackRect.center, parentRect.center); + expect(trackRect.height, lessThan(parentRect.height)); + if (fillMainAxis) { + expect(trackRect.width, parentRect.width); + } else { + expect(trackRect.width, lessThan(parentRect.width)); + } + } + semantics.dispose(); + }, + ); + + testWidgets( + 'vertical root and semantics shrink horizontally and only fill height', + (tester) async { + final semantics = tester.ensureSemantics(); + const parentKey = ValueKey('vertical-bounds'); + + Widget build({required bool fillMainAxis}) { + return SizedBox( + key: parentKey, + width: 220, + height: 300, + child: Center( + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + orientation: Axis.vertical, + semanticLabel: 'Vertical periods', + style: fillMainAxis + ? SegmentedControlStyler().mainAxisSize(.max) + : SegmentedControlStyler(), + ), + ), + ); + } + + for (final fillMainAxis in [false, true]) { + await tester.pumpRemixApp(build(fillMainAxis: fillMainAxis)); + await tester.pumpAndSettle(); + + final parentRect = tester.getRect(find.byKey(parentKey)); + final rootRect = tester.getRect( + find.byType(RemixSegmentedControl), + ); + final trackRect = tester.getRect(_trackBox()); + final semanticsRect = tester.getRect( + find.bySemanticsLabel('Vertical periods'), + ); + + expect(rootRect, trackRect); + expect(semanticsRect, trackRect); + expect(trackRect.center, parentRect.center); + expect(trackRect.width, lessThan(parentRect.width)); + if (fillMainAxis) { + expect(trackRect.height, parentRect.height); + } else { + expect(trackRect.height, lessThan(parentRect.height)); + } + } + semantics.dispose(); + }, + ); + + testWidgets('styleSpec preserves vertical layout orientation', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: 'list', + onChanged: (_) {}, + orientation: Axis.vertical, + styleSpec: const SegmentedControlSpec(), + ), + ); + await tester.pumpAndSettle(); + + final listPosition = tester.getTopLeft(find.text('List')); + final gridPosition = tester.getTopLeft(find.text('Grid')); + expect(listPosition.dx, gridPosition.dx); + expect(listPosition.dy, lessThan(gridPosition.dy)); + }); + + testWidgets('styleSpec bypasses fluent style resolution', (tester) async { + var fluentBuilds = 0; + final fluentStyle = SegmentedControlStyler().onBuilder((context) { + fluentBuilds += 1; + + return SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ); + }); + const rawSpec = SegmentedControlSpec( + container: StyleSpec( + spec: BoxSpec(decoration: BoxDecoration(color: Colors.green)), + ), + item: StyleSpec( + spec: SegmentedControlItemSpec( + label: StyleSpec( + spec: TextSpec(style: TextStyle(color: Colors.red)), + ), + ), + ), + ); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: fluentStyle, + styleSpec: rawSpec, + ), + ); + await tester.pumpAndSettle(); + + expect(fluentBuilds, 0); + final track = tester.widget(_trackBox()); + final decoration = track.styleSpec!.spec.decoration as BoxDecoration; + expect(decoration.color, Colors.green); + expect(tester.widget(find.text('List')).style?.color, Colors.red); + }); + + testWidgets('raw item defaults bypass per-item fluent styles', ( + tester, + ) async { + const rawSpec = SegmentedControlSpec( + item: StyleSpec( + spec: SegmentedControlItemSpec( + label: StyleSpec( + spec: TextSpec(style: TextStyle(color: Colors.red)), + ), + ), + ), + ); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + style: SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + styleSpec: rawSpec, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.text('List')).style?.color, Colors.red); + }); + + testWidgets('group context variants compose with item state variants', ( + tester, + ) async { + final style = + SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ), + ).onRtl( + SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ), + ); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'selected', label: 'Selected'), + RemixSegmentedControlItem(value: 'other', label: 'Other'), + ], + selectedValue: 'selected', + onChanged: (_) {}, + style: style, + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('Selected')).style?.color, + Colors.blue, + ); + expect( + tester.widget(find.text('Other')).style?.color, + Colors.green, + ); + }); + + testWidgets('item context variants compose with item state variants', ( + tester, + ) async { + final style = SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ) + .onRtl( + SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ), + ); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'selected', label: 'Selected'), + RemixSegmentedControlItem(value: 'other', label: 'Other'), + ], + selectedValue: 'selected', + onChanged: (_) {}, + style: style, + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('Selected')).style?.color, + Colors.blue, + ); + expect( + tester.widget(find.text('Other')).style?.color, + Colors.green, + ); + }); + + testWidgets('tapping emits a non-null value when selection starts null', ( + tester, + ) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + final changes = []; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: null, + onChanged: changes.add, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Grid')); + await tester.pumpAndSettle(); + + expect(changes, ['grid']); + }); + + testWidgets('tapping the selected item does not emit a change', ( + tester, + ) async { + var changes = 0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + onChanged: (_) => changes += 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('List')); + await tester.pumpAndSettle(); + + expect(changes, 0); + }); + + testWidgets('a disabled item ignores taps', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + var changes = 0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes, disableMiddle: true), + selectedValue: 'list', + onChanged: (_) => changes += 1, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Grid')); + await tester.pumpAndSettle(); + + expect(changes, 0); + }); + + testWidgets('a null callback disables interaction and item styling', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + style: SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onDisabled( + SegmentedControlItemStyler() + .labelColor(Colors.grey) + .iconColor(Colors.grey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.text('List')).style?.color, Colors.grey); + final option = find.bySemanticsLabel('List'); + expect(option, findsOneWidget); + expect( + tester.getSemantics(option), + isSemantics( + label: 'List', + isButton: true, + hasSelectedState: true, + isSelected: true, + hasEnabledState: true, + isEnabled: false, + isFocusable: false, + hasTapAction: false, + hasFocusAction: false, + hasCheckedState: false, + hasToggledState: false, + ), + ); + semantics.dispose(); + }); + + testWidgets('exposes one tab stop and then exits the group', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final nodes = _focusNodes(); + final before = FocusNode(); + final after = FocusNode(); + addTearDown(() { + _disposeNodes(nodes); + before.dispose(); + after.dispose(); + }); + + await tester.pumpRemixApp( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + focusNode: before, + autofocus: true, + onPressed: () {}, + child: const Text('Before'), + ), + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'grid', + onChanged: (_) {}, + ), + TextButton( + focusNode: after, + onPressed: () {}, + child: const Text('After'), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(before.hasFocus, isTrue); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.tab); + expect(nodes[1].hasFocus, isTrue); + expect( + tester.getSemantics(find.bySemanticsLabel('Grid')), + isSemantics(isFocused: true, isFocusable: true, hasFocusAction: true), + ); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.tab); + expect(after.hasFocus, isTrue); + expect( + tester.getSemantics(find.bySemanticsLabel('Grid')), + isSemantics(isFocused: false), + ); + semantics.dispose(); + }); + + for (final (orientation, key) in [ + (Axis.horizontal, LogicalKeyboardKey.arrowRight), + (Axis.vertical, LogicalKeyboardKey.arrowDown), + ]) { + testWidgets('$key moves focus forward without selecting', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + var changes = 0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) => changes += 1, + orientation: orientation, + ), + ); + await tester.pumpAndSettle(); + nodes[0].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, key); + + expect(nodes[1].hasFocus, isTrue); + expect(changes, 0); + }); + } + + for (final (orientation, key) in [ + (Axis.horizontal, LogicalKeyboardKey.arrowLeft), + (Axis.vertical, LogicalKeyboardKey.arrowUp), + ]) { + testWidgets('$key moves focus backward', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'grid', + onChanged: (_) {}, + orientation: orientation, + ), + ); + await tester.pumpAndSettle(); + nodes[1].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, key); + + expect(nodes[0].hasFocus, isTrue); + }); + } + + testWidgets('Home and End move to the first and last item', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'grid', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + nodes[1].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.home); + expect(nodes[0].hasFocus, isTrue); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.end); + expect(nodes[2].hasFocus, isTrue); + }); + + testWidgets('arrow traversal skips disabled items', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes, disableMiddle: true), + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + nodes[0].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + + expect(nodes[2].hasFocus, isTrue); + expect(nodes[1].hasFocus, isFalse); + }); + + testWidgets('loop wraps while non-looping navigation clamps', ( + tester, + ) async { + final loopingNodes = _focusNodes(); + final clampedNodes = _focusNodes(); + addTearDown(() { + _disposeNodes(loopingNodes); + _disposeNodes(clampedNodes); + }); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(loopingNodes), + selectedValue: 'board', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + loopingNodes[2].requestFocus(); + await tester.pumpAndSettle(); + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + expect(loopingNodes[0].hasFocus, isTrue); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(clampedNodes), + selectedValue: 'board', + onChanged: (_) {}, + loop: false, + ), + ); + await tester.pumpAndSettle(); + clampedNodes[2].requestFocus(); + await tester.pumpAndSettle(); + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + expect(clampedNodes[2].hasFocus, isTrue); + }); + + testWidgets('RTL inverts horizontal arrow direction', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'grid', + onChanged: (_) {}, + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + nodes[1].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + + expect(nodes[0].hasFocus, isTrue); + }); + + testWidgets( + 'ambient direction controls visual, keyboard, and semantics order', + (tester) async { + final semantics = tester.ensureSemantics(); + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) {}, + ), + textDirection: TextDirection.ltr, + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final firstRect = tester.getRect(options.at(0)); + final secondRect = tester.getRect(options.at(1)); + final thirdRect = tester.getRect(options.at(2)); + + expect(firstRect.left, lessThan(secondRect.left)); + expect(secondRect.left, lessThan(thirdRect.left)); + expect(tester.getRect(find.bySemanticsLabel('List')), firstRect); + expect(tester.getRect(find.bySemanticsLabel('Grid')), secondRect); + expect(tester.getRect(find.bySemanticsLabel('Board')), thirdRect); + + nodes[0].requestFocus(); + await tester.pumpAndSettle(); + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + + expect(nodes[1].hasFocus, isTrue); + semantics.dispose(); + }, + ); + + testWidgets('item identity follows values when items reorder', ( + tester, + ) async { + final before = FocusNode(); + addTearDown(before.dispose); + late StateSetter update; + var values = ['a', 'b', 'c']; + + await tester.pumpRemixApp( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + focusNode: before, + autofocus: true, + onPressed: () {}, + child: const Text('Before'), + ), + StatefulBuilder( + builder: (context, setState) { + update = setState; + + return RemixSegmentedControl( + items: [ + for (final value in values) + RemixSegmentedControlItem( + value: value, + label: value.toUpperCase(), + ), + ], + selectedValue: 'b', + onChanged: (_) {}, + ); + }, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await sendKeyAndSettle(tester, LogicalKeyboardKey.tab); + expect(Focus.of(tester.element(find.text('B'))).hasFocus, isTrue); + + update(() => values = ['c', 'a', 'b']); + await tester.pumpAndSettle(); + + expect(Focus.of(tester.element(find.text('B'))).hasFocus, isTrue); + }); + + testWidgets('restores caller-owned focus node properties', (tester) async { + final node = FocusNode(canRequestFocus: false, skipTraversal: true); + addTearDown(node.dispose); + late StateSetter update; + var showControl = true; + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + return showControl + ? RemixSegmentedControl( + items: [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + focusNode: node, + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + ) + : const SizedBox(); + }, + ), + ); + await tester.pumpAndSettle(); + + expect(node.canRequestFocus, isTrue); + expect(node.skipTraversal, isFalse); + + update(() => showControl = false); + await tester.pumpAndSettle(); + + expect(node.canRequestFocus, isFalse); + expect(node.skipTraversal, isTrue); + }); + + for (final key in [LogicalKeyboardKey.space, LogicalKeyboardKey.enter]) { + testWidgets('$key activates the focused item', (tester) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + String? selected; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (value) => selected = value, + ), + ); + await tester.pumpAndSettle(); + nodes[1].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, key); + + expect(selected, 'grid'); + }); + } + + testWidgets('a disabled group has no tab stop', (tester) async { + final nodes = _focusNodes(); + final before = FocusNode(); + final after = FocusNode(); + addTearDown(() { + _disposeNodes(nodes); + before.dispose(); + after.dispose(); + }); + + await tester.pumpRemixApp( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + focusNode: before, + autofocus: true, + onPressed: () {}, + child: const Text('Before'), + ), + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) {}, + enabled: false, + ), + TextButton( + focusNode: after, + onPressed: () {}, + child: const Text('After'), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.tab); + + expect(after.hasFocus, isTrue); + expect(nodes.every((node) => !node.hasFocus), isTrue); + }); + + testWidgets('icon-only items use their semantic label', (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem( + value: 'grid', + icon: Icons.grid_view, + semanticLabel: 'Grid view', + ), + ], + selectedValue: 'grid', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + final option = find.bySemanticsLabel('Grid view'); + expect(option, findsOneWidget); + expect( + tester.getSemantics(option), + isSemantics( + label: 'Grid view', + isButton: true, + isSelected: true, + hasSelectedState: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('group label contains explicit option nodes', (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: 'list', + onChanged: (_) {}, + semanticLabel: 'View style', + ), + ); + await tester.pumpAndSettle(); + + final group = tester.getSemantics(find.bySemanticsLabel('View style')); + + expect(group.childrenCount, 2); + expect(find.bySemanticsLabel('List'), findsOneWidget); + expect(find.bySemanticsLabel('Grid'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('options expose selected-button mutually exclusive semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + final selected = find.bySemanticsLabel('List'); + final unselected = find.bySemanticsLabel('Grid'); + expect(selected, findsOneWidget); + expect(unselected, findsOneWidget); + expect( + tester.getSemantics(selected), + isSemantics( + label: 'List', + isButton: true, + isSelected: true, + hasSelectedState: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isInMutuallyExclusiveGroup: true, + hasTapAction: true, + hasFocusAction: true, + hasCheckedState: false, + hasToggledState: false, + ), + ); + expect( + tester.getSemantics(unselected), + isSemantics( + label: 'Grid', + isButton: true, + isSelected: false, + hasSelectedState: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isInMutuallyExclusiveGroup: true, + hasTapAction: true, + hasFocusAction: true, + hasCheckedState: false, + hasToggledState: false, + ), + ); + semantics.dispose(); + }); + + testWidgets('semantics activation selects an inactive option', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + String? selected; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: 'list', + onChanged: (value) => selected = value, + ), + ); + await tester.pumpAndSettle(); + + final grid = find.semantics.byLabel('Grid'); + expect(grid, findsOne); + tester.semantics.tap(grid); + await tester.pumpAndSettle(); + + expect(selected, 'grid'); + semantics.dispose(); + }); + + testWidgets('semanticLabel replaces visible label semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + semanticLabel: 'List view', + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + final option = find.bySemanticsLabel('List view'); + expect(option, findsOneWidget); + expect( + tester.getSemantics(option), + isSemantics( + label: 'List view', + isButton: true, + isSelected: true, + hasSelectedState: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('excludeSemantics hides the group and its items', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + onChanged: (_) {}, + semanticLabel: 'View style', + excludeSemantics: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('View style'), findsNothing); + expect(find.bySemanticsLabel('List'), findsNothing); + semantics.dispose(); + }); + + testWidgets('an empty group is valid when nothing is selected', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [], + selectedValue: null, + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(NakedToggleGroup), findsOneWidget); + expect(find.byType(NakedToggleOption), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('rejects duplicate item values', (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'list', label: 'List again'), + ], + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + + expect( + tester.takeException().toString(), + contains('item values must be unique'), + ); + }); + + testWidgets('rejects a selected value that is not an item', (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'grid', + onChanged: (_) {}, + ), + ); + + expect( + tester.takeException().toString(), + contains('selectedValue must match one item'), + ); + }); + + testWidgets('rejects more than one autofocus item', (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + autofocus: true, + ), + RemixSegmentedControlItem( + value: 'grid', + label: 'Grid', + autofocus: true, + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + ), + ); + + expect( + tester.takeException().toString(), + contains('Only one item may autofocus'), + ); + }); + + testWidgets('vertical orientation ignores horizontal arrows', ( + tester, + ) async { + final nodes = _focusNodes(); + addTearDown(() => _disposeNodes(nodes)); + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _items(nodes), + selectedValue: 'list', + onChanged: (_) {}, + orientation: Axis.vertical, + ), + ); + await tester.pumpAndSettle(); + nodes[0].requestFocus(); + await tester.pumpAndSettle(); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowRight); + expect(nodes[0].hasFocus, isTrue); + + await sendKeyAndSettle(tester, LogicalKeyboardKey.arrowDown); + expect(nodes[1].hasFocus, isTrue); + }); + }); + + group('item visual states', () { + testWidgets('hovered and pressed styles resolve at runtime', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onHovered( + SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ) + .onPressed( + SegmentedControlItemStyler() + .labelColor(Colors.purple) + .iconColor(Colors.purple), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.text('List'))); + await tester.pump(); + expect(tester.widget(find.text('List')).style?.color, Colors.green); + + await mouse.down(tester.getCenter(find.text('List'))); + await tester.pump(); + expect( + tester.widget(find.text('List')).style?.color, + Colors.purple, + ); + + await mouse.up(); + await mouse.moveTo(Offset.zero); + await tester.pump(); + expect(tester.widget(find.text('List')).style?.color, Colors.red); + }); + + testWidgets('selected style is removed when selection changes', ( + tester, + ) async { + late StateSetter update; + var selectedValue = 'list'; + final style = SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ), + ); + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + + return RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: selectedValue, + onChanged: (_) {}, + style: style, + ); + }, + ), + ); + await tester.pumpAndSettle(); + expect(tester.widget(find.text('List')).style?.color, Colors.blue); + expect(tester.widget(find.text('Grid')).style?.color, Colors.red); + + update(() => selectedValue = 'grid'); + await tester.pumpAndSettle(); + + expect(tester.widget(find.text('List')).style?.color, Colors.red); + expect(tester.widget(find.text('Grid')).style?.color, Colors.blue); + }); + + testWidgets('disabled style wins when an item is also selected', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + enabled: false, + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ) + .onDisabled( + SegmentedControlItemStyler() + .labelColor(Colors.grey) + .iconColor(Colors.grey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.text('List')).style?.color, Colors.grey); + }); + + testWidgets('focus style is removed when the focused item is disabled', ( + tester, + ) async { + final node = FocusNode(); + addTearDown(node.dispose); + late StateSetter update; + var itemEnabled = true; + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + + return RemixSegmentedControl( + items: [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + enabled: itemEnabled, + focusNode: node, + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onFocused( + SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ) + .onDisabled( + SegmentedControlItemStyler() + .labelColor(Colors.grey) + .iconColor(Colors.grey), + ), + ), + ); + }, + ), + ); + node.requestFocus(); + await tester.pumpAndSettle(); + expect(node.hasFocus, isTrue); + expect(tester.widget(find.text('List')).style?.color, Colors.green); + + update(() => itemEnabled = false); + await tester.pumpAndSettle(); + + expect(node.hasFocus, isFalse); + expect(tester.widget(find.text('List')).style?.color, Colors.grey); + }); + + testWidgets('per-item state variants override group item variants', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + style: SegmentedControlItemStyler().onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.purple) + .iconColor(Colors.purple), + ), + ), + RemixSegmentedControlItem( + value: 'grid', + label: 'Grid', + style: SegmentedControlItemStyler() + .labelColor(Colors.green) + .iconColor(Colors.green), + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler() + .labelColor(Colors.red) + .iconColor(Colors.red) + .onSelected( + SegmentedControlItemStyler() + .labelColor(Colors.blue) + .iconColor(Colors.blue), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('List')).style?.color, + Colors.purple, + ); + expect(tester.widget(find.text('Grid')).style?.color, Colors.green); + }); + }); + + group('segmented layout and track anatomy', () { + testWidgets( + 'bounded horizontal spacing contains options and semantics and activates', + (tester) async { + final semantics = tester.ensureSemantics(); + const labels = ['A', 'B', 'C']; + String? changedValue; + + await tester.pumpRemixApp( + SizedBox( + width: 50, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'b', label: 'B'), + RemixSegmentedControlItem(value: 'c', label: 'C'), + ], + selectedValue: 'a', + onChanged: (value) => changedValue = value, + style: SegmentedControlStyler().mainAxisSize(.max).spacing(30), + ), + ), + ); + await tester.pumpAndSettle(); + + final trackRect = tester.getRect(_trackBox()); + final options = find.byType(NakedToggleOption); + for (var index = 0; index < 3; index++) { + final optionRect = tester.getRect(options.at(index)); + expect(optionRect.width, greaterThan(0)); + _expectContained(optionRect, trackRect); + _expectContained( + tester.getRect(find.bySemanticsLabel(labels[index])), + trackRect, + ); + } + + await tester.tap(options.at(2)); + await tester.pumpAndSettle(); + + expect(changedValue, 'c'); + semantics.dispose(); + }, + ); + + testWidgets( + 'bounded vertical spacing contains options and semantics and activates', + (tester) async { + final semantics = tester.ensureSemantics(); + const labels = ['A', 'B', 'C']; + String? changedValue; + + await tester.pumpRemixApp( + SizedBox( + height: 50, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'b', label: 'B'), + RemixSegmentedControlItem(value: 'c', label: 'C'), + ], + selectedValue: 'a', + onChanged: (value) => changedValue = value, + orientation: Axis.vertical, + style: SegmentedControlStyler().mainAxisSize(.max).spacing(30), + ), + ), + ); + await tester.pumpAndSettle(); + + final trackRect = tester.getRect(_trackBox()); + final options = find.byType(NakedToggleOption); + for (var index = 0; index < 3; index++) { + final optionRect = tester.getRect(options.at(index)); + expect(optionRect.height, greaterThan(0)); + _expectContained(optionRect, trackRect); + _expectContained( + tester.getRect(find.bySemanticsLabel(labels[index])), + trackRect, + ); + } + + await tester.tap(options.at(2)); + await tester.pumpAndSettle(); + + expect(changedValue, 'c'); + semantics.dispose(); + }, + ); + + testWidgets( + 'maximum finite spacing preserves two nonzero options and semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + String? changedValue; + + await tester.pumpRemixApp( + SizedBox( + width: 50, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'b', label: 'B'), + ], + selectedValue: 'a', + onChanged: (value) => changedValue = value, + style: SegmentedControlStyler() + .mainAxisSize(.max) + .spacing(double.maxFinite), + ), + ), + ); + await tester.pumpAndSettle(); + + final trackRect = tester.getRect(_trackBox()); + final options = find.byType(NakedToggleOption); + for (var index = 0; index < 2; index++) { + final optionRect = tester.getRect(options.at(index)); + final semanticsRect = tester.getRect( + find.bySemanticsLabel(index == 0 ? 'A' : 'B'), + ); + expect(optionRect.width, greaterThan(0)); + expect(semanticsRect.width, greaterThan(0)); + _expectContained(optionRect, trackRect); + _expectContained(semanticsRect, trackRect); + } + + await tester.tap(options.at(1)); + await tester.pumpAndSettle(); + + expect(changedValue, 'b'); + semantics.dispose(); + }, + ); + + testWidgets( + 'maximum finite spacing stays finite on an unbounded main axis', + (tester) async { + await tester.pumpRemixApp( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'b', label: 'B'), + RemixSegmentedControlItem(value: 'c', label: 'C'), + ], + selectedValue: 'a', + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(double.maxFinite), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(_trackBox()).width.isFinite, isTrue); + final options = find.byType(NakedToggleOption); + for (var index = 0; index < 3; index++) { + final optionWidth = tester.getSize(options.at(index)).width; + expect(optionWidth.isFinite, isTrue); + expect(optionWidth, greaterThan(0)); + } + }, + ); + + testWidgets( + 'compressed RTL spacing contains options and semantics and activates', + (tester) async { + final semantics = tester.ensureSemantics(); + const labels = ['A', 'B', 'C']; + String? changedValue; + + await tester.pumpRemixApp( + SizedBox( + width: 50, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'b', label: 'B'), + RemixSegmentedControlItem(value: 'c', label: 'C'), + ], + selectedValue: 'a', + onChanged: (value) => changedValue = value, + style: SegmentedControlStyler().mainAxisSize(.max).spacing(30), + ), + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + + final trackRect = tester.getRect(_trackBox()); + final options = find.byType(NakedToggleOption); + final optionRects = [ + for (var index = 0; index < 3; index++) + tester.getRect(options.at(index)), + ]; + expect(optionRects[0].left, greaterThan(optionRects[1].left)); + expect(optionRects[1].left, greaterThan(optionRects[2].left)); + for (var index = 0; index < 3; index++) { + _expectContained(optionRects[index], trackRect); + _expectContained( + tester.getRect(find.bySemanticsLabel(labels[index])), + trackRect, + ); + } + + await tester.tap(options.at(2)); + await tester.pumpAndSettle(); + + expect(changedValue, 'c'); + semantics.dispose(); + }, + ); + + testWidgets('item spacing controls the horizontal icon-label gap', ( + tester, + ) async { + const spacing = 11.0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem( + value: 'list', + label: 'List', + icon: Icons.view_list, + ), + ], + selectedValue: 'list', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().spacing(spacing), + ), + ), + ); + await tester.pumpAndSettle(); + + final iconRect = tester.getRect(find.byIcon(Icons.view_list)); + final labelRect = tester.getRect(find.text('List')); + expect(labelRect.left - iconRect.right, closeTo(spacing, 0.01)); + }); + + testWidgets('horizontal segments use the largest intrinsic width', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem(value: 'long', label: 'Longer label'), + RemixSegmentedControlItem( + value: 'icon', + icon: Icons.grid_view, + semanticLabel: 'Grid', + ), + ], + selectedValue: 'a', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().paddingAll(8), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final widths = [ + for (var index = 0; index < 3; index++) + tester.getSize(options.at(index)).width, + ]; + final trackWidth = tester.getSize(_trackBox()).width; + + expect(widths[0], closeTo(widths[1], 0.01)); + expect(widths[1], closeTo(widths[2], 0.01)); + expect(trackWidth, closeTo(widths.first * 3, 0.01)); + expect(trackWidth, lessThan(800)); + }); + + testWidgets('an explicit track width is divided equally', (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + RemixSegmentedControlItem(value: 'month', label: 'Month'), + ], + selectedValue: 'day', + onChanged: (_) {}, + style: SegmentedControlStyler().constraintsOnly(width: 300), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + + expect(tester.getSize(_trackBox()).width, 300); + for (var index = 0; index < 3; index++) { + expect(tester.getSize(options.at(index)).width, closeTo(100, 0.01)); + } + }); + + testWidgets('horizontal segments fill the shared cross axis', ( + tester, + ) async { + await tester.pumpRemixApp( + SizedBox( + width: 180, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'This week'), + RemixSegmentedControlItem(value: 'month', label: 'Month'), + ], + selectedValue: 'week', + onChanged: (_) {}, + style: SegmentedControlStyler() + .mainAxisSize(.max) + .item(SegmentedControlItemStyler().paddingAll(8)), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final heights = [ + for (var index = 0; index < 3; index++) + tester.getSize(options.at(index)).height, + ]; + + expect(heights[0], closeTo(heights[1], 0.01)); + expect(heights[1], closeTo(heights[2], 0.01)); + }); + + testWidgets('mainAxisSize max opts into the bounded width', (tester) async { + Widget build(SegmentedControlStyler style) { + return SizedBox( + width: 300, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + style: style, + ), + ); + } + + await tester.pumpRemixApp(build(SegmentedControlStyler())); + await tester.pumpAndSettle(); + expect(tester.getSize(_trackBox()).width, lessThan(300)); + + await tester.pumpRemixApp( + build(SegmentedControlStyler().mainAxisSize(.max)), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(_trackBox()).width, 300); + }); + + testWidgets('rejects negative spacing when creating the layout', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(-1), + ), + ); + + expect(tester.takeException(), isA()); + }); + + testWidgets('rejects negative spacing when updating the layout', ( + tester, + ) async { + late StateSetter update; + var spacing = 0.0; + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + return RemixSegmentedControl( + items: const [], + selectedValue: null, + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(spacing), + ); + }, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + update(() => spacing = -1); + await tester.pump(); + + expect(tester.takeException(), isA()); + }); + + for (final invalidSpacing in [ + (name: 'NaN', value: double.nan), + (name: 'infinite', value: double.infinity), + ]) { + testWidgets( + 'rejects ${invalidSpacing.name} spacing when creating the layout', + (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(invalidSpacing.value), + ), + ); + + expect(tester.takeException(), isA()); + }, + ); + + testWidgets( + 'rejects ${invalidSpacing.name} spacing when updating the layout', + (tester) async { + late StateSetter update; + var spacing = 0.0; + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + return RemixSegmentedControl( + items: const [], + selectedValue: null, + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(spacing), + ); + }, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + update(() => spacing = invalidSpacing.value); + await tester.pump(); + + expect(tester.takeException(), isA()); + }, + ); + } + + testWidgets('vertical intrinsic widths use per-segment height', ( + tester, + ) async { + const trackHeight = 100.0; + const spacing = 10.0; + const aspectRatio = 2.0; + + await tester.pumpRemixApp( + SizedBox( + height: trackHeight, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler() + .mainAxisSize(.max) + .spacing(spacing) + .item( + SegmentedControlItemStyler().wrap(.aspectRatio(aspectRatio)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final track = tester.renderObject(_trackBox()); + final segmentHeight = (trackHeight - spacing) / 2; + final expectedWidth = segmentHeight * aspectRatio; + + expect(track.size, Size(expectedWidth, trackHeight)); + expect( + track.getMinIntrinsicWidth(trackHeight), + closeTo(expectedWidth, 0.01), + ); + expect( + track.getMaxIntrinsicWidth(trackHeight), + closeTo(expectedWidth, 0.01), + ); + }); + + testWidgets('oversized spacing clamps intrinsics the way layout does', ( + tester, + ) async { + // Spacing wider than the track would starve every segment, so layout + // clamps it. The intrinsic queries must apply the same clamp or an + // intrinsic-sizing parent collapses the track to zero. + const trackHeight = 100.0; + const spacing = 150.0; + const aspectRatio = 2.0; + + Widget buildControl() => RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'Week'), + ], + selectedValue: 'day', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler() + .mainAxisSize(.max) + .spacing(spacing) + .item(SegmentedControlItemStyler().wrap(.aspectRatio(aspectRatio))), + ); + + await tester.pumpRemixApp( + SizedBox(height: trackHeight, child: buildControl()), + ); + await tester.pumpAndSettle(); + + // effectiveSpacing clamps to trackHeight / childCount == 50. + const segmentHeight = (trackHeight - 50.0) / 2; + const expectedWidth = segmentHeight * aspectRatio; + + final track = tester.renderObject(_trackBox()); + expect(track.size, const Size(expectedWidth, trackHeight)); + expect( + track.getMinIntrinsicWidth(trackHeight), + closeTo(expectedWidth, 0.01), + ); + expect( + track.getMaxIntrinsicWidth(trackHeight), + closeTo(expectedWidth, 0.01), + ); + + await tester.pumpRemixApp( + SizedBox( + height: trackHeight, + child: IntrinsicWidth(child: buildControl()), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.renderObject(_trackBox()).size, + const Size(expectedWidth, trackHeight), + ); + }); + + testWidgets( + 'horizontal main-axis intrinsics separate segment minimums from maximums', + (tester) async { + const spacing = 6.0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _wrappableItems, + selectedValue: 'a', + onChanged: (_) {}, + style: SegmentedControlStyler().spacing(spacing), + ), + ); + await tester.pumpAndSettle(); + + final track = tester.renderObject(_trackBox()); + final segmentMin = _largestSegmentExtent( + tester, + (segment) => segment.getMinIntrinsicWidth(double.infinity), + ); + final segmentMax = _largestSegmentExtent( + tester, + (segment) => segment.getMaxIntrinsicWidth(double.infinity), + ); + + // Guards the fixture: if the labels did not wrap, both segment getters + // would agree and the track assertions could not tell them apart. + expect(segmentMin, lessThan(segmentMax)); + + expect( + track.getMinIntrinsicWidth(double.infinity), + closeTo(segmentMin * 2 + spacing, 0.01), + ); + expect( + track.getMaxIntrinsicWidth(double.infinity), + closeTo(segmentMax * 2 + spacing, 0.01), + ); + }, + ); + + testWidgets( + 'vertical main-axis intrinsics separate segment minimums from maximums', + (tester) async { + const spacing = 8.0; + + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _wrappableItems, + selectedValue: 'a', + onChanged: (_) {}, + orientation: Axis.vertical, + // A quarter turn swaps each segment's width and height intrinsics, + // which is what makes its minimum and maximum heights differ. + style: SegmentedControlStyler() + .spacing(spacing) + .item(SegmentedControlItemStyler().wrap(.rotatedBox(1))), + ), + ); + await tester.pumpAndSettle(); + + final track = tester.renderObject(_trackBox()); + final segmentMin = _largestSegmentExtent( + tester, + (segment) => segment.getMinIntrinsicHeight(double.infinity), + ); + final segmentMax = _largestSegmentExtent( + tester, + (segment) => segment.getMaxIntrinsicHeight(double.infinity), + ); + + expect(segmentMin, lessThan(segmentMax)); + + expect( + track.getMinIntrinsicHeight(double.infinity), + closeTo(segmentMin * 2 + spacing, 0.01), + ); + expect( + track.getMaxIntrinsicHeight(double.infinity), + closeTo(segmentMax * 2 + spacing, 0.01), + ); + }, + ); + + testWidgets('an intrinsic-column parent wraps instead of overflowing', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: _wrappableItems, + selectedValue: 'a', + onChanged: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + final segmentMin = _largestSegmentExtent( + tester, + (segment) => segment.getMinIntrinsicWidth(double.infinity), + ); + final segmentMax = _largestSegmentExtent( + tester, + (segment) => segment.getMaxIntrinsicWidth(double.infinity), + ); + // Sits between the two segment minimums and the two maximums, so a track + // that reports maximums from computeMinIntrinsicWidth cannot shrink into + // it while a correct one can. + final parentWidth = segmentMin + segmentMax; + + await tester.pumpRemixApp( + SizedBox( + width: parentWidth, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + TableRow( + children: [ + RemixSegmentedControl( + items: _wrappableItems, + selectedValue: 'a', + onChanged: (_) {}, + ), + ], + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(_trackBox()).width, + lessThanOrEqualTo(parentWidth + 0.01), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('an IntrinsicWidth parent still sizes to segment maximums', ( + tester, + ) async { + await tester.pumpRemixApp( + IntrinsicWidth( + child: RemixSegmentedControl( + items: _wrappableItems, + selectedValue: 'a', + onChanged: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + final segmentMax = _largestSegmentExtent( + tester, + (segment) => segment.getMaxIntrinsicWidth(double.infinity), + ); + + expect(tester.getSize(_trackBox()).width, closeTo(segmentMax * 2, 0.01)); + }); + + testWidgets('vertical segments use equal intrinsic heights', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'one', label: 'One'), + RemixSegmentedControlItem( + value: 'two', + label: 'Two', + icon: Icons.star, + ), + RemixSegmentedControlItem(value: 'three', label: 'Three'), + ], + selectedValue: 'one', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().paddingY(12), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final heights = [ + for (var index = 0; index < 3; index++) + tester.getSize(options.at(index)).height, + ]; + + expect(heights[0], closeTo(heights[1], 0.01)); + expect(heights[1], closeTo(heights[2], 0.01)); + }); + + testWidgets( + 'narrow vertical segments fit wrapped labels at 200% text scale', + (tester) async { + const longLabel = 'Wrapped'; + + await tester.pumpRemixApp( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: SizedBox( + width: 90, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'short', label: 'Short'), + RemixSegmentedControlItem( + value: 'localized', + label: longLabel, + ), + ], + selectedValue: 'short', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().paddingAll(4), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final labelBox = tester.renderObject(find.text(longLabel)); + final wrappedHeight = labelBox.getMaxIntrinsicHeight( + labelBox.constraints.maxWidth, + ); + final singleLineHeight = labelBox.getMaxIntrinsicHeight( + double.infinity, + ); + + expect(wrappedHeight, greaterThan(singleLineHeight)); + expect(labelBox.size.height, closeTo(wrappedHeight, 0.01)); + expect( + tester.getSize(options.at(0)).height, + closeTo(tester.getSize(options.at(1)).height, 0.01), + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('vertical segments fill the shared cross axis', (tester) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'a', label: 'A'), + RemixSegmentedControlItem( + value: 'comfortable', + label: 'Comfortable', + ), + RemixSegmentedControlItem(value: 'wide', label: 'Wide'), + ], + selectedValue: 'comfortable', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().paddingAll(8), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final widths = [ + for (var index = 0; index < 3; index++) + tester.getSize(options.at(index)).width, + ]; + + expect(widths[0], closeTo(widths[1], 0.01)); + expect(widths[1], closeTo(widths[2], 0.01)); + }); + + testWidgets('an explicit vertical track height is divided equally', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'one', label: 'One'), + RemixSegmentedControlItem(value: 'two', label: 'Two'), + RemixSegmentedControlItem(value: 'three', label: 'Three'), + ], + selectedValue: 'one', + onChanged: (_) {}, + orientation: Axis.vertical, + style: SegmentedControlStyler().constraintsOnly(height: 240), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + expect(tester.getSize(_trackBox()).height, 240); + for (var index = 0; index < 3; index++) { + expect(tester.getSize(options.at(index)).height, closeTo(80, 0.01)); + } + }); + + testWidgets('RTL visual order follows widget and semantics order', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'first', label: 'First'), + RemixSegmentedControlItem(value: 'second', label: 'Second'), + RemixSegmentedControlItem(value: 'third', label: 'Third'), + ], + selectedValue: 'first', + onChanged: (_) {}, + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final firstRect = tester.getRect(options.at(0)); + final secondRect = tester.getRect(options.at(1)); + final thirdRect = tester.getRect(options.at(2)); + + expect(firstRect.left, greaterThan(secondRect.left)); + expect(secondRect.left, greaterThan(thirdRect.left)); + expect(firstRect.width, closeTo(secondRect.width, 0.01)); + expect(secondRect.width, closeTo(thirdRect.width, 0.01)); + expect(tester.getRect(find.bySemanticsLabel('First')), firstRect); + expect(tester.getRect(find.bySemanticsLabel('Second')), secondRect); + expect(tester.getRect(find.bySemanticsLabel('Third')), thirdRect); + semantics.dispose(); + }); + + testWidgets('200% text scale fits a narrow bounded parent', (tester) async { + await tester.pumpRemixApp( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: SizedBox( + width: 180, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'short', label: 'Short'), + RemixSegmentedControlItem( + value: 'localized', + label: 'Long localized label', + ), + ], + selectedValue: 'short', + onChanged: (_) {}, + style: SegmentedControlStyler( + item: SegmentedControlItemStyler().paddingAll(4), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + expect(tester.getSize(_trackBox()).width, lessThanOrEqualTo(180)); + expect( + tester.getSize(options.at(0)).width, + closeTo(tester.getSize(options.at(1)).width, 0.01), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('dry track layout matches wet layout', (tester) async { + await tester.pumpRemixApp( + SizedBox( + width: 240, + child: RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'day', label: 'Day'), + RemixSegmentedControlItem(value: 'week', label: 'This week'), + RemixSegmentedControlItem(value: 'month', label: 'Month'), + ], + selectedValue: 'week', + onChanged: (_) {}, + style: SegmentedControlStyler() + .mainAxisSize(.max) + .spacing(6) + .paddingAll(4), + ), + ), + textDirection: TextDirection.rtl, + ); + await tester.pumpAndSettle(); + + final track = tester.renderObject(_trackBox()); + final drySize = track.getDryLayout(track.constraints); + final options = find.byType(NakedToggleOption); + final firstRect = tester.getRect(options.at(0)); + final secondRect = tester.getRect(options.at(1)); + final thirdRect = tester.getRect(options.at(2)); + + expect(drySize, track.size); + expect(track.size.width, 240); + expect(firstRect.width, closeTo(secondRect.width, 0.01)); + expect(secondRect.width, closeTo(thirdRect.width, 0.01)); + expect(firstRect.left, greaterThan(secondRect.left)); + expect(secondRect.left, greaterThan(thirdRect.left)); + expect(firstRect.left - secondRect.right, closeTo(6, 0.01)); + expect(secondRect.left - thirdRect.right, closeTo(6, 0.01)); + }); + + testWidgets('null callback resolves the disabled track variant', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + style: SegmentedControlStyler() + .color(Colors.red) + .onDisabled(SegmentedControlStyler().color(Colors.grey)), + ), + ); + await tester.pumpAndSettle(); + + final track = tester.widget(_trackBox()); + final decoration = track.styleSpec!.spec.decoration as BoxDecoration?; + + expect(decoration?.color, Colors.grey); + }); + + testWidgets('enabled false resolves the disabled track variant', ( + tester, + ) async { + await tester.pumpRemixApp( + RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + ], + selectedValue: 'list', + onChanged: (_) {}, + enabled: false, + style: SegmentedControlStyler() + .color(Colors.red) + .onDisabled(SegmentedControlStyler().color(Colors.grey)), + ), + ); + await tester.pumpAndSettle(); + + final track = tester.widget(_trackBox()); + final decoration = track.styleSpec!.spec.decoration as BoxDecoration?; + + expect(decoration?.color, Colors.grey); + }); + + testWidgets('selected item effects do not change segment geometry', ( + tester, + ) async { + late StateSetter update; + var selectedValue = 'list'; + final style = SegmentedControlStyler( + item: SegmentedControlItemStyler().onSelected( + SegmentedControlItemStyler().containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: Colors.blue, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 3, + ), + ), + ), + ); + + await tester.pumpRemixApp( + StatefulBuilder( + builder: (context, setState) { + update = setState; + return RemixSegmentedControl( + items: const [ + RemixSegmentedControlItem(value: 'list', label: 'List'), + RemixSegmentedControlItem(value: 'grid', label: 'Grid'), + ], + selectedValue: selectedValue, + onChanged: (_) {}, + style: style, + ); + }, + ), + ); + await tester.pumpAndSettle(); + + final options = find.byType(NakedToggleOption); + final before = [ + tester.getSize(options.at(0)), + tester.getSize(options.at(1)), + ]; + + update(() => selectedValue = 'grid'); + await tester.pumpAndSettle(); + + expect(tester.getSize(options.at(0)), before[0]); + expect(tester.getSize(options.at(1)), before[1]); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/packages/remix/test/public_api_compatibility_test.dart b/packages/remix/test/public_api_compatibility_test.dart index db6f594a..2f97d608 100644 --- a/packages/remix/test/public_api_compatibility_test.dart +++ b/packages/remix/test/public_api_compatibility_test.dart @@ -41,6 +41,14 @@ void main() { const progress = RemixProgress(value: 0.5); const tabBar = RemixTabBar(child: Text('Tabs')); const spinner = RemixSpinner(); + const segmentedItem = RemixSegmentedControlItem( + value: 'list', + label: 'List', + ); + const segmentedControl = RemixSegmentedControl( + items: [segmentedItem], + selectedValue: 'list', + ); const textArea = RemixTextArea(label: 'Notes'); const checkboxGroup = RemixCheckboxGroup( values: {'one'}, @@ -71,6 +79,8 @@ void main() { expect(progress.value, 0.5); expect(tabBar.child, isA()); expect(spinner, isA()); + expect(segmentedControl.items.single, segmentedItem); + expect(segmentedControl.items.single.value, 'list'); expect(textArea, isA()); expect(textArea.label, 'Notes'); expect(checkbox.selected, isFalse); @@ -196,6 +206,20 @@ void main() { expect(checkbox.minimumTapTargetSize, Size.zero); }); + test('segmented control accepts a non-nullable value callback', () { + final changes = []; + final control = RemixSegmentedControl( + items: const [RemixSegmentedControlItem(value: 'list', label: 'List')], + selectedValue: null, + onChanged: changes.add, + ); + + control.onChanged?.call('list'); + + expect(control.selectedValue, isNull); + expect(changes, ['list']); + }); + test('theme configuration exposes only canonical names', () { const config = FortalThemeConfig( accent: .red, diff --git a/packages/remix/test/public_api_test.dart b/packages/remix/test/public_api_test.dart index 84f37b78..0381bbab 100644 --- a/packages/remix/test/public_api_test.dart +++ b/packages/remix/test/public_api_test.dart @@ -5,6 +5,75 @@ import 'package:remix/remix.dart'; enum Interest { design, code } void main() { + test('segmented control API is constructible from the package barrel', () { + const item = RemixSegmentedControlItem( + value: 'list', + label: 'List', + ); + const control = RemixSegmentedControl( + items: [item], + selectedValue: 'list', + ); + const unselectedControl = RemixSegmentedControl( + items: [RemixSegmentedControlItem(value: 1, label: 'One')], + selectedValue: null, + ); + + expect(control.items.single, item); + expect(unselectedControl.items.single.value, 1); + expect(unselectedControl.selectedValue, isNull); + expect(control.style, isA()); + expect(const SegmentedControlSpec(), isA()); + expect(const SegmentedControlItemSpec(), isA()); + }); + + test('segmented control styleFrom builds the widget in one step', () { + final SegmentedControlStyler styler = RemixSegmentedControl.styleFrom( + spacing: 4, + mainAxisSize: MainAxisSize.max, + ); + const forwardedKey = ValueKey('forwarded'); + const items = [RemixSegmentedControlItem(value: 'a', label: 'A')]; + + final called = styler( + key: forwardedKey, + items: items, + selectedValue: 'a', + orientation: Axis.vertical, + loop: false, + semanticLabel: 'Forwarded', + excludeSemantics: true, + ); + + expect(called, isA>()); + expect(called.key, forwardedKey); + expect(called.items, items); + expect(called.selectedValue, 'a'); + expect(called.orientation, Axis.vertical); + expect(called.loop, isFalse); + expect(called.semanticLabel, 'Forwarded'); + expect(called.excludeSemantics, isTrue); + expect(called.style, same(styler)); + }); + + test( + 'segmented callback receives T while selectedValue remains nullable', + () { + final changes = []; + + final control = RemixSegmentedControl( + items: const [RemixSegmentedControlItem(value: 'grid', label: 'Grid')], + selectedValue: null, + onChanged: changes.add, + ); + + control.onChanged?.call('grid'); + + expect(control.selectedValue, isNull); + expect(changes, ['grid']); + }, + ); + test('RemixTextArea is exported as the multiline TextField facade', () { const textArea = RemixTextArea();