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