From bef70ea5e87abb91d07bb638220490d1aa48ab7d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 08:21:51 -0400 Subject: [PATCH 01/17] feat(registry): draw Vanilla focus rings through the effects slots Tab, toggle, toggle group option, menu trigger, accordion, and disclosure drew their ring with a foreground decoration, which needed a second helper with its own radius and inset arguments and, on the accordion, ringed the whole section so the ring grew with the revealed panel. They now use vanillaFocusRing in containerEffects, or triggerEffects on the accordion and disclosure, so the ring follows the part's own corners, takes no layout space, and wraps only the trigger. A toggle group option rings inside itself with the new inset argument because the group clips. The vanillaFocusRingDecoration helper is removed. --- .../lib/ui/components/accordion.dart | 9 +- .../lib/ui/components/disclosure.dart | 23 +- apps/playground/lib/ui/components/menu.dart | 19 +- apps/playground/lib/ui/components/tabs.dart | 26 +- apps/playground/lib/ui/components/toggle.dart | 19 +- .../lib/ui/components/toggle_group.dart | 14 +- apps/playground/lib/ui/theme/effects.dart | 39 +-- .../lib/ui/components/accordion.dart | 9 +- .../lib/ui/components/disclosure.dart | 23 +- .../lib/ui/components/menu.dart | 19 +- .../lib/ui/components/tabs.dart | 26 +- .../lib/ui/components/toggle.dart | 19 +- .../lib/ui/components/toggle_group.dart | 14 +- .../lib/ui/theme/effects.dart | 39 +-- open_code/fixture/test/open_code_test.dart | 55 +-- .../templates/accordion/accordion.dart.tmpl | 9 +- .../templates/disclosure/disclosure.dart.tmpl | 23 +- .../vanilla/templates/menu/menu.dart.tmpl | 19 +- .../vanilla/templates/tabs/tabs.dart.tmpl | 26 +- .../vanilla/templates/theme/effects.dart.tmpl | 39 +-- .../vanilla/templates/toggle/toggle.dart.tmpl | 19 +- .../toggle_group/toggle_group.dart.tmpl | 14 +- .../lib/src/default/components/accordion.dart | 9 +- .../src/default/components/disclosure.dart | 23 +- .../lib/src/default/components/menu.dart | 19 +- .../lib/src/default/components/tabs.dart | 26 +- .../lib/src/default/components/toggle.dart | 19 +- .../src/default/components/toggle_group.dart | 14 +- .../lib/src/default/theme/effects.dart | 39 +-- registry_source/specs/vanilla.md | 4 +- .../test/vanilla/focus_ring_test.dart | 323 ++++++++++++++++++ .../state_distinguishability_test.dart | 8 +- registry_source/test/vanilla/support.dart | 4 - .../test/vanilla/vanilla_spec.dart | 92 ++++- 34 files changed, 631 insertions(+), 451 deletions(-) create mode 100644 registry_source/test/vanilla/focus_ring_test.dart diff --git a/apps/playground/lib/ui/components/accordion.dart b/apps/playground/lib/ui/components/accordion.dart index 42416b90d..e0545d33d 100644 --- a/apps/playground/lib/ui/components/accordion.dart +++ b/apps/playground/lib/ui/components/accordion.dart @@ -131,12 +131,13 @@ AccordionStyler _openStyle() => AccordionStyler() /// The keyboard focus ring: a 3px band of `ring` at half strength. /// /// An outline rather than a border: `RemixBoxEffects` paints it outside the -/// section without taking layout space, and the section's own border is -/// already carrying the rule between rows. +/// trigger without taking layout space. It is on the trigger, not the section: +/// the section also holds the revealed panel, and a ring there would grow with +/// it. AccordionStyler _focusVisibleStyle() => - AccordionStyler().containerEffects(playgroundFocusRing()); + AccordionStyler().triggerEffects(playgroundFocusRing()); /// Declared last so it wins over every other state fragment. AccordionStyler _disabledStyle() => AccordionStyler() - .containerEffects(.outline(.style(.none))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(PlaygroundTokens.disabledOpacity)); diff --git a/apps/playground/lib/ui/components/disclosure.dart b/apps/playground/lib/ui/components/disclosure.dart index 483c1dc76..405f73f78 100644 --- a/apps/playground/lib/ui/components/disclosure.dart +++ b/apps/playground/lib/ui/components/disclosure.dart @@ -90,25 +90,18 @@ DisclosureStyler playgroundDisclosureStyle({ /// The keyboard focus ring, drawn on the trigger alone. /// -/// Not on the container, which is where the effects layer lives and where the -/// accordion puts its ring. Keyboard focus is on the trigger, and an expanded -/// disclosure's container is the trigger *plus* everything it revealed — a -/// ring there claims the content is focused too, and grows with it. -/// -/// `foregroundDecoration` is what makes a trigger-only ring possible: it paints -/// over the box's own bounds rather than beside them, so the ring takes no -/// layout space and opening the section never reflows the page. A plain -/// `.border(...)` would push the trigger's content in by two pixels the moment -/// it took focus. The ring is a 3px band of `ring` at half strength, stroked -/// outside the trigger. -DisclosureStyler _focusVisibleStyle() => DisclosureStyler().trigger( - .foregroundDecoration(playgroundFocusRingDecoration()), -); +/// Not on the container, whose effects cover the trigger *plus* everything it +/// revealed: a ring there claims the content is focused too, and grows with +/// it. `triggerEffects` is an outline around the trigger, so it takes no +/// layout space and opening the section never reflows the page. The ring is a +/// 3px band of `ring` at half strength. +DisclosureStyler _focusVisibleStyle() => + DisclosureStyler().triggerEffects(playgroundFocusRing()); /// Declared last so it wins over every other state fragment. /// /// The ring is cleared as well as faded: a disabled control that still draws /// a focus ring reads as reachable. DisclosureStyler _disabledStyle() => DisclosureStyler() - .trigger(.foregroundDecoration(BoxDecorationMix.border(.style(.none)))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(PlaygroundTokens.disabledOpacity)); diff --git a/apps/playground/lib/ui/components/menu.dart b/apps/playground/lib/ui/components/menu.dart index 66f5cb090..5d7ca1954 100644 --- a/apps/playground/lib/ui/components/menu.dart +++ b/apps/playground/lib/ui/components/menu.dart @@ -53,14 +53,12 @@ MenuStyler playgroundMenuStyle({ .divider(_dividerStyle()) .merge(style); -/// The control that opens the menu. +/// The standard label trigger of the menu. /// -/// It is deliberately quiet at rest — no fill, no outline — because the -/// trigger usually already wraps a button or an icon button with a recipe of -/// its own, and two competing surfaces would read as a control inside a -/// control. It still answers hover and focus, because a caller is equally -/// free to wrap a bare `Text`, and that caller must not end up with a -/// keyboard-reachable control that shows nothing when it is reached. +/// It is quiet at rest, with no fill and no outline, and still answers hover +/// and focus, so a bare label never ends up keyboard-reachable while showing +/// nothing when it is reached. A real button is the trigger through +/// `RemixMenuTrigger.control`, which owns its own look; none of this styles it. MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() .animate(PlaygroundTokens.motion) .direction(.horizontal) @@ -85,11 +83,8 @@ MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() ) .icon(.size(PlaygroundTokens.icon).color(PlaygroundTokens.foreground())) .onHovered(.color(PlaygroundTokens.accent())) - // A trigger is keyboard-reachable whether or not it wraps a control that - // rings itself, so it rings too. A *foreground* decoration, because - // `MenuTriggerSpec` has no `containerEffects` layer and a real border - // would nudge the label. - .onFocusVisible(.foregroundDecoration(playgroundFocusRingDecoration())) + // An outline rather than a border, which would nudge the label. + .onFocusVisible(.containerEffects(playgroundFocusRing())) .onDisabled( MenuTriggerStyler().wrap(.opacity(PlaygroundTokens.disabledOpacity)), ); diff --git a/apps/playground/lib/ui/components/tabs.dart b/apps/playground/lib/ui/components/tabs.dart index 8c47140bd..62bb38be7 100644 --- a/apps/playground/lib/ui/components/tabs.dart +++ b/apps/playground/lib/ui/components/tabs.dart @@ -130,7 +130,7 @@ TabStyler playgroundTabStyle({ .line => _line(), }) .onHovered(_current()) - .onFocusVisible(_focusVisibleStyle(variant)) + .onFocusVisible(_focusVisibleStyle()) .onDisabled(_disabledStyle()) .merge(style); } @@ -166,8 +166,7 @@ const _restingAlpha = 0.6; /// /// The dark fill is blended here rather than painted translucent: the tab's /// shadow is a decoration shadow, which Flutter paints under the whole box, -/// so it would show through a translucent fill and darken it. Tabs have no -/// effects layer to move the shadow to. +/// so it would show through a translucent fill and darken it. final _currentFill = playgroundByBrightness( light: PlaygroundTokens.background, dark: _darkCurrentFill, @@ -245,20 +244,11 @@ TabStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `TabSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the tab and -/// takes no layout space. -TabStyler _focusVisibleStyle(PlaygroundTabsVariant variant) => - TabStyler().foregroundDecoration( - playgroundFocusRingDecoration( - radius: switch (variant) { - .filled => null, - .line => Radius.zero, - }, - ), - ); +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// tab, following its corners, without taking layout space, so focusing a tab +/// never nudges its label or its neighbors. +TabStyler _focusVisibleStyle() => + TabStyler().containerEffects(playgroundFocusRing()); /// Declared last so it wins over every other state fragment. /// @@ -266,5 +256,5 @@ TabStyler _focusVisibleStyle(PlaygroundTabsVariant variant) => /// the focus ring is cleared because a disabled tab that still draws a focus /// ring reads as reachable. TabStyler _disabledStyle() => TabStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(PlaygroundTokens.disabledOpacity)); diff --git a/apps/playground/lib/ui/components/toggle.dart b/apps/playground/lib/ui/components/toggle.dart index 1ef2ad9e7..9f3f145f1 100644 --- a/apps/playground/lib/ui/components/toggle.dart +++ b/apps/playground/lib/ui/components/toggle.dart @@ -119,8 +119,8 @@ ToggleStyler _variantStyle( PlaygroundTokens.mutedForeground(), ).color(PlaygroundTokens.muted()), ), - // No shadow: `ToggleSpec` has no effects layer, and a decoration shadow under - // a transparent control shows through it as a gray wash. + // No shadow: a decoration shadow under a transparent control shows through + // it as a gray wash. .outline => ToggleStyler() .border( @@ -141,16 +141,11 @@ ToggleStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `ToggleSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the box, so -/// the ring sits where an outline would and takes no layout space. The -/// outline variant also turns its own border `ring`. +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// toggle without taking layout space, so focusing one never reflows the row +/// it sits in. The outline variant also turns its own border `ring`. ToggleStyler _focusVisibleStyle(PlaygroundToggleVariant variant) { - final ring = ToggleStyler().foregroundDecoration( - playgroundFocusRingDecoration(), - ); + final ring = ToggleStyler().containerEffects(playgroundFocusRing()); return switch (variant) { .ghost => ring, @@ -164,5 +159,5 @@ ToggleStyler _focusVisibleStyle(PlaygroundToggleVariant variant) { /// fades; the focus ring is cleared because a disabled control that still /// draws a focus ring reads as actionable. ToggleStyler _disabledStyle() => ToggleStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(PlaygroundTokens.disabledOpacity)); diff --git a/apps/playground/lib/ui/components/toggle_group.dart b/apps/playground/lib/ui/components/toggle_group.dart index e7ea6a004..1223e2e06 100644 --- a/apps/playground/lib/ui/components/toggle_group.dart +++ b/apps/playground/lib/ui/components/toggle_group.dart @@ -130,16 +130,14 @@ ToggleGroupItemStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration, because `ToggleGroupItemSpec` has no -/// `containerEffects` layer to paint an outline into. It is stroked inside -/// the option rather than outside: the strip clips its options, and an -/// outside ring on the first or last option would be cut off at its edge. +/// An outline rather than a border, so focusing an option takes no layout +/// space. It is drawn inside the option rather than outside: the strip clips +/// its options, and an outside ring on the first or last option would be cut +/// off at its edge. ToggleGroupItemStyler _focusVisibleStyle() => - ToggleGroupItemStyler().foregroundDecoration( - playgroundFocusRingDecoration(radius: Radius.zero, inset: true), - ); + ToggleGroupItemStyler().containerEffects(playgroundFocusRing(inset: true)); /// Declared last so it wins over every other state fragment. ToggleGroupItemStyler _disabledStyle() => ToggleGroupItemStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(PlaygroundTokens.disabledOpacity)); diff --git a/apps/playground/lib/ui/theme/effects.dart b/apps/playground/lib/ui/theme/effects.dart index b78af6e3d..a3f1a493c 100644 --- a/apps/playground/lib/ui/theme/effects.dart +++ b/apps/playground/lib/ui/theme/effects.dart @@ -69,9 +69,13 @@ final _byBrightness = /// /// A 3px band of [color] at [alpha] (or [dark] in a dark theme), drawn /// outside the control with no offset, for a spec's Remix effects slot: -/// `containerEffects`, a switch's `trackEffects`, or a slider's -/// `thumbFocusEffects`. An outline takes no layout space, so focusing a -/// control never reflows the row it sits in. +/// `containerEffects`, an accordion's `triggerEffects`, a switch's +/// `trackEffects`, or a slider's `thumbFocusEffects`. An outline takes no +/// layout space, so focusing a control never reflows the row it sits in. +/// +/// A control clipped by its container, such as a toggle group's option, would +/// have an outside band cut off at the container's edge; pass [inset] to draw +/// it inside the control instead. /// /// A destructive control rings in its own color: `playgroundFocusRing(color: /// PlaygroundTokens.destructive, alpha: 0.2, dark: 0.4)`. @@ -80,38 +84,13 @@ RemixBoxEffectsMix playgroundFocusRing({ double alpha = _ringAlpha, double? dark, double width = PlaygroundTokens.strokeRing, + bool inset = false, }) => RemixBoxEffectsMix( outline: BorderSideMix( color: playgroundTint(color, alpha, dark: dark)(), width: width, ), - outlineOffset: 0, -); - -/// [playgroundFocusRing] as a foreground decoration, for the specs that have no -/// effects slot: a toggle, a toggle group's items, a tab, a menu trigger. -/// -/// The border is stroked outside the box, so the ring sits where the effects -/// outline would and still takes no layout space. [radius] is the control's -/// own corner radius, which the ring follows. -/// -/// A control clipped by its container (a toggle group's option) would have -/// that outside stroke cut off at the container's edge; pass [inset] to -/// stroke the ring inside the control instead. -BoxDecorationMix playgroundFocusRingDecoration({ - Radius? radius, - bool inset = false, -}) => BoxDecorationMix( - border: .all( - BorderSideMix( - color: playgroundTint(PlaygroundTokens.ring, _ringAlpha)(), - width: PlaygroundTokens.strokeRing, - strokeAlign: inset - ? BorderSide.strokeAlignInside - : BorderSide.strokeAlignOutside, - ), - ), - borderRadius: .all(radius ?? PlaygroundTokens.radiusMd()), + outlineOffset: inset ? -width : 0, ); /// A focused control's own border, turned `ring`. diff --git a/apps/widgetbook_vanilla/lib/ui/components/accordion.dart b/apps/widgetbook_vanilla/lib/ui/components/accordion.dart index 7f42eb89b..03c1d597a 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/accordion.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/accordion.dart @@ -129,12 +129,13 @@ AccordionStyler _openStyle() => AccordionStyler() /// The keyboard focus ring: a 3px band of `ring` at half strength. /// /// An outline rather than a border: `RemixBoxEffects` paints it outside the -/// section without taking layout space, and the section's own border is -/// already carrying the rule between rows. +/// trigger without taking layout space. It is on the trigger, not the section: +/// the section also holds the revealed panel, and a ring there would grow with +/// it. AccordionStyler _focusVisibleStyle() => - AccordionStyler().containerEffects(vanillaFocusRing()); + AccordionStyler().triggerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. AccordionStyler _disabledStyle() => AccordionStyler() - .containerEffects(.outline(.style(.none))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/apps/widgetbook_vanilla/lib/ui/components/disclosure.dart b/apps/widgetbook_vanilla/lib/ui/components/disclosure.dart index 1de1153dd..1b460c372 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/disclosure.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/disclosure.dart @@ -90,25 +90,18 @@ DisclosureStyler vanillaDisclosureStyle({ /// The keyboard focus ring, drawn on the trigger alone. /// -/// Not on the container, which is where the effects layer lives and where the -/// accordion puts its ring. Keyboard focus is on the trigger, and an expanded -/// disclosure's container is the trigger *plus* everything it revealed — a -/// ring there claims the content is focused too, and grows with it. -/// -/// `foregroundDecoration` is what makes a trigger-only ring possible: it paints -/// over the box's own bounds rather than beside them, so the ring takes no -/// layout space and opening the section never reflows the page. A plain -/// `.border(...)` would push the trigger's content in by two pixels the moment -/// it took focus. The ring is a 3px band of `ring` at half strength, stroked -/// outside the trigger. -DisclosureStyler _focusVisibleStyle() => DisclosureStyler().trigger( - .foregroundDecoration(vanillaFocusRingDecoration()), -); +/// Not on the container, whose effects cover the trigger *plus* everything it +/// revealed: a ring there claims the content is focused too, and grows with +/// it. `triggerEffects` is an outline around the trigger, so it takes no +/// layout space and opening the section never reflows the page. The ring is a +/// 3px band of `ring` at half strength. +DisclosureStyler _focusVisibleStyle() => + DisclosureStyler().triggerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. /// /// The ring is cleared as well as faded: a disabled control that still draws /// a focus ring reads as reachable. DisclosureStyler _disabledStyle() => DisclosureStyler() - .trigger(.foregroundDecoration(BoxDecorationMix.border(.style(.none)))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/apps/widgetbook_vanilla/lib/ui/components/menu.dart b/apps/widgetbook_vanilla/lib/ui/components/menu.dart index 734c75958..c6a06b400 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/menu.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/menu.dart @@ -52,14 +52,12 @@ MenuStyler vanillaMenuStyle({MenuStyler style = const MenuStyler.create()}) => .divider(_dividerStyle()) .merge(style); -/// The control that opens the menu. +/// The standard label trigger of the menu. /// -/// It is deliberately quiet at rest — no fill, no outline — because the -/// trigger usually already wraps a button or an icon button with a recipe of -/// its own, and two competing surfaces would read as a control inside a -/// control. It still answers hover and focus, because a caller is equally -/// free to wrap a bare `Text`, and that caller must not end up with a -/// keyboard-reachable control that shows nothing when it is reached. +/// It is quiet at rest, with no fill and no outline, and still answers hover +/// and focus, so a bare label never ends up keyboard-reachable while showing +/// nothing when it is reached. A real button is the trigger through +/// `RemixMenuTrigger.control`, which owns its own look; none of this styles it. MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() .animate(VanillaTokens.motion) .direction(.horizontal) @@ -81,11 +79,8 @@ MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() ) .icon(.size(VanillaTokens.icon).color(VanillaTokens.foreground())) .onHovered(.color(VanillaTokens.accent())) - // A trigger is keyboard-reachable whether or not it wraps a control that - // rings itself, so it rings too. A *foreground* decoration, because - // `MenuTriggerSpec` has no `containerEffects` layer and a real border - // would nudge the label. - .onFocusVisible(.foregroundDecoration(vanillaFocusRingDecoration())) + // An outline rather than a border, which would nudge the label. + .onFocusVisible(.containerEffects(vanillaFocusRing())) .onDisabled( MenuTriggerStyler().wrap(.opacity(VanillaTokens.disabledOpacity)), ); diff --git a/apps/widgetbook_vanilla/lib/ui/components/tabs.dart b/apps/widgetbook_vanilla/lib/ui/components/tabs.dart index 341ae21d7..32a8f67ed 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/tabs.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/tabs.dart @@ -130,7 +130,7 @@ TabStyler vanillaTabStyle({ .line => _line(), }) .onHovered(_current()) - .onFocusVisible(_focusVisibleStyle(variant)) + .onFocusVisible(_focusVisibleStyle()) .onDisabled(_disabledStyle()) .merge(style); } @@ -166,8 +166,7 @@ const _restingAlpha = 0.6; /// /// The dark fill is blended here rather than painted translucent: the tab's /// shadow is a decoration shadow, which Flutter paints under the whole box, -/// so it would show through a translucent fill and darken it. Tabs have no -/// effects layer to move the shadow to. +/// so it would show through a translucent fill and darken it. final _currentFill = vanillaByBrightness( light: VanillaTokens.background, dark: _darkCurrentFill, @@ -237,20 +236,11 @@ TabStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `TabSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the tab and -/// takes no layout space. -TabStyler _focusVisibleStyle(VanillaTabsVariant variant) => - TabStyler().foregroundDecoration( - vanillaFocusRingDecoration( - radius: switch (variant) { - .filled => null, - .line => Radius.zero, - }, - ), - ); +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// tab, following its corners, without taking layout space, so focusing a tab +/// never nudges its label or its neighbors. +TabStyler _focusVisibleStyle() => + TabStyler().containerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. /// @@ -258,5 +248,5 @@ TabStyler _focusVisibleStyle(VanillaTabsVariant variant) => /// the focus ring is cleared because a disabled tab that still draws a focus /// ring reads as reachable. TabStyler _disabledStyle() => TabStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart index 95dd6693f..26d95d0f7 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart @@ -103,8 +103,8 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .ghost => ToggleStyler().onHovered( _content(VanillaTokens.mutedForeground()).color(VanillaTokens.muted()), ), - // No shadow: `ToggleSpec` has no effects layer, and a decoration shadow under - // a transparent control shows through it as a gray wash. + // No shadow: a decoration shadow under a transparent control shows through + // it as a gray wash. .outline => ToggleStyler() .border( @@ -123,16 +123,11 @@ ToggleStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `ToggleSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the box, so -/// the ring sits where an outline would and takes no layout space. The -/// outline variant also turns its own border `ring`. +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// toggle without taking layout space, so focusing one never reflows the row +/// it sits in. The outline variant also turns its own border `ring`. ToggleStyler _focusVisibleStyle(VanillaToggleVariant variant) { - final ring = ToggleStyler().foregroundDecoration( - vanillaFocusRingDecoration(), - ); + final ring = ToggleStyler().containerEffects(vanillaFocusRing()); return switch (variant) { .ghost => ring, @@ -146,5 +141,5 @@ ToggleStyler _focusVisibleStyle(VanillaToggleVariant variant) { /// fades; the focus ring is cleared because a disabled control that still /// draws a focus ring reads as actionable. ToggleStyler _disabledStyle() => ToggleStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/apps/widgetbook_vanilla/lib/ui/components/toggle_group.dart b/apps/widgetbook_vanilla/lib/ui/components/toggle_group.dart index bd8264ce4..5c6752bbc 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/toggle_group.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/toggle_group.dart @@ -130,16 +130,14 @@ ToggleGroupItemStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration, because `ToggleGroupItemSpec` has no -/// `containerEffects` layer to paint an outline into. It is stroked inside -/// the option rather than outside: the strip clips its options, and an -/// outside ring on the first or last option would be cut off at its edge. +/// An outline rather than a border, so focusing an option takes no layout +/// space. It is drawn inside the option rather than outside: the strip clips +/// its options, and an outside ring on the first or last option would be cut +/// off at its edge. ToggleGroupItemStyler _focusVisibleStyle() => - ToggleGroupItemStyler().foregroundDecoration( - vanillaFocusRingDecoration(radius: Radius.zero, inset: true), - ); + ToggleGroupItemStyler().containerEffects(vanillaFocusRing(inset: true)); /// Declared last so it wins over every other state fragment. ToggleGroupItemStyler _disabledStyle() => ToggleGroupItemStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/apps/widgetbook_vanilla/lib/ui/theme/effects.dart b/apps/widgetbook_vanilla/lib/ui/theme/effects.dart index 54d75c16c..38de827d9 100644 --- a/apps/widgetbook_vanilla/lib/ui/theme/effects.dart +++ b/apps/widgetbook_vanilla/lib/ui/theme/effects.dart @@ -68,9 +68,13 @@ final _byBrightness = /// /// A 3px band of [color] at [alpha] (or [dark] in a dark theme), drawn /// outside the control with no offset, for a spec's Remix effects slot: -/// `containerEffects`, a switch's `trackEffects`, or a slider's -/// `thumbFocusEffects`. An outline takes no layout space, so focusing a -/// control never reflows the row it sits in. +/// `containerEffects`, an accordion's `triggerEffects`, a switch's +/// `trackEffects`, or a slider's `thumbFocusEffects`. An outline takes no +/// layout space, so focusing a control never reflows the row it sits in. +/// +/// A control clipped by its container, such as a toggle group's option, would +/// have an outside band cut off at the container's edge; pass [inset] to draw +/// it inside the control instead. /// /// A destructive control rings in its own color: `vanillaFocusRing(color: /// VanillaTokens.destructive, alpha: 0.2, dark: 0.4)`. @@ -79,38 +83,13 @@ RemixBoxEffectsMix vanillaFocusRing({ double alpha = _ringAlpha, double? dark, double width = VanillaTokens.strokeRing, + bool inset = false, }) => RemixBoxEffectsMix( outline: BorderSideMix( color: vanillaTint(color, alpha, dark: dark)(), width: width, ), - outlineOffset: 0, -); - -/// [vanillaFocusRing] as a foreground decoration, for the specs that have no -/// effects slot: a toggle, a toggle group's items, a tab, a menu trigger. -/// -/// The border is stroked outside the box, so the ring sits where the effects -/// outline would and still takes no layout space. [radius] is the control's -/// own corner radius, which the ring follows. -/// -/// A control clipped by its container (a toggle group's option) would have -/// that outside stroke cut off at the container's edge; pass [inset] to -/// stroke the ring inside the control instead. -BoxDecorationMix vanillaFocusRingDecoration({ - Radius? radius, - bool inset = false, -}) => BoxDecorationMix( - border: .all( - BorderSideMix( - color: vanillaTint(VanillaTokens.ring, _ringAlpha)(), - width: VanillaTokens.strokeRing, - strokeAlign: inset - ? BorderSide.strokeAlignInside - : BorderSide.strokeAlignOutside, - ), - ), - borderRadius: .all(radius ?? VanillaTokens.radiusMd()), + outlineOffset: inset ? -width : 0, ); /// A focused control's own border, turned `ring`. diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index c3e36c424..6627202d5 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -2122,12 +2122,9 @@ void main() { states: const {WidgetState.focused}, ); - // A foreground decoration, because a real border would inset the - // label; `TabSpec` has no effects layer to paint an outline into. - expect( - _flexForegroundBorder(spec.spec.container), - _focusRingBorder(theme.data), - ); + // An outline, because a real border would inset the label. + expect(spec.spec.containerEffects?.outline, _focusRing(theme.data)); + expect(spec.spec.containerEffects?.outlineOffset ?? 0, 0); }); } @@ -2137,7 +2134,7 @@ void main() { theme: const AcmeThemeData.light(), ); - expect(_flexForegroundBorder(spec.spec.container), isNull); + expect(spec.spec.containerEffects?.outline.width ?? 0, 0); }); testWidgets('disabled fades the tab and clears the ring', (tester) async { @@ -2155,10 +2152,7 @@ void main() { ); // Merging leaves the ring's color and width in place and turns its // style off, which is what stops Flutter painting it. - expect( - (_flexForegroundBorder(spec.spec.container) as Border?)?.top.style, - BorderStyle.none, - ); + expect(spec.spec.containerEffects?.outline.style, BorderStyle.none); }); testWidgets('the caller style merges last', (tester) async { @@ -3500,11 +3494,8 @@ void main() { states: const {WidgetState.focused}, ); - expect(_flexForegroundBorder(idle.spec.container), isNull); - expect( - _flexForegroundBorder(focused.spec.container), - _focusRingBorder(theme), - ); + expect(idle.spec.containerEffects?.outline.width ?? 0, 0); + expect(focused.spec.containerEffects?.outline, _focusRing(theme)); // And the ring genuinely does not move the label. Measured rather than // inferred: asserting *how* the ring is built only proves the mechanism // the recipe happens to use today, while this fails for any focus @@ -3535,10 +3526,8 @@ void main() { await tester.pumpAndSettle(); expect( - _flexForegroundBorder( - _resolvedSpecOf(tester).spec.container, - ), - isNotNull, + _resolvedSpecOf(tester).spec.containerEffects?.outline, + _focusRing(theme), ); expect(tester.getRect(find.text('Bold')), restingLabel); // The control's own footprint too. A symmetric change — extra padding, @@ -3563,10 +3552,7 @@ void main() { isA().having((m) => m.opacity, 'opacity', 0.5), ), ); - expect( - (_flexForegroundBorder(spec.spec.container) as Border?)?.top.style, - BorderStyle.none, - ); + expect(spec.spec.containerEffects?.outline.style, BorderStyle.none); }); testWidgets('toggling reaches the callback and reports its state', ( @@ -5286,7 +5272,9 @@ void main() { ); group('acmeAccordionStyle states and surface', () { - testWidgets('focus-visible rings the section', (tester) async { + testWidgets('focus-visible rings the trigger, not the section', ( + tester, + ) async { const theme = AcmeThemeData.light(); final idle = await _resolve(tester, acmeAccordionStyle(), theme: theme); final focused = await _resolve( @@ -5296,9 +5284,10 @@ void main() { states: const {WidgetState.focused}, ); - expect(idle.spec.containerEffects?.outline.width ?? 0, 0); - expect(focused.spec.containerEffects?.outline, _focusRing(theme)); - expect(focused.spec.containerEffects?.outlineOffset ?? 0, 0); + expect(idle.spec.triggerEffects?.outline.width ?? 0, 0); + expect(focused.spec.triggerEffects?.outline, _focusRing(theme)); + expect(focused.spec.triggerEffects?.outlineOffset ?? 0, 0); + expect(focused.spec.containerEffects, isNull); }); testWidgets('disabled fades the section and wins over focus-visible', ( @@ -5317,7 +5306,7 @@ void main() { isA().having((m) => m.opacity, 'opacity', 0.5), ), ); - expect(spec.spec.containerEffects?.outline.style, BorderStyle.none); + expect(spec.spec.triggerEffects?.outline.style, BorderStyle.none); }); testWidgets('forwards the curated RemixAccordion surface', (tester) async { @@ -5932,11 +5921,6 @@ BorderSide _focusRing(AcmeThemeData theme) => BorderSide( width: 3, ); -/// [_focusRing] as the foreground border of a control with no effects slot. -Border _focusRingBorder(AcmeThemeData theme) => Border.fromBorderSide( - _focusRing(theme).copyWith(strokeAlign: BorderSide.strokeAlignOutside), -); - /// A form control's fill: transparent, or `input` at 30% in the dark theme. Color _fieldFill(AcmeThemeData theme) => theme.input.withValues( alpha: theme.input.a * (theme.brightness == Brightness.dark ? 0.3 : 0), @@ -6330,9 +6314,6 @@ BoxDecoration? _flexDecoration(StyleSpec container) => BoxBorder? _flexBorder(StyleSpec container) => _flexDecoration(container)?.border; -BoxBorder? _flexForegroundBorder(StyleSpec container) => - (container.spec.box?.spec.foregroundDecoration as BoxDecoration?)?.border; - /// Asserts [content] stays readable where the recipe actually puts it. /// /// Both colors may be translucent, and the surface may be the page itself, so diff --git a/registry/vanilla/templates/accordion/accordion.dart.tmpl b/registry/vanilla/templates/accordion/accordion.dart.tmpl index cdf07574b..aa9d62597 100644 --- a/registry/vanilla/templates/accordion/accordion.dart.tmpl +++ b/registry/vanilla/templates/accordion/accordion.dart.tmpl @@ -129,12 +129,13 @@ AccordionStyler _openStyle() => AccordionStyler() /// The keyboard focus ring: a 3px band of `ring` at half strength. /// /// An outline rather than a border: `RemixBoxEffects` paints it outside the -/// section without taking layout space, and the section's own border is -/// already carrying the rule between rows. +/// trigger without taking layout space. It is on the trigger, not the section: +/// the section also holds the revealed panel, and a ring there would grow with +/// it. AccordionStyler _focusVisibleStyle() => - AccordionStyler().containerEffects({{valuePrefix}}FocusRing()); + AccordionStyler().triggerEffects({{valuePrefix}}FocusRing()); /// Declared last so it wins over every other state fragment. AccordionStyler _disabledStyle() => AccordionStyler() - .containerEffects(.outline(.style(.none))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)); diff --git a/registry/vanilla/templates/disclosure/disclosure.dart.tmpl b/registry/vanilla/templates/disclosure/disclosure.dart.tmpl index fe4e21cee..64bef1e4e 100644 --- a/registry/vanilla/templates/disclosure/disclosure.dart.tmpl +++ b/registry/vanilla/templates/disclosure/disclosure.dart.tmpl @@ -90,25 +90,18 @@ DisclosureStyler {{valuePrefix}}DisclosureStyle({ /// The keyboard focus ring, drawn on the trigger alone. /// -/// Not on the container, which is where the effects layer lives and where the -/// accordion puts its ring. Keyboard focus is on the trigger, and an expanded -/// disclosure's container is the trigger *plus* everything it revealed — a -/// ring there claims the content is focused too, and grows with it. -/// -/// `foregroundDecoration` is what makes a trigger-only ring possible: it paints -/// over the box's own bounds rather than beside them, so the ring takes no -/// layout space and opening the section never reflows the page. A plain -/// `.border(...)` would push the trigger's content in by two pixels the moment -/// it took focus. The ring is a 3px band of `ring` at half strength, stroked -/// outside the trigger. -DisclosureStyler _focusVisibleStyle() => DisclosureStyler().trigger( - .foregroundDecoration({{valuePrefix}}FocusRingDecoration()), -); +/// Not on the container, whose effects cover the trigger *plus* everything it +/// revealed: a ring there claims the content is focused too, and grows with +/// it. `triggerEffects` is an outline around the trigger, so it takes no +/// layout space and opening the section never reflows the page. The ring is a +/// 3px band of `ring` at half strength. +DisclosureStyler _focusVisibleStyle() => + DisclosureStyler().triggerEffects({{valuePrefix}}FocusRing()); /// Declared last so it wins over every other state fragment. /// /// The ring is cleared as well as faded: a disabled control that still draws /// a focus ring reads as reachable. DisclosureStyler _disabledStyle() => DisclosureStyler() - .trigger(.foregroundDecoration(BoxDecorationMix.border(.style(.none)))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)); diff --git a/registry/vanilla/templates/menu/menu.dart.tmpl b/registry/vanilla/templates/menu/menu.dart.tmpl index 2c410aaf7..f7e0f9fb3 100644 --- a/registry/vanilla/templates/menu/menu.dart.tmpl +++ b/registry/vanilla/templates/menu/menu.dart.tmpl @@ -52,14 +52,12 @@ MenuStyler {{valuePrefix}}MenuStyle({MenuStyler style = const MenuStyler.create( .divider(_dividerStyle()) .merge(style); -/// The control that opens the menu. +/// The standard label trigger of the menu. /// -/// It is deliberately quiet at rest — no fill, no outline — because the -/// trigger usually already wraps a button or an icon button with a recipe of -/// its own, and two competing surfaces would read as a control inside a -/// control. It still answers hover and focus, because a caller is equally -/// free to wrap a bare `Text`, and that caller must not end up with a -/// keyboard-reachable control that shows nothing when it is reached. +/// It is quiet at rest, with no fill and no outline, and still answers hover +/// and focus, so a bare label never ends up keyboard-reachable while showing +/// nothing when it is reached. A real button is the trigger through +/// `RemixMenuTrigger.control`, which owns its own look; none of this styles it. MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() .animate({{typePrefix}}Tokens.motion) .direction(.horizontal) @@ -81,11 +79,8 @@ MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() ) .icon(.size({{typePrefix}}Tokens.icon).color({{typePrefix}}Tokens.foreground())) .onHovered(.color({{typePrefix}}Tokens.accent())) - // A trigger is keyboard-reachable whether or not it wraps a control that - // rings itself, so it rings too. A *foreground* decoration, because - // `MenuTriggerSpec` has no `containerEffects` layer and a real border - // would nudge the label. - .onFocusVisible(.foregroundDecoration({{valuePrefix}}FocusRingDecoration())) + // An outline rather than a border, which would nudge the label. + .onFocusVisible(.containerEffects({{valuePrefix}}FocusRing())) .onDisabled( MenuTriggerStyler().wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)), ); diff --git a/registry/vanilla/templates/tabs/tabs.dart.tmpl b/registry/vanilla/templates/tabs/tabs.dart.tmpl index ceae6d893..ab40192be 100644 --- a/registry/vanilla/templates/tabs/tabs.dart.tmpl +++ b/registry/vanilla/templates/tabs/tabs.dart.tmpl @@ -130,7 +130,7 @@ TabStyler {{valuePrefix}}TabStyle({ .line => _line(), }) .onHovered(_current()) - .onFocusVisible(_focusVisibleStyle(variant)) + .onFocusVisible(_focusVisibleStyle()) .onDisabled(_disabledStyle()) .merge(style); } @@ -166,8 +166,7 @@ const _restingAlpha = 0.6; /// /// The dark fill is blended here rather than painted translucent: the tab's /// shadow is a decoration shadow, which Flutter paints under the whole box, -/// so it would show through a translucent fill and darken it. Tabs have no -/// effects layer to move the shadow to. +/// so it would show through a translucent fill and darken it. final _currentFill = {{valuePrefix}}ByBrightness( light: {{typePrefix}}Tokens.background, dark: _darkCurrentFill, @@ -237,20 +236,11 @@ TabStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `TabSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the tab and -/// takes no layout space. -TabStyler _focusVisibleStyle({{typePrefix}}TabsVariant variant) => - TabStyler().foregroundDecoration( - {{valuePrefix}}FocusRingDecoration( - radius: switch (variant) { - .filled => null, - .line => Radius.zero, - }, - ), - ); +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// tab, following its corners, without taking layout space, so focusing a tab +/// never nudges its label or its neighbors. +TabStyler _focusVisibleStyle() => + TabStyler().containerEffects({{valuePrefix}}FocusRing()); /// Declared last so it wins over every other state fragment. /// @@ -258,5 +248,5 @@ TabStyler _focusVisibleStyle({{typePrefix}}TabsVariant variant) => /// the focus ring is cleared because a disabled tab that still draws a focus /// ring reads as reachable. TabStyler _disabledStyle() => TabStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)); diff --git a/registry/vanilla/templates/theme/effects.dart.tmpl b/registry/vanilla/templates/theme/effects.dart.tmpl index 89eec1f89..cbf99f365 100644 --- a/registry/vanilla/templates/theme/effects.dart.tmpl +++ b/registry/vanilla/templates/theme/effects.dart.tmpl @@ -68,9 +68,13 @@ final _byBrightness = /// /// A 3px band of [color] at [alpha] (or [dark] in a dark theme), drawn /// outside the control with no offset, for a spec's Remix effects slot: -/// `containerEffects`, a switch's `trackEffects`, or a slider's -/// `thumbFocusEffects`. An outline takes no layout space, so focusing a -/// control never reflows the row it sits in. +/// `containerEffects`, an accordion's `triggerEffects`, a switch's +/// `trackEffects`, or a slider's `thumbFocusEffects`. An outline takes no +/// layout space, so focusing a control never reflows the row it sits in. +/// +/// A control clipped by its container, such as a toggle group's option, would +/// have an outside band cut off at the container's edge; pass [inset] to draw +/// it inside the control instead. /// /// A destructive control rings in its own color: `{{valuePrefix}}FocusRing(color: /// {{typePrefix}}Tokens.destructive, alpha: 0.2, dark: 0.4)`. @@ -79,38 +83,13 @@ RemixBoxEffectsMix {{valuePrefix}}FocusRing({ double alpha = _ringAlpha, double? dark, double width = {{typePrefix}}Tokens.strokeRing, + bool inset = false, }) => RemixBoxEffectsMix( outline: BorderSideMix( color: {{valuePrefix}}Tint(color, alpha, dark: dark)(), width: width, ), - outlineOffset: 0, -); - -/// [{{valuePrefix}}FocusRing] as a foreground decoration, for the specs that have no -/// effects slot: a toggle, a toggle group's items, a tab, a menu trigger. -/// -/// The border is stroked outside the box, so the ring sits where the effects -/// outline would and still takes no layout space. [radius] is the control's -/// own corner radius, which the ring follows. -/// -/// A control clipped by its container (a toggle group's option) would have -/// that outside stroke cut off at the container's edge; pass [inset] to -/// stroke the ring inside the control instead. -BoxDecorationMix {{valuePrefix}}FocusRingDecoration({ - Radius? radius, - bool inset = false, -}) => BoxDecorationMix( - border: .all( - BorderSideMix( - color: {{valuePrefix}}Tint({{typePrefix}}Tokens.ring, _ringAlpha)(), - width: {{typePrefix}}Tokens.strokeRing, - strokeAlign: inset - ? BorderSide.strokeAlignInside - : BorderSide.strokeAlignOutside, - ), - ), - borderRadius: .all(radius ?? {{typePrefix}}Tokens.radiusMd()), + outlineOffset: inset ? -width : 0, ); /// A focused control's own border, turned `ring`. diff --git a/registry/vanilla/templates/toggle/toggle.dart.tmpl b/registry/vanilla/templates/toggle/toggle.dart.tmpl index 50a39f871..fc543d079 100644 --- a/registry/vanilla/templates/toggle/toggle.dart.tmpl +++ b/registry/vanilla/templates/toggle/toggle.dart.tmpl @@ -103,8 +103,8 @@ ToggleStyler _variantStyle({{typePrefix}}ToggleVariant variant) => switch (varia .ghost => ToggleStyler().onHovered( _content({{typePrefix}}Tokens.mutedForeground()).color({{typePrefix}}Tokens.muted()), ), - // No shadow: `ToggleSpec` has no effects layer, and a decoration shadow under - // a transparent control shows through it as a gray wash. + // No shadow: a decoration shadow under a transparent control shows through + // it as a gray wash. .outline => ToggleStyler() .border( @@ -123,16 +123,11 @@ ToggleStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `ToggleSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the box, so -/// the ring sits where an outline would and takes no layout space. The -/// outline variant also turns its own border `ring`. +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// toggle without taking layout space, so focusing one never reflows the row +/// it sits in. The outline variant also turns its own border `ring`. ToggleStyler _focusVisibleStyle({{typePrefix}}ToggleVariant variant) { - final ring = ToggleStyler().foregroundDecoration( - {{valuePrefix}}FocusRingDecoration(), - ); + final ring = ToggleStyler().containerEffects({{valuePrefix}}FocusRing()); return switch (variant) { .ghost => ring, @@ -146,5 +141,5 @@ ToggleStyler _focusVisibleStyle({{typePrefix}}ToggleVariant variant) { /// fades; the focus ring is cleared because a disabled control that still /// draws a focus ring reads as actionable. ToggleStyler _disabledStyle() => ToggleStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)); diff --git a/registry/vanilla/templates/toggle_group/toggle_group.dart.tmpl b/registry/vanilla/templates/toggle_group/toggle_group.dart.tmpl index d7feab6f0..e8ef34f45 100644 --- a/registry/vanilla/templates/toggle_group/toggle_group.dart.tmpl +++ b/registry/vanilla/templates/toggle_group/toggle_group.dart.tmpl @@ -130,16 +130,14 @@ ToggleGroupItemStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration, because `ToggleGroupItemSpec` has no -/// `containerEffects` layer to paint an outline into. It is stroked inside -/// the option rather than outside: the strip clips its options, and an -/// outside ring on the first or last option would be cut off at its edge. +/// An outline rather than a border, so focusing an option takes no layout +/// space. It is drawn inside the option rather than outside: the strip clips +/// its options, and an outside ring on the first or last option would be cut +/// off at its edge. ToggleGroupItemStyler _focusVisibleStyle() => - ToggleGroupItemStyler().foregroundDecoration( - {{valuePrefix}}FocusRingDecoration(radius: Radius.zero, inset: true), - ); + ToggleGroupItemStyler().containerEffects({{valuePrefix}}FocusRing(inset: true)); /// Declared last so it wins over every other state fragment. ToggleGroupItemStyler _disabledStyle() => ToggleGroupItemStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity({{typePrefix}}Tokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/components/accordion.dart b/registry_source/lib/src/default/components/accordion.dart index 7f42eb89b..03c1d597a 100644 --- a/registry_source/lib/src/default/components/accordion.dart +++ b/registry_source/lib/src/default/components/accordion.dart @@ -129,12 +129,13 @@ AccordionStyler _openStyle() => AccordionStyler() /// The keyboard focus ring: a 3px band of `ring` at half strength. /// /// An outline rather than a border: `RemixBoxEffects` paints it outside the -/// section without taking layout space, and the section's own border is -/// already carrying the rule between rows. +/// trigger without taking layout space. It is on the trigger, not the section: +/// the section also holds the revealed panel, and a ring there would grow with +/// it. AccordionStyler _focusVisibleStyle() => - AccordionStyler().containerEffects(vanillaFocusRing()); + AccordionStyler().triggerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. AccordionStyler _disabledStyle() => AccordionStyler() - .containerEffects(.outline(.style(.none))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/components/disclosure.dart b/registry_source/lib/src/default/components/disclosure.dart index 1de1153dd..1b460c372 100644 --- a/registry_source/lib/src/default/components/disclosure.dart +++ b/registry_source/lib/src/default/components/disclosure.dart @@ -90,25 +90,18 @@ DisclosureStyler vanillaDisclosureStyle({ /// The keyboard focus ring, drawn on the trigger alone. /// -/// Not on the container, which is where the effects layer lives and where the -/// accordion puts its ring. Keyboard focus is on the trigger, and an expanded -/// disclosure's container is the trigger *plus* everything it revealed — a -/// ring there claims the content is focused too, and grows with it. -/// -/// `foregroundDecoration` is what makes a trigger-only ring possible: it paints -/// over the box's own bounds rather than beside them, so the ring takes no -/// layout space and opening the section never reflows the page. A plain -/// `.border(...)` would push the trigger's content in by two pixels the moment -/// it took focus. The ring is a 3px band of `ring` at half strength, stroked -/// outside the trigger. -DisclosureStyler _focusVisibleStyle() => DisclosureStyler().trigger( - .foregroundDecoration(vanillaFocusRingDecoration()), -); +/// Not on the container, whose effects cover the trigger *plus* everything it +/// revealed: a ring there claims the content is focused too, and grows with +/// it. `triggerEffects` is an outline around the trigger, so it takes no +/// layout space and opening the section never reflows the page. The ring is a +/// 3px band of `ring` at half strength. +DisclosureStyler _focusVisibleStyle() => + DisclosureStyler().triggerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. /// /// The ring is cleared as well as faded: a disabled control that still draws /// a focus ring reads as reachable. DisclosureStyler _disabledStyle() => DisclosureStyler() - .trigger(.foregroundDecoration(BoxDecorationMix.border(.style(.none)))) + .triggerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/components/menu.dart b/registry_source/lib/src/default/components/menu.dart index 734c75958..c6a06b400 100644 --- a/registry_source/lib/src/default/components/menu.dart +++ b/registry_source/lib/src/default/components/menu.dart @@ -52,14 +52,12 @@ MenuStyler vanillaMenuStyle({MenuStyler style = const MenuStyler.create()}) => .divider(_dividerStyle()) .merge(style); -/// The control that opens the menu. +/// The standard label trigger of the menu. /// -/// It is deliberately quiet at rest — no fill, no outline — because the -/// trigger usually already wraps a button or an icon button with a recipe of -/// its own, and two competing surfaces would read as a control inside a -/// control. It still answers hover and focus, because a caller is equally -/// free to wrap a bare `Text`, and that caller must not end up with a -/// keyboard-reachable control that shows nothing when it is reached. +/// It is quiet at rest, with no fill and no outline, and still answers hover +/// and focus, so a bare label never ends up keyboard-reachable while showing +/// nothing when it is reached. A real button is the trigger through +/// `RemixMenuTrigger.control`, which owns its own look; none of this styles it. MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() .animate(VanillaTokens.motion) .direction(.horizontal) @@ -81,11 +79,8 @@ MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() ) .icon(.size(VanillaTokens.icon).color(VanillaTokens.foreground())) .onHovered(.color(VanillaTokens.accent())) - // A trigger is keyboard-reachable whether or not it wraps a control that - // rings itself, so it rings too. A *foreground* decoration, because - // `MenuTriggerSpec` has no `containerEffects` layer and a real border - // would nudge the label. - .onFocusVisible(.foregroundDecoration(vanillaFocusRingDecoration())) + // An outline rather than a border, which would nudge the label. + .onFocusVisible(.containerEffects(vanillaFocusRing())) .onDisabled( MenuTriggerStyler().wrap(.opacity(VanillaTokens.disabledOpacity)), ); diff --git a/registry_source/lib/src/default/components/tabs.dart b/registry_source/lib/src/default/components/tabs.dart index 341ae21d7..32a8f67ed 100644 --- a/registry_source/lib/src/default/components/tabs.dart +++ b/registry_source/lib/src/default/components/tabs.dart @@ -130,7 +130,7 @@ TabStyler vanillaTabStyle({ .line => _line(), }) .onHovered(_current()) - .onFocusVisible(_focusVisibleStyle(variant)) + .onFocusVisible(_focusVisibleStyle()) .onDisabled(_disabledStyle()) .merge(style); } @@ -166,8 +166,7 @@ const _restingAlpha = 0.6; /// /// The dark fill is blended here rather than painted translucent: the tab's /// shadow is a decoration shadow, which Flutter paints under the whole box, -/// so it would show through a translucent fill and darken it. Tabs have no -/// effects layer to move the shadow to. +/// so it would show through a translucent fill and darken it. final _currentFill = vanillaByBrightness( light: VanillaTokens.background, dark: _darkCurrentFill, @@ -237,20 +236,11 @@ TabStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `TabSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the tab and -/// takes no layout space. -TabStyler _focusVisibleStyle(VanillaTabsVariant variant) => - TabStyler().foregroundDecoration( - vanillaFocusRingDecoration( - radius: switch (variant) { - .filled => null, - .line => Radius.zero, - }, - ), - ); +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// tab, following its corners, without taking layout space, so focusing a tab +/// never nudges its label or its neighbors. +TabStyler _focusVisibleStyle() => + TabStyler().containerEffects(vanillaFocusRing()); /// Declared last so it wins over every other state fragment. /// @@ -258,5 +248,5 @@ TabStyler _focusVisibleStyle(VanillaTabsVariant variant) => /// the focus ring is cleared because a disabled tab that still draws a focus /// ring reads as reachable. TabStyler _disabledStyle() => TabStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/components/toggle.dart b/registry_source/lib/src/default/components/toggle.dart index 95dd6693f..26d95d0f7 100644 --- a/registry_source/lib/src/default/components/toggle.dart +++ b/registry_source/lib/src/default/components/toggle.dart @@ -103,8 +103,8 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .ghost => ToggleStyler().onHovered( _content(VanillaTokens.mutedForeground()).color(VanillaTokens.muted()), ), - // No shadow: `ToggleSpec` has no effects layer, and a decoration shadow under - // a transparent control shows through it as a gray wash. + // No shadow: a decoration shadow under a transparent control shows through + // it as a gray wash. .outline => ToggleStyler() .border( @@ -123,16 +123,11 @@ ToggleStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration rather than the box border: `ToggleSpec` has no -/// `containerEffects` layer to paint an outline into, and Flutter insets a -/// container's content by its border widths — so adding a real border on -/// focus would nudge the label. The decoration strokes outside the box, so -/// the ring sits where an outline would and takes no layout space. The -/// outline variant also turns its own border `ring`. +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// toggle without taking layout space, so focusing one never reflows the row +/// it sits in. The outline variant also turns its own border `ring`. ToggleStyler _focusVisibleStyle(VanillaToggleVariant variant) { - final ring = ToggleStyler().foregroundDecoration( - vanillaFocusRingDecoration(), - ); + final ring = ToggleStyler().containerEffects(vanillaFocusRing()); return switch (variant) { .ghost => ring, @@ -146,5 +141,5 @@ ToggleStyler _focusVisibleStyle(VanillaToggleVariant variant) { /// fades; the focus ring is cleared because a disabled control that still /// draws a focus ring reads as actionable. ToggleStyler _disabledStyle() => ToggleStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/components/toggle_group.dart b/registry_source/lib/src/default/components/toggle_group.dart index bd8264ce4..5c6752bbc 100644 --- a/registry_source/lib/src/default/components/toggle_group.dart +++ b/registry_source/lib/src/default/components/toggle_group.dart @@ -130,16 +130,14 @@ ToggleGroupItemStyler _content(Color foreground) => /// The keyboard focus ring: a 3px band of `ring` at half strength. /// -/// A *foreground* decoration, because `ToggleGroupItemSpec` has no -/// `containerEffects` layer to paint an outline into. It is stroked inside -/// the option rather than outside: the strip clips its options, and an -/// outside ring on the first or last option would be cut off at its edge. +/// An outline rather than a border, so focusing an option takes no layout +/// space. It is drawn inside the option rather than outside: the strip clips +/// its options, and an outside ring on the first or last option would be cut +/// off at its edge. ToggleGroupItemStyler _focusVisibleStyle() => - ToggleGroupItemStyler().foregroundDecoration( - vanillaFocusRingDecoration(radius: Radius.zero, inset: true), - ); + ToggleGroupItemStyler().containerEffects(vanillaFocusRing(inset: true)); /// Declared last so it wins over every other state fragment. ToggleGroupItemStyler _disabledStyle() => ToggleGroupItemStyler() - .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .containerEffects(.outline(.style(.none))) .wrap(.opacity(VanillaTokens.disabledOpacity)); diff --git a/registry_source/lib/src/default/theme/effects.dart b/registry_source/lib/src/default/theme/effects.dart index 54d75c16c..38de827d9 100644 --- a/registry_source/lib/src/default/theme/effects.dart +++ b/registry_source/lib/src/default/theme/effects.dart @@ -68,9 +68,13 @@ final _byBrightness = /// /// A 3px band of [color] at [alpha] (or [dark] in a dark theme), drawn /// outside the control with no offset, for a spec's Remix effects slot: -/// `containerEffects`, a switch's `trackEffects`, or a slider's -/// `thumbFocusEffects`. An outline takes no layout space, so focusing a -/// control never reflows the row it sits in. +/// `containerEffects`, an accordion's `triggerEffects`, a switch's +/// `trackEffects`, or a slider's `thumbFocusEffects`. An outline takes no +/// layout space, so focusing a control never reflows the row it sits in. +/// +/// A control clipped by its container, such as a toggle group's option, would +/// have an outside band cut off at the container's edge; pass [inset] to draw +/// it inside the control instead. /// /// A destructive control rings in its own color: `vanillaFocusRing(color: /// VanillaTokens.destructive, alpha: 0.2, dark: 0.4)`. @@ -79,38 +83,13 @@ RemixBoxEffectsMix vanillaFocusRing({ double alpha = _ringAlpha, double? dark, double width = VanillaTokens.strokeRing, + bool inset = false, }) => RemixBoxEffectsMix( outline: BorderSideMix( color: vanillaTint(color, alpha, dark: dark)(), width: width, ), - outlineOffset: 0, -); - -/// [vanillaFocusRing] as a foreground decoration, for the specs that have no -/// effects slot: a toggle, a toggle group's items, a tab, a menu trigger. -/// -/// The border is stroked outside the box, so the ring sits where the effects -/// outline would and still takes no layout space. [radius] is the control's -/// own corner radius, which the ring follows. -/// -/// A control clipped by its container (a toggle group's option) would have -/// that outside stroke cut off at the container's edge; pass [inset] to -/// stroke the ring inside the control instead. -BoxDecorationMix vanillaFocusRingDecoration({ - Radius? radius, - bool inset = false, -}) => BoxDecorationMix( - border: .all( - BorderSideMix( - color: vanillaTint(VanillaTokens.ring, _ringAlpha)(), - width: VanillaTokens.strokeRing, - strokeAlign: inset - ? BorderSide.strokeAlignInside - : BorderSide.strokeAlignOutside, - ), - ), - borderRadius: .all(radius ?? VanillaTokens.radiusMd()), + outlineOffset: inset ? -width : 0, ); /// A focused control's own border, turned `ring`. diff --git a/registry_source/specs/vanilla.md b/registry_source/specs/vanilla.md index e852eaa86..6ba0a1db2 100644 --- a/registry_source/specs/vanilla.md +++ b/registry_source/specs/vanilla.md @@ -191,12 +191,12 @@ the steps above. The source column names the file under | Checkbox, select, and menu 9×9 glyphs | `size-3.5` (14) / `size-4` (16) | 10 (`VanillaTokens.s2_5`) | The reference sizes padded 24×24 Lucide icons; Remix draws edge-to-edge 9×9 paths (`thickCheck`, `chevronDown`, `thickChevronRight`), which match the painted mark at 10. | | Select item horizontal padding | `pl-2 pr-8` (8 / 32) | `px-2` (8) | CSS positions the check mark inside the 32px right gutter (`right-2`); Remix lays the check mark out in the row after the label, so `px-2` places it 8px from the trailing edge. | | Toggle group, outline | per-item borders sharing edges | one border on the clipped group | A recipe cannot tell the first and last items apart. | -| Toggle and toggle group, outline | `shadow-xs` | no shadow | Their specs have no effects layer, and Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that do have an effects layer draw `shadow-xs` there, which cuts the box out as CSS does. | +| Toggle and toggle group, outline | `shadow-xs` | no shadow | Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that draw `shadow-xs` in the effects layer cut the box out as CSS does; the toggle group's strip has no effects layer, and the toggle's outline variant does not use its own for a shadow. | | Chart palette | gray ramp | gray ramp | The reference's lightest series measures about 1.5:1 on the light page, and the darkest two (`chart4`, `chart5`) about 1.3 to 1.8:1 on the dark page and card; Vanilla keeps the palette and picks each pie label from `foreground` or `background` by contrast (`vanillaPieSliceLabelColor`). | | Focus ring | `ring/50`, 3px | `ring` at 50%, 3px | Matches the reference, which is below the 3:1 non-text floor: about 1.5:1 on the light page and 1.85:1 on the dark. The old Vanilla `focusRing` cleared 3:1. Raise the band's alpha in the recipes, or darken `ring`, where focus visibility matters more than matching. | | Text field and text area focus | `focus-visible:` | any focus (`onFocused`) | Browsers treat a focused text input as `:focus-visible` even after a click, but Mix's `onFocusVisible` does not, so the recipe keys the ring on `onFocused` to show it on click as the reference does. Other controls stay on `onFocusVisible`. | | Callout, destructive | description at `destructive/90` | one text color for the whole callout | `CalloutSpec` has a single text role, so the description cannot take its own color. | -| Current filled tab, dark | `bg-input/30` over the list | `input` at 30% pre-blended over `muted`, opaque | Flutter paints the tab's `shadow-sm` under the whole box, so a translucent fill would show it through and darken the tab. Tabs have no effects layer to move the shadow to. | +| Current filled tab, dark | `bg-input/30` over the list | `input` at 30% pre-blended over `muted`, opaque | Flutter paints the tab's `shadow-sm` under the whole box, so a translucent fill would show it through and darken the tab. The shadow stays a decoration shadow rather than moving to the tab's effects layer. | | App bar | no `ui/` primitive | the header the Vanilla dashboard already shipped | shadcn builds page headers inline in its blocks. Vanilla keeps the dashboard's metrics so the `app_bar` item and the dashboard shell look the same, and names the row height `vanillaAppBarToolbarHeight` so a sidebar header can line up with it. | | Sidebar destination padding | `h-8 p-2` | 32 min height, 8px across and 6px down | CSS fixes the height and lets the 20px line overrun the padding; Flutter lays the line out inside it, so `p-2` would make a 36px row. | | Sidebar layout, dashboard shell | 48 collapsed | 48 for `sidebar_layout`; the dashboard shell keeps its own 72 | The dashboard shell is out of this spec's scope. | diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart new file mode 100644 index 000000000..96430c3ce --- /dev/null +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -0,0 +1,323 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/vanilla.dart'; +import 'package:remix/remix.dart'; + +import 'support.dart'; + +/// A part that rings on keyboard focus draws the ring around itself and moves +/// nothing: neither the part nor what sits beside it changes size or position, +/// and the ring is absent until the part is focus-visible. +void main() { + for (final theme in vanillaThemes) { + group(theme.name, () { + testWidgets('a toggle rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => VanillaToggle( + selected: false, + label: 'Bold', + focusNode: node, + onChanged: (_) {}, + ), + ); + + await probe.expectRing( + part: find.byType(VanillaToggle), + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('an outline toggle rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => VanillaToggle.outline( + selected: false, + label: 'Bold', + focusNode: node, + onChanged: (_) {}, + ), + ); + + await probe.expectRing( + part: find.byType(VanillaToggle), + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('a toggle group option rings inside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => VanillaToggleGroup( + selectedValue: 'right', + onChanged: (_) {}, + items: [ + RemixToggleGroupItem( + value: 'left', + label: 'Left', + focusNode: node, + ), + const RemixToggleGroupItem(value: 'right', label: 'Right'), + ], + ), + ); + + await probe.expectRing( + part: find.byType(VanillaToggleGroup), + includes: (part) => [part.centerLeft.translate(1.5, 0)], + excludes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('a tab rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => RemixTabs( + selectedTabId: 'account', + onChanged: (_) {}, + child: VanillaTabBar( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + VanillaTab( + tabId: 'account', + label: 'Account', + focusNode: node, + ), + const VanillaTab(tabId: 'billing', label: 'Billing'), + ], + ), + ), + ), + ); + + await probe.expectRing( + part: find.byType(VanillaTab).first, + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('a line tab rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => RemixTabs( + selectedTabId: 'account', + onChanged: (_) {}, + child: VanillaTabBar( + variant: .line, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + VanillaTab( + variant: .line, + tabId: 'account', + label: 'Account', + focusNode: node, + ), + const VanillaTab( + variant: .line, + tabId: 'billing', + label: 'Billing', + ), + ], + ), + ), + ), + ); + + await probe.expectRing( + part: find.byType(VanillaTab).first, + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('a menu trigger rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => VanillaMenu( + trigger: const RemixMenuTrigger(label: 'Actions'), + triggerFocusNode: node, + items: const [RemixMenuItem(value: 'copy', label: 'Copy')], + ), + ); + + await probe.expectRing( + part: find.byType(VanillaMenu), + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + testWidgets('an accordion rings its trigger, not the opened section', ( + tester, + ) async { + const body = ValueKey('accordion-body'); + final controller = RemixAccordionController(); + addTearDown(controller.dispose); + final probe = await _pump( + tester, + theme.data, + (node) => RemixAccordionGroup( + controller: controller, + initialExpandedValues: const ['shipping'], + child: VanillaAccordion( + value: 'shipping', + title: 'Shipping', + focusNode: node, + child: const SizedBox(key: body, height: 80, width: 200), + ), + ), + ); + + await probe.expectRing( + part: find.byType(VanillaAccordion), + includes: (part) => [ + Offset(part.left - 1.5, tester.getCenter(find.text('Shipping')).dy), + ], + excludes: (part) => [ + Offset(part.left - 1.5, tester.getCenter(find.byKey(body)).dy), + ], + ); + }); + + testWidgets('a disclosure rings its trigger, not the opened section', ( + tester, + ) async { + const body = ValueKey('disclosure-body'); + final probe = await _pump( + tester, + theme.data, + (node) => VanillaDisclosure( + defaultExpanded: true, + focusNode: node, + trigger: const Text('Details'), + content: const SizedBox(key: body, height: 80, width: 200), + ), + ); + + await probe.expectRing( + part: find.byType(VanillaDisclosure), + includes: (part) => [ + Offset(part.left - 1.5, tester.getCenter(find.text('Details')).dy), + ], + excludes: (part) => [ + Offset(part.left - 1.5, tester.getCenter(find.byKey(body)).dy), + ], + ); + }); + }); + } +} + +const _neighbor = ValueKey('neighbor'); + +/// Pumps the widget [build] returns above a [_neighbor], on a keyboard-driven +/// focus highlight, so a focused part is focus-visible. +Future<_Probe> _pump( + WidgetTester tester, + VanillaThemeData theme, + Widget Function(FocusNode focusNode) build, +) async { + final previous = FocusManager.instance.highlightStrategy; + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + addTearDown(() => FocusManager.instance.highlightStrategy = previous); + final focusNode = FocusNode(debugLabel: 'ring-probe'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + VanillaTheme( + theme: theme, + child: hostOf( + Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + build(focusNode), + const SizedBox(key: _neighbor, width: 40, height: 10), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return _Probe(tester, theme, focusNode); +} + +final class _Probe { + const _Probe(this.tester, this.theme, this.focusNode); + + final WidgetTester tester; + final VanillaThemeData theme; + final FocusNode focusNode; + + /// Focuses the part and checks that it rings without moving anything. + /// + /// [includes] and [excludes] are points, in global coordinates, that the + /// ring's band must and must not cover. + Future expectRing({ + required Finder part, + Iterable Function(Rect part) includes = _none, + Iterable Function(Rect part) excludes = _none, + }) async { + final ring = tint(theme.ring, 0.5); + final rect = tester.getRect(part); + final neighbor = tester.getRect(find.byKey(_neighbor)); + expect( + tester.renderObject(part), + isNot(_paintsPath(ring)), + reason: 'a part that is not focused draws no ring', + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(tester.getRect(part), rect, reason: 'the part keeps its box'); + expect( + tester.getRect(find.byKey(_neighbor)), + neighbor, + reason: 'the part does not push what sits beside it', + ); + expect( + tester.renderObject(part), + _paintsPath( + ring, + includes: [for (final point in includes(rect)) point - rect.topLeft], + excludes: [for (final point in excludes(rect)) point - rect.topLeft], + ), + reason: 'a focus-visible part draws the ring', + ); + } +} + +/// Matches a render object that draws a path of [color] covering every +/// [includes] point and none of the [excludes] points. +/// +/// Other paths may be drawn before it, such as a tab's underline. +PaintPattern _paintsPath( + Color color, { + Iterable includes = const [], + Iterable excludes = const [], +}) => paints + ..something((method, arguments) { + if (method != #drawPath) return false; + final path = arguments[0] as Path; + final paint = arguments[1] as Paint; + + return paint.color.toARGB32() == color.toARGB32() && + includes.every(path.contains) && + !excludes.any(path.contains); + }); + +Iterable _none(Rect part) => const []; diff --git a/registry_source/test/vanilla/state_distinguishability_test.dart b/registry_source/test/vanilla/state_distinguishability_test.dart index 754d1ceef..dd40fa70e 100644 --- a/registry_source/test/vanilla/state_distinguishability_test.dart +++ b/registry_source/test/vanilla/state_distinguishability_test.dart @@ -263,6 +263,7 @@ final _cases = <_Case>[ spec.spec.container, label: spec.spec.label.spec.style, icon: spec.spec.icon.spec.color, + effects: spec.spec.containerEffects, ), ), for (final variant in VanillaToggleGroupVariant.values) @@ -278,6 +279,7 @@ final _cases = <_Case>[ spec.spec.item.spec.container, label: spec.spec.item.spec.label.spec.style, icon: spec.spec.item.spec.icon.spec.color, + effects: spec.spec.item.spec.containerEffects, ), ), _resolved( @@ -294,6 +296,7 @@ final _cases = <_Case>[ spec.spec.container, label: spec.spec.label.spec.style, icon: spec.spec.icon.spec.color, + effects: spec.spec.containerEffects, ), ), _resolved( @@ -412,7 +415,7 @@ final _cases = <_Case>[ ], read: (spec) => [ ..._flexLook(spec.spec.trigger, label: spec.spec.title.spec.style), - spec.spec.containerEffects?.outline, + spec.spec.triggerEffects?.outline, spec.spec.trailingIcon.spec.color, ], ), @@ -436,7 +439,7 @@ final _cases = <_Case>[ ); return [ - ..._boxLook(spec.spec.trigger, effects: spec.spec.containerEffects), + ..._boxLook(spec.spec.trigger, effects: spec.spec.triggerEffects), spec.spec.trigger.widgetModifiers, ]; }, @@ -454,6 +457,7 @@ final _cases = <_Case>[ spec.spec.destination.spec.container, label: spec.spec.destination.spec.label.spec.style, icon: spec.spec.destination.spec.icon.spec.color, + effects: spec.spec.destination.spec.containerEffects, ), ), _resolved( diff --git a/registry_source/test/vanilla/support.dart b/registry_source/test/vanilla/support.dart index 6f79b86c4..2280b6948 100644 --- a/registry_source/test/vanilla/support.dart +++ b/registry_source/test/vanilla/support.dart @@ -130,10 +130,6 @@ BoxDecoration? decorationOf(StyleSpec box) => BoxDecoration? flexDecorationOf(StyleSpec box) => box.spec.box?.spec.decoration as BoxDecoration?; -/// The foreground decoration of a flex box. -BoxDecoration? flexForegroundOf(StyleSpec box) => - box.spec.box?.spec.foregroundDecoration as BoxDecoration?; - /// The box half of a flex box. BoxSpec? boxOf(StyleSpec box) => box.spec.box?.spec; diff --git a/registry_source/test/vanilla/vanilla_spec.dart b/registry_source/test/vanilla/vanilla_spec.dart index df23b0c3f..82b69151a 100644 --- a/registry_source/test/vanilla/vanilla_spec.dart +++ b/registry_source/test/vanilla/vanilla_spec.dart @@ -592,8 +592,8 @@ final _toggleTargets = [ read: (spec) => flexDecorationOf(spec.spec.container)?.border, expected: (theme) => _hairline(theme.input), ), - // Deviation: no `shadow-xs`. `ToggleSpec` has no effects layer, and a - // decoration shadow under a transparent fill reads as a gray wash. + // Deviation: no `shadow-xs`. A decoration shadow under a transparent fill + // reads as a gray wash. _toggle( 'outline has no decoration shadow', variant: .outline, @@ -616,15 +616,11 @@ final _toggleTargets = [ 'focus ring', states: const {WidgetState.focused}, source: 'toggle.tsx: focus-visible:ring-[3px] ring-ring/50', - read: (spec) { - final border = flexForegroundOf(spec.spec.container)?.border; - return border is Border ? border.top : null; - }, - expected: (theme) => BorderSide( - color: tint(theme.ring, 0.5), - width: 3, - strokeAlign: BorderSide.strokeAlignOutside, + read: (spec) => ( + spec.spec.containerEffects?.outline, + spec.spec.containerEffects?.outlineOffset, ), + expected: (theme) => (_focusRing(theme), 0.0), ), _toggle( 'transition', @@ -683,6 +679,18 @@ final _toggleGroupTargets = [ read: (spec) => boxOf(spec.spec.item.spec.container)?.padding, expected: (_) => const EdgeInsets.symmetric(horizontal: 12), ), + _toggleGroup( + 'item focus ring sits inside the item', + states: const {WidgetState.focused}, + source: + 'toggle-group.tsx: focus-visible:ring-[3px] ring-ring/50, drawn ' + 'inside because the group clips', + read: (spec) => ( + spec.spec.item.spec.containerEffects?.outline, + spec.spec.item.spec.containerEffects?.outlineOffset, + ), + expected: (theme) => (_focusRing(theme), -3.0), + ), _toggleGroup( 'outline border sits on the group', variant: .outline, @@ -1307,6 +1315,18 @@ final _tabsTargets = [ read: (spec) => flexDecorationOf(spec.spec.container)?.color, expected: (_) => anyOf(isNull, const Color(0x00000000)), ), + _target( + 'tabs', + 'focus ring', + source: 'tabs.tsx: focus-visible:ring-[3px] ring-ring/50', + style: vanillaTabStyle, + states: const {WidgetState.focused}, + read: (spec) => ( + spec.spec.containerEffects?.outline, + spec.spec.containerEffects?.outlineOffset, + ), + expected: (theme) => (_focusRing(theme), 0.0), + ), _target( 'tabs', 'line underline', @@ -1514,6 +1534,7 @@ final _sidebarTargets = [ SpecTarget _menu( String property, { + Set states = const {}, required String source, required Object? Function(StyleSpec spec) read, required Object? Function(VanillaThemeData theme) expected, @@ -1522,6 +1543,7 @@ SpecTarget _menu( property, source: source, style: vanillaMenuStyle, + states: states, read: read, expected: expected, ); @@ -1553,6 +1575,17 @@ final _menuTargets = [ _effectShadows(VanillaShadow.md), ), ), + _menu( + 'trigger focus ring', + states: const {WidgetState.focused}, + source: + 'dropdown-menu.tsx: Trigger asChild Button, focus-visible:ring-[3px]', + read: (spec) { + final effects = spec.spec.trigger.spec.containerEffects; + return (effects?.outline, effects?.outlineOffset); + }, + expected: (theme) => (_focusRing(theme), 0.0), + ), _menu( 'item', source: 'dropdown-menu.tsx: DropdownMenuItem px-2 py-1.5 rounded-sm', @@ -1606,6 +1639,19 @@ final _accordionTargets = [ read: (spec) => spec.spec.title.spec.style?.decoration, expected: (_) => TextDecoration.underline, ), + _target( + 'accordion', + 'focus ring is on the trigger, not the section', + source: 'accordion.tsx: AccordionTrigger focus-visible:ring-[3px]', + style: vanillaAccordionStyle, + states: const {WidgetState.focused}, + read: (spec) => ( + spec.spec.triggerEffects?.outline, + spec.spec.triggerEffects?.outlineOffset, + spec.spec.containerEffects, + ), + expected: (theme) => (_focusRing(theme), 0.0, isNull), + ), _target( 'accordion', 'content', @@ -1635,6 +1681,32 @@ final _accordionTargets = [ }, expected: (_) => const EdgeInsets.symmetric(vertical: 16), ), + SpecTarget._( + component: 'disclosure', + property: 'focus ring is on the trigger, not the section', + source: 'accordion.tsx: AccordionTrigger focus-visible:ring-[3px]', + actual: (tester, theme) async { + final spec = await interactedSpec( + tester, + (focusNode) => SizedBox( + width: 320, + child: VanillaDisclosure( + trigger: const Text('Details'), + content: const Text('Body'), + focusNode: focusNode, + ), + ), + theme: theme, + focused: true, + ); + return ( + spec.spec.triggerEffects?.outline, + spec.spec.triggerEffects?.outlineOffset, + spec.spec.containerEffects, + ); + }, + expected: (theme) => (_focusRing(theme), 0.0, isNull), + ), ]; // -- surfaces ----------------------------------------------------------------- From 88b43a9939306876261d042fa8addc46642cb3a0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 08:22:02 -0400 Subject: [PATCH 02/17] feat(registry): style the label of the Vanilla radio and switch RemixRadio and RemixSwitch take a label drawn inside the control's own tap target, focus ring, and accessible name. The Vanilla recipes now set it as a form label like the checkbox does, textSm at medium weight in foreground, 12px from a radio and 8px from a switch, so callers no longer compose a Row of the control and a Text. The fixture gallery and the Vanilla widgetbook radio and switch cases pass label instead. --- apps/playground/lib/ui/components/radio.dart | 15 +++++++--- .../playground/lib/ui/components/radio.g.dart | 9 +++--- apps/playground/lib/ui/components/switch.dart | 11 +++++-- .../lib/ui/components/switch.g.dart | 5 ++-- .../lib/components/radio.dart | 18 ++--------- .../lib/components/switch.dart | 2 +- .../lib/ui/components/radio.dart | 15 +++++++--- .../lib/ui/components/radio.g.dart | 9 +++--- .../lib/ui/components/switch.dart | 11 +++++-- .../lib/ui/components/switch.g.dart | 5 ++-- open_code/fixture/lib/main.dart | 18 +++-------- open_code/fixture/test/open_code_test.dart | 30 +++++++++++++++++-- .../vanilla/templates/radio/radio.dart.tmpl | 15 +++++++--- .../vanilla/templates/switch/switch.dart.tmpl | 11 +++++-- .../lib/src/default/components/radio.dart | 15 +++++++--- .../lib/src/default/components/radio.g.dart | 9 +++--- .../lib/src/default/components/switch.dart | 11 +++++-- .../lib/src/default/components/switch.g.dart | 5 ++-- registry_source/specs/vanilla.md | 4 +-- .../test/vanilla/vanilla_spec.dart | 22 ++++++++++++++ 20 files changed, 162 insertions(+), 78 deletions(-) diff --git a/apps/playground/lib/ui/components/radio.dart b/apps/playground/lib/ui/components/radio.dart index fd402e100..ddcc7bec2 100644 --- a/apps/playground/lib/ui/components/radio.dart +++ b/apps/playground/lib/ui/components/radio.dart @@ -22,8 +22,8 @@ part 'radio.g.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// PlaygroundRadio(value: 'free', semanticLabel: 'Free'), -/// PlaygroundRadio(value: 'pro', semanticLabel: 'Pro'), +/// PlaygroundRadio(value: 'free', label: 'Free'), +/// PlaygroundRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -36,8 +36,9 @@ part 'radio.g.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. @MixWidget(target: RemixRadio.new) RadioStyler playgroundRadioStyle({ RadioStyler style = const RadioStyler.create(), @@ -55,6 +56,12 @@ RadioStyler playgroundRadioStyle({ // transparent, and a decoration shadow would show through it. .containerEffects(.behindContent(PlaygroundShadow.xs.effects)) .indicator(BoxStyler().size(_dot, _dot).borderRadius(.all(_circular))) + .labelSpacing(PlaygroundTokens.s3) + .label( + .style( + PlaygroundTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(PlaygroundTokens.foreground()), + ) .onFocusVisible(_focusVisibleStyle()) .onSelected(RadioStyler().indicatorColor(PlaygroundTokens.primary())) .onDisabled(_disabledStyle()) diff --git a/apps/playground/lib/ui/components/radio.g.dart b/apps/playground/lib/ui/components/radio.g.dart index f4b376014..6b32beb2a 100644 --- a/apps/playground/lib/ui/components/radio.g.dart +++ b/apps/playground/lib/ui/components/radio.g.dart @@ -21,8 +21,8 @@ part of 'radio.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// PlaygroundRadio(value: 'free', semanticLabel: 'Free'), -/// PlaygroundRadio(value: 'pro', semanticLabel: 'Pro'), +/// PlaygroundRadio(value: 'free', label: 'Free'), +/// PlaygroundRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -35,8 +35,9 @@ part of 'radio.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. class PlaygroundRadio extends StatelessWidget { const PlaygroundRadio({ super.key, diff --git a/apps/playground/lib/ui/components/switch.dart b/apps/playground/lib/ui/components/switch.dart index 62cef4eb7..176bca73b 100644 --- a/apps/playground/lib/ui/components/switch.dart +++ b/apps/playground/lib/ui/components/switch.dart @@ -14,8 +14,9 @@ part 'switch.g.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. @MixWidget(target: RemixSwitch.new) SwitchStyler playgroundSwitchStyle({ SwitchStyler style = const SwitchStyler.create(), @@ -39,6 +40,12 @@ SwitchStyler playgroundSwitchStyle({ .borderRadius(.all(_pill)) .color(_offThumb()), ) + .labelSpacing(PlaygroundTokens.s2) + .label( + .style( + PlaygroundTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(PlaygroundTokens.foreground()), + ) .onSelected( SwitchStyler() .trackColor(PlaygroundTokens.primary()) diff --git a/apps/playground/lib/ui/components/switch.g.dart b/apps/playground/lib/ui/components/switch.g.dart index e68f53a80..451d13bba 100644 --- a/apps/playground/lib/ui/components/switch.g.dart +++ b/apps/playground/lib/ui/components/switch.g.dart @@ -13,8 +13,9 @@ part of 'switch.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. class PlaygroundSwitch extends StatelessWidget { const PlaygroundSwitch({ super.key, diff --git a/apps/widgetbook_vanilla/lib/components/radio.dart b/apps/widgetbook_vanilla/lib/components/radio.dart index 5cfb33055..1fad3ddbd 100644 --- a/apps/widgetbook_vanilla/lib/components/radio.dart +++ b/apps/widgetbook_vanilla/lib/components/radio.dart @@ -17,22 +17,8 @@ Widget buildVanillaRadioUseCase(BuildContext context) { crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - VanillaRadio(value: 'free', semanticLabel: 'Free'), - Text('Free'), - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - VanillaRadio(value: 'pro', semanticLabel: 'Pro'), - Text('Pro'), - ], - ), + VanillaRadio(value: 'free', label: 'Free'), + VanillaRadio(value: 'pro', label: 'Pro'), ], ), ), diff --git a/apps/widgetbook_vanilla/lib/components/switch.dart b/apps/widgetbook_vanilla/lib/components/switch.dart index d3e6c05d9..232156bbf 100644 --- a/apps/widgetbook_vanilla/lib/components/switch.dart +++ b/apps/widgetbook_vanilla/lib/components/switch.dart @@ -10,7 +10,7 @@ Widget buildVanillaSwitchUseCase(BuildContext context) { child: StatefulBuilder( builder: (context, setState) => VanillaSwitch( selected: v, - semanticLabel: 'Notifications', + label: 'Notifications', onChanged: (value) => setState(() => v = value), ), ), diff --git a/apps/widgetbook_vanilla/lib/ui/components/radio.dart b/apps/widgetbook_vanilla/lib/ui/components/radio.dart index bc7e3df76..7e59d105e 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/radio.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/radio.dart @@ -22,8 +22,8 @@ part 'radio.g.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// VanillaRadio(value: 'free', semanticLabel: 'Free'), -/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// VanillaRadio(value: 'free', label: 'Free'), +/// VanillaRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -36,8 +36,9 @@ part 'radio.g.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. @MixWidget(target: RemixRadio.new) RadioStyler vanillaRadioStyle({ RadioStyler style = const RadioStyler.create(), @@ -53,6 +54,12 @@ RadioStyler vanillaRadioStyle({ // transparent, and a decoration shadow would show through it. .containerEffects(.behindContent(VanillaShadow.xs.effects)) .indicator(BoxStyler().size(_dot, _dot).borderRadius(.all(_circular))) + .labelSpacing(VanillaTokens.s3) + .label( + .style( + VanillaTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) .onFocusVisible(_focusVisibleStyle()) .onSelected(RadioStyler().indicatorColor(VanillaTokens.primary())) .onDisabled(_disabledStyle()) diff --git a/apps/widgetbook_vanilla/lib/ui/components/radio.g.dart b/apps/widgetbook_vanilla/lib/ui/components/radio.g.dart index 07df0e45c..bb6b8e3ba 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/radio.g.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/radio.g.dart @@ -21,8 +21,8 @@ part of 'radio.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// VanillaRadio(value: 'free', semanticLabel: 'Free'), -/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// VanillaRadio(value: 'free', label: 'Free'), +/// VanillaRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -35,8 +35,9 @@ part of 'radio.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. class VanillaRadio extends StatelessWidget { const VanillaRadio({ super.key, diff --git a/apps/widgetbook_vanilla/lib/ui/components/switch.dart b/apps/widgetbook_vanilla/lib/ui/components/switch.dart index a33718943..b4e506363 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/switch.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/switch.dart @@ -14,8 +14,9 @@ part 'switch.g.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. @MixWidget(target: RemixSwitch.new) SwitchStyler vanillaSwitchStyle({ SwitchStyler style = const SwitchStyler.create(), @@ -37,6 +38,12 @@ SwitchStyler vanillaSwitchStyle({ .borderRadius(.all(_pill)) .color(_offThumb()), ) + .labelSpacing(VanillaTokens.s2) + .label( + .style( + VanillaTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) .onSelected( SwitchStyler() .trackColor(VanillaTokens.primary()) diff --git a/apps/widgetbook_vanilla/lib/ui/components/switch.g.dart b/apps/widgetbook_vanilla/lib/ui/components/switch.g.dart index 473768959..a57f3554d 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/switch.g.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/switch.g.dart @@ -13,8 +13,9 @@ part of 'switch.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. class VanillaSwitch extends StatelessWidget { const VanillaSwitch({ super.key, diff --git a/open_code/fixture/lib/main.dart b/open_code/fixture/lib/main.dart index af78b68a3..f02f8737c 100644 --- a/open_code/fixture/lib/main.dart +++ b/open_code/fixture/lib/main.dart @@ -661,7 +661,7 @@ class _AcmeThemeSectionState extends State { children: [ AcmeSwitch( selected: _notifications, - semanticLabel: 'Notifications', + label: 'Notifications', onChanged: (value) => setState(() { _notifications = value; _lastAction = 'switch -> $value'; @@ -670,7 +670,7 @@ class _AcmeThemeSectionState extends State { const AcmeSwitch( selected: true, enabled: false, - semanticLabel: 'Disabled switch', + label: 'Disabled switch', ), ], ), @@ -690,20 +690,10 @@ class _AcmeThemeSectionState extends State { crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final plan in const ['free', 'pro']) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - AcmeRadio(value: plan, semanticLabel: plan), - const SizedBox(width: 8), - Text( - plan, - style: TextStyle(color: data.foreground), - ), - ], - ), + AcmeRadio(value: plan, label: plan), const AcmeRadio( value: 'enterprise', - semanticLabel: 'Enterprise', + label: 'Enterprise', enabled: false, ), ], diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index 6627202d5..2df702fa4 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -3692,6 +3692,30 @@ void main() { ); handle.dispose(); }); + + testWidgets('the label is part of the switch and names it', (tester) async { + final handle = tester.ensureSemantics(); + final changes = []; + + await _pumpInScope( + tester, + AcmeSwitch( + selected: false, + label: 'Notifications', + onChanged: changes.add, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + + expect(changes, [true]); + expect( + tester.getSemantics(find.bySemanticsLabel('Notifications')), + isSemantics(hasEnabledState: true, isEnabled: true, isToggled: true), + ); + handle.dispose(); + }); }); group('acmeRadioStyle', () { @@ -3766,8 +3790,8 @@ void main() { child: const Column( mainAxisSize: MainAxisSize.min, children: [ - AcmeRadio(value: 'free', semanticLabel: 'Free'), - AcmeRadio(value: 'pro', semanticLabel: 'Pro'), + AcmeRadio(value: 'free', label: 'Free'), + AcmeRadio(value: 'pro', label: 'Pro'), ], ), ), @@ -3777,7 +3801,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.bySemanticsLabel('Pro')); + await tester.tap(find.text('Pro')); await tester.pumpAndSettle(); expect(chosen, 'pro'); diff --git a/registry/vanilla/templates/radio/radio.dart.tmpl b/registry/vanilla/templates/radio/radio.dart.tmpl index bc435cb21..a3bbd237d 100644 --- a/registry/vanilla/templates/radio/radio.dart.tmpl +++ b/registry/vanilla/templates/radio/radio.dart.tmpl @@ -22,8 +22,8 @@ part 'radio.g.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// {{typePrefix}}Radio(value: 'free', semanticLabel: 'Free'), -/// {{typePrefix}}Radio(value: 'pro', semanticLabel: 'Pro'), +/// {{typePrefix}}Radio(value: 'free', label: 'Free'), +/// {{typePrefix}}Radio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -36,8 +36,9 @@ part 'radio.g.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. @MixWidget(target: RemixRadio.new) RadioStyler {{valuePrefix}}RadioStyle({ RadioStyler style = const RadioStyler.create(), @@ -53,6 +54,12 @@ RadioStyler {{valuePrefix}}RadioStyle({ // transparent, and a decoration shadow would show through it. .containerEffects(.behindContent({{typePrefix}}Shadow.xs.effects)) .indicator(BoxStyler().size(_dot, _dot).borderRadius(.all(_circular))) + .labelSpacing({{typePrefix}}Tokens.s3) + .label( + .style( + {{typePrefix}}Tokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color({{typePrefix}}Tokens.foreground()), + ) .onFocusVisible(_focusVisibleStyle()) .onSelected(RadioStyler().indicatorColor({{typePrefix}}Tokens.primary())) .onDisabled(_disabledStyle()) diff --git a/registry/vanilla/templates/switch/switch.dart.tmpl b/registry/vanilla/templates/switch/switch.dart.tmpl index 91a0c92c9..8c1597243 100644 --- a/registry/vanilla/templates/switch/switch.dart.tmpl +++ b/registry/vanilla/templates/switch/switch.dart.tmpl @@ -14,8 +14,9 @@ part 'switch.g.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. @MixWidget(target: RemixSwitch.new) SwitchStyler {{valuePrefix}}SwitchStyle({ SwitchStyler style = const SwitchStyler.create(), @@ -37,6 +38,12 @@ SwitchStyler {{valuePrefix}}SwitchStyle({ .borderRadius(.all(_pill)) .color(_offThumb()), ) + .labelSpacing({{typePrefix}}Tokens.s2) + .label( + .style( + {{typePrefix}}Tokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color({{typePrefix}}Tokens.foreground()), + ) .onSelected( SwitchStyler() .trackColor({{typePrefix}}Tokens.primary()) diff --git a/registry_source/lib/src/default/components/radio.dart b/registry_source/lib/src/default/components/radio.dart index bc7e3df76..7e59d105e 100644 --- a/registry_source/lib/src/default/components/radio.dart +++ b/registry_source/lib/src/default/components/radio.dart @@ -22,8 +22,8 @@ part 'radio.g.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// VanillaRadio(value: 'free', semanticLabel: 'Free'), -/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// VanillaRadio(value: 'free', label: 'Free'), +/// VanillaRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -36,8 +36,9 @@ part 'radio.g.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. @MixWidget(target: RemixRadio.new) RadioStyler vanillaRadioStyle({ RadioStyler style = const RadioStyler.create(), @@ -53,6 +54,12 @@ RadioStyler vanillaRadioStyle({ // transparent, and a decoration shadow would show through it. .containerEffects(.behindContent(VanillaShadow.xs.effects)) .indicator(BoxStyler().size(_dot, _dot).borderRadius(.all(_circular))) + .labelSpacing(VanillaTokens.s3) + .label( + .style( + VanillaTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) .onFocusVisible(_focusVisibleStyle()) .onSelected(RadioStyler().indicatorColor(VanillaTokens.primary())) .onDisabled(_disabledStyle()) diff --git a/registry_source/lib/src/default/components/radio.g.dart b/registry_source/lib/src/default/components/radio.g.dart index 07df0e45c..bb6b8e3ba 100644 --- a/registry_source/lib/src/default/components/radio.g.dart +++ b/registry_source/lib/src/default/components/radio.g.dart @@ -21,8 +21,8 @@ part of 'radio.dart'; /// groupValue: plan, /// onChanged: (value) => setState(() => plan = value), /// child: Column(children: const [ -/// VanillaRadio(value: 'free', semanticLabel: 'Free'), -/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// VanillaRadio(value: 'free', label: 'Free'), +/// VanillaRadio(value: 'pro', label: 'Pro'), /// ]), /// ) /// ``` @@ -35,8 +35,9 @@ part of 'radio.dart'; /// There is no hover fragment: choosing an option is the feedback, and the /// pointer cursor Remix sets says it can be chosen. /// -/// `RemixRadio` requires a `semanticLabel` because it renders no text of its -/// own — the visible label beside it belongs to the caller's layout. +/// A radio takes a `label`, drawn beside the circle inside the radio's own tap, +/// focus, and semantics target, and set like the checkbox's: `textSm` at medium +/// weight, 12px from the circle. A radio without one needs a `semanticLabel`. class VanillaRadio extends StatelessWidget { const VanillaRadio({ super.key, diff --git a/registry_source/lib/src/default/components/switch.dart b/registry_source/lib/src/default/components/switch.dart index a33718943..b4e506363 100644 --- a/registry_source/lib/src/default/components/switch.dart +++ b/registry_source/lib/src/default/components/switch.dart @@ -14,8 +14,9 @@ part 'switch.g.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. @MixWidget(target: RemixSwitch.new) SwitchStyler vanillaSwitchStyle({ SwitchStyler style = const SwitchStyler.create(), @@ -37,6 +38,12 @@ SwitchStyler vanillaSwitchStyle({ .borderRadius(.all(_pill)) .color(_offThumb()), ) + .labelSpacing(VanillaTokens.s2) + .label( + .style( + VanillaTokens.textSm.mix(), + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) .onSelected( SwitchStyler() .trackColor(VanillaTokens.primary()) diff --git a/registry_source/lib/src/default/components/switch.g.dart b/registry_source/lib/src/default/components/switch.g.dart index 473768959..a57f3554d 100644 --- a/registry_source/lib/src/default/components/switch.g.dart +++ b/registry_source/lib/src/default/components/switch.g.dart @@ -13,8 +13,9 @@ part of 'switch.dart'; /// leading edge when off and the trailing edge when on. This recipe supplies /// only the two boxes' geometry and their colors. /// -/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible -/// text of its own. That is a Remix rule, not a recipe choice. +/// A switch takes a `label`, drawn beside the track inside the switch's own +/// tap, focus, and semantics target, as a form label: `textSm` at medium +/// weight, 8px from the track. A switch without one needs a `semanticLabel`. class VanillaSwitch extends StatelessWidget { const VanillaSwitch({ super.key, diff --git a/registry_source/specs/vanilla.md b/registry_source/specs/vanilla.md index 6ba0a1db2..5747c7629 100644 --- a/registry_source/specs/vanilla.md +++ b/registry_source/specs/vanilla.md @@ -152,8 +152,8 @@ the steps above. The source column names the file under | Select content | `popover` fill, border, radius md, `shadow-md`, p 4, min-w 128 | `select.tsx` | | Select item | h 32; px 8, py 6 (see Deviations); radius sm; highlight accent; check 10 muted-fg | `select.tsx` | | Checkbox | 16, radius 4, border `input`, `shadow-xs`; checked primary; check 10 (see Deviations); no hover fill; label `text-sm` w500, gap 12 | `checkbox.tsx`, `label.tsx` | -| Radio | 16, border `input` in both states, `shadow-xs`; dot 8 primary | `radio-group.tsx` | -| Switch | 32 × 18.4, transparent 1px border, `shadow-xs`; off `input` (dark input/80), on primary; thumb 16, no border | `switch.tsx` | +| Radio | 16, border `input` in both states, `shadow-xs`; dot 8 primary; label `text-sm` w500, gap 12 | `radio-group.tsx`, `label.tsx` | +| Switch | 32 × 18.4, transparent 1px border, `shadow-xs`; off `input` (dark input/80), on primary; thumb 16, no border; label `text-sm` w500, gap 8 | `switch.tsx`, `label.tsx` | | Slider | rail 6 muted, range primary; thumb 16, 1px primary border, white, `shadow-sm`; 4px ring/50 on hover and focus | `slider.tsx` | | Tabs (filled) | list h 36, p 3, radius lg, muted; tab radius md, px 8, py 4, gap 6; inactive foreground/60; active background + `shadow-sm` | `tabs.tsx` | | Tabs (line) | transparent list; active tab marked by a 2px `foreground` underline | `tabs.tsx` (`variant=line`) | diff --git a/registry_source/test/vanilla/vanilla_spec.dart b/registry_source/test/vanilla/vanilla_spec.dart index 82b69151a..1a92150b6 100644 --- a/registry_source/test/vanilla/vanilla_spec.dart +++ b/registry_source/test/vanilla/vanilla_spec.dart @@ -1074,6 +1074,17 @@ final _radioTargets = [ read: (spec) => decorationOf(spec.spec.container)?.border, expected: (theme) => _hairline(theme.input), ), + _radio( + 'label', + source: 'label.tsx: text-sm font-medium; radio-group demo: gap-3', + read: (spec) => ( + spec.spec.label.spec.style?.fontSize, + spec.spec.label.spec.style?.fontWeight, + spec.spec.label.spec.style?.color, + spec.spec.labelSpacing, + ), + expected: (theme) => (14.0, FontWeight.w500, theme.foreground, 12.0), + ), _radio( 'dot', states: const {WidgetState.selected}, @@ -1123,6 +1134,17 @@ final _switchTargets = [ _effectShadows(VanillaShadow.xs), ), ), + _switch( + 'label', + source: 'label.tsx: text-sm font-medium; switch demo: space-x-2', + read: (spec) => ( + spec.spec.label.spec.style?.fontSize, + spec.spec.label.spec.style?.fontWeight, + spec.spec.label.spec.style?.color, + spec.spec.labelSpacing, + ), + expected: (theme) => (14.0, FontWeight.w500, theme.foreground, 8.0), + ), _switch( 'off track', source: 'switch.tsx: data-[state=unchecked]:bg-input dark:bg-input/80', From a1a07c2f31d4df2a81cc7f5ebb3d7fe2800acf4f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 08:22:02 -0400 Subject: [PATCH 03/17] feat(registry): open the Vanilla order actions menu from a real icon button The order table kebab was a Semantics-wrapped Box passed as a visual RemixMenuTrigger.builder, so it had no hover, focus ring, or disabled state of its own. It is now a ghost VanillaIconButton given to RemixMenuTrigger.control with the menu handle's focusNode and toggle, keeping the muted glyph. The menu still owns the overlay, Escape, and focus return, and merges its expanded state onto the button, which stays the one focus stop. --- .../dashboard_demo_records_page.dart | 27 +++---- .../dashboard_demo_records_page.dart.tmpl | 27 +++---- .../dashboard/dashboard_demo_records.dart | 27 +++---- .../test/dashboard/dashboard_demo_test.dart | 71 +++++++++++++++++++ 4 files changed, 99 insertions(+), 53 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart index bf791d398..9707d7e41 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart @@ -6,6 +6,7 @@ import '../../components/avatar.dart'; import '../../components/badge.dart'; import '../../components/button.dart'; import '../../components/data_table.dart'; +import '../../components/icon_button.dart'; import '../../components/menu.dart'; import '../../components/segmented_control.dart'; import '../../components/textfield.dart'; @@ -377,23 +378,15 @@ List> _orderColumns( ]; Widget _actions(BuildContext context, String value) => PlaygroundMenu( - trigger: RemixMenuTrigger.builder( - label: 'Actions for $value', - icon: RemixIcons.dotsHorizontal, - builder: (_, _, _) => Semantics( - label: 'Actions for $value', - button: true, - child: Box( - style: BoxStyler() - .size(28, 28) - .alignment(.center) - .borderRadius(.all(PlaygroundTokens.radiusSm())), - child: StyledIcon( - icon: RemixIcons.dotsHorizontal, - style: IconStyler() - .size(PlaygroundTokens.icon) - .color(PlaygroundTokens.mutedForeground()), - ), + trigger: RemixMenuTrigger.control( + builder: (_, menu) => PlaygroundIconButton.ghost( + size: .small, + icon: RemixIcons.dotsHorizontal, + semanticLabel: 'Actions for $value', + focusNode: menu.focusNode, + onPressed: menu.toggle, + style: IconButtonStyler().icon( + .color(PlaygroundTokens.mutedForeground()), ), ), ), diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl index 8cf759b08..2c0e73c7d 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl @@ -6,6 +6,7 @@ import '../../components/avatar.dart'; import '../../components/badge.dart'; import '../../components/button.dart'; import '../../components/data_table.dart'; +import '../../components/icon_button.dart'; import '../../components/menu.dart'; import '../../components/segmented_control.dart'; import '../../components/textfield.dart'; @@ -374,24 +375,14 @@ List> _orderColumns( ]; Widget _actions(BuildContext context, String value) => {{typePrefix}}Menu( - trigger: RemixMenuTrigger.builder( - label: 'Actions for $value', - icon: {{icon:dotsHorizontal}}, - builder: (_, _, _) => Semantics( - label: 'Actions for $value', - button: true, - child: Box( - style: BoxStyler() - .size(28, 28) - .alignment(.center) - .borderRadius(.all({{typePrefix}}Tokens.radiusSm())), - child: StyledIcon( - icon: {{icon:dotsHorizontal}}, - style: IconStyler() - .size({{typePrefix}}Tokens.icon) - .color({{typePrefix}}Tokens.mutedForeground()), - ), - ), + trigger: RemixMenuTrigger.control( + builder: (_, menu) => {{typePrefix}}IconButton.ghost( + size: .small, + icon: {{icon:dotsHorizontal}}, + semanticLabel: 'Actions for $value', + focusNode: menu.focusNode, + onPressed: menu.toggle, + style: IconButtonStyler().icon(.color({{typePrefix}}Tokens.mutedForeground())), ), ), items: const [ diff --git a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart index ceb628d65..34ef78978 100644 --- a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart +++ b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart @@ -7,6 +7,7 @@ import '../../components/avatar.dart'; import '../../components/badge.dart'; import '../../components/button.dart'; import '../../components/data_table.dart'; +import '../../components/icon_button.dart'; import '../../components/menu.dart'; import '../../components/segmented_control.dart'; import '../../components/textfield.dart'; @@ -374,24 +375,14 @@ List> _orderColumns( ]; Widget _actions(BuildContext context, String value) => VanillaMenu( - trigger: RemixMenuTrigger.builder( - label: 'Actions for $value', - icon: RemixIcons.dotsHorizontal, - builder: (_, _, _) => Semantics( - label: 'Actions for $value', - button: true, - child: Box( - style: BoxStyler() - .size(28, 28) - .alignment(.center) - .borderRadius(.all(VanillaTokens.radiusSm())), - child: StyledIcon( - icon: RemixIcons.dotsHorizontal, - style: IconStyler() - .size(VanillaTokens.icon) - .color(VanillaTokens.mutedForeground()), - ), - ), + trigger: RemixMenuTrigger.control( + builder: (_, menu) => VanillaIconButton.ghost( + size: .small, + icon: RemixIcons.dotsHorizontal, + semanticLabel: 'Actions for $value', + focusNode: menu.focusNode, + onPressed: menu.toggle, + style: IconButtonStyler().icon(.color(VanillaTokens.mutedForeground())), ), ), items: const [ diff --git a/registry_source/test/dashboard/dashboard_demo_test.dart b/registry_source/test/dashboard/dashboard_demo_test.dart index 89a4da03d..6953bf24a 100644 --- a/registry_source/test/dashboard/dashboard_demo_test.dart +++ b/registry_source/test/dashboard/dashboard_demo_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix_chart/mix_chart.dart'; @@ -10,6 +11,8 @@ import 'package:registry_source/src/agent/components/permission.dart'; import 'package:registry_source/src/dashboard/dashboard_demo_base.dart'; import 'package:registry_source/src/dashboard/dashboard_demo_records.dart'; import 'package:registry_source/src/dashboard/dashboard_sample_data.dart'; +import 'package:registry_source/src/default/components/icon_button.dart'; +import 'package:registry_source/src/default/components/menu.dart'; import 'package:registry_source/src/default/recipes/dashboard/dashboard_demo.dart'; import 'package:registry_source/src/default/recipes/dashboard/dashboard_overview.dart'; import 'package:registry_source/src/default/theme/theme_data.dart'; @@ -57,6 +60,60 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('an order row opens its actions from a real icon button', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pumpDashboard( + tester, + preset: .defaultPreset, + initialPage: RegistryDashboardDemoPage.orders, + ); + const label = 'Actions for ORD-1048'; + final button = find.byWidgetPredicate( + (widget) => widget is VanillaIconButton && widget.semanticLabel == label, + ); + expect(button, findsOneWidget); + final menu = find.ancestor( + of: button, + matching: find.byType(VanillaMenu), + ); + + expect(find.semantics.byLabel(label), findsOne); + expect(_focusStops(tester, menu), 1); + expect( + tester.getSemantics(button), + isSemantics( + label: label, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.text('Archive'), findsOneWidget); + expect( + tester.getSemantics(button), + isSemantics(label: label, hasExpandedState: true, isExpanded: true), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Archive'), findsNothing); + expect( + tester.getSemantics(button), + isSemantics(label: label, isFocused: true), + ); + semantics.dispose(); + }); + for (final preset in _Preset.values) { group(preset.name, () { testWidgets('overview renders coherent sample data', (tester) async { @@ -917,3 +974,17 @@ double _transformAngleIn(WidgetTester tester, Finder ancestor) { .atan2(transform.transform.entry(1, 0), transform.transform.entry(0, 0)) .abs(); } + +/// How many focus stops sit inside [root]. +int _focusStops(WidgetTester tester, Finder root) { + final rootElement = tester.element(root); + + return FocusManager.instance.rootScope.traversalDescendants.where((node) { + var inside = false; + node.context?.visitAncestorElements((ancestor) { + inside = identical(ancestor, rootElement); + return !inside; + }); + return inside; + }).length; +} From 398412a968f07378bc2b29e190968bc7d4ae618c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 08:26:15 -0400 Subject: [PATCH 04/17] docs(registry): describe the Vanilla ring helper, labels, and control trigger The using-remix Vanilla reference lists the single focus ring helper and the slots it goes in, the radio and switch label, and how to open a menu from a real button, and both it and the open-code guide tell an older install to reinstall the ring recipes with the theme now that the decoration helper is gone. --- docs/open-code.mdx | 5 +++++ skills/using-remix/references/vanilla.md | 25 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/open-code.mdx b/docs/open-code.mdx index def41982b..542fd77f4 100644 --- a/docs/open-code.mdx +++ b/docs/open-code.mdx @@ -595,6 +595,11 @@ Then adjust any code of your own that read the old names: - `UiTokens.focusRing` is `UiTokens.ring`. Recipes draw the ring at half strength, 3px wide with no offset, and turn a bordered control's outline `ring` while it has focus. +- `uiFocusRingDecoration()` is gone. `UiTab`, `UiToggle`, `UiToggleGroup`, + `UiMenu`, `UiAccordion`, and `UiDisclosure` draw their rings with + `uiFocusRing()` through `containerEffects` (`triggerEffects` on the + accordion and disclosure), so reinstall them with the theme, and replace the + helper in your own code. - `UiTokens.radius` is replaced by four steps derived from the theme's one `radius` value (now 10): `radiusSm` (6), `radiusMd` (8, the control radius), `radiusLg` (10), and `radiusXl` (14, cards). diff --git a/skills/using-remix/references/vanilla.md b/skills/using-remix/references/vanilla.md index 5f50a60f6..9213eb999 100644 --- a/skills/using-remix/references/vanilla.md +++ b/skills/using-remix/references/vanilla.md @@ -69,9 +69,12 @@ spacing (`s0_5` through `s16`, a 4px grid), sizes (`controlSm`/`controlMd`/`cont another in the dark. - `Shadow.xs` to `.lg`: the shadow scale, as `.box` for a styler's `shadows` or `.effects` for a `containerEffects` layer. -- `FocusRing()`, `FocusRingDecoration()`, and - `FocusBorder()`: the 3px `ring` band at half strength, and a - bordered control's outline turned `ring`. +- `FocusRing()` and `FocusBorder()`: the 3px `ring` band at + half strength as a `RemixBoxEffectsMix`, and a bordered control's outline + turned `ring`. The ring goes in `containerEffects` (`triggerEffects` on an + accordion or disclosure, `trackEffects` on a switch), so it paints outside + the part without taking layout space. `inset: true` draws it inside, for a + part whose container clips it, such as a toggle group's option. ## Theme scope @@ -104,10 +107,19 @@ families carry over. lands on the hover fill. - **Toggle** — on is `accent` with `accentForeground`; a ghost toggle hovers onto `muted` with `mutedForeground`. There is no selected border. +- **Radio and Switch** — pass `label` for visible text. It sits inside the + control's tap target, focus ring, and accessible name, and is set as a form + label: `textSm` at medium weight in `foreground`, 12px from a radio's circle + and 8px from a switch's track. Without a `label`, give a `semanticLabel`. - **Tabs** — compose `RemixTabs` (the behavioral root) with `TabBar`, `Tab`, and `TabView`. `TabsVariant { filled, line }`: `filled` (the default) is a recessed pill list, `line` an underline. Pass the same variant to the bar and to each tab. +- **Menu** — the standard trigger is a quiet label. To open a menu from a real + button, such as `IconButton.ghost`, pass + `RemixMenuTrigger.control(builder: ...)` and give the button the handle's + `focusNode` and `toggle` as its `focusNode` and `onPressed`; the button then + owns the look and accessible name, and `Menu` styles only the panel. - **Toast** — style lives on the scope: `RemixToastScope(style: uiToastStyle(), child: ...)`. A per-toast `RemixToastData.style` (for example `uiToastStyle(variant: .destructive)`) @@ -156,3 +168,10 @@ names and stops compiling. Then rename `focusRing` to `ring` in your own code, replace `Tokens.radius` with a step (`radiusMd` for controls), and give a hand-built `ThemeData` the new required colors (`card`, `popover`, `input`, `ring`, and the `sidebar*` set), or start from `.light().copyWith(...)`. + +The same applies to the focus ring helper. A theme installed before the ring +moved onto the effects slots also exports `FocusRingDecoration()`, +which the tab, toggle, toggle group, menu, and disclosure recipes used. The +current theme drops it: those recipes draw the ring with `FocusRing()` +(`triggerEffects` on the accordion and disclosure), so reinstall them with the +theme in one command, and replace the helper in your own code. From 69e01d522b50b52d6ee7041e68233834fa2c0c56 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 08:33:27 -0400 Subject: [PATCH 05/17] test(registry): read the labeled switch name before tapping it --- open_code/fixture/test/open_code_test.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index 2df702fa4..a3d1de9e5 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -3706,14 +3706,16 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Notifications')); - await tester.pumpAndSettle(); - expect(changes, [true]); expect( tester.getSemantics(find.bySemanticsLabel('Notifications')), - isSemantics(hasEnabledState: true, isEnabled: true, isToggled: true), + isSemantics(hasEnabledState: true, isEnabled: true, isToggled: false), ); + + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + + expect(changes, [true]); handle.dispose(); }); }); From a59512402dce79dc61d1ae7c16ba5016f79f9bca Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 09:33:07 -0400 Subject: [PATCH 06/17] test(registry): read Vanilla focus rings from pixels and cover the sidebar The ring probe matched a drawPath call, so most cases failed against the old foreground-decoration recipes for how the ring was painted rather than for what a reader sees. It now compares the frame before and after focus: the ring's color laid over what was there at each point it must cover, and no change at the points it must not, keeping the size and neighbor checks. A sidebar destination case is added, and the state distinguishability case for the sidebar destination reads the spec as before instead of its effects layer. RemixSidebar draws destinations with RowBox, which does not paint containerEffects, so both fail until the sidebar paints them. --- .../test/vanilla/focus_ring_test.dart | 178 +++++++++++++----- .../state_distinguishability_test.dart | 1 - 2 files changed, 129 insertions(+), 50 deletions(-) diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart index 96430c3ce..44ae01d8f 100644 --- a/registry_source/test/vanilla/focus_ring_test.dart +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -1,3 +1,6 @@ +import 'dart:typed_data'; + +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:registry_source/vanilla.dart'; @@ -7,7 +10,10 @@ import 'support.dart'; /// A part that rings on keyboard focus draws the ring around itself and moves /// nothing: neither the part nor what sits beside it changes size or position, -/// and the ring is absent until the part is focus-visible. +/// and the ring appears only once the part is focus-visible. +/// +/// The ring is read from the rendered pixels, so the test holds however a +/// recipe or a Remix part chooses to paint it. void main() { for (final theme in vanillaThemes) { group(theme.name, () { @@ -185,6 +191,42 @@ void main() { ); }); + testWidgets('a sidebar destination rings outside itself', (tester) async { + final probe = await _pump( + tester, + theme.data, + (node) => SizedBox( + width: 280, + height: 120, + child: VanillaSidebar( + selectedValue: 'billing', + onSelected: (_) {}, + sections: [ + RemixSidebarSection( + label: 'Workspace', + destinations: [ + RemixSidebarDestination( + value: 'overview', + label: 'Overview', + focusNode: node, + ), + const RemixSidebarDestination( + value: 'billing', + label: 'Billing', + ), + ], + ), + ], + ), + ), + ); + + await probe.expectRing( + part: find.byType(VanillaSidebar), + includes: (_) => [probe.focusNode.rect.centerLeft.translate(-1.5, 0)], + ); + }); + testWidgets('a disclosure rings its trigger, not the opened section', ( tester, ) async { @@ -215,6 +257,7 @@ void main() { } const _neighbor = ValueKey('neighbor'); +const _frame = ValueKey('frame'); /// Pumps the widget [build] returns above a [_neighbor], on a keyboard-driven /// focus highlight, so a focused part is focus-visible. @@ -231,19 +274,22 @@ Future<_Probe> _pump( addTearDown(focusNode.dispose); await tester.pumpWidget( - VanillaTheme( - theme: theme, - child: hostOf( - Center( - child: SizedBox( - width: 320, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - build(focusNode), - const SizedBox(key: _neighbor, width: 40, height: 10), - ], + RepaintBoundary( + key: _frame, + child: VanillaTheme( + theme: theme, + child: hostOf( + Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + build(focusNode), + const SizedBox(key: _neighbor, width: 40, height: 10), + ], + ), ), ), ), @@ -264,24 +310,21 @@ final class _Probe { /// Focuses the part and checks that it rings without moving anything. /// - /// [includes] and [excludes] are points, in global coordinates, that the - /// ring's band must and must not cover. + /// [includes] are points, in global coordinates, where the ring must be + /// painted: each pixel there turns into the ring's color laid over what it + /// showed before. At each of [excludes] the pixel must not change at all. Future expectRing({ required Finder part, Iterable Function(Rect part) includes = _none, Iterable Function(Rect part) excludes = _none, }) async { - final ring = tint(theme.ring, 0.5); final rect = tester.getRect(part); final neighbor = tester.getRect(find.byKey(_neighbor)); - expect( - tester.renderObject(part), - isNot(_paintsPath(ring)), - reason: 'a part that is not focused draws no ring', - ); + final before = await _capture(); focusNode.requestFocus(); await tester.pumpAndSettle(); + final after = await _capture(); expect(tester.getRect(part), rect, reason: 'the part keeps its box'); expect( @@ -289,35 +332,72 @@ final class _Probe { neighbor, reason: 'the part does not push what sits beside it', ); - expect( - tester.renderObject(part), - _paintsPath( - ring, - includes: [for (final point in includes(rect)) point - rect.topLeft], - excludes: [for (final point in excludes(rect)) point - rect.topLeft], - ), - reason: 'a focus-visible part draws the ring', + final ring = tint(theme.ring, 0.5); + for (final point in includes(rect)) { + final resting = before.colorAt(point); + expect( + after.colorAt(point), + isNot(_isColor(resting)), + reason: 'focusing the part paints something at $point', + ); + expect( + after.colorAt(point), + _isColor(Color.alphaBlend(ring, resting)), + reason: 'what it paints at $point is the ring over what was there', + ); + } + for (final point in excludes(rect)) { + expect( + after.colorAt(point), + _isColor(before.colorAt(point)), + reason: 'the ring stays off $point', + ); + } + } + + Future<_Pixels> _capture() async { + final boundary = tester.renderObject( + find.byKey(_frame), ); + final pixels = await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(); + final pixels = _Pixels(image.width, data!); + image.dispose(); + return pixels; + }); + + return pixels!; } } -/// Matches a render object that draws a path of [color] covering every -/// [includes] point and none of the [excludes] points. -/// -/// Other paths may be drawn before it, such as a tab's underline. -PaintPattern _paintsPath( - Color color, { - Iterable includes = const [], - Iterable excludes = const [], -}) => paints - ..something((method, arguments) { - if (method != #drawPath) return false; - final path = arguments[0] as Path; - final paint = arguments[1] as Paint; - - return paint.color.toARGB32() == color.toARGB32() && - includes.every(path.contains) && - !excludes.any(path.contains); - }); - Iterable _none(Rect part) => const []; + +/// A frame of logical pixels, one per physical pixel. +final class _Pixels { + const _Pixels(this.width, this._rgba); + + final int width; + final ByteData _rgba; + + Color colorAt(Offset point) { + final offset = (point.dy.floor() * width + point.dx.floor()) * 4; + + return Color.fromARGB( + _rgba.getUint8(offset + 3), + _rgba.getUint8(offset), + _rgba.getUint8(offset + 1), + _rgba.getUint8(offset + 2), + ); + } +} + +/// Matches [expected] to within a level or two per channel, which is what +/// blending and rounding leave. +Matcher _isColor(Color expected) => predicate((actual) { + int channel(Color color, int shift) => (color.toARGB32() >> shift) & 0xFF; + + return [0, 8, 16, 24].every( + (shift) => (channel(actual, shift) - channel(expected, shift)).abs() <= 2, + ); +}, 'is $expected'); diff --git a/registry_source/test/vanilla/state_distinguishability_test.dart b/registry_source/test/vanilla/state_distinguishability_test.dart index dd40fa70e..8576ab0e7 100644 --- a/registry_source/test/vanilla/state_distinguishability_test.dart +++ b/registry_source/test/vanilla/state_distinguishability_test.dart @@ -457,7 +457,6 @@ final _cases = <_Case>[ spec.spec.destination.spec.container, label: spec.spec.destination.spec.label.spec.style, icon: spec.spec.destination.spec.icon.spec.color, - effects: spec.spec.destination.spec.containerEffects, ), ), _resolved( From d032554dc31c5b6abb027443e2174b45467e821d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 09:33:07 -0400 Subject: [PATCH 07/17] feat(registry): keep the Vanilla select panel at least as wide as its trigger shadcn's default item-aligned select never draws the panel narrower than its trigger. The panel keeps its 128px minimum and adds remixAnchorWidth as a second minimum through a box modifier, so the panel is max(128, trigger) and a caller who replaces the floor through style still keeps the trigger's width. --- apps/playground/lib/ui/components/select.dart | 6 ++- .../lib/ui/components/select.dart | 6 ++- .../vanilla/templates/select/select.dart.tmpl | 6 ++- .../lib/src/default/components/select.dart | 6 ++- registry_source/specs/vanilla.md | 2 +- registry_source/test/vanilla/layout_test.dart | 46 +++++++++++++++++++ 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/playground/lib/ui/components/select.dart b/apps/playground/lib/ui/components/select.dart index 2f868e5c2..bf155cb94 100644 --- a/apps/playground/lib/ui/components/select.dart +++ b/apps/playground/lib/ui/components/select.dart @@ -120,7 +120,8 @@ SelectTriggerStyler _triggerStyle() => SelectTriggerStyler() ); /// The floating panel the options live in: `popover` inside a `border` -/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. +/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. It is at +/// least 128px wide and at least as wide as its trigger. SelectContentStyler _contentStyle() => SelectContentStyler() .color(PlaygroundTokens.popover()) .border( @@ -129,6 +130,9 @@ SelectContentStyler _contentStyle() => SelectContentStyler() .borderRadius(.all(PlaygroundTokens.radiusMd())) .padding(.all(PlaygroundTokens.s1)) .minWidth(PlaygroundTokens.panelMinWidth) + // A second minimum on a modifier: the trigger's width still holds where a + // caller replaces the 128px floor through `style`. + .wrap(.box(BoxStyler().minWidth(remixAnchorWidth()))) .maxHeight(_panelMaxHeight) .containerEffects(.behindContent(PlaygroundShadow.md.effects)); diff --git a/apps/widgetbook_vanilla/lib/ui/components/select.dart b/apps/widgetbook_vanilla/lib/ui/components/select.dart index 9c2f4736e..de65cd931 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/select.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/select.dart @@ -105,13 +105,17 @@ SelectTriggerStyler _triggerStyle() => SelectTriggerStyler() ); /// The floating panel the options live in: `popover` inside a `border` -/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. +/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. It is at +/// least 128px wide and at least as wide as its trigger. SelectContentStyler _contentStyle() => SelectContentStyler() .color(VanillaTokens.popover()) .border(.color(VanillaTokens.border()).width(VanillaTokens.strokeHairline)) .borderRadius(.all(VanillaTokens.radiusMd())) .padding(.all(VanillaTokens.s1)) .minWidth(VanillaTokens.panelMinWidth) + // A second minimum on a modifier: the trigger's width still holds where a + // caller replaces the 128px floor through `style`. + .wrap(.box(BoxStyler().minWidth(remixAnchorWidth()))) .maxHeight(_panelMaxHeight) .containerEffects(.behindContent(VanillaShadow.md.effects)); diff --git a/registry/vanilla/templates/select/select.dart.tmpl b/registry/vanilla/templates/select/select.dart.tmpl index 92edc97e0..3750e02b0 100644 --- a/registry/vanilla/templates/select/select.dart.tmpl +++ b/registry/vanilla/templates/select/select.dart.tmpl @@ -105,13 +105,17 @@ SelectTriggerStyler _triggerStyle() => SelectTriggerStyler() ); /// The floating panel the options live in: `popover` inside a `border` -/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. +/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. It is at +/// least 128px wide and at least as wide as its trigger. SelectContentStyler _contentStyle() => SelectContentStyler() .color({{typePrefix}}Tokens.popover()) .border(.color({{typePrefix}}Tokens.border()).width({{typePrefix}}Tokens.strokeHairline)) .borderRadius(.all({{typePrefix}}Tokens.radiusMd())) .padding(.all({{typePrefix}}Tokens.s1)) .minWidth({{typePrefix}}Tokens.panelMinWidth) + // A second minimum on a modifier: the trigger's width still holds where a + // caller replaces the 128px floor through `style`. + .wrap(.box(BoxStyler().minWidth(remixAnchorWidth()))) .maxHeight(_panelMaxHeight) .containerEffects(.behindContent({{typePrefix}}Shadow.md.effects)); diff --git a/registry_source/lib/src/default/components/select.dart b/registry_source/lib/src/default/components/select.dart index 9c2f4736e..de65cd931 100644 --- a/registry_source/lib/src/default/components/select.dart +++ b/registry_source/lib/src/default/components/select.dart @@ -105,13 +105,17 @@ SelectTriggerStyler _triggerStyle() => SelectTriggerStyler() ); /// The floating panel the options live in: `popover` inside a `border` -/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. +/// hairline, `radiusMd` corners, a 4px inset, and the `md` shadow. It is at +/// least 128px wide and at least as wide as its trigger. SelectContentStyler _contentStyle() => SelectContentStyler() .color(VanillaTokens.popover()) .border(.color(VanillaTokens.border()).width(VanillaTokens.strokeHairline)) .borderRadius(.all(VanillaTokens.radiusMd())) .padding(.all(VanillaTokens.s1)) .minWidth(VanillaTokens.panelMinWidth) + // A second minimum on a modifier: the trigger's width still holds where a + // caller replaces the 128px floor through `style`. + .wrap(.box(BoxStyler().minWidth(remixAnchorWidth()))) .maxHeight(_panelMaxHeight) .containerEffects(.behindContent(VanillaShadow.md.effects)); diff --git a/registry_source/specs/vanilla.md b/registry_source/specs/vanilla.md index 5747c7629..cefa3aabd 100644 --- a/registry_source/specs/vanilla.md +++ b/registry_source/specs/vanilla.md @@ -149,7 +149,7 @@ the steps above. The source column names the file under | Text area | min-h 64; px 12, py 8 | `textarea.tsx` | | Field label and helper | label `text-sm` w500, gap 8; helper `text-sm` muted-fg; error helper `destructive` | `label.tsx`, `field.tsx` | | Select trigger | the text field's metrics, gap 8; chevron 10 at 50% (see Deviations) | `select.tsx` | -| Select content | `popover` fill, border, radius md, `shadow-md`, p 4, min-w 128 | `select.tsx` | +| Select content | `popover` fill, border, radius md, `shadow-md`, p 4, min-w 128, never narrower than the trigger (the default `item-aligned` position) | `select.tsx` | | Select item | h 32; px 8, py 6 (see Deviations); radius sm; highlight accent; check 10 muted-fg | `select.tsx` | | Checkbox | 16, radius 4, border `input`, `shadow-xs`; checked primary; check 10 (see Deviations); no hover fill; label `text-sm` w500, gap 12 | `checkbox.tsx`, `label.tsx` | | Radio | 16, border `input` in both states, `shadow-xs`; dot 8 primary; label `text-sm` w500, gap 12 | `radio-group.tsx`, `label.tsx` | diff --git a/registry_source/test/vanilla/layout_test.dart b/registry_source/test/vanilla/layout_test.dart index be700aed4..47b731a4f 100644 --- a/registry_source/test/vanilla/layout_test.dart +++ b/registry_source/test/vanilla/layout_test.dart @@ -2,6 +2,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:registry_source/vanilla.dart'; import 'package:remix/remix.dart'; +// `RemixBoxAdapter` is `@internal` to `remix`, but the panel's width is the one +// thing to measure and no public widget is the panel. Suppressed at the use. +import 'package:remix/src/rendering/remix_box_effects.dart'; import 'package:remix_ui_icons/remix_ui_icons.dart'; /// Laid-out sizes the spec states. The spec targets read style inputs, which @@ -116,6 +119,49 @@ void main() { expect(panelOf(tester, 'Overview').height, 32); }); + testWidgets( + 'a select panel is at least 128px and at least its trigger wide', + (tester) async { + Future panelWidth(double triggerWidth) async { + await tester.pumpWidget( + host( + Center( + child: SizedBox( + width: triggerWidth, + child: VanillaSelect( + trigger: const RemixSelectTrigger(placeholder: 'Plan'), + items: const [RemixSelectItem(value: 'free', label: 'Free')], + onChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.tap(find.byType(VanillaSelect)); + await tester.pumpAndSettle(); + final width = tester + .getSize( + find + .ancestor( + of: find.text('Free'), + // ignore: invalid_use_of_internal_member + matching: find.byType(RemixBoxAdapter), + ) + .last, + ) + .width; + await tester.tapAt(const Offset(2, 2)); + await tester.pumpAndSettle(); + + return width; + } + + expect(await panelWidth(100), 128); + expect(await panelWidth(240), 240); + expect(await panelWidth(400), 400); + }, + ); + testWidgets('a segmented control stays 36px tall inside a taller row', ( tester, ) async { From 146b08b6541c63f15dad86e77d377e713c013a43 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 09:33:07 -0400 Subject: [PATCH 08/17] feat(registry): fill the Vanilla order actions button while its menu is open shadcn's dashboard block fills the kebab with muted while its menu is open. The button reads the menu handle's isOpen and merges that fill in, and the dashboard test checks the fill follows the menu open and closed. --- .../dashboard_demo_records_page.dart | 10 ++++++--- .../dashboard_demo_records_page.dart.tmpl | 8 ++++++- .../dashboard/dashboard_demo_records.dart | 8 ++++++- .../test/dashboard/dashboard_demo_test.dart | 22 +++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart index 9707d7e41..28edda147 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart @@ -385,9 +385,13 @@ Widget _actions(BuildContext context, String value) => PlaygroundMenu( semanticLabel: 'Actions for $value', focusNode: menu.focusNode, onPressed: menu.toggle, - style: IconButtonStyler().icon( - .color(PlaygroundTokens.mutedForeground()), - ), + style: IconButtonStyler() + .icon(.color(PlaygroundTokens.mutedForeground())) + .merge( + menu.isOpen + ? IconButtonStyler().color(PlaygroundTokens.muted()) + : null, + ), ), ), items: const [ diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl index 2c0e73c7d..49236fb42 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl @@ -382,7 +382,13 @@ Widget _actions(BuildContext context, String value) => {{typePrefix}}Menu VanillaMenu( semanticLabel: 'Actions for $value', focusNode: menu.focusNode, onPressed: menu.toggle, - style: IconButtonStyler().icon(.color(VanillaTokens.mutedForeground())), + style: IconButtonStyler() + .icon(.color(VanillaTokens.mutedForeground())) + .merge( + menu.isOpen + ? IconButtonStyler().color(VanillaTokens.muted()) + : null, + ), ), ), items: const [ diff --git a/registry_source/test/dashboard/dashboard_demo_test.dart b/registry_source/test/dashboard/dashboard_demo_test.dart index 6953bf24a..73fbb4818 100644 --- a/registry_source/test/dashboard/dashboard_demo_test.dart +++ b/registry_source/test/dashboard/dashboard_demo_test.dart @@ -79,8 +79,24 @@ void main() { matching: find.byType(VanillaMenu), ); + Color? fill() { + final provider = tester.widget>( + find + .descendant( + of: button, + matching: find.byType(StyleSpecProvider), + ) + .first, + ); + return (provider.spec.spec.container.spec.decoration as BoxDecoration?) + ?.color; + } + + final muted = VanillaTheme.of(tester.element(button)).muted; + expect(find.semantics.byLabel(label), findsOne); expect(_focusStops(tester, menu), 1); + expect(fill(), isNot(muted)); expect( tester.getSemantics(button), isSemantics( @@ -98,6 +114,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Archive'), findsOneWidget); + expect( + fill(), + muted, + reason: 'the button is filled while its menu is open', + ); expect( tester.getSemantics(button), isSemantics(label: label, hasExpandedState: true, isExpanded: true), @@ -107,6 +128,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Archive'), findsNothing); + expect(fill(), isNot(muted), reason: 'the fill goes with the menu'); expect( tester.getSemantics(button), isSemantics(label: label, isFocused: true), From 8abcb30a18f8a9e2864985f8500dff0d1fc8378f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 09:33:13 -0400 Subject: [PATCH 09/17] docs(registry): record the inset toggle group ring and correct the label note The Vanilla spec and the open-code guide said the focus ring is always drawn outside with no offset. A toggle group option draws it inside because the group clips its options, where the reference lifts the focused item with focus-visible:z-10, so the spec gains a deviation row and the guide notes it. The using-remix Vanilla reference no longer says the radio and switch label sits inside the focus ring: the label is part of the tap and focus target, and the ring wraps the circle or track only. The toggle's "No shadow" comment is dropped, since a decoration shadow is no longer the reason the outline toggle has none, and the spec row says so. --- apps/playground/lib/ui/components/toggle.dart | 43 +++++++++---------- .../lib/ui/components/toggle.dart | 2 - docs/open-code.mdx | 3 +- .../vanilla/templates/toggle/toggle.dart.tmpl | 2 - .../lib/src/default/components/toggle.dart | 2 - registry_source/specs/vanilla.md | 6 ++- .../test/vanilla/vanilla_spec.dart | 3 +- skills/using-remix/references/vanilla.md | 9 ++-- 8 files changed, 32 insertions(+), 38 deletions(-) diff --git a/apps/playground/lib/ui/components/toggle.dart b/apps/playground/lib/ui/components/toggle.dart index 9f3f145f1..026ef2d6f 100644 --- a/apps/playground/lib/ui/components/toggle.dart +++ b/apps/playground/lib/ui/components/toggle.dart @@ -111,29 +111,26 @@ ToggleStyler _base(_PlaygroundToggleMetrics metrics) => .icon(.size(PlaygroundTokens.icon)); /// The variant's outline and its hover. -ToggleStyler _variantStyle( - PlaygroundToggleVariant variant, -) => switch (variant) { - .ghost => ToggleStyler().onHovered( - _content( - PlaygroundTokens.mutedForeground(), - ).color(PlaygroundTokens.muted()), - ), - // No shadow: a decoration shadow under a transparent control shows through - // it as a gray wash. - .outline => - ToggleStyler() - .border( - .color( - PlaygroundTokens.input(), - ).width(PlaygroundTokens.strokeHairline), - ) - .onHovered( - _content( - PlaygroundTokens.accentForeground(), - ).color(PlaygroundTokens.accent()), - ), -}; +ToggleStyler _variantStyle(PlaygroundToggleVariant variant) => + switch (variant) { + .ghost => ToggleStyler().onHovered( + _content( + PlaygroundTokens.mutedForeground(), + ).color(PlaygroundTokens.muted()), + ), + .outline => + ToggleStyler() + .border( + .color( + PlaygroundTokens.input(), + ).width(PlaygroundTokens.strokeHairline), + ) + .onHovered( + _content( + PlaygroundTokens.accentForeground(), + ).color(PlaygroundTokens.accent()), + ), + }; /// Applies one content color to the label and the icons. ToggleStyler _content(Color foreground) => diff --git a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart index 26d95d0f7..fce50df64 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart @@ -103,8 +103,6 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .ghost => ToggleStyler().onHovered( _content(VanillaTokens.mutedForeground()).color(VanillaTokens.muted()), ), - // No shadow: a decoration shadow under a transparent control shows through - // it as a gray wash. .outline => ToggleStyler() .border( diff --git a/docs/open-code.mdx b/docs/open-code.mdx index 542fd77f4..942da80c0 100644 --- a/docs/open-code.mdx +++ b/docs/open-code.mdx @@ -594,7 +594,8 @@ Then adjust any code of your own that read the old names: `UiThemeData.light().copyWith(...)` instead. - `UiTokens.focusRing` is `UiTokens.ring`. Recipes draw the ring at half strength, 3px wide with no offset, and turn a bordered control's outline - `ring` while it has focus. + `ring` while it has focus. A toggle-group option draws the band inside + itself, because the group clips its options. - `uiFocusRingDecoration()` is gone. `UiTab`, `UiToggle`, `UiToggleGroup`, `UiMenu`, `UiAccordion`, and `UiDisclosure` draw their rings with `uiFocusRing()` through `containerEffects` (`triggerEffects` on the diff --git a/registry/vanilla/templates/toggle/toggle.dart.tmpl b/registry/vanilla/templates/toggle/toggle.dart.tmpl index fc543d079..bdd0e9590 100644 --- a/registry/vanilla/templates/toggle/toggle.dart.tmpl +++ b/registry/vanilla/templates/toggle/toggle.dart.tmpl @@ -103,8 +103,6 @@ ToggleStyler _variantStyle({{typePrefix}}ToggleVariant variant) => switch (varia .ghost => ToggleStyler().onHovered( _content({{typePrefix}}Tokens.mutedForeground()).color({{typePrefix}}Tokens.muted()), ), - // No shadow: a decoration shadow under a transparent control shows through - // it as a gray wash. .outline => ToggleStyler() .border( diff --git a/registry_source/lib/src/default/components/toggle.dart b/registry_source/lib/src/default/components/toggle.dart index 26d95d0f7..fce50df64 100644 --- a/registry_source/lib/src/default/components/toggle.dart +++ b/registry_source/lib/src/default/components/toggle.dart @@ -103,8 +103,6 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .ghost => ToggleStyler().onHovered( _content(VanillaTokens.mutedForeground()).color(VanillaTokens.muted()), ), - // No shadow: a decoration shadow under a transparent control shows through - // it as a gray wash. .outline => ToggleStyler() .border( diff --git a/registry_source/specs/vanilla.md b/registry_source/specs/vanilla.md index cefa3aabd..37ed4a56c 100644 --- a/registry_source/specs/vanilla.md +++ b/registry_source/specs/vanilla.md @@ -125,7 +125,8 @@ Tailwind's `transition` default, `cubic-bezier(0.4, 0, 0.2, 1)`. **Focus.** `focus-visible:ring-[3px] ring-ring/50`: a 3px band of `ring` at 50%, drawn outside the control with no offset. A control with a border also turns its border `ring` (`focus-visible:border-ring`). Text fields and text areas -ring on any focus, including a click (see Deviations). +ring on any focus, including a click (see Deviations). A toggle-group option +draws the band inside itself (see Deviations). **Disabled.** Opacity 0.5; colors are unchanged. @@ -191,9 +192,10 @@ the steps above. The source column names the file under | Checkbox, select, and menu 9×9 glyphs | `size-3.5` (14) / `size-4` (16) | 10 (`VanillaTokens.s2_5`) | The reference sizes padded 24×24 Lucide icons; Remix draws edge-to-edge 9×9 paths (`thickCheck`, `chevronDown`, `thickChevronRight`), which match the painted mark at 10. | | Select item horizontal padding | `pl-2 pr-8` (8 / 32) | `px-2` (8) | CSS positions the check mark inside the 32px right gutter (`right-2`); Remix lays the check mark out in the row after the label, so `px-2` places it 8px from the trailing edge. | | Toggle group, outline | per-item borders sharing edges | one border on the clipped group | A recipe cannot tell the first and last items apart. | -| Toggle and toggle group, outline | `shadow-xs` | no shadow | Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that draw `shadow-xs` in the effects layer cut the box out as CSS does; the toggle group's strip has no effects layer, and the toggle's outline variant does not use its own for a shadow. | +| Toggle and toggle group, outline | `shadow-xs` | no shadow | Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that draw `shadow-xs` in the effects layer cut the box out as CSS does; the toggle group's strip has no effects layer, and the toggle's outline variant has one but does not draw the shadow there yet. | | Chart palette | gray ramp | gray ramp | The reference's lightest series measures about 1.5:1 on the light page, and the darkest two (`chart4`, `chart5`) about 1.3 to 1.8:1 on the dark page and card; Vanilla keeps the palette and picks each pie label from `foreground` or `background` by contrast (`vanillaPieSliceLabelColor`). | | Focus ring | `ring/50`, 3px | `ring` at 50%, 3px | Matches the reference, which is below the 3:1 non-text floor: about 1.5:1 on the light page and 1.85:1 on the dark. The old Vanilla `focusRing` cleared 3:1. Raise the band's alpha in the recipes, or darken `ring`, where focus visibility matters more than matching. | +| Toggle group option focus | band drawn outside, the focused item lifted with `focus-visible:z-10` | the same 3px band drawn inside the option | The group clips its options to the rounded strip, which would crop an outside band on the first and last option, and a filled neighbor painted after the focused option would cover its side of one. A recipe cannot lift an option above its neighbors, so the band goes inside. | | Text field and text area focus | `focus-visible:` | any focus (`onFocused`) | Browsers treat a focused text input as `:focus-visible` even after a click, but Mix's `onFocusVisible` does not, so the recipe keys the ring on `onFocused` to show it on click as the reference does. Other controls stay on `onFocusVisible`. | | Callout, destructive | description at `destructive/90` | one text color for the whole callout | `CalloutSpec` has a single text role, so the description cannot take its own color. | | Current filled tab, dark | `bg-input/30` over the list | `input` at 30% pre-blended over `muted`, opaque | Flutter paints the tab's `shadow-sm` under the whole box, so a translucent fill would show it through and darken the tab. The shadow stays a decoration shadow rather than moving to the tab's effects layer. | diff --git a/registry_source/test/vanilla/vanilla_spec.dart b/registry_source/test/vanilla/vanilla_spec.dart index 1a92150b6..51a25e3d0 100644 --- a/registry_source/test/vanilla/vanilla_spec.dart +++ b/registry_source/test/vanilla/vanilla_spec.dart @@ -592,8 +592,7 @@ final _toggleTargets = [ read: (spec) => flexDecorationOf(spec.spec.container)?.border, expected: (theme) => _hairline(theme.input), ), - // Deviation: no `shadow-xs`. A decoration shadow under a transparent fill - // reads as a gray wash. + // Deviation: no `shadow-xs` (see specs/vanilla.md). _toggle( 'outline has no decoration shadow', variant: .outline, diff --git a/skills/using-remix/references/vanilla.md b/skills/using-remix/references/vanilla.md index 9213eb999..9ffd6694f 100644 --- a/skills/using-remix/references/vanilla.md +++ b/skills/using-remix/references/vanilla.md @@ -107,10 +107,11 @@ families carry over. lands on the hover fill. - **Toggle** — on is `accent` with `accentForeground`; a ghost toggle hovers onto `muted` with `mutedForeground`. There is no selected border. -- **Radio and Switch** — pass `label` for visible text. It sits inside the - control's tap target, focus ring, and accessible name, and is set as a form - label: `textSm` at medium weight in `foreground`, 12px from a radio's circle - and 8px from a switch's track. Without a `label`, give a `semanticLabel`. +- **Radio and Switch** — pass `label` for visible text. It is part of the + control's tap and focus target and gives its accessible name, and it is set + as a form label: `textSm` at medium weight in `foreground`, 12px from a + radio's circle and 8px from a switch's track. The focus ring wraps the circle + or track only. Without a `label`, give a `semanticLabel`. - **Tabs** — compose `RemixTabs` (the behavioral root) with `TabBar`, `Tab`, and `TabView`. `TabsVariant { filled, line }`: `filled` (the default) is a recessed pill list, `line` an underline. From 3484e056513e68b872096621ef34232002163745 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 10:14:32 -0400 Subject: [PATCH 10/17] test(registry): read the Vanilla sidebar destination ring now that Remix paints it --- registry_source/test/vanilla/state_distinguishability_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/registry_source/test/vanilla/state_distinguishability_test.dart b/registry_source/test/vanilla/state_distinguishability_test.dart index 8576ab0e7..dd40fa70e 100644 --- a/registry_source/test/vanilla/state_distinguishability_test.dart +++ b/registry_source/test/vanilla/state_distinguishability_test.dart @@ -457,6 +457,7 @@ final _cases = <_Case>[ spec.spec.destination.spec.container, label: spec.spec.destination.spec.label.spec.style, icon: spec.spec.destination.spec.icon.spec.color, + effects: spec.spec.destination.spec.containerEffects, ), ), _resolved( From 1c15b3e98c8bddea39f0d0721e9a6e3aea44ea0f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 11:29:41 -0400 Subject: [PATCH 11/17] fix(registry): draw a focus ring on the Vanilla account menu trigger The dashboard's account row is a RemixMenuTrigger.builder, and a builder replaces the styled trigger and its effects, so the row was a keyboard focus stop that showed no focus. Its content is now a RemixCard that draws vanillaFocusRing() in containerEffects on focus-visible, resolved under the menu's widget states, so the ring paints outside the row and moves nothing. It is the only builder trigger in the Vanilla recipes. The ring test focuses the real trigger in the dashboard and checks the ring pixels and that the trigger and its content keep their boxes. The menu docs' avatar builder example draws its own ring and says why, and the Vanilla reference notes it. --- .../ui/recipes/dashboard/dashboard_demo.dart | 10 +- docs/components/menu.mdx | 20 +++- .../dashboard_demo/dashboard_demo.dart.tmpl | 8 +- .../recipes/dashboard/dashboard_demo.dart | 8 +- .../test/vanilla/focus_ring_test.dart | 94 ++++++++++++++----- skills/using-remix/references/vanilla.md | 3 + 6 files changed, 105 insertions(+), 38 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart index cf593a2bb..ff49554d0 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart @@ -10,6 +10,7 @@ import '../../components/menu.dart'; import '../../components/popover.dart'; import '../../components/segmented_control.dart'; import '../../components/toast.dart'; +import '../../theme/effects.dart'; import '../../theme/theme_data.dart'; import '../../theme/theme_scope.dart'; import '../../theme/tokens.dart'; @@ -385,8 +386,8 @@ class _PlaygroundAccount extends StatelessWidget { trigger: RemixMenuTrigger.builder( label: 'Ada Chen', icon: RemixIcons.person, - builder: (context, _, _) => Box( - style: BoxStyler() + builder: (context, _, _) => RemixCard( + style: CardStyler() .padding( .symmetric( horizontal: PlaygroundTokens.s2_5, @@ -395,7 +396,10 @@ class _PlaygroundAccount extends StatelessWidget { ) .borderRadius(.all(PlaygroundTokens.radiusMd())) .border(.all(.color(PlaygroundTokens.border()))) - .color(PlaygroundTokens.card()), + .color(PlaygroundTokens.card()) + .onFocusVisible( + CardStyler().containerEffects(playgroundFocusRing()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index cf4cc6e65..f51287264 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -164,8 +164,20 @@ class AccountMenuExample extends StatelessWidget { return FortalMenu( trigger: RemixMenuTrigger.builder( label: 'Account menu', - builder: (context, state, defaultTrigger) => - const FortalAvatar(label: 'LF'), + // The builder replaces the styled trigger, so it draws its own ring. + builder: (context, state, defaultTrigger) => RemixCard( + style: CardStyler() + .borderRadius(.circular(999)) + .onFocusVisible( + CardStyler().containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix(color: Colors.indigo, width: 2), + outlineOffset: 2, + ), + ), + ), + child: const FortalAvatar(label: 'LF'), + ), ), items: const [ RemixMenuItem(value: 'profile', label: 'View profile'), @@ -469,7 +481,9 @@ are long enough to scroll, the list clips at its own edges, inside the popup's padding, so only an inside ring stays whole. A `RemixMenuTrigger.builder` that returns a different widget instead of its -child replaces the styled trigger and its effects. +child replaces the styled trigger and its effects, so the returned content has +to draw its own focus ring, as the avatar example above does. A builder runs +under the menu's widget states, so `onFocusVisible` on its styles works. ## Long menus diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl index 69308a967..6ad3dc6a9 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl @@ -10,6 +10,7 @@ import '../../components/menu.dart'; import '../../components/popover.dart'; import '../../components/segmented_control.dart'; import '../../components/toast.dart'; +import '../../theme/effects.dart'; import '../../theme/theme_data.dart'; import '../../theme/theme_scope.dart'; import '../../theme/tokens.dart'; @@ -383,8 +384,8 @@ class _{{typePrefix}}Account extends StatelessWidget { trigger: RemixMenuTrigger.builder( label: 'Ada Chen', icon: {{icon:person}}, - builder: (context, _, _) => Box( - style: BoxStyler() + builder: (context, _, _) => RemixCard( + style: CardStyler() .padding( .symmetric( horizontal: {{typePrefix}}Tokens.s2_5, @@ -393,7 +394,8 @@ class _{{typePrefix}}Account extends StatelessWidget { ) .borderRadius(.all({{typePrefix}}Tokens.radiusMd())) .border(.all(.color({{typePrefix}}Tokens.border()))) - .color({{typePrefix}}Tokens.card()), + .color({{typePrefix}}Tokens.card()) + .onFocusVisible(CardStyler().containerEffects({{valuePrefix}}FocusRing())), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) diff --git a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart index f2385167f..6c3aae659 100644 --- a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart +++ b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart @@ -21,6 +21,7 @@ import '../message_recipe.dart'; import '../permission_recipe.dart'; import '../plan_recipe.dart'; import '../transcript_recipe.dart'; +import '../../theme/effects.dart'; import '../../theme/tokens.dart'; import '../../theme/theme_data.dart'; import '../../theme/theme_scope.dart'; @@ -383,8 +384,8 @@ class _VanillaAccount extends StatelessWidget { trigger: RemixMenuTrigger.builder( label: 'Ada Chen', icon: RemixIcons.person, - builder: (context, _, _) => Box( - style: BoxStyler() + builder: (context, _, _) => RemixCard( + style: CardStyler() .padding( .symmetric( horizontal: VanillaTokens.s2_5, @@ -393,7 +394,8 @@ class _VanillaAccount extends StatelessWidget { ) .borderRadius(.all(VanillaTokens.radiusMd())) .border(.all(.color(VanillaTokens.border()))) - .color(VanillaTokens.card()), + .color(VanillaTokens.card()) + .onFocusVisible(CardStyler().containerEffects(vanillaFocusRing())), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart index 44ae01d8f..cb7a077a8 100644 --- a/registry_source/test/vanilla/focus_ring_test.dart +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/src/default/recipes/dashboard/dashboard_demo.dart'; import 'package:registry_source/vanilla.dart'; import 'package:remix/remix.dart'; @@ -227,6 +228,34 @@ void main() { ); }); + testWidgets('the dashboard account trigger rings outside itself', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.reset); + await _pumpFrame( + tester, + theme.data, + Overlay.wrap(child: const VanillaDashboardDemo(initialPage: .orders)), + ); + const workspace = 'Northstar workspace'; + final probe = _Probe( + tester, + theme.data, + Focus.of(tester.element(find.text(workspace))), + ); + + await probe.expectRing( + part: find.ancestor( + of: find.text(workspace), + matching: find.byType(VanillaMenu), + ), + neighbor: find.text(workspace), + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + testWidgets('a disclosure rings its trigger, not the opened section', ( tester, ) async { @@ -265,40 +294,48 @@ Future<_Probe> _pump( WidgetTester tester, VanillaThemeData theme, Widget Function(FocusNode focusNode) build, +) async { + final focusNode = FocusNode(debugLabel: 'ring-probe'); + addTearDown(focusNode.dispose); + await _pumpFrame( + tester, + theme, + Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + build(focusNode), + const SizedBox(key: _neighbor, width: 40, height: 10), + ], + ), + ), + ), + ); + + return _Probe(tester, theme, focusNode); +} + +/// Pumps [child] under [theme] inside the frame the probe captures. +Future _pumpFrame( + WidgetTester tester, + VanillaThemeData theme, + Widget child, ) async { final previous = FocusManager.instance.highlightStrategy; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; addTearDown(() => FocusManager.instance.highlightStrategy = previous); - final focusNode = FocusNode(debugLabel: 'ring-probe'); - addTearDown(focusNode.dispose); await tester.pumpWidget( RepaintBoundary( key: _frame, - child: VanillaTheme( - theme: theme, - child: hostOf( - Center( - child: SizedBox( - width: 320, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - build(focusNode), - const SizedBox(key: _neighbor, width: 40, height: 10), - ], - ), - ), - ), - ), - ), + child: VanillaTheme(theme: theme, child: hostOf(child)), ), ); await tester.pumpAndSettle(); - - return _Probe(tester, theme, focusNode); } final class _Probe { @@ -310,16 +347,21 @@ final class _Probe { /// Focuses the part and checks that it rings without moving anything. /// + /// [neighbor] is what must stay put beside or inside it; it defaults to a + /// marker [_pump] places after the part. + /// /// [includes] are points, in global coordinates, where the ring must be /// painted: each pixel there turns into the ring's color laid over what it /// showed before. At each of [excludes] the pixel must not change at all. Future expectRing({ required Finder part, + Finder? neighbor, Iterable Function(Rect part) includes = _none, Iterable Function(Rect part) excludes = _none, }) async { final rect = tester.getRect(part); - final neighbor = tester.getRect(find.byKey(_neighbor)); + final neighborFinder = neighbor ?? find.byKey(_neighbor); + final neighborRect = tester.getRect(neighborFinder); final before = await _capture(); focusNode.requestFocus(); @@ -328,9 +370,9 @@ final class _Probe { expect(tester.getRect(part), rect, reason: 'the part keeps its box'); expect( - tester.getRect(find.byKey(_neighbor)), - neighbor, - reason: 'the part does not push what sits beside it', + tester.getRect(neighborFinder), + neighborRect, + reason: 'the part does not push what sits beside or inside it', ); final ring = tint(theme.ring, 0.5); for (final point in includes(rect)) { diff --git a/skills/using-remix/references/vanilla.md b/skills/using-remix/references/vanilla.md index 9ffd6694f..092f5fa3e 100644 --- a/skills/using-remix/references/vanilla.md +++ b/skills/using-remix/references/vanilla.md @@ -121,6 +121,9 @@ families carry over. `RemixMenuTrigger.control(builder: ...)` and give the button the handle's `focusNode` and `toggle` as its `focusNode` and `onPressed`; the button then owns the look and accessible name, and `Menu` styles only the panel. + A `RemixMenuTrigger.builder` replaces the styled trigger, ring included, so + its content draws its own `onFocusVisible` ring, as the dashboard's account + row does with `RemixCard` and `FocusRing()`. - **Toast** — style lives on the scope: `RemixToastScope(style: uiToastStyle(), child: ...)`. A per-toast `RemixToastData.style` (for example `uiToastStyle(variant: .destructive)`) From d0cbf2a023de1978e2e524647bbc6af642a9335e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 11:49:48 -0400 Subject: [PATCH 12/17] test(registry): drive the fixture popover from its button with openOnTap false The fixture and the Vanilla popover recipe docs said a button trigger needs a controller because the button consumes the tap that openOnTap waits for. NakedPopover with openOnTap false no longer wraps its child in a focus stop of its own, so the button can be the only tab stop and closing can return focus to it when the popover and the button share one FocusNode. The fixture gallery now passes openOnTap false, the shared node as triggerFocusNode, and the same node to the button, keeping the controller toggle. The fixture test asserts one focus stop inside the popover, that focus leaves the button when the panel opens, and that Escape closes the panel and returns focus to the button. The popover recipe doc comment shows the same pattern, with its template and installed copies regenerated. --- .../playground/lib/ui/components/popover.dart | 14 +++--- .../lib/ui/components/popover.g.dart | 14 +++--- .../lib/ui/components/popover.dart | 14 +++--- .../lib/ui/components/popover.g.dart | 14 +++--- open_code/fixture/lib/main.dart | 24 +++++++--- open_code/fixture/test/open_code_test.dart | 44 +++++++++++++++---- .../templates/popover/popover.dart.tmpl | 14 +++--- .../lib/src/default/components/popover.dart | 14 +++--- .../lib/src/default/components/popover.g.dart | 14 +++--- 9 files changed, 116 insertions(+), 50 deletions(-) diff --git a/apps/playground/lib/ui/components/popover.dart b/apps/playground/lib/ui/components/popover.dart index 2873b6058..912995f8f 100644 --- a/apps/playground/lib/ui/components/popover.dart +++ b/apps/playground/lib/ui/components/popover.dart @@ -19,20 +19,24 @@ part 'popover.g.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `PlaygroundButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// PlaygroundPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: PlaygroundButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/apps/playground/lib/ui/components/popover.g.dart b/apps/playground/lib/ui/components/popover.g.dart index 7638a6dfd..eed7d5c60 100644 --- a/apps/playground/lib/ui/components/popover.g.dart +++ b/apps/playground/lib/ui/components/popover.g.dart @@ -18,20 +18,24 @@ part of 'popover.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `PlaygroundButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// PlaygroundPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: PlaygroundButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/apps/widgetbook_vanilla/lib/ui/components/popover.dart b/apps/widgetbook_vanilla/lib/ui/components/popover.dart index c67328e0f..da285b007 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.dart @@ -19,20 +19,24 @@ part 'popover.g.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// VanillaPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: VanillaButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart b/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart index 44bc00d09..d0fbcb244 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart @@ -18,20 +18,24 @@ part of 'popover.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// VanillaPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: VanillaButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/open_code/fixture/lib/main.dart b/open_code/fixture/lib/main.dart index f02f8737c..ba2983264 100644 --- a/open_code/fixture/lib/main.dart +++ b/open_code/fixture/lib/main.dart @@ -143,10 +143,19 @@ class _AcmeThemeSectionState extends State { final RemixAccordionController _sections = RemixAccordionController(); - /// Drives the popover from its trigger button. See the popover below for - /// why `openOnTap` alone does not work when the trigger is a button. + /// Toggles the filter popover from its button. final MenuController _filters = MenuController(); + /// Shared by the button and the popover, so the button is the only tab stop + /// and takes focus back when the popover closes. + final FocusNode _filtersFocus = FocusNode(debugLabel: 'Filter'); + + @override + void dispose() { + _filtersFocus.dispose(); + super.dispose(); + } + void _record(String action) => setState(() => _lastAction = action); @override @@ -824,13 +833,13 @@ class _AcmeThemeSectionState extends State { onPressed: () => _record('tooltip anchor'), ), ), - // The trigger drives the popover through a controller - // rather than relying on `openOnTap`. `RemixPopover` - // opens on a tap of its own child, and a button with its - // own `onPressed` consumes that tap before the popover - // sees it — a trigger built the obvious way never opens. + // The button owns activation (`openOnTap: false`), so the + // popover adds no focus stop of its own; `triggerFocusNode` + // is the button's node, which closing returns focus to. AcmePopover( controller: _filters, + openOnTap: false, + triggerFocusNode: _filtersFocus, popoverChild: Text( 'Filters go here.', style: TextStyle(color: data.foreground), @@ -838,6 +847,7 @@ class _AcmeThemeSectionState extends State { semanticLabel: 'Filters', child: AcmeButton.outline( label: 'Filter', + focusNode: _filtersFocus, onPressed: () { _filters.isOpen ? _filters.close() : _filters.open(); _record('popover'); diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index a3d1de9e5..fc188a407 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix_chart/mix_chart.dart'; @@ -4979,10 +4980,8 @@ void main() { }); group('acmePopover opens', () { - // The gallery shipped a popover that could not open: `RemixPopover` opens - // on a tap of its own child, and the button used as the trigger consumed - // that tap first. Nothing failed — the panel simply never appeared, and - // the only assertion covering it checked that the trigger rendered. + // A popover that cannot open fails silently: the panel simply never + // appears. Each way of triggering it is opened for real. testWidgets('a plain child opens it on tap', (tester) async { await _pumpInScope( @@ -5004,31 +5003,46 @@ void main() { expect(find.text('Filters go here.'), findsOneWidget); }); - testWidgets('a button trigger needs a controller', (tester) async { + testWidgets('a button trigger is the only tab stop and gets focus back', ( + tester, + ) async { final controller = MenuController(); + final focusNode = FocusNode(debugLabel: 'filter'); + addTearDown(focusNode.dispose); await _pumpInScope( tester, _overlaid( AcmePopover( controller: controller, + openOnTap: false, + triggerFocusNode: focusNode, popoverChild: const Text('Filters go here.'), semanticLabel: 'Filters', child: AcmeButton.outline( label: 'Filter', - onPressed: () => controller.open(), + focusNode: focusNode, + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), ), ), ), ); await tester.pumpAndSettle(); - // The button consumes the tap, so `openOnTap` never fires — this is the - // trap. The controller is what makes the trigger work. + expect(_focusStops(tester, find.byType(AcmePopover)), 1); + await tester.tap(find.text('Filter')); await tester.pumpAndSettle(); expect(find.text('Filters go here.'), findsOneWidget); + expect(focusNode.hasFocus, isFalse, reason: 'focus moves into the panel'); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Filters go here.'), findsNothing); + expect(focusNode.hasFocus, isTrue, reason: 'focus returns to the button'); }); }); @@ -6008,6 +6022,20 @@ Widget _host(Widget child) => Widget _overlaid(Widget child) => Overlay(initialEntries: [OverlayEntry(builder: (_) => child)]); +/// How many focus stops sit inside the widget [root] finds. +int _focusStops(WidgetTester tester, Finder root) { + final rootElement = tester.element(root); + + return FocusManager.instance.rootScope.traversalDescendants.where((node) { + var inside = false; + node.context?.visitAncestorElements((ancestor) { + inside = identical(ancestor, rootElement); + return !inside; + }); + return inside; + }).length; +} + /// Renders [child] and hands its context back, so a test can read inherited /// values from exactly where a real widget would. class _Probe extends StatelessWidget { diff --git a/registry/vanilla/templates/popover/popover.dart.tmpl b/registry/vanilla/templates/popover/popover.dart.tmpl index e5a26b33a..c3182264c 100644 --- a/registry/vanilla/templates/popover/popover.dart.tmpl +++ b/registry/vanilla/templates/popover/popover.dart.tmpl @@ -19,20 +19,24 @@ part 'popover.g.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `{{typePrefix}}Button` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// {{typePrefix}}Popover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: {{typePrefix}}Button.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/registry_source/lib/src/default/components/popover.dart b/registry_source/lib/src/default/components/popover.dart index c67328e0f..da285b007 100644 --- a/registry_source/lib/src/default/components/popover.dart +++ b/registry_source/lib/src/default/components/popover.dart @@ -19,20 +19,24 @@ part 'popover.g.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// VanillaPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: VanillaButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) diff --git a/registry_source/lib/src/default/components/popover.g.dart b/registry_source/lib/src/default/components/popover.g.dart index 44bc00d09..d0fbcb244 100644 --- a/registry_source/lib/src/default/components/popover.g.dart +++ b/registry_source/lib/src/default/components/popover.g.dart @@ -18,20 +18,24 @@ part of 'popover.dart'; /// surface every floating panel shares: the page color in the light theme and a /// step lighter than the page in the dark one. /// -/// One composition trap worth knowing: `RemixPopover` opens on a tap of its -/// own `child`, so a trigger that handles its own taps never lets the popover -/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a -/// popover built the obvious way silently never opens. Drive it from a -/// `MenuController` when the trigger has to be a button: +/// `RemixPopover` opens on a tap of its own `child`, which a button with its +/// own `onPressed` consumes first. For a button trigger, set `openOnTap: +/// false`, toggle a `MenuController` from the button, and give the popover and +/// the button one `FocusNode`, so the button is the only tab stop and takes +/// focus back when the popover closes: /// /// ```dart /// final filters = MenuController(); +/// final filtersFocus = FocusNode(); /// /// VanillaPopover( /// controller: filters, +/// openOnTap: false, +/// triggerFocusNode: filtersFocus, /// popoverChild: const Text('Filters go here.'), /// child: VanillaButton.outline( /// label: 'Filter', +/// focusNode: filtersFocus, /// onPressed: () => filters.isOpen ? filters.close() : filters.open(), /// ), /// ) From d79e9600a3c531d16058d9cb2eee49298409fe80 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 16:06:14 -0400 Subject: [PATCH 13/17] fix(registry): give the Vanilla header popovers a real button trigger The notifications and theme settings popovers in the dashboard header held a VanillaIconButton with no onPressed inside a popover that kept the default openOnTap. The popover's own wrapper was the one focus stop and the one semantics node, so the styled button inside could never take focus: keyboard focus showed no ring, and the button's own label was hidden behind the popover's. A small stateful trigger now owns the button: openOnTap is false, the button toggles a MenuController, and one FocusNode, owned and disposed by that State, is both the popover's triggerFocusNode and the button's focusNode. The button is the only focus stop, rings on keyboard focus, names itself, reports the expanded state the popover's anchor puts on it, and gets focus back when the popover closes. The popover's semanticLabel, ignored with openOnTap false, is dropped. The dashboard test checks one focus stop, the button's label and expanded state, opening and closing from the button, and focus returning on Escape for both triggers; the ring test checks the ring pixels for both. --- .../ui/recipes/dashboard/dashboard_demo.dart | 104 +++++++++++++----- .../dashboard_demo/dashboard_demo.dart.tmpl | 103 ++++++++++++----- .../recipes/dashboard/dashboard_demo.dart | 103 ++++++++++++----- .../test/dashboard/dashboard_demo_test.dart | 101 +++++++++++++++-- .../test/vanilla/focus_ring_test.dart | 59 ++++++++-- 5 files changed, 361 insertions(+), 109 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart index ff49554d0..ea907f6c8 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart @@ -182,8 +182,10 @@ class _PlaygroundDashboardDemoState extends State { semanticLabel: _isDark ? 'Use light appearance' : 'Use dark appearance', onPressed: () => setState(() => _dark = !_isDark), ), - PlaygroundPopover( - semanticLabel: 'Notifications', + _PlaygroundHeaderPopover( + icon: RemixIcons.bell, + semanticLabel: 'Open notifications', + showBadge: !_notificationsRead, popoverChild: Box( style: BoxStyler() .width(PlaygroundTokens.popoverWidth) @@ -232,31 +234,10 @@ class _PlaygroundDashboardDemoState extends State { ], ), ), - child: StackBox( - style: StackBoxStyler().clipBehavior(.none), - children: [ - const PlaygroundIconButton.ghost( - icon: RemixIcons.bell, - semanticLabel: 'Open notifications', - ), - if (!_notificationsRead) - Positioned( - right: 8, - top: 8, - child: IgnorePointer( - child: Box( - style: BoxStyler() - .size(7, 7) - .borderRadius(.circular(99)) - .color(PlaygroundTokens.primary()), - ), - ), - ), - ], - ), ), - PlaygroundPopover( - semanticLabel: 'Theme settings', + _PlaygroundHeaderPopover( + icon: RemixIcons.gear, + semanticLabel: 'Open theme settings', popoverChild: Box( style: BoxStyler() .width(PlaygroundTokens.popoverWidth) @@ -288,10 +269,6 @@ class _PlaygroundDashboardDemoState extends State { ], ), ), - child: const PlaygroundIconButton.ghost( - icon: RemixIcons.gear, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -334,6 +311,73 @@ class _PlaygroundDashboardDemoState extends State { ); } +/// A header icon button that opens a popover. +/// +/// The button owns activation (`openOnTap: false`), so the popover adds no +/// focus stop of its own, and the one node shared with `triggerFocusNode` is +/// what closing returns focus to. +class _PlaygroundHeaderPopover extends StatefulWidget { + const _PlaygroundHeaderPopover({ + required this.icon, + required this.semanticLabel, + required this.popoverChild, + this.showBadge = false, + }); + + final IconData icon; + final String semanticLabel; + final Widget popoverChild; + final bool showBadge; + + @override + State<_PlaygroundHeaderPopover> createState() => + _PlaygroundHeaderPopoverState(); +} + +class _PlaygroundHeaderPopoverState extends State<_PlaygroundHeaderPopover> { + final _controller = MenuController(); + final _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PlaygroundPopover( + controller: _controller, + openOnTap: false, + triggerFocusNode: _focusNode, + popoverChild: widget.popoverChild, + child: StackBox( + style: StackBoxStyler().clipBehavior(.none), + children: [ + PlaygroundIconButton.ghost( + icon: widget.icon, + semanticLabel: widget.semanticLabel, + focusNode: _focusNode, + onPressed: () => + _controller.isOpen ? _controller.close() : _controller.open(), + ), + if (widget.showBadge) + Positioned( + right: 8, + top: 8, + child: IgnorePointer( + child: Box( + style: BoxStyler() + .size(7, 7) + .borderRadius(.circular(99)) + .color(PlaygroundTokens.primary()), + ), + ), + ), + ], + ), + ); +} + class _PlaygroundBrand extends StatelessWidget { const _PlaygroundBrand(); diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl index 6ad3dc6a9..7f24c1b53 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl @@ -180,8 +180,10 @@ class _{{typePrefix}}DashboardDemoState extends State<{{typePrefix}}DashboardDem semanticLabel: _isDark ? 'Use light appearance' : 'Use dark appearance', onPressed: () => setState(() => _dark = !_isDark), ), - {{typePrefix}}Popover( - semanticLabel: 'Notifications', + _{{typePrefix}}HeaderPopover( + icon: {{icon:bell}}, + semanticLabel: 'Open notifications', + showBadge: !_notificationsRead, popoverChild: Box( style: BoxStyler() .width({{typePrefix}}Tokens.popoverWidth) @@ -230,31 +232,10 @@ class _{{typePrefix}}DashboardDemoState extends State<{{typePrefix}}DashboardDem ], ), ), - child: StackBox( - style: StackBoxStyler().clipBehavior(.none), - children: [ - const {{typePrefix}}IconButton.ghost( - icon: {{icon:bell}}, - semanticLabel: 'Open notifications', - ), - if (!_notificationsRead) - Positioned( - right: 8, - top: 8, - child: IgnorePointer( - child: Box( - style: BoxStyler() - .size(7, 7) - .borderRadius(.circular(99)) - .color({{typePrefix}}Tokens.primary()), - ), - ), - ), - ], - ), ), - {{typePrefix}}Popover( - semanticLabel: 'Theme settings', + _{{typePrefix}}HeaderPopover( + icon: {{icon:gear}}, + semanticLabel: 'Open theme settings', popoverChild: Box( style: BoxStyler() .width({{typePrefix}}Tokens.popoverWidth) @@ -286,10 +267,6 @@ class _{{typePrefix}}DashboardDemoState extends State<{{typePrefix}}DashboardDem ], ), ), - child: const {{typePrefix}}IconButton.ghost( - icon: {{icon:gear}}, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -332,6 +309,72 @@ class _{{typePrefix}}DashboardDemoState extends State<{{typePrefix}}DashboardDem ); } +/// A header icon button that opens a popover. +/// +/// The button owns activation (`openOnTap: false`), so the popover adds no +/// focus stop of its own, and the one node shared with `triggerFocusNode` is +/// what closing returns focus to. +class _{{typePrefix}}HeaderPopover extends StatefulWidget { + const _{{typePrefix}}HeaderPopover({ + required this.icon, + required this.semanticLabel, + required this.popoverChild, + this.showBadge = false, + }); + + final IconData icon; + final String semanticLabel; + final Widget popoverChild; + final bool showBadge; + + @override + State<_{{typePrefix}}HeaderPopover> createState() => _{{typePrefix}}HeaderPopoverState(); +} + +class _{{typePrefix}}HeaderPopoverState extends State<_{{typePrefix}}HeaderPopover> { + final _controller = MenuController(); + final _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => {{typePrefix}}Popover( + controller: _controller, + openOnTap: false, + triggerFocusNode: _focusNode, + popoverChild: widget.popoverChild, + child: StackBox( + style: StackBoxStyler().clipBehavior(.none), + children: [ + {{typePrefix}}IconButton.ghost( + icon: widget.icon, + semanticLabel: widget.semanticLabel, + focusNode: _focusNode, + onPressed: () => + _controller.isOpen ? _controller.close() : _controller.open(), + ), + if (widget.showBadge) + Positioned( + right: 8, + top: 8, + child: IgnorePointer( + child: Box( + style: BoxStyler() + .size(7, 7) + .borderRadius(.circular(99)) + .color({{typePrefix}}Tokens.primary()), + ), + ), + ), + ], + ), + ); +} + class _{{typePrefix}}Brand extends StatelessWidget { const _{{typePrefix}}Brand(); diff --git a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart index 6c3aae659..2e69085e4 100644 --- a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart +++ b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart @@ -180,8 +180,10 @@ class _VanillaDashboardDemoState extends State { semanticLabel: _isDark ? 'Use light appearance' : 'Use dark appearance', onPressed: () => setState(() => _dark = !_isDark), ), - VanillaPopover( - semanticLabel: 'Notifications', + _VanillaHeaderPopover( + icon: RemixIcons.bell, + semanticLabel: 'Open notifications', + showBadge: !_notificationsRead, popoverChild: Box( style: BoxStyler() .width(VanillaTokens.popoverWidth) @@ -230,31 +232,10 @@ class _VanillaDashboardDemoState extends State { ], ), ), - child: StackBox( - style: StackBoxStyler().clipBehavior(.none), - children: [ - const VanillaIconButton.ghost( - icon: RemixIcons.bell, - semanticLabel: 'Open notifications', - ), - if (!_notificationsRead) - Positioned( - right: 8, - top: 8, - child: IgnorePointer( - child: Box( - style: BoxStyler() - .size(7, 7) - .borderRadius(.circular(99)) - .color(VanillaTokens.primary()), - ), - ), - ), - ], - ), ), - VanillaPopover( - semanticLabel: 'Theme settings', + _VanillaHeaderPopover( + icon: RemixIcons.gear, + semanticLabel: 'Open theme settings', popoverChild: Box( style: BoxStyler() .width(VanillaTokens.popoverWidth) @@ -286,10 +267,6 @@ class _VanillaDashboardDemoState extends State { ], ), ), - child: const VanillaIconButton.ghost( - icon: RemixIcons.gear, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -332,6 +309,72 @@ class _VanillaDashboardDemoState extends State { ); } +/// A header icon button that opens a popover. +/// +/// The button owns activation (`openOnTap: false`), so the popover adds no +/// focus stop of its own, and the one node shared with `triggerFocusNode` is +/// what closing returns focus to. +class _VanillaHeaderPopover extends StatefulWidget { + const _VanillaHeaderPopover({ + required this.icon, + required this.semanticLabel, + required this.popoverChild, + this.showBadge = false, + }); + + final IconData icon; + final String semanticLabel; + final Widget popoverChild; + final bool showBadge; + + @override + State<_VanillaHeaderPopover> createState() => _VanillaHeaderPopoverState(); +} + +class _VanillaHeaderPopoverState extends State<_VanillaHeaderPopover> { + final _controller = MenuController(); + final _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => VanillaPopover( + controller: _controller, + openOnTap: false, + triggerFocusNode: _focusNode, + popoverChild: widget.popoverChild, + child: StackBox( + style: StackBoxStyler().clipBehavior(.none), + children: [ + VanillaIconButton.ghost( + icon: widget.icon, + semanticLabel: widget.semanticLabel, + focusNode: _focusNode, + onPressed: () => + _controller.isOpen ? _controller.close() : _controller.open(), + ), + if (widget.showBadge) + Positioned( + right: 8, + top: 8, + child: IgnorePointer( + child: Box( + style: BoxStyler() + .size(7, 7) + .borderRadius(.circular(99)) + .color(VanillaTokens.primary()), + ), + ), + ), + ], + ), + ); +} + class _VanillaBrand extends StatelessWidget { const _VanillaBrand(); diff --git a/registry_source/test/dashboard/dashboard_demo_test.dart b/registry_source/test/dashboard/dashboard_demo_test.dart index 73fbb4818..b6f4b8195 100644 --- a/registry_source/test/dashboard/dashboard_demo_test.dart +++ b/registry_source/test/dashboard/dashboard_demo_test.dart @@ -13,6 +13,7 @@ import 'package:registry_source/src/dashboard/dashboard_demo_records.dart'; import 'package:registry_source/src/dashboard/dashboard_sample_data.dart'; import 'package:registry_source/src/default/components/icon_button.dart'; import 'package:registry_source/src/default/components/menu.dart'; +import 'package:registry_source/src/default/components/popover.dart'; import 'package:registry_source/src/default/recipes/dashboard/dashboard_demo.dart'; import 'package:registry_source/src/default/recipes/dashboard/dashboard_overview.dart'; import 'package:registry_source/src/default/theme/theme_data.dart'; @@ -136,6 +137,68 @@ void main() { semantics.dispose(); }); + for (final (label, panelText) in const [ + ('Open notifications', 'Order ORD-1047 requires review.'), + ('Open theme settings', 'Theme settings'), + ]) { + testWidgets('the $label button is its popover\'s one focus stop and ' + 'toggles it', (tester) async { + final semantics = tester.ensureSemantics(); + await _pumpDashboard(tester, preset: .defaultPreset); + final button = find.byWidgetPredicate( + (widget) => + widget is VanillaIconButton && widget.semanticLabel == label, + ); + final popover = find.ancestor( + of: button, + matching: find.byType(VanillaPopover), + ); + + expect(_focusStops(tester, popover), 1); + expect( + tester.getSemantics(button), + isSemantics( + label: label, + isButton: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.text(panelText), findsOneWidget); + expect( + tester.getSemantics(button), + isSemantics(label: label, hasExpandedState: true, isExpanded: true), + ); + + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.text(panelText), findsNothing, reason: 'it closes again'); + expect( + tester.getSemantics(button), + isSemantics(label: label, hasExpandedState: true, isExpanded: false), + ); + + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.text(panelText), findsOneWidget); + expect(_hasFocusWithin(tester, button), isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text(panelText), findsNothing); + expect(_hasFocusWithin(tester, button), isTrue); + semantics.dispose(); + }); + } + for (final preset in _Preset.values) { group(preset.name, () { testWidgets('overview renders coherent sample data', (tester) async { @@ -249,7 +312,11 @@ void main() { await tester.pump(); expect(find.bySemanticsLabel('Use light appearance'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('Notifications').first); + final notifications = switch (preset) { + .defaultPreset => 'Open notifications', + .fortal => 'Notifications', + }; + await tester.tap(find.bySemanticsLabel(notifications).first); await tester.pump(); expect( find.text(switch (preset) { @@ -268,7 +335,7 @@ void main() { ); expect(find.text('Order ORD-1047 requires review.'), findsNothing); } - await tester.tap(find.bySemanticsLabel('Notifications').first); + await tester.tap(find.bySemanticsLabel(notifications).first); await tester.pump(); await _pumpDashboard( @@ -822,7 +889,7 @@ void main() { ); expect(radiusOf(VanillaTokens.radiusLg), after); - await tester.tap(find.bySemanticsLabel('Theme settings')); + await tester.tap(find.bySemanticsLabel('Open theme settings')); await tester.pumpAndSettle(); expect(find.text('Theme settings'), findsOneWidget); expect(find.text('Accent color'), findsNothing); @@ -1001,12 +1068,24 @@ double _transformAngleIn(WidgetTester tester, Finder ancestor) { int _focusStops(WidgetTester tester, Finder root) { final rootElement = tester.element(root); - return FocusManager.instance.rootScope.traversalDescendants.where((node) { - var inside = false; - node.context?.visitAncestorElements((ancestor) { - inside = identical(ancestor, rootElement); - return !inside; - }); - return inside; - }).length; + return FocusManager.instance.rootScope.traversalDescendants + .where((node) => _isInside(node.context, rootElement)) + .length; +} + +/// Whether the primary focus sits inside [root]. +bool _hasFocusWithin(WidgetTester tester, Finder root) => _isInside( + FocusManager.instance.primaryFocus?.context, + tester.element(root), +); + +bool _isInside(BuildContext? context, Element root) { + if (context == null) return false; + var inside = identical(context, root); + context.visitAncestorElements((ancestor) { + inside = identical(ancestor, root); + return !inside; + }); + + return inside; } diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart index cb7a077a8..174e2f2ce 100644 --- a/registry_source/test/vanilla/focus_ring_test.dart +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -231,14 +231,7 @@ void main() { testWidgets('the dashboard account trigger rings outside itself', ( tester, ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 800); - addTearDown(tester.view.reset); - await _pumpFrame( - tester, - theme.data, - Overlay.wrap(child: const VanillaDashboardDemo(initialPage: .orders)), - ); + await _pumpDashboard(tester, theme.data); const workspace = 'Northstar workspace'; final probe = _Probe( tester, @@ -256,6 +249,32 @@ void main() { ); }); + for (final label in const ['Open notifications', 'Open theme settings']) + testWidgets('the dashboard $label button rings outside itself', ( + tester, + ) async { + await _pumpDashboard(tester, theme.data); + final button = find.byWidgetPredicate( + (widget) => + widget is VanillaIconButton && widget.semanticLabel == label, + ); + final popover = find.ancestor( + of: button, + matching: find.byType(VanillaPopover), + ); + final stop = FocusManager.instance.rootScope.traversalDescendants + .singleWhere( + (node) => _isInside(node.context, tester.element(popover)), + ); + final probe = _Probe(tester, theme.data, stop); + + await probe.expectRing( + part: button, + neighbor: find.byType(VanillaIconButton).first, + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + testWidgets('a disclosure rings its trigger, not the opened section', ( tester, ) async { @@ -318,6 +337,30 @@ Future<_Probe> _pump( return _Probe(tester, theme, focusNode); } +/// Pumps the built-in dashboard chrome on a desktop-sized view. +Future _pumpDashboard(WidgetTester tester, VanillaThemeData theme) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.reset); + + await _pumpFrame( + tester, + theme, + Overlay.wrap(child: const VanillaDashboardDemo(initialPage: .orders)), + ); +} + +bool _isInside(BuildContext? context, Element root) { + if (context == null) return false; + var inside = identical(context, root); + context.visitAncestorElements((ancestor) { + inside = identical(ancestor, root); + return !inside; + }); + + return inside; +} + /// Pumps [child] under [theme] inside the frame the probe captures. Future _pumpFrame( WidgetTester tester, From 142666557eec9fb407d5f333982cc263f7161217 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 17:36:57 -0400 Subject: [PATCH 14/17] fix(registry): keep the open Vanilla kebab's look under the pointer The kebab fills with muted while its menu is open, but Mix applies the active hover and press variants after the base, so with the pointer still on the button the icon turned accentForeground and, in the dark theme, the fill became accent at half strength. The open style now repeats its fill and muted glyph in its hover and press variants, so it stays in force under the pointer. The dashboard test opens the menu with a mouse that is already hovering the button and holds a press, in both themes. --- .../dashboard_demo_records_page.dart | 22 +++-- .../dashboard_demo_records_page.dart.tmpl | 22 +++-- .../dashboard/dashboard_demo_records.dart | 22 +++-- .../test/dashboard/dashboard_demo_test.dart | 83 ++++++++++++++++--- 4 files changed, 116 insertions(+), 33 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart index 28edda147..53d690632 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo_records_page.dart @@ -385,13 +385,7 @@ Widget _actions(BuildContext context, String value) => PlaygroundMenu( semanticLabel: 'Actions for $value', focusNode: menu.focusNode, onPressed: menu.toggle, - style: IconButtonStyler() - .icon(.color(PlaygroundTokens.mutedForeground())) - .merge( - menu.isOpen - ? IconButtonStyler().color(PlaygroundTokens.muted()) - : null, - ), + style: _kebabStyle(open: menu.isOpen), ), ), items: const [ @@ -404,6 +398,20 @@ Widget _actions(BuildContext context, String value) => PlaygroundMenu( playgroundDashboardToast(context, '$action selected for $value'), ); +/// The kebab: a muted glyph, filled with `muted` while its menu is open. Hover +/// and press come after the base in Mix, so the open look repeats there to stay +/// in force under the pointer. +IconButtonStyler _kebabStyle({required bool open}) { + final resting = IconButtonStyler().icon( + .color(PlaygroundTokens.mutedForeground()), + ); + if (!open) return resting; + + final filled = resting.color(PlaygroundTokens.muted()); + + return filled.onHovered(filled).onPressed(filled); +} + Widget _status(String value) => switch (value) { 'active' || 'paid' => PlaygroundBadge(label: value), 'suspended' || 'cancelled' => PlaygroundBadge.destructive(label: value), diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl index 49236fb42..10cddb3f5 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo_records_page.dart.tmpl @@ -382,13 +382,7 @@ Widget _actions(BuildContext context, String value) => {{typePrefix}}Menu {{typePrefix}}Menu switch (value) { 'active' || 'paid' => {{typePrefix}}Badge(label: value), 'suspended' || 'cancelled' => {{typePrefix}}Badge.destructive(label: value), diff --git a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart index 18693b926..7a412a7f1 100644 --- a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart +++ b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo_records.dart @@ -382,13 +382,7 @@ Widget _actions(BuildContext context, String value) => VanillaMenu( semanticLabel: 'Actions for $value', focusNode: menu.focusNode, onPressed: menu.toggle, - style: IconButtonStyler() - .icon(.color(VanillaTokens.mutedForeground())) - .merge( - menu.isOpen - ? IconButtonStyler().color(VanillaTokens.muted()) - : null, - ), + style: _kebabStyle(open: menu.isOpen), ), ), items: const [ @@ -401,6 +395,20 @@ Widget _actions(BuildContext context, String value) => VanillaMenu( vanillaDashboardToast(context, '$action selected for $value'), ); +/// The kebab: a muted glyph, filled with `muted` while its menu is open. Hover +/// and press come after the base in Mix, so the open look repeats there to stay +/// in force under the pointer. +IconButtonStyler _kebabStyle({required bool open}) { + final resting = IconButtonStyler().icon( + .color(VanillaTokens.mutedForeground()), + ); + if (!open) return resting; + + final filled = resting.color(VanillaTokens.muted()); + + return filled.onHovered(filled).onPressed(filled); +} + Widget _status(String value) => switch (value) { 'active' || 'paid' => VanillaBadge(label: value), 'suspended' || 'cancelled' => VanillaBadge.destructive(label: value), diff --git a/registry_source/test/dashboard/dashboard_demo_test.dart b/registry_source/test/dashboard/dashboard_demo_test.dart index b6f4b8195..c01947794 100644 --- a/registry_source/test/dashboard/dashboard_demo_test.dart +++ b/registry_source/test/dashboard/dashboard_demo_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -80,18 +81,7 @@ void main() { matching: find.byType(VanillaMenu), ); - Color? fill() { - final provider = tester.widget>( - find - .descendant( - of: button, - matching: find.byType(StyleSpecProvider), - ) - .first, - ); - return (provider.spec.spec.container.spec.decoration as BoxDecoration?) - ?.color; - } + Color? fill() => _fillOf(_specOf(tester, button)); final muted = VanillaTheme.of(tester.element(button)).muted; @@ -137,6 +127,59 @@ void main() { semantics.dispose(); }); + for (final dark in const [false, true]) { + testWidgets('the open kebab keeps its look under the pointer in ' + '${dark ? 'dark' : 'light'}', (tester) async { + await _pumpDashboard( + tester, + preset: .defaultPreset, + initialPage: RegistryDashboardDemoPage.orders, + ); + if (dark) { + await tester.tap( + find.byKey(const ValueKey('dashboard-appearance-toggle')).first, + ); + await tester.pumpAndSettle(); + } + final button = find.byWidgetPredicate( + (widget) => + widget is VanillaIconButton && + widget.semanticLabel == 'Actions for ORD-1048', + ); + final theme = VanillaTheme.of(tester.element(button)); + expect(theme.brightness, dark ? Brightness.dark : Brightness.light); + + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(pointer.removePointer); + await pointer.addPointer(location: Offset.zero); + await pointer.moveTo(tester.getCenter(button)); + await tester.pumpAndSettle(); + final hovered = _specOf(tester, button); + expect( + hovered.icon.spec.color, + theme.accentForeground, + reason: 'the pointer is on the closed button', + ); + + await pointer.down(tester.getCenter(button)); + await pointer.up(); + await tester.pumpAndSettle(); + + expect(find.text('Archive'), findsOneWidget); + final open = _specOf(tester, button); + expect(_fillOf(open), theme.muted); + expect(open.icon.spec.color, theme.mutedForeground); + + await pointer.down(tester.getCenter(button)); + await tester.pump(); + final pressed = _specOf(tester, button); + expect(_fillOf(pressed), theme.muted, reason: 'pressed while open'); + expect(pressed.icon.spec.color, theme.mutedForeground); + await pointer.up(); + await tester.pumpAndSettle(); + }); + } + for (final (label, panelText) in const [ ('Open notifications', 'Order ORD-1047 requires review.'), ('Open theme settings', 'Theme settings'), @@ -1064,6 +1107,22 @@ double _transformAngleIn(WidgetTester tester, Finder ancestor) { .abs(); } +/// The spec the icon button under [button] resolved. +IconButtonSpec _specOf(WidgetTester tester, Finder button) => tester + .widget>( + find + .descendant( + of: button, + matching: find.byType(StyleSpecProvider), + ) + .first, + ) + .spec + .spec; + +Color? _fillOf(IconButtonSpec spec) => + (spec.container.spec.decoration as BoxDecoration?)?.color; + /// How many focus stops sit inside [root]. int _focusStops(WidgetTester tester, Finder root) { final rootElement = tester.element(root); From d8541921af3b1d686524c10667126c94ba68d049 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 17:37:09 -0400 Subject: [PATCH 15/17] fix(registry): turn the Vanilla account row's border to ring on focus The account row is bordered, and a bordered control turns its own border ring while it has focus, as the buttons, the text field, the select trigger, and the outline toggle do. The row's focus fragment drew only the ring band, so the border stayed its resting color. The focus fragment now also sets vanillaFocusBorder(). Only the color changes, so the row keeps its box. The ring test checks the border pixel turns ring over the row's card fill. --- .../ui/recipes/dashboard/dashboard_demo.dart | 4 +++- .../dashboard_demo/dashboard_demo.dart.tmpl | 6 ++++- .../recipes/dashboard/dashboard_demo.dart | 6 ++++- .../test/vanilla/focus_ring_test.dart | 22 ++++++++++++++----- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart index ea907f6c8..b875185bf 100644 --- a/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart +++ b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart @@ -442,7 +442,9 @@ class _PlaygroundAccount extends StatelessWidget { .border(.all(.color(PlaygroundTokens.border()))) .color(PlaygroundTokens.card()) .onFocusVisible( - CardStyler().containerEffects(playgroundFocusRing()), + CardStyler() + .containerEffects(playgroundFocusRing()) + .border(playgroundFocusBorder()), ), child: RowBox( style: FlexBoxStyler() diff --git a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl index 7f24c1b53..65bb80ec2 100644 --- a/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl +++ b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl @@ -438,7 +438,11 @@ class _{{typePrefix}}Account extends StatelessWidget { .borderRadius(.all({{typePrefix}}Tokens.radiusMd())) .border(.all(.color({{typePrefix}}Tokens.border()))) .color({{typePrefix}}Tokens.card()) - .onFocusVisible(CardStyler().containerEffects({{valuePrefix}}FocusRing())), + .onFocusVisible( + CardStyler() + .containerEffects({{valuePrefix}}FocusRing()) + .border({{valuePrefix}}FocusBorder()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) diff --git a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart index 2e69085e4..74579bd57 100644 --- a/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart +++ b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart @@ -438,7 +438,11 @@ class _VanillaAccount extends StatelessWidget { .borderRadius(.all(VanillaTokens.radiusMd())) .border(.all(.color(VanillaTokens.border()))) .color(VanillaTokens.card()) - .onFocusVisible(CardStyler().containerEffects(vanillaFocusRing())), + .onFocusVisible( + CardStyler() + .containerEffects(vanillaFocusRing()) + .border(vanillaFocusBorder()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart index 174e2f2ce..1da7ae0d0 100644 --- a/registry_source/test/vanilla/focus_ring_test.dart +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -239,14 +239,22 @@ void main() { Focus.of(tester.element(find.text(workspace))), ); - await probe.expectRing( - part: find.ancestor( - of: find.text(workspace), - matching: find.byType(VanillaMenu), - ), + final row = find.ancestor( + of: find.text(workspace), + matching: find.byType(VanillaMenu), + ); + + final focused = await probe.expectRing( + part: row, neighbor: find.text(workspace), includes: (part) => [part.centerLeft.translate(-1.5, 0)], ); + + expect( + focused.colorAt(tester.getRect(row).centerLeft.translate(0.5, 0)), + _isColor(Color.alphaBlend(theme.data.ring, theme.data.card)), + reason: 'the row\'s own border turns ring', + ); }); for (final label in const ['Open notifications', 'Open theme settings']) @@ -396,7 +404,7 @@ final class _Probe { /// [includes] are points, in global coordinates, where the ring must be /// painted: each pixel there turns into the ring's color laid over what it /// showed before. At each of [excludes] the pixel must not change at all. - Future expectRing({ + Future<_Pixels> expectRing({ required Finder part, Finder? neighbor, Iterable Function(Rect part) includes = _none, @@ -438,6 +446,8 @@ final class _Probe { reason: 'the ring stays off $point', ); } + + return after; } Future<_Pixels> _capture() async { From 2d0b982ff01648c194fec795bb433c4fb9ef864e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 17:37:09 -0400 Subject: [PATCH 16/17] feat(registry): draw the Vanilla outline toggle's shadow-xs The reference's outline toggle carries shadow-xs, and the toggle spec now has an effects layer that cuts the box out of its shadow the way CSS does, as the outline icon button, radio, and switch already use. The outline variant draws the xs shadow through containerEffects behindContent, and keeps it while the ring is drawn on focus. The spec targets read the shadow for the outline variant, no shadow for the ghost variant, and both the shadow and the ring when focused. The deviation row now covers only the toggle group strip, which has no effects layer. --- apps/playground/lib/ui/components/toggle.dart | 3 ++ .../lib/ui/components/toggle.dart | 3 ++ .../vanilla/templates/toggle/toggle.dart.tmpl | 3 ++ .../lib/src/default/components/toggle.dart | 3 ++ registry_source/specs/vanilla.md | 4 +-- .../test/vanilla/vanilla_spec.dart | 32 ++++++++++++++++--- 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/playground/lib/ui/components/toggle.dart b/apps/playground/lib/ui/components/toggle.dart index 026ef2d6f..b08ec76cb 100644 --- a/apps/playground/lib/ui/components/toggle.dart +++ b/apps/playground/lib/ui/components/toggle.dart @@ -125,6 +125,9 @@ ToggleStyler _variantStyle(PlaygroundToggleVariant variant) => PlaygroundTokens.input(), ).width(PlaygroundTokens.strokeHairline), ) + // In the effects layer rather than the decoration: the toggle is + // transparent, and a decoration shadow would show through it. + .containerEffects(.behindContent(PlaygroundShadow.xs.effects)) .onHovered( _content( PlaygroundTokens.accentForeground(), diff --git a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart index fce50df64..a41565c58 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart @@ -108,6 +108,9 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .border( .color(VanillaTokens.input()).width(VanillaTokens.strokeHairline), ) + // In the effects layer rather than the decoration: the toggle is + // transparent, and a decoration shadow would show through it. + .containerEffects(.behindContent(VanillaShadow.xs.effects)) .onHovered( _content( VanillaTokens.accentForeground(), diff --git a/registry/vanilla/templates/toggle/toggle.dart.tmpl b/registry/vanilla/templates/toggle/toggle.dart.tmpl index bdd0e9590..dd381e0f1 100644 --- a/registry/vanilla/templates/toggle/toggle.dart.tmpl +++ b/registry/vanilla/templates/toggle/toggle.dart.tmpl @@ -108,6 +108,9 @@ ToggleStyler _variantStyle({{typePrefix}}ToggleVariant variant) => switch (varia .border( .color({{typePrefix}}Tokens.input()).width({{typePrefix}}Tokens.strokeHairline), ) + // In the effects layer rather than the decoration: the toggle is + // transparent, and a decoration shadow would show through it. + .containerEffects(.behindContent({{typePrefix}}Shadow.xs.effects)) .onHovered( _content( {{typePrefix}}Tokens.accentForeground(), diff --git a/registry_source/lib/src/default/components/toggle.dart b/registry_source/lib/src/default/components/toggle.dart index fce50df64..a41565c58 100644 --- a/registry_source/lib/src/default/components/toggle.dart +++ b/registry_source/lib/src/default/components/toggle.dart @@ -108,6 +108,9 @@ ToggleStyler _variantStyle(VanillaToggleVariant variant) => switch (variant) { .border( .color(VanillaTokens.input()).width(VanillaTokens.strokeHairline), ) + // In the effects layer rather than the decoration: the toggle is + // transparent, and a decoration shadow would show through it. + .containerEffects(.behindContent(VanillaShadow.xs.effects)) .onHovered( _content( VanillaTokens.accentForeground(), diff --git a/registry_source/specs/vanilla.md b/registry_source/specs/vanilla.md index 37ed4a56c..b7716e665 100644 --- a/registry_source/specs/vanilla.md +++ b/registry_source/specs/vanilla.md @@ -144,7 +144,7 @@ the steps above. The source column names the file under | Icon button | 32/36/40 squares, icon 16, the button's fills | `button.tsx` (`icon-*` sizes) | | Link | `text-sm`, underline on hover | `button.tsx` (`link`) | | Badge | radius full; 1px border on every variant, transparent except outline; `text-xs` w500; px 8, py 2 | `badge.tsx` | -| Toggle | h 32/36/40, min-w equal to h; px 6/8/10; `text-sm` w500; hover muted + muted-fg; on accent + accent-fg; outline adds an `input` border | `toggle.tsx` | +| Toggle | h 32/36/40, min-w equal to h; px 6/8/10; `text-sm` w500; hover muted + muted-fg; on accent + accent-fg; outline adds an `input` border and `shadow-xs` | `toggle.tsx` | | Toggle group | items px 12, radius 0 inside a clipped md container, gap 0; outline adds the border | `toggle-group.tsx` | | Text field | h 36; px 12, py 4; border `input`, transparent fill, `shadow-xs`; `text-sm`; placeholder muted-fg | `input.tsx` | | Text area | min-h 64; px 12, py 8 | `textarea.tsx` | @@ -192,7 +192,7 @@ the steps above. The source column names the file under | Checkbox, select, and menu 9×9 glyphs | `size-3.5` (14) / `size-4` (16) | 10 (`VanillaTokens.s2_5`) | The reference sizes padded 24×24 Lucide icons; Remix draws edge-to-edge 9×9 paths (`thickCheck`, `chevronDown`, `thickChevronRight`), which match the painted mark at 10. | | Select item horizontal padding | `pl-2 pr-8` (8 / 32) | `px-2` (8) | CSS positions the check mark inside the 32px right gutter (`right-2`); Remix lays the check mark out in the row after the label, so `px-2` places it 8px from the trailing edge. | | Toggle group, outline | per-item borders sharing edges | one border on the clipped group | A recipe cannot tell the first and last items apart. | -| Toggle and toggle group, outline | `shadow-xs` | no shadow | Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that draw `shadow-xs` in the effects layer cut the box out as CSS does; the toggle group's strip has no effects layer, and the toggle's outline variant has one but does not draw the shadow there yet. | +| Toggle group, outline | `shadow-xs` | no shadow | Flutter paints a decoration shadow under the whole box, where it shows through a transparent fill as a gray wash. Controls that draw `shadow-xs` in the effects layer cut the box out as CSS does, but the strip has no effects layer. | | Chart palette | gray ramp | gray ramp | The reference's lightest series measures about 1.5:1 on the light page, and the darkest two (`chart4`, `chart5`) about 1.3 to 1.8:1 on the dark page and card; Vanilla keeps the palette and picks each pie label from `foreground` or `background` by contrast (`vanillaPieSliceLabelColor`). | | Focus ring | `ring/50`, 3px | `ring` at 50%, 3px | Matches the reference, which is below the 3:1 non-text floor: about 1.5:1 on the light page and 1.85:1 on the dark. The old Vanilla `focusRing` cleared 3:1. Raise the band's alpha in the recipes, or darken `ring`, where focus visibility matters more than matching. | | Toggle group option focus | band drawn outside, the focused item lifted with `focus-visible:z-10` | the same 3px band drawn inside the option | The group clips its options to the rounded strip, which would crop an outside band on the first and last option, and a filled neighbor painted after the focused option would cover its side of one. A recipe cannot lift an option above its neighbors, so the band goes inside. | diff --git a/registry_source/test/vanilla/vanilla_spec.dart b/registry_source/test/vanilla/vanilla_spec.dart index 51a25e3d0..e83b42ba0 100644 --- a/registry_source/test/vanilla/vanilla_spec.dart +++ b/registry_source/test/vanilla/vanilla_spec.dart @@ -592,13 +592,35 @@ final _toggleTargets = [ read: (spec) => flexDecorationOf(spec.spec.container)?.border, expected: (theme) => _hairline(theme.input), ), - // Deviation: no `shadow-xs` (see specs/vanilla.md). _toggle( - 'outline has no decoration shadow', + 'outline shadow', variant: .outline, - source: 'toggle.tsx: outline, shadow-xs (see specs/vanilla.md)', - read: (spec) => flexDecorationOf(spec.spec.container)?.boxShadow, - expected: (_) => anyOf(isNull, isEmpty), + source: 'toggle.tsx: outline, shadow-xs', + read: (spec) => ( + flexDecorationOf(spec.spec.container)?.boxShadow, + spec.spec.containerEffects?.behindContent?.shadows, + ), + expected: (_) => (anyOf(isNull, isEmpty), _effectShadows(VanillaShadow.xs)), + ), + _toggle( + 'ghost has no shadow', + source: 'toggle.tsx: default variant has no shadow', + read: (spec) => ( + flexDecorationOf(spec.spec.container)?.boxShadow, + spec.spec.containerEffects?.behindContent?.shadows, + ), + expected: (_) => (anyOf(isNull, isEmpty), anyOf(isNull, isEmpty)), + ), + _toggle( + 'outline keeps its shadow while focused', + variant: .outline, + states: const {WidgetState.focused}, + source: 'toggle.tsx: shadow-xs and focus-visible:ring-[3px] together', + read: (spec) => ( + spec.spec.containerEffects?.behindContent?.shadows, + spec.spec.containerEffects?.outline, + ), + expected: (theme) => (_effectShadows(VanillaShadow.xs), _focusRing(theme)), ), _toggle( 'outline hover', From 615d647c69fce716f4fa4a6c482d1566b18de194 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 27 Oct 2026 17:37:09 -0400 Subject: [PATCH 17/17] docs(registry): show who owns the popover's focus node, and tidy its tests The popover recipe's doc example now says the controller and focus node live in a State and the node is disposed there, as the Naked UI example does, with the installed copies regenerated. The Vanilla reference gains a Popover bullet for the openOnTap false pattern. The fixture popover drops a semanticLabel that openOnTap false ignores. The header popover ring test checks the adjacent appearance toggle stays put rather than a button across the header, and a probe comment now says the captured frame has one pixel per logical pixel. --- .../playground/lib/ui/components/popover.dart | 39 ++++++++++++------- .../lib/ui/components/popover.g.dart | 39 ++++++++++++------- .../lib/ui/components/popover.dart | 39 ++++++++++++------- .../lib/ui/components/popover.g.dart | 39 ++++++++++++------- open_code/fixture/lib/main.dart | 1 - open_code/fixture/test/open_code_test.dart | 1 - .../templates/popover/popover.dart.tmpl | 39 ++++++++++++------- .../lib/src/default/components/popover.dart | 39 ++++++++++++------- .../lib/src/default/components/popover.g.dart | 39 ++++++++++++------- .../test/vanilla/focus_ring_test.dart | 6 ++- skills/using-remix/references/vanilla.md | 6 +++ 11 files changed, 185 insertions(+), 102 deletions(-) diff --git a/apps/playground/lib/ui/components/popover.dart b/apps/playground/lib/ui/components/popover.dart index 912995f8f..208e7c6d1 100644 --- a/apps/playground/lib/ui/components/popover.dart +++ b/apps/playground/lib/ui/components/popover.dart @@ -23,23 +23,34 @@ part 'popover.g.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// PlaygroundPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: PlaygroundButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => PlaygroundPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: PlaygroundButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` @MixWidget(target: RemixPopover.new) PopoverStyler playgroundPopoverStyle({ diff --git a/apps/playground/lib/ui/components/popover.g.dart b/apps/playground/lib/ui/components/popover.g.dart index eed7d5c60..5e81540eb 100644 --- a/apps/playground/lib/ui/components/popover.g.dart +++ b/apps/playground/lib/ui/components/popover.g.dart @@ -22,23 +22,34 @@ part of 'popover.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// PlaygroundPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: PlaygroundButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => PlaygroundPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: PlaygroundButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` class PlaygroundPopover extends StatelessWidget { const PlaygroundPopover({ diff --git a/apps/widgetbook_vanilla/lib/ui/components/popover.dart b/apps/widgetbook_vanilla/lib/ui/components/popover.dart index da285b007..4f43dae72 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.dart @@ -23,23 +23,34 @@ part 'popover.g.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => VanillaPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` @MixWidget(target: RemixPopover.new) PopoverStyler vanillaPopoverStyle({ diff --git a/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart b/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart index d0fbcb244..dbb33bf9d 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart @@ -22,23 +22,34 @@ part of 'popover.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => VanillaPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` class VanillaPopover extends StatelessWidget { const VanillaPopover({ diff --git a/open_code/fixture/lib/main.dart b/open_code/fixture/lib/main.dart index ba2983264..052d14989 100644 --- a/open_code/fixture/lib/main.dart +++ b/open_code/fixture/lib/main.dart @@ -844,7 +844,6 @@ class _AcmeThemeSectionState extends State { 'Filters go here.', style: TextStyle(color: data.foreground), ), - semanticLabel: 'Filters', child: AcmeButton.outline( label: 'Filter', focusNode: _filtersFocus, diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index fc188a407..09b46da60 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -5018,7 +5018,6 @@ void main() { openOnTap: false, triggerFocusNode: focusNode, popoverChild: const Text('Filters go here.'), - semanticLabel: 'Filters', child: AcmeButton.outline( label: 'Filter', focusNode: focusNode, diff --git a/registry/vanilla/templates/popover/popover.dart.tmpl b/registry/vanilla/templates/popover/popover.dart.tmpl index c3182264c..4cce14d34 100644 --- a/registry/vanilla/templates/popover/popover.dart.tmpl +++ b/registry/vanilla/templates/popover/popover.dart.tmpl @@ -23,23 +23,34 @@ part 'popover.g.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// {{typePrefix}}Popover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: {{typePrefix}}Button.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => {{typePrefix}}Popover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: {{typePrefix}}Button.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` @MixWidget(target: RemixPopover.new) PopoverStyler {{valuePrefix}}PopoverStyle({ diff --git a/registry_source/lib/src/default/components/popover.dart b/registry_source/lib/src/default/components/popover.dart index da285b007..4f43dae72 100644 --- a/registry_source/lib/src/default/components/popover.dart +++ b/registry_source/lib/src/default/components/popover.dart @@ -23,23 +23,34 @@ part 'popover.g.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => VanillaPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` @MixWidget(target: RemixPopover.new) PopoverStyler vanillaPopoverStyle({ diff --git a/registry_source/lib/src/default/components/popover.g.dart b/registry_source/lib/src/default/components/popover.g.dart index d0fbcb244..dbb33bf9d 100644 --- a/registry_source/lib/src/default/components/popover.g.dart +++ b/registry_source/lib/src/default/components/popover.g.dart @@ -22,23 +22,34 @@ part of 'popover.dart'; /// own `onPressed` consumes first. For a button trigger, set `openOnTap: /// false`, toggle a `MenuController` from the button, and give the popover and /// the button one `FocusNode`, so the button is the only tab stop and takes -/// focus back when the popover closes: +/// focus back when the popover closes. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); -/// final filtersFocus = FocusNode(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// openOnTap: false, -/// triggerFocusNode: filtersFocus, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// focusNode: filtersFocus, -/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), -/// ), -/// ) +/// @override +/// void dispose() { +/// _triggerFocus.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) => VanillaPopover( +/// controller: _controller, +/// openOnTap: false, +/// triggerFocusNode: _triggerFocus, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// focusNode: _triggerFocus, +/// onPressed: () => +/// _controller.isOpen ? _controller.close() : _controller.open(), +/// ), +/// ); +/// } /// ``` class VanillaPopover extends StatelessWidget { const VanillaPopover({ diff --git a/registry_source/test/vanilla/focus_ring_test.dart b/registry_source/test/vanilla/focus_ring_test.dart index 1da7ae0d0..801254e4a 100644 --- a/registry_source/test/vanilla/focus_ring_test.dart +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -278,7 +278,9 @@ void main() { await probe.expectRing( part: button, - neighbor: find.byType(VanillaIconButton).first, + neighbor: find + .byKey(const ValueKey('dashboard-appearance-toggle')) + .first, includes: (part) => [part.centerLeft.translate(-1.5, 0)], ); }); @@ -468,7 +470,7 @@ final class _Probe { Iterable _none(Rect part) => const []; -/// A frame of logical pixels, one per physical pixel. +/// A frame captured at one pixel per logical pixel. final class _Pixels { const _Pixels(this.width, this._rgba); diff --git a/skills/using-remix/references/vanilla.md b/skills/using-remix/references/vanilla.md index 092f5fa3e..106a907dd 100644 --- a/skills/using-remix/references/vanilla.md +++ b/skills/using-remix/references/vanilla.md @@ -124,6 +124,12 @@ families carry over. A `RemixMenuTrigger.builder` replaces the styled trigger, ring included, so its content draws its own `onFocusVisible` ring, as the dashboard's account row does with `RemixCard` and `FocusRing()`. +- **Popover** — `Popover` opens on a tap of its own child, which a + button with its own `onPressed` consumes. To open it from a real button, set + `openOnTap: false`, toggle a `MenuController` from the button, and pass one + `FocusNode` as the popover's `triggerFocusNode` and the button's `focusNode`. + Keep the controller and node in a `State` and dispose the node there. The + button is then the only tab stop and gets focus back when the popover closes. - **Toast** — style lives on the scope: `RemixToastScope(style: uiToastStyle(), child: ...)`. A per-toast `RemixToastData.style` (for example `uiToastStyle(variant: .destructive)`)