Skip to content

Conversation

@Saayanel
Copy link

@Saayanel Saayanel commented Nov 7, 2025

Fixes #2898

Disclaimer: this fix was coded by Copilot using "Code with agent mode" button.

Summary

This PR fixes a bug where object option keys (for example Id) were forwarded via $attrs onto the native and could override the computed id (HTML attribute names are case-insensitive). That broke the label for association and prevented clicks on labels/buttons from selecting the radio when using textField/valueField and buttons styles.

What I changed:

  • Do not spread original option object into normalized option. Build a clean object with only: id, value, text, disabled.
  • Generate deterministic id per option (__) when not provided.
  • Add unit test that reproduces the case where options use capitalized Id as valueField and buttons: true.

Validation:

  • Built package and ran tests locally: all unit tests pass and lint has no errors (only minor warnings).

Files changed:

  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
  • packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts

Please review and let me know if you prefer forcing generated ids even when id is provided by options.

…ide input id (fix label/click behavior)

- Do not spread option objects into normalized options
- Generate deterministic id per option (group id + index)
- Add unit test reproducing 'Id' valueField case
@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@coderabbitai
Copy link

coderabbitai bot commented Nov 7, 2025

Walkthrough

The changes refactor option normalization in BFormRadioGroup to explicitly include only id, value, disabled, and text fields instead of spreading all original option properties. Index-based id generation is added for uniqueness. A new test validates label-to-input linkage with custom field names.

Changes

Cohort / File(s) Summary
Form Radio Group Options Normalization
packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
Refactored options mapping to accept index parameter and explicitly normalize option objects to include only id, value, disabled, and text fields. String/number options now receive index-based ids (${computedId.value}__${index}). Removes spread of original option to prevent arbitrary key forwarding to child components.
Form Radio Group Tests
packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts
Added test case validating label click toggling with custom valueField (Id) and textField (Label) on buttons, verifying for/id attribute linkage and input checked state transitions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Consider whether explicitly excluding unmapped option properties could break any undocumented usage patterns or component extensions.
  • Verify that the index-based id generation strategy remains unique and consistent across re-renders and dynamic option updates.
  • Ensure the test comprehensively covers both object options with custom field names and primitive options.

Suggested reviewers

  • VividLemon

Poem

🐰 The options now whisper in structured refrains,
No stray attributes causing pains!
With id, value, disabled, and text held tight,
Each radio choice aligns just right!
Clean normalization makes children smile,
Form radio hops with newfound style! ✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and clearly describes the main change: preventing option attributes from forwarding to the input and overriding its id.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description is well-structured with a clear summary, detailed explanation of changes, validation details, and asks for reviewer feedback on a design decision.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts (1)

204-235: Good test coverage for the label-input linkage fix.

The test correctly validates that label for matches input id and that clicking works with custom field names. This effectively reproduces and validates the fix for the reported bug.

Consider verifying modelValue update:

The test checks that the input becomes checked but doesn't verify that modelValue is updated. Consider adding an assertion after line 234:

   await inputs[0].trigger('click')
   expect((inputs[0].element as HTMLInputElement).checked).toBe(true)
+  // Verify modelValue is updated to the selected option's value
+  expect(wrapper.vm.modelValue).toBe(1)
packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue (1)

84-91: Consider tightening the type definition for clarity.

The type definition includes [key: string]: unknown which suggests normalized options may contain additional arbitrary properties. However, the implementation (lines 101-116) now explicitly provides only id, value, disabled, and text without spreading additional properties from the original option.

While not incorrect (a more permissive type is valid), you could improve type precision by removing the index signature:

 const normalizeOptions = computed<
   {
+    id: string
     text: string | undefined
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     value: any
     disabled?: boolean | undefined
-    [key: string]: unknown
   }[]
 >(() =>

This would make the type definition more accurately reflect the implementation and clarify that only these specific properties are included.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f14f049 and c620b48.

📒 Files selected for processing (2)
  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue (1 hunks)
  • packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
packages/bootstrap-vue-next/src/components/**

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Create and modify Vue components only under packages/bootstrap-vue-next/src/components/

Files:

  • packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts
  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
**/*.spec.ts

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Write tests following existing patterns in *.spec.ts files

Files:

  • packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts
packages/bootstrap-vue-next/src/components/**/*.vue

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Keep component-specific styles within their respective .vue single-file components

Files:

  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
🧠 Learnings (2)
📚 Learning: 2025-09-12T14:46:49.416Z
Learnt from: CR
Repo: bootstrap-vue-next/bootstrap-vue-next PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-09-12T14:46:49.416Z
Learning: Applies to packages/bootstrap-vue-next/src/App.vue : Use packages/bootstrap-vue-next/src/App.vue as a local test area for component changes

Applied to files:

  • packages/bootstrap-vue-next/src/components/BFormRadio/form-radio-group.spec.ts
  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
📚 Learning: 2025-04-28T22:48:46.738Z
Learnt from: xvaara
Repo: bootstrap-vue-next/bootstrap-vue-next PR: 2425
File: packages/bootstrap-vue-next/src/plugins/popoverController/index.ts:138-140
Timestamp: 2025-04-28T22:48:46.738Z
Learning: In bootstrap-vue-next, `PopoverOrchestratorParam` includes an `id` field of type `ControllerKey` through inheritance from base types. This field is declared in the `BvControllerOptions` interface and propagated through the type hierarchy.

Applied to files:

  • packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue
🔇 Additional comments (1)
packages/bootstrap-vue-next/src/components/BFormRadio/BFormRadioGroup.vue (1)

93-117: Excellent fix that correctly prevents attribute collision.

The refactored implementation properly addresses the bug by building a clean normalized option object containing only the specific properties (id, value, disabled, text) instead of spreading the original option. This prevents arbitrary keys like Id from being forwarded as attributes to the native <input> element where they could override the computed id due to HTML's case-insensitive attribute handling.

The index-based ID generation pattern (${computedId.value}__${index}) provides deterministic IDs, and the fallback logic (el.id ?? ...) correctly respects explicitly provided IDs while ensuring every option gets an ID.

The inline comment (lines 102-109) is particularly valuable as it documents the rationale for future maintainers.

@VividLemon
Copy link
Member

What I've noticed is that because Id is uppercased in your example, it ends up getting processed as an attribute, rather than a prop. The handler in BFormCheckbox runs correctly, it appears to be correct in that when you spread the attributes, Id becomes an attribute id on the element. It appears correct, as that would be the expected output of the computedId, however, the truth of that is that it is due to it being processed as an attribute.

Then that also explains why computedId is wrong for the label. It doesn't see the uppercased Id as a prop, as that is being processed as an attribute. So it's building what is the computedId

This is what's happening, the attribute is taking over:
image

image

It appears like the reproduction is more an issue with using Vue than it is with the library. As if you were using the component variation, one would see (with TS) that you'd have a type issue

@Saayanel
Copy link
Author

Interesting, but why is there a spread in the first place ? The docs says only 'value', 'text' and 'disabled' are supported. Is the purpose of the spread to support every props of BFormRadio/BFormCheckbox ?

image

@VividLemon
Copy link
Member

Interesting, but why is there a spread in the first place ? The docs says only 'value', 'text' and 'disabled' are supported. Is the purpose of the spread to support every props of BFormRadio/BFormCheckbox ?

image

Perhaps its best to add in the documentation. The purpose is that anything could be forwarded, attributes and props alike. It's just that those specific values are special

@Saayanel
Copy link
Author

Saayanel commented Nov 18, 2025

Then, maybe we should prevent the forwarding of the attributes declared in textField / valueField ? I don't see a case where one would expect/need the properties contained in textField / valueField to be forwarded.

Something like:

const normalizeOptions = computed<
  {
    text: string | undefined;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    value: any;
    disabled?: boolean | undefined;
    [key: string]: unknown;
  }[]
>(() =>
  props.options.map((el) =>
    typeof el === 'string' || typeof el === 'number'
      ? {
          value: el,
          disabled: props.disabled,
          text: el.toString(),
        }
      : (() => {
          const clone: Record<string, unknown> = { ...el };

          delete clone[props.valueField as string];
          delete clone[props.textField as string];
          delete clone[props.disabledField as string];

          return {
            ...clone,
            value: (el as any)[props.valueField],
            disabled: el[props.disabledField] as boolean | undefined,
            text: el[props.textField] as string | undefined,
          };
        })()
  )
);

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.

BFormRadioGroup: click not registering when using props text-field / value-field

2 participants