Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="danger-zone">
<div class="warning-banner">
<span class="warning-icon">⚠️</span>
<span class="warning-text">Danger Zone</span>
</div>

<button id="danger-btn" class="hold-button">
<svg class="progress-ring" viewBox="0 0 100 100">
<circle class="ring-bg" cx="50" cy="50" r="45" />
<circle
id="ring-progress"
class="ring-progress"
cx="50"
cy="50"
r="45"
/>
</svg>
<div class="button-content">
<span id="icon" class="icon">🗑️</span>
<span id="label" class="label">Hold to Delete</span>
</div>
</button>

<p class="hint">Press and hold for 2 seconds to confirm</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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");

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);
}

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", 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);
};
}
27 changes: 27 additions & 0 deletions python/concept-source/components-danger-button/streamlit_app.py
Original file line number Diff line number Diff line change
@@ -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}")
9 changes: 9 additions & 0 deletions python/concept-source/components-hello-world.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import streamlit as st

hello_component = st.components.v2.component(
name="hello_world",
html="<h2>Hello, World!</h2>",
css="h2 { color: var(--st-primary-color); }",
)

hello_component()
Loading