Reactivity Patterns in 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()` | `<div>` with buttons
Fieldset | `ds_fieldset()` | `<fieldset>`
Details | `ds_details()` | `<details>`
Dialog | `ds_dialog()` | `<dialog>`
Popover | `ds_popover()` | `<div 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:

    # 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:

    # 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:

    # 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`:

    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:

    # 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:

    # 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:

    # 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. `<legend>` inside `<fieldset>`). 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