diff --git a/docs/examples/managing_state/all_possible_states.py b/docs/examples/managing_state/all_possible_states.py new file mode 100644 index 000000000..a71f9b54a --- /dev/null +++ b/docs/examples/managing_state/all_possible_states.py @@ -0,0 +1,8 @@ +from reactpy import hooks + +# start +is_empty, set_is_empty = hooks.use_state(True) +is_typing, set_is_typing = hooks.use_state(False) +is_submitting, set_is_submitting = hooks.use_state(False) +is_success, set_is_success = hooks.use_state(False) +is_error, set_is_error = hooks.use_state(False) diff --git a/docs/examples/managing_state/alt_stateful_picture_component.py b/docs/examples/managing_state/alt_stateful_picture_component.py new file mode 100644 index 000000000..9cdfb18c2 --- /dev/null +++ b/docs/examples/managing_state/alt_stateful_picture_component.py @@ -0,0 +1,37 @@ +from reactpy import component, event, hooks, html + + +# start +@component +def picture(): + is_active, set_is_active = hooks.use_state(False) + + if is_active: + return html.div( + { + "className": "background", + "onClick": lambda event: set_is_active(False), + }, + html.img( + { + "onClick": event(stop_propagation=True), + "className": "picture picture--active", + "alt": "Rainbow houses in Kampung Pelangi, Indonesia", + "src": "https://i.imgur.com/5qwVYb1.jpeg", + } + ), + ) + else: + return html.div( + {"className": "background background--active"}, + html.img( + { + "onClick": event( + lambda event: set_is_active(True), stop_propagation=True + ), + "className": "picture", + "alt": "Rainbow houses in Kampung Pelangi, Indonesia", + "src": "https://i.imgur.com/5qwVYb1.jpeg", + } + ), + ) diff --git a/docs/examples/managing_state/basic_form_component.py b/docs/examples/managing_state/basic_form_component.py new file mode 100644 index 000000000..ecf5c88f3 --- /dev/null +++ b/docs/examples/managing_state/basic_form_component.py @@ -0,0 +1,16 @@ +from reactpy import component, html + + +# start +@component +def form(status="empty"): + if status == "success": + return html.h1("That's right!") + + return html.fragment( + html.h2("City quiz"), + html.p( + "In which city is there a billboard that turns air into drinkable water?" + ), + html.form(html.textarea(), html.br(), html.button("Submit")), + ) diff --git a/docs/examples/managing_state/conditional_form_component.css b/docs/examples/managing_state/conditional_form_component.css new file mode 100644 index 000000000..2cc8b7afe --- /dev/null +++ b/docs/examples/managing_state/conditional_form_component.css @@ -0,0 +1,3 @@ +.Error { + color: red; +} diff --git a/docs/examples/managing_state/conditional_form_component.py b/docs/examples/managing_state/conditional_form_component.py new file mode 100644 index 000000000..65307a829 --- /dev/null +++ b/docs/examples/managing_state/conditional_form_component.py @@ -0,0 +1,35 @@ +from reactpy import component, html + + +# start +@component +def error(status): + if status == "error": + return html.p( + {"className": "error"}, "Good guess but a wrong answer. Try again!" + ) + + return "" + + +@component +def form(status="empty"): + # Try "submitting", "error", "success": + if status == "success": + return html.h1("That's right!") + + return html.fragment( + html.h2("City quiz"), + html.p( + "In which city is there a billboard that turns air into drinkable water?" + ), + html.form( + html.textarea({"disabled": "True" if status == "submitting" else "False"}), + html.br(), + html.button( + {"disabled": (True if status in ["empty", "submitting"] else "False")}, + "Submit", + ), + error(status), + ), + ) diff --git a/docs/examples/managing_state/multiple_form_components.css b/docs/examples/managing_state/multiple_form_components.css new file mode 100644 index 000000000..b24e106e8 --- /dev/null +++ b/docs/examples/managing_state/multiple_form_components.css @@ -0,0 +1,13 @@ +section { + border-bottom: 1px solid #aaa; + padding: 20px; +} +h4 { + color: #222; +} +body { + margin: 0; +} +.Error { + color: red; +} diff --git a/docs/examples/managing_state/multiple_form_components.py b/docs/examples/managing_state/multiple_form_components.py new file mode 100644 index 000000000..48d6c3ca2 --- /dev/null +++ b/docs/examples/managing_state/multiple_form_components.py @@ -0,0 +1,15 @@ +# start +from conditional_form_component import form + +from reactpy import component, html + + +@component +def item(status): + return html.section(html.h4("Form", status, ":"), form(status)) + + +@component +def app(): + statuses = ["empty", "typing", "submitting", "success", "error"] + return html.fragment([item(status) for status in statuses]) diff --git a/docs/examples/managing_state/necessary_states.py b/docs/examples/managing_state/necessary_states.py new file mode 100644 index 000000000..ee70c5686 --- /dev/null +++ b/docs/examples/managing_state/necessary_states.py @@ -0,0 +1,5 @@ +from reactpy import hooks + +# start +answer, set_answer = hooks.use_state("") +error, set_error = hooks.use_state(None) diff --git a/docs/examples/managing_state/picture_component.css b/docs/examples/managing_state/picture_component.css new file mode 100644 index 000000000..85827067c --- /dev/null +++ b/docs/examples/managing_state/picture_component.css @@ -0,0 +1,28 @@ +body { + margin: 0; + padding: 0; + height: 250px; +} + +.background { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: #eee; +} + +.background--active { + background: #a6b5ff; +} + +.picture { + width: 200px; + height: 200px; + border-radius: 10px; +} + +.picture--active { + border: 5px solid #a6b5ff; +} diff --git a/docs/examples/managing_state/picture_component.py b/docs/examples/managing_state/picture_component.py new file mode 100644 index 000000000..9efc57b17 --- /dev/null +++ b/docs/examples/managing_state/picture_component.py @@ -0,0 +1,16 @@ +from reactpy import component, html + + +# start +@component +def picture(): + return html.div( + {"className": "background background--active"}, + html.img( + { + "className": "picture", + "alt": "Rainbow houses in Kampung Pelangi, Indonesia", + "src": "https://i.imgur.com/5qwVYb1.jpeg", + } + ), + ) diff --git a/docs/examples/managing_state/refactored_states.py b/docs/examples/managing_state/refactored_states.py new file mode 100644 index 000000000..3d080c92c --- /dev/null +++ b/docs/examples/managing_state/refactored_states.py @@ -0,0 +1,6 @@ +from reactpy import hooks + +# start +answer, set_answer = hooks.use_state("") +error, set_error = hooks.use_state(None) +status, set_status = hooks.use_state("typing") # 'typing', 'submitting', or 'success' diff --git a/docs/examples/managing_state/stateful_form_component.py b/docs/examples/managing_state/stateful_form_component.py new file mode 100644 index 000000000..d2ef5580d --- /dev/null +++ b/docs/examples/managing_state/stateful_form_component.py @@ -0,0 +1,69 @@ +import asyncio + +from reactpy import component, event, hooks, html + + +async def submit_form(*args): + await asyncio.wait(5) + + +# start +@component +def error_msg(error): + if error: + return html.p( + {"className": "error"}, "Good guess but a wrong answer. Try again!" + ) + else: + return "" + + +@component +def form(status="empty"): + answer, set_answer = hooks.use_state("") + error, set_error = hooks.use_state(None) + status, set_status = hooks.use_state("typing") + + @event(prevent_default=True) + async def handle_submit(event): + set_status("submitting") + try: + await submit_form(answer) + set_status("success") + except Exception: + set_status("typing") + set_error(Exception) + + @event() + def handle_textarea_change(event): + set_answer(event["target"]["value"]) + + if status == "success": + return html.h1("That's right!") + else: + return html.fragment( + html.h2("City quiz"), + html.p( + "In which city is there a billboard that turns air into drinkable water?" + ), + html.form( + {"onSubmit": handle_submit}, + html.textarea( + { + "value": answer, + "onChange": handle_textarea_change, + "disabled": (True if status == "submitting" else "False"), + } + ), + html.br(), + html.button( + { + "disabled": ( + True if status in ["empty", "submitting"] else "False" + ) + }, + "Submit", + ), + error_msg(error), + ), + ) diff --git a/docs/examples/managing_state/stateful_picture_component.py b/docs/examples/managing_state/stateful_picture_component.py new file mode 100644 index 000000000..e3df7991c --- /dev/null +++ b/docs/examples/managing_state/stateful_picture_component.py @@ -0,0 +1,30 @@ +from reactpy import component, event, hooks, html + + +# start +@component +def picture(): + is_active, set_is_active = hooks.use_state(False) + background_className = "background" + picture_className = "picture" + + if is_active: + picture_className += " picture--active" + else: + background_className += " background--active" + + @event(stop_propagation=True) + def handle_click(event): + set_is_active(True) + + return html.div( + {"className": background_className, "onClick": set_is_active(False)}, + html.img( + { + "onClick": handle_click, + "className": picture_className, + "alt": "Rainbow houses in Kampung Pelangi, Indonesia", + "src": "https://i.imgur.com/5qwVYb1.jpeg", + } + ), + ) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a55cf24c2..2594e705e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -34,7 +34,7 @@ nav: - Updating Objects in State 🚧: learn/updating-objects-in-state.md - Updating Arrays in State 🚧: learn/updating-arrays-in-state.md - Managing State: - - Reacting to Input with State 🚧: learn/reacting-to-input-with-state.md + - Reacting to Input with State: learn/reacting-to-input-with-state.md - Choosing the State Structure 🚧: learn/choosing-the-state-structure.md - Sharing State Between Components 🚧: learn/sharing-state-between-components.md - Preserving and Resetting State 🚧: learn/preserving-and-resetting-state.md diff --git a/docs/src/assets/css/code.css b/docs/src/assets/css/code.css index c54654980..f77c25e5f 100644 --- a/docs/src/assets/css/code.css +++ b/docs/src/assets/css/code.css @@ -1,111 +1,114 @@ :root { - --code-max-height: 17.25rem; - --md-code-backdrop: rgba(0, 0, 0, 0) 0px 0px 0px 0px, - rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.03) 0px 0.8px 2px 0px, - rgba(0, 0, 0, 0.047) 0px 2.7px 6.7px 0px, - rgba(0, 0, 0, 0.08) 0px 12px 30px 0px; + --code-max-height: 17.25rem; + --md-code-backdrop: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.03) 0px 0.8px 2px 0px, rgba(0, 0, 0, 0.047) 0px 2.7px 6.7px 0px, rgba(0, 0, 0, 0.08) 0px 12px 30px 0px; } [data-md-color-scheme="slate"] { - --md-code-hl-color: #ffffcf1c; - --md-code-bg-color: #16181d; - --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); - --code-tab-color: rgb(52, 58, 70); - --md-code-hl-name-color: #aadafc; - --md-code-hl-string-color: hsl(21 49% 63% / 1); - --md-code-hl-keyword-color: hsl(289.67deg 35% 60%); - --md-code-hl-constant-color: hsl(213.91deg 68% 61%); - --md-code-hl-number-color: #bfd9ab; - --func-and-decorator-color: #dcdcae; - --module-import-color: #60c4ac; + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: #16181d; + --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); + --code-tab-color: rgb(52, 58, 70); + --md-code-hl-name-color: #aadafc; + --md-code-hl-string-color: hsl(21 49% 63% / 1); + --md-code-hl-keyword-color: hsl(289.67deg 35% 60%); + --md-code-hl-constant-color: hsl(213.91deg 68% 61%); + --md-code-hl-number-color: #bfd9ab; + --func-and-decorator-color: #dcdcae; + --module-import-color: #60c4ac; } [data-md-color-scheme="default"] { - --md-code-hl-color: #ffffcf1c; - --md-code-bg-color: rgba(208, 211, 220, 0.4); - --md-code-fg-color: rgb(64, 71, 86); - --code-tab-color: #fff; - --func-and-decorator-color: var(--md-code-hl-function-color); - --module-import-color: #e153e5; + --md-code-hl-color: #ffffcf1c; + --md-code-bg-color: rgba(208, 211, 220, 0.4); + --md-code-fg-color: rgb(64, 71, 86); + --code-tab-color: #fff; + --func-and-decorator-color: var(--md-code-hl-function-color); + --module-import-color: #e153e5; } [data-md-color-scheme="default"] .md-typeset .highlight > pre > code, [data-md-color-scheme="default"] .md-typeset .highlight > table.highlighttable { - --md-code-bg-color: #fff; + --md-code-bg-color: #fff; } /* All code blocks */ .md-typeset pre > code { - max-height: var(--code-max-height); + max-height: var(--code-max-height); } /* Code blocks with no line number */ .md-typeset .highlight > pre > code { - border-radius: 16px; - max-height: var(--code-max-height); - box-shadow: var(--md-code-backdrop); + border-radius: 16px; + max-height: var(--code-max-height); + box-shadow: var(--md-code-backdrop); } /* Code blocks with line numbers */ .md-typeset .highlighttable .linenos { - max-height: var(--code-max-height); - overflow: hidden; + max-height: var(--code-max-height); + overflow: hidden; } .md-typeset .highlighttable { - box-shadow: var(--md-code-backdrop); - border-radius: 8px; - overflow: hidden; + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; } /* Tabbed code blocks */ .md-typeset .tabbed-set { - box-shadow: var(--md-code-backdrop); - border-radius: 8px; - overflow: hidden; - border: 1px solid var(--md-default-fg-color--lightest); + box-shadow: var(--md-code-backdrop); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--md-default-fg-color--lightest); } .md-typeset .tabbed-set .tabbed-block { - overflow: hidden; + overflow: hidden; } .js .md-typeset .tabbed-set .tabbed-labels { - background: var(--code-tab-color); - margin: 0; - padding-left: 0.8rem; + background: var(--code-tab-color); + margin: 0; + padding-left: 0.8rem; } .md-typeset .tabbed-set .tabbed-labels > label { - font-weight: 400; - font-size: 0.7rem; - padding-top: 0.55em; - padding-bottom: 0.35em; + font-weight: 400; + font-size: 0.7rem; + padding-top: 0.55em; + padding-bottom: 0.35em; } .md-typeset .tabbed-set .highlighttable { - border-radius: 0; + border-radius: 0; } -/* Code hightlighting colors */ +/* Code highlighting colors */ /* Module imports */ .highlight .nc, .highlight .ne, .highlight .nn, .highlight .nv { - color: var(--module-import-color); + color: var(--module-import-color); } /* Function def name and decorator */ .highlight .nd, .highlight .nf { - color: var(--func-and-decorator-color); + color: var(--func-and-decorator-color); } /* None type */ .highlight .kc { - color: var(--md-code-hl-constant-color); + color: var(--md-code-hl-constant-color); } /* Keywords such as def and return */ .highlight .k { - color: var(--md-code-hl-constant-color); + color: var(--md-code-hl-constant-color); } /* HTML tags */ .highlight .nt { - color: var(--md-code-hl-constant-color); + color: var(--md-code-hl-constant-color); +} + +/* Code blocks that are challenges */ +.challenge { + padding: 0.1rem 1rem 0.6rem 1rem; + background: var(--code-tab-color); } diff --git a/docs/src/learn/reacting-to-input-with-state.md b/docs/src/learn/reacting-to-input-with-state.md index 4247a88d1..2a8b9efd9 100644 --- a/docs/src/learn/reacting-to-input-with-state.md +++ b/docs/src/learn/reacting-to-input-with-state.md @@ -29,103 +29,107 @@ They don't know where you want to go, they just follow your commands. (And if yo In this example of imperative UI programming, the form is built _without_ React. It only uses the browser [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model): -```js -async function handleFormSubmit(e) { - e.preventDefault(); - disable(textarea); - disable(button); - show(loadingMessage); - hide(errorMessage); - try { - await submitForm(textarea.value); - show(successMessage); - hide(form); - } catch (err) { - show(errorMessage); - errorMessage.textContent = err.message; - } finally { - hide(loadingMessage); - enable(textarea); - enable(button); - } -} - -function handleTextareaChange() { - if (textarea.value.length === 0) { - disable(button); - } else { - enable(button); - } -} - -function hide(el) { - el.style.display = "none"; -} - -function show(el) { - el.style.display = ""; -} - -function enable(el) { - el.disabled = false; -} - -function disable(el) { - el.disabled = true; -} - -function submitForm(answer) { - // Pretend it's hitting the network. - return new Promise((resolve, reject) => { - setTimeout(() => { - if (answer.toLowerCase() == "istanbul") { - resolve(); - } else { - reject(new Error("Good guess but a wrong answer. Try again!")); - } - }, 1500); - }); -} - -let form = document.getElementById("form"); -let textarea = document.getElementById("textarea"); -let button = document.getElementById("button"); -let loadingMessage = document.getElementById("loading"); -let errorMessage = document.getElementById("error"); -let successMessage = document.getElementById("success"); -form.onsubmit = handleFormSubmit; -textarea.oninput = handleTextareaChange; -``` - -```js -{ - "hardReloadOnChange": true -} -``` - -```html -
-

City quiz

-

What city is located on two continents?

- -
- - - -
-

That's right!

- - -``` +=== "index.js" + + ```js + async function handleFormSubmit(e) { + e.preventDefault(); + disable(textarea); + disable(button); + show(loadingMessage); + hide(errorMessage); + try { + await submitForm(textarea.value); + show(successMessage); + hide(form); + } catch (err) { + show(errorMessage); + errorMessage.textContent = err.message; + } finally { + hide(loadingMessage); + enable(textarea); + enable(button); + } + } + + function handleTextareaChange() { + if (textarea.value.length === 0) { + disable(button); + } else { + enable(button); + } + } + + function hide(el) { + el.style.display = "none"; + } + + function show(el) { + el.style.display = ""; + } + + function enable(el) { + el.disabled = false; + } + + function disable(el) { + el.disabled = true; + } + + function submitForm(answer) { + // Pretend it's hitting the network. + return new Promise((resolve, reject) => { + setTimeout(() => { + if (answer.toLowerCase() == "istanbul") { + resolve(); + } else { + reject(new Error("Good guess but a wrong answer. Try again!")); + } + }, 1500); + }); + } + + let form = document.getElementById("form"); + let textarea = document.getElementById("textarea"); + let button = document.getElementById("button"); + let loadingMessage = document.getElementById("loading"); + let errorMessage = document.getElementById("error"); + let successMessage = document.getElementById("success"); + form.onsubmit = handleFormSubmit; + textarea.oninput = handleTextareaChange; + ``` + +=== "index.html" + + ```html +
+

City quiz

+

What city is located on two continents?

+ +
+ + + +
+

That's right!

+ + + ``` + +=== ":material-play: Run" + + ```html + # TODO + ``` Manipulating the UI imperatively works well enough for isolated examples, but it gets exponentially more difficult to manage in more complex systems. Imagine updating a page full of different forms like this one. Adding a new UI element or a new interaction would require carefully checking all existing code to make sure you haven't introduced a bug (for example, forgetting to show or hide something). @@ -141,7 +145,7 @@ You've seen how to implement a form imperatively above. To better understand how 1. **Identify** your component's different visual states 2. **Determine** what triggers those state changes -3. **Represent** the state in memory using `useState` +3. **Represent** the state in memory using `use_state` 4. **Remove** any non-essential state variables 5. **Connect** the event handlers to set the state @@ -159,136 +163,71 @@ First, you need to visualize all the different "states" of the UI the user might Just like a designer, you'll want to "mock up" or create "mocks" for the different states before you add logic. For example, here is a mock for just the visual part of the form. This mock is controlled by a prop called `status` with a default value of `'empty'`: -```js -export default function Form({ status = "empty" }) { - if (status === "success") { - return

That's right!

; - } - return ( - <> -

City quiz

-

- In which city is there a billboard that turns air into drinkable - water? -

-
-