Skip to content

Commit

Permalink
Add icon parameter to st.expander (streamlit#8716)
Browse files Browse the repository at this point in the history
## Describe your changes

Adds an `icon` kwarg to `st.expander` to optionally support
single-character emojis or material icons.

- Reused the existing `icon` proto field
- Replaced the custom check and error icons in `st.status` with
`DynamicIcon` material icons


## GitHub Issue Link (if applicable)

N/A

## Testing Plan

- [x] Python unit tests
- [x] FE JS test
- [x] E2E test 

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.
  • Loading branch information
snehankekre authored Jun 13, 2024
1 parent 51cd0c9 commit 152993c
Show file tree
Hide file tree
Showing 25 changed files with 205 additions and 53 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions e2e_playwright/st_expander.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,11 @@ def update_value():
"dolor, eleifend sit amet purus id, dictum aliquam quam."
)
collapsed_long.write("I am already collapsed")

expander_material_icon = st.expander(
"Expander with material icon!", icon=":material/bolt:"
).write("This is an expander with a material icon.")

expander_emoji_icon = st.expander("Expander with emoji icon!", icon="🎈").write(
"This is an expander with an emoji icon."
)
24 changes: 21 additions & 3 deletions e2e_playwright/st_expander_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from playwright.sync_api import Page, expect

from e2e_playwright.conftest import ImageCompareFunction, wait_for_app_run
from e2e_playwright.shared.app_utils import get_expander

EXPANDER_HEADER_IDENTIFIER = "summary"

Expand All @@ -24,7 +25,7 @@ def test_expander_displays_correctly(
):
"""Test that all expanders are displayed correctly via screenshot testing."""
expander_elements = themed_app.get_by_test_id("stExpander")
expect(expander_elements).to_have_count(6)
expect(expander_elements).to_have_count(8)

for expander in expander_elements.all():
expect(expander.locator(EXPANDER_HEADER_IDENTIFIER)).to_be_visible()
Expand All @@ -35,13 +36,15 @@ def test_expander_displays_correctly(
assert_snapshot(expander_elements.nth(3), name="st_expander-with_input")
assert_snapshot(expander_elements.nth(4), name="st_expander-long_expanded")
assert_snapshot(expander_elements.nth(5), name="st_expander-long_collapsed")
assert_snapshot(expander_elements.nth(6), name="st_expander-with_material_icon")
assert_snapshot(expander_elements.nth(7), name="st_expander-with_emoji_icon")


def test_expander_collapses_and_expands(app: Page):
"""Test that an expander collapses and expands."""
main_container = app.get_by_test_id("stAppViewBlockContainer")
main_expanders = main_container.get_by_test_id("stExpander")
expect(main_expanders).to_have_count(5)
expect(main_expanders).to_have_count(7)

expanders = main_expanders.all()
# Starts expanded
Expand Down Expand Up @@ -72,7 +75,7 @@ def test_expander_session_state_set(app: Page):
"""Test that session state updates are propagated to expander content"""
main_container = app.get_by_test_id("stAppViewBlockContainer")
main_expanders = main_container.get_by_test_id("stExpander")
expect(main_expanders).to_have_count(5)
expect(main_expanders).to_have_count(7)

# Show the Number Input
num_input = main_expanders.nth(2).get_by_test_id("stNumberInput").locator("input")
Expand All @@ -94,3 +97,18 @@ def test_expander_session_state_set(app: Page):

expect(text_elements.nth(0)).to_have_text("0.0", use_inner_text=True)
expect(text_elements.nth(1)).to_have_text("0.0", use_inner_text=True)


def test_expander_renders_icon(app: Page):
"""Test that an expander renders a material icon and an emoji icon."""
material_icon = get_expander(app, "Expander with material icon!").get_by_test_id(
"stExpanderIcon"
)
expect(material_icon).to_be_visible()
expect(material_icon).to_have_text("bolt")

emoji_icon = get_expander(app, "Expander with emoji icon!").get_by_test_id(
"stExpanderIcon"
)
expect(emoji_icon).to_be_visible()
expect(emoji_icon).to_have_text("🎈")
26 changes: 24 additions & 2 deletions frontend/lib/src/components/elements/Expander/Expander.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("Expander container", () => {
})

it("renders expander with a check icon", () => {
const props = getProps({ icon: "check" })
const props = getProps({ icon: ":material/check:" })
render(
<Expander {...props}>
<div>test</div>
Expand All @@ -100,7 +100,7 @@ describe("Expander container", () => {
})

it("renders expander with a error icon", () => {
const props = getProps({ icon: "error" })
const props = getProps({ icon: ":material/error:" })
render(
<Expander {...props}>
<div>test</div>
Expand All @@ -109,6 +109,28 @@ describe("Expander container", () => {
expect(screen.getByTestId("stExpanderIconError")).toBeInTheDocument()
})

it("renders expander with an emoji icon", () => {
const props = getProps({ icon: "🚀" })
render(
<Expander {...props}>
<div>test</div>
</Expander>
)
expect(screen.getByTestId("stExpanderIcon")).toBeInTheDocument()
expect(screen.getByText("🚀")).toBeInTheDocument()
})

it("renders expander with a material icon", () => {
const props = getProps({ icon: ":material/add_circle:" })
render(
<Expander {...props}>
<div>test</div>
</Expander>
)
expect(screen.getByTestId("stExpanderIcon")).toBeInTheDocument()
expect(screen.getByText("add_circle")).toBeInTheDocument()
})

it("should render a expanded component", () => {
const props = getProps()
render(
Expand Down
52 changes: 21 additions & 31 deletions frontend/lib/src/components/elements/Expander/Expander.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@
*/

import React, { ReactElement, useEffect, useRef, useState } from "react"
import {
ExpandMore,
ExpandLess,
Check,
ErrorOutline,
} from "@emotion-icons/material-outlined"
import { ExpandMore, ExpandLess } from "@emotion-icons/material-outlined"
import { Block as BlockProto } from "@streamlit/lib/src/proto"
import {
DynamicIcon,
StyledSpinnerIcon,
StyledIcon,
} from "@streamlit/lib/src/components/shared/Icon"
Expand All @@ -41,15 +37,14 @@ import {
} from "./styled-components"

export interface ExpanderIconProps {
icon: string
icon?: string
}

/**
* Renders an icon for the expander.
* Renders an icon for the expander and optionally a user-defined icon.
*
* If the icon is "spinner", it will render a spinner icon.
* If the icon is "check", it will render a check icon.
* If the icon is "error", it will render an error icon.
* If the icon is a valid, user-defined icon, it will render the user-defined icon.
* Otherwise, it will render nothing.
*
* @param {string} icon - The icon to render.
Expand All @@ -64,6 +59,12 @@ export const ExpanderIcon = (props: ExpanderIconProps): ReactElement => {
margin: "",
padding: "",
}

const statusIconTestIds: Record<string, string> = {
":material/check:": "stExpanderIconCheck",
":material/error:": "stExpanderIconError",
}

if (icon === "spinner") {
const usingCustomTheme = !isPresetTheme(activeTheme)
return (
Expand All @@ -73,29 +74,18 @@ export const ExpanderIcon = (props: ExpanderIconProps): ReactElement => {
{...iconProps}
/>
)
} else if (icon === "check") {
return (
<StyledIcon
as={Check}
color={"inherit"}
aria-hidden="true"
data-testid="stExpanderIconCheck"
{...iconProps}
/>
)
} else if (icon === "error") {
return (
<StyledIcon
as={ErrorOutline}
color={"inherit"}
aria-hidden="true"
data-testid="stExpanderIconError"
{...iconProps}
/>
)
}

return <></>
return icon ? (
<DynamicIcon
color="inherit"
iconValue={icon}
testid={statusIconTestIds[icon] || "stExpanderIcon"}
{...iconProps}
/>
) : (
<></>
)
}

export interface ExpanderProps {
Expand Down
6 changes: 6 additions & 0 deletions frontend/lib/src/components/shared/Icon/DynamicIcon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,22 @@ describe("Dynamic icon", () => {
it("renders without crashing with Material icon", () => {
const props = getProps({ iconValue: ":material/add_circle:" })
render(<DynamicIcon {...props} />)
const testId = screen.getByTestId("stIconMaterial")
const icon = screen.getByText("add_circle")

expect(testId).toBeInTheDocument()
expect(icon).toBeInTheDocument()
expect(testId.textContent).toEqual(icon.textContent)
})

it("renders without crashing with Emoji icon", () => {
const props = getProps({ iconValue: "⛰️" })
render(<DynamicIcon {...props} />)
const testId = screen.getByTestId("stIconEmoji")
const icon = screen.getByText("⛰️")

expect(testId).toBeInTheDocument()
expect(icon).toBeInTheDocument()
expect(testId.textContent).toEqual(icon.textContent)
})
})
19 changes: 16 additions & 3 deletions frontend/lib/src/components/shared/Icon/DynamicIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import React, { Suspense } from "react"
import { IconSize, ThemeColor } from "@streamlit/lib/src/theme"
import { EmojiIcon } from "./Icon"
import MaterialFontIcon from "./Material/MaterialFontIcon"
import { StyledDynamicIcon } from "./styled-components"

interface IconPackEntry {
pack: string
Expand Down Expand Up @@ -54,16 +55,28 @@ const DynamicIconDispatcher = ({
const { pack, icon } = parseIconPackEntry(iconValue)
switch (pack) {
case "material":
return <MaterialFontIcon pack={pack} iconName={icon} {...props} />
return (
<StyledDynamicIcon {...props}>
<MaterialFontIcon pack={pack} iconName={icon} {...props} />
</StyledDynamicIcon>
)
case "emoji":
default:
return <EmojiIcon {...props}>{icon}</EmojiIcon>
return (
<StyledDynamicIcon {...props}>
<EmojiIcon {...props}>{icon}</EmojiIcon>
</StyledDynamicIcon>
)
}
}

export const DynamicIcon = (props: DynamicIconProps): React.ReactElement => (
<Suspense
fallback={<EmojiIcon {...props}>&nbsp;</EmojiIcon>}
fallback={
<StyledDynamicIcon {...props}>
<EmojiIcon {...props}>&nbsp;</EmojiIcon>
</StyledDynamicIcon>
}
key={props.iconValue}
>
<DynamicIconDispatcher {...props} />
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/src/components/shared/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const EmojiIcon = ({
testid,
}: EmojiIconProps): ReactElement => (
<StyledEmojiIcon
data-testid={testid}
data-testid={testid || "stIconEmoji"}
aria-hidden="true"
{...getDefaultProps({ size, margin, padding })}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const MaterialFontIcon = ({
...props
}: MaterialIconProps): ReactElement => {
return (
<StyledMaterialIcon {...getDefaultProps(props)}>
<StyledMaterialIcon
{...getDefaultProps(props)}
data-testid={props.testid || "stIconMaterial"}
>
{snakeCase(iconName)}
</StyledMaterialIcon>
)
Expand Down
23 changes: 23 additions & 0 deletions frontend/lib/src/components/shared/Icon/styled-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ export const StyledIcon = styled("span", {
}
})

export interface StyledDynamicIconProps {
size?: IconSize
margin?: string
padding?: string
}

export const StyledDynamicIcon = styled.span<StyledDynamicIconProps>(
({ size = "lg", margin = "", padding = "", theme }) => {
return {
fill: "currentColor",
display: "inline-flex",
alignItems: "center",
justifyContents: "center",
fontSize: theme.iconSizes[size],
width: theme.iconSizes[size],
height: theme.iconSizes[size],
margin: computeSpacingStyle(margin, theme),
padding: computeSpacingStyle(padding, theme),
flexShrink: 0,
}
}
)

interface StyledEmojiIconProps {
size: IconSize
margin: string
Expand Down
27 changes: 26 additions & 1 deletion lib/streamlit/elements/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from streamlit.errors import StreamlitAPIException
from streamlit.proto.Block_pb2 import Block as BlockProto
from streamlit.runtime.metrics_util import gather_metrics
from streamlit.string_util import validate_icon_or_emoji

if TYPE_CHECKING:
from streamlit.delta_generator import DeltaGenerator
Expand Down Expand Up @@ -408,7 +409,13 @@ def tab_proto(label: str) -> BlockProto:
return tuple(tab_container._block(tab_proto(tab_label)) for tab_label in tabs)

@gather_metrics("expander")
def expander(self, label: str, expanded: bool = False) -> DeltaGenerator:
def expander(
self,
label: str,
expanded: bool = False,
*,
icon: str | None = None,
) -> DeltaGenerator:
r"""Insert a multi-element container that can be expanded/collapsed.
Inserts a container into your app that can be used to hold multiple elements
Expand Down Expand Up @@ -452,6 +459,22 @@ def expander(self, label: str, expanded: bool = False) -> DeltaGenerator:
expanded : bool
If True, initializes the expander in "expanded" state. Defaults to
False (collapsed).
icon : str, None
An optional emoji or icon to display next to the expander label. If ``icon``
is ``None`` (default), no icon is displayed. If ``icon`` is a
string, the following options are valid:
* A single-character emoji. For example, you can set ``icon="🚨"``
or ``icon="🔥"``. Emoji short codes are not supported.
* An icon from the Material Symbols library (outlined style) in the
format ``":material/icon_name:"`` where "icon_name" is the name
of the icon in snake case.
For example, ``icon=":material/thumb_up:"`` will display the
Thumb Up icon. Find additional icons in the `Material Symbols \
<https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Outlined>`_
font library.
Examples
--------
Expand Down Expand Up @@ -498,6 +521,8 @@ def expander(self, label: str, expanded: bool = False) -> DeltaGenerator:
expandable_proto = BlockProto.Expandable()
expandable_proto.expanded = expanded
expandable_proto.label = label
if icon is not None:
expandable_proto.icon = validate_icon_or_emoji(icon)

block_proto = BlockProto()
block_proto.allow_empty = False
Expand Down
Loading

0 comments on commit 152993c

Please sign in to comment.