Skip to content
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

Add virtual list ability to listbox #1626

Merged
merged 7 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/short-ravens-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/listbox': patch
---

Add virtual list ability to listbox
2 changes: 1 addition & 1 deletion packages/components/grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"lit": "^3.1.4"
},
"peerDependencies": {
"@lit-labs/virtualizer": "^2.0.12",
"@lit-labs/virtualizer": "^2.0.13",
"@lit/localize": "^0.12.1",
"@open-wc/scoped-elements": "^3.0.5",
"lit": "^3.1.4"
Expand Down
2 changes: 2 additions & 0 deletions packages/components/listbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
"@sl-design-system/icon": "^1.0.2"
},
"devDependencies": {
"@lit-labs/virtualizer": "^2.0.13",
"@open-wc/scoped-elements": "^3.0.5",
"lit": "^3.1.4"
},
"peerDependencies": {
"@lit-labs/virtualizer": "^2.0.13",
"@open-wc/scoped-elements": "^3.0.5",
"lit": "^3.1.4"
}
Expand Down
12 changes: 2 additions & 10 deletions packages/components/listbox/src/listbox.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
:host {
align-items: stretch;
contain: layout paint style;
display: flex;
flex-direction: column;
gap: var(--sl-space-new-2xs);
Expand All @@ -8,16 +10,6 @@
scrollbar-width: thin;
}

::slotted(sl-option) {
margin-inline: var(--sl-space-new-md);
}

::slotted(sl-option-group:not(:first-child)) {
border-block-start: var(--sl-color-elevation-border-raised) solid var(--sl-size-borderWidth-default);
margin-block-start: var(--sl-space-new-sm);
padding-block-start: var(--sl-space-new-md);
}

::slotted(hr) {
border: 0;
border-block-start: var(--sl-color-elevation-border-raised) solid var(--sl-size-borderWidth-default);
Expand Down
90 changes: 86 additions & 4 deletions packages/components/listbox/src/listbox.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,98 @@
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 Listbox } from './listbox.js';
import { type Option } from './option.js';

setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach);

describe('sl-listbox', () => {
let el: Listbox;

beforeEach(async () => {
el = await fixture(html`<sl-listbox></sl-listbox>`);
describe('defaults', () => {
beforeEach(async () => {
el = await fixture(html`<sl-listbox></sl-listbox>`);
});

it('should have a role of listbox', () => {
expect(el).to.have.attribute('role', 'listbox');
});
});

it('should have a role of listbox', () => {
expect(el).to.have.attribute('role', 'listbox');
describe('virtual list', () => {
const options = Array.from({ length: 1000 }).map((_, i) => ({
label: `Item ${i + 1}`,
selected: i % 2 === 0,
value: i
}));

beforeEach(async () => {
el = await fixture(html`<sl-listbox .options=${options} style="height: 200px"></sl-listbox>`);

// Give the virtualizer time to render
await new Promise(resolve => setTimeout(resolve, 10));
});

it('should render a virtualizer', () => {
expect(el.querySelector('lit-virtualizer')).to.exist;
});

it('should render options for each option', () => {
const renderedOptions = Array.from(el.querySelectorAll('sl-option'));

expect(renderedOptions.length).to.be.greaterThan(0);
expect(renderedOptions.length).to.be.lessThan(options.length);
expect(renderedOptions.map(o => o.textContent)).to.deep.equal(
options.slice(0, renderedOptions.length).map(i => JSON.stringify(i))
);
});

it('should update the options when the options changed', async () => {
el.options = options.map(o => o.label);
await new Promise(resolve => setTimeout(resolve, 10));

const renderedOptions = Array.from(el.querySelectorAll('sl-option'));

expect(renderedOptions.map(o => o.textContent)).to.deep.equal(el.options.slice(0, renderedOptions.length));
});

it('should use the given label, selected and value path for each item', async () => {
el.optionLabelPath = 'label';
el.optionSelectedPath = 'selected';
el.optionValuePath = 'value';
await el.updateComplete;

const renderedOptions = Array.from<Option<(typeof options)[0]>>(el.querySelectorAll('sl-option'));

expect(renderedOptions.map(o => o.textContent)).to.deep.equal(
options.slice(0, renderedOptions.length).map(i => i.label)
);
expect(renderedOptions.map(o => o.selected)).to.deep.equal(
options.slice(0, renderedOptions.length).map(i => i.selected)
);
expect(renderedOptions.map(o => o.value)).to.deep.equal(
options.slice(0, renderedOptions.length).map(i => i.value)
);
});

it('should support a custom renderer', async () => {
el.renderer = (option: (typeof options)[0]) => {
const div = document.createElement('div');
div.setAttribute('role', 'option');
div.textContent = option.label;

return div;
};
await el.updateComplete;

const renderedOptions = Array.from(el.querySelectorAll('div[role="option"]'));

expect(renderedOptions.length).to.be.greaterThan(0);
expect(renderedOptions.length).to.be.lessThan(options.length);
expect(renderedOptions.map(o => o.textContent)).to.deep.equal(
options.slice(0, renderedOptions.length).map(i => i.label)
);
});
});
});
117 changes: 90 additions & 27 deletions packages/components/listbox/src/listbox.stories.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import '@sl-design-system/badge/register.js';
import { type Meta, type StoryObj } from '@storybook/web-components';
import { type TemplateResult, html } from 'lit';
import '../register.js';
import { type Listbox } from './listbox.js';

type Props = { options?(): TemplateResult | TemplateResult[] };
type Props = Pick<Listbox, 'options' | 'optionLabelPath' | 'optionSelectedPath' | 'optionValuePath'> & {
slot?(): TemplateResult;
};
type Story = StoryObj<Props>;

export default {
Expand All @@ -11,26 +15,45 @@ export default {
argTypes: {
options: {
table: { disable: true }
},
slot: {
table: { disable: true }
}
},
render: ({ options }) => {
return html`<sl-listbox>${options?.()}</sl-listbox>`;
render: ({ options, optionLabelPath, optionSelectedPath, optionValuePath, slot }) => {
return html`
<style>
sl-listbox {
border: var(--sl-color-elevation-border-raised) solid var(--sl-size-borderWidth-default);
border-radius: var(--sl-size-borderRadius-default);
max-block-size: calc(100dvh - 3rem);
}
</style>
<sl-listbox
.options=${options}
.optionLabelPath=${optionLabelPath}
.optionSelectedPath=${optionSelectedPath}
.optionValuePath=${optionValuePath}
>
${slot?.()}
</sl-listbox>
`;
}
} satisfies Meta<Props>;

export const Basic: Story = {
args: {
options: () => html`
slot: () => html`
<sl-option>Option 1</sl-option>
<sl-option>Option 2</sl-option>
<sl-option selected>Option 2</sl-option>
<sl-option>Option 3</sl-option>
`
}
};

export const Disabled: Story = {
args: {
options: () => html`
slot: () => html`
<sl-option disabled>Option 1</sl-option>
<sl-option>Option 2</sl-option>
<sl-option>Option 3</sl-option>
Expand All @@ -40,7 +63,7 @@ export const Disabled: Story = {

export const Divider: Story = {
args: {
options: () => html`
slot: () => html`
<sl-option>Option 1</sl-option>
<sl-option>Option 2</sl-option>
<hr />
Expand All @@ -51,7 +74,7 @@ export const Divider: Story = {

export const Grouped: Story = {
args: {
options: () => html`
slot: () => html`
<sl-option-group label="Group 1">
<sl-option>Option 1</sl-option>
<sl-option>Option 2</sl-option>
Expand All @@ -64,28 +87,68 @@ export const Grouped: Story = {
}
};

export const Selected: Story = {
export const Overflow: Story = {
args: {
options: () => html`
<sl-option>Option 1</sl-option>
<sl-option selected>Option 2</sl-option>
<sl-option>Option 3</sl-option>
slot: () => html`
<sl-option>
Magna ea amet aute est ullamco elit. Culpa fugiat commodo exercitation nulla sunt et ea eiusmod et duis sit.
Labore ad laborum esse mollit nulla amet fugiat incididunt. Velit aliquip amet nostrud aliquip labore velit
consectetur sint aute. Nostrud aliquip dolore minim commodo ea. Ut veniam dolor laborum sunt voluptate voluptate
adipisicing.
</sl-option>
<sl-option selected>
Excepteur nisi tempor nisi sint. Deserunt esse eiusmod tempor aliqua. Adipisicing est est nostrud pariatur eu
dolore veniam exercitation. Anim labore et ea non sunt irure excepteur ad. Ex duis aliqua et esse. Adipisicing
id laboris cupidatat ullamco fugiat in. Sunt deserunt sint veniam labore reprehenderit magna mollit commodo id
irure ut excepteur.
</sl-option>
<sl-option>
Nisi ut cupidatat do qui dolore aliquip reprehenderit ad proident laboris pariatur in nostrud laborum. Mollit
esse occaecat ex duis dolore officia laboris quis. Duis eiusmod sint exercitation enim consequat eu occaecat eu
magna dolore nulla ut proident non. Anim Lorem reprehenderit consectetur duis quis exercitation cupidatat
laboris cupidatat fugiat consectetur culpa.
</sl-option>
`
}
};

export const Overflow: Story = {
parameters: {
layout: 'fullscreen'
},
render: () => html`
<style>
sl-listbox {
max-block-size: calc(100dvh - 1rem);
}
</style>
<sl-listbox>
${Array.from({ length: 100 }).map((_, i) => html`<sl-option>Option ${i + 1}</sl-option>`)}
</sl-listbox>
`
export const RichContent: Story = {
args: {
slot: () => html`
<style>
sl-option::part(wrapper) {
gap: 0.5rem;
}
sl-badge {
flex-shrink: 0;
margin-inline-start: auto;
}
</style>
<sl-option-group label="Module 1">
<sl-option>Chapter 1 <sl-badge emphasis="bold" variant="info">Published</sl-badge></sl-option>
<sl-option>Chapter 2 <sl-badge emphasis="bold" variant="info">Published</sl-badge></sl-option>
</sl-option-group>
<sl-option-group label="Module 2">
<sl-option selected>
Cillum proident reprehenderit amet ipsum labore aliqua ea excepteur enim duis. Nisi eu nulla eiusmod irure ut
anim aute ex eiusmod nisi do Lorem ut. Pariatur anim tempor in fugiat. Sit ullamco exercitation ipsum et eu
nisi id minim ut. Labore id fugiat exercitation dolor fugiat non dolore anim et enim ex consequat non Lorem.
Lorem quis sint et et. <sl-badge emphasis="bold">Draft</sl-badge>
</sl-option>
</sl-option-group>
`
}
};

export const VirtualList: Story = {
args: {
optionLabelPath: 'label',
optionSelectedPath: 'selected',
optionValuePath: 'value',
options: Array.from({ length: 10000 }).map((_, i) => ({
label: `Option ${i + 1}`,
selected: i % 2 === 0,
value: i
}))
}
};
Loading
Loading