/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx
1 111 строк
39 KB
Lukas Masuch
Split `Arrow.proto` into `Table.proto` and `Dataframe.proto` (#13768)
04 фев 2026, 22:22
Не верифицирован
04 фев 2026, 22:22
dc9d8fe
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { memo, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react" import { Add, Close, Delete, FileDownload, Search, Visibility, } from "@emotion-icons/material-outlined" import { CompactSelection, DataEditorRef, DataEditor as GlideDataEditor, GridCell, GridColumn, GridMouseEventArgs, GridSelection, type Item, Rectangle, } from "@glideapps/glide-data-grid" import { Resizable } from "re-resizable" import { createPortal } from "react-dom" import { Dataframe as DataframeProto, streamlit } from "@streamlit/protobuf" import { FlexContext } from "~lib/components/core/Layout/FlexContext" import { LibConfigContext } from "~lib/components/core/LibConfigContext" import { ElementFullscreenContext } from "~lib/components/shared/ElementFullscreen/ElementFullscreenContext" import { withFullScreenWrapper } from "~lib/components/shared/FullScreenWrapper" import Toolbar, { ToolbarAction } from "~lib/components/shared/Toolbar" import { useFormClearHelper } from "~lib/components/widgets/Form" import { Quiver } from "~lib/dataframes/Quiver" import { useCalculatedDimensions } from "~lib/hooks/useCalculatedDimensions" import { useDebouncedCallback } from "~lib/hooks/useDebouncedCallback" import { useRequiredContext } from "~lib/hooks/useRequiredContext" import { useScrollbarGutterSize } from "~lib/hooks/useScrollbarGutterSize" import { convertRemToPx } from "~lib/theme/utils" import { isNullOrUndefined } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" import { getTextCell, ImageCellEditor, toGlideColumn } from "./columns" import { useColumnFormatting, useColumnLoader, useColumnPinning, useColumnReordering, useColumnSizer, useColumnSort, useColumnVisibility, useCustomEditors, useCustomRenderer, useCustomTheme, useDataEditor, useDataExporter, useDataLoader, useRowHover, useSelectionHandler, useTableSizer, useTooltips, useWidgetState, } from "./hooks" import { DEBOUNCE_TIME_MS } from "./hooks/useWidgetState" import ColumnMenu from "./menus/ColumnMenu" import ColumnVisibilityMenu from "./menus/ColumnVisibilityMenu" import { StyledResizableContainer } from "./styled-components" import Tooltip from "./Tooltip" import "@glideapps/glide-data-grid/dist/index.css" import "@glideapps/glide-data-grid-cells/dist/index.css" // Number of rows that triggers some optimization features // for large tables. const LARGE_TABLE_ROWS_THRESHOLD = 150000 // Fallback size for the scrollbar gutter size in rem. // If the scrollbar gutter size is 0, it means that we the system is using // overlay scrollbars that don't take any space. In this case, we assume // a scrollbar size of ~8px to prevent clicks on the scrollbar to be applied // in the data grid. const SCROLLBAR_FALLBACK_SIZE_REM = "0.5rem" export interface DataFrameProps { element: DataframeProto data: Quiver disabled: boolean widgetMgr: WidgetStateManager | undefined disableFullscreenMode?: boolean fragmentId?: string // Custom toolbar actions (as React nodes) to display in the grid toolbar. customToolbarActions?: React.ReactNode[] widthConfig?: streamlit.IWidthConfig | null heightConfig?: streamlit.IHeightConfig | null } /** * The main component used by dataframe & data_editor to render an editable table. * * @param element - The element's proto message * @param data - The Arrow data to render (extracted from the proto message) * @param disabled - Whether the widget is disabled * @param widgetMgr - The widget manager */ function DataFrame({ element, data, disabled, widgetMgr, disableFullscreenMode, fragmentId, customToolbarActions, widthConfig, heightConfig, }: Readonly<DataFrameProps>): ReactElement { const { expanded: isFullScreen, expand, collapse, width: containerWidth, height: fullScreenHeight, } = useRequiredContext(ElementFullscreenContext) const { isInHorizontalLayout, isInRoot } = useRequiredContext(FlexContext) const resizableRef = useRef<Resizable>(null) const dataEditorRef = useRef<DataEditorRef>(null) const scrollbarGutterSize = useScrollbarGutterSize() const { height: measuredContainerHeight, elementRef: resizableContainerRef, } = useCalculatedDimensions() const gridTheme = useCustomTheme() const { getRowThemeOverride, onItemHovered: handleRowHover } = useRowHover(gridTheme) // Default to false, if no libConfig, e.g. for tests const { enforceDownloadInNewTab = false } = useContext(LibConfigContext) const [isFocused, setIsFocused] = useState<boolean>(true) const [showSearch, setShowSearch] = useState(false) const [hasVerticalScroll, setHasVerticalScroll] = useState<boolean>(false) const [hasHorizontalScroll, setHasHorizontalScroll] = useState<boolean>(false) const [showMenu, setShowMenu] = useState<{ // The index number of the column that the menu is shown for: columnIdx: number // The bounds of the column header: headerBounds: Rectangle }>() const [showColumnVisibilityMenu, setShowColumnVisibilityMenu] = useState(false) const handleToggleColumnVisibilityMenu = useCallback( (): void => setShowColumnVisibilityMenu(show => !show), [] ) const handleCloseColumnVisibilityMenu = useCallback( () => setShowColumnVisibilityMenu(false), [] ) // Determine if the device is primary using touch as input: const isTouchDevice = useMemo<boolean>( () => window.matchMedia && window.matchMedia("(pointer: coarse)").matches, [] ) // This is done to keep some backwards compatibility // so that old arrow proto messages from the st.dataframe // would still work. Those messages don't have the // editingMode field defined. if (isNullOrUndefined(element.editingMode)) { element.editingMode = DataframeProto.EditingMode.READ_ONLY } const { READ_ONLY, DYNAMIC, ADD_ONLY, DELETE_ONLY } = DataframeProto.EditingMode // Number of rows of the table minus 1 for the header row: const dataDimensions = data.dimensions const originalNumRows = Math.max(0, dataDimensions.numDataRows) // For empty tables, we show an extra row that // contains "empty" as a way to indicate that the table is empty. const isEmptyTable = originalNumRows === 0 && // We don't show empty state for modes that allow adding rows // with a table that has data columns defined. !( (element.editingMode === DYNAMIC || element.editingMode === ADD_ONLY) && dataDimensions.numDataColumns > 0 ) // For large tables, we apply some optimizations to handle large data const isLargeTable = originalNumRows > LARGE_TABLE_ROWS_THRESHOLD // Sorting is disabled for modes that allow adding rows (DYNAMIC, ADD_ONLY) // because sorting and row addition can conflict const isSortingEnabled = !isLargeTable && !isEmptyTable && element.editingMode !== DYNAMIC && element.editingMode !== ADD_ONLY // Check if the editing mode allows adding rows (DYNAMIC or ADD_ONLY) const canAddRows = !isEmptyTable && (element.editingMode === DYNAMIC || element.editingMode === ADD_ONLY) && !disabled // Check if the editing mode allows deleting rows (DYNAMIC or DELETE_ONLY) const canDeleteRows = !isEmptyTable && (element.editingMode === DYNAMIC || element.editingMode === DELETE_ONLY) && !disabled const [columnOrder, setColumnOrder] = useState(element.columnOrder) // Update the column order if the element.columnOrder value changes // e.g. if the user has applied changes to the column order in the code. useEffect(() => { setColumnOrder(element.columnOrder) // eslint-disable-next-line react-hooks/exhaustive-deps }, [element.columnOrder.join(",")]) const { columns: originalColumns, allColumns, setColumnConfigMapping, } = useColumnLoader(element, data, disabled, columnOrder, widthConfig) // Widget state management hook - handles editing state, syncing with widget manager, // and form clear handling const { editingState, numRows, updateNumRows, syncEditState, createSyncSelectionState, onFormCleared: handleFormCleared, loadInitialSelectionState, } = useWidgetState({ element, widgetMgr, fragmentId, originalNumRows, originalColumns, }) const { getCellContent: getOriginalCellContent } = useDataLoader( data, originalColumns, numRows, editingState ) const { columns, sortColumn, getOriginalIndex, getCellContent } = useColumnSort(originalNumRows, originalColumns, getOriginalCellContent) // Create the sync selection state callback using the sorted columns and getOriginalIndex. // This is done here because it needs the output from useColumnSort. const innerSyncSelectionState = useMemo( () => createSyncSelectionState(columns, getOriginalIndex), [createSyncSelectionState, columns, getOriginalIndex] ) // Use a debounce to prevent rapid updates to the widget state. const { debouncedCallback: syncSelectionState } = useDebouncedCallback( innerSyncSelectionState, DEBOUNCE_TIME_MS ) const { gridSelection, isRowSelectionActivated, isMultiRowSelectionActivated, isColumnSelectionActivated, isMultiColumnSelectionActivated, isCellSelectionActivated, isMultiCellSelectionActivated, isRowSelected, isColumnSelected, isCellSelected, clearSelection, processSelectionChange, } = useSelectionHandler( element, isEmptyTable, disabled, columns, syncSelectionState ) useEffect(() => { if (isCellSelectionActivated) { // We don't clear anything if cell selection is activated. return } // Clear cell selections if fullscreen mode changes // but keep row & column selections. // In the past we saw some weird side-effects, so we decided to clean // it when entering fullscreen-mode. If we decide to change this, we have // to play around and get to the bottom of it. clearSelection(true, true) // Only run this on changes to the fullscreen mode: // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: Update to match React best practices }, [isFullScreen]) // This callback is used to refresh the rendering of specified cells const refreshCells = useCallback( ( cells: { cell: Item }[] ) => { dataEditorRef.current?.updateCells(cells) }, [] ) /** * On the first rendering, try to load initial selection state * from the widget state if it exists. This is required in the * case that other elements are inserted before this widget. * * This effect needs to run after the fullscreen effect that * clears cell selections, since both modify the same state object. */ useEffect( () => { const initialSelection = loadInitialSelectionState({ columns, isRowSelectionActivated, isColumnSelectionActivated, isCellSelectionActivated, isMultiCellSelectionActivated, }) if (initialSelection) { processSelectionChange(initialSelection) } }, // We only want to run this effect once during the initial component load // so we disable the eslint rule. // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: Update to match React best practices [] ) const { exportToCsv } = useDataExporter( getCellContent, columns, numRows, enforceDownloadInNewTab ) const { onCellEdited, onPaste, onRowAppended, onDelete, validateCell } = useDataEditor({ columns, canAddRows, canDeleteRows, editingState, getCellContent, getOriginalIndex, refreshCells, updateNumRows, syncEditState, clearSelection, }) const ignoredRowIndices = useMemo(() => { // If empty table, ignore row index 0 which is just a visual gimmick // If row adding is enabled, we need to ignore the last row (trailing row) // because it would result in some undesired errors in the tooltips. // The index are 0-based -> therefore, numRows will point to the trailing row // (which is not part of the actual data). if (isEmptyTable) { return [0] } if (canAddRows) { return [numRows] } return [] }, [isEmptyTable, canAddRows, numRows]) const { tooltip, clearTooltip, onItemHovered: handleTooltips, } = useTooltips(columns, getCellContent, ignoredRowIndices) const { drawCell, customRenderers } = useCustomRenderer( columns, element.placeholder ?? undefined ) const { provideEditor } = useCustomEditors() // Callback that can be used to configure the column menu for the columns const configureColumnMenu = useCallback( (column: GridColumn): GridColumn => { return { ...column, hasMenu: !isEmptyTable, } }, [isEmptyTable] ) // Convert columns from our structure into the glide-data-grid compatible structure const transformedColumns = useMemo( () => columns.map(column => configureColumnMenu(toGlideColumn(column))), [columns, configureColumnMenu] ) const { columns: glideColumns, onColumnResize } = useColumnSizer(transformedColumns) // To activate the group row feature (multi-level headers), // we need more than one header row. const usesGroupRow = data.dimensions.numHeaderRows > 1 const { minHeight, maxHeight, minWidth, maxWidth, rowHeight, resizableSize, setResizableSize, } = useTableSizer( element, gridTheme, numRows, usesGroupRow, containerWidth || 0, fullScreenHeight, isFullScreen, widthConfig, heightConfig, measuredContainerHeight, isInRoot ) // This is used as fallback in case the table is empty to // insert cells indicating this state: const getEmptyStateContent = useCallback( ([_col, _row]: readonly [number, number]): GridCell => { return { ...getTextCell(true, false), displayData: "empty", contentAlign: "center", allowOverlay: false, themeOverride: { textDark: gridTheme.glideTheme.textLight, }, span: [0, Math.max(columns.length - 1, 0)], } as GridCell }, [columns, gridTheme.glideTheme.textLight] ) const onFormCleared = useCallback(() => { // Clear the editing state and the selection state handleFormCleared() clearSelection() }, [handleFormCleared, clearSelection]) useFormClearHelper({ element, widgetMgr, onFormCleared }) const { pinColumn, unpinColumn, freezeColumns } = useColumnPinning( columns, isEmptyTable, containerWidth || 0, gridTheme.minColumnWidth, clearSelection, setColumnConfigMapping ) const { changeColumnFormat } = useColumnFormatting(setColumnConfigMapping) const { hideColumn, showColumn } = useColumnVisibility( clearSelection, setColumnConfigMapping ) const { onColumnMoved } = useColumnReordering( columns, freezeColumns, pinColumn, unpinColumn, setColumnOrder ) // Determine if the table requires horizontal or vertical scrolling: useEffect(() => { // Use requestAnimationFrame + setTimeout to ensure the DOM is fully rendered // before measuring. This is more reliable than setTimeout alone. // requestAnimationFrame ensures the browser has calculated layout, // and setTimeout pushes the callback to the next event loop tick. let timeoutId: ReturnType<typeof setTimeout> | undefined const rafId = requestAnimationFrame(() => { timeoutId = setTimeout(() => { if (resizableContainerRef.current && dataEditorRef.current) { // Get the bounds of the glide-data-grid scroll area (dvn-stack): // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage const scrollAreaBounds = resizableContainerRef.current ?.querySelector(".dvn-stack") ?.getBoundingClientRect() // We might also be able to use the following as an alternative, // but it seems to cause "Maximum update depth exceeded" when scrollbars // are activated or deactivated. // const scrollAreaBounds = dataEditorRef.current?.getBounds() // Also see: https://github.com/glideapps/glide-data-grid/issues/784 if (scrollAreaBounds) { setHasVerticalScroll( scrollAreaBounds.height > // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage resizableContainerRef.current.clientHeight ) setHasHorizontalScroll( scrollAreaBounds.width > // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage resizableContainerRef.current.clientWidth ) } } }, 0) }) // Cleanup on unmount return () => { cancelAnimationFrame(rafId) if (timeoutId) { clearTimeout(timeoutId) } } }, [resizableSize, numRows, glideColumns, resizableContainerRef]) // Hide the column visibility menu if all columns are visible: useEffect(() => { if (allColumns.length == columns.length) { setShowColumnVisibilityMenu(false) } }, [allColumns.length, columns.length]) // Disable resize if the dataframe is in a horizontal layout or if it is a content-width dataframe // and not in the root container. This is because the feature requires measurements from the parent container // which cannot be determined when the parent container has a fit-content width or when there are multiple siblings // in a nested container. const disableResize = isInHorizontalLayout || (widthConfig?.useContent && !isInRoot) ? true : false return ( <StyledResizableContainer className="stDataFrame" data-testid="stDataFrame" ref={resizableContainerRef} isInHorizontalLayout={isInHorizontalLayout} minHeight={minHeight} disableResize={disableResize} onPointerDown={e => { if (resizableContainerRef.current) { // Prevent clicks on the scrollbar handle to propagate to the grid: const boundingClient = // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage resizableContainerRef.current.getBoundingClientRect() // For whatever reason, we are still able to use the scrollbars even // if the mouse is one pixel outside of the scrollbar. Therefore, we add // an additional pixel. const scrollbarSize = (scrollbarGutterSize || Math.round(convertRemToPx(SCROLLBAR_FALLBACK_SIZE_REM))) + 1 if ( hasHorizontalScroll && boundingClient.height - scrollbarSize < e.clientY - boundingClient.top ) { e.stopPropagation() } if ( hasVerticalScroll && boundingClient.width - scrollbarSize < e.clientX - boundingClient.left ) { e.stopPropagation() } } }} onBlur={event => { // If the container loses focus, clear the current selection. // Touch screen devices have issues with this, so we don't clear // the selection on those devices. // We also don't want to clear the selection if the user clicks on // on the toolbar by checking that relatedTarget is not a children of // this element. Unfortunately, this check isn't working reliably in Safari. if ( !isFocused && !isTouchDevice && !event.currentTarget.contains( event.relatedTarget as HTMLElement | null ) && !isCellSelectionActivated ) { // Clear cell selections, but keep row & column selections. clearSelection(true, true) } }} > <Toolbar isFullScreen={isFullScreen} disableFullscreenMode={disableFullscreenMode} // Lock the toolbar in some specific situations: locked={ (isRowSelected && !isRowSelectionActivated) || isCellSelected || (isTouchDevice && isFocused) || showColumnVisibilityMenu } onExpand={expand} onCollapse={collapse} target={StyledResizableContainer} > {customToolbarActions?.map(action => action)} {((isRowSelectionActivated && isRowSelected) || (isColumnSelectionActivated && isColumnSelected) || (isCellSelectionActivated && isCellSelected)) && ( // Add clear selection action if selections are active // and a valid selections currently exists. Cell selections // are not relevant since they are not synced to the backend // at the moment. <ToolbarAction label="Clear selection" icon={Close} onClick={() => { clearSelection() clearTooltip() }} /> )} {canDeleteRows && isRowSelected && ( <ToolbarAction label="Delete row(s)" icon={Delete} onClick={() => { if (onDelete) { onDelete(gridSelection) clearTooltip() } }} /> )} {canAddRows && !isRowSelected && ( <ToolbarAction label="Add row" icon={Add} onClick={() => { if (onRowAppended) { setIsFocused(true) // eslint-disable-next-line @typescript-eslint/no-floating-promises -- TODO: Fix this onRowAppended() clearTooltip() // Automatically scroll to the new row on the vertical axis: dataEditorRef.current?.scrollTo(0, numRows, "vertical") } }} /> )} {!isEmptyTable && allColumns.length > columns.length && ( <ColumnVisibilityMenu columns={allColumns} columnOrder={columnOrder} setColumnOrder={setColumnOrder} hideColumn={hideColumn} showColumn={showColumn} isOpen={showColumnVisibilityMenu} onClose={handleCloseColumnVisibilityMenu} > <ToolbarAction label="Show/hide columns" icon={Visibility} onClick={handleToggleColumnVisibilityMenu} /> </ColumnVisibilityMenu> )} {!isLargeTable && !isEmptyTable && ( <ToolbarAction label="Download as CSV" icon={FileDownload} onClick={exportToCsv} /> )} {!isEmptyTable && ( <ToolbarAction label="Search" icon={Search} onClick={() => { if (!showSearch) { setIsFocused(true) setShowSearch(true) } else { setShowSearch(false) } clearTooltip() }} /> )} </Toolbar> <Resizable data-testid="stDataFrameResizable" ref={resizableRef} defaultSize={resizableSize} style={{ border: `${gridTheme.tableBorderWidth}px solid ${gridTheme.glideTheme.borderColor}`, borderRadius: `${gridTheme.tableBorderRadius}`, }} minHeight={minHeight} maxHeight={maxHeight} minWidth={minWidth} // The maxWidth is not calculated correctly for content width // dataframes in horizontal layouts, so it is disabled. The // resize handles are also disabled so that the dataframe cannot be // stretched beyond the container width. maxWidth={disableResize ? undefined : maxWidth} size={resizableSize} enable={{ top: false, right: false, bottom: false, left: false, topRight: false, bottomRight: disableResize ? false : true, bottomLeft: false, topLeft: false, }} grid={[1, rowHeight]} snapGap={rowHeight / 3} onResizeStop={(_event, _direction, _ref, _delta) => { if (resizableRef.current) { const borderThreshold = 2 * gridTheme.tableBorderWidth setResizableSize({ width: resizableRef.current.size.width, height: // Add additional pixels if it is stretched to full width // to allow the full cell border to be visible maxHeight - resizableRef.current.size.height === borderThreshold ? resizableRef.current.size.height + borderThreshold : resizableRef.current.size.height, }) } }} > <GlideDataEditor // The className is used in styled components: className="stDataFrameGlideDataEditor" data-testid="stDataFrameGlideDataEditor" ref={dataEditorRef} columns={glideColumns} rows={isEmptyTable ? 1 : numRows} minColumnWidth={gridTheme.minColumnWidth} maxColumnWidth={gridTheme.maxColumnWidth} maxColumnAutoWidth={gridTheme.maxColumnAutoWidth} rowHeight={rowHeight} headerHeight={gridTheme.defaultHeaderHeight} getCellContent={isEmptyTable ? getEmptyStateContent : getCellContent} onColumnResize={isTouchDevice ? undefined : onColumnResize} // Configure resize indicator to only show on the header: resizeIndicator={"header"} // Freeze all index columns: freezeColumns={freezeColumns} smoothScrollX={true} smoothScrollY={true} // Show borders between cells: verticalBorder={true} // Activate copy to clipboard functionality: getCellsForSelection={true} // Deactivate row markers and numbers: rowMarkers={"none"} // Deactivate selections: rangeSelect={isTouchDevice ? "cell" : "rect"} columnSelect={"none"} rowSelect={"none"} // Enable interactive column reordering: onColumnMoved={ // Column selection is not compatible with column reordering. isColumnSelectionActivated ? undefined : onColumnMoved } // Enable tooltips and row hovering theme on hover of a cell or column header: onItemHovered={(args: GridMouseEventArgs) => { handleRowHover?.(args) handleTooltips?.(args) }} // Activate keybindings: keybindings={{ downFill: true, ...(isCellSelectionActivated || isLargeTable ? { // Deactivate select all to prevent potential performance issues // with too many selected cells being processed for cell selection: selectAll: false, } : {}), }} // Search needs to be activated manually, to support search // via the toolbar: onKeyDown={event => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { setShowSearch(cv => !cv) event.stopPropagation() event.preventDefault() } }} showSearch={showSearch} searchResults={!showSearch ? [] : undefined} onSearchClose={() => { setShowSearch(false) clearTooltip() }} // Header click is used for column sorting: onHeaderClicked={(columnIdx: number, _event) => { if (!isSortingEnabled || isColumnSelectionActivated) { // Deactivate sorting for empty state, for large dataframes, or // when column selection is activated. return } // Hide search before sorting to clear search results if (showSearch) { setShowSearch(false) } if (isRowSelectionActivated && isRowSelected) { // Keeping row selections when sorting columns is not supported at the moment. // So we need to clear the selection before we do the sorting. // The reason is that the user would expect the selection to be kept on // the same row after sorting, hover that would require us to map the selection // to the new index of the selected row which adds complexity. clearSelection() } else { // Cell selection are kept on the old position, // which can be confusing. So we clear all cell selections before sorting. clearSelection(true, true) } sortColumn(columnIdx, "auto") }} gridSelection={gridSelection} // We don't have to react to "onSelectionCleared" since // we already correctly process selections in // the "onGridSelectionChange" callback. onGridSelectionChange={(newSelection: GridSelection) => { // Only allow selection changes if the grid is focused. // This is mainly done because there is a bug when overlay click actions // are outside of the bounds of the table (e.g. select dropdown or date picker). // This results in the first cell being selected for a short period of time // But for touch devices, preventing this can cause issues to select cells. // So we allow selection changes for touch devices even when it is not focused. if (isFocused || isTouchDevice) { processSelectionChange(newSelection) if (tooltip !== undefined) { // Remove the tooltip on every grid selection change: clearTooltip() } // Close menus: setShowMenu(undefined) setShowColumnVisibilityMenu(false) } }} theme={gridTheme.glideTheme} getRowThemeOverride={getRowThemeOverride} onMouseMove={(args: GridMouseEventArgs) => { // Determine if the dataframe is focused or not if (args.kind === "out-of-bounds" && isFocused) { setIsFocused(false) } else if (args.kind !== "out-of-bounds" && !isFocused) { setIsFocused(true) } }} // Add shadow for index columns and header on scroll: fixedShadowX={true} fixedShadowY={true} experimental={{ // Deactivate the native scrollbar override to optimize our // scrollbars to always behave like overlay scrollbars. scrollbarWidthOverride: 0, // Add negative padding to the right and bottom to allow the scrollbars // to overlay the table: paddingBottom: hasHorizontalScroll ? -scrollbarGutterSize : undefined, paddingRight: hasVerticalScroll ? -scrollbarGutterSize : undefined, }} provideEditor={provideEditor} // Apply custom rendering (e.g. for missing or required cells): drawCell={drawCell} // Add support for additional cells: customRenderers={customRenderers} // Custom image editor to render single images: imageEditorOverride={ImageCellEditor} // Add our custom SVG header icons: headerIcons={gridTheme.headerIcons} // Add support for user input validation: validateCell={validateCell} // Open column context menu: onHeaderMenuClick={(columnIdx, screenPosition) => { // There is an issue that clicking on the column visibility menu // can trigger a menu click event on the column header. // To prevent another menu from opening, we check if column // visibility menu open state. // https://github.com/streamlit/streamlit/pull/12233 if (!showColumnVisibilityMenu) { setShowMenu({ columnIdx, headerBounds: screenPosition, }) } }} // The default setup is read only, and therefore we deactivate paste here: onPaste={false} // Activate features required for row selection: {...(isRowSelectionActivated && { rowMarkers: { // Apply style settings for the row markers column: kind: "checkbox-visible", checkboxStyle: "square", theme: { bgCell: gridTheme.glideTheme.bgHeader, bgCellMedium: gridTheme.glideTheme.bgHeader, // Use a lighter color for the checkboxes in the row markers column, // otherwise its a bit too prominent: textMedium: gridTheme.glideTheme.textLight, }, }, rowSelectionMode: isMultiRowSelectionActivated ? "multi" : "auto", rowSelect: disabled ? "none" : isMultiRowSelectionActivated ? "multi" : "single", rowSelectionBlending: "additive", rangeSelectionBlending: "additive", })} // Activate features required for column selection: {...(isColumnSelectionActivated && { columnSelect: disabled ? "none" : isMultiColumnSelectionActivated ? "multi" : "single", columnSelectionBlending: "additive", columnSelectionMode: isMultiColumnSelectionActivated ? "multi" : "auto", rangeSelectionBlending: "additive", })} // Activate features required for cell selection: {...(isCellSelectionActivated && { rangeSelect: isMultiCellSelectionActivated ? "rect" : "cell", // Allow mixing cell selections with row and column selections: rangeSelectionBlending: "additive", })} // If element is editable, enable editing features: {...(!isEmptyTable && element.editingMode !== READ_ONLY && !disabled && { // Support fill handle for bulk editing: fillHandle: !isTouchDevice, // Support editing: onCellEdited, // Support pasting data for bulk editing: onPaste, // Support deleting cells & rows: onDelete, })} // If element allows adding rows (DYNAMIC or ADD_ONLY), enable trailing row // and deactivate sorting: {...(canAddRows && { trailingRowOptions: { sticky: false, tint: true, }, onRowAppended, // Deactivate sorting for modes that allow adding rows: onHeaderClicked: undefined, })} // If element allows deleting rows (DYNAMIC or DELETE_ONLY), enable row selection: {...(canDeleteRows && { rowMarkers: { kind: "checkbox", checkboxStyle: "square", theme: { bgCell: gridTheme.glideTheme.bgHeader, bgCellMedium: gridTheme.glideTheme.bgHeader, }, }, rowSelectionMode: "multi", rowSelect: disabled ? "none" : "multi", })} /> </Resizable> {tooltip?.content && ( <Tooltip top={tooltip.top} left={tooltip.left} content={tooltip.content} clearTooltip={clearTooltip} ></Tooltip> )} {showMenu && createPortal( // A context menu that provides interactive features (sorting, pinning, show/hide) // for a grid column. <ColumnMenu top={showMenu.headerBounds.y + showMenu.headerBounds.height} left={showMenu.headerBounds.x + showMenu.headerBounds.width} column={originalColumns[showMenu.columnIdx]} onCloseMenu={() => setShowMenu(undefined)} onSortColumn={ isSortingEnabled ? (direction: "asc" | "desc" | undefined) => { // Hide search before sorting to clear search results if (showSearch) { setShowSearch(false) } if (isRowSelectionActivated && isRowSelected) { // Keeping row selections when sorting columns is not supported at the moment. // So we need to clear the selected rows before we do the sorting (Issue #11345). // Maintain column selections as these are not impacted. clearSelection(false, true) } else { // Cell selection are kept on the old position, // which can be confusing. So we clear all cell selections before sorting. clearSelection(true, true) } sortColumn(showMenu.columnIdx, direction, true) } : undefined } isColumnPinned={originalColumns[showMenu.columnIdx].isPinned} onUnpinColumn={() => { unpinColumn(originalColumns[showMenu.columnIdx].id) }} onPinColumn={() => { pinColumn(originalColumns[showMenu.columnIdx].id) }} onHideColumn={() => { hideColumn(originalColumns[showMenu.columnIdx].id) }} onChangeFormat={(format: string) => { changeColumnFormat( originalColumns[showMenu.columnIdx].id, format ) // After changing the format, remeasure the column to ensure that // the column width is updated to the new format. // We need to apply a short timeout here to ensure that // the column format already has been fully applied to all cells // before we remeasure the column. setTimeout(() => { dataEditorRef.current?.remeasureColumns( CompactSelection.fromSingleSelection(showMenu.columnIdx) ) }, 100) }} onAutosize={() => { dataEditorRef.current?.remeasureColumns( CompactSelection.fromSingleSelection(showMenu.columnIdx) ) }} />, // We put the column menu into the portal element which is also // used for the cell overlays. This allows us to correctly position // the column menu also when the grid is used in a dialog, popover, // or anything else that apply a transform (position fixed is influenced // by the transform property of the parent element). // The portal element is expected to always exist (-> PortalProvider). document.querySelector("#portal") as HTMLElement )} </StyledResizableContainer> ) } const DataFrameWithFullscreen = withFullScreenWrapper(DataFrame) export default memo(DataFrameWithFullscreen)