Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
957f321
table inline editing
snowystinger Aug 21, 2025
097ba50
remove extra exports
snowystinger Aug 21, 2025
98e8d84
Add extra controls for different interactions, mobile, inline save, i…
snowystinger Aug 25, 2025
7ea9be0
fix lint
snowystinger Aug 25, 2025
f447491
Add fake saving logic
snowystinger Aug 25, 2025
59bea14
Merge branch 'main' into inline-table-editing
snowystinger Aug 25, 2025
0bff5e3
use better color and fix flex grow
snowystinger Aug 25, 2025
feb8a96
add back hiding logic
snowystinger Aug 25, 2025
9c6e6f5
simplify fake save logic
snowystinger Aug 25, 2025
337b809
Merge branch 'main' into inline-table-editing
snowystinger Aug 27, 2025
6be0394
set boundary element of the table, design updates
snowystinger Aug 27, 2025
a887e36
Add picker, restore focus to cell when trigger is hidden, converge im…
snowystinger Aug 28, 2025
150efc7
fix lint and small screen rendering
snowystinger Aug 28, 2025
a471192
Change editable cell hover color when row is hovered
snowystinger Sep 1, 2025
7ef763a
invert hover color for non-selection
snowystinger Sep 1, 2025
bffae5a
use a pending action button and change background cell color for hover
snowystinger Sep 1, 2025
7dd5a30
fix lint
snowystinger Sep 1, 2025
3b478af
Merge branch 'main' into inline-table-editing
snowystinger Sep 1, 2025
65c4e61
fix density, pending is disabled, some of cell sizing
snowystinger Sep 2, 2025
9679410
Add bulk edit bar
snowystinger Sep 2, 2025
2a80b8b
fix lint
snowystinger Sep 2, 2025
b02b164
add "More" actions and remove actionbar bulk actions
snowystinger Sep 4, 2025
b2e8d7e
fix rendering
snowystinger Sep 4, 2025
b56a69a
Add other components so we know if there's anything else
snowystinger Sep 18, 2025
308a5ec
simplify code and example
snowystinger Sep 19, 2025
e2dfc1c
Merge branch 'main' into inline-table-editing
snowystinger Sep 19, 2025
41dbd4b
add comments and fix types
snowystinger Sep 19, 2025
bc6c359
Make picker nice
snowystinger Sep 19, 2025
b9d2f2e
fix lint
snowystinger Sep 19, 2025
745afcb
Merge branch 'main' into inline-table-editing
snowystinger Oct 1, 2025
1f641a4
Implement our actual component and start tests
snowystinger Oct 1, 2025
ec72958
add default slot so that people can add other buttons to the row
snowystinger Oct 1, 2025
70a6336
fix lint
snowystinger Oct 1, 2025
08623b2
Merge branch 'main' into inline-table-editing
snowystinger Oct 2, 2025
871bcda
add tests
snowystinger Oct 2, 2025
f8ad4f7
fix lint
snowystinger Oct 2, 2025
bcb7c0e
review updates
snowystinger Oct 2, 2025
b1e2303
fix tab behaviour
snowystinger Oct 2, 2025
bd79935
add tab tests
snowystinger Oct 2, 2025
ad83b85
Merge branch 'main' into inline-table-editing
snowystinger Oct 3, 2025
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 packages/@react-aria/overlays/src/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
export function getRect(node: Element, ignoreScale: boolean) {
let {top, left, width, height} = node.getBoundingClientRect();

// Use offsetWidth and offsetHeight if this is an HTML element, so that
// Use offsetWidth and offsetHeight if this is an HTML element, so that
// the size is not affected by scale transforms.
if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) {
width = node.offsetWidth;
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-spectrum/s2/intl/ar-AE.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
"picker.selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}",
"slider.maximum": "أقصى",
"slider.minimum": "أدنى",
"table.cancel": "إلغاء",
"table.editCell": "تعديل الخلية",
"table.loading": "جارٍ التحميل...",
"table.loadingMore": "جارٍ تحميل المزيد...",
"table.resizeColumn": "تغيير حجم العمود",
"table.save": "حفظ",
"table.sortAscending": "فرز بترتيب تصاعدي",
"table.sortDescending": "فرز بترتيب تنازلي",
"tag.actions": "الإجراءات",
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-spectrum/s2/intl/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
"picker.selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}",
"slider.maximum": "Maximum",
"slider.minimum": "Minimum",
"table.cancel": "Cancel",
"table.editCell": "Edit cell",
"table.loading": "Loading…",
"table.loadingMore": "Loading more…",
"table.resizeColumn": "Resize column",
"table.save": "Save",
"table.sortAscending": "Sort Ascending",
"table.sortDescending": "Sort Descending",
"tag.actions": "Actions",
Expand Down
200 changes: 198 additions & 2 deletions packages/@react-spectrum/s2/src/TableView.tsx
Copy link
Member

Choose a reason for hiding this comment

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

one thing I noticed when testing is that tabbing through the table will move you to the edit buttons instead of treating the table as a single tab stop like it does for v3 table, not quite sure why. Note that this specifically happens when selection is enabled: https://reactspectrum.blob.core.windows.net/reactspectrum/f8ad4f7b2000787617b3b6097af722ecf8b5679f/storybook-s2/index.html?path=/story/tableview--editable-table&args=selectionMode:multiple

Copy link
Member Author

Choose a reason for hiding this comment

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

hmmm odd, i'll have a look

Copy link
Member Author

Choose a reason for hiding this comment

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

o, i see
lastChild is the last checkbox, which is in a row, we focus it without scrolling, which makes the edit button visible
then we let tab naturally move us to the next element, which is the now visible edit button...

I think i've fixed it by applying excludeFromTabOrder since the collection should handle moving to and from it

Copy link
Member Author

Choose a reason for hiding this comment

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

added tests as well

Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@
* governing permissions and limitations under the License.
*/

import {ActionButton, ActionButtonContext} from './ActionButton';
import {baseColor, colorMix, focusRing, fontRelative, lightDark, space, style} from '../style' with {type: 'macro'};
import {
Button,
ButtonContext,
CellRenderProps,
Collection,
ColumnRenderProps,
ColumnResizer,
ContextValue,
DEFAULT_SLOT,
Form,
Key,
OverlayTriggerStateContext,
Provider,
Cell as RACCell,
CellProps as RACCellProps,
CheckboxContext as RACCheckboxContext,
Column as RACColumn,
ColumnProps as RACColumnProps,
Popover as RACPopover,
Row as RACRow,
RowProps as RACRowProps,
Table as RACTable,
Expand All @@ -44,9 +50,11 @@ import {
useTableOptions,
Virtualizer
} from 'react-aria-components';
import {centerPadding, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
import {Checkbox} from './Checkbox';
import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg';
import Chevron from '../ui-icons/Chevron';
import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
import {ColumnSize} from '@react-types/table';
import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared';
import {GridNode} from '@react-types/grid';
Expand All @@ -58,11 +66,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
import {ProgressCircle} from './ProgressCircle';
import {raw} from '../style/style-macro' with {type: 'macro'};
import React, {createContext, forwardRef, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo, useRef, useState} from 'react';
import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg';
import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg';
import {useActionBarContainer} from './ActionBar';
import {useDOMRef} from '@react-spectrum/utils';
import {useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {useScale} from './utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';
Expand Down Expand Up @@ -1044,6 +1053,193 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef<HTMLD
);
});

let editPopover = style({
...colorScheme(),
'--s2-container-bg': {
type: 'backgroundColor',
value: 'layer-2'
},
backgroundColor: '--s2-container-bg',
borderBottomRadius: 'default',
// Use box-shadow instead of filter when an arrow is not shown.
// This fixes the shadow stacking problem with submenus.
boxShadow: 'elevated',
borderStyle: 'solid',
borderWidth: 1,
borderColor: {
default: 'gray-200',
forcedColors: 'ButtonBorder'
},
boxSizing: 'content-box',
isolation: 'isolate',
pointerEvents: {
isExiting: 'none'
},
outlineStyle: 'none',
minWidth: '--trigger-width',
padding: 8,
display: 'flex',
alignItems: 'center'
}, getAllowedOverrides());

interface EditableCellProps extends Omit<CellProps, 'isSticky'> {
renderEditing: () => ReactNode,
isSaving?: boolean,
onSubmit: () => void,
onCancel: () => void
}

/**
* An exditable cell within a table row.
*/
export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef<HTMLDivElement>) {
let {children, showDivider = false, textValue, ...otherProps} = props;
let tableVisualOptions = useContext(InternalTableContext);
let domRef = useObjectRef(ref);
textValue ||= typeof children === 'string' ? children : undefined;

return (
<RACCell
ref={domRef}
className={renderProps => cell({
...renderProps,
...tableVisualOptions,
isDivider: showDivider
})}
textValue={textValue}
{...otherProps}>
{({isFocusVisible}) => (
<EditableCellInner {...props} isFocusVisible={isFocusVisible} cellRef={domRef as RefObject<HTMLDivElement>} />
)}
</RACCell>
);
});

function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject<HTMLDivElement>}) {
let {children, align, renderEditing, isSaving, onSubmit, onCancel, isFocusVisible, cellRef} = props;
let [isOpen, setIsOpen] = useState(false);
let popoverRef = useRef<HTMLDivElement>(null);
let formRef = useRef<HTMLFormElement>(null);
let [triggerWidth, setTriggerWidth] = useState(0);
let [tableWidth, setTableWidth] = useState(0);
let [verticalOffset, setVerticalOffset] = useState(0);
let tableVisualOptions = useContext(InternalTableContext);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');

let {density} = useContext(InternalTableContext);
let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M';
if (density === 'compact') {
size = 'S';
} else if (density === 'spacious') {
size = 'L';
}


// Popover positioning
useLayoutEffect(() => {
if (!isOpen) {
return;
}
let width = cellRef.current?.clientWidth || 0;
let cell = cellRef.current;
let boundingRect = cell?.parentElement?.getBoundingClientRect();
let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0);

let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0;
setTriggerWidth(width);
setVerticalOffset(verticalOffset);
setTableWidth(tableWidth);
}, [cellRef, density, isOpen]);

// Cancel, don't save the value
let cancel = () => {
setIsOpen(false);
onCancel();
};

return (
<Provider
values={[
[ButtonContext, null],
[ActionButtonContext, {
slots: {
[DEFAULT_SLOT]: {},
edit: {
onPress: () => setIsOpen(true),
isPending: isSaving,
isQuiet: !isSaving,
size,
excludeFromTabOrder: true,
styles: style({
Copy link
Member

Choose a reason for hiding this comment

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

nit: noticed that the edit button's focus ring is visible behind the checkbox:
image

Copy link
Member Author

Choose a reason for hiding this comment

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

probably ok? probably not common that people keyboard focus, then scroll without clicking somewhere

Copy link
Member

Choose a reason for hiding this comment

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

bit unfortunate that the button is sized/the row outlines are made in such a way that the top of the ring sits flush under the top row's border but the bottom of the ring sits on top of the border. Not too bothered by it, we can look into it as a followup

// TODO: really need access to display here instead, but not possible right now
// will be addressable with displayOuter
visibility: {
default: 'hidden',
isForcedVisible: 'visible',
':is([role="row"]:hover *)': 'visible',
':is([role="row"][data-focus-visible-within] *)': 'visible',
'@media not (any-pointer: fine)': 'visible'
}
})({isForcedVisible: isOpen || !!isSaving})
}
}
}]
]}>
<span className={cellContent({...tableVisualOptions, align: align || 'start'})}>{children}</span>
{isFocusVisible && <CellFocusRing />}

<Provider
values={[
[ActionButtonContext, null]
]}>
<RACPopover
isOpen={isOpen}
onOpenChange={setIsOpen}
ref={popoverRef}
shouldCloseOnInteractOutside={() => {
if (!popoverRef.current?.contains(document.activeElement)) {
return false;
}
formRef.current?.requestSubmit();
Copy link
Member

Choose a reason for hiding this comment

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

I might just be forgetting prior examples, but I feel like dismissing a popover via clicking outside shouldn't be a confirm/submit action but instead just a cancel action

Copy link
Member Author

Choose a reason for hiding this comment

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

Design specifically says this should confirm 🤷🏻

return false;
}}
triggerRef={cellRef}
aria-label={stringFormatter.format('table.editCell')}
offset={verticalOffset}
placement="bottom start"
style={{
minWidth: `min(${triggerWidth}px, ${tableWidth}px)`,
maxWidth: `${tableWidth}px`,
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
zIndex: undefined
}}
className={editPopover}>
<Provider
values={[
[OverlayTriggerStateContext, null]
]}>
<Form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
onSubmit();
setIsOpen(false);
}}
className={style({width: 'full', display: 'flex', alignItems: 'baseline', gap: 16})}
style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}>
{renderEditing()}
<div className={style({display: 'flex', flexDirection: 'row', alignItems: 'baseline', flexShrink: 0, flexGrow: 0})}>
<ActionButton isQuiet onPress={cancel} aria-label={stringFormatter.format('table.cancel')}><Close /></ActionButton>
<ActionButton isQuiet type="submit" aria-label={stringFormatter.format('table.save')}><Checkmark /></ActionButton>
</div>
</Form>
</Provider>
</RACPopover>
</Provider>
</Provider>
);
}

// Use color-mix instead of transparency so sticky cells work correctly.
const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10));
const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15));
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export {Skeleton, useIsSkeleton} from './Skeleton';
export {SkeletonCollection} from './SkeletonCollection';
export {StatusLight, StatusLightContext} from './StatusLight';
export {Switch, SwitchContext} from './Switch';
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} from './TableView';
export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from './TableView';
export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs';
export {TagGroup, Tag, TagGroupContext} from './TagGroup';
export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField';
Expand Down
Loading