Skip to content

Add filter components #1917

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

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
28 changes: 25 additions & 3 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,31 @@ const preview: Preview = {
default: 'Default'
},
options: {
storySort: {
method: 'alphabetical',
order: ['', 'Getting started']
storySort: (a, b) => {
if (a.id === b.id) {
return 0;
} else {
const [aGroup, aName] = a.title.split('/'),
[bGroup, bName] = b.title.split('/');

if (aGroup === bGroup) {
if (aName === 'Examples') {
return -1;
} else if (bName === 'Examples') {
return 1;
} else {
return aName.localeCompare(bName, undefined, { numeric: true });
}
} else {
if (aGroup === 'Getting started') {
return -1;
} else if (bGroup === 'Getting started') {
return 1;
} else {
return aGroup.localeCompare(bGroup, undefined, { numeric: true });
}
}
}
}
},
viewport: {
Expand Down
2 changes: 2 additions & 0 deletions packages/components/filter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './src/group.js';
export * from './src/status.js';
55 changes: 55 additions & 0 deletions packages/components/filter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@sl-design-system/filter",
"version": "0.0.0",
"description": "Filter components for the SL Design System",
"license": "Apache-2.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"repository": {
"type": "git",
"url": "https://github.com/sl-design-system/components.git",
"directory": "packages/components/filter"
},
"homepage": "https://sanomalearning.design/components/filter",
"bugs": {
"url": "https://github.com/sl-design-system/components/issues"
},
"type": "module",
"main": "./index.js",
"module": "./index.js",
"types": "./index.d.ts",
"customElements": "custom-elements.json",
"exports": {
".": "./index.js",
"./package.json": "./package.json",
"./register.js": "./register.js"
},
"files": [
"**/*.d.ts",
"**/*.js",
"**/*.js.map",
"custom-elements.json"
],
"sideEffects": [
"register.js"
],
"scripts": {
"test": "echo \"Error: run tests from monorepo root.\" && exit 1"
},
"dependencies": {
"@sl-design-system/button": "^1.2.1",
"@sl-design-system/shared": "^0.6.0",
"@sl-design-system/tag": "^0.1.1"
},
"devDependencies": {
"@lit/localize": "^0.12.2",
"@open-wc/scoped-elements": "^3.0.5",
"lit": "^3.2.1"
},
"peerDependencies": {
"@lit/localize": "^0.12.1",
"@open-wc/scoped-elements": "^3.0.5",
"lit": "^3.1.4"
}
}
5 changes: 5 additions & 0 deletions packages/components/filter/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { FilterGroup } from './src/group.js';
import { FilterStatus } from './src/status.js';

customElements.define('sl-filter-group', FilterGroup);
customElements.define('sl-filter-status', FilterStatus);
19 changes: 19 additions & 0 deletions packages/components/filter/src/group.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
:host {
display: block;
}

[part='count'] {
color: var(--sl-color-text-subtle);
}

sl-label {
/* stylelint-disable-next-line color-no-hex */
background: #fff;
inset-block-start: 0;
position: sticky;
z-index: 1;
}

sl-button {
align-self: start;
}
56 changes: 56 additions & 0 deletions packages/components/filter/src/group.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ArrayListDataSource, type ListDataSource } from '@sl-design-system/data-source';
import { type Person, getPeople } from '@sl-design-system/example-data';
import { type Meta, type StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import '../register.js';
import { type FilterGroup } from './group.js';

type Props = Pick<FilterGroup, 'label' | 'options' | 'path'> & {
dataSource?(people: Person[]): ListDataSource<Person>;
};
type Story = StoryObj<Props>;

export default {
title: 'Filter/Group',
tags: ['draft'],
args: {
dataSource: undefined
},
argTypes: {
dataSource: { table: { disable: true } }
},
loaders: [async () => ({ people: (await getPeople()).people })],
render: ({ dataSource, label, options, path }, { loaded: { people } }) => {
return html`
<sl-filter-group
.dataSource=${dataSource?.(people as Person[])}
.label=${label}
.options=${options}
.path=${path}
></sl-filter-group>
`;
}
} satisfies Meta<Props>;

export const Basic: Story = {
args: {
dataSource: people => new ArrayListDataSource(people),
label: 'Membership',
options: ['Regular', 'Premium', 'VIP'],
path: 'membership'
}
};

export const Filtered: Story = {
args: {
...Basic.args,
dataSource: people => {
const dataSource = new ArrayListDataSource(people);
dataSource.addFilter('membership-Regular', 'membership', 'Regular');
dataSource.addFilter('membership-Premium', 'membership', 'Premium');
dataSource.update();

return dataSource;
}
}
};
140 changes: 140 additions & 0 deletions packages/components/filter/src/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { localized, msg } from '@lit/localize';
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Button } from '@sl-design-system/button';
import { Checkbox, CheckboxGroup } from '@sl-design-system/checkbox';
import { type ListDataSource } from '@sl-design-system/data-source';
import { FormField, Label } from '@sl-design-system/form';
import { type SlChangeEvent } from '@sl-design-system/shared/events.js';
import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import styles from './group.scss.js';

export type FilterOption = {
label: string;
value: string;
active?: boolean;
};

@localized()
export class FilterGroup<T = unknown> extends ScopedElementsMixin(LitElement) {
/**
* The threshold at which the component switches from using a checkbox-group
* to using a combobox for rendering the options.
*/
static threshold = 6;

/** @internal */
static get scopedElements(): ScopedElementsMap {
return {
'sl-button': Button,
'sl-checkbox': Checkbox,
'sl-checkbox-group': CheckboxGroup,
'sl-form-field': FormField,
'sl-label': Label
};
}

/** @internal */
static override styles: CSSResultGroup = styles;

/** The source of information for this component. */
#dataSource?: ListDataSource<T>;

/** The filter options. */
#options: FilterOption[] = [];

get dataSource() {
return this.#dataSource;
}

/** The data source used for displaying the filter status. */
@property({ attribute: false })
set dataSource(value: ListDataSource | undefined) {
if (this.#dataSource) {
this.#dataSource.removeEventListener('sl-update', this.#onUpdate);
}

this.#dataSource = value;
this.#dataSource?.addEventListener('sl-update', this.#onUpdate);
this.#onUpdate();
}

/** The label for this group. */
@property() label?: string;

get options() {
return this.#options;
}

/** The options that can be filtered. */
@property({ type: Array })
set options(value: string[] | FilterOption[] | undefined) {
this.#options =
value?.map(option => (typeof option === 'string' ? { label: option, value: option } : option)) ?? [];
}

/** The path to the property in the data model to filter on. */
@property() path?: string;

/** @internal Whether to show all options when the amount exceeds the threshold. */
@state() showMore?: boolean;

override render(): TemplateResult {
const exceedsThreshold = this.#options.length > FilterGroup.threshold,
options = this.#options.slice(0, this.showMore ? this.#options.length : FilterGroup.threshold);

return html`
<sl-form-field>
<sl-label>
${this.label} ${exceedsThreshold ? html`<span part="count">(${this.#options.length})</span>` : nothing}
</sl-label>
<sl-checkbox-group>
${options.map(
option => html`
<sl-checkbox
@sl-change=${(event: SlChangeEvent<string>) => this.#onCheckboxChange(event, option)}
.checked=${option.active}
.value=${option.value}
>
${option.label}
</sl-checkbox>
`
)}
</sl-checkbox-group>
${exceedsThreshold
? html`
<sl-button @click=${this.#onClick} fill="link">
${this.showMore ? msg('Show less') : msg('Show more')}
</sl-button>
`
: nothing}
</sl-form-field>
`;
}

#onCheckboxChange(event: SlChangeEvent<string>, option: FilterOption): void {
const id = `${this.path}-${option.value}`;

if (event.detail) {
this.dataSource?.addFilter(id, this.path!, option.value);
} else {
this.dataSource?.removeFilter(id);
}

this.dataSource?.update();
}

#onClick(): void {
this.showMore = !this.showMore;
}

#onUpdate = (): void => {
const filters = this.dataSource!.filters;

this.#options = this.#options.map(option => {
return { ...option, active: filters.has(`${this.path}-${option.value}`) };
});

this.requestUpdate('options');
};
}
15 changes: 15 additions & 0 deletions packages/components/filter/src/status.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
:host {
align-items: center;
display: flex;
gap: 1rem;
min-inline-size: 0;
}

[part='count'] {
flex-shrink: 0;
}

sl-tag-list {
flex: 1;
min-inline-size: 0;
}
62 changes: 62 additions & 0 deletions packages/components/filter/src/status.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ArrayListDataSource, type ListDataSource } from '@sl-design-system/data-source';
import { type Person, getPeople } from '@sl-design-system/example-data';
import { type Meta, type StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import '../register.js';

type Props = { dataSource?(people: Person[]): ListDataSource<Person> };
type Story = StoryObj<Props>;

export default {
title: 'Filter/Status',
tags: ['draft'],
args: {
dataSource: undefined
},
argTypes: {
dataSource: { table: { disable: true } }
},
loaders: [async () => ({ people: (await getPeople()).people })],
render: ({ dataSource }, { loaded: { people } }) => {
return html`<sl-filter-status .dataSource=${dataSource?.(people as Person[])}></sl-filter-status>`;
}
} satisfies Meta<Props>;

export const Basic: Story = {
args: {
dataSource: people => new ArrayListDataSource(people)
}
};

export const Blank: Story = {
args: {
dataSource: undefined
}
};

export const FilterByPath: Story = {
args: {
dataSource: people => {
const dataSource = new ArrayListDataSource(people);
dataSource.addFilter('profession', 'profession', 'Endocrinologist');
dataSource.addFilter('membership', 'membership', 'Premium');
dataSource.update();

return dataSource;
}
}
};

export const FilterByFunction: Story = {
args: {
dataSource: people => {
const dataSource = new ArrayListDataSource(people);
dataSource.addFilter('search', ({ firstName, lastName }) => {
return /Ann/.test(firstName) || /Ann/.test(lastName);
});
dataSource.update();

return dataSource;
}
}
};
Loading