From cb9f815777debae75c1016a1bd16797ee8de27d4 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 20:41:24 -0400 Subject: [PATCH 1/3] feat(remix): add containerEffects to focusable parts Add containerEffects to the Tab, Toggle, ToggleGroup item, SelectMenuItem, MenuItem, and MenuTrigger specs, and triggerEffects to Accordion and Disclosure, whose containerEffects already covers the whole panel. Each widget renders through RemixBoxAdapter or RemixFlexBoxAdapter, so an outline paints outside the box without taking layout space. --- docs/components/accordion.mdx | 13 +- docs/components/disclosure.mdx | 4 + docs/components/menu.mdx | 33 ++ docs/components/select.mdx | 7 + docs/components/tabs.mdx | 6 + docs/components/toggle.mdx | 5 + docs/components/toggle_group.mdx | 35 ++ packages/remix/CHANGELOG.md | 13 + .../src/components/accordion/accordion.g.dart | 23 + .../components/accordion/accordion_spec.dart | 12 + .../accordion/accordion_widget.dart | 3 +- .../components/disclosure/disclosure.g.dart | 30 +- .../disclosure/disclosure_spec.dart | 12 + .../disclosure/disclosure_widget.dart | 6 +- .../remix/lib/src/components/menu/menu.g.dart | 61 ++- .../lib/src/components/menu/menu_spec.dart | 44 ++ .../lib/src/components/menu/menu_widget.dart | 7 +- .../lib/src/components/select/select.g.dart | 37 +- .../src/components/select/select_spec.dart | 22 + .../src/components/select/select_widget.dart | 4 +- .../remix/lib/src/components/tabs/tabs.dart | 1 + .../remix/lib/src/components/tabs/tabs.g.dart | 31 +- .../lib/src/components/tabs/tabs_spec.dart | 23 + .../lib/src/components/tabs/tabs_widget.dart | 3 +- .../lib/src/components/toggle/toggle.dart | 1 + .../lib/src/components/toggle/toggle.g.dart | 31 +- .../src/components/toggle/toggle_spec.dart | 23 + .../src/components/toggle/toggle_widget.dart | 4 +- .../components/toggle_group/toggle_group.dart | 1 + .../toggle_group/toggle_group.g.dart | 31 +- .../toggle_group/toggle_group_spec.dart | 22 + .../toggle_group/toggle_group_widget.dart | 4 +- .../accordion/accordion_spec_test.dart | 6 +- .../accordion/accordion_style_test.dart | 3 +- .../test/components/menu/menu_spec_test.dart | 9 +- .../components/menu/menu_widget_test.dart | 78 +-- .../components/select/select_spec_test.dart | 11 +- .../tabs/tab_child_container_test.dart | 4 +- .../test/components/tabs/tabs_spec_test.dart | 3 +- .../components/toggle/toggle_spec_test.dart | 8 +- .../components/toggle/toggle_style_test.dart | 3 +- .../components/toggle/toggle_widget_test.dart | 10 +- .../toggle_group/toggle_group_spec_test.dart | 2 +- .../remix_box_effects_slots_test.dart | 476 ++++++++++++++++++ .../components/menu/menu_widget_test.dart | 33 +- .../components/toggle/toggle_widget_test.dart | 23 +- .../references/components.md | 8 + 47 files changed, 1149 insertions(+), 80 deletions(-) create mode 100644 packages/remix/test/rendering/remix_box_effects_slots_test.dart diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index 48312ed91..ed3e0b0b8 100644 --- a/docs/components/accordion.mdx +++ b/docs/components/accordion.mdx @@ -22,7 +22,9 @@ An accordion item is one panel with two parts inside it: `containerEffects` for layered fills, strokes, and backdrop blur painted with it. - **`trigger`** — the always-visible header row (leading icon, title, trailing - icon). It owns only its own internal layout, such as padding and spacing. + icon). It owns only its own internal layout, such as padding and spacing. Use + `triggerEffects` for an outline, such as a focus ring, drawn around the + header alone. - **`content`** — the expanded body. It owns its own padding and any interior divider, drawn as a foreground border so it does not inset the body by a pixel. @@ -339,6 +341,15 @@ Configures layered fills, strokes, and backdrop blur painted with `container`. Configures the trigger row style. +#### `triggerEffects(RemixBoxEffectsMix value)` + +Configures an outline and paint layers drawn around the trigger row only, +without taking layout space. Use it in `onFocusVisible` for a focus ring that +does not wrap the revealed content, unlike `containerEffects`. When the +`container` clips, an outside ring is cropped at the panel edge; use a negative +`outlineOffset` to draw it inside the trigger. A custom `builder` replaces the +default trigger and its effects. + #### `leadingIcon(IconStyler value)` Configures the leading icon style. diff --git a/docs/components/disclosure.mdx b/docs/components/disclosure.mdx index f772c658d..6c0ca9417 100644 --- a/docs/components/disclosure.mdx +++ b/docs/components/disclosure.mdx @@ -95,6 +95,10 @@ and `borderRadius` to the trigger. Style the other parts explicitly: - `container(...)` — frame around trigger and content - `containerEffects(...)` — shadows, strokes, backdrop blur - `trigger(...)` / `content(...)` +- `triggerEffects(...)` — an outline, such as a focus ring, around the trigger + alone; `containerEffects` wraps the revealed content too. When `container` + clips, an outside ring is cropped at the panel edge; use a negative + `outlineOffset` to draw it inside the trigger. - `onExpanded(...)` / `onCollapsed(...)` - `onHovered`, `onPressed`, `onFocused`, `onFocusVisible`, `onDisabled` diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index 2683f5920..83c47501e 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -318,6 +318,39 @@ that panel directly contains a checkbox or radio group. Nested submenu panels decide this independently. A custom `trailingIcon` replaces the default directional submenu chevron. +## Focus rings + +`MenuTriggerStyler.containerEffects` and `MenuItemStyler.containerEffects` +paint an outline around the trigger or a row without taking layout space, so +focus does not resize either. `MenuStyler.containerEffects` is separate: it +paints the popup panel. + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +final menuRing = RemixBoxEffectsMix( + outline: BorderSideMix(color: Colors.indigo, width: 2), + outlineOffset: 2, +); + +final ringedMenuStyle = MenuStyler() + .trigger( + MenuTriggerStyler().onFocusVisible( + MenuTriggerStyler().containerEffects(menuRing), + ), + ) + .item( + MenuItemStyler().onFocusVisible( + MenuItemStyler().containerEffects(menuRing), + ), + ); +``` + +A popup that clips (`clipBehavior` on `overlay`) crops an outside row ring at +its edge. Pad the popup by at least the ring's offset plus width, or use a +negative `outlineOffset` to draw the row ring inside the row. + ## Fortal widgets The application-owned Fortal preset provides a themed widget for this component: diff --git a/docs/components/select.mdx b/docs/components/select.mdx index 0d5e834a9..286a835a8 100644 --- a/docs/components/select.mdx +++ b/docs/components/select.mdx @@ -491,6 +491,13 @@ Sets single icon shadow Configures the menu item container style. +#### `containerEffects(RemixBoxEffectsMix value)` + +Configures an outline and paint layers drawn around the item row without +taking layout space, such as a focus ring in `onFocusVisible`. When the popup +content clips (`clipBehavior` on its container), an outside ring is cropped at +the popup edge; use a negative `outlineOffset` to draw it inside the row. + #### `text(TextStyler value)` Sets menu item text styling. diff --git a/docs/components/tabs.mdx b/docs/components/tabs.mdx index d9572c3c0..63140f401 100644 --- a/docs/components/tabs.mdx +++ b/docs/components/tabs.mdx @@ -356,6 +356,12 @@ Methods on `TabStyler` configure each interactive tab item. Configures the tab item flex box style. +#### `containerEffects(RemixBoxEffectsMix value)` + +Configures an outline and paint layers drawn around the tab without taking +layout space, such as a focus ring in `onFocusVisible`. A `builder` that +returns a different widget replaces the styled container and its effects. + #### `label(TextStyler value)` Configures the tab label style. diff --git a/docs/components/toggle.mdx b/docs/components/toggle.mdx index c23c87dbf..326ac812a 100644 --- a/docs/components/toggle.mdx +++ b/docs/components/toggle.mdx @@ -193,6 +193,11 @@ Optional. Cursor when hovering over the toggle. Configures the toggle container style. +#### `containerEffects(RemixBoxEffectsMix value)` + +Configures an outline and paint layers drawn around the toggle without taking +layout space, such as a focus ring in `onFocusVisible`. + #### `label(TextStyler value)` Configures the label text style. diff --git a/docs/components/toggle_group.mdx b/docs/components/toggle_group.mdx index 42bc1c374..1106d17e9 100644 --- a/docs/components/toggle_group.mdx +++ b/docs/components/toggle_group.mdx @@ -137,6 +137,41 @@ Widget buildStatusToggleGroup({ ``` +## Focus rings + +`ToggleGroupItemStyler.containerEffects` paints an outline around the item +without taking layout space, so focus does not resize the item or shift its +neighbors. Put it in an `onFocusVisible` variant. + +A group usually clips its items to a rounded outline, and the clip also crops +the ring of an end item when the ring is drawn outside the item. Draw the ring +inside instead: give `outlineOffset` the negative of the ring width. + + +```dart +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +final insetRing = RemixBoxEffectsMix( + outline: BorderSideMix(color: Colors.indigo, width: 2), + outlineOffset: -2, +); + +final segmentedStyle = ToggleGroupStyler( + container: FlexBoxStyler() + .borderRadius(.circular(8)) + .clipBehavior(.antiAlias), + item: ToggleGroupItemStyler() + .padding(.horizontal(12)) + .padding(.vertical(8)) + .onFocusVisible(ToggleGroupItemStyler().containerEffects(insetRing)), +); +``` + + +A group that does not clip can use a positive offset to draw the ring outside +the item. + ## Constructor diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index 8103958b9..232fdbcde 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,3 +1,16 @@ +## Unreleased + +### Features + +- Add `containerEffects` to `TabStyler`, `ToggleStyler`, + `ToggleGroupItemStyler`, `SelectMenuItemStyler`, `MenuItemStyler`, and + `MenuTriggerStyler`, and `triggerEffects` to `AccordionStyler` and + `DisclosureStyler`. An outline or paint layer draws around the part without + taking layout space, so a focus ring no longer resizes the part or shifts its + neighbors. On the accordion and disclosure, `containerEffects` still covers + the whole panel and `triggerEffects` covers only the trigger. A toggle group + clips its items, so ring an end item with a negative `outlineOffset`. + ## 1.0.0-beta.13 ### Breaking changes diff --git a/packages/remix/lib/src/components/accordion/accordion.g.dart b/packages/remix/lib/src/components/accordion/accordion.g.dart index 1d370c917..4ef7ca402 100644 --- a/packages/remix/lib/src/components/accordion/accordion.g.dart +++ b/packages/remix/lib/src/components/accordion/accordion.g.dart @@ -10,6 +10,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { StyleSpec get container; RemixBoxEffectsSpec? get containerEffects; StyleSpec get trigger; + RemixBoxEffectsSpec? get triggerEffects; StyleSpec get leadingIcon; StyleSpec get title; StyleSpec get trailingIcon; @@ -23,6 +24,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { StyleSpec? container, RemixBoxEffectsSpec? containerEffects, StyleSpec? trigger, + RemixBoxEffectsSpec? triggerEffects, StyleSpec? leadingIcon, StyleSpec? title, StyleSpec? trailingIcon, @@ -32,6 +34,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { container: container ?? this.container, containerEffects: containerEffects ?? this.containerEffects, trigger: trigger ?? this.trigger, + triggerEffects: triggerEffects ?? this.triggerEffects, leadingIcon: leadingIcon ?? this.leadingIcon, title: title ?? this.title, trailingIcon: trailingIcon ?? this.trailingIcon, @@ -49,6 +52,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { t, ), trigger: trigger.lerp(other?.trigger, t), + triggerEffects: MixOps.lerpSnap(triggerEffects, other?.triggerEffects, t), leadingIcon: leadingIcon.lerp(other?.leadingIcon, t), title: title.lerp(other?.title, t), trailingIcon: trailingIcon.lerp(other?.trailingIcon, t), @@ -61,6 +65,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { container, containerEffects, trigger, + triggerEffects, leadingIcon, title, trailingIcon, @@ -110,6 +115,7 @@ mixin _$AccordionSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('trigger', trigger)) + ..add(DiagnosticsProperty('triggerEffects', triggerEffects)) ..add(DiagnosticsProperty('leadingIcon', leadingIcon)) ..add(DiagnosticsProperty('title', title)) ..add(DiagnosticsProperty('trailingIcon', trailingIcon)) @@ -132,6 +138,7 @@ class AccordionStyler extends MixStyler final Prop>? $container; final Prop? $containerEffects; final Prop>? $trigger; + final Prop? $triggerEffects; final Prop>? $leadingIcon; final Prop>? $title; final Prop>? $trailingIcon; @@ -141,6 +148,7 @@ class AccordionStyler extends MixStyler Prop>? container, Prop? containerEffects, Prop>? trigger, + Prop? triggerEffects, Prop>? leadingIcon, Prop>? title, Prop>? trailingIcon, @@ -151,6 +159,7 @@ class AccordionStyler extends MixStyler }) : $container = container, $containerEffects = containerEffects, $trigger = trigger, + $triggerEffects = triggerEffects, $leadingIcon = leadingIcon, $title = title, $trailingIcon = trailingIcon, @@ -160,6 +169,7 @@ class AccordionStyler extends MixStyler BoxStyler? container, RemixBoxEffectsMix? containerEffects, FlexBoxStyler? trigger, + RemixBoxEffectsMix? triggerEffects, IconStyler? leadingIcon, TextStyler? title, IconStyler? trailingIcon, @@ -171,6 +181,7 @@ class AccordionStyler extends MixStyler container: Prop.maybeMix(container), containerEffects: Prop.maybeMix(containerEffects), trigger: Prop.maybeMix(trigger), + triggerEffects: Prop.maybeMix(triggerEffects), leadingIcon: Prop.maybeMix(leadingIcon), title: Prop.maybeMix(title), trailingIcon: Prop.maybeMix(trailingIcon), @@ -186,6 +197,8 @@ class AccordionStyler extends MixStyler AccordionStyler().containerEffects(value); factory AccordionStyler.trigger(FlexBoxStyler value) => AccordionStyler().trigger(value); + factory AccordionStyler.triggerEffects(RemixBoxEffectsMix value) => + AccordionStyler().triggerEffects(value); factory AccordionStyler.leadingIcon(IconStyler value) => AccordionStyler().leadingIcon(value); factory AccordionStyler.title(TextStyler value) => @@ -751,6 +764,7 @@ class AccordionStyler extends MixStyler 'container', 'containerEffects', 'trigger', + 'triggerEffects', 'leadingIcon', 'title', 'trailingIcon', @@ -775,6 +789,11 @@ class AccordionStyler extends MixStyler return merge(AccordionStyler(trigger: value)); } + /// Sets the triggerEffects. + AccordionStyler triggerEffects(RemixBoxEffectsMix value) { + return merge(AccordionStyler(triggerEffects: value)); + } + /// Sets the leadingIcon. AccordionStyler leadingIcon(IconStyler value) { return merge(AccordionStyler(leadingIcon: value)); @@ -869,6 +888,7 @@ class AccordionStyler extends MixStyler other?.$containerEffects, ), trigger: MixOps.merge($trigger, other?.$trigger), + triggerEffects: MixOps.merge($triggerEffects, other?.$triggerEffects), leadingIcon: MixOps.merge($leadingIcon, other?.$leadingIcon), title: MixOps.merge($title, other?.$title), trailingIcon: MixOps.merge($trailingIcon, other?.$trailingIcon), @@ -886,6 +906,7 @@ class AccordionStyler extends MixStyler container: MixOps.resolve(context, $container), containerEffects: MixOps.resolve(context, $containerEffects), trigger: MixOps.resolve(context, $trigger), + triggerEffects: MixOps.resolve(context, $triggerEffects), leadingIcon: MixOps.resolve(context, $leadingIcon), title: MixOps.resolve(context, $title), trailingIcon: MixOps.resolve(context, $trailingIcon), @@ -906,6 +927,7 @@ class AccordionStyler extends MixStyler ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('trigger', $trigger)) + ..add(DiagnosticsProperty('triggerEffects', $triggerEffects)) ..add(DiagnosticsProperty('leadingIcon', $leadingIcon)) ..add(DiagnosticsProperty('title', $title)) ..add(DiagnosticsProperty('trailingIcon', $trailingIcon)) @@ -917,6 +939,7 @@ class AccordionStyler extends MixStyler $container, $containerEffects, $trigger, + $triggerEffects, $leadingIcon, $title, $trailingIcon, diff --git a/packages/remix/lib/src/components/accordion/accordion_spec.dart b/packages/remix/lib/src/components/accordion/accordion_spec.dart index eea52b15f..fd8e8ccbc 100644 --- a/packages/remix/lib/src/components/accordion/accordion_spec.dart +++ b/packages/remix/lib/src/components/accordion/accordion_spec.dart @@ -23,6 +23,12 @@ class AccordionSpec with _$AccordionSpec { @override @MixableField(forwardStyler: true) final StyleSpec trigger; + + /// Outline and paint layers drawn around [trigger] only, so a focus ring + /// does not wrap the revealed content. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? triggerEffects; @override final StyleSpec leadingIcon; @override @@ -36,6 +42,7 @@ class AccordionSpec with _$AccordionSpec { StyleSpec? container, this.containerEffects, StyleSpec? trigger, + this.triggerEffects, StyleSpec? leadingIcon, StyleSpec? title, StyleSpec? trailingIcon, @@ -60,6 +67,11 @@ class AccordionSpec with _$AccordionSpec { other.containerEffects, t, ), + triggerEffects: RemixBoxEffectsSpec.lerpNullable( + triggerEffects, + other.triggerEffects, + t, + ), ); } } diff --git a/packages/remix/lib/src/components/accordion/accordion_widget.dart b/packages/remix/lib/src/components/accordion/accordion_widget.dart index b54ef43f1..c747e05bc 100644 --- a/packages/remix/lib/src/components/accordion/accordion_widget.dart +++ b/packages/remix/lib/src/components/accordion/accordion_widget.dart @@ -269,8 +269,9 @@ class _RemixAccordionBodyState extends State<_RemixAccordionBody> { final leadingIcon = _config.leadingIcon; final title = _config.title; - return FlexBox( + return RemixFlexBoxAdapter( styleSpec: spec.trigger, + containerEffects: spec.triggerEffects, children: [ if (leadingIcon != null) StyledIcon(icon: leadingIcon, styleSpec: spec.leadingIcon), diff --git a/packages/remix/lib/src/components/disclosure/disclosure.g.dart b/packages/remix/lib/src/components/disclosure/disclosure.g.dart index bebd1dbfc..3599d0c32 100644 --- a/packages/remix/lib/src/components/disclosure/disclosure.g.dart +++ b/packages/remix/lib/src/components/disclosure/disclosure.g.dart @@ -10,6 +10,7 @@ mixin _$DisclosureSpec implements Spec, Diagnosticable { StyleSpec get container; RemixBoxEffectsSpec? get containerEffects; StyleSpec get trigger; + RemixBoxEffectsSpec? get triggerEffects; StyleSpec get content; @override @@ -20,12 +21,14 @@ mixin _$DisclosureSpec implements Spec, Diagnosticable { StyleSpec? container, RemixBoxEffectsSpec? containerEffects, StyleSpec? trigger, + RemixBoxEffectsSpec? triggerEffects, StyleSpec? content, }) { return DisclosureSpec( container: container ?? this.container, containerEffects: containerEffects ?? this.containerEffects, trigger: trigger ?? this.trigger, + triggerEffects: triggerEffects ?? this.triggerEffects, content: content ?? this.content, ); } @@ -40,12 +43,19 @@ mixin _$DisclosureSpec implements Spec, Diagnosticable { t, ), trigger: trigger.lerp(other?.trigger, t), + triggerEffects: MixOps.lerpSnap(triggerEffects, other?.triggerEffects, t), content: content.lerp(other?.content, t), ); } @override - List get props => [container, containerEffects, trigger, content]; + List get props => [ + container, + containerEffects, + trigger, + triggerEffects, + content, + ]; @override bool operator ==(Object other) { @@ -90,6 +100,7 @@ mixin _$DisclosureSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('container', container)) ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('trigger', trigger)) + ..add(DiagnosticsProperty('triggerEffects', triggerEffects)) ..add(DiagnosticsProperty('content', content)); } } @@ -109,12 +120,14 @@ class DisclosureStyler extends MixStyler final Prop>? $container; final Prop? $containerEffects; final Prop>? $trigger; + final Prop? $triggerEffects; final Prop>? $content; const DisclosureStyler.create({ Prop>? container, Prop? containerEffects, Prop>? trigger, + Prop? triggerEffects, Prop>? content, super.variants, super.modifier, @@ -122,12 +135,14 @@ class DisclosureStyler extends MixStyler }) : $container = container, $containerEffects = containerEffects, $trigger = trigger, + $triggerEffects = triggerEffects, $content = content; DisclosureStyler({ BoxStyler? container, RemixBoxEffectsMix? containerEffects, BoxStyler? trigger, + RemixBoxEffectsMix? triggerEffects, BoxStyler? content, AnimationConfig? animation, WidgetModifierConfig? modifier, @@ -136,6 +151,7 @@ class DisclosureStyler extends MixStyler container: Prop.maybeMix(container), containerEffects: Prop.maybeMix(containerEffects), trigger: Prop.maybeMix(trigger), + triggerEffects: Prop.maybeMix(triggerEffects), content: Prop.maybeMix(content), variants: variants, modifier: modifier, @@ -148,6 +164,8 @@ class DisclosureStyler extends MixStyler DisclosureStyler().containerEffects(value); factory DisclosureStyler.trigger(BoxStyler value) => DisclosureStyler().trigger(value); + factory DisclosureStyler.triggerEffects(RemixBoxEffectsMix value) => + DisclosureStyler().triggerEffects(value); factory DisclosureStyler.content(BoxStyler value) => DisclosureStyler().content(value); factory DisclosureStyler.alignment(AlignmentGeometry value) => @@ -651,6 +669,7 @@ class DisclosureStyler extends MixStyler 'container', 'containerEffects', 'trigger', + 'triggerEffects', 'content', 'animation', 'modifier', @@ -672,6 +691,11 @@ class DisclosureStyler extends MixStyler return merge(DisclosureStyler(trigger: value)); } + /// Sets the triggerEffects. + DisclosureStyler triggerEffects(RemixBoxEffectsMix value) { + return merge(DisclosureStyler(triggerEffects: value)); + } + /// Sets the content. DisclosureStyler content(BoxStyler value) { return merge(DisclosureStyler(content: value)); @@ -761,6 +785,7 @@ class DisclosureStyler extends MixStyler other?.$containerEffects, ), trigger: MixOps.merge($trigger, other?.$trigger), + triggerEffects: MixOps.merge($triggerEffects, other?.$triggerEffects), content: MixOps.merge($content, other?.$content), variants: MixOps.mergeVariants($variants, other?.$variants), modifier: MixOps.mergeModifier($modifier, other?.$modifier), @@ -775,6 +800,7 @@ class DisclosureStyler extends MixStyler container: MixOps.resolve(context, $container), containerEffects: MixOps.resolve(context, $containerEffects), trigger: MixOps.resolve(context, $trigger), + triggerEffects: MixOps.resolve(context, $triggerEffects), content: MixOps.resolve(context, $content), ); @@ -792,6 +818,7 @@ class DisclosureStyler extends MixStyler ..add(DiagnosticsProperty('container', $container)) ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('trigger', $trigger)) + ..add(DiagnosticsProperty('triggerEffects', $triggerEffects)) ..add(DiagnosticsProperty('content', $content)); } @@ -800,6 +827,7 @@ class DisclosureStyler extends MixStyler $container, $containerEffects, $trigger, + $triggerEffects, $content, $animation, $modifier, diff --git a/packages/remix/lib/src/components/disclosure/disclosure_spec.dart b/packages/remix/lib/src/components/disclosure/disclosure_spec.dart index 594a63fc5..fff2f63b8 100644 --- a/packages/remix/lib/src/components/disclosure/disclosure_spec.dart +++ b/packages/remix/lib/src/components/disclosure/disclosure_spec.dart @@ -18,6 +18,12 @@ class DisclosureSpec with _$DisclosureSpec { @MixableField(forwardStyler: true) final StyleSpec trigger; + /// Outline and paint layers drawn around [trigger] only, so a focus ring + /// does not wrap the revealed content. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? triggerEffects; + @override final StyleSpec content; @@ -25,6 +31,7 @@ class DisclosureSpec with _$DisclosureSpec { StyleSpec? container, this.containerEffects, StyleSpec? trigger, + this.triggerEffects, StyleSpec? content, }) : container = container ?? const StyleSpec(spec: BoxSpec()), trigger = trigger ?? const StyleSpec(spec: BoxSpec()), @@ -43,6 +50,11 @@ class DisclosureSpec with _$DisclosureSpec { other.containerEffects, t, ), + triggerEffects: RemixBoxEffectsSpec.lerpNullable( + triggerEffects, + other.triggerEffects, + t, + ), ); } } diff --git a/packages/remix/lib/src/components/disclosure/disclosure_widget.dart b/packages/remix/lib/src/components/disclosure/disclosure_widget.dart index c809c9dbd..98a309783 100644 --- a/packages/remix/lib/src/components/disclosure/disclosure_widget.dart +++ b/packages/remix/lib/src/components/disclosure/disclosure_widget.dart @@ -26,8 +26,10 @@ final class _RemixDisclosureTrigger extends StatelessWidget { @override Widget build(BuildContext context) { - return Box( - styleSpec: _RemixDisclosureStyleScope.of(context).trigger, + final spec = _RemixDisclosureStyleScope.of(context); + return RemixBoxAdapter( + styleSpec: spec.trigger, + containerEffects: spec.triggerEffects, child: child, ); } diff --git a/packages/remix/lib/src/components/menu/menu.g.dart b/packages/remix/lib/src/components/menu/menu.g.dart index 87766f185..65d3d266b 100644 --- a/packages/remix/lib/src/components/menu/menu.g.dart +++ b/packages/remix/lib/src/components/menu/menu.g.dart @@ -8,6 +8,7 @@ part of 'menu.dart'; mixin _$MenuTriggerSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get label; StyleSpec get icon; @@ -17,11 +18,13 @@ mixin _$MenuTriggerSpec implements Spec, Diagnosticable { @override MenuTriggerSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? label, StyleSpec? icon, }) { return MenuTriggerSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, label: label ?? this.label, icon: icon ?? this.icon, ); @@ -31,13 +34,18 @@ mixin _$MenuTriggerSpec implements Spec, Diagnosticable { MenuTriggerSpec lerp(MenuTriggerSpec? other, double t) { return MenuTriggerSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), label: label.lerp(other?.label, t), icon: icon.lerp(other?.icon, t), ); } @override - List get props => [container, label, icon]; + List get props => [container, containerEffects, label, icon]; @override bool operator ==(Object other) { @@ -80,6 +88,7 @@ mixin _$MenuTriggerSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)); } @@ -214,6 +223,7 @@ typedef _$MenuSpecMethods = _$MenuSpec; // ignore: unused_element mixin _$MenuItemSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get label; StyleSpec get leadingIcon; StyleSpec get trailingIcon; @@ -225,6 +235,7 @@ mixin _$MenuItemSpec implements Spec, Diagnosticable { @override MenuItemSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? label, StyleSpec? leadingIcon, StyleSpec? trailingIcon, @@ -232,6 +243,7 @@ mixin _$MenuItemSpec implements Spec, Diagnosticable { }) { return MenuItemSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, label: label ?? this.label, leadingIcon: leadingIcon ?? this.leadingIcon, trailingIcon: trailingIcon ?? this.trailingIcon, @@ -243,6 +255,11 @@ mixin _$MenuItemSpec implements Spec, Diagnosticable { MenuItemSpec lerp(MenuItemSpec? other, double t) { return MenuItemSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), label: label.lerp(other?.label, t), leadingIcon: leadingIcon.lerp(other?.leadingIcon, t), trailingIcon: trailingIcon.lerp(other?.trailingIcon, t), @@ -253,6 +270,7 @@ mixin _$MenuItemSpec implements Spec, Diagnosticable { @override List get props => [ container, + containerEffects, label, leadingIcon, trailingIcon, @@ -300,6 +318,7 @@ mixin _$MenuItemSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('leadingIcon', leadingIcon)) ..add(DiagnosticsProperty('trailingIcon', trailingIcon)) @@ -323,22 +342,26 @@ class MenuTriggerStyler extends MixStyler IconStyleMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $label; final Prop>? $icon; const MenuTriggerStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? label, Prop>? icon, super.variants, super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $label = label, $icon = icon; MenuTriggerStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? label, IconStyler? icon, AnimationConfig? animation, @@ -346,6 +369,7 @@ class MenuTriggerStyler extends MixStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), label: Prop.maybeMix(label), icon: Prop.maybeMix(icon), variants: variants, @@ -355,6 +379,8 @@ class MenuTriggerStyler extends MixStyler factory MenuTriggerStyler.container(FlexBoxStyler value) => MenuTriggerStyler().container(value); + factory MenuTriggerStyler.containerEffects(RemixBoxEffectsMix value) => + MenuTriggerStyler().containerEffects(value); factory MenuTriggerStyler.label(TextStyler value) => MenuTriggerStyler().label(value); factory MenuTriggerStyler.icon(IconStyler value) => @@ -916,6 +942,7 @@ class MenuTriggerStyler extends MixStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'label', 'icon', 'animation', @@ -928,6 +955,11 @@ class MenuTriggerStyler extends MixStyler return merge(MenuTriggerStyler(container: value)); } + /// Sets the containerEffects. + MenuTriggerStyler containerEffects(RemixBoxEffectsMix value) { + return merge(MenuTriggerStyler(containerEffects: value)); + } + /// Sets the label. @override MenuTriggerStyler label(TextStyler value) { @@ -968,6 +1000,10 @@ class MenuTriggerStyler extends MixStyler MenuTriggerStyler merge(MenuTriggerStyler? other) { return MenuTriggerStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), label: MixOps.merge($label, other?.$label), icon: MixOps.merge($icon, other?.$icon), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -981,6 +1017,7 @@ class MenuTriggerStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = MenuTriggerSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), label: MixOps.resolve(context, $label), icon: MixOps.resolve(context, $icon), ); @@ -997,6 +1034,7 @@ class MenuTriggerStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('icon', $icon)); } @@ -1004,6 +1042,7 @@ class MenuTriggerStyler extends MixStyler @override List get props => [ $container, + $containerEffects, $label, $icon, $animation, @@ -1281,6 +1320,7 @@ class MenuItemStyler extends MixStyler with RemixBoxStylerMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $label; final Prop>? $leadingIcon; final Prop>? $trailingIcon; @@ -1288,6 +1328,7 @@ class MenuItemStyler extends MixStyler const MenuItemStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? label, Prop>? leadingIcon, Prop>? trailingIcon, @@ -1296,6 +1337,7 @@ class MenuItemStyler extends MixStyler super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $label = label, $leadingIcon = leadingIcon, $trailingIcon = trailingIcon, @@ -1303,6 +1345,7 @@ class MenuItemStyler extends MixStyler MenuItemStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? label, IconStyler? leadingIcon, IconStyler? trailingIcon, @@ -1312,6 +1355,7 @@ class MenuItemStyler extends MixStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), label: Prop.maybeMix(label), leadingIcon: Prop.maybeMix(leadingIcon), trailingIcon: Prop.maybeMix(trailingIcon), @@ -1323,6 +1367,8 @@ class MenuItemStyler extends MixStyler factory MenuItemStyler.container(FlexBoxStyler value) => MenuItemStyler().container(value); + factory MenuItemStyler.containerEffects(RemixBoxEffectsMix value) => + MenuItemStyler().containerEffects(value); factory MenuItemStyler.label(TextStyler value) => MenuItemStyler().label(value); factory MenuItemStyler.leadingIcon(IconStyler value) => @@ -1883,6 +1929,7 @@ class MenuItemStyler extends MixStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'label', 'leadingIcon', 'trailingIcon', @@ -1897,6 +1944,11 @@ class MenuItemStyler extends MixStyler return merge(MenuItemStyler(container: value)); } + /// Sets the containerEffects. + MenuItemStyler containerEffects(RemixBoxEffectsMix value) { + return merge(MenuItemStyler(containerEffects: value)); + } + /// Sets the label. MenuItemStyler label(TextStyler value) { return merge(MenuItemStyler(label: value)); @@ -1945,6 +1997,10 @@ class MenuItemStyler extends MixStyler MenuItemStyler merge(MenuItemStyler? other) { return MenuItemStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), label: MixOps.merge($label, other?.$label), leadingIcon: MixOps.merge($leadingIcon, other?.$leadingIcon), trailingIcon: MixOps.merge($trailingIcon, other?.$trailingIcon), @@ -1960,6 +2016,7 @@ class MenuItemStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = MenuItemSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), label: MixOps.resolve(context, $label), leadingIcon: MixOps.resolve(context, $leadingIcon), trailingIcon: MixOps.resolve(context, $trailingIcon), @@ -1978,6 +2035,7 @@ class MenuItemStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('leadingIcon', $leadingIcon)) ..add(DiagnosticsProperty('trailingIcon', $trailingIcon)) @@ -1987,6 +2045,7 @@ class MenuItemStyler extends MixStyler @override List get props => [ $container, + $containerEffects, $label, $leadingIcon, $trailingIcon, diff --git a/packages/remix/lib/src/components/menu/menu_spec.dart b/packages/remix/lib/src/components/menu/menu_spec.dart index e7fc8b322..b4f4a5c01 100644 --- a/packages/remix/lib/src/components/menu/menu_spec.dart +++ b/packages/remix/lib/src/components/menu/menu_spec.dart @@ -13,6 +13,12 @@ class MenuTriggerSpec with _$MenuTriggerSpec { @MixableField(forwardStyler: true) final StyleSpec container; + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Text style for the trigger label. @override final StyleSpec label; @@ -24,11 +30,27 @@ class MenuTriggerSpec with _$MenuTriggerSpec { /// Creates a trigger spec with default empty child specs. const MenuTriggerSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? label, StyleSpec? icon, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), 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 + MenuTriggerSpec lerp(MenuTriggerSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [MenuTriggerSpec]. @@ -160,6 +182,12 @@ class MenuItemSpec with _$MenuItemSpec { @MixableField(forwardStyler: true) final StyleSpec container; + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Text style for the item label. @override final StyleSpec label; @@ -179,6 +207,7 @@ class MenuItemSpec with _$MenuItemSpec { /// Creates an item spec with default empty child specs. const MenuItemSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? label, StyleSpec? leadingIcon, StyleSpec? trailingIcon, @@ -188,6 +217,21 @@ class MenuItemSpec with _$MenuItemSpec { leadingIcon = leadingIcon ?? const StyleSpec(spec: IconSpec()), trailingIcon = trailingIcon ?? const StyleSpec(spec: IconSpec()), indicator = indicator ?? const StyleSpec(spec: IconSpec()); + + // Deliberate: route effects through lerpNullable so shadows/blends animate; + // the generator's default snap-lerps unrecognized spec types. + @override + MenuItemSpec lerp(MenuItemSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [MenuItemSpec]. diff --git a/packages/remix/lib/src/components/menu/menu_widget.dart b/packages/remix/lib/src/components/menu/menu_widget.dart index 5d55359e2..dfdc1e7ec 100644 --- a/packages/remix/lib/src/components/menu/menu_widget.dart +++ b/packages/remix/lib/src/components/menu/menu_widget.dart @@ -472,8 +472,10 @@ class _RemixMenuState extends State> { } Widget _buildDefaultTrigger(MenuTriggerSpec triggerSpec) { - return RowBox( + return RemixFlexBoxAdapter( styleSpec: triggerSpec.container, + direction: Axis.horizontal, + containerEffects: triggerSpec.containerEffects, children: [ if (widget.trigger.icon != null) StyledIcon(icon: widget.trigger.icon!, styleSpec: triggerSpec.icon), @@ -916,8 +918,9 @@ class _RemixMenuItemContent extends StatelessWidget { ); } - return FlexBox( + return RemixFlexBoxAdapter( styleSpec: spec.container, + containerEffects: spec.containerEffects, children: [ if (reserveChoiceSlot) Visibility.maintain( diff --git a/packages/remix/lib/src/components/select/select.g.dart b/packages/remix/lib/src/components/select/select.g.dart index 64ce2f38b..b91ddd832 100644 --- a/packages/remix/lib/src/components/select/select.g.dart +++ b/packages/remix/lib/src/components/select/select.g.dart @@ -310,6 +310,7 @@ typedef _$SelectContentSpecMethods = _$SelectContentSpec; // ignore: unused_elem mixin _$SelectMenuItemSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get text; StyleSpec get indicator; StyleSpec get icon; @@ -320,12 +321,14 @@ mixin _$SelectMenuItemSpec implements Spec, Diagnosticable { @override SelectMenuItemSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? text, StyleSpec? indicator, StyleSpec? icon, }) { return SelectMenuItemSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, text: text ?? this.text, indicator: indicator ?? this.indicator, icon: icon ?? this.icon, @@ -336,6 +339,11 @@ mixin _$SelectMenuItemSpec implements Spec, Diagnosticable { SelectMenuItemSpec lerp(SelectMenuItemSpec? other, double t) { return SelectMenuItemSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), text: text.lerp(other?.text, t), indicator: indicator.lerp(other?.indicator, t), icon: icon.lerp(other?.icon, t), @@ -343,7 +351,13 @@ mixin _$SelectMenuItemSpec implements Spec, Diagnosticable { } @override - List get props => [container, text, indicator, icon]; + List get props => [ + container, + containerEffects, + text, + indicator, + icon, + ]; @override bool operator ==(Object other) { @@ -386,6 +400,7 @@ mixin _$SelectMenuItemSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('text', text)) ..add(DiagnosticsProperty('indicator', indicator)) ..add(DiagnosticsProperty('icon', icon)); @@ -2506,12 +2521,14 @@ class SelectMenuItemStyler IconStyleMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $text; final Prop>? $indicator; final Prop>? $icon; const SelectMenuItemStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? text, Prop>? indicator, Prop>? icon, @@ -2519,12 +2536,14 @@ class SelectMenuItemStyler super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $text = text, $indicator = indicator, $icon = icon; SelectMenuItemStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? text, BoxStyler? indicator, IconStyler? icon, @@ -2533,6 +2552,7 @@ class SelectMenuItemStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), text: Prop.maybeMix(text), indicator: Prop.maybeMix(indicator), icon: Prop.maybeMix(icon), @@ -2543,6 +2563,8 @@ class SelectMenuItemStyler factory SelectMenuItemStyler.container(FlexBoxStyler value) => SelectMenuItemStyler().container(value); + factory SelectMenuItemStyler.containerEffects(RemixBoxEffectsMix value) => + SelectMenuItemStyler().containerEffects(value); factory SelectMenuItemStyler.text(TextStyler value) => SelectMenuItemStyler().text(value); factory SelectMenuItemStyler.indicator(BoxStyler value) => @@ -3112,6 +3134,7 @@ class SelectMenuItemStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'text', 'indicator', 'icon', @@ -3125,6 +3148,11 @@ class SelectMenuItemStyler return merge(SelectMenuItemStyler(container: value)); } + /// Sets the containerEffects. + SelectMenuItemStyler containerEffects(RemixBoxEffectsMix value) { + return merge(SelectMenuItemStyler(containerEffects: value)); + } + /// Sets the text. SelectMenuItemStyler text(TextStyler value) { return merge(SelectMenuItemStyler(text: value)); @@ -3169,6 +3197,10 @@ class SelectMenuItemStyler SelectMenuItemStyler merge(SelectMenuItemStyler? other) { return SelectMenuItemStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), text: MixOps.merge($text, other?.$text), indicator: MixOps.merge($indicator, other?.$indicator), icon: MixOps.merge($icon, other?.$icon), @@ -3183,6 +3215,7 @@ class SelectMenuItemStyler StyleSpec resolve(BuildContext context) { final spec = SelectMenuItemSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), text: MixOps.resolve(context, $text), indicator: MixOps.resolve(context, $indicator), icon: MixOps.resolve(context, $icon), @@ -3200,6 +3233,7 @@ class SelectMenuItemStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('text', $text)) ..add(DiagnosticsProperty('indicator', $indicator)) ..add(DiagnosticsProperty('icon', $icon)); @@ -3208,6 +3242,7 @@ class SelectMenuItemStyler @override List get props => [ $container, + $containerEffects, $text, $indicator, $icon, diff --git a/packages/remix/lib/src/components/select/select_spec.dart b/packages/remix/lib/src/components/select/select_spec.dart index 126dc4682..1b232ce03 100644 --- a/packages/remix/lib/src/components/select/select_spec.dart +++ b/packages/remix/lib/src/components/select/select_spec.dart @@ -138,6 +138,12 @@ class SelectMenuItemSpec with _$SelectMenuItemSpec { @override @MixableField(forwardStyler: true) final StyleSpec container; + + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; @override final StyleSpec text; @override @@ -147,6 +153,7 @@ class SelectMenuItemSpec with _$SelectMenuItemSpec { const SelectMenuItemSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? text, StyleSpec? indicator, StyleSpec? icon, @@ -154,6 +161,21 @@ class SelectMenuItemSpec with _$SelectMenuItemSpec { text = text ?? const StyleSpec(spec: TextSpec()), indicator = indicator ?? const StyleSpec(spec: BoxSpec()), 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 + SelectMenuItemSpec lerp(SelectMenuItemSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [SelectMenuItemSpec]. diff --git a/packages/remix/lib/src/components/select/select_widget.dart b/packages/remix/lib/src/components/select/select_widget.dart index 9a71e7935..ed1d2eb3a 100644 --- a/packages/remix/lib/src/components/select/select_widget.dart +++ b/packages/remix/lib/src/components/select/select_widget.dart @@ -502,8 +502,10 @@ class _RemixSelectItemWidget extends StatelessWidget { builder: (context, spec) => StyleSpecBuilder( styleSpec: spec.item, builder: (context, item) => ExcludeSemantics( - child: RowBox( + child: RemixFlexBoxAdapter( styleSpec: item.container, + direction: Axis.horizontal, + containerEffects: item.containerEffects, children: [ Expanded(child: StyledText(data.label, styleSpec: item.text)), if (state.isSelected) diff --git a/packages/remix/lib/src/components/tabs/tabs.dart b/packages/remix/lib/src/components/tabs/tabs.dart index f677f83f1..8a1cd4e6c 100644 --- a/packages/remix/lib/src/components/tabs/tabs.dart +++ b/packages/remix/lib/src/components/tabs/tabs.dart @@ -7,6 +7,7 @@ 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'; diff --git a/packages/remix/lib/src/components/tabs/tabs.g.dart b/packages/remix/lib/src/components/tabs/tabs.g.dart index 36a868f83..6cca06433 100644 --- a/packages/remix/lib/src/components/tabs/tabs.g.dart +++ b/packages/remix/lib/src/components/tabs/tabs.g.dart @@ -75,6 +75,7 @@ typedef _$TabBarSpecMethods = _$TabBarSpec; // ignore: unused_element mixin _$TabSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get label; StyleSpec get icon; @@ -84,11 +85,13 @@ mixin _$TabSpec implements Spec, Diagnosticable { @override TabSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? label, StyleSpec? icon, }) { return TabSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, label: label ?? this.label, icon: icon ?? this.icon, ); @@ -98,13 +101,18 @@ mixin _$TabSpec implements Spec, Diagnosticable { TabSpec lerp(TabSpec? other, double t) { return TabSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), label: label.lerp(other?.label, t), icon: icon.lerp(other?.icon, t), ); } @override - List get props => [container, label, icon]; + List get props => [container, containerEffects, label, icon]; @override bool operator ==(Object other) { @@ -147,6 +155,7 @@ mixin _$TabSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)); } @@ -879,22 +888,26 @@ class TabStyler extends MixStyler IconStyleMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $label; final Prop>? $icon; const TabStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? label, Prop>? icon, super.variants, super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $label = label, $icon = icon; TabStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? label, IconStyler? icon, AnimationConfig? animation, @@ -902,6 +915,7 @@ class TabStyler extends MixStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), label: Prop.maybeMix(label), icon: Prop.maybeMix(icon), variants: variants, @@ -911,6 +925,8 @@ class TabStyler extends MixStyler factory TabStyler.container(FlexBoxStyler value) => TabStyler().container(value); + factory TabStyler.containerEffects(RemixBoxEffectsMix value) => + TabStyler().containerEffects(value); factory TabStyler.label(TextStyler value) => TabStyler().label(value); factory TabStyler.icon(IconStyler value) => TabStyler().icon(value); factory TabStyler.color(Color value) => TabStyler().color(value); @@ -1448,6 +1464,7 @@ class TabStyler extends MixStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'label', 'icon', 'animation', @@ -1460,6 +1477,11 @@ class TabStyler extends MixStyler return merge(TabStyler(container: value)); } + /// Sets the containerEffects. + TabStyler containerEffects(RemixBoxEffectsMix value) { + return merge(TabStyler(containerEffects: value)); + } + /// Sets the label. @override TabStyler label(TextStyler value) { @@ -1537,6 +1559,10 @@ class TabStyler extends MixStyler TabStyler merge(TabStyler? other) { return TabStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), label: MixOps.merge($label, other?.$label), icon: MixOps.merge($icon, other?.$icon), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -1550,6 +1576,7 @@ class TabStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = TabSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), label: MixOps.resolve(context, $label), icon: MixOps.resolve(context, $icon), ); @@ -1566,6 +1593,7 @@ class TabStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('icon', $icon)); } @@ -1573,6 +1601,7 @@ class TabStyler extends MixStyler @override List get props => [ $container, + $containerEffects, $label, $icon, $animation, diff --git a/packages/remix/lib/src/components/tabs/tabs_spec.dart b/packages/remix/lib/src/components/tabs/tabs_spec.dart index ac92c7570..763cd9208 100644 --- a/packages/remix/lib/src/components/tabs/tabs_spec.dart +++ b/packages/remix/lib/src/components/tabs/tabs_spec.dart @@ -26,6 +26,13 @@ class TabSpec with _$TabSpec { @override @MixableField(forwardStyler: true) final StyleSpec container; + + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + @override final StyleSpec label; @override @@ -33,11 +40,27 @@ class TabSpec with _$TabSpec { const TabSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? label, StyleSpec? icon, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), 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 + TabSpec lerp(TabSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [TabSpec]. diff --git a/packages/remix/lib/src/components/tabs/tabs_widget.dart b/packages/remix/lib/src/components/tabs/tabs_widget.dart index fb0fbb5f4..0bf6a988a 100644 --- a/packages/remix/lib/src/components/tabs/tabs_widget.dart +++ b/packages/remix/lib/src/components/tabs/tabs_widget.dart @@ -204,8 +204,9 @@ class RemixTab extends StatelessWidget { TabSpec spec, NakedTabState state, ) { - final defaultContent = FlexBox( + final defaultContent = RemixFlexBoxAdapter( styleSpec: spec.container, + containerEffects: spec.containerEffects, children: [ if (child != null) child! diff --git a/packages/remix/lib/src/components/toggle/toggle.dart b/packages/remix/lib/src/components/toggle/toggle.dart index 5a25c13a9..4b177d7fd 100644 --- a/packages/remix/lib/src/components/toggle/toggle.dart +++ b/packages/remix/lib/src/components/toggle/toggle.dart @@ -7,6 +7,7 @@ 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 '../../utilities/remix_style.dart'; import '../../style/style.dart'; diff --git a/packages/remix/lib/src/components/toggle/toggle.g.dart b/packages/remix/lib/src/components/toggle/toggle.g.dart index d2e9caf0d..bfce7b047 100644 --- a/packages/remix/lib/src/components/toggle/toggle.g.dart +++ b/packages/remix/lib/src/components/toggle/toggle.g.dart @@ -8,6 +8,7 @@ part of 'toggle.dart'; mixin _$ToggleSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get label; StyleSpec get icon; @@ -17,11 +18,13 @@ mixin _$ToggleSpec implements Spec, Diagnosticable { @override ToggleSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? label, StyleSpec? icon, }) { return ToggleSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, label: label ?? this.label, icon: icon ?? this.icon, ); @@ -31,13 +34,18 @@ mixin _$ToggleSpec implements Spec, Diagnosticable { ToggleSpec lerp(ToggleSpec? other, double t) { return ToggleSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), label: label.lerp(other?.label, t), icon: icon.lerp(other?.icon, t), ); } @override - List get props => [container, label, icon]; + List get props => [container, containerEffects, label, icon]; @override bool operator ==(Object other) { @@ -80,6 +88,7 @@ mixin _$ToggleSpec implements Spec, Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)); } @@ -101,22 +110,26 @@ class ToggleStyler extends MixStyler IconStyleMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $label; final Prop>? $icon; const ToggleStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? label, Prop>? icon, super.variants, super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $label = label, $icon = icon; ToggleStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? label, IconStyler? icon, AnimationConfig? animation, @@ -124,6 +137,7 @@ class ToggleStyler extends MixStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), label: Prop.maybeMix(label), icon: Prop.maybeMix(icon), variants: variants, @@ -133,6 +147,8 @@ class ToggleStyler extends MixStyler factory ToggleStyler.container(FlexBoxStyler value) => ToggleStyler().container(value); + factory ToggleStyler.containerEffects(RemixBoxEffectsMix value) => + ToggleStyler().containerEffects(value); factory ToggleStyler.label(TextStyler value) => ToggleStyler().label(value); factory ToggleStyler.icon(IconStyler value) => ToggleStyler().icon(value); factory ToggleStyler.color(Color value) => ToggleStyler().color(value); @@ -683,6 +699,7 @@ class ToggleStyler extends MixStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'label', 'icon', 'animation', @@ -695,6 +712,11 @@ class ToggleStyler extends MixStyler return merge(ToggleStyler(container: value)); } + /// Sets the containerEffects. + ToggleStyler containerEffects(RemixBoxEffectsMix value) { + return merge(ToggleStyler(containerEffects: value)); + } + /// Sets the label. @override ToggleStyler label(TextStyler value) { @@ -766,6 +788,10 @@ class ToggleStyler extends MixStyler ToggleStyler merge(ToggleStyler? other) { return ToggleStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), label: MixOps.merge($label, other?.$label), icon: MixOps.merge($icon, other?.$icon), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -779,6 +805,7 @@ class ToggleStyler extends MixStyler StyleSpec resolve(BuildContext context) { final spec = ToggleSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), label: MixOps.resolve(context, $label), icon: MixOps.resolve(context, $icon), ); @@ -795,6 +822,7 @@ class ToggleStyler extends MixStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('icon', $icon)); } @@ -802,6 +830,7 @@ class ToggleStyler extends MixStyler @override List get props => [ $container, + $containerEffects, $label, $icon, $animation, diff --git a/packages/remix/lib/src/components/toggle/toggle_spec.dart b/packages/remix/lib/src/components/toggle/toggle_spec.dart index 4fb654d12..bdb85334d 100644 --- a/packages/remix/lib/src/components/toggle/toggle_spec.dart +++ b/packages/remix/lib/src/components/toggle/toggle_spec.dart @@ -9,6 +9,13 @@ class ToggleSpec with _$ToggleSpec { @override @MixableField(forwardStyler: true) final StyleSpec container; + + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + @override final StyleSpec label; @override @@ -16,11 +23,27 @@ class ToggleSpec with _$ToggleSpec { const ToggleSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? label, StyleSpec? icon, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), 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 + ToggleSpec lerp(ToggleSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [ToggleSpec]. diff --git a/packages/remix/lib/src/components/toggle/toggle_widget.dart b/packages/remix/lib/src/components/toggle/toggle_widget.dart index 04fba502a..01eeac9b5 100644 --- a/packages/remix/lib/src/components/toggle/toggle_widget.dart +++ b/packages/remix/lib/src/components/toggle/toggle_widget.dart @@ -110,8 +110,10 @@ class RemixToggle extends StatelessWidget { controller: NakedToggleState.controllerOf(context), builder: (context, spec) { return LayoutBuilder( - builder: (context, constraints) => RowBox( + builder: (context, constraints) => RemixFlexBoxAdapter( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ if (icon != null) StyledIcon(icon: icon, styleSpec: spec.icon), diff --git a/packages/remix/lib/src/components/toggle_group/toggle_group.dart b/packages/remix/lib/src/components/toggle_group/toggle_group.dart index 650595934..4a0afcca1 100644 --- a/packages/remix/lib/src/components/toggle_group/toggle_group.dart +++ b/packages/remix/lib/src/components/toggle_group/toggle_group.dart @@ -6,6 +6,7 @@ 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'; diff --git a/packages/remix/lib/src/components/toggle_group/toggle_group.g.dart b/packages/remix/lib/src/components/toggle_group/toggle_group.g.dart index 442c54a73..4396f9ec3 100644 --- a/packages/remix/lib/src/components/toggle_group/toggle_group.g.dart +++ b/packages/remix/lib/src/components/toggle_group/toggle_group.g.dart @@ -88,6 +88,7 @@ typedef _$ToggleGroupSpecMethods = _$ToggleGroupSpec; // ignore: unused_element mixin _$ToggleGroupItemSpec implements Spec, Diagnosticable { StyleSpec get container; + RemixBoxEffectsSpec? get containerEffects; StyleSpec get label; StyleSpec get icon; @@ -97,11 +98,13 @@ mixin _$ToggleGroupItemSpec @override ToggleGroupItemSpec copyWith({ StyleSpec? container, + RemixBoxEffectsSpec? containerEffects, StyleSpec? label, StyleSpec? icon, }) { return ToggleGroupItemSpec( container: container ?? this.container, + containerEffects: containerEffects ?? this.containerEffects, label: label ?? this.label, icon: icon ?? this.icon, ); @@ -111,13 +114,18 @@ mixin _$ToggleGroupItemSpec ToggleGroupItemSpec lerp(ToggleGroupItemSpec? other, double t) { return ToggleGroupItemSpec( container: container.lerp(other?.container, t), + containerEffects: MixOps.lerpSnap( + containerEffects, + other?.containerEffects, + t, + ), label: label.lerp(other?.label, t), icon: icon.lerp(other?.icon, t), ); } @override - List get props => [container, label, icon]; + List get props => [container, containerEffects, label, icon]; @override bool operator ==(Object other) { @@ -160,6 +168,7 @@ mixin _$ToggleGroupItemSpec void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('containerEffects', containerEffects)) ..add(DiagnosticsProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)); } @@ -881,22 +890,26 @@ class ToggleGroupItemStyler IconStyleMixin implements StylerFieldMetadata { final Prop>? $container; + final Prop? $containerEffects; final Prop>? $label; final Prop>? $icon; const ToggleGroupItemStyler.create({ Prop>? container, + Prop? containerEffects, Prop>? label, Prop>? icon, super.variants, super.modifier, super.animation, }) : $container = container, + $containerEffects = containerEffects, $label = label, $icon = icon; ToggleGroupItemStyler({ FlexBoxStyler? container, + RemixBoxEffectsMix? containerEffects, TextStyler? label, IconStyler? icon, AnimationConfig? animation, @@ -904,6 +917,7 @@ class ToggleGroupItemStyler List>? variants, }) : this.create( container: Prop.maybeMix(container), + containerEffects: Prop.maybeMix(containerEffects), label: Prop.maybeMix(label), icon: Prop.maybeMix(icon), variants: variants, @@ -913,6 +927,8 @@ class ToggleGroupItemStyler factory ToggleGroupItemStyler.container(FlexBoxStyler value) => ToggleGroupItemStyler().container(value); + factory ToggleGroupItemStyler.containerEffects(RemixBoxEffectsMix value) => + ToggleGroupItemStyler().containerEffects(value); factory ToggleGroupItemStyler.label(TextStyler value) => ToggleGroupItemStyler().label(value); factory ToggleGroupItemStyler.icon(IconStyler value) => @@ -1483,6 +1499,7 @@ class ToggleGroupItemStyler @override Set get $stylerFieldNames => const { 'container', + 'containerEffects', 'label', 'icon', 'animation', @@ -1495,6 +1512,11 @@ class ToggleGroupItemStyler return merge(ToggleGroupItemStyler(container: value)); } + /// Sets the containerEffects. + ToggleGroupItemStyler containerEffects(RemixBoxEffectsMix value) { + return merge(ToggleGroupItemStyler(containerEffects: value)); + } + /// Sets the label. @override ToggleGroupItemStyler label(TextStyler value) { @@ -1537,6 +1559,10 @@ class ToggleGroupItemStyler ToggleGroupItemStyler merge(ToggleGroupItemStyler? other) { return ToggleGroupItemStyler.create( container: MixOps.merge($container, other?.$container), + containerEffects: MixOps.merge( + $containerEffects, + other?.$containerEffects, + ), label: MixOps.merge($label, other?.$label), icon: MixOps.merge($icon, other?.$icon), variants: MixOps.mergeVariants($variants, other?.$variants), @@ -1550,6 +1576,7 @@ class ToggleGroupItemStyler StyleSpec resolve(BuildContext context) { final spec = ToggleGroupItemSpec( container: MixOps.resolve(context, $container), + containerEffects: MixOps.resolve(context, $containerEffects), label: MixOps.resolve(context, $label), icon: MixOps.resolve(context, $icon), ); @@ -1566,6 +1593,7 @@ class ToggleGroupItemStyler super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('containerEffects', $containerEffects)) ..add(DiagnosticsProperty('label', $label)) ..add(DiagnosticsProperty('icon', $icon)); } @@ -1573,6 +1601,7 @@ class ToggleGroupItemStyler @override List get props => [ $container, + $containerEffects, $label, $icon, $animation, diff --git a/packages/remix/lib/src/components/toggle_group/toggle_group_spec.dart b/packages/remix/lib/src/components/toggle_group/toggle_group_spec.dart index 668cbf913..3eccab78f 100644 --- a/packages/remix/lib/src/components/toggle_group/toggle_group_spec.dart +++ b/packages/remix/lib/src/components/toggle_group/toggle_group_spec.dart @@ -38,6 +38,12 @@ class ToggleGroupItemSpec with _$ToggleGroupItemSpec { @MixableField(forwardStyler: true) final StyleSpec container; + /// Outline and paint layers drawn around [container] without affecting + /// layout, such as a focus ring. + @override + @MixableField(setterType: RemixBoxEffectsMix) + final RemixBoxEffectsSpec? containerEffects; + /// Text style for the optional label. @override final StyleSpec label; @@ -48,11 +54,27 @@ class ToggleGroupItemSpec with _$ToggleGroupItemSpec { const ToggleGroupItemSpec({ StyleSpec? container, + this.containerEffects, StyleSpec? label, StyleSpec? icon, }) : container = container ?? const StyleSpec(spec: FlexBoxSpec()), 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 + ToggleGroupItemSpec lerp(ToggleGroupItemSpec? other, double t) { + final generated = super.lerp(other, t); + if (other == null) return generated; + return generated.copyWith( + containerEffects: RemixBoxEffectsSpec.lerpNullable( + containerEffects, + other.containerEffects, + t, + ), + ); + } } /// Backward-compatible name for [ToggleGroupItemSpec]. diff --git a/packages/remix/lib/src/components/toggle_group/toggle_group_widget.dart b/packages/remix/lib/src/components/toggle_group/toggle_group_widget.dart index 1de0ea3cc..6ae7e0122 100644 --- a/packages/remix/lib/src/components/toggle_group/toggle_group_widget.dart +++ b/packages/remix/lib/src/components/toggle_group/toggle_group_widget.dart @@ -266,8 +266,10 @@ class _RemixToggleGroupItemWidget extends StatelessWidget { child: StyleSpecBuilder( styleSpec: _resolveStyle(context), builder: (context, spec) { - return RowBox( + return RemixFlexBoxAdapter( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ if (data.icon != null) StyledIcon(icon: data.icon!, styleSpec: spec.icon), diff --git a/packages/remix/test/components/accordion/accordion_spec_test.dart b/packages/remix/test/components/accordion/accordion_spec_test.dart index 47db22b2c..b8d0f8282 100644 --- a/packages/remix/test/components/accordion/accordion_spec_test.dart +++ b/packages/remix/test/components/accordion/accordion_spec_test.dart @@ -199,10 +199,11 @@ void main() { test('props list contains all properties', () { const spec = AccordionSpec(); - expect(spec.props, hasLength(7)); + expect(spec.props, hasLength(8)); expect(spec.props, contains(spec.container)); expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.trigger)); + expect(spec.props, contains(spec.triggerEffects)); expect(spec.props, contains(spec.leadingIcon)); expect(spec.props, contains(spec.title)); expect(spec.props, contains(spec.trailingIcon)); @@ -227,12 +228,13 @@ void main() { spec.debugFillProperties(builder); final properties = builder.properties; - expect(properties, hasLength(7)); + expect(properties, hasLength(8)); final propertyNames = properties.map((p) => p.name).toList(); expect(propertyNames, contains('container')); expect(propertyNames, contains('containerEffects')); expect(propertyNames, contains('trigger')); + expect(propertyNames, contains('triggerEffects')); expect(propertyNames, contains('leadingIcon')); expect(propertyNames, contains('title')); expect(propertyNames, contains('trailingIcon')); diff --git a/packages/remix/test/components/accordion/accordion_style_test.dart b/packages/remix/test/components/accordion/accordion_style_test.dart index d8fa93c24..404b241fa 100644 --- a/packages/remix/test/components/accordion/accordion_style_test.dart +++ b/packages/remix/test/components/accordion/accordion_style_test.dart @@ -594,10 +594,11 @@ void main() { test('props list contains all properties', () { final style = AccordionStyler(); - expect(style.props, hasLength(10)); + expect(style.props, hasLength(11)); expect(style.props, contains(style.$container)); expect(style.props, contains(style.$containerEffects)); expect(style.props, contains(style.$trigger)); + expect(style.props, contains(style.$triggerEffects)); expect(style.props, contains(style.$leadingIcon)); expect(style.props, contains(style.$title)); expect(style.props, contains(style.$trailingIcon)); diff --git a/packages/remix/test/components/menu/menu_spec_test.dart b/packages/remix/test/components/menu/menu_spec_test.dart index f0ebd5fd4..fd89cfd9c 100644 --- a/packages/remix/test/components/menu/menu_spec_test.dart +++ b/packages/remix/test/components/menu/menu_spec_test.dart @@ -136,8 +136,9 @@ void main() { test('props list contains all properties', () { const spec = MenuTriggerSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); + expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.icon)); }); @@ -150,7 +151,7 @@ void main() { spec.debugFillProperties(builder); expect( builder.properties.map((property) => property.name), - unorderedEquals(['container', 'label', 'icon']), + unorderedEquals(['container', 'containerEffects', 'label', 'icon']), ); }); }); @@ -609,8 +610,9 @@ void main() { test('props list contains all properties', () { const spec = MenuItemSpec(); - expect(spec.props, hasLength(5)); + expect(spec.props, hasLength(6)); expect(spec.props, contains(spec.container)); + expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.leadingIcon)); expect(spec.props, contains(spec.trailingIcon)); @@ -627,6 +629,7 @@ void main() { builder.properties.map((property) => property.name), unorderedEquals([ 'container', + 'containerEffects', 'label', 'leadingIcon', 'trailingIcon', diff --git a/packages/remix/test/components/menu/menu_widget_test.dart b/packages/remix/test/components/menu/menu_widget_test.dart index 4b1e5e343..d7b147cbb 100644 --- a/packages/remix/test/components/menu/menu_widget_test.dart +++ b/packages/remix/test/components/menu/menu_widget_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixFlexBoxAdapter; import 'package:remix/src/utilities/remix_path_icon.dart'; import '../../helpers/test_helpers.dart'; @@ -74,7 +76,7 @@ void main() { expect(openStates, isNotEmpty); expect(openStates.first, isFalse); - expect(receivedChild, isA()); + expect(receivedChild, isA()); expect(find.text('Options'), findsOneWidget); expect(find.byIcon(Icons.more_vert), findsOneWidget); @@ -647,13 +649,16 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(RemixMenu), findsOneWidget); - final trigger = tester.widget( + final trigger = tester.widget( find - .ancestor(of: find.text('Options'), matching: find.byType(RowBox)) + .ancestor( + of: find.text('Options'), + matching: find.byType(RemixFlexBoxAdapter), + ) .first, ); expect( - trigger.styleSpec!.spec.box!.spec.padding, + trigger.styleSpec.spec.box!.spec.padding, const EdgeInsets.all(20), ); }); @@ -676,12 +681,15 @@ void main() { expect(find.byType(RemixMenu), findsOneWidget); await tester.tap(find.text('Options')); await tester.pumpAndSettle(); - final item = tester.widget( + final item = tester.widget( find - .ancestor(of: find.text('Copy'), matching: find.byType(FlexBox)) + .ancestor( + of: find.text('Copy'), + matching: find.byType(RemixFlexBoxAdapter), + ) .first, ); - expect(item.styleSpec!.spec.box!.spec.padding, const EdgeInsets.all(12)); + expect(item.styleSpec.spec.box!.spec.padding, const EdgeInsets.all(12)); }); testWidgets('applies menu-level default item style', (tester) async { @@ -2064,7 +2072,10 @@ void main() { await tester.pump(); Finder rowFor(String label) => find - .ancestor(of: find.text(label), matching: find.byType(FlexBox)) + .ancestor( + of: find.text(label), + matching: find.byType(RemixFlexBoxAdapter), + ) .first; final first = tester.getRect(rowFor('First radio')); final second = tester.getRect(rowFor('Second radio')); @@ -2124,15 +2135,15 @@ void main() { await tester.tap(find.text('Options')); await tester.pump(); - final beforeRow = find - .ancestor(of: find.text('Before'), matching: find.byType(FlexBox)) - .first; - final radioRow = find - .ancestor(of: find.text('Radio'), matching: find.byType(FlexBox)) - .first; - final afterRow = find - .ancestor(of: find.text('After'), matching: find.byType(FlexBox)) + Finder rowFor(String label) => find + .ancestor( + of: find.text(label), + matching: find.byType(RemixFlexBoxAdapter), + ) .first; + final beforeRow = rowFor('Before'); + final radioRow = rowFor('Radio'); + final afterRow = rowFor('After'); expect( tester.getSize(beforeRow).height, tester.getSize(radioRow).height, @@ -2206,16 +2217,17 @@ void main() { await tester.tap(find.text('More')); await tester.pump(); - final rootRow = tester.widget( - find - .ancestor(of: find.text('More'), matching: find.byType(FlexBox)) - .first, - ); - final nestedRow = tester.widget( - find - .ancestor(of: find.text('Nested'), matching: find.byType(FlexBox)) - .first, - ); + RemixFlexBoxAdapter rowFor(String label) => + tester.widget( + find + .ancestor( + of: find.text(label), + matching: find.byType(RemixFlexBoxAdapter), + ) + .first, + ); + final rootRow = rowFor('More'); + final nestedRow = rowFor('Nested'); expect(rootRow.children.first, isA()); expect(nestedRow.children.first, isA()); }); @@ -2253,9 +2265,12 @@ void main() { await tester.pump(); Finder slotFor(String label) { - final row = tester.widget( + final row = tester.widget( find - .ancestor(of: find.text(label), matching: find.byType(FlexBox)) + .ancestor( + of: find.text(label), + matching: find.byType(RemixFlexBoxAdapter), + ) .first, ); return find.byWidget(row.children.first); @@ -2303,9 +2318,12 @@ void main() { ('Unchecked', false), ]) { final row = find - .ancestor(of: find.text(label), matching: find.byType(FlexBox)) + .ancestor( + of: find.text(label), + matching: find.byType(RemixFlexBoxAdapter), + ) .first; - final slot = tester.widget(row).children.first; + final slot = tester.widget(row).children.first; expect( slot, diff --git a/packages/remix/test/components/select/select_spec_test.dart b/packages/remix/test/components/select/select_spec_test.dart index 840b7b795..62a285fe2 100644 --- a/packages/remix/test/components/select/select_spec_test.dart +++ b/packages/remix/test/components/select/select_spec_test.dart @@ -442,8 +442,9 @@ void main() { test('props list contains all properties', () { const spec = SelectMenuItemSpec(); - expect(spec.props, hasLength(4)); + expect(spec.props, hasLength(5)); expect(spec.props, contains(spec.container)); + expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.text)); expect(spec.props, contains(spec.icon)); }); @@ -456,7 +457,13 @@ void main() { spec.debugFillProperties(builder); expect( builder.properties.map((property) => property.name), - unorderedEquals(['container', 'text', 'indicator', 'icon']), + unorderedEquals([ + 'container', + 'containerEffects', + 'text', + 'indicator', + 'icon', + ]), ); }); }); diff --git a/packages/remix/test/components/tabs/tab_child_container_test.dart b/packages/remix/test/components/tabs/tab_child_container_test.dart index f2ceedb82..1d80f31c5 100644 --- a/packages/remix/test/components/tabs/tab_child_container_test.dart +++ b/packages/remix/test/components/tabs/tab_child_container_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixFlexBoxAdapter; import '../../helpers/test_helpers.dart'; @@ -80,7 +82,7 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(defaultContent, isA()); + expect(defaultContent, isA()); expect(tester.getSize(find.byKey(key)), const Size(24, 24)); expect(tester.takeException(), isNull); }); diff --git a/packages/remix/test/components/tabs/tabs_spec_test.dart b/packages/remix/test/components/tabs/tabs_spec_test.dart index 6f80f26df..82b6720e8 100644 --- a/packages/remix/test/components/tabs/tabs_spec_test.dart +++ b/packages/remix/test/components/tabs/tabs_spec_test.dart @@ -465,8 +465,9 @@ void main() { test('props includes all relevant properties', () { const spec = TabSpec(); - expect(spec.props.length, equals(3)); + expect(spec.props.length, equals(4)); expect(spec.props, contains(spec.container)); + expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.icon)); }); diff --git a/packages/remix/test/components/toggle/toggle_spec_test.dart b/packages/remix/test/components/toggle/toggle_spec_test.dart index d58554eec..7d81179d8 100644 --- a/packages/remix/test/components/toggle/toggle_spec_test.dart +++ b/packages/remix/test/components/toggle/toggle_spec_test.dart @@ -155,8 +155,9 @@ void main() { test('props list contains all properties', () { const spec = ToggleSpec(); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); expect(spec.props, contains(spec.container)); + expect(spec.props, contains(spec.containerEffects)); expect(spec.props, contains(spec.label)); expect(spec.props, contains(spec.icon)); }); @@ -169,7 +170,7 @@ void main() { spec.debugFillProperties(builder); expect( builder.properties.map((property) => property.name), - unorderedEquals(['container', 'label', 'icon']), + unorderedEquals(['container', 'containerEffects', 'label', 'icon']), ); }); @@ -180,10 +181,11 @@ void main() { spec.debugFillProperties(builder); final properties = builder.properties; - expect(properties, hasLength(3)); + expect(properties, hasLength(4)); final propertyNames = properties.map((p) => p.name).toList(); expect(propertyNames, contains('container')); + expect(propertyNames, contains('containerEffects')); expect(propertyNames, contains('label')); expect(propertyNames, contains('icon')); }); diff --git a/packages/remix/test/components/toggle/toggle_style_test.dart b/packages/remix/test/components/toggle/toggle_style_test.dart index 69b4cc56b..f673805ab 100644 --- a/packages/remix/test/components/toggle/toggle_style_test.dart +++ b/packages/remix/test/components/toggle/toggle_style_test.dart @@ -405,8 +405,9 @@ void main() { test('props list contains all properties', () { final style = ToggleStyler(); - expect(style.props, hasLength(6)); + expect(style.props, hasLength(7)); expect(style.props, contains(style.$container)); + expect(style.props, contains(style.$containerEffects)); expect(style.props, contains(style.$label)); expect(style.props, contains(style.$icon)); expect(style.props, contains(style.$variants)); diff --git a/packages/remix/test/components/toggle/toggle_widget_test.dart b/packages/remix/test/components/toggle/toggle_widget_test.dart index 4ebfbdf85..0ec35a412 100644 --- a/packages/remix/test/components/toggle/toggle_widget_test.dart +++ b/packages/remix/test/components/toggle/toggle_widget_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixFlexBoxAdapter; import '../../helpers/test_helpers.dart'; import '../../helpers/test_methods.dart'; @@ -361,8 +363,8 @@ void main() { expect(find.byType(RemixToggle), findsOneWidget); final container = tester - .widget(find.byType(RowBox)) - .styleSpec! + .widget(find.byType(RemixFlexBoxAdapter)) + .styleSpec .spec .box! .spec; @@ -453,8 +455,8 @@ void main() { await tester.pumpAndSettle(); final decorations = tester - .widgetList(find.byType(RowBox)) - .map((box) => box.styleSpec?.spec.box?.spec.decoration); + .widgetList(find.byType(RemixFlexBoxAdapter)) + .map((box) => box.styleSpec.spec.box?.spec.decoration); expect( decorations, diff --git a/packages/remix/test/components/toggle_group/toggle_group_spec_test.dart b/packages/remix/test/components/toggle_group/toggle_group_spec_test.dart index 03a9a5f18..4fff54679 100644 --- a/packages/remix/test/components/toggle_group/toggle_group_spec_test.dart +++ b/packages/remix/test/components/toggle_group/toggle_group_spec_test.dart @@ -51,7 +51,7 @@ void main() { expect(spec.container, isA>()); expect(spec.label, isA>()); expect(spec.icon, isA>()); - expect(spec.props, hasLength(3)); + expect(spec.props, hasLength(4)); }); test('copyWith and lerp preserve value semantics', () { diff --git a/packages/remix/test/rendering/remix_box_effects_slots_test.dart b/packages/remix/test/rendering/remix_box_effects_slots_test.dart new file mode 100644 index 000000000..883b91aef --- /dev/null +++ b/packages/remix/test/rendering/remix_box_effects_slots_test.dart @@ -0,0 +1,476 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:remix/src/rendering/remix_box_effects.dart' + show RemixBoxAdapter, RemixFlexBoxAdapter; + +const _ringColor = Color(0xFF0000FF); +const _boundaryKey = ValueKey('effects-slots-boundary'); + +RemixBoxEffectsMix _ring({double offset = 2}) => RemixBoxEffectsMix( + outline: BorderSideMix(color: _ringColor, width: 2), + outlineOffset: offset, +); + +typedef _Capture = ({ByteData bytes, int width}); + +/// A focusable part whose own box should carry the ring. +/// +/// [label] is the text inside that box, and [adapter] is the widget type that +/// renders it, so `ancestor(label, adapter).first` is the ringed box. +typedef _Part = ({ + String name, + String label, + Type adapter, + Widget Function(RemixBoxEffectsMix ring, FocusNode node) build, + Future Function(WidgetTester tester, FocusNode node) focus, + Future Function(WidgetTester tester)? open, +}); + +Future _focusNode(WidgetTester tester, FocusNode node) async { + node.requestFocus(); + await tester.pumpAndSettle(); +} + +Future _focusWithArrowDown(WidgetTester tester, FocusNode node) async { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); +} + +Future _tapAndSettle(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); +} + +final List<_Part> _parts = [ + ( + name: 'Tab', + label: 'Alpha', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixTabs( + selectedTabId: 'a', + onChanged: (_) {}, + child: RemixTabBar( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RemixTab( + tabId: 'a', + label: 'Alpha', + focusNode: node, + style: TabStyler() + .padding(.all(8)) + .onFocusVisible(TabStyler().containerEffects(ring)), + ), + const RemixTab(tabId: 'b', label: 'Beta'), + ], + ), + ), + ), + focus: _focusNode, + open: null, + ), + ( + name: 'Toggle', + label: 'Bold', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixToggle( + selected: false, + onChanged: (_) {}, + label: 'Bold', + focusNode: node, + style: ToggleStyler() + .padding(.all(8)) + .onFocusVisible(ToggleStyler().containerEffects(ring)), + ), + focus: _focusNode, + open: null, + ), + ( + name: 'ToggleGroup item', + label: 'Alpha', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixToggleGroup( + selectedValue: 'a', + onChanged: (_) {}, + items: [ + RemixToggleGroupItem(value: 'a', label: 'Alpha', focusNode: node), + const RemixToggleGroupItem(value: 'b', label: 'Beta'), + ], + style: ToggleGroupStyler( + item: ToggleGroupItemStyler() + .padding(.all(8)) + .onFocusVisible(ToggleGroupItemStyler().containerEffects(ring)), + ), + ), + focus: _focusNode, + open: null, + ), + ( + name: 'SelectMenuItem', + label: 'Alpha', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixSelect( + trigger: const RemixSelectTrigger(placeholder: 'Pick'), + onChanged: (_) {}, + items: const [ + RemixSelectItem(value: 'a', label: 'Alpha'), + RemixSelectItem(value: 'b', label: 'Beta'), + ], + style: SelectStyler().item( + SelectMenuItemStyler() + .padding(.all(8)) + .onFocusVisible(SelectMenuItemStyler().containerEffects(ring)), + ), + ), + focus: _focusWithArrowDown, + open: (tester) => _tapAndSettle(tester, 'Pick'), + ), + ( + name: 'MenuItem', + label: 'Copy', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixMenu( + trigger: const RemixMenuTrigger(label: 'Options'), + onSelected: (_) {}, + items: const [ + RemixMenuItem(value: 'copy', label: 'Copy'), + RemixMenuItem(value: 'paste', label: 'Paste'), + ], + style: MenuStyler().item( + MenuItemStyler() + .padding(.all(8)) + .onFocusVisible(MenuItemStyler().containerEffects(ring)), + ), + ), + focus: _focusWithArrowDown, + open: (tester) => _tapAndSettle(tester, 'Options'), + ), + ( + name: 'MenuTrigger', + label: 'Options', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixMenu( + triggerFocusNode: node, + trigger: const RemixMenuTrigger(label: 'Options'), + onSelected: (_) {}, + items: const [RemixMenuItem(value: 'copy', label: 'Copy')], + style: MenuStyler().trigger( + MenuTriggerStyler() + .padding(.all(8)) + .onFocusVisible(MenuTriggerStyler().containerEffects(ring)), + ), + ), + focus: _focusNode, + open: null, + ), + ( + name: 'Accordion trigger', + label: 'Title', + adapter: RemixFlexBoxAdapter, + build: (ring, node) => RemixAccordionGroup( + controller: RemixAccordionController(), + child: RemixAccordion( + value: 'a', + title: 'Title', + focusNode: node, + style: AccordionStyler() + .padding(.all(8)) + .onFocusVisible(AccordionStyler().triggerEffects(ring)), + child: const Text('Content'), + ), + ), + focus: _focusNode, + open: null, + ), + ( + name: 'Disclosure trigger', + label: 'Trigger', + adapter: RemixBoxAdapter, + build: (ring, node) => RemixDisclosure( + trigger: const Text('Trigger'), + content: const Text('Content'), + focusNode: node, + style: DisclosureStyler() + .padding(.all(8)) + .onFocusVisible(DisclosureStyler().triggerEffects(ring)), + ), + focus: _focusNode, + open: null, + ), +]; + +Widget _harness(Widget child) => RepaintBoundary( + key: _boundaryKey, + child: MixScope.empty( + child: MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(40), + child: Center(child: child), + ), + ), + ), + ), +); + +Future<_Capture> _capture(WidgetTester tester) async { + final boundary = tester.renderObject( + find.byKey(_boundaryKey), + ); + final result = await tester.runAsync(() async { + final image = boundary.toImageSync(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + image.dispose(); + return (bytes: bytes!, width: width); + }); + return result!; +} + +int _pixel(_Capture image, int x, int y) => + image.bytes.getUint32((y * image.width + x) * 4); + +int _rgba(Color color) => + (color.r * 255).round() << 24 | + (color.g * 255).round() << 16 | + (color.b * 255).round() << 8 | + (color.a * 255).round(); + +/// The pixel in the middle of a 2px ring drawn 2px outside [box]'s left edge. +int _outsideRingPixel(_Capture image, Rect box, {double? y}) => + _pixel(image, (box.left - 3).floor(), (y ?? box.center.dy).floor()); + +/// The pixel in the middle of a 2px ring drawn flush inside [box]'s left edge. +int _insideRingPixel(_Capture image, Rect box) => + _pixel(image, (box.left + 1).floor(), box.center.dy.floor()); + +List _adapterRects(WidgetTester tester) => [ + for (final type in [RemixBoxAdapter, RemixFlexBoxAdapter]) + for (final element in find.byType(type).evaluate()) + (element.renderObject! as RenderBox).localToGlobal(Offset.zero) & + (element.renderObject! as RenderBox).size, +]; + +void _useTraditionalFocus() { + final previous = FocusManager.instance.highlightStrategy; + addTearDown(() => FocusManager.instance.highlightStrategy = previous); + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; +} + +void main() { + group('containerEffects and triggerEffects on focusable parts', () { + for (final part in _parts) { + testWidgets( + '${part.name} paints its ring on focus without changing layout', + (tester) async { + _useTraditionalFocus(); + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(_harness(part.build(_ring(), node))); + await tester.pumpAndSettle(); + await part.open?.call(tester); + + final box = find + .ancestor( + of: find.text(part.label), + matching: find.byType(part.adapter), + ) + .first; + final rect = tester.getRect(box); + final rectsBefore = _adapterRects(tester); + + expect( + _outsideRingPixel(await _capture(tester), rect), + isNot(_rgba(_ringColor)), + reason: 'idle', + ); + + await part.focus(tester, node); + + expect(_adapterRects(tester), rectsBefore, reason: 'layout'); + expect( + _outsideRingPixel(await _capture(tester), rect), + _rgba(_ringColor), + reason: 'focused', + ); + }, + ); + } + + for (final part in _parts.where( + (part) => + const {'Accordion trigger', 'Disclosure trigger'}.contains(part.name), + )) { + testWidgets('${part.name} ring hugs the trigger, not the panel', ( + tester, + ) async { + _useTraditionalFocus(); + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(_harness(part.build(_ring(), node))); + await _tapAndSettle(tester, part.label); + await _focusNode(tester, node); + + final trigger = tester.getRect( + find + .ancestor( + of: find.text(part.label), + matching: find.byType(part.adapter), + ) + .first, + ); + final content = tester.getRect(find.text('Content')); + expect(content.top, greaterThanOrEqualTo(trigger.bottom)); + final image = await _capture(tester); + + expect(_outsideRingPixel(image, trigger), _rgba(_ringColor)); + expect( + _outsideRingPixel(image, trigger, y: content.center.dy), + isNot(_rgba(_ringColor)), + ); + }); + } + + group('on the end item of a toggle group', () { + // Pixel at the group's left edge on the first item, for a ring drawn + // `offset` from the item (outside it when positive, inside when negative) + // in a group that clips with [clip]. + Future edgePixel( + WidgetTester tester, { + required double offset, + required Clip clip, + }) async { + _useTraditionalFocus(); + final node = FocusNode(); + addTearDown(node.dispose); + await tester.pumpWidget( + _harness( + RemixToggleGroup( + selectedValue: 'a', + onChanged: (_) {}, + items: [ + RemixToggleGroupItem( + value: 'a', + label: 'Alpha', + focusNode: node, + ), + const RemixToggleGroupItem(value: 'b', label: 'Beta'), + ], + style: ToggleGroupStyler( + container: FlexBoxStyler() + .color(Colors.white) + .borderRadius(.circular(4)) + .clipBehavior(clip), + item: ToggleGroupItemStyler() + .padding(.all(8)) + .onFocusVisible( + ToggleGroupItemStyler().containerEffects( + _ring(offset: offset), + ), + ), + ), + ), + ), + ); + await _focusNode(tester, node); + final item = tester.getRect( + find + .ancestor( + of: find.text('Alpha'), + matching: find.byType(RemixFlexBoxAdapter), + ) + .first, + ); + final image = await _capture(tester); + return offset < 0 + ? _insideRingPixel(image, item) + : _outsideRingPixel(image, item); + } + + testWidgets('the group clip crops a ring drawn outside the item', ( + tester, + ) async { + expect( + await edgePixel(tester, offset: 2, clip: Clip.none), + _rgba(_ringColor), + reason: 'without a clip', + ); + expect( + await edgePixel(tester, offset: 2, clip: Clip.antiAlias), + isNot(_rgba(_ringColor)), + reason: 'with a clip', + ); + }); + + testWidgets('the group clip keeps a ring drawn with a negative offset', ( + tester, + ) async { + expect( + await edgePixel(tester, offset: -2, clip: Clip.antiAlias), + _rgba(_ringColor), + ); + }); + }); + }); + + group('effects slots animate through RemixBoxEffectsSpec.lerpNullable', () { + const ring = RemixBoxEffectsSpec( + outline: BorderSide(color: _ringColor, width: 4), + outlineOffset: 2, + ); + + // Each entry lerps one spec's effects slot between two optional values. + final lerps = + < + String, + RemixBoxEffectsSpec? Function( + RemixBoxEffectsSpec? from, + RemixBoxEffectsSpec? to, + double t, + ) + >{ + 'TabSpec': (from, to, t) => TabSpec( + containerEffects: from, + ).lerp(TabSpec(containerEffects: to), t).containerEffects, + 'ToggleSpec': (from, to, t) => ToggleSpec( + containerEffects: from, + ).lerp(ToggleSpec(containerEffects: to), t).containerEffects, + 'ToggleGroupItemSpec': (from, to, t) => ToggleGroupItemSpec( + containerEffects: from, + ).lerp(ToggleGroupItemSpec(containerEffects: to), t).containerEffects, + 'SelectMenuItemSpec': (from, to, t) => SelectMenuItemSpec( + containerEffects: from, + ).lerp(SelectMenuItemSpec(containerEffects: to), t).containerEffects, + 'MenuItemSpec': (from, to, t) => MenuItemSpec( + containerEffects: from, + ).lerp(MenuItemSpec(containerEffects: to), t).containerEffects, + 'MenuTriggerSpec': (from, to, t) => MenuTriggerSpec( + containerEffects: from, + ).lerp(MenuTriggerSpec(containerEffects: to), t).containerEffects, + 'AccordionSpec.triggerEffects': (from, to, t) => AccordionSpec( + triggerEffects: from, + ).lerp(AccordionSpec(triggerEffects: to), t).triggerEffects, + 'DisclosureSpec.triggerEffects': (from, to, t) => DisclosureSpec( + triggerEffects: from, + ).lerp(DisclosureSpec(triggerEffects: to), t).triggerEffects, + }; + + for (final MapEntry(key: name, value: lerp) in lerps.entries) { + test('$name fades an outline in and out', () { + expect(lerp(null, ring, 0.5)?.outline.width, 2); + expect(lerp(ring, null, 0.5)?.outline.width, 2); + expect(lerp(null, ring, 1), ring); + expect(lerp(ring, null, 0), ring); + expect(lerp(null, null, 0.5), isNull); + }); + } + }); +} diff --git a/registry_source/test/components/menu/menu_widget_test.dart b/registry_source/test/components/menu/menu_widget_test.dart index 74e55ad85..ac5f3022f 100644 --- a/registry_source/test/components/menu/menu_widget_test.dart +++ b/registry_source/test/components/menu/menu_widget_test.dart @@ -2,6 +2,11 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +// `RemixFlexBoxAdapter` is `@internal` to `remix`, but this sibling package's +// tests need it to read the container spec Remix resolved for a Fortal recipe. +// Suppressed per-use below, so a future accidental internal-member use still +// gets flagged. +import 'package:remix/src/rendering/remix_box_effects.dart'; import 'package:remix/src/utilities/remix_path_icon.dart'; import 'package:remix/remix.dart'; import 'package:registry_source/fortal.dart'; @@ -271,12 +276,10 @@ void main() { tester.getSize(find.byIcon(Icons.tune)), Size.square(contentIconSize), ); - final triggerRow = tester.widget( - find - .ancestor(of: find.text('Options'), matching: find.byType(RowBox)) - .first, + expect( + _rowAround(tester, 'Options').styleSpec.spec.flex?.spec.spacing, + triggerGap, ); - expect(triggerRow.styleSpec?.spec.flex?.spec.spacing, triggerGap); }); } @@ -366,8 +369,20 @@ void main() { } Color? _menuRowColor(WidgetTester tester, String label) { - final row = tester.widget( - find.ancestor(of: find.text(label), matching: find.byType(FlexBox)).first, - ); - return (row.styleSpec?.spec.box?.spec.decoration as BoxDecoration?)?.color; + final box = _rowAround(tester, label).styleSpec.spec.box; + return (box?.spec.decoration as BoxDecoration?)?.color; } + +/// The Remix row that owns the container spec of the trigger or item labelled +/// [label]. +// ignore: invalid_use_of_internal_member +RemixFlexBoxAdapter _rowAround(WidgetTester tester, String label) => + tester.widget( + find + .ancestor( + of: find.text(label), + // ignore: invalid_use_of_internal_member + matching: find.byType(RemixFlexBoxAdapter), + ) + .first, + ); diff --git a/registry_source/test/components/toggle/toggle_widget_test.dart b/registry_source/test/components/toggle/toggle_widget_test.dart index 36ea03205..55a199865 100644 --- a/registry_source/test/components/toggle/toggle_widget_test.dart +++ b/registry_source/test/components/toggle/toggle_widget_test.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; +// `RemixFlexBoxAdapter` is `@internal` to `remix`, but this sibling package's +// tests need it to read the container spec Remix resolved for a Fortal recipe. +// Suppressed per-use below, so a future accidental internal-member use still +// gets flagged. +import 'package:remix/src/rendering/remix_box_effects.dart'; import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; @@ -22,8 +27,7 @@ void main() { ), ); await tester.pumpAndSettle(); - final row = tester.widget(find.byType(RowBox)); - final box = row.styleSpec!.spec.box!.spec; + final box = _toggleRow(tester).styleSpec.spec.box!.spec; final padding = switch (size) { FortalToggleSize.size1 => const EdgeInsets.symmetric( horizontal: 8, @@ -39,7 +43,7 @@ void main() { ), }; expect(box.padding!.resolve(TextDirection.ltr), padding); - final context = tester.element(find.byType(RowBox)); + final context = tester.element(_toggleRowFinder); Color color(ColorToken token) => MixScope.tokenOf(token, context); final decoration = box.decoration! as BoxDecoration; expect( @@ -93,7 +97,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(tester.getSize(find.byType(RowBox)).width, lessThanOrEqualTo(200)); + expect(tester.getSize(_toggleRowFinder).width, lessThanOrEqualTo(200)); expect( tester.getSemantics(find.byType(RemixToggle)).label, 'Workspace Write', @@ -109,7 +113,7 @@ void main() { ); await tester.pumpAndSettle(); - final rowSize = tester.getSize(find.byType(RowBox)); + final rowSize = tester.getSize(_toggleRowFinder); final labelSize = tester.getSize(find.text('Bold')); expect(rowSize.width, closeTo(labelSize.width + 24, 0.01)); expect(rowSize.height, closeTo(labelSize.height + 16, 0.01)); @@ -127,7 +131,7 @@ void main() { ), ); await tester.pumpAndSettle(); - iconOnlySizes.add(tester.getSize(find.byType(RowBox))); + iconOnlySizes.add(tester.getSize(_toggleRowFinder)); } expect(iconOnlySizes[1], iconOnlySizes[0]); @@ -239,3 +243,10 @@ Color? _outlineBorderColor(WidgetTester tester) { if (decoration is! BoxDecoration || decoration.border is! Border) return null; return (decoration.border! as Border).top.color; } + +// ignore: invalid_use_of_internal_member +final _toggleRowFinder = find.byType(RemixFlexBoxAdapter); + +// ignore: invalid_use_of_internal_member +RemixFlexBoxAdapter _toggleRow(WidgetTester tester) => + tester.widget(_toggleRowFinder); diff --git a/skills/building-remix-design-system/references/components.md b/skills/building-remix-design-system/references/components.md index 048fb0d64..a955c2d31 100644 --- a/skills/building-remix-design-system/references/components.md +++ b/skills/building-remix-design-system/references/components.md @@ -333,6 +333,14 @@ ButtonStyler _focusRing() => ButtonStyler().containerEffects( Text inputs ring on any focus, as browsers do, so key their ring on `onFocused` instead. +Tab, toggle, toggle group item, select menu item, menu item, and menu trigger +take `containerEffects` like a button. Accordion and disclosure take +`triggerEffects`, because their `containerEffects` wraps the revealed content +too. A toggle group clips its items, and so can a select, menu, accordion, or +disclosure container; a ring outside the part is cropped at that edge. Draw it +inside with a negative `outlineOffset` equal to the ring width, or pad the +clipping container by the offset plus the width. + **Derived colors: `ContextToken`, not `withValues`.** `AcmeTokens.interactivePrimary().withValues(alpha: 0.9)` records a Mix directive that survives every later merge, so a caller who replaces the hover fill still gets From e019c09af5d60a5320f2e49266712be0cbdce17e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 21:47:43 -0400 Subject: [PATCH 2/3] docs(remix): correct focus ring notes for toggle groups and menu triggers --- docs/components/menu.mdx | 3 +++ docs/components/toggle_group.mdx | 4 +++- packages/remix/CHANGELOG.md | 10 +++++++++- .../references/components.md | 6 ++++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index 83c47501e..c7bdc28bf 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -351,6 +351,9 @@ A popup that clips (`clipBehavior` on `overlay`) crops an outside row ring at its edge. Pad the popup by at least the ring's offset plus width, or use a negative `outlineOffset` to draw the row ring inside the row. +A `RemixMenuTrigger.builder` that returns a different widget instead of its +child replaces the styled trigger and its effects. + ## Fortal widgets The application-owned Fortal preset provides a themed widget for this component: diff --git a/docs/components/toggle_group.mdx b/docs/components/toggle_group.mdx index 1106d17e9..80f08b7d3 100644 --- a/docs/components/toggle_group.mdx +++ b/docs/components/toggle_group.mdx @@ -170,7 +170,9 @@ final segmentedStyle = ToggleGroupStyler( A group that does not clip can use a positive offset to draw the ring outside -the item. +the item. Items paint in order, so a filled item after the focused one covers +that side of an outside ring; in joined rows use the inset ring, or leave a gap +of at least the ring's offset plus width. ## Constructor diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index 232fdbcde..cbeb48d2e 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -9,7 +9,15 @@ taking layout space, so a focus ring no longer resizes the part or shifts its neighbors. On the accordion and disclosure, `containerEffects` still covers the whole panel and `triggerEffects` covers only the trigger. A toggle group - clips its items, so ring an end item with a negative `outlineOffset`. + styled with `clipBehavior` (every shipped preset sets one) crops an outside + ring on its end items; use a negative `outlineOffset` there. + +### Fixes + +- Tab, toggle, toggle group item, select menu item, menu item, menu trigger, + accordion trigger, and disclosure trigger content keeps its state when a + style adds or removes a decoration such as a focus ring. Previously such a + change rebuilt the part's content from scratch. ## 1.0.0-beta.13 diff --git a/skills/building-remix-design-system/references/components.md b/skills/building-remix-design-system/references/components.md index a955c2d31..fd2f78d96 100644 --- a/skills/building-remix-design-system/references/components.md +++ b/skills/building-remix-design-system/references/components.md @@ -336,8 +336,10 @@ Text inputs ring on any focus, as browsers do, so key their ring on Tab, toggle, toggle group item, select menu item, menu item, and menu trigger take `containerEffects` like a button. Accordion and disclosure take `triggerEffects`, because their `containerEffects` wraps the revealed content -too. A toggle group clips its items, and so can a select, menu, accordion, or -disclosure container; a ring outside the part is cropped at that edge. Draw it +too. A toggle group styled with `clipBehavior` (every shipped preset sets one) +clips its items, and so can a select, menu, accordion, or disclosure container; +a ring outside the part is cropped at that edge. Items also paint in order, so +a filled item after the focused one covers that side of an outside ring. Draw it inside with a negative `outlineOffset` equal to the ring width, or pad the clipping container by the offset plus the width. From 0381b5ae77167cc1aa05f9d8c3037453af5da785 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 09:56:09 -0400 Subject: [PATCH 3/3] fix(remix): paint containerEffects on sidebar destinations and custom accordion triggers RemixSidebar drew destinations with Mix RowBox, so the containerEffects slot that SidebarDestinationStyler (a ToggleStyler) gained never painted: a preset that moves its toggle focus ring there lost the sidebar destination ring. Both destination row layouts (settled and collapsing or expanding) now render through RemixFlexBoxAdapter. RemixAccordion painted triggerEffects only on its default trigger, while RemixDisclosure also wraps a custom triggerBuilder widget. A custom RemixAccordion.builder widget is now wrapped in triggerEffects too, without applying the trigger box, so a preset ring shows around a custom trigger. --- docs/components/accordion.mdx | 4 +- docs/components/disclosure.mdx | 3 +- docs/components/sidebar.mdx | 7 + packages/remix/CHANGELOG.md | 11 +- .../accordion/accordion_widget.dart | 11 +- .../lib/src/components/sidebar/sidebar.dart | 1 + .../components/sidebar/sidebar_widget.dart | 8 +- .../remix_box_effects_slots_test.dart | 150 +++++++++++++++++- .../references/components.md | 11 +- 9 files changed, 188 insertions(+), 18 deletions(-) diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index ed3e0b0b8..cf82a1079 100644 --- a/docs/components/accordion.mdx +++ b/docs/components/accordion.mdx @@ -348,7 +348,9 @@ without taking layout space. Use it in `onFocusVisible` for a focus ring that does not wrap the revealed content, unlike `containerEffects`. When the `container` clips, an outside ring is cropped at the panel edge; use a negative `outlineOffset` to draw it inside the trigger. A custom `builder` replaces the -default trigger and its effects. +default trigger row, but its widget is still wrapped in `triggerEffects`, so the +ring shows around a custom trigger. The `trigger` box (padding, decoration) does +not apply to it. #### `leadingIcon(IconStyler value)` diff --git a/docs/components/disclosure.mdx b/docs/components/disclosure.mdx index 6c0ca9417..932f9994e 100644 --- a/docs/components/disclosure.mdx +++ b/docs/components/disclosure.mdx @@ -98,7 +98,8 @@ and `borderRadius` to the trigger. Style the other parts explicitly: - `triggerEffects(...)` — an outline, such as a focus ring, around the trigger alone; `containerEffects` wraps the revealed content too. When `container` clips, an outside ring is cropped at the panel edge; use a negative - `outlineOffset` to draw it inside the trigger. + `outlineOffset` to draw it inside the trigger. It also wraps the output of a + custom `triggerBuilder`. - `onExpanded(...)` / `onCollapsed(...)` - `onHovered`, `onPressed`, `onFocused`, `onFocusVisible`, `onDisabled` diff --git a/docs/components/sidebar.mdx b/docs/components/sidebar.mdx index eadfba25a..3cb0181a4 100644 --- a/docs/components/sidebar.mdx +++ b/docs/components/sidebar.mdx @@ -265,6 +265,13 @@ A destination's `style` merges after the panel's default destination style, so local exceptions retain shared hover, focus, selected, and disabled variants. A raw `SidebarSpec` is authoritative and bypasses both fluent layers. +`SidebarDestinationStyler` is a `ToggleStyler`, so `containerEffects` draws an +outline around a destination without taking layout space, collapsed or expanded. +Put a focus ring in an `onFocusVisible` variant. In a bounded-height sidebar the +destinations scroll, and the scroll region crops a ring drawn outside a +destination at its edge: keep the ring within the `content` padding, or use a +negative `outlineOffset`. + ```dart import 'package:flutter/material.dart'; diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index cbeb48d2e..927cdf920 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -7,10 +7,13 @@ `MenuTriggerStyler`, and `triggerEffects` to `AccordionStyler` and `DisclosureStyler`. An outline or paint layer draws around the part without taking layout space, so a focus ring no longer resizes the part or shifts its - neighbors. On the accordion and disclosure, `containerEffects` still covers - the whole panel and `triggerEffects` covers only the trigger. A toggle group - styled with `clipBehavior` (every shipped preset sets one) crops an outside - ring on its end items; use a negative `outlineOffset` there. + neighbors. `RemixSidebar` destinations, which use `ToggleStyler` through + `SidebarDestinationStyler`, draw `containerEffects` too. On the accordion and + disclosure, `containerEffects` still covers the whole panel and + `triggerEffects` covers only the trigger, including the widget a custom + `RemixAccordion.builder` or `RemixDisclosure.triggerBuilder` returns. A toggle + group styled with `clipBehavior` (every shipped preset sets one) crops an + outside ring on its end items; use a negative `outlineOffset` there. ### Fixes diff --git a/packages/remix/lib/src/components/accordion/accordion_widget.dart b/packages/remix/lib/src/components/accordion/accordion_widget.dart index c747e05bc..5904eb225 100644 --- a/packages/remix/lib/src/components/accordion/accordion_widget.dart +++ b/packages/remix/lib/src/components/accordion/accordion_widget.dart @@ -336,9 +336,14 @@ class _RemixAccordionBodyState extends State<_RemixAccordionBody> { onPressChange: _handlePressChange, semanticLabel: _config.semanticLabel ?? _config.title, child: _config.child, - builder: - _config.builder ?? - (context, state) => _buildDefaultTrigger(spec, state), + builder: (context, state) => switch (_config.builder) { + final builder? => RemixBoxAdapter( + styleSpec: const StyleSpec(spec: BoxSpec()), + containerEffects: spec.triggerEffects, + child: builder(context, state), + ), + null => _buildDefaultTrigger(spec, state), + }, ), ); } diff --git a/packages/remix/lib/src/components/sidebar/sidebar.dart b/packages/remix/lib/src/components/sidebar/sidebar.dart index b310244e2..f909add93 100644 --- a/packages/remix/lib/src/components/sidebar/sidebar.dart +++ b/packages/remix/lib/src/components/sidebar/sidebar.dart @@ -9,6 +9,7 @@ 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 '../../utilities/remix_style.dart'; import '../toggle/toggle.dart'; import '../tooltip/tooltip.dart'; diff --git a/packages/remix/lib/src/components/sidebar/sidebar_widget.dart b/packages/remix/lib/src/components/sidebar/sidebar_widget.dart index 7db42873c..9bb5ade6d 100644 --- a/packages/remix/lib/src/components/sidebar/sidebar_widget.dart +++ b/packages/remix/lib/src/components/sidebar/sidebar_widget.dart @@ -863,8 +863,10 @@ class _SidebarDestinationContent extends StatelessWidget { // content changes here; the interactive NakedToggle remains mounted. if (motion.expansion == 1 && !motion.isAnimating) { final textWidget = StyledText(label, styleSpec: spec.label); - return RowBox( + return RemixFlexBoxAdapter( styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [ ?iconWidget, if (hasBoundedWidth) Flexible(child: textWidget) else textWidget, @@ -911,10 +913,12 @@ class _SidebarDestinationContent extends StatelessWidget { ), ); - return RowBox( + return RemixFlexBoxAdapter( styleSpec: railAtRest ? _centeredContainer(context, spec.container) : spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, children: [if (hasBoundedWidth) Flexible(child: inner) else inner], ); } diff --git a/packages/remix/test/rendering/remix_box_effects_slots_test.dart b/packages/remix/test/rendering/remix_box_effects_slots_test.dart index 883b91aef..53f462e5b 100644 --- a/packages/remix/test/rendering/remix_box_effects_slots_test.dart +++ b/packages/remix/test/rendering/remix_box_effects_slots_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:naked_ui/naked_ui.dart' show NakedToggle; import 'package:remix/remix.dart'; import 'package:remix/src/rendering/remix_box_effects.dart' show RemixBoxAdapter, RemixFlexBoxAdapter; @@ -187,6 +188,26 @@ final List<_Part> _parts = [ focus: _focusNode, open: null, ), + ( + name: 'Accordion custom trigger', + label: 'Custom', + adapter: RemixBoxAdapter, + build: (ring, node) => RemixAccordionGroup( + controller: RemixAccordionController(), + child: RemixAccordion( + value: 'a', + focusNode: node, + builder: (context, state) => + const Padding(padding: EdgeInsets.all(8), child: Text('Custom')), + style: AccordionStyler().onFocusVisible( + AccordionStyler().triggerEffects(ring), + ), + child: const Text('Content'), + ), + ), + focus: _focusNode, + open: null, + ), ( name: 'Disclosure trigger', label: 'Trigger', @@ -202,6 +223,23 @@ final List<_Part> _parts = [ focus: _focusNode, open: null, ), + ( + name: 'Disclosure custom trigger', + label: 'Custom', + adapter: RemixBoxAdapter, + build: (ring, node) => RemixDisclosure( + trigger: const Text('Trigger'), + triggerBuilder: (context, state, child) => + const Padding(padding: EdgeInsets.all(8), child: Text('Custom')), + content: const Text('Content'), + focusNode: node, + style: DisclosureStyler().onFocusVisible( + DisclosureStyler().triggerEffects(ring), + ), + ), + focus: _focusNode, + open: null, + ), ]; Widget _harness(Widget child) => RepaintBoundary( @@ -256,6 +294,47 @@ List _adapterRects(WidgetTester tester) => [ (element.renderObject! as RenderBox).size, ]; +Widget _sidebar({required bool collapsed, required FocusNode node}) => _harness( + SizedBox( + height: 300, + child: RemixSidebar( + expandedWidth: 256, + collapsedWidth: 72, + collapsed: collapsed, + showTooltips: false, + selectedValue: 'home', + onSelected: (_) {}, + sections: [ + RemixSidebarSection( + label: 'Workspace', + destinations: [ + RemixSidebarDestination( + value: 'home', + label: 'Overview', + icon: Icons.home, + focusNode: node, + ), + const RemixSidebarDestination( + value: 'settings', + label: 'Settings', + icon: Icons.settings, + ), + ], + ), + ], + style: SidebarStyler() + .content(FlexBoxStyler().padding(.all(12))) + .destination( + SidebarDestinationStyler() + .padding(.all(8)) + .onFocusVisible( + SidebarDestinationStyler().containerEffects(_ring()), + ), + ), + ), + ), +); + void _useTraditionalFocus() { final previous = FocusManager.instance.highlightStrategy; addTearDown(() => FocusManager.instance.highlightStrategy = previous); @@ -305,8 +384,12 @@ void main() { } for (final part in _parts.where( - (part) => - const {'Accordion trigger', 'Disclosure trigger'}.contains(part.name), + (part) => const { + 'Accordion trigger', + 'Accordion custom trigger', + 'Disclosure trigger', + 'Disclosure custom trigger', + }.contains(part.name), )) { testWidgets('${part.name} ring hugs the trigger, not the panel', ( tester, @@ -421,6 +504,69 @@ void main() { }); }); + group('sidebar destination', () { + final destination = find.ancestor( + of: find.byIcon(Icons.home), + matching: find.byType(NakedToggle), + ); + + Future expectRingOnFocus( + WidgetTester tester, { + required bool collapsed, + }) async { + _useTraditionalFocus(); + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(_sidebar(collapsed: collapsed, node: node)); + await tester.pumpAndSettle(); + final rect = tester.getRect(destination); + final iconRect = tester.getRect(find.byIcon(Icons.home)); + + expect( + _outsideRingPixel(await _capture(tester), rect), + isNot(_rgba(_ringColor)), + reason: 'idle', + ); + + await _focusNode(tester, node); + + expect(tester.getRect(destination), rect, reason: 'destination bounds'); + expect(tester.getRect(find.byIcon(Icons.home)), iconRect, reason: 'icon'); + expect( + _outsideRingPixel(await _capture(tester), rect), + _rgba(_ringColor), + reason: 'focused', + ); + } + + testWidgets('paints its ring on focus when expanded', (tester) async { + await expectRingOnFocus(tester, collapsed: false); + }); + + testWidgets('paints its ring on focus when collapsed', (tester) async { + await expectRingOnFocus(tester, collapsed: true); + }); + + testWidgets('keeps painting its ring while the sidebar animates', ( + tester, + ) async { + _useTraditionalFocus(); + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget(_sidebar(collapsed: false, node: node)); + await _focusNode(tester, node); + await tester.pumpWidget(_sidebar(collapsed: true, node: node)); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + _outsideRingPixel(await _capture(tester), tester.getRect(destination)), + _rgba(_ringColor), + ); + }); + }); + group('effects slots animate through RemixBoxEffectsSpec.lerpNullable', () { const ring = RemixBoxEffectsSpec( outline: BorderSide(color: _ringColor, width: 4), diff --git a/skills/building-remix-design-system/references/components.md b/skills/building-remix-design-system/references/components.md index fd2f78d96..047ffa6e5 100644 --- a/skills/building-remix-design-system/references/components.md +++ b/skills/building-remix-design-system/references/components.md @@ -334,11 +334,12 @@ Text inputs ring on any focus, as browsers do, so key their ring on `onFocused` instead. Tab, toggle, toggle group item, select menu item, menu item, and menu trigger -take `containerEffects` like a button. Accordion and disclosure take -`triggerEffects`, because their `containerEffects` wraps the revealed content -too. A toggle group styled with `clipBehavior` (every shipped preset sets one) -clips its items, and so can a select, menu, accordion, or disclosure container; -a ring outside the part is cropped at that edge. Items also paint in order, so +take `containerEffects` like a button, and so does a sidebar destination, which +is a toggle. Accordion and disclosure take `triggerEffects`, because their +`containerEffects` wraps the revealed content too. A toggle group styled with +`clipBehavior` (every shipped preset sets one) clips its items, and so can a +select, menu, accordion, or disclosure container, or a bounded sidebar's scroll +region; a ring outside the part is cropped at that edge. Items also paint in order, so a filled item after the focused one covers that side of an outside ring. Draw it inside with a negative `outlineOffset` equal to the ring width, or pad the clipping container by the offset plus the width.