Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bef70ea
feat(registry): draw Vanilla focus rings through the effects slots
leoafarias Oct 27, 2026
88b43a9
feat(registry): style the label of the Vanilla radio and switch
leoafarias Oct 27, 2026
a1a07c2
feat(registry): open the Vanilla order actions menu from a real icon …
leoafarias Oct 27, 2026
398412a
docs(registry): describe the Vanilla ring helper, labels, and control…
leoafarias Oct 27, 2026
69e01d5
test(registry): read the labeled switch name before tapping it
leoafarias Oct 27, 2026
a595124
test(registry): read Vanilla focus rings from pixels and cover the si…
leoafarias Oct 27, 2026
d032554
feat(registry): keep the Vanilla select panel at least as wide as its…
leoafarias Oct 27, 2026
146b08b
feat(registry): fill the Vanilla order actions button while its menu …
leoafarias Oct 27, 2026
8abcb30
docs(registry): record the inset toggle group ring and correct the la…
leoafarias Oct 27, 2026
3484e05
test(registry): read the Vanilla sidebar destination ring now that Re…
leoafarias Oct 27, 2026
1c15b3e
fix(registry): draw a focus ring on the Vanilla account menu trigger
leoafarias Oct 27, 2026
d0cbf2a
test(registry): drive the fixture popover from its button with openOn…
leoafarias Oct 27, 2026
d79e960
fix(registry): give the Vanilla header popovers a real button trigger
leoafarias Oct 27, 2026
1426665
fix(registry): keep the open Vanilla kebab's look under the pointer
leoafarias Oct 27, 2026
d854192
fix(registry): turn the Vanilla account row's border to ring on focus
leoafarias Oct 27, 2026
2d0b982
feat(registry): draw the Vanilla outline toggle's shadow-xs
leoafarias Oct 27, 2026
615d647
docs(registry): show who owns the popover's focus node, and tidy its …
leoafarias Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/playground/lib/ui/components/accordion.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
23 changes: 8 additions & 15 deletions apps/playground/lib/ui/components/disclosure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
19 changes: 7 additions & 12 deletions apps/playground/lib/ui/components/menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)),
);
Expand Down
43 changes: 29 additions & 14 deletions apps/playground/lib/ui/components/popover.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Filters> {
/// 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({
Expand Down
43 changes: 29 additions & 14 deletions apps/playground/lib/ui/components/popover.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 11 additions & 4 deletions apps/playground/lib/ui/components/radio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
/// ]),
/// )
/// ```
Expand All @@ -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(),
Expand All @@ -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())
Expand Down
9 changes: 5 additions & 4 deletions apps/playground/lib/ui/components/radio.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion apps/playground/lib/ui/components/select.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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));

Expand Down
11 changes: 9 additions & 2 deletions apps/playground/lib/ui/components/switch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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())
Expand Down
5 changes: 3 additions & 2 deletions apps/playground/lib/ui/components/switch.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 8 additions & 18 deletions apps/playground/lib/ui/components/tabs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ TabStyler playgroundTabStyle({
.line => _line(),
})
.onHovered(_current())
.onFocusVisible(_focusVisibleStyle(variant))
.onFocusVisible(_focusVisibleStyle())
.onDisabled(_disabledStyle())
.merge(style);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -245,26 +244,17 @@ 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.
///
/// A disabled tab keeps whatever surface its state gives it and simply fades;
/// 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));
Loading
Loading