Skip to content

Rework multiple selection with activatable rows in grid #2064

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 48 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
965e933
🔈
jpzwarte May 12, 2025
25fc94a
🎰
jpzwarte May 12, 2025
2cdd97a
🎀
jpzwarte May 12, 2025
2767893
🐱
jpzwarte May 12, 2025
862a45e
⛵️
jpzwarte May 13, 2025
309d260
🍫
jpzwarte May 13, 2025
f692721
⛺️
jpzwarte May 14, 2025
db98d24
🚣
jpzwarte May 14, 2025
098ab66
💪
jpzwarte May 14, 2025
26616d0
🎀
jpzwarte May 14, 2025
f7cd372
🌻
jpzwarte May 15, 2025
9831271
Merge branch 'main' into feature/2028-table-data-source
jpzwarte May 15, 2025
3ae9c52
🐭
jpzwarte May 18, 2025
2b47876
Merge branch 'main' into feature/2028-table-data-source
jpzwarte May 19, 2025
727d58c
🐄
jpzwarte May 20, 2025
6c50fde
🎆
jpzwarte May 20, 2025
6e4fb68
🐩
jpzwarte May 21, 2025
dcb27b1
🍫
jpzwarte May 21, 2025
c07067f
🌷
jpzwarte May 21, 2025
6facdfe
🐥
jpzwarte May 21, 2025
5629a7e
Merge branch 'main' into feature/2028-table-data-source
jpzwarte May 21, 2025
ae9f909
🏸
jpzwarte May 22, 2025
ebf05a3
🏅
jpzwarte May 22, 2025
19bdc38
🐨
jpzwarte May 22, 2025
ea60a41
Merge branch 'main' into feature/2028-table-data-source
jpzwarte May 22, 2025
5afa578
🍣
jpzwarte May 22, 2025
1a45d2e
🏈
jpzwarte May 22, 2025
dc3ba37
Merge branch 'main' into feature/2028-table-data-source
jpzwarte May 22, 2025
4bfd009
🐍
jpzwarte May 22, 2025
369a652
🐚
jpzwarte May 22, 2025
893bde7
🌝
jpzwarte May 22, 2025
367968e
🐞
jpzwarte May 23, 2025
ede591d
🐺
jpzwarte May 23, 2025
e804370
💿
jpzwarte May 23, 2025
ea260ab
🐝
jpzwarte May 23, 2025
3823024
🍀
jpzwarte May 23, 2025
a302f93
🍷
jpzwarte May 23, 2025
f8ed8df
🐖
jpzwarte May 23, 2025
bd06ae3
🌎
jpzwarte May 23, 2025
17440dc
🚨
jpzwarte May 25, 2025
f121c6b
🔮
jpzwarte May 26, 2025
ce83aa5
Merge branch 'main' into fix/2046-grid-mixed-selection
jpzwarte May 27, 2025
bd7fd1c
🍗
jpzwarte May 27, 2025
64ac559
Merge branch 'main' into fix/2046-grid-mixed-selection
jpzwarte May 27, 2025
468986c
💥
jpzwarte May 28, 2025
432e3e2
🐵
jpzwarte May 28, 2025
4d93437
😈
jpzwarte May 28, 2025
de094ba
🏂
jpzwarte May 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/moody-islands-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@sl-design-system/grid': minor
---

Add `activeRow` property to grid with accompanying event

This changes adds a new `activeRow` property to the `<sl-grid>` component. This allows users to specify which row should be marked as active. The active row is marked using the `active` DOM part. It is styled similarly to a selected row. In order to enable the ability to activate a row, you need to add the `activatable-row` attribute to the `<sl-grid>` component. When the active row changes, an `sl-grid-active-row-change` event is dispatched.

You can combine the `activatable-row` feature with `selects="multiple"` feature to allow users to both activate and select a row.
2 changes: 1 addition & 1 deletion packages/components/grid/src/grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
border: none;
}

:host([selects$='-row']) {
:host(:where([activatable-row], [selects$='-row'])) {
[part~='row'] {
--_bg-color: transparent;
--_bg-mix-color: var(--sl-color-background-input-interactive);
Expand Down
289 changes: 287 additions & 2 deletions packages/components/grid/src/grid.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js';
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
import { type SinonSpy, spy } from 'sinon';
import '../register.js';
import { type Grid } from './grid.js';
import { type Grid, type SlActiveRowChangeEvent } from './grid.js';
import { waitForGridToRenderData } from './utils.js';

setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach, { suppressErrorLogging: true });

type Person = { firstName: string; lastName: string };

describe('sl-grid', () => {
let el: Grid;
let el: Grid<Person>;

describe('defaults', () => {
beforeEach(async () => {
Expand Down Expand Up @@ -52,4 +55,286 @@ describe('sl-grid', () => {
]);
});
});

describe('activatable row', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-grid
activatable-row
.items=${[
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Smith' }
]}
>
<sl-grid-column path="firstName"></sl-grid-column>
<sl-grid-column path="lastName"></sl-grid-column>
</sl-grid>
`);

await waitForGridToRenderData(el);
});

it('should not have an active row by default', () => {
const activeRow = el.renderRoot.querySelector('[part~="active"]');

expect(activeRow).to.be.null;
expect(el.activeRow).to.be.undefined;
});

it('should toggle the "active" part of the row when clicked', async () => {
const firstRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr');

firstRow?.click();
await new Promise(resolve => setTimeout(resolve));

expect(firstRow?.part.contains('active')).to.be.true;
expect(el.activeRow).to.equal(el.items?.at(0));

firstRow?.click();
await new Promise(resolve => setTimeout(resolve));

expect(firstRow?.part.contains('active')).to.be.false;
expect(el.activeRow).to.be.undefined;
});

it('should remove the "active" part when another row is clicked', async () => {
const [firstRow, secondRow] = el.renderRoot.querySelectorAll<HTMLTableRowElement>('tbody tr');

firstRow?.click();
await new Promise(resolve => setTimeout(resolve));

expect(firstRow?.part.contains('active')).to.be.true;
expect(el.activeRow).to.equal(el.items?.at(0));

secondRow?.click();
await new Promise(resolve => setTimeout(resolve));

expect(firstRow?.part.contains('active')).to.be.false;
expect(secondRow?.part.contains('active')).to.be.true;
expect(el.activeRow).to.equal(el.items?.at(1));
});

it('should emit an sl-grid-active-row-change event when the active row changes', async () => {
const onActiveRowChange = spy() as SinonSpy<[SlActiveRowChangeEvent], void>;

el.addEventListener('sl-grid-active-row-change', onActiveRowChange);

el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(onActiveRowChange).to.have.been.calledOnce;
expect(onActiveRowChange.firstCall.args[0].detail.item).to.equal(el.items?.at(0));
});

it('should set the activeRow property to the clicked row item', async () => {
el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(el.activeRow).to.equal(el.items?.at(0));
});

it('should add the "active" part to the active row', async () => {
el.activeRow = el.items!.at(1);
await el.updateComplete;

const row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:last-of-type');
expect(row?.part.contains('active')).to.be.true;
});
});

describe('selects multiple', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-grid
.items=${[
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Smith' }
]}
selects="multiple"
>
<sl-grid-selection-column></sl-grid-selection-column>
<sl-grid-column path="firstName"></sl-grid-column>
<sl-grid-column path="lastName"></sl-grid-column>
</sl-grid>
`);

await waitForGridToRenderData(el);
});

it('should toggle the "selected" part of the row when clicking in the selection column', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
await new Promise(resolve => setTimeout(resolve));

const row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.true;
});

it('should not toggle the "selected" part of the row when clicking anywhere in the row', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

const selectedRow = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr[part~="selected"]');
expect(selectedRow).to.be.null;
});

it('should support multiple selection by clicking different rows', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:last-of-type td[part~="selection"]')?.click();
await new Promise(resolve => setTimeout(resolve));

const rows = el.renderRoot.querySelectorAll<HTMLTableRowElement>('tbody tr[part~="selected"]');
expect(rows).to.have.lengthOf(2);
});

it('should emit an sl-grid-selection-change event when the selection changes', () => {
const onSelectionChange = spy();

el.addEventListener('sl-grid-selection-change', onSelectionChange);

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:last-of-type td[part~="selection"]')?.click();

expect(onSelectionChange).to.have.been.calledTwice;
});

it('should call toggle() on the data source when a row is selected', () => {
const toggleSpy = spy(el.dataSource!, 'toggle');

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();

expect(toggleSpy).to.have.been.calledOnce;
expect(toggleSpy.firstCall.args[0]).to.have.property('data', el.items?.at(0));
});
});

describe('activatable row with selects multiple', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-grid
activatable-row
.items=${[
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Smith' }
]}
selects="multiple"
>
<sl-grid-selection-column></sl-grid-selection-column>
<sl-grid-column path="firstName"></sl-grid-column>
<sl-grid-column path="lastName"></sl-grid-column>
</sl-grid>
`);

await waitForGridToRenderData(el);
});

it('should emit an sl-grid-active-row-change event a row is selected', async () => {
const onActiveRowChange = spy() as SinonSpy<[SlActiveRowChangeEvent], void>;

el.addEventListener('sl-grid-active-row-change', onActiveRowChange);

// Click the first row, last column to activate the row
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

// Now click the selection column to select the row
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(onActiveRowChange).to.have.been.calledTwice;
expect(onActiveRowChange.lastCall.args[0].detail.item).to.be.undefined;
});

it('should emit an sl-grid-selection-change event when a row is activated', async () => {
const onSelectionChange = spy();

el.addEventListener('sl-grid-selection-change', onSelectionChange);

// Click the first row, first column to select the row
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:first-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

// Now click the last row, last column to activate the row
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:last-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

expect(onSelectionChange).to.have.been.calledTwice;
});
});

describe('selects multiple row', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-grid
.items=${[
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Smith' }
]}
selects="multiple-row"
>
<sl-grid-selection-column></sl-grid-selection-column>
<sl-grid-column path="firstName"></sl-grid-column>
<sl-grid-column path="lastName"></sl-grid-column>
</sl-grid>
`);

await waitForGridToRenderData(el);
});

it('should toggle the "selected" part of the row when clicking in the selection column', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
await new Promise(resolve => setTimeout(resolve));

let row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.true;

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td[part~="selection"]')?.click();
await new Promise(resolve => setTimeout(resolve));

row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.false;
});

it('should toggle the "selected" part of the row when clicking anywhere in the row', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

let row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.true;

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

row = el.renderRoot.querySelector<HTMLTableRowElement>('tbody tr:first-of-type');
expect(row?.part.contains('selected')).to.be.false;
});

it('should support multiple selection by clicking different rows', async () => {
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:last-of-type td:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve));

const rows = el.renderRoot.querySelectorAll<HTMLTableRowElement>('tbody tr[part~="selected"]');
expect(rows).to.have.lengthOf(2);
});

it('should emit an sl-grid-selection-change event when the selection changes', () => {
const onSelectionChange = spy();

el.addEventListener('sl-grid-selection-change', onSelectionChange);

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();
el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:last-of-type td:last-of-type')?.click();

expect(onSelectionChange).to.have.been.calledTwice;
});

it('should call toggle() on the data source when a row is selected', () => {
const toggleSpy = spy(el.dataSource!, 'toggle');

el.renderRoot.querySelector<HTMLTableCellElement>('tbody tr:first-of-type td:last-of-type')?.click();

expect(toggleSpy).to.have.been.calledOnce;
expect(toggleSpy.firstCall.args[0]).to.have.property('data', el.items?.at(0));
});
});
});
Loading