--- title: "Reactivity Patterns in shinyds" output: html vignette: > %\VignetteIndexEntry{Reactivity Patterns in shinyds} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- ```{r} #| label: setup #| include: false library(shinyds) ``` ## Two kinds of components `shinyds` components fall into two categories that require different approaches to Shiny reactivity. ### Standard input bindings Most components use a `Shiny.InputBinding` registered in `ds-bindings.js`. You use them exactly like native Shiny inputs: | Component | Function(s) | `input$id` value | |---|---|---| | Button | `ds_button(inputId=)` | click count (integer) | | Text input | `ds_input()` | character string | | Textarea | `ds_textarea()` | character string | | Checkbox | `ds_checkbox()` | `TRUE` / `FALSE` | | Radio | `ds_radio()` | selected value string | | Select | `ds_select()` | selected value string | | Search | `ds_search()` | character string | | Suggestion | `ds_suggestion()` | selected value string | | Tabs | `ds_tabs()` | selected tab value string | | Pagination | `ds_pagination()` | current page integer | ### Behaviour-only module components The Designsystemet JavaScript bundle also includes modules that **enhance native HTML elements** rather than defining custom elements. The affected components are: | Component | Function(s) | HTML element | |---|---|---| | Toggle group | `ds_toggle_group()` | `
` with buttons | | Fieldset | `ds_fieldset()` | `
` | | Details | `ds_details()` | `
` | | Dialog | `ds_dialog()` | `` | | Popover | `ds_popover()` | `
` | These modules take over the element's behaviour for accessibility purposes (focus management, ARIA attributes, keyboard navigation). Registering a `Shiny.InputBinding` on the same element creates a conflict — the binding and the module fight over the element's state. **Do not use `Shiny.InputBinding` for these components.** Use `Shiny.setInputValue()` from a plain JavaScript event listener instead. ## The `Shiny.setInputValue()` pattern `Shiny.setInputValue(id, value)` pushes a value directly to the Shiny server without needing a binding on the element. The server receives it via `input$id` and you react to it with `observeEvent()`. `ds_toggle_group()` uses this pattern out of the box — it generates the script block for you: ```{r} #| eval: false # UI ds_toggle_group( "view_mode", tags$button(class = "ds-button", `data-variant` = "secondary", `aria-pressed` = "true", value = "list", "List"), tags$button(class = "ds-button", `data-variant` = "secondary", `aria-pressed` = "false", value = "grid", "Grid"), tags$button(class = "ds-button", `data-variant` = "secondary", `aria-pressed` = "false", value = "map", "Map") ) # Server observeEvent(input$view_mode, { # input$view_mode is "list", "grid", or "map" }) ``` For the other behaviour-only components you attach your own listener. ## Details / accordion React to open/close events: ```{r} #| eval: false # UI ds_details( id = "my_details", summary = "Click to expand", ds_paragraph("Hidden content revealed on open.") ) tags$script(HTML(" document.getElementById('my_details').addEventListener('toggle', function(e) { Shiny.setInputValue('my_details_open', e.target.open, {priority: 'event'}); }); ")) # Server observeEvent(input$my_details_open, { if (isTRUE(input$my_details_open)) { # user expanded the panel } }) ``` `{priority: 'event'}` ensures the value fires even when it hasn't changed (e.g. opening, closing, and reopening without navigating away). ## Dialog `ds_dialog()` must be present in the UI. Open and close it from the server using `show_ds_dialog()` and `hide_ds_dialog()` — no JavaScript required: ```{r} #| eval: false # UI ds_button("Delete item", inputId = "open_confirm", variant = "primary") ds_dialog( id = "confirm-dialog", ds_heading("Confirm deletion", level = 2, size = "md"), ds_paragraph("This action cannot be undone."), tags$div( style = "display:flex; gap:0.75rem; margin-top:1rem;", ds_button("Delete", inputId = "btn_confirm", variant = "primary"), ds_button("Cancel", inputId = "btn_cancel", variant = "secondary") ) ) # Server observeEvent(input$open_confirm, { show_ds_dialog("confirm-dialog") }) observeEvent(input$btn_cancel, { hide_ds_dialog("confirm-dialog") }) observeEvent(input$btn_confirm, { hide_ds_dialog("confirm-dialog") # perform deletion }) ``` ### Reacting to close events from within the dialog If you need to detect which button closed the dialog (e.g. Escape key vs Cancel vs Confirm), attach a `close` event listener and use `returnValue`: ```{r} #| eval: false tags$script(HTML(" document.getElementById('confirm-dialog').addEventListener('close', function(e) { Shiny.setInputValue('confirm_result', e.target.returnValue, {priority: 'event'}); }); ")) ``` Pass a return value when closing: `this.closest('dialog').close('confirm')`. `returnValue` will be `"confirm"`, `"cancel"`, or `""` (Escape key). ## Popover Detect when a popover is shown or hidden: ```{r} #| eval: false # UI ds_button("Info", inputId = "info-btn", `popovertarget` = "info-pop") ds_popover( id = "info-pop", popover = NA, ds_paragraph("Contextual help text.") ) tags$script(HTML(" var pop = document.getElementById('info-pop'); pop.addEventListener('toggle', function(e) { Shiny.setInputValue('info_pop_open', e.newState === 'open', {priority: 'event'}); }); ")) # Server observeEvent(input$info_pop_open, { if (isTRUE(input$info_pop_open)) { # log that user opened the popover, lazy-load content, etc. } }) ``` ## Fieldset React when a checkbox or radio inside a fieldset changes, reporting the full set of checked values: ```{r} #| eval: false # UI ds_fieldset( id = "notif-fieldset", legend = "Notification preferences", ds_checkbox("notif_email", label = "Email"), ds_checkbox("notif_sms", label = "SMS"), ds_checkbox("notif_push", label = "Push") ) tags$script(HTML(" document.getElementById('notif-fieldset').addEventListener('change', function(e) { var checked = Array.from( e.currentTarget.querySelectorAll('input[type=checkbox]:checked') ).map(function(el) { return el.id; }); Shiny.setInputValue('notif_prefs', checked); }); ")) # Server observeEvent(input$notif_prefs, { # input$notif_prefs is a character vector of checked checkbox IDs }) ``` ## Dropdown `ds_dropdown()` combines a trigger element and a content panel but has no built-in Shiny reactivity. To react to the dropdown opening or closing, listen for a `click` on the trigger and track state yourself: ```{r} #| eval: false # UI ds_dropdown( trigger = ds_button("Options", inputId = "btn_options", variant = "secondary"), ds_list( ds_list_item(ds_link("Edit", href = "#")), ds_list_item(ds_link("Delete", href = "#")) ) ) tags$script(HTML(" (function() { var open = false; document.getElementById('btn_options').addEventListener('click', function() { open = !open; Shiny.setInputValue('options_open', open, {priority: 'event'}); }); })(); ")) # Server observeEvent(input$options_open, { if (isTRUE(input$options_open)) { # dropdown was opened — lazy-load data, log analytics, etc. } }) ``` If you only need to react to which menu item was chosen, it is often simpler to give each item a `ds_button()` with its own `inputId` and handle them individually with `observeEvent()`, without tracking open/close state at all. ## Phantom input suppression The Designsystemet JavaScript bundle's `useId` utility auto-generates IDs like `:ds:1`, `:ds:2`, … for child elements that have no `id` attribute (e.g. `` inside `
`). Shiny picks these up as phantom input names and produces errors: ``` No handler registered for type :ds:1 key must not be "" or NA ``` Two guards prevent this: 1. **`R/zzz.R`** — registers a pass-through handler for the `"ds"` input type so Shiny does not error on type lookup. 2. **`inst/www/js/ds-bindings.js`** — a `shiny:inputchanged` listener that calls `preventDefault()` on any input whose name starts with `:`, blocking phantom inputs before they reach the server. Both guards are always active. You do not need to add anything to your app. If you add a new behaviour-only module component and see this error, check whether the module assigns `:ds:*` IDs to elements that an existing binding might pick up. ## Summary | Component | Approach | Notes | |---|---|---| | `ds_toggle_group()` | `Shiny.setInputValue()` built in | script generated by the R function | | `ds_details()` | `toggle` event → `setInputValue` | use `{priority:'event'}` | | `ds_dialog()` | `show_ds_dialog()` / `hide_ds_dialog()` | preferred; JS listener only needed for `returnValue` | | `ds_popover()` | `toggle` event → `setInputValue` | `e.newState === 'open'` | | `ds_fieldset()` | `change` event → `setInputValue` | collect checked inputs manually | | `ds_dropdown()` | `click` on trigger → `setInputValue` | track open/close state manually |