diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index 48312ed91..cf82a1079 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,17 @@ 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 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)` Configures the leading icon style. diff --git a/docs/components/disclosure.mdx b/docs/components/disclosure.mdx index f772c658d..932f9994e 100644 --- a/docs/components/disclosure.mdx +++ b/docs/components/disclosure.mdx @@ -95,6 +95,11 @@ 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. It also wraps the output of a + custom `triggerBuilder`. - `onExpanded(...)` / `onCollapsed(...)` - `onHovered`, `onPressed`, `onFocused`, `onFocusVisible`, `onDisabled` diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index 2683f5920..c7bdc28bf 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -318,6 +318,42 @@ 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. + +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/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/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/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..80f08b7d3 100644 --- a/docs/components/toggle_group.mdx +++ b/docs/components/toggle_group.mdx @@ -137,6 +137,43 @@ 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. 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 8103958b9..927cdf920 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,3 +1,27 @@ +## 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. `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 + +- 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 ### 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..5904eb225 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), @@ -335,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/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/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/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..53f462e5b --- /dev/null +++ b/packages/remix/test/rendering/remix_box_effects_slots_test.dart @@ -0,0 +1,622 @@ +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:naked_ui/naked_ui.dart' show NakedToggle; +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: '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', + 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, + ), + ( + 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( + 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, +]; + +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); + 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', + 'Accordion custom trigger', + 'Disclosure trigger', + 'Disclosure custom 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('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), + 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..047ffa6e5 100644 --- a/skills/building-remix-design-system/references/components.md +++ b/skills/building-remix-design-system/references/components.md @@ -333,6 +333,17 @@ 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, 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. + **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