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]];
}
}
|