Skip to content

feat(fortal): add FortalCheckboxGroupItem and share the focus ring - #120

Merged
leoafarias merged 5 commits into
mainfrom
feat/fortal-checkbox-group
Aug 9, 2026
Merged

feat(fortal): add FortalCheckboxGroupItem and share the focus ring#120
leoafarias merged 5 commits into
mainfrom
feat/fortal-checkbox-group

Conversation

@leoafarias

@leoafarias leoafarias commented Aug 9, 2026

Copy link
Copy Markdown
Member

Closes the last missing Fortal generated component, and de-duplicates the focus ring while in the same code.

No remix API was touched.

1. FortalCheckboxGroupItem

RemixCheckboxGroupItem was the only Remix widget owning a Styler with no Fortal recipe. The reason it was orphaned is structural:

Item Self-rendering widget? Parent has styler? Parent spec has item slot?
RemixToggleGroupItem no (data) yes yes
RemixSegmentedControlItem no (data) yes yes
RemixSelectItem no (data) yes yes
RemixMenuItem no (data) yes yes
RemixCheckboxGroupItem yes no no

Every other item is a data class whose parent spec carries an item slot the Fortal parent recipe fills. RemixCheckboxGroup is behavioral and has no styler, so nothing pushed item styling down — callers hand-attached the recipe to each item, and a missed one in a loop rendered unstyled beside its styled siblings.

Before / after

// before — the recipe has to be attached to every item by hand
final _checkboxStyle = fortalCheckboxStyle();

RemixCheckboxGroup<String>(
  values: _channels,
  onChanged: (v) => setState(() => _channels = v),
  child: Wrap(children: [
    for (final (value, label) in options)
      RemixCheckboxGroupItem<String>(
        value: value,
        label: label,
        style: _checkboxStyle,   // <- miss one and it renders unstyled
      ),
  ]),
)

// after
RemixCheckboxGroup<String>(
  values: _channels,
  onChanged: (v) => setState(() => _channels = v),
  child: Wrap(children: [
    for (final (value, label) in options)
      FortalCheckboxGroupItem<String>(value: value, label: label),
  ]),
)

fortalCheckboxGroupItemStyle delegates to fortalCheckboxStyle rather than restating any of it, so it inherits the checkbox family's verified parity and introduces no new visual surface. That is also why no new Chromium probe was needed — the reference fixture's 25 probes already cover checkbox.

Parity is unchanged. The Radix checkbox_group family stays in unmappedUpstreamFamilies: the root gap is still caller-owned, which is what the manifest's reopenCondition is about. Only the supportedRemixComposition prose was updated to name the new wrapper, keeping the per-item style escape hatch documented.

2. Shared focus ring

fortalFocusRing / fortalFocusRingBox replace four hand-rolled copies in accordion, toggle, toggle group, and tabs. Strictly behavior-preserving — verified per call site against git show HEAD.

Tabs keeps its solid focus-8 where the other three use alpha focus-a8. That difference is preserved, not unified: the pinned Chromium probes capture computed styles only, not :focus-visible, so the reference cannot settle whether it is intentional. A test fails if someone unifies it, forcing the decision to be deliberate.

Two entry points because FlexBoxStyler is a Mix type that sits outside Remix's RemixBoxStylerAnchors interface — a compiler-enforced boundary, not a style choice.

Rendered output

Every component below is the real recipe, rendered at 2x with Roboto loaded — not a mock. Checkbox group is the row this PR adds.

Fortal component catalog

Usage for each family shown above
// Wrap the app (or any subtree) once. Everything below reads from this scope.
FortalScope(accent: .indigo, gray: .slate, child: MyApp());

// Actions — variant is a named constructor or the `variant:` argument
FortalButton(onPressed: () {}, label: 'Button');
FortalButton.soft(onPressed: () {}, label: 'Button');
FortalIconButton(onPressed: () {}, icon: Icons.add, semanticLabel: 'Add');
FortalToggle(selected: true, icon: Icons.format_bold, label: 'Bold', onChanged: (_) {});

// Display
FortalBadge(label: 'Active');
FortalAvatar(label: 'LF', size: .size3);
FortalAvatar.solid(icon: Icons.person);
FortalCallout(icon: Icons.info_outline, text: 'Changes apply across the workspace.');
FortalProgress(value: 0.68);
FortalDataList(items: [
  RemixDataListItem(label: 'Plan', value: 'Enterprise'),
  RemixDataListItem(label: 'Seats', value: '48'),
]);

// Selection
FortalCheckbox(selected: true, label: 'Weekly digest', onChanged: (_) {});
FortalSwitch(selected: true, onChanged: (_) {}, semanticLabel: 'Notifications');
RemixRadioGroup<int>(groupValue: 1, onChanged: (_) {}, child: FortalRadio<int>(value: 1));

// Checkbox group — the API this PR adds
RemixCheckboxGroup<String>(
  values: selected,
  onChanged: (next) => setState(() => selected = next),
  child: Row(children: const [
    FortalCheckboxGroupItem<String>(value: 'email', label: 'Email'),
    FortalCheckboxGroupItem<String>(value: 'sms', label: 'SMS'),
    FortalCheckboxGroupItem<String>(value: 'push', label: 'Push'),
  ]),
);

// Exclusive choice
FortalSegmentedControl<String>(
  selectedValue: 'week',
  onChanged: (v) => setState(() => range = v),
  items: const [
    RemixSegmentedControlItem(value: 'day', label: 'Day'),
    RemixSegmentedControlItem(value: 'week', label: 'Week'),
    RemixSegmentedControlItem(value: 'month', label: 'Month'),
  ],
);

// Input
FortalTextField(hintText: 'Search…');
FortalTextArea(hintText: 'Notes…');

Re-scope to restyle any subtree without touching the widget — this is how the dashboard renders status badges:

FortalScope(
  accent: .red,
  hasBackground: false,
  child: FortalBadge(label: 'Refunded'),
);

Tests

  • test/fortal/focus_ring_test.dart — pins all four rings to their pre-refactor literals.
  • test/components/checkbox/checkbox_group_widget_test.dart — delegation across the full variant × size × highContrast matrix, plus group-selection plumbing.

The matrix test compares resolved specs rather than stylers, because CheckboxStyler is not value-comparable — see the note below.

Verification

Gate Result
remix_fortal tests 281 pass
remix tests 2552 pass
dashboard tests 25 pass
analyze (apps + packages) clean
fortal:parity:check 25 mapped, 3 extensions, 1 audited unmapped
docs:check pass
generated drift 27 artifacts byte-for-byte

Follow-ups found while doing this (not in this PR)

  • RemixToggle does not forward excludeSemantics, so it cannot express a selected destination #119RemixToggle does not forward excludeSemantics.
  • CheckboxStyler is the only Fortal recipe that is not value-comparable. Root cause is not codegen — the generated props/== are correct. CheckboxStyler.onIndeterminate (packages/remix/lib/src/components/checkbox/checkbox_style.dart:10-18) allocates a fresh ContextVariant with a closure on every call, and ContextVariant has no == override, unlike its subclass WidgetStateVariant that onSelected/onFocused/onDisabled use. Bisected: onSelected, onFocused, onDisabled all preserve equality; only onIndeterminate breaks it. accordion_style.dart:73,85,97,109 has the same latent pattern.
  • fortalFocusOutline second passcheckbox, card, radio, and switch recipes each inline the CSS-outline pattern the existing helper already produces.

RemixCheckboxGroupItem was the only Remix widget owning a Styler with no
Fortal recipe. Unlike menu, select, segmented control, and toggle group —
whose parent specs carry an `item` slot the Fortal parent recipe fills —
RemixCheckboxGroup is behavioral and has no styler, so nothing pushed item
styling down. Callers hand-attached fortalCheckboxStyle() to every item, and
a missed one in a loop rendered unstyled beside its styled siblings.

fortalCheckboxGroupItemStyle delegates to fortalCheckboxStyle rather than
restating any of it, so it inherits the checkbox family's verified parity and
introduces no new visual surface. The Radix checkbox-group family stays
unmapped: the root gap is still caller-owned, which is what the manifest's
reopenCondition is about.

Also extracts fortalFocusRing/fortalFocusRingBox from four hand-rolled copies
in accordion, toggle, toggle group, and tabs. Strictly behavior-preserving —
tabs keeps its solid focus-8 where the others use focus-a8, preserved rather
than unified because the pinned Chromium probes capture computed styles only,
not :focus-visible, so the reference cannot settle whether it is intentional.

Verified: 281 remix_fortal tests, 2552 remix tests, 25 dashboard tests,
parity check (25 mapped families, 1 audited unmapped), docs validation, and
clean generation reproducing all 27 artifacts byte-for-byte.
Both entry points are now `.fortalFocusRing()`, disambiguated by receiver
type, instead of a function plus a `Box`-suffixed sibling. Reads as the
fluent chain the surrounding recipes already use, and drops a name whose
suffix carried no meaning to the reader.

Deliberately not `fortalFocusRingStyle`: the `*Style` suffix is load-bearing
for the MixWidget generator, which strips it to derive a widget class name.
Naming a non-recipe helper that way would advertise a FortalFocusRing widget
that does not exist. The other helpers in this file follow the same rule --
fortalFocusOutline, fortalInsetSurface, fortalModeAwareFilter.
Rendered from the real recipes at 2x with Roboto loaded, so it shows actual
Fortal output rather than a mock. Available for docs/fortal.mdx; referenced
from #120 as visual evidence for FortalCheckboxGroupItem.
The comment documents [color] and [strokeAlign], which became method
parameters when this converted from a function to an extension. Dartdoc
cannot resolve parameter references from the extension declaration.
@leoafarias
leoafarias merged commit feac3a1 into main Aug 9, 2026
2 checks passed
@leoafarias
leoafarias deleted the feat/fortal-checkbox-group branch August 9, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant