streamlit-vanilla-components 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. streamlit_vanilla_components-0.1.0/LICENSE +21 -0
  2. streamlit_vanilla_components-0.1.0/PKG-INFO +132 -0
  3. streamlit_vanilla_components-0.1.0/README.md +116 -0
  4. streamlit_vanilla_components-0.1.0/pyproject.toml +30 -0
  5. streamlit_vanilla_components-0.1.0/setup.cfg +4 -0
  6. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/__init__.py +6 -0
  7. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/__init__.py +130 -0
  8. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/index.html +10 -0
  9. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/index.js +155 -0
  10. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/style.css +130 -0
  11. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/__init__.py +150 -0
  12. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/index.html +4 -0
  13. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/index.js +93 -0
  14. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/style.css +103 -0
  15. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/PKG-INFO +132 -0
  16. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/SOURCES.txt +18 -0
  17. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/dependency_links.txt +1 -0
  18. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/requires.txt +4 -0
  19. streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/top_level.txt +1 -0
  20. streamlit_vanilla_components-0.1.0/tests/test_smoke.py +54 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luke Horton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamlit-vanilla-components
3
+ Version: 0.1.0
4
+ Summary: Vanilla HTML/CSS/JS Streamlit components with no npm dependencies.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/lukehorton1/streamlit-vanilla-components
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: streamlit>=1.57
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # Streamlit vanilla components
18
+
19
+ Lightweight [Streamlit components v2](https://docs.streamlit.io/develop/api-reference/custom-components) in plain HTML, CSS and JavaScript. No npm packages, no build step. Each component copies its native Streamlit look and follows the active theme.
20
+
21
+ Built using GitHub Copilot as an AI coding agent, with manual oversight, design and testing throughout.
22
+
23
+ ```python
24
+ from streamlit_vanilla_components import st_live_searchbox, stateful_button
25
+ ```
26
+
27
+ Run the demo with `streamlit run example.py`.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install streamlit-vanilla-components
33
+ ```
34
+
35
+ Or install straight from GitHub:
36
+
37
+ ```bash
38
+ pip install "git+https://github.com/lukehorton1/streamlit-vanilla-components.git"
39
+ ```
40
+
41
+ Requires Python 3.10+ and Streamlit 1.57+.
42
+
43
+ ## Components
44
+
45
+ - [`st_live_searchbox`](#st_live_searchbox)
46
+ - [`stateful_button`](#stateful_button)
47
+
48
+ ---
49
+
50
+ ## `st_live_searchbox`
51
+
52
+ A search box whose dropdown is filled by your Python function as the user types. Returns the picked option as a string.
53
+
54
+ ```python
55
+ def search_places(term: str) -> list[str]:
56
+ return db.query("SELECT label FROM places WHERE label ILIKE %s LIMIT 300", f"%{term}%")
57
+
58
+ choice = st_live_searchbox(search_places, placeholder="Search a place...", rank=True, max_results=50)
59
+ ```
60
+
61
+ ### Parameters
62
+
63
+ | Parameter | Description |
64
+ |---|---|
65
+ | `search_fn` *(callable)* | Receives the typed text and returns a list of options. Required. |
66
+ | `placeholder` *(str)* | Placeholder text. Default `"Type to search..."`. |
67
+ | `key` *(str)* | Widget key. Default `"live_search"`. |
68
+ | `on_change` *(callable or None)* | Runs when the typed text changes. |
69
+ | `on_submit` *(callable or None)* | Runs when an option is picked. |
70
+ | `args` *(tuple or None)* | Passed to `on_change` and `on_submit`. |
71
+ | `kwargs` *(dict or None)* | Passed to `on_change` and `on_submit`. |
72
+ | `debounce` *(int)* | Milliseconds to wait after typing stops before searching. Default `250`. |
73
+ | `default` *(str or None)* | Returned until something is picked. Default `None`. |
74
+ | `label` *(str or None)* | Label above the box. |
75
+ | `label_visibility` *(str)* | `"visible"`, `"hidden"` or `"collapsed"`. Default `"visible"`. |
76
+ | `help` *(str or None)* | Tooltip next to the label. |
77
+ | `disabled` *(bool)* | Disables the box. Default `False`. |
78
+ | `min_chars` *(int)* | Characters typed before `search_fn` runs. Default `1`. |
79
+ | `max_results` *(int or None)* | Maximum options shown, applied after ranking. Default `None`. |
80
+ | `rank` *(bool)* | Sorts options by match quality (exact, starts with, word starts with, contains). Default `False`. |
81
+ | `clear_on_submit` *(bool)* | Empties the box after a pick. Default `False`. |
82
+ | `width` *(str or int)* | `"stretch"` or a pixel width. Default `"stretch"`. |
83
+
84
+ **Returns** *(str or None)*: the picked option, or `default`.
85
+
86
+ ### See also
87
+
88
+ - Typing again after a pick replaces it with the new text.
89
+ - Ranking only sees what `search_fn` returns, so return more candidates than `max_results`.
90
+
91
+ ---
92
+
93
+ ## `stateful_button`
94
+
95
+ A `st.button` look-alike that disables itself on click, shows a spinner in place of its icon while your code runs, then a tick on success.
96
+
97
+ ```python
98
+ with stateful_button("Submit", type="primary") as clicked:
99
+ if clicked:
100
+ save_records()
101
+ st.success("Saved!")
102
+
103
+ # or, for a single function call
104
+ stateful_button("Run job", on_click=run_job, args=("Job 1",))
105
+ ```
106
+
107
+ ### Parameters
108
+
109
+ Same as `st.button`, plus `icon_position` and `wrap`.
110
+
111
+ | Parameter | Description |
112
+ |---|---|
113
+ | `label` *(str)* | Button text. Plain text, no Markdown. Required. |
114
+ | `key` *(str or None)* | Widget key. Derived from the other arguments if omitted. |
115
+ | `help` *(str or None)* | Tooltip. |
116
+ | `on_click` *(callable or None)* | Work to run. The button stays busy until it finishes. |
117
+ | `args` *(tuple or None)* | Passed to `on_click`. |
118
+ | `kwargs` *(dict or None)* | Passed to `on_click`. |
119
+ | `type` *(str)* | `"primary"`, `"secondary"` or `"tertiary"`. Default `"secondary"`. |
120
+ | `icon` *(str or None)* | Emoji or `:material/name:`. |
121
+ | `icon_position` *(str)* | `"left"` or `"right"`. Default `"left"`. |
122
+ | `disabled` *(bool)* | Disables the button. Default `False`. |
123
+ | `width` *(str or int)* | `"content"`, `"stretch"` or a pixel width. Default `"content"`. |
124
+ | `shortcut` *(str or None)* | Keyboard shortcut. |
125
+ | `wrap` *(bool or None)* | Whether the label may wrap. |
126
+
127
+ **Returns** *(StatefulButton)*: truthy on the run it was clicked. Works as a context manager, and `.release(success=True)` ends the busy state early.
128
+
129
+ ### See also
130
+
131
+ - Use either the `with` form or `on_click`. Without either, the spinner never stops.
132
+ - Errors in the `with` block or in `on_click` re-enable the button, then raise.
@@ -0,0 +1,116 @@
1
+ # Streamlit vanilla components
2
+
3
+ Lightweight [Streamlit components v2](https://docs.streamlit.io/develop/api-reference/custom-components) in plain HTML, CSS and JavaScript. No npm packages, no build step. Each component copies its native Streamlit look and follows the active theme.
4
+
5
+ Built using GitHub Copilot as an AI coding agent, with manual oversight, design and testing throughout.
6
+
7
+ ```python
8
+ from streamlit_vanilla_components import st_live_searchbox, stateful_button
9
+ ```
10
+
11
+ Run the demo with `streamlit run example.py`.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install streamlit-vanilla-components
17
+ ```
18
+
19
+ Or install straight from GitHub:
20
+
21
+ ```bash
22
+ pip install "git+https://github.com/lukehorton1/streamlit-vanilla-components.git"
23
+ ```
24
+
25
+ Requires Python 3.10+ and Streamlit 1.57+.
26
+
27
+ ## Components
28
+
29
+ - [`st_live_searchbox`](#st_live_searchbox)
30
+ - [`stateful_button`](#stateful_button)
31
+
32
+ ---
33
+
34
+ ## `st_live_searchbox`
35
+
36
+ A search box whose dropdown is filled by your Python function as the user types. Returns the picked option as a string.
37
+
38
+ ```python
39
+ def search_places(term: str) -> list[str]:
40
+ return db.query("SELECT label FROM places WHERE label ILIKE %s LIMIT 300", f"%{term}%")
41
+
42
+ choice = st_live_searchbox(search_places, placeholder="Search a place...", rank=True, max_results=50)
43
+ ```
44
+
45
+ ### Parameters
46
+
47
+ | Parameter | Description |
48
+ |---|---|
49
+ | `search_fn` *(callable)* | Receives the typed text and returns a list of options. Required. |
50
+ | `placeholder` *(str)* | Placeholder text. Default `"Type to search..."`. |
51
+ | `key` *(str)* | Widget key. Default `"live_search"`. |
52
+ | `on_change` *(callable or None)* | Runs when the typed text changes. |
53
+ | `on_submit` *(callable or None)* | Runs when an option is picked. |
54
+ | `args` *(tuple or None)* | Passed to `on_change` and `on_submit`. |
55
+ | `kwargs` *(dict or None)* | Passed to `on_change` and `on_submit`. |
56
+ | `debounce` *(int)* | Milliseconds to wait after typing stops before searching. Default `250`. |
57
+ | `default` *(str or None)* | Returned until something is picked. Default `None`. |
58
+ | `label` *(str or None)* | Label above the box. |
59
+ | `label_visibility` *(str)* | `"visible"`, `"hidden"` or `"collapsed"`. Default `"visible"`. |
60
+ | `help` *(str or None)* | Tooltip next to the label. |
61
+ | `disabled` *(bool)* | Disables the box. Default `False`. |
62
+ | `min_chars` *(int)* | Characters typed before `search_fn` runs. Default `1`. |
63
+ | `max_results` *(int or None)* | Maximum options shown, applied after ranking. Default `None`. |
64
+ | `rank` *(bool)* | Sorts options by match quality (exact, starts with, word starts with, contains). Default `False`. |
65
+ | `clear_on_submit` *(bool)* | Empties the box after a pick. Default `False`. |
66
+ | `width` *(str or int)* | `"stretch"` or a pixel width. Default `"stretch"`. |
67
+
68
+ **Returns** *(str or None)*: the picked option, or `default`.
69
+
70
+ ### See also
71
+
72
+ - Typing again after a pick replaces it with the new text.
73
+ - Ranking only sees what `search_fn` returns, so return more candidates than `max_results`.
74
+
75
+ ---
76
+
77
+ ## `stateful_button`
78
+
79
+ A `st.button` look-alike that disables itself on click, shows a spinner in place of its icon while your code runs, then a tick on success.
80
+
81
+ ```python
82
+ with stateful_button("Submit", type="primary") as clicked:
83
+ if clicked:
84
+ save_records()
85
+ st.success("Saved!")
86
+
87
+ # or, for a single function call
88
+ stateful_button("Run job", on_click=run_job, args=("Job 1",))
89
+ ```
90
+
91
+ ### Parameters
92
+
93
+ Same as `st.button`, plus `icon_position` and `wrap`.
94
+
95
+ | Parameter | Description |
96
+ |---|---|
97
+ | `label` *(str)* | Button text. Plain text, no Markdown. Required. |
98
+ | `key` *(str or None)* | Widget key. Derived from the other arguments if omitted. |
99
+ | `help` *(str or None)* | Tooltip. |
100
+ | `on_click` *(callable or None)* | Work to run. The button stays busy until it finishes. |
101
+ | `args` *(tuple or None)* | Passed to `on_click`. |
102
+ | `kwargs` *(dict or None)* | Passed to `on_click`. |
103
+ | `type` *(str)* | `"primary"`, `"secondary"` or `"tertiary"`. Default `"secondary"`. |
104
+ | `icon` *(str or None)* | Emoji or `:material/name:`. |
105
+ | `icon_position` *(str)* | `"left"` or `"right"`. Default `"left"`. |
106
+ | `disabled` *(bool)* | Disables the button. Default `False`. |
107
+ | `width` *(str or int)* | `"content"`, `"stretch"` or a pixel width. Default `"content"`. |
108
+ | `shortcut` *(str or None)* | Keyboard shortcut. |
109
+ | `wrap` *(bool or None)* | Whether the label may wrap. |
110
+
111
+ **Returns** *(StatefulButton)*: truthy on the run it was clicked. Works as a context manager, and `.release(success=True)` ends the busy state early.
112
+
113
+ ### See also
114
+
115
+ - Use either the `with` form or `on_click`. Without either, the spinner never stops.
116
+ - Errors in the `with` block or in `on_click` re-enable the button, then raise.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "streamlit-vanilla-components"
7
+ version = "0.1.0"
8
+ description = "Vanilla HTML/CSS/JS Streamlit components with no npm dependencies."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["streamlit>=1.57"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8"]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/lukehorton1/streamlit-vanilla-components"
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["streamlit_vanilla_components*"]
27
+
28
+ [tool.setuptools.package-data]
29
+ "streamlit_vanilla_components.searchbox" = ["frontend/*"]
30
+ "streamlit_vanilla_components.stateful_button" = ["frontend/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Vanilla HTML/JS/CSS components for Streamlit."""
2
+
3
+ from .searchbox import st_live_searchbox
4
+ from .stateful_button import stateful_button
5
+
6
+ __all__ = ["st_live_searchbox", "stateful_button"]
@@ -0,0 +1,130 @@
1
+ """A live search box: typing runs your Python search function."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ import re
9
+
10
+ import streamlit as st
11
+
12
+ _FRONTEND = Path(__file__).parent / "frontend"
13
+
14
+
15
+ def _read(name: str) -> str:
16
+ return (_FRONTEND / name).read_text(encoding="utf-8")
17
+
18
+
19
+ _COMPONENT = st.components.v2.component(
20
+ "live_searchbox",
21
+ html=_read("index.html"),
22
+ css=_read("style.css"),
23
+ js=_read("index.js"),
24
+ )
25
+
26
+
27
+ def _rank(options: list[str], term: str) -> list[str]:
28
+ """Stable sort by match quality, judged on the text before the first comma."""
29
+ t = term.strip().lower()
30
+ if not t:
31
+ return options
32
+ word_start = re.compile(r"\b" + re.escape(t))
33
+
34
+ def tier(option: str) -> int:
35
+ text = option.split(",")[0].strip().lower()
36
+ if text == t:
37
+ return 0
38
+ if text.startswith(t):
39
+ return 1
40
+ if word_start.search(text):
41
+ return 2
42
+ if t in text:
43
+ return 3
44
+ return 4
45
+
46
+ return sorted(options, key=tier) # sorted() is stable: incoming order breaks ties
47
+
48
+
49
+ def st_live_searchbox(
50
+ search_fn: Callable[[str], list[str]],
51
+ placeholder: str = "Type to search...",
52
+ key: str = "live_search",
53
+ *,
54
+ on_change: Callable | None = None,
55
+ on_submit: Callable | None = None,
56
+ args: tuple | None = None,
57
+ kwargs: dict | None = None,
58
+ debounce: int = 250,
59
+ default: str | None = None,
60
+ label: str | None = None,
61
+ label_visibility: str = "visible",
62
+ help: str | None = None,
63
+ disabled: bool = False,
64
+ min_chars: int = 1,
65
+ max_results: int | None = None,
66
+ rank: bool = False,
67
+ clear_on_submit: bool = False,
68
+ width: str | int = "stretch",
69
+ ) -> str | None:
70
+ """Show a text box whose dropdown is filled by `search_fn(search_term)`.
71
+
72
+ `search_fn` returns a list of strings (e.g. from a SQL query) and runs after the
73
+ user pauses typing for `debounce` milliseconds. Returns the last option the user
74
+ picked (or `default`).
75
+
76
+ `on_change` runs when the typed text changes, `on_submit` when an option is picked.
77
+ Both run before the script reruns, like native widgets, and can read
78
+ `st.session_state[key].query` and `st.session_state[key].selected`.
79
+
80
+ `rank=True` re-orders the results by match quality: exact match, starts with the
81
+ term, a word starts with the term, contains the term. Results in the same tier keep
82
+ the order `search_fn` returned them in, so sort by e.g. population there. Because
83
+ ranking only sees what `search_fn` returns, return more candidates than
84
+ `max_results` or the best match may already have been cut off.
85
+
86
+ `min_chars` holds back searching until enough text is typed, `max_results` caps the
87
+ list (applied after ranking), and `clear_on_submit` empties the box after a pick.
88
+ """
89
+
90
+ def wrap(fn):
91
+ if fn is None:
92
+ return lambda: None
93
+ return lambda: fn(*(args or ()), **(kwargs or {}))
94
+
95
+ state = st.session_state.get(key)
96
+ query = getattr(state, "query", None) or ""
97
+ selected = getattr(state, "selected", None)
98
+ if selected is None:
99
+ selected = default
100
+ selected = selected or None # "" means the user cleared it
101
+
102
+ options: list[str] = []
103
+ if len(query) >= min_chars:
104
+ options = [str(o) for o in (search_fn(query) or [])]
105
+ if rank:
106
+ options = _rank(options, query)
107
+ if max_results is not None:
108
+ options = options[:max_results]
109
+
110
+ result = _COMPONENT(
111
+ key=key,
112
+ data={
113
+ "options": options,
114
+ "placeholder": placeholder,
115
+ "initial": "" if clear_on_submit else (selected or ""),
116
+ "debounce": max(0, int(debounce)),
117
+ "label": label,
118
+ "labelVisibility": label_visibility,
119
+ "help": help,
120
+ "disabled": disabled,
121
+ "clearOnSubmit": clear_on_submit,
122
+ },
123
+ width=width,
124
+ on_query_change=wrap(on_change),
125
+ on_selected_change=lambda: None,
126
+ on_submitted_change=wrap(on_submit),
127
+ )
128
+ if result.selected is None:
129
+ return default
130
+ return result.selected or None
@@ -0,0 +1,10 @@
1
+ <div id="wrap">
2
+ <label id="label" for="input" hidden></label>
3
+ <div id="field">
4
+ <div id="box">
5
+ <input id="input" type="text" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="list" />
6
+ <button id="clear" type="button" aria-label="Clear" title="Clear" hidden><svg viewBox="0 0 24 24" height="1rem" width="1rem" aria-hidden="true" focusable="false" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0V0z"></path><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm4.29 12.89a.996.996 0 11-1.41 1.41L12 13.41 9.11 16.3a.996.996 0 11-1.41-1.41L10.59 12 7.71 9.11A.996.996 0 119.12 7.7L12 10.59l2.89-2.89a.996.996 0 111.41 1.41L13.41 12l2.88 2.89z"></path></svg></button>
7
+ </div>
8
+ <ul id="list" role="listbox" hidden></ul>
9
+ </div>
10
+ </div>
@@ -0,0 +1,155 @@
1
+ export default function (component) {
2
+ const { data, parentElement, setStateValue, setTriggerValue } = component
3
+
4
+ const input = parentElement.querySelector("#input")
5
+ const list = parentElement.querySelector("#list")
6
+ const label = parentElement.querySelector("#label")
7
+ const clear = parentElement.querySelector("#clear")
8
+
9
+ // The script reruns after each query and this function runs again every time, so
10
+ // what the user is doing (open/closed, pending debounce) lives on the element.
11
+ const ui = (parentElement._searchbox ??= { open: false, index: 0, timer: null, picked: null })
12
+ const options = data.options ?? []
13
+
14
+ input.placeholder = data.placeholder ?? ""
15
+ input.title = data.help ?? ""
16
+ input.disabled = !!data.disabled
17
+ parentElement.querySelector("#box").classList.toggle("disabled", input.disabled)
18
+ label.hidden = !data.label
19
+ label.textContent = data.label ?? ""
20
+ label.className = data.labelVisibility === "visible" ? "" : "collapsed"
21
+ if (!ui.initialized) {
22
+ input.value = data.initial ?? ""
23
+ ui.initialized = true
24
+ }
25
+
26
+ // Fresh options from Python: highlight the first. Open state is left alone, so a
27
+ // rerun after a selection never pops the list back open.
28
+ ui.index = 0
29
+
30
+ function render() {
31
+ clear.hidden = !input.value || input.disabled
32
+ const show = ui.open && options.length > 0
33
+ list.hidden = !show
34
+ input.setAttribute("aria-expanded", String(show))
35
+ list.textContent = ""
36
+ options.forEach((text, i) => {
37
+ const li = document.createElement("li")
38
+ const span = document.createElement("span")
39
+ span.title = text
40
+ const term = input.value.trim()
41
+ const at = term ? text.toLowerCase().indexOf(term.toLowerCase()) : -1
42
+ if (at < 0) {
43
+ span.textContent = text
44
+ } else {
45
+ const strong = document.createElement("strong")
46
+ strong.textContent = text.slice(at, at + term.length)
47
+ span.append(text.slice(0, at), strong, text.slice(at + term.length))
48
+ }
49
+ li.appendChild(span)
50
+ li.role = "option"
51
+ li.className = i === ui.index ? "active" : ""
52
+ li.onmouseenter = () => highlight(i, false)
53
+ // mousedown fires before the input loses focus, so the click can't be lost
54
+ li.onmousedown = (e) => {
55
+ e.preventDefault()
56
+ select(i)
57
+ }
58
+ list.appendChild(li)
59
+ })
60
+ if (show) list.children[ui.index]?.scrollIntoView({ block: "nearest" })
61
+ }
62
+
63
+ function highlight(i, scroll = true) {
64
+ ui.index = i
65
+ ;[...list.children].forEach((li, n) => li.classList.toggle("active", n === i))
66
+ if (scroll) list.children[i]?.scrollIntoView({ block: "nearest" })
67
+ }
68
+
69
+ function close() {
70
+ ui.open = false
71
+ render()
72
+ input.blur()
73
+ }
74
+
75
+ function select(i) {
76
+ const value = options[i]
77
+ if (value === undefined) return
78
+ clearTimeout(ui.timer)
79
+ setStateValue("selected", value)
80
+ setTriggerValue("submitted", value)
81
+ if (data.clearOnSubmit) {
82
+ input.value = ""
83
+ setStateValue("query", "")
84
+ } else {
85
+ input.value = value
86
+ ui.picked = value
87
+ }
88
+ close()
89
+ }
90
+
91
+ // mousedown keeps focus in the input; the click clears text and the picked result
92
+ clear.onmousedown = (e) => e.preventDefault()
93
+ clear.onclick = () => {
94
+ clearTimeout(ui.timer)
95
+ ui.picked = null
96
+ input.value = ""
97
+ setStateValue("selected", "")
98
+ setStateValue("query", "")
99
+ input.focus()
100
+ render()
101
+ }
102
+
103
+ function changed() {
104
+ ui.open = true
105
+ clearTimeout(ui.timer)
106
+ const text = input.value
107
+ ui.timer = setTimeout(() => setStateValue("query", text), data.debounce ?? 250)
108
+ render()
109
+ }
110
+
111
+ // Typing over a picked result replaces it instead of appending to it. Changing the
112
+ // value inside beforeinput drops the pending keystroke, so apply the edit ourselves.
113
+ input.onbeforeinput = (e) => {
114
+ if (ui.picked === null || input.value !== ui.picked) return
115
+ ui.picked = null
116
+ e.preventDefault()
117
+ input.value = e.inputType.startsWith("insert") ? (e.data ?? e.dataTransfer?.getData("text") ?? "") : ""
118
+ changed()
119
+ }
120
+ input.oninput = changed
121
+ input.onfocus = () => {
122
+ ui.open = true
123
+ render()
124
+ }
125
+ input.onblur = () => {
126
+ ui.open = false
127
+ render()
128
+ }
129
+ input.onkeydown = (e) => {
130
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
131
+ e.preventDefault()
132
+ if (!options.length) return
133
+ if (!ui.open) {
134
+ ui.open = true
135
+ render()
136
+ return
137
+ }
138
+ const step = e.key === "ArrowDown" ? 1 : -1
139
+ highlight((ui.index + step + options.length) % options.length)
140
+ } else if (e.key === "Enter") {
141
+ e.preventDefault()
142
+ if (ui.open && options.length) select(ui.index)
143
+ else input.blur()
144
+ } else if (e.key === "Escape") {
145
+ e.preventDefault()
146
+ close()
147
+ }
148
+ }
149
+
150
+ render()
151
+
152
+ return () => {
153
+ clear.onclick = clear.onmousedown = input.onbeforeinput = input.oninput = input.onfocus = input.onblur = input.onkeydown = null
154
+ }
155
+ }
@@ -0,0 +1,130 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ #wrap {
6
+ --primary: var(--st-primary-color, #ff4b4b);
7
+ --bg: var(--st-background-color, #fff);
8
+ --sec-bg: var(--st-secondary-background-color, #f0f2f6);
9
+ --text: var(--st-text-color, #31333f);
10
+ --font: var(--st-font, sans-serif);
11
+ --border: var(--st-border-color, rgba(128, 128, 128, 0.4));
12
+
13
+ font-family: var(--font);
14
+ color: var(--text);
15
+ }
16
+
17
+ #label {
18
+ display: block;
19
+ margin-bottom: 0.25rem;
20
+ min-height: 1.5rem;
21
+ font-size: 0.875rem;
22
+ line-height: 1.6;
23
+ }
24
+ #label[hidden] { display: none; }
25
+ #label.collapsed {
26
+ display: block;
27
+ position: absolute;
28
+ width: 1px;
29
+ height: 1px;
30
+ overflow: hidden;
31
+ clip: rect(0 0 0 0);
32
+ }
33
+
34
+ #field { position: relative; }
35
+
36
+ /* Measured against st.selectbox: 40px box, 1px border in the secondary background
37
+ colour (primary when focused), 14px text, 8px padding. */
38
+ #box {
39
+ display: flex;
40
+ box-sizing: border-box;
41
+ width: 100%;
42
+ height: 2.5rem;
43
+ border: 1px solid var(--sec-bg);
44
+ border-radius: var(--st-button-radius, 0.5rem);
45
+ background: var(--sec-bg);
46
+ }
47
+ #box:focus-within { border-color: var(--primary); }
48
+ #box.disabled { opacity: 0.4; cursor: not-allowed; }
49
+
50
+ input {
51
+ flex: 1 1 0%;
52
+ min-width: 0.125rem;
53
+ padding: 0.5rem;
54
+ border: none;
55
+ outline: none;
56
+ background: none;
57
+ font: inherit;
58
+ font-size: 0.875rem;
59
+ line-height: 1.4;
60
+ font-weight: 400;
61
+ color: var(--text);
62
+ caret-color: var(--text);
63
+ box-sizing: border-box;
64
+ }
65
+ input:disabled { cursor: not-allowed; }
66
+ input::placeholder { color: color-mix(in srgb, var(--text) 60%, transparent); opacity: 1; }
67
+
68
+ ul {
69
+ position: absolute;
70
+ top: calc(100% + 4px);
71
+ left: 0;
72
+ right: 0;
73
+ z-index: 1000;
74
+ box-sizing: border-box;
75
+ max-height: calc(300px + 2px);
76
+ margin: 0;
77
+ padding: 0;
78
+ overflow-y: auto;
79
+ list-style: none;
80
+ background: var(--bg);
81
+ border: 1px solid var(--border);
82
+ border-radius: var(--st-button-radius, 0.5rem);
83
+ }
84
+ ul[hidden] { display: none; }
85
+
86
+ /* 40px rows with a 28px rounded highlight inset 5px/6px, like the native options */
87
+ li {
88
+ display: flex;
89
+ align-items: center;
90
+ box-sizing: border-box;
91
+ height: 40px;
92
+ padding: 0 5px;
93
+ cursor: pointer;
94
+ font-size: 0.875rem;
95
+ line-height: 1.6;
96
+ }
97
+ li > span {
98
+ flex: 1;
99
+ min-width: 0;
100
+ padding: 0 8px;
101
+ line-height: 28px;
102
+ border-radius: 6px;
103
+ white-space: nowrap;
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ }
107
+ li > span strong { font-weight: 600; }
108
+ li.active > span { background: rgba(172, 177, 195, 0.15); }
109
+
110
+ #clear {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ align-self: center;
115
+ flex-shrink: 0;
116
+ font-size: 1rem;
117
+ margin-right: 0.25rem;: 0.5rem;
118
+ padding: 0.125rem;
119
+ width: 1.5em;
120
+ height: 1.5em;
121
+ border: none;
122
+ outline: none;
123
+ border-radius: 0.5rem;
124
+ background: none;
125
+ cursor: pointer;
126
+ color: color-mix(in srgb, var(--text) 60%, transparent);
127
+ }
128
+ #clear:hover { color: var(--text); }
129
+ #clear:focus-visible { box-shadow: color-mix(in srgb, var(--primary) 50%, transparent) 0 0 0 0.2rem; }
130
+ #clear[hidden] { display: none; }
@@ -0,0 +1,150 @@
1
+ """A st.button look-alike that disables itself while your script is busy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ import streamlit as st
9
+
10
+ _FRONTEND = Path(__file__).parent / "frontend"
11
+
12
+
13
+ def _read(name: str) -> str:
14
+ return (_FRONTEND / name).read_text(encoding="utf-8")
15
+
16
+
17
+ # Registered once at import time (re-registering the same name causes warnings)
18
+ _COMPONENT = st.components.v2.component(
19
+ "stateful_button",
20
+ html=_read("index.html"),
21
+ css=_read("style.css"),
22
+ js=_read("index.js"),
23
+ )
24
+
25
+
26
+ class StatefulButton:
27
+ """Returned by `stateful_button`. Use it as a bool, a context manager, or both."""
28
+
29
+ def __init__(self, clicked: bool, release):
30
+ self.clicked = clicked
31
+ self._release = release
32
+ self._released = False
33
+
34
+ def release(self, success: bool = True) -> None:
35
+ # Only ever re-render once per run, or Streamlit complains about a duplicate key
36
+ if self.clicked and not self._released:
37
+ self._released = True
38
+ self._release(success)
39
+
40
+ def __bool__(self) -> bool:
41
+ return self.clicked
42
+
43
+ def __enter__(self) -> StatefulButton:
44
+ return self
45
+
46
+ def __exit__(self, exc_type, exc, tb) -> bool:
47
+ # Runs on success and on errors, so the button never gets stuck
48
+ self.release(exc_type is None)
49
+ return False
50
+
51
+
52
+ def stateful_button(
53
+ label: str,
54
+ key: str | None = None,
55
+ help: str | None = None,
56
+ on_click=None,
57
+ args=None,
58
+ kwargs=None,
59
+ *,
60
+ type: str = "secondary",
61
+ icon: str | None = None,
62
+ icon_position: str = "left",
63
+ disabled: bool = False,
64
+ width: str | int = "content",
65
+ shortcut: str | None = None,
66
+ wrap: bool | None = None,
67
+ ) -> StatefulButton:
68
+ """Same parameters as `st.button`. Labels are plain text (no Markdown).
69
+
70
+ The button disables itself the moment it's clicked and shows a spinner in place
71
+ of its icon, then a tick when the work succeeds. Pick one way to do the work:
72
+
73
+ # Several lines, or you want to show results afterwards
74
+ with stateful_button("Submit") as clicked:
75
+ if clicked:
76
+ save_records()
77
+
78
+ # A single function call
79
+ stateful_button("Submit", on_click=save_records)
80
+
81
+ Without either, nothing tells the button when the work is done, so it keeps
82
+ spinning until the next rerun.
83
+ """
84
+ # Without a key, the same arguments give the same identity on every rerun
85
+ ident = key or hashlib.md5(
86
+ repr((label, type, icon, icon_position, width, shortcut)).encode()
87
+ ).hexdigest()[:10]
88
+ nonce_name = f"_stateful_button_nonce_{ident}"
89
+ done_name = f"_stateful_button_done_{ident}"
90
+ failed_name = f"_stateful_button_failed_{ident}"
91
+ nonce = st.session_state.get(nonce_name, 0)
92
+ # Must match what the browser is already showing, or the data changes and the
93
+ # button resets itself mid-click (losing its spinner and disabled state)
94
+ was_done = st.session_state.pop(done_name, False)
95
+
96
+ data = {
97
+ "label": label,
98
+ "help": help,
99
+ "type": type,
100
+ "icon": icon,
101
+ "iconPosition": icon_position,
102
+ "disabled": disabled,
103
+ "width": width,
104
+ "shortcut": shortcut,
105
+ "wrap": wrap,
106
+ }
107
+
108
+ def callback():
109
+ if on_click:
110
+ try:
111
+ on_click(*(args or ()), **(kwargs or {}))
112
+ except Exception as e:
113
+ # Raising here would abort the whole run before the button is drawn,
114
+ # so keep the error and raise it later in the script instead
115
+ st.session_state[failed_name] = e
116
+
117
+ def mount(n: int, done: bool = False):
118
+ # A new nonce means a new component identity, which gives a fresh idle button
119
+ return _COMPONENT(
120
+ key=f"{ident}-{n}",
121
+ data={**data, "done": done},
122
+ width=width,
123
+ on_clicked_change=callback,
124
+ )
125
+
126
+ slot = st.empty()
127
+ with slot:
128
+ result = mount(nonce, done=was_done)
129
+
130
+ if not result.clicked:
131
+ return StatefulButton(False, lambda ok: None)
132
+
133
+ # The browser keeps the old button disabled. Bump the nonce so the next run
134
+ # (or the end of the `with` block) mounts a fresh, enabled one.
135
+ st.session_state[nonce_name] = nonce + 1
136
+
137
+ def release(success: bool):
138
+ # On success the fresh button briefly shows a tick before going back to normal
139
+ st.session_state[done_name] = success
140
+ with slot:
141
+ mount(nonce + 1, done=success)
142
+
143
+ button = StatefulButton(True, release)
144
+ if on_click:
145
+ # The callback already ran before this script started, so the work is finished
146
+ error = st.session_state.pop(failed_name, None)
147
+ button.release(error is None)
148
+ if error is not None:
149
+ raise error
150
+ return button
@@ -0,0 +1,4 @@
1
+ <button id="btn" type="button">
2
+ <span id="icon" class="icon"></span>
3
+ <span id="label"></span>
4
+ </button>
@@ -0,0 +1,93 @@
1
+ const MATERIAL = /^:material\/([a-z0-9_]+):$/
2
+
3
+ // Maps the key names Streamlit documents for `shortcut` to KeyboardEvent.key values
4
+ const KEY_NAMES = {
5
+ esc: "Escape", space: " ", up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight",
6
+ pageup: "PageUp", pagedown: "PageDown", enter: "Enter", tab: "Tab",
7
+ backspace: "Backspace", delete: "Delete", home: "Home", end: "End",
8
+ }
9
+
10
+ // "Cmd+Shift+O" -> a function that tests a KeyboardEvent
11
+ function parseShortcut(text) {
12
+ const parts = text.split("+").map((p) => p.trim().toLowerCase())
13
+ const key = parts.pop()
14
+ const want = {
15
+ mod: parts.some((p) => ["ctrl", "cmd", "meta", "mod"].includes(p)),
16
+ alt: parts.some((p) => ["alt", "option"].includes(p)),
17
+ shift: parts.includes("shift"),
18
+ }
19
+ const expected = KEY_NAMES[key] ?? key
20
+ return (e) =>
21
+ e.key.toLowerCase() === expected.toLowerCase() &&
22
+ (e.ctrlKey || e.metaKey) === want.mod &&
23
+ e.altKey === want.alt &&
24
+ e.shiftKey === want.shift
25
+ }
26
+
27
+ function renderIcon(el, icon, busy, done) {
28
+ el.className = "icon"
29
+ el.textContent = ""
30
+ if (busy) {
31
+ el.className = "icon spinner"
32
+ } else if (done) {
33
+ el.className = "icon material-symbols"
34
+ el.textContent = "check"
35
+ } else if (icon) {
36
+ const match = MATERIAL.exec(icon)
37
+ el.textContent = match ? match[1] : icon
38
+ if (match) el.className = "icon material-symbols"
39
+ }
40
+ }
41
+
42
+ export default function (component) {
43
+ const { data, parentElement, setTriggerValue } = component
44
+
45
+ const btn = parentElement.querySelector("#btn")
46
+ const icon = parentElement.querySelector("#icon")
47
+ const label = parentElement.querySelector("#label")
48
+
49
+ btn.dataset.type = data.type
50
+ btn.dataset.width = data.width === "stretch" ? "stretch" : "content"
51
+ btn.dataset.iconPosition = data.iconPosition
52
+ btn.dataset.wrap = String(data.wrap !== false)
53
+ btn.style.width = typeof data.width === "number" ? `${data.width}px` : ""
54
+ btn.title = [data.help, data.shortcut && `(${data.shortcut})`].filter(Boolean).join(" ")
55
+ label.textContent = data.label
56
+ renderIcon(icon, data.icon, false, data.done)
57
+ btn.disabled = data.disabled || data.done
58
+
59
+ // After a successful run, show a tick for a moment, then restore the original icon
60
+ const timer = data.done
61
+ ? setTimeout(() => {
62
+ renderIcon(icon, data.icon, false, false)
63
+ btn.disabled = data.disabled
64
+ }, 1500)
65
+ : null
66
+
67
+ // Disable straight away in the browser, so a second click can't happen
68
+ // while Python is still working. Python re-renders the button when it's done.
69
+ function click() {
70
+ if (btn.disabled) return
71
+ btn.disabled = true
72
+ renderIcon(icon, data.icon, true)
73
+ setTriggerValue("clicked", true)
74
+ }
75
+ btn.onclick = click
76
+
77
+ let onKey = null
78
+ if (data.shortcut) {
79
+ const matches = parseShortcut(data.shortcut)
80
+ onKey = (e) => {
81
+ if (matches(e)) {
82
+ e.preventDefault()
83
+ click()
84
+ }
85
+ }
86
+ document.addEventListener("keydown", onKey)
87
+ }
88
+
89
+ return () => {
90
+ clearTimeout(timer)
91
+ onKey && document.removeEventListener("keydown", onKey)
92
+ }
93
+ }
@@ -0,0 +1,103 @@
1
+ /* Approximation of the native st.button, driven by Streamlit's theme variables. */
2
+ :host {
3
+ display: block;
4
+ }
5
+
6
+ button {
7
+ --primary: var(--st-primary-color, #ff4b4b);
8
+ --on-primary: #fff;
9
+ --hover-overlay: rgba(172, 177, 195, 0.15);
10
+ --active-overlay: rgba(172, 177, 195, 0.25);
11
+
12
+ display: inline-flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ gap: 0.5rem;
16
+ box-sizing: border-box;
17
+ min-height: 2.5rem;
18
+ max-width: 100%;
19
+ padding: 0.25rem 0.75rem;
20
+ border: 1px solid var(--st-border-color);
21
+ border-radius: var(--st-button-radius, 0.5rem);
22
+ background: color-mix(in srgb, var(--st-secondary-background-color) 30%, var(--st-background-color));
23
+ color: var(--st-text-color);
24
+ font-family: var(--st-font, sans-serif);
25
+ font-size: 1rem;
26
+ font-weight: var(--st-base-font-weight, 400);
27
+ line-height: 1.6;
28
+ cursor: pointer;
29
+ transition: color 0.15s, background-color 0.15s, border-color 0.15s;
30
+ }
31
+
32
+ /* Layout options: width and icon side */
33
+ button[data-width="stretch"] { width: 100%; }
34
+ button[data-icon-position="right"] { flex-direction: row-reverse; }
35
+
36
+ /* Label: wrap onto extra lines, or stay on one row with an ellipsis */
37
+ #label { min-width: 0; font-size: 0.875rem; line-height: 1.6; }
38
+ button[data-wrap="true"] #label { white-space: normal; overflow-wrap: anywhere; }
39
+ button[data-wrap="false"] #label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
+
41
+ /* Icons */
42
+ .icon:empty:not(.spinner) { display: none; }
43
+ /* Every icon (emoji, Material, spinner, tick) gets the same box so the button never resizes */
44
+ .icon {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ flex: none;
49
+ width: 1rem;
50
+ height: 1rem;
51
+ }
52
+ .material-symbols {
53
+ font-family: "Material Symbols Rounded";
54
+ font-size: 1rem;
55
+ line-height: 1;
56
+ font-weight: normal;
57
+ font-feature-settings: "liga";
58
+ -webkit-font-feature-settings: "liga";
59
+ }
60
+
61
+ /* Spinner shown while the script is working. The ring spins inside the standard icon box. */
62
+ .spinner::before {
63
+ content: "";
64
+ box-sizing: border-box;
65
+ width: 0.875rem;
66
+ height: 0.875rem;
67
+ border: 2px solid currentColor;
68
+ border-top-color: transparent;
69
+ border-radius: 50%;
70
+ animation: spin 0.8s linear infinite;
71
+ }
72
+ @keyframes spin { to { transform: rotate(360deg); } }
73
+
74
+ /* Secondary (default): hover and active only tint the background */
75
+ button:hover:not(:disabled) { background-image: linear-gradient(var(--hover-overlay), var(--hover-overlay)); }
76
+ button:active:not(:disabled) { background-image: linear-gradient(var(--active-overlay), var(--active-overlay)); }
77
+
78
+ /* Primary (darker shades are the primary colour with lightness reduced) */
79
+ button[data-type="primary"] { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
80
+ button[data-type="primary"]:hover:not(:disabled) {
81
+ background: hsl(from var(--primary) h s calc(l - 15));
82
+ border-color: hsl(from var(--primary) h s calc(l - 15));
83
+ background-image: none;
84
+ }
85
+ button[data-type="primary"]:active:not(:disabled) {
86
+ background: var(--primary);
87
+ border-color: hsl(from var(--primary) h s calc(l - 15));
88
+ background-image: none;
89
+ }
90
+
91
+ /* Tertiary: no border, no padding, text changes colour */
92
+ button[data-type="tertiary"] { background: transparent; border: 0; padding: 0; }
93
+ button[data-type="tertiary"]:hover:not(:disabled) { color: var(--primary); background-image: none; }
94
+ button[data-type="tertiary"]:active:not(:disabled) { color: hsl(from var(--primary) h s calc(l - 25)); background-image: none; }
95
+
96
+ button:focus-visible { outline: none; box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--primary) 50%, transparent); }
97
+ button:focus-visible:not(:disabled):not([data-type="primary"]):not([data-type="tertiary"]) { background-image: linear-gradient(var(--hover-overlay), var(--hover-overlay)); }
98
+
99
+ button:disabled {
100
+ background: transparent;
101
+ color: color-mix(in srgb, var(--st-text-color) 40%, transparent);
102
+ cursor: not-allowed;
103
+ }
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamlit-vanilla-components
3
+ Version: 0.1.0
4
+ Summary: Vanilla HTML/CSS/JS Streamlit components with no npm dependencies.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/lukehorton1/streamlit-vanilla-components
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: streamlit>=1.57
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # Streamlit vanilla components
18
+
19
+ Lightweight [Streamlit components v2](https://docs.streamlit.io/develop/api-reference/custom-components) in plain HTML, CSS and JavaScript. No npm packages, no build step. Each component copies its native Streamlit look and follows the active theme.
20
+
21
+ Built using GitHub Copilot as an AI coding agent, with manual oversight, design and testing throughout.
22
+
23
+ ```python
24
+ from streamlit_vanilla_components import st_live_searchbox, stateful_button
25
+ ```
26
+
27
+ Run the demo with `streamlit run example.py`.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install streamlit-vanilla-components
33
+ ```
34
+
35
+ Or install straight from GitHub:
36
+
37
+ ```bash
38
+ pip install "git+https://github.com/lukehorton1/streamlit-vanilla-components.git"
39
+ ```
40
+
41
+ Requires Python 3.10+ and Streamlit 1.57+.
42
+
43
+ ## Components
44
+
45
+ - [`st_live_searchbox`](#st_live_searchbox)
46
+ - [`stateful_button`](#stateful_button)
47
+
48
+ ---
49
+
50
+ ## `st_live_searchbox`
51
+
52
+ A search box whose dropdown is filled by your Python function as the user types. Returns the picked option as a string.
53
+
54
+ ```python
55
+ def search_places(term: str) -> list[str]:
56
+ return db.query("SELECT label FROM places WHERE label ILIKE %s LIMIT 300", f"%{term}%")
57
+
58
+ choice = st_live_searchbox(search_places, placeholder="Search a place...", rank=True, max_results=50)
59
+ ```
60
+
61
+ ### Parameters
62
+
63
+ | Parameter | Description |
64
+ |---|---|
65
+ | `search_fn` *(callable)* | Receives the typed text and returns a list of options. Required. |
66
+ | `placeholder` *(str)* | Placeholder text. Default `"Type to search..."`. |
67
+ | `key` *(str)* | Widget key. Default `"live_search"`. |
68
+ | `on_change` *(callable or None)* | Runs when the typed text changes. |
69
+ | `on_submit` *(callable or None)* | Runs when an option is picked. |
70
+ | `args` *(tuple or None)* | Passed to `on_change` and `on_submit`. |
71
+ | `kwargs` *(dict or None)* | Passed to `on_change` and `on_submit`. |
72
+ | `debounce` *(int)* | Milliseconds to wait after typing stops before searching. Default `250`. |
73
+ | `default` *(str or None)* | Returned until something is picked. Default `None`. |
74
+ | `label` *(str or None)* | Label above the box. |
75
+ | `label_visibility` *(str)* | `"visible"`, `"hidden"` or `"collapsed"`. Default `"visible"`. |
76
+ | `help` *(str or None)* | Tooltip next to the label. |
77
+ | `disabled` *(bool)* | Disables the box. Default `False`. |
78
+ | `min_chars` *(int)* | Characters typed before `search_fn` runs. Default `1`. |
79
+ | `max_results` *(int or None)* | Maximum options shown, applied after ranking. Default `None`. |
80
+ | `rank` *(bool)* | Sorts options by match quality (exact, starts with, word starts with, contains). Default `False`. |
81
+ | `clear_on_submit` *(bool)* | Empties the box after a pick. Default `False`. |
82
+ | `width` *(str or int)* | `"stretch"` or a pixel width. Default `"stretch"`. |
83
+
84
+ **Returns** *(str or None)*: the picked option, or `default`.
85
+
86
+ ### See also
87
+
88
+ - Typing again after a pick replaces it with the new text.
89
+ - Ranking only sees what `search_fn` returns, so return more candidates than `max_results`.
90
+
91
+ ---
92
+
93
+ ## `stateful_button`
94
+
95
+ A `st.button` look-alike that disables itself on click, shows a spinner in place of its icon while your code runs, then a tick on success.
96
+
97
+ ```python
98
+ with stateful_button("Submit", type="primary") as clicked:
99
+ if clicked:
100
+ save_records()
101
+ st.success("Saved!")
102
+
103
+ # or, for a single function call
104
+ stateful_button("Run job", on_click=run_job, args=("Job 1",))
105
+ ```
106
+
107
+ ### Parameters
108
+
109
+ Same as `st.button`, plus `icon_position` and `wrap`.
110
+
111
+ | Parameter | Description |
112
+ |---|---|
113
+ | `label` *(str)* | Button text. Plain text, no Markdown. Required. |
114
+ | `key` *(str or None)* | Widget key. Derived from the other arguments if omitted. |
115
+ | `help` *(str or None)* | Tooltip. |
116
+ | `on_click` *(callable or None)* | Work to run. The button stays busy until it finishes. |
117
+ | `args` *(tuple or None)* | Passed to `on_click`. |
118
+ | `kwargs` *(dict or None)* | Passed to `on_click`. |
119
+ | `type` *(str)* | `"primary"`, `"secondary"` or `"tertiary"`. Default `"secondary"`. |
120
+ | `icon` *(str or None)* | Emoji or `:material/name:`. |
121
+ | `icon_position` *(str)* | `"left"` or `"right"`. Default `"left"`. |
122
+ | `disabled` *(bool)* | Disables the button. Default `False`. |
123
+ | `width` *(str or int)* | `"content"`, `"stretch"` or a pixel width. Default `"content"`. |
124
+ | `shortcut` *(str or None)* | Keyboard shortcut. |
125
+ | `wrap` *(bool or None)* | Whether the label may wrap. |
126
+
127
+ **Returns** *(StatefulButton)*: truthy on the run it was clicked. Works as a context manager, and `.release(success=True)` ends the busy state early.
128
+
129
+ ### See also
130
+
131
+ - Use either the `with` form or `on_click`. Without either, the spinner never stops.
132
+ - Errors in the `with` block or in `on_click` re-enable the button, then raise.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ streamlit_vanilla_components/__init__.py
5
+ streamlit_vanilla_components.egg-info/PKG-INFO
6
+ streamlit_vanilla_components.egg-info/SOURCES.txt
7
+ streamlit_vanilla_components.egg-info/dependency_links.txt
8
+ streamlit_vanilla_components.egg-info/requires.txt
9
+ streamlit_vanilla_components.egg-info/top_level.txt
10
+ streamlit_vanilla_components/searchbox/__init__.py
11
+ streamlit_vanilla_components/searchbox/frontend/index.html
12
+ streamlit_vanilla_components/searchbox/frontend/index.js
13
+ streamlit_vanilla_components/searchbox/frontend/style.css
14
+ streamlit_vanilla_components/stateful_button/__init__.py
15
+ streamlit_vanilla_components/stateful_button/frontend/index.html
16
+ streamlit_vanilla_components/stateful_button/frontend/index.js
17
+ streamlit_vanilla_components/stateful_button/frontend/style.css
18
+ tests/test_smoke.py
@@ -0,0 +1,4 @@
1
+ streamlit>=1.57
2
+
3
+ [dev]
4
+ pytest>=8
@@ -0,0 +1,54 @@
1
+ """Smoke tests: the package imports, ships its frontend assets, and renders in a Streamlit app."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from streamlit.testing.v1 import AppTest
7
+
8
+ import streamlit_vanilla_components as svc
9
+ from streamlit_vanilla_components.searchbox import _rank
10
+
11
+ PACKAGE_DIR = Path(svc.__file__).parent
12
+ FRONTEND_DIRS = [PACKAGE_DIR / "searchbox" / "frontend", PACKAGE_DIR / "stateful_button" / "frontend"]
13
+
14
+
15
+ def test_public_api_exports():
16
+ assert callable(svc.st_live_searchbox)
17
+ assert callable(svc.stateful_button)
18
+ assert set(svc.__all__) == {"st_live_searchbox", "stateful_button"}
19
+
20
+
21
+ @pytest.mark.parametrize("folder", FRONTEND_DIRS, ids=lambda p: p.parent.name)
22
+ @pytest.mark.parametrize("name", ["index.html", "index.js", "style.css"])
23
+ def test_frontend_assets_present(folder, name):
24
+ path = folder / name
25
+ assert path.is_file(), f"missing {path}"
26
+ assert path.read_text(encoding="utf-8").strip(), f"empty {path}"
27
+
28
+
29
+ def test_rank_puts_exact_matches_before_partial_matches():
30
+ options = ["Old Liverpool", "Liverpool, United Kingdom", "Liverpool"]
31
+ ranked = _rank(options, "liverpool")
32
+ assert ranked[-1] == "Old Liverpool"
33
+ assert set(ranked[:2]) == {"Liverpool, United Kingdom", "Liverpool"}
34
+
35
+
36
+ def test_rank_blank_term_keeps_order():
37
+ options = ["b", "a"]
38
+ assert _rank(options, " ") == options
39
+
40
+
41
+ def test_stateful_button_renders_in_app():
42
+ # AppTest uses a fresh component registry per run; the module-level
43
+ # registration only happens on import, so reload it inside the script.
44
+ script = """
45
+ import importlib
46
+ import streamlit as st
47
+
48
+ sb = importlib.import_module("streamlit_vanilla_components.stateful_button")
49
+ importlib.reload(sb)
50
+ if sb.stateful_button("Go", key="go"):
51
+ st.write("clicked")
52
+ """
53
+ at = AppTest.from_string(script).run()
54
+ assert not at.exception