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>
This commit is contained in:
		
							parent
							
								
									c73512dbd4
								
							
						
					
					
						commit
						7c9627f4bb
					
				| @ -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 }, | ||||
|     ); | ||||
|  | ||||
| @ -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 `<btrix-data-grid>`, it may be necessary to | ||||
|  * implement this controller on the container component. | ||||
|  */ | ||||
| export class DataGridRowsController implements ReactiveController { | ||||
| export class DataGridRowsController<Item extends GridItem = GridItem> | ||||
|   implements ReactiveController | ||||
| { | ||||
|   readonly #host: ReactiveControllerHost & | ||||
|     EventTarget & { | ||||
|       items?: GridItem[]; | ||||
|       rowKey?: DataGrid["rowKey"]; | ||||
|       defaultItem?: DataGrid["defaultItem"]; | ||||
|       removeRows?: DataGrid["removeRows"]; | ||||
|       addRows?: DataGrid["addRows"]; | ||||
|     }; | ||||
|       items?: Item[]; | ||||
|     } & Partial< | ||||
|       Pick<DataGrid, "rowKey" | "defaultItem" | "rowsRemovable" | "rowsAddible"> | ||||
|     >; | ||||
| 
 | ||||
|   #prevItems?: GridItem[]; | ||||
|   #prevItems?: Item[]; | ||||
| 
 | ||||
|   public rows: GridRows<GridItem> = new Map<GridRowId, GridItem>(); | ||||
|   public rows: GridRows<Item | EmptyObject> = new Map< | ||||
|     GridRowId, | ||||
|     Item | EmptyObject | ||||
|   >(); | ||||
| 
 | ||||
|   constructor(host: ReactiveControllerHost & EventTarget) { | ||||
|     this.#host = host; | ||||
| @ -46,22 +56,19 @@ export class DataGridRowsController implements ReactiveController { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private setRowsFromItems<T extends GridItem = GridItem>(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<T extends GridItem = GridItem>(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<T extends GridItem = GridItem>(id: GridRowId, item: T) { | ||||
|   public updateItem(id: GridRowId, item: Item) { | ||||
|     this.rows.set(id, item); | ||||
|     this.#host.requestUpdate(); | ||||
|   } | ||||
| 
 | ||||
|   public addRows<T extends GridItem = GridItem>( | ||||
|     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<Item>(this.rows, renderRow); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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 = ({ | ||||
|  | ||||
| @ -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<GridItem> = {}; | ||||
| 
 | ||||
| @ -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` | ||||
|         <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 | ||||
| @ -160,57 +205,58 @@ export class DataGridRow extends FormControl(TableRow) { | ||||
|       `;
 | ||||
|     } | ||||
| 
 | ||||
|     return html`${this.columns.map(this.renderCell)}${removeCell}`; | ||||
|     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 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` | ||||
|       <sl-tooltip | ||||
|         ?disabled=${!validationMessage} | ||||
|         content=${validationMessage || ""} | ||||
|         class="[--max-width:40ch]" | ||||
|         ?disabled=${!tooltipContent} | ||||
|         hoist | ||||
|         placement="bottom" | ||||
|         trigger=${ | ||||
|           // Manually show/hide tooltip on blur/focus
 | ||||
|           "manual" | ||||
|           // 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( | ||||
|             i > 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(); | ||||
|             } | ||||
|           }} | ||||
|         ></btrix-data-grid-cell> | ||||
| 
 | ||||
|         <div slot="content">${tooltipContent}</div> | ||||
|       </sl-tooltip> | ||||
|     `;
 | ||||
|   }; | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| } | ||||
| @ -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()} | ||||
|       </div> | ||||
| 
 | ||||
|       ${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` | ||||
|       <btrix-table | ||||
| @ -168,8 +183,9 @@ export class DataGrid extends TailwindElement { | ||||
|           this.stickyHeader === "table" && | ||||
|             tw`max-h-[calc(100vh-4rem)] overflow-y-auto`, | ||||
|         )} | ||||
|         style="--btrix-table-grid-template-columns: ${cssWidths.join(" ")}${this | ||||
|           .removeRows | ||||
|         style="--btrix-table-grid-template-columns: ${this.rowsExpandable | ||||
|           ? "max-content " | ||||
|           : ""}${cssWidths.join(" ")}${this.rowsRemovable | ||||
|           ? " max-content" | ||||
|           : ""}" | ||||
|         aria-labelledby=${ifDefined( | ||||
| @ -181,14 +197,29 @@ export class DataGrid extends TailwindElement { | ||||
|           class=${clsx( | ||||
|             tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`, | ||||
|             this.stickyHeader | ||||
|               ? tw`sticky top-0 z-10 rounded-t-[0.1875rem] border-b bg-neutral-50 [&>*: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` | ||||
|                 <btrix-table-header-cell> | ||||
|                   <span class="sr-only">${msg("Expand row")}</span> | ||||
|                 </btrix-table-header-cell> | ||||
|               ` | ||||
|             : nothing} | ||||
|           ${this.columns.map( | ||||
|             (col) => html` | ||||
|               <btrix-table-header-cell | ||||
|                 class=${clsx(col.description && tw`flex-wrap`)} | ||||
|                 class=${clsx( | ||||
|                   col.description && tw`flex-wrap`, | ||||
|                   col.align === "center" && tw`justify-center`, | ||||
|                   col.align === "end" && tw`justify-end`, | ||||
|                 )} | ||||
|               > | ||||
|                 ${col.label} | ||||
|                 ${col.description | ||||
| @ -204,7 +235,7 @@ export class DataGrid extends TailwindElement { | ||||
|               </btrix-table-header-cell> | ||||
|             `,
 | ||||
|           )} | ||||
|           ${this.removeRows | ||||
|           ${this.rowsRemovable | ||||
|             ? html`<btrix-table-header-cell>
 | ||||
|                 <span class="sr-only">${msg("Remove row")}</span> | ||||
|               </btrix-table-header-cell>` | ||||
| @ -212,12 +243,19 @@ export class DataGrid extends TailwindElement { | ||||
|         </btrix-table-head> | ||||
|         <btrix-table-body | ||||
|           class=${clsx( | ||||
|             "data-grid-body data-grid-body--horizontalRule", | ||||
|             this.stickyHeader | ||||
|               ? "data-grid-body--stickyHeader" | ||||
|               : "data-grid-body--not-stickyHeader", | ||||
|             this.rowsSelectable && "data-grid-body--rowsSelectable", | ||||
|             this.rowsAddible | ||||
|               ? "data-grid-body--rowsAddible" | ||||
|               : !this.addRowsInputValue && "data-grid-body--not-rowsAddible", | ||||
|             this.editCells && "data-grid-body--editCells", | ||||
|             tw`[--btrix-table-cell-padding:var(--sl-spacing-x-small)]`, | ||||
|             tw`bg-[var(--sl-panel-background-color)] leading-none`, | ||||
|             !this.stickyHeader && [ | ||||
|               tw`border`, | ||||
|               addRowsInputValue ? tw`rounded-t` : tw`rounded`, | ||||
|             ], | ||||
|             !this.stickyHeader && tw`border`, | ||||
|             addRowsInputValue ? tw`rounded-t` : tw`rounded`, | ||||
|           )} | ||||
|           @btrix-remove=${(e: CustomEvent<RowRemoveEventDetail>) => { | ||||
|             const { key } = e.detail; | ||||
| @ -288,15 +326,31 @@ export class DataGrid extends TailwindElement { | ||||
|     return html` | ||||
|       <slot name="rows" class="contents" @slotchange=${this.onRowSlotChange}> | ||||
|         ${this.items | ||||
|           ? renderRows( | ||||
|               this.rowsController.rows, | ||||
|           ? this.rowsController.renderRows( | ||||
|               ({ id, item }) => html` | ||||
|                 <btrix-data-grid-row | ||||
|                   key=${id} | ||||
|                   .item=${item} | ||||
|                   .columns=${this.columns} | ||||
|                   ?removable=${this.removeRows} | ||||
|                   alignContent=${ifDefined(this.alignRows)} | ||||
|                   ?removable=${this.rowsRemovable} | ||||
|                   ?clickable=${this.rowsSelectable} | ||||
|                   ?expandable=${this.rowsExpandable} | ||||
|                   ?editCells=${this.editCells} | ||||
|                   @click=${() => { | ||||
|                     if (this.rowsSelectable) { | ||||
|                       this.dispatchEvent( | ||||
|                         new CustomEvent<BtrixSelectRowEvent["detail"]>( | ||||
|                           "btrix-select-row", | ||||
|                           { | ||||
|                             detail: { id, item }, | ||||
|                             bubbles: true, | ||||
|                             composed: true, | ||||
|                           }, | ||||
|                         ), | ||||
|                       ); | ||||
|                     } | ||||
|                   }} | ||||
|                 ></btrix-data-grid-row> | ||||
|               `,
 | ||||
|             ) | ||||
| @ -337,7 +391,7 @@ export class DataGrid extends TailwindElement { | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     const removable = this.removeRows; | ||||
|     const removable = this.rowsRemovable; | ||||
|     const editCells = this.editCells; | ||||
| 
 | ||||
|     rows.forEach((el) => { | ||||
|  | ||||
| @ -0,0 +1,12 @@ | ||||
| import type { GridItem, GridRowId } from "../types"; | ||||
| 
 | ||||
| export type BtrixSelectRowEvent<T = GridItem> = CustomEvent<{ | ||||
|   id: GridRowId; | ||||
|   item: T; | ||||
| }>; | ||||
| 
 | ||||
| declare global { | ||||
|   interface GlobalEventHandlersEventMap { | ||||
|     "btrix-select-row": BtrixSelectRowEvent; | ||||
|   } | ||||
| } | ||||
| @ -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<T = GridItem>( | ||||
|   rows: GridRows<GridItem>, | ||||
|   rows: GridRows<T | EmptyObject>, | ||||
|   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), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -25,7 +25,7 @@ export type GridColumnSelectType = { | ||||
|   }[]; | ||||
| }; | ||||
| 
 | ||||
| export type GridColumn<T = string> = { | ||||
| export type GridColumn<T = string, Item = GridItem> = { | ||||
|   field: T; | ||||
|   label: string | TemplateResult; | ||||
|   description?: string; | ||||
| @ -33,11 +33,13 @@ export type GridColumn<T = string> = { | ||||
|   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; | ||||
|  | ||||
| @ -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<SelectorItem>(this); | ||||
| 
 | ||||
|   @queryAll("btrix-syntax-input") | ||||
|   private readonly syntaxInputs!: NodeListOf<SyntaxInput>; | ||||
| @ -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) { | ||||
|           )} | ||||
|         </btrix-table-head> | ||||
|         <btrix-table-body class="overflow-auto"> | ||||
|           ${renderRows<SelectorItem>(this.#rowsController.rows, this.row)} | ||||
|           ${this.#rowsController.renderRows(this.row)} | ||||
|         </btrix-table-body> | ||||
|       </btrix-table> | ||||
| 
 | ||||
| @ -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` | ||||
|       <btrix-table-row class=${i > 0 ? "border-t" : ""}> | ||||
|  | ||||
| @ -33,6 +33,9 @@ type Story = StoryObj<RenderProps>; | ||||
|  * 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( | ||||
|  | ||||
| @ -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<RenderProps>) => { | ||||
| @ -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); | ||||
|       }} | ||||
|     > | ||||
|     </btrix-data-grid> | ||||
|   `;
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user