browsertrix/frontend/src/components/ui/data-grid/data-grid-row.ts
sua yoo 7c9627f4bb
chore: Clean up data grid component (#2604)
- Moves data grid styles to separate stylesheet.
- Adds `rowsSelectable` option, renames `rows-` properties to match.
- Adds WIP `rowsExpandable` option.
- Fixes showing tooltip on focus.
- Cleans up rows controller typing.

---------

Co-authored-by: Emma Segal-Grossman <hi@emma.cafe>
2025-05-14 09:44:07 -07:00

390 lines
10 KiB
TypeScript

import { localized, msg } from "@lit/localize";
import clsx from "clsx";
import { html, type PropertyValues } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators.js";
import { directive } from "lit/directive.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js";
import isEqual from "lodash/fp/isEqual";
import { CellDirective } from "./cellDirective";
import type {
CellEditEventDetail,
DataGridCell,
InputElement,
} from "./data-grid-cell";
import type { GridColumn, GridItem, GridRowId } from "./types";
import { DataGridFocusController } from "@/components/ui/data-grid/controllers/focus";
import { TableRow } from "@/components/ui/table/table-row";
import { FormControl } from "@/mixins/FormControl";
import { tw } from "@/utils/tailwind";
export type RowRemoveEventDetail = {
key?: string;
};
const cell = directive(CellDirective);
const cellStyle = tw`focus-visible:-outline-offset-2`;
const editableCellStyle = tw`p-0 focus-visible:bg-slate-50 `;
/**
* @fires btrix-remove CustomEvent
*/
@customElement("btrix-data-grid-row")
@localized()
export class DataGridRow extends FormControl(TableRow) {
/**
* Set of columns.
*/
@property({ type: Array })
columns?: GridColumn[] = [];
/**
* Row key/ID.
*/
@property({ type: String })
key?: GridRowId;
/**
* Data to be presented as a row.
*/
@property({ type: Object, hasChanged: (a, b) => !isEqual(a, b) })
item?: GridItem;
/**
* Whether the row can be removed.
*/
@property({ type: Boolean })
removable = false;
/**
* Whether the row can be clicked.
*/
@property({ type: Boolean })
clickable = false;
/**
* Whether the row can be expanded.
*/
@property({ type: Boolean })
expandable = false;
/**
* Whether cells can be edited.
*/
@property({ type: Boolean })
editCells = false;
/**
* Vertical alignment of content.
*/
@property({ type: String })
alignContent: "start" | "center" | "end" = "center";
/**
* Form control name, if used in a form.
*/
@property({ type: String, reflect: true })
name?: string;
@state()
private expanded = false;
@state()
private cellValues: Partial<GridItem> = {};
readonly #focus = new DataGridFocusController(this);
readonly #invalidInputsMap = new Map<
GridColumn["field"],
InputElement["validationMessage"]
>();
public formResetCallback() {
this.setValue(this.item || {});
this.commitValue();
}
protected createRenderRoot() {
const root = super.createRenderRoot();
// Attach to render root so that `e.target` is table cell
root.addEventListener(
"btrix-input",
(e) => void this.onCellInput(e as CustomEvent<CellEditEventDetail>),
);
root.addEventListener(
"btrix-change",
(e) => void this.onCellChange(e as CustomEvent<CellEditEventDetail>),
);
return root;
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
(changedProperties.has("item") || changedProperties.has("editCells")) &&
this.item &&
this.editCells
) {
this.setValue(this.item);
this.commitValue();
}
}
@queryAll("btrix-data-grid-cell")
private readonly gridCells?: NodeListOf<DataGridCell>;
private setValue(cellValues: Partial<GridItem>) {
Object.keys(cellValues).forEach((field) => {
this.cellValues[field] = cellValues[field];
});
this.setFormValue(JSON.stringify(this.cellValues));
}
private commitValue() {
this.cellValues = {
...this.cellValues,
};
}
render() {
if (!this.columns?.length) return html``;
let expandCell = html``;
let removeCell = html``;
if (this.expandable) {
expandCell = html`
<btrix-data-grid-cell
class=${clsx(tw`border-l p-0`, cellStyle)}
@keydown=${this.onKeydown}
>
<sl-icon-button
class=${clsx(
tw`p-1 text-base transition-transform`,
this.expanded && tw`rotate-90`,
)}
name="chevron-right"
label=${this.expanded ? msg("Contract") : msg("Expand")}
@click=${(e: MouseEvent) => {
e.stopPropagation();
this.expanded = !this.expanded;
}}
></sl-icon-button>
</btrix-data-grid-cell>
`;
}
if (this.removable) {
removeCell = html`
<btrix-data-grid-cell
class=${clsx(tw`border-l p-0`, cellStyle)}
@keydown=${this.onKeydown}
>
<sl-tooltip content=${msg("Remove")} hoist>
<sl-icon-button
class="p-1 text-base hover:text-danger"
name="trash3"
@click=${() =>
this.dispatchEvent(
new CustomEvent<RowRemoveEventDetail>("btrix-remove", {
detail: {
key: this.key,
},
bubbles: true,
composed: true,
}),
)}
></sl-icon-button>
</sl-tooltip>
</btrix-data-grid-cell>
`;
}
return html`${expandCell}${this.columns.map(this.renderCell)}${removeCell}
${when(this.expanded && this.item, (item) => this.renderDetails({ item }))} `;
}
renderDetails = (_row: { item: GridItem }) => html``;
private readonly renderCell = (col: GridColumn, i: number) => {
const item = this.item;
if (!item) return;
const editable = this.editCells && col.editable;
const tooltipContent = editable
? this.#invalidInputsMap.get(col.field)
: col.renderCellTooltip
? col.renderCellTooltip({ item })
: undefined;
return html`
<sl-tooltip
class="[--max-width:40ch]"
?disabled=${!tooltipContent}
hoist
placement="bottom"
trigger=${
// Disable showing tooltip on focus by default
// so that it doesn't show along with the browser
// validation message on form submit.
// The tooltip is shown manually when tabbed to
// by checking `:focus-visible` on focus.
"hover"
}
>
<btrix-data-grid-cell
class=${clsx(
!this.clickable && i > 0 && tw`border-l`,
cellStyle,
editable && editableCellStyle,
this.alignContent === "start" && tw`items-start`,
this.alignContent === "end" && tw`items-end`,
col.align === "center" && tw`justify-center`,
col.align === "end" && tw`justify-end`,
)}
.column=${col}
.item=${item}
value=${ifDefined(this.cellValues[col.field] ?? undefined)}
?editable=${editable}
${cell(col)}
@keydown=${this.onKeydown}
></btrix-data-grid-cell>
<div slot="content">${tooltipContent}</div>
</sl-tooltip>
`;
};
/**
* Keyboard navigation based on recommendations from
* https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells
*/
private onKeydown(e: KeyboardEvent) {
const tableCell = e.currentTarget as DataGridCell;
const composedTarget = e.composedPath()[0] as HTMLElement;
if (composedTarget === tableCell) {
if (!this.gridCells) {
console.debug("no grid cells");
return;
}
const gridCells = Array.from(this.gridCells);
const i = gridCells.indexOf(e.target as DataGridCell);
if (i === -1) return;
const findNextTabbable = (idx: number, direction: -1 | 1) => {
const el = gridCells[idx + direction];
if (!(el as unknown)) return;
if (this.#focus.isTabbable(el)) {
e.preventDefault();
el.focus();
} else {
findNextTabbable(idx + direction, direction);
}
};
switch (e.key) {
case "ArrowRight":
case "ArrowDown": {
findNextTabbable(i, 1);
break;
}
case "ArrowLeft":
case "ArrowUp": {
findNextTabbable(i, -1);
break;
}
case "Tab": {
// Check if tabbing was prevented, likely by the focus controller
if (e.defaultPrevented) {
findNextTabbable(i, 1);
}
break;
}
default:
break;
}
} else {
if (e.key === "Escape") {
const tabIndex = composedTarget.tabIndex;
// Temporarily disable focusable child so that focus
// doesn't move when exiting
composedTarget.setAttribute("tabindex", "-1");
// Exit back into grid navigation
tableCell.focus();
// Reinstate focusable child
composedTarget.setAttribute("tabindex", `${tabIndex}`);
}
}
}
private readonly onCellInput = async (
e: CustomEvent<CellEditEventDetail>,
) => {
e.stopPropagation();
const { field, value, validity, validationMessage } = e.detail;
const tableCell = e.target as DataGridCell;
if (validity.valid) {
this.#invalidInputsMap.delete(field);
} else {
this.#invalidInputsMap.set(field, validationMessage);
this.setValidity(validity, validationMessage, tableCell);
}
this.setValue({
[field]: value.toString(),
});
};
private readonly onCellChange = async (
e: CustomEvent<CellEditEventDetail>,
) => {
e.stopPropagation();
const { field, validity, validationMessage } = e.detail;
const tableCell = e.target as DataGridCell;
if (validity.valid) {
this.#invalidInputsMap.delete(field);
} else {
this.#invalidInputsMap.set(field, validationMessage);
this.setValidity(validity, validationMessage, tableCell);
}
this.commitValue();
await this.updateComplete;
await tableCell.input?.updateComplete;
if (validity.valid) {
const firstInvalid = Array.from(this.gridCells || []).find((cell) =>
cell.validity?.valid ? false : cell,
);
if (firstInvalid?.validity && firstInvalid.validationMessage) {
this.setValidity(
firstInvalid.validity,
firstInvalid.validationMessage,
firstInvalid,
);
} else {
this.setValidity({});
}
}
};
}