diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 5fed8d4e..4785e954 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -31,6 +31,7 @@ module.exports = { "textarea", "popover", "notification", + "table", ], ], }, diff --git a/src/baklava.ts b/src/baklava.ts index 43ab0fd7..2516e234 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -25,4 +25,10 @@ export { default as BlDropdownGroup } from "./components/dropdown/group/bl-dropd export { default as BlSwitch } from "./components/switch/bl-switch"; export { default as BlNotification } from "./components/notification/bl-notification"; export { default as BlNotificationCard } from "./components/notification/card/bl-notification-card"; +export { default as BlTable } from "./components/table/bl-table"; +export { default as BlTableHeader } from "./components/table/table-header/bl-table-header"; +export { default as BlTableBody } from "./components/table/table-body/bl-table-body"; +export { default as BlTableRow } from "./components/table/table-row/bl-table-row"; +export { default as BlTableHeaderCell } from "./components/table/table-header-cell/bl-table-header-cell"; +export { default as BlTableCell } from "./components/table/table-cell/bl-table-cell"; export { getIconPath, setIconPath } from "./utilities/asset-paths"; diff --git a/src/components/icon/icon-list.ts b/src/components/icon/icon-list.ts index df58defc..bb14f09c 100644 --- a/src/components/icon/icon-list.ts +++ b/src/components/icon/icon-list.ts @@ -194,6 +194,8 @@ const icons = [ "shopping_bag_discount", "shopping_bag_return", "sorting", + "sorting_asc", + "sorting_desc", "sound_off", "sound_on", "split_money", diff --git a/src/components/icon/icons/sorting_asc.svg b/src/components/icon/icons/sorting_asc.svg new file mode 100644 index 00000000..f78bb925 --- /dev/null +++ b/src/components/icon/icons/sorting_asc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/icon/icons/sorting_desc.svg b/src/components/icon/icons/sorting_desc.svg new file mode 100644 index 00000000..263b7224 --- /dev/null +++ b/src/components/icon/icons/sorting_desc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/table/bl-table.css b/src/components/table/bl-table.css new file mode 100644 index 00000000..23a3446c --- /dev/null +++ b/src/components/table/bl-table.css @@ -0,0 +1,18 @@ +:host { + display: block; + height: 100%; +} + +.table-wrapper { + overflow: auto; + border: 1px solid var(--bl-color-neutral-lighter); + border-radius: var(--bl-size-3xs); + position: relative; + max-height: 100%; +} + +.table { + width: 100%; + display: table; + border-spacing: 0; +} diff --git a/src/components/table/bl-table.stories.mdx b/src/components/table/bl-table.stories.mdx new file mode 100644 index 00000000..a86eb4f5 --- /dev/null +++ b/src/components/table/bl-table.stories.mdx @@ -0,0 +1,564 @@ +import {html} from 'lit'; +import {Meta, Canvas, ArgsTable, Story} from '@storybook/addon-docs'; +import { UPDATE_STORY_ARGS } from "@storybook/core-events"; + + + +export const TableTemplate = (args, {id}) => { + const updateArgs = (_args)=>{ + window.__STORYBOOK_ADDONS_CHANNEL__.emit(UPDATE_STORY_ARGS, { + storyId: id, + updatedArgs: _args, + }); + + } + let {data, headers, selectValue, sortedData} = args; + const tableData = sortedData || data; + const onSort = (event) => { + const [sortKey, sortDirection] = event.detail; + const columnType = sortKey==="id" ? "number" : "string"; + updateArgs({ + ...args, + sortedData: (sortKey&&sortDirection)? [...data].sort((a, b) => { + if (columnType === 'string') { + return a[sortKey].localeCompare(b[sortKey], 'tr', { sensitivity: 'base' }) * (sortDirection === 'asc' ? 1 : -1); + } + if (a[sortKey] < b[sortKey]) { + return sortDirection === 'asc' ? -1 : 1; + } + if (a[sortKey] > b[sortKey]) { + return sortDirection === 'asc' ? 1 : -1; + } + return 0; + }) : [...data], + }); + } + const onRowSelect = (event) => { + updateArgs({ + ...args, + selectValue: event.detail, + }); + } + + return html` +
+ + + + ${args.headers.map((header) => + html` + + ${header.text} + ${header.tooltip && + html`
+ + + ${header.tooltip} + +
`} +
` + )} +
+
+ ${tableData.map((row) => + html` + ${headers.map((header, col_idx) => + html` ${row[header.key]}` + )} + ` + )} +
+
+ `; +} + +# Table Component + +Table is a component to visualize a data set in rows and columns. It needs to be used with `bl-table-header`, `bl-table-body`, `bl-table-row`, `bl-table-header-cell`, `bl-table-cell` component. + + + + {TableTemplate.bind({})} + + + + +# Row Selection + +To enable row selection, set the `selectable` attribute on ``. This allows users to select rows. Listen for the `@bl-row-select` event to handle row selection changes. This event provides details about the selected rows, enabling actions or updates based on the user's selection. + + + + {TableTemplate.bind({})} + + + +# Multiple Row Selection + +To enable multiple row selection, set both `selectable` and `multiple` attributes on ``. This modification allows users to select or unselect all rows by using checkbox in header. The `@bl-row-select` event is also used here to handle changes in row selection, providing details about all selected rows. + + + + {TableTemplate.bind({})} + + + +# Default Selected Value + +To pre-select a row or rows upon initialization, provide the `selected` attribute with the ID(s) of the row(s) to be selected by default. This is useful for situations where certain rows need to be highlighted as selected when the table is first rendered. + + + + {TableTemplate.bind({})} + + + +# Sortable + +To enable sorting functionality on a column, assign `sortable`, `sort-key`, and optionally `sort-direction` attributes to ``. The sort-key should correspond to the data field associated with the column, and sort-direction can be set to asc or desc to control the initial sorting direction. + + + + {TableTemplate.bind({})} + + + +# Default Sorted Column + +To set a column as the default sorted column when the table loads, specify the `sort-key` and `sort-direction` on ` `to match the desired column and direction. This sets the initial sort state for the table. + + + + {TableTemplate.bind({})} + + + +# Sticky Header + +To make the table header sticky, meaning it stays at the top of the table viewport as the user scrolls, use the `sticky` attribute on ``. + + + + {TableTemplate.bind({})} + + + +# Sticky First Column + +To make the first column of the table sticky, meaning it stays visible at the left side of the table viewport as the user scrolls horizontally, use the `sticky-first-column` attribute on ``. + + + + {TableTemplate.bind({})} + + + +# Sticky Last Column + +Similar to the sticky first column, to make the last column of the table sticky, meaning it stays visible at the right side of the table viewport as the user scrolls horizontally, use the `sticky-last-column` attribute on ``. + + + {TableTemplate.bind({})} + + + +## Reference +### bl-table + +___ +### bl-table-header + +___ +### bl-table-body +There is no prop or css properties for `bl-table-body`. +___ +### bl-table-row + +___ +### bl-table-header-cell + +___ +### bl-table-cell + +___ diff --git a/src/components/table/bl-table.test.ts b/src/components/table/bl-table.test.ts new file mode 100644 index 00000000..0a6f65f1 --- /dev/null +++ b/src/components/table/bl-table.test.ts @@ -0,0 +1,1354 @@ +import {expect, fixture, html, oneEvent} from "@open-wc/testing"; +import BlTable, { blRowSelectChangeEventName, blSortChangeEventName } from "./bl-table"; +import "./table-header/bl-table-header"; +import "./table-cell/bl-table-cell"; +import "./table-header-cell/bl-table-header-cell"; +import "./table-body/bl-table-body"; +import "./table-row/bl-table-row"; + +describe("bl-table", () => { + it("should be defined table instance", () => { + //when + const el = document.createElement("bl-table"); + + //then + expect(el).instanceOf(BlTable); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + //then + expect(el).shadowDom.equal( + `
+
+ + +
+
+
` + ); + }); + + it("should be rendered with sortable", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + const sortIcon = tableHeaderCell!.shadowRoot!.querySelector(".sort-icons-wrapper bl-icon") as HTMLElement; + + //then + expect(sortIcon.getAttribute("name")).to.equal( + "sorting" + ); + }); + + it("should be rendered with initial sorted as asc", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + const sortIcon = tableHeaderCell!.shadowRoot!.querySelector(".sort-icons-wrapper bl-icon") as HTMLElement; + + //then + expect(sortIcon.getAttribute("name")).to.equal( + "sorting_asc" + ); + }); + + it("should be rendered with initial sorted as desc", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + const sortIcon = tableHeaderCell!.shadowRoot!.querySelector(".sort-icons-wrapper bl-icon") as HTMLElement; + + //then + expect(sortIcon.getAttribute("name")).to.equal( + "sorting_desc" + ); + }); + + it("should be rendered with initial selected row", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const firstTableRow = el.querySelector("bl-table-body bl-table-row"); + + //then + expect(firstTableRow!.getAttribute("checked")).to.equal( + "true" + ); + }); + + it("should be rendered with sticky first column", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const tableHeaderCell = el.querySelector("bl-table-header-cell")!.shadowRoot!.querySelector(".table-header-cell"); + const tableCellList = el.querySelectorAll("bl-table-body bl-table-row bl-table-cell:first-child"); + + //then + tableCellList.forEach(tc=>{ + const tableCell = tc!.shadowRoot!.querySelector(".table-cell"); + + expect(tableCell!.className).to.equal( + "table-cell shadow-right" + ); + }); + expect(tableHeaderCell!.className).to.equal( + "table-header-cell shadow-right" + ); + }); + + it("should be rendered with sticky last column", async () => { + //when + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + const tableHeaderCell = el.querySelector("bl-table-header bl-table-row bl-table-header-cell:last-child")!.shadowRoot!.querySelector(".table-header-cell"); + const tableCellList = el.querySelectorAll("bl-table-body bl-table-row bl-table-cell:last-child"); + + //then + tableCellList.forEach(tc=>{ + const tableCell = tc!.shadowRoot!.querySelector(".table-cell"); + + expect(tableCell!.className).to.equal( + "table-cell shadow-left" + ); + }); + expect(tableHeaderCell!.className).to.equal( + "table-header-cell shadow-left" + ); + }); + + describe("events", () => { + it("should fire bl-sort event as asc when user click on sortable table header cell that not sorted", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const sortIcons = tableHeaderCell.shadowRoot.querySelector(".sort-icons-wrapper") as HTMLElement; + + setTimeout(() => sortIcons?.click()); + } + + const ev = await oneEvent(el, blSortChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal(["id", "asc"]); + }); + + it("should fire bl-sort event as desc when user click on sortable table header cell that sorted as asc", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const sortIcons = tableHeaderCell.shadowRoot.querySelector(".sort-icons-wrapper") as HTMLElement; + + setTimeout(() => sortIcons?.click()); + } + + const ev = await oneEvent(el, blSortChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal(["id", "desc"]); + }); + + it("should fire bl-sort event when user click on sortable table header cell that sorted as desc", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const sortIcons = tableHeaderCell.shadowRoot.querySelector(".sort-icons-wrapper") as HTMLElement; + + setTimeout(() => sortIcons?.click()); + } + + const ev = await oneEvent(el, blSortChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal(["id", ""]); + }); + + it("should fire bl-row-select event when user checked on checkbox in header row", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + + 3 + + + Name + + + Surname + + + name1@test.net + + + Male + + + 255.169.123.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const checkbox = tableHeaderCell.shadowRoot.querySelector("bl-checkbox") as HTMLElement; + const checkboxEvent = new CustomEvent("bl-checkbox-change", { + detail: true, + }); + + setTimeout(() => checkbox?.dispatchEvent(checkboxEvent)); + } + + const ev = await oneEvent(el, blRowSelectChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal([ + "row-1", + "row-3", + ]); + }); + it("should fire bl-row-select event with unchecked all rows when user checked on checkbox when all available rows are selected", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + + 3 + + + Name + + + Surname + + + name1@test.net + + + Male + + + 255.169.123.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const checkbox = tableHeaderCell.shadowRoot.querySelector("bl-checkbox") as HTMLElement; + const checkboxEvent = new CustomEvent("bl-checkbox-change", { + detail: true, + }); + + setTimeout(() => checkbox?.dispatchEvent(checkboxEvent)); + } + + const ev = await oneEvent(el, blRowSelectChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal([ ]); + }); + + it("should fire bl-row-select event when user unchecked on checkbox in header row", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableHeaderCell = el.querySelector("bl-table-header-cell"); + + if(tableHeaderCell?.shadowRoot){ + const checkbox = tableHeaderCell.shadowRoot.querySelector("bl-checkbox") as HTMLElement; + const checkboxEvent = new CustomEvent("bl-checkbox-change", { + detail: false, + }); + + setTimeout(() => checkbox?.dispatchEvent(checkboxEvent)); + } + + const ev = await oneEvent(el, blRowSelectChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal([]); + }); + + it("should fire bl-row-select event when user checked on checkbox in first table row", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableCell = el.querySelector("bl-table-cell"); + + if(tableCell?.shadowRoot){ + const checkbox = tableCell.shadowRoot.querySelector("bl-checkbox") as HTMLElement; + const checkboxEvent = new CustomEvent("bl-checkbox-change", { + detail: true, + }); + + setTimeout(() => checkbox?.dispatchEvent(checkboxEvent)); + } + + const ev = await oneEvent(el, blRowSelectChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal(["row-1"]); + }); + + it("should fire bl-row-select event when user unchecked on checkbox in first table row", async () => { + const el = await fixture( + html` + + + + + ID + + + First Name + + + Last Name + + + Email + + + Gender + + + IP Address + + + + + + + 1 + + + Antonella + + + Bellefonte + + + abellefonte0@nba.com + + + Female + + + 193.108.174.118 + + + + + 2 + + + Wash + + + Carnson + + + wcarnson1@jalbum.net + + + Male + + + 255.169.128.60 + + + + ` + ); + + const tableCell = el.querySelector("bl-table-cell"); + + if(tableCell?.shadowRoot){ + const checkbox = tableCell.shadowRoot.querySelector("bl-checkbox") as HTMLElement; + const checkboxEvent = new CustomEvent("bl-checkbox-change", { + detail: false, + }); + + setTimeout(() => checkbox?.dispatchEvent(checkboxEvent)); + } + + const ev = await oneEvent(el, blRowSelectChangeEventName); + + expect(ev).to.exist; + expect(ev.detail).to.be.deep.equal(["row-2"]); + }); + }); +}); diff --git a/src/components/table/bl-table.ts b/src/components/table/bl-table.ts new file mode 100644 index 00000000..16f621b9 --- /dev/null +++ b/src/components/table/bl-table.ts @@ -0,0 +1,279 @@ +import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import "element-internals-polyfill"; +import { event, EventDispatcher } from "../../utilities/event"; +import style from "./bl-table.css"; +import BlTableCell from "./table-cell/bl-table-cell"; +import BlTableHeaderCell from "./table-header-cell/bl-table-header-cell"; +import BlTableRow from "./table-row/bl-table-row"; + +export const blTableTag = "bl-table"; + +export const blSortChangeEventName = "bl-sort"; +export const blRowSelectChangeEventName = "bl-row-select"; + +export type SortDirection = "asc" | "desc" | ""; + +/** + * @tag bl-table + * @summary Baklava Table component + * + */ +@customElement(blTableTag) +export default class BlTable extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Selected table row selection key list + */ + @property({ type: Array, reflect: true, attribute: "selected" }) + get selected(): string[] { + return this._selectedValues; + } + + set selected(value: string[]) { + this._selectedValues = value; + this.updateComplete.then(() => { + this.querySelectorAll("bl-table-header-cell,bl-table-cell,bl-table-row").forEach(com => { + (com as BlTableHeaderCell | BlTableCell | BlTableRow).requestUpdate(); + }); + }); + } + + /** + * Sets table row as selectable + */ + @property({ type: Boolean, reflect: true }) + selectable = false; + + /** + * Sets table row multiple selection enable + */ + @property({ type: Boolean, reflect: true }) + multiple = false; + + /** + * Sets table as sortable + */ + @property({ type: Boolean, reflect: true }) + sortable = false; + /** + * Sets table first column as sticky + */ + @property({ type: Boolean, reflect: true, attribute: "sticky-first-column" }) + stickyFirstColumn = false; + /** + * Sets table last column as sticky + */ + @property({ type: Boolean, reflect: true, attribute: "sticky-last-column" }) + stickyLastColumn = false; + + /** + * Sets table sorted column key + */ + @property({ type: String, reflect: true, attribute: "sort-key" }) + get sortKey(): string { + return this._sortKey; + } + + set sortKey(value: string) { + this._sortKey = value; + } + + /** + * Sets table sorting direction + */ + @property({ type: String, reflect: true, attribute: "sort-direction" }) + get sortDirection(): SortDirection { + return this._sortDirection; + } + + set sortDirection(value: SortDirection) { + this._sortDirection = value; + } + + /** + * Fires when table sort options changed + */ + @event(blSortChangeEventName) private onSort: EventDispatcher; + + /** + * Fires when selected table rows changed + */ + @event(blRowSelectChangeEventName) private onRowSelect: EventDispatcher; + + @state() private _selectedValues: string[] = []; + + @state() private _sortKey: string = ""; + + @state() private _sortDirection: SortDirection = ""; + + protected updated(_changedProperties: PropertyValues) { + if ( + _changedProperties.has("selectable") || + _changedProperties.has("multiple") || + _changedProperties.has("stickyFirstColumn") || + _changedProperties.has("stickyLastColumn") || + _changedProperties.has("sortable") + ) { + this.querySelectorAll("bl-table-header-cell,bl-table-cell,bl-table-row").forEach(com => { + (com as BlTableHeaderCell | BlTableCell | BlTableRow).requestUpdate(); + }); + } + } + + get tableRows() { + return this.querySelectorAll("bl-table-body bl-table-row"); + } + + isFirstColumnSticky() { + return this.stickyFirstColumn; + } + + isLastColumnSticky() { + return this.stickyLastColumn; + } + + isSelectable(isHeaderCell = false) { + return isHeaderCell ? this.multiple && this.selectable : this.selectable; + } + + isRowSelected(selectionKey: string) { + return this.selected.includes(selectionKey); + } + + isAllSelected() { + return Array.from(this.tableRows).every(tr => this.selected.includes(tr.selectionKey)); + } + + isAnySelected() { + return ( + !this.isAllSelected() && + Array.from(this.tableRows).some(tr => !tr.disabled && this.selected.includes(tr.selectionKey)) + ); + } + + isAllUnselectedDisabled() { + return Array.from(this.tableRows) + .filter(tr => !this.selected.includes(tr.selectionKey)) + .every(tr => tr.disabled); + } + + /** + * Handles selection changes for both header and row selections. + * @param isHeader - Indicates if the selection change is for the header. + * @param isSelected - The selection state. + * @param selectionKey - The key identifying the selected row. It must be there if it is not the header. + */ + onSelectionChange(isHeader: boolean = false, isSelected: boolean, selectionKey: string) { + if (isHeader) { + this.handleHeaderSelection(isSelected); + } else { + this.handleRowSelection(isSelected, selectionKey); + } + + this.notifyRowSelectionChange(); + } + + /** + * Updates selected values based on header selection. + * @param isSelected - The selection state. + */ + private handleHeaderSelection(isSelected: boolean) { + this.selected = isSelected ? this.getSelectedValuesFromRows() : []; + } + + /** + * Updates selected values based on row selection. + * @param isSelected - The selection state. + * @param selectionKey - The key identifying the selected row. + */ + private handleRowSelection(isSelected: boolean, selectionKey: string) { + if (isSelected) { + this.addSelection(selectionKey); + } else { + this.removeSelection(selectionKey); + } + } + + /** + * Notifies about the row selection change. + */ + private notifyRowSelectionChange() { + this.onRowSelect(this.selected); + } + + /** + * Adds a selection key to the selected values. + * @param selectionKey - The key to add. + */ + private addSelection(selectionKey: string) { + if (!this.selected.includes(selectionKey)) { + this.selected.push(selectionKey); + } + } + + /** + * Removes a selection key from the selected values. + * @param selectionKey - The key to remove. + */ + private removeSelection(selectionKey: string) { + this.selected = this.selected.filter(value => value !== selectionKey); + } + + /** + * Gets the selection keys from all selectable table rows. + * @returns An array of selection keys. + */ + private getSelectedValuesFromRows(): string[] { + return Array.from(this.tableRows) + .filter(tableRow => !tableRow.disabled) + .map(tableRow => tableRow.selectionKey); + } + + resetScrollPosition(): void { + const tableWrapper = this.shadowRoot?.querySelector(".table-wrapper"); + + if (tableWrapper) { + tableWrapper.scrollTo({ + behavior: "smooth", + top: 0, + }); + } + } + + onSortChange(sortKey: string, sortDirection: SortDirection) { + this._sortKey = sortKey; + this._sortDirection = sortDirection; + this.onSort([this.sortKey, this.sortDirection]); + this.updateComplete.then(() => { + this.querySelectorAll("bl-table-header-cell").forEach(com => { + (com as BlTableHeaderCell).requestUpdate(); + }); + }); + this.resetScrollPosition(); + } + + render(): TemplateResult { + return html`
+
+ + +
+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableTag]: BlTable; + } + + interface HTMLElementEventMap { + [blSortChangeEventName]: CustomEvent; + [blRowSelectChangeEventName]: CustomEvent; + } +} diff --git a/src/components/table/table-body/bl-table-body.css b/src/components/table/table-body/bl-table-body.css new file mode 100644 index 00000000..d9cebbbd --- /dev/null +++ b/src/components/table/table-body/bl-table-body.css @@ -0,0 +1,3 @@ +:host { + display: table-row-group; +} diff --git a/src/components/table/table-body/bl-table-body.test.ts b/src/components/table/table-body/bl-table-body.test.ts new file mode 100644 index 00000000..f616c981 --- /dev/null +++ b/src/components/table/table-body/bl-table-body.test.ts @@ -0,0 +1,22 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import BlTableBody from "./bl-table-body"; + +describe("bl-table-body", () => { + it("should be defined table-body instance", () => { + //when + const el = document.createElement("bl-table-body"); + + //then + expect(el).instanceOf(BlTableBody); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture(html``); + + //then + expect(el).shadowDom.equal( + "" + ); + }); +}); diff --git a/src/components/table/table-body/bl-table-body.ts b/src/components/table/table-body/bl-table-body.ts new file mode 100644 index 00000000..e1cf30bf --- /dev/null +++ b/src/components/table/table-body/bl-table-body.ts @@ -0,0 +1,36 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement } from "lit/decorators.js"; +import { CSSResultGroup } from "lit/development"; +import "element-internals-polyfill"; +import { blTableTag } from "../bl-table"; +import type BlTable from "../bl-table"; +import style from "../table-body/bl-table-body.css"; + +export const blTableBodyTag = "bl-table-body"; + +/** + * @tag bl-table-body + * @summary Baklava Table component + */ +@customElement(blTableBodyTag) +export default class BlTableBody extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + connectedCallback(): void { + super.connectedCallback(); + if (!this.closest(blTableTag)) { + console.warn("bl-table-body is designed to be used inside a bl-table", this); + } + } + + render(): TemplateResult { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableBodyTag]: BlTableBody; + } +} diff --git a/src/components/table/table-cell/bl-table-cell.css b/src/components/table/table-cell/bl-table-cell.css new file mode 100644 index 00000000..c01cac3a --- /dev/null +++ b/src/components/table/table-cell/bl-table-cell.css @@ -0,0 +1,47 @@ +:host { + display: table-cell; + border: 1px solid var(--bl-color-neutral-lighter); + padding: var(--bl-size-m); + font: var(--bl-font-title-3-regular); + color: var(--bl-color-neutral-darker); + box-sizing: border-box; + vertical-align: middle; + word-break: break-word; + background-color: var(--bl-color-neutral-full); + background-clip: padding-box; + border-top: none; + border-right: none; +} + +.table-cell { + display: flex; + align-items: center; +} + +.table-cell.shadow-right::before { + content: ""; + position: absolute; + right: -1px; + top: 0; + width: 16px; + height: 100%; + z-index: -1; + border-right: 1px solid var(--bl-color-neutral-lighter); + box-shadow: 8px 0 16px 0 rgb(39 49 66 / 10%); +} + +.table-cell.shadow-left::before { + content: ""; + position: absolute; + left: -1px; + top: 0; + width: 16px; + height: 100%; + z-index: -1; + border-left: 1px solid var(--bl-color-neutral-lighter); + box-shadow: -8px 0 16px 0 rgb(39 49 66 / 10%); +} + +bl-checkbox { + margin-right: var(--bl-size-m); +} diff --git a/src/components/table/table-cell/bl-table-cell.test.ts b/src/components/table/table-cell/bl-table-cell.test.ts new file mode 100644 index 00000000..ef10f44e --- /dev/null +++ b/src/components/table/table-cell/bl-table-cell.test.ts @@ -0,0 +1,26 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import BlTableCell from "./bl-table-cell"; + +describe("bl-table-cell", () => { + it("should be defined table-cell instance", () => { + //when + const el = document.createElement("bl-table-cell"); + + //then + expect(el).instanceOf(BlTableCell); + expect(el.index).to.equal(-1); + expect(el.selectionKey).to.equal(""); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture(html``); + + //then + expect(el).shadowDom.equal( + `
+ +
` + ); + }); +}); diff --git a/src/components/table/table-cell/bl-table-cell.ts b/src/components/table/table-cell/bl-table-cell.ts new file mode 100644 index 00000000..4cca1b3a --- /dev/null +++ b/src/components/table/table-cell/bl-table-cell.ts @@ -0,0 +1,95 @@ +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { CSSResultGroup } from "lit/development"; +import "element-internals-polyfill"; +import "../../checkbox-group/checkbox/bl-checkbox"; +import style from "../table-cell/bl-table-cell.css"; +import BlTableRow, { blTableRowTag } from "../table-row/bl-table-row"; + +export const blTableCellTag = "bl-table-cell"; +/** + * @tag bl-table-cell + * @summary Baklava Table component + */ +@customElement(blTableCellTag) +export default class BlTableCell extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Disable selection + */ + @property({ type: Boolean, reflect: true, attribute: "disabled" }) + disableSelection = false; + + private get _table() { + return this.closest("bl-table"); + } + private get _tableRow() { + return this.closest("bl-table-row"); + } + get disabled() { + return this.disableSelection; + } + get selectable() { + return this.index === 0 && !!this._table?.isSelectable(false) && this.selectionKey; + } + get index() { + const parent = this.parentNode; + + if (!parent) { + return -1; + } + return [...parent.children].indexOf(this); + } + get selectionKey(): string { + return this._tableRow ? this._tableRow.selectionKey : ""; + } + get checked() { + return !!this._tableRow?.checked; + } + get shadowRight() { + return !!this._tableRow?.stickyFirstColumn && this.index === 0; + } + get shadowLeft() { + return !!this._tableRow?.stickyLastColumn && this.nextElementSibling === null; + } + + connectedCallback(): void { + super.connectedCallback(); + if (!this.closest(blTableRowTag)) { + console.warn("bl-table-cell is designed to be used inside a bl-table-row", this); + } + } + + onChange(event: CustomEvent) { + this._table?.onSelectionChange(false, event.detail, this.selectionKey); + } + + private _renderCheckbox() { + return this.selectable + ? html` + ` + : null; + } + render(): TemplateResult { + const className = this.shadowRight ? "shadow-right" : this.shadowLeft ? "shadow-left" : ""; + + return html`
+ ${this._renderCheckbox()} + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableCellTag]: BlTableCell; + } +} diff --git a/src/components/table/table-header-cell/bl-table-header-cell.css b/src/components/table/table-header-cell/bl-table-header-cell.css new file mode 100644 index 00000000..2388a5b5 --- /dev/null +++ b/src/components/table/table-header-cell/bl-table-header-cell.css @@ -0,0 +1,71 @@ +:host { + --header-cell-width: var(--bl-table-header-cell-width, auto); + --header-cell-min-width: var(--bl-table-header-cell-min-width, auto); + + display: table-cell; + border: 1px solid var(--bl-color-neutral-lighter); + background-color: var(--bl-color-neutral-lightest); + padding: var(--bl-size-m); + font: var(--bl-font-title-3-medium); + color: var(--bl-color-neutral-darker); + box-sizing: border-box; + vertical-align: middle; + white-space: normal; + width: var(--header-cell-width); + min-width: var(--header-cell-min-width); + background-clip: padding-box; +} + +.table-header-cell { + display: flex; + align-items: center; +} + +.table-header-cell.shadow-right::before { + content: ""; + position: absolute; + right: -1px; + top: 0; + width: 16px; + height: 100%; + z-index: -1; + border-right: 1px solid var(--bl-color-neutral-lighter); + box-shadow: 8px 0 16px 0 rgb(39 49 66 / 10%); +} + +.table-header-cell.shadow-left::before { + content: ""; + position: absolute; + left: -1px; + top: 0; + width: 16px; + height: 100%; + z-index: -1; + border-left: 1px solid var(--bl-color-neutral-lighter); + box-shadow: -8px 0 16px 0 rgb(39 49 66 / 10%); +} + +bl-checkbox { + margin-right: var(--bl-size-m); +} + +.sort-icons-wrapper { + all: unset; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bl-size-4xs); + cursor: pointer; +} + +.sort-icons-wrapper:focus-visible { + outline: 2px solid var(--bl-color-primary); + outline-offset: 2px; + border-radius: var(--bl-border-radius-xs); +} + +.sort-icons-wrapper bl-icon { + font-size: var(--bl-font-size-m); + color: var(--bl-color-neutral-darker); +} diff --git a/src/components/table/table-header-cell/bl-table-header-cell.test.ts b/src/components/table/table-header-cell/bl-table-header-cell.test.ts new file mode 100644 index 00000000..0699f9e7 --- /dev/null +++ b/src/components/table/table-header-cell/bl-table-header-cell.test.ts @@ -0,0 +1,25 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import BlTableHeaderCell from "./bl-table-header-cell"; + +describe("bl-table-header-cell", () => { + it("should be defined table-header-cell instance", () => { + //when + const el = document.createElement("bl-table-header-cell"); + + //then + expect(el).instanceOf(BlTableHeaderCell); + expect(el.index).to.equal(-1); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture(html``); + + //then + expect(el).shadowDom.equal( + `
+ +
` + ); + }); +}); diff --git a/src/components/table/table-header-cell/bl-table-header-cell.ts b/src/components/table/table-header-cell/bl-table-header-cell.ts new file mode 100644 index 00000000..9f5b068a --- /dev/null +++ b/src/components/table/table-header-cell/bl-table-header-cell.ts @@ -0,0 +1,165 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import "element-internals-polyfill"; +import "../../checkbox-group/checkbox/bl-checkbox"; +import BlCheckbox from "../../checkbox-group/checkbox/bl-checkbox"; +import "../../icon/bl-icon"; +import { BaklavaIcon } from "../../icon/icon-list"; +import { SortDirection } from "../bl-table"; +import type BlTableRow from "../table-row/bl-table-row"; +import { blTableRowTag } from "../table-row/bl-table-row"; +import style from "./bl-table-header-cell.css"; + +export const blTableHeaderCellTag = "bl-table-header-cell"; + +/** + * @tag bl-table-header-cell + * @summary Baklava Table component + * + * @cssproperty [--bl-table-header-cell-width] Set the column width + * @cssproperty [--bl-table-header-cell-min-width] Set the column min width + */ +@customElement(blTableHeaderCellTag) +export default class BlTableHeaderCell extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + /** + * Set key value for column + */ + @property({ type: String, reflect: true, attribute: "sort-key" }) + sortKey = ""; + + private get _table() { + return this.closest("bl-table"); + } + private get _tableRow() { + return this.closest("bl-table-row"); + } + get selectable() { + return this.index === 0 && !!this._table?.isSelectable(true); + } + get sortable() { + return !!this._table?.sortable && !!this.sortKey; + } + get index() { + const parent = this.parentNode; + + if (!parent) { + return -1; + } + return [...parent.children].indexOf(this); + } + get checked() { + return !!this._table?.isAllSelected(); + } + get indeterminate() { + return !!this._table?.isAnySelected(); + } + get isAllUnselectedDisabled() { + return !!this._table?.isAllUnselectedDisabled(); + } + get sortDirection(): string { + if (this._table?.sortKey === this.sortKey) { + return this._table?.sortDirection || ""; + } + + return ""; + } + get sortIconName(): BaklavaIcon { + if (this.sortDirection === "asc") { + return "sorting_asc"; + } else if (this.sortDirection === "desc") { + return "sorting_desc"; + } + + return "sorting"; + } + + get shadowRight() { + return !!this._tableRow?.stickyFirstColumn && this.index === 0; + } + get shadowLeft() { + return !!this._tableRow?.stickyLastColumn && this.nextElementSibling === null; + } + + connectedCallback(): void { + super.connectedCallback(); + if (!this.closest(blTableRowTag)) { + console.warn("bl-table-header-cell is designed to be used inside a bl-table-row", this); + } + } + + onChange(event: CustomEvent) { + const selectAllEl = this.shadowRoot?.querySelector(".select-all") as BlCheckbox; + + const checked = event.detail; + + // If all available rows are selected, instead of checking, uncheck all options + if (checked && this.isAllUnselectedDisabled) { + setTimeout(() => { + const checkbox = selectAllEl?.shadowRoot?.querySelector("input"); + + checkbox?.click(); + }, 0); + return; + } + this._table?.onSelectionChange(true, event.detail, ""); + setTimeout(() => { + selectAllEl.checked = this.checked; + selectAllEl.indeterminate = this.indeterminate; + }); + } + + onSort() { + let _sortDirection: SortDirection = "asc"; + + if (this.sortDirection === "asc") { + _sortDirection = "desc"; + } else if (this.sortDirection === "desc") { + _sortDirection = ""; + } + + this._table?.onSortChange(this.sortKey, _sortDirection); + } + + private _renderCheckbox() { + return this.selectable + ? html` + ` + : null; + } + + render(): TemplateResult { + const isAscending = this.sortDirection === "asc"; + const isDescending = this.sortDirection === "desc"; + const ariaSort = isAscending ? "ascending" : isDescending ? "descending" : undefined; + + const className = this.shadowRight ? "shadow-right" : this.shadowLeft ? "shadow-left" : ""; + const template = this.sortable + ? html` ` + : html` `; + + return html`
+ ${this._renderCheckbox()} ${template} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableHeaderCellTag]: BlTableHeaderCell; + } +} diff --git a/src/components/table/table-header/bl-table-header.css b/src/components/table/table-header/bl-table-header.css new file mode 100644 index 00000000..23e873a8 --- /dev/null +++ b/src/components/table/table-header/bl-table-header.css @@ -0,0 +1,24 @@ +:host { + display: table-header-group; +} + +:host([sticky]) { + position: sticky; + top: 0; + left: 0; + right: 0; + z-index: 3; + transition: top 0.05s ease; + box-shadow: 0 8px 16px 0 rgb(39 49 66 / 10%); +} + +:host([sticky])::after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--bl-color-neutral-lighter); +} diff --git a/src/components/table/table-header/bl-table-header.test.ts b/src/components/table/table-header/bl-table-header.test.ts new file mode 100644 index 00000000..9b69faf2 --- /dev/null +++ b/src/components/table/table-header/bl-table-header.test.ts @@ -0,0 +1,22 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import BlTableHeader from "./bl-table-header"; + +describe("bl-table-header", () => { + it("should be defined table-header instance", () => { + //when + const el = document.createElement("bl-table-header"); + + //then + expect(el).instanceOf(BlTableHeader); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture(html``); + + //then + expect(el).shadowDom.equal( + "" + ); + }); +}); diff --git a/src/components/table/table-header/bl-table-header.ts b/src/components/table/table-header/bl-table-header.ts new file mode 100644 index 00000000..0ed7b12b --- /dev/null +++ b/src/components/table/table-header/bl-table-header.ts @@ -0,0 +1,41 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import "element-internals-polyfill"; +import { blTableTag } from "../bl-table"; +import type BlTable from "../bl-table"; +import style from "../table-header/bl-table-header.css"; + +export const blTableHeaderTag = "bl-table-header"; + +/** + * @tag bl-table-header + * @summary Baklava Table component + */ +@customElement(blTableHeaderTag) +export default class BlTableHeader extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + /** + * Set table header as sticky + */ + @property({ type: Boolean, reflect: true }) + sticky = false; + + connectedCallback(): void { + super.connectedCallback(); + if (!this.closest(blTableTag)) { + console.warn("bl-table-header is designed to be used inside a bl-table", this); + } + } + + render(): TemplateResult { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableHeaderTag]: BlTableHeader; + } +} diff --git a/src/components/table/table-row/bl-table-row.css b/src/components/table/table-row/bl-table-row.css new file mode 100644 index 00000000..c18b5b30 --- /dev/null +++ b/src/components/table/table-row/bl-table-row.css @@ -0,0 +1,71 @@ +:host { + display: table-row; +} + +:host([checked]), +:host([checked]) ::slotted(bl-table-cell) { + background-color: var(--bl-color-primary-contrast); +} + +:host([disabled]), +:host([disabled]) ::slotted(bl-table-cell) { + background-color: var(--bl-color-neutral-lightest); + color: var(--bl-color-neutral-light); +} + +:host(:not([checked], [disabled]):hover), +:host(:not([checked], [disabled]):hover) ::slotted(bl-table-cell) { + background-color: var(--bl-color-tertiary-background); +} + +:host ::slotted(*:first-child) { + border-left: none; +} + +:host ::slotted(*:last-child) { + border-right: none; +} + +:host(:first-child) ::slotted(bl-table-header-cell) { + border-top: none; + border-right: none; +} + +:host(:first-child) ::slotted(bl-table-header-cell:first-child) { + border-top-left-radius: var(--bl-size-3xs); +} + +:host(:first-child) ::slotted(bl-table-header-cell:last-child) { + border-top-right-radius: var(--bl-size-3xs); + border-right: 1px; +} + +:host(:last-child) ::slotted(bl-table-cell) { + border-bottom: none; +} + +:host(:first-child) ::slotted(bl-table-cell) { + border-top: none; +} + +:host(:last-child) ::slotted(bl-table-cell:first-child) { + border-bottom-left-radius: var(--bl-size-3xs); +} + +:host(:last-child) ::slotted(bl-table-cell:last-child) { + border-bottom-right-radius: var(--bl-size-3xs); +} + +:host([sticky-first-column]) ::slotted(bl-table-header-cell:first-child), +:host([sticky-first-column]) ::slotted(bl-table-cell:first-child) { + position: sticky; + z-index: 2; + left: 0; +} + +:host([sticky-last-column]) ::slotted(bl-table-header-cell:last-child), +:host([sticky-last-column]) ::slotted(bl-table-cell:last-child) { + position: sticky; + z-index: 2; + right: 0; +} diff --git a/src/components/table/table-row/bl-table-row.test.ts b/src/components/table/table-row/bl-table-row.test.ts new file mode 100644 index 00000000..c80869ae --- /dev/null +++ b/src/components/table/table-row/bl-table-row.test.ts @@ -0,0 +1,22 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import BlTableRow from "./bl-table-row"; + +describe("bl-table-row", () => { + it("should be defined table-row instance", () => { + //when + const el = document.createElement("bl-table-row"); + + //then + expect(el).instanceOf(BlTableRow); + }); + + it("should be rendered with default values", async () => { + //when + const el = await fixture(html``); + + //then + expect(el).shadowDom.equal( + "" + ); + }); +}); diff --git a/src/components/table/table-row/bl-table-row.ts b/src/components/table/table-row/bl-table-row.ts new file mode 100644 index 00000000..a4aad7a6 --- /dev/null +++ b/src/components/table/table-row/bl-table-row.ts @@ -0,0 +1,104 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { CSSResultGroup } from "lit/development"; +import "element-internals-polyfill"; +import "../../checkbox-group/checkbox/bl-checkbox"; +import { blTableBodyTag } from "../table-body/bl-table-body"; +import type BlTableBody from "../table-body/bl-table-body"; +import BlTableCell from "../table-cell/bl-table-cell"; +import BlTableHeaderCell from "../table-header-cell/bl-table-header-cell"; +import { blTableHeaderTag } from "../table-header/bl-table-header"; +import type BlTableHeader from "../table-header/bl-table-header"; +import style from "../table-row/bl-table-row.css"; + +export const blTableRowTag = "bl-table-row"; + +/** + * @tag bl-table-row + * @summary Baklava Table component + */ +@customElement(blTableRowTag) +export default class BlTableRow extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * selection key for table row + */ + @property({ type: String, reflect: true, attribute: "selection-key" }) + selectionKey: string = ""; + + connectedCallback(): void { + super.connectedCallback(); + if ( + !this.closest(blTableHeaderTag) && + !this.closest(blTableBodyTag) + ) { + console.warn( + "bl-table-row is designed to be used inside a bl-table-header or bl-table-body", + this + ); + } + } + + updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + this.removeAttribute("checked"); + this.removeAttribute("disabled"); + this.removeAttribute("sticky-first-column"); + this.removeAttribute("sticky-last-column"); + + if (this.stickyFirstColumn) { + this.setAttribute("sticky-first-column", "true"); + } + if (this.stickyLastColumn) { + this.setAttribute("sticky-last-column", "true"); + } + if (this.checked) { + this.setAttribute("checked", "true"); + } else if (this.disabled) { + this.setAttribute("disabled", "true"); + } + if (_changedProperties.has("selectionKey")) { + this.updateComplete.then(() => { + Array.from(this.querySelectorAll("bl-table-header-cell,bl-table-cell")).map(com => { + (com as BlTableHeaderCell | BlTableCell).requestUpdate(); + }); + }); + } + } + + private get _table() { + return this.closest("bl-table"); + } + + private get _firstTableCell() { + return this.querySelector("bl-table-cell"); + } + get disabled() { + return !!this._firstTableCell?.disabled; + } + + get checked() { + return !!this._table?.isRowSelected(this.selectionKey); + } + + get stickyFirstColumn() { + return !!this._table?.isFirstColumnSticky(); + } + + get stickyLastColumn() { + return !!this._table?.isLastColumnSticky(); + } + + render(): TemplateResult { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blTableRowTag]: BlTableRow; + } +}