瀏覽代碼

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 2 周之前
父節點
當前提交
700502741a
共有 38 個文件被更改,包括 2129 次插入1366 次删除
  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):
 def evaluate(state, var_name: str, payload: dict):
     # Retrieve the callback parameters
     # Retrieve the callback parameters
-    (_, _, expression, sender_id) = payload.get("args", [])
+    (_, _, expression, sender_id, _) = payload.get("args", [])
     # Add the input content as a sent message
     # Add the input content as a sent message
     messages.append((f"{len(messages)}", expression, sender_id))
     messages.append((f"{len(messages)}", expression, sender_id))
     # Default message used if evaluation fails
     # Default message used if evaluation fails
@@ -42,7 +42,7 @@ def evaluate(state, var_name: str, payload: dict):
 
 
 
 
 page = """
 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")
 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.
 # incognito windows so a given user's context is not reused.
 # -----------------------------------------------------------------------------------------
 # -----------------------------------------------------------------------------------------
 from os import path
 from os import path
-from typing import Union
+from typing import Optional, Union, cast
 
 
 from taipy.gui import Gui, Icon
 from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 from taipy.gui.gui_actions import navigate, notify
 
 
 username = ""
 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")
 Gui.add_shared_variables("messages", "users")
 
 
@@ -62,8 +62,8 @@ def register(state):
 
 
 
 
 def send(state, _: str, payload: dict):
 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
     state.messages = messages
 
 
 
 
@@ -82,4 +82,6 @@ discuss_page = """
 """
 """
 
 
 pages = {"register": register_page, "discuss": 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",
   "name": "taipy-gui-base",
-  "version": "4.0.2",
+  "version": "4.1.0",
   "private": true,
   "private": true,
   "main": "./taipy-gui-base.js",
   "main": "./taipy-gui-base.js",
   "types": "./taipy-gui-base.d.ts"
   "types": "./taipy-gui-base.d.ts"

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

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

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

@@ -27,7 +27,7 @@ module.exports = {
     ],
     ],
     coverageReporters: ["json", "html", "text"],
     coverageReporters: ["json", "html", "text"],
     modulePathIgnorePatterns: ["<rootDir>/packaging/"],
     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()
     ...createJsWithTsPreset()
 };
 };

文件差異過大導致無法顯示
+ 189 - 546
frontend/taipy-gui/package-lock.json


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

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "taipy-gui",
   "name": "taipy-gui",
-  "version": "4.0.2",
+  "version": "4.1.0",
   "private": true,
   "private": true,
   "main": "./taipy-gui.js",
   "main": "./taipy-gui.js",
   "types": "./taipy-gui.d.ts"
   "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 React from "react";
-import { render, waitFor } from "@testing-library/react";
+import { render, waitFor, fireEvent } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 import userEvent from "@testing-library/user-event";
 
 
@@ -22,15 +22,24 @@ import { TaipyContext } from "../../context/taipyContext";
 import { stringIcon } from "../../utils/icon";
 import { stringIcon } from "../../utils/icon";
 import { TableValueType } from "./tableUtils";
 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 valueKey = "Infinite-Entity--asc";
 const messages: TableValueType = {
 const messages: TableValueType = {
     [valueKey]: {
     [valueKey]: {
         data: [
         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 user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
 const users = [user1, user2];
 const users = [user1, user2];
@@ -46,32 +55,48 @@ describe("Chat Component", () => {
         expect(input.tagName).toBe("INPUT");
         expect(input.tagName).toBe("INPUT");
     });
     });
     it("uses the class", async () => {
     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);
         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 () => {
     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");
         const elt = getByAltText("Fred.png");
         expect(elt.tagName).toBe("IMG");
         expect(elt.tagName).toBe("IMG");
     });
     });
     it("is disabled", async () => {
     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 () => {
     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 () => {
     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 () => {
     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");
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
         expect(elt).toBeNull();
     });
     });
@@ -81,13 +106,17 @@ describe("Chat Component", () => {
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
         await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
     });
     });
     it("can render pre", async () => {
     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);
         const elt = getByText(searchMsg);
         expect(elt.tagName).toBe("PRE");
         expect(elt.tagName).toBe("PRE");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
         expect(elt.parentElement).toHaveClass("taipy-chat-pre");
     });
     });
     it("can render raw", async () => {
     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);
         const elt = getByText(searchMsg);
         expect(elt).toHaveClass("taipy-chat-raw");
         expect(elt).toHaveClass("taipy-chat-raw");
     });
     });
@@ -96,7 +125,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
         const { getByLabelText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <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>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
@@ -108,7 +137,7 @@ describe("Chat Component", () => {
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 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 state: TaipyState = INITIAL_STATE;
         const { getByLabelText, getByRole } = render(
         const { getByLabelText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <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>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         await userEvent.click(elt);
         await userEvent.keyboard("new message");
         await userEvent.keyboard("new message");
-        await userEvent.click(getByRole("button"))
+        await userEvent.click(getByRole("button", { name: /send message/i }));
         expect(dispatch).toHaveBeenCalledWith({
         expect(dispatch).toHaveBeenCalledWith({
             type: "SEND_ACTION_ACTION",
             type: "SEND_ACTION_ACTION",
             name: "",
             name: "",
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 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,
     useEffect,
     ReactNode,
     ReactNode,
     lazy,
     lazy,
+    ChangeEvent,
     UIEvent,
     UIEvent,
 } from "react";
 } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 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 Send from "@mui/icons-material/Send";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 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 { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { LoVElt, useLovListMemo } from "./lovUtils";
 import { LoVElt, useLovListMemo } from "./lovUtils";
@@ -47,11 +53,14 @@ import { IconAvatar, avatarSx } from "../../utils/icon";
 import { emptyArray, getInitials } from "../../utils";
 import { emptyArray, getInitials } from "../../utils";
 import { RowType, TableValueType } from "./tableUtils";
 import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { Stack } from "@mui/material";
+import { noDisplayStyle } from "./utils";
+import { toDataUrl } from "../../utils/image";
 
 
 const Markdown = lazy(() => import("react-markdown"));
 const Markdown = lazy(() => import("react-markdown"));
 
 
 interface ChatProps extends TaipyActiveProps {
 interface ChatProps extends TaipyActiveProps {
     messages?: TableValueType;
     messages?: TableValueType;
+    maxFileSize?: number;
     withInput?: boolean;
     withInput?: boolean;
     users?: LoVElt[];
     users?: LoVElt[];
     defaultUsers?: string;
     defaultUsers?: string;
@@ -62,6 +71,7 @@ interface ChatProps extends TaipyActiveProps {
     pageSize?: number;
     pageSize?: number;
     showSender?: boolean;
     showSender?: boolean;
     mode?: string;
     mode?: string;
+    allowSendImages?: boolean;
 }
 }
 
 
 const ENTER_KEY = "Enter";
 const ENTER_KEY = "Enter";
@@ -132,7 +142,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
-
+const imageSx = { width: 3 / 5, height: "auto" };
 interface key2Rows {
 interface key2Rows {
     key: string;
     key: string;
 }
 }
@@ -140,6 +150,7 @@ interface key2Rows {
 interface ChatRowProps {
 interface ChatRowProps {
     senderId: string;
     senderId: string;
     message: string;
     message: string;
+    image?: string;
     name: string;
     name: string;
     className?: string;
     className?: string;
     getAvatar: (id: string, sender: boolean) => ReactNode;
     getAvatar: (id: string, sender: boolean) => ReactNode;
@@ -149,7 +160,7 @@ interface ChatRowProps {
 }
 }
 
 
 const ChatRow = (props: 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 sender = senderId == name;
     const avatar = getAvatar(name, sender);
     const avatar = getAvatar(name, sender);
 
 
@@ -162,6 +173,11 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : undefined}
             justifyContent={sender ? "flex-end" : undefined}
         >
         >
             <Grid sx={sender ? senderMsgSx : 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 ? (
                 {(!sender || showSender) && avatar ? (
                     <Stack direction="row" gap={1}>
                     <Stack direction="row" gap={1}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
@@ -213,8 +229,10 @@ const Chat = (props: ChatProps) => {
         onAction,
         onAction,
         withInput = true,
         withInput = true,
         defaultKey = "",
         defaultKey = "",
+        maxFileSize = 0.8 * 1024 * 1024, // 0.8 MB
         pageSize = 50,
         pageSize = 50,
         showSender = false,
         showSender = false,
+        allowSendImages = true,
     } = props;
     } = props;
     const dispatch = useDispatch();
     const dispatch = useDispatch();
     const module = useModule();
     const module = useModule();
@@ -225,8 +243,13 @@ const Chat = (props: ChatProps) => {
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const scrollDivRef = useRef<HTMLDivElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const anchorDivRef = useRef<HTMLElement>(null);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
+    const [enableSend, setEnableSend] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
     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 userScrolled = useRef(false);
 
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
@@ -251,36 +274,98 @@ const Chat = (props: ChatProps) => {
         [props.height]
         [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(
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
             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();
                 evt.preventDefault();
             }
             }
         },
         },
-        [updateVarName, onAction, senderId, id, dispatch, module]
+        [sendAction]
     );
     );
 
 
     const handleClick = useCallback(
     const handleClick = useCallback(
         (evt: MouseEvent<HTMLButtonElement>) => {
         (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();
             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(() => {
     const avatars = useMemo(() => {
         return users.reduce((pv, elt) => {
         return users.reduce((pv, elt) => {
             if (elt.id) {
             if (elt.id) {
@@ -392,6 +477,14 @@ const Chat = (props: ChatProps) => {
         loadMoreItems(0);
         loadMoreItems(0);
     }, [loadMoreItems]);
     }, [loadMoreItems]);
 
 
+    useEffect(() => {
+        return () => {
+            for (const objectURL of objectURLs) {
+                URL.revokeObjectURL(objectURL);
+            }
+        };
+    }, [objectURLs]);
+
     const loadOlder = useCallback(
     const loadOlder = useCallback(
         (evt: MouseEvent<HTMLElement>) => {
         (evt: MouseEvent<HTMLElement>) => {
             const { start } = evt.currentTarget.dataset;
             const { start } = evt.currentTarget.dataset;
@@ -403,7 +496,11 @@ const Chat = (props: ChatProps) => {
     );
     );
 
 
     const handleOnScroll = useCallback((evt: UIEvent) => {
     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 (
     return (
@@ -430,6 +527,11 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 message={`${row[columns[1]]}`}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
+                                image={
+                                    columns[3] && columns[3] != "_tp_index" && row[columns[3]]
+                                        ? `${row[columns[3]]}`
+                                        : undefined
+                                }
                                 className={className}
                                 className={className}
                                 getAvatar={getAvatar}
                                 getAvatar={getAvatar}
                                 index={idx}
                                 index={idx}
@@ -449,31 +551,69 @@ const Chat = (props: ChatProps) => {
                     />
                     />
                 </Popper>
                 </Popper>
                 {withInput ? (
                 {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}
                 ) : null}
             </Paper>
             </Paper>
         </Tooltip>
         </Tooltip>

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

@@ -61,13 +61,31 @@ describe("Field Component", () => {
         expect(elt).toHaveStyle("width: 500px");
         expect(elt).toHaveStyle("width: 500px");
     });
     });
     it("can render markdown", async () => {
     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 () => {
     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();
         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.
  * 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 Typography from "@mui/material/Typography";
 import Tooltip from "@mui/material/Tooltip";
 import Tooltip from "@mui/material/Tooltip";
 
 
 import { formatWSValue } from "../../utils";
 import { formatWSValue } from "../../utils";
+import { getSuffixedClassNames } from "./utils";
 import { useClassNames, useDynamicProperty, useFormatConfig } from "../../utils/hooks";
 import { useClassNames, useDynamicProperty, useFormatConfig } from "../../utils/hooks";
 import { TaipyBaseProps, TaipyHoverProps, getCssSize } from "./utils";
 import { TaipyBaseProps, TaipyHoverProps, getCssSize } from "./utils";
 
 
@@ -32,6 +33,23 @@ interface TaipyFieldProps extends TaipyBaseProps, TaipyHoverProps {
 const unsetWeightSx = { fontWeight: "unset" };
 const unsetWeightSx = { fontWeight: "unset" };
 
 
 const Markdown = lazy(() => import("react-markdown"));
 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 Field = (props: TaipyFieldProps) => {
     const { id, dataType, format, defaultValue, raw } = props;
     const { id, dataType, format, defaultValue, raw } = props;
@@ -65,21 +83,61 @@ const Field = (props: TaipyFieldProps) => {
 
 
     return (
     return (
         <Tooltip title={hover || ""}>
         <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>
         </Tooltip>
     );
     );
 };
 };

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

@@ -30,9 +30,9 @@ const getActionKeys = (keys?: string): string[] => {
     const ak = (
     const ak = (
         keys
         keys
             ? 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);
     ).map((v) => AUTHORIZED_KEYS.find((k) => k.toLowerCase() == v) as string);
     return ak.length > 0 ? ak : [AUTHORIZED_KEYS[0]];
     return ak.length > 0 ? ak : [AUTHORIZED_KEYS[0]];
@@ -63,6 +63,7 @@ const Input = (props: TaipyInputProps) => {
         onAction,
         onAction,
         onChange,
         onChange,
         multiline = false,
         multiline = false,
+        actionOnBlur = false,
         linesShown = 5,
         linesShown = 5,
     } = props;
     } = props;
 
 
@@ -85,9 +86,9 @@ const Input = (props: TaipyInputProps) => {
         () =>
         () =>
             props.width
             props.width
                 ? {
                 ? {
-                      ...numberSx,
-                      maxWidth: getCssSize(props.width),
-                  }
+                    ...numberSx,
+                    maxWidth: getCssSize(props.width),
+                }
                 : numberSx,
                 : numberSx,
         [props.width]
         [props.width]
     );
     );
@@ -138,6 +139,27 @@ const Input = (props: TaipyInputProps) => {
         [changeDelay, dispatch, updateVarName, module, onChange, propagate]
         [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(
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
         (evt: KeyboardEvent<HTMLDivElement>) => {
             if (evt.shiftKey && type === "number") {
             if (evt.shiftKey && type === "number") {
@@ -296,22 +318,22 @@ const Input = (props: TaipyInputProps) => {
                     },
                     },
                 }
                 }
                 : type == "password"
                 : 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,
             active,
             type,
             type,
@@ -334,23 +356,26 @@ const Input = (props: TaipyInputProps) => {
 
 
     return (
     return (
         <Tooltip title={hover || ""}>
         <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>
         </Tooltip>
     );
     );
 };
 };

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

@@ -214,6 +214,44 @@ describe("Selector Component", () => {
             await userEvent.click(elt);
             await userEvent.click(elt);
             expect(queryAllByRole("listbox")).toHaveLength(0);
             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", () => {
     describe("Selector Component with dropdown + filter", () => {

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

@@ -122,7 +122,15 @@ const renderBoxSx = {
     width: "100%",
     width: "100%",
 } as CSSProperties;
 } 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 {
     const {
         id,
         id,
         defaultValue = "",
         defaultValue = "",
@@ -137,6 +145,7 @@ const Selector = (props: SelTreeProps) => {
         height,
         height,
         valueById,
         valueById,
         mode = "",
         mode = "",
+        showSelectAll = false,
     } = props;
     } = props;
     const [searchValue, setSearchValue] = useState("");
     const [searchValue, setSearchValue] = useState("");
     const [selectedValue, setSelectedValue] = useState<string[]>([]);
     const [selectedValue, setSelectedValue] = useState<string[]>([]);
@@ -147,6 +156,7 @@ const Selector = (props: SelTreeProps) => {
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
+    const selectionMessage = useDynamicProperty(props.selectionMessage, props.defaultSelectionMessage, undefined);
 
 
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars, updateVarName);
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars, updateVarName);
 
 
@@ -283,6 +293,24 @@ const Selector = (props: SelTreeProps) => {
         [dispatch, updateVarName, propagate, updateVars, valueById, props.onChange, module]
         [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 [autoValue, setAutoValue] = useState<LovItem | LovItem[] | null>(() => (multiple ? [] : null));
     const handleAutoChange = useCallback(
     const handleAutoChange = useCallback(
         (e: SyntheticEvent, sel: LovItem | LovItem[] | null) => {
         (e: SyntheticEvent, sel: LovItem | LovItem[] | null) => {
@@ -329,183 +357,265 @@ const Selector = (props: SelTreeProps) => {
     const dropdownValue = ((dropdown || isRadio) &&
     const dropdownValue = ((dropdown || isRadio) &&
         (multiple ? selectedValue : selectedValue.length ? selectedValue[0] : "")) as string[];
         (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
                                                 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}
                                 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}
                                         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;
     changeDelay?: number;
     onAction?: string;
     onAction?: string;
     actionKeys?: string;
     actionKeys?: string;
+    actionOnBlur?: boolean;
     multiline?: boolean;
     multiline?: boolean;
     linesShown?: number;
     linesShown?: number;
     width?: string | 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;

文件差異過大導致無法顯示
+ 361 - 336
frontend/taipy/package-lock.json


+ 3 - 2
frontend/taipy/package.json

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

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

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

+ 27 - 7
taipy/gui/gui.py

@@ -24,7 +24,7 @@ import typing as t
 import warnings
 import warnings
 from importlib import metadata, util
 from importlib import metadata, util
 from importlib.util import find_spec
 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 pathlib import Path
 from threading import Timer
 from threading import Timer
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
@@ -71,7 +71,7 @@ from .extension.library import Element, ElementLibrary
 from .page import Page
 from .page import Page
 from .partial import Partial
 from .partial import Partial
 from .server import _Server
 from .server import _Server
-from .state import State
+from .state import State, _AsyncState, _GuiState
 from .types import _WsType
 from .types import _WsType
 from .utils import (
 from .utils import (
     _delscopeattr,
     _delscopeattr,
@@ -89,6 +89,7 @@ from .utils import (
     _getscopeattr_drill,
     _getscopeattr_drill,
     _hasscopeattr,
     _hasscopeattr,
     _is_in_notebook,
     _is_in_notebook,
+    _is_unnamed_function,
     _LocalsContext,
     _LocalsContext,
     _MapDict,
     _MapDict,
     _setscopeattr,
     _setscopeattr,
@@ -110,6 +111,7 @@ from .utils._evaluator import _Evaluator
 from .utils._variable_directory import _is_moduled_variable, _VariableDirectory
 from .utils._variable_directory import _is_moduled_variable, _VariableDirectory
 from .utils.chart_config_builder import _build_chart_config
 from .utils.chart_config_builder import _build_chart_config
 from .utils.table_col_builder import _enhance_columns
 from .utils.table_col_builder import _enhance_columns
+from .utils.threads import _invoke_async_callback
 
 
 
 
 class Gui:
 class Gui:
@@ -1129,6 +1131,7 @@ class Gui:
             for var, val in state_context.items():
             for var, val in state_context.items():
                 self._update_var(var, val, True, forward=False)
                 self._update_var(var, val, True, forward=False)
 
 
+
     def __request_data_update(self, var_name: str, payload: t.Any) -> None:
     def __request_data_update(self, var_name: str, payload: t.Any) -> None:
         # Use custom attrgetter function to allow value binding for _MapDict
         # Use custom attrgetter function to allow value binding for _MapDict
         newvalue = _getscopeattr_drill(self, var_name)
         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:
     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 = [] 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
         argcount = user_function.__code__.co_argcount
         if argcount > 0 and ismethod(user_function):
         if argcount > 0 and ismethod(user_function):
             argcount -= 1
             argcount -= 1
@@ -1555,7 +1563,10 @@ class Gui:
             cp_args += (argcount - len(cp_args)) * [None]
             cp_args += (argcount - len(cp_args)) * [None]
         else:
         else:
             cp_args = cp_args[:argcount]
             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]:
     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()
         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,
         callback: t.Optional[t.Union[str, t.Callable]] = None,
         message: t.Optional[str] = "Work in Progress...",
         message: t.Optional[str] = "Work in Progress...",
     ):  # pragma: no cover
     ):  # 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)
         func = self.__get_on_cancel_block_ui(action_name)
         def_action_name = func.__name__
         def_action_name = func.__name__
         _setscopeattr(self, def_action_name, func)
         _setscopeattr(self, def_action_name, func)
@@ -2805,7 +2823,9 @@ class Gui:
         self.__var_dir.set_default(self.__frame)
         self.__var_dir.set_default(self.__frame)
 
 
         if self.__state is None or is_reloading:
         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():
         if _is_in_notebook():
             # Allow gui.state.x in notebook mode
             # 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 ._warnings import _warn
 from .gui import Gui
 from .gui import Gui
 from .state import State
 from .state import State
+from .utils.callable import _is_function
 
 
 
 
 def download(
 def download(
@@ -372,19 +373,20 @@ def invoke_long_callback(
     """
     """
     if not state or not isinstance(state._gui, Gui):
     if not state or not isinstance(state._gui, Gui):
         _warn("'invoke_long_callback()' must be called in the context of a callback.")
         _warn("'invoke_long_callback()' must be called in the context of a callback.")
+        return
 
 
     if user_status_function_args is None:
     if user_status_function_args is None:
         user_status_function_args = []
         user_status_function_args = []
     if user_function_args is None:
     if user_function_args is None:
         user_function_args = []
         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):
     if not isinstance(state_id, str) or not isinstance(module_context, str):
         return
         return
 
 
-    this_gui = state._gui
-
     def callback_on_exception(state: State, function_name: str, e: Exception):
     def callback_on_exception(state: State, function_name: str, e: Exception):
         if not this_gui._call_on_exception(function_name, e):
         if not this_gui._call_on_exception(function_name, e):
             _warn(f"invoke_long_callback(): Exception raised in function {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_name: t.Optional[str] = None,
         function_result: t.Optional[t.Any] = None,
         function_result: t.Optional[t.Any] = None,
     ):
     ):
-        if callable(user_status_function):
+        if _is_function(user_status_function):
             this_gui.invoke_callback(
             this_gui.invoke_callback(
                 str(state_id),
                 str(state_id),
-                user_status_function,
+                t.cast(t.Callable, user_status_function),
                 [status] + list(user_status_function_args) + [function_result],  # type: ignore
                 [status] + list(user_status_function_args) + [function_result],  # type: ignore
                 str(module_context),
                 str(module_context),
             )
             )
@@ -428,5 +430,5 @@ def invoke_long_callback(
 
 
     thread = threading.Thread(target=user_function_in_thread, args=user_function_args)
     thread = threading.Thread(target=user_function_in_thread, args=user_function_args)
     thread.start()
     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)
         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 inspect
 import typing as t
 import typing as t
+from abc import ABCMeta, abstractmethod
 from contextlib import nullcontext
 from contextlib import nullcontext
 from operator import attrgetter
 from operator import attrgetter
 from pathlib import Path
 from pathlib import Path
@@ -25,7 +26,7 @@ if t.TYPE_CHECKING:
     from .gui import Gui
     from .gui import Gui
 
 
 
 
-class State(SimpleNamespace):
+class State(SimpleNamespace, metaclass=ABCMeta):
     """Accessor to the bound variables from callbacks.
     """Accessor to the bound variables from callbacks.
 
 
     `State` is used when you need to access the value of variables
     `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"
     __gui_attr = "_gui"
     __attrs = (
     __attrs = (
         __gui_attr,
         __gui_attr,
@@ -93,75 +175,70 @@ class State(SimpleNamespace):
         "_get_placeholder_attrs",
         "_get_placeholder_attrs",
         "_add_attribute",
         "_add_attribute",
     )
     )
-    __placeholder_attrs = (
-        "_taipy_p1",
-        "_current_context",
-    )
+    __placeholder_attrs = ("_taipy_p1", "_current_context", "__state_id")
     __excluded_attrs = __attrs + __methods + __placeholder_attrs
     __excluded_attrs = __attrs + __methods + __placeholder_attrs
 
 
     def __init__(self, gui: "Gui", var_list: t.Iterable[str], context_list: t.Iterable[str]) -> None:
     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
     @staticmethod
     def __filter_var_list(var_list: t.Iterable[str], excluded_attrs: t.Iterable[str]) -> t.Iterable[str]:
     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)
         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:
     def __getattribute__(self, name: str) -> t.Any:
         if name == "__class__":
         if name == "__class__":
-            return State
-        if name in State.__methods:
+            return _GuiState
+        if name in _GuiState.__methods:
             return super().__getattribute__(name)
             return super().__getattribute__(name)
         gui: "Gui" = self.get_gui()
         gui: "Gui" = self.get_gui()
-        if name == State.__gui_attr:
+        if name == _GuiState.__gui_attr:
             return gui
             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.")
             raise AttributeError(f"Variable '{name}' is protected and is not accessible.")
         if gui._is_in_brdcst_callback() and (
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
             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.")
             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.")
             raise AttributeError(f"Variable '{name}' is not defined.")
         with self._notebook_context(gui), self._set_context(gui):
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             encoded_name = gui._bind_var(name)
             return getattr(gui._bindings(), encoded_name)
             return getattr(gui._bindings(), encoded_name)
 
 
     def __setattr__(self, name: str, value: t.Any) -> None:
     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 (
         if gui._is_in_brdcst_callback() and (
             name not in gui._get_shared_variables() and not gui._bindings()._is_single_client()
             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.")
             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.")
             raise AttributeError(f"Variable '{name}' is not accessible.")
         with self._notebook_context(gui), self._set_context(gui):
         with self._notebook_context(gui), self._set_context(gui):
             encoded_name = gui._bind_var(name)
             encoded_name = gui._bind_var(name)
             setattr(gui._bindings(), encoded_name, value)
             setattr(gui._bindings(), encoded_name, value)
 
 
     def __getitem__(self, key: str):
     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:
         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)
             page_ctx = gui._get_page_context(key)
             context = page_ctx if page_ctx is not None else None
             context = page_ctx if page_ctx is not None else None
         if context is None:
         if context is None:
             raise RuntimeError(f"Can't resolve context '{key}' from state object")
             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
         return self
 
 
     def _set_context(self, gui: "Gui") -> t.ContextManager[None]:
     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():
             if pl_ctx != gui._get_locals_context():
                 return gui._set_locals_context(pl_ctx)
                 return gui._set_locals_context(pl_ctx)
         if len(inspect.stack()) > 1:
         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()
         return gui.get_flask_app().app_context() if not has_app_context() and _is_in_notebook() else nullcontext()
 
 
     def _get_placeholder(self, name: str):
     def _get_placeholder(self, name: str):
-        if name in State.__placeholder_attrs:
+        if name in _GuiState.__placeholder_attrs:
             try:
             try:
                 return super().__getattribute__(name)
                 return super().__getattribute__(name)
             except AttributeError:
             except AttributeError:
@@ -184,81 +261,40 @@ class State(SimpleNamespace):
         return None
         return None
 
 
     def _set_placeholder(self, name: str, value: t.Any):
     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)
             super().__setattr__(name, value)
 
 
     def _get_placeholder_attrs(self):
     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:
     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:
         if name not in attrs:
             attrs.append(name)
             attrs.append(name)
-            gui = super().__getattribute__(State.__gui_attr)
+            gui = super().__getattribute__(_GuiState.__gui_attr)
             return gui._bind_var_val(name, default_value)
             return gui._bind_var_val(name, default_value)
         return False
         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 ._runtime_manager import _RuntimeManager
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from ._variable_directory import _variable_decode, _variable_encode, _VariableDirectory
 from .boolean import _is_boolean, _is_true
 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 .clientvarname import _get_broadcast_var_name, _get_client_var_name, _to_camel_case
 from .datatype import _get_data_type
 from .datatype import _get_data_type
 from .date import _date_to_string, _string_to_date
 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",
                         "name": "mode",
                         "type": "str",
                         "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",
                         "name": "format",
@@ -34,7 +34,7 @@
                         "name": "width",
                         "name": "width",
                         "type": "Union[str,int]",
                         "type": "Union[str,int]",
                         "default_value": "None",
                         "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",
                         "name": "password",
                         "type": "bool",
                         "type": "bool",
                         "default_value": "False",
                         "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",
                         "name": "label",
                         "type": "str",
                         "type": "str",
                         "default_value": "None",
                         "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",
                         "name": "multiline",
                         "type": "bool",
                         "type": "bool",
                         "default_value": "False",
                         "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",
                         "name": "lines_shown",
                         "type": "int",
                         "type": "int",
                         "default_value": "5",
                         "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",
                         "name": "type",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"text\"",
                         "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",
                         "name": "label",
                         "type": "str",
                         "type": "str",
                         "default_value": "None",
                         "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",
                         "name": "step",
                         "type": "dynamic(Union[int,float])",
                         "type": "dynamic(Union[int,float])",
                         "default_value": "1",
                         "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",
                         "name": "step_multiplier",
                         "type": "dynamic(Union[int,float])",
                         "type": "dynamic(Union[int,float])",
                         "default_value": "10",
                         "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",
                         "name": "min",
                         "type": "dynamic(Union[int,float])",
                         "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",
                         "name": "max",
                         "type": "dynamic(Union[int,float])",
                         "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",
                         "name": "continuous",
                         "type": "bool",
                         "type": "bool",
                         "default_value": "True",
                         "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",
                         "name": "change_delay",
                         "type": "int",
                         "type": "int",
                         "default_value": "<i>App config</i>",
                         "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",
                         "name": "width",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"300px\"",
                         "default_value": "\"300px\"",
-                        "doc": "The width of this slider, in CSS units."
+                        "doc": "The width of the slider, in CSS units."
                     },
                     },
                     {
                     {
                         "name": "height",
                         "name": "height",
                         "type": "str",
                         "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",
                         "name": "orientation",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"horizontal\"",
                         "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",
                         "name": "title",
                         "type": "str",
                         "type": "str",
-                        "doc": "The title of this chart control."
+                        "doc": "The title of the chart control."
                     },
                     },
                     {
                     {
                         "name": "render",
                         "name": "render",
@@ -596,7 +608,7 @@
                     {
                     {
                         "name": "selected_marker",
                         "name": "selected_marker",
                         "type": "indexed(dict[str, Any])",
                         "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",
                         "name": "layout",
@@ -637,12 +649,12 @@
                         "name": "width",
                         "name": "width",
                         "type": "Union[str,int,float]",
                         "type": "Union[str,int,float]",
                         "default_value": "\"100%\"",
                         "default_value": "\"100%\"",
-                        "doc": "The width of this chart, in CSS units."
+                        "doc": "The width of the chart, in CSS units."
                     },
                     },
                     {
                     {
                         "name": "height",
                         "name": "height",
                         "type": "Union[str,int,float]",
                         "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",
                         "name": "template",
@@ -832,13 +844,13 @@
                         "name": "width",
                         "name": "width",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"100%\"",
                         "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",
                         "name": "height",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"80vh\"",
                         "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",
                         "name": "filter",
@@ -880,7 +892,7 @@
                         "name": "on_edit",
                         "name": "on_edit",
                         "type": "Union[bool, Callable]",
                         "type": "Union[bool, Callable]",
                         "default_value": "<i>default implementation</i>",
                         "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": [
                         "signature": [
                             [
                             [
                                 "state",
                                 "state",
@@ -1028,7 +1040,7 @@
                     {
                     {
                         "name": "mode",
                         "name": "mode",
                         "type": "str",
                         "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",
                         "name": "dropdown",
@@ -1036,12 +1048,23 @@
                         "default_value": "False",
                         "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."
                         "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",
                         "name": "multiple",
                         "type": "bool",
                         "type": "bool",
                         "default_value": "False",
                         "default_value": "False",
                         "doc": "If True, the user can select multiple items."
                         "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",
                         "name": "filter",
                         "type": "bool",
                         "type": "bool",
@@ -1052,12 +1075,12 @@
                         "name": "width",
                         "name": "width",
                         "type": "Union[str,int]",
                         "type": "Union[str,int]",
                         "default_value": "\"360px\"",
                         "default_value": "\"360px\"",
-                        "doc": "The width of this selector, in CSS units."
+                        "doc": "The width of the selector, in CSS units."
                     },
                     },
                     {
                     {
                         "name": "height",
                         "name": "height",
                         "type": "Union[str,int]",
                         "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",
                         "name": "width",
                         "type": "Union[str,int,float]",
                         "type": "Union[str,int,float]",
                         "default_value": "\"300px\"",
                         "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",
                         "name": "height",
                         "type": "Union[str,int,float]",
                         "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",
                         "name": "min",
                         "type": "Union[int,float]",
                         "type": "Union[int,float]",
                         "default_value": "0",
                         "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",
                         "name": "max",
                         "type": "Union[int,float]",
                         "type": "Union[int,float]",
                         "default_value": "100",
                         "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",
                         "name": "delta",
@@ -1417,7 +1440,7 @@
                         "name": "width",
                         "name": "width",
                         "type": "Union[str,int]",
                         "type": "Union[str,int]",
                         "default_value": "None",
                         "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",
                         "name": "orientation",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"horizontal\"",
                         "default_value": "\"horizontal\"",
-                        "doc": "The orientation of this slider."
+                        "doc": "The orientation of the indicator."
                     },
                     },
                     {
                     {
                         "name": "width",
                         "name": "width",
@@ -1499,7 +1522,7 @@
                     {
                     {
                         "name": "adapter",
                         "name": "adapter",
                         "type": "Union[str, Callable]",
                         "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."
                         "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",
                         "name": "height",
                         "type": "Union[str,int,float]",
                         "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",
                         "name": "show_sender",
@@ -1705,6 +1728,18 @@
                         "type": "str",
                         "type": "str",
                         "default_value": "\"markdown\"",
                         "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>"
                         "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",
                         "name": "row_height",
                         "type": "str",
                         "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",
                         "name": "height",
                         "type": "dynamic(str)",
                         "type": "dynamic(str)",
-                        "doc": "The height, in CSS units, of this block."
+                        "doc": "The height of the part, in CSS units."
                     },
                     },
                     {
                     {
                         "name": "content",
                         "name": "content",
@@ -1795,7 +1838,7 @@
                         "name": "title",
                         "name": "title",
                         "default_property": true,
                         "default_property": true,
                         "type": "dynamic(str)",
                         "type": "dynamic(str)",
-                        "doc": "Title of this block element."
+                        "doc": "Title of the expandable block."
                     },
                     },
                     {
                     {
                         "name": "expanded",
                         "name": "expanded",
@@ -1845,22 +1888,22 @@
                         "name": "close_label",
                         "name": "close_label",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"Close\"",
                         "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",
                         "name": "labels",
                         "type": "Union[str,list[str]]",
                         "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",
                         "name": "width",
                         "type": "Union[str,int,float]",
                         "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",
                         "name": "height",
                         "type": "Union[str,int,float]",
                         "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",
                         "name": "width",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"30vw\"",
                         "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",
                         "name": "height",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"30vh\"",
                         "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",
                         "name": "show_button",
@@ -1969,7 +2012,7 @@
                         "name": "active",
                         "name": "active",
                         "type": "dynamic(bool)",
                         "type": "dynamic(bool)",
                         "default_value": "True",
                         "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",
                         "name": "adapter",
                         "type": "Union[str, Callable]",
                         "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."
                         "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",
                         "name": "on_change",
                         "type": "Union[str, Callable]",
                         "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": [
                         "signature": [
                             [
                             [
                                 "state",
                                 "state",
@@ -2068,7 +2111,7 @@
                         "name": "propagate",
                         "name": "propagate",
                         "type": "bool",
                         "type": "bool",
                         "default_value": "<i>App config</i>",
                         "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",
                         "name": "change_delay",
                         "type": "int",
                         "type": "int",
                         "default_value": "<i>App config</i>",
                         "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",
                         "name": "on_action",
                         "type": "Union[str, Callable]",
                         "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": [
                         "signature": [
                             [
                             [
                                 "state",
                                 "state",
@@ -2106,13 +2149,13 @@
                         "name": "action_keys",
                         "name": "action_keys",
                         "type": "str",
                         "type": "str",
                         "default_value": "\"Enter\"",
                         "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",
                         "name": "width",
                         "type": "Union[str,int]",
                         "type": "Union[str,int]",
                         "default_value": "None",
                         "default_value": "None",
-                        "doc": "The width of the element."
+                        "doc": "The width of the element, in CSS units."
                     }
                     }
                 ]
                 ]
             }
             }
@@ -2124,22 +2167,22 @@
                     {
                     {
                         "name": "id",
                         "name": "id",
                         "type": "str",
                         "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",
                         "name": "properties",
                         "type": "dict[str, Any]",
                         "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",
                         "name": "class_name",
                         "type": "dynamic(str)",
                         "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",
                         "name": "hover_text",
                         "type": "dynamic(str)",
                         "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",
                         "name": "on_submission_change",
                         "type": "Union[str, Callable]",
                         "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": [
                         "signature": [
                             [
                             [
                                 "state",
                                 "state",

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

@@ -32,7 +32,7 @@ def test_download(gui: Gui, helpers):
     gui.run(run_server=False)
     gui.run(run_server=False)
     flask_client = gui._server.test_client()
     flask_client = gui._server.test_client()
     # WS client and emit
     # 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)
     cid = helpers.create_scope_and_get_sid(gui)
     # Get the jsx once so that the page will be evaluated -> variable will be registered
     # 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}")
     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() == (
         assert state._get_placeholder_attrs() == (
             "_taipy_p1",
             "_taipy_p1",
             "_current_context",
             "_current_context",
+            "__state_id"
         )
         )
 
 
         assert get_a(state) == 20
         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

部分文件因文件數量過多而無法顯示