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
87 changes: 86 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,87 @@ 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 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={noop}
/>
),
editableOnHover: true,
}),
w: "100px",
};

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

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

return (
<GridTable
columns={[nameColumn, selectColumn, date1Column, date2Column]}
rows={rows}
style={{ bordered: true, allWhite: true, rowHoverColor: Palette.Blue50 }}
/>
);
}
2 changes: 1 addition & 1 deletion src/components/Table/GridTableApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export class GridTableApiImpl<R extends Kinded> implements GridTableApi<R> {
.filter((c) => !c.isAction)
.map((c) => {
// Just guessing for level=1
const maybeContent = applyRowFn(c, rs.row, this as any as GridRowApi<R>, 1, true, undefined);
const maybeContent = applyRowFn(c, rs.row, this as any as GridRowApi<R>, 1, true, false, undefined);
if (isGridCellContent(maybeContent)) {
const cell = maybeContent;
const content = maybeApply(cell.content);
Expand Down
2 changes: 2 additions & 0 deletions src/components/Table/TableStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ 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 */
Expand Down
47 changes: 39 additions & 8 deletions src/components/Table/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
const sortOn = tableState.sortConfig?.on;

const revealOnRowHoverClass = "revealOnRowHover";
const editableOnRowHoverClass = "editableOnRowHover";
Copy link
Contributor

Choose a reason for hiding this comment

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

@0ces I'm still reading the PR (on mobile 😅) but can't this special class name go away with the PC based approach?

I.e. my primary concern with the original PR was that TextField was being told "don't show your border" ... And then later GridTable had a bunch of special "reach into TextFields DOM and tell it to do something else", which is a brittle approach/breaks encapsulation.

...I suppose you're staying with the special class name to take advantage of :hover psuedo-selector?

I'm not totally against using the :hover selector, but I'd like any "what does TextFields border look like when borderless but hovered" to be owned/live directly in TextField, and not GridTable.

Wdyt, in terms of approaches to do that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@stephenh yeah, I'm keeping that class just to propagate the :hover state to the cells when hovering the rows and not the actual field, I really don't know a better way of doing the propagation, also as this is just CSS just for it not to cause a ton of re-renders.

Copy link
Contributor

Choose a reason for hiding this comment

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

@0ces right, but can we get the hover psuedo-selectors at least placed within the TextField class itself? I.e. GridTable will only "turn on" the hiber behavior, but the actual render details are in the TF.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can give it a try


const showRowHoverColor = !reservedRowKinds.includes(row.kind) && !omitRowHover && style.rowHoverColor !== "none";

Expand Down Expand Up @@ -123,6 +124,11 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
[` > .${revealOnRowHoverClass} > *`]: Css.vh.$,
[`:hover > .${revealOnRowHoverClass} > *`]: Css.vv.$,
},
...{
[`:hover > .${editableOnRowHoverClass} .textFieldBaseWrapper`]: Css.px1.br4.ba.bc(
style.rowEditableCellBorderColor ?? Palette.Blue300,
).$,
},
...(isLastKeptRow && Css.addIn("&>*", style.keptLastRowCss).$),
};

Expand Down Expand Up @@ -210,7 +216,19 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
onDragOver: onDragOverDebounced,
};

const maybeContent = applyRowFn(column as GridColumnWithId<R>, row, rowApi, level, isExpanded, dragData);
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,
isCellActive,
dragData,
);

// Only use the `numExpandedColumns` as the `colspan` when rendering the "Expandable Header"
currentColspan =
Expand All @@ -220,6 +238,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
? numExpandedColumns + 1
: 1;
const revealOnRowHover = isGridCellContent(maybeContent) ? maybeContent.revealOnRowHover : false;
const editableOnRowHover = isGridCellContent(maybeContent) ? maybeContent.editableOnHover : false;

const canSortColumn =
(sortOn === "client" && column.clientSideSort !== false) ||
Expand Down Expand Up @@ -274,11 +293,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,8 +348,15 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
})`,
};

const cellClassNames = revealOnRowHover ? revealOnRowHoverClass : undefined;
const cellClassNames = [
...(revealOnRowHover ? [revealOnRowHoverClass] : []),
...(editableOnRowHover && (isCellActive || !tableState.activeCellId) ? [editableOnRowHoverClass] : []),
].join(" ");

const cellOnHover =
isGridCellContent(maybeContent) && maybeContent.editableOnHover
? (enter: boolean) => (enter ? api.setActiveCellId(cellId) : api.setActiveCellId(undefined))
: undefined;
const cellOnClick = applyCellHighlight ? () => api.setActiveCellId(cellId) : undefined;
const tooltip = isGridCellContent(maybeContent) ? maybeContent.tooltip : undefined;

Expand All @@ -348,7 +369,17 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
? rowClickRenderFn(as, api, currentColspan)
: defaultRenderFn(as, currentColspan);

return renderFn(columnIndex, cellCss, content, row, rowStyle, cellClassNames, cellOnClick, tooltip);
return renderFn(
columnIndex,
cellCss,
content,
row,
rowStyle,
cellClassNames,
cellOnClick,
cellOnHover,
tooltip,
);
})
)}
</RowTag>
Expand Down
8 changes: 7 additions & 1 deletion src/components/Table/components/cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type GridCellContent = {
revealOnRowHover?: true;
/** Tooltip to add to a cell */
tooltip?: ReactNode;
/** Allows cell to be editable when hovering, and also highlights the field on hover */
editableOnHover?: true;
0ces marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

@0ces cc @apattersonATX-HB do we want this to be a per-cell flag? Kinda thinking that the entire table should be consistent about "all columns/cells are editableOnHover yes/no"...

Copy link
Contributor

Choose a reason for hiding this comment

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

Setting at the table makes sense. Maybe have an override on the cell/column level?

Copy link
Contributor

Choose a reason for hiding this comment

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

If we can articulate a reason why a cell/column legitimately should not follow the "hover draws borders" behavior, yeah we could, but otherwise I'd prefer not to provide the option as a strong recommendation to the engineer/UX designer that "you should not be doing this".

(I.e. without a strong Figma-based design system driving consistency from the UX side, we've basically been using Beam's "opinionated APIs" to drive consistency back to the design team.)

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit/suggestion: editableOnHover doesn't really tell me whats happening here. Really we're just putting a border around fields on hover. The field doesn't actually become editable on hover, it just has a border now 🤷.
Taking a step back, this feels like something that should be globally applied, not just for one or two tables with inputs. I would request more design direction here. The issue with Blueprint is that every page is so different. This is just going to add to that problem.
Also, when a user uses their tab key to go between fields, then we don't get the same "bordered" treatment, unless they're hovering that row. As this PR stands, it's creating an inconsistent experience that I don't believe provides a better UX for our users. I would ask Design to fully flush this idea out, and figure out if this should be the global pattern. If not the global pattern, have rules that define when to use this pattern vs not. We should be doing this as it is best for our users.

Copy link
Contributor

@stephenh stephenh Nov 22, 2024

Choose a reason for hiding this comment

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

Ah damn, I was pushing towards "each table itself should at least be consistent" (instead of a per cell decision), but ngl I like Brandon's push about "do they (design) just want all tables in BP to act this way?"

Because that is actually easier for us to globally roll out, and per Brandon's point likely (surely?) better for users (consistency), to just have our tables "always do that" (whatever the latest/greatest UX is), vs. picking tables onsey-twosy that do/do not get the special behavior.

Copy link
Contributor Author

@0ces 0ces Nov 22, 2024

Choose a reason for hiding this comment

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

AFAIK this is a new design thing, I can ask design if this will be the new standard, I tried not to impose massive changes, this is the figma that I've been following for this PR: https://www.figma.com/design/xQ3iNLHJCFEJeOi7boONY4/Q1-2024-%7C-Dynamic-Schedules?node-id=11225-33807&t=BgAyiY5hjDNYwF9v-1

Copy link
Contributor

Choose a reason for hiding this comment

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

@0ces cool, feel free to loop me into any design discussions

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, this is supposed to be the new global standard.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@stephenh the feedback that I got from design:

imagen

As I understand it design does want to make this to make this the new standard but with a slow rollout

Copy link
Contributor

Choose a reason for hiding this comment

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

What does this comment mean by "non-table cells"?

};

/** Allows rendering a specific cell. */
Expand All @@ -45,19 +47,23 @@ export type RenderCellFn<R extends Kinded> = (
rowStyle: RowStyle<R> | undefined,
classNames: string | undefined,
onClick: VoidFunction | undefined,
onHover: ((enter: boolean) => void) | undefined,
tooltip: ReactNode | undefined,
) => ReactNode;

/** Renders our default cell element, i.e. if no row links and no custom renderCell are used. */
export const defaultRenderFn: (as: RenderAs, colSpan: number) => RenderCellFn<any> =
(as: RenderAs, colSpan) => (key, css, content, row, rowStyle, classNames: string | undefined, onClick, tooltip) => {
(as: RenderAs, colSpan) =>
(key, css, content, row, rowStyle, classNames: string | undefined, onClick, onHover, tooltip) => {
const Cell = as === "table" ? "td" : "div";
return (
<Cell
key={key}
css={{ ...css, ...Css.cursor("default").$ }}
className={classNames}
onClick={onClick}
onMouseEnter={() => onHover?.(true)}
onMouseLeave={() => onHover?.(false)}
{...(as === "table" && { colSpan })}
>
{content}
Expand Down
6 changes: 4 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 Expand Up @@ -85,6 +85,8 @@ export type GridColumn<R extends Kinded> = {
initExpanded?: boolean;
/** Determines whether this column should be hidden when expanded (only the 'expandColumns' would show) */
hideOnExpand?: boolean;
/** Flag that changes the field behavior to be editable on hover */
editableOnHover?: boolean;
};

/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/Table/utils/RowState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ export class RowState<R extends Kinded> {
const { visibleColumns, search } = this.states.table;
return search.every((term) =>
visibleColumns
.map((c) => applyRowFn(c, this.row, this.api, 0, false))
.map((c) => applyRowFn(c, this.row, this.api, 0, false, false))
.some((maybeContent) => matchesFilter(maybeContent, term)),
);
} finally {
Expand Down
4 changes: 2 additions & 2 deletions src/components/Table/utils/sortRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ function compare<R extends Kinded>(
invert: boolean,
caseSensitive: boolean,
) {
const v1 = sortValue(applyRowFn(column, a, {} as any, 0, false), caseSensitive);
const v2 = sortValue(applyRowFn(column, b, {} as any, 0, false), caseSensitive);
const v1 = sortValue(applyRowFn(column, a, {} as any, 0, false, false), caseSensitive);
const v2 = sortValue(applyRowFn(column, b, {} as any, 0, false, false), caseSensitive);
const v1e = v1 === null || v1 === undefined;
const v2e = v2 === null || v2 === undefined;
if ((v1e && v2e) || v1 === v2) {
Expand Down
10 changes: 9 additions & 1 deletion src/components/Table/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,21 @@ export function applyRowFn<R extends Kinded>(
api: GridRowApi<R>,
level: number,
expanded: boolean,
editable: boolean,
dragData?: DragData<R>,
): ReactNode | GridCellContent {
// Usually this is a function to apply against the row, but sometimes it's a hard-coded value, i.e. for headers
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,
editable,
dragData,
});
} else {
return maybeContent;
}
Expand Down
2 changes: 2 additions & 0 deletions src/inputs/TextFieldBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,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 +260,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
...(errorMsg && !inputProps.disabled ? fieldStyles.error : {}),
...Css.if(multiline).aifs.oh.mhPx(textAreaMinHeight).$,
}}
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