Skip to content

Commit

Permalink
feat: highlight editable cells on hover (#1087)
Browse files Browse the repository at this point in the history
  • Loading branch information
0ces authored Dec 3, 2024
1 parent dbc59f0 commit 007119b
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/components/PresentationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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;
// Typically used for highlighting editable fields in a table. Adds a border on hover.
borderOnHover?: boolean;
// Defines height of the field
compact?: boolean;
// Changes default font styles for input fields and Chips
Expand Down
102 changes: 101 additions & 1 deletion src/components/Table/GridTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,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 +2145,102 @@ export function MinColumnWidths() {
</div>
);
}

enum EditableRowStatus {
Foo = "Foo",
Bar = "Bar",
}

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

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

const setRow = useCallback((rowId: string, field: keyof EditableRowData["data"], value: any) => {
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) => setRow(row.id, "status", status)}
/>
),
}),
w: "120px",
};

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

return (
<div css={Css.m2.$}>
<GridTable columns={[nameColumn, selectColumn, date1Column, date2Column]} rows={rows} />
</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
15 changes: 12 additions & 3 deletions src/components/Table/TableStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface GridStyle {
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 +90,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;
/** Defines if the table should highlight the row on hover. Defaults to true */
highlightOnHover?: boolean;
}

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

const key = safeKeys(props)
Expand Down Expand Up @@ -170,9 +173,14 @@ 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,
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 +256,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
6 changes: 5 additions & 1 deletion 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={BorderHoverParent}>
{isKeptGroupRow ? (
<KeptGroupRow as={as} style={style} columnSizes={columnSizes} row={row} colSpan={columns.length} />
) : (
Expand Down Expand Up @@ -430,3 +430,7 @@ 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"]>>;

// Used by TextFieldBase to set a border when the row is being hovered over
export const BorderHoverParent = "BorderHoverParent";
export const BorderHoverChild = "BorderHoverChild";
25 changes: 21 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 { BorderHoverChild, BorderHoverParent } 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
[Palette.Transparent, Palette.Blue100, 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,12 @@ 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.br4.ba.bcTransparent.add("transition", "border-color 200ms").$),
...(borderOnHover && Css.if(isHovered).bgBlue100.ba.bcBlue300.$),
...{
// Highlight the field when hovering over the row in a table, unless some other edit component (including ourselves) is hovered
[`.${BorderHoverParent}:hover:not(:has(.${BorderHoverChild}:hover)) &`]: Css.ba.bcBlue300.$,
},
// 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 +180,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 +243,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...(multiline ? Css.fdc.aifs.gap2.$ : Css.if(wrap === false).truncate.$),
...xss,
}}
className={BorderHoverChild}
data-readonly="true"
{...tid}
>
Expand Down Expand Up @@ -259,6 +274,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={BorderHoverChild}
{...hoverProps}
ref={inputWrapRef as any}
onClick={unfocusedPlaceholder ? handleUnfocusedPlaceholderClick : undefined}
Expand Down

0 comments on commit 007119b

Please sign in to comment.