From 6b4d42383c84ea7b52a0140f1a0132659469dab1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 14 Jan 2026 09:32:04 -0500 Subject: [PATCH 1/5] feat: Support React-based Shiny components --- .gitignore | 1 + README.md | 210 +++++++++++++- examples/1-hello-world/py/shinyreact.py | 11 +- examples/1-hello-world/r/shinyreact.R | 9 +- examples/2-inputs/py/shinyreact.py | 11 +- examples/2-inputs/r/shinyreact.R | 6 +- examples/3-outputs/py/shinyreact.py | 11 +- examples/3-outputs/r/shinyreact.R | 6 +- examples/4-messages/py/shinyreact.py | 11 +- examples/4-messages/r/shinyreact.R | 6 +- examples/5-shadcn/py/shinyreact.py | 11 +- examples/5-shadcn/r/shinyreact.R | 6 +- examples/6-dashboard/py/shinyreact.py | 11 +- examples/6-dashboard/r/shinyreact.R | 6 +- examples/7-chat/py/shinyreact.py | 18 +- examples/7-chat/r/shinyreact.R | 6 +- examples/8-modules/README.md | 266 ++++++++++++++++++ examples/8-modules/package.json | 55 ++++ examples/8-modules/py/app-standard.py | 214 ++++++++++++++ examples/8-modules/py/app.py | 42 +++ examples/8-modules/py/shinyreact.py | 109 +++++++ examples/8-modules/r/app-standard.R | 236 ++++++++++++++++ examples/8-modules/r/app.R | 41 +++ examples/8-modules/r/shinyreact.R | 87 ++++++ .../srcts-standard/CounterWidget.tsx | 38 +++ examples/8-modules/srcts-standard/main.tsx | 40 +++ examples/8-modules/srcts-standard/styles.css | 72 +++++ examples/8-modules/srcts/App.tsx | 55 ++++ examples/8-modules/srcts/CounterWidget.tsx | 47 ++++ examples/8-modules/srcts/main.tsx | 16 ++ examples/8-modules/srcts/styles.css | 178 ++++++++++++ examples/8-modules/tsconfig.json | 19 ++ src/ImageOutput.tsx | 24 +- src/ShinyModuleContext.tsx | 59 ++++ src/index.ts | 4 + src/use-shiny.ts | 63 ++++- 36 files changed, 1959 insertions(+), 46 deletions(-) create mode 100644 examples/8-modules/README.md create mode 100644 examples/8-modules/package.json create mode 100644 examples/8-modules/py/app-standard.py create mode 100644 examples/8-modules/py/app.py create mode 100644 examples/8-modules/py/shinyreact.py create mode 100644 examples/8-modules/r/app-standard.R create mode 100644 examples/8-modules/r/app.R create mode 100644 examples/8-modules/r/shinyreact.R create mode 100644 examples/8-modules/srcts-standard/CounterWidget.tsx create mode 100644 examples/8-modules/srcts-standard/main.tsx create mode 100644 examples/8-modules/srcts-standard/styles.css create mode 100644 examples/8-modules/srcts/App.tsx create mode 100644 examples/8-modules/srcts/CounterWidget.tsx create mode 100644 examples/8-modules/srcts/main.tsx create mode 100644 examples/8-modules/srcts/styles.css create mode 100644 examples/8-modules/tsconfig.json create mode 100644 src/ShinyModuleContext.tsx diff --git a/.gitignore b/.gitignore index 3aa08a7..cc7c310 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .DS_Store examples/*/package-lock.json shinylive-pages/ +examples/*/*/www/ diff --git a/README.md b/README.md index eb563d1..58a726b 100644 --- a/README.md +++ b/README.md @@ -91,22 +91,198 @@ def server(input, output, session): ``` +## Creating Reusable React Widgets + +When building React widgets for Shiny apps, **use custom web elements** for self-contained components with automatic lifecycle management: + +```typescript +// Define a custom element that wraps your React component +class MyWidgetElement extends HTMLElement { + private root: Root | null = null; + + connectedCallback() { + // Read attributes from the HTML element using dataset + const namespace = this.id; + const title = this.dataset.title || "Default Title"; + const initialValue = parseInt(this.dataset.initialValue || "0"); + + this.root = createRoot(this); + this.root.render( + + + + + + ); + } + + disconnectedCallback() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} + +customElements.define("my-widget", MyWidgetElement); +``` + +**Why custom web elements?** +- Pass configuration through HTML attributes to React props +- Automatic initialization when added to DOM +- Automatic cleanup when removed (works with dynamic rendering) +- Semantic HTML: `` instead of generic `
` +- Self-contained: all widget logic in one place +- Compatible with Shiny's `insertUI()`/`removeUI()` and `ui.insert_ui()`/`ui.remove_ui()` + +Then create clean Shiny APIs that pass attributes: + +```r +# R +my_widget_ui <- function(id, title = "My Widget", initial_value = 0) { + card( + card_header(title), + tag("my-widget", list( + id = id, + `data-title` = title, + `data-initial-value` = initial_value + )) + ) +} +``` + +```python +# Python +def my_widget_ui(id: str, title: str = "My Widget", initial_value: int = 0): + return ui.card( + ui.card_header(title), + ui.HTML(f'') + ) +``` + +**Tip:** Use `data-*` attributes for custom configuration to follow HTML standards. In the custom element, you can read these attributes and pass them as props to your React component. + +See [examples/8-modules/app-standard.R](examples/8-modules/app-standard.R) for a complete working example with dynamic widget rendering. + +## Shiny Module Namespaces + +Shiny-React supports Shiny module namespaces, enabling multiple independent React components on a single page without ID conflicts. This is essential when: + +- Embedding multiple instances of the same React widget +- Integrating React components with Shiny modules (`moduleServer` in R, `@module.server` in Python) +- Creating reusable React widgets that work like standard Shiny UI components + +### Using ShinyModuleProvider + +Wrap your React components in `ShinyModuleProvider` to automatically namespace all hooks: + +```typescript +import { ShinyModuleProvider } from '@posit/shiny-react'; + + + + +``` + +All hooks inside the provider (`useShinyInput`, `useShinyOutput`, `useShinyMessageHandler`, and `ImageOutput`) will automatically prefix their IDs with the module namespace using a `-` separator (e.g., `count` becomes `counter1-count`). + +### Explicit Namespace Option + +Alternatively, pass a `namespace` option directly to hooks: + +```typescript +const [value, setValue] = useShinyInput("count", 0, { namespace: "counter1" }); +// Connects to input$counter1-count in R or input.counter1_count() in Python +``` + +The explicit option overrides any context-provided namespace. + +### Server-Side Integration + +On the server side, use Shiny's standard module pattern. The `post_message()` function automatically applies namespacing via `session$ns()` (R) or `resolve_id()` (Python): + +**R Example:** +```r +counter_server <- function(id) { + moduleServer(id, function(input, output, session) { + # input$count is automatically namespaced + output$serverCount <- render_json({ input$count * 2 }) + + # Messages are automatically namespaced via session$ns() + post_message(session, "notification", list(text = "Updated!")) + }) +} +``` + +**Python Example:** +```python +@module.server +def counter_server(input, output, session): + @render_json + def serverCount(): + return input.count() * 2 + + # Messages are automatically namespaced via resolve_id() + await post_message(session, "notification", {"text": "Updated!"}) +``` + +### Example: Reusable React Widget + +See [examples/8-modules/](examples/8-modules/) for a complete example with two variants: + +1. **Full React app** using `page_react()` with multiple `ShinyModuleProvider` instances +2. **Standard Shiny app** (recommended) with React widgets embedded in traditional Shiny UI, following a clean API pattern: + +```r +# Create widget UI +counter_ui <- function(id, title = "Counter") { + card( + card_header(title), + tags$tag("counter-widget", list(`data-namespace` = id)) + ) +} + +# Widget server returns reactive value +counter_server <- function(id) { + moduleServer(id, function(input, output, session) { + # ... server logic ... + reactive({ input$count }) + }) +} + +# Use in app +ui <- page_fluid( + counter_ui("counter1", "Counter A"), + counter_ui("counter2", "Counter B") +) + +server <- function(input, output, session) { + count1 <- counter_server("counter1") + count2 <- counter_server("counter2") + # Use reactive values elsewhere +} +``` + + ## TypeScript/JavaScript API ### React Hooks -- **`useShinyInput(id, defaultValue, options?)`** - Send data from React to Shiny server with debouncing and priority control -- **`useShinyOutput(outputId, defaultValue?)`** - Receive reactive data from Shiny server outputs -- **`useShinyMessageHandler(messageType, handler)`** - Handle custom messages sent from Shiny server with automatic cleanup +- **`useShinyInput(id, defaultValue, options?)`** - Send data from React to Shiny server with debouncing, priority control, and optional namespace +- **`useShinyOutput(outputId, defaultValue?, options?)`** - Receive reactive data from Shiny server outputs with optional namespace +- **`useShinyMessageHandler(messageType, handler, options?)`** - Handle custom messages sent from Shiny server with automatic cleanup and optional namespace - **`useShinyInitialized()`** - Hook to determine when Shiny has finished initializing ### Components -- **`ImageOutput`** - Display Shiny image/plot outputs with automatic sizing +- **`ImageOutput`** - Display Shiny image/plot outputs with automatic sizing and optional namespace +- **`ShinyModuleProvider`** - Context provider for automatic namespace application to child hooks ### Options -Input options support debouncing (`debounceMs`) and event priority (`priority`) for fine-grained control over server communication timing. +- **Debouncing** (`debounceMs`) - Control timing of server communication (default: 100ms for inputs) +- **Event Priority** (`priority`) - Use `"event"` for button clicks to ensure each event is captured +- **Namespace** (`namespace`) - Apply Shiny module namespace to IDs (available in all hooks and `ImageOutput`) @@ -283,3 +459,27 @@ Key features demonstrated: ![AI Chat Example](docs/7-chat.jpeg) +### Shiny Module Namespaces + +The [examples/8-modules/](examples/8-modules/) directory demonstrates how to use Shiny module namespaces to create multiple independent React widgets on a single page. This example includes two variants: + +1. **Full React App** (`app.R` / `app.py`) - Single-page React application using `page_react()` with multiple `ShinyModuleProvider` instances +2. **Standard Shiny App** (`app-standard.R` / `app-standard.py`) - Traditional Shiny/bslib app embedding React widgets as reusable components (recommended for integration) + +Key features demonstrated: +- **Module Namespacing** - Multiple widget instances without ID conflicts +- **Independent State** - Each widget maintains its own state +- **Custom Element Pattern** - Uses `` for semantic initialization +- **Communication Patterns** - Demonstrates inputs, outputs, and messages with namespacing +- **Reactive Return Values** - Server functions return reactive values for integration +- **Clean API** - Simple `counter_ui()` and `counter_server()` functions following Shiny conventions + +The standard app variant shows the recommended pattern for embedding React widgets in traditional Shiny applications, making React components feel like native Shiny UI components. + +**Run it:** +```bash +cd examples/8-modules +npm install +npm run dev-standard # Runs both R and Python variants +``` + diff --git a/examples/1-hello-world/py/shinyreact.py b/examples/1-hello-world/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/1-hello-world/py/shinyreact.py +++ b/examples/1-hello-world/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/1-hello-world/r/shinyreact.R b/examples/1-hello-world/r/shinyreact.R index 6c5b402..f7dbe1c 100644 --- a/examples/1-hello-world/r/shinyreact.R +++ b/examples/1-hello-world/r/shinyreact.R @@ -66,14 +66,21 @@ render_json <- function( #' React components using useShinyMessageHandler() hook. This wraps messages in a #' standard format and sends them via the "shinyReactMessage" channel. #' +#' When called from within a Shiny module, the message type is automatically +#' namespaced using session$ns() to match the React component's namespace. +#' #' @param session The Shiny session object #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/2-inputs/py/shinyreact.py b/examples/2-inputs/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/2-inputs/py/shinyreact.py +++ b/examples/2-inputs/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/2-inputs/r/shinyreact.R b/examples/2-inputs/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/2-inputs/r/shinyreact.R +++ b/examples/2-inputs/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/3-outputs/py/shinyreact.py b/examples/3-outputs/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/3-outputs/py/shinyreact.py +++ b/examples/3-outputs/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/3-outputs/r/shinyreact.R b/examples/3-outputs/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/3-outputs/r/shinyreact.R +++ b/examples/3-outputs/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/4-messages/py/shinyreact.py b/examples/4-messages/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/4-messages/py/shinyreact.py +++ b/examples/4-messages/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/4-messages/r/shinyreact.R b/examples/4-messages/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/4-messages/r/shinyreact.R +++ b/examples/4-messages/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/5-shadcn/py/shinyreact.py b/examples/5-shadcn/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/5-shadcn/py/shinyreact.py +++ b/examples/5-shadcn/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/5-shadcn/r/shinyreact.R b/examples/5-shadcn/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/5-shadcn/r/shinyreact.R +++ b/examples/5-shadcn/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/6-dashboard/py/shinyreact.py b/examples/6-dashboard/py/shinyreact.py index c7c9769..fe82475 100644 --- a/examples/6-dashboard/py/shinyreact.py +++ b/examples/6-dashboard/py/shinyreact.py @@ -4,6 +4,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -22,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -89,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -99,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/6-dashboard/r/shinyreact.R b/examples/6-dashboard/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/6-dashboard/r/shinyreact.R +++ b/examples/6-dashboard/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/7-chat/py/shinyreact.py b/examples/7-chat/py/shinyreact.py index 92559b3..fe82475 100644 --- a/examples/7-chat/py/shinyreact.py +++ b/examples/7-chat/py/shinyreact.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Sequence, Union - -from shiny import Session, ui +from shiny import ui, Session from shiny.html_dependencies import shiny_deps -from shiny.render.renderer import Renderer, ValueFn from shiny.types import Jsonifiable +from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id +from typing import Any, Mapping, Optional, Sequence, Union def page_bare(*args: ui.TagChild, title: str | None = None, lang: str = "en") -> ui.Tag: @@ -23,7 +23,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -90,6 +89,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -100,4 +103,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/7-chat/r/shinyreact.R b/examples/7-chat/r/shinyreact.R index 6c5b402..a9e9980 100644 --- a/examples/7-chat/r/shinyreact.R +++ b/examples/7-chat/r/shinyreact.R @@ -70,10 +70,14 @@ render_json <- function( #' @param type The message type (should match messageType in useShinyMessageHandler) #' @param data The data to send to the client post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/examples/8-modules/README.md b/examples/8-modules/README.md new file mode 100644 index 0000000..5913091 --- /dev/null +++ b/examples/8-modules/README.md @@ -0,0 +1,266 @@ +# Example 8: Shiny Module Namespaces + +This example demonstrates how to use shiny-react with Shiny module namespaces to create multiple independent React widgets on a single page. + +## Two Variants + +This example includes two variants that demonstrate different use cases: + +### 1. Full React App (`app.R` / `app.py`) + +A single-page React application that uses `page_react()`. This is best for when your entire UI is built in React. + +**Features:** +- Uses `page_react()` to create a full React app +- All UI defined in React components +- Three counter instances with different namespaces + +**Run it:** +```bash +# R version +npm run dev-r + +# Python version +npm run dev-py +``` + +### 2. Standard Shiny App (`app-standard.R` / `app-standard.py`) ⭐ + +A traditional Shiny/bslib app that embeds React widgets as reusable components. This is the **recommended approach** for integrating React components into existing Shiny applications. + +**Features:** +- Uses standard Shiny UI (bslib cards in R, standard layout in Python) +- React widgets are embedded as custom components +- Clean API: `counter_ui(id, title)` and `counter_server(id)` +- The `counter_server()` function returns a reactive value for the current count +- Demonstrates proper module pattern for reusable widgets +- **Dynamic rendering**: Add and remove counter widgets on the fly using action buttons +- Custom web element (``) handles automatic React initialization and cleanup + +**Run it:** +```bash +# R version (port 8000) +npm run dev-standard-r + +# Python version (port 8001) +npm run dev-standard-py + +# Or run both +npm run dev-standard +``` + +## How the Standard Variant Works + +### Client-Side (React) + +The React widget uses a custom web element that automatically initializes when added to the DOM: + +```tsx +// main.tsx +class CounterWidgetElement extends HTMLElement { + private root: Root | null = null; + + connectedCallback() { + const namespace = this.id; + this.root = createRoot(this); + this.root.render( + + + + + + ); + } + + disconnectedCallback() { + // Clean up React root when element is removed + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} + +customElements.define("counter-widget", CounterWidgetElement); +``` + +This custom element approach: +- Uses the standard HTML `id` attribute (familiar to Shiny users) +- Automatically initializes React when the element is added to the DOM +- Properly cleans up when the element is removed (important for dynamic rendering) +- Works seamlessly with Shiny's `insertUI()` and `removeUI()` functions + +**Configuration Pattern:** You can pass additional configuration by reading HTML attributes via `dataset` in `connectedCallback()` and passing them as props to your React component: + +```typescript +connectedCallback() { + const namespace = this.id; + const title = this.dataset.title || "Default"; + const initialValue = parseInt(this.dataset.initialValue || "0"); + + this.root.render( + + + + ); +} +``` + +Then from R/Python: +```r +tag("counter-widget", list(id = id, `data-title` = "My Title", `data-initial-value` = 10)) +``` + +### Server-Side API + +#### R Example + +```r +# UI function creates a bslib card with the counter widget +counter_ui <- function(id, title = "Counter") { + card( + card_header(title), + tag( + "counter-widget", + list(id = id) # Standard HTML id attribute + ) + ) +} + +# Server function returns a reactive with the current count +counter_server <- function(id) { + moduleServer(id, function(input, output, session) { + # ... server logic ... + + reactive({ input$count }) # Return reactive value + }) +} + +# Usage in app +ui <- page_fluid( + counter_ui("counter1", "Counter A"), + counter_ui("counter2", "Counter B") +) + +server <- function(input, output, session) { + count1 <- counter_server("counter1") + count2 <- counter_server("counter2") + + # Use the reactive values + observe({ + print(count1()) + print(count2()) + }) +} +``` + +#### Python Example + +```python +def counter_ui(id: str, title: str = "Counter"): + """Creates a card with the counter widget""" + return ui.card( + ui.card_header(title), + ui.HTML(f'') + ) + +@module.server +def counter_server(input, output, session): + """Server logic, returns reactive with current count""" + # ... server logic ... + + @reactive.calc + def count(): + return input.count() if input.count() is not None else 0 + + return count # Return reactive value + +# Usage in app +def server(input, output, session): + count1 = counter_server("counter1") + count2 = counter_server("counter2") + + # Use the reactive values + @reactive.effect + def _(): + print(count1()) + print(count2()) +``` + +## Key Features Demonstrated + +1. **Module Namespacing**: Each widget has its own namespace, preventing ID conflicts +2. **Independent State**: Each counter maintains its own state +3. **Custom Web Element**: Uses `` custom element with automatic lifecycle management +4. **Dynamic Rendering**: Add and remove widgets dynamically using `insertUI()`/`removeUI()` (R) or `ui.insert_ui()`/`ui.remove_ui()` (Python) +5. **Communication Patterns**: + - **Inputs**: Client count → Server (via `useShinyInput`) + - **Outputs**: Server doubled value → Client (via `useShinyOutput`) + - **Messages**: Server notifications → Client (via `useShinyMessageHandler`) +6. **Reactive Return Values**: Server functions return reactive values that can be used elsewhere in the app +7. **Clean API**: Simple `counter_ui()` and `counter_server()` functions that follow Shiny module patterns + +## Build Commands + +```bash +# Build everything (both variants for R and Python) +npm run build + +# Build individual variants +npm run build-app # Full React app only +npm run build-standard # Standard app only + +# Development mode (auto-rebuild + run app) +npm run dev # Full React app (both R and Python, ports 8000/8001) +npm run dev-standard # Standard app (both R and Python, ports 8000/8001) + +# Run specific variants +npm run dev-r # Full React app (R only, port 8000) +npm run dev-py # Full React app (Python only, port 8001) +npm run dev-standard-r # Standard app (R only, port 8000) +npm run dev-standard-py # Standard app (Python only, port 8001) +``` + +### Build Output + +The build process creates separate JavaScript and CSS files for each variant to avoid conflicts: + +- **Full React app**: `app.js` and `app.css` - Single-page React application +- **Standard app**: `widget.js` and `widget.css` - React widgets for embedding + +## Directory Structure + +``` +8-modules/ +├── srcts/ # Full React app source +│ ├── App.tsx +│ ├── CounterWidget.tsx +│ ├── main.tsx +│ └── styles.css +├── srcts-standard/ # Standard app widget source +│ ├── CounterWidget.tsx +│ ├── main.tsx +│ └── styles.css +├── r/ +│ ├── app.R # Full React app +│ ├── app-standard.R # Standard app with bslib +│ └── shinyreact.R +├── py/ +│ ├── app.py # Full React app +│ ├── app-standard.py # Standard app +│ └── shinyreact.py +└── package.json +``` + +## Which Variant Should I Use? + +- **Use the Standard variant** (`app-standard`) if you're: + - Adding React components to an existing Shiny app + - Building a traditional Shiny app that needs some React widgets + - Want to follow familiar Shiny module patterns + - Need to return reactive values from your widgets + +- **Use the Full React variant** (`app`) if you're: + - Building an entirely new app with React + - Want full control over the entire UI in React + - Don't need traditional Shiny UI components diff --git a/examples/8-modules/package.json b/examples/8-modules/package.json new file mode 100644 index 0000000..43f5fb8 --- /dev/null +++ b/examples/8-modules/package.json @@ -0,0 +1,55 @@ +{ + "private": true, + "name": "shiny-react-modules", + "version": "1.0.0", + "type": "module", + "description": "Shiny module namespace example using shiny-react", + "scripts": { + "build": "concurrently -c auto \"npm run build-app\" \"npm run build-standard\" \"tsc --noEmit\"", + "build-app": "concurrently -c auto \"npm run build-app-r\" \"npm run build-app-py\"", + "build-app-r": "esbuild srcts/main.tsx --bundle --minify --outdir=r/www --entry-names=app --format=esm --alias:react=react", + "build-app-py": "esbuild srcts/main.tsx --bundle --minify --outdir=py/www --entry-names=app --format=esm --alias:react=react", + "build-standard": "concurrently -c auto \"npm run build-standard-r\" \"npm run build-standard-py\"", + "build-standard-r": "esbuild srcts-standard/main.tsx --bundle --minify --outdir=r/www --entry-names=widget --format=esm --alias:react=react", + "build-standard-py": "esbuild srcts-standard/main.tsx --bundle --minify --outdir=py/www --entry-names=widget --format=esm --alias:react=react", + "dev": "concurrently -c auto \"npm run watch-app\" \"npm run shinyapp\"", + "dev-r": "concurrently -c auto \"npm run watch-app-r\" \"npm run shinyapp-r\"", + "dev-py": "concurrently -c auto \"npm run watch-app-py\" \"npm run shinyapp-py\"", + "dev-standard": "concurrently -c auto \"npm run watch-standard\" \"npm run shinyapp-standard\"", + "dev-standard-r": "concurrently -c auto \"npm run watch-standard-r\" \"npm run shinyapp-standard-r\"", + "dev-standard-py": "concurrently -c auto \"npm run watch-standard-py\" \"npm run shinyapp-standard-py\"", + "watch-app": "concurrently -c auto \"npm run watch-app-r\" \"npm run watch-app-py\" \"tsc --noEmit --watch --preserveWatchOutput\"", + "watch-app-r": "esbuild srcts/main.tsx --bundle --outdir=r/www --entry-names=app --sourcemap --format=esm --alias:react=react --watch", + "watch-app-py": "esbuild srcts/main.tsx --bundle --outdir=py/www --entry-names=app --sourcemap --format=esm --alias:react=react --watch", + "watch-standard": "concurrently -c auto \"npm run watch-standard-r\" \"npm run watch-standard-py\"", + "watch-standard-r": "esbuild srcts-standard/main.tsx --bundle --outdir=r/www --entry-names=widget --sourcemap --format=esm --alias:react=react --watch", + "watch-standard-py": "esbuild srcts-standard/main.tsx --bundle --outdir=py/www --entry-names=widget --sourcemap --format=esm --alias:react=react --watch", + "shinyapp": "concurrently -c auto \"npm run shinyapp-r\" \"npm run shinyapp-py\"", + "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"", + "shinyapp-py": "cd py && shiny run app.py --reload --port ${PY_PORT:-8001}", + "shinyapp-standard": "concurrently -c auto \"npm run shinyapp-standard-r\" \"npm run shinyapp-standard-py\"", + "shinyapp-standard-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app-standard.R', port=${R_PORT:-8000})\"", + "shinyapp-standard-py": "cd py && shiny run app-standard.py --reload --port ${PY_PORT:-8001}", + "clean": "rm -rf r/www py/www" + }, + "author": "Winston Chang", + "license": "MIT", + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "concurrently": "^9.0.1", + "esbuild": "^0.25.9", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "typescript": "^5.9.2" + }, + "dependencies": { + "@posit/shiny-react": "file:../.." + }, + "exampleMetadata": { + "title": "Module Namespaces", + "description": "Multiple React widgets using Shiny module namespaces", + "deployToShinylive": true, + "comment": "" + } +} diff --git a/examples/8-modules/py/app-standard.py b/examples/8-modules/py/app-standard.py new file mode 100644 index 0000000..406ed15 --- /dev/null +++ b/examples/8-modules/py/app-standard.py @@ -0,0 +1,214 @@ +from pathlib import Path + +from shiny import App, module, reactive, render, ui +import shinyreact + + +def counter_ui(id: str, title: str = "Counter"): + """ + Counter Widget UI + + Creates a React-powered counter widget card that can be used in a standard + Shiny app. The counter maintains its state independently using Shiny modules. + + Args: + id: Module ID for namespacing + title: Title to display on the card + + Returns: + UI elements for the counter widget + """ + return ui.card( + ui.card_header(title), + # Custom element for the React component + # The counter-widget element is automatically initialized by the custom element + # The id attribute provides the module namespace + ui.HTML(f''), + # Load the React widget bundle + ui.head_content( + ui.include_css(Path(__file__).parent / "www/widget.css"), + ui.include_js(Path(__file__).parent / "www/widget.js"), + ), + ) + + +@module.server +def counter_server(input, output, session): + """ + Counter Widget Server + + Server logic for the counter widget. Handles the reactive communication + between the React component and Shiny server. + + Returns: + A reactive value containing the current count + """ + + @output(id="serverCount") + @shinyreact.render_json + def _(): + """Double the count value and send to client""" + if input.count() is not None: + return input.count() * 2 + return 0 + + @reactive.effect + async def _(): + """Send notification message every 5 counts""" + count = input.count() + if count is not None and count > 0 and count % 5 == 0: + await shinyreact.post_message( + session, + "notification", + {"message": f"Milestone reached: {count}"}, + ) + + # Return a reactive containing the current count + @reactive.calc + def count(): + return input.count() if input.count() is not None else 0 + + return count + + +# UI +app_ui = ui.page_fluid( + # Header + ui.div( + {"class": "container mt-4"}, + ui.h1("Shiny React Counter Widgets"), + ui.p( + "React-powered counter components in a traditional Shiny app.", + class_="lead", + ), + ui.hr(), + ), + # Static counter widgets in a grid layout + ui.layout_columns( + counter_ui("counter1", "Counter A"), + counter_ui("counter2", "Counter B"), + counter_ui("counter3", "Counter C"), + col_widths=(4, 4, 4), + ), + # Dynamic widget management section + ui.div( + {"class": "container mt-4"}, + ui.card( + ui.card_header("Dynamic Widget Management"), + ui.card_body( + ui.p("Test dynamic rendering by adding and removing counter widgets:"), + ui.div( + {"class": "d-flex gap-2 mb-3"}, + ui.input_action_button("add_widget", "Add Counter", class_="btn-primary"), + ui.input_action_button("remove_widget", "Remove Last Counter", class_="btn-secondary"), + ), + ui.div( + id="dynamic_widgets_container", + style="display: grid;" + "grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));" + "gap: 1rem;", + ), + ), + ), + ), + # Summary section showing the reactive values + ui.div( + {"class": "container mt-4"}, + ui.card( + ui.card_header("Current Counts (from server)"), + ui.card_body( + ui.p("These values are returned by the counter_server() function:"), + ui.output_text_verbatim("counts_summary"), + ), + ), + ), + # Info section + ui.div( + {"class": "container mt-4 mb-4"}, + ui.card( + ui.card_header("How It Works"), + ui.card_body( + ui.tags.ul( + ui.tags.li( + ui.tags.strong("counter_ui()"), + " creates a card with a mount point for the React widget", + ), + ui.tags.li( + ui.tags.strong("counter_server()"), + " sets up the Shiny module logic and returns a reactive", + ), + ui.tags.li( + "Each widget operates independently thanks to Shiny module namespacing" + ), + ui.tags.li( + "The React components use ", + ui.tags.code("ShinyModuleProvider"), + " to automatically namespace all hooks", + ), + ) + ), + ), + ), +) + + +# Server +def server(input, output, session): + # Initialize counter servers and capture their reactive return values + count1 = counter_server("counter1") + count2 = counter_server("counter2") + count3 = counter_server("counter3") + + # Track dynamically added widgets + dynamic_widget_ids = reactive.value([]) + next_widget_num = reactive.value(1) + + @reactive.effect + @reactive.event(input.add_widget) + def _(): + """Add a new dynamic widget""" + widget_num = next_widget_num() + widget_id = f"dynamic{widget_num}" + + # Insert the widget UI + ui.insert_ui( + ui.div( + {"id": f"widget_wrapper_{widget_id}", "class": "mb-3"}, + counter_ui(widget_id, f"Dynamic Counter {widget_num}"), + ), + selector="#dynamic_widgets_container", + where="beforeEnd", + ) + + # Initialize the server for this widget + counter_server(widget_id) + + # Track the widget ID + ids = dynamic_widget_ids() + dynamic_widget_ids.set(ids + [widget_id]) + + # Increment counter for next widget + next_widget_num.set(widget_num + 1) + + @reactive.effect + @reactive.event(input.remove_widget) + def _(): + """Remove the last dynamic widget""" + ids = dynamic_widget_ids() + + if len(ids) > 0: + # Get the last widget ID + last_id = ids[-1] + + # Remove the UI + ui.remove_ui(selector=f"#widget_wrapper_{last_id}") + + # Remove from tracking + dynamic_widget_ids.set(ids[:-1]) + + @render.text + def counts_summary(): + return f"Counter A: {count1()}\nCounter B: {count2()}\nCounter C: {count3()}" + + +app = App(app_ui, server) diff --git a/examples/8-modules/py/app.py b/examples/8-modules/py/app.py new file mode 100644 index 0000000..977c12a --- /dev/null +++ b/examples/8-modules/py/app.py @@ -0,0 +1,42 @@ +from shiny import App, module, reactive, ui +import shinyreact + + +# Module server function +@module.server +def counter_module_server(input, output, session): + @output(id="serverCount") + @shinyreact.render_json + def _(): + """Double the count value and send to client""" + if input.count() is not None: + return input.count() * 2 + return 0 + + @reactive.effect + def _(): + """Send notification message every 5 counts""" + count = input.count() + if count is not None and count > 0 and count % 5 == 0: + shinyreact.post_message( + session, + "notification", + {"message": f"Milestone reached: {count}"}, + ) + + +def server(input, output, session): + # Initialize three independent module servers + counter_module_server("counter1") + counter_module_server("counter2") + counter_module_server("counter3") + + +app = App( + shinyreact.page_react( + title="Modules - Shiny React", + js_file="app.js", + css_file="app.css", + ), + server, +) diff --git a/examples/8-modules/py/shinyreact.py b/examples/8-modules/py/shinyreact.py new file mode 100644 index 0000000..fe82475 --- /dev/null +++ b/examples/8-modules/py/shinyreact.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from shiny import ui, Session +from shiny.html_dependencies import shiny_deps +from shiny.types import Jsonifiable +from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id +from typing import Any, Mapping, Optional, Sequence, Union + + +def page_bare(*args: ui.TagChild, title: str | None = None, lang: str = "en") -> ui.Tag: + return ui.tags.html( + ui.tags.head(ui.tags.title(title)), + ui.tags.body(shiny_deps(False), *args), + lang=lang, + ) + + +def page_react( + *args: ui.TagChild, + title: str | None = None, + js_file: str | None = "main.js", + css_file: str | None = "main.css", + lang: str = "en", +) -> ui.Tag: + head_items: list[ui.TagChild] = [] + + if js_file: + head_items.append(ui.tags.script(src=js_file, type="module")) + if css_file: + head_items.append(ui.tags.link(href=css_file, rel="stylesheet")) + + return page_bare( + ui.head_content(*head_items), + ui.div(id="root"), + *args, + title=title, + lang=lang, + ) + + +class render_json(Renderer[Jsonifiable]): + """ + Reactively render arbitrary JSON object. + + This is a generic renderer that can be used to render any Jsonifiable data. + It sends the data to the client-side and let the client-side code handle the + rendering. + + Returns + ------- + : + A decorator for a function that returns a Jsonifiable object. + + """ + + def __init__( + self, + _fn: Optional[ValueFn[Any]] = None, + ) -> None: + super().__init__(_fn) + + async def transform(self, value: Jsonifiable) -> Jsonifiable: + return value + + +# This is like Jsonifiable, but where Jsonifiable uses Dict, List, and Tuple, +# this replaces those with Mapping and Sequence. Because Dict and List are +# invariant, it can cause problems when a parameter is specified as Jsonifiable; +# the replacements are covariant, which solves these problems. +JsonifiableIn = Union[ + str, + int, + float, + bool, + None, + Sequence["JsonifiableIn"], + "JsonifiableMapping", +] + +JsonifiableMapping = Mapping[str, JsonifiableIn] + + +async def post_message(session: Session, type: str, data: JsonifiableIn): + """ + Send a custom message to the client. + + A convenience function for sending custom messages from the Shiny server to + React components using useShinyMessageHandler() hook. This wraps messages in + a standard format and sends them via the "shinyReactMessage" channel. + + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + + Parameters + ---------- + session + The Shiny session object + type + The message type (should match the messageType in + useShinyMessageHandler) + data + The data to send to the client + """ + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/8-modules/r/app-standard.R b/examples/8-modules/r/app-standard.R new file mode 100644 index 0000000..933fe4b --- /dev/null +++ b/examples/8-modules/r/app-standard.R @@ -0,0 +1,236 @@ +library(shiny) +library(bslib) +source("shinyreact.R", local = TRUE) + +#' Counter Widget UI +#' +#' Creates a React-powered counter widget card that can be used in a standard +#' Shiny app. The counter maintains its state independently using Shiny modules. +#' +#' @param id Module ID for namespacing +#' @param title Title to display on the card +#' +#' @return A bslib card containing the counter widget +counter_ui <- function(id, title = "Counter") { + ns <- NS(id) + + card( + card_header(title), + # Custom element for the React component + # The counter-widget element is automatically initialized by the custom element + # The id attribute provides the module namespace + tag( + "counter-widget", + list( + id = id, + class = "counter-widget-container" + ) + ), + # Load the React widget bundle + tags$head( + tags$script(src = "widget.js", type = "module"), + tags$link(href = "widget.css", rel = "stylesheet") + ) + ) +} + +#' Counter Widget Server +#' +#' Server logic for the counter widget. Handles the reactive communication +#' between the React component and Shiny server. +#' +#' @param id Module ID (must match the ID used in counter_ui) +#' +#' @return A reactive value containing the current count +counter_server <- function(id) { + moduleServer(id, function(input, output, session) { + # Double the count value and send to client + output$serverCount <- render_json({ + req(input$count) + input$count * 2 + }) + + # Send notification message every 5 counts + observe({ + req(input$count) + if (input$count > 0 && input$count %% 5 == 0) { + post_message( + session, + "notification", + list(message = paste("Milestone reached:", input$count)) + ) + } + }) + + # Return a reactive containing the current count + reactive({ + input$count + }) + }) +} + +# UI +ui <- page_fluid( + theme = bs_theme(version = 5, preset = "shiny"), + + # Header + div( + class = "container mt-4", + h1("Shiny React Counter Widgets"), + p( + class = "lead", + "React-powered counter components in a traditional Shiny app using bslib." + ), + hr() + ), + + # Static counter widgets in a grid layout + layout_columns( + col_widths = c(4, 4, 4), + counter_ui("counter1", "Counter A"), + counter_ui("counter2", "Counter B"), + counter_ui("counter3", "Counter C") + ), + + # Dynamic widget management section + div( + class = "container mt-4", + card( + card_header("Dynamic Widget Management"), + card_body( + p("Test dynamic rendering by adding and removing counter widgets:"), + div( + class = "d-flex gap-2 mb-3", + actionButton("add_widget", "Add Counter", class = "btn-primary"), + actionButton( + "remove_widget", + "Remove Last Counter", + class = "btn-secondary" + ) + ), + div( + id = "dynamic_widgets_container", + style = css( + display = "grid", + grid_template_columns = "repeat(auto-fit, minmax(200px, 1fr))", + gap = "1rem" + ) + ) + ) + ) + ), + + # Summary section showing the reactive values + div( + class = "container mt-4", + card( + card_header("Current Counts (from server)"), + card_body( + p("These values are returned by the counter_server() function:"), + verbatimTextOutput("counts_summary") + ) + ) + ), + + # Info section + div( + class = "container mt-4 mb-4", + card( + card_header("How It Works"), + card_body( + tags$ul( + tags$li( + tags$strong("counter_ui()"), + " creates a bslib card with a mount point for the React widget" + ), + tags$li( + tags$strong("counter_server()"), + " sets up the Shiny module logic and returns a reactive" + ), + tags$li( + "Each widget operates independently thanks to Shiny module namespacing" + ), + tags$li( + "The React components use ", + tags$code("ShinyModuleProvider"), + " to automatically namespace all hooks" + ) + ) + ) + ) + ) +) + +# Server +server <- function(input, output, session) { + # Initialize counter servers and capture their reactive return values + count1 <- counter_server("counter1") + count2 <- counter_server("counter2") + count3 <- counter_server("counter3") + + # Track dynamically added widgets + dynamic_widget_ids <- reactiveVal(character(0)) + next_widget_num <- reactiveVal(1) + + # Add a new dynamic widget + observeEvent(input$add_widget, { + widget_num <- next_widget_num() + widget_id <- paste0("dynamic", widget_num) + + # Insert the widget UI + insertUI( + selector = "#dynamic_widgets_container", + where = "beforeEnd", + ui = div( + id = paste0("widget_wrapper_", widget_id), + class = "mb-3", + counter_ui(widget_id, paste("Dynamic Counter", widget_num)) + ) + ) + + # Initialize the server for this widget + counter_server(widget_id) + + # Track the widget ID + ids <- dynamic_widget_ids() + dynamic_widget_ids(c(ids, widget_id)) + + # Increment counter for next widget + next_widget_num(widget_num + 1) + }) + + # Remove the last dynamic widget + observeEvent(input$remove_widget, { + ids <- dynamic_widget_ids() + + if (length(ids) > 0) { + # Get the last widget ID + last_id <- ids[length(ids)] + + # Remove the UI + removeUI( + selector = paste0("#widget_wrapper_", last_id), + immediate = TRUE + ) + + # Remove from tracking + dynamic_widget_ids(ids[-length(ids)]) + } + }) + + # Display the current counts + output$counts_summary <- renderText({ + paste0( + "Counter A: ", + count1() %||% 0, + "\n", + "Counter B: ", + count2() %||% 0, + "\n", + "Counter C: ", + count3() %||% 0 + ) + }) +} + +shinyApp(ui, server) diff --git a/examples/8-modules/r/app.R b/examples/8-modules/r/app.R new file mode 100644 index 0000000..96318a0 --- /dev/null +++ b/examples/8-modules/r/app.R @@ -0,0 +1,41 @@ +library(shiny) +source("shinyreact.R", local = TRUE) + +# Module server function +counterModuleServer <- function(id) { + moduleServer(id, function(input, output, session) { + # Double the count value and send to client + output$serverCount <- render_json({ + req(input$count) + input$count * 2 + }) + + # Send notification message every 5 counts + observe({ + req(input$count) + if (input$count > 0 && input$count %% 5 == 0) { + post_message( + session, + "notification", + list(message = paste("Milestone reached:", input$count)) + ) + } + }) + }) +} + +server <- function(input, output, session) { + # Initialize three independent module servers + counterModuleServer("counter1") + counterModuleServer("counter2") + counterModuleServer("counter3") +} + +shinyApp( + ui = page_react( + title = "Modules - Shiny React", + js_file = "app.js", + css_file = "app.css" + ), + server = server +) diff --git a/examples/8-modules/r/shinyreact.R b/examples/8-modules/r/shinyreact.R new file mode 100644 index 0000000..f7dbe1c --- /dev/null +++ b/examples/8-modules/r/shinyreact.R @@ -0,0 +1,87 @@ +library(shiny) + +page_bare <- function(..., title = NULL, lang = NULL) { + ui <- list( + shiny:::jqueryDependency(), + if (!is.null(title)) tags$head(tags$title(title)), + ... + ) + attr(ui, "lang") <- lang + ui +} + +page_react <- function( + ..., + title = NULL, + js_file = "main.js", + css_file = "main.css", + lang = "en" +) { + page_bare( + title = title, + tags$head( + if (!is.null(js_file)) tags$script(src = js_file, type = "module"), + if (!is.null(css_file)) tags$link(href = css_file, rel = "stylesheet") + ), + tags$div(id = "root"), + ... + ) +} + + +#' Reactively render arbitrary JSON object data. +#' +#' This is a generic renderer that can be used to render any Jsonifiable data. +#' The data goes through shiny:::toJSON() before being sent to the client. +render_json <- function( + expr, + env = parent.frame(), + quoted = FALSE, + outputArgs = list(), + sep = " " +) { + func <- installExprFunction( + expr, + "func", + env, + quoted, + label = "render_json" + ) + + createRenderFunction( + func, + function(value, session, name, ...) { + value + }, + function(...) { + stop("Not implemented") + }, + outputArgs + ) +} + +#' Send a custom message to the client +#' +#' A convenience function for sending custom messages from the Shiny server to +#' React components using useShinyMessageHandler() hook. This wraps messages in a +#' standard format and sends them via the "shinyReactMessage" channel. +#' +#' When called from within a Shiny module, the message type is automatically +#' namespaced using session$ns() to match the React component's namespace. +#' +#' @param session The Shiny session object +#' @param type The message type (should match messageType in useShinyMessageHandler) +#' @param data The data to send to the client +post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + + session$sendCustomMessage( + "shinyReactMessage", + list( + type = namespaced_type, + data = data + ) + ) +} diff --git a/examples/8-modules/srcts-standard/CounterWidget.tsx b/examples/8-modules/srcts-standard/CounterWidget.tsx new file mode 100644 index 0000000..2d576cd --- /dev/null +++ b/examples/8-modules/srcts-standard/CounterWidget.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { + useShinyInput, + useShinyOutput, + useShinyMessageHandler, +} from "@posit/shiny-react"; + +export function CounterWidget() { + const [count, setCount] = useShinyInput("count", 0); + const [serverCount] = useShinyOutput("serverCount", 0); + const [notification, setNotification] = useState(null); + + useShinyMessageHandler<{ message: string }>("notification", (data) => { + setNotification(data.message); + setTimeout(() => setNotification(null), 3000); + }); + + return ( +
+
+
+ Client count: + {count} +
+
+ Server doubled: + {serverCount} +
+
+ + {notification && ( +
{notification}
+ )} +
+ ); +} diff --git a/examples/8-modules/srcts-standard/main.tsx b/examples/8-modules/srcts-standard/main.tsx new file mode 100644 index 0000000..2549da8 --- /dev/null +++ b/examples/8-modules/srcts-standard/main.tsx @@ -0,0 +1,40 @@ +import { StrictMode } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { ShinyModuleProvider } from "@posit/shiny-react"; +import { CounterWidget } from "./CounterWidget"; +import "./styles.css"; + +// Custom element that automatically initializes React when connected to DOM +class CounterWidgetElement extends HTMLElement { + private root: Root | null = null; + + connectedCallback() { + const namespace = this.id; + + if (!namespace) { + console.error("counter-widget missing `id` attribute"); + return; + } + + // Create React root and render + this.root = createRoot(this); + this.root.render( + + + + + + ); + } + + disconnectedCallback() { + // Clean up React root when element is removed + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} + +// Register the custom element +customElements.define("counter-widget", CounterWidgetElement); diff --git a/examples/8-modules/srcts-standard/styles.css b/examples/8-modules/srcts-standard/styles.css new file mode 100644 index 0000000..0acbd58 --- /dev/null +++ b/examples/8-modules/srcts-standard/styles.css @@ -0,0 +1,72 @@ +.counter-widget-content { + padding: 16px; +} + +.counter-display { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.counter-value { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #f8f9fa; + border-radius: 6px; +} + +.counter-value .label { + font-weight: 500; + color: #666; +} + +.counter-value .value { + font-size: 1.5rem; + font-weight: bold; + color: #667eea; +} + +.increment-button { + width: 100%; + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; +} + +.increment-button:hover { + opacity: 0.9; +} + +.increment-button:active { + transform: scale(0.98); +} + +.notification { + margin-top: 16px; + padding: 12px; + background: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; + border-radius: 6px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/examples/8-modules/srcts/App.tsx b/examples/8-modules/srcts/App.tsx new file mode 100644 index 0000000..d52dc80 --- /dev/null +++ b/examples/8-modules/srcts/App.tsx @@ -0,0 +1,55 @@ +import { ShinyModuleProvider } from "@posit/shiny-react"; +import CounterWidget from "./CounterWidget"; + +function App() { + return ( +
+
+

Shiny Module Namespace Demo

+

+ Three independent counter widgets, each in its own namespace +

+
+ +
+ + + + + + + + + + + +
+ +
+

How It Works

+

+ Each counter widget is wrapped in a ShinyModuleProvider{" "} + with a unique namespace. This allows multiple instances of the same + component to operate independently without ID conflicts. +

+
    +
  • + Counter 1 uses namespace counter1 +
  • +
  • + Counter 2 uses namespace counter2 +
  • +
  • + Counter 3 uses namespace counter3 +
  • +
+

+ On the server side, Shiny modules automatically namespace the outputs + and messages, keeping each widget's state completely separate. +

+
+
+ ); +} + +export default App; diff --git a/examples/8-modules/srcts/CounterWidget.tsx b/examples/8-modules/srcts/CounterWidget.tsx new file mode 100644 index 0000000..9641b8c --- /dev/null +++ b/examples/8-modules/srcts/CounterWidget.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { + useShinyInput, + useShinyOutput, + useShinyMessageHandler, +} from "@posit/shiny-react"; + +interface CounterWidgetProps { + label: string; +} + +function CounterWidget({ label }: CounterWidgetProps) { + const [count, setCount] = useShinyInput("count", 0); + const [serverCount] = useShinyOutput("serverCount", 0); + const [notification, setNotification] = useState(null); + + useShinyMessageHandler<{ message: string }>("notification", (data) => { + setNotification(data.message); + setTimeout(() => setNotification(null), 3000); + }); + + return ( +
+

{label}

+
+
+
+ Client count: + {count} +
+
+ Server doubled: + {serverCount} +
+
+ + {notification && ( +
{notification}
+ )} +
+
+ ); +} + +export default CounterWidget; diff --git a/examples/8-modules/srcts/main.tsx b/examples/8-modules/srcts/main.tsx new file mode 100644 index 0000000..a68262f --- /dev/null +++ b/examples/8-modules/srcts/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const container = document.getElementById("root"); +if (container) { + const root = createRoot(container); + root.render( + + + , + ); +} else { + console.error("Could not find root element to mount React component."); +} diff --git a/examples/8-modules/srcts/styles.css b/examples/8-modules/srcts/styles.css new file mode 100644 index 0000000..fce961b --- /dev/null +++ b/examples/8-modules/srcts/styles.css @@ -0,0 +1,178 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.app-container { + max-width: 1200px; + margin: 0 auto; +} + +.app-header { + text-align: center; + color: white; + margin-bottom: 40px; +} + +.app-header h1 { + font-size: 2.5rem; + margin-bottom: 10px; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} + +.subtitle { + font-size: 1.1rem; + opacity: 0.95; +} + +.widgets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + margin-bottom: 40px; +} + +.counter-widget { + background: white; + border-radius: 12px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + padding: 24px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.counter-widget:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); +} + +.counter-widget h3 { + font-size: 1.5rem; + color: #333; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 2px solid #667eea; +} + +.counter-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.counter-display { + display: flex; + flex-direction: column; + gap: 12px; +} + +.counter-value { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #f8f9fa; + border-radius: 8px; +} + +.counter-value .label { + font-weight: 500; + color: #666; +} + +.counter-value .value { + font-size: 1.5rem; + font-weight: bold; + color: #667eea; +} + +.increment-button { + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; +} + +.increment-button:hover { + opacity: 0.9; +} + +.increment-button:active { + transform: scale(0.98); +} + +.notification { + padding: 12px; + background: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; + border-radius: 8px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.info-section { + background: white; + border-radius: 12px; + padding: 32px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} + +.info-section h2 { + font-size: 1.8rem; + color: #333; + margin-bottom: 16px; +} + +.info-section p { + color: #666; + line-height: 1.6; + margin-bottom: 16px; +} + +.info-section code { + background: #f8f9fa; + padding: 2px 6px; + border-radius: 4px; + font-family: "Courier New", monospace; + color: #667eea; +} + +.info-section ul { + list-style-position: inside; + color: #666; + line-height: 1.8; + margin-bottom: 16px; +} + +.info-section ul li { + margin-bottom: 8px; +} + +.info-section strong { + color: #333; + font-weight: 600; +} diff --git a/examples/8-modules/tsconfig.json b/examples/8-modules/tsconfig.json new file mode 100644 index 0000000..b8f08e9 --- /dev/null +++ b/examples/8-modules/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "noEmit": true, + "moduleResolution": "node", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@/*": ["./srcts/*"] + } + }, + "include": ["srcts/**/*.ts", "srcts/**/*.tsx", "srcts-standard/**/*.ts", "srcts-standard/**/*.tsx"] +} diff --git a/src/ImageOutput.tsx b/src/ImageOutput.tsx index a791e35..791a3f6 100644 --- a/src/ImageOutput.tsx +++ b/src/ImageOutput.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useShinyInput, useShinyOutput } from "./use-shiny"; import { createDebouncedFn } from "./utils"; +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; export type ImageData = { src: string; @@ -71,6 +75,8 @@ export type ImageData = { * @param props.onRecalculating - Optional callback function that gets called * whenever the recalculation status changes. Receives a boolean indicating * whether the image is currently recalculating. + * @param props.namespace - Optional namespace prefix for Shiny module support. + * If provided, the ID will be prefixed as `${namespace}-${id}`. * * @remarks * The component automatically: @@ -135,6 +141,7 @@ export function ImageOutput({ height, debounceMs = 400, onRecalculating, + namespace: explicitNamespace, }: { id: string; className?: string; @@ -142,22 +149,31 @@ export function ImageOutput({ height?: string; debounceMs?: number; onRecalculating?: (isRecalculating: boolean) => void; + namespace?: string; }) { + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedId = applyNamespace(id, namespace); + const [imgWidth, setImgWidth] = useShinyInput( - ".clientdata_output_" + id + "_width", + `.clientdata_output_${namespacedId}_width`, null, ); const [imgHeight, setImgHeight] = useShinyInput( - ".clientdata_output_" + id + "_height", + `.clientdata_output_${namespacedId}_height`, null, ); // Track if the image is hidden const [imgHidden] = useShinyInput( - ".clientdata_output_" + id + "_hidden", + `.clientdata_output_${namespacedId}_hidden`, false, ); - const [imgData, imgRecalculating] = useShinyOutput(id, undefined); + const [imgData, imgRecalculating] = useShinyOutput( + namespacedId, + undefined, + ); // Create a reference to the img element to access its properties const imgRef = useRef(null); diff --git a/src/ShinyModuleContext.tsx b/src/ShinyModuleContext.tsx new file mode 100644 index 0000000..419d818 --- /dev/null +++ b/src/ShinyModuleContext.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, type ReactNode } from "react"; + +const ShinyModuleContext = createContext(null); + +export interface ShinyModuleProviderProps { + namespace: string; + children: ReactNode; +} + +/** + * Provides a namespace context for Shiny module support. + * + * All child components using useShinyInput, useShinyOutput, or + * useShinyMessageHandler will automatically have their IDs prefixed + * with the provided namespace. + * + * Note: This provider does NOT support nesting. If you need nested modules, + * pass the full namespace string (e.g., "outer-inner") directly. + * + * @param namespace The complete namespace string to apply to child hooks. + * @param children React children that will receive the namespace context. + * + * @example + * ```tsx + * + * {/* useShinyInput("x") becomes "myModule-x" *} + * + * ``` + */ +export function ShinyModuleProvider({ + namespace, + children, +}: ShinyModuleProviderProps) { + return ( + + {children} + + ); +} + +/** + * Hook to access the current module namespace from context. + * Returns null if not within a ShinyModuleProvider. + */ +export function useShinyModuleNamespace(): string | null { + return useContext(ShinyModuleContext); +} + +/** + * Utility function to apply namespace to an ID. + * If namespace is provided, returns `${namespace}-${id}`. + * Otherwise returns the original id. + */ +export function applyNamespace(id: string, namespace: string | null): string { + if (namespace) { + return `${namespace}-${id}`; + } + return id; +} diff --git a/src/index.ts b/src/index.ts index 7705f57..eaa4753 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ export { useShinyMessageHandler, useShinyOutput, } from "./use-shiny"; +export { + ShinyModuleProvider, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; export type ShinyClassExtended = ShinyClass & { reactRegistry: ShinyReactRegistry; diff --git a/src/use-shiny.ts b/src/use-shiny.ts index b3a4d13..dddf605 100644 --- a/src/use-shiny.ts +++ b/src/use-shiny.ts @@ -7,6 +7,10 @@ import { type InputRegistryEntry } from "./input-registry"; import { initializeMessageRegistry } from "./message-registry"; import { createReactOutputBinding } from "./output-registry"; import { getReactRegistry, initializeReactRegistry } from "./react-registry"; +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; /** * A React hook for managing a Shiny input value. @@ -34,6 +38,8 @@ import { getReactRegistry, initializeReactRegistry } from "./react-registry"; * (default: 100). * @param options.priority Priority level for the input event (from Shiny's * EventPriority enum). + * @param options.namespace Optional namespace prefix for Shiny module support. + * If provided, the ID will be prefixed as `${namespace}-${id}`. * @returns A tuple containing the current value and a function to set the * value: `[value, setValue]`. */ @@ -43,13 +49,20 @@ export function useShinyInput( { debounceMs = 100, priority, + namespace: explicitNamespace, }: { debounceMs?: number; priority?: EventPriority; + namespace?: string; } = {}, ): [T, (value: T) => void] { ensureShinyReactInitialized(); + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedId = applyNamespace(id, namespace); + // NOTE: It's a little odd that debounceMs and priority passed this way; the // debounceMs is associated with the specific input name, and in Shiny's API, // priority is associated with each individual call to setInputValue(). But @@ -60,7 +73,7 @@ export function useShinyInput( let startValue: T = defaultValue; const reactRegistry = getReactRegistry(); const inputRegistryEntry = reactRegistry.inputs.get( - id, + namespacedId, ) as InputRegistryEntry; if (inputRegistryEntry) { @@ -85,7 +98,7 @@ export function useShinyInput( // Make sure the input registry entry exists for this Shiny input ID const reactRegistry = getReactRegistry(); const inputRegistryEntry = reactRegistry.inputs.getOrCreate( - id, + namespacedId, defaultValue, ); @@ -109,7 +122,7 @@ export function useShinyInput( // useEffect will be called again. If someone wants to really get rid of // the registry entry, they will have to do so manually. }; - }, [id, shinyInitialized, debounceMs, priority, defaultValue]); + }, [namespacedId, shinyInitialized, debounceMs, priority, defaultValue]); const setValueWrapped = useCallback( (value: T) => { @@ -118,14 +131,14 @@ export function useShinyInput( // } const reactRegistry = getReactRegistry(); - const inputRegistryEntry = reactRegistry.inputs.get(id); + const inputRegistryEntry = reactRegistry.inputs.get(namespacedId); if (!inputRegistryEntry) { console.error(`Input ${id} not found`); return; } inputRegistryEntry.setValue(value); }, - [id], + [namespacedId, id], ); return [value, setValueWrapped]; @@ -139,6 +152,9 @@ export function useShinyInput( * @param outputId The ID of the Shiny output to subscribe to. * @param defaultValue Optional default value to use before the first server * update. + * @param options Optional configuration object. + * @param options.namespace Optional namespace prefix for Shiny module support. + * If provided, the outputId will be prefixed as `${namespace}-${outputId}`. * @returns A tuple containing [value, recalculating] where: * - value: The current value of the Shiny output * - recalculating: Boolean indicating if the server is currently @@ -147,6 +163,11 @@ export function useShinyInput( export function useShinyOutput( outputId: string, defaultValue: T | undefined = undefined, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, ): [T | undefined, boolean] { const [value, setValue] = useState(defaultValue); const [recalculating, setRecalculating] = useState(false); @@ -154,17 +175,22 @@ export function useShinyOutput( ensureShinyReactInitialized(); + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedOutputId = applyNamespace(outputId, namespace); + useEffect(() => { if (!shinyInitialized) { return; } const reactRegistry = getReactRegistry(); - reactRegistry.outputs.add(outputId, setValue, setRecalculating); + reactRegistry.outputs.add(namespacedOutputId, setValue, setRecalculating); return () => { - reactRegistry.outputs.remove(outputId); + reactRegistry.outputs.remove(namespacedOutputId); }; - }, [outputId, shinyInitialized]); + }, [namespacedOutputId, shinyInitialized]); return [value, recalculating]; } @@ -191,17 +217,30 @@ export function useShinyOutput( * @param messageType The type/name of the custom message to listen for. * @param handler The function to call when a message of this type is received. * The handler receives the message data as its parameter. + * @param options Optional configuration object. + * @param options.namespace Optional namespace prefix for Shiny module support. + * If provided, the messageType will be prefixed as `${namespace}-${messageType}`. */ export function useShinyMessageHandler( messageType: string, handler: (data: T) => void, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, ): void { const shinyInitialized = useShinyInitialized(); ensureShinyReactInitialized(); + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedMessageType = applyNamespace(messageType, namespace); + useEffect(() => { - if (!shinyInitialized || !messageType || !handler) { + if (!shinyInitialized || !namespacedMessageType || !handler) { return; } const shiny = getShiny(); @@ -210,14 +249,14 @@ export function useShinyMessageHandler( } // Register the message handler with our dedicated message registry - shiny.messageRegistry.addHandler(messageType, handler); + shiny.messageRegistry.addHandler(namespacedMessageType, handler); // Cleanup function that removes the handler when component unmounts // or when messageType/handler changes return () => { - shiny.messageRegistry.removeHandler(messageType, handler); + shiny.messageRegistry.removeHandler(namespacedMessageType, handler); }; - }, [shinyInitialized, messageType, handler]); + }, [shinyInitialized, namespacedMessageType, handler]); } /** From 9422aff9794cf4e775e0c24042224ee305c61d95 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 14 Jan 2026 15:35:55 -0500 Subject: [PATCH 2/5] feat: Add blended Shiny+React wrapper component example --- blended-components-pattern.md | 385 +++++++++++++++++++++ examples/9-blended/README.md | 169 +++++++++ examples/9-blended/package.json | 39 +++ examples/9-blended/python/app.py | 172 +++++++++ examples/9-blended/python/shinyreact.py | 214 ++++++++++++ examples/9-blended/r/app.R | 153 ++++++++ examples/9-blended/r/shinyreact.R | 174 ++++++++++ examples/9-blended/srcts/SidebarLayout.tsx | 116 +++++++ examples/9-blended/srcts/main.tsx | 80 +++++ examples/9-blended/srcts/styles.css | 169 +++++++++ examples/9-blended/tsconfig.json | 19 + 11 files changed, 1690 insertions(+) create mode 100644 blended-components-pattern.md create mode 100644 examples/9-blended/README.md create mode 100644 examples/9-blended/package.json create mode 100644 examples/9-blended/python/app.py create mode 100644 examples/9-blended/python/shinyreact.py create mode 100644 examples/9-blended/r/app.R create mode 100644 examples/9-blended/r/shinyreact.R create mode 100644 examples/9-blended/srcts/SidebarLayout.tsx create mode 100644 examples/9-blended/srcts/main.tsx create mode 100644 examples/9-blended/srcts/styles.css create mode 100644 examples/9-blended/tsconfig.json diff --git a/blended-components-pattern.md b/blended-components-pattern.md new file mode 100644 index 0000000..7a59857 --- /dev/null +++ b/blended-components-pattern.md @@ -0,0 +1,385 @@ +# Blended Components Pattern + +Best practices for creating React components that wrap native Shiny UI elements, designed to integrate seamlessly with Shiny + bslib (R) or Shiny for Python applications. + +## Overview + +A "blended" component uses React to provide custom layout, interactivity, or visual chrome while allowing native Shiny UI elements (inputs, outputs, bslib components) to live inside. This pattern enables: + +- Custom layouts not available in standard Shiny/bslib +- React-powered animations and interactions +- Full Shiny reactivity within React-managed containers +- Seamless visual integration with bslib themes + +## Core Pattern: Slot Preservation + +The fundamental challenge is that React manages its own DOM, but Shiny UI elements need to: +1. Be rendered by Shiny's tag system (R or Python) +2. Have their bindings initialized by `Shiny.bindAll()` +3. Maintain state when hidden/shown + +**Solution: Capture, Render, Restore, Bind** + +``` +1. R/Python renders Shiny content as children of a custom element +2. Custom element's connectedCallback() captures children before React renders +3. React renders layout with empty container refs +4. Captured content is moved into React containers after mount +5. Shiny.bindAll() initializes bindings on each container +``` + +## Implementation Layers + +### Layer 1: R/Python API + +Create wrapper functions that feel natural to Shiny users: + +```r +# Container function +my_layout <- function(..., id = NULL, option = "default") { + items <- list(...) + + # Extract metadata for React + item_config <- lapply(items, function(item) { + list( + id = item$attribs$`data-item-id`, + label = item$attribs$`data-item-label` + ) + }) + + tagList( + # Include JS/CSS dependencies + tags$head( + tags$script(src = "my-component.js", type = "module"), + tags$link(href = "my-component.css", rel = "stylesheet") + ), + # Custom element with configuration and Shiny content + tag("my-custom-element", list( + id = id, + `data-config` = jsonlite::toJSON(item_config, auto_unbox = TRUE), + `data-option` = option, + items # Shiny content as children + )) + ) +} + +# Item wrapper (marks content for React to find) +my_item <- function(label, ..., id = label) { + div( + `data-item-id` = id, + `data-item-label` = label, + class = "my-component-item", + style = "display: none;", # Hidden until React takes over + ... + ) +} +``` + +**Key patterns:** +- Use `data-*` attributes to pass configuration to React +- Wrap Shiny content in identifiable containers (`data-item-id`) +- Hide content initially (`display: none`) to prevent flash +- Include JS/CSS via `tags$head()` + +### Layer 2: Custom Element (TypeScript) + +The custom element bridges Shiny's DOM and React: + +```typescript +import { createRoot, Root } from "react-dom/client"; +import { ShinyModuleProvider } from "@posit/shiny-react"; +import { MyComponent } from "./MyComponent"; + +class MyCustomElement extends HTMLElement { + private root: Root | null = null; + private itemContents: Map = new Map(); + + connectedCallback() { + // 1. CAPTURE: Store Shiny content before React clears DOM + const itemDivs = this.querySelectorAll('[data-item-id]'); + itemDivs.forEach(div => { + const itemId = div.getAttribute('data-item-id')!; + this.itemContents.set(itemId, Array.from(div.childNodes)); + }); + + // 2. PARSE: Read configuration from attributes + const config = JSON.parse(this.dataset.config || '[]'); + const option = this.dataset.option || 'default'; + const namespace = this.id || undefined; + + // 3. RENDER: Clear and create React root + this.innerHTML = ''; + this.root = createRoot(this); + + // 4. Wrap in ShinyModuleProvider if namespaced + const element = ( + + ); + + this.root.render( + namespace + ? {element} + : element + ); + } + + // 5. RESTORE + BIND: Move content back and initialize Shiny + private handleItemMount = (itemId: string, containerEl: HTMLElement | null) => { + const content = this.itemContents.get(itemId); + if (content && containerEl) { + content.forEach(node => containerEl.appendChild(node)); + window.Shiny?.bindAll?.(containerEl); + } + }; + + disconnectedCallback() { + // Clean up: unbind Shiny, unmount React + window.Shiny?.unbindAll?.(this); + this.root?.unmount(); + this.root = null; + } +} + +customElements.define('my-custom-element', MyCustomElement); +``` + +**Key patterns:** +- Capture children in `connectedCallback()` before any DOM manipulation +- Pass `onItemMount` callback to React component +- Call `Shiny.bindAll()` after moving content to initialize bindings +- Call `Shiny.unbindAll()` in `disconnectedCallback()` for cleanup +- Wrap in `ShinyModuleProvider` when `id` is present for namespace support + +### Layer 3: React Component + +The React component manages layout and provides container refs: + +```typescript +import { useState, useEffect, useRef } from "react"; + +interface MyComponentProps { + config: Array<{ id: string; label: string }>; + option: string; + onItemMount: (itemId: string, containerEl: HTMLElement | null) => void; +} + +export function MyComponent({ config, option, onItemMount }: MyComponentProps) { + const [activeItem, setActiveItem] = useState(config[0]?.id || ''); + const itemRefs = useRef>(new Map()); + const mountedItems = useRef>(new Set()); + + // Call onItemMount once per item after initial render + useEffect(() => { + config.forEach(item => { + if (!mountedItems.current.has(item.id)) { + const containerEl = itemRefs.current.get(item.id); + if (containerEl) { + onItemMount(item.id, containerEl); + mountedItems.current.add(item.id); + } + } + }); + }, [config, onItemMount]); + + return ( +
+ {/* React-controlled chrome */} + + + {/* Containers for Shiny content */} +
+ {config.map(item => ( +
itemRefs.current.set(item.id, el)} + className={activeItem === item.id ? 'active' : 'inactive'} + /> + ))} +
+
+ ); +} +``` + +**Key patterns:** +- Track mounted items to call `onItemMount` only once per container +- Use refs to provide DOM elements back to the custom element +- Empty container divs—content comes from Shiny via `onItemMount` + +### Layer 4: CSS Styling + +**Critical: Use Bootstrap CSS Variables** + +For seamless integration with bslib themes, reference Bootstrap's CSS custom properties with sensible fallbacks: + +```css +:root { + /* Map component variables to Bootstrap variables */ + --my-bg: var(--bs-secondary-bg, #f8f9fa); + --my-text: var(--bs-body-color, #212529); + --my-hover: var(--bs-tertiary-bg, #e9ecef); + --my-active: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.15); + --my-active-text: var(--bs-primary, #0d6efd); + --my-border: var(--bs-border-color, #dee2e6); + --my-content-bg: var(--bs-body-bg, #fff); +} + +.my-component { + background-color: var(--my-bg); + color: var(--my-text); + border: 1px solid var(--my-border); +} + +.my-component button:hover { + background-color: var(--my-hover); +} + +.my-component button.active { + background-color: var(--my-active); + color: var(--my-active-text); +} + +.my-component main { + background-color: var(--my-content-bg); +} +``` + +**Common Bootstrap CSS variables:** +| Variable | Usage | +|----------|-------| +| `--bs-body-bg` | Page/content background | +| `--bs-body-color` | Primary text color | +| `--bs-secondary-bg` | Secondary backgrounds (cards, sidebars) | +| `--bs-tertiary-bg` | Hover states, subtle backgrounds | +| `--bs-primary` | Primary accent color | +| `--bs-primary-rgb` | Primary as RGB for rgba() | +| `--bs-border-color` | Standard border color | +| `--bs-border-radius` | Standard border radius | + +### Visibility vs Display for Hidden Panels + +**Use `visibility: hidden` instead of `display: none`** to preserve Shiny state: + +```css +.panel { + position: absolute; + width: 100%; + height: 100%; + visibility: hidden; + pointer-events: none; +} + +.panel.active { + visibility: visible; + pointer-events: auto; +} +``` + +Why this matters: +- `display: none` removes elements from layout, which can reset some Shiny input states +- `visibility: hidden` keeps elements in the DOM with their state intact +- Use `pointer-events: none` to prevent interaction with hidden panels + +## Module Namespace Support + +For components used within Shiny modules: + +**R side:** Apply `NS(id)` to child inputs/outputs: +```r +my_module_ui <- function(id) { + ns <- NS(id) + my_layout( + id = ns("layout"), # Namespace the container + my_item("Panel 1", + plotOutput(ns("plot")) # Namespace child outputs + ) + ) +} +``` + +**TypeScript side:** Wrap in `ShinyModuleProvider`: +```typescript +const element = ; + +this.root.render( + namespace + ? {element} + : element +); +``` + +The Shiny content is already namespaced by R—`ShinyModuleProvider` only affects React hooks (like `useShinyInput`) used within the component itself. + +## Icons + +Accept icons from bsicons or fontawesome, which render to SVG strings: + +**R side:** +```r +my_item <- function(label, ..., icon = NULL) { + icon_svg <- if (!is.null(icon)) { + if (inherits(icon, "shiny.tag")) as.character(icon) else icon + } else NULL + + div(`data-icon` = icon_svg, ...) +} +``` + +**React side:** +```tsx +{icon && ( + +)} +``` + +**CSS for SVG icons:** +```css +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +.icon svg { + width: 20px; + height: 20px; + fill: currentColor; /* Inherit text color */ +} +``` + +## Checklist for New Blended Components + +- [ ] R/Python wrapper functions with `data-*` attributes for configuration +- [ ] Child content wrapped in identifiable containers (`data-item-id`) +- [ ] Custom element captures children in `connectedCallback()` +- [ ] React renders empty container refs +- [ ] `onItemMount` callback moves content and calls `Shiny.bindAll()` +- [ ] `disconnectedCallback()` calls `Shiny.unbindAll()` and unmounts React +- [ ] CSS uses Bootstrap variables (`--bs-*`) with fallbacks +- [ ] Hidden panels use `visibility: hidden`, not `display: none` +- [ ] Module namespace support via `ShinyModuleProvider` +- [ ] Icons accept shiny.tag objects and render SVG with `currentColor` + +## Common Pitfalls + +1. **Forgetting `Shiny.bindAll()`** — Inputs won't work +2. **Using `display: none`** — Can lose Shiny input state +3. **Hardcoded colors** — Won't match bslib themes +4. **Missing fallbacks** — CSS variables need defaults for non-Bootstrap contexts +5. **Calling `onItemMount` multiple times** — Track mounted items to call only once +6. **Not unbinding on disconnect** — Can cause memory leaks or stale handlers diff --git a/examples/9-blended/README.md b/examples/9-blended/README.md new file mode 100644 index 0000000..1fe1efd --- /dev/null +++ b/examples/9-blended/README.md @@ -0,0 +1,169 @@ +# Example 9: Blended React + Shiny UI + +This example demonstrates a "blended" architecture where React handles the layout and navigation chrome while native Shiny UI elements live inside each panel. This approach enables custom React-powered layouts with full Shiny reactivity and state management. + +## Overview + +The blended pattern combines the best of both worlds: + +- **React manages the layout**: A collapsible sidebar with navigation tabs, smooth animations, and custom styling +- **Shiny handles the content**: Traditional Shiny inputs and outputs (`plotOutput`, `textInput`, etc.) rendered inside React containers +- **Full reactivity preserved**: All Shiny reactive bindings work normally, with proper initialization and cleanup + +This is particularly useful when you want React's sophisticated UI components (like custom sidebars, navigation, or layout systems) but prefer writing application logic and outputs in R. + +## Key Concept: Slot Preservation Pattern + +The implementation uses a "slot preservation" pattern to seamlessly integrate Shiny content into React components: + +1. **R renders Shiny UI** as children of a custom HTML element (``) +2. **Custom element captures children** before React renders, storing the Shiny content by panel ID +3. **React renders the layout** with empty container refs for each panel +4. **Content is moved** into React containers after mount using `appendChild` +5. **`Shiny.bindAll()`** initializes bindings to activate inputs/outputs + +This approach preserves Shiny's DOM structure and reactive bindings while giving React full control over layout and navigation. + +## R API + +### `react_sidebar_layout(...)` + +Main container for the blended layout. Accepts `react_nav_panel()` children and configuration options. + +**Parameters:** +- `...` - One or more `react_nav_panel()` elements +- `id` - Optional ID for the layout container (enables Shiny module namespacing) +- `title` - Optional title displayed in the sidebar header +- `collapsible` - Whether sidebar can collapse (default: `TRUE`) +- `default_open` - Whether sidebar starts open (default: `TRUE`) +- `position` - Sidebar position: `"left"` or `"right"` (default: `"left"`) +- `width` - Sidebar width when open (default: `"250px"`) + +### `react_nav_panel(title, ..., icon, value)` + +Defines a navigation panel containing Shiny UI elements. + +**Parameters:** +- `title` - Display title for the navigation item +- `...` - Shiny UI elements to display in the panel +- `icon` - Optional icon (supports `bsicons::bs_icon()` or `fontawesome::fa()`) +- `value` - Panel identifier (defaults to `title`) + +### Example + +```r +library(shiny) +library(bslib) +library(bsicons) + +ui <- page_fillable( + react_sidebar_layout( + title = "My App", + + react_nav_panel( + "Dashboard", + icon = bs_icon("bar-chart-fill"), + card( + card_header("Sales Overview"), + plotOutput("salesPlot") + ), + sliderInput("months", "Months:", min = 1, max = 12, value = 6) + ), + + react_nav_panel( + "Settings", + icon = bs_icon("gear-fill"), + textInput("username", "Username:"), + checkboxInput("darkMode", "Dark Mode", FALSE) + ) + ) +) + +server <- function(input, output, session) { + output$salesPlot <- renderPlot({ + # Plot logic using input$months + }) +} + +shinyApp(ui, server) +``` + +## Running the Example + +### Prerequisites + +- Node.js (for building TypeScript) +- R with packages: `shiny`, `bslib`, `bsicons` + +### Commands + +```bash +# Install dependencies +npm install + +# Build TypeScript to JavaScript +npm run build + +# Run with hot reloading (auto-rebuilds on file changes) +npm run dev-r +``` + +The app will be available at http://localhost:8000 (or the port specified by `R_PORT` environment variable). + +## Technical Details + +### Sidebar State Management + +- Collapse/expand state is managed by React's `useState` +- Sidebar width transitions smoothly when toggled +- Collapse button icon direction adapts to sidebar position (left/right) + +### Panel Switching + +- Uses `visibility` CSS property instead of `display: none` to preserve Shiny state +- Active panel: `visibility: visible; position: relative` +- Inactive panels: `visibility: hidden; position: absolute` +- This prevents Shiny from unbinding inputs/outputs when switching panels + +### Icon Rendering + +- Icons from `bsicons::bs_icon()` or `fontawesome::fa()` are rendered as SVG strings +- SVG content is injected using `dangerouslySetInnerHTML` in React +- Tooltip shows full panel title when sidebar is collapsed + +### Module Support + +- Full Shiny module namespace support via `id` parameter on `react_sidebar_layout()` +- When `id` is set, the custom element wraps React components in `` +- All Shiny inputs/outputs inside panels are automatically namespaced + +## Known Limitations / Future Work + +- **Plot resizing**: Plots may need manual resize triggers when switching panels (Shiny plots don't automatically detect visibility changes) +- **Complex outputs**: Some Shiny outputs with custom initialization may need additional handling in the `onPanelMount` callback +- **Animation smoothness**: Panel switching is instant; could add fade transitions for better UX +- **Accessibility**: Keyboard navigation between panels could be enhanced + +## File Structure + +``` +9-blended/ +├── r/ +│ ├── app.R # Main Shiny application +│ ├── shinyreact.R # R API functions (react_sidebar_layout, etc.) +│ └── www/ # Built assets (generated by npm run build) +│ ├── sidebar.js # Bundled React code +│ └── sidebar.css # Generated styles +├── srcts/ +│ ├── main.tsx # Custom element definition & registration +│ ├── SidebarLayout.tsx # React component for sidebar UI +│ └── styles.css # Component styles +├── package.json # Node.js dependencies and build scripts +└── README.md # This file +``` + +## Learn More + +- See [shiny-react documentation](../../README.md) for more blended patterns +- Example 8 demonstrates pure React components with Shiny communication +- Example 10 shows advanced patterns with dynamic content injection diff --git a/examples/9-blended/package.json b/examples/9-blended/package.json new file mode 100644 index 0000000..f8811c7 --- /dev/null +++ b/examples/9-blended/package.json @@ -0,0 +1,39 @@ +{ + "private": true, + "name": "shiny-react-blended", + "version": "1.0.0", + "type": "module", + "description": "Blended React layout with native Shiny UI content", + "scripts": { + "build": "concurrently -c auto \"npm run build-r\" \"npm run build-py\" \"tsc --noEmit\"", + "build-r": "esbuild srcts/main.tsx --bundle --minify --outdir=r/www --entry-names=sidebar --format=esm --alias:react=react", + "build-py": "esbuild srcts/main.tsx --bundle --minify --outdir=python/www --entry-names=sidebar --format=esm --alias:react=react", + "dev-r": "concurrently -c auto \"npm run watch-r\" \"npm run shinyapp-r\"", + "dev-py": "concurrently -c auto \"npm run watch-py\" \"npm run shinyapp-py\"", + "watch-r": "esbuild srcts/main.tsx --bundle --outdir=r/www --entry-names=sidebar --sourcemap --format=esm --alias:react=react --watch", + "watch-py": "esbuild srcts/main.tsx --bundle --outdir=python/www --entry-names=sidebar --sourcemap --format=esm --alias:react=react --watch", + "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"", + "shinyapp-py": "uvx --with matplotlib,pandas,faicons,jinja2 shiny run python/app.py --port ${PY_PORT:-8001} --reload", + "clean": "rm -rf r/www python/www" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "concurrently": "^9.0.1", + "esbuild": "^0.25.9", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "typescript": "^5.9.2" + }, + "dependencies": { + "@posit/shiny-react": "file:../.." + }, + "exampleMetadata": { + "title": "Blended React + Shiny UI", + "description": "React sidebar layout containing native Shiny UI elements", + "deployToShinylive": true, + "comment": "" + } +} diff --git a/examples/9-blended/python/app.py b/examples/9-blended/python/app.py new file mode 100644 index 0000000..26078b9 --- /dev/null +++ b/examples/9-blended/python/app.py @@ -0,0 +1,172 @@ +from pathlib import Path + +from shiny import App, ui, render, reactive +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from shinyreact import react_sidebar_layout, react_nav_panel +import faicons as fa + +# Create mtcars equivalent dataset +mtcars = pd.DataFrame({ + 'mpg': [21.0, 21.0, 22.8, 21.4, 18.7, 18.1, 14.3, 24.4, 22.8, 19.2], + 'cyl': [6, 6, 4, 6, 8, 6, 8, 4, 4, 6], + 'disp': [160.0, 160.0, 108.0, 258.0, 360.0, 225.0, 360.0, 146.7, 140.8, 167.6], + 'hp': [110, 110, 93, 110, 175, 105, 245, 62, 95, 123], + 'drat': [3.90, 3.90, 3.85, 3.08, 3.15, 2.76, 3.21, 3.69, 3.92, 3.92] +}) +mtcars.index = ['Mazda RX4', 'Mazda RX4 Wag', 'Datsun 710', 'Hornet 4 Drive', + 'Hornet Sportabout', 'Valiant', 'Duster 360', 'Merc 240D', + 'Merc 230', 'Merc 280'] + +# Define UI +app_ui = ui.page_fillable( + ui.tags.head( + ui.tags.link( + href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css", + rel="stylesheet" + ) + ), + + # React sidebar layout with three panels + react_sidebar_layout( + # Panel 1: Dashboard + react_nav_panel( + "Dashboard", + + # Sales Overview Card + ui.card( + ui.card_header("Sales Overview"), + ui.card_body( + ui.output_plot("salesPlot", height="300px") + ) + ), + + # Controls Card + ui.card( + ui.card_header("Controls"), + ui.card_body( + ui.input_slider("months", "Months:", min=1, max=12, value=6), + ui.input_select( + "region", + "Region:", + choices=["North", "South", "East", "West"] + ) + ) + ), + + icon=fa.icon_svg("chart-bar"), + value="dashboard", + ), + + # Panel 2: Data + react_nav_panel( + "Data", + + # Data Table Card + ui.card( + ui.card_header("Data Table"), + ui.card_body( + ui.output_table("dataTable") + ) + ), + + # Refresh Controls + ui.input_action_button("refresh", "Refresh Data", class_="btn-primary mt-3"), + ui.output_text_verbatim("refreshCount"), + + icon=fa.icon_svg("table"), + value="data", + ), + + # Panel 3: Settings + react_nav_panel( + "Settings", + + # Preferences Card + ui.card( + ui.card_header("Preferences"), + ui.card_body( + ui.input_text("username", "Username:"), + ui.input_checkbox("darkMode", "Dark Mode", False), + ui.input_checkbox("notifications", "Enable Notifications", True) + ) + ), + + # Current Settings Card + ui.card( + ui.card_header("Current Settings"), + ui.card_body( + ui.output_text_verbatim("currentSettings") + ) + ), + + icon=fa.icon_svg("gear"), + value="settings", + ), + + title="Blended Demo", + ), + padding=0, +) + + +# Define server logic +def server(input, output, session): + # Sales Plot - reactive to months and region + @render.plot + def salesPlot(): + # Generate cumulative sales data based on inputs + months = np.arange(1, input.months() + 1) + np.random.seed(42) # For reproducibility + sales = np.cumsum(np.random.uniform(50, 150, input.months())) + + # Create line plot + fig, ax = plt.subplots(figsize=(8, 4)) + ax.plot(months, sales, linewidth=2, color='#2563eb', marker='o', + markersize=8, markerfacecolor='#2563eb') + ax.set_title(f'Sales Trend - {input.region()}', fontsize=14, fontweight='bold') + ax.set_xlabel('Month', fontsize=11) + ax.set_ylabel('Cumulative Sales ($1000s)', fontsize=11) + ax.grid(True, color='#e5e7eb', linestyle='-', linewidth=0.5) + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + + return fig + + # Data Table - showing mtcars data + @render.table + def dataTable(): + return mtcars.head(10) + + # Refresh Counter - reactive value for button clicks + refresh_count = reactive.value(0) + + @reactive.effect + @reactive.event(input.refresh) + def _(): + refresh_count.set(refresh_count() + 1) + + @render.text + def refreshCount(): + return f"Data refreshed {refresh_count()} times" + + # Current Settings Display + @render.text + def currentSettings(): + username = input.username() if len(input.username()) > 0 else "(not set)" + return f"""username: {username} +darkMode: {input.darkMode()} +notifications: {input.notifications()}""" + + # Dark Mode Toggle + @reactive.effect + @reactive.event(input.darkMode) + def _(): + # Note: toggle_dark_mode function would need to be implemented + # in shinyreact.py or as a custom message handler + pass + + +# Run the application +app = App(app_ui, server, static_assets=Path(__file__).parent / "www") diff --git a/examples/9-blended/python/shinyreact.py b/examples/9-blended/python/shinyreact.py new file mode 100644 index 0000000..128a773 --- /dev/null +++ b/examples/9-blended/python/shinyreact.py @@ -0,0 +1,214 @@ +import json +from shiny import ui, render, Session +from htmltools import Tag, TagList, HTMLDependency + + +def page_bare(*args, title=None, lang=None): + """Create a bare page with minimal dependencies.""" + jquery_dep = HTMLDependency( + name="jquery", + version="3.6.0", + source={"href": "https://code.jquery.com"}, + script={"src": "jquery-3.6.0.min.js"} + ) + + elements = [jquery_dep] + if title is not None: + elements.append(ui.tags.head(ui.tags.title(title))) + elements.extend(args) + + result = ui.TagList(*elements) + if lang is not None: + # Store lang attribute for potential HTML wrapper + pass + return result + + +def page_react( + *args, + title=None, + js_file="main.js", + css_file="main.css", + lang="en" +): + """Create a React-enabled page with script and style dependencies.""" + head_elements = [] + if js_file is not None: + head_elements.append(ui.tags.script(src=js_file, type="module")) + if css_file is not None: + head_elements.append(ui.tags.link(href=css_file, rel="stylesheet")) + + return page_bare( + ui.tags.head(*head_elements) if head_elements else None, + ui.tags.div(id="root"), + *args, + title=title + ) + + +def render_json(func=None): + """ + Reactively render arbitrary JSON object data. + + This is a generic renderer that can be used to render any JSON-serializable data. + The data is converted to JSON before being sent to the client. + + Usage: + @render_json + def my_output(): + return {"key": "value", "number": 42} + """ + if func is None: + return lambda f: render_json(f) + + @render.ui + def _render(): + return func() + + return _render + + +def post_message(session: Session, type: str, data): + """ + Send a custom message to the client. + + A convenience function for sending custom messages from the Shiny server to + React components using useShinyMessageHandler() hook. This wraps messages in a + standard format and sends them via the "shinyReactMessage" channel. + + When called from within a Shiny module, the message type is automatically + namespaced using session.ns() to match the React component's namespace. + + Args: + session: The Shiny session object + type: The message type (should match messageType in useShinyMessageHandler) + data: The data to send to the client + """ + # Apply namespace to message type using session.ns() + # session.ns() returns the ID unchanged if not in a module context + namespaced_type = session.ns(type) + + session.send_custom_message( + "shinyReactMessage", + { + "type": namespaced_type, + "data": data + } + ) + + +def react_nav_panel(title, *args, icon=None, value=None): + """ + Create a React navigation panel. + + Wraps Shiny content for a navigation panel in a React sidebar layout. + The panel content is initially hidden and shown by React when the panel + is activated. + + Args: + title: The display title for the navigation panel + *args: Shiny UI elements to display in the panel + icon: Optional icon for the panel. Can be an icon object that renders to SVG string + value: The value/ID for this panel (defaults to title) + + Returns: + A div tag with appropriate data attributes for React integration + """ + if value is None: + value = title + + # icon can be an icon object - convert to string if it's a Tag + icon_svg = None + if icon is not None: + if isinstance(icon, (Tag, TagList)): + icon_svg = str(icon) + else: + icon_svg = icon + + attrs = { + "data-panel-id": value, + "data-panel-title": title, + "class": "react-sidebar-panel-content", + "style": "display: none;" # Hidden until React takes over + } + + if icon_svg is not None: + attrs["data-panel-icon"] = icon_svg + + return ui.tags.div( + *args, + **attrs + ) + + +def react_sidebar_layout( + *args, + id=None, + title=None, + collapsible=True, + default_open=True, + position="left", + width="250px" +): + """ + Create a React sidebar layout. + + Creates a custom element that combines React-based navigation with Shiny + content panels. The sidebar navigation is rendered by React while panel + content is pure Shiny UI. + + Args: + *args: react_nav_panel() elements defining the sidebar panels + id: Optional ID for the layout container + title: Optional title to display in the sidebar header + collapsible: Whether the sidebar can be collapsed (default: True) + default_open: Whether sidebar starts open (default: True) + position: Sidebar position: "left" or "right" (default: "left") + width: Sidebar width when open (default: "250px") + + Returns: + A custom element tag containing the sidebar layout + """ + panels = list(args) + + # Extract panel metadata for React + panel_config = [] + for p in panels: + if hasattr(p, 'attrs'): + panel_config.append({ + "id": p.attrs.get("data-panel-id"), + "title": p.attrs.get("data-panel-title"), + "icon": p.attrs.get("data-panel-icon") + }) + + # Build attributes for custom element + attrs = { + "class": "react-sidebar-layout-container", + "style": "display: flex; height: 100%; width: 100%;", + "data-panels": json.dumps(panel_config), + "data-collapsible": str(collapsible).lower(), + "data-default-open": str(default_open).lower(), + "data-position": position, + "data-width": width + } + + if id is not None: + attrs["id"] = id + + if title is not None: + attrs["data-title"] = title + + # Create the custom element + custom_element = Tag( + "react-sidebar-layout", + attrs, + *panels + ) + + return ui.TagList( + ui.tags.head( + ui.tags.script(src="sidebar.js", type="module"), + ui.tags.link(href="sidebar.css", rel="stylesheet") + ), + custom_element + ) diff --git a/examples/9-blended/r/app.R b/examples/9-blended/r/app.R new file mode 100644 index 0000000..5692e38 --- /dev/null +++ b/examples/9-blended/r/app.R @@ -0,0 +1,153 @@ +library(shiny) +library(bslib) +library(bsicons) + +source("shinyreact.R", local = TRUE) + +# Define UI +ui <- page_fillable( + theme = bs_theme(version = 5, preset = "lumen"), + padding = 0, + + # React sidebar layout with three panels + react_sidebar_layout( + title = "Blended Demo", + + # Panel 1: Dashboard + react_nav_panel( + "Dashboard", + icon = bs_icon("bar-chart-fill"), + value = "dashboard", + + # Sales Overview Card + card( + card_header("Sales Overview"), + card_body( + plotOutput("salesPlot", height = "300px") + ) + ), + + # Controls Card + card( + card_header("Controls"), + card_body( + sliderInput("months", "Months:", min = 1, max = 12, value = 6), + selectInput( + "region", + "Region:", + choices = c("North", "South", "East", "West") + ) + ) + ) + ), + + # Panel 2: Data + react_nav_panel( + "Data", + icon = bs_icon("table"), + value = "data", + + # Data Table Card + card( + card_header("Data Table"), + card_body( + tableOutput("dataTable") + ) + ), + + # Refresh Controls + actionButton("refresh", "Refresh Data", class = "btn-primary mt-3"), + verbatimTextOutput("refreshCount") + ), + + # Panel 3: Settings + react_nav_panel( + "Settings", + icon = bs_icon("gear-fill"), + value = "settings", + + # Preferences Card + card( + card_header("Preferences"), + card_body( + textInput("username", "Username:"), + checkboxInput("darkMode", "Dark Mode", FALSE), + checkboxInput("notifications", "Enable Notifications", TRUE) + ) + ), + + # Current Settings Card + card( + card_header("Current Settings"), + card_body( + verbatimTextOutput("currentSettings") + ) + ) + ) + ) +) + +# Define server logic +server <- function(input, output, session) { + # Sales Plot - reactive to months and region + output$salesPlot <- renderPlot({ + # Generate cumulative sales data based on inputs + months <- seq_len(input$months) + set.seed(42) # For reproducibility + sales <- cumsum(runif(input$months, min = 50, max = 150)) + + # Create line plot + plot( + months, + sales, + type = "l", + lwd = 2, + col = "#2563eb", + main = paste("Sales Trend -", input$region), + xlab = "Month", + ylab = "Cumulative Sales ($1000s)", + las = 1 + ) + + # Add points + points(months, sales, pch = 19, col = "#2563eb", cex = 1.2) + + # Add grid + grid(col = "#e5e7eb", lty = 1) + }) + + # Data Table - showing mtcars data + output$dataTable <- renderTable( + { + head(mtcars[, 1:5], 10) + }, + rownames = TRUE + ) + + # Refresh Counter - reactive value for button clicks + refreshCount <- reactiveVal(0) + + observeEvent(input$refresh, { + refreshCount(refreshCount() + 1) + }) + + output$refreshCount <- renderText({ + paste("Data refreshed", refreshCount(), "times") + }) + + # Current Settings Display + output$currentSettings <- renderPrint({ + list( + username = if (nchar(input$username) > 0) input$username else "(not set)", + darkMode = input$darkMode, + notifications = input$notifications + ) + }) + + observeEvent(input$darkMode, { + toggle_dark_mode(if (input$darkMode) "dark" else "light") + }) +} + +# Run the application +shinyApp(ui = ui, server = server) diff --git a/examples/9-blended/r/shinyreact.R b/examples/9-blended/r/shinyreact.R new file mode 100644 index 0000000..0b2b162 --- /dev/null +++ b/examples/9-blended/r/shinyreact.R @@ -0,0 +1,174 @@ +library(shiny) + +page_bare <- function(..., title = NULL, lang = NULL) { + ui <- list( + shiny:::jqueryDependency(), + if (!is.null(title)) tags$head(tags$title(title)), + ... + ) + attr(ui, "lang") <- lang + ui +} + +page_react <- function( + ..., + title = NULL, + js_file = "main.js", + css_file = "main.css", + lang = "en" +) { + page_bare( + title = title, + tags$head( + if (!is.null(js_file)) tags$script(src = js_file, type = "module"), + if (!is.null(css_file)) tags$link(href = css_file, rel = "stylesheet") + ), + tags$div(id = "root"), + ... + ) +} + + +#' Reactively render arbitrary JSON object data. +#' +#' This is a generic renderer that can be used to render any Jsonifiable data. +#' The data goes through shiny:::toJSON() before being sent to the client. +render_json <- function( + expr, + env = parent.frame(), + quoted = FALSE, + outputArgs = list(), + sep = " " +) { + func <- installExprFunction( + expr, + "func", + env, + quoted, + label = "render_json" + ) + + createRenderFunction( + func, + function(value, session, name, ...) { + value + }, + function(...) { + stop("Not implemented") + }, + outputArgs + ) +} + +#' Send a custom message to the client +#' +#' A convenience function for sending custom messages from the Shiny server to +#' React components using useShinyMessageHandler() hook. This wraps messages in a +#' standard format and sends them via the "shinyReactMessage" channel. +#' +#' When called from within a Shiny module, the message type is automatically +#' namespaced using session$ns() to match the React component's namespace. +#' +#' @param session The Shiny session object +#' @param type The message type (should match messageType in useShinyMessageHandler) +#' @param data The data to send to the client +post_message <- function(session, type, data) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + + session$sendCustomMessage( + "shinyReactMessage", + list( + type = namespaced_type, + data = data + ) + ) +} + +#' Create a React navigation panel +#' +#' Wraps Shiny content for a navigation panel in a React sidebar layout. +#' The panel content is initially hidden and shown by React when the panel +#' is activated. +#' +#' @param title The display title for the navigation panel +#' @param ... Shiny UI elements to display in the panel +#' @param icon Optional icon for the panel. Can be bsicons::bs_icon() or +#' fontawesome::fa() which renders to SVG string +#' @param value The value/ID for this panel (defaults to title) +#' @return A div tag with appropriate data attributes for React integration +react_nav_panel <- function(title, ..., icon = NULL, value = title) { + # icon can be bsicons::bs_icon() or fontawesome::fa() - renders to SVG string + icon_svg <- if (!is.null(icon)) { + if (inherits(icon, "shiny.tag")) as.character(icon) else icon + } else { + NULL + } + + div( + `data-panel-id` = value, + `data-panel-title` = title, + `data-panel-icon` = icon_svg, + class = "react-sidebar-panel-content", + style = "display: none;", # Hidden until React takes over + ... + ) +} + +#' Create a React sidebar layout +#' +#' Creates a custom element that combines React-based navigation with Shiny +#' content panels. The sidebar navigation is rendered by React while panel +#' content is pure Shiny UI. +#' +#' @param ... react_nav_panel() elements defining the sidebar panels +#' @param id Optional ID for the layout container +#' @param title Optional title to display in the sidebar header +#' @param collapsible Whether the sidebar can be collapsed (default: TRUE) +#' @param default_open Whether sidebar starts open (default: TRUE) +#' @param position Sidebar position: "left" or "right" (default: "left") +#' @param width Sidebar width when open (default: "250px") +#' @return A custom element tag containing the sidebar layout +react_sidebar_layout <- function( + ..., + id = NULL, + title = NULL, + collapsible = TRUE, + default_open = TRUE, + position = "left", + width = "250px" +) { + panels <- list(...) + + # Extract panel metadata for React + panel_config <- lapply(panels, function(p) { + list( + id = p$attribs$`data-panel-id`, + title = p$attribs$`data-panel-title`, + icon = p$attribs$`data-panel-icon` + ) + }) + + tagList( + tags$head( + tags$script(src = "sidebar.js", type = "module"), + tags$link(href = "sidebar.css", rel = "stylesheet") + ), + tag( + "react-sidebar-layout", + list( + id = id, + `data-title` = title, + `data-panels` = jsonlite::toJSON(panel_config, auto_unbox = TRUE), + `data-collapsible` = tolower(as.character(collapsible)), + `data-default-open` = tolower(as.character(default_open)), + `data-position` = position, + `data-width` = width, + class = "react-sidebar-layout-container", + style = "display: flex; height: 100%; width: 100%;", + panels # Shiny content as children + ) + ) + ) +} diff --git a/examples/9-blended/srcts/SidebarLayout.tsx b/examples/9-blended/srcts/SidebarLayout.tsx new file mode 100644 index 0000000..4af7280 --- /dev/null +++ b/examples/9-blended/srcts/SidebarLayout.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef } from "react"; + +interface PanelConfig { + id: string; + title: string; + icon: string | null; +} + +interface SidebarLayoutProps { + title: string | null; + panels: PanelConfig[]; + collapsible: boolean; + defaultOpen: boolean; + position: 'left' | 'right'; + width: string; + onPanelMount: (panelId: string, containerEl: HTMLElement | null) => void; +} + +export function SidebarLayout({ + title, + panels, + collapsible, + defaultOpen, + position, + width, + onPanelMount, +}: SidebarLayoutProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const [activePanel, setActivePanel] = useState(panels[0]?.id || ''); + const panelRefs = useRef>(new Map()); + const mountedPanels = useRef>(new Set()); + + useEffect(() => { + panels.forEach(panel => { + if (!mountedPanels.current.has(panel.id)) { + const containerEl = panelRefs.current.get(panel.id); + if (containerEl) { + onPanelMount(panel.id, containerEl); + mountedPanels.current.add(panel.id); + } + } + }); + }, [panels, onPanelMount]); + + const toggleSidebar = () => { + setIsOpen(!isOpen); + }; + + return ( +
+ +
+ {panels.map(panel => ( +
{ + panelRefs.current.set(panel.id, el); + }} + className={`sidebar-panel ${activePanel === panel.id ? 'active' : ''}`} + /> + ))} +
+
+ ); +} diff --git a/examples/9-blended/srcts/main.tsx b/examples/9-blended/srcts/main.tsx new file mode 100644 index 0000000..091de64 --- /dev/null +++ b/examples/9-blended/srcts/main.tsx @@ -0,0 +1,80 @@ +import { createRoot, Root } from "react-dom/client"; +import { ShinyModuleProvider } from "@posit/shiny-react"; +import { SidebarLayout } from "./SidebarLayout"; +import "./styles.css"; + +interface PanelConfig { + id: string; + title: string; + icon: string | null; +} + +class ReactSidebarLayoutElement extends HTMLElement { + private root: Root | null = null; + private panelContents: Map = new Map(); + + connectedCallback() { + // 1. Capture panel content children before React renders + const panelDivs = this.querySelectorAll('[data-panel-id]'); + panelDivs.forEach(div => { + const panelId = div.getAttribute('data-panel-id')!; + // Store the child nodes (the actual Shiny content) + this.panelContents.set(panelId, Array.from(div.childNodes)); + }); + + // 2. Parse configuration from attributes + const config = { + title: this.dataset.title || null, + panels: JSON.parse(this.dataset.panels || '[]') as PanelConfig[], + collapsible: this.dataset.collapsible !== 'false', + defaultOpen: this.dataset.defaultOpen !== 'false', + position: (this.dataset.position || 'left') as 'left' | 'right', + width: this.dataset.width || '250px', + }; + + const namespace = this.id || undefined; + + // 3. Clear element and create React root + this.innerHTML = ''; + this.root = createRoot(this); + + // 4. Render with callback to restore Shiny content + const element = namespace ? ( + + + + ) : ( + + ); + + this.root.render(element); + } + + private handlePanelMount = (panelId: string, containerEl: HTMLElement | null) => { + const content = this.panelContents.get(panelId); + if (content && containerEl) { + content.forEach(node => containerEl.appendChild(node)); + // Initialize Shiny bindings after content is moved + if (window.Shiny?.bindAll) { + window.Shiny.bindAll(containerEl); + } + } + }; + + disconnectedCallback() { + // Unbind Shiny before unmounting + if (window.Shiny?.unbindAll) { + window.Shiny.unbindAll(this); + } + this.root?.unmount(); + this.root = null; + } +} + +customElements.define('react-sidebar-layout', ReactSidebarLayoutElement); diff --git a/examples/9-blended/srcts/styles.css b/examples/9-blended/srcts/styles.css new file mode 100644 index 0000000..a28dd00 --- /dev/null +++ b/examples/9-blended/srcts/styles.css @@ -0,0 +1,169 @@ +:root { + --sidebar-bg: var(--bs-secondary-bg, #f8f9fa); + --sidebar-text: var(--bs-body-color, #212529); + --sidebar-hover: var(--bs-tertiary-bg, #e9ecef); + --sidebar-active: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.15); + --sidebar-active-text: var(--bs-primary, #0d6efd); + --sidebar-border: var(--bs-border-color, #dee2e6); +} + +.sidebar-layout { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.sidebar-layout[data-position="right"] { + flex-direction: row-reverse; +} + +.sidebar { + background-color: var(--sidebar-bg); + color: var(--sidebar-text); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.3s ease; + border-right: 1px solid var(--sidebar-border); + overflow: hidden; +} + +.sidebar-layout[data-position="right"] .sidebar { + border-right: none; + border-left: 1px solid var(--sidebar-border); +} + +.sidebar-header { + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--sidebar-border); + min-height: 60px; +} + +.sidebar-layout[data-open="false"] .sidebar-header { + justify-content: center; +} + +.sidebar-layout[data-open="false"] .sidebar-nav-item { + justify-content: center; + padding: 0.75rem 0; +} + +.sidebar-title { + font-size: 1.125rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.sidebar-collapse-btn { + background: none; + border: none; + color: var(--sidebar-text); + cursor: pointer; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s ease; + flex-shrink: 0; +} + +.sidebar-collapse-btn:hover { + background-color: var(--sidebar-hover); +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.5rem; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: none; + border: none; + color: var(--sidebar-text); + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s ease; + text-align: left; + width: 100%; + height: 44px; + box-sizing: border-box; +} + +.sidebar-nav-item:hover { + background-color: var(--sidebar-hover); +} + +.sidebar-nav-item.active { + background-color: var(--sidebar-active); + color: var(--sidebar-active-text); +} + +.sidebar-nav-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.sidebar-nav-icon svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.sidebar-nav-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 1; + transition: opacity 0.3s ease; +} + +.sidebar-layout[data-open="false"] .sidebar-nav-label { + opacity: 0; + width: 0; +} + +.sidebar-content { + flex: 1; + overflow: auto; + position: relative; + background-color: var(--bs-body-bg, #fff); +} + +.sidebar-panel { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + overflow: auto; + padding: 1rem; + visibility: hidden; + z-index: 0; + pointer-events: none; +} + +.sidebar-panel.active { + visibility: visible; + z-index: 1; + pointer-events: auto; + background-color: var(--bs-body-bg, #fff); +} diff --git a/examples/9-blended/tsconfig.json b/examples/9-blended/tsconfig.json new file mode 100644 index 0000000..d788f15 --- /dev/null +++ b/examples/9-blended/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "noEmit": true, + "moduleResolution": "node", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@/*": ["./srcts/*"] + } + }, + "include": ["srcts/**/*.ts", "srcts/**/*.tsx"] +} From 6b6ea50d212d688235b77c5f866342af1187d6b7 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 14 Jan 2026 16:55:30 -0500 Subject: [PATCH 3/5] feat: Simplify boilerplate for Shiny-React components Implements a base custom element class `ShinyReactComponentElement` that handles the React lifecycle, parsing config from attributes, wrapping the component in an ID namespace, and allowing the component to accept Shiny UI/inputs/outputs seamlessly as children with named slots. In most cases, this makes it super simple to create a new Shiny-React component by just extending this base class and specifying the React component to render. --- README.md | 115 +++++++---- examples/8-modules/package.json | 4 +- examples/8-modules/srcts-standard/main.tsx | 41 +--- examples/9-blended/package.json | 4 +- examples/9-blended/python/shinyreact.py | 4 +- examples/9-blended/r/shinyreact.R | 4 +- examples/9-blended/srcts/main.tsx | 80 ++------ src/ShinyReactComponentElement.tsx | 218 +++++++++++++++++++++ src/index.ts | 1 + 9 files changed, 322 insertions(+), 149 deletions(-) create mode 100644 src/ShinyReactComponentElement.tsx diff --git a/README.md b/README.md index 58a726b..7b4e733 100644 --- a/README.md +++ b/README.md @@ -93,55 +93,83 @@ def server(input, output, session): ## Creating Reusable React Widgets -When building React widgets for Shiny apps, **use custom web elements** for self-contained components with automatic lifecycle management: +For self-contained React widgets, extend `ShinyReactComponentElement` - a custom HTML element base class that handles React lifecycle, Shiny bindings, and namespace support automatically. + +### Simple Widget + +```typescript +import { ShinyReactComponentElement } from "@posit/shiny-react"; +import { CounterWidget } from "./CounterWidget"; + +class CounterWidgetElement extends ShinyReactComponentElement { + static component = CounterWidget; +} + +if (!customElements.get("counter-widget")) { + customElements.define("counter-widget", CounterWidgetElement); +} +``` + +That's it! The base class automatically: +- Creates a React root and renders your component +- Wraps in `ShinyModuleProvider` if the element has an `id` attribute +- Parses `data-*` attributes into props via `getConfig()` (with JSON auto-parsing) +- Cleans up React and Shiny bindings on disconnect + +### Blended Components (React + Shiny Content) + +For layouts where React controls the structure but Shiny provides the content (inputs, outputs, plots), use the slot system: ```typescript -// Define a custom element that wraps your React component -class MyWidgetElement extends HTMLElement { - private root: Root | null = null; - - connectedCallback() { - // Read attributes from the HTML element using dataset - const namespace = this.id; - const title = this.dataset.title || "Default Title"; - const initialValue = parseInt(this.dataset.initialValue || "0"); - - this.root = createRoot(this); - this.root.render( - - - - - +import { ShinyReactComponentElement } from "@posit/shiny-react"; +import { SidebarLayout } from "./SidebarLayout"; + +class SidebarLayoutElement extends ShinyReactComponentElement { + protected render() { + const config = this.getConfig(); + return ( + ); } +} - disconnectedCallback() { - if (this.root) { - this.root.unmount(); - this.root = null; - } - } +if (!customElements.get("react-sidebar-layout")) { + customElements.define("react-sidebar-layout", SidebarLayoutElement); } +``` -customElements.define("my-widget", MyWidgetElement); +In your React component, call `onSlotMount(slotName, containerElement)` after the container mounts to move Shiny content into place. + +**Slot naming:** +- Use `data-slot="name"` attributes in R/Python to create named slots +- If no `data-slot` elements exist, all children are captured as `__children__` + +### Configuration via Data Attributes + +The `getConfig()` method automatically parses `data-*` attributes: + +```html + ``` -**Why custom web elements?** -- Pass configuration through HTML attributes to React props -- Automatic initialization when added to DOM -- Automatic cleanup when removed (works with dynamic rendering) -- Semantic HTML: `` instead of generic `
` -- Self-contained: all widget logic in one place -- Compatible with Shiny's `insertUI()`/`removeUI()` and `ui.insert_ui()`/`ui.remove_ui()` +Becomes: `{ count: 5, enabled: true, items: [1,2,3], title: "Hello" }` -Then create clean Shiny APIs that pass attributes: +- Numbers and booleans are parsed from JSON +- Arrays and objects work via JSON +- Plain strings that aren't valid JSON stay as strings +### R/Python Widget APIs + +Create clean Shiny APIs that pass attributes: + +**R:** ```r -# R my_widget_ui <- function(id, title = "My Widget", initial_value = 0) { - card( - card_header(title), + tagList( + htmlDependency(...), # Include your JS/CSS tag("my-widget", list( id = id, `data-title` = title, @@ -151,18 +179,23 @@ my_widget_ui <- function(id, title = "My Widget", initial_value = 0) { } ``` +**Python:** ```python -# Python def my_widget_ui(id: str, title: str = "My Widget", initial_value: int = 0): - return ui.card( - ui.card_header(title), + return ui.TagList( + ui.include_js(...), # Include your JS/CSS ui.HTML(f'') ) ``` -**Tip:** Use `data-*` attributes for custom configuration to follow HTML standards. In the custom element, you can read these attributes and pass them as props to your React component. +**Why custom web elements?** +- Automatic initialization when added to DOM +- Automatic cleanup when removed (works with `insertUI()`/`removeUI()`) +- Configuration via HTML attributes → React props +- Semantic HTML: `` instead of generic `
` +- Self-contained: all widget logic in one place -See [examples/8-modules/app-standard.R](examples/8-modules/app-standard.R) for a complete working example with dynamic widget rendering. +See [examples/8-modules/](examples/8-modules/) for complete working examples. ## Shiny Module Namespaces diff --git a/examples/8-modules/package.json b/examples/8-modules/package.json index 43f5fb8..7ee89e6 100644 --- a/examples/8-modules/package.json +++ b/examples/8-modules/package.json @@ -39,8 +39,8 @@ "@types/react-dom": "^19.1.9", "concurrently": "^9.0.1", "esbuild": "^0.25.9", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "19.1.1", + "react-dom": "19.1.1", "typescript": "^5.9.2" }, "dependencies": { diff --git a/examples/8-modules/srcts-standard/main.tsx b/examples/8-modules/srcts-standard/main.tsx index 2549da8..b3c0287 100644 --- a/examples/8-modules/srcts-standard/main.tsx +++ b/examples/8-modules/srcts-standard/main.tsx @@ -1,40 +1,11 @@ -import { StrictMode } from "react"; -import { createRoot, Root } from "react-dom/client"; -import { ShinyModuleProvider } from "@posit/shiny-react"; +import { ShinyReactComponentElement } from "@posit/shiny-react"; import { CounterWidget } from "./CounterWidget"; import "./styles.css"; -// Custom element that automatically initializes React when connected to DOM -class CounterWidgetElement extends HTMLElement { - private root: Root | null = null; - - connectedCallback() { - const namespace = this.id; - - if (!namespace) { - console.error("counter-widget missing `id` attribute"); - return; - } - - // Create React root and render - this.root = createRoot(this); - this.root.render( - - - - - - ); - } - - disconnectedCallback() { - // Clean up React root when element is removed - if (this.root) { - this.root.unmount(); - this.root = null; - } - } +class CounterWidgetElement extends ShinyReactComponentElement { + static component = CounterWidget; } -// Register the custom element -customElements.define("counter-widget", CounterWidgetElement); +if (!customElements.get("counter-widget")) { + customElements.define("counter-widget", CounterWidgetElement); +} diff --git a/examples/9-blended/package.json b/examples/9-blended/package.json index f8811c7..b847d9a 100644 --- a/examples/9-blended/package.json +++ b/examples/9-blended/package.json @@ -23,8 +23,8 @@ "@types/react-dom": "^19.1.9", "concurrently": "^9.0.1", "esbuild": "^0.25.9", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "19.1.1", + "react-dom": "19.1.1", "typescript": "^5.9.2" }, "dependencies": { diff --git a/examples/9-blended/python/shinyreact.py b/examples/9-blended/python/shinyreact.py index 128a773..ca89a7e 100644 --- a/examples/9-blended/python/shinyreact.py +++ b/examples/9-blended/python/shinyreact.py @@ -126,7 +126,7 @@ def react_nav_panel(title, *args, icon=None, value=None): icon_svg = icon attrs = { - "data-panel-id": value, + "data-slot": value, "data-panel-title": title, "class": "react-sidebar-panel-content", "style": "display: none;" # Hidden until React takes over @@ -176,7 +176,7 @@ def react_sidebar_layout( for p in panels: if hasattr(p, 'attrs'): panel_config.append({ - "id": p.attrs.get("data-panel-id"), + "id": p.attrs.get("data-slot"), "title": p.attrs.get("data-panel-title"), "icon": p.attrs.get("data-panel-icon") }) diff --git a/examples/9-blended/r/shinyreact.R b/examples/9-blended/r/shinyreact.R index 0b2b162..1017802 100644 --- a/examples/9-blended/r/shinyreact.R +++ b/examples/9-blended/r/shinyreact.R @@ -107,7 +107,7 @@ react_nav_panel <- function(title, ..., icon = NULL, value = title) { } div( - `data-panel-id` = value, + `data-slot` = value, `data-panel-title` = title, `data-panel-icon` = icon_svg, class = "react-sidebar-panel-content", @@ -144,7 +144,7 @@ react_sidebar_layout <- function( # Extract panel metadata for React panel_config <- lapply(panels, function(p) { list( - id = p$attribs$`data-panel-id`, + id = p$attribs$`data-slot`, title = p$attribs$`data-panel-title`, icon = p$attribs$`data-panel-icon` ) diff --git a/examples/9-blended/srcts/main.tsx b/examples/9-blended/srcts/main.tsx index 091de64..800ce5b 100644 --- a/examples/9-blended/srcts/main.tsx +++ b/examples/9-blended/srcts/main.tsx @@ -1,5 +1,4 @@ -import { createRoot, Root } from "react-dom/client"; -import { ShinyModuleProvider } from "@posit/shiny-react"; +import { ShinyReactComponentElement } from "@posit/shiny-react"; import { SidebarLayout } from "./SidebarLayout"; import "./styles.css"; @@ -9,72 +8,23 @@ interface PanelConfig { icon: string | null; } -class ReactSidebarLayoutElement extends HTMLElement { - private root: Root | null = null; - private panelContents: Map = new Map(); - - connectedCallback() { - // 1. Capture panel content children before React renders - const panelDivs = this.querySelectorAll('[data-panel-id]'); - panelDivs.forEach(div => { - const panelId = div.getAttribute('data-panel-id')!; - // Store the child nodes (the actual Shiny content) - this.panelContents.set(panelId, Array.from(div.childNodes)); - }); - - // 2. Parse configuration from attributes - const config = { - title: this.dataset.title || null, - panels: JSON.parse(this.dataset.panels || '[]') as PanelConfig[], - collapsible: this.dataset.collapsible !== 'false', - defaultOpen: this.dataset.defaultOpen !== 'false', - position: (this.dataset.position || 'left') as 'left' | 'right', - width: this.dataset.width || '250px', - }; - - const namespace = this.id || undefined; - - // 3. Clear element and create React root - this.innerHTML = ''; - this.root = createRoot(this); - - // 4. Render with callback to restore Shiny content - const element = namespace ? ( - - - - ) : ( +class ReactSidebarLayoutElement extends ShinyReactComponentElement { + protected render() { + const config = this.getConfig(); + return ( ); - - this.root.render(element); - } - - private handlePanelMount = (panelId: string, containerEl: HTMLElement | null) => { - const content = this.panelContents.get(panelId); - if (content && containerEl) { - content.forEach(node => containerEl.appendChild(node)); - // Initialize Shiny bindings after content is moved - if (window.Shiny?.bindAll) { - window.Shiny.bindAll(containerEl); - } - } - }; - - disconnectedCallback() { - // Unbind Shiny before unmounting - if (window.Shiny?.unbindAll) { - window.Shiny.unbindAll(this); - } - this.root?.unmount(); - this.root = null; } } -customElements.define('react-sidebar-layout', ReactSidebarLayoutElement); +if (!customElements.get("react-sidebar-layout")) { + customElements.define("react-sidebar-layout", ReactSidebarLayoutElement); +} diff --git a/src/ShinyReactComponentElement.tsx b/src/ShinyReactComponentElement.tsx new file mode 100644 index 0000000..3a12c1a --- /dev/null +++ b/src/ShinyReactComponentElement.tsx @@ -0,0 +1,218 @@ +import { createRoot, type Root } from "react-dom/client"; +import { ShinyModuleProvider } from "./ShinyModuleContext"; + +/** + * Base class for creating custom elements that render React components + * with automatic Shiny integration. + * + * Features: + * - Automatic namespace support via ShinyModuleProvider (uses element's id) + * - Slot preservation for blended React + Shiny content + * - Config parsing from data-* attributes (with JSON auto-parsing) + * - Proper Shiny binding lifecycle (bindAll/unbindAll) + * - Default slot capture: When no [data-slot] elements are found, all children + * are captured under the reserved slot name "__children__" + * + * @example Simple widget (no slots): + * ```typescript + * class MyCounterElement extends ShinyReactComponentElement { + * static component = CounterWidget; + * } + * customElements.define('my-counter', MyCounterElement); + * ``` + * + * @example Blended component (with slots): + * ```typescript + * class MySidebarElement extends ShinyReactComponentElement { + * static component = SidebarLayout; + * + * protected render() { + * return ; + * } + * } + * customElements.define('my-sidebar', MySidebarElement); + * ``` + * + * @example Skip clearing innerHTML (rare): + * ```typescript + * class MyOverlayElement extends ShinyReactComponentElement { + * protected clearContent() {} // no-op + * } + * ``` + */ +export class ShinyReactComponentElement extends HTMLElement { + protected root: Root | null = null; + protected slotContents: Map = new Map(); + + /** + * The React component to render. Set this on your subclass. + * @example + * ```typescript + * class MyElement extends ShinyReactComponentElement { + * static component = MyReactComponent; + * } + * ``` + */ + static component: React.ComponentType> | null = null; + + /** + * Captures children with [data-slot] attribute, storing their contents + * keyed by slot name. Called automatically in connectedCallback. + * + * If no [data-slot] elements are found and the element has child nodes, + * all children are captured under the reserved slot name "__children__". + * This provides a default slot for components that don't use named slots. + * + * @param selector CSS selector for slot containers (default: '[data-slot]') + * @returns Map of slot names to their child nodes + */ + protected captureSlots( + selector: string = "[data-slot]", + ): Map { + const elements = this.querySelectorAll(selector); + if (elements.length === 0) { + // No named slots - capture all children as default slot + if (this.childNodes.length > 0) { + this.slotContents.set("__children__", Array.from(this.childNodes)); + } + } else { + // Named slots found - capture each one + elements.forEach((el) => { + const slotName = el.getAttribute("data-slot"); + if (slotName) { + this.slotContents.set(slotName, Array.from(el.childNodes)); + } + }); + } + return this.slotContents; + } + + /** + * Moves captured slot content into a container element and initializes + * Shiny bindings. Call this from your React component via onSlotMount callback. + * + * @param slotName The slot identifier (from data-slot attribute) + * @param container The DOM element to move content into + */ + protected async mountSlot( + slotName: string, + container: HTMLElement | null, + ): Promise { + const content = this.slotContents.get(slotName); + if (content && container) { + content.forEach((node) => container.appendChild(node)); + await window.Shiny?.bindAll?.(container); + } + } + + /** + * Bound callback for mounting slots. Pass this to your React component + * to handle slot content mounting. + * + * @example + * ```typescript + * protected render() { + * return ; + * } + * ``` + */ + protected get onSlotMount(): ( + slotName: string, + el: HTMLElement | null, + ) => Promise { + return this.mountSlot.bind(this); + } + + /** + * Converts data-* attributes to a props object. + * Automatically attempts JSON parsing for rich values (numbers, booleans, + * arrays, objects). Falls back to string if parsing fails. + * + * @returns Config object with parsed data attributes + * + * @example + * ```html + * + * ``` + * Results in: { count: 5, enabled: true, items: [1,2,3], title: "Hello" } + */ + protected getConfig(): Record { + const config: Record = {}; + for (const [key, value] of Object.entries(this.dataset)) { + if (value === undefined) continue; + try { + config[key] = JSON.parse(value); + } catch { + config[key] = value; + } + } + return config; + } + + /** + * The namespace for Shiny module support, derived from the element's id. + * Returns undefined if no id is set. + */ + protected get namespace(): string | undefined { + return this.id || undefined; + } + + /** + * Renders the React component. Override this to customize rendering, + * pass additional props, or wrap in providers. + * + * @returns React node to render + */ + protected render(): React.ReactNode { + const Component = (this.constructor as typeof ShinyReactComponentElement) + .component; + if (!Component) { + console.error(`${this.constructor.name}: No static component defined`); + return null; + } + return ; + } + + /** + * Wraps content in ShinyModuleProvider if namespace exists. + */ + private wrapWithProvider(content: React.ReactNode): React.ReactNode { + if (this.namespace) { + return ( + + {content} + + ); + } + return content; + } + + /** + * Clears the element's innerHTML before React renders. + * Override with a no-op if you need to preserve existing content. + */ + protected clearContent(): void { + this.innerHTML = ""; + } + + /** + * Called when the element is added to the DOM. + * Captures slots, clears content, and renders the React component. + */ + connectedCallback(): void { + this.captureSlots(); + this.clearContent(); + this.root = createRoot(this); + this.root.render(this.wrapWithProvider(this.render())); + } + + /** + * Called when the element is removed from the DOM. + * Unbinds Shiny and unmounts the React root. + */ + disconnectedCallback(): void { + window.Shiny?.unbindAll?.(this); + this.root?.unmount(); + this.root = null; + } +} diff --git a/src/index.ts b/src/index.ts index eaa4753..5c6f328 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { type ShinyMessageRegistry } from "./message-registry"; import { type ShinyReactRegistry } from "./react-registry"; export { ImageOutput } from "./ImageOutput"; +export { ShinyReactComponentElement } from "./ShinyReactComponentElement"; export { useShinyInitialized, useShinyInput, From 3befcf80077c28a15781adde9c212f22c757090f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 15 Jan 2026 13:23:09 -0500 Subject: [PATCH 4/5] docs: don't need `data-namespace` anymore --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7b4e733..e966288 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ See [examples/8-modules/](examples/8-modules/) for a complete example with two v counter_ui <- function(id, title = "Counter") { card( card_header(title), - tags$tag("counter-widget", list(`data-namespace` = id)) + tags$tag("counter-widget", list(id = id)) ) } From ecff619f7bfea9b191981e70227745cc75910112 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 15 Jan 2026 13:27:36 -0500 Subject: [PATCH 5/5] chore: delete intermediate work file --- blended-components-pattern.md | 385 ---------------------------------- 1 file changed, 385 deletions(-) delete mode 100644 blended-components-pattern.md diff --git a/blended-components-pattern.md b/blended-components-pattern.md deleted file mode 100644 index 7a59857..0000000 --- a/blended-components-pattern.md +++ /dev/null @@ -1,385 +0,0 @@ -# Blended Components Pattern - -Best practices for creating React components that wrap native Shiny UI elements, designed to integrate seamlessly with Shiny + bslib (R) or Shiny for Python applications. - -## Overview - -A "blended" component uses React to provide custom layout, interactivity, or visual chrome while allowing native Shiny UI elements (inputs, outputs, bslib components) to live inside. This pattern enables: - -- Custom layouts not available in standard Shiny/bslib -- React-powered animations and interactions -- Full Shiny reactivity within React-managed containers -- Seamless visual integration with bslib themes - -## Core Pattern: Slot Preservation - -The fundamental challenge is that React manages its own DOM, but Shiny UI elements need to: -1. Be rendered by Shiny's tag system (R or Python) -2. Have their bindings initialized by `Shiny.bindAll()` -3. Maintain state when hidden/shown - -**Solution: Capture, Render, Restore, Bind** - -``` -1. R/Python renders Shiny content as children of a custom element -2. Custom element's connectedCallback() captures children before React renders -3. React renders layout with empty container refs -4. Captured content is moved into React containers after mount -5. Shiny.bindAll() initializes bindings on each container -``` - -## Implementation Layers - -### Layer 1: R/Python API - -Create wrapper functions that feel natural to Shiny users: - -```r -# Container function -my_layout <- function(..., id = NULL, option = "default") { - items <- list(...) - - # Extract metadata for React - item_config <- lapply(items, function(item) { - list( - id = item$attribs$`data-item-id`, - label = item$attribs$`data-item-label` - ) - }) - - tagList( - # Include JS/CSS dependencies - tags$head( - tags$script(src = "my-component.js", type = "module"), - tags$link(href = "my-component.css", rel = "stylesheet") - ), - # Custom element with configuration and Shiny content - tag("my-custom-element", list( - id = id, - `data-config` = jsonlite::toJSON(item_config, auto_unbox = TRUE), - `data-option` = option, - items # Shiny content as children - )) - ) -} - -# Item wrapper (marks content for React to find) -my_item <- function(label, ..., id = label) { - div( - `data-item-id` = id, - `data-item-label` = label, - class = "my-component-item", - style = "display: none;", # Hidden until React takes over - ... - ) -} -``` - -**Key patterns:** -- Use `data-*` attributes to pass configuration to React -- Wrap Shiny content in identifiable containers (`data-item-id`) -- Hide content initially (`display: none`) to prevent flash -- Include JS/CSS via `tags$head()` - -### Layer 2: Custom Element (TypeScript) - -The custom element bridges Shiny's DOM and React: - -```typescript -import { createRoot, Root } from "react-dom/client"; -import { ShinyModuleProvider } from "@posit/shiny-react"; -import { MyComponent } from "./MyComponent"; - -class MyCustomElement extends HTMLElement { - private root: Root | null = null; - private itemContents: Map = new Map(); - - connectedCallback() { - // 1. CAPTURE: Store Shiny content before React clears DOM - const itemDivs = this.querySelectorAll('[data-item-id]'); - itemDivs.forEach(div => { - const itemId = div.getAttribute('data-item-id')!; - this.itemContents.set(itemId, Array.from(div.childNodes)); - }); - - // 2. PARSE: Read configuration from attributes - const config = JSON.parse(this.dataset.config || '[]'); - const option = this.dataset.option || 'default'; - const namespace = this.id || undefined; - - // 3. RENDER: Clear and create React root - this.innerHTML = ''; - this.root = createRoot(this); - - // 4. Wrap in ShinyModuleProvider if namespaced - const element = ( - - ); - - this.root.render( - namespace - ? {element} - : element - ); - } - - // 5. RESTORE + BIND: Move content back and initialize Shiny - private handleItemMount = (itemId: string, containerEl: HTMLElement | null) => { - const content = this.itemContents.get(itemId); - if (content && containerEl) { - content.forEach(node => containerEl.appendChild(node)); - window.Shiny?.bindAll?.(containerEl); - } - }; - - disconnectedCallback() { - // Clean up: unbind Shiny, unmount React - window.Shiny?.unbindAll?.(this); - this.root?.unmount(); - this.root = null; - } -} - -customElements.define('my-custom-element', MyCustomElement); -``` - -**Key patterns:** -- Capture children in `connectedCallback()` before any DOM manipulation -- Pass `onItemMount` callback to React component -- Call `Shiny.bindAll()` after moving content to initialize bindings -- Call `Shiny.unbindAll()` in `disconnectedCallback()` for cleanup -- Wrap in `ShinyModuleProvider` when `id` is present for namespace support - -### Layer 3: React Component - -The React component manages layout and provides container refs: - -```typescript -import { useState, useEffect, useRef } from "react"; - -interface MyComponentProps { - config: Array<{ id: string; label: string }>; - option: string; - onItemMount: (itemId: string, containerEl: HTMLElement | null) => void; -} - -export function MyComponent({ config, option, onItemMount }: MyComponentProps) { - const [activeItem, setActiveItem] = useState(config[0]?.id || ''); - const itemRefs = useRef>(new Map()); - const mountedItems = useRef>(new Set()); - - // Call onItemMount once per item after initial render - useEffect(() => { - config.forEach(item => { - if (!mountedItems.current.has(item.id)) { - const containerEl = itemRefs.current.get(item.id); - if (containerEl) { - onItemMount(item.id, containerEl); - mountedItems.current.add(item.id); - } - } - }); - }, [config, onItemMount]); - - return ( -
- {/* React-controlled chrome */} - - - {/* Containers for Shiny content */} -
- {config.map(item => ( -
itemRefs.current.set(item.id, el)} - className={activeItem === item.id ? 'active' : 'inactive'} - /> - ))} -
-
- ); -} -``` - -**Key patterns:** -- Track mounted items to call `onItemMount` only once per container -- Use refs to provide DOM elements back to the custom element -- Empty container divs—content comes from Shiny via `onItemMount` - -### Layer 4: CSS Styling - -**Critical: Use Bootstrap CSS Variables** - -For seamless integration with bslib themes, reference Bootstrap's CSS custom properties with sensible fallbacks: - -```css -:root { - /* Map component variables to Bootstrap variables */ - --my-bg: var(--bs-secondary-bg, #f8f9fa); - --my-text: var(--bs-body-color, #212529); - --my-hover: var(--bs-tertiary-bg, #e9ecef); - --my-active: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.15); - --my-active-text: var(--bs-primary, #0d6efd); - --my-border: var(--bs-border-color, #dee2e6); - --my-content-bg: var(--bs-body-bg, #fff); -} - -.my-component { - background-color: var(--my-bg); - color: var(--my-text); - border: 1px solid var(--my-border); -} - -.my-component button:hover { - background-color: var(--my-hover); -} - -.my-component button.active { - background-color: var(--my-active); - color: var(--my-active-text); -} - -.my-component main { - background-color: var(--my-content-bg); -} -``` - -**Common Bootstrap CSS variables:** -| Variable | Usage | -|----------|-------| -| `--bs-body-bg` | Page/content background | -| `--bs-body-color` | Primary text color | -| `--bs-secondary-bg` | Secondary backgrounds (cards, sidebars) | -| `--bs-tertiary-bg` | Hover states, subtle backgrounds | -| `--bs-primary` | Primary accent color | -| `--bs-primary-rgb` | Primary as RGB for rgba() | -| `--bs-border-color` | Standard border color | -| `--bs-border-radius` | Standard border radius | - -### Visibility vs Display for Hidden Panels - -**Use `visibility: hidden` instead of `display: none`** to preserve Shiny state: - -```css -.panel { - position: absolute; - width: 100%; - height: 100%; - visibility: hidden; - pointer-events: none; -} - -.panel.active { - visibility: visible; - pointer-events: auto; -} -``` - -Why this matters: -- `display: none` removes elements from layout, which can reset some Shiny input states -- `visibility: hidden` keeps elements in the DOM with their state intact -- Use `pointer-events: none` to prevent interaction with hidden panels - -## Module Namespace Support - -For components used within Shiny modules: - -**R side:** Apply `NS(id)` to child inputs/outputs: -```r -my_module_ui <- function(id) { - ns <- NS(id) - my_layout( - id = ns("layout"), # Namespace the container - my_item("Panel 1", - plotOutput(ns("plot")) # Namespace child outputs - ) - ) -} -``` - -**TypeScript side:** Wrap in `ShinyModuleProvider`: -```typescript -const element = ; - -this.root.render( - namespace - ? {element} - : element -); -``` - -The Shiny content is already namespaced by R—`ShinyModuleProvider` only affects React hooks (like `useShinyInput`) used within the component itself. - -## Icons - -Accept icons from bsicons or fontawesome, which render to SVG strings: - -**R side:** -```r -my_item <- function(label, ..., icon = NULL) { - icon_svg <- if (!is.null(icon)) { - if (inherits(icon, "shiny.tag")) as.character(icon) else icon - } else NULL - - div(`data-icon` = icon_svg, ...) -} -``` - -**React side:** -```tsx -{icon && ( - -)} -``` - -**CSS for SVG icons:** -```css -.icon { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; -} - -.icon svg { - width: 20px; - height: 20px; - fill: currentColor; /* Inherit text color */ -} -``` - -## Checklist for New Blended Components - -- [ ] R/Python wrapper functions with `data-*` attributes for configuration -- [ ] Child content wrapped in identifiable containers (`data-item-id`) -- [ ] Custom element captures children in `connectedCallback()` -- [ ] React renders empty container refs -- [ ] `onItemMount` callback moves content and calls `Shiny.bindAll()` -- [ ] `disconnectedCallback()` calls `Shiny.unbindAll()` and unmounts React -- [ ] CSS uses Bootstrap variables (`--bs-*`) with fallbacks -- [ ] Hidden panels use `visibility: hidden`, not `display: none` -- [ ] Module namespace support via `ShinyModuleProvider` -- [ ] Icons accept shiny.tag objects and render SVG with `currentColor` - -## Common Pitfalls - -1. **Forgetting `Shiny.bindAll()`** — Inputs won't work -2. **Using `display: none`** — Can lose Shiny input state -3. **Hardcoded colors** — Won't match bslib themes -4. **Missing fallbacks** — CSS variables need defaults for non-Bootstrap contexts -5. **Calling `onItemMount` multiple times** — Track mounted items to call only once -6. **Not unbinding on disconnect** — Can cause memory leaks or stale handlers