diff --git a/frontend/src/components/ui/data-grid/controllers/focus.ts b/frontend/src/components/ui/data-grid/controllers/focus.ts index 78957c7a..8cdf6834 100644 --- a/frontend/src/components/ui/data-grid/controllers/focus.ts +++ b/frontend/src/components/ui/data-grid/controllers/focus.ts @@ -41,6 +41,7 @@ export class DataGridFocusController implements ReactiveController { return; } + // Move focus from table cell to on first tabbable element const el = opts.setFocusOnTabbable ? this.firstTabbable : this.firstFocusable; @@ -55,6 +56,28 @@ export class DataGridFocusController implements ReactiveController { el.focus(); } } + + // Show tooltip on tab focus. Tooltip on any focus should be + // disabled in `btrix-data-grid-row` to prevent tooltips being + // showing duplicate messages during form submission. + const tooltip = this.#host.closest("sl-tooltip"); + + if (tooltip && !tooltip.disabled) { + const hideTooltip = () => { + void tooltip.hide(); + this.#host.removeEventListener("input", hideTooltip); + this.#host.removeEventListener("blur", hideTooltip); + }; + + this.#host.addEventListener("input", hideTooltip, { + once: true, + }); + this.#host.addEventListener("blur", hideTooltip, { + once: true, + }); + + void tooltip.show(); + } }, { passive: true, capture: true }, ); diff --git a/frontend/src/components/ui/data-grid/controllers/rows.ts b/frontend/src/components/ui/data-grid/controllers/rows.ts index f31c56b9..1e97639b 100644 --- a/frontend/src/components/ui/data-grid/controllers/rows.ts +++ b/frontend/src/components/ui/data-grid/controllers/rows.ts @@ -1,12 +1,19 @@ -import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { + ReactiveController, + ReactiveControllerHost, + TemplateResult, +} from "lit"; import { nanoid } from "nanoid"; import type { EmptyObject } from "type-fest"; import type { DataGrid } from "../data-grid"; +import { renderRows } from "../renderRows"; import type { GridItem, GridRowId, GridRows } from "../types"; import { cached } from "@/utils/weakCache"; +export const emptyItem: EmptyObject = {}; + /** * Enables removing and adding rows from a grid. * @@ -15,19 +22,22 @@ import { cached } from "@/utils/weakCache"; * that are slotted into ``, it may be necessary to * implement this controller on the container component. */ -export class DataGridRowsController implements ReactiveController { +export class DataGridRowsController + implements ReactiveController +{ readonly #host: ReactiveControllerHost & EventTarget & { - items?: GridItem[]; - rowKey?: DataGrid["rowKey"]; - defaultItem?: DataGrid["defaultItem"]; - removeRows?: DataGrid["removeRows"]; - addRows?: DataGrid["addRows"]; - }; + items?: Item[]; + } & Partial< + Pick + >; - #prevItems?: GridItem[]; + #prevItems?: Item[]; - public rows: GridRows = new Map(); + public rows: GridRows = new Map< + GridRowId, + Item | EmptyObject + >(); constructor(host: ReactiveControllerHost & EventTarget) { this.#host = host; @@ -46,22 +56,19 @@ export class DataGridRowsController implements ReactiveController { } } - private setRowsFromItems(items: T[]) { + private setRowsFromItems(items: Item[]) { const rowKey = this.#host.rowKey; this.rows = new Map( - this.#host.rowKey - ? items.map((item) => [ - item[rowKey as unknown as string] as GridRowId, - item, - ]) + rowKey + ? items.map((item) => [item[rowKey] as GridRowId, item]) : items.map( cached((item) => [nanoid(), item], { cacheConstructor: Map }), ), ); } - public setItems(items: T[]) { + public setItems(items: Item[]) { if (!this.#prevItems || items !== this.#prevItems) { this.setRowsFromItems(items); @@ -69,15 +76,12 @@ export class DataGridRowsController implements ReactiveController { } } - public updateItem(id: GridRowId, item: T) { + public updateItem(id: GridRowId, item: Item) { this.rows.set(id, item); this.#host.requestUpdate(); } - public addRows( - defaultItem: T | EmptyObject = {}, - count = 1, - ) { + public addRows(defaultItem: Item | EmptyObject = emptyItem, count = 1) { for (let i = 0; i < count; i++) { const id = nanoid(); @@ -96,4 +100,17 @@ export class DataGridRowsController implements ReactiveController { this.#host.requestUpdate(); } + + public isEmpty(item: Item | EmptyObject): item is EmptyObject { + return item === emptyItem; + } + + public renderRows( + renderRow: ( + { id, item }: { id: GridRowId; item: Item | EmptyObject }, + index: number, + ) => TemplateResult, + ) { + return renderRows(this.rows, renderRow); + } } diff --git a/frontend/src/components/ui/data-grid/data-grid-cell.ts b/frontend/src/components/ui/data-grid/data-grid-cell.ts index c02ef6b9..1b38b576 100644 --- a/frontend/src/components/ui/data-grid/data-grid-cell.ts +++ b/frontend/src/components/ui/data-grid/data-grid-cell.ts @@ -3,6 +3,7 @@ import clsx from "clsx"; import { html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import get from "lodash/fp/get"; import { TableCell } from "../table/table-cell"; @@ -119,7 +120,7 @@ export class DataGridCell extends TableCell { } renderCell = ({ item }: { item: GridItem }) => { - return html`${(this.column && item[this.column.field]) ?? ""}`; + return html`${(this.column && get(this.column.field, item)) ?? ""}`; }; renderEditCell = ({ diff --git a/frontend/src/components/ui/data-grid/data-grid-row.ts b/frontend/src/components/ui/data-grid/data-grid-row.ts index 75f5d50f..3ab858fb 100644 --- a/frontend/src/components/ui/data-grid/data-grid-row.ts +++ b/frontend/src/components/ui/data-grid/data-grid-row.ts @@ -4,6 +4,7 @@ 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"; @@ -58,18 +59,39 @@ export class DataGridRow extends FormControl(TableRow) { @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 = {}; @@ -132,8 +154,31 @@ export class DataGridRow extends FormControl(TableRow) { render() { if (!this.columns?.length) return html``; + let expandCell = html``; let removeCell = html``; + if (this.expandable) { + expandCell = html` + + { + e.stopPropagation(); + this.expanded = !this.expanded; + }} + > + + `; + } + if (this.removable) { removeCell = html` this.renderDetails({ item }))} `; } + renderDetails = (_row: { item: GridItem }) => html``; + private readonly renderCell = (col: GridColumn, i: number) => { - const validationMessage = this.#invalidInputsMap.get(col.field); + 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` 0 && tw`border-l`, + !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=${this.item} + .item=${item} value=${ifDefined(this.cellValues[col.field] ?? undefined)} ?editable=${editable} ${cell(col)} @keydown=${this.onKeydown} - @focus=${(e: CustomEvent) => { - e.stopPropagation(); - - const tableCell = e.target as DataGridCell; - const tooltip = tableCell.closest("sl-tooltip"); - - if (tooltip?.open) { - void tooltip.hide(); - } - }} - @blur=${(e: CustomEvent) => { - e.stopPropagation(); - - const tableCell = e.target as DataGridCell; - const tooltip = tableCell.closest("sl-tooltip"); - - if (tooltip && !tooltip.disabled) { - void tooltip.show(); - } - }} > + +
${tooltipContent}
`; }; diff --git a/frontend/src/components/ui/data-grid/data-grid.stylesheet.css b/frontend/src/components/ui/data-grid/data-grid.stylesheet.css new file mode 100644 index 00000000..711d534f --- /dev/null +++ b/frontend/src/components/ui/data-grid/data-grid.stylesheet.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + :host { + --border: 1px solid var(--sl-panel-border-color); + } + + .data-grid-body--horizontalRule btrix-data-grid-row:nth-of-type(n + 2), + .data-grid-body--horizontalRule + ::slotted(btrix-data-grid-row:nth-of-type(n + 2)) { + border-top: var(--border) !important; + } + + .data-grid-body--rowsSelectable btrix-data-grid-row, + .data-grid-body--rowsSelectable ::slotted(btrix-data-grid-row) { + /* TODO Same ring color as edit cells */ + @apply cursor-pointer ring-inset hover:bg-blue-50/50 hover:ring-1; + } + + .data-grid-body--editCells btrix-data-grid-row, + .data-grid-body--editCells ::slotted(btrix-data-grid-row) { + /* TODO Support different input sizes */ + min-height: calc(var(--sl-input-height-medium) + 1px); + } + + .data-grid-body--not-stickyHeader btrix-data-grid-row:first-child, + .data-grid-body--not-stickyHeader ::slotted(btrix-data-grid-row:first-child) { + @apply rounded-t; + } + + .data-grid-body--not-rowsAddible btrix-data-grid-row:last-child, + .data-grid-body--not-rowsAddible ::slotted(btrix-data-grid-row:last-child) { + @apply rounded-b; + } +} diff --git a/frontend/src/components/ui/data-grid/data-grid.ts b/frontend/src/components/ui/data-grid/data-grid.ts index 6bc5fcd3..b05d17de 100644 --- a/frontend/src/components/ui/data-grid/data-grid.ts +++ b/frontend/src/components/ui/data-grid/data-grid.ts @@ -1,7 +1,7 @@ import { localized, msg } from "@lit/localize"; import type { SlChangeEvent, SlInput } from "@shoelace-style/shoelace"; import clsx from "clsx"; -import { css, html, nothing } from "lit"; +import { html, nothing, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { nanoid } from "nanoid"; @@ -9,40 +9,31 @@ import type { EmptyObject } from "type-fest"; import { DataGridRowsController } from "./controllers/rows"; import type { DataGridRow, RowRemoveEventDetail } from "./data-grid-row"; -import { renderRows } from "./renderRows"; +import stylesheet from "./data-grid.stylesheet.css"; +import type { BtrixSelectRowEvent } from "./events/btrix-select-row"; import type { GridColumn, GridItem } from "./types"; import { TailwindElement } from "@/classes/TailwindElement"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; +const styles = unsafeCSS(stylesheet); + /** * Data grids structure data into rows and and columns. * + * [Figma design file](https://www.figma.com/design/ySaSMMI2vctbxP3edAHXib/Webrecorder-Shoelace?node-id=1327-354&p=f) + * * @slot label * @slot rows * @fires btrix-change * @fires btrix-remove + * @fires btrix-select-row */ @customElement("btrix-data-grid") @localized() export class DataGrid extends TailwindElement { - static styles = css` - :host { - --border: 1px solid var(--sl-panel-border-color); - } - - btrix-data-grid-row:not(:first-of-type), - btrix-table-body ::slotted(*:nth-of-type(n + 2)) { - border-top: var(--border) !important; - } - - btrix-data-grid-row, - btrix-table-body ::slotted(btrix-data-grid-row) { - /* TODO Support different input sizes */ - min-height: calc(var(--sl-input-height-medium) + 1px); - } - `; + static styles = styles; /** * Set of columns. @@ -71,17 +62,41 @@ export class DataGrid extends TailwindElement { @property({ type: String }) rowKey?: string; + /** + * Whether rows can be selected, firing a `btrix-select-row` event. + */ + @property({ type: Boolean }) + rowsSelectable = false; + + /** + * Whether a single or multiple rows can be selected (multiple not yet implemented.) + */ + @property({ type: String }) + selectMode: "single" | "multiple" = "single"; + + /** + * WIP: Whether rows can be expanded, revealing more content below the row. + */ + @property({ type: Boolean }) + rowsExpandable = false; + /** * Whether rows can be removed. */ @property({ type: Boolean }) - removeRows = false; + rowsRemovable = false; /** * Whether rows can be added. */ @property({ type: Boolean }) - addRows = false; + rowsAddible = false; + + /** + * Vertical alignment of content in body rows. + */ + @property({ type: String }) + alignRows: "start" | "center" | "end" = "center"; /** * Make the number of rows being added configurable, @@ -145,7 +160,7 @@ export class DataGrid extends TailwindElement { ${this.renderTable()} - ${this.addRows && !this.addRowsInputValue + ${this.rowsAddible && !this.addRowsInputValue ? this.renderAddButton() : nothing} `; @@ -155,7 +170,7 @@ export class DataGrid extends TailwindElement { if (!this.columns?.length) return; const cssWidths = this.columns.map((col) => col.width ?? "1fr"); - const addRowsInputValue = this.addRows && this.addRowsInputValue; + const addRowsInputValue = this.rowsAddible && this.addRowsInputValue; return html` *:not(:first-of-type)]:border-l` + ? [ + tw`sticky top-0 z-10 self-start rounded-t-[0.1875rem] border-b bg-neutral-50`, + !this.rowsSelectable && + tw`[&>*:not(:first-of-type)]:border-l`, + ] : tw`px-px`, )} > + ${this.rowsExpandable + ? html` + + ${msg("Expand row")} + + ` + : nothing} ${this.columns.map( (col) => html` ${col.label} ${col.description @@ -204,7 +235,7 @@ export class DataGrid extends TailwindElement { `, )} - ${this.removeRows + ${this.rowsRemovable ? html` ${msg("Remove row")} ` @@ -212,12 +243,19 @@ export class DataGrid extends TailwindElement { ) => { const { key } = e.detail; @@ -288,15 +326,31 @@ export class DataGrid extends TailwindElement { return html` ${this.items - ? renderRows( - this.rowsController.rows, + ? this.rowsController.renderRows( ({ id, item }) => html` { + if (this.rowsSelectable) { + this.dispatchEvent( + new CustomEvent( + "btrix-select-row", + { + detail: { id, item }, + bubbles: true, + composed: true, + }, + ), + ); + } + }} > `, ) @@ -337,7 +391,7 @@ export class DataGrid extends TailwindElement { } }; - const removable = this.removeRows; + const removable = this.rowsRemovable; const editCells = this.editCells; rows.forEach((el) => { diff --git a/frontend/src/components/ui/data-grid/events/btrix-select-row.ts b/frontend/src/components/ui/data-grid/events/btrix-select-row.ts new file mode 100644 index 00000000..315e8eff --- /dev/null +++ b/frontend/src/components/ui/data-grid/events/btrix-select-row.ts @@ -0,0 +1,12 @@ +import type { GridItem, GridRowId } from "../types"; + +export type BtrixSelectRowEvent = CustomEvent<{ + id: GridRowId; + item: T; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "btrix-select-row": BtrixSelectRowEvent; + } +} diff --git a/frontend/src/components/ui/data-grid/renderRows.ts b/frontend/src/components/ui/data-grid/renderRows.ts index 8c436c0b..ab7a35c6 100644 --- a/frontend/src/components/ui/data-grid/renderRows.ts +++ b/frontend/src/components/ui/data-grid/renderRows.ts @@ -1,18 +1,19 @@ import { type TemplateResult } from "lit"; import { repeat } from "lit/directives/repeat.js"; +import type { EmptyObject } from "type-fest"; import type { GridItem, GridRowId, GridRows } from "./types"; export function renderRows( - rows: GridRows, + rows: GridRows, renderRow: ( - { id, item }: { id: GridRowId; item: T }, + { id, item }: { id: GridRowId; item: T | EmptyObject }, index: number, ) => TemplateResult, ) { return repeat( rows, ([id]) => id, - ([id, item], i) => renderRow({ id, item: item as T }, i), + ([id, item], i) => renderRow({ id, item }, i), ); } diff --git a/frontend/src/components/ui/data-grid/types.ts b/frontend/src/components/ui/data-grid/types.ts index cc353eae..6e2c271f 100644 --- a/frontend/src/components/ui/data-grid/types.ts +++ b/frontend/src/components/ui/data-grid/types.ts @@ -25,7 +25,7 @@ export type GridColumnSelectType = { }[]; }; -export type GridColumn = { +export type GridColumn = { field: T; label: string | TemplateResult; description?: string; @@ -33,11 +33,13 @@ export type GridColumn = { required?: boolean; inputPlaceholder?: string; width?: string; + align?: "start" | "center" | "end"; renderEditCell?: (props: { - item: GridItem; - value?: GridItem[keyof GridItem]; + item: Item; + value?: Item[keyof Item]; }) => TemplateResult<1>; - renderCell?: (props: { item: GridItem }) => TemplateResult<1>; + renderCell?: (props: { item: Item }) => TemplateResult<1>; + renderCellTooltip?: (props: { item: Item }) => TemplateResult<1>; } & ( | { inputType?: GridColumnType; diff --git a/frontend/src/features/crawl-workflows/link-selector-table.ts b/frontend/src/features/crawl-workflows/link-selector-table.ts index b45e505b..dd5d92b9 100644 --- a/frontend/src/features/crawl-workflows/link-selector-table.ts +++ b/frontend/src/features/crawl-workflows/link-selector-table.ts @@ -5,10 +5,13 @@ import { html, type PropertyValues } from "lit"; import { customElement, property, queryAll } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import isEqual from "lodash/fp/isEqual"; +import type { EmptyObject } from "type-fest"; import { BtrixElement } from "@/classes/BtrixElement"; -import { DataGridRowsController } from "@/components/ui/data-grid/controllers/rows"; -import { renderRows } from "@/components/ui/data-grid/renderRows"; +import { + DataGridRowsController, + emptyItem, +} from "@/components/ui/data-grid/controllers/rows"; import type { SyntaxInput } from "@/components/ui/syntax-input"; import { FormControlController } from "@/controllers/formControl"; import type { BtrixChangeEvent } from "@/events/btrix-change"; @@ -24,11 +27,6 @@ type SelectorItem = { attribute: string; }; -const emptyItem = { - selector: "", - attribute: "", -}; - /** * Displays link selector crawl configuration in an editable table. * @@ -48,7 +46,7 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { @property({ type: Boolean }) editable = false; - readonly #rowsController = new DataGridRowsController(this); + readonly #rowsController = new DataGridRowsController(this); @queryAll("btrix-syntax-input") private readonly syntaxInputs!: NodeListOf; @@ -64,7 +62,7 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { const selectLinks: string[] = []; this.#rowsController.rows.forEach((val) => { - if (val === emptyItem) return; + if (this.#rowsController.isEmpty(val)) return; selectLinks.push(`${val.selector}${SELECTOR_DELIMITER}${val.attribute}`); }); @@ -76,7 +74,8 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { const selectLinks: string[] = []; this.#rowsController.rows.forEach((val) => { - if (!val.selector || !val.attribute) return; + if (this.#rowsController.isEmpty(val) || !val.selector || !val.attribute) + return; selectLinks.push(`${val.selector}${SELECTOR_DELIMITER}${val.attribute}`); }); @@ -122,7 +121,7 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { )} - ${renderRows(this.#rowsController.rows, this.row)} + ${this.#rowsController.renderRows(this.row)} @@ -144,11 +143,16 @@ export class LinkSelectorTable extends FormControl(BtrixElement) { } private readonly row = ( - { id, item }: { id: string; item: SelectorItem }, + { id, item }: { id: string; item: SelectorItem | EmptyObject }, i: number, ) => { - const sel = item.selector; - const attr = item.attribute; + let sel = ""; + let attr = ""; + + if (!this.#rowsController.isEmpty(item)) { + sel = item.selector; + attr = item.attribute; + } return html` 0 ? "border-t" : ""}> diff --git a/frontend/src/stories/components/DataGrid.stories.ts b/frontend/src/stories/components/DataGrid.stories.ts index 6ec8108c..6242cd3d 100644 --- a/frontend/src/stories/components/DataGrid.stories.ts +++ b/frontend/src/stories/components/DataGrid.stories.ts @@ -33,6 +33,9 @@ type Story = StoryObj; * In its most basic configuration, the only required fields * are a list of items, and a list of columns that define which * key-value pairs of an item should be displayed. + * + * Nested keys are supported by specifying a deep path, e.g. + * `object.nestedObject.key`. */ export const Basic: Story = { args: {}, @@ -103,7 +106,7 @@ export const ColumnWidths: Story = { */ export const RemoveRows: Story = { args: { - removeRows: true, + rowsRemovable: true, }, }; @@ -112,7 +115,7 @@ export const RemoveRows: Story = { */ export const AddRows: Story = { args: { - addRows: true, + rowsAddible: true, defaultItem: { a: "A", b: "--", @@ -129,7 +132,7 @@ export const AddRows: Story = { export const AddRowsInput: Story = { name: "Add more than one row", args: { - addRows: true, + rowsAddible: true, addRowsInputValue: 5, defaultItem: { a: "A", @@ -141,6 +144,18 @@ export const AddRowsInput: Story = { }, }; +/** + * Rows can be selected. + * + * Open your browser console logs to view the clicked row. + */ +export const SelectRow: Story = { + args: { + items: makeItems(5), + rowsSelectable: true, + }, +}; + /** * Cells can be editable. */ @@ -262,9 +277,9 @@ export const FormControl: Story = { } formControlLabel="Page QA Table" stickyHeader="table" - addRows + rowsAddible addRowsInputValue="10" - removeRows + rowsRemovable editCells > ${renderRows( diff --git a/frontend/src/stories/components/DataGrid.ts b/frontend/src/stories/components/DataGrid.ts index 78262dae..6a3ba8ba 100644 --- a/frontend/src/stories/components/DataGrid.ts +++ b/frontend/src/stories/components/DataGrid.ts @@ -3,6 +3,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { nanoid } from "nanoid"; import type { DataGrid } from "@/components/ui/data-grid/data-grid"; +import type { BtrixSelectRowEvent } from "@/components/ui/data-grid/events/btrix-select-row"; import "@/components/ui/data-grid"; @@ -37,9 +38,11 @@ export const renderComponent = ({ items, formControlLabel, stickyHeader, - addRows, + rowsAddible, addRowsInputValue, - removeRows, + rowsRemovable, + rowsSelectable, + selectMode, editCells, defaultItem, }: Partial) => { @@ -50,10 +53,15 @@ export const renderComponent = ({ .defaultItem=${defaultItem} formControlLabel=${ifDefined(formControlLabel)} stickyHeader=${ifDefined(stickyHeader)} - ?addRows=${addRows} + ?rowsAddible=${rowsAddible} addRowsInputValue=${ifDefined(addRowsInputValue)} - ?removeRows=${removeRows} + ?rowsRemovable=${rowsRemovable} + ?rowsSelectable=${rowsSelectable} + selectMode=${ifDefined(selectMode)} ?editCells=${editCells} + @btrix-select-row=${(e: BtrixSelectRowEvent) => { + console.log("row clicked:", e.detail); + }} >
`;