1010 * governing permissions and limitations under the License.
1111 */
1212
13+ import { ActionButton , ActionButtonContext } from './ActionButton' ;
1314import { baseColor , colorMix , focusRing , fontRelative , lightDark , space , style } from '../style' with { type : 'macro' } ;
1415import {
1516 Button ,
17+ ButtonContext ,
1618 CellRenderProps ,
1719 Collection ,
1820 ColumnRenderProps ,
1921 ColumnResizer ,
2022 ContextValue ,
23+ DEFAULT_SLOT ,
24+ Form ,
2125 Key ,
26+ OverlayTriggerStateContext ,
2227 Provider ,
2328 Cell as RACCell ,
2429 CellProps as RACCellProps ,
2530 CheckboxContext as RACCheckboxContext ,
2631 Column as RACColumn ,
2732 ColumnProps as RACColumnProps ,
33+ Popover as RACPopover ,
2834 Row as RACRow ,
2935 RowProps as RACRowProps ,
3036 Table as RACTable ,
@@ -44,11 +50,16 @@ import {
4450 useTableOptions ,
4551 Virtualizer
4652} from 'react-aria-components' ;
47- import { centerPadding , controlFont , getAllowedOverrides , StylesPropWithHeight , UnsafeStyles } from './style-utils' with { type : 'macro' } ;
53+ import { ButtonGroup } from './ButtonGroup' ;
54+ import { centerPadding , colorScheme , controlFont , getAllowedOverrides , StylesPropWithHeight , UnsafeStyles } from './style-utils' with { type : 'macro' } ;
4855import { Checkbox } from './Checkbox' ;
56+ import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg' ;
4957import Chevron from '../ui-icons/Chevron' ;
58+ import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg' ;
5059import { ColumnSize } from '@react-types/table' ;
60+ import { CustomDialog , DialogContainer } from '..' ;
5161import { DOMRef , DOMRefValue , forwardRefType , GlobalDOMAttributes , LoadingState , Node } from '@react-types/shared' ;
62+ import { getActiveElement , getOwnerDocument , useLayoutEffect , useObjectRef } from '@react-aria/utils' ;
5263import { GridNode } from '@react-types/grid' ;
5364import { IconContext } from './Icon' ;
5465// @ts -ignore
@@ -58,11 +69,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
5869import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg' ;
5970import { ProgressCircle } from './ProgressCircle' ;
6071import { raw } from '../style/style-macro' with { type : 'macro' } ;
61- import React , { createContext , forwardRef , ReactElement , ReactNode , useCallback , useContext , useMemo , useRef , useState } from 'react' ;
72+ import React , { createContext , CSSProperties , FormEvent , FormHTMLAttributes , ForwardedRef , forwardRef , ReactElement , ReactNode , RefObject , useCallback , useContext , useEffect , useMemo , useRef , useState } from 'react' ;
6273import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg' ;
6374import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg' ;
75+ import { Button as SpectrumButton } from './Button' ;
6476import { useActionBarContainer } from './ActionBar' ;
65- import { useDOMRef } from '@react-spectrum/utils' ;
77+ import { useDOMRef , useMediaQuery } from '@react-spectrum/utils' ;
6678import { useLocalizedStringFormatter } from '@react-aria/i18n' ;
6779import { useScale } from './utils' ;
6880import { useSpectrumContextProps } from './useSpectrumContextProps' ;
@@ -1047,6 +1059,308 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
10471059 ) ;
10481060} ) ;
10491061
1062+
1063+ const editableCell = style < CellRenderProps & S2TableProps & { isDivider : boolean , selectionMode ?: 'none' | 'single' | 'multiple' , isSaving ?: boolean } > ( {
1064+ ...commonCellStyles ,
1065+ color : {
1066+ default : baseColor ( 'neutral' ) ,
1067+ isSaving : baseColor ( 'neutral-subdued' )
1068+ } ,
1069+ paddingY : centerPadding ( ) ,
1070+ boxSizing : 'border-box' ,
1071+ height : 'calc(100% - 1px)' , // so we don't overlap the border of the next cell
1072+ width : 'full' ,
1073+ fontSize : controlFont ( ) ,
1074+ alignItems : 'center' ,
1075+ display : 'flex' ,
1076+ borderStyle : {
1077+ default : 'none' ,
1078+ isDivider : 'solid'
1079+ } ,
1080+ borderEndWidth : {
1081+ default : 0 ,
1082+ isDivider : 1
1083+ } ,
1084+ borderColor : {
1085+ default : 'gray-300' ,
1086+ forcedColors : 'ButtonBorder'
1087+ }
1088+ } ) ;
1089+
1090+ let editPopover = style ( {
1091+ ...colorScheme ( ) ,
1092+ '--s2-container-bg' : {
1093+ type : 'backgroundColor' ,
1094+ value : 'layer-2'
1095+ } ,
1096+ backgroundColor : '--s2-container-bg' ,
1097+ borderBottomRadius : 'default' ,
1098+ // Use box-shadow instead of filter when an arrow is not shown.
1099+ // This fixes the shadow stacking problem with submenus.
1100+ boxShadow : 'elevated' ,
1101+ borderStyle : 'solid' ,
1102+ borderWidth : 1 ,
1103+ borderColor : {
1104+ default : 'gray-200' ,
1105+ forcedColors : 'ButtonBorder'
1106+ } ,
1107+ boxSizing : 'content-box' ,
1108+ isolation : 'isolate' ,
1109+ pointerEvents : {
1110+ isExiting : 'none'
1111+ } ,
1112+ outlineStyle : 'none' ,
1113+ minWidth : '--trigger-width' ,
1114+ padding : 8 ,
1115+ display : 'flex' ,
1116+ alignItems : 'center'
1117+ } , getAllowedOverrides ( ) ) ;
1118+
1119+ interface EditableCellProps extends Omit < CellProps , 'isSticky' > {
1120+ /** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */
1121+ renderEditing : ( ) => ReactNode ,
1122+ /** Whether the cell is currently being saved. */
1123+ isSaving ?: boolean ,
1124+ /** Handler that is called when the value has been changed and is ready to be saved. */
1125+ onSubmit ?: ( e : FormEvent < HTMLFormElement > ) => void ,
1126+ /** Handler that is called when the user cancels the edit. */
1127+ onCancel ?: ( ) => void ,
1128+ /** The action to submit the form to. Only available in React 19+. */
1129+ action ?: string | FormHTMLAttributes < HTMLFormElement > [ 'action' ]
1130+ }
1131+
1132+ /**
1133+ * An editable cell within a table row.
1134+ */
1135+ export const EditableCell = forwardRef ( function EditableCell ( props : EditableCellProps , ref : ForwardedRef < HTMLDivElement > ) {
1136+ let { children, showDivider = false , textValue, isSaving, ...otherProps } = props ;
1137+ let tableVisualOptions = useContext ( InternalTableContext ) ;
1138+ let domRef = useObjectRef ( ref ) ;
1139+ textValue ||= typeof children === 'string' ? children : undefined ;
1140+
1141+ return (
1142+ < RACCell
1143+ ref = { domRef }
1144+ className = { renderProps => editableCell ( {
1145+ ...renderProps ,
1146+ ...tableVisualOptions ,
1147+ isDivider : showDivider ,
1148+ isSaving
1149+ } ) }
1150+ textValue = { textValue }
1151+ { ...otherProps } >
1152+ { ( { isFocusVisible} ) => (
1153+ < EditableCellInner { ...props } isFocusVisible = { isFocusVisible } cellRef = { domRef as RefObject < HTMLDivElement > } />
1154+ ) }
1155+ </ RACCell >
1156+ ) ;
1157+ } ) ;
1158+
1159+ const nonTextInputTypes = new Set ( [
1160+ 'checkbox' ,
1161+ 'radio' ,
1162+ 'range' ,
1163+ 'color' ,
1164+ 'file' ,
1165+ 'image' ,
1166+ 'button' ,
1167+ 'submit' ,
1168+ 'reset'
1169+ ] ) ;
1170+
1171+ function EditableCellInner ( props : EditableCellProps & { isFocusVisible : boolean , cellRef : RefObject < HTMLDivElement > } ) {
1172+ let { children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props ;
1173+ let [ isOpen , setIsOpen ] = useState ( false ) ;
1174+ let popoverRef = useRef < HTMLDivElement > ( null ) ;
1175+ let formRef = useRef < HTMLFormElement > ( null ) ;
1176+ let [ triggerWidth , setTriggerWidth ] = useState ( 0 ) ;
1177+ let [ tableWidth , setTableWidth ] = useState ( 0 ) ;
1178+ let [ verticalOffset , setVerticalOffset ] = useState ( 0 ) ;
1179+ let tableVisualOptions = useContext ( InternalTableContext ) ;
1180+ let stringFormatter = useLocalizedStringFormatter ( intlMessages , '@react-spectrum/s2' ) ;
1181+ let dialogRef = useRef < DOMRefValue < HTMLElement > > ( null ) ;
1182+
1183+ let { density} = useContext ( InternalTableContext ) ;
1184+ let size : 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M' ;
1185+ if ( density === 'compact' ) {
1186+ size = 'S' ;
1187+ } else if ( density === 'spacious' ) {
1188+ size = 'L' ;
1189+ }
1190+
1191+ // Popover positioning
1192+ useLayoutEffect ( ( ) => {
1193+ if ( ! isOpen ) {
1194+ return ;
1195+ }
1196+ let width = cellRef . current ?. clientWidth || 0 ;
1197+ let cell = cellRef . current ;
1198+ let boundingRect = cell ?. parentElement ?. getBoundingClientRect ( ) ;
1199+ let verticalOffset = ( boundingRect ?. top ?? 0 ) - ( boundingRect ?. bottom ?? 0 ) ;
1200+
1201+ let tableWidth = cellRef . current ?. closest ( '[role="grid"]' ) ?. clientWidth || 0 ;
1202+ setTriggerWidth ( width ) ;
1203+ setVerticalOffset ( verticalOffset ) ;
1204+ setTableWidth ( tableWidth ) ;
1205+ } , [ cellRef , density , isOpen ] ) ;
1206+
1207+ // Auto select the entire text range of the autofocused input on overlay opening
1208+ // Maybe replace with FocusScope or one of those utilities
1209+ useEffect ( ( ) => {
1210+ if ( isOpen ) {
1211+ let activeElement = getActiveElement ( getOwnerDocument ( formRef . current ) ) ;
1212+ if ( activeElement
1213+ && formRef . current ?. contains ( activeElement )
1214+ // not going to handle contenteditable https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element
1215+ // seems like an edge case anyways
1216+ && (
1217+ ( activeElement instanceof HTMLInputElement && ! nonTextInputTypes . has ( activeElement . type ) )
1218+ || activeElement instanceof HTMLTextAreaElement )
1219+ && typeof activeElement . select === 'function' ) {
1220+ activeElement . select ( ) ;
1221+ }
1222+ }
1223+ } , [ isOpen ] ) ;
1224+
1225+ let cancel = useCallback ( ( ) => {
1226+ setIsOpen ( false ) ;
1227+ onCancel ?.( ) ;
1228+ } , [ onCancel ] ) ;
1229+
1230+ let isMobile = ! useMediaQuery ( '(hover: hover) and (pointer: fine)' ) ;
1231+ // Can't differentiate between Dialog click outside dismissal and Escape key dismissal
1232+ let prevIsOpen = useRef ( isOpen ) ;
1233+ useEffect ( ( ) => {
1234+ let dialog = dialogRef . current ?. UNSAFE_getDOMNode ( ) ;
1235+ if ( isOpen && dialog && ! prevIsOpen . current ) {
1236+ let handler = ( e : KeyboardEvent ) => {
1237+ if ( e . key === 'Escape' ) {
1238+ cancel ( ) ;
1239+ e . stopPropagation ( ) ;
1240+ e . preventDefault ( ) ;
1241+ }
1242+ } ;
1243+ dialog . addEventListener ( 'keydown' , handler ) ;
1244+ prevIsOpen . current = isOpen ;
1245+ return ( ) => {
1246+ dialog . removeEventListener ( 'keydown' , handler ) ;
1247+ } ;
1248+ }
1249+ prevIsOpen . current = isOpen ;
1250+ } , [ isOpen , cancel ] ) ;
1251+
1252+ return (
1253+ < Provider
1254+ values = { [
1255+ [ ButtonContext , null ] ,
1256+ [ ActionButtonContext , {
1257+ slots : {
1258+ [ DEFAULT_SLOT ] : { } ,
1259+ edit : {
1260+ onPress : ( ) => setIsOpen ( true ) ,
1261+ isPending : isSaving ,
1262+ isQuiet : ! isSaving ,
1263+ size,
1264+ excludeFromTabOrder : true ,
1265+ styles : style ( {
1266+ // TODO: really need access to display here instead, but not possible right now
1267+ // will be addressable with displayOuter
1268+ // Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment
1269+ visibility : {
1270+ default : 'hidden' ,
1271+ isForcedVisible : 'visible' ,
1272+ ':is([role="row"]:hover *)' : 'visible' ,
1273+ ':is([role="row"][data-focus-visible-within] *)' : 'visible' ,
1274+ '@media not ((hover: hover) and (pointer: fine))' : 'visible'
1275+ }
1276+ } ) ( { isForcedVisible : isOpen || ! ! isSaving } )
1277+ }
1278+ }
1279+ } ]
1280+ ] } >
1281+ < span className = { cellContent ( { ...tableVisualOptions , align : align || 'start' } ) } > { children } </ span >
1282+ { isFocusVisible && < CellFocusRing /> }
1283+
1284+ < Provider
1285+ values = { [
1286+ [ ActionButtonContext , null ]
1287+ ] } >
1288+ { ! isMobile && (
1289+ < RACPopover
1290+ isOpen = { isOpen }
1291+ onOpenChange = { setIsOpen }
1292+ ref = { popoverRef }
1293+ shouldCloseOnInteractOutside = { ( ) => {
1294+ if ( ! popoverRef . current ?. contains ( document . activeElement ) ) {
1295+ return false ;
1296+ }
1297+ formRef . current ?. requestSubmit ( ) ;
1298+ return false ;
1299+ } }
1300+ triggerRef = { cellRef }
1301+ aria-label = { props [ 'aria-label' ] ?? stringFormatter . format ( 'table.editCell' ) }
1302+ offset = { verticalOffset }
1303+ placement = "bottom start"
1304+ style = { {
1305+ minWidth : `min(${ triggerWidth } px, ${ tableWidth } px)` ,
1306+ maxWidth : `${ tableWidth } px` ,
1307+ // Override default z-index from useOverlayPosition. We use isolation: isolate instead.
1308+ zIndex : undefined
1309+ } }
1310+ className = { editPopover } >
1311+ < Provider
1312+ values = { [
1313+ [ OverlayTriggerStateContext , null ]
1314+ ] } >
1315+ < Form
1316+ ref = { formRef }
1317+ action = { action }
1318+ onSubmit = { ( e ) => {
1319+ onSubmit ?.( e ) ;
1320+ setIsOpen ( false ) ;
1321+ } }
1322+ className = { style ( { width : 'full' , display : 'flex' , alignItems : 'start' , gap : 16 } ) }
1323+ style = { { '--input-width' : `calc(${ triggerWidth } px - 32px)` } as CSSProperties } >
1324+ { renderEditing ( ) }
1325+ < div className = { style ( { display : 'flex' , flexDirection : 'row' , alignItems : 'baseline' , flexShrink : 0 , flexGrow : 0 } ) } >
1326+ < ActionButton isQuiet onPress = { cancel } aria-label = { stringFormatter . format ( 'table.cancel' ) } > < Close /> </ ActionButton >
1327+ < ActionButton isQuiet type = "submit" aria-label = { stringFormatter . format ( 'table.save' ) } > < Checkmark /> </ ActionButton >
1328+ </ div >
1329+ </ Form >
1330+ </ Provider >
1331+ </ RACPopover >
1332+ ) }
1333+ { isMobile && (
1334+ < DialogContainer onDismiss = { ( ) => formRef . current ?. requestSubmit ( ) } >
1335+ { isOpen && (
1336+ < CustomDialog
1337+ ref = { dialogRef }
1338+ isDismissible
1339+ isKeyboardDismissDisabled
1340+ aria-label = { props [ 'aria-label' ] ?? stringFormatter . format ( 'table.editCell' ) } >
1341+ < Form
1342+ ref = { formRef }
1343+ action = { action }
1344+ onSubmit = { ( e ) => {
1345+ onSubmit ?.( e ) ;
1346+ setIsOpen ( false ) ;
1347+ } }
1348+ className = { style ( { width : 'full' , display : 'flex' , flexDirection : 'column' , alignItems : 'start' , gap : 16 } ) } >
1349+ { renderEditing ( ) }
1350+ < ButtonGroup align = "end" styles = { style ( { alignSelf : 'end' } ) } >
1351+ < SpectrumButton onPress = { cancel } variant = "secondary" fillStyle = "outline" > Cancel</ SpectrumButton >
1352+ < SpectrumButton type = "submit" variant = "accent" > Save</ SpectrumButton >
1353+ </ ButtonGroup >
1354+ </ Form >
1355+ </ CustomDialog >
1356+ ) }
1357+ </ DialogContainer >
1358+ ) }
1359+ </ Provider >
1360+ </ Provider >
1361+ ) ;
1362+ } ;
1363+
10501364// Use color-mix instead of transparency so sticky cells work correctly.
10511365const selectedBackground = lightDark ( colorMix ( 'gray-25' , 'informative-900' , 10 ) , colorMix ( 'gray-25' , 'informative-700' , 10 ) ) ;
10521366const selectedActiveBackground = lightDark ( colorMix ( 'gray-25' , 'informative-900' , 15 ) , colorMix ( 'gray-25' , 'informative-700' , 15 ) ) ;
0 commit comments