WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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,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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Since these are unchanging consts, it is preferred to define them outside of this function as module level consts. eg:

const HOLD_DURATION = 2000; // 2 seconds
const COOLDOWN_DURATION = 1500; // cooldown after trigger
const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference

export default function() {
}


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);
};
Comment on lines 99 to 112
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: All of the events that are created must be cleaned up in here too. So that would be all of the mousedown, mouseup, etc events.

}
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}")
Original file line number Diff line number Diff line change
@@ -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()
Loading