Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: highlight editable cells on hover #1087

Merged
merged 15 commits into from
Dec 3, 2024
1 change: 1 addition & 0 deletions src/components/PresentationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PresentationFieldProps {
labelSuffix?: LabelSuffixStyle;
// Typically used for compact fields in a table. Removes border and uses an box-shadow for focus behavior
borderless?: boolean;
borderOnHover?: boolean;
0ces marked this conversation as resolved.
Show resolved Hide resolved
// Defines height of the field
compact?: boolean;
// Changes default font styles for input fields and Chips
Expand Down
113 changes: 112 additions & 1 deletion src/components/Table/GridTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
defaultStyle,
dragHandleColumn,
emptyCell,
getTableStyles,
GridCellAlignment,
GridColumn,
GridDataRow,
Expand All @@ -32,8 +33,9 @@ import {
useGridTableApi,
} from "src/components/index";
import { Css, Palette } from "src/Css";
import { jan1, jan2, jan29 } from "src/forms/formStateDomain";
import { useComputed } from "src/hooks";
import { SelectField } from "src/inputs";
import { DateField, SelectField } from "src/inputs";
import { NumberField } from "src/inputs/NumberField";
import { noop } from "src/utils";
import { newStory, withRouter, zeroTo } from "src/utils/sb";
Expand Down Expand Up @@ -2144,3 +2146,112 @@ export function MinColumnWidths() {
</div>
);
}

enum EditableRowStatus {
Active = "Active",
Inactive = "Inactive",
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this enum supposed to be actively changing the coloring/styling/behavior of the style, or is it just a dummy enum to show what a select field looks like in the table?

If it's just a dummy enum, can we make the enum values something like Foo = "Foo" and Bar = "Bar"` just to highlight their meaningless-ness?

}

type EditableRowData = {
kind: "data";
id: string;
data: { id: string; name: string; status: EditableRowStatus; value: number; date?: Date };
};
type EditableRow = EditableRowData | HeaderRow;

export function EditableRows() {
const [rows, setRows] = useState<GridDataRow<EditableRow>[]>([
simpleHeader,
{
kind: "data" as const,
id: "1",
data: { id: "1", name: "Tony Stark", status: EditableRowStatus.Active, value: 1, date: jan1 },
},
{
kind: "data" as const,
id: "2",
data: { id: "2", name: "Natasha Romanova", status: EditableRowStatus.Active, value: 2, date: jan2 },
},
{
kind: "data" as const,
id: "3",
data: { id: "3", name: "Thor Odinson", status: EditableRowStatus.Active, value: 3, date: jan29 },
},
]);

const handleCellChange = (rowId: string, field: keyof EditableRowData["data"], value: any) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is just a story, but can we rename this to setRow , which better describes what it does? Also would prefer a function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this story is a react component I'll change it to be a useCallback

setRows((rows) =>
rows.map((row) =>
row.kind === "data" && row.id === rowId ? { ...row, data: { ...row.data, [field]: value } } : row,
),
);
};

const nameColumn: GridColumn<EditableRow> = {
header: "Name",
data: ({ name }) => name,
};

const selectColumn: GridColumn<EditableRow> = {
header: "Status",
data: (row) => ({
content: (
<SelectField
label=""
options={Object.values(EditableRowStatus).map((status) => ({ label: status, code: status }))}
value={row.status}
onSelect={(status) => handleCellChange(row.id, "status", status)}
/>
),
}),
w: "120px",
};

const date1Column: GridColumn<EditableRow> = {
header: "Date",
data: (row) => ({
content: (
<DateField
label=""
value={row.date}
onChange={(date) => handleCellChange(row.id, "date", date)}
hideCalendarIcon
format="medium"
/>
),
}),
w: "120px",
};

const date2Column: GridColumn<EditableRow> = {
header: "Date",
data: (row) => ({
content: (
<DateField
label=""
value={row.date}
onChange={(date) => handleCellChange(row.id, "date", date)}
hideCalendarIcon
format="medium"
/>
),
}),
w: "120px",
};

const style = getTableStyles({ bordered: true, allWhite: true, highlightOnHover: true });

return (
<div css={Css.m2.$}>
<GridTable
columns={[nameColumn, selectColumn, date1Column, date2Column]}
rows={rows}
style={{
...style,
rowHoverColor: Palette.Blue50,
0ces marked this conversation as resolved.
Show resolved Hide resolved
rowEditableCellBorderColor: Palette.Blue300,
}}
/>
</div>
);
}
4 changes: 3 additions & 1 deletion src/components/Table/GridTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ export function GridTable<R extends Kinded, X extends Only<GridTableXss, X> = an

const borderless = style?.presentationSettings?.borderless;
const typeScale = style?.presentationSettings?.typeScale;
const borderOnHover = style?.presentationSettings?.borderOnHover;
const fieldProps: PresentationFieldProps = useMemo(
() => ({
labelStyle: "hidden",
Expand All @@ -479,8 +480,9 @@ export function GridTable<R extends Kinded, X extends Only<GridTableXss, X> = an
// Avoid passing `undefined` as it will unset existing PresentationContext settings
...(borderless !== undefined ? { borderless } : {}),
...(typeScale !== undefined ? { typeScale } : {}),
...(borderOnHover !== undefined ? { borderOnHover } : {}),
}),
[borderless, typeScale],
[borderOnHover, borderless, typeScale],
);

// If we're running in Jest, force using `as=div` b/c jsdom doesn't support react-virtuoso.
Expand Down
18 changes: 15 additions & 3 deletions src/components/Table/TableStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ export interface GridStyle {
firstRowMessageCss?: Properties;
/** Applied on hover if a row has a rowLink/onClick set. */
rowHoverColor?: Palette | "none";
/** Applied on hover to a cell TextFieldBase */
rowEditableCellBorderColor?: Palette;
Copy link
Contributor

Choose a reason for hiding this comment

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

@0ces afaict we're not really setting this on "the cell", but instead pushing it down into the child TextField's DOM? If so, maybe it shouldn't really be a "table style" and instead each component can know how to style itself has "editable" or "borderless: unless-hovered" or what not.

/** Applied on hover of a row */
nonHeaderRowHoverCss?: Properties;
/** Default content to put into an empty cell */
emptyCell?: ReactNode;
presentationSettings?: Pick<PresentationFieldProps, "borderless" | "typeScale"> &
presentationSettings?: Pick<PresentationFieldProps, "borderless" | "borderOnHover" | "typeScale"> &
Pick<PresentationContextProps, "wrap">;
/** Minimum table width in pixels. Used when calculating columns sizes */
minWidthPx?: number;
Expand Down Expand Up @@ -90,6 +92,8 @@ export interface GridStyleDef {
vAlign?: "top" | "center" | "bottom";
/** Defines the Typography for the table body's cells (not the header). This only applies to rows that are not nested/grouped */
cellTypography?: Typography;
/** */
highlightOnHover?: boolean;
}

// Returns a "blessed" style of GridTable
Expand All @@ -105,6 +109,7 @@ function memoizedTableStyles() {
bordered = false,
vAlign = "center",
cellTypography = "xs" as const,
highlightOnHover = true,
} = props;

const key = safeKeys(props)
Expand Down Expand Up @@ -170,9 +175,15 @@ function memoizedTableStyles() {
Css.borderRadius("0 0 8px 0").$,
).$
: Css.addIn("> *", Css.bsh0.$).$,
presentationSettings: { borderless: true, typeScale: "xs", wrap: rowHeight === "flexible" },
presentationSettings: {
borderless: true,
typeScale: "xs",
wrap: rowHeight === "flexible",
borderOnHover: highlightOnHover,
},
levels: grouped ? groupedLevels : defaultLevels,
rowHoverColor: Palette.Blue100,
rowHoverColor: Palette.Blue50,
rowEditableCellBorderColor: Palette.Blue300,
keptGroupRowCss: Css.bgYellow100.gray900.xsMd.df.aic.$,
keptLastRowCss: Css.boxShadow("inset 0px -14px 8px -11px rgba(63,63,63,.18)").$,
};
Expand Down Expand Up @@ -248,6 +259,7 @@ export function resolveStyles(style: GridStyle | GridStyleDef): GridStyle {
rowHover: true,
vAlign: true,
cellTypography: true,
highlightOnHover: true,
};
const keys = safeKeys(style);
const defKeys = safeKeys(defKeysRecord);
Expand Down
17 changes: 10 additions & 7 deletions src/components/Table/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
const onDragOverDebounced = useDebouncedCallback(dragOverCallback, 100);

const RowContent = () => (
<RowTag css={rowCss} {...others} data-gridrow {...getCount(row.id)} ref={ref}>
<RowTag css={rowCss} {...others} data-gridrow {...getCount(row.id)} ref={ref} className={ROW_CSS_SELECTOR}>
{isKeptGroupRow ? (
<KeptGroupRow as={as} style={style} columnSizes={columnSizes} row={row} colSpan={columns.length} />
) : (
Expand Down Expand Up @@ -210,6 +210,10 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
onDragOver: onDragOverDebounced,
};

const cellId = `${row.kind}_${row.id}_${column.id}`;
const applyCellHighlight = cellHighlight && !!column.id && !isHeader && !isTotals;
const isCellActive = tableState.activeCellId === cellId;

const maybeContent = applyRowFn(column as GridColumnWithId<R>, row, rowApi, level, isExpanded, dragData);

// Only use the `numExpandedColumns` as the `colspan` when rendering the "Expandable Header"
Expand All @@ -220,6 +224,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
? numExpandedColumns + 1
: 1;
const revealOnRowHover = isGridCellContent(maybeContent) ? maybeContent.revealOnRowHover : false;
const borderOnHover = style.presentationSettings?.borderOnHover ?? false;
0ces marked this conversation as resolved.
Show resolved Hide resolved

const canSortColumn =
(sortOn === "client" && column.clientSideSort !== false) ||
Expand Down Expand Up @@ -274,11 +279,6 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {

// This relies on our column sizes being defined in pixel values, which is currently true as we calculate to pixel values in the `useSetupColumnSizes` hook
minStickyLeftOffset += maybeSticky === "left" ? parseInt(columnSizes[columnIndex].replace("px", ""), 10) : 0;

const cellId = `${row.kind}_${row.id}_${column.id}`;
const applyCellHighlight = cellHighlight && !!column.id && !isHeader && !isTotals;
const isCellActive = tableState.activeCellId === cellId;

// Note that it seems expensive to calc a per-cell class name/CSS-in-JS output,
// vs. setting global/table-wide CSS like `style.cellCss` on the root grid div with
// a few descendent selectors. However, that approach means the root grid-applied
Expand Down Expand Up @@ -334,7 +334,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
})`,
};

const cellClassNames = revealOnRowHover ? revealOnRowHoverClass : undefined;
const cellClassNames = [CELL_CSS_SELECTOR, ...(revealOnRowHover ? [revealOnRowHoverClass] : [])].join(" ");

const cellOnClick = applyCellHighlight ? () => api.setActiveCellId(cellId) : undefined;
const tooltip = isGridCellContent(maybeContent) ? maybeContent.tooltip : undefined;
Expand Down Expand Up @@ -430,3 +430,6 @@ export type GridDataRow<R extends Kinded> = {
/** Whether this row is draggable, usually to allow drag & drop reordering of rows */
draggable?: boolean;
} & IfAny<R, AnyObject, DiscriminateUnion<R, "kind", R["kind"]>>;

export const ROW_CSS_SELECTOR = "Row";
0ces marked this conversation as resolved.
Show resolved Hide resolved
export const CELL_CSS_SELECTOR = "Cell";
0ces marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 2 additions & 2 deletions src/components/Table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export type GridColumn<R extends Kinded> = {
| (DiscriminateUnion<R, "kind", K> extends { data: infer D }
? (
data: D,
opts: { row: GridRowKind<R, K>; api: GridRowApi<R>; level: number; expanded: boolean },
opts: { row: GridRowKind<R, K>; api: GridRowApi<R>; level: number; expanded: boolean; editable: boolean },
) => ReactNode | GridCellContent
: (
data: undefined,
opts: { row: GridRowKind<R, K>; api: GridRowApi<R>; level: number; expanded: boolean },
opts: { row: GridRowKind<R, K>; api: GridRowApi<R>; level: number; expanded: boolean; editable: boolean },
) => ReactNode | GridCellContent);
} & {
/**
Expand Down
8 changes: 7 additions & 1 deletion src/components/Table/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,13 @@ export function applyRowFn<R extends Kinded>(
const maybeContent = column[row.kind];
if (typeof maybeContent === "function") {
// Auto-destructure data
return (maybeContent as Function)((row as any)["data"], { row: row as any, api, level, expanded, dragData });
return (maybeContent as Function)((row as any)["data"], {
row: row as any,
api,
level,
expanded,
dragData,
});
} else {
return maybeContent;
}
Expand Down
26 changes: 22 additions & 4 deletions src/inputs/TextFieldBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Icon, IconButton, maybeTooltip } from "src/components";
import { HelperText } from "src/components/HelperText";
import { InlineLabel, Label } from "src/components/Label";
import { usePresentationContext } from "src/components/PresentationContext";
import { CELL_CSS_SELECTOR, ROW_CSS_SELECTOR } from "src/components/Table/components/Row";
import { Css, Only, Palette } from "src/Css";
import { getLabelSuffix } from "src/forms/labelUtils";
import { useGetRef } from "src/hooks/useGetRef";
Expand All @@ -37,6 +38,7 @@ export interface TextFieldBaseProps<X>
| "placeholder"
| "compact"
| "borderless"
| "borderOnHover"
| "visuallyDisabled"
| "fullWidth"
| "xss"
Expand Down Expand Up @@ -90,6 +92,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
labelStyle = fieldProps?.labelStyle ?? "above",
contrast = false,
borderless = fieldProps?.borderless ?? false,
borderOnHover = fieldProps?.borderOnHover ?? false,
textAreaMinHeight = 96,
clearable = false,
tooltip,
Expand Down Expand Up @@ -120,9 +123,12 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB

const [bgColor, hoverBgColor, disabledBgColor] = contrast
? [Palette.Gray700, Palette.Gray600, Palette.Gray700]
: borderless && !compound
? [Palette.Gray100, Palette.Gray200, Palette.Gray200]
: [Palette.White, Palette.Gray100, Palette.Gray100];
: borderOnHover
? // Use transparent backgrounds to blend with the table row hover color
0ces marked this conversation as resolved.
Show resolved Hide resolved
[Palette.Transparent, Palette.Transparent, Palette.Gray100]
: borderless && !compound
? [Palette.Gray100, Palette.Gray200, Palette.Gray200]
: [Palette.White, Palette.Gray100, Palette.Gray100];

const fieldMaxWidth = getFieldWidth(fullWidth);

Expand All @@ -143,6 +149,13 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
: Css.bcGray300.if(contrast).bcGray700.$),
// Do not add borders to compound fields. A compound field is responsible for drawing its own borders
...(!compound ? Css.ba.$ : {}),
...(borderOnHover && Css.pl1.ml(-1).br4.ba.bcTransparent.$),
0ces marked this conversation as resolved.
Show resolved Hide resolved
...(borderOnHover && Css.if(isHovered).bgBlue100.ba.bcBlue300.$),
...{
// Highlight the field when hovering over the row in a table
[`.${ROW_CSS_SELECTOR}:hover:not(:has(.textFieldBaseWrapper:hover)) &`]: Css.ba.bcBlue300.$,
0ces marked this conversation as resolved.
Show resolved Hide resolved
[`.${CELL_CSS_SELECTOR}:hover &`]: Css.bgBlue100.$,
},
// When multiline is true, then we want to allow the field to grow to the height of the content, but not shrink below the minHeight
// Otherwise, set fixed heights values accordingly.
...(multiline
Expand All @@ -168,11 +181,13 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...Css.w100.mw0.outline0.fg1.bgColor(bgColor).$,
// Not using Truss's inline `if` statement here because `addIn` properties do not respect the if statement.
...(contrast && Css.addIn("&::selection", Css.bgGray800.$).$),
// Make the background transparent when highlighting the field on hover
...(borderOnHover && Css.bgTransparent.$),
// For "multiline" fields we add top and bottom padding of 7px for compact, or 11px for non-compact, to properly match the height of the single line fields
...(multiline ? Css.br4.pyPx(compact ? 7 : 11).add("resize", "none").$ : Css.truncate.$),
},
hover: Css.bgColor(hoverBgColor).if(contrast).bcGray600.$,
focus: Css.bcBlue700.if(contrast).bcBlue500.$,
focus: Css.bcBlue700.if(contrast).bcBlue500.if(borderOnHover).bgBlue100.bcBlue500.$,
disabled: visuallyDisabled
? Css.cursorNotAllowed.gray600.bgColor(disabledBgColor).if(contrast).gray500.$
: Css.cursorNotAllowed.$,
Expand Down Expand Up @@ -229,6 +244,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...(multiline ? Css.fdc.aifs.gap2.$ : Css.if(wrap === false).truncate.$),
...xss,
}}
className="textFieldBaseWrapper"
data-readonly="true"
{...tid}
>
Expand Down Expand Up @@ -259,6 +275,8 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...(errorMsg && !inputProps.disabled ? fieldStyles.error : {}),
...Css.if(multiline).aifs.oh.mhPx(textAreaMinHeight).$,
}}
// Class name used for the grid table on row hover for highlighting
className="textFieldBaseWrapper"
Copy link
Contributor

Choose a reason for hiding this comment

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

I could envision eventually needing other non-TextFieldBase components controlling the same "I'm hovered, so don't hover the other editable components in the row anymore", so I think it'd potentially be neat to:

  • Rename ROW_CSS_SELECTOR to BorderHoverParent
  • Extract textFieldBaseWrapper and name it BorderHoverChild

To have the names align to the general capability we're adding, which is that should a component have borderOnHover=true set, it will hook up its styles to "whatever BorderHoverParent", as long as said BHP doesn't already have a hovered BHC.

{...hoverProps}
ref={inputWrapRef as any}
onClick={unfocusedPlaceholder ? handleUnfocusedPlaceholderClick : undefined}
Expand Down
Loading