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