diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..40448e6 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv \ No newline at end of file diff --git a/.python-version b/.python-version index e4fba21..2c07333 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.11 diff --git a/docs_for_sl/button.md b/docs_for_sl/button.md new file mode 100644 index 0000000..218c5e6 --- /dev/null +++ b/docs_for_sl/button.md @@ -0,0 +1,570 @@ +--- +meta: + title: Button + description: Buttons represent actions that are available to the user. +layout: component +--- + +```html:preview +Button +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => Button; +``` + +## Examples + +### Variants + +Use the `variant` attribute to set the button's variant. + +```html:preview +Default +Primary +Success +Neutral +Warning +Danger +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + Default + Primary + Success + Neutral + Warning + Danger + +); +``` + +### Sizes + +Use the `size` attribute to change a button's size. + +```html:preview +Small +Medium +Large +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + Small + Medium + Large + +); +``` + +### Outline Buttons + +Use the `outline` attribute to draw outlined buttons with transparent backgrounds. + +```html:preview +Default +Primary +Success +Neutral +Warning +Danger +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Default + + + Primary + + + Success + + + Neutral + + + Warning + + + Danger + + +); +``` + +### Pill Buttons + +Use the `pill` attribute to give buttons rounded edges. + +```html:preview +Small +Medium +Large +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Small + + + Medium + + + Large + + +); +``` + +### Circle Buttons + +Use the `circle` attribute to create circular icon buttons. When this attribute is set, the button expects a single `` in the default slot. + +```html:preview + + + + + + + + + + + +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; +import SlIcon from '@shoelace-style/shoelace/dist/react/icon'; + +const App = () => ( + <> + + + + + + + + + + +); +``` + +### Text Buttons + +Use the `text` variant to create text buttons that share the same size as regular buttons but don't have backgrounds or borders. + +```html:preview +Text +Text +Text +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Text + + + Text + + + Text + + +); +``` + +### Link Buttons + +It's often helpful to have a button that works like a link. This is possible by setting the `href` attribute, which will make the component render an `` under the hood. This gives you all the default link behavior the browser provides (e.g. [[CMD/CTRL/SHIFT]] + [[CLICK]]) and exposes the `target` and `download` attributes. + +```html:preview +Link +New Window +Download +Disabled +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + Link + + New Window + + + Download + + + Disabled + + +); +``` + +:::tip +When a `target` is set, the link will receive `rel="noreferrer noopener"` for [security reasons](https://mathiasbynens.github.io/rel-noopener/). +::: + +### Setting a Custom Width + +As expected, buttons can be given a custom width by passing inline styles to the component (or using a class). This is useful for making buttons span the full width of their container on smaller screens. + +```html:preview +Small +Medium +Large +``` + +{% raw %} + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Small + + + Medium + + + Large + + +); +``` + +{% endraw %} + +### Prefix and Suffix Icons + +Use the `prefix` and `suffix` slots to add icons. + +```html:preview + + + Settings + + + + + Refresh + + + + + + Open + + +

+ + + + Settings + + + + + Refresh + + + + + + Open + + +

+ + + + Settings + + + + + Refresh + + + + + + Open + +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; +import SlIcon from '@shoelace-style/shoelace/dist/react/icon'; + +const App = () => ( + <> + + + Settings + + + + + Refresh + + + + + + Open + + +
+
+ + + + Settings + + + + + Refresh + + + + + + Open + + +
+
+ + + + Settings + + + + + Refresh + + + + + + Open + + +); +``` + +### Caret + +Use the `caret` attribute to add a dropdown indicator when a button will trigger a dropdown, menu, or popover. + +```html:preview +Small +Medium +Large +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Small + + + Medium + + + Large + + +); +``` + +### Loading + +Use the `loading` attribute to make a button busy. The width will remain the same as before, preventing adjacent elements from moving around. + +```html:preview +Default +Primary +Success +Neutral +Warning +Danger +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Default + + + Primary + + + Success + + + Neutral + + + Warning + + + Danger + + +); +``` + +### Disabled + +Use the `disabled` attribute to disable a button. + +```html:preview +Default +Primary +Success +Neutral +Warning +Danger +``` + +```jsx:react +import SlButton from '@shoelace-style/shoelace/dist/react/button'; + +const App = () => ( + <> + + Default + + + + Primary + + + + Success + + + + Neutral + + + + Warning + + + + Danger + + +); +``` + +### Styling Buttons + +This example demonstrates how to style buttons using a custom class. This is the recommended approach if you need to add additional variations. To customize an existing variation, modify the selector to target the button's `variant` attribute instead of a class (e.g. `sl-button[variant="primary"]`). + +```html:preview +Pink Button + + +``` +# Shoelace Button + +The Shoelace button component is a customizable button that can be used in various contexts within your application. + +## Usage + +```python +hs.sl_button(label: str, variant: str = "default", size: str = "medium", key: str = None, **kwargs) +``` + +## Parameters + +- `label` (str): The text to display on the button. +- `variant` (str, optional): The button's visual variant. Options: "default", "primary", "success", "neutral", "warning", "danger". Default is "default". +- `size` (str, optional): The button's size. Options: "small", "medium", "large". Default is "medium". +- `key` (str, optional): A unique identifier for the button. Default is None. +- `**kwargs`: Additional attributes to pass to the button element. + +## Example + +```python +hs.sl_button("Click me!", variant="primary", size="large", key="my_button") +``` + +This will create a large primary button with the text "Click me!". diff --git a/hstream/components/components.py b/hstream/components/components.py index 85b7047..3b09344 100644 --- a/hstream/components/components.py +++ b/hstream/components/components.py @@ -118,7 +118,7 @@ def text_input( "input", ("hx-ext", "debug"), ("name", "new_value"), - ("hx-post", f"/set_component_value?component_id={key}"), + ("hx-get", f"/set_component_value?component_id={key}"), ("hx-swap", "none"), ("type", "text"), ): @@ -152,7 +152,7 @@ def number_input( with self.tag( "input", ("name", "new_value"), - ("hx-post", f"/set_component_value?component_id={key}"), + ("hx-get", f"/set_component_value?component_id={key}"), ("hx-trigger", "focusout"), ("type", "number"), ("value", str(kwargs["value"])), @@ -176,7 +176,7 @@ def select_box( # HStream developer note: The default value logic is handled by the `@component_wrapper` with self.doc.select( ("name", "new_value"), - ("hx-post", f"/set_component_value?component_id={key}"), + ("hx-get", f"/set_component_value?component_id={key}"), ): for value in label: with self.tag( @@ -250,7 +250,7 @@ def multiselect( with self.tag( "details", ("class", "dropdown"), - ("name", "new_value"), + ("name", "new_multi_value"), ("multiple", ""), ): with self.tag("summary", ("style", "overflow:hidden;")): @@ -358,7 +358,7 @@ def nav( "li", ("hx-trigger", "click"), ( - "hx-post", + "hx-get", f"/set_component_value/?component_id={key}&new_value={item}", ), ("hx-swap", "none"), @@ -446,7 +446,7 @@ def bool_checker_format_fn(user_checkbox_value): # so we attach the state of the checkbox here # https://htmx.org/attributes/hx-vals/, https://github.com/bigskysoftware/htmx/issues/894 ("hx-vals", "js:{new_value: event.srcElement.checked}"), - ("hx-post", f"/set_component_value?component_id={key}"), + ("hx-get", f"/set_component_value?component_id={key}"), # ( # "hx-get", # f"/set_component_value/?component_id={key}&new_value={not value}", @@ -481,7 +481,7 @@ def button( ("id", key), ("type", "submit") if full_width else ("type", "button"), ("hx-trigger", "click"), - ("hx-post", f"/set_component_value?component_id={key}&new_value=true"), + ("hx-get", f"/set_component_value?component_id={key}&new_value=true"), ("hx-swap", "none"), ): self.text(label) diff --git a/hstream/components/shoelace/__init__.py b/hstream/components/shoelace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hstream/components/shoelace/card.py b/hstream/components/shoelace/card.py new file mode 100644 index 0000000..3666191 --- /dev/null +++ b/hstream/components/shoelace/card.py @@ -0,0 +1,41 @@ +from hstream.components.components import ComponentsGeneric, component_wrapper + +@component_wrapper +def sl_card( + self, + content: str, + key: str = None, + **kwargs +) -> None: + """ + Render a Shoelace card component. + + Args: + content (str): The main content of the card. + key (str, optional): A unique identifier for the card. Default is None. + **kwargs: Additional attributes to pass to the card element. + - header (str, optional): The content to display in the header. Default is None. + - footer (str, optional): The content to display in the footer. Default is None. + - image_url (str, optional): The URL of an image to display at the top of the card. Default is None. + """ + header: str = kwargs.get("header", None) + footer: str = kwargs.get("footer", None) + image_url: str = kwargs.get("image_url", None) + + with self.tag("sl-card", ("id", key) if key else None): + if header: + with self.tag("div", ("slot", "header")): + self.doc.text(header) + + if image_url: + with self.tag("img", ("slot", "image"), ("src", image_url), ("alt", "Card image")): + pass + + self.doc.text(content) + + if footer: + with self.tag("div", ("slot", "footer")): + self.doc.text(footer) + + _hs_session[key] = None # Store session data for the card if needed + return lambda s: s # Card doesn't return any specific action value diff --git a/hstream/components/shoelace/format_date.py b/hstream/components/shoelace/format_date.py new file mode 100644 index 0000000..d391ac4 --- /dev/null +++ b/hstream/components/shoelace/format_date.py @@ -0,0 +1,32 @@ +from hstream.components.components import component_wrapper +from typing import Optional + + +@component_wrapper +def sl_format_date(self, date: str, key: Optional[str] = None, **kwargs) -> None: + """ + Render a Shoelace format-date component. + + Args: + date (str): The date to format. + key (str, optional): A unique identifier for the component. + **kwargs: Additional attributes to pass to the format-date element. + Possible attributes include: + - locale: The locale to use when formatting the date. + - weekday: How to display the weekday. + - era: How to display the era. + - year: How to display the year. + - month: How to display the month. + - day: How to display the day. + - hour: How to display the hour. + - minute: How to display the minute. + - second: How to display the second. + - time_zone_name: How to display the time zone name. + - time_zone: The time zone to express the time in. + - hour_format: The hour format to use. + """ + attributes = [("date", date)] + attributes.extend([(k.replace("_", "-"), v) for k, v in kwargs.items() if v is not None]) + + with self.tag("sl-format-date", *attributes): + pass diff --git a/hstream/components/shoelace_components.py b/hstream/components/shoelace_components.py new file mode 100644 index 0000000..5fb0f05 --- /dev/null +++ b/hstream/components/shoelace_components.py @@ -0,0 +1,97 @@ +from hstream.components.components import ComponentsGeneric, component_wrapper +from typing import List, Optional, Any +from hstream.components.shoelace.card import sl_card +from hstream.components.shoelace.format_date import sl_format_date + +class ShoelaceComponents(ComponentsGeneric): + + sl_card = sl_card + sl_format_date = sl_format_date + + @component_wrapper + def sl_button( + self, label: str, default_value: List[str] = None, key: str = None, **kwargs + ) -> None: + """ + Render a Shoelace button component. + + Args: + label (str): The text to display on the button. + variant (str, optional): The button's visual variant. Default is "default". + size (str, optional): The button's size. Default is "medium". + key (str, optional): A unique identifier for the button. Default is None. + **kwargs: Additional attributes to pass to the button element. + """ + variant: str = kwargs.get("variant", "default") + size: str = kwargs.get("size", "medium") + with self.tag( + "sl-button", + ("variant", variant), + ("size", size), + ("hx-get", f"/set_component_value?component_id={key}&new_value=true"), + ("hx-trigger", "click"), + ): + self.doc.text(label) + _hs_session[ # noqa: F821 + key + ] = False # set the button back to false after it has been clicked + return lambda s: True if s in ["True", "true", True] else False + + @component_wrapper + def sl_input( + self, label: str, default_value: Any = None, key: Optional[str] = None, **kwargs + ) -> None: + """ + Render a Shoelace input component. + + Args: + label (str): The label for the input field. + default_value (Any, optional): The default value for the input. Default is None. + key (str, optional): A unique identifier for the input. Default is None. + **kwargs: Additional attributes to pass to the input element. + Possible attributes include: + - type: The type of input (e.g., "text", "number", "email", etc.) + - placeholder: Placeholder text for the input + - size: The size of the input field + - disabled: Whether the input is disabled + - readonly: Whether the input is read-only + """ + input_type = kwargs.get("type", "text") + placeholder = kwargs.get("placeholder", "") + size = kwargs.get("size", "medium") + disabled = "disabled" if kwargs.get("disabled", False) else "" + readonly = "readonly" if kwargs.get("readonly", False) else "false" + with self.tag( + "sl-input", + ("name", "new_value"), + ("type", input_type), + ("label", label), + ("placeholder", placeholder), + ("size", size), + (disabled, ""), + (readonly, ""), + ("value", str(kwargs['value']) if kwargs['value'] is not None else ""), + ("hx-get", f"/set_component_value?component_id={key}"), + ("hx-trigger", "sl-change"), + ): + pass + return lambda x: x[0] if type(x) == list else x + + @component_wrapper + def sl_multiselect( + self, label: List[str], default_value: List[str] = None, key: str = None, **kwargs + ) -> None: + options = [ f'{option}' for option in label] + assert default_value is None or all([d in label for d in default_value]), f"Default value must be in options: {default_value} not in {label} - default_value must also be itterable" + value = ' '.join(list(kwargs["value"] )) if default_value else '' + + with self.tag( + "sl-select", + ("value", value), + ("name", "new_value"), + ("multiple", "" ), + ("clearable", ""), + ("hx-post", f"/set_component_value?component_id={key}"), + ("hx-trigger", "sl-hide"), + ): + self.doc.asis(''.join(options)) diff --git a/hstream/django_server/hs/views.py b/hstream/django_server/hs/views.py index f094a1e..e3c967d 100644 --- a/hstream/django_server/hs/views.py +++ b/hstream/django_server/hs/views.py @@ -178,11 +178,14 @@ def run_user_code_and_return_hs_instance(file: Path, request: HttpRequest) -> hs def set_component_value( request: HttpRequest, ): - # import ipdb; ipdb.set_trace() component_id = request.GET.get("component_id") - new_value = request.POST.get("new_value") + # POST for multi values + # we neeed to convert the request.POST to a dict to handle multiple values coming back + new_value = dict(request.POST).get("new_multi_value") request_server_stop_running_user_script(request, wait=True) + + # GET for single values if new_value is None: new_value = request.GET.get("new_value") set_session_var(request, component_id, new_value) diff --git a/hstream/hs.py b/hstream/hs.py index a533135..8fa9d3d 100644 --- a/hstream/hs.py +++ b/hstream/hs.py @@ -3,7 +3,7 @@ # from hstream.components.text_input import text_input from hstream.components.components import Components from hstream.components.styling_components import StyledComponents - +from hstream.components.shoelace_components import ShoelaceComponents class HSDoc(Doc): class Tag(Doc.Tag): @@ -13,7 +13,7 @@ def __exit__(self, tpe, value, traceback): return res -class hs(Components, StyledComponents): +class hs(Components, StyledComponents, ShoelaceComponents): def __init__(self) -> (HSDoc, HSDoc.tag, HSDoc.text): self.doc, self.tag, self.text = HSDoc().tagtext() diff --git a/hstream/templates/format_html.html b/hstream/templates/format_html.html index c4a8487..a6dce5a 100644 --- a/hstream/templates/format_html.html +++ b/hstream/templates/format_html.html @@ -2,14 +2,16 @@ + + + + - - - - + + diff --git a/test.py b/test.py new file mode 100644 index 0000000..b0bff90 --- /dev/null +++ b/test.py @@ -0,0 +1,10 @@ +from hstream import hs + +hs.markdown('test') + +sl_ms = hs.sl_multiselect(['test1', 'test2', 'test3'], default_value=['test2']) +sl_ms = hs.sl_button('Shoelace button') + +hs.markdown(sl_ms) + +hs.button('Normal button') \ No newline at end of file diff --git a/tests/1_basic_test.py b/tests/1_basic_test.py index 42f6201..36df385 100644 --- a/tests/1_basic_test.py +++ b/tests/1_basic_test.py @@ -3,12 +3,13 @@ from .conftest import write_py_script import platform +HEADLESS = False def do_content_in_page(content): with sync_playwright() as playwright: if not platform.system() == "Darwin": sleep(10) # wait for github server to start - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") sleep(2) @@ -18,7 +19,7 @@ def do_content_in_page(content): def test_blank_page(): - sleep(0) + sleep(2) write_py_script(contents="from hstream import hs \nhs.markdown('Hello world')") do_content_in_page(content="Hello world") @@ -34,7 +35,7 @@ def test_button(): hs.markdown('pressed') """ ) - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") assert "pressed" not in page.inner_text("body") @@ -60,7 +61,7 @@ def test_text(): hs.markdown(text) """ ) - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") text_input = page.locator("input") @@ -80,7 +81,7 @@ def test_number_input(): hs.markdown(str(number)) """ ) - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") number_input = page.locator("input[type=number]") @@ -101,7 +102,7 @@ def test_select_box(): hs.markdown(option) """ ) - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") select_box = page.locator("select") @@ -122,7 +123,7 @@ def test_checkbox(): hs.text_input('text') """ ) - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch(headless=HEADLESS) page = browser.new_page() page.goto("http://127.0.0.1:9000/") checkbox = page.locator("input[type=checkbox]") @@ -132,3 +133,87 @@ def test_checkbox(): sleep(2) assert "True" in page.inner_text("body") browser.close() + +def test_sl_button(): + sleep(0) + with sync_playwright() as playwright: + write_py_script( + contents=""" +from hstream import hs +if hs.sl_button('Click me', variant='primary'): + hs.markdown('Button clicked!') +""" + ) + browser = playwright.chromium.launch(headless=HEADLESS) + page = browser.new_page() + page.goto("http://127.0.0.1:9000/") + button = page.locator('sl-button:has-text("Click me")') + assert button.get_attribute('variant') == 'primary' + assert "Button clicked!" not in page.inner_text("body") + button.click() + sleep(2) + assert "Button clicked!" in page.inner_text("body") + browser.close() + +def test_sl_input(): + sleep(1) + with sync_playwright() as playwright: + write_py_script( + contents=""" +from hstream import hs +text = hs.sl_input('Enter text', placeholder='Type here') +hs.markdown(text) +""" + ) + browser = playwright.chromium.launch(headless=HEADLESS) + page = browser.new_page() + page.goto("http://127.0.0.1:9000/") + input_field = page.locator("sl-input") + assert input_field.get_attribute('placeholder') == 'Type here' + input_field.focus() + input_field.click() + input_field.type("Hello, Shoelace!") + input_field.press("Enter") + sleep(2) + assert "Hello, Shoelace!" in page.inner_text("body") + browser.close() + +def test_sl_format_date(): + sleep(1) + with sync_playwright() as playwright: + write_py_script( + contents=""" +from hstream import hs +hs.sl_format_date('1988-05-15', month='long', day='numeric', year='numeric') +""" + ) + browser = playwright.chromium.launch(headless=HEADLESS) + page = browser.new_page() + page.goto("http://127.0.0.1:9000/") + # formatted_date = page.locator("sl-format-date") + sleep(2) + assert "1988" in page.content() + browser.close() + +def test_sl_multiselect(): + sleep(0) + with sync_playwright() as playwright: + write_py_script( + contents=""" +from hstream import hs +options = ['Apple', 'Banana', 'Cherry'] +selected = hs.sl_multiselect(options, default_value=['Apple']) +hs.markdown(str(selected)) +""" + ) + browser = playwright.chromium.launch(headless=HEADLESS) + page = browser.new_page() + page.goto("http://127.0.0.1:9000/") + multiselect = page.locator("sl-select") + assert "Apple" in multiselect.inner_text() + multiselect.click() + page.locator("sl-option", has_text="Cherry").click() + page.keyboard.press("Escape") + sleep(2) + assert "['Apple', 'Cherry']" in page.inner_text("body") + browser.close()