Skip to contents

Whole apps, built from the layout components and the controls together. Each is a file under examples/dashboards/ in the installed package, so it can be run as it stands – shiny::runApp(system.file("examples/dashboards/sales", package = "shiny.element")).

Element’s own layout example

Element’s documentation closes its Container page with an admin layout: a side menu of grouped options, a header holding a dropdown, and a table in the main area. Here it is in R, piece for piece:

# Element UI's own "Container" example, rebuilt in R: a side menu, a header
# with a dropdown, and a table in the main area.
#
#   shiny::runApp(system.file("examples/dashboards/admin-layout", package = "shiny.element"))

library(shiny)
library(shiny.element)

rows <- data.frame(
  date = rep("2016-05-02", 20),
  name = rep("Tom", 20),
  address = rep("No. 189, Grove St, Los Angeles", 20)
)

# One navigator: two titled groups and a nested submenu, as upstream has it
navigator <- function(i, icon, label) {
  sub <- function(n) paste0(i, "-", n)
  list(
    index = as.character(i),
    label = label,
    icon = icon,
    children = list(
      list(
        group = TRUE,
        title = "Group 1",
        children = list(
          list(index = sub(1), label = "Option 1"),
          list(index = sub(2), label = "Option 2")
        )
      ),
      list(
        group = TRUE,
        title = "Group 2",
        children = list(
          list(index = sub(3), label = "Option 3")
        )
      ),
      list(
        index = sub(4),
        label = "Option 4",
        children = list(
          list(index = sub("4-1"), label = "Option 4-1")
        )
      )
    )
  )
}

ui <- el_page(
  el_container(
    style = "height: 500px; border: 1px solid #eee",
    el_aside(
      width = "200px",
      style = "background-color: rgb(238, 241, 246)",
      el_menu(
        "nav",
        default_openeds = c("1", "3"),
        items = list(
          navigator(1, "el-icon-message", "Navigator One"),
          navigator(2, "el-icon-menu", "Navigator Two"),
          navigator(3, "el-icon-setting", "Navigator Three")
        )
      )
    ),
    el_container(
      el_header(
        style = "text-align: right; font-size: 12px; background-color: #B3C0D1;
                 color: #333; line-height: 60px",
        el_dropdown(
          "account",
          trigger_label = tags$i(
            class = "el-icon-setting",
            style = "margin-right: 15px"
          ),
          items = list(
            list(command = "view", label = "View"),
            list(command = "add", label = "Add"),
            list(command = "delete", label = "Delete")
          )
        ),
        tags$span("Tom")
      ),
      el_main(
        el_table(
          data = rows,
          columns = list(
            list(prop = "date", label = "Date", width = "140"),
            list(prop = "name", label = "Name", width = "120"),
            list(prop = "address", label = "Address")
          )
        )
      )
    )
  )
)

server <- function(input, output, session) {
  observeEvent(input$nav, {
    el_message(session, paste("Navigated to", input$nav))
  })
  observeEvent(input$account, {
    el_message(session, paste("Account:", input$account), type = "info")
  })
}

shinyApp(ui, server)
The admin-layout example, running

A sales overview

Filters in the header drive everything below them: the headline numbers are el_statistic()s moved with update_el_statistic(), the chart is an ordinary plotOutput(), and the products table renders a progress bar in a cell with a cell template.

# A sales overview: filters in the header, headline numbers in cards, a
# chart and a ranked table, all following the filters.
#
#   shiny::runApp(system.file("examples/dashboards/sales", package = "shiny.element"))

library(shiny)
library(shiny.element)

set.seed(42)
months <- seq(as.Date("2026-01-01"), by = "month", length.out = 9)
regions <- c("North", "South", "East", "West")
products <- c(
  "Laptops",
  "Monitors",
  "Keyboards",
  "Headsets",
  "Docks",
  "Webcams"
)
sales <- expand.grid(
  month = months,
  region = regions,
  product = products,
  stringsAsFactors = FALSE
)
sales$revenue <- round(runif(nrow(sales), 2, 30) * 1000)
sales$orders <- round(sales$revenue / runif(nrow(sales), 80, 160))

kpi_card <- function(id, title, prefix = NULL, suffix = NULL, precision = 0) {
  el_col(
    span = 6,
    el_card(
      shadow = "hover",
      el_statistic(
        id,
        value = 0,
        title = title,
        prefix = prefix,
        suffix = suffix,
        precision = precision,
        group_separator = ","
      )
    )
  )
}

ui <- el_page(
  el_container(
    el_header(
      style = "display: flex; align-items: center; justify-content: space-between;
               border-bottom: 1px solid #ebeef5",
      tags$h3("Sales overview", style = "margin: 0"),
      tags$div(
        style = "display: flex; gap: 10px",
        el_date_picker(
          "span",
          type = "monthrange",
          value = c("2026-01", "2026-09"),
          value_format = "yyyy-MM",
          format = "yyyy-MM",
          start_placeholder = "From",
          end_placeholder = "To",
          width = "260px",
          size = "small"
        ),
        el_select(
          "region",
          choices = regions,
          selected = regions,
          multiple = TRUE,
          collapse_tags = TRUE,
          size = "small",
          width = "220px"
        )
      )
    ),
    el_main(
      el_row(
        gutter = 16,
        kpi_card("revenue", "Revenue", prefix = "$"),
        kpi_card("orders", "Orders"),
        kpi_card("basket", "Average order", prefix = "$", precision = 2),
        kpi_card(
          "best",
          "Best month, share of revenue",
          suffix = "%",
          precision = 1
        )
      ),
      el_row(
        gutter = 16,
        style = "margin-top: 16px",
        el_col(
          span = 14,
          el_card(
            header = "Revenue by month",
            plotOutput("trend", height = "260px")
          )
        ),
        el_col(
          span = 10,
          el_card(
            header = "Products",
            el_table_output("products")
          )
        )
      )
    )
  )
)

server <- function(input, output, session) {
  # Both filters report on load; until they have, there is nothing to show
  picked <- reactive({
    req(input$span, input$region)
    span <- as.Date(paste0(input$span, "-01"))
    sales[
      sales$month >= span[1] &
        sales$month <= span[2] &
        sales$region %in% input$region,
    ]
  })

  observe({
    d <- picked()
    req(nrow(d) > 0)
    by_month <- tapply(d$revenue, d$month, sum)
    update_el_statistic(session, "revenue", value = sum(d$revenue))
    update_el_statistic(session, "orders", value = sum(d$orders))
    update_el_statistic(
      session,
      "basket",
      value = if (sum(d$orders)) sum(d$revenue) / sum(d$orders) else 0
    )
    update_el_statistic(
      session,
      "best",
      value = if (length(by_month)) 100 * max(by_month) / sum(by_month) else 0
    )
  })

  output$products <- render_el_table({
    d <- picked()
    req(nrow(d) > 0)
    by_product <- aggregate(revenue ~ product, d, sum)
    by_product <- by_product[order(-by_product$revenue), ]
    by_product$share <- round(
      100 * by_product$revenue / sum(by_product$revenue)
    )
    el_table(
      data = by_product,
      columns = list(
        list(type = "index", label = "#", width = "50"),
        list(prop = "product", label = "Product"),
        list(
          prop = "revenue",
          label = "Revenue",
          align = "right",
          formatter = JS(
            "function(r, c, v) { return '$' + v.toLocaleString(); }"
          )
        ),
        list(
          prop = "share",
          label = "Share",
          width = "130",
          cell = el$progress(
            ":percentage" = "scope.row.share",
            ":stroke-width" = "8"
          )
        )
      )
    )
  })

  output$trend <- renderPlot({
    d <- picked()
    req(nrow(d) > 0)
    by_month <- tapply(d$revenue, format(d$month, "%b"), sum)[format(
      unique(sort(d$month)),
      "%b"
    )]
    par(
      mar = c(3, 4, 1, 1),
      family = "sans",
      col.axis = "#606266",
      fg = "#dcdfe6"
    )
    barplot(
      by_month / 1000,
      col = "#409EFF",
      border = NA,
      las = 1,
      ylab = "Revenue ($k)",
      col.lab = "#606266"
    )
  })
}

shinyApp(ui, server)
The sales example, running

An orders admin page

The table most admin pages are built around: search over it, a status tag and a pair of buttons in each row, an expanding row for detail, sorting and paging done by the server, a dialog holding a form to add and edit, and a confirmation before anything is deleted. Each row’s buttons report through rowAction() – input$orders_edit and input$orders_delete carry the row.

# An orders admin page: search, a table with row actions, server-side
# sorting and paging, a dialog to add and edit, and confirmation to delete.
#
#   shiny::runApp(system.file("examples/dashboards/orders", package = "shiny.element"))

library(shiny)
library(shiny.element)

set.seed(1)
n <- 57
statuses <- c(
  Pending = "pending",
  Paid = "paid",
  Shipped = "shipped",
  Refunded = "refunded"
)
cities <- c("Beijing", "Shanghai", "Shenzhen", "Hangzhou")
seed_orders <- data.frame(
  id = 1000 + seq_len(n),
  date = format(as.Date("2026-09-30") - sample(0:60, n, TRUE)),
  customer = sample(
    c(
      "Ada Lovelace",
      "Grace Hopper",
      "Alan Turing",
      "Linus Torvalds",
      "Ken Thompson",
      "Barbara Liskov",
      "Donald Knuth"
    ),
    n,
    TRUE
  ),
  city = sample(cities, n, TRUE),
  amount = round(runif(n, 20, 900), 2),
  status = sample(unname(statuses), n, TRUE, prob = c(.2, .4, .3, .1)),
  items = sample(
    c("Laptop", "Monitor, cable", "Keyboard, mouse", "Dock"),
    n,
    TRUE
  )
)

# A tag type per status, chosen in the browser for each row
status_tag <- el$tag(
  size = "small",
  ":type" = paste0(
    "({pending: 'warning', paid: 'success', shipped: 'primary', ",
    "refunded: 'info'})[scope.row.status]"
  ),
  "{{ scope.row.status }}"
)

columns <- list(
  list(
    type = "expand",
    cell = tags$div(
      tags$b("Items: "),
      "{{ scope.row.items }}",
      tags$br(),
      tags$b("Ships to: "),
      "{{ scope.row.city }}"
    )
  ),
  list(prop = "id", label = "Order", width = "80"),
  list(prop = "date", label = "Date", width = "115", sortable = "custom"),
  list(prop = "customer", label = "Customer", min_width = "140"),
  list(
    prop = "amount",
    label = "Amount",
    width = "110",
    align = "right",
    sortable = "custom",
    formatter = JS("function(r, c, v) { return '$' + v.toFixed(2); }")
  ),
  list(prop = "status", label = "Status", width = "100", cell = status_tag),
  # Pinned to the right edge, so the buttons stay in view when the table
  # is narrower than its columns and scrolls sideways
  list(
    label = "",
    width = "160",
    fixed = "right",
    cell = tagList(
      el$button(size = "small", "@click" = "rowAction('edit', scope)", "Edit"),
      el$button(
        size = "small",
        type = "danger",
        plain = NA,
        "@click" = "rowAction('delete', scope)",
        "Delete"
      )
    )
  )
)

ui <- el_page(
  el_breadcrumb(
    "crumbs",
    items = list(list(label = "Home"), list(label = "Orders"))
  ),
  tags$div(
    style = "display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0",
    el_input(
      "q",
      placeholder = "Customer or order number",
      clearable = TRUE,
      prefix_icon = "el-icon-search",
      width = "240px"
    ),
    el_select(
      "status",
      choices = statuses,
      multiple = TRUE,
      collapse_tags = TRUE,
      placeholder = "Any status",
      clearable = TRUE,
      width = "200px"
    ),
    el_date_picker(
      "dates",
      type = "daterange",
      start_placeholder = "From",
      end_placeholder = "To",
      width = "260px"
    ),
    el_button("search", "Search", type = "primary", icon = "el-icon-search"),
    el_button("reset", "Reset"),
    tags$div(style = "flex: 1"),
    el_button(
      "add",
      "New order",
      type = "primary",
      plain = TRUE,
      icon = "el-icon-plus"
    ),
    el_button(
      "remove_many",
      "Delete selected",
      type = "danger",
      plain = TRUE,
      disabled = TRUE
    )
  ),
  el_table_output("orders"),
  tags$div(
    style = "margin-top: 16px; text-align: right",
    el_pagination(
      "pager",
      total = n,
      page_size = 10,
      page_sizes = c(10, 20, 50),
      layout = "total, sizes, prev, pager, next",
      background = TRUE
    )
  ),

  el_dialog(
    "editor",
    title = "Order",
    width = "560px",
    # The dialog's footer holds the buttons, so the form draws none of its own
    content = el_form(
      id = "order_form",
      label_width = "90px",
      submit_label = NULL,
      el_form_field(
        "customer",
        "input",
        label = "Customer",
        rules = el_rule(required = TRUE, message = "Who is it for?")
      ),
      el_form_field(
        "city",
        "select",
        label = "City",
        choices = cities,
        rules = el_rule(
          required = TRUE,
          message = "Pick a city",
          trigger = "change"
        )
      ),
      el_form_field(
        "amount",
        "input-number",
        label = "Amount",
        min = 0,
        precision = 2,
        step = 10
      ),
      el_form_field(
        "status",
        "radio-group",
        label = "Status",
        choices = statuses,
        value = "pending"
      ),
      el_form_field(
        "date",
        "date-picker",
        label = "Date",
        value_format = "yyyy-MM-dd"
      )
    ),
    footer = tagList(
      el_button("cancel", "Cancel"),
      el_button("save", "Save", type = "primary")
    )
  )
)

server <- function(input, output, session) {
  orders <- reactiveVal(seed_orders)
  query <- reactiveVal(list())
  sorting <- reactiveVal(list(column = "date", order = "descending"))
  editing <- reactiveVal(NULL) # the id being edited; NA for a new order

  # ── search ──────────────────────────────────────────────────────────────
  observeEvent(input$search, {
    query(list(q = input$q, status = input$status, dates = input$dates))
  })
  observeEvent(input$reset, {
    update_el_input(session, "q", value = "")
    update_el_select(session, "status", selected = list())
    update_el_date_picker(session, "dates", value = list())
    query(list())
  })

  matching <- reactive({
    d <- orders()
    q <- query()
    if (length(q$q) && nzchar(q$q)) {
      hit <- grepl(q$q, d$customer, ignore.case = TRUE) |
        grepl(q$q, d$id, fixed = TRUE)
      d <- d[hit, ]
    }
    if (length(q$status)) {
      d <- d[d$status %in% q$status, ]
    }
    if (length(q$dates) == 2) {
      d <- d[d$date >= q$dates[1] & d$date <= q$dates[2], ]
    }
    s <- sorting()
    if (length(s$order) && !is.null(s$column)) {
      d <- d[order(d[[s$column]], decreasing = s$order == "descending"), ]
    }
    d
  })

  # ── sorting and paging, both done here rather than in the browser ───────
  observeEvent(input$orders_sort_change, sorting(input$orders_sort_change))

  page <- reactive({
    d <- matching()
    size <- input$pager_size %||% 10
    first <- ((input$pager %||% 1) - 1) * size
    d[seq_len(nrow(d)) > first & seq_len(nrow(d)) <= first + size, ]
  })

  observe({
    update_el_pagination(session, "pager", total = nrow(matching()))
  })
  output$orders <- render_el_table(el_table(
    data = page(),
    selection = TRUE,
    row_key = "id",
    columns = columns,
    empty_text = "No orders match"
  ))

  # ── selection ────────────────────────────────────────────────────────────
  observe({
    update_el_button(
      session,
      "remove_many",
      disabled = !length(input$orders_selection_rows)
    )
  })

  # ── add and edit ─────────────────────────────────────────────────────────
  open_editor <- function(row) {
    editing(row$id)
    update_el_form(
      session,
      "order_form",
      model = row[c("customer", "city", "amount", "status", "date")]
    )
    update_el_dialog(session, "editor", visible = TRUE)
  }
  observeEvent(input$add, {
    open_editor(list(
      id = NA,
      customer = "",
      city = "",
      amount = 0,
      status = "pending",
      date = format(Sys.Date())
    ))
  })
  observeEvent(input$orders_edit, open_editor(input$orders_edit$row))
  observeEvent(
    input$cancel,
    update_el_dialog(session, "editor", visible = FALSE)
  )
  observeEvent(input$save, el_form_validate(session, "order_form"))

  observeEvent(input$order_form_submit, {
    req(isTRUE(input$order_form_valid))
    m <- input$order_form
    d <- orders()
    if (is.na(editing())) {
      row <- data.frame(
        id = max(d$id) + 1,
        date = m$date,
        customer = m$customer,
        city = m$city,
        amount = m$amount,
        status = m$status,
        items = ""
      )
      orders(rbind(row, d))
      el_message(session, sprintf("Order %d added", row$id), type = "success")
    } else {
      i <- match(editing(), d$id)
      d[i, c("customer", "city", "amount", "status", "date")] <-
        list(m$customer, m$city, m$amount, m$status, m$date)
      orders(d)
      el_message(
        session,
        sprintf("Order %d saved", editing()),
        type = "success"
      )
    }
    update_el_dialog(session, "editor", visible = FALSE)
  })

  # ── delete, one or many, after asking ───────────────────────────────────
  doomed <- reactiveVal(NULL)
  ask_delete <- function(ids) {
    doomed(ids)
    el_message_box(
      session,
      "confirm_delete",
      sprintf(
        "Delete %s? This cannot be undone.",
        if (length(ids) == 1) {
          paste("order", ids)
        } else {
          paste(length(ids), "orders")
        }
      ),
      title = "Delete",
      type = "warning",
      confirm_button_text = "Delete",
      cancel_button_text = "Keep"
    )
  }
  observeEvent(input$orders_delete, ask_delete(input$orders_delete$row$id))
  observeEvent(input$remove_many, {
    ask_delete(input$orders_selection_change$id)
  })
  observeEvent(input$confirm_delete, {
    req(identical(input$confirm_delete, "confirm"))
    orders(orders()[!orders()$id %in% doomed(), ])
    el_message(
      session,
      sprintf("Deleted %d order(s)", length(doomed())),
      type = "success"
    )
  })
}

shinyApp(ui, server)
The orders example, running

Opening the editor from a row’s Edit button fills the form from that row:

# An orders admin page: search, a table with row actions, server-side
# sorting and paging, a dialog to add and edit, and confirmation to delete.
#
#   shiny::runApp(system.file("examples/dashboards/orders", package = "shiny.element"))

library(shiny)
library(shiny.element)

set.seed(1)
n <- 57
statuses <- c(
  Pending = "pending",
  Paid = "paid",
  Shipped = "shipped",
  Refunded = "refunded"
)
cities <- c("Beijing", "Shanghai", "Shenzhen", "Hangzhou")
seed_orders <- data.frame(
  id = 1000 + seq_len(n),
  date = format(as.Date("2026-09-30") - sample(0:60, n, TRUE)),
  customer = sample(
    c(
      "Ada Lovelace",
      "Grace Hopper",
      "Alan Turing",
      "Linus Torvalds",
      "Ken Thompson",
      "Barbara Liskov",
      "Donald Knuth"
    ),
    n,
    TRUE
  ),
  city = sample(cities, n, TRUE),
  amount = round(runif(n, 20, 900), 2),
  status = sample(unname(statuses), n, TRUE, prob = c(.2, .4, .3, .1)),
  items = sample(
    c("Laptop", "Monitor, cable", "Keyboard, mouse", "Dock"),
    n,
    TRUE
  )
)

# A tag type per status, chosen in the browser for each row
status_tag <- el$tag(
  size = "small",
  ":type" = paste0(
    "({pending: 'warning', paid: 'success', shipped: 'primary', ",
    "refunded: 'info'})[scope.row.status]"
  ),
  "{{ scope.row.status }}"
)

columns <- list(
  list(
    type = "expand",
    cell = tags$div(
      tags$b("Items: "),
      "{{ scope.row.items }}",
      tags$br(),
      tags$b("Ships to: "),
      "{{ scope.row.city }}"
    )
  ),
  list(prop = "id", label = "Order", width = "80"),
  list(prop = "date", label = "Date", width = "115", sortable = "custom"),
  list(prop = "customer", label = "Customer", min_width = "140"),
  list(
    prop = "amount",
    label = "Amount",
    width = "110",
    align = "right",
    sortable = "custom",
    formatter = JS("function(r, c, v) { return '$' + v.toFixed(2); }")
  ),
  list(prop = "status", label = "Status", width = "100", cell = status_tag),
  # Pinned to the right edge, so the buttons stay in view when the table
  # is narrower than its columns and scrolls sideways
  list(
    label = "",
    width = "160",
    fixed = "right",
    cell = tagList(
      el$button(size = "small", "@click" = "rowAction('edit', scope)", "Edit"),
      el$button(
        size = "small",
        type = "danger",
        plain = NA,
        "@click" = "rowAction('delete', scope)",
        "Delete"
      )
    )
  )
)

ui <- el_page(
  el_breadcrumb(
    "crumbs",
    items = list(list(label = "Home"), list(label = "Orders"))
  ),
  tags$div(
    style = "display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0",
    el_input(
      "q",
      placeholder = "Customer or order number",
      clearable = TRUE,
      prefix_icon = "el-icon-search",
      width = "240px"
    ),
    el_select(
      "status",
      choices = statuses,
      multiple = TRUE,
      collapse_tags = TRUE,
      placeholder = "Any status",
      clearable = TRUE,
      width = "200px"
    ),
    el_date_picker(
      "dates",
      type = "daterange",
      start_placeholder = "From",
      end_placeholder = "To",
      width = "260px"
    ),
    el_button("search", "Search", type = "primary", icon = "el-icon-search"),
    el_button("reset", "Reset"),
    tags$div(style = "flex: 1"),
    el_button(
      "add",
      "New order",
      type = "primary",
      plain = TRUE,
      icon = "el-icon-plus"
    ),
    el_button(
      "remove_many",
      "Delete selected",
      type = "danger",
      plain = TRUE,
      disabled = TRUE
    )
  ),
  el_table_output("orders"),
  tags$div(
    style = "margin-top: 16px; text-align: right",
    el_pagination(
      "pager",
      total = n,
      page_size = 10,
      page_sizes = c(10, 20, 50),
      layout = "total, sizes, prev, pager, next",
      background = TRUE
    )
  ),

  el_dialog(
    "editor",
    title = "Order",
    width = "560px",
    # The dialog's footer holds the buttons, so the form draws none of its own
    content = el_form(
      id = "order_form",
      label_width = "90px",
      submit_label = NULL,
      el_form_field(
        "customer",
        "input",
        label = "Customer",
        rules = el_rule(required = TRUE, message = "Who is it for?")
      ),
      el_form_field(
        "city",
        "select",
        label = "City",
        choices = cities,
        rules = el_rule(
          required = TRUE,
          message = "Pick a city",
          trigger = "change"
        )
      ),
      el_form_field(
        "amount",
        "input-number",
        label = "Amount",
        min = 0,
        precision = 2,
        step = 10
      ),
      el_form_field(
        "status",
        "radio-group",
        label = "Status",
        choices = statuses,
        value = "pending"
      ),
      el_form_field(
        "date",
        "date-picker",
        label = "Date",
        value_format = "yyyy-MM-dd"
      )
    ),
    footer = tagList(
      el_button("cancel", "Cancel"),
      el_button("save", "Save", type = "primary")
    )
  )
)

server <- function(input, output, session) {
  orders <- reactiveVal(seed_orders)
  query <- reactiveVal(list())
  sorting <- reactiveVal(list(column = "date", order = "descending"))
  editing <- reactiveVal(NULL) # the id being edited; NA for a new order

  # ── search ──────────────────────────────────────────────────────────────
  observeEvent(input$search, {
    query(list(q = input$q, status = input$status, dates = input$dates))
  })
  observeEvent(input$reset, {
    update_el_input(session, "q", value = "")
    update_el_select(session, "status", selected = list())
    update_el_date_picker(session, "dates", value = list())
    query(list())
  })

  matching <- reactive({
    d <- orders()
    q <- query()
    if (length(q$q) && nzchar(q$q)) {
      hit <- grepl(q$q, d$customer, ignore.case = TRUE) |
        grepl(q$q, d$id, fixed = TRUE)
      d <- d[hit, ]
    }
    if (length(q$status)) {
      d <- d[d$status %in% q$status, ]
    }
    if (length(q$dates) == 2) {
      d <- d[d$date >= q$dates[1] & d$date <= q$dates[2], ]
    }
    s <- sorting()
    if (length(s$order) && !is.null(s$column)) {
      d <- d[order(d[[s$column]], decreasing = s$order == "descending"), ]
    }
    d
  })

  # ── sorting and paging, both done here rather than in the browser ───────
  observeEvent(input$orders_sort_change, sorting(input$orders_sort_change))

  page <- reactive({
    d <- matching()
    size <- input$pager_size %||% 10
    first <- ((input$pager %||% 1) - 1) * size
    d[seq_len(nrow(d)) > first & seq_len(nrow(d)) <= first + size, ]
  })

  observe({
    update_el_pagination(session, "pager", total = nrow(matching()))
  })
  output$orders <- render_el_table(el_table(
    data = page(),
    selection = TRUE,
    row_key = "id",
    columns = columns,
    empty_text = "No orders match"
  ))

  # ── selection ────────────────────────────────────────────────────────────
  observe({
    update_el_button(
      session,
      "remove_many",
      disabled = !length(input$orders_selection_rows)
    )
  })

  # ── add and edit ─────────────────────────────────────────────────────────
  open_editor <- function(row) {
    editing(row$id)
    update_el_form(
      session,
      "order_form",
      model = row[c("customer", "city", "amount", "status", "date")]
    )
    update_el_dialog(session, "editor", visible = TRUE)
  }
  observeEvent(input$add, {
    open_editor(list(
      id = NA,
      customer = "",
      city = "",
      amount = 0,
      status = "pending",
      date = format(Sys.Date())
    ))
  })
  observeEvent(input$orders_edit, open_editor(input$orders_edit$row))
  observeEvent(
    input$cancel,
    update_el_dialog(session, "editor", visible = FALSE)
  )
  observeEvent(input$save, el_form_validate(session, "order_form"))

  observeEvent(input$order_form_submit, {
    req(isTRUE(input$order_form_valid))
    m <- input$order_form
    d <- orders()
    if (is.na(editing())) {
      row <- data.frame(
        id = max(d$id) + 1,
        date = m$date,
        customer = m$customer,
        city = m$city,
        amount = m$amount,
        status = m$status,
        items = ""
      )
      orders(rbind(row, d))
      el_message(session, sprintf("Order %d added", row$id), type = "success")
    } else {
      i <- match(editing(), d$id)
      d[i, c("customer", "city", "amount", "status", "date")] <-
        list(m$customer, m$city, m$amount, m$status, m$date)
      orders(d)
      el_message(
        session,
        sprintf("Order %d saved", editing()),
        type = "success"
      )
    }
    update_el_dialog(session, "editor", visible = FALSE)
  })

  # ── delete, one or many, after asking ───────────────────────────────────
  doomed <- reactiveVal(NULL)
  ask_delete <- function(ids) {
    doomed(ids)
    el_message_box(
      session,
      "confirm_delete",
      sprintf(
        "Delete %s? This cannot be undone.",
        if (length(ids) == 1) {
          paste("order", ids)
        } else {
          paste(length(ids), "orders")
        }
      ),
      title = "Delete",
      type = "warning",
      confirm_button_text = "Delete",
      cancel_button_text = "Keep"
    )
  }
  observeEvent(input$orders_delete, ask_delete(input$orders_delete$row$id))
  observeEvent(input$remove_many, {
    ask_delete(input$orders_selection_change$id)
  })
  observeEvent(input$confirm_delete, {
    req(identical(input$confirm_delete, "confirm"))
    orders(orders()[!orders()$id %in% doomed(), ])
    el_message(
      session,
      sprintf("Deleted %d order(s)", length(doomed())),
      type = "success"
    )
  })
}

shinyApp(ui, server)
The orders-edit example, running