diff --git a/R/module-expdes-server.R b/R/module-expdes-server.R index a5f83a07..e0605c8b 100644 --- a/R/module-expdes-server.R +++ b/R/module-expdes-server.R @@ -90,11 +90,13 @@ expdesServer <- function(input, output, session, parent_session, loadpage_input, tagList( h4("Dose response power analysis"), - selectizeInput(ns(NAMESPACE_EXPDES$protein_select), - label = h5("Select protein template"), - choices = protein_choices, - options = list(placeholder = "Search protein..."), - multiple = FALSE), + copyable_select( + selectizeInput(ns(NAMESPACE_EXPDES$protein_select), + label = h5("Select protein template"), + choices = protein_choices, + options = list(placeholder = "Search protein..."), + multiple = FALSE), + "Copy protein name"), sliderInput(ns(NAMESPACE_EXPDES$rep_range), "Replicates per dose", min = 1, max = 10, value = c(1, 5), step = 1), diff --git a/R/qc-server-plots.R b/R/qc-server-plots.R index e1ed9ea2..2c2578e9 100644 --- a/R/qc-server-plots.R +++ b/R/qc-server-plots.R @@ -52,9 +52,11 @@ register_qc_plots <- function(input, output, session, loadpage_input, get_data, label = h5("Quality metric"), choices = metric_cols, selected = metric_cols[1]), - selectizeInput(ns("qm_protein"), - label = h5("Show plot for"), - choices = c("", protein_choices)) + copyable_select( + selectizeInput(ns("qm_protein"), + label = h5("Show plot for"), + choices = c("", protein_choices)), + "Copy analyte name") ) }) @@ -70,21 +72,29 @@ register_qc_plots <- function(input, output, session, loadpage_input, get_data, error = function(e) NULL) if ((loadpage_input()$BIO!="PTM" && input$qc_page_plot_type == "QCPlot")) { req(length(protein_choices) > 0) - selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", - choices = c("", "ALL ANALYTES" = "allonly", protein_choices)) + copyable_select( + selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", + choices = c("", "ALL ANALYTES" = "allonly", protein_choices)), + "Copy analyte name") } else if (loadpage_input()$BIO == "PTM"){ if (input$qc_page_plot_type == "QCPlot"){ - selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", - choices = c("", "ALL ANALYTES" = "allonly", - unique(get_data()$PTM$ProteinName))) + copyable_select( + selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", + choices = c("", "ALL ANALYTES" = "allonly", + unique(get_data()$PTM$ProteinName))), + "Copy analyte name") } else { - selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", - choices = c("", unique(get_data()$PTM$ProteinName))) + copyable_select( + selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", + choices = c("", unique(get_data()$PTM$ProteinName))), + "Copy analyte name") } } else { req(length(protein_choices) > 0) - selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", - choices = c("", protein_choices)) + copyable_select( + selectizeInput(ns("which_protein_for_data_process_plots"), "Show plot for", + choices = c("", protein_choices)), + "Copy analyte name") } }) diff --git a/R/qc-server-sidebar.R b/R/qc-server-sidebar.R index 77f8c4f9..5591cdc4 100644 --- a/R/qc-server-sidebar.R +++ b/R/qc-server-sidebar.R @@ -304,10 +304,14 @@ register_qc_sidebar_options <- function(input, output, session, loadpage_input, } if (input$standards == "Proteins") { - selectizeInput(ns("names"), "choose standard", unique(get_data()$ProteinName), multiple = TRUE) + copyable_select( + selectizeInput(ns("names"), "choose standard", unique(get_data()$ProteinName), multiple = TRUE), + "Copy standard name") } else if (input$standards == "Peptides") { - selectizeInput(ns("names"), "choose standard", unique(get_data()$PeptideSequence), multiple = TRUE) + copyable_select( + selectizeInput(ns("names"), "choose standard", unique(get_data()$PeptideSequence), multiple = TRUE), + "Copy standard name") } }) diff --git a/R/statmodel-server-visualization.R b/R/statmodel-server-visualization.R index 1f3f9469..a92b25bd 100644 --- a/R/statmodel-server-visualization.R +++ b/R/statmodel-server-visualization.R @@ -31,9 +31,11 @@ render_group_comparison_plot_inputs = function(output, session, rownames, get_da proteins = tryCatch(unique(as.character(preprocess_data()$ProteinLevelData$Protein)), error = function(e) NULL) req(length(proteins) > 0) - selectInput(ns(NAMESPACE_STATMODEL$visualization_which_protein), - label = h4("which analyte to plot"), - proteins) + copyable_select( + selectInput(ns(NAMESPACE_STATMODEL$visualization_which_protein), + label = h4("which analyte to plot"), + proteins), + "Copy analyte name") }) output[[NAMESPACE_STATMODEL$visualization_plot_options_conditional_panel]] = renderUI({ diff --git a/R/utils-copyable-select.R b/R/utils-copyable-select.R new file mode 100644 index 00000000..130c80e7 --- /dev/null +++ b/R/utils-copyable-select.R @@ -0,0 +1,88 @@ +# ============================================================================ +# Copy-to-clipboard wrapper for protein-name dropdowns +# ============================================================================ + +#' HTML dependency carrying the copy-button assets. +#' +#' Delivered as a dependency rather than as `tags$link` / `tags$script` in the +#' page headers for two reasons: every call site renders inside `renderUI()`, +#' and `expdesUI()` does not load `assets/style.css` at all, so a header-tag +#' approach would silently skip the experimental design dropdown. +#' +#' @noRd +copyable_select_dependency <- function() { + htmltools::htmlDependency( + name = "msstatsshiny-copyable-select", + version = as.character(utils::packageVersion("MSstatsShiny")), + src = c(file = system.file("assets", package = "MSstatsShiny")), + script = "copy-select.js", + stylesheet = "copy-select.css" + ) +} + +#' Count `select` elements in a tag tree. +#' +#' Used only to enforce the one-select contract of [copyable_select()]. +#' +#' @noRd +count_select_tags <- function(x) { + if (inherits(x, "shiny.tag")) { + return(as.integer(identical(x$name, "select")) + count_select_tags(x$children)) + } + if (is.list(x)) { + return(sum(vapply(x, count_select_tags, integer(1)))) + } + 0L +} + +#' Add a copy-to-clipboard button to a select input. +#' +#' Wraps an already-built [shiny::selectInput()] or [shiny::selectizeInput()] +#' so the user can copy the selected protein name out of the dropdown. +#' Selectize renders the current selection as a `div`, which browsers will not +#' let the user select as text, so a button is the only reliable affordance. +#' +#' This is a pure wrapper: the input keeps its id, choices, label and options +#' untouched. The button finds its select by DOM traversal, so the namespaced +#' `inputId` does not need to be repeated here. +#' +#' Wrap one select, not a `tagList` of several. The button has no way to know +#' which of several selects it belongs to, so that case is rejected rather +#' than silently binding to the first one. +#' +#' @param select_tag A select input tag, as returned by `selectInput()` or +#' `selectizeInput()`. +#' @param tooltip Hover text for the button, and the text announced to screen +#' readers as the button's name. +#' +#' @return A `div` containing `select_tag`, the copy button, and the JS/CSS +#' dependency. +#' +#' @noRd +copyable_select <- function(select_tag, tooltip = "Copy name") { + n_selects <- count_select_tags(select_tag) + if (n_selects != 1L) { + stop("copyable_select() needs exactly one select input, got ", n_selects, + ". Wrap the individual selectInput()/selectizeInput(), not a tagList.", + call. = FALSE) + } + + button <- tags$button( + type = "button", + class = "copyable-select-btn", + `aria-label` = tooltip, + icon("copy", lib = "font-awesome"), + # span, not div: a button's content model is phrasing content only. + # role/aria-live let the flashed result ("Copied") reach screen readers, + # which the static aria-label above cannot. + tags$span(tooltip, + class = "copyable-select-tip", + role = "status", + `aria-live` = "polite") + ) + + htmltools::attachDependencies( + div(class = "copyable-select", select_tag, button), + copyable_select_dependency() + ) +} diff --git a/inst/assets/copy-select.css b/inst/assets/copy-select.css new file mode 100644 index 00000000..808f1ae8 --- /dev/null +++ b/inst/assets/copy-select.css @@ -0,0 +1,80 @@ +/* Copy-to-clipboard affordance for protein-name dropdowns. + Paired with assets/copy-select.js; both are delivered as one + htmlDependency by copyable_select() in R/utils-copyable-select.R. + + Deliberately self-contained: it must not lean on assets/style.css, because + expdesUI() never loads that file. Hence the tooltip rules below duplicate + the .icon-tooltip pattern rather than extending it. */ + +.copyable-select { + display: flex; + align-items: flex-end; + gap: 6px; +} + +/* The wrapped shiny input keeps all the horizontal room it had before. */ +.copyable-select > .form-group, +.copyable-select > .shiny-input-container { + flex: 1 1 auto; + min-width: 0; + margin-bottom: 15px; +} + +/* flex-end aligns margin boxes, so the button carries the same bottom margin + as the form-group to sit level with the select rather than 15px below it. + Aligning on the bottom edge keeps the button level no matter how tall the + label is - call sites use h4(), h5() and bare strings. */ +.copyable-select-btn { + position: relative; + flex: 0 0 auto; + margin-bottom: 15px; + height: 34px; + width: 34px; + padding: 0; + line-height: 1; + cursor: pointer; + color: #333; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; +} + +.copyable-select-btn:hover:not(:disabled) { + background-color: #e6e6e6; +} + +.copyable-select-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.copyable-select-tip { + display: block; + visibility: hidden; + opacity: 0; + transition: opacity 0.3s; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + z-index: 999; + margin-bottom: 6px; + padding: 5px 10px; + border-radius: 6px; + background-color: #333; + color: #fff; + font-size: 10px; + font-weight: normal; + text-align: center; + white-space: nowrap; + pointer-events: none; +} + +/* :focus-visible so keyboard users get the same hint as hover; the flash + class covers the post-click result for both. */ +.copyable-select-btn:hover:not(:disabled) .copyable-select-tip, +.copyable-select-btn:focus-visible .copyable-select-tip, +.copyable-select-btn.is-flashing .copyable-select-tip { + visibility: visible; + opacity: 1; +} diff --git a/inst/assets/copy-select.js b/inst/assets/copy-select.js new file mode 100644 index 00000000..9e5242bd --- /dev/null +++ b/inst/assets/copy-select.js @@ -0,0 +1,143 @@ +/* Copy-to-clipboard support for the protein-name dropdowns. + * + * Every call site renders inside renderUI(), so the select elements are + * created and destroyed as the user switches plot types. All handlers are + * therefore delegated from `document` - nothing is bound per element. + * + * The button reaches its select by DOM traversal rather than by id, so the + * R helper stays a pure wrapper and does not need the namespaced inputId. + */ +(function () { + var FLASH_MS = 1400; + + function selectFor(btn) { + var wrap = btn.closest(".copyable-select"); + return wrap ? wrap.querySelector("select") : null; + } + + /* Copy what the user SEES, not the underlying value. The QC plot selector + offers choices = c("ALL ANALYTES" = "allonly", ...), where the value is + "allonly" - copying that would be meaningless. For real protein names + label and value are identical, so reading the label is correct in both + cases. multiple = TRUE selects are joined. */ + function selectedText(sel) { + if (!sel) return ""; + if (sel.selectize) { + return sel.selectize.$control + .find(".item") + .map(function () { + return this.textContent.trim(); + }) + .get() + .filter(Boolean) + .join("; "); + } + return Array.prototype.slice + .call(sel.selectedOptions || []) + .map(function (o) { + return o.text.trim(); + }) + .filter(Boolean) + .join("; "); + } + + /* navigator.clipboard needs a secure context. 127.0.0.1 qualifies, but an + instance served over plain http on a lab host does not, hence the + execCommand fallback. */ + function copyText(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + return new Promise(function (resolve, reject) { + var restoreFocus = document.activeElement; + var ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "-1000px"; + ta.style.left = "-1000px"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + var ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + document.body.removeChild(ta); + /* ta.select() stole focus; put it back so keyboard users keep their place. */ + if (restoreFocus && restoreFocus.focus) restoreFocus.focus(); + return ok ? resolve() : reject(new Error("execCommand copy failed")); + }); + } + + function flash(btn, msg) { + var tip = btn.querySelector(".copyable-select-tip"); + if (!tip) return; + if (btn.dataset.restoreTip === undefined) { + btn.dataset.restoreTip = tip.textContent; + } + tip.textContent = msg; + btn.classList.add("is-flashing"); + clearTimeout(btn.flashTimer); + btn.flashTimer = setTimeout(function () { + btn.classList.remove("is-flashing"); + tip.textContent = btn.dataset.restoreTip; + }, FLASH_MS); + } + + /* Keyed on the wrapper, not on a select: iterating selects would let a + wrapper holding more than one select disagree with selectFor() about which + select the button represents. copyable_select() rejects that case on the R + side; this keeps the two ends consistent regardless. */ + function refresh(wrap) { + var btn = wrap.querySelector(".copyable-select-btn"); + if (!btn) return; + btn.disabled = selectedText(wrap.querySelector("select")) === ""; + } + + function refreshAll() { + var wraps = document.querySelectorAll(".copyable-select"); + Array.prototype.forEach.call(wraps, refresh); + } + + document.addEventListener("click", function (e) { + var btn = e.target.closest ? e.target.closest(".copyable-select-btn") : null; + if (!btn) return; + /* No preventDefault/stopPropagation here: a click on the button never + reaches the sibling select anyway, and stopPropagation would suppress + any window-level click handler the host page installs. */ + var text = selectedText(selectFor(btn)); + if (!text) { + flash(btn, "Nothing selected"); + return; + } + copyText(text).then( + function () { + flash(btn, "Copied"); + }, + function () { + flash(btn, "Copy failed"); + } + ); + }); + + /* Fallback for plain (selectize = FALSE) selects, which do emit a native + change event. Selectize inputs never reach this: selectize signals via + jQuery .trigger("change"), which dispatches no native event for