From 23439ded9e960cdae806e64bce3b835c14fa175d Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 7 Dec 2025 22:24:52 -0800 Subject: [PATCH 01/12] Quickstart examples --- .../my_component/__init__.py | 24 +++++ .../my_component/my_css.css | 70 +++++++++++++++ .../my_component/my_html.html | 13 +++ .../my_component/my_js.js | 85 ++++++++++++++++++ .../streamlit_app.py | 36 ++++++++ .../my_component/__init__.py | 24 +++++ .../my_component/my_css.css | 29 ++++++ .../my_component/my_html.html | 8 ++ .../my_component/my_js.js | 43 +++++++++ .../streamlit_app.py | 24 +++++ .../components-rich-data-exchange.py | 61 +++++++++++++ .../components-simple-interactive-button.py | 37 ++++++++ python/concept-source/favi.png | Bin 0 -> 17118 bytes 13 files changed, 454 insertions(+) create mode 100644 python/concept-source/components-form-with-validation/my_component/__init__.py create mode 100644 python/concept-source/components-form-with-validation/my_component/my_css.css create mode 100644 python/concept-source/components-form-with-validation/my_component/my_html.html create mode 100644 python/concept-source/components-form-with-validation/my_component/my_js.js create mode 100644 python/concept-source/components-form-with-validation/streamlit_app.py create mode 100644 python/concept-source/components-interactive-counter/my_component/__init__.py create mode 100644 python/concept-source/components-interactive-counter/my_component/my_css.css create mode 100644 python/concept-source/components-interactive-counter/my_component/my_html.html create mode 100644 python/concept-source/components-interactive-counter/my_component/my_js.js create mode 100644 python/concept-source/components-interactive-counter/streamlit_app.py create mode 100644 python/concept-source/components-rich-data-exchange.py create mode 100644 python/concept-source/components-simple-interactive-button.py create mode 100644 python/concept-source/favi.png diff --git a/python/concept-source/components-form-with-validation/my_component/__init__.py b/python/concept-source/components-form-with-validation/my_component/__init__.py new file mode 100644 index 000000000..9532406c9 --- /dev/null +++ b/python/concept-source/components-form-with-validation/my_component/__init__.py @@ -0,0 +1,24 @@ +import streamlit as st +from pathlib import Path + +# Get the current file's directory +_COMPONENT_DIR = Path(__file__).parent + +@st.cache_data +def load_html(): + with open(_COMPONENT_DIR / "my_html.html", "r") as f: + return f.read() + +@st.cache_data +def load_css(): + with open(_COMPONENT_DIR / "my_css.css", "r") as f: + return f.read() + +@st.cache_data +def load_js(): + with open(_COMPONENT_DIR / "my_js.js", "r") as f: + return f.read() + +HTML = load_html() +CSS = load_css() +JS = load_js() \ No newline at end of file diff --git a/python/concept-source/components-form-with-validation/my_component/my_css.css b/python/concept-source/components-form-with-validation/my_component/my_css.css new file mode 100644 index 000000000..a3830f532 --- /dev/null +++ b/python/concept-source/components-form-with-validation/my_component/my_css.css @@ -0,0 +1,70 @@ +.form-container { + padding: 1rem; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + box-sizing: border-box; +} + +h3 { + font-size: var(--st-heading-font-size-h3, inherit); + font-weight: var(--st-heading-font-weight-h3, inherit); + margin: 0; +} + +input, +textarea { + width: 100%; + padding: 0.5rem; + margin: 0.5rem 0; + background: var(--st-secondary-background-color); + border: 1px solid transparent; + border-radius: var(--st-base-radius); + box-sizing: border-box; + font-size: inherit; + font-family: inherit; +} + +input:focus, +textarea:focus { + outline: none; + border-color: var(--st-primary-color); +} + +textarea { + height: 5rem; + resize: vertical; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 0.75rem; +} + +button { + padding: 0.5rem 1rem; + border-radius: var(--st-button-radius); + border: 1px solid transparent; + font-size: inherit; + font-family: inherit; +} + +button[type="submit"] { + background: var(--st-primary-color); + color: white; +} + +button[type="button"] { + border: 1px solid var(--st-border-color); + background: var(--st-primary-background-color); + color: var(--st-text-color); +} + +button:hover { + opacity: 0.9; + border-color: var(--st-primary-color); +} + +#status { + margin-top: 0.5rem; +} diff --git a/python/concept-source/components-form-with-validation/my_component/my_html.html b/python/concept-source/components-form-with-validation/my_component/my_html.html new file mode 100644 index 000000000..936ebd2b0 --- /dev/null +++ b/python/concept-source/components-form-with-validation/my_component/my_html.html @@ -0,0 +1,13 @@ +
+

Contact Form

+
+ + + +
+ + +
+
+
+
diff --git a/python/concept-source/components-form-with-validation/my_component/my_js.js b/python/concept-source/components-form-with-validation/my_component/my_js.js new file mode 100644 index 000000000..c757f2dd4 --- /dev/null +++ b/python/concept-source/components-form-with-validation/my_component/my_js.js @@ -0,0 +1,85 @@ +export default function ({ + parentElement, + setStateValue, + setTriggerValue, + data, +}) { + const form = parentElement.querySelector("#contact-form"); + const nameInput = parentElement.querySelector("#name"); + const emailInput = parentElement.querySelector("#email"); + const messageInput = parentElement.querySelector("#message"); + const saveDraftBtn = parentElement.querySelector("#save-draft"); + const status = parentElement.querySelector("#status"); + + // Register custom CSS variables with third values from --st-heading-font-sizes and --st-heading-font-weights + requestAnimationFrame(() => { + const container = parentElement.querySelector(".form-container"); + const headingSizes = getComputedStyle(form) + .getPropertyValue("--st-heading-font-sizes") + .trim(); + const headingWeights = getComputedStyle(form) + .getPropertyValue("--st-heading-font-weights") + .trim(); + const sizes = headingSizes.split(",").map((s) => s.trim()); + const weights = headingWeights.split(",").map((s) => s.trim()); + if (sizes[2] && container) { + container.style.setProperty("--st-heading-font-size-h3", sizes[2]); + } + if (weights[2] && container) { + container.style.setProperty("--st-heading-font-weight-h3", weights[2]); + } + }); + + // Load draft if available + const loadDraft = (draft) => { + nameInput.value = draft.name || ""; + emailInput.value = draft.email || ""; + messageInput.value = draft.message || ""; + }; + + loadDraft(data?.draft || {}); + + // Save draft + const saveDraft = () => { + setStateValue("draft", { + name: nameInput.value, + email: emailInput.value, + message: messageInput.value, + }); + setTriggerValue("action", "save_draft"); + status.textContent = "Draft saved!"; + status.style.color = "var(--st-green-color)"; + setTimeout(() => (status.textContent = ""), 2000); + }; + + // Submit form + const submitForm = (e) => { + e.preventDefault(); + + if (!nameInput.value || !emailInput.value || !messageInput.value) { + status.textContent = "Please fill all fields"; + status.style.color = "var(--st-red-color)"; + return; + } + + status.textContent = "Message sent!"; + status.style.color = "var(--st-blue-color)"; + setTimeout(() => (status.textContent = ""), 2000); + setTriggerValue("submit", { + name: nameInput.value, + email: emailInput.value, + message: messageInput.value, + }); + loadDraft({}); + setStateValue("draft", {}); + }; + + // Event listeners - only update on button clicks + saveDraftBtn.addEventListener("click", saveDraft); + form.addEventListener("submit", submitForm); + + return () => { + saveDraftBtn.removeEventListener("click", saveDraft); + form.removeEventListener("submit", submitForm); + }; +} diff --git a/python/concept-source/components-form-with-validation/streamlit_app.py b/python/concept-source/components-form-with-validation/streamlit_app.py new file mode 100644 index 000000000..24623eed6 --- /dev/null +++ b/python/concept-source/components-form-with-validation/streamlit_app.py @@ -0,0 +1,36 @@ +import streamlit as st +from my_component import HTML, CSS, JS + +form_component = st.components.v2.component( + "contact_form", + html=HTML, + css=CSS, + js=JS, +) + + +# Handle form actions +def handle_form_action(): + # Process submission + # if submission_failed: + # submission = st.session_state.message_form.submit + # st.session_state.message_form.draft=submission + pass + + +# Use the component +form_state = st.session_state.get("message_form", {}) +result = form_component( + data={"draft": form_state.get("draft", {})}, + default={"draft": form_state.get("draft", {})}, + on_draft_change=lambda: None, + on_submit_change=handle_form_action, + key="message_form", +) + +if result.submit: + st.write("Message Submitted:") + result.submit +else: + st.write("Current Draft:") + result.draft diff --git a/python/concept-source/components-interactive-counter/my_component/__init__.py b/python/concept-source/components-interactive-counter/my_component/__init__.py new file mode 100644 index 000000000..11d46767a --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/__init__.py @@ -0,0 +1,24 @@ +import streamlit as st +from pathlib import Path + +# Get the current file's directory +_COMPONENT_DIR = Path(__file__).parent + +@st.cache_data +def load_html(): + with open(_COMPONENT_DIR / "my_html.html", "r") as f: + return f.read() + +@st.cache_data +def load_css(): + with open(_COMPONENT_DIR / "my_css.css", "r") as f: + return f.read() + +@st.cache_data +def load_js(): + with open(_COMPONENT_DIR / "my_js.js", "r") as f: + return f.read() + +HTML = load_html() +CSS = load_css() +JS = load_js() diff --git a/python/concept-source/components-interactive-counter/my_component/my_css.css b/python/concept-source/components-interactive-counter/my_component/my_css.css new file mode 100644 index 000000000..6bf531a0f --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/my_css.css @@ -0,0 +1,29 @@ +.counter { + padding: 20px; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + text-align: center; +} + +.buttons { + margin-top: 15px; +} + +button { + margin: 0 5px; + padding: 8px 16px; + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + cursor: pointer; +} + +button:hover { + opacity: 0.8; +} + +#reset { + background: var(--st-red-color); +} diff --git a/python/concept-source/components-interactive-counter/my_component/my_html.html b/python/concept-source/components-interactive-counter/my_component/my_html.html new file mode 100644 index 000000000..18f566bbb --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/my_html.html @@ -0,0 +1,8 @@ +
+

Count: 0

+
+ + + +
+
diff --git a/python/concept-source/components-interactive-counter/my_component/my_js.js b/python/concept-source/components-interactive-counter/my_component/my_js.js new file mode 100644 index 000000000..07cc7c1e3 --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/my_js.js @@ -0,0 +1,43 @@ +export default function ({ + parentElement, + setStateValue, + setTriggerValue, + data, +}) { + let count = data?.initialCount || 0; + const display = parentElement.querySelector("#display"); + const incrementBtn = parentElement.querySelector("#increment"); + const decrementBtn = parentElement.querySelector("#decrement"); + const resetBtn = parentElement.querySelector("#reset"); + + const updateDisplay = () => { + display.textContent = count; + setStateValue("count", count); // Persistent state + }; + + incrementBtn.onclick = () => { + count++; + updateDisplay(); + }; + + decrementBtn.onclick = () => { + count--; + updateDisplay(); + }; + + resetBtn.onclick = () => { + count = 0; + updateDisplay(); + setTriggerValue("reset", true); // One-time trigger + }; + + // Initialize + updateDisplay(); + + // Cleanup function + return () => { + incrementBtn.removeEventListener("click", incrementBtn.onclick); + decrementBtn.removeEventListener("click", decrementBtn.onclick); + resetBtn.removeEventListener("click", resetBtn.onclick); + }; +} diff --git a/python/concept-source/components-interactive-counter/streamlit_app.py b/python/concept-source/components-interactive-counter/streamlit_app.py new file mode 100644 index 000000000..778c7cf3e --- /dev/null +++ b/python/concept-source/components-interactive-counter/streamlit_app.py @@ -0,0 +1,24 @@ +import streamlit as st +from my_component import HTML, CSS, JS + +# Interactive counter with both state and triggers +counter = st.components.v2.component( + "interactive_counter", + html=HTML, + css=CSS, + js=JS, +) + +# Use with callbacks +result = counter( + data={"initialCount": 0}, + on_count_change=lambda: None, # Track count state + on_reset_change=lambda: None, # Handle reset events +) + +# Display current state +st.write(f"Current count: {result.count}") + +# Show when reset was triggered (only for one rerun) +if result.reset: + st.toast("Counter was reset!") diff --git a/python/concept-source/components-rich-data-exchange.py b/python/concept-source/components-rich-data-exchange.py new file mode 100644 index 000000000..b585e597e --- /dev/null +++ b/python/concept-source/components-rich-data-exchange.py @@ -0,0 +1,61 @@ +import pandas as pd +import streamlit as st +import base64 +from pathlib import Path + + +_APP_DIR = Path(__file__).parent + + +# Create sample data +@st.cache_data +def create_sample_df(): + return pd.DataFrame( + { + "name": ["Alice", "Bob", "Charlie"], + "city": ["New York", "London", "Tokyo"], + } + ) + + +df = create_sample_df() + + +# Load an image and convert to bytes +@st.cache_data +def load_image_as_base64(image_path): + with open(image_path, "rb") as img_file: + img_bytes = img_file.read() + return base64.b64encode(img_bytes).decode("utf-8") + + +img_base64 = load_image_as_base64(_APP_DIR / "favi.png") + +# Serialization is automatically handled by Streamlit components +chart_component = st.components.v2.component( + "data_display", + html="""
Loading data...
""", + js=""" + export default function({ data, parentElement }) { + const container = parentElement.querySelector("#data-container"); + + const df = data.df; + const userInfo = data.user_info; + const imgBase64 = data.image_base64; + + container.innerHTML = ` +

Dataframe: ${df}

+

User Info: ${userInfo.name}

+ + `; + } + """, +) + +result = chart_component( + data={ + "df": df, # Arrow-serializable dataframe + "user_info": {"name": "Alice"}, # JSON-serializable data + "image_base64": img_base64, # Image as base64 string + } +) diff --git a/python/concept-source/components-simple-interactive-button.py b/python/concept-source/components-simple-interactive-button.py new file mode 100644 index 000000000..9e6d42224 --- /dev/null +++ b/python/concept-source/components-simple-interactive-button.py @@ -0,0 +1,37 @@ +import streamlit as st + +if "click_count" not in st.session_state: + st.session_state.click_count = 0 + + +def handle_button_click(): + st.session_state.click_count += 1 + + +my_component = st.components.v2.component( + "interactive_button", + html="""""", + css=""" + button { + border: none; + padding: .5rem; + border-radius: var(--st-button-radius); + background-color: var(--st-primary-color); + color: white; + } + """, + js=""" + export default function(component) { + const { setTriggerValue, parentElement } = component; + + parentElement.querySelector("button").onclick = () => { + setTriggerValue("action", "button_clicked"); + }; + } + """, +) + +result = my_component(on_action_change=handle_button_click) + +if result.action: + st.write(f"Button clicked! Total clicks: {st.session_state.click_count}") diff --git a/python/concept-source/favi.png b/python/concept-source/favi.png new file mode 100644 index 0000000000000000000000000000000000000000..7ffb2bce001bc7600f510a982d62124d48d90b50 GIT binary patch literal 17118 zcmeHu^;=Y5^zNCVOHf3Ckx)wMMtTqlY3UB>PU#px38hmSr5ou6rE}=+h9RV3h`ICm z-v8m=U%uyg<~e6(_St8zwfB1WyVhANTt!KS2%i!k005#7vhP&^0F1r_1Gw1e)4As) z5`DsRlGSkq00PqgJ|G}9ogDom$W>M59Z){kdm=LHY9nw;un=bHYd?lH=1Mldzy?^-2?ZQ1=TQpcyl z^(}6C^|W`^mDkFZmlS$1|9kwu*T8reE-a*~SUdf3*cCtB^6%JMI#D`7+u_4cziL}e zj#4Ka3@-|SJ`pgG4PrHDnhfa8s^h5ZNoVS5Q4rs1?@K-!?fskF>@C#p)mv8o@RR-t z9!%jB3z~1A%Euy*W4B^1C1x5Ss4Bj7xILKGHE<#kBm586k0b8#ZyJZj;rTrwZ0H0N znlCD9cFhv;Bid?1+#lNhN7gXyj>q?Ra;DSi8yo_d!Yd5GOub0`RBUZ)C*;+0TPFW` z!+Q%-aR}pe4?h*}O$TD3-ogAe&_~Vmc?F1{Jr`Xx!t#lnR+XJ`=yWd36LTQ zpnLg)U0{XL25mkIBsBbU)$-jzXl;uS3!vyE0b(Quzju#$nYNV_keV6kP?-3d8uEE> zB3WqsYP0||Fa>KKR@>& z$~>9m%p)Ilw^a=Oz`%ksk^lh3r%1tpFJpRaSLLw2`$@)Q zR>2$j3Ox9YyMcvzF=NxGbi=vmXR5}1qEzZ4ieC_`+0;P2C zR5SVGk-&@bjU5iRcw#K*K3b?Z7ge9e^6pmZW+3tB|BFwl<%H~JqC zdE(Q{7r!lHbLIc&qN*;}i#Q$w;qTFZ&6ba5y?WAWpsXfPG(t{ct>`&+`sw%f?NeZu z2R%D~PTPE?Rv6{wbZnKZYnS)5*bD}I>1a*JvoN0Y*_Y?MIS;6nW{<6@uIm0WmZoSd z0JnAIAb`E6#Q^9PJ{%8H*GG)Z1|+a@avQj8XWV3F#)4_N&}#f5SgxH;@_ze%-euZ8 zZAEV4C#<^3g{|TV5RTpw==)9?t=uy9%e)c^_bi-&j3I2iKTDp_{QMZq%4;FAC zgYFx)u*+2Q0O=kWWSAK8mmXU18Q6XeQqe-sUPdEBKu&K|MujMlfum1XxSP0z zV6j~fm?Q!LDjql1nk{D+o4D3M4>>$6ox*EbH3(RBOgztgZ7r>0lf5sHWl(9Hd;I`D zX`$mgUr~;3-z{*Qi4HK)ES+VC1{2tOM)7}Vq8LUAJhf%#oL(umgkV}3OWB_rHI?`# z{N@%v*v(+KrwfXP3-0|?`BfV&-nuc3ny<=}EY2<-bUr95P>R<-iuV@Mf;_}hA_WkQ z3)F6D)X{s+jR%Wfk=BkYFQFJsdwCqX|8deAnzx7B8qxTpFm|ES zhTz;7pWw{QyiQnrEe(m|P1#bQ!8}yFaQGbF+2?x_H!PraM_nWb7|Sy#^E*u9q+RjM zojuFHvNrx_zPH!dUux)=1rV(xKRMeN-&LdxDc>!W8T#$|EzbUvKbv1D86O12ZDYt# z&&}&wr5)*beSWP^ghe1za5zqa1>qG@&87}m=TD(1m4-sr)bFAR`{_Ykg@7}Qq7 zt@2ktHG7`idG-N)Z>eq_&bo!Qjt^4CXI=Q?>aQ+EabQEuR+r(SwNG_dDkP1?aU%r- zlq1`7L@yWuYaL_pOFRMNrA;ij&)3Wmnt!Mj-Q-ixJZddD65noi-J18X22g7MTSbcy%j!{z+V@Pbeh5fPKd8S9?g9=6ZnyN`6G2rc; zTKio512%kO4db~;dj4^BUgJ&Z<)m6gTqw=5S)JY9W7=;0KUuOtXOXkNMv>x!Y($f5 zG4Ì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 HanSz(tPMB< literal 0 HcmV?d00001 From c6f434eb4d92c997995feec259769465a50f5e71 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 7 Dec 2025 22:34:06 -0800 Subject: [PATCH 02/12] Create components examples --- .../my_component/my_js.js | 3 ++- .../concept-source/components-hello-world.py | 9 +++++++ .../my_component/__init__.py | 24 ++++++++++++++++++ .../my_component/my_css.css | 25 +++++++++++++++++++ .../my_component/my_html.html | 4 +++ .../my_component/my_js.js | 11 ++++++++ .../streamlit_app.py | 24 ++++++++++++++++++ 7 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 python/concept-source/components-hello-world.py create mode 100644 python/concept-source/components-simple-counter/my_component/__init__.py create mode 100644 python/concept-source/components-simple-counter/my_component/my_css.css create mode 100644 python/concept-source/components-simple-counter/my_component/my_html.html create mode 100644 python/concept-source/components-simple-counter/my_component/my_js.js create mode 100644 python/concept-source/components-simple-counter/streamlit_app.py diff --git a/python/concept-source/components-form-with-validation/my_component/my_js.js b/python/concept-source/components-form-with-validation/my_component/my_js.js index c757f2dd4..57b4105a3 100644 --- a/python/concept-source/components-form-with-validation/my_component/my_js.js +++ b/python/concept-source/components-form-with-validation/my_component/my_js.js @@ -11,7 +11,8 @@ export default function ({ const saveDraftBtn = parentElement.querySelector("#save-draft"); const status = parentElement.querySelector("#status"); - // Register custom CSS variables with third values from --st-heading-font-sizes and --st-heading-font-weights + // Register custom CSS variables with third values from + // --st-heading-font-sizes and --st-heading-font-weights requestAnimationFrame(() => { const container = parentElement.querySelector(".form-container"); const headingSizes = getComputedStyle(form) diff --git a/python/concept-source/components-hello-world.py b/python/concept-source/components-hello-world.py new file mode 100644 index 000000000..8aab3d5ff --- /dev/null +++ b/python/concept-source/components-hello-world.py @@ -0,0 +1,9 @@ +import streamlit as st + +hello_component = st.components.v2.component( + name="hello_world", + html="

Hello, World!

", + css="h2 { color: var(--st-primary-color); }", +) + +hello_component() diff --git a/python/concept-source/components-simple-counter/my_component/__init__.py b/python/concept-source/components-simple-counter/my_component/__init__.py new file mode 100644 index 000000000..11d46767a --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/__init__.py @@ -0,0 +1,24 @@ +import streamlit as st +from pathlib import Path + +# Get the current file's directory +_COMPONENT_DIR = Path(__file__).parent + +@st.cache_data +def load_html(): + with open(_COMPONENT_DIR / "my_html.html", "r") as f: + return f.read() + +@st.cache_data +def load_css(): + with open(_COMPONENT_DIR / "my_css.css", "r") as f: + return f.read() + +@st.cache_data +def load_js(): + with open(_COMPONENT_DIR / "my_js.js", "r") as f: + return f.read() + +HTML = load_html() +CSS = load_css() +JS = load_js() diff --git a/python/concept-source/components-simple-counter/my_component/my_css.css b/python/concept-source/components-simple-counter/my_component/my_css.css new file mode 100644 index 000000000..5c4bfde26 --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/my_css.css @@ -0,0 +1,25 @@ +.counter { + padding: 0.5rem 0.5rem; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + font-size: var(--st-base-font-size); + color: var(--st-text-color); +} + +#count { + padding: 0.75rem; +} + +#increment { + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + padding: 0.25rem 0.5rem; + margin-left: 0.25rem; +} + +#increment:hover { + opacity: 0.8; +} diff --git a/python/concept-source/components-simple-counter/my_component/my_html.html b/python/concept-source/components-simple-counter/my_component/my_html.html new file mode 100644 index 000000000..f49c2162f --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/my_html.html @@ -0,0 +1,4 @@ +
+ 0 + +
diff --git a/python/concept-source/components-simple-counter/my_component/my_js.js b/python/concept-source/components-simple-counter/my_component/my_js.js new file mode 100644 index 000000000..7a03d22a8 --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/my_js.js @@ -0,0 +1,11 @@ +export default function ({ parentElement, setStateValue }) { + let count = 0; + const display = parentElement.querySelector("#count"); + const button = parentElement.querySelector("#increment"); + + button.onclick = () => { + count++; + display.textContent = count; + setStateValue("count", count); + }; +} diff --git a/python/concept-source/components-simple-counter/streamlit_app.py b/python/concept-source/components-simple-counter/streamlit_app.py new file mode 100644 index 000000000..2815ab3e7 --- /dev/null +++ b/python/concept-source/components-simple-counter/streamlit_app.py @@ -0,0 +1,24 @@ +import streamlit as st +from my_component import HTML, CSS, JS + +counter_component = st.components.v2.component( + name="counter", html=HTML, css=CSS, js=JS +) + + +# Define callback function for the count state value +def handle_count_change(): + # Called when the component calls setStateValue("count", value) + st.toast("Count was updated!") + + +# Mount the counter component with callback +result = counter_component( + width="content", on_count_change=handle_count_change, key="counter_1" +) + +# Access the current count value +st.write(f"Current count: {result.count}") + +# Access the current count value in Session State +st.write(f"Current count: {st.session_state.counter_1.count}") From 23b1034c11d0aa255e476273db99b885591cd615 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 7 Dec 2025 22:39:41 -0800 Subject: [PATCH 03/12] Update precommit linter --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8e42344e7..16dce74e4 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "tailwindcss": "^3.4.0" }, "lint-staged": { - "*": "prettier --ignore-unknown --write" + "python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html}": "prettier --ignore-unknown --write --tab-width=4", + "!(python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html})": "prettier --ignore-unknown --write" } } From 4f375d3c119c680ae6b22edda6c3069897003d12 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 7 Dec 2025 22:41:23 -0800 Subject: [PATCH 04/12] Re-format --- .../my_component/my_css.css | 72 ++++----- .../my_component/my_html.html | 22 +-- .../my_component/my_js.js | 151 +++++++++--------- .../my_component/my_css.css | 30 ++-- .../my_component/my_html.html | 12 +- .../my_component/my_js.js | 68 ++++---- .../my_component/my_css.css | 28 ++-- .../my_component/my_html.html | 4 +- .../my_component/my_js.js | 16 +- 9 files changed, 203 insertions(+), 200 deletions(-) diff --git a/python/concept-source/components-form-with-validation/my_component/my_css.css b/python/concept-source/components-form-with-validation/my_component/my_css.css index a3830f532..019d497b7 100644 --- a/python/concept-source/components-form-with-validation/my_component/my_css.css +++ b/python/concept-source/components-form-with-validation/my_component/my_css.css @@ -1,70 +1,70 @@ .form-container { - padding: 1rem; - border: 1px solid var(--st-border-color); - border-radius: var(--st-base-radius); - box-sizing: border-box; + padding: 1rem; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + box-sizing: border-box; } h3 { - font-size: var(--st-heading-font-size-h3, inherit); - font-weight: var(--st-heading-font-weight-h3, inherit); - margin: 0; + font-size: var(--st-heading-font-size-h3, inherit); + font-weight: var(--st-heading-font-weight-h3, inherit); + margin: 0; } input, textarea { - width: 100%; - padding: 0.5rem; - margin: 0.5rem 0; - background: var(--st-secondary-background-color); - border: 1px solid transparent; - border-radius: var(--st-base-radius); - box-sizing: border-box; - font-size: inherit; - font-family: inherit; + width: 100%; + padding: 0.5rem; + margin: 0.5rem 0; + background: var(--st-secondary-background-color); + border: 1px solid transparent; + border-radius: var(--st-base-radius); + box-sizing: border-box; + font-size: inherit; + font-family: inherit; } input:focus, textarea:focus { - outline: none; - border-color: var(--st-primary-color); + outline: none; + border-color: var(--st-primary-color); } textarea { - height: 5rem; - resize: vertical; + height: 5rem; + resize: vertical; } .form-actions { - display: flex; - gap: 1rem; - margin-top: 0.75rem; + display: flex; + gap: 1rem; + margin-top: 0.75rem; } button { - padding: 0.5rem 1rem; - border-radius: var(--st-button-radius); - border: 1px solid transparent; - font-size: inherit; - font-family: inherit; + padding: 0.5rem 1rem; + border-radius: var(--st-button-radius); + border: 1px solid transparent; + font-size: inherit; + font-family: inherit; } button[type="submit"] { - background: var(--st-primary-color); - color: white; + background: var(--st-primary-color); + color: white; } button[type="button"] { - border: 1px solid var(--st-border-color); - background: var(--st-primary-background-color); - color: var(--st-text-color); + border: 1px solid var(--st-border-color); + background: var(--st-primary-background-color); + color: var(--st-text-color); } button:hover { - opacity: 0.9; - border-color: var(--st-primary-color); + opacity: 0.9; + border-color: var(--st-primary-color); } #status { - margin-top: 0.5rem; + margin-top: 0.5rem; } diff --git a/python/concept-source/components-form-with-validation/my_component/my_html.html b/python/concept-source/components-form-with-validation/my_component/my_html.html index 936ebd2b0..b2ac79dee 100644 --- a/python/concept-source/components-form-with-validation/my_component/my_html.html +++ b/python/concept-source/components-form-with-validation/my_component/my_html.html @@ -1,13 +1,13 @@
-

Contact Form

-
- - - -
- - -
-
-
+

Contact Form

+
+ + + +
+ + +
+
+
diff --git a/python/concept-source/components-form-with-validation/my_component/my_js.js b/python/concept-source/components-form-with-validation/my_component/my_js.js index 57b4105a3..a1cf41df1 100644 --- a/python/concept-source/components-form-with-validation/my_component/my_js.js +++ b/python/concept-source/components-form-with-validation/my_component/my_js.js @@ -1,86 +1,89 @@ export default function ({ - parentElement, - setStateValue, - setTriggerValue, - data, + parentElement, + setStateValue, + setTriggerValue, + data, }) { - const form = parentElement.querySelector("#contact-form"); - const nameInput = parentElement.querySelector("#name"); - const emailInput = parentElement.querySelector("#email"); - const messageInput = parentElement.querySelector("#message"); - const saveDraftBtn = parentElement.querySelector("#save-draft"); - const status = parentElement.querySelector("#status"); + const form = parentElement.querySelector("#contact-form"); + const nameInput = parentElement.querySelector("#name"); + const emailInput = parentElement.querySelector("#email"); + const messageInput = parentElement.querySelector("#message"); + const saveDraftBtn = parentElement.querySelector("#save-draft"); + const status = parentElement.querySelector("#status"); - // Register custom CSS variables with third values from - // --st-heading-font-sizes and --st-heading-font-weights - requestAnimationFrame(() => { - const container = parentElement.querySelector(".form-container"); - const headingSizes = getComputedStyle(form) - .getPropertyValue("--st-heading-font-sizes") - .trim(); - const headingWeights = getComputedStyle(form) - .getPropertyValue("--st-heading-font-weights") - .trim(); - const sizes = headingSizes.split(",").map((s) => s.trim()); - const weights = headingWeights.split(",").map((s) => s.trim()); - if (sizes[2] && container) { - container.style.setProperty("--st-heading-font-size-h3", sizes[2]); - } - if (weights[2] && container) { - container.style.setProperty("--st-heading-font-weight-h3", weights[2]); - } - }); + // Register custom CSS variables with third values from + // --st-heading-font-sizes and --st-heading-font-weights + requestAnimationFrame(() => { + const container = parentElement.querySelector(".form-container"); + const headingSizes = getComputedStyle(form) + .getPropertyValue("--st-heading-font-sizes") + .trim(); + const headingWeights = getComputedStyle(form) + .getPropertyValue("--st-heading-font-weights") + .trim(); + const sizes = headingSizes.split(",").map((s) => s.trim()); + const weights = headingWeights.split(",").map((s) => s.trim()); + if (sizes[2] && container) { + container.style.setProperty("--st-heading-font-size-h3", sizes[2]); + } + if (weights[2] && container) { + container.style.setProperty( + "--st-heading-font-weight-h3", + weights[2], + ); + } + }); - // Load draft if available - const loadDraft = (draft) => { - nameInput.value = draft.name || ""; - emailInput.value = draft.email || ""; - messageInput.value = draft.message || ""; - }; + // Load draft if available + const loadDraft = (draft) => { + nameInput.value = draft.name || ""; + emailInput.value = draft.email || ""; + messageInput.value = draft.message || ""; + }; - loadDraft(data?.draft || {}); + loadDraft(data?.draft || {}); - // Save draft - const saveDraft = () => { - setStateValue("draft", { - name: nameInput.value, - email: emailInput.value, - message: messageInput.value, - }); - setTriggerValue("action", "save_draft"); - status.textContent = "Draft saved!"; - status.style.color = "var(--st-green-color)"; - setTimeout(() => (status.textContent = ""), 2000); - }; + // Save draft + const saveDraft = () => { + setStateValue("draft", { + name: nameInput.value, + email: emailInput.value, + message: messageInput.value, + }); + setTriggerValue("action", "save_draft"); + status.textContent = "Draft saved!"; + status.style.color = "var(--st-green-color)"; + setTimeout(() => (status.textContent = ""), 2000); + }; - // Submit form - const submitForm = (e) => { - e.preventDefault(); + // Submit form + const submitForm = (e) => { + e.preventDefault(); - if (!nameInput.value || !emailInput.value || !messageInput.value) { - status.textContent = "Please fill all fields"; - status.style.color = "var(--st-red-color)"; - return; - } + if (!nameInput.value || !emailInput.value || !messageInput.value) { + status.textContent = "Please fill all fields"; + status.style.color = "var(--st-red-color)"; + return; + } - status.textContent = "Message sent!"; - status.style.color = "var(--st-blue-color)"; - setTimeout(() => (status.textContent = ""), 2000); - setTriggerValue("submit", { - name: nameInput.value, - email: emailInput.value, - message: messageInput.value, - }); - loadDraft({}); - setStateValue("draft", {}); - }; + status.textContent = "Message sent!"; + status.style.color = "var(--st-blue-color)"; + setTimeout(() => (status.textContent = ""), 2000); + setTriggerValue("submit", { + name: nameInput.value, + email: emailInput.value, + message: messageInput.value, + }); + loadDraft({}); + setStateValue("draft", {}); + }; - // Event listeners - only update on button clicks - saveDraftBtn.addEventListener("click", saveDraft); - form.addEventListener("submit", submitForm); + // Event listeners - only update on button clicks + saveDraftBtn.addEventListener("click", saveDraft); + form.addEventListener("submit", submitForm); - return () => { - saveDraftBtn.removeEventListener("click", saveDraft); - form.removeEventListener("submit", submitForm); - }; + return () => { + saveDraftBtn.removeEventListener("click", saveDraft); + form.removeEventListener("submit", submitForm); + }; } diff --git a/python/concept-source/components-interactive-counter/my_component/my_css.css b/python/concept-source/components-interactive-counter/my_component/my_css.css index 6bf531a0f..6afc58e6e 100644 --- a/python/concept-source/components-interactive-counter/my_component/my_css.css +++ b/python/concept-source/components-interactive-counter/my_component/my_css.css @@ -1,29 +1,29 @@ .counter { - padding: 20px; - border: 1px solid var(--st-border-color); - border-radius: var(--st-base-radius); - font-family: var(--st-font); - text-align: center; + padding: 20px; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + text-align: center; } .buttons { - margin-top: 15px; + margin-top: 15px; } button { - margin: 0 5px; - padding: 8px 16px; - background: var(--st-primary-color); - color: white; - border: none; - border-radius: var(--st-button-radius); - cursor: pointer; + margin: 0 5px; + padding: 8px 16px; + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + cursor: pointer; } button:hover { - opacity: 0.8; + opacity: 0.8; } #reset { - background: var(--st-red-color); + background: var(--st-red-color); } diff --git a/python/concept-source/components-interactive-counter/my_component/my_html.html b/python/concept-source/components-interactive-counter/my_component/my_html.html index 18f566bbb..5b0dc73b8 100644 --- a/python/concept-source/components-interactive-counter/my_component/my_html.html +++ b/python/concept-source/components-interactive-counter/my_component/my_html.html @@ -1,8 +1,8 @@
-

Count: 0

-
- - - -
+

Count: 0

+
+ + + +
diff --git a/python/concept-source/components-interactive-counter/my_component/my_js.js b/python/concept-source/components-interactive-counter/my_component/my_js.js index 07cc7c1e3..c44717e1d 100644 --- a/python/concept-source/components-interactive-counter/my_component/my_js.js +++ b/python/concept-source/components-interactive-counter/my_component/my_js.js @@ -1,43 +1,43 @@ export default function ({ - parentElement, - setStateValue, - setTriggerValue, - data, + parentElement, + setStateValue, + setTriggerValue, + data, }) { - let count = data?.initialCount || 0; - const display = parentElement.querySelector("#display"); - const incrementBtn = parentElement.querySelector("#increment"); - const decrementBtn = parentElement.querySelector("#decrement"); - const resetBtn = parentElement.querySelector("#reset"); + let count = data?.initialCount || 0; + const display = parentElement.querySelector("#display"); + const incrementBtn = parentElement.querySelector("#increment"); + const decrementBtn = parentElement.querySelector("#decrement"); + const resetBtn = parentElement.querySelector("#reset"); - const updateDisplay = () => { - display.textContent = count; - setStateValue("count", count); // Persistent state - }; + const updateDisplay = () => { + display.textContent = count; + setStateValue("count", count); // Persistent state + }; - incrementBtn.onclick = () => { - count++; - updateDisplay(); - }; + incrementBtn.onclick = () => { + count++; + updateDisplay(); + }; - decrementBtn.onclick = () => { - count--; - updateDisplay(); - }; + decrementBtn.onclick = () => { + count--; + updateDisplay(); + }; - resetBtn.onclick = () => { - count = 0; - updateDisplay(); - setTriggerValue("reset", true); // One-time trigger - }; + resetBtn.onclick = () => { + count = 0; + updateDisplay(); + setTriggerValue("reset", true); // One-time trigger + }; - // Initialize - updateDisplay(); + // Initialize + updateDisplay(); - // Cleanup function - return () => { - incrementBtn.removeEventListener("click", incrementBtn.onclick); - decrementBtn.removeEventListener("click", decrementBtn.onclick); - resetBtn.removeEventListener("click", resetBtn.onclick); - }; + // Cleanup function + return () => { + incrementBtn.removeEventListener("click", incrementBtn.onclick); + decrementBtn.removeEventListener("click", decrementBtn.onclick); + resetBtn.removeEventListener("click", resetBtn.onclick); + }; } diff --git a/python/concept-source/components-simple-counter/my_component/my_css.css b/python/concept-source/components-simple-counter/my_component/my_css.css index 5c4bfde26..3a16535ae 100644 --- a/python/concept-source/components-simple-counter/my_component/my_css.css +++ b/python/concept-source/components-simple-counter/my_component/my_css.css @@ -1,25 +1,25 @@ .counter { - padding: 0.5rem 0.5rem; - border: 1px solid var(--st-border-color); - border-radius: var(--st-base-radius); - font-family: var(--st-font); - font-size: var(--st-base-font-size); - color: var(--st-text-color); + padding: 0.5rem 0.5rem; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + font-size: var(--st-base-font-size); + color: var(--st-text-color); } #count { - padding: 0.75rem; + padding: 0.75rem; } #increment { - background: var(--st-primary-color); - color: white; - border: none; - border-radius: var(--st-button-radius); - padding: 0.25rem 0.5rem; - margin-left: 0.25rem; + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + padding: 0.25rem 0.5rem; + margin-left: 0.25rem; } #increment:hover { - opacity: 0.8; + opacity: 0.8; } diff --git a/python/concept-source/components-simple-counter/my_component/my_html.html b/python/concept-source/components-simple-counter/my_component/my_html.html index f49c2162f..36ce6c0bb 100644 --- a/python/concept-source/components-simple-counter/my_component/my_html.html +++ b/python/concept-source/components-simple-counter/my_component/my_html.html @@ -1,4 +1,4 @@
- 0 - + 0 +
diff --git a/python/concept-source/components-simple-counter/my_component/my_js.js b/python/concept-source/components-simple-counter/my_component/my_js.js index 7a03d22a8..c4c5f0e61 100644 --- a/python/concept-source/components-simple-counter/my_component/my_js.js +++ b/python/concept-source/components-simple-counter/my_component/my_js.js @@ -1,11 +1,11 @@ export default function ({ parentElement, setStateValue }) { - let count = 0; - const display = parentElement.querySelector("#count"); - const button = parentElement.querySelector("#increment"); + let count = 0; + const display = parentElement.querySelector("#count"); + const button = parentElement.querySelector("#increment"); - button.onclick = () => { - count++; - display.textContent = count; - setStateValue("count", count); - }; + button.onclick = () => { + count++; + display.textContent = count; + setStateValue("count", count); + }; } From c5aa5bf2fbcc7c5daefab48d582bb6a1764f43e3 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Sun, 7 Dec 2025 22:45:04 -0800 Subject: [PATCH 05/12] Radial menu --- .../radial_menu_component/__init__.py | 25 ++++ .../radial_menu_component/menu.css | 112 ++++++++++++++++++ .../radial_menu_component/menu.html | 11 ++ .../radial_menu_component/menu.js | 58 +++++++++ .../components-radial-menu/streamlit_app.py | 26 ++++ 5 files changed, 232 insertions(+) create mode 100644 python/concept-source/components-radial-menu/radial_menu_component/__init__.py create mode 100644 python/concept-source/components-radial-menu/radial_menu_component/menu.css create mode 100644 python/concept-source/components-radial-menu/radial_menu_component/menu.html create mode 100644 python/concept-source/components-radial-menu/radial_menu_component/menu.js create mode 100644 python/concept-source/components-radial-menu/streamlit_app.py diff --git a/python/concept-source/components-radial-menu/radial_menu_component/__init__.py b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py new file mode 100644 index 000000000..52f033809 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "menu.css", "r") as f: + CSS = f.read() + with open(component_dir / "menu.html", "r") as f: + HTML = f.read() + with open(component_dir / "menu.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +radial_menu = st.components.v2.component( + name="radial_menu", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.css b/python/concept-source/components-radial-menu/radial_menu_component/menu.css new file mode 100644 index 000000000..e583eafa4 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.css @@ -0,0 +1,112 @@ +.radial-menu { + position: relative; + display: inline-block; + font-family: var(--st-font); +} + +/* The circular selector button and menu items*/ +.menu-selector, +.menu-item { + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + border: 2px solid var(--st-border-color); + cursor: pointer; + background: var(--st-secondary-background-color); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + font-size: 1.5rem; +} + +.menu-selector:hover { + transform: scale(1.05); + border-color: var(--st-primary-color); +} + +/* Overlay container */ +.menu-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; + pointer-events: none; +} + +/* The ring of menu items */ +.menu-ring { + position: relative; + width: 13rem; + height: 13rem; + transform: scale(0); + opacity: 0; + transition: + transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.2s ease; +} + +.menu-ring.open { + transform: scale(1); + opacity: 1; + pointer-events: auto; +} + +/* Menu items arranged in a circle (6 items at 60 degree intervals)*/ +.menu-item { + --angle: calc(var(--i) * 60deg - 90deg); + + background: var(--st-background-color); + position: absolute; + top: 50%; + left: 50%; + margin: -1.6125rem; + transform: rotate(var(--angle)) translateX(4rem) + rotate(calc(-1 * var(--angle))); +} + +.menu-item:hover { + transform: rotate(var(--angle)) translateX(4rem) + rotate(calc(-1 * var(--angle))) scale(1.15); + border-color: var(--st-primary-color); + background: var(--st-secondary-background-color); +} + +.menu-item.selected { + border-color: var(--st-primary-color); + background: var(--st-secondary-background-color); +} + +/* Backdrop when menu is open */ +.menu-overlay::before { + content: ""; + position: fixed; + inset: -100vh -100vw; + background: var(--st-background-color); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: -1; +} + +.menu-overlay.open::before { + opacity: 0.7; + pointer-events: auto; +} + +/* Center decoration */ +.menu-ring::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 2rem; + height: 2rem; + transform: translate(-50%, -50%); + border-radius: 50%; + background: var(--st-secondary-background-color); + border: 2px dashed var(--st-border-color); + opacity: 0.6; + box-sizing: border-box; +} diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.html b/python/concept-source/components-radial-menu/radial_menu_component/menu.html new file mode 100644 index 000000000..4f860918c --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.html @@ -0,0 +1,11 @@ +
+ + + +
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.js b/python/concept-source/components-radial-menu/radial_menu_component/menu.js new file mode 100644 index 000000000..2bca0b4c7 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.js @@ -0,0 +1,58 @@ +export default function ({ parentElement, data, setStateValue }) { + const selector = parentElement.querySelector("#selector"); + const selectorIcon = parentElement.querySelector("#selector-icon"); + const overlay = parentElement.querySelector("#overlay"); + const ring = parentElement.querySelector("#ring"); + + let isOpen = false; + const options = data?.options || {}; + let currentSelection = data?.selection || Object.keys(options)[0]; + + // Create the 6 menu items from options + Object.entries(options).forEach(([value, icon], index) => { + const button = document.createElement("button"); + button.className = "menu-item"; + button.dataset.value = value; + button.style.setProperty("--i", index); + button.textContent = icon; + + button.addEventListener("click", () => { + currentSelection = value; + updateDisplay(); + toggleMenu(); + setStateValue("selection", currentSelection); + }); + + ring.appendChild(button); + }); + + // Update the selector icon and highlight selected item + function updateDisplay() { + selectorIcon.textContent = options[currentSelection] || "?"; + + ring.querySelectorAll(".menu-item").forEach((item) => { + item.classList.toggle( + "selected", + item.dataset.value === currentSelection, + ); + }); + } + + // Toggle menu open/closed + function toggleMenu() { + isOpen = !isOpen; + overlay.classList.toggle("open", isOpen); + ring.classList.toggle("open", isOpen); + } + + // Initialize display + updateDisplay(); + + // Selector click toggles menu + selector.addEventListener("click", toggleMenu); + + // Click outside closes menu + overlay.addEventListener("click", (e) => { + if (e.target === overlay) toggleMenu(); + }); +} diff --git a/python/concept-source/components-radial-menu/streamlit_app.py b/python/concept-source/components-radial-menu/streamlit_app.py new file mode 100644 index 000000000..1c18581ad --- /dev/null +++ b/python/concept-source/components-radial-menu/streamlit_app.py @@ -0,0 +1,26 @@ +import streamlit as st +from radial_menu_component import radial_menu + +st.header("Radial Menu Component") + +st.write("Click the button to open the menu. Select your favorite food!") + +options = { + "pizza": "🍕", + "burger": "🍔", + "taco": "🌮", + "ramen": "🍜", + "sushi": "🍣", + "salad": "🥗", +} + +result = radial_menu( + data={"options": options, "selection": "burger"}, + default={"selection": "burger"}, + on_selection_change=lambda: None, + key="food_menu", +) + +if result.selection: + icon = options.get(result.selection, "") + st.write(f"You selected: **{icon} {result.selection.title()}**") \ No newline at end of file From 7f8f7b0ecf5ebfe8f28f9c9a15f121b644771d05 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Mon, 8 Dec 2025 06:33:08 -0800 Subject: [PATCH 06/12] Danger button custom component --- .../danger_button_component/__init__.py | 25 +++ .../danger_button_component/button.css | 175 ++++++++++++++++++ .../danger_button_component/button.html | 25 +++ .../danger_button_component/button.js | 99 ++++++++++ .../components-danger-button/streamlit_app.py | 27 +++ 5 files changed, 351 insertions(+) create mode 100644 python/concept-source/components-danger-button/danger_button_component/__init__.py create mode 100644 python/concept-source/components-danger-button/danger_button_component/button.css create mode 100644 python/concept-source/components-danger-button/danger_button_component/button.html create mode 100644 python/concept-source/components-danger-button/danger_button_component/button.js create mode 100644 python/concept-source/components-danger-button/streamlit_app.py diff --git a/python/concept-source/components-danger-button/danger_button_component/__init__.py b/python/concept-source/components-danger-button/danger_button_component/__init__.py new file mode 100644 index 000000000..f39638441 --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "button.css", "r") as f: + CSS = f.read() + with open(component_dir / "button.html", "r") as f: + HTML = f.read() + with open(component_dir / "button.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +danger_button = st.components.v2.component( + name="hold_to_confirm", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-danger-button/danger_button_component/button.css b/python/concept-source/components-danger-button/danger_button_component/button.css new file mode 100644 index 000000000..d1db972db --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.css @@ -0,0 +1,175 @@ +.danger-zone { + font-family: var(--st-font); + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +.warning-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--st-red-background-color); + border: 1px solid var(--st-red-color); + border-radius: var(--st-base-radius); +} + +.warning-icon { + font-size: 1rem; +} + +.warning-text { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--st-red-color); +} + +.hold-button { + position: relative; + width: 7.5rem; + height: 7.5rem; + padding: 0 2rem; + border-radius: 50%; + border: 1px solid var(--st-primary-color); + background: var(--st-secondary-background-color); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.hold-button:hover { + transform: scale(1.05); + border-color: var(--st-red-color); +} + +.hold-button:active:not(:disabled) { + transform: scale(0.98); +} + +.hold-button:disabled { + cursor: not-allowed; + opacity: 0.9; +} + +.hold-button.holding { + animation: pulse 0.5s ease-in-out infinite; + border-color: var(--st-red-color); +} + +.hold-button.triggered { + animation: success-burst 0.6s ease-out forwards; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--st-red-color); + } + 50% { + box-shadow: 0 0 0 15px transparent; + } +} + +@keyframes success-burst { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.15); + background: var(--st-red-background-color); + } + 100% { + transform: scale(1); + } +} + +.progress-ring { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.ring-bg { + fill: none; + stroke: var(--st-border-color); + stroke-width: 4; +} + +.ring-progress { + fill: none; + stroke: var(--st-red-color); + stroke-width: 4; + stroke-linecap: round; + stroke-dasharray: 283; + stroke-dashoffset: 283; + transition: stroke-dashoffset 0.1s linear; + filter: drop-shadow(0 0 6px var(--st-red-color)); +} + +.button-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.icon { + font-size: 2rem; + transition: transform 0.3s ease; +} + +.hold-button:hover .icon { + transform: scale(1.1); +} + +.hold-button.holding .icon { + animation: shake 0.15s ease-in-out infinite; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px) rotate(-5deg); + } + 75% { + transform: translateX(2px) rotate(5deg); + } +} + +.label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--st-text-color); + opacity: 0.6; + transition: all 0.3s ease; +} + +.hold-button.holding .label { + color: var(--st-red-color); + opacity: 1; +} + +.hold-button.triggered .icon, +.hold-button.triggered .label { + color: var(--st-primary-color); + opacity: 1; +} + +.hint { + font-size: 0.7rem; + color: var(--st-text-color); + opacity: 0.5; + margin: 0; +} diff --git a/python/concept-source/components-danger-button/danger_button_component/button.html b/python/concept-source/components-danger-button/danger_button_component/button.html new file mode 100644 index 000000000..74b0eb25b --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.html @@ -0,0 +1,25 @@ +
+
+ ⚠️ + Danger Zone +
+ + + +

Press and hold for 2 seconds to confirm

+
diff --git a/python/concept-source/components-danger-button/danger_button_component/button.js b/python/concept-source/components-danger-button/danger_button_component/button.js new file mode 100644 index 000000000..904407c65 --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.js @@ -0,0 +1,99 @@ +export default function ({ parentElement, setTriggerValue }) { + const button = parentElement.querySelector("#danger-btn"); + const progress = parentElement.querySelector("#ring-progress"); + const icon = parentElement.querySelector("#icon"); + const label = parentElement.querySelector("#label"); + + const HOLD_DURATION = 2000; // 2 seconds + const COOLDOWN_DURATION = 1500; // cooldown after trigger + const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference + + let startTime = null; + let animationFrame = null; + let isDisabled = false; // Prevent interaction during cooldown + + function updateProgress() { + if (!startTime) return; + + const elapsed = Date.now() - startTime; + const progressPercent = Math.min(elapsed / HOLD_DURATION, 1); + const offset = CIRCUMFERENCE * (1 - progressPercent); + + progress.style.strokeDashoffset = offset; + + if (progressPercent >= 1) { + // Triggered! + triggerAction(); + } else { + animationFrame = requestAnimationFrame(updateProgress); + } + } + + function startHold() { + if (isDisabled) return; // Ignore if in cooldown + + startTime = Date.now(); + button.classList.add("holding"); + label.textContent = "Keep holding..."; + animationFrame = requestAnimationFrame(updateProgress); + } + + function cancelHold() { + if (isDisabled) return; // Ignore if in cooldown + + startTime = null; + button.classList.remove("holding"); + label.textContent = "Hold to Delete"; + progress.style.strokeDashoffset = CIRCUMFERENCE; + + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + } + + function triggerAction() { + cancelAnimationFrame(animationFrame); + animationFrame = null; + startTime = null; + isDisabled = true; // Disable during cooldown + + button.classList.remove("holding"); + button.classList.add("triggered"); + button.disabled = true; + + icon.textContent = "✓"; + label.textContent = "Deleted!"; + progress.style.strokeDashoffset = 0; + + // Send trigger to Python + setTriggerValue("confirmed", true); + + // Reset after cooldown + setTimeout(() => { + button.classList.remove("triggered"); + button.disabled = false; + isDisabled = false; + icon.textContent = "🗑️"; + label.textContent = "Hold to Delete"; + progress.style.strokeDashoffset = CIRCUMFERENCE; + }, COOLDOWN_DURATION); + } + + // Mouse events + button.addEventListener("mousedown", startHold); + button.addEventListener("mouseup", cancelHold); + button.addEventListener("mouseleave", cancelHold); + + // Touch events for mobile + button.addEventListener("touchstart", (e) => { + e.preventDefault(); + startHold(); + }); + button.addEventListener("touchend", cancelHold); + button.addEventListener("touchcancel", cancelHold); + + return () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + }; +} diff --git a/python/concept-source/components-danger-button/streamlit_app.py b/python/concept-source/components-danger-button/streamlit_app.py new file mode 100644 index 000000000..72d97a623 --- /dev/null +++ b/python/concept-source/components-danger-button/streamlit_app.py @@ -0,0 +1,27 @@ +import streamlit as st +from danger_button_component import danger_button + +st.title("Hold-to-Confirm Button") +st.caption("A dangerous action that requires intentional confirmation") + +# Track deletion events +if "deleted_items" not in st.session_state: + st.session_state.deleted_items = [] + +# Callback when deletion is confirmed +def on_delete_confirmed(): + st.session_state.deleted_items.append( + f"Deleted item #{len(st.session_state.deleted_items) + 1}" + ) + st.toast("🗑️ Item permanently deleted!", icon="⚠️") + + +# Render the component +result = danger_button(key="danger_btn", on_confirmed_change=on_delete_confirmed) + +# Show deletion history +if st.session_state.deleted_items: + st.divider() + st.subheader("Deletion Log") + for item in reversed(st.session_state.deleted_items[-3:]): + st.write(f"• {item}") \ No newline at end of file From dcabf478dab2a7b24a6c6b02fb34bbd7fcde7174 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Mon, 8 Dec 2025 06:37:21 -0800 Subject: [PATCH 07/12] Stopwatch custom component --- .../stopwatch_component/__init__.py | 25 ++ .../stopwatch_component/stopwatch.css | 205 ++++++++++++++++ .../stopwatch_component/stopwatch.html | 38 +++ .../stopwatch_component/stopwatch.js | 218 ++++++++++++++++++ .../components-stopwatch/streamlit_app.py | 32 +++ 5 files changed, 518 insertions(+) create mode 100644 python/concept-source/components-stopwatch/stopwatch_component/__init__.py create mode 100644 python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css create mode 100644 python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html create mode 100644 python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js create mode 100644 python/concept-source/components-stopwatch/streamlit_app.py diff --git a/python/concept-source/components-stopwatch/stopwatch_component/__init__.py b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py new file mode 100644 index 000000000..f922b03a9 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "stopwatch.css", "r") as f: + CSS = f.read() + with open(component_dir / "stopwatch.html", "r") as f: + HTML = f.read() + with open(component_dir / "stopwatch.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +stopwatch = st.components.v2.component( + name="stopwatch", + html=HTML, + css=CSS, + js=JS +) \ No newline at end of file diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css new file mode 100644 index 000000000..2e15a231a --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css @@ -0,0 +1,205 @@ +.stopwatch { + font-family: var(--st-font); + color: var(--st-text-color); + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + gap: 2rem; +} + +/* Ring Display */ +.display-ring { + position: relative; + width: 14rem; + height: 14rem; +} + +.ring-svg { + position: absolute; + inset: -0.75rem; + padding: 0.75rem; + transform: rotate(-90deg); + overflow: visible; +} + +.ring-track, +.ring-progress { + fill: none; + stroke-width: 6; +} + +.ring-track { + stroke: var(--st-secondary-background-color); +} + +.ring-progress { + stroke: var(--st-primary-color); + stroke-linecap: round; + stroke-dasharray: 565.5; + stroke-dashoffset: 565.5; + transition: stroke-dashoffset 0.1s linear; + filter: drop-shadow(0 0 8px var(--st-primary-color)); +} + +.ring-progress.running { + animation: glow 2s ease-in-out infinite; +} + +@keyframes glow { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} + +/* Time Display */ +.display { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: baseline; + gap: 2px; + font-family: var(--st-code-font); + font-size: 2.5rem; + font-weight: 700; +} + +.time-segment { + min-width: 2ch; + text-align: center; + letter-spacing: 0.05em; +} + +.separator { + opacity: 0.5; +} + +.time-segment.small, +.separator.small { + font-size: 1.5rem; + font-weight: 500; +} + +.time-segment.small { + opacity: 0.7; +} + +/* Controls */ +.controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.ctrl-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 1.25rem; + border: none; + border-radius: var(--st-button-radius); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + min-width: 5rem; +} + +.ctrl-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ctrl-btn:hover:not(:disabled) { + transform: scale(1.05); +} + +.ctrl-btn.primary { + background: var(--st-primary-color); + color: white; +} + +.ctrl-btn.primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.ctrl-btn.secondary { + background: var(--st-secondary-background-color); + border: 1px solid var(--st-border-color); +} + +.ctrl-btn.secondary:hover:not(:disabled) { + border-color: var(--st-primary-color); +} + +.btn-icon { + font-size: 1.25rem; + line-height: 1; +} + +.btn-label { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Lap List */ +.lap-list { + width: 100%; + max-width: 280px; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 150px; + overflow-y: auto; +} + +.lap-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--st-secondary-background-color); + border-radius: var(--st-base-radius); + font-size: 0.85rem; + animation: slide-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.lap-number { + color: var(--st-primary-color); + font-weight: 600; +} + +.lap-time, +.lap-delta { + font-family: var(--st-code-font); + font-size: 0.8rem; + opacity: 0.8; +} + +.lap-delta.fastest { + color: var(--st-green-color); + opacity: 1; +} + +.lap-delta.slowest { + color: var(--st-red-color); + opacity: 1; +} diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html new file mode 100644 index 000000000..614fd6cf8 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html @@ -0,0 +1,38 @@ +
+
+ + + + +
+ 00 + : + 00 + . + 00 +
+
+ +
+ + + +
+ +
+
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js new file mode 100644 index 000000000..9957c64d7 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js @@ -0,0 +1,218 @@ +export default function ({ + parentElement, + data, + setStateValue, + setTriggerValue, +}) { + const minutes = parentElement.querySelector("#minutes"); + const seconds = parentElement.querySelector("#seconds"); + const centiseconds = parentElement.querySelector("#centiseconds"); + const ringProgress = parentElement.querySelector("#ring-progress"); + const startBtn = parentElement.querySelector("#start-btn"); + const lapBtn = parentElement.querySelector("#lap-btn"); + const resetBtn = parentElement.querySelector("#reset-btn"); + const lapList = parentElement.querySelector("#lap-list"); + + const CIRCUMFERENCE = 2 * Math.PI * 90; + + // Initialize from state or defaults + let elapsedMs = data?.elapsed || 0; + let isRunning = data?.running || false; + let laps = data?.laps || []; + let lastTimestamp = null; + let animationFrame = null; + + let lastMinute = Math.floor(elapsedMs / 60000); + let isTransitioning = false; + + function formatTime(ms) { + const totalSeconds = Math.floor(ms / 1000); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + const cents = Math.floor((ms % 1000) / 10); + return { + mins: String(mins).padStart(2, "0"), + secs: String(secs).padStart(2, "0"), + cents: String(cents).padStart(2, "0"), + }; + } + + function updateDisplay() { + const time = formatTime(elapsedMs); + minutes.textContent = time.mins; + seconds.textContent = time.secs; + centiseconds.textContent = time.cents; + + const currentMinute = Math.floor(elapsedMs / 60000); + const secondsInMinute = (elapsedMs % 60000) / 1000; + + // Arc length: 0 at second 0, full circle at second 60 + const arcLength = (secondsInMinute / 60) * CIRCUMFERENCE; + + // Detect minute boundary - quick fade transition + if (currentMinute > lastMinute && !isTransitioning) { + lastMinute = currentMinute; + isTransitioning = true; + + // Quick fade out + ringProgress.style.transition = "opacity 0.15s ease-out"; + ringProgress.style.opacity = "0"; + + setTimeout(() => { + // Reset to small arc while invisible + ringProgress.style.transition = "none"; + ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`; + ringProgress.style.strokeDashoffset = 0; + + // Fade back in + requestAnimationFrame(() => { + ringProgress.style.transition = "opacity 0.15s ease-in"; + ringProgress.style.opacity = "1"; + + setTimeout(() => { + ringProgress.style.transition = ""; + isTransitioning = false; + }, 150); + }); + }, 150); + } + + // Normal ring update + if (!isTransitioning) { + ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`; + ringProgress.style.strokeDashoffset = 0; + } + } + + function updateButtons() { + startBtn.querySelector(".btn-icon").textContent = isRunning + ? "⏸" + : "▶"; + startBtn.querySelector(".btn-label").textContent = isRunning + ? "Pause" + : "Start"; + startBtn.classList.toggle("running", isRunning); + ringProgress.classList.toggle("running", isRunning); + + lapBtn.disabled = !isRunning; + resetBtn.disabled = isRunning || elapsedMs === 0; + } + + function renderLaps() { + lapList.innerHTML = ""; + + if (laps.length === 0) return; + + // Calculate deltas and find fastest/slowest + const deltas = laps.map((lap, i) => { + return i === 0 ? lap : lap - laps[i - 1]; + }); + + const minDelta = Math.min(...deltas); + const maxDelta = Math.max(...deltas); + + // Render in reverse (newest first) + [...laps].reverse().forEach((lap, reverseIdx) => { + const idx = laps.length - 1 - reverseIdx; + const delta = deltas[idx]; + const time = formatTime(lap); + const deltaTime = formatTime(delta); + + let deltaClass = ""; + if (laps.length > 1) { + if (delta === minDelta) deltaClass = "fastest"; + else if (delta === maxDelta) deltaClass = "slowest"; + } + + const item = document.createElement("div"); + item.className = "lap-item"; + item.innerHTML = ` + Lap ${idx + 1} + +${deltaTime.mins}:${deltaTime.secs}.${deltaTime.cents} + ${time.mins}:${time.secs}.${time.cents} + `; + lapList.appendChild(item); + }); + } + + function tick(timestamp) { + if (!lastTimestamp) lastTimestamp = timestamp; + + const delta = timestamp - lastTimestamp; + lastTimestamp = timestamp; + + elapsedMs += delta; + updateDisplay(); + + if (isRunning) { + animationFrame = requestAnimationFrame(tick); + } + } + + function start() { + isRunning = true; + lastTimestamp = null; + animationFrame = requestAnimationFrame(tick); + updateButtons(); + setStateValue("running", true); + } + + function pause() { + isRunning = false; + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + updateButtons(); + setStateValue("running", false); + setStateValue("elapsed", elapsedMs); + } + + function recordLap() { + laps.push(elapsedMs); + renderLaps(); + setStateValue("laps", laps); + const t = formatTime(elapsedMs); + setTriggerValue("lap", { + number: laps.length, + time: elapsedMs, + formatted: `${t.mins}:${t.secs}.${t.cents}`, + }); + } + + function reset() { + elapsedMs = 0; + laps = []; + updateDisplay(); + renderLaps(); + updateButtons(); + setStateValue("laps", []); + setStateValue("elapsed", 0); + setStateValue("running", false); + setTriggerValue("reset", true); + } + + // Event listeners + startBtn.addEventListener("click", () => { + if (isRunning) pause(); + else start(); + }); + + lapBtn.addEventListener("click", recordLap); + resetBtn.addEventListener("click", reset); + + // Initialize display + updateDisplay(); + updateButtons(); + renderLaps(); + + // Resume if was running + if (isRunning) { + lastTimestamp = null; + animationFrame = requestAnimationFrame(tick); + } + + return () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + }; +} diff --git a/python/concept-source/components-stopwatch/streamlit_app.py b/python/concept-source/components-stopwatch/streamlit_app.py new file mode 100644 index 000000000..4161ed875 --- /dev/null +++ b/python/concept-source/components-stopwatch/streamlit_app.py @@ -0,0 +1,32 @@ +import streamlit as st +from stopwatch_component import stopwatch + +st.title("Stopwatch with Laps") +st.caption("Combining state values (time, running) with trigger values (lap, reset)") + +# Track laps in Python +if "laps" not in st.session_state: + st.session_state.laps = [] + +# Render the component +result = stopwatch( + key="stopwatch", + on_lap_change=lambda: None, + on_reset_change=lambda: None, + on_running_change=lambda: None, + on_elapsed_change=lambda: None, + on_laps_change=lambda: None, + default={"elapsed": 0, "running": False, "laps": []}, +) + +# Display state info +col1, col2 = st.columns(2) +with col1: + st.metric("Status", "Running" if result.running else "Paused") + elapsed_sec = (result.elapsed or 0) / 1000 + st.metric("Elapsed", f"{elapsed_sec:.1f}s") +with col2: + st.subheader("Lap Records (Python)") + for i, lap_ms in enumerate(result.laps[-5:]): + mins, secs = divmod(lap_ms / 1000, 60) + st.write(f"**Lap {i+1}**: {int(mins):02d}:{secs:05.2f}") \ No newline at end of file From d29fdfe4212064407a71a70d82df5d1fa9dd0e86 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Mon, 8 Dec 2025 16:05:16 -0800 Subject: [PATCH 08/12] Radial dial example --- .../radial_dial_component/__init__.py | 25 +++ .../radial_dial_component/dial.css | 160 ++++++++++++++++++ .../radial_dial_component/dial.html | 40 +++++ .../radial_dial_component/dial.js | 133 +++++++++++++++ .../components-radial-dial/streamlit_app.py | 20 +++ 5 files changed, 378 insertions(+) create mode 100644 python/concept-source/components-radial-dial/radial_dial_component/__init__.py create mode 100644 python/concept-source/components-radial-dial/radial_dial_component/dial.css create mode 100644 python/concept-source/components-radial-dial/radial_dial_component/dial.html create mode 100644 python/concept-source/components-radial-dial/radial_dial_component/dial.js create mode 100644 python/concept-source/components-radial-dial/streamlit_app.py diff --git a/python/concept-source/components-radial-dial/radial_dial_component/__init__.py b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py new file mode 100644 index 000000000..78dd41897 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "dial.css", "r") as f: + CSS = f.read() + with open(component_dir / "dial.html", "r") as f: + HTML = f.read() + with open(component_dir / "dial.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +radial_dial = st.components.v2.component( + name="radial_dial", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.css b/python/concept-source/components-radial-dial/radial_dial_component/dial.css new file mode 100644 index 000000000..5a6ca37a4 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.css @@ -0,0 +1,160 @@ +.dial-container { + font-family: var(--st-font); + color: var(--st-text-color); + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem; + position: relative; +} + +.dial-svg { + width: 18rem; + height: 12rem; +} + +/* Track (background arc) */ +.dial-track { + fill: none; + stroke: var(--st-secondary-background-color); + stroke-width: 20; + stroke-linecap: round; +} + +/* Colored segments */ +.dial-segment { + fill: none; + stroke-width: 20; + stroke-linecap: round; + opacity: 0.15; +} + +.segment-low { + stroke: var(--st-green-color); +} +.segment-mid { + stroke: var(--st-yellow-color); +} +.segment-high { + stroke: var(--st-red-color); +} + +/* Progress arc */ +.dial-progress { + fill: none; + stroke: var(--st-green-color); + stroke-width: 20; + stroke-linecap: round; + filter: drop-shadow(0 0 8px var(--st-green-color)); + transition: + stroke-dashoffset 0.8s cubic-bezier(0.34, 1.2, 0.64, 1), + stroke 0.5s ease; +} + +/* Tick marks */ +.ticks line { + stroke: var(--st-text-color); + stroke-width: 2; + opacity: 0.3; +} + +.ticks line.major { + stroke-width: 3; + opacity: 0.5; +} + +/* Needle */ +.needle-group { + transform-origin: 100px 95px; + transition: transform 0.8s cubic-bezier(0.34, 1.2, 0.64, 1); +} + +.needle { + fill: var(--st-text-color); + filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)); +} + +.needle-shadow { + fill: rgba(0, 0, 0, 0.2); + transform: translate(2px, 2px); +} + +.needle-cap { + fill: var(--st-secondary-background-color); + stroke: var(--st-border-color); + stroke-width: 2; +} + +.needle-cap-inner { + fill: var(--st-text-color); + opacity: 0.8; +} + +/* Value display */ +.value-display { + margin-top: -2rem; + text-align: center; +} + +.value { + font-family: var(--st-code-font); + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -0.02em; + transition: color 0.5s ease; +} + +.unit { + font-size: 1rem; + font-weight: 500; + opacity: 0.6; + margin-left: 0.25rem; +} + +/* Labels */ +.labels { + display: flex; + justify-content: space-between; + width: 240px; + margin-top: 0.5rem; +} + +.label { + font-size: 0.75rem; + font-weight: 500; + opacity: 0.5; +} + +/* Title */ +.title { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.7; + margin-top: 0.75rem; +} + +/* Color themes based on value */ +.dial-progress.low { + stroke: var(--st-green-color); + filter: drop-shadow(0 0 8px var(--st-green-color)); +} +.dial-progress.mid { + stroke: var(--st-yellow-color); + filter: drop-shadow(0 0 8px var(--st-yellow-color)); +} +.dial-progress.high { + stroke: var(--st-red-color); + filter: drop-shadow(0 0 8px var(--st-red-color)); +} + +.value.low { + color: var(--st-green-color); +} +.value.mid { + color: var(--st-yellow-color); +} +.value.high { + color: var(--st-red-color); +} diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.html b/python/concept-source/components-radial-dial/radial_dial_component/dial.html new file mode 100644 index 000000000..c673e99e6 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.html @@ -0,0 +1,40 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 0 + +
+ + +
+ 0 + 100 +
+ + +
+
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.js b/python/concept-source/components-radial-dial/radial_dial_component/dial.js new file mode 100644 index 000000000..e6279806f --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.js @@ -0,0 +1,133 @@ +export default function ({ parentElement, data }) { + const track = parentElement.querySelector("#track"); + const progress = parentElement.querySelector("#progress"); + const segmentLow = parentElement.querySelector("#segment-low"); + const segmentMid = parentElement.querySelector("#segment-mid"); + const segmentHigh = parentElement.querySelector("#segment-high"); + const ticksGroup = parentElement.querySelector("#ticks"); + const needleGroup = parentElement.querySelector("#needle-group"); + const valueEl = parentElement.querySelector("#value"); + const unitEl = parentElement.querySelector("#unit"); + const minLabel = parentElement.querySelector("#min-label"); + const maxLabel = parentElement.querySelector("#max-label"); + const titleEl = parentElement.querySelector("#title"); + + // Arc parameters + const cx = 100, + cy = 95; + const radius = 75; + const startAngle = -135; // degrees from vertical + const endAngle = 135; + const angleRange = endAngle - startAngle; // 270 degrees + + function polarToCartesian(angle) { + const rad = ((angle - 90) * Math.PI) / 180; + return { + x: cx + radius * Math.cos(rad), + y: cy + radius * Math.sin(rad), + }; + } + + function describeArc(start, end) { + const startPoint = polarToCartesian(start); + const endPoint = polarToCartesian(end); + const largeArc = end - start > 180 ? 1 : 0; + return `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`; + } + + // Draw background track + track.setAttribute("d", describeArc(startAngle, endAngle)); + + // Draw colored segments (thirds) + const third = angleRange / 3; + segmentLow.setAttribute("d", describeArc(startAngle, startAngle + third)); + segmentMid.setAttribute( + "d", + describeArc(startAngle + third, startAngle + 2 * third), + ); + segmentHigh.setAttribute( + "d", + describeArc(startAngle + 2 * third, endAngle), + ); + + // Draw tick marks + const numTicks = 10; + ticksGroup.innerHTML = ""; + for (let i = 0; i <= numTicks; i++) { + const angle = startAngle + (angleRange * i) / numTicks; + const isMajor = i % 5 === 0; + const innerRadius = isMajor ? radius - 30 : radius - 25; + const outerRadius = radius - 20; + + const rad = ((angle - 90) * Math.PI) / 180; + const x1 = cx + innerRadius * Math.cos(rad); + const y1 = cy + innerRadius * Math.sin(rad); + const x2 = cx + outerRadius * Math.cos(rad); + const y2 = cy + outerRadius * Math.sin(rad); + + const line = document.createElementNS( + "http://www.w3.org/2000/svg", + "line", + ); + line.setAttribute("x1", x1); + line.setAttribute("y1", y1); + line.setAttribute("x2", x2); + line.setAttribute("y2", y2); + if (isMajor) line.classList.add("major"); + ticksGroup.appendChild(line); + } + + function update(config) { + const { + value = 0, + min = 0, + max = 100, + min_label = String(min), + max_label = String(max), + unit = "", + title = "", + color_zones = true, + } = config; + + // Calculate percentage + const percent = Math.max(0, Math.min(1, (value - min) / (max - min))); + + // Calculate angle for this value + const valueAngle = startAngle + angleRange * percent; + + // Update progress arc + progress.setAttribute("d", describeArc(startAngle, valueAngle)); + + // Update needle rotation + needleGroup.style.transform = `rotate(${valueAngle}deg)`; + + // Determine color zone + let zone = "low"; + if (percent > 0.66) zone = "high"; + else if (percent > 0.33) zone = "mid"; + + // Apply color classes if color_zones enabled + progress.classList.remove("low", "mid", "high"); + valueEl.classList.remove("low", "mid", "high"); + + if (color_zones) { + progress.classList.add(zone); + valueEl.classList.add(zone); + } + + // Update text + valueEl.textContent = + typeof value === "number" + ? Number.isInteger(value) + ? value + : value.toFixed(1) + : value; + unitEl.textContent = unit; + minLabel.textContent = min_label; + maxLabel.textContent = max_label; + titleEl.textContent = title; + } + + // Initial render + update(data || {}); +} diff --git a/python/concept-source/components-radial-dial/streamlit_app.py b/python/concept-source/components-radial-dial/streamlit_app.py new file mode 100644 index 000000000..a032a0755 --- /dev/null +++ b/python/concept-source/components-radial-dial/streamlit_app.py @@ -0,0 +1,20 @@ +import streamlit as st +from radial_dial_component import radial_dial + +st.title("Radial Dial") +st.caption("A display-only component with smooth transitions") + +st.subheader("CPU Temperature") +temp = st.slider("Adjust temperature", 30, 100, 45) +radial_dial( + key="temp_dial", + data={ + "value": temp, + "min": 30, + "max": 100, + "min_label": "Cool", + "max_label": "Hot", + "unit": "°C", + "title": "CPU Temperature" + } +) \ No newline at end of file From 490ec10b14c4fd306bd37de01fa969254a2b2a47 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Tue, 16 Dec 2025 19:58:18 -0800 Subject: [PATCH 09/12] Add cleanup --- .../danger_button_component/button.js | 28 ++++-- .../my_component/__init__.py | 24 ----- .../my_component/my_css.css | 70 --------------- .../my_component/my_html.html | 13 --- .../my_component/my_js.js | 89 ------------------- .../streamlit_app.py | 36 -------- .../radial_dial_component/dial.js | 74 +++++++-------- 7 files changed, 57 insertions(+), 277 deletions(-) delete mode 100644 python/concept-source/components-form-with-validation/my_component/__init__.py delete mode 100644 python/concept-source/components-form-with-validation/my_component/my_css.css delete mode 100644 python/concept-source/components-form-with-validation/my_component/my_html.html delete mode 100644 python/concept-source/components-form-with-validation/my_component/my_js.js delete mode 100644 python/concept-source/components-form-with-validation/streamlit_app.py diff --git a/python/concept-source/components-danger-button/danger_button_component/button.js b/python/concept-source/components-danger-button/danger_button_component/button.js index 904407c65..a78ab4df3 100644 --- a/python/concept-source/components-danger-button/danger_button_component/button.js +++ b/python/concept-source/components-danger-button/danger_button_component/button.js @@ -1,13 +1,13 @@ +const HOLD_DURATION = 2000; // 2 seconds +const COOLDOWN_DURATION = 1500; // cooldown after trigger +const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference + export default function ({ parentElement, setTriggerValue }) { const button = parentElement.querySelector("#danger-btn"); const progress = parentElement.querySelector("#ring-progress"); const icon = parentElement.querySelector("#icon"); const label = parentElement.querySelector("#label"); - const HOLD_DURATION = 2000; // 2 seconds - const COOLDOWN_DURATION = 1500; // cooldown after trigger - const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference - let startTime = null; let animationFrame = null; let isDisabled = false; // Prevent interaction during cooldown @@ -80,20 +80,32 @@ export default function ({ parentElement, setTriggerValue }) { }, COOLDOWN_DURATION); } + function handleTouchStart(e) { + e.preventDefault(); + startHold(); + } + // Mouse events button.addEventListener("mousedown", startHold); button.addEventListener("mouseup", cancelHold); button.addEventListener("mouseleave", cancelHold); // Touch events for mobile - button.addEventListener("touchstart", (e) => { - e.preventDefault(); - startHold(); - }); + button.addEventListener("touchstart", handleTouchStart); button.addEventListener("touchend", cancelHold); button.addEventListener("touchcancel", cancelHold); return () => { if (animationFrame) cancelAnimationFrame(animationFrame); + + // Remove mouse event listeners + button.removeEventListener("mousedown", startHold); + button.removeEventListener("mouseup", cancelHold); + button.removeEventListener("mouseleave", cancelHold); + + // Remove touch event listeners + button.removeEventListener("touchstart", handleTouchStart); + button.removeEventListener("touchend", cancelHold); + button.removeEventListener("touchcancel", cancelHold); }; } diff --git a/python/concept-source/components-form-with-validation/my_component/__init__.py b/python/concept-source/components-form-with-validation/my_component/__init__.py deleted file mode 100644 index 9532406c9..000000000 --- a/python/concept-source/components-form-with-validation/my_component/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -import streamlit as st -from pathlib import Path - -# Get the current file's directory -_COMPONENT_DIR = Path(__file__).parent - -@st.cache_data -def load_html(): - with open(_COMPONENT_DIR / "my_html.html", "r") as f: - return f.read() - -@st.cache_data -def load_css(): - with open(_COMPONENT_DIR / "my_css.css", "r") as f: - return f.read() - -@st.cache_data -def load_js(): - with open(_COMPONENT_DIR / "my_js.js", "r") as f: - return f.read() - -HTML = load_html() -CSS = load_css() -JS = load_js() \ No newline at end of file diff --git a/python/concept-source/components-form-with-validation/my_component/my_css.css b/python/concept-source/components-form-with-validation/my_component/my_css.css deleted file mode 100644 index 019d497b7..000000000 --- a/python/concept-source/components-form-with-validation/my_component/my_css.css +++ /dev/null @@ -1,70 +0,0 @@ -.form-container { - padding: 1rem; - border: 1px solid var(--st-border-color); - border-radius: var(--st-base-radius); - box-sizing: border-box; -} - -h3 { - font-size: var(--st-heading-font-size-h3, inherit); - font-weight: var(--st-heading-font-weight-h3, inherit); - margin: 0; -} - -input, -textarea { - width: 100%; - padding: 0.5rem; - margin: 0.5rem 0; - background: var(--st-secondary-background-color); - border: 1px solid transparent; - border-radius: var(--st-base-radius); - box-sizing: border-box; - font-size: inherit; - font-family: inherit; -} - -input:focus, -textarea:focus { - outline: none; - border-color: var(--st-primary-color); -} - -textarea { - height: 5rem; - resize: vertical; -} - -.form-actions { - display: flex; - gap: 1rem; - margin-top: 0.75rem; -} - -button { - padding: 0.5rem 1rem; - border-radius: var(--st-button-radius); - border: 1px solid transparent; - font-size: inherit; - font-family: inherit; -} - -button[type="submit"] { - background: var(--st-primary-color); - color: white; -} - -button[type="button"] { - border: 1px solid var(--st-border-color); - background: var(--st-primary-background-color); - color: var(--st-text-color); -} - -button:hover { - opacity: 0.9; - border-color: var(--st-primary-color); -} - -#status { - margin-top: 0.5rem; -} diff --git a/python/concept-source/components-form-with-validation/my_component/my_html.html b/python/concept-source/components-form-with-validation/my_component/my_html.html deleted file mode 100644 index b2ac79dee..000000000 --- a/python/concept-source/components-form-with-validation/my_component/my_html.html +++ /dev/null @@ -1,13 +0,0 @@ -
-

Contact Form

-
- - - -
- - -
-
-
-
diff --git a/python/concept-source/components-form-with-validation/my_component/my_js.js b/python/concept-source/components-form-with-validation/my_component/my_js.js deleted file mode 100644 index a1cf41df1..000000000 --- a/python/concept-source/components-form-with-validation/my_component/my_js.js +++ /dev/null @@ -1,89 +0,0 @@ -export default function ({ - parentElement, - setStateValue, - setTriggerValue, - data, -}) { - const form = parentElement.querySelector("#contact-form"); - const nameInput = parentElement.querySelector("#name"); - const emailInput = parentElement.querySelector("#email"); - const messageInput = parentElement.querySelector("#message"); - const saveDraftBtn = parentElement.querySelector("#save-draft"); - const status = parentElement.querySelector("#status"); - - // Register custom CSS variables with third values from - // --st-heading-font-sizes and --st-heading-font-weights - requestAnimationFrame(() => { - const container = parentElement.querySelector(".form-container"); - const headingSizes = getComputedStyle(form) - .getPropertyValue("--st-heading-font-sizes") - .trim(); - const headingWeights = getComputedStyle(form) - .getPropertyValue("--st-heading-font-weights") - .trim(); - const sizes = headingSizes.split(",").map((s) => s.trim()); - const weights = headingWeights.split(",").map((s) => s.trim()); - if (sizes[2] && container) { - container.style.setProperty("--st-heading-font-size-h3", sizes[2]); - } - if (weights[2] && container) { - container.style.setProperty( - "--st-heading-font-weight-h3", - weights[2], - ); - } - }); - - // Load draft if available - const loadDraft = (draft) => { - nameInput.value = draft.name || ""; - emailInput.value = draft.email || ""; - messageInput.value = draft.message || ""; - }; - - loadDraft(data?.draft || {}); - - // Save draft - const saveDraft = () => { - setStateValue("draft", { - name: nameInput.value, - email: emailInput.value, - message: messageInput.value, - }); - setTriggerValue("action", "save_draft"); - status.textContent = "Draft saved!"; - status.style.color = "var(--st-green-color)"; - setTimeout(() => (status.textContent = ""), 2000); - }; - - // Submit form - const submitForm = (e) => { - e.preventDefault(); - - if (!nameInput.value || !emailInput.value || !messageInput.value) { - status.textContent = "Please fill all fields"; - status.style.color = "var(--st-red-color)"; - return; - } - - status.textContent = "Message sent!"; - status.style.color = "var(--st-blue-color)"; - setTimeout(() => (status.textContent = ""), 2000); - setTriggerValue("submit", { - name: nameInput.value, - email: emailInput.value, - message: messageInput.value, - }); - loadDraft({}); - setStateValue("draft", {}); - }; - - // Event listeners - only update on button clicks - saveDraftBtn.addEventListener("click", saveDraft); - form.addEventListener("submit", submitForm); - - return () => { - saveDraftBtn.removeEventListener("click", saveDraft); - form.removeEventListener("submit", submitForm); - }; -} diff --git a/python/concept-source/components-form-with-validation/streamlit_app.py b/python/concept-source/components-form-with-validation/streamlit_app.py deleted file mode 100644 index 24623eed6..000000000 --- a/python/concept-source/components-form-with-validation/streamlit_app.py +++ /dev/null @@ -1,36 +0,0 @@ -import streamlit as st -from my_component import HTML, CSS, JS - -form_component = st.components.v2.component( - "contact_form", - html=HTML, - css=CSS, - js=JS, -) - - -# Handle form actions -def handle_form_action(): - # Process submission - # if submission_failed: - # submission = st.session_state.message_form.submit - # st.session_state.message_form.draft=submission - pass - - -# Use the component -form_state = st.session_state.get("message_form", {}) -result = form_component( - data={"draft": form_state.get("draft", {})}, - default={"draft": form_state.get("draft", {})}, - on_draft_change=lambda: None, - on_submit_change=handle_form_action, - key="message_form", -) - -if result.submit: - st.write("Message Submitted:") - result.submit -else: - st.write("Current Draft:") - result.draft diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.js b/python/concept-source/components-radial-dial/radial_dial_component/dial.js index e6279806f..f230b731c 100644 --- a/python/concept-source/components-radial-dial/radial_dial_component/dial.js +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.js @@ -1,3 +1,26 @@ +// Arc parameters +const CX = 100; +const CY = 95; +const RADIUS = 75; +const START_ANGLE = -135; // degrees from vertical +const END_ANGLE = 135; +const ANGLE_RANGE = END_ANGLE - START_ANGLE; // 270 degrees + +function polarToCartesian(angle) { + const rad = ((angle - 90) * Math.PI) / 180; + return { + x: CX + RADIUS * Math.cos(rad), + y: CY + RADIUS * Math.sin(rad), + }; +} + +function describeArc(start, end) { + const startPoint = polarToCartesian(start); + const endPoint = polarToCartesian(end); + const largeArc = end - start > 180 ? 1 : 0; + return `M ${startPoint.x} ${startPoint.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`; +} + export default function ({ parentElement, data }) { const track = parentElement.querySelector("#track"); const progress = parentElement.querySelector("#progress"); @@ -12,58 +35,35 @@ export default function ({ parentElement, data }) { const maxLabel = parentElement.querySelector("#max-label"); const titleEl = parentElement.querySelector("#title"); - // Arc parameters - const cx = 100, - cy = 95; - const radius = 75; - const startAngle = -135; // degrees from vertical - const endAngle = 135; - const angleRange = endAngle - startAngle; // 270 degrees - - function polarToCartesian(angle) { - const rad = ((angle - 90) * Math.PI) / 180; - return { - x: cx + radius * Math.cos(rad), - y: cy + radius * Math.sin(rad), - }; - } - - function describeArc(start, end) { - const startPoint = polarToCartesian(start); - const endPoint = polarToCartesian(end); - const largeArc = end - start > 180 ? 1 : 0; - return `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`; - } - // Draw background track - track.setAttribute("d", describeArc(startAngle, endAngle)); + track.setAttribute("d", describeArc(START_ANGLE, END_ANGLE)); // Draw colored segments (thirds) - const third = angleRange / 3; - segmentLow.setAttribute("d", describeArc(startAngle, startAngle + third)); + const third = ANGLE_RANGE / 3; + segmentLow.setAttribute("d", describeArc(START_ANGLE, START_ANGLE + third)); segmentMid.setAttribute( "d", - describeArc(startAngle + third, startAngle + 2 * third), + describeArc(START_ANGLE + third, START_ANGLE + 2 * third), ); segmentHigh.setAttribute( "d", - describeArc(startAngle + 2 * third, endAngle), + describeArc(START_ANGLE + 2 * third, END_ANGLE), ); // Draw tick marks const numTicks = 10; ticksGroup.innerHTML = ""; for (let i = 0; i <= numTicks; i++) { - const angle = startAngle + (angleRange * i) / numTicks; + const angle = START_ANGLE + (ANGLE_RANGE * i) / numTicks; const isMajor = i % 5 === 0; - const innerRadius = isMajor ? radius - 30 : radius - 25; - const outerRadius = radius - 20; + const innerRadius = isMajor ? RADIUS - 30 : RADIUS - 25; + const outerRadius = RADIUS - 20; const rad = ((angle - 90) * Math.PI) / 180; - const x1 = cx + innerRadius * Math.cos(rad); - const y1 = cy + innerRadius * Math.sin(rad); - const x2 = cx + outerRadius * Math.cos(rad); - const y2 = cy + outerRadius * Math.sin(rad); + const x1 = CX + innerRadius * Math.cos(rad); + const y1 = CY + innerRadius * Math.sin(rad); + const x2 = CX + outerRadius * Math.cos(rad); + const y2 = CY + outerRadius * Math.sin(rad); const line = document.createElementNS( "http://www.w3.org/2000/svg", @@ -93,10 +93,10 @@ export default function ({ parentElement, data }) { const percent = Math.max(0, Math.min(1, (value - min) / (max - min))); // Calculate angle for this value - const valueAngle = startAngle + angleRange * percent; + const valueAngle = START_ANGLE + ANGLE_RANGE * percent; // Update progress arc - progress.setAttribute("d", describeArc(startAngle, valueAngle)); + progress.setAttribute("d", describeArc(START_ANGLE, valueAngle)); // Update needle rotation needleGroup.style.transform = `rotate(${valueAngle}deg)`; From b80388d22f2455d3b6a9923b3c7cd5e7fa3506b0 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Tue, 16 Dec 2025 21:49:40 -0800 Subject: [PATCH 10/12] Rename and formatting --- ...mponents-rich-data-exchange.py => components-rich-data.py} | 4 ++-- ...mple-interactive-button.py => components-simple-button.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename python/concept-source/{components-rich-data-exchange.py => components-rich-data.py} (95%) rename python/concept-source/{components-simple-interactive-button.py => components-simple-button.py} (100%) diff --git a/python/concept-source/components-rich-data-exchange.py b/python/concept-source/components-rich-data.py similarity index 95% rename from python/concept-source/components-rich-data-exchange.py rename to python/concept-source/components-rich-data.py index b585e597e..ad74b6d05 100644 --- a/python/concept-source/components-rich-data-exchange.py +++ b/python/concept-source/components-rich-data.py @@ -21,7 +21,7 @@ def create_sample_df(): df = create_sample_df() -# Load an image and convert to bytes +# Load an image and convert to b64 string @st.cache_data def load_image_as_base64(image_path): with open(image_path, "rb") as img_file: @@ -52,7 +52,7 @@ def load_image_as_base64(image_path): """, ) -result = chart_component( +chart_component( data={ "df": df, # Arrow-serializable dataframe "user_info": {"name": "Alice"}, # JSON-serializable data diff --git a/python/concept-source/components-simple-interactive-button.py b/python/concept-source/components-simple-button.py similarity index 100% rename from python/concept-source/components-simple-interactive-button.py rename to python/concept-source/components-simple-button.py From 2a81f8b5b5e5c03ac6231837aa09962b36ca5208 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Wed, 17 Dec 2025 08:49:09 -0800 Subject: [PATCH 11/12] Rename files --- .../components-interactive-counter/my_component/__init__.py | 6 +++--- .../my_component/{my_css.css => component.css} | 0 .../my_component/{my_html.html => component.html} | 0 .../my_component/{my_js.js => component.js} | 0 .../components-simple-counter/my_component/__init__.py | 6 +++--- .../my_component/{my_css.css => component.css} | 0 .../my_component/{my_html.html => component.html} | 0 .../my_component/{my_js.js => component.js} | 0 8 files changed, 6 insertions(+), 6 deletions(-) rename python/concept-source/components-interactive-counter/my_component/{my_css.css => component.css} (100%) rename python/concept-source/components-interactive-counter/my_component/{my_html.html => component.html} (100%) rename python/concept-source/components-interactive-counter/my_component/{my_js.js => component.js} (100%) rename python/concept-source/components-simple-counter/my_component/{my_css.css => component.css} (100%) rename python/concept-source/components-simple-counter/my_component/{my_html.html => component.html} (100%) rename python/concept-source/components-simple-counter/my_component/{my_js.js => component.js} (100%) diff --git a/python/concept-source/components-interactive-counter/my_component/__init__.py b/python/concept-source/components-interactive-counter/my_component/__init__.py index 11d46767a..a26f9d391 100644 --- a/python/concept-source/components-interactive-counter/my_component/__init__.py +++ b/python/concept-source/components-interactive-counter/my_component/__init__.py @@ -6,17 +6,17 @@ @st.cache_data def load_html(): - with open(_COMPONENT_DIR / "my_html.html", "r") as f: + with open(_COMPONENT_DIR / "component.html", "r") as f: return f.read() @st.cache_data def load_css(): - with open(_COMPONENT_DIR / "my_css.css", "r") as f: + with open(_COMPONENT_DIR / "component.css", "r") as f: return f.read() @st.cache_data def load_js(): - with open(_COMPONENT_DIR / "my_js.js", "r") as f: + with open(_COMPONENT_DIR / "component.js", "r") as f: return f.read() HTML = load_html() diff --git a/python/concept-source/components-interactive-counter/my_component/my_css.css b/python/concept-source/components-interactive-counter/my_component/component.css similarity index 100% rename from python/concept-source/components-interactive-counter/my_component/my_css.css rename to python/concept-source/components-interactive-counter/my_component/component.css diff --git a/python/concept-source/components-interactive-counter/my_component/my_html.html b/python/concept-source/components-interactive-counter/my_component/component.html similarity index 100% rename from python/concept-source/components-interactive-counter/my_component/my_html.html rename to python/concept-source/components-interactive-counter/my_component/component.html diff --git a/python/concept-source/components-interactive-counter/my_component/my_js.js b/python/concept-source/components-interactive-counter/my_component/component.js similarity index 100% rename from python/concept-source/components-interactive-counter/my_component/my_js.js rename to python/concept-source/components-interactive-counter/my_component/component.js diff --git a/python/concept-source/components-simple-counter/my_component/__init__.py b/python/concept-source/components-simple-counter/my_component/__init__.py index 11d46767a..a26f9d391 100644 --- a/python/concept-source/components-simple-counter/my_component/__init__.py +++ b/python/concept-source/components-simple-counter/my_component/__init__.py @@ -6,17 +6,17 @@ @st.cache_data def load_html(): - with open(_COMPONENT_DIR / "my_html.html", "r") as f: + with open(_COMPONENT_DIR / "component.html", "r") as f: return f.read() @st.cache_data def load_css(): - with open(_COMPONENT_DIR / "my_css.css", "r") as f: + with open(_COMPONENT_DIR / "component.css", "r") as f: return f.read() @st.cache_data def load_js(): - with open(_COMPONENT_DIR / "my_js.js", "r") as f: + with open(_COMPONENT_DIR / "component.js", "r") as f: return f.read() HTML = load_html() diff --git a/python/concept-source/components-simple-counter/my_component/my_css.css b/python/concept-source/components-simple-counter/my_component/component.css similarity index 100% rename from python/concept-source/components-simple-counter/my_component/my_css.css rename to python/concept-source/components-simple-counter/my_component/component.css diff --git a/python/concept-source/components-simple-counter/my_component/my_html.html b/python/concept-source/components-simple-counter/my_component/component.html similarity index 100% rename from python/concept-source/components-simple-counter/my_component/my_html.html rename to python/concept-source/components-simple-counter/my_component/component.html diff --git a/python/concept-source/components-simple-counter/my_component/my_js.js b/python/concept-source/components-simple-counter/my_component/component.js similarity index 100% rename from python/concept-source/components-simple-counter/my_component/my_js.js rename to python/concept-source/components-simple-counter/my_component/component.js From 6987e10b1c48dd6973ee8bd4370623795b4d7111 Mon Sep 17 00:00:00 2001 From: Debbie Matthews Date: Fri, 19 Dec 2025 23:53:26 -0800 Subject: [PATCH 12/12] Theming tweaks --- .../danger_button_component/button.css | 2 +- .../my_component/component.css | 8 +++--- .../radial_dial_component/dial.css | 12 ++++---- .../radial_dial_component/dial.html | 2 +- .../radial_menu_component/menu.js | 28 +++++++++++++++++++ .../stopwatch_component/stopwatch.css | 11 ++++---- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/python/concept-source/components-danger-button/danger_button_component/button.css b/python/concept-source/components-danger-button/danger_button_component/button.css index d1db972db..372775098 100644 --- a/python/concept-source/components-danger-button/danger_button_component/button.css +++ b/python/concept-source/components-danger-button/danger_button_component/button.css @@ -109,7 +109,7 @@ stroke-dasharray: 283; stroke-dashoffset: 283; transition: stroke-dashoffset 0.1s linear; - filter: drop-shadow(0 0 6px var(--st-red-color)); + filter: drop-shadow(0 0 0.5rem var(--st-red-color)); } .button-content { diff --git a/python/concept-source/components-interactive-counter/my_component/component.css b/python/concept-source/components-interactive-counter/my_component/component.css index 6afc58e6e..1b6e6bac1 100644 --- a/python/concept-source/components-interactive-counter/my_component/component.css +++ b/python/concept-source/components-interactive-counter/my_component/component.css @@ -1,5 +1,5 @@ .counter { - padding: 20px; + padding: 2rem; border: 1px solid var(--st-border-color); border-radius: var(--st-base-radius); font-family: var(--st-font); @@ -7,12 +7,12 @@ } .buttons { - margin-top: 15px; + margin-top: 1rem; } button { - margin: 0 5px; - padding: 8px 16px; + margin: 0 0.5rem; + padding: 0.5rem 1rem; background: var(--st-primary-color); color: white; border: none; diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.css b/python/concept-source/components-radial-dial/radial_dial_component/dial.css index 5a6ca37a4..5a37e7aed 100644 --- a/python/concept-source/components-radial-dial/radial_dial_component/dial.css +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.css @@ -45,7 +45,7 @@ stroke: var(--st-green-color); stroke-width: 20; stroke-linecap: round; - filter: drop-shadow(0 0 8px var(--st-green-color)); + filter: drop-shadow(0 0 0.5rem var(--st-green-color)); transition: stroke-dashoffset 0.8s cubic-bezier(0.34, 1.2, 0.64, 1), stroke 0.5s ease; @@ -65,7 +65,7 @@ /* Needle */ .needle-group { - transform-origin: 100px 95px; + transform-origin: 50% 67.857%; transition: transform 0.8s cubic-bezier(0.34, 1.2, 0.64, 1); } @@ -115,7 +115,7 @@ .labels { display: flex; justify-content: space-between; - width: 240px; + width: 15rem; margin-top: 0.5rem; } @@ -138,15 +138,15 @@ /* Color themes based on value */ .dial-progress.low { stroke: var(--st-green-color); - filter: drop-shadow(0 0 8px var(--st-green-color)); + filter: drop-shadow(0 0 0.5rem var(--st-green-color)); } .dial-progress.mid { stroke: var(--st-yellow-color); - filter: drop-shadow(0 0 8px var(--st-yellow-color)); + filter: drop-shadow(0 0 0.5rem var(--st-yellow-color)); } .dial-progress.high { stroke: var(--st-red-color); - filter: drop-shadow(0 0 8px var(--st-red-color)); + filter: drop-shadow(0 0 0.5rem var(--st-red-color)); } .value.low { diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.html b/python/concept-source/components-radial-dial/radial_dial_component/dial.html index c673e99e6..72605004b 100644 --- a/python/concept-source/components-radial-dial/radial_dial_component/dial.html +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.html @@ -16,7 +16,7 @@ - + diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.js b/python/concept-source/components-radial-menu/radial_menu_component/menu.js index 2bca0b4c7..221761ba7 100644 --- a/python/concept-source/components-radial-menu/radial_menu_component/menu.js +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.js @@ -43,6 +43,34 @@ export default function ({ parentElement, data, setStateValue }) { isOpen = !isOpen; overlay.classList.toggle("open", isOpen); ring.classList.toggle("open", isOpen); + + if (isOpen) { + // Calculate viewport-safe position + const selectorRect = selector.getBoundingClientRect(); + const menuRadius = ring.offsetWidth / 2; + const toolbarHeight = 60; // Streamlit toolbar height + + // Center of selector in viewport + const centerX = selectorRect.left + selectorRect.width / 2; + const centerY = selectorRect.top + selectorRect.height / 2; + + // Calculate overflow on each side (account for toolbar at top) + const overflowLeft = menuRadius - centerX; + const overflowRight = centerX + menuRadius - window.innerWidth; + const overflowTop = menuRadius - (centerY - toolbarHeight); + const overflowBottom = centerY + menuRadius - window.innerHeight; + + // Apply offset to keep menu in viewport + const offsetX = + Math.max(0, overflowLeft) - Math.max(0, overflowRight); + const offsetY = + Math.max(0, overflowTop) - Math.max(0, overflowBottom); + + overlay.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`; + } else { + // Reset position when closing + overlay.style.transform = ""; + } } // Initialize display diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css index 2e15a231a..638ea1090 100644 --- a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css @@ -11,8 +11,8 @@ /* Ring Display */ .display-ring { position: relative; - width: 14rem; - height: 14rem; + width: 16rem; + height: 16rem; } .ring-svg { @@ -143,6 +143,7 @@ } .btn-label { + font-family: var(--st-font); font-size: 0.7rem; font-weight: 500; text-transform: uppercase; @@ -152,11 +153,11 @@ /* Lap List */ .lap-list { width: 100%; - max-width: 280px; + max-width: 17.5rem; display: flex; flex-direction: column; gap: 0.5rem; - max-height: 150px; + max-height: 10rem; overflow-y: auto; } @@ -174,7 +175,7 @@ @keyframes slide-in { from { opacity: 0; - transform: translateY(-10px); + transform: translateY(-0.625rem); } to { opacity: 1;