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/popover.dart b/apps/playground/lib/ui/components/popover.dart index 2873b6058..208e7c6d1 100644 --- a/apps/playground/lib/ui/components/popover.dart +++ b/apps/playground/lib/ui/components/popover.dart @@ -19,23 +19,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// PlaygroundPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: PlaygroundButton.outline( -/// label: 'Filter', -/// 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 7638a6dfd..5e81540eb 100644 --- a/apps/playground/lib/ui/components/popover.g.dart +++ b/apps/playground/lib/ui/components/popover.g.dart @@ -18,23 +18,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// PlaygroundPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: PlaygroundButton.outline( -/// label: 'Filter', -/// 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/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/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/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/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..b08ec76cb 100644 --- a/apps/playground/lib/ui/components/toggle.dart +++ b/apps/playground/lib/ui/components/toggle.dart @@ -111,29 +111,29 @@ 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: `ToggleSpec` has no effects layer, and 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), + ) + // 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(), + ).color(PlaygroundTokens.accent()), + ), + }; /// Applies one content color to the label and the icons. ToggleStyler _content(Color foreground) => @@ -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/recipes/dashboard/dashboard_demo.dart b/apps/playground/lib/ui/recipes/dashboard/dashboard_demo.dart index cf593a2bb..b875185bf 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'; @@ -181,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) @@ -231,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) @@ -287,10 +269,6 @@ class _PlaygroundDashboardDemoState extends State { ], ), ), - child: const PlaygroundIconButton.ghost( - icon: RemixIcons.gear, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -333,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(); @@ -385,8 +430,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 +440,12 @@ class _PlaygroundAccount extends StatelessWidget { ) .borderRadius(.all(PlaygroundTokens.radiusMd())) .border(.all(.color(PlaygroundTokens.border()))) - .color(PlaygroundTokens.card()), + .color(PlaygroundTokens.card()) + .onFocusVisible( + CardStyler() + .containerEffects(playgroundFocusRing()) + .border(playgroundFocusBorder()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) 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..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 @@ -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,24 +378,14 @@ 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: _kebabStyle(open: menu.isOpen), ), ), items: const [ @@ -407,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/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/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/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/popover.dart b/apps/widgetbook_vanilla/lib/ui/components/popover.dart index c67328e0f..4f43dae72 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.dart @@ -19,23 +19,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// 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 44bc00d09..dbb33bf9d 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/popover.g.dart @@ -18,23 +18,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// 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/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/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/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/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..a41565c58 100644 --- a/apps/widgetbook_vanilla/lib/ui/components/toggle.dart +++ b/apps/widgetbook_vanilla/lib/ui/components/toggle.dart @@ -103,13 +103,14 @@ 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. .outline => ToggleStyler() .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(), @@ -123,16 +124,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 +142,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/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/docs/open-code.mdx b/docs/open-code.mdx index def41982b..942da80c0 100644 --- a/docs/open-code.mdx +++ b/docs/open-code.mdx @@ -594,7 +594,13 @@ 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 + 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/open_code/fixture/lib/main.dart b/open_code/fixture/lib/main.dart index af78b68a3..052d14989 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 @@ -661,7 +670,7 @@ class _AcmeThemeSectionState extends State { children: [ AcmeSwitch( selected: _notifications, - semanticLabel: 'Notifications', + label: 'Notifications', onChanged: (value) => setState(() { _notifications = value; _lastAction = 'switch -> $value'; @@ -670,7 +679,7 @@ class _AcmeThemeSectionState extends State { const AcmeSwitch( selected: true, enabled: false, - semanticLabel: 'Disabled switch', + label: 'Disabled switch', ), ], ), @@ -690,20 +699,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, ), ], @@ -834,20 +833,20 @@ 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), ), - 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 c3e36c424..09b46da60 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'; @@ -2122,12 +2123,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 +2135,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 +2153,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 +3495,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 +3527,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 +3553,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', ( @@ -3706,6 +3693,32 @@ 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(); + + expect( + tester.getSemantics(find.bySemanticsLabel('Notifications')), + isSemantics(hasEnabledState: true, isEnabled: true, isToggled: false), + ); + + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + + expect(changes, [true]); + handle.dispose(); + }); }); group('acmeRadioStyle', () { @@ -3780,8 +3793,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'), ], ), ), @@ -3791,7 +3804,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.bySemanticsLabel('Pro')); + await tester.tap(find.text('Pro')); await tester.pumpAndSettle(); expect(chosen, 'pro'); @@ -4967,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( @@ -4992,31 +5003,45 @@ 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'); }); }); @@ -5286,7 +5311,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 +5323,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 +5345,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 +5960,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), @@ -5998,6 +6021,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 { @@ -6330,9 +6367,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/dashboard_demo/dashboard_demo.dart.tmpl b/registry/vanilla/templates/dashboard_demo/dashboard_demo.dart.tmpl index 69308a967..65bb80ec2 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'; @@ -179,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) @@ -229,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) @@ -285,10 +267,6 @@ class _{{typePrefix}}DashboardDemoState extends State<{{typePrefix}}DashboardDem ], ), ), - child: const {{typePrefix}}IconButton.ghost( - icon: {{icon:gear}}, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -331,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(); @@ -383,8 +427,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 +437,12 @@ 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()) + .border({{valuePrefix}}FocusBorder()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) 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..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 @@ -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: _kebabStyle(open: menu.isOpen), ), ), items: const [ @@ -404,6 +395,20 @@ Widget _actions(BuildContext context, String value) => {{typePrefix}}Menu switch (value) { 'active' || 'paid' => {{typePrefix}}Badge(label: value), 'suspended' || 'cancelled' => {{typePrefix}}Badge.destructive(label: value), 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/popover/popover.dart.tmpl b/registry/vanilla/templates/popover/popover.dart.tmpl index e5a26b33a..4cce14d34 100644 --- a/registry/vanilla/templates/popover/popover.dart.tmpl +++ b/registry/vanilla/templates/popover/popover.dart.tmpl @@ -19,23 +19,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// {{typePrefix}}Popover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: {{typePrefix}}Button.outline( -/// label: 'Filter', -/// 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/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/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/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/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..dd381e0f1 100644 --- a/registry/vanilla/templates/toggle/toggle.dart.tmpl +++ b/registry/vanilla/templates/toggle/toggle.dart.tmpl @@ -103,13 +103,14 @@ 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. .outline => ToggleStyler() .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(), @@ -123,16 +124,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 +142,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/popover.dart b/registry_source/lib/src/default/components/popover.dart index c67328e0f..4f43dae72 100644 --- a/registry_source/lib/src/default/components/popover.dart +++ b/registry_source/lib/src/default/components/popover.dart @@ -19,23 +19,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// 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 44bc00d09..dbb33bf9d 100644 --- a/registry_source/lib/src/default/components/popover.g.dart +++ b/registry_source/lib/src/default/components/popover.g.dart @@ -18,23 +18,38 @@ 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. Keep the controller and the node in a +/// `State` and dispose the node there: /// /// ```dart -/// final filters = MenuController(); +/// class _FiltersState extends State { +/// final _controller = MenuController(); +/// final _triggerFocus = FocusNode(); /// -/// VanillaPopover( -/// controller: filters, -/// popoverChild: const Text('Filters go here.'), -/// child: VanillaButton.outline( -/// label: 'Filter', -/// 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/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/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/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/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..a41565c58 100644 --- a/registry_source/lib/src/default/components/toggle.dart +++ b/registry_source/lib/src/default/components/toggle.dart @@ -103,13 +103,14 @@ 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. .outline => ToggleStyler() .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(), @@ -123,16 +124,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 +142,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/recipes/dashboard/dashboard_demo.dart b/registry_source/lib/src/default/recipes/dashboard/dashboard_demo.dart index f2385167f..74579bd57 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'; @@ -179,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) @@ -229,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) @@ -285,10 +267,6 @@ class _VanillaDashboardDemoState extends State { ], ), ), - child: const VanillaIconButton.ghost( - icon: RemixIcons.gear, - semanticLabel: 'Open theme settings', - ), ), ]; @@ -331,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(); @@ -383,8 +427,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 +437,12 @@ class _VanillaAccount extends StatelessWidget { ) .borderRadius(.all(VanillaTokens.radiusMd())) .border(.all(.color(VanillaTokens.border()))) - .color(VanillaTokens.card()), + .color(VanillaTokens.card()) + .onFocusVisible( + CardStyler() + .containerEffects(vanillaFocusRing()) + .border(vanillaFocusBorder()), + ), child: RowBox( style: FlexBoxStyler() .crossAxisAlignment(.center) 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..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 @@ -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: _kebabStyle(open: menu.isOpen), ), ), items: const [ @@ -404,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/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..b7716e665 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. @@ -143,17 +144,17 @@ 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` | | 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 | `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`) | @@ -191,12 +192,13 @@ 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 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. | | 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/dashboard/dashboard_demo_test.dart b/registry_source/test/dashboard/dashboard_demo_test.dart index 89a4da03d..c01947794 100644 --- a/registry_source/test/dashboard/dashboard_demo_test.dart +++ b/registry_source/test/dashboard/dashboard_demo_test.dart @@ -1,6 +1,8 @@ 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'; import 'package:mix_chart/mix_chart.dart'; @@ -10,6 +12,9 @@ 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/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'; @@ -57,6 +62,186 @@ 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), + ); + + Color? fill() => _fillOf(_specOf(tester, button)); + + 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( + 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( + fill(), + muted, + reason: 'the button is filled while its menu is open', + ); + 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(fill(), isNot(muted), reason: 'the fill goes with the menu'); + expect( + tester.getSemantics(button), + isSemantics(label: label, isFocused: true), + ); + 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'), + ]) { + 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 { @@ -170,7 +355,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) { @@ -189,7 +378,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( @@ -743,7 +932,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); @@ -917,3 +1106,45 @@ double _transformAngleIn(WidgetTester tester, Finder ancestor) { .atan2(transform.transform.entry(1, 0), transform.transform.entry(0, 0)) .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); + + 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 new file mode 100644 index 000000000..801254e4a --- /dev/null +++ b/registry_source/test/vanilla/focus_ring_test.dart @@ -0,0 +1,500 @@ +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'; + +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 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, () { + 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 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('the dashboard account trigger rings outside itself', ( + tester, + ) async { + await _pumpDashboard(tester, theme.data); + const workspace = 'Northstar workspace'; + final probe = _Probe( + tester, + theme.data, + Focus.of(tester.element(find.text(workspace))), + ); + + 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']) + 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 + .byKey(const ValueKey('dashboard-appearance-toggle')) + .first, + includes: (part) => [part.centerLeft.translate(-1.5, 0)], + ); + }); + + 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'); +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. +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 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, + VanillaThemeData theme, + Widget child, +) async { + final previous = FocusManager.instance.highlightStrategy; + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + addTearDown(() => FocusManager.instance.highlightStrategy = previous); + + await tester.pumpWidget( + RepaintBoundary( + key: _frame, + child: VanillaTheme(theme: theme, child: hostOf(child)), + ), + ); + await tester.pumpAndSettle(); +} + +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. + /// + /// [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<_Pixels> 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 neighborFinder = neighbor ?? find.byKey(_neighbor); + final neighborRect = tester.getRect(neighborFinder); + 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( + 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)) { + 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', + ); + } + + return after; + } + + 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!; + } +} + +Iterable _none(Rect part) => const []; + +/// A frame captured at one pixel per logical 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/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 { 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..e83b42ba0 100644 --- a/registry_source/test/vanilla/vanilla_spec.dart +++ b/registry_source/test/vanilla/vanilla_spec.dart @@ -592,14 +592,35 @@ 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. _toggle( - 'outline has no decoration shadow', + 'outline shadow', + variant: .outline, + 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, - source: 'toggle.tsx: outline, shadow-xs (see specs/vanilla.md)', - read: (spec) => flexDecorationOf(spec.spec.container)?.boxShadow, - expected: (_) => anyOf(isNull, isEmpty), + 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', @@ -616,15 +637,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 +700,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, @@ -1066,6 +1095,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}, @@ -1115,6 +1155,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', @@ -1307,6 +1358,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 +1577,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 +1586,7 @@ SpecTarget _menu( property, source: source, style: vanillaMenuStyle, + states: states, read: read, expected: expected, ); @@ -1553,6 +1618,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 +1682,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 +1724,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 ----------------------------------------------------------------- diff --git a/skills/using-remix/references/vanilla.md b/skills/using-remix/references/vanilla.md index 5f50a60f6..106a907dd 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,29 @@ 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 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. 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. + 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)`) @@ -156,3 +178,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.