Repository: streamlit/streamlit

Issue:
Markdown support for radio buttons
### Problem

Colored text and other markdown elements work in the label of `st.radio` but not in the texts of the radio elements. This is a bit weird since we do support it in the texts of checkboxes (where the text next to the checkbox is the label). 


### Solution

Allow markdown in the options of `st.radio`. 


---

Community voting on feature requests enables the Streamlit team to understand which features are most important to our users.

**If you'd like the Streamlit team to prioritize this feature request, please use the 👍 (thumbs up emoji) reaction in response to the initial post.**



Discussion:

Resolving patch:
diff --git a/e2e/scripts/st_radio.py b/e2e/scripts/st_radio.py
--- a/e2e/scripts/st_radio.py
+++ b/e2e/scripts/st_radio.py
@@ -19,6 +19,16 @@
 from tests.streamlit import pyspark_mocks
 
 options = ("female", "male")
+markdown_options = (
+    "**bold text**",
+    "*italics text*",
+    "~strikethrough text~",
+    "shortcode: :blush:",
+    # link should not work in radio options
+    "[link text](www.example.com)",
+    "`code text`",
+    ":red[red] :blue[blue] :green[green] :violet[violet] :orange[orange]",
+)
 i1 = st.radio("radio 1", options, 1)
 st.write("value 1:", i1)
 
@@ -43,14 +53,16 @@
 i8 = st.radio("radio 8", options, label_visibility="collapsed")
 st.write("value 8:", i8)
 
+i9 = st.radio("radio 9", markdown_options)
+st.write("value 9:", i9)
 
 if runtime.exists():
 
     def on_change():
         st.session_state.radio_changed = True
 
-    st.radio("radio 9", options, 1, key="radio9", on_change=on_change)
-    st.write("value 9:", st.session_state.radio9)
+    st.radio("radio 10", options, 1, key="radio10", on_change=on_change)
+    st.write("value 10:", st.session_state.radio10)
     st.write("radio changed:", "radio_changed" in st.session_state)
 
 st.radio("PySpark radio", pyspark_mocks.DataFrame())  # type: ignore


Test patch:
diff --git a/frontend/lib/src/components/shared/Radio/Radio.test.tsx b/frontend/lib/src/components/shared/Radio/Radio.test.tsx
--- a/frontend/lib/src/components/shared/Radio/Radio.test.tsx
+++ b/frontend/lib/src/components/shared/Radio/Radio.test.tsx
@@ -15,9 +15,10 @@
  */
 
 import React from "react"
-import { mount } from "@streamlit/lib/src/test_util"
+import { render } from "@streamlit/lib/src/test_util"
+import { screen, fireEvent } from "@testing-library/react"
+import "@testing-library/jest-dom"
 
-import { Radio as UIRadio, RadioGroup, ALIGN } from "baseui/radio"
 import { LabelVisibilityOptions } from "@streamlit/lib/src/util/utils"
 import { mockTheme } from "@streamlit/lib/src/mocks/mockTheme"
 import Radio, { Props } from "./Radio"
@@ -37,112 +38,110 @@ const getProps = (props: Partial<Props> = {}): Props => ({
 describe("Radio widget", () => {
   it("renders without crashing", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
+    const radioGroup = screen.getByRole("radiogroup")
+    const radioOptions = screen.getAllByRole("radio")
 
-    expect(wrapper.find(RadioGroup).length).toBe(1)
-    expect(wrapper.find(UIRadio).length).toBe(3)
+    expect(radioGroup).toBeInTheDocument()
+    expect(radioOptions).toHaveLength(3)
   })
 
   it("renders without crashing if no label is provided", () => {
     const props = getProps({ label: undefined })
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).length).toBe(1)
-    expect(wrapper.find(UIRadio).length).toBe(3)
+    render(<Radio {...props} />)
+    const widgetLabel = screen.queryByText("Label")
+    const radioOptions = screen.getByRole("radiogroup")
+
+    expect(widgetLabel).toBeNull()
+    expect(radioOptions).toBeInTheDocument()
   })
 
   it("pass labelVisibility prop to StyledWidgetLabel correctly when hidden", () => {
     const props = getProps({
       labelVisibility: LabelVisibilityOptions.Hidden,
     })
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find("StyledWidgetLabel").prop("labelVisibility")).toEqual(
-      LabelVisibilityOptions.Hidden
-    )
+    render(<Radio {...props} />)
+
+    const widgetLabel = screen.getByText("Label")
+    expect(widgetLabel).toHaveStyle("visibility: hidden")
+    expect(widgetLabel).not.toBeVisible()
   })
 
   it("pass labelVisibility prop to StyledWidgetLabel correctly when collapsed", () => {
     const props = getProps({
       labelVisibility: LabelVisibilityOptions.Collapsed,
     })
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find("StyledWidgetLabel").prop("labelVisibility")).toEqual(
-      LabelVisibilityOptions.Collapsed
-    )
+    render(<Radio {...props} />)
+    const widgetLabel = screen.getByText("Label")
+    expect(widgetLabel).not.toBeVisible()
   })
 
   it("has correct className and style", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    const wrappedDiv = wrapper.find("div").first()
-
-    const { className, style } = wrappedDiv.props()
-    // @ts-expect-error
-    const splittedClassName = className.split(" ")
+    render(<Radio {...props} />)
+    const radioElement = screen.getByTestId("stRadio")
 
-    expect(splittedClassName).toContain("row-widget")
-    expect(splittedClassName).toContain("stRadio")
-
-    // @ts-expect-error
-    expect(style.width).toBe(getProps().width)
+    expect(radioElement).toHaveClass("row-widget")
+    expect(radioElement).toHaveClass("stRadio")
+    expect(radioElement).toHaveStyle(`width: ${props.width}px`)
   })
 
   it("renders a label", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find("StyledWidgetLabel").text()).toBe(props.label)
+    render(<Radio {...props} />)
+    const widgetLabel = screen.queryByText(`${props.label}`)
+
+    expect(widgetLabel).toBeInTheDocument()
   })
 
   it("has a default value", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).prop("value")).toBe(props.value.toString())
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+    expect(radioOptions).toHaveLength(3)
+
+    const checked = radioOptions[props.value]
+    expect(checked).toBeChecked()
   })
 
   it("can be disabled", () => {
-    const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).prop("disabled")).toBe(props.disabled)
-  })
+    const props = getProps({ disabled: true })
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
 
-  it("can be horizontally aligned", () => {
-    const props = getProps({ horizontal: true })
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).prop("align")).toBe(ALIGN.horizontal)
+    radioOptions.forEach(option => {
+      expect(option).toBeDisabled()
+    })
   })
 
   it("has the correct options", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    const options = wrapper.find(UIRadio)
+    render(<Radio {...props} />)
 
-    options.forEach((option, index) => {
-      expect(option.prop("value")).toBe(index.toString())
-      expect(option.prop("children")).toBe(props.options[index])
+    props.options.forEach(option => {
+      expect(screen.getByText(option)).toBeInTheDocument()
     })
   })
 
   it("shows a message when there are no options to be shown", () => {
     const props = getProps({ options: [] })
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+    const noOptionLabel = screen.getByText("No options to select.")
 
-    expect(wrapper.find(UIRadio).length).toBe(1)
-    expect(wrapper.find(UIRadio).prop("children")).toBe(
-      "No options to select."
-    )
+    expect(radioOptions).toHaveLength(1)
+    expect(noOptionLabel).toBeInTheDocument()
   })
 
   it("handles value changes", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+
+    const secondOption = radioOptions[1]
 
-    // @ts-expect-error
-    wrapper.find(RadioGroup).prop("onChange")({
-      target: {
-        value: "1",
-      },
-    } as React.ChangeEvent<HTMLInputElement>)
-    wrapper.update()
+    fireEvent.click(secondOption)
 
-    expect(wrapper.find(RadioGroup).prop("value")).toBe("1")
+    expect(secondOption).toBeChecked()
   })
 })
diff --git a/frontend/lib/src/components/shared/StreamlitMarkdown/StreamlitMarkdown.test.tsx b/frontend/lib/src/components/shared/StreamlitMarkdown/StreamlitMarkdown.test.tsx
--- a/frontend/lib/src/components/shared/StreamlitMarkdown/StreamlitMarkdown.test.tsx
+++ b/frontend/lib/src/components/shared/StreamlitMarkdown/StreamlitMarkdown.test.tsx
@@ -249,13 +249,12 @@ describe("StreamlitMarkdown", () => {
 
   it("doesn't render links when isButton is true", () => {
     // Valid markdown further restricted with buttons to eliminate links
-    const source = "[text](www.example.com)"
+    const source = "[Link text](www.example.com)"
     render(
       <StreamlitMarkdown source={source} allowHTML={false} isLabel isButton />
     )
-    const markdown = screen.getByText("text")
-    const tagName = markdown.nodeName.toLowerCase()
-    expect(tagName).not.toBe("a")
+    const tag = screen.getByText("Link text")
+    expect(tag instanceof HTMLAnchorElement).toBe(false)
   })
 
   it("renders smaller text sizing when isToast is true", () => {
@@ -266,6 +265,21 @@ describe("StreamlitMarkdown", () => {
     expect(textTag).toHaveStyle("font-size: 14px")
   })
 
+  it("renders regular text sizing when largerLabel is true", () => {
+    const source = "Here is some checkbox label text"
+    render(
+      <StreamlitMarkdown
+        source={source}
+        allowHTML={false}
+        isLabel
+        largerLabel
+      />
+    )
+
+    const textTag = screen.getByText("Here is some checkbox label text")
+    expect(textTag).toHaveStyle("font-size: inherit")
+  })
+
   it("colours text properly", () => {
     const colorMapping = new Map([
       ["red", colors.red80],
diff --git a/frontend/lib/src/components/widgets/Radio/Radio.test.tsx b/frontend/lib/src/components/widgets/Radio/Radio.test.tsx
--- a/frontend/lib/src/components/widgets/Radio/Radio.test.tsx
+++ b/frontend/lib/src/components/widgets/Radio/Radio.test.tsx
@@ -15,14 +15,18 @@
  */
 
 import React from "react"
-import { mount } from "@streamlit/lib/src/test_util"
-import { WidgetStateManager } from "@streamlit/lib/src/WidgetStateManager"
+import { render } from "@streamlit/lib/src/test_util"
+import { screen, fireEvent } from "@testing-library/react"
+import "@testing-library/jest-dom"
 
-import { Radio as UIRadio, RadioGroup } from "baseui/radio"
+import { WidgetStateManager } from "@streamlit/lib/src/WidgetStateManager"
 import { Radio as RadioProto } from "@streamlit/lib/src/proto"
 import Radio, { Props } from "./Radio"
 
-const getProps = (elementProps: Partial<RadioProto> = {}): Props => ({
+const getProps = (
+  elementProps: Partial<RadioProto> = {},
+  otherProps: Partial<Props> = {}
+): Props => ({
   element: RadioProto.create({
     id: "1",
     label: "Label",
@@ -36,21 +40,24 @@ const getProps = (elementProps: Partial<RadioProto> = {}): Props => ({
     sendRerunBackMsg: jest.fn(),
     formsDataChanged: jest.fn(),
   }),
+  ...otherProps,
 })
 
 describe("Radio widget", () => {
   it("renders without crashing", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
+    const radioGroup = screen.getByRole("radiogroup")
+    const radioOptions = screen.getAllByRole("radio")
 
-    expect(wrapper.find(RadioGroup).length).toBe(1)
-    expect(wrapper.find(UIRadio).length).toBe(3)
+    expect(radioGroup).toBeInTheDocument()
+    expect(radioOptions).toHaveLength(3)
   })
 
   it("sets widget value on mount", () => {
     const props = getProps()
     jest.spyOn(props.widgetMgr, "setIntValue")
-    mount(<Radio {...props} />)
+    render(<Radio {...props} />)
 
     expect(props.widgetMgr.setIntValue).toHaveBeenCalledWith(
       props.element,
@@ -61,78 +68,77 @@ describe("Radio widget", () => {
 
   it("has correct className and style", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    const wrappedDiv = wrapper.find("div").first()
-
-    const { className, style } = wrappedDiv.props()
-    // @ts-expect-error
-    const splittedClassName = className.split(" ")
-
-    expect(splittedClassName).toContain("row-widget")
-    expect(splittedClassName).toContain("stRadio")
+    render(<Radio {...props} />)
+    const radioElement = screen.getByTestId("stRadio")
 
-    // @ts-expect-error
-    expect(style.width).toBe(getProps().width)
+    expect(radioElement).toHaveClass("row-widget")
+    expect(radioElement).toHaveClass("stRadio")
+    expect(radioElement).toHaveStyle(`width: ${props.width}px`)
   })
 
   it("renders a label", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find("StyledWidgetLabel").text()).toBe(props.element.label)
+    render(<Radio {...props} />)
+    const widgetLabel = screen.queryByText(`${props.element.label}`)
+
+    expect(widgetLabel).toBeInTheDocument()
   })
 
   it("has a default value", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).prop("value")).toBe(
-      props.element.default.toString()
-    )
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+    expect(radioOptions).toHaveLength(3)
+
+    const checked = radioOptions[props.element.default]
+    expect(checked).toBeChecked()
   })
 
   it("can be disabled", () => {
-    const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    expect(wrapper.find(RadioGroup).prop("disabled")).toBe(props.disabled)
+    const props = getProps({}, { disabled: true })
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+
+    radioOptions.forEach(option => {
+      expect(option).toBeDisabled()
+    })
   })
 
   it("has the correct options", () => {
     const props = getProps()
-    const wrapper = mount(<Radio {...props} />)
-    const options = wrapper.find(UIRadio)
+    render(<Radio {...props} />)
 
-    options.forEach((option, index) => {
-      expect(option.prop("value")).toBe(index.toString())
-      expect(option.prop("children")).toBe(props.element.options[index])
+    props.element.options.forEach(option => {
+      expect(screen.getByText(option)).toBeInTheDocument()
     })
   })
 
   it("shows a message when there are no options to be shown", () => {
     const props = getProps({ options: [] })
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
 
-    expect(wrapper.find(UIRadio).length).toBe(1)
-    expect(wrapper.find(UIRadio).prop("children")).toBe(
-      "No options to select."
-    )
+    const radioOptions = screen.getAllByRole("radio")
+    const noOptionLabel = screen.getByText("No options to select.")
+
+    expect(radioOptions).toHaveLength(1)
+    expect(noOptionLabel).toBeInTheDocument()
   })
 
   it("sets the widget value when an option is selected", () => {
     const props = getProps()
     jest.spyOn(props.widgetMgr, "setIntValue")
-    const wrapper = mount(<Radio {...props} />)
+    render(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+    const secondOption = radioOptions[1]
 
-    // @ts-expect-error
-    wrapper.find(RadioGroup).prop("onChange")({
-      target: { value: "1" },
-    } as React.ChangeEvent<HTMLInputElement>)
-    wrapper.update()
+    fireEvent.click(secondOption)
 
-    expect(wrapper.find(RadioGroup).prop("value")).toBe("1")
     expect(props.widgetMgr.setIntValue).toHaveBeenLastCalledWith(
       props.element,
       1,
       { fromUi: true }
     )
+    expect(secondOption).toBeChecked()
   })
 
   it("resets its value when form is cleared", () => {
@@ -141,17 +147,15 @@ describe("Radio widget", () => {
     props.widgetMgr.setFormClearOnSubmit("form", true)
 
     jest.spyOn(props.widgetMgr, "setIntValue")
+    render(<Radio {...props} />)
 
-    const wrapper = mount(<Radio {...props} />)
+    const radioOptions = screen.getAllByRole("radio")
+    const secondOption = radioOptions[1]
 
     // Change the widget value
-    // @ts-expect-error
-    wrapper.find(RadioGroup).prop("onChange")({
-      target: { value: "1" },
-    } as React.ChangeEvent<HTMLInputElement>)
-    wrapper.update()
+    fireEvent.click(secondOption)
+    expect(secondOption).toBeChecked()
 
-    expect(wrapper.find(RadioGroup).prop("value")).toBe("1")
     expect(props.widgetMgr.setIntValue).toHaveBeenLastCalledWith(
       props.element,
       1,
@@ -160,12 +164,11 @@ describe("Radio widget", () => {
 
     // "Submit" the form
     props.widgetMgr.submitForm("form")
-    wrapper.update()
 
     // Our widget should be reset, and the widgetMgr should be updated
-    expect(wrapper.find(RadioGroup).prop("value")).toBe(
-      props.element.default.toString()
-    )
+    const defaultValue = radioOptions[props.element.default]
+    expect(defaultValue).toBeChecked()
+
     expect(props.widgetMgr.setIntValue).toHaveBeenLastCalledWith(
       props.element,
       props.element.default,