Quellcode durchsuchen

Tmp/4.1 (#2617)

* Change version to 4.1.0.dev0

* Make core event registration able to support multiple topics (#2505)

* Make core event registration able to support multiple topics

Co-authored-by: Đỗ Trường Giang <do.giang@avaiga.com>

(cherry picked from commit c0ba439faba937ac4f16b09118ff02646ecf2838)

* Implement EventConsumer API

* Implement UTs for EventConsumer

* Delete wrong push

* Backport simplified build process (#2575)

* Backport simplified build process

* Change version to 4.1.0

* Update __init__.py

* Update __init__.py

* Make Ruff happy

* Feedback from Eric and Florian: Systematically pass the gui as param

* Adding a mock State class that can be instantiated and queried while testing (#2101)

* Adding a testing State class that can be instantiated and queried during tests
I hoped it would not have any impact ont the real code but it has. Any discussion is welcome before merge.
resolves #2098

* tricky to get gui :-)
* consistency
* linter
* test => mock
more tests

* Adding a mock State class that can be instantiated and queried while testing (#2101)

* Adding a testing State class that can be instantiated and queried during tests
I hoped it would not have any impact ont the real code but it has. Any discussion is welcome before merge.
resolves #2098

* tricky to get gui :-)
* consistency
* linter
* test => mock
more tests

* selection message
show select all when multiple
resolves #1834

* Input control validation on focus out event (#2036)

* Added action_on_blur property to 'input' and 'number'

* LaTeX mode to text control via mathjax (#2221)

* add latex mode to text control via mathjax
* allow square brackets for displayed equations
* increase timeout for latex to render
* move config object outside component
* minor fixes
* resolve conflict in contributors
* Update contributors.txt
* fix classes and formatting
* remove contributors (back to normal)
* trying to come around markdown package
* already loaded
* latex mode

* support images in chat (#2268)

* support images in chat
resolves #1314

* fix version and merge errors

* linter chat_discuss.py

fix type

* Update chat_discuss.py

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fred Lefévère-Laoide <90181748+FredLL-Avaiga@users.noreply.github.com>
Jean-Robin vor 1 Woche
Ursprung
Commit
700502741a
38 geänderte Dateien mit 2129 neuen und 1366 gelöschten Zeilen
  1. 52 0
      doc/gui/examples/async_callback.py
  2. 2 2
      doc/gui/examples/controls/chat_calculator.py
  3. 8 6
      doc/gui/examples/controls/chat_discuss.py
  4. 47 0
      doc/gui/examples/controls/chat_images.py
  5. 36 0
      doc/gui/examples/controls/text_latex.py
  6. 1 1
      frontend/taipy-gui/base/src/packaging/package.json
  7. 1 1
      frontend/taipy-gui/dom/package.json
  8. 2 2
      frontend/taipy-gui/jest.config.js
  9. 189 546
      frontend/taipy-gui/package-lock.json
  10. 3 2
      frontend/taipy-gui/package.json
  11. 1 1
      frontend/taipy-gui/packaging/package.json
  12. 125 26
      frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx
  13. 185 45
      frontend/taipy-gui/src/components/Taipy/Chat.tsx
  14. 23 5
      frontend/taipy-gui/src/components/Taipy/Field.spec.tsx
  15. 74 16
      frontend/taipy-gui/src/components/Taipy/Field.tsx
  16. 64 39
      frontend/taipy-gui/src/components/Taipy/Input.tsx
  17. 38 0
      frontend/taipy-gui/src/components/Taipy/Selector.spec.tsx
  18. 279 169
      frontend/taipy-gui/src/components/Taipy/Selector.tsx
  19. 1 0
      frontend/taipy-gui/src/components/Taipy/utils.ts
  20. 30 0
      frontend/taipy-gui/src/utils/image.ts
  21. 11 0
      frontend/taipy-gui/test-config/markdown.tsx
  22. 361 336
      frontend/taipy/package-lock.json
  23. 3 2
      frontend/taipy/package.json
  24. 7 1
      taipy/gui/_renderers/factory.py
  25. 27 7
      taipy/gui/gui.py
  26. 9 7
      taipy/gui/gui_actions.py
  27. 10 0
      taipy/gui/mock/__init__.py
  28. 62 0
      taipy/gui/mock/mock_state.py
  29. 130 94
      taipy/gui/state.py
  30. 1 0
      taipy/gui/utils/__init__.py
  31. 34 0
      taipy/gui/utils/callable.py
  32. 22 0
      taipy/gui/utils/threads.py
  33. 99 56
      taipy/gui/viselements.json
  34. 1 1
      taipy/gui_core/viselements.json
  35. 1 1
      tests/gui/actions/test_download.py
  36. 83 0
      tests/gui/e2e/with_action/test_input.py
  37. 1 0
      tests/gui/gui_specific/test_state.py
  38. 106 0
      tests/gui/mock/test_mock_state.py

+ 52 - 0
doc/gui/examples/async_callback.py

@@ -0,0 +1,52 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+# Demonstrate how to update the value of a variable across multiple clients.
+# This application creates a thread that sets a variable to the current time.
+# The value is updated for every client when Gui.broadcast_change() is invoked.
+# -----------------------------------------------------------------------------------------
+import asyncio
+
+import taipy.gui.builder as tgb
+from taipy.gui import Gui, State
+
+
+# This callback is invoked inside a separate thread
+# it can access the state but cannot return a value
+async def heavy_function(state: State):
+    state.logs = "Starting...\n"
+    state.logs += "Searching documents\n"
+    await asyncio.sleep(5)
+    state.logs += "Responding to user\n"
+    await asyncio.sleep(5)
+    state.logs += "Fact Checking\n"
+    await asyncio.sleep(5)
+    state.result = "Done!"
+
+logs = ""
+result = "No response yet"
+
+with tgb.Page() as main_page:
+    # the async callback is used as any other callback
+    tgb.button("Respond", on_action=heavy_function)
+    with tgb.part("card"):
+        tgb.text("{logs}", mode="pre")
+
+    tgb.text("# Result", mode="md")
+    tgb.text("{result}")
+
+
+if __name__ == "__main__":
+    Gui(main_page).run(title="Async - Callback")

+ 2 - 2
doc/gui/examples/controls/chat_calculator.py

@@ -26,7 +26,7 @@ messages: list[tuple[str, str, str]] = []
 
 def evaluate(state, var_name: str, payload: dict):
     # Retrieve the callback parameters
-    (_, _, expression, sender_id) = payload.get("args", [])
+    (_, _, expression, sender_id, _) = payload.get("args", [])
     # Add the input content as a sent message
     messages.append((f"{len(messages)}", expression, sender_id))
     # Default message used if evaluation fails
@@ -42,7 +42,7 @@ def evaluate(state, var_name: str, payload: dict):
 
 
 page = """
-<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|>
+<|{messages}|chat|users={users}|sender_id={users[0]}|on_action=evaluate|don't allow_send_images|>
 """
 
 Gui(page).run(title="Chat - Calculator")

+ 8 - 6
doc/gui/examples/controls/chat_discuss.py

@@ -19,14 +19,14 @@
 # incognito windows so a given user's context is not reused.
 # -----------------------------------------------------------------------------------------
 from os import path
-from typing import Union
+from typing import Optional, Union, cast
 
 from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 
 username = ""
-users: list[Union[str, Icon]] = []
-messages: list[tuple[str, str, str]] = []
+users: list[tuple[str, Union[str, Icon]]] = []
+messages: list[tuple[str, str, str, Optional[str]]] = []
 
 Gui.add_shared_variables("messages", "users")
 
@@ -62,8 +62,8 @@ def register(state):
 
 
 def send(state, _: str, payload: dict):
-    (_, _, message, sender_id) = payload.get("args", [])
-    messages.append((f"{len(messages)}", message, sender_id))
+    (_, _, message, sender_id, image_url) = payload.get("args", [])
+    messages.append((f"{len(messages)}", cast(str, message), cast(str, sender_id), cast(str, image_url)))
     state.messages = messages
 
 
@@ -82,4 +82,6 @@ discuss_page = """
 """
 
 pages = {"register": register_page, "discuss": discuss_page}
-gui = Gui(pages=pages).run(title="Chat - Discuss")
+
+if __name__ == "__main__":
+    gui = Gui(pages=pages).run(title="Chat - Discuss")

+ 47 - 0
doc/gui/examples/controls/chat_images.py

@@ -0,0 +1,47 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+# A chatting application based on the chat control.
+# In order to see the users' avatars, the image files must be stored next to this script.
+# If you want to test this application locally, you need to use several browsers and/or
+# incognito windows so a given user's context is not reused.
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui, Icon
+
+msgs = [
+    ["1", "msg 1", "Alice", None],
+    ["2", "msg From Another unknown User", "Charles", None],
+    ["3", "This from the sender User", "taipy", "./beatrix-avatar.png"],
+    ["4", "And from another known one", "Alice", None],
+]
+users = [
+    ["Alice", Icon("./alice-avatar.png", "Alice avatar")],
+    ["Charles", Icon("./charles-avatar.png", "Charles avatar")],
+    ["taipy", Icon("./beatrix-avatar.png", "Beatrix avatar")],
+]
+
+
+def on_action(state, id: str, payload: dict):
+    (reason, varName, text, senderId, imageData) = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", text, senderId, imageData])
+    state.msgs = msgs
+
+
+page = """
+<|{msgs}|chat|users={users}|allow_send_images|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chat - Images")

+ 36 - 0
doc/gui/examples/controls/text_latex.py

@@ -0,0 +1,36 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+latex = """
+# Generated by *Taipy*
+
+You can insert *LaTeX* in a `text` control to
+display math equations.
+
+Displayed Equations:
+$$Attention(Q, K, V) = softmax(\\frac{QK^T}{\\sqrt{d_k}})V$$
+
+Inline Equations:
+Each head $h_i$ is the attention function of $\\textbf{Query}$, $\\textbf{Key}$ and $\\textbf{Value}$ with trainable parameters ($W_i^Q$, $W_i^K$, $W_i^V$)
+"""  # noqa W291
+
+page = """
+<|{latex}|text|mode=latex|>
+"""
+
+if __name__ == "__main__":
+    Gui(page).run(title="Text - Latex mode")

+ 1 - 1
frontend/taipy-gui/base/src/packaging/package.json

@@ -1,6 +1,6 @@
 {
   "name": "taipy-gui-base",
-  "version": "4.0.2",
+  "version": "4.1.0",
   "private": true,
   "main": "./taipy-gui-base.js",
   "types": "./taipy-gui-base.d.ts"

+ 1 - 1
frontend/taipy-gui/dom/package.json

@@ -1,6 +1,6 @@
 {
   "name": "taipy-gui-dom",
-  "version": "4.0.2",
+  "version": "4.1.0",
   "private": true,
   "dependencies": {
     "react": "^18.2.0",

+ 2 - 2
frontend/taipy-gui/jest.config.js

@@ -27,7 +27,7 @@ module.exports = {
     ],
     coverageReporters: ["json", "html", "text"],
     modulePathIgnorePatterns: ["<rootDir>/packaging/"],
-    moduleNameMapper: {"react-markdown": "<rootDir>/node_modules/react-markdown/react-markdown.min.js"},
-    transformIgnorePatterns: ["<rootDir>/node_modules/(?!react-jsx-parser|react-markdown/)"],
+    moduleNameMapper: {"react-markdown": "<rootDir>/test-config/markdown.tsx"},
+    transformIgnorePatterns: ["<rootDir>/node_modules/(?!react-jsx-parser/)"],
     ...createJsWithTsPreset()
 };

Datei-Diff unterdrückt, da er zu groß ist
+ 189 - 546
frontend/taipy-gui/package-lock.json


+ 3 - 2
frontend/taipy-gui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "taipy-gui",
-  "version": "4.0.3",
+  "version": "4.1.0",
   "private": true,
   "dependencies": {
     "@emotion/react": "^11.10.0",
@@ -11,6 +11,7 @@
     "@mui/x-tree-view": "^7.0.0",
     "apache-arrow": "^17.0.0",
     "axios": "^1.2.0",
+    "better-react-mathjax": "^2.0.3",
     "date-fns": "^3.6.0",
     "date-fns-tz": "^3.1.3",
     "lodash": "^4.17.21",
@@ -115,7 +116,7 @@
     "ts-jest": "^29.0.0",
     "ts-jest-mock-import-meta": "^1.2.0",
     "ts-loader": "^9.2.6",
-    "typedoc": "^0.26.3",
+    "typedoc": "^0.28",
     "typedoc-plugin-markdown": "^4.1.1",
     "typescript": "^5.5.3",
     "webpack": "^5.61.0",

+ 1 - 1
frontend/taipy-gui/packaging/package.json

@@ -1,6 +1,6 @@
 {
   "name": "taipy-gui",
-  "version": "4.0.2",
+  "version": "4.1.0",
   "private": true,
   "main": "./taipy-gui.js",
   "types": "./taipy-gui.d.ts"

+ 125 - 26
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -12,7 +12,7 @@
  */
 
 import React from "react";
-import { render, waitFor } from "@testing-library/react";
+import { render, waitFor, fireEvent } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 
@@ -22,15 +22,24 @@ import { TaipyContext } from "../../context/taipyContext";
 import { stringIcon } from "../../utils/icon";
 import { TableValueType } from "./tableUtils";
 
+import { toDataUrl } from "../../utils/image";
+jest.mock('../../utils/image', () => ({
+    toDataUrl: (url: string) => new Promise((resolve) => resolve(url)),
+  }));
+
 const valueKey = "Infinite-Entity--asc";
 const messages: TableValueType = {
     [valueKey]: {
         data: [
-    ["1", "msg 1", "Fred"],
-    ["2", "msg From Another unknown User", "Fredo"],
-    ["3", "This from the sender User", "taipy"],
-    ["4", "And from another known one", "Fredi"],
-], rowcount: 4, start: 0}};
+            ["1", "msg 1", "Fred"],
+            ["2", "msg From Another unknown User", "Fredo"],
+            ["3", "This from the sender User", "taipy"],
+            ["4", "And from another known one", "Fredi"],
+        ],
+        rowcount: 4,
+        start: 0,
+    },
+};
 const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const users = [user1, user2];
@@ -46,32 +55,48 @@ describe("Chat Component", () => {
         expect(input.tagName).toBe("INPUT");
     });
     it("uses the class", async () => {
-        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />
+        );
         const elt = getByText(searchMsg);
-        expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
+        expect(
+            elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
+        ).toHaveClass("taipy-chat");
     });
     it("can display an avatar", async () => {
-        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw"/>);
+        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw" />);
         const elt = getByAltText("Fred.png");
         expect(elt.tagName).toBe("IMG");
     });
     it("is disabled", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).toHaveClass("Mui-disabled");
+        expect(getByRole("button", { name: /send message/i })).toHaveClass("Mui-disabled");
     });
     it("is enabled by default", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     it("is enabled by active", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw"/>);
-        const elts = getAllByRole("button");
-        elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
+        const { getByLabelText, getByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw" />);
+        const elt = getByLabelText("message (taipy)");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        const sendButton = getByRole("button", { name: /send message/i });
+        expect(sendButton).toHaveClass("Mui-disabled");
+        await userEvent.click(elt);
+        await userEvent.keyboard("new message");
+        expect(elt).not.toHaveClass("Mui-disabled");
+        expect(sendButton).not.toHaveClass("Mui-disabled");
     });
     it("can hide input", async () => {
-        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw"/>);
+        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
     });
@@ -81,13 +106,17 @@ describe("Chat Component", () => {
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
     });
     it("can render pre", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="pre" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="pre" />
+        );
         const elt = getByText(searchMsg);
         expect(elt.tagName).toBe("PRE");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
     });
     it("can render raw", async () => {
-        const { getByText } = render(<Chat messages={messages} defaultKey={valueKey} className="taipy-chat"  mode="raw" />);
+        const { getByText } = render(
+            <Chat messages={messages} defaultKey={valueKey} className="taipy-chat" mode="raw" />
+        );
         const elt = getByText(searchMsg);
         expect(elt).toHaveClass("taipy-chat-raw");
     });
@@ -96,7 +125,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
         );
         const elt = getByLabelText("message (taipy)");
@@ -108,7 +137,7 @@ describe("Chat Component", () => {
             context: undefined,
             payload: {
                 action: undefined,
-                args: ["Enter", "varName", "new message", "taipy"],
+                args: ["Enter", "varName", "new message", "taipy", null],
             },
         });
     });
@@ -117,21 +146,91 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
             </TaipyContext.Provider>
         );
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         await userEvent.keyboard("new message");
-        await userEvent.click(getByRole("button"))
+        await userEvent.click(getByRole("button", { name: /send message/i }));
         expect(dispatch).toHaveBeenCalledWith({
             type: "SEND_ACTION_ACTION",
             name: "",
             context: undefined,
             payload: {
                 action: undefined,
-                args: ["click", "varName", "new message", "taipy"],
+                args: ["click", "varName", "new message", "taipy", null],
             },
         });
     });
+    it("handle image upload", async () => {
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByLabelText, getByText, getByAltText, queryByText, getByRole } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw" />
+            </TaipyContext.Provider>
+        );
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
+        URL.revokeObjectURL = jest.fn();
+
+        const attachButton = getByLabelText("upload image");
+        expect(attachButton).toBeInTheDocument();
+
+        const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+        expect(fileInput).toBeInTheDocument();
+        fireEvent.change(fileInput, { target: { files: [file] } });
+
+        await waitFor(() => {
+            const chipWithImage = getByText("test.png");
+            expect(chipWithImage).toBeInTheDocument();
+            const previewImg = getByAltText("Image preview");
+            expect(previewImg).toBeInTheDocument();
+            expect(previewImg).toHaveAttribute("src", "mocked-url");
+        });
+
+        const elt = getByLabelText("message (taipy)");
+        await userEvent.click(elt);
+        await userEvent.keyboard("Test message with image");
+        await userEvent.click(getByRole("button", { name: /send message/i }));
+
+        // needed mocked toDataUrl
+        await waitFor(() => {
+            expect(dispatch).toHaveBeenCalledWith(
+                expect.objectContaining({
+                    type: "SEND_ACTION_ACTION",
+                    payload: expect.objectContaining({
+                        args: ["click", "varName", "Test message with image", "taipy", "mocked-url"],
+                    }),
+                })
+            );
+        });
+        await waitFor(() => {
+            const chipWithImage = queryByText("test.png");
+            expect(chipWithImage).not.toBeInTheDocument();
+        });
+        jest.restoreAllMocks();
+    });
+    it("Not upload image over a file size limit", async () => {
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByText, getByAltText } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw" />
+            </TaipyContext.Provider>
+        );
+        const file = new File(["(⌐□_□)"], "test.png", { type: "image/png" });
+        URL.createObjectURL = jest.fn(() => "mocked-url");
+
+        const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+        expect(fileInput).toBeInTheDocument();
+        fireEvent.change(fileInput, { target: { files: [file] } });
+
+        await waitFor(() => {
+            expect(() => getByText("test.png")).toThrow();
+            expect(() => getByAltText("Image preview")).toThrow();
+        });
+        jest.restoreAllMocks();
+    });
 });

+ 185 - 45
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -21,6 +21,7 @@ import React, {
     useEffect,
     ReactNode,
     lazy,
+    ChangeEvent,
     UIEvent,
 } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
@@ -38,8 +39,13 @@ import Tooltip from "@mui/material/Tooltip";
 import Send from "@mui/icons-material/Send";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
+import AttachFile from "@mui/icons-material/AttachFile";
 
-import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
+import {
+    createAlertAction,
+    createRequestInfiniteTableUpdateAction,
+    createSendActionNameAction,
+} from "../../context/taipyReducers";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
@@ -47,11 +53,14 @@ import { IconAvatar, avatarSx } from "../../utils/icon";
 import { emptyArray, getInitials } from "../../utils";
 import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
+import { noDisplayStyle } from "./utils";
+import { toDataUrl } from "../../utils/image";
 
 const Markdown = lazy(() => import("react-markdown"));
 
 interface ChatProps extends TaipyActiveProps {
     messages?: TableValueType;
+    maxFileSize?: number;
     withInput?: boolean;
     users?: LoVElt[];
     defaultUsers?: string;
@@ -62,6 +71,7 @@ interface ChatProps extends TaipyActiveProps {
     pageSize?: number;
     showSender?: boolean;
     mode?: string;
+    allowSendImages?: boolean;
 }
 
 const ENTER_KEY = "Enter";
@@ -132,7 +142,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
-
+const imageSx = { width: 3 / 5, height: "auto" };
 interface key2Rows {
     key: string;
 }
@@ -140,6 +150,7 @@ interface key2Rows {
 interface ChatRowProps {
     senderId: string;
     message: string;
+    image?: string;
     name: string;
     className?: string;
     getAvatar: (id: string, sender: boolean) => ReactNode;
@@ -149,7 +160,7 @@ interface ChatRowProps {
 }
 
 const ChatRow = (props: ChatRowProps) => {
-    const { senderId, message, name, className, getAvatar, index, showSender, mode } = props;
+    const { senderId, message, image, name, className, getAvatar, index, showSender, mode } = props;
     const sender = senderId == name;
     const avatar = getAvatar(name, sender);
 
@@ -162,6 +173,11 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : undefined}
         >
             <Grid sx={sender ? senderMsgSx : undefined}>
+                {image ? (
+                    <Grid container justifyContent={sender ? "flex-end" : undefined}>
+                        <Box component="img" sx={imageSx} alt="Uploaded image" src={image} />
+                    </Grid>
+                ) : null}
                 {(!sender || showSender) && avatar ? (
                     <Stack direction="row" gap={1}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
@@ -213,8 +229,10 @@ const Chat = (props: ChatProps) => {
         onAction,
         withInput = true,
         defaultKey = "",
+        maxFileSize = 0.8 * 1024 * 1024, // 0.8 MB
         pageSize = 50,
         showSender = false,
+        allowSendImages = true,
     } = props;
     const dispatch = useDispatch();
     const module = useModule();
@@ -225,8 +243,13 @@ const Chat = (props: ChatProps) => {
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
+    const [enableSend, setEnableSend] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
+    const [selectedFile, setSelectedFile] = useState<File | null>(null);
+    const [imagePreview, setImagePreview] = useState<string | null>(null);
+    const [objectURLs, setObjectURLs] = useState<string[]>([]);
+    const fileInputRef = useRef<HTMLInputElement>(null);
     const userScrolled = useRef(false);
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
@@ -251,36 +274,98 @@ const Chat = (props: ChatProps) => {
         [props.height]
     );
 
+    const onChangeHandler = useCallback((evt: ChangeEvent<HTMLInputElement>) => setEnableSend(!!evt.target.value), []);
+
+    const sendAction = useCallback(
+        (elt: HTMLInputElement | null | undefined, reason: string) => {
+            if (elt && (elt?.value || imagePreview)) {
+                toDataUrl(imagePreview)
+                    .then((dataUrl) => {
+                        dispatch(
+                            createSendActionNameAction(
+                                id,
+                                module,
+                                onAction,
+                                reason,
+                                updateVarName,
+                                elt?.value,
+                                senderId,
+                                dataUrl
+                            )
+                        );
+                        elt.value = "";
+                        setSelectedFile(null);
+                        setImagePreview((url) => {
+                            url && URL.revokeObjectURL(url);
+                            return null;
+                        });
+                        fileInputRef.current && (fileInputRef.current.value = "");
+                    })
+                    .catch(console.log);
+            }
+        },
+        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
+    );
+
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
-                const elt = evt.currentTarget.querySelector("input");
-                if (elt?.value) {
-                    dispatch(
-                        createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value, senderId)
-                    );
-                    elt.value = "";
-                }
+                sendAction(evt.currentTarget.querySelector("input"), evt.key);
                 evt.preventDefault();
             }
         },
-        [updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
 
     const handleClick = useCallback(
         (evt: MouseEvent<HTMLButtonElement>) => {
-            const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");
-            if (elt?.value) {
-                dispatch(
-                    createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value, senderId)
-                );
-                elt.value = "";
-            }
+            sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click");
             evt.preventDefault();
         },
-        [updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
+    );
+
+    const handleFileSelect = useCallback(
+        (event: React.ChangeEvent<HTMLInputElement>) => {
+            const file = event.target.files ? event.target.files[0] : null;
+            if (file) {
+                if (file.type.startsWith("image/") && file.size <= maxFileSize) {
+                    setSelectedFile(file);
+                    const newImagePreview = URL.createObjectURL(file);
+                    setImagePreview(newImagePreview);
+                    setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
+                } else {
+                    dispatch(
+                        createAlertAction({
+                            atype: "info",
+                            message:
+                                file.size > maxFileSize
+                                    ? `Image size is limited to ${maxFileSize / 1024} KB`
+                                    : "Only image file are authorized",
+                            system: false,
+                            duration: 3000,
+                        })
+                    );
+                    setSelectedFile(null);
+                    setImagePreview(null);
+                    fileInputRef.current && (fileInputRef.current.value = "");
+                }
+            }
+        },
+        [maxFileSize, dispatch]
     );
 
+    const handleAttachClick = useCallback(() => fileInputRef.current && fileInputRef.current.click(), [fileInputRef]);
+
+    const handleImageDelete = useCallback(() => {
+        setSelectedFile(null);
+        setImagePreview((url) => {
+            url && URL.revokeObjectURL(url);
+            return null;
+        });
+        fileInputRef.current && (fileInputRef.current.value = "");
+    }, []);
+
     const avatars = useMemo(() => {
         return users.reduce((pv, elt) => {
             if (elt.id) {
@@ -392,6 +477,14 @@ const Chat = (props: ChatProps) => {
         loadMoreItems(0);
     }, [loadMoreItems]);
 
+    useEffect(() => {
+        return () => {
+            for (const objectURL of objectURLs) {
+                URL.revokeObjectURL(objectURL);
+            }
+        };
+    }, [objectURLs]);
+
     const loadOlder = useCallback(
         (evt: MouseEvent<HTMLElement>) => {
             const { start } = evt.currentTarget.dataset;
@@ -403,7 +496,11 @@ const Chat = (props: ChatProps) => {
     );
 
     const handleOnScroll = useCallback((evt: UIEvent) => {
-        userScrolled.current = (evt.target as HTMLDivElement).scrollHeight - (evt.target as HTMLDivElement).offsetHeight - (evt.target as HTMLDivElement).scrollTop > 1;
+        userScrolled.current =
+            (evt.target as HTMLDivElement).scrollHeight -
+                (evt.target as HTMLDivElement).offsetHeight -
+                (evt.target as HTMLDivElement).scrollTop >
+            1;
     }, []);
 
     return (
@@ -430,6 +527,11 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
+                                image={
+                                    columns[3] && columns[3] != "_tp_index" && row[columns[3]]
+                                        ? `${row[columns[3]]}`
+                                        : undefined
+                                }
                                 className={className}
                                 getAvatar={getAvatar}
                                 index={idx}
@@ -449,31 +551,69 @@ const Chat = (props: ChatProps) => {
                     />
                 </Popper>
                 {withInput ? (
-                    <TextField
-                        margin="dense"
-                        fullWidth
-                        className={getSuffixedClassNames(className, "-input")}
-                        label={`message (${senderId})`}
-                        disabled={!active}
-                        onKeyDown={handleAction}
-                        slotProps={{
-                            input: {
-                                endAdornment: (
-                                    <InputAdornment position="end">
-                                        <IconButton
-                                            aria-label="send message"
-                                            onClick={handleClick}
-                                            edge="end"
-                                            disabled={!active}
-                                        >
-                                            <Send color={disableColor("primary", !active)} />
-                                        </IconButton>
-                                    </InputAdornment>
-                                ),
-                            },
-                        }}
-                        sx={inputSx}
-                    />
+                    <>
+                        {imagePreview && (
+                            <Box mb={1}>
+                                <Chip
+                                    label={selectedFile?.name}
+                                    avatar={<Avatar alt="Image preview" src={imagePreview} />}
+                                    onDelete={handleImageDelete}
+                                    variant="outlined"
+                                />
+                            </Box>
+                        )}
+                        <input
+                            type="file"
+                            ref={fileInputRef}
+                            style={noDisplayStyle}
+                            onChange={handleFileSelect}
+                            accept="image/*"
+                        />
+
+                        <TextField
+                            margin="dense"
+                            fullWidth
+                            onChange={onChangeHandler}
+                            className={getSuffixedClassNames(className, "-input")}
+                            label={`message (${senderId})`}
+                            disabled={!active}
+                            onKeyDown={handleAction}
+                            slotProps={{
+                                input: {
+                                    startAdornment: allowSendImages ? (
+                                        <InputAdornment position="start">
+                                            <IconButton
+                                                aria-label="upload image"
+                                                onClick={handleAttachClick}
+                                                edge="start"
+                                                disabled={!active}
+                                            >
+                                                <AttachFile color={disableColor("primary", !active)} />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ) : undefined,
+                                    endAdornment: (
+                                        <InputAdornment position="end">
+                                            <IconButton
+                                                aria-label="send message"
+                                                onClick={handleClick}
+                                                edge="end"
+                                                disabled={!active || !(enableSend || imagePreview)}
+                                            >
+                                                <Send
+                                                    color={disableColor(
+                                                        "primary",
+                                                        !active || !(enableSend || imagePreview)
+                                                    )}
+                                                />
+                                            </IconButton>
+                                        </InputAdornment>
+                                    ),
+                                },
+                            }}
+                            sx={inputSx}
+                        />
+                    </>
                 ) : null}
             </Paper>
         </Tooltip>

+ 23 - 5
frontend/taipy-gui/src/components/Taipy/Field.spec.tsx

@@ -61,13 +61,31 @@ describe("Field Component", () => {
         expect(elt).toHaveStyle("width: 500px");
     });
     it("can render markdown", async () => {
-        render(<Field value="titi" className="taipy-text" mode="md" />);
-        const elt = document.querySelector(".taipy-text");
-        await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
+        const { container, getByText, findByText } = render(<Field value="titi" className="taipy-text" mode="md" />);
+        getByText(/markdown/i);
+        // https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
+        // expect(await findByText(/titi/i)).toBeInTheDocument();
     });
     it("can render pre", async () => {
-        render(<Field value="titi" className="taipy-text" mode="pre" />);
-        const elt = document.querySelector("pre.taipy-text");
+        const { container } = render(<Field value="titi" className="taipy-text" mode="pre" />);
+        const elt = container.querySelector("pre.taipy-text-pre");
         expect(elt).toBeInTheDocument();
     });
+    describe("latex mode", () => {
+        it("renders LaTeX as block math", async () => {
+            const { container, getByText } = render(
+                <Field value={"$$x = y + 1$$"} className="taipy-text" mode="latex" />
+            );
+            getByText(/latex/i);
+            await waitFor(() => expect(container.querySelector(".taipy-text-latex")).toBeInTheDocument());
+        });
+        it("renders LaTeX as inline math", async () => {
+            const { container, getByText, findByText } = render(
+                <Field value={"This is inline $x = y + 1$ math."} className="taipy-text" mode="latex" />
+            );
+            // getByText(/latex/i); // already loaded ?
+            await waitFor(() => expect(container.querySelector(".taipy-text-latex")).toBeInTheDocument());
+            expect(await findByText(/inline/i)).toBeInTheDocument();
+        });
+    });
 });

+ 74 - 16
frontend/taipy-gui/src/components/Taipy/Field.tsx

@@ -11,11 +11,12 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { lazy, useMemo } from "react";
+import React, { lazy, useMemo, Suspense } from "react";
 import Typography from "@mui/material/Typography";
 import Tooltip from "@mui/material/Tooltip";
 
 import { formatWSValue } from "../../utils";
+import { getSuffixedClassNames } from "./utils";
 import { useClassNames, useDynamicProperty, useFormatConfig } from "../../utils/hooks";
 import { TaipyBaseProps, TaipyHoverProps, getCssSize } from "./utils";
 
@@ -32,6 +33,23 @@ interface TaipyFieldProps extends TaipyBaseProps, TaipyHoverProps {
 const unsetWeightSx = { fontWeight: "unset" };
 
 const Markdown = lazy(() => import("react-markdown"));
+const MathJax = lazy(() => import("better-react-mathjax").then((module) => ({ default: module.MathJax })));
+const MathJaxContext = lazy(() =>
+    import("better-react-mathjax").then((module) => ({ default: module.MathJaxContext }))
+);
+
+const mathJaxConfig = {
+    tex: {
+        inlineMath: [
+            ["$", "$"],
+            ["\\(", "\\)"],
+        ],
+        displayMath: [
+            ["$$", "$$"],
+            ["\\[", "\\]"],
+        ],
+    },
+};
 
 const Field = (props: TaipyFieldProps) => {
     const { id, dataType, format, defaultValue, raw } = props;
@@ -65,21 +83,61 @@ const Field = (props: TaipyFieldProps) => {
 
     return (
         <Tooltip title={hover || ""}>
-            {mode == "pre" ? (
-                <pre className={className} id={id} style={style}>
-                    {value}
-                </pre>
-            ) : mode == "markdown" || mode == "md" ? (
-                <Markdown className={className}>{value}</Markdown>
-            ) : raw || mode == "raw" ? (
-                <span className={className} id={id} style={style}>
-                    {value}
-                </span>
-            ) : (
-                <Typography className={className} id={id} component="span" sx={typoSx}>
-                    {value}
-                </Typography>
-            )}
+            <>
+                {mode == "pre" ? (
+                    <pre
+                        className={`${className} ${getSuffixedClassNames(className, "-pre")}`}
+                        id={id}
+                        style={style}
+                    >
+                        {value}
+                    </pre>
+                ) : mode == "markdown" || mode == "md" ? (
+                    <Suspense fallback={<div>Loading Markdown...</div>}>
+                        <Markdown
+                            className={`${className} ${getSuffixedClassNames(
+                                className,
+                                "-markdown"
+                            )}`}
+                        >
+                            {value}
+                        </Markdown>
+                    </Suspense>
+                ) : raw || mode == "raw" ? (
+                    <span
+                        className={`${className} ${getSuffixedClassNames(className, "-raw")}`}
+                        id={id}
+                        style={style}
+                    >
+                        {value}
+                    </span>
+                ) : mode == "latex" ? (
+                    <Suspense fallback={<div>Loading LaTex...</div>}>
+                        <MathJaxContext config={mathJaxConfig}>
+                            <MathJax
+                                className={`${className} ${getSuffixedClassNames(
+                                    className,
+                                    "-latex"
+                                )}`}
+                                id={id}
+                            >
+                                {value}
+                            </MathJax>
+                        </MathJaxContext>
+                    </Suspense>
+                ) : (
+                    <Typography
+                        className={`${className} ${
+                            mode ? getSuffixedClassNames(className, "-" + mode) : ""
+                        }`}
+                        id={id}
+                        component="span"
+                        sx={typoSx}
+                    >
+                        {value}
+                    </Typography>
+                )}
+            </>
         </Tooltip>
     );
 };

+ 64 - 39
frontend/taipy-gui/src/components/Taipy/Input.tsx

@@ -30,9 +30,9 @@ const getActionKeys = (keys?: string): string[] => {
     const ak = (
         keys
             ? keys
-                  .split(";")
-                  .map((v) => v.trim().toLowerCase())
-                  .filter((v) => AUTHORIZED_KEYS.some((k) => k.toLowerCase() === v))
+                .split(";")
+                .map((v) => v.trim().toLowerCase())
+                .filter((v) => AUTHORIZED_KEYS.some((k) => k.toLowerCase() === v))
             : []
     ).map((v) => AUTHORIZED_KEYS.find((k) => k.toLowerCase() == v) as string);
     return ak.length > 0 ? ak : [AUTHORIZED_KEYS[0]];
@@ -63,6 +63,7 @@ const Input = (props: TaipyInputProps) => {
         onAction,
         onChange,
         multiline = false,
+        actionOnBlur = false,
         linesShown = 5,
     } = props;
 
@@ -85,9 +86,9 @@ const Input = (props: TaipyInputProps) => {
         () =>
             props.width
                 ? {
-                      ...numberSx,
-                      maxWidth: getCssSize(props.width),
-                  }
+                    ...numberSx,
+                    maxWidth: getCssSize(props.width),
+                }
                 : numberSx,
         [props.width]
     );
@@ -138,6 +139,27 @@ const Input = (props: TaipyInputProps) => {
         [changeDelay, dispatch, updateVarName, module, onChange, propagate]
     );
 
+    const handleBlur = useCallback(
+        (evt: React.FocusEvent<HTMLInputElement>) => {
+            const val = (type === "number")
+                ? Number(evt.currentTarget.querySelector("input")?.value)
+                : (multiline
+                    ? evt.currentTarget.querySelector("textarea")?.value
+                    : evt.currentTarget.querySelector("input")?.value)
+                ;
+            if (delayCall.current > 0) {
+                if (changeDelay > 0) {
+                    clearTimeout(delayCall.current);
+                    delayCall.current = -1;
+                }
+                dispatch(createSendUpdateAction(updateVarName, val, module, onChange, propagate));
+            }
+            onAction && dispatch(createSendActionNameAction(id, module, onAction, "Tab", updateVarName, val));
+            evt.preventDefault();
+        },
+        [dispatch, type, updateVarName, module, onChange, propagate, changeDelay, id, multiline, onAction]
+    );
+
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (evt.shiftKey && type === "number") {
@@ -296,22 +318,22 @@ const Input = (props: TaipyInputProps) => {
                     },
                 }
                 : type == "password"
-                ? {
-                      htmlInput: { autoComplete: "current-password" },
-                      input: {
-                          endAdornment: (
-                              <IconButton
-                                  aria-label="toggle password visibility"
-                                  onClick={handleClickShowPassword}
-                                  onMouseDown={handleMouseDownPassword}
-                                  edge="end"
-                              >
-                                  {showPassword ? <VisibilityOff /> : <Visibility />}
-                              </IconButton>
-                          ),
-                      },
-                  }
-                : undefined,
+                    ? {
+                        htmlInput: { autoComplete: "current-password" },
+                        input: {
+                            endAdornment: (
+                                <IconButton
+                                    aria-label="toggle password visibility"
+                                    onClick={handleClickShowPassword}
+                                    onMouseDown={handleMouseDownPassword}
+                                    edge="end"
+                                >
+                                    {showPassword ? <VisibilityOff /> : <Visibility />}
+                                </IconButton>
+                            ),
+                        },
+                    }
+                    : undefined,
         [
             active,
             type,
@@ -334,23 +356,26 @@ const Input = (props: TaipyInputProps) => {
 
     return (
         <Tooltip title={hover || ""}>
-            <TextField
-                sx={textSx}
-                margin="dense"
-                hiddenLabel
-                value={value ?? ""}
-                className={className}
-                type={showPassword && type == "password" ? "text" : type}
-                id={id}
-                slotProps={inputProps}
-                label={props.label}
-                onChange={handleInput}
-                disabled={!active}
-                onKeyDown={handleAction}
-                multiline={multiline}
-                minRows={linesShown}
-                maxRows={linesShown}
-            />
+            <>
+                <TextField
+                    sx={textSx}
+                    margin="dense"
+                    hiddenLabel
+                    value={value ?? ""}
+                    className={className}
+                    type={showPassword && type == "password" ? "text" : type}
+                    id={id}
+                    slotProps={inputProps}
+                    label={props.label}
+                    onChange={handleInput}
+                    onBlur={actionOnBlur ? handleBlur : undefined}
+                    disabled={!active}
+                    onKeyDown={handleAction}
+                    multiline={multiline}
+                    minRows={linesShown}
+                    maxRows={linesShown}
+                />
+            </>
         </Tooltip>
     );
 };

+ 38 - 0
frontend/taipy-gui/src/components/Taipy/Selector.spec.tsx

@@ -214,6 +214,44 @@ describe("Selector Component", () => {
             await userEvent.click(elt);
             expect(queryAllByRole("listbox")).toHaveLength(0);
         });
+        it("renders selectionMessage if defined", async () => {
+            const { getByText, getByRole } = render(<Selector lov={lov} dropdown={true} selectionMessage="a selection message" />);
+            const butElt = getByRole("combobox");
+            expect(butElt).toBeInTheDocument();
+            await userEvent.click(butElt);
+            getByRole("listbox");
+            const elt = getByText("Item 2");
+            await userEvent.click(elt);
+            const msg = getByText("a selection message");
+            expect(msg).toBeInTheDocument();
+        });
+        it("renders showSelectAll in dropdown if True", async () => {
+            const { getByText, getByRole } = render(<Selector lov={lov} dropdown={true} multiple={true} showSelectAll={true} />);
+            const checkElt = getByRole("checkbox");
+            expect(checkElt).toBeInTheDocument();
+            expect(checkElt).not.toBeChecked();
+            const butElt = getByRole("combobox");
+            await userEvent.click(butElt);
+            getByRole("listbox");
+            const elt = getByText("Item 2");
+            await userEvent.click(elt);
+            expect(checkElt.parentElement).toHaveClass("MuiCheckbox-indeterminate");
+            await userEvent.click(checkElt);
+            expect(checkElt).toBeChecked();
+        });
+        it("renders showSelectAll in list if True", async () => {
+            const { getByText, getByRole } = render(<Selector lov={lov} multiple={true} showSelectAll={true} />);
+            const msgElt = getByText(/select all/i);
+            expect(msgElt).toBeInTheDocument();
+            const checkElement = msgElt.parentElement?.querySelector("input");
+            expect(checkElement).not.toBeNull();
+            expect(checkElement).not.toBeChecked();
+            const elt = getByText("Item 2");
+            await userEvent.click(elt);
+            expect(checkElement?.parentElement).toHaveClass("MuiCheckbox-indeterminate");
+            checkElement && await userEvent.click(checkElement);
+            expect(checkElement).toBeChecked();
+        });
     });
 
     describe("Selector Component with dropdown + filter", () => {

+ 279 - 169
frontend/taipy-gui/src/components/Taipy/Selector.tsx

@@ -122,7 +122,15 @@ const renderBoxSx = {
     width: "100%",
 } as CSSProperties;
 
-const Selector = (props: SelTreeProps) => {
+interface SelectorProps extends SelTreeProps {
+    dropdown?: boolean;
+    mode?: string;
+    defaultSelectionMessage?: string;
+    selectionMessage?: string;
+    showSelectAll?: boolean;
+}
+
+const Selector = (props: SelectorProps) => {
     const {
         id,
         defaultValue = "",
@@ -137,6 +145,7 @@ const Selector = (props: SelTreeProps) => {
         height,
         valueById,
         mode = "",
+        showSelectAll = false,
     } = props;
     const [searchValue, setSearchValue] = useState("");
     const [selectedValue, setSelectedValue] = useState<string[]>([]);
@@ -147,6 +156,7 @@ const Selector = (props: SelTreeProps) => {
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
+    const selectionMessage = useDynamicProperty(props.selectionMessage, props.defaultSelectionMessage, undefined);
 
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars, updateVarName);
 
@@ -283,6 +293,24 @@ const Selector = (props: SelTreeProps) => {
         [dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
     );
 
+    const handleCheckAllChange = useCallback(
+        (event: SelectChangeEvent<HTMLInputElement>, checked: boolean) => {
+            const sel = checked ? lovList.map((elt) => elt.id) : [];
+            setSelectedValue(sel);
+            dispatch(
+                createSendUpdateAction(
+                    updateVarName,
+                    sel,
+                    module,
+                    props.onChange,
+                    propagate,
+                    valueById ? undefined : getUpdateVar(updateVars, "lov")
+                )
+            );
+        },
+        [lovList, dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
+    );
+
     const [autoValue, setAutoValue] = useState<LovItem | LovItem[] | null>(() => (multiple ? [] : null));
     const handleAutoChange = useCallback(
         (e: SyntheticEvent, sel: LovItem | LovItem[] | null) => {
@@ -329,183 +357,265 @@ const Selector = (props: SelTreeProps) => {
     const dropdownValue = ((dropdown || isRadio) &&
         (multiple ? selectedValue : selectedValue.length ? selectedValue[0] : "")) as string[];
 
-    return isRadio || isCheck ? (
-        <FormControl sx={controlSx} className={className}>
-            {props.label ? <FormLabel>{props.label}</FormLabel> : null}
-            <Tooltip title={hover || ""}>
-                {isRadio ? (
-                    <RadioGroup
-                        value={dropdownValue}
-                        onChange={handleChange}
-                        className={getSuffixedClassNames(className, "-radio-group")}
-                    >
-                        {lovList.map((item) => (
-                            <FormControlLabel
-                                key={item.id}
-                                value={item.id}
-                                control={<Radio />}
-                                label={
-                                    typeof item.item === "string" ? item.item : <LovImage item={item.item as Icon} />
-                                }
-                                style={getStyles(item.id, selectedValue, theme)}
-                                disabled={!active}
-                            />
-                        ))}
-                    </RadioGroup>
-                ) : (
-                    <FormGroup className={getSuffixedClassNames(className, "-check-group")}>
-                        {lovList.map((item) => (
-                            <FormControlLabel
-                                key={item.id}
-                                control={
-                                    <Checkbox
-                                        data-id={item.id}
-                                        checked={selectedValue.includes(item.id)}
-                                        onChange={changeHandler}
+    return (
+        <>
+            {isRadio || isCheck ? (
+                <FormControl sx={controlSx} className={className}>
+                    {props.label ? <FormLabel>{props.label}</FormLabel> : null}
+                    <Tooltip title={hover || ""}>
+                        {isRadio ? (
+                            <RadioGroup
+                                value={dropdownValue}
+                                onChange={handleChange}
+                                className={getSuffixedClassNames(className, "-radio-group")}
+                            >
+                                {lovList.map((item) => (
+                                    <FormControlLabel
+                                        key={item.id}
+                                        value={item.id}
+                                        control={<Radio />}
+                                        label={
+                                            typeof item.item === "string" ? (
+                                                item.item
+                                            ) : (
+                                                <LovImage item={item.item as Icon} />
+                                            )
+                                        }
+                                        style={getStyles(item.id, selectedValue, theme)}
+                                        disabled={!active}
                                     />
-                                }
-                                label={
-                                    typeof item.item === "string" ? item.item : <LovImage item={item.item as Icon} />
-                                }
-                                style={getStyles(item.id, selectedValue, theme)}
-                                disabled={!active}
-                            ></FormControlLabel>
-                        ))}
-                    </FormGroup>
-                )}
-            </Tooltip>
-        </FormControl>
-    ) : dropdown ? (
-        filter ? (
-            <Tooltip title={hover || ""} placement="top">
-                <Autocomplete
-                    id={id}
-                    disabled={!active}
-                    multiple={multiple}
-                    options={lovList}
-                    value={autoValue}
-                    onChange={handleAutoChange}
-                    getOptionLabel={getOptionLabel}
-                    getOptionKey={getOptionKey}
-                    isOptionEqualToValue={isOptionEqualToValue}
-                    sx={controlSx}
-                    className={className}
-                    renderInput={(params) => <TextField {...params} label={props.label} margin="dense" />}
-                    renderOption={renderOption}
-                />
-            </Tooltip>
-        ) : (
-            <FormControl sx={controlSx} className={className}>
-                {props.label ? <InputLabel disableAnimation>{props.label}</InputLabel> : null}
-                <Tooltip title={hover || ""} placement="top">
-                    <Select
-                        id={id}
-                        multiple={multiple}
-                        value={dropdownValue}
-                        onChange={handleChange}
-                        input={<OutlinedInput label={props.label} />}
-                        disabled={!active}
-                        renderValue={(selected) => (
-                            <Box sx={renderBoxSx}>
-                                {lovList
-                                    .filter((it) =>
-                                        Array.isArray(selected) ? selected.includes(it.id) : selected === it.id
-                                    )
-                                    .map((item, idx) => {
-                                        if (multiple) {
-                                            const chipProps = {} as Record<string, unknown>;
-                                            if (typeof item.item === "string") {
-                                                chipProps.label = item.item;
-                                            } else {
-                                                chipProps.label = item.item.text || "";
-                                                chipProps.avatar = <Avatar src={item.item.path} />;
-                                            }
-                                            return (
-                                                <Chip
-                                                    key={item.id}
-                                                    {...chipProps}
-                                                    onDelete={handleDelete}
-                                                    data-id={item.id}
-                                                    onMouseDown={doNotPropagateEvent}
-                                                    disabled={!active}
-                                                />
-                                            );
-                                        } else if (idx === 0) {
-                                            return typeof item.item === "string" ? (
+                                ))}
+                            </RadioGroup>
+                        ) : (
+                            <FormGroup className={getSuffixedClassNames(className, "-check-group")}>
+                                {lovList.map((item) => (
+                                    <FormControlLabel
+                                        key={item.id}
+                                        control={
+                                            <Checkbox
+                                                data-id={item.id}
+                                                checked={selectedValue.includes(item.id)}
+                                                onChange={changeHandler}
+                                            />
+                                        }
+                                        label={
+                                            typeof item.item === "string" ? (
                                                 item.item
                                             ) : (
-                                                <LovImage item={item.item} />
-                                            );
-                                        } else {
-                                            return null;
+                                                <LovImage item={item.item as Icon} />
+                                            )
                                         }
-                                    })}
-                            </Box>
+                                        style={getStyles(item.id, selectedValue, theme)}
+                                        disabled={!active}
+                                    ></FormControlLabel>
+                                ))}
+                            </FormGroup>
                         )}
-                        MenuProps={getMenuProps(height)}
-                    >
-                        {lovList.map((item) => (
-                            <MenuItem
-                                key={item.id}
-                                value={item.id}
-                                style={getStyles(item.id, selectedValue, theme)}
-                                disabled={item.id === null}
-                            >
-                                {typeof item.item === "string" ? item.item : <LovImage item={item.item as Icon} />}
-                            </MenuItem>
-                        ))}
-                    </Select>
-                </Tooltip>
-            </FormControl>
-        )
-    ) : (
-        <FormControl sx={controlSx} className={className}>
-            {props.label ? (
-                <InputLabel disableAnimation className="static-label">
-                    {props.label}
-                </InputLabel>
-            ) : null}
-            <Tooltip title={hover || ""}>
-                <Paper sx={paperSx}>
-                    {filter && (
-                        <Box>
-                            <OutlinedInput
-                                margin="dense"
-                                placeholder="Search field"
-                                value={searchValue}
-                                onChange={handleInput}
+                    </Tooltip>
+                </FormControl>
+            ) : dropdown ? (
+                filter ? (
+                    <Tooltip title={hover || ""} placement="top">
+                        <Autocomplete
+                            id={id}
+                            disabled={!active}
+                            multiple={multiple}
+                            options={lovList}
+                            value={autoValue}
+                            onChange={handleAutoChange}
+                            getOptionLabel={getOptionLabel}
+                            getOptionKey={getOptionKey}
+                            isOptionEqualToValue={isOptionEqualToValue}
+                            sx={controlSx}
+                            className={className}
+                            renderInput={(params) => <TextField {...params} label={props.label} margin="dense" />}
+                            renderOption={renderOption}
+                        />
+                    </Tooltip>
+                ) : (
+                    <FormControl sx={controlSx} className={className}>
+                        {props.label ? <InputLabel disableAnimation>{props.label}</InputLabel> : null}
+                        <Tooltip title={hover || ""} placement="top">
+                            <Select
+                                id={id}
+                                multiple={multiple}
+                                value={dropdownValue}
+                                onChange={handleChange}
+                                input={
+                                    <OutlinedInput
+                                        label={props.label}
+                                        startAdornment={
+                                            multiple && showSelectAll ? (
+                                                <Tooltip
+                                                    title={
+                                                        selectedValue.length == lovList.length
+                                                            ? "Deselect All"
+                                                            : "Select All"
+                                                    }
+                                                >
+                                                    <Checkbox
+                                                        disabled={!active}
+                                                        indeterminate={
+                                                            selectedValue.length > 0 &&
+                                                            selectedValue.length < lovList.length
+                                                        }
+                                                        checked={selectedValue.length == lovList.length}
+                                                        onChange={handleCheckAllChange}
+                                                    ></Checkbox>
+                                                </Tooltip>
+                                            ) : null
+                                        }
+                                    />
+                                }
                                 disabled={!active}
-                            />
-                        </Box>
-                    )}
-                    <List sx={listSx} id={id}>
-                        {lovList
-                            .filter((elt) => showItem(elt, searchValue))
-                            .map((elt) =>
-                                multiple ? (
-                                    <MultipleItem
-                                        key={elt.id}
-                                        value={elt.id}
-                                        item={elt.item}
-                                        selectedValue={selectedValue}
-                                        clickHandler={clickHandler}
+                                renderValue={(selected) => (
+                                    <Box sx={renderBoxSx}>
+                                        {typeof selectionMessage === "string"
+                                            ? selectionMessage
+                                            : lovList
+                                                  .filter((it) =>
+                                                      Array.isArray(selected)
+                                                          ? selected.includes(it.id)
+                                                          : selected === it.id
+                                                  )
+                                                  .map((item, idx) => {
+                                                      if (multiple) {
+                                                          const chipProps = {} as Record<string, unknown>;
+                                                          if (typeof item.item === "string") {
+                                                              chipProps.label = item.item;
+                                                          } else {
+                                                              chipProps.label = item.item.text || "";
+                                                              chipProps.avatar = <Avatar src={item.item.path} />;
+                                                          }
+                                                          return (
+                                                              <Chip
+                                                                  key={item.id}
+                                                                  {...chipProps}
+                                                                  onDelete={handleDelete}
+                                                                  data-id={item.id}
+                                                                  onMouseDown={doNotPropagateEvent}
+                                                                  disabled={!active}
+                                                              />
+                                                          );
+                                                      } else if (idx === 0) {
+                                                          return typeof item.item === "string" ? (
+                                                              item.item
+                                                          ) : (
+                                                              <LovImage item={item.item} />
+                                                          );
+                                                      } else {
+                                                          return null;
+                                                      }
+                                                  })}
+                                    </Box>
+                                )}
+                                MenuProps={getMenuProps(height)}
+                            >
+                                {lovList.map((item) => (
+                                    <MenuItem
+                                        key={item.id}
+                                        value={item.id}
+                                        style={getStyles(item.id, selectedValue, theme)}
+                                        disabled={item.id === null}
+                                    >
+                                        {typeof item.item === "string" ? (
+                                            item.item
+                                        ) : (
+                                            <LovImage item={item.item as Icon} />
+                                        )}
+                                    </MenuItem>
+                                ))}
+                            </Select>
+                        </Tooltip>
+                    </FormControl>
+                )
+            ) : (
+                <FormControl sx={controlSx} className={className}>
+                    {props.label ? (
+                        <InputLabel disableAnimation className="static-label">
+                            {props.label}
+                        </InputLabel>
+                    ) : null}
+                    <Tooltip title={hover || ""}>
+                        <Paper sx={paperSx}>
+                            {filter ? (
+                                <Box>
+                                    <OutlinedInput
+                                        margin="dense"
+                                        placeholder="Search field"
+                                        value={searchValue}
+                                        onChange={handleInput}
                                         disabled={!active}
+                                        startAdornment={
+                                            multiple && showSelectAll ? (
+                                                <Tooltip
+                                                    title={
+                                                        selectedValue.length == lovList.length
+                                                            ? "Deselect All"
+                                                            : "Select All"
+                                                    }
+                                                >
+                                                    <Checkbox
+                                                        disabled={!active}
+                                                        indeterminate={
+                                                            selectedValue.length > 0 &&
+                                                            selectedValue.length < lovList.length
+                                                        }
+                                                        checked={selectedValue.length == lovList.length}
+                                                        onChange={handleCheckAllChange}
+                                                    ></Checkbox>
+                                                </Tooltip>
+                                            ) : null
+                                        }
                                     />
-                                ) : (
-                                    <SingleItem
-                                        key={elt.id}
-                                        value={elt.id}
-                                        item={elt.item}
-                                        selectedValue={selectedValue}
-                                        clickHandler={clickHandler}
-                                        disabled={!active}
+                                </Box>
+                            ) : multiple && showSelectAll ? (
+                                <Box paddingLeft={1}>
+                                    <FormControlLabel
+                                        control={
+                                            <Checkbox
+                                                disabled={!active}
+                                                indeterminate={
+                                                    selectedValue.length > 0 && selectedValue.length < lovList.length
+                                                }
+                                                checked={selectedValue.length == lovList.length}
+                                                onChange={handleCheckAllChange}
+                                            ></Checkbox>
+                                        }
+                                        label={selectedValue.length == lovList.length ? "Deselect All" : "Select All"}
                                     />
-                                )
-                            )}
-                    </List>
-                </Paper>
-            </Tooltip>
-        </FormControl>
+                                </Box>
+                            ) : null}
+                            <List sx={listSx} id={id}>
+                                {lovList
+                                    .filter((elt) => showItem(elt, searchValue))
+                                    .map((elt) =>
+                                        multiple ? (
+                                            <MultipleItem
+                                                key={elt.id}
+                                                value={elt.id}
+                                                item={elt.item}
+                                                selectedValue={selectedValue}
+                                                clickHandler={clickHandler}
+                                                disabled={!active}
+                                            />
+                                        ) : (
+                                            <SingleItem
+                                                key={elt.id}
+                                                value={elt.id}
+                                                item={elt.item}
+                                                selectedValue={selectedValue}
+                                                clickHandler={clickHandler}
+                                                disabled={!active}
+                                            />
+                                        )
+                                    )}
+                            </List>
+                        </Paper>
+                    </Tooltip>
+                </FormControl>
+            )}
+        </>
     );
 };
 

+ 1 - 0
frontend/taipy-gui/src/components/Taipy/utils.ts

@@ -60,6 +60,7 @@ export interface TaipyInputProps extends TaipyActiveProps, TaipyChangeProps, Tai
     changeDelay?: number;
     onAction?: string;
     actionKeys?: string;
+    actionOnBlur?: boolean;
     multiline?: boolean;
     linesShown?: number;
     width?: string | number;

+ 30 - 0
frontend/taipy-gui/src/utils/image.ts

@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+export const toDataUrl = (url: string | null) =>
+    new Promise((resolve, reject) => {
+        if (!url) {
+            resolve(null);
+        }
+        const xhr = new XMLHttpRequest();
+        xhr.onload = () => {
+            const reader = new FileReader();
+            reader.onloadend = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsDataURL(xhr.response);
+        };
+        xhr.onerror = reject;
+        xhr.open("GET", url || "");
+        xhr.responseType = "blob";
+        xhr.send();
+    });

+ 11 - 0
frontend/taipy-gui/test-config/markdown.tsx

@@ -0,0 +1,11 @@
+import React, { ReactNode } from "react";
+
+interface ChildrenProps {
+    children: ReactNode;
+}
+
+function ReactMarkdownMock({ children }: ChildrenProps) {
+    return <p>{children}</p>;
+}
+
+export default ReactMarkdownMock;

Datei-Diff unterdrückt, da er zu groß ist
+ 361 - 336
frontend/taipy/package-lock.json


+ 3 - 2
frontend/taipy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "taipy-gui-core",
-  "version": "4.0.3",
+  "version": "4.1.0",
   "private": true,
   "devDependencies": {
     "@types/react": "^18.0.15",
@@ -30,7 +30,8 @@
     "fast-deep-equal": "^3.1.3",
     "formik": "^2.2.9",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "taipy-gui": "file:../../taipy/gui/webapp"
   },
   "scripts": {
     "postinstall": "node scripts/install.js",

+ 7 - 1
taipy/gui/_renderers/factory.py

@@ -97,8 +97,10 @@ class _Factory:
                 ("users", PropertyType.lov),
                 ("sender_id",),
                 ("height",),
-                ("page_size", PropertyType.number, 50),
+                ("page_size", PropertyType.number),
+                ("max_file_size", PropertyType.number),
                 ("show_sender", PropertyType.boolean, False),
+                ("allow_send_images", PropertyType.boolean, True),
                 ("mode",),
             ]
         ),
@@ -302,6 +304,7 @@ class _Factory:
                 ("action_keys",),
                 ("label",),
                 ("change_delay", PropertyType.number, gui._get_config("change_delay", None)),
+                ("action_on_blur", PropertyType.boolean, False),
                 ("multiline", PropertyType.boolean, False),
                 ("lines_shown", PropertyType.number, 5),
                 ("width", PropertyType.string_or_number),
@@ -409,6 +412,7 @@ class _Factory:
                 ("on_action", PropertyType.function),
                 ("label",),
                 ("change_delay", PropertyType.number, gui._get_config("change_delay", None)),
+                ("action_on_blur", PropertyType.boolean, False),
                 ("width", PropertyType.string_or_number),
             ]
         ),
@@ -480,6 +484,8 @@ class _Factory:
                 ("label",),
                 ("mode",),
                 ("lov", PropertyType.lov),
+                ("selection_message", PropertyType.dynamic_string),
+                ("show_select_all", PropertyType.boolean),
             ]
         )
         ._set_propagate(),

+ 27 - 7
taipy/gui/gui.py

@@ -24,7 +24,7 @@ import typing as t
 import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
-from inspect import currentframe, getabsfile, ismethod, ismodule, isroutine
+from inspect import currentframe, getabsfile, iscoroutinefunction, ismethod, ismodule, isroutine
 from pathlib import Path
 from threading import Timer
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
@@ -71,7 +71,7 @@ from .extension.library import Element, ElementLibrary
 from .page import Page
 from .partial import Partial
 from .server import _Server
-from .state import State
+from .state import State, _AsyncState, _GuiState
 from .types import _WsType
 from .utils import (
     _delscopeattr,
@@ -89,6 +89,7 @@ from .utils import (
     _getscopeattr_drill,
     _hasscopeattr,
     _is_in_notebook,
+    _is_unnamed_function,
     _LocalsContext,
     _MapDict,
     _setscopeattr,
@@ -110,6 +111,7 @@ from .utils._evaluator import _Evaluator
 from .utils._variable_directory import _is_moduled_variable, _VariableDirectory
 from .utils.chart_config_builder import _build_chart_config
 from .utils.table_col_builder import _enhance_columns
+from .utils.threads import _invoke_async_callback
 
 
 class Gui:
@@ -1129,6 +1131,7 @@ class Gui:
             for var, val in state_context.items():
                 self._update_var(var, val, True, forward=False)
 
+
     def __request_data_update(self, var_name: str, payload: t.Any) -> None:
         # Use custom attrgetter function to allow value binding for _MapDict
         newvalue = _getscopeattr_drill(self, var_name)
@@ -1547,7 +1550,12 @@ class Gui:
 
     def _call_function_with_state(self, user_function: t.Callable, args: t.Optional[t.List[t.Any]] = None) -> t.Any:
         cp_args = [] if args is None else args.copy()
-        cp_args.insert(0, self.__get_state())
+        cp_args.insert(
+            0,
+            _AsyncState(t.cast(_GuiState, self.__get_state()))
+            if iscoroutinefunction(user_function)
+            else self.__get_state(),
+        )
         argcount = user_function.__code__.co_argcount
         if argcount > 0 and ismethod(user_function):
             argcount -= 1
@@ -1555,7 +1563,10 @@ class Gui:
             cp_args += (argcount - len(cp_args)) * [None]
         else:
             cp_args = cp_args[:argcount]
-        return user_function(*cp_args)
+        if iscoroutinefunction(user_function):
+            return _invoke_async_callback(user_function, cp_args)
+        else:
+            return user_function(*cp_args)
 
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
@@ -2278,8 +2289,15 @@ class Gui:
         callback: t.Optional[t.Union[str, t.Callable]] = None,
         message: t.Optional[str] = "Work in Progress...",
     ):  # pragma: no cover
-        action_name = callback.__name__ if callable(callback) else callback
-        # TODO: what if lambda? (it does work)
+        action_name = (
+            callback
+            if isinstance(callback, str)
+            else _get_lambda_id(t.cast(LambdaType, callback))
+            if _is_unnamed_function(callback)
+            else callback.__name__
+            if callback is not None
+            else None
+        )
         func = self.__get_on_cancel_block_ui(action_name)
         def_action_name = func.__name__
         _setscopeattr(self, def_action_name, func)
@@ -2805,7 +2823,9 @@ class Gui:
         self.__var_dir.set_default(self.__frame)
 
         if self.__state is None or is_reloading:
-            self.__state = State(self, self.__locals_context.get_all_keys(), self.__locals_context.get_all_context())
+            self.__state = _GuiState(
+                self, self.__locals_context.get_all_keys(), self.__locals_context.get_all_context()
+            )
 
         if _is_in_notebook():
             # Allow gui.state.x in notebook mode

+ 9 - 7
taipy/gui/gui_actions.py

@@ -15,6 +15,7 @@ import typing as t
 from ._warnings import _warn
 from .gui import Gui
 from .state import State
+from .utils.callable import _is_function
 
 
 def download(
@@ -372,19 +373,20 @@ def invoke_long_callback(
     """
     if not state or not isinstance(state._gui, Gui):
         _warn("'invoke_long_callback()' must be called in the context of a callback.")
+        return
 
     if user_status_function_args is None:
         user_status_function_args = []
     if user_function_args is None:
         user_function_args = []
 
-    state_id = get_state_id(state)
-    module_context = get_module_context(state)
+    this_gui = state.get_gui()
+
+    state_id = this_gui._get_client_id()
+    module_context = this_gui._get_locals_context()
     if not isinstance(state_id, str) or not isinstance(module_context, str):
         return
 
-    this_gui = state._gui
-
     def callback_on_exception(state: State, function_name: str, e: Exception):
         if not this_gui._call_on_exception(function_name, e):
             _warn(f"invoke_long_callback(): Exception raised in function {function_name}()", e)
@@ -395,10 +397,10 @@ def invoke_long_callback(
         function_name: t.Optional[str] = None,
         function_result: t.Optional[t.Any] = None,
     ):
-        if callable(user_status_function):
+        if _is_function(user_status_function):
             this_gui.invoke_callback(
                 str(state_id),
-                user_status_function,
+                t.cast(t.Callable, user_status_function),
                 [status] + list(user_status_function_args) + [function_result],  # type: ignore
                 str(module_context),
             )
@@ -428,5 +430,5 @@ def invoke_long_callback(
 
     thread = threading.Thread(target=user_function_in_thread, args=user_function_args)
     thread.start()
-    if isinstance(period, int) and period >= 500 and callable(user_status_function):
+    if isinstance(period, int) and period >= 500 and _is_function(user_status_function):
         thread_status(thread.name, period / 1000.0, 0)

+ 10 - 0
taipy/gui/mock/__init__.py

@@ -0,0 +1,10 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.

+ 62 - 0
taipy/gui/mock/mock_state.py

@@ -0,0 +1,62 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+import typing as t
+
+from .. import Gui, State
+from ..utils import _MapDict
+
+
+class MockState(State):
+    """A Mock implementation for `State`.
+    TODO
+    example of use:
+    ```py
+    def test_callback():
+        ms = MockState(Gui(""), a = 1)
+        on_action(ms) # function to test
+        assert ms.a == 2
+    ```
+    """
+
+    __VARS = "vars"
+
+    def __init__(self, gui: Gui, **kwargs) -> None:
+        super().__setattr__(MockState.__VARS, {k: _MapDict(v) if isinstance(v, dict) else v for k, v in kwargs.items()})
+        self._gui = gui
+        super().__init__()
+
+    def get_gui(self) -> Gui:
+        return self._gui
+
+    def __getattribute__(self, name: str) -> t.Any:
+        if (attr := t.cast(dict, super().__getattribute__(MockState.__VARS)).get(name, None)) is not None:
+            return attr
+        try:
+            return super().__getattribute__(name)
+        except Exception:
+            return None
+
+    def __setattr__(self, name: str, value: t.Any) -> None:
+        t.cast(dict, super().__getattribute__(MockState.__VARS))[name] = (
+            _MapDict(value) if isinstance(value, dict) else value
+        )
+
+    def __getitem__(self, key: str):
+        return self
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        return True
+
+    def broadcast(self, name: str, value: t.Any):
+        pass

+ 130 - 94
taipy/gui/state.py

@@ -11,6 +11,7 @@
 
 import inspect
 import typing as t
+from abc import ABCMeta, abstractmethod
 from contextlib import nullcontext
 from operator import attrgetter
 from pathlib import Path
@@ -25,7 +26,7 @@ if t.TYPE_CHECKING:
     from .gui import Gui
 
 
-class State(SimpleNamespace):
+class State(SimpleNamespace, metaclass=ABCMeta):
     """Accessor to the bound variables from callbacks.
 
     `State` is used when you need to access the value of variables
@@ -73,6 +74,87 @@ class State(SimpleNamespace):
     ```
     """
 
+    def __init__(self) -> None:
+        self._gui: "Gui"
+
+    @abstractmethod
+    def get_gui(self) -> "Gui":
+        """Return the Gui instance for this state object.
+
+        Returns:
+            Gui: The Gui instance for this state object.
+        """
+        raise NotImplementedError
+
+    def assign(self, name: str, value: t.Any) -> t.Any:
+        """Assign a value to a state variable.
+
+        This should be used only from within a lambda function used
+        as a callback in a visual element.
+
+        Arguments:
+            name (str): The variable name to assign to.
+            value (Any): The new variable value.
+
+        Returns:
+            Any: The previous value of the variable.
+        """
+        val = attrgetter(name)(self)
+        _attrsetter(self, name, value)
+        return val
+
+    def refresh(self, name: str):
+        """Refresh a state variable.
+
+        This allows to re-sync the user interface with a variable value.
+
+        Arguments:
+            name (str): The variable name to refresh.
+        """
+        val = attrgetter(name)(self)
+        _attrsetter(self, name, val)
+
+    def _set_context(self, gui: "Gui") -> t.ContextManager[None]:
+        return nullcontext()
+
+    def broadcast(self, name: str, value: t.Any):
+        """Update a variable on all clients.
+
+        All connected clients will receive an update of the variable called *name* with the
+        provided value, even if it is not shared.
+
+        Arguments:
+            name (str): The variable name to update.
+            value (Any): The new variable value.
+        """
+        with self._set_context(self._gui):
+            encoded_name = self._gui._bind_var(name)
+            self._gui._broadcast_all_clients(encoded_name, value)
+
+    def __enter__(self):
+        self._gui.__enter__()
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        return self._gui.__exit__(exc_type, exc_value, traceback)
+
+    def set_favicon(self, favicon_path: t.Union[str, Path]):
+        """Change the favicon for the client of this state.
+
+        This function dynamically changes the favicon (the icon associated with the application's
+        pages) of Taipy GUI pages for the specific client of this state.
+
+        Note that the *favicon* parameter to `(Gui.)run()^` can also be used to change
+        the favicon when the application starts.
+
+        Arguments:
+            favicon_path: The path to the image file to use.<br/>
+                This can be expressed as a path name or a URL (relative or not).
+        """
+        self._gui.set_favicon(favicon_path, self)
+
+
+class _GuiState(State):
     __gui_attr = "_gui"
     __attrs = (
         __gui_attr,
@@ -93,75 +175,70 @@ class State(SimpleNamespace):
         "_get_placeholder_attrs",
         "_add_attribute",
     )
-    __placeholder_attrs = (
-        "_taipy_p1",
-        "_current_context",
-    )
+    __placeholder_attrs = ("_taipy_p1", "_current_context", "__state_id")
     __excluded_attrs = __attrs + __methods + __placeholder_attrs
 
     def __init__(self, gui: "Gui", var_list: t.Iterable[str], context_list: t.Iterable[str]) -> None:
-        super().__setattr__(State.__attrs[1], list(State.__filter_var_list(var_list, State.__excluded_attrs)))
-        super().__setattr__(State.__attrs[2], list(context_list))
-        super().__setattr__(State.__attrs[0], gui)
-
-    def get_gui(self) -> "Gui":
-        """Return the Gui instance for this state object.
-
-        Returns:
-            Gui: The Gui instance for this state object.
-        """
-        return super().__getattribute__(State.__gui_attr)
+        super().__setattr__(
+            _GuiState.__attrs[1], list(_GuiState.__filter_var_list(var_list, _GuiState.__excluded_attrs))
+        )
+        super().__setattr__(_GuiState.__attrs[2], list(context_list))
+        super().__setattr__(_GuiState.__attrs[0], gui)
+        super().__init__()
 
     @staticmethod
     def __filter_var_list(var_list: t.Iterable[str], excluded_attrs: t.Iterable[str]) -> t.Iterable[str]:
         return filter(lambda n: n not in excluded_attrs, var_list)
 
+    def get_gui(self) -> "Gui":
+        return super().__getattribute__(_GuiState.__gui_attr)
+
     def __getattribute__(self, name: str) -> t.Any:
         if name == "__class__":
-            return State
-        if name in State.__methods:
+            return _GuiState
+        if name in _GuiState.__methods:
             return super().__getattribute__(name)
         gui: "Gui" = self.get_gui()
-        if name == State.__gui_attr:
+        if name == _GuiState.__gui_attr:
             return gui
-        if name in State.__excluded_attrs:
+        if name in _GuiState.__excluded_attrs:
             raise AttributeError(f"Variable '{name}' is protected and is not accessible.")
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
         ):
             raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.")
-        if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]):
+        if not name.startswith("__") and name not in super().__getattribute__(_GuiState.__attrs[1]):
             raise AttributeError(f"Variable '{name}' is not defined.")
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             return getattr(gui._bindings(), encoded_name)
 
     def __setattr__(self, name: str, value: t.Any) -> None:
-        gui: "Gui" = super().__getattribute__(State.__gui_attr)
+        gui: "Gui" = super().__getattribute__(_GuiState.__gui_attr)
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
         ):
             raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.")
-        if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]):
+        if not name.startswith("__") and name not in super().__getattribute__(_GuiState.__attrs[1]):
             raise AttributeError(f"Variable '{name}' is not accessible.")
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             setattr(gui._bindings(), encoded_name, value)
 
     def __getitem__(self, key: str):
-        context = key if key in super().__getattribute__(State.__attrs[2]) else None
+        context = key if key in super().__getattribute__(_GuiState.__attrs[2]) else None
         if context is None:
-            gui: "Gui" = super().__getattribute__(State.__gui_attr)
+            gui: "Gui" = super().__getattribute__(_GuiState.__gui_attr)
             page_ctx = gui._get_page_context(key)
             context = page_ctx if page_ctx is not None else None
         if context is None:
             raise RuntimeError(f"Can't resolve context '{key}' from state object")
-        self._set_placeholder(State.__placeholder_attrs[1], context)
+        self._set_placeholder(_GuiState.__placeholder_attrs[1], context)
         return self
 
     def _set_context(self, gui: "Gui") -> t.ContextManager[None]:
-        if (pl_ctx := self._get_placeholder(State.__placeholder_attrs[1])) is not None:
-            self._set_placeholder(State.__placeholder_attrs[1], None)
+        if (pl_ctx := self._get_placeholder(_GuiState.__placeholder_attrs[1])) is not None:
+            self._set_placeholder(_GuiState.__placeholder_attrs[1], None)
             if pl_ctx != gui._get_locals_context():
                 return gui._set_locals_context(pl_ctx)
         if len(inspect.stack()) > 1:
@@ -176,7 +253,7 @@ class State(SimpleNamespace):
         return gui.get_flask_app().app_context() if not has_app_context() and _is_in_notebook() else nullcontext()
 
     def _get_placeholder(self, name: str):
-        if name in State.__placeholder_attrs:
+        if name in _GuiState.__placeholder_attrs:
             try:
                 return super().__getattribute__(name)
             except AttributeError:
@@ -184,81 +261,40 @@ class State(SimpleNamespace):
         return None
 
     def _set_placeholder(self, name: str, value: t.Any):
-        if name in State.__placeholder_attrs:
+        if name in _GuiState.__placeholder_attrs:
             super().__setattr__(name, value)
 
     def _get_placeholder_attrs(self):
-        return State.__placeholder_attrs
+        return _GuiState.__placeholder_attrs
 
     def _add_attribute(self, name: str, default_value: t.Optional[t.Any] = None) -> bool:
-        attrs: t.List[str] = super().__getattribute__(State.__attrs[1])
+        attrs: t.List[str] = super().__getattribute__(_GuiState.__attrs[1])
         if name not in attrs:
             attrs.append(name)
-            gui = super().__getattribute__(State.__gui_attr)
+            gui = super().__getattribute__(_GuiState.__gui_attr)
             return gui._bind_var_val(name, default_value)
         return False
 
-    def assign(self, name: str, value: t.Any) -> t.Any:
-        """Assign a value to a state variable.
-
-        This should be used only from within a lambda function used
-        as a callback in a visual element.
-
-        Arguments:
-            name (str): The variable name to assign to.
-            value (Any): The new variable value.
-
-        Returns:
-            Any: The previous value of the variable.
-        """
-        val = attrgetter(name)(self)
-        _attrsetter(self, name, value)
-        return val
-
-    def refresh(self, name: str):
-        """Refresh a state variable.
-
-        This allows to re-sync the user interface with a variable value.
-
-        Arguments:
-            name (str): The variable name to refresh.
-        """
-        val = attrgetter(name)(self)
-        _attrsetter(self, name, val)
-
-    def broadcast(self, name: str, value: t.Any):
-        """Update a variable on all clients.
-
-        All connected clients will receive an update of the variable called *name* with the
-        provided value, even if it is not shared.
-
-        Arguments:
-            name (str): The variable name to update.
-            value (Any): The new variable value.
-        """
-        gui: "Gui" = super().__getattribute__(State.__gui_attr)
-        with self._set_context(gui):
-            encoded_name = gui._bind_var(name)
-            gui._broadcast_all_clients(encoded_name, value)
-
-    def __enter__(self):
-        super().__getattribute__(State.__attrs[0]).__enter__()
-        return self
 
-    def __exit__(self, exc_type, exc_value, traceback):
-        return super().__getattribute__(State.__attrs[0]).__exit__(exc_type, exc_value, traceback)
-
-    def set_favicon(self, favicon_path: t.Union[str, Path]):
-        """Change the favicon for the client of this state.
-
-        This function dynamically changes the favicon (the icon associated with the application's
-        pages) of Taipy GUI pages for the specific client of this state.
+class _AsyncState(_GuiState):
+    def __init__(self, state: State) -> None:
+        super().__init__(state.get_gui(), [], [])
+        self._set_placeholder("__state_id", state.get_gui()._get_client_id())
 
-        Note that the *favicon* parameter to `(Gui.)run()^` can also be used to change
-        the favicon when the application starts.
+    @staticmethod
+    def __set_var_in_state(state: State, var_name: str, value: t.Any):
+        setattr(state, var_name, value)
 
-        Arguments:
-            favicon_path: The path to the image file to use.<br/>
-                This can be expressed as a path name or a URL (relative or not).
-        """
-        super().__getattribute__(State.__gui_attr).set_favicon(favicon_path, self)
+    @staticmethod
+    def __get_var_from_state(state: State, var_name: str):
+        return getattr(state, var_name)
+
+    def __setattr__(self, var_name: str, var_value: t.Any) -> None:
+        self.get_gui().invoke_callback(
+            t.cast(str, self._get_placeholder("__state_id")), _AsyncState.__set_var_in_state, [var_name, var_value]
+        )
+
+    def __getattr__(self, var_name: str) -> t.Any:
+        return self.get_gui().invoke_callback(
+            t.cast(str, self._get_placeholder("__state_id")), _AsyncState.__get_var_from_state, [var_name]
+        )

+ 1 - 0
taipy/gui/utils/__init__.py

@@ -23,6 +23,7 @@ from ._map_dict import _MapDict
 from ._runtime_manager import _RuntimeManager
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from .boolean import _is_boolean, _is_true
+from .callable import _is_unnamed_function
 from .clientvarname import _get_broadcast_var_name, _get_client_var_name, _to_camel_case
 from .datatype import _get_data_type
 from .date import _date_to_string, _string_to_date

+ 34 - 0
taipy/gui/utils/callable.py

@@ -0,0 +1,34 @@
+# Copyright 2021-2025 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import typing as t
+from inspect import isclass
+
+
+def _is_function(s: t.Any) -> bool:
+    return callable(s) and not isclass(s)
+
+
+def _function_name(s: t.Any) -> str:
+    if hasattr(s, "__name__"):
+        return s.__name__
+    elif callable(s):
+        return f"<instance of {type(s).__name__}>"
+    else:
+        return str(s)
+
+
+def _is_unnamed_function(s: t.Any):
+    return (
+        (hasattr(s, "__name__") and s.__name__ == "<lambda>")
+        or (callable(s) and not hasattr(s, "__name__"))
+        or (hasattr(s, "__qualname__") and "<locals>" in s.__qualname__)
+    )

+ 22 - 0
taipy/gui/utils/threads.py

@@ -0,0 +1,22 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+import asyncio
+import threading
+import typing as t
+
+
+def _thread_async_target(user_function, args: t.List[t.Any]):
+    asyncio.run(user_function(*args))
+
+
+def _invoke_async_callback(user_function, args: t.List[t.Any]):
+    thread = threading.Thread(target=_thread_async_target, args=[user_function, args])
+    thread.start()

+ 99 - 56
taipy/gui/viselements.json

@@ -23,7 +23,7 @@
                     {
                         "name": "mode",
                         "type": "str",
-                        "doc": "Define the way the text is processed:\n<ul><li>&quot;raw&quot;: synonym for setting the <i>raw</i> property to True</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown."
+                        "doc": "Define the way the text is processed:\n<ul><li>&quot;raw&quot;: synonym for setting the <i>raw</i> property to True</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown</li><li>&quot;latex&quot;: LaTe&chi; support</li>"
                     },
                     {
                         "name": "format",
@@ -34,7 +34,7 @@
                         "name": "width",
                         "type": "Union[str,int]",
                         "default_value": "None",
-                        "doc": "The width of the element."
+                        "doc": "The width of the text element, in CSS units."
                     }
                 ]
             }
@@ -102,31 +102,37 @@
                         "name": "password",
                         "type": "bool",
                         "default_value": "False",
-                        "doc": "If True, the text is obscured: all input characters are displayed as an asterisk ('*')."
+                        "doc": "If True, the text is obscured, and all characters are displayed as asterisks ('*').<br/>This can be useful for sensitive information such as passwords."
                     },
                     {
                         "name": "label",
                         "type": "str",
                         "default_value": "None",
-                        "doc": "The label associated with the input."
+                        "doc": "The label associated with the input field.<br/>This provides context to the user and improves accessibility."
                     },
                     {
                         "name": "multiline",
                         "type": "bool",
                         "default_value": "False",
-                        "doc": "If True, the text is presented as a multi line input."
+                        "doc": "If True, the input is rendered as a multi-line text area<br/>The default behavior is a single-line input."
                     },
                     {
                         "name": "lines_shown",
                         "type": "int",
                         "default_value": "5",
-                        "doc": "The number of lines shown in the input control, when multiline is True."
+                        "doc": "The number of lines displayed in the input control when multiline is True."
                     },
                     {
                         "name": "type",
                         "type": "str",
                         "default_value": "\"text\"",
-                        "doc": "The type of generated input HTML element, as defined in [HTML input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types).<br/>This value forces certain values to be entered and can be set to \"text\", \"tel\", \"email\", \"url\"..., among other choices."
+                        "doc": "The type of input element, as per <a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types\">HTML input types</a>.<br/>This property enforces specific input formats where applicable. Supported values include \"text\", \"tel\", \"email\", \"url\", etc."
+                    },
+                    {
+                        "name": "action_on_blur",
+                        "type": "bool",
+                        "default_value": "False",
+                        "doc": "If True, the <code>on_action</code> callback is triggered when the input control looses keyboard focus (e.g., when the user presses the Tab key). When this happens, the key name for the event (set in the <i>args</i> property of the <i>payload</i> parameter to the callback function) is set to \"Tab\"."
                     }
                 ]
             }
@@ -150,29 +156,35 @@
                         "name": "label",
                         "type": "str",
                         "default_value": "None",
-                        "doc": "The label associated with the input."
+                        "doc": "The label associated with the number field.<br/>This provides context to the user and improves accessibility."
                     },
                     {
                         "name": "step",
                         "type": "dynamic(Union[int,float])",
                         "default_value": "1",
-                        "doc": "The amount by which the value is incremented or decremented when the user clicks one of the arrow buttons."
+                        "doc": "The increment or decrement applied to the value when the user clicks the arrow buttons."
                     },
                     {
                         "name": "step_multiplier",
                         "type": "dynamic(Union[int,float])",
                         "default_value": "10",
-                        "doc": "A factor that multiplies <i>step</i> when the user presses the Shift key while clicking one of the arrow buttons."
+                        "doc": "The factor by which the step value is multiplied when the user holds the Shift key while clicking the arrow buttons."
                     },
                     {
                         "name": "min",
                         "type": "dynamic(Union[int,float])",
-                        "doc": "The minimum value to accept for this input."
+                        "doc": "The minimum acceptable value.<br/>Values below this threshold are invalid."
                     },
                     {
                         "name": "max",
                         "type": "dynamic(Union[int,float])",
-                        "doc": "The maximum value to accept for this input."
+                        "doc": "The maximum acceptable value.<br/>Values above this threshold are invalid."
+                    },
+                    {
+                        "name": "action_on_blur",
+                        "type": "bool",
+                        "default_value": "False",
+                        "doc": "If True, the <code>on_action</code> callback is triggered when the number control looses keyboard focus (e.g., when the user presses the Tab key). When this happens, the key name for the event (set in the <i>args</i> property of the <i>payload</i> parameter to the callback function) is set to \"Tab\"."
                     }
                 ]
             }
@@ -224,30 +236,30 @@
                         "name": "continuous",
                         "type": "bool",
                         "default_value": "True",
-                        "doc": "If set to False, the control emits an <tt>on_change</tt> notification only when the mouse button is released, otherwise notifications are emitted during the cursor movements.<br/>If <i>lov</i> is defined, the default value is False."
+                        "doc": "If set to False, the control emits an <code>on_change</code> notification only when the mouse button is released, otherwise notifications are emitted during the cursor movements.<br/>If <i>lov</i> is defined, the default value is False."
                     },
                     {
                         "name": "change_delay",
                         "type": "int",
                         "default_value": "<i>App config</i>",
-                        "doc": "Minimum time between triggering two <tt>on_change</tt> callbacks.<br/>The default value is defined at the application configuration level by the <strong>change_delay</strong> configuration option. if None or 0, there's no delay."
+                        "doc": "Minimum time between triggering two <code>on_change</code> callbacks.<br/>The default value is defined at the application configuration level by the <strong>change_delay</strong> configuration option. if None or 0, there's no delay."
                     },
                     {
                         "name": "width",
                         "type": "str",
                         "default_value": "\"300px\"",
-                        "doc": "The width of this slider, in CSS units."
+                        "doc": "The width of the slider, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "str",
-                        "doc": "The height of this slider, in CSS units.<br/>It defaults to the value of <i>width</i> when using the vertical orientation."
+                        "doc": "The height of the slider, in CSS units.<br/>It defaults to the value of <i>width</i> when using the vertical orientation."
                     },
                     {
                         "name": "orientation",
                         "type": "str",
                         "default_value": "\"horizontal\"",
-                        "doc": "The orientation of this slider.<br/>Valid values are \"horizontal\" or \"vertical\"."
+                        "doc": "The orientation of the slider.<br/>Valid values are \"horizontal\" or \"vertical\"."
                     }
                 ]
             }
@@ -525,7 +537,7 @@
                     {
                         "name": "title",
                         "type": "str",
-                        "doc": "The title of this chart control."
+                        "doc": "The title of the chart control."
                     },
                     {
                         "name": "render",
@@ -596,7 +608,7 @@
                     {
                         "name": "selected_marker",
                         "type": "indexed(dict[str, Any])",
-                        "doc": "The type of markers used for selected points in the indicated trace.<br/>See <a href=\"https://plotly.com/javascript/reference/scatter/#scatter-selected-marker\">selected marker for more details."
+                        "doc": "The type of markers used for selected points in the indicated trace.<br/>See <a href=\"https://plotly.com/javascript/reference/scatter/#scatter-selected-marker\">selected marker for more details.</a>"
                     },
                     {
                         "name": "layout",
@@ -637,12 +649,12 @@
                         "name": "width",
                         "type": "Union[str,int,float]",
                         "default_value": "\"100%\"",
-                        "doc": "The width of this chart, in CSS units."
+                        "doc": "The width of the chart, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "Union[str,int,float]",
-                        "doc": "The height of this chart, in CSS units."
+                        "doc": "The height of the chart, in CSS units."
                     },
                     {
                         "name": "template",
@@ -832,13 +844,13 @@
                         "name": "width",
                         "type": "str",
                         "default_value": "\"100%\"",
-                        "doc": "The width of this table control, in CSS units."
+                        "doc": "The width of the table control, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "str",
                         "default_value": "\"80vh\"",
-                        "doc": "The height of this table control, in CSS units."
+                        "doc": "The height of the table control, in CSS units."
                     },
                     {
                         "name": "filter",
@@ -880,7 +892,7 @@
                         "name": "on_edit",
                         "type": "Union[bool, Callable]",
                         "default_value": "<i>default implementation</i>",
-                        "doc": "A function or the name of a function triggered when an edited cell is validated.<br/>This function is invoked with the following parameters:<ul><li><i>state</i> (<code>State^</code>): the state instance.</li><li><i>var_name</i> (str): the name of the tabular data variable.</li><li><i>payload</i> (dict): a dictionary containing details about the callback invocation, with the following keys:<ul><li><i>index</i> (int): the row index.</li><li><i>col</i> (str): the column name.</li><li><i>value</i> (Any): the new cell value, cast to the column's data type.</li><li><i>user_value</i> (str): the new cell value, as entered by the user.</li><li><i>tz</i> (str): the timezone, if the column type is <tt>date</tt>.</li></ul></li></ul>If this property is set to False, the table does not provide the cell editing functionality.<br/>If this property is not set, the table will use the default implementation for editing cells.",
+                        "doc": "A function or the name of a function triggered when an edited cell is validated.<br/>This function is invoked with the following parameters:<ul><li><i>state</i> (<code>State^</code>): the state instance.</li><li><i>var_name</i> (str): the name of the tabular data variable.</li><li><i>payload</i> (dict): a dictionary containing details about the callback invocation, with the following keys:<ul><li><i>index</i> (int): the row index.</li><li><i>col</i> (str): the column name.</li><li><i>value</i> (Any): the new cell value, cast to the column's data type.</li><li><i>user_value</i> (str): the new cell value, as entered by the user.</li><li><i>tz</i> (str): the timezone, if the column type is <code>date</code>.</li></ul></li></ul>If this property is set to False, the table does not provide the cell editing functionality.<br/>If this property is not set, the table will use the default implementation for editing cells.",
                         "signature": [
                             [
                                 "state",
@@ -1028,7 +1040,7 @@
                     {
                         "name": "mode",
                         "type": "str",
-                        "doc": "Define the way the selector is displayed:\n<ul><li>&quot;radio&quot;: as a list of radio buttons</li><li>&quot;check&quot;: as a list of check boxes</li><li>any other value: a plain list."
+                        "doc": "Define the way the selector is displayed:\n<ul><li>&quot;radio&quot;: as a list of radio buttons</li><li>&quot;check&quot;: as a list of check boxes</li><li>any other value: a plain list.</ul>"
                     },
                     {
                         "name": "dropdown",
@@ -1036,12 +1048,23 @@
                         "default_value": "False",
                         "doc": "If True, the list of items is shown in a dropdown menu.<br/><br/>You cannot use the filter in that situation."
                     },
+                    {
+                        "name": "selection_message",
+                        "type": "dynamic(str)",
+                        "doc": "TODO the message shown in the selection area of a dropdown selector when at least one element is selected, list the selected elements if None."
+                    },
                     {
                         "name": "multiple",
                         "type": "bool",
                         "default_value": "False",
                         "doc": "If True, the user can select multiple items."
                     },
+                    {
+                        "name": "show_select_all",
+                        "type": "bool",
+                        "default_value": "False",
+                        "doc": "TODO If True and multiple, show a select all option"
+                    },
                     {
                         "name": "filter",
                         "type": "bool",
@@ -1052,12 +1075,12 @@
                         "name": "width",
                         "type": "Union[str,int]",
                         "default_value": "\"360px\"",
-                        "doc": "The width of this selector, in CSS units."
+                        "doc": "The width of the selector, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "Union[str,int]",
-                        "doc": "The height of this selector, in CSS units."
+                        "doc": "The height of the selector, in CSS units."
                     }
                 ]
             }
@@ -1245,12 +1268,12 @@
                         "name": "width",
                         "type": "Union[str,int,float]",
                         "default_value": "\"300px\"",
-                        "doc": "The width of this image control, in CSS units."
+                        "doc": "The width of the image control, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "Union[str,int,float]",
-                        "doc": "The height of this image control, in CSS units."
+                        "doc": "The height of the image control, in CSS units."
                     }
                 ]
             }
@@ -1278,13 +1301,13 @@
                         "name": "min",
                         "type": "Union[int,float]",
                         "default_value": "0",
-                        "doc": "The minimum value of this metric control's gauge."
+                        "doc": "The minimum value of the metric control's gauge."
                     },
                     {
                         "name": "max",
                         "type": "Union[int,float]",
                         "default_value": "100",
-                        "doc": "The maximum value of this metric control's gauge."
+                        "doc": "The maximum value of the metric control's gauge."
                     },
                     {
                         "name": "delta",
@@ -1417,7 +1440,7 @@
                         "name": "width",
                         "type": "Union[str,int]",
                         "default_value": "None",
-                        "doc": "The width of this progress indicator, in CSS units."
+                        "doc": "The width of the progress indicator, in CSS units."
                     }
                 ]
             }
@@ -1462,7 +1485,7 @@
                         "name": "orientation",
                         "type": "str",
                         "default_value": "\"horizontal\"",
-                        "doc": "The orientation of this slider."
+                        "doc": "The orientation of the indicator."
                     },
                     {
                         "name": "width",
@@ -1499,7 +1522,7 @@
                     {
                         "name": "adapter",
                         "type": "Union[str, Callable]",
-                        "default_value": "<tt>lambda x: str(x)</tt>",
+                        "default_value": "<code>lambda x: str(x)</code>",
                         "doc": "A function or the name of the function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>.<br/>The default value is a function that returns the string representation of the <i>lov</i> element."
                     },
                     {
@@ -1692,7 +1715,7 @@
                     {
                         "name": "height",
                         "type": "Union[str,int,float]",
-                        "doc": "The maximum height of this chat control, in CSS units."
+                        "doc": "The maximum height of the chat control, in CSS units."
                     },
                     {
                         "name": "show_sender",
@@ -1705,6 +1728,18 @@
                         "type": "str",
                         "default_value": "\"markdown\"",
                         "doc": "Define the way the messages are processed when they are displayed:\n<ul><li>&quot;raw&quot; no processing</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown.</li></ul>"
+                    },
+                    {
+                        "name": "max_file_size",
+                        "type": "int",
+                        "default_value": "0.8 * 1024 * 1024",
+                        "doc": "The maximum allowable file size, in bytes, for files uploaded to a chat message.\nThe default is 0.8 MB."
+                    },
+                    {
+                        "name": "allow_send_images",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "TODO if True, an upload image icon is shown."
                     }
                 ]
             }
@@ -1737,7 +1772,15 @@
                     {
                         "name": "row_height",
                         "type": "str",
-                        "doc": "The height of each row of this tree, in CSS units."
+                        "doc": "The height of each row of the tree, in CSS units."
+                    },
+                    {
+                        "name": "mode",
+                        "hide": true
+                    },
+                    {
+                        "name": "dropdown",
+                        "hide": true
                     }
                 ]
             }
@@ -1772,7 +1815,7 @@
                     {
                         "name": "height",
                         "type": "dynamic(str)",
-                        "doc": "The height, in CSS units, of this block."
+                        "doc": "The height of the part, in CSS units."
                     },
                     {
                         "name": "content",
@@ -1795,7 +1838,7 @@
                         "name": "title",
                         "default_property": true,
                         "type": "dynamic(str)",
-                        "doc": "Title of this block element."
+                        "doc": "Title of the expandable block."
                     },
                     {
                         "name": "expanded",
@@ -1845,22 +1888,22 @@
                         "name": "close_label",
                         "type": "str",
                         "default_value": "\"Close\"",
-                        "doc": "The tooltip of the top-right close icon button. In the <tt>on_action</tt> callback, <i>args</i> will be set to -1."
+                        "doc": "The tooltip of the top-right close icon button. In the <code>on_action</code> callback, <i>args</i> will be set to -1."
                     },
                     {
                         "name": "labels",
                         "type": "Union[str,list[str]]",
-                        "doc": "A list of labels to show in a row of buttons at the bottom of the dialog. The index of the button in the list is reported as args in the <tt>on_action</tt> callback (that index is -1 for the <i>close</i> icon)."
+                        "doc": "A list of labels to show in a row of buttons at the bottom of the dialog. The index of the button in the list is reported as args in the <code>on_action</code> callback (that index is -1 for the <i>close</i> icon)."
                     },
                     {
                         "name": "width",
                         "type": "Union[str,int,float]",
-                        "doc": "The width of this dialog, in CSS units."
+                        "doc": "The width of the dialog, in CSS units."
                     },
                     {
                         "name": "height",
                         "type": "Union[str,int,float]",
-                        "doc": "The height of this dialog, in CSS units."
+                        "doc": "The height of the dialog, in CSS units."
                     }
                 ]
             }
@@ -1942,13 +1985,13 @@
                         "name": "width",
                         "type": "str",
                         "default_value": "\"30vw\"",
-                        "doc": "Width, in CSS units, of this pane.<br/>This is used only if <i>anchor</i> is \"left\" or \"right\"."
+                        "doc": "Width of the pane, in CSS units.<br/>This is used only if <i>anchor</i> is \"left\" or \"right\"."
                     },
                     {
                         "name": "height",
                         "type": "str",
                         "default_value": "\"30vh\"",
-                        "doc": "Height, in CSS units, of this pane.<br/>This is used only if <i>anchor</i> is \"top\" or \"bottom\"."
+                        "doc": "Height of this pane, in CSS units.<br/>This is used only if <i>anchor</i> is \"top\" or \"bottom\"."
                     },
                     {
                         "name": "show_button",
@@ -1969,7 +2012,7 @@
                         "name": "active",
                         "type": "dynamic(bool)",
                         "default_value": "True",
-                        "doc": "Indicates if this component is active.<br/>An inactive component allows no user interaction."
+                        "doc": "Indicates if this element is active.<br/>If False, the element is disabled, and user interaction is not allowed."
                     }
                 ]
             }
@@ -1995,7 +2038,7 @@
                     {
                         "name": "adapter",
                         "type": "Union[str, Callable]",
-                        "default_value": "<tt>lambda x: str(x)</tt>",
+                        "default_value": "<code>lambda x: str(x)</code>",
                         "doc": "A function or the name of the function that transforms an element of <i>lov</i> into a <i>tuple(id:str, label:Union[str,Icon])</i>.<br/>The default value is a function that returns the string representation of the <i>lov</i> element."
                     },
                     {
@@ -2020,7 +2063,7 @@
                     {
                         "name": "on_change",
                         "type": "Union[str, Callable]",
-                        "doc": "A function or the name of a function that is triggered when the value is updated.<br/>This function is invoked with the following parameters:<ul>\n<li>state (<code>State^</code>): the state instance.</li><li>var_name (str): the variable name.</li><li>value (Any): the new value.</li></ul>",
+                        "doc": "A function or the name of a function that is triggered when the value changes.<br/>The callback function receives the following parameters:<ul>\n<li>state (<code>State^</code>): the state instance.</li><li>var_name (str): the bound variable name.</li><li>value (Any): the updated value.</li></ul>",
                         "signature": [
                             [
                                 "state",
@@ -2068,7 +2111,7 @@
                         "name": "propagate",
                         "type": "bool",
                         "default_value": "<i>App config</i>",
-                        "doc": "Allows the control's main value to be automatically propagated.<br/>The default value is defined at the application configuration level by the <strong>propagate</strong> configuration option.<br/>If True, any change to the control's value is immediately reflected in the bound application variable."
+                        "doc": "Determines whether the control's value is automatically reflected in the bound application variable.<br/>The default value is defined at the application configuration level by the <strong>propagate</strong> configuration option.<br/>If True, any change to the control's value is immediately reflected in the variable."
                     }
                 ]
             }
@@ -2081,12 +2124,12 @@
                         "name": "change_delay",
                         "type": "int",
                         "default_value": "<i>App config</i>",
-                        "doc": "Minimum interval between two consecutive calls to the <tt>on_change</tt> callback.<br/>The default value is defined at the application configuration level by the <strong>change_delay</strong> configuration option.<br/>if None, the delay is set to 300 ms.<br/>If set to -1, the input change is triggered only when the user presses the Enter key."
+                        "doc": "The minimum interval (in milliseconds) between two consecutive calls to the <code>on_change</code> callback.<br/>The default value is defined at the application configuration level by the <strong>change_delay</strong> configuration option.<br/>if None, the delay is set to 300 ms.<br/>If set to -1, the callback is triggered only when the user presses the Enter key."
                     },
                     {
                         "name": "on_action",
                         "type": "Union[str, Callable]",
-                        "doc": "A function or the name of a function that is triggered when a specific key is pressed.<br/>This function is invoked with the following parameters:<ul>\n<li>state (<code>State^</code>): the state instance.</li><li>id (str): the identifier of the control if it has one.</li><li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li><li>args (list):\n<ul><li>key name</li><li>variable name</li><li>current value</li></ul></li></ul></li></ul>",
+                        "doc": "A function or the name of a function that is triggered when a specific key is pressed.<br/>The callback function is invoked with the following parameters:<ul>\n<li>state (<code>State^</code>): the state instance.</li><li>id (str): the identifier of the control if it has one.</li><li>payload (dict): the callback details<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li><li>args (list):\n<ul><li>The key name pressed.</li><li>The variable name.</li><li>The current value of the variable.</li></ul></li></ul></li></ul>",
                         "signature": [
                             [
                                 "state",
@@ -2106,13 +2149,13 @@
                         "name": "action_keys",
                         "type": "str",
                         "default_value": "\"Enter\"",
-                        "doc": "Semicolon (';')-separated list of supported key names.<br/>Authorized values are Enter, Escape, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12."
+                        "doc": "A semicolon-separated list of keys that can trigger the <code>on_action</code> callback.<br/>Authorized values are Enter, Escape, and function keys F1 to F12."
                     },
                     {
                         "name": "width",
                         "type": "Union[str,int]",
                         "default_value": "None",
-                        "doc": "The width of the element."
+                        "doc": "The width of the element, in CSS units."
                     }
                 ]
             }
@@ -2124,22 +2167,22 @@
                     {
                         "name": "id",
                         "type": "str",
-                        "doc": "The identifier that is assigned to the rendered HTML component."
+                        "doc": "The identifier assigned to the rendered HTML component.<br/>This can be used in callbacks or to target the element for styling."
                     },
                     {
                         "name": "properties",
                         "type": "dict[str, Any]",
-                        "doc": "Bound to a dictionary that contains additional properties for this element."
+                        "doc": "A dictionary of additional properties that can be set to the element."
                     },
                     {
                         "name": "class_name",
                         "type": "dynamic(str)",
-                        "doc": "The list of CSS class names that are associated with the generated HTML Element.<br/>These class names are added to the default <code>taipy-[element_type]</code> class name."
+                        "doc": "A space-separated list of CSS class names to be applied to the generated HTML element.<br/>These classes are added to the default <code>taipy-[element_type]</code> class."
                     },
                     {
                         "name": "hover_text",
                         "type": "dynamic(str)",
-                        "doc": "The information that is displayed when the user hovers over this element."
+                        "doc": "The text that is displayed when the user hovers over the element."
                     }
                 ]
             }

+ 1 - 1
taipy/gui_core/viselements.json

@@ -208,7 +208,7 @@
                     {
                         "name": "on_submission_change",
                         "type": "Union[str, Callable]",
-                        "doc": "A function or the name of a function that is triggered when a submission status is changed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>submission (Submission): the submission entity containing submission information.</li>\n<li>details (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>submission_status (str): the new status of the submission (possible values are: \"SUBMITTED\", \"COMPLETED\", \"CANCELED\", \"FAILED\", \"BLOCKED\", \"WAITING\", or \"RUNNING\").</li>\n<li>job: the Job (if any) that is at the origin of the submission status change.</li>\n<li>submittable_entity (Submittable): the entity (usually a Scenario) that was submitted.</li>\n</ul>",
+                        "doc": "A function or the name of a function that is triggered when a submission status is changed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>submission (Submission): the submission entity containing submission information.</li>\n<li>details (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>submission_status (str): the new status of the submission (possible values are: \"SUBMITTED\", \"COMPLETED\", \"CANCELED\", \"FAILED\", \"BLOCKED\", \"WAITING\", or \"RUNNING\").</li>\n<li>job: the Job (if any) that is at the origin of the submission status change.</li>\n<li>submittable_entity (Submittable): the entity (usually a Scenario) that was submitted.</li></ul></ul>",
                         "signature": [
                             [
                                 "state",

+ 1 - 1
tests/gui/actions/test_download.py

@@ -32,7 +32,7 @@ def test_download(gui: Gui, helpers):
     gui.run(run_server=False)
     flask_client = gui._server.test_client()
     # WS client and emit
-    ws_client = gui._server._ws.test_client(gui._server.get_flask())
+    ws_client = gui._server._ws.test_client(t.cast(Flask, gui._server.get_flask()))
     cid = helpers.create_scope_and_get_sid(gui)
     # Get the jsx once so that the page will be evaluated -> variable will be registered
     flask_client.get(f"/taipy-jsx/test?client_id={cid}")

+ 83 - 0
tests/gui/e2e/with_action/test_input.py

@@ -0,0 +1,83 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import inspect
+import logging
+from importlib import util
+
+import pytest
+
+if util.find_spec("playwright"):
+    from playwright._impl._page import Page
+
+from taipy.gui import Gui
+
+
+@pytest.mark.teste2e
+def test_input_action(page: "Page", gui: Gui, helpers):
+    page_md = """
+<|{input1_value}|input|on_action=input_action|id=input1|>
+<|{input2_value}|input|on_action=input_action|id=input2|action_on_blur|>
+<|X|button|id=button1|on_action=button_action|>
+<|{input1_action_tracker}|id=input1_tracker|>
+<|{input2_action_tracker}|id=input2_tracker|>
+<|{button_action_tracker}|id=button_tracker|>
+"""
+    input1_value = "init"  # noqa: F841
+    input2_value = "init"  # noqa: F841
+    input1_action_tracker = 0  # noqa: F841
+    input2_action_tracker = 0  # noqa: F841
+    button_action_tracker = 0  # noqa: F841
+
+    def input_action(state, id):
+        if id == "input1":
+            state.input1_action_tracker = state.input1_action_tracker + 1
+        elif id == "input2":
+            state.input2_action_tracker = state.input2_action_tracker + 1
+
+    def button_action(state, id):
+        state.button_action_tracker = state.button_action_tracker + 1
+
+    gui._set_frame(inspect.currentframe())
+    gui.add_page(name="test", page=page_md)
+    helpers.run_e2e(gui)
+    page.goto("./test")
+    page.expect_websocket()
+    page.wait_for_selector("#input1_tracker")
+    assert page.query_selector("#input1").input_value() == "init", "Wrong initial value"
+    page.click("#button1")
+    try:
+        page.wait_for_function("document.querySelector('#button_tracker').innerText !== '0'")
+    except Exception as e:
+        logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+    assert page.query_selector("#button_tracker").inner_text() == "1"
+    page.click("#input1")
+    page.fill("#input1", "step2")
+    page.click("#button1")
+    try:
+        page.wait_for_function("document.querySelector('#button_tracker').innerText !== '1'")
+    except Exception as e:
+        logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+    assert page.query_selector("#button_tracker").inner_text() == "2", "Button action should have been invoked"
+    assert (
+        page.query_selector("#input1_tracker").inner_text() == "0"
+    ), "Action should not have been invoked (no action_on_blur)"
+    page.click("#input2")
+    page.fill("#input2", "step2")
+    page.click("#button1")
+    try:
+        page.wait_for_function("document.querySelector('#button_tracker').innerText !== '2'")
+    except Exception as e:
+        logging.getLogger().debug(f"Function evaluation timeout.\n{e}")
+    assert page.query_selector("#button_tracker").inner_text() == "3", "Button action should have been invoked"
+    assert (
+        page.query_selector("#input2_tracker").inner_text() == "1"
+    ), "Action should have been invoked (action_on_blur)"

+ 1 - 0
tests/gui/gui_specific/test_state.py

@@ -54,6 +54,7 @@ def test_state(gui: Gui):
         assert state._get_placeholder_attrs() == (
             "_taipy_p1",
             "_current_context",
+            "__state_id"
         )
 
         assert get_a(state) == 20

+ 106 - 0
tests/gui/mock/test_mock_state.py

@@ -0,0 +1,106 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from unittest.mock import Mock
+
+from taipy.gui import Gui, State
+from taipy.gui.mock.mock_state import MockState
+from taipy.gui.utils import _MapDict
+
+
+def test_gui():
+    gui = Gui("")
+    ms = MockState(gui)
+    assert ms.get_gui() is gui
+    assert ms._gui is gui
+
+
+def test_read_attr():
+    gui = Gui("")
+    ms = MockState(gui, a=1)
+    assert ms is not None
+    assert ms.a == 1
+    assert ms.b is None
+
+
+def test_read_context():
+    ms = MockState(Gui(""), a=1)
+    assert ms["b"] is not None
+    assert ms["b"].a == 1
+
+
+def test_write_attr():
+    ms = MockState(Gui(""), a=1)
+    ms.a = 2
+    assert ms.a == 2
+    ms.b = 3
+    assert ms.b == 3
+    ms.a += 1
+    assert ms.a == 3
+
+def test_dict():
+    ms = MockState(Gui(""))
+    a_dict = {"a": 1}
+    ms.d = a_dict
+    assert isinstance(ms.d, _MapDict)
+    assert ms.d._dict is a_dict
+
+
+def test_write_context():
+    ms = MockState(Gui(""), a=1)
+    ms["page"].a = 2
+    assert ms["page"].a == 2
+    ms["page"].b = 3
+    assert ms["page"].b == 3
+
+def test_assign():
+    ms = MockState(Gui(""), a=1)
+    ms.assign("a", 2)
+    assert ms.a == 2
+    ms.assign("b", 1)
+    assert ms.b == 1
+
+def test_refresh():
+    ms = MockState(Gui(""), a=1)
+    ms.refresh("a")
+    assert ms.a == 1
+    ms.a = 2
+    ms.refresh("a")
+    assert ms.a == 2
+
+def test_context_manager():
+    with MockState(Gui(""), a=1) as ms:
+        assert ms is not None
+        ms.a = 2
+    assert ms.a == 2
+
+def test_broadcast():
+    ms = MockState(Gui(""), a=1)
+    ms.broadcast("a", 2)
+
+def test_set_favicon():
+    gui = Gui("")
+    gui.set_favicon = Mock()
+    ms = MockState(gui, a=1)
+    ms.set_favicon("a_path")
+    gui.set_favicon.assert_called_once()
+
+def test_callback():
+    def on_action(state: State):
+        state.assign("a", 2)
+
+    ms = MockState(Gui(""), a=1)
+    on_action(ms)
+    assert ms.a == 2
+
+def test_false():
+    ms = MockState(Gui(""), a=False)
+    assert ms.a is False

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.