Repository: arnaudmiribel/streamlit-extras

Issue:
Add Chanin's metric card
As seen in https://twitter.com/andrejusb/status/1578099977036779520?s=46&t=MFCVxxXAuuu-icifGbq5KA and see slack thread

Discussion:

Patch:
diff --git a/gallery/streamlit_app.py b/gallery/streamlit_app.py
index c406d7f..aea78a7 100644
--- a/gallery/streamlit_app.py
+++ b/gallery/streamlit_app.py
@@ -133,6 +133,7 @@ def get_page_content(
     twitter_username: Optional[str] = None,
     buymeacoffee_username: Optional[str] = None,
     experimental_playground: bool = False,
+    experimental_playground_funcs: Optional[List[Callable]] = None,
 ) -> Callable:
     def page_content():
         st.title(icon + " " + title)
@@ -218,6 +219,9 @@ def get_page_content(
                 st.write("#### Playground 🛝 [experimental]")
                 st.caption("In this section, you can test the function live!")
                 function_explorer(func=func)
+                if experimental_playground_funcs:
+                    for e_p_func in experimental_playground_funcs:
+                        e_p_func()
 
     page_content.__name__ = title
 
@@ -243,6 +247,9 @@ for extra_name in extra_names:
     buymeacoffee_username = getattr(mod, "__buymeacoffee_username__", None)
     forum_url = getattr(mod, "__forum_url__", None)
     experimental_playground = getattr(mod, "__experimental_playground__", False)
+    experimental_playground_funcs = getattr(
+        mod, "__experimental_playground_funcs__", None
+    )
 
     settings[extra_name] = dict(
         path=get_page_content(
@@ -262,6 +269,7 @@ for extra_name in extra_names:
             buymeacoffee_username=buymeacoffee_username,
             forum_url=forum_url,
             experimental_playground=experimental_playground,
+            experimental_playground_funcs=experimental_playground_funcs,
         ),
         name=title,
         icon=icon,
diff --git a/src/streamlit_extras/function_explorer/__init__.py b/src/streamlit_extras/function_explorer/__init__.py
index f51c923..dbf1f43 100644
--- a/src/streamlit_extras/function_explorer/__init__.py
+++ b/src/streamlit_extras/function_explorer/__init__.py
@@ -74,12 +74,20 @@ def function_explorer(func: Callable):
                 )
                 inputs[argument] = st.number_input(label, value=default)
             elif type_hint == str:
-                default = (
-                    get_arg_from_session_state(func.__name__, argument) or default
-                    if not is_empty(default)
-                    else "Sample string"
-                )
-                inputs[argument] = st_keyup(label, value=default)
+                if argument.endswith("_color"):
+                    default = (
+                        get_arg_from_session_state(func.__name__, argument) or default
+                        if not is_empty(default)
+                        else "#000000"
+                    )
+                    inputs[argument] = st.color_picker(label, value=default)
+                else:
+                    default = (
+                        get_arg_from_session_state(func.__name__, argument) or default
+                        if not is_empty(default)
+                        else "Sample string"
+                    )
+                    inputs[argument] = st_keyup(label, value=default)
             elif type_hint == bool:
                 default = (
                     get_arg_from_session_state(func.__name__, argument) or default
diff --git a/src/streamlit_extras/mandatory_date_range/__init__.py b/src/streamlit_extras/mandatory_date_range/__init__.py
new file mode 100644
index 0000000..d4151ae
--- /dev/null
+++ b/src/streamlit_extras/mandatory_date_range/__init__.py
@@ -0,0 +1,74 @@
+from datetime import date, timedelta
+from typing import Optional, Tuple, cast
+
+import streamlit as st
+
+from streamlit_extras import extra
+
+
+@extra
+def date_range_picker(
+    title: str,
+    default_start: Optional[date] = None,
+    default_end: Optional[date] = None,
+    min_date: Optional[date] = None,
+    max_date: Optional[date] = None,
+    error_message: str = "Please select start and end date",
+    key: Optional[str] = None,
+) -> Tuple[date, date]:
+    """
+    Working with date_input with a date range is frustrating becuase if you're
+    assuming you will get a start and end date out of it, your code can break (not
+    to mention your type hints), because if a user clicks on just one date, the app
+    will go ahead and run with a single output. This widget enforces a start and
+    end date being selected, and will stop the app if only one is chosen.
+
+    Defaults to a range of 30 days ago to today
+
+    Returns the selected start and end date
+    """
+
+    if default_start is None:
+        default_start = date.today() - timedelta(days=30)
+    if default_end is None:
+        default_end = date.today()
+
+    val = st.date_input(
+        title,
+        value=[default_start, default_end],
+        min_value=min_date,
+        max_value=max_date,
+        key=key,
+    )
+    try:
+        start_date, end_date = cast(Tuple[date, date], val)
+    except ValueError:
+        st.error(error_message)
+        st.stop()
+
+    return start_date, end_date
+
+
+def example():
+    st.write(
+        """
+        This is an example of a date range picker that *always* returns a start and
+        end date, even if the user has only selected one of the dates. Until the
+        user selects both dates, the app will not run.
+        """
+    )
+    result = date_range_picker("Select a date range")
+    st.write("Result:", result)
+
+
+st.date_input
+__title__ = "Mandatory Date Range Picker"
+__desc__ = """
+Just like st.date_input, but enforces that it always and only returns a start and
+end date, even if the user has only selected one of the dates. Until the user
+selects both dates, the app will not run.
+"""
+__icon__ = "📅"
+__examples__ = [example]
+__author__ = "Zachary Blackwood"
+__experimental_playground__ = False
diff --git a/src/streamlit_extras/metric_cards/__init__.py b/src/streamlit_extras/metric_cards/__init__.py
new file mode 100644
index 0000000..fb60f4d
--- /dev/null
+++ b/src/streamlit_extras/metric_cards/__init__.py
@@ -0,0 +1,63 @@
+import streamlit as st
+
+from streamlit_extras import extra
+
+
+@extra
+def style_metric_cards(
+    background_color: str = "#FFF",
+    border_size_px: int = 1,
+    border_color: str = "#CCC",
+    border_radius_px: int = 5,
+    border_left_color: str = "#9AD8E1",
+    box_shadow: bool = True,
+):
+
+    box_shadow_str = (
+        "box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;"
+        if box_shadow
+        else "box-shadow: none !important;"
+    )
+    st.markdown(
+        f"""
+        <style>
+            div[data-testid="metric-container"] {{
+                background-color: {background_color};
+                border: {border_size_px}px solid {border_color};
+                padding: 5% 5% 5% 10%;
+                border-radius: {border_radius_px}px;
+                border-left: 0.5rem solid {border_left_color} !important;
+                {box_shadow_str}
+            }}
+        </style>
+        """,
+        unsafe_allow_html=True,
+    )
+
+
+def example():
+    col1, col2, col3 = st.columns(3)
+
+    col1.metric(label="Gain", value=5000, delta=1000)
+    col2.metric(label="Loss", value=5000, delta=-1000)
+    col3.metric(label="No Change", value=5000, delta=0)
+
+    style_metric_cards()
+
+
+def _add_metric_card_cards():
+    """For playground"""
+    col1, col2, col3 = st.columns(3)
+
+    col1.metric(label="Gain", value=5000, delta=1000)
+    col2.metric(label="Loss", value=5000, delta=-1000)
+    col3.metric(label="No Change", value=5000, delta=0)
+
+
+__title__ = "Metric Cards"
+__desc__ = "Restyle metrics as cards"
+__icon__ = "♠️"
+__examples__ = [example]
+__author__ = "Chanin Nantasenamat"
+__experimental_playground__ = True
+__experimental_playground_funcs__ = [_add_metric_card_cards]
diff --git a/src/streamlit_extras/no_default_selectbox/__init__.py b/src/streamlit_extras/no_default_selectbox/__init__.py
new file mode 100644
index 0000000..972809a
--- /dev/null
+++ b/src/streamlit_extras/no_default_selectbox/__init__.py
@@ -0,0 +1,66 @@
+import streamlit as st
+
+from streamlit_extras import extra
+
+
+@extra
+def selectbox(*args, **kwargs):
+    """A selectbox that returns None unless the user has explicitly selected one of the
+    options.
+
+    All arguments are passed to st.selectbox except for `no_selection_label`, which is
+    used to specify the label of the option that represents no selection.
+
+    Parameters
+    ----------
+    no_selection_label : str
+        The label to use for the no-selection option. Defaults to "---".
+    """
+    no_selection_label = kwargs.pop("no_selection_label", "---")
+
+    args = list(args)
+
+    # Get the options from either the args or kwargs
+    try:
+        options = args.pop(1)
+    except IndexError:
+        options = kwargs["options"]
+
+    # Prepend the no-selection option to the list of options
+    if no_selection_label not in options:
+        options = [no_selection_label] + options
+        kwargs["options"] = options
+
+    result = st.selectbox(*args, **kwargs)
+    if result == no_selection_label:
+        return None
+    return result
+
+
+def example():
+    st.write(
+        """
+        This is an example of a selectbox that returns None unless the user has
+        explicitly selected one of the options.
+        """
+    )
+    st.write(
+        """
+        The selectbox below has no default value, so it will return None until the
+        user selects an option.
+        """
+    )
+    result = selectbox("Select an option", ["A", "B", "C"])
+    st.write("Result:", result)
+
+
+__title__ = "No-Default Selectbox"
+__desc__ = """
+Just like st.selectbox, but with no default value -- returns None if nothing is selected.
+
+Meant to be a solution to https://github.com/streamlit/streamlit/issues/949
+"""
+__icon__ = "🗳️"
+__examples__ = [example]
+__author__ = "Zachary Blackwood"
+__experimental_playground__ = False


Test patch:
diff --git a/tests/test_extras.py b/tests/test_extras.py
index 4c60c63..0bd2b93 100644
--- a/tests/test_extras.py
+++ b/tests/test_extras.py
@@ -52,6 +52,10 @@ def test_extra_attributes(extra: str):
         assert "discuss.streamlit.io" in mod.__forum_url__
     if hasattr(mod, "__experimental_playground__"):
         assert type(mod.__experimental_playground__) == bool
+    if hasattr(mod, "__experimental_playground_funcs__"):
+        assert type(mod.__experimental_playground_funcs__) == list
+        for func in mod.__experimental_playground_funcs__:
+            assert callable(func)
 
 
 @pytest.mark.parametrize("extra", get_extras())