From 007119bc4e23c3a719a324f0238a02cbd6866ccf Mon Sep 17 00:00:00 2001 From: Edwar Plata Date: Tue, 3 Dec 2024 15:00:32 -0500 Subject: [PATCH] feat: highlight editable cells on hover (#1087) --- src/components/PresentationContext.tsx | 2 + src/components/Table/GridTable.stories.tsx | 102 ++++++++++++++++++++- src/components/Table/GridTable.tsx | 4 +- src/components/Table/TableStyles.tsx | 15 ++- src/components/Table/components/Row.tsx | 6 +- src/inputs/TextFieldBase.tsx | 25 ++++- 6 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/components/PresentationContext.tsx b/src/components/PresentationContext.tsx index 0bec8d15d..fae8c8265 100644 --- a/src/components/PresentationContext.tsx +++ b/src/components/PresentationContext.tsx @@ -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 diff --git a/src/components/Table/GridTable.stories.tsx b/src/components/Table/GridTable.stories.tsx index ac374cbe0..2ca537015 100644 --- a/src/components/Table/GridTable.stories.tsx +++ b/src/components/Table/GridTable.stories.tsx @@ -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"; @@ -2144,3 +2145,102 @@ export function MinColumnWidths() { ); } + +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[]>([ + 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 = { + header: "Name", + data: ({ name }) => name, + }; + + const selectColumn: GridColumn = { + header: "Status", + data: (row) => ({ + content: ( + ({ label: status, code: status }))} + value={row.status} + onSelect={(status) => setRow(row.id, "status", status)} + /> + ), + }), + w: "120px", + }; + + const date1Column: GridColumn = { + header: "Date", + data: (row) => ({ + content: ( + setRow(row.id, "date", date)} + hideCalendarIcon + format="medium" + /> + ), + }), + w: "120px", + }; + + const date2Column: GridColumn = { + header: "Date", + data: (row) => ({ + content: ( + setRow(row.id, "date", date)} + hideCalendarIcon + format="medium" + /> + ), + }), + w: "120px", + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/Table/GridTable.tsx b/src/components/Table/GridTable.tsx index 7863f3c10..ad8acbe9d 100644 --- a/src/components/Table/GridTable.tsx +++ b/src/components/Table/GridTable.tsx @@ -470,6 +470,7 @@ export function GridTable = an const borderless = style?.presentationSettings?.borderless; const typeScale = style?.presentationSettings?.typeScale; + const borderOnHover = style?.presentationSettings?.borderOnHover; const fieldProps: PresentationFieldProps = useMemo( () => ({ labelStyle: "hidden", @@ -479,8 +480,9 @@ export function GridTable = 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. diff --git a/src/components/Table/TableStyles.tsx b/src/components/Table/TableStyles.tsx index c36b15c52..a3fc354a2 100644 --- a/src/components/Table/TableStyles.tsx +++ b/src/components/Table/TableStyles.tsx @@ -46,7 +46,7 @@ export interface GridStyle { nonHeaderRowHoverCss?: Properties; /** Default content to put into an empty cell */ emptyCell?: ReactNode; - presentationSettings?: Pick & + presentationSettings?: Pick & Pick; /** Minimum table width in pixels. Used when calculating columns sizes */ minWidthPx?: number; @@ -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 @@ -105,6 +107,7 @@ function memoizedTableStyles() { bordered = false, vAlign = "center", cellTypography = "xs" as const, + highlightOnHover = true, } = props; const key = safeKeys(props) @@ -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)").$, }; @@ -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); diff --git a/src/components/Table/components/Row.tsx b/src/components/Table/components/Row.tsx index 1d1759aa2..af4153155 100644 --- a/src/components/Table/components/Row.tsx +++ b/src/components/Table/components/Row.tsx @@ -145,7 +145,7 @@ function RowImpl(props: RowProps): ReactElement { const onDragOverDebounced = useDebouncedCallback(dragOverCallback, 100); const RowContent = () => ( - + {isKeptGroupRow ? ( ) : ( @@ -430,3 +430,7 @@ export type GridDataRow = { /** Whether this row is draggable, usually to allow drag & drop reordering of rows */ draggable?: boolean; } & IfAny>; + +// Used by TextFieldBase to set a border when the row is being hovered over +export const BorderHoverParent = "BorderHoverParent"; +export const BorderHoverChild = "BorderHoverChild"; diff --git a/src/inputs/TextFieldBase.tsx b/src/inputs/TextFieldBase.tsx index 77434e97b..ec50fc717 100644 --- a/src/inputs/TextFieldBase.tsx +++ b/src/inputs/TextFieldBase.tsx @@ -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"; @@ -37,6 +38,7 @@ export interface TextFieldBaseProps | "placeholder" | "compact" | "borderless" + | "borderOnHover" | "visuallyDisabled" | "fullWidth" | "xss" @@ -90,6 +92,7 @@ export function TextFieldBase>(props: TextFieldB labelStyle = fieldProps?.labelStyle ?? "above", contrast = false, borderless = fieldProps?.borderless ?? false, + borderOnHover = fieldProps?.borderOnHover ?? false, textAreaMinHeight = 96, clearable = false, tooltip, @@ -120,9 +123,12 @@ export function TextFieldBase>(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); @@ -143,6 +149,12 @@ export function TextFieldBase>(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 @@ -168,11 +180,13 @@ export function TextFieldBase>(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.$, @@ -229,6 +243,7 @@ export function TextFieldBase>(props: TextFieldB ...(multiline ? Css.fdc.aifs.gap2.$ : Css.if(wrap === false).truncate.$), ...xss, }} + className={BorderHoverChild} data-readonly="true" {...tid} > @@ -259,6 +274,8 @@ export function TextFieldBase>(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}