Skip to content

Commit 45eff8f

Browse files
[feat] Add v2 template-reactless cookiecutter support (#114)
### TL;DR Added support for Pure TypeScript components without React in the v2 component template. ### What changed? - Added a new template variant for v2 components that uses Pure TypeScript instead of React - Created a new replay file for the reactless template - Updated the cookiecutter template to conditionally include React or Pure TypeScript based on framework choice - Updated installation instructions to use `uv pip install` instead of `pip install` - Added detailed build instructions for packaging components as wheels
1 parent aef2ed4 commit 45eff8f

File tree

23 files changed

+725
-22
lines changed

23 files changed

+725
-22
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"cookiecutter": {
3+
"author_name": "John Smith",
4+
"author_email": "[email protected]",
5+
"project_name": "Streamlit Component X",
6+
"package_name": "streamlit-custom-component",
7+
"import_name": "my_component",
8+
"description": "Streamlit component that allows you to do X",
9+
"open_source_license": "MIT license",
10+
"framework": "Pure Typescript"
11+
}
12+
}

cookiecutter/v2/{{ cookiecutter.package_name }}/README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
## Installation instructions
66

77
```sh
8-
pip install {{ cookiecutter.package_name }}
8+
uv pip install {{ cookiecutter.package_name }}
99
```
1010

1111
## Usage instructions
@@ -19,3 +19,31 @@ value = {{ cookiecutter.import_name }}()
1919

2020
st.write(value)
2121
```
22+
23+
## Build a wheel
24+
25+
To package this component for distribution:
26+
27+
1. Build the frontend assets (from `{{ cookiecutter.import_name }}/frontend`):
28+
29+
```sh
30+
npm i
31+
npm run build
32+
```
33+
34+
2. Build the Python wheel using UV (from the project root with `pyproject.toml`):
35+
```sh
36+
uv run --with build python -m build --wheel
37+
```
38+
39+
This will create a `dist/` directory containing your wheel. The wheel includes the compiled frontend from `{{ cookiecutter.import_name }}/frontend/build`.
40+
41+
### Requirements
42+
43+
- Python >= 3.10
44+
- Node.js >= 24 (LTS)
45+
46+
### Expected output
47+
48+
- `dist/{{ cookiecutter.package_name|replace('-', '_') }}-0.0.1-py3-none-any.whl`
49+
- If you run `uv run --with build python -m build` (without `--wheel`), you’ll also get an sdist: `dist/{{ cookiecutter.package_name }}-0.0.1.tar.gz`
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from e2e_utils import StreamlitRunner
5+
from playwright.sync_api import Page, expect
6+
7+
ROOT_DIRECTORY = Path(__file__).parent.parent.absolute()
8+
BASIC_EXAMPLE_FILE = ROOT_DIRECTORY / "my_component" / "example.py"
9+
10+
11+
@pytest.fixture(autouse=True, scope="module")
12+
def streamlit_app():
13+
with StreamlitRunner(BASIC_EXAMPLE_FILE) as runner:
14+
yield runner
15+
16+
17+
@pytest.fixture(autouse=True, scope="function")
18+
def go_to_app(page: Page, streamlit_app: StreamlitRunner):
19+
page.goto(streamlit_app.server_url)
20+
# Wait for app to load
21+
page.get_by_role("img", name="Running...").is_hidden()
22+
23+
24+
def test_should_render_template(page: Page):
25+
frame_0 = page.frame_locator('iframe[title="my_component\\.my_component"]').nth(0)
26+
frame_1 = page.frame_locator('iframe[title="my_component\\.my_component"]').nth(1)
27+
28+
st_markdown_0 = page.get_by_role("paragraph").nth(0)
29+
st_markdown_1 = page.get_by_role("paragraph").nth(1)
30+
31+
expect(st_markdown_0).to_contain_text("You've clicked 0 times!")
32+
33+
frame_0.get_by_role("button", name="Click me!").click()
34+
35+
expect(st_markdown_0).to_contain_text("You've clicked 1 times!")
36+
expect(st_markdown_1).to_contain_text("You've clicked 0 times!")
37+
38+
frame_1.get_by_role("button", name="Click me!").click()
39+
frame_1.get_by_role("button", name="Click me!").click()
40+
41+
expect(st_markdown_0).to_contain_text("You've clicked 1 times!")
42+
expect(st_markdown_1).to_contain_text("You've clicked 2 times!")
43+
44+
page.get_by_label("Enter a name").click()
45+
page.get_by_label("Enter a name").fill("World")
46+
page.get_by_label("Enter a name").press("Enter")
47+
48+
expect(frame_1.get_by_text("Hello, World!")).to_be_visible()
49+
50+
frame_1.get_by_role("button", name="Click me!").click()
51+
52+
expect(st_markdown_0).to_contain_text("You've clicked 1 times!")
53+
expect(st_markdown_1).to_contain_text("You've clicked 3 times!")
54+
55+
56+
def test_should_change_iframe_height(page: Page):
57+
frame = page.frame_locator('iframe[title="my_component\\.my_component"]').nth(1)
58+
59+
expect(frame.get_by_text("Hello, Streamlit!")).to_be_visible()
60+
61+
locator = page.locator('iframe[title="my_component\\.my_component"]').nth(1)
62+
63+
page.wait_for_timeout(1000)
64+
init_frame_height = locator.bounding_box()["height"]
65+
assert init_frame_height != 0
66+
67+
page.get_by_label("Enter a name").click()
68+
69+
page.get_by_label("Enter a name").fill(35 * "Streamlit ")
70+
page.get_by_label("Enter a name").press("Enter")
71+
72+
expect(frame.get_by_text("Streamlit Streamlit Streamlit")).to_be_visible()
73+
74+
page.wait_for_timeout(1000)
75+
frame_height = locator.bounding_box()["height"]
76+
assert frame_height > init_frame_height
77+
78+
page.set_viewport_size({"width": 150, "height": 150})
79+
80+
expect(frame.get_by_text("Streamlit Streamlit Streamlit")).not_to_be_in_viewport()
81+
82+
page.wait_for_timeout(1000)
83+
frame_height_after_viewport_change = locator.bounding_box()["height"]
84+
assert frame_height_after_viewport_change > frame_height

cookiecutter/v2/{{ cookiecutter.package_name }}/pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ version = "0.0.1"
88
description = "{{ cookiecutter.description }}"
99
readme = "README.md"
1010
requires-python = ">=3.10"
11-
authors = [
12-
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" },
13-
]
11+
authors = [{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }]
1412
# TODO: Restore this
1513
# dependencies = ["streamlit >= 1.51"]
1614

cookiecutter/v2/{{ cookiecutter.package_name }}/{{ cookiecutter.import_name }}/__init__.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
from typing import TYPE_CHECKING
2-
31
import streamlit as st
42

5-
if TYPE_CHECKING:
6-
from streamlit.components.v2.bidi_component import BidiComponentResult
7-
83
out = st.components.v2.component(
94
"{{ cookiecutter.package_name }}.{{ cookiecutter.import_name }}",
105
js="index-*.js",
6+
{%- if cookiecutter.framework == "React + Typescript" %}
117
html='<div class="react-root"></div>',
8+
{%- else %}
9+
html="""
10+
<div class="component-root">
11+
<span>
12+
<h1></h1>
13+
<button>Click me!</button>
14+
</span>
15+
</div>
16+
""",
17+
{%- endif %}
1218
)
1319

1420

cookiecutter/v2/{{ cookiecutter.package_name }}/{{ cookiecutter.import_name }}/frontend-reactless/src/index.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,48 @@ export type ComponentData = {
88
name: string;
99
};
1010

11-
const Root: Component<ComponentState, ComponentData> = (args) => {
12-
// TODO:
13-
};
11+
// Handle the possibility of multiple instances of the component to keep track
12+
// of any long-running state for each component instance.
13+
const instances: WeakMap<ComponentArgs["parentElement"], { numClicks: number }> = new WeakMap();
14+
15+
const MyComponent: Component<ComponentState, ComponentData> = (args) => {
16+
const { parentElement, data, setStateValue } = args;
17+
18+
const rootElement = parentElement.querySelector(".component-root");
19+
if (!rootElement) {
20+
throw new Error("Unexpected: root element not found");
21+
}
22+
23+
// Set dynamic content
24+
const heading = rootElement.querySelector("h1");
25+
if (heading) {
26+
heading.textContent = `Hello, ${data.name}!`;
27+
}
1428

15-
export default Root;
29+
// Wire up interactions on existing DOM from Python-provided HTML
30+
const button = rootElement.querySelector<HTMLButtonElement>("button");
31+
if (!button) {
32+
throw new Error("Unexpected: button element not found");
33+
}
1634

35+
const handleClick = () => {
36+
const numClicks = (instances.get(parentElement)?.numClicks || 0) + 1;
37+
instances.set(parentElement, { numClicks });
38+
setStateValue("num_clicks", numClicks);
39+
};
40+
41+
// Set up event listener for the button when the component is first
42+
// initialized
43+
if (!instances.has(parentElement)) {
44+
button.addEventListener("click", handleClick);
45+
instances.set(parentElement, { numClicks: 0 });
46+
}
47+
48+
// Cleanup
49+
return () => {
50+
button.removeEventListener("click", handleClick);
51+
instances.delete(parentElement);
52+
};
53+
};
1754

55+
export default MyComponent;

dev.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,24 @@ class CookiecutterVariant(typing.NamedTuple):
356356
repo_directory=THIS_DIRECTORY / "templates" / "v1" / "template-reactless",
357357
cookiecutter_dir=THIS_DIRECTORY / "cookiecutter" / "v1",
358358
),
359+
CookiecutterVariant(
360+
replay_file=THIS_DIRECTORY
361+
/ ".github"
362+
/ "replay-files"
363+
/ "v2"
364+
/ "template.json",
365+
repo_directory=THIS_DIRECTORY / "templates" / "v2" / "template",
366+
cookiecutter_dir=THIS_DIRECTORY / "cookiecutter" / "v2",
367+
),
368+
CookiecutterVariant(
369+
replay_file=THIS_DIRECTORY
370+
/ ".github"
371+
/ "replay-files"
372+
/ "v2"
373+
/ "template-reactless.json",
374+
repo_directory=THIS_DIRECTORY / "templates" / "v2" / "template-reactless",
375+
cookiecutter_dir=THIS_DIRECTORY / "cookiecutter" / "v2",
376+
),
359377
]
360378

361379

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
YOUR LICENSE HERE
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
recursive-include my_component/frontend/build *
2+
include pyproject.toml
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# streamlit-custom-component
2+
3+
Streamlit component that allows you to do X
4+
5+
## Installation instructions
6+
7+
```sh
8+
uv pip install streamlit-custom-component
9+
```
10+
11+
## Usage instructions
12+
13+
```python
14+
import streamlit as st
15+
16+
from my_component import my_component
17+
18+
value = my_component()
19+
20+
st.write(value)
21+
```
22+
23+
## Build a wheel
24+
25+
To package this component for distribution:
26+
27+
1. Build the frontend assets (from `my_component/frontend`):
28+
29+
```sh
30+
npm i
31+
npm run build
32+
```
33+
34+
2. Build the Python wheel using UV (from the project root with `pyproject.toml`):
35+
```sh
36+
uv run --with build python -m build --wheel
37+
```
38+
39+
This will create a `dist/` directory containing your wheel. The wheel includes the compiled frontend from `my_component/frontend/build`.
40+
41+
### Requirements
42+
43+
- Python >= 3.10
44+
- Node.js >= 24 (LTS)
45+
46+
### Expected output
47+
48+
- `dist/streamlit_custom_component-0.0.1-py3-none-any.whl`
49+
- If you run `uv run --with build python -m build` (without `--wheel`), you’ll also get an sdist: `dist/streamlit-custom-component-0.0.1.tar.gz`

0 commit comments

Comments
 (0)