diff --git a/packages/components/grid/src/column-group.spec.ts b/packages/components/grid/src/column-group.spec.ts new file mode 100644 index 000000000..5859ae03c --- /dev/null +++ b/packages/components/grid/src/column-group.spec.ts @@ -0,0 +1,104 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import '../register.js'; +import { type Grid } from './grid.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +describe('sl-column-group', () => { + let el: Grid; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + + + + + + + + `); + el.items = [{ firstName: 'John', lastName: 'Doe', grades: { biology: 'A', maths: 'B', english: 'B+' } }]; + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + }); + + it('should render column headers', () => { + const columns = Array.from(el.renderRoot.querySelectorAll('th')).map(col => col.textContent); + + expect(columns).to.deep.equal([ + 'Name', + 'Grades', + 'First name', + 'Last name', + 'Biology', + 'Maths', + 'English', + 'Age' + ]); + }); + + it('should have the correct width', () => { + const cells = Array.from(el.renderRoot.querySelectorAll('th')); + expect(cells.map(cell => Math.floor(parseFloat(getComputedStyle(cell).width)))).to.deep.equal([ + 300, 481, 151, 148, 128, 120, 128, 103 + ]); + }); + + // it('should emit an sl-column-update event after clicking the checkbox', async () => { + // const columnUpdateEvent = spy(); + // const columnGoup = el.querySelector('sl-grid-column-group:first-of-type') as GridColumnGroup; + // columnGoup?.addEventListener('sl-column-update', columnUpdateEvent); + + // const newColumn = document.createElement('sl-grid-column'); + // await new Promise(resolve => setTimeout(resolve, 100)); + + // columnGoup?.appendChild(newColumn); + + // // expect(columnUpdateEvent).to.have.been.called; + // expect(columnGoup?.columns).to.equal(3); + // }); + }); + + describe('explicit width', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + + + + + + + `); + + el.items = [{ firstName: 'John', lastName: 'Doe', grades: { biology: 'A', maths: 'B', english: 'B+' } }]; + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + }); + + it('should have the correct width when one is set explicitly', () => { + const cells = Array.from(el.renderRoot.querySelectorAll('th')); + expect(cells.map(cell => Math.floor(parseFloat(getComputedStyle(cell).width)))).to.deep.equal([ + 209, 600, 177, 175, 155, 147, 155 + ]); + }); + }); +}); diff --git a/packages/components/grid/src/column-group.ts b/packages/components/grid/src/column-group.ts index acc617099..15a3aefbc 100644 --- a/packages/components/grid/src/column-group.ts +++ b/packages/components/grid/src/column-group.ts @@ -1,4 +1,3 @@ -import { getNameByPath } from '@sl-design-system/shared'; import { type PropertyValues, type TemplateResult, html } from 'lit'; import { state } from 'lit/decorators.js'; import { GridColumn } from './column.js'; @@ -35,7 +34,7 @@ export class GridColumnGroup extends GridColumn { } override renderHeader(): TemplateResult { - return html`${this.header ?? getNameByPath(this.path)}`; + return html`${this.header}`; } #onSlotchange(event: Event & { target: HTMLSlotElement }): void { diff --git a/packages/components/grid/src/column.spec.ts b/packages/components/grid/src/column.spec.ts new file mode 100644 index 000000000..b7bb754f6 --- /dev/null +++ b/packages/components/grid/src/column.spec.ts @@ -0,0 +1,104 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { Avatar } from '@sl-design-system/avatar'; +import '@sl-design-system/avatar/register.js'; +import { html } from 'lit'; +import { Person } from 'tools/example-data/index.js'; +import '../register.js'; +import { GridColumnDataRenderer } from './column.js'; +import { type Grid } from './grid.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +describe('sl-column', () => { + let el: Grid; + let cells: HTMLElement[]; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + el.items = [ + { firstName: 'John', lastName: 'Doe', age: 20 }, + { firstName: 'Jane', lastName: 'Smith', age: 40 } + ]; + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + + cells = Array.from(el.renderRoot.querySelectorAll('tbody tr:first-of-type td')); + }); + + it('should render column headers', () => { + const columns = Array.from(el.renderRoot.querySelectorAll('th')).map(col => col.textContent); + + expect(columns).to.deep.equal(['First name', 'Last name', 'Current age']); + }); + + it('should have the right justify-content value', () => { + expect(cells.map(cell => getComputedStyle(cell).justifyContent)).to.deep.equal(['start', 'start', 'end']); + }); + + it('should have the right grow value', () => { + expect(cells.map(cell => getComputedStyle(cell).flexGrow)).to.deep.equal(['1', '1', '3']); + }); + + it('should have the right parts', () => { + expect(cells.map(cell => cell.getAttribute('part'))).to.deep.equal([ + 'data first-name', + 'data last-name', + 'data age' + ]); + }); + }); + + describe('custom renderer', () => { + beforeEach(async () => { + const avatarRenderer: GridColumnDataRenderer = ({ firstName, lastName }) => { + return html``; + }; + + el = await fixture(html` + + + + + `); + el.items = [ + { firstName: 'John', lastName: 'Doe', age: 20 }, + { firstName: 'Jane', lastName: 'Smith', age: 40 } + ]; + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + + cells = Array.from(el.renderRoot.querySelectorAll('tbody tr:first-of-type td')); + }); + + it('should render the elements set with the custom renderer', () => { + const avatar = cells[0].querySelector('sl-avatar') as Avatar; + + expect(avatar).to.exist; + expect(avatar?.shadowRoot?.querySelector('[part="name"]')?.textContent).to.equal('John Doe'); + }); + + it('should have the right parts, including one set on the column', () => { + expect(cells.map(cell => cell.getAttribute('part'))).to.deep.equal(['data', 'data number age']); + }); + }); +}); diff --git a/packages/components/grid/src/column.ts b/packages/components/grid/src/column.ts index 91063e7cc..b556a85af 100644 --- a/packages/components/grid/src/column.ts +++ b/packages/components/grid/src/column.ts @@ -43,7 +43,7 @@ export class GridColumn extends LitElement { #width?: number; /** The alignment of the content within the column. */ - @property() align: GridColumnAlignment = 'start'; + @property() align?: GridColumnAlignment; /** * Automatically sets the width of the column based on the column contents when this is set to `true`. @@ -160,6 +160,7 @@ export class GridColumn extends LitElement { if (typeof this.parts === 'string') { parts = this.parts.split(' '); } else if (typeof this.parts === 'function' && item) { + // TODO: what does this do? How can parts ever be a function? According to the typing this should not be possible. parts = this.parts(item)?.split(' ') ?? []; } diff --git a/packages/components/grid/src/filter-column.spec.ts b/packages/components/grid/src/filter-column.spec.ts new file mode 100644 index 000000000..e4d0d3408 --- /dev/null +++ b/packages/components/grid/src/filter-column.spec.ts @@ -0,0 +1,296 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import '../register.js'; +import { type Grid } from './grid.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +const ITEMS = [ + { + firstName: 'John', + lastName: 'Doe', + profession: 'Endocrinologist', + status: 'Available', + membership: 'Regular' + }, + { + firstName: 'Jane', + lastName: 'Smith', + profession: 'Anesthesiologist', + status: 'Busy', + membership: 'Premium' + }, + { + firstName: 'Jimmy', + lastName: 'Adams', + profession: 'Cardiologist', + status: 'Busy', + membership: 'Premium' + } +]; + +describe('sl-grid-filter-column', () => { + let el: Grid; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should render column and filter column headers', () => { + const columns = Array.from(el.renderRoot.querySelectorAll('th')); + const filterColumns = Array.from(el.renderRoot?.querySelectorAll('sl-grid-filter')).map(col => + col.textContent?.trim() + ); + + expect(columns).to.exist; + expect(filterColumns).to.exist; + + expect(columns.map(col => col.getAttribute('part')?.trim())).to.deep.equal([ + 'header filter profession', + 'header filter status', + 'header filter membership' + ]); + expect(filterColumns).to.deep.equal(['Profession', 'Status', 'Membership']); + }); + + it('should have no filter mode by default', () => { + const filterColumn = el.querySelector('sl-grid-filter-column'); + + expect(filterColumn).to.exist; + expect(filterColumn).not.to.have.attribute('mode'); + }); + + it('should have no active filter by default', () => { + const active = Array.from(el.renderRoot.querySelectorAll('sl-grid-filter')).map(filter => + filter.hasAttribute('active') + ); + + expect(active).to.deep.equal([false, false, false]); + }); + + it('should have a button with the right icon when it is not filtered', () => { + const filter = el.renderRoot.querySelector('sl-grid-filter'), + button = filter?.renderRoot.querySelector('sl-button'), + icon = button?.querySelector('sl-icon'); + + expect(icon).to.exist; + expect(icon!.getAttribute('name')).to.equal('far-filter'); + }); + + it('should have proper filter titles', () => { + const titles = Array.from(el.renderRoot.querySelectorAll('sl-grid-filter')).map(filter => + filter?.renderRoot.querySelector('sl-popover')?.querySelector('#title')?.textContent?.trim() + ); + + expect(titles).to.eql(['Filter by Profession', 'Filter by Status', 'Filter by Membership']); + }); + + it('should have filter buttons and popovers with filter options', () => { + const buttons = Array.from(el.renderRoot.querySelectorAll('sl-grid-filter')).map(filter => + filter?.renderRoot.querySelector('.toggle') + ); + const popovers = Array.from(el.renderRoot.querySelectorAll('sl-grid-filter')).map(filter => + filter?.renderRoot.querySelector('sl-popover') + ); + + expect(buttons).to.exist; + expect(popovers).to.exist; + }); + + it('should have proper filter options when there is no mode', async () => { + const filter = el.renderRoot.querySelector('sl-grid-filter'), + popover = filter?.renderRoot.querySelector('sl-popover'), + button = filter?.renderRoot.querySelector('sl-button'); + + expect(button).to.exist; + expect(button).to.have.attribute('id'); + expect(button!.id).to.equal(popover?.getAttribute('anchor')); + + // Open the popover + button?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroup = popover?.querySelector('sl-checkbox-group'); + + expect(checkboxGroup).to.exist; + expect(checkboxGroup).to.have.attribute('aria-labelledby'); + expect(checkboxGroup?.getAttribute('aria-labelledby')).to.equal(popover?.querySelector('h1')?.id); + + const options = Array.from(checkboxGroup!.querySelectorAll('sl-checkbox')), + labels = options.map(o => o.querySelector('[slot="label"]')?.textContent?.trim()); + + expect(options).to.have.length(3); + expect(options.map(o => o.checked)).to.deep.equal([false, false, false]); + expect(labels).to.deep.equal(['Anesthesiologist', 'Cardiologist', 'Endocrinologist']); + }); + }); + + describe('active filter', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should have active filter', () => { + const active = Array.from(el.renderRoot.querySelectorAll('sl-grid-filter')).map(filter => + filter.hasAttribute('active') + ); + + expect(active).to.deep.equal([true, true, false]); + }); + + it('should have checked option in the select mode when it is filtered', async () => { + const filter = el.renderRoot.querySelector('sl-grid-filter'), + popover = filter?.renderRoot.querySelector('sl-popover'), + button = filter?.renderRoot.querySelector('sl-button'); + + expect(button).to.exist; + + // Open the popover + button?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroup = popover?.querySelector('sl-checkbox-group'); + + expect(checkboxGroup).to.exist; + + const optionsChecked = Array.from(checkboxGroup!.querySelectorAll('sl-checkbox[checked]')), + labels = optionsChecked.map(o => o.querySelector('[slot="label"]')?.textContent?.trim()); + + expect(labels).to.deep.equal(['Cardiologist', 'Endocrinologist']); + }); + + it('should have a button with the right icon when it is filtered', () => { + const filter = el.renderRoot.querySelector('sl-grid-filter'), + button = filter?.renderRoot.querySelector('sl-button'), + icon = button?.querySelector('sl-icon'); + + expect(icon).to.exist; + expect(icon!.getAttribute('name')).to.equal('fas-filter'); + }); + }); + + describe('select mode', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should have proper options', async () => { + const filters = el.renderRoot.querySelectorAll('sl-grid-filter'), + popoverProfession = filters[0]?.renderRoot.querySelector('sl-popover'), + buttonProfession = filters[0]?.renderRoot.querySelector('sl-button'), + popoverStatus = filters[1]?.renderRoot.querySelector('sl-popover'), + buttonStatus = filters[1]?.renderRoot.querySelector('sl-button'), + popoverMembership = filters[2]?.renderRoot.querySelector('sl-popover'), + buttonMembership = filters[2]?.renderRoot.querySelector('sl-button'); + + expect(buttonProfession).to.exist; + + // Open the popover + buttonProfession?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroupProfession = popoverProfession?.querySelector('sl-checkbox-group'); + + expect(checkboxGroupProfession).to.exist; + + const optionsProfession = Array.from(checkboxGroupProfession!.querySelectorAll('sl-checkbox')), + labelsProfession = optionsProfession.map(o => o.querySelector('[slot="label"]')?.textContent?.trim()); + + expect(labelsProfession).to.deep.equal(['Anesthesiologist', 'Cardiologist', 'Endocrinologist']); + + expect(buttonStatus).to.exist; + + // Open the popover + buttonStatus?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroupStatus = popoverStatus?.querySelector('sl-checkbox-group'); + + expect(checkboxGroupStatus).to.exist; + + const optionsStatus = Array.from(checkboxGroupStatus!.querySelectorAll('sl-checkbox')), + labelsStatus = optionsStatus.map(o => o.querySelector('[slot="label"]')?.textContent?.trim()); + + expect(labelsStatus).to.deep.equal(['Available', 'Busy']); + + expect(buttonMembership).to.exist; + + // Open the popover + buttonMembership?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroupMembership = popoverMembership?.querySelector('sl-checkbox-group'); + + expect(checkboxGroupProfession).to.exist; + + const optionsMembership = Array.from(checkboxGroupMembership!.querySelectorAll('sl-checkbox')), + labelsMembership = optionsMembership.map(o => o.querySelector('[slot="label"]')?.textContent?.trim()); + + expect(labelsMembership).to.deep.equal(['Premium', 'Regular']); + }); + }); + + describe('text mode', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should have text field', () => { + const filters = el.renderRoot.querySelectorAll('sl-grid-filter'), + popovers = Array.from(filters).map(o => o.renderRoot.querySelector('sl-popover')); + + expect(popovers).to.exist; + + const textFields = Array.from(popovers).map(o => o!.querySelector('sl-text-field')); + + expect(textFields).to.exist; + expect(textFields).to.have.length(3); + }); + }); +}); diff --git a/packages/components/grid/src/filter.spec.ts b/packages/components/grid/src/filter.spec.ts new file mode 100644 index 000000000..113778d38 --- /dev/null +++ b/packages/components/grid/src/filter.spec.ts @@ -0,0 +1,140 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import '../register.js'; +import { GridFilterColumn } from './filter-column.js'; +import { GridFilter } from './filter.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +describe('sl-grid-filter', () => { + try { + customElements.define('sl-grid-filter', GridFilter); + } catch { + // + } + + let el: GridFilter; + + const column = new GridFilterColumn(); + + const options = [ + { + label: 'Premium', + value: 'Premium' + }, + { + label: 'Regular', + value: 'Regular' + }, + { + label: 'VIP', + value: 'VIP' + } + ]; + + describe('defaults', () => { + beforeEach(async () => { + column.path = 'membership'; + column.value = 'Premium'; + await new Promise(resolve => setTimeout(resolve, 200)); + + el = await fixture(html` + + Membership + + `); + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should render correct icon', () => { + const button = el.renderRoot?.querySelector('sl-button'), + icon = button?.querySelector('sl-icon'); + + expect(button).to.exist; + expect(icon).to.exist; + expect(icon!.getAttribute('name')).to.equal('far-filter'); + }); + + it('should have no active filter by default', () => { + const active = el.hasAttribute('active'); + + expect(active).to.equal(false); + }); + }); + + describe('active filter', () => { + beforeEach(async () => { + column.path = 'membership'; + column.value = 'Premium'; + await new Promise(resolve => setTimeout(resolve, 200)); + + el = await fixture(html` + + Membership + + `); + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + }); + + it('should have active attribute when filtered', async () => { + const button = el.renderRoot?.querySelector('sl-button'), + popover = el?.renderRoot.querySelector('sl-popover'); + + // Open the popover + button?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroup = popover?.querySelector('sl-checkbox-group'); + + expect(checkboxGroup).to.exist; + + const options = Array.from(checkboxGroup!.querySelectorAll('sl-checkbox')); + + expect(options).to.exist; + + options[0].click(); + el.value = 'Premium'; + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + + expect(el.hasAttribute('active')).to.equal(true); + }); + + it('should have a proper icon when filtered', async () => { + const button = el.renderRoot?.querySelector('sl-button'), + popover = el?.renderRoot.querySelector('sl-popover'), + icon = button?.querySelector('sl-icon'); + + // Open the popover + button?.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const checkboxGroup = popover?.querySelector('sl-checkbox-group'); + + expect(checkboxGroup).to.exist; + + const options = Array.from(checkboxGroup!.querySelectorAll('sl-checkbox')); + + expect(options).to.exist; + + options[0].click(); + el.value = 'Premium'; + await new Promise(resolve => setTimeout(resolve, 200)); + await el.updateComplete; + + expect(icon).to.exist; + expect(icon!.getAttribute('name')).to.equal('fas-filter'); + }); + }); +}); diff --git a/packages/components/grid/src/filter.ts b/packages/components/grid/src/filter.ts index c1bf27021..e88be943a 100644 --- a/packages/components/grid/src/filter.ts +++ b/packages/components/grid/src/filter.ts @@ -74,7 +74,7 @@ export class GridFilter extends ScopedElementsMixin(LitElement) { /** @internal Emits when the filter has been added or removed. */ @event({ name: 'sl-filter-change' }) filterChangeEvent!: EventEmitter; - /** @internal Emits when the value of the this filter has changed. */ + /** @internal Emits when the value of the filter has changed. */ @event({ name: 'sl-filter-value-change' }) filterValueChangeEvent!: EventEmitter>; /** The mode of the filter. */ diff --git a/packages/components/grid/src/grid.scss b/packages/components/grid/src/grid.scss index 8dde7fc0a..4f95ac229 100644 --- a/packages/components/grid/src/grid.scss +++ b/packages/components/grid/src/grid.scss @@ -39,7 +39,7 @@ --_cell-border: none; } -:host([striped]) tr[part~='odd'] { +:host([striped]) tr[part~='even'] { --_cell-background: var(--_striped-background); } @@ -173,6 +173,7 @@ th[part~='filter'] { td { border-block-end: var(--_cell-border); + justify-content: start; } td[part~='drag-handle']:not([part~='fixed']) { diff --git a/packages/components/grid/src/grid.spec.ts b/packages/components/grid/src/grid.spec.ts index bf7a5ac38..be022827e 100644 --- a/packages/components/grid/src/grid.spec.ts +++ b/packages/components/grid/src/grid.spec.ts @@ -43,7 +43,7 @@ describe('sl-grid', () => { ]; // Give grid time to render the rows - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); const rows = Array.from(el.renderRoot.querySelectorAll('tbody tr')).map(row => Array.from(row.querySelectorAll('td')).map(cell => cell.textContent?.trim()) diff --git a/packages/components/grid/src/grid.ts b/packages/components/grid/src/grid.ts index 69390830c..746595b23 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -17,7 +17,7 @@ import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { type Virtualizer } from 'node_modules/@lit-labs/virtualizer/Virtualizer.js'; -import { type GridColumnGroup } from './column-group.js'; +import { GridColumnGroup } from './column-group.js'; import { GridColumn } from './column.js'; import { GridFilterColumn } from './filter-column.js'; import { type GridFilter, type SlFilterChangeEvent } from './filter.js'; @@ -312,14 +312,16 @@ export class Grid extends ScopedElementsMixin(LitElement) { return html` ${rows.slice(0, -1).map((row, rowIndex) => { return row.map((col, colIndex) => { - return ` + return col instanceof GridColumnGroup + ? ` thead tr:nth-child(${rowIndex + 1}) th:nth-child(${colIndex + 1}) { - flex-grow: ${(col as GridColumnGroup).columns.length}; + flex-grow: ${Math.max((col as GridColumnGroup).columns.length, 1)}; inline-size: ${col.width || '100'}px; justify-content: ${col.align}; ${col.renderStyles()?.toString() ?? ''} } - `; + ` + : nothing; }); })} ${rows[rows.length - 1].map((col, index) => { @@ -350,7 +352,6 @@ export class Grid extends ScopedElementsMixin(LitElement) { selectionColumn && this.selection.size > 0 && (this.selection.areSomeSelected() || this.selection.areAllSelected()); - return html` ${rows.slice(0, -1).map( row => html` @@ -437,9 +438,8 @@ export class Grid extends ScopedElementsMixin(LitElement) { rows[rows.length - 1] .filter(col => !col.hidden && col.autoWidth) .forEach(col => { - const index = this.view.columns.indexOf(col), + const index = this.view.headerRows[this.view.headerRows.length - 1].indexOf(col), cells = this.renderRoot.querySelectorAll(`:where(td, th):nth-child(${index + 1})`); - col.width = Array.from(cells).reduce((acc, cur) => { cur.style.flexGrow = '0'; cur.style.width = 'auto'; diff --git a/packages/components/grid/src/sort-column.spec.ts b/packages/components/grid/src/sort-column.spec.ts new file mode 100644 index 000000000..710b5f877 --- /dev/null +++ b/packages/components/grid/src/sort-column.spec.ts @@ -0,0 +1,107 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit'; +import '../register.js'; +import { Grid } from './grid.js'; +import { GridSortColumn } from './sort-column.js'; +import { GridSorter } from './sorter.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +describe('sl-sort-column', () => { + let el: Grid; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + + + + + `); + el.items = [ + { firstName: 'John', lastName: 'Doe', age: 20 }, + { firstName: 'Jane', lastName: 'Smith', age: 40 }, + { firstName: 'Jimmy', lastName: 'Adams', age: 30 }, + { firstName: 'Jane', lastName: 'Brown', age: 15 } + ]; + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + }); + + it('should render column headers', () => { + const columns = Array.from(el.renderRoot.querySelectorAll('th')); + const sortColumns = Array.from(el.querySelectorAll('sl-grid-sort-column')); + + expect(columns.map(col => col.textContent?.trim())).to.deep.equal(['First name', 'Last name', 'Age']); + expect(columns.map(col => col.getAttribute('part')?.trim())).to.deep.equal([ + 'header sort first-name', + 'header sort last-name', + 'header sort age' + ]); + expect(sortColumns.map(col => col.direction)).to.deep.equal([undefined, undefined, undefined]); + }); + + it('should pass the right information to the sorter element', async () => { + const sortColumns = Array.from(el.querySelectorAll('sl-grid-sort-column')); + await el.updateComplete; + await new Promise(resolve => setTimeout(resolve, 100)); + sortColumns.forEach(col => { + const sorter = el.querySelector(`#${col.id} sl-grid-sorter`); + expect(col.direction).to.equal(sorter?.direction); + expect(col.sorter).to.equal(sorter?.sorter); + + // TODO: checking for these doesn't work? + // expect(col).to.equal(sorter?.column); + // expect(col.path).to.equal(sorter?.path); + }); + }); + + // it('should set the directions correctly when the sorting is set for the first time ', async () => { + // const gridCols = Array.from(el.querySelectorAll('sl-grid-sort-column')); + // el.dataSource?.setSort(gridCols[0].id, 'string', 'asc'); + // const allUpdated = gridCols.map(el => { + // el.stateChanged(); + // return el.updateComplete; + // }); + + // await el.updateComplete; + // await Promise.all(allUpdated); + // await new Promise(resolve => setTimeout(resolve, 1000)); + // await el.updateComplete; + + // expect(gridCols.map(col => col.direction)).to.deep.equal(['asc', undefined, undefined]); + + // const ths = gridCols.map(col => col.shadowRoot?.querySelector('th')); + + // // expect(ths.map(th => th.getAttribute('aria-sort'))).to.deep.equal(['ascending', undefined, undefined]); + // }); + + it('should set the directions correctly when the sorting is set switched from another column ', async () => { + const gridCols = Array.from(el.querySelectorAll('sl-grid-sort-column')); + + el.dataSource?.setSort(gridCols[0].id, 'string', 'asc'); + let allUpdated = gridCols.map(el => { + el.stateChanged(); + return el.updateComplete; + }); + + await Promise.all(allUpdated); + + expect(gridCols.map(col => col.direction)).to.deep.equal(['asc', undefined, undefined]); + + el.dataSource?.setSort(gridCols[1].id, 'string', 'asc'); + allUpdated = gridCols.map(el => { + el.stateChanged(); + return el.updateComplete; + }); + + await Promise.all(allUpdated); + expect(gridCols.map(col => col.direction)).to.deep.equal([undefined, 'asc', undefined]); + }); + }); +}); diff --git a/packages/components/grid/src/sort-column.ts b/packages/components/grid/src/sort-column.ts index eaaa8fde2..b7d673846 100644 --- a/packages/components/grid/src/sort-column.ts +++ b/packages/components/grid/src/sort-column.ts @@ -1,6 +1,7 @@ import { type DataSourceSortDirection, type DataSourceSortFunction, getNameByPath } from '@sl-design-system/shared'; import { type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { GridColumn } from './column.js'; import { GridSorter } from './sorter.js'; @@ -20,6 +21,9 @@ export class GridSortColumn extends GridColumn { /** If you want to provide a custom sort function, you can via this property. */ @property({ attribute: false }) sorter?: DataSourceSortFunction; + /** The direction of the sorting */ + @property({ attribute: false }) ariaSorting?: 'ascending' | 'descending'; + override connectedCallback(): void { super.connectedCallback(); @@ -36,13 +40,19 @@ export class GridSortColumn extends GridColumn { } else { this.direction = undefined; } + + if (!this.direction) { + this.ariaSorting = undefined; + } else { + this.ariaSorting = this.direction === 'asc' ? 'ascending' : 'descending'; + } } override renderHeader(): TemplateResult { const parts = ['header', 'sort', ...this.getParts()]; return html` - + ${this.header ?? getNameByPath(this.path)} diff --git a/packages/components/grid/src/sorter.spec.ts b/packages/components/grid/src/sorter.spec.ts new file mode 100644 index 000000000..b73737cc2 --- /dev/null +++ b/packages/components/grid/src/sorter.spec.ts @@ -0,0 +1,47 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { Icon } from '@sl-design-system/icon'; +import { ArrayDataSource, DataSource } from '@sl-design-system/shared'; +import { html } from 'lit'; +import '../register.js'; +import { GridSortColumn } from './sort-column.js'; +import { GridSorter } from './sorter.js'; + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); +customElements.define('sl-grid-sorter', GridSorter); + +describe('sl-grid-sorter', () => { + let el: GridSorter; + const items = [{ name: 'John' }, { name: 'Jane' }, { name: 'Jimmy' }, { name: 'Jane' }]; + + const column = new GridSortColumn(); + const dataSource = new ArrayDataSource(items) as DataSource; + dataSource.setSort('', 'name', 'asc'); + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + Name + `); + await el.updateComplete; + + // Give grid time to render the table structure + await new Promise(resolve => setTimeout(resolve, 100)); + await el.updateComplete; + }); + + it('should render the correct icon', async () => { + el.direction = 'asc'; + await el.updateComplete; + expect(el.renderRoot.querySelector('sl-icon')?.getAttribute('name')).to.equal('sort-up'); + + el.direction = 'desc'; + await el.updateComplete; + expect(el.renderRoot.querySelector('sl-icon')?.getAttribute('name')).to.equal('sort-down'); + + el.reset(); + await el.updateComplete; + expect(el.renderRoot.querySelector('sl-icon')?.getAttribute('name')).to.equal('sort'); + }); + }); +}); diff --git a/packages/components/grid/src/sorter.ts b/packages/components/grid/src/sorter.ts index 7f0d09589..27effd9ed 100644 --- a/packages/components/grid/src/sorter.ts +++ b/packages/components/grid/src/sorter.ts @@ -7,7 +7,7 @@ import { EventsController, event } from '@sl-design-system/shared'; -import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { type GridColumn } from './column.js'; @@ -21,7 +21,7 @@ declare global { } interface HTMLElementTagNameMap { - 'sl-sorter': GridSorter; + 'sl-grid-sorter': GridSorter; } } @@ -77,20 +77,6 @@ export class GridSorter extends ScopedElementsMixin(LitElement) { this.sorterChangeEvent.emit('added'); } - override updated(changes: PropertyValues): void { - super.updated(changes); - - if (changes.has('direction')) { - const header = this.closest('th'); - - if (!this.direction) { - header?.removeAttribute('aria-sort'); - } else { - header?.setAttribute('aria-sort', this.direction === 'asc' ? 'ascending' : 'descending'); - } - } - } - override disconnectedCallback(): void { // FIXME: This event is not emitted when the component is removed from the DOM. this.sorterChangeEvent.emit('removed'); diff --git a/packages/components/grid/src/stories/basics.stories.ts b/packages/components/grid/src/stories/basics.stories.ts index dd732cdeb..f448793e7 100644 --- a/packages/components/grid/src/stories/basics.stories.ts +++ b/packages/components/grid/src/stories/basics.stories.ts @@ -69,6 +69,7 @@ export const ColumnGroups: Story = { + ` }; diff --git a/packages/components/grid/src/view-model.ts b/packages/components/grid/src/view-model.ts index 36612ac43..6f1c63345 100644 --- a/packages/components/grid/src/view-model.ts +++ b/packages/components/grid/src/view-model.ts @@ -1,6 +1,6 @@ import { type DataSource, getStringByPath, getValueByPath } from '@sl-design-system/shared'; import { GridColumnGroup } from './column-group.js'; -import { type GridColumn } from './column.js'; +import { GridColumn } from './column.js'; import { GridDragHandleColumn } from './drag-handle-column.js'; import { type Grid } from './grid.js'; @@ -193,12 +193,26 @@ export class GridViewModel { } #getHeaderRows(columns: Array>): Array>> { - const children = columns - .filter((col): col is GridColumnGroup => col instanceof GridColumnGroup) - .reduce((acc: Array>>, cur) => { - return [...acc, ...this.#getHeaderRows(cur.columns)]; - }, []); - - return children.length ? [[...columns], children.flat(2)] : [[...columns]]; + const groups = columns.filter((col): col is GridColumnGroup => col instanceof GridColumnGroup); + const columnsOutsideGroups = columns.filter((col): col is GridColumn => !(col instanceof GridColumnGroup)); + const groupsNew = columns + .map(col => { + if (col instanceof GridColumnGroup) { + return col; + } + const newGroup = new GridColumnGroup(); + if (!(col.parentElement instanceof GridColumnGroup)) { + // add the column this header group represents to the group in order to calculate the width correctly. + newGroup.columns = [col as GridColumnGroup]; + } + return newGroup; + }) + .filter(g => !!g); + const children = groups.reduce((acc: Array>>, cur) => { + return [...acc, ...this.#getHeaderRows(cur.columns)]; + }, []); + + // when there are columns groups, return the groups and the columns outside groups, otherwise only return the columns + return children.length ? [[...groupsNew], [...children.flat(2), ...columnsOutsideGroups]] : [[...columns]]; } }