Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ License: LGPL-3
Encoding: UTF-8
LazyData: true
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.1
RoxygenNote: 7.3.2
Depends:
R (>= 2.10)
Imports:
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export(DocumentCardTitle)
export(Dropdown)
export(Dropdown.shinyInput)
export(Facepile)
export(FileUploadButton.shinyInput)
export(FocusTrapCallout)
export(FocusTrapZone)
export(FocusZone)
Expand Down Expand Up @@ -136,6 +137,7 @@ export(updateCompoundButton.shinyInput)
export(updateDatePicker.shinyInput)
export(updateDefaultButton.shinyInput)
export(updateDropdown.shinyInput)
export(updateFileUploadButton.shinyInput)
export(updateIconButton.shinyInput)
export(updateNormalPeoplePicker.shinyInput)
export(updatePrimaryButton.shinyInput)
Expand Down
47 changes: 47 additions & 0 deletions R/documentation.R
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,53 @@ NULL
#' @name Button
NULL

#' FileUploadButton
#'
#' @description
#' A Safari-compatible file upload button that combines Fluent UI styling with cross-browser file selection functionality. This component solves the issue where Safari blocks programmatic file input clicks by using React-based file input handling.
#'
#' For more details about Fluent UI buttons visit the [official docs](https://developer.microsoft.com/en-us/fluentui#/controls/web/Button).
#' The R package cannot handle each and every case, so for advanced use cases
#' you need to work using the original docs to achieve the desired result.
#'
#' @param inputId ID of the component.
#' @param value Starting value.
#' @param session Object passed as the `session` argument to Shiny server.
#' @param ... Props to pass to the component.
#' The allowed props are listed below in the \bold{Details} section.
#'
#' @section Best practices:
#' ### Usage
#' - Use FileUploadButton when you need Safari-compatible file uploads
#' - Choose appropriate buttonType for visual hierarchy (primary for main actions)
#' - Set meaningful accept attributes to filter file types
#' - Use multiple=TRUE for bulk file uploads
#'
#' ### Content
#' - Use clear, action-oriented text ("Upload Files", "Select Document")
#' - Include relevant icons when helpful (Upload, Attach, FolderOpen)
#' - Keep button text concise but descriptive
#'
#' ### Accessibility
#' - Button automatically includes proper ARIA attributes

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: that's misleading. The button might have attributes, but there is nothing specific to file upload.

#' - Supports keyboard navigation and screen readers
#' - File input maintains semantic meaning for assistive technology

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: "screen readers" and "assistive technology" seems to be a duplicated information

#'
#' @details
#'
#' * \bold{ text } `string` \cr Text to display on the button.
#' * \bold{ buttonType } `string` \cr Type of Fluent button: "primary", "default", "compound", "action", "command", "commandBar", or "icon". Defaults to "default".
#' * \bold{ icon } `string` \cr Optional Fluent UI icon name (e.g., "Upload", "Attach", "FolderOpen").
#' * \bold{ accept } `string` \cr File types to accept (e.g., ".xlsx,.csv", ".pdf"). Passed to underlying file input.
#' * \bold{ multiple } `boolean` \cr Whether to allow multiple file selection. Defaults to FALSE.
#' * \bold{ disabled } `boolean` \cr Whether the button is disabled.
#' * \bold{ className } `string` \cr Additional CSS class name for the button.
#' * \bold{ style } `string` \cr Inline CSS styles for the button.
#'
#' @md
#' @name FileUploadButton
NULL

#' Calendar
#'
#' @description
Expand Down
8 changes: 8 additions & 0 deletions R/inputs.R
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,11 @@ Toggle.shinyInput <- input("Toggle", FALSE)
#' @rdname Toggle
#' @export
updateToggle.shinyInput <- shiny.react::updateReactInput

#' @rdname FileUploadButton
#' @export
FileUploadButton.shinyInput <- input("FileUploadButton", NULL)

#' @rdname FileUploadButton
#' @export
updateFileUploadButton.shinyInput <- shiny.react::updateReactInput
121 changes: 94 additions & 27 deletions inst/examples/Button3.R

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: this example doesn't work. The app crashes when using Data Files and Images inputs.

Additionally, the insertUI seems to be an overkill for such example. Could we use a simple renderText to display what was uploaded instead?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@R3myG, the upload now works, but the MessageBar isn't really the success variant as the comments suggest. Additionally, uploading a .xlsx file to the first one and `.jpg to the last input doesn't seem to produce a meaningful output:
Image

Additionally, the comment about using renderText inside a static MessageBar instead of re-rendering the entire MessageBar still holds.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file could also use formatting with {styler}.

Original file line number Diff line number Diff line change
@@ -1,50 +1,117 @@

# Example 3
# Example 3: File Upload with Fluent UI Buttons
library(shiny)
library(shiny.fluent)
library(shinyjs)

# This example app shows how to use a Fluent UI Button to trigger a file upload.
# File upload is not natively supported by shiny.fluent so shinyjs is used
# to trigger the file upload input.
# This example demonstrates FileUploadButton - a native Fluent UI file upload component
# that works across all browsers, including Safari.

ui <- function(id) {
ns <- NS(id)
fluentPage(
useShinyjs(),
h3("File Upload with Fluent UI"),
p("Native file upload components with full cross-browser support:"),

Stack(
tokens = list(
childrenGap = 10L
tokens = list(childrenGap = 20),
horizontal = FALSE,

# Primary button for important uploads
div(
Text(variant = "mediumPlus", "Upload Data Files"),
FileUploadButton.shinyInput(
inputId = ns("data_files"),
text = "Choose Data Files",
buttonType = "primary",
icon = "Upload",
accept = ".xlsx,.csv,.json",
multiple = TRUE
)
),
horizontal = TRUE,
DefaultButton.shinyInput(
inputId = ns("uploadFileButton"),
text = "Upload File",
iconProps = list(iconName = "Upload")

# Default button for documents
div(
Text(variant = "mediumPlus", "Upload Document"),
FileUploadButton.shinyInput(
inputId = ns("document"),
text = "Choose Document",
buttonType = "default",
icon = "TextDocument",
accept = ".pdf,.docx,.txt"
)
),

# Compound button with description
div(
style = "
visibility: hidden;
height: 0;
width: 0;
",
fileInput(
inputId = ns("uploadFile"),
label = NULL
Text(variant = "mediumPlus", "Upload Images"),
FileUploadButton.shinyInput(
inputId = ns("images"),
text = "Choose Images",
buttonType = "compound",
icon = "Photo2",
accept = ".png,.jpg,.jpeg,.gif",
multiple = TRUE
)
)
),
textOutput(ns("file_path"))

br(),
h4("Upload Status:"),
div(id = ns("upload_status"))
)
}

server <- function(id) {
moduleServer(id, function(input, output, session) {
observeEvent(input$uploadFileButton, {
click("uploadFile")

# Handle data files upload
observeEvent(input$data_files, {
req(input$data_files)
files <- if (length(input$data_files$name) > 1) {
paste(input$data_files$name, collapse = ", ")
} else {
input$data_files$name
}
insertUI(
paste0("#", session$ns("upload_status")),
"beforeEnd",
MessageBar(
messageBarType = 1, # success
paste("✅ Data files uploaded:", files)
)
)
})

output$file_path <- renderText({
input$uploadFile$name

# Handle document upload
observeEvent(input$document, {
req(input$document)
insertUI(
paste0("#", session$ns("upload_status")),
"beforeEnd",
MessageBar(
messageBarType = 1, # success
paste("📄 Document uploaded:", input$document$name)
)
)
})

# Handle images upload
observeEvent(input$images, {
req(input$images)
files <- if (length(input$images$name) > 1) {
paste(length(input$images$name), "images:",
paste(input$images$name, collapse = ", "))
} else {
paste("Image uploaded:", input$images$name)
}
insertUI(
paste0("#", session$ns("upload_status")),
"beforeEnd",
MessageBar(
messageBarType = 1, # success
paste("🖼️", files)
)
)
})
})
}
Expand Down
2 changes: 1 addition & 1 deletion inst/www/shiny.fluent/shiny-fluent.js

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions js/src/inputs.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import * as Fluent from '@fluentui/react';
import { ButtonAdapter, InputAdapter, debounce } from '@/shiny.react';

Expand Down Expand Up @@ -107,3 +108,82 @@ export const Toggle = InputAdapter(Fluent.Toggle, (value, setValue) => ({
checked: value,
onChange: (e, v) => setValue(v),
}));

// Safari-compatible file upload button
export const FileUploadButton = InputAdapter(
({
value,
onChange,
buttonType = 'default',
icon,
text,
accept,
multiple,
...otherProps
}) => {
const fileInputRef = React.useRef(null);

const handleClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};

const handleFileChange = (event) => {
const { files } = event.target;
if (files && files.length > 0) {
// Convert FileList to format Shiny expects
const fileData = Array.from(files).map((file) => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
}));
onChange(multiple ? fileData : fileData[0]);
}
};

// Select the appropriate button component
let ButtonComponent;
if (buttonType === 'primary') {
ButtonComponent = Fluent.PrimaryButton;
} else if (buttonType === 'compound') {
ButtonComponent = Fluent.CompoundButton;
} else if (buttonType === 'action') {
ButtonComponent = Fluent.ActionButton;
} else if (buttonType === 'command') {
ButtonComponent = Fluent.CommandButton;
} else if (buttonType === 'commandBar') {
ButtonComponent = Fluent.CommandBarButton;
} else if (buttonType === 'icon') {
ButtonComponent = Fluent.IconButton;
} else {
ButtonComponent = Fluent.DefaultButton;
}

return (
<div>
<ButtonComponent

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: it looks like it's not possible to pass props to the button than the hard-coded handful.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@R3myG this is still an issue. Although, I see the list of supported attributed extended.

onClick={handleClick}
text={text}
iconProps={icon ? { iconName: icon } : undefined}
disabled={otherProps.disabled}
className={otherProps.className}
style={otherProps.style}
/>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
},
(value, setValue) => ({
value,
onChange: setValue,
}),
);
Loading
Loading