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.
- streamlit_vanilla_components-0.1.0/LICENSE +21 -0
- streamlit_vanilla_components-0.1.0/PKG-INFO +132 -0
- streamlit_vanilla_components-0.1.0/README.md +116 -0
- streamlit_vanilla_components-0.1.0/pyproject.toml +30 -0
- streamlit_vanilla_components-0.1.0/setup.cfg +4 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/__init__.py +6 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/__init__.py +130 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/index.html +10 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/index.js +155 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/searchbox/frontend/style.css +130 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/__init__.py +150 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/index.html +4 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/index.js +93 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/style.css +103 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/PKG-INFO +132 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/SOURCES.txt +18 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/dependency_links.txt +1 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/requires.txt +4 -0
- streamlit_vanilla_components-0.1.0/streamlit_vanilla_components.egg-info/top_level.txt +1 -0
- 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,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
|
streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/index.js
ADDED
|
@@ -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
|
+
}
|
streamlit_vanilla_components-0.1.0/streamlit_vanilla_components/stateful_button/frontend/style.css
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
streamlit_vanilla_components
|
|
@@ -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
|