feat: add support for nested fields rendered inside card selectable groups#3833
Conversation
✅ No New Circular DependenciesNo new circular dependencies detected. Current count: 0 |
📦 Alpha Package Version PublishedUse Use |
🔍 Visual review for your branch is published 🔍Here are the links to: |
There was a problem hiding this comment.
Pull request overview
This PR extends the F0Form system to support inline nested dependent fields inside selectable “card” UIs (switch-card groups and a new cardSelect field type), improving visual grouping and enabling more compact conditional form layouts.
Changes:
- Added
selectedContentsupport toCardSelectableitems with animated expand/collapse behavior. - Introduced a new
cardSelectfield type (incl.hideLabelandgroupedoptions) and wired it through the schema/config + field renderer pipeline. - Implemented “dependent field absorption” so fields with
renderIfconditions can be rendered inside the controlling switch/card option instead of as standalone fields.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/experimental/Forms/CardSelectable/types.ts | Adds selectedContent?: ReactNode to card items. |
| packages/react/src/experimental/Forms/CardSelectable/index.stories.tsx | Adds stories demonstrating selectedContent behavior. |
| packages/react/src/experimental/Forms/CardSelectable/CardSelectable.tsx | Implements expandable selectedContent with reduced-motion support. |
| packages/react/src/experimental/Forms/CardSelectable/tests/CardSelectable.test.tsx | Adds test coverage for selectedContent rendering and interaction. |
| packages/react/src/components/F0Form/useSchemaDefinition.ts | Maps grouped for switches and adds cardSelect config-to-field conversion. |
| packages/react/src/components/F0Form/fields/types.ts | Extends field type unions/exports to include cardSelect. |
| packages/react/src/components/F0Form/fields/switch/types.ts | Adds grouped?: boolean to switch config/field types. |
| packages/react/src/components/F0Form/fields/renderFieldInput.tsx | Registers CardSelectFieldRenderer in the field render switch. |
| packages/react/src/components/F0Form/fields/number/NumberFieldRenderer.tsx | Suppresses auto-generated number hints by forcing hint="". |
| packages/react/src/components/F0Form/fields/FieldRenderer.tsx | Hides visible label for cardSelect when hideLabel is set. |
| packages/react/src/components/F0Form/fields/cardSelect/types.ts | Defines cardSelect field/config types and renderIf condition typing. |
| packages/react/src/components/F0Form/fields/cardSelect/CardSelectFieldRenderer.tsx | Renders cardSelect using CardSelectableContainer + deps context. |
| packages/react/src/components/F0Form/fields/cardSelect/CardSelectDepsContext.ts | Adds context to pass option-value → dependent content mappings. |
| packages/react/src/components/F0Form/f0Schema.ts | Adds cardSelect to schema config types and f0FormField overloads. |
| packages/react/src/components/F0Form/F0Form.tsx | Implements switch/cardSelect dependency absorption + context wiring. |
| packages/react/src/components/F0Form/components/SwitchGroupRenderer.tsx | Renders absorbed dependent fields inside switch cards via selectedContent. |
| packages/react/src/components/F0Form/components/SectionRenderer.tsx | Mirrors grouping/absorption behavior for section-based rendering. |
| packages/react/src/components/F0Form/components/F0FormSection.tsx | Updates switch grouping logic for per-section form rendering. |
| packages/react/src/components/F0Form/tests/F0Form.test.tsx | Adds tests for cardSelect, nested dependents, and grouped: false. |
| packages/react/src/components/F0Form/stories/F0Form.stories.tsx | Adds stories demonstrating nested dependent rendering + ungrouped behavior. |
packages/react/src/experimental/Forms/CardSelectable/CardSelectable.tsx
Outdated
Show resolved
Hide resolved
packages/react/src/components/F0Form/components/F0FormSection.tsx
Outdated
Show resolved
Hide resolved
packages/react/src/experimental/Forms/CardSelectable/__tests__/CardSelectable.test.tsx
Outdated
Show resolved
Hide resolved
| className={cn( | ||
| "flex cursor-pointer items-center gap-3", | ||
| "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-f1-special-ring", | ||
| grouped ? "px-4 py-3" : "p-4" | ||
| )} |
There was a problem hiding this comment.
CardSelectable replaced the shared focusRing() helper with custom focus-visible:* classes. This diverges from the repo convention (and changes behavior vs focusRing() — e.g. missing ring offset). Prefer using focusRing() (optionally with extra classes) so focus styling stays consistent across components.
| // For checkbox and custom fields, label is handled internally | ||
| const showLabel = field.type !== "checkbox" && field.type !== "custom" | ||
| const showLabel = | ||
| field.type !== "checkbox" && | ||
| field.type !== "custom" && | ||
| !(field.type === "cardSelect" && field.hideLabel) |
There was a problem hiding this comment.
FieldRenderer renders a <label htmlFor={field.id}> for cardSelect when hideLabel is false, but the cardSelect control is a custom radiogroup built from <div role="radiogroup"> / <div role="radio"> (not a labelable element). This makes the htmlFor association invalid and label-click won’t focus/activate the control. Consider rendering a non-label element for cardSelect labels (or wiring up aria-labelledby/an actual labelable element) so the label is semantically correct and connected to the radiogroup.
| // Wrap cardSelect fields with pre-rendered dependent content via context | ||
| if ( | ||
| f.type === "cardSelect" && | ||
| cardSelectDependentFields?.has(f.id) | ||
| ) { | ||
| const valueMap = cardSelectDependentFields.get(f.id)! | ||
| const contentMap = new Map<string, React.ReactNode>() | ||
| for (const [equalsTo, deps] of valueMap) { | ||
| contentMap.set( | ||
| equalsTo, | ||
| <div key={equalsTo} className="flex flex-col gap-4"> | ||
| {deps.map((innerDep) => | ||
| "type" in innerDep && innerDep.type === "row" ? ( | ||
| <RowRenderer | ||
| key={innerDep.fields.map((fd) => fd.id).join("-")} | ||
| row={innerDep} | ||
| sectionId={sectionId} | ||
| /> | ||
| ) : ( | ||
| <FieldRenderer | ||
| key={(innerDep as F0Field).id} | ||
| field={innerDep as F0Field} | ||
| sectionId={sectionId} | ||
| /> | ||
| ) | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
| return ( |
There was a problem hiding this comment.
In SwitchGroupRenderer, the logic that builds the Map<optionValue, ReactNode> for nested cardSelect dependent content duplicates buildCardSelectContentMap() from groupingUtils.tsx (and re-implements the same RowRenderer/FieldRenderer mapping). Reusing the shared helper here would reduce duplication and keep nested rendering consistent between standalone cardSelect and switch-nested cardSelect.
…e-card-selectable-groups
| @@ -1,4 +1,4 @@ | |||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" | |||
There was a problem hiding this comment.
React is imported as a default but never referenced in this file. With noUnusedLocals enabled, this will fail typecheck/lint; remove the default import (or use a named import like Fragment if needed).
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" | |
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" |
| const valueMap = cardSelectDependentFields.get(f.id)! | ||
| const contentMap = new Map<string, React.ReactNode>() | ||
| for (const [equalsTo, deps] of valueMap) { | ||
| contentMap.set( | ||
| equalsTo, | ||
| <div key={equalsTo} className="flex flex-col gap-4"> |
There was a problem hiding this comment.
This cardSelect option→content Map is being built inline, duplicating the same mapping logic handled by buildCardSelectContentMap() in groupingUtils. Reusing the shared helper here would reduce duplication and the risk of the two implementations diverging.
| return <RadioIndicator checked={selected} /> | ||
| } | ||
|
|
||
| const hasSelectedContent = !!item.selectedContent |
There was a problem hiding this comment.
hasSelectedContent is computed with !!item.selectedContent, which treats valid ReactNode values like 0 or an empty string as “no content” and will skip rendering the expandable area. Prefer a null/undefined check (e.g., item.selectedContent != null) so any valid ReactNode can be rendered when intended.
| const hasSelectedContent = !!item.selectedContent | |
| const hasSelectedContent = item.selectedContent != null |
| } else if (next.type === "row") { | ||
| // Absorb the row if ALL its fields depend on the same switch | ||
| const rowParents = next.fields.map((f) => | ||
| isDependentOnSwitch(f, switchIds) | ||
| ) | ||
| const firstParent = rowParents[0] | ||
| if (firstParent && rowParents.every((p) => p === firstParent)) { | ||
| const existing = dependentFields.get(firstParent) ?? [] | ||
| existing.push(next) | ||
| dependentFields.set(firstParent, existing) | ||
| i++ | ||
| continue | ||
| } |
There was a problem hiding this comment.
When absorbing a row as dependent on a switch, any cardSelect fields inside that row are not added to cardSelectIds. As a result, subsequent fields that depend on that cardSelect value won’t be absorbed into the switch group (and won’t render inside the selected card). Consider scanning absorbed rows for cardSelect fields and registering their IDs (similar to the field-level path).
| import { describe, expect, it, vi } from "vitest" | ||
|
|
||
| import type { CardSelectableItem } from "@/components/CardSelectable/types" | ||
|
|
||
| import { CardSelectableContainer } from "@/components/CardSelectable/index" | ||
| import { zeroRender as render, screen, userEvent } from "@/testing/test-utils" | ||
|
|
||
| const baseItems: CardSelectableItem<string>[] = [ | ||
| { | ||
| value: "a", |
There was a problem hiding this comment.
This test file duplicates the same CardSelectable selectedContent coverage that also exists under src/components/CardSelectable/__tests__/…, and this experimental/Forms/CardSelectable folder appears to contain only tests (no component implementation). Consider removing one of the duplicates or relocating the test so there’s a single, canonical suite for @/components/CardSelectable behavior.
Add support for nested fields rendered inside card selectable groups
Problem
F0Form had no way to:
renderIf: { fieldId, equalsTo: true }) rendered below the switch group as standalone fields, with no visual association to the toggle that controls them.cardSelectfield type — radio-style card selection (single choice among bordered cards) didn't exist as a declarative field type.Changes
1.
selectedContenton CardSelectable (animated expand/collapse)selectedContent?: ReactNodeprop toCardSelectableItemselectedContentexpands with a spring animation (motion/react); it collapses when deselectedgrouped(divider-separated) and ungrouped (individual bordered cards) layouts2. Dependent field absorption into switch groups
renderIf: { fieldId: "<switchFieldId>", equalsTo: true }are automatically detected and rendered asselectedContentinside the corresponding switch cardisDependentOnSwitchhelper performs static analysis of object-formrenderIfconditions3. New
cardSelectfield typefieldType: "cardSelect"withoptions: CardSelectOption[]CardSelectableContainerwith radio-style single selectionhideLabel?: booleanto suppress the field labelgrouped?: boolean(defaulttrue) — whenfalse, renders each option as a separate bordered cardf0FormField), field type union, andrenderFieldInput4. Dependent field absorption into cardSelect options
renderIf: { fieldId: "<cardSelectFieldId>", equalsTo: "<optionValue>" }are automatically rendered inside the matching card'sselectedContentCardSelectDepsContext(React context withMap<string, ReactNode>) to pass rendered content toCardSelectFieldRenderer5.
grouped: falseopt-out for switches and cardSelectgrouped?: booleantoF0SwitchConfig/F0SwitchFieldandF0CardSelectConfig/F0CardSelectFieldgrouped: falserender as solo bordered cards, breaking the contiguous groupgrouped: falsepassesgrouped={false}toCardSelectableContainergroupContiguousSwitches(F0Form, SectionRenderer, F0FormSection)6. Number input hint suppression
NumberFieldRenderernow passeshint=""to preventNumberInputInternalfrom auto-generating constraint hints (e.g. "It should be between 0 and 100") — Zod validation errors handle this instead