diff --git a/.changeset/clever-ties-search.md b/.changeset/clever-ties-search.md new file mode 100644 index 0000000000..e8fe3c1a3d --- /dev/null +++ b/.changeset/clever-ties-search.md @@ -0,0 +1,16 @@ +--- +'@sl-design-system/editorial-suite': patch +'@sl-design-system/my-digital-book': patch +'@sl-design-system/sanoma-learning': patch +'@sl-design-system/itslearning': patch +'@sl-design-system/bingel-int': patch +'@sl-design-system/bingel-dc': patch +'@sl-design-system/clickedu': patch +'@sl-design-system/magister': patch +'@sl-design-system/kampus': patch +'@sl-design-system/neon': patch +'@sl-design-system/teas': patch +'@sl-design-system/max': patch +--- + +New paginator tokens, new `ellipsis-down` icon. diff --git a/.changeset/dry-wasps-thank.md b/.changeset/dry-wasps-thank.md new file mode 100644 index 0000000000..6700c451be --- /dev/null +++ b/.changeset/dry-wasps-thank.md @@ -0,0 +1,8 @@ +--- +'@sl-design-system/data-source': patch +'@sl-design-system/shared': patch +'@sl-design-system/form': patch +'@sl-design-system/grid': patch +--- + +Refactor `getValueByPath` and related functions to properly infer type diff --git a/.changeset/rare-impalas-attend.md b/.changeset/rare-impalas-attend.md new file mode 100644 index 0000000000..c4c26ac92b --- /dev/null +++ b/.changeset/rare-impalas-attend.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/data-source': patch +--- + +Fix missing data-source reference diff --git a/.changeset/real-steaks-agree.md b/.changeset/real-steaks-agree.md new file mode 100644 index 0000000000..6019e252f3 --- /dev/null +++ b/.changeset/real-steaks-agree.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/locales': patch +--- + +New translations added necessary for `paginator`, `items counter` and `page size` components. diff --git a/.changeset/smart-bikes-melt.md b/.changeset/smart-bikes-melt.md new file mode 100644 index 0000000000..d6ae980a0e --- /dev/null +++ b/.changeset/smart-bikes-melt.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/paginator': patch +--- + +Added new `paginator`, `items counter` and `page size` components that can be used together or separately. diff --git a/.changeset/tiny-buses-taste.md b/.changeset/tiny-buses-taste.md new file mode 100644 index 0000000000..c6fc5cd049 --- /dev/null +++ b/.changeset/tiny-buses-taste.md @@ -0,0 +1,7 @@ +--- +'@sl-design-system/breadcrumbs': patch +'@sl-design-system/tooltip': patch +'@sl-design-system/select': patch +--- + +Fix `ShadowRoot.createElement` type definition to properly match `document.createElement` diff --git a/.changeset/twenty-rice-lie.md b/.changeset/twenty-rice-lie.md new file mode 100644 index 0000000000..058deb72dd --- /dev/null +++ b/.changeset/twenty-rice-lie.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/data-source': patch +--- + +Add possibility to use data source with pagination. Added `paginate` method. diff --git a/packages/components/breadcrumbs/src/breadcrumbs.ts b/packages/components/breadcrumbs/src/breadcrumbs.ts index 4c1d183057..667d9185f6 100644 --- a/packages/components/breadcrumbs/src/breadcrumbs.ts +++ b/packages/components/breadcrumbs/src/breadcrumbs.ts @@ -16,7 +16,10 @@ declare global { interface ShadowRoot { // Workaround for missing type in @open-wc/scoped-elements - createElement(tagName: string): HTMLElement; + createElement( + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K]; } } diff --git a/packages/components/data-source/src/array-data-source.spec.ts b/packages/components/data-source/src/array-data-source.spec.ts index 20eb15246e..472bd3fe57 100644 --- a/packages/components/data-source/src/array-data-source.spec.ts +++ b/packages/components/data-source/src/array-data-source.spec.ts @@ -139,15 +139,60 @@ describe('ArrayDataSource', () => { }); it('should reset the original order when removing a sort', () => { - ds.setSort('id', 'firstName', 'asc'); + ds.setSort('id', 'profession', 'asc'); ds.update(); - expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'Ann', 'Bob', 'Jane', 'John']); + expect(ds.items.map(({ profession }) => profession)).to.deep.equal([ + 'Endocrinologist', + 'Gastroenterologist', + 'Gastroenterologist', + 'Nephrologist', + 'Ophthalmologist' + ]); ds.removeSort(); ds.update(); - expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'John', 'Jane', 'Ann', 'Bob']); + expect(ds.items.map(({ profession }) => profession)).to.deep.equal([ + 'Endocrinologist', + 'Nephrologist', + 'Ophthalmologist', + 'Gastroenterologist', + 'Gastroenterologist' + ]); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + ds = new ArrayDataSource(people); + + ds.paginate(2, 3, people.length); + ds.update(); + }); + + it('should paginate people', () => { + expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'Bob']); + }); + + it('should set the page', () => { + ds.setPage(1); + ds.update(); + + expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'John', 'Jane']); + + ds.setPage(2); + ds.update(); + + expect(ds.items.map(({ firstName }) => firstName)).to.deep.equal(['Ann', 'Bob']); + }); + + it('should set page size', () => { + ds.setPageSize(2); + ds.update(); + + expect(ds.page).to.exist; + expect(ds.page!.pageSize).to.equal(2); }); }); }); diff --git a/packages/components/data-source/src/array-data-source.ts b/packages/components/data-source/src/array-data-source.ts index 32b87fb988..c576890edb 100644 --- a/packages/components/data-source/src/array-data-source.ts +++ b/packages/components/data-source/src/array-data-source.ts @@ -1,4 +1,4 @@ -import { getStringByPath, getValueByPath } from '@sl-design-system/shared'; +import { type PathKeys, getStringByPath, getValueByPath } from '@sl-design-system/shared'; import { DataSource, type DataSourceFilterByFunction, @@ -37,7 +37,7 @@ export class ArrayDataSource extends DataSource { const filters = Array.from(this.filters.values()); const pathFilters = filters - .filter((f): f is DataSourceFilterByPath => 'path' in f && !!f.path) + .filter((f): f is DataSourceFilterByPath => 'path' in f && !!f.path) .reduce( (acc, { path, value }) => { if (!acc[path]) { @@ -52,10 +52,10 @@ export class ArrayDataSource extends DataSource { return acc; }, - {} as Record + {} as Record, string[]> ); - Object.entries(pathFilters).forEach(([path, values]) => { + for (const [path, values] of Object.entries(pathFilters)) { /** * Convert the value to a string and trim it, so we can match * an empty string to: @@ -64,8 +64,14 @@ export class ArrayDataSource extends DataSource { * - null * - undefined */ - items = items.filter(item => values.includes(getValueByPath(item, path)?.toString()?.trim() ?? '')); - }); + items = items.filter(item => + values.includes( + getValueByPath(item, path as PathKeys) + ?.toString() + ?.trim() ?? '' + ) + ); + } filters .filter((f): f is DataSourceFilterByFunction => 'filter' in f && !!f.filter) @@ -125,6 +131,15 @@ export class ArrayDataSource extends DataSource { }); } + // paginate items + if (this.page) { + const startIndex = (this.page.page - 1) * this.page.pageSize, + endIndex = startIndex + this.page.pageSize; + + this.page.totalItems = items.length; + items = items.slice(startIndex, endIndex); + } + this.#filteredItems = items; this.dispatchEvent(new CustomEvent('sl-update', { detail: { dataSource: this } })); } diff --git a/packages/components/data-source/src/data-source.ts b/packages/components/data-source/src/data-source.ts index f5298541ec..7b5279766f 100644 --- a/packages/components/data-source/src/data-source.ts +++ b/packages/components/data-source/src/data-source.ts @@ -1,3 +1,5 @@ +import { type PathKeys } from '@sl-design-system/shared'; + declare global { interface GlobalEventHandlersEventMap { 'sl-update': DataSourceUpdateEvent; @@ -11,12 +13,12 @@ export type DataSourceFilterByFunction = { value?: string | string[]; }; -export type DataSourceFilterByPath = { path: string; value: string | string[] }; +export type DataSourceFilterByPath = { path: PathKeys; value: string | string[] }; -export type DataSourceFilter = DataSourceFilterByFunction | DataSourceFilterByPath; +export type DataSourceFilter = DataSourceFilterByFunction | DataSourceFilterByPath; export type DataSourceGroupBy = { - path: string; + path: PathKeys; sorter?: DataSourceSortFunction; direction?: DataSourceSortDirection; }; @@ -25,7 +27,7 @@ export type DataSourceSortDirection = 'asc' | 'desc'; export type DataSourceSortFunction = (a: T, b: T) => number; -export type DataSourceSortByPath = { id?: string; path: string; direction: DataSourceSortDirection }; +export type DataSourceSortByPath = { id?: string; path: PathKeys; direction: DataSourceSortDirection }; export type DataSourceSortByFunction = { id?: string; @@ -33,7 +35,9 @@ export type DataSourceSortByFunction = { direction: DataSourceSortDirection; }; -export type DataSourceSort = DataSourceSortByFunction | DataSourceSortByPath; +export type DataSourceSort = DataSourceSortByFunction | DataSourceSortByPath; + +export type DataSourcePagination = { page: number; pageSize: number; totalItems: number }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type DataSourceUpdateEvent = CustomEvent<{ dataSource: DataSource }>; @@ -46,6 +50,9 @@ export abstract class DataSource extends EventTarget { /** Order the items by grouping them on the given attributes. */ #groupBy?: DataSourceGroupBy; + /** Parameters for pagination, contains page number, page size and total items amount. */ + #page?: DataSourcePagination; + /** * The value and path/function to use for sorting. When setting this property, * it will cause the data to be automatically sorted. @@ -60,6 +67,10 @@ export abstract class DataSource extends EventTarget { return this.#groupBy; } + get page(): DataSourcePagination | undefined { + return this.#page; + } + get sort(): DataSourceSort | undefined { return this.#sort; } @@ -70,16 +81,16 @@ export abstract class DataSource extends EventTarget { /** Total number of items in this data source. */ abstract readonly size: number; - /** Updates the list of items using filter and sorting if available. */ + /** Updates the list of items using filter, sorting and pagination if available. */ abstract update(): void; - addFilter>( + addFilter | DataSourceFilterFunction>( id: string, pathOrFilter: U, value?: string | string[] ): void { if (typeof pathOrFilter === 'string') { - this.#filters.set(id, { path: pathOrFilter, value: value ?? '' }); + this.#filters.set(id, { path: pathOrFilter as PathKeys, value: value ?? '' }); } else { this.#filters.set(id, { filter: pathOrFilter, value }); } @@ -99,7 +110,7 @@ export abstract class DataSource extends EventTarget { * @param sorter Optional sorter function. * @param direction Optional sort direction. */ - setGroupBy(path: string, sorter?: DataSourceSortFunction, direction?: DataSourceSortDirection): void { + setGroupBy(path: PathKeys, sorter?: DataSourceSortFunction, direction?: DataSourceSortDirection): void { this.#groupBy = { path, sorter, direction }; } @@ -110,20 +121,28 @@ export abstract class DataSource extends EventTarget { this.#groupBy = undefined; } - setSort>( + setSort | DataSourceSortFunction>( id: string, pathOrSorter: U, direction: DataSourceSortDirection ): void { if (typeof pathOrSorter === 'string') { - this.#sort = { id, path: pathOrSorter, direction }; + this.#sort = { id, path: pathOrSorter as PathKeys, direction }; } else { this.#sort = { id, sorter: pathOrSorter, direction }; } + + if (this.#page) { + this.setPage(0); + } } removeSort(): void { this.#sort = undefined; + + if (this.#page) { + this.setPage(0); + } } /** @@ -147,4 +166,29 @@ export abstract class DataSource extends EventTarget { this.update(); } + + setPage(page: number): void { + if (this.#page) { + this.paginate(page, this.#page.pageSize, this.#page.totalItems); + } + } + + setPageSize(pageSize: number): void { + if (this.#page) { + this.paginate(0, pageSize, this.#page.totalItems); + } + } + + setTotalItems(totalItems: number): void { + if (this.#page) { + this.paginate(this.#page.page, this.#page.pageSize, totalItems); + } + } + + /** + * Use to get the paginated data for usage with the sl-paginator component. + * */ + paginate(page: number, pageSize: number, totalItems: number): void { + this.#page = { page: page, pageSize: pageSize, totalItems: totalItems }; + } } diff --git a/packages/components/form/src/form-field.ts b/packages/components/form/src/form-field.ts index 1fadeec263..4cee2c9644 100644 --- a/packages/components/form/src/form-field.ts +++ b/packages/components/form/src/form-field.ts @@ -19,7 +19,10 @@ declare global { interface ShadowRoot { // Workaround for missing type in @open-wc/scoped-elements - createElement(tagName: string): HTMLElement; + createElement( + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K]; } } diff --git a/packages/components/form/src/form.ts b/packages/components/form/src/form.ts index c411493b81..a9dcc8bb5f 100644 --- a/packages/components/form/src/form.ts +++ b/packages/components/form/src/form.ts @@ -1,10 +1,17 @@ -import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; +import { + type EventEmitter, + EventsController, + type Path, + type PathKeys, + event, + getValueByPath, + setValueByPath +} from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; import { type FormControl, type SlFormControlEvent } from './form-control-mixin.js'; import { FormField, type SlFormFieldEvent } from './form-field.js'; import styles from './form.scss.js'; -import { getValueByPath, setValueByPath } from './path.js'; declare global { interface HTMLElementTagNameMap { @@ -101,7 +108,7 @@ export class Form = Record> e get value(): T { const value = this.controls.reduce((value, control) => { if (control.name) { - setValueByPath(value, control.name, control.formValue); + setValueByPath(value as T, control.name as PathKeys, control.formValue as Path>); } return value; }, {}) as T; @@ -118,7 +125,7 @@ export class Form = Record> e this.#value = value; if (value) { - this.controls.filter(c => c.name).forEach(c => (c.formValue = getValueByPath(value, c.name!))); + this.controls.filter(c => c.name).forEach(c => (c.formValue = getValueByPath(value, c.name! as PathKeys))); } else { this.controls.forEach(c => (c.formValue = undefined)); } @@ -184,7 +191,7 @@ export class Form = Record> e // Wait for the next frame change the control's properties requestAnimationFrame(() => { if (control.name && this.#value) { - control.formValue = getValueByPath(this.#value, control.name); + control.formValue = getValueByPath(this.#value, control.name as PathKeys); } if (this.disabled) { diff --git a/packages/components/form/src/path.spec.ts b/packages/components/form/src/path.spec.ts deleted file mode 100644 index 4dcb8742f6..0000000000 --- a/packages/components/form/src/path.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { expect } from '@open-wc/testing'; -import { getValueByPath, setValueByPath } from './path.js'; - -describe('path utils', () => { - describe('getValueByPath', () => { - it('should return undefined if the path does not exist', () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - expect(getValueByPath({}, 'foo')).to.be.undefined; - }); - - it('should return a value in the root of the object', () => { - expect(getValueByPath({ foo: 'bar' }, 'foo')).to.equal('bar'); - }); - - it('should return a value in a nested object', () => { - expect(getValueByPath({ foo: { bar: 'baz' } }, 'foo.bar')).to.equal('baz'); - }); - - it('should return an array item', () => { - expect(getValueByPath({ foo: { bar: ['baz'] } }, 'foo.bar[0]')).to.equal('baz'); - }); - - it('should return a value in a nested object in an array item', () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - expect(getValueByPath({ foo: [{ bar: 'baz' }] }, 'foo[0].bar')).to.equal('baz'); - }); - }); - - describe('setValueByPath', () => { - describe('new object', () => { - it('should set a value in the root of the object', () => { - const obj = {}; - - setValueByPath(obj, 'foo', 'baz'); - - expect(obj).to.deep.equal({ foo: 'baz' }); - }); - - it('should set a value in a nested object', () => { - const obj = {}; - - setValueByPath(obj, 'foo.bar', 'qux'); - - expect(obj).to.deep.equal({ foo: { bar: 'qux' } }); - }); - - it('should set an array item', () => { - const obj = {}; - - setValueByPath(obj, 'foo.bar[0]', 'qux'); - - expect(obj).to.deep.equal({ foo: { bar: ['qux'] } }); - }); - - it('should set a non-zero array item', () => { - const obj = {}; - - setValueByPath(obj, 'foo.bar[1]', 'qux'); - - expect(obj).to.deep.equal({ foo: { bar: [undefined, 'qux'] } }); - }); - - it('should set a value in a nested object in an array item', () => { - const obj = {}; - - setValueByPath(obj, 'foo[0].bar', 'qux'); - - expect(obj).to.deep.equal({ foo: [{ bar: 'qux' }] }); - }); - }); - - describe('existing object', () => { - it('should set a value in the root of the object', () => { - const obj = { foo: 'bar' }; - - setValueByPath(obj, 'foo', 'baz'); - - expect(obj).to.deep.equal({ foo: 'baz' }); - }); - - it('should set a value in a nested object', () => { - const obj = { foo: { bar: 'baz' } }; - - setValueByPath(obj, 'foo.bar', 'qux'); - - expect(obj).to.deep.equal({ foo: { bar: 'qux' } }); - }); - - it('should set an array item', () => { - const obj = { foo: { bar: ['baz'] } }; - - setValueByPath(obj, 'foo.bar[0]', 'qux'); - - expect(obj).to.deep.equal({ foo: { bar: ['qux'] } }); - }); - - it('should set a value in a nested object in an array item', () => { - const obj = { foo: [{ bar: 'baz' }] }; - - setValueByPath(obj, 'foo[0].bar', 'qux'); - - expect(obj).to.deep.equal({ foo: [{ bar: 'qux' }] }); - }); - }); - }); -}); diff --git a/packages/components/form/src/path.ts b/packages/components/form/src/path.ts deleted file mode 100644 index 6d5b51dc01..0000000000 --- a/packages/components/form/src/path.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ -export type Path = P extends `${infer K}.${infer Rest}` - ? K extends keyof T - ? Rest extends PathKeys - ? Path - : never - : never - : P extends `${infer K}[${infer I}]` - ? K extends keyof T - ? T[K] extends Array - ? I extends `${number}` - ? U - : never - : never - : never - : P extends keyof T - ? T[P] - : never; - -export type PathKeys = T extends object - ? { [K in keyof T]: K extends string ? `${K}.${PathKeys}` | K : never }[keyof T] - : ''; - -export function getValueByPath(obj: T, path: P): Path | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let result: any = obj; - - for (const key of path.split('.')) { - if (result === undefined || result === null) { - break; - } - - const match = /\[(\d+)\]$/.exec(key); - if (match) { - const index = Number(match[1]), - arrayKey = key.slice(0, -match[0].length); - - result = result[arrayKey][index]; - } else { - result = result[key]; - } - } - - return result as Path; -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function setValueByPath(obj: T, path: string, value: any): void; - -export function setValueByPath(obj: T, path: P, value: Path): void { - const keys = path.split('.'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let current: any = obj; - - for (let i = 0; i < keys.length; i++) { - const key = keys[i], - match = /\[(\d+)\]$/.exec(key); - - if (match) { - const index = Number(match[1]), - arrayKey = key.slice(0, -match[0].length); - - current[arrayKey] ??= []; - - if (i === keys.length - 1) { - current[arrayKey][index] = value; - } else { - current = current[arrayKey][index] ??= {}; - } - } else { - current[key] ??= {}; - - if (i === keys.length - 1) { - current[key] = value; - } else { - current = current[key]; - } - } - } -} diff --git a/packages/components/grid/package.json b/packages/components/grid/package.json index e974dafce1..306681646d 100644 --- a/packages/components/grid/package.json +++ b/packages/components/grid/package.json @@ -51,11 +51,13 @@ "devDependencies": { "@lit-labs/virtualizer": "^2.0.13", "@lit/localize": "^0.12.1", - "@open-wc/scoped-elements": "^3.0.5" + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.1.4" }, "peerDependencies": { "@lit-labs/virtualizer": "^2.0.12", "@lit/localize": "^0.12.1", - "@open-wc/scoped-elements": "^3.0.5" + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.1.4" } } diff --git a/packages/components/grid/src/column.ts b/packages/components/grid/src/column.ts index 3af444e2c6..efce144da4 100644 --- a/packages/components/grid/src/column.ts +++ b/packages/components/grid/src/column.ts @@ -1,5 +1,12 @@ import { FetchDataSourcePlaceholder } from '@sl-design-system/data-source'; -import { type EventEmitter, dasherize, event, getNameByPath, getValueByPath } from '@sl-design-system/shared'; +import { + type EventEmitter, + type PathKeys, + dasherize, + event, + getNameByPath, + getValueByPath +} from '@sl-design-system/shared'; import { type CSSResult, LitElement, type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { type Grid } from './grid.js'; @@ -92,7 +99,7 @@ export class GridColumn extends LitElement { @property() header?: string | GridColumnHeaderRenderer; /** The path to the value for this column. */ - @property() path?: string; + @property() path?: PathKeys; /** Custom parts to be set on the `` so it can be styled externally. */ @property() parts?: string | GridColumnParts; diff --git a/packages/components/grid/src/filter-column.ts b/packages/components/grid/src/filter-column.ts index 18e00534ea..bf3440a618 100644 --- a/packages/components/grid/src/filter-column.ts +++ b/packages/components/grid/src/filter-column.ts @@ -1,6 +1,6 @@ import { localized, msg } from '@lit/localize'; import { type DataSource, type DataSourceFilterFunction } from '@sl-design-system/data-source'; -import { getNameByPath, getValueByPath } from '@sl-design-system/shared'; +import { type Path, type PathKeys, getNameByPath, getValueByPath } from '@sl-design-system/shared'; import { type TemplateResult, html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { GridColumn } from './column.js'; @@ -65,12 +65,12 @@ export class GridFilterColumn extends GridColumn { // No options were provided, so we'll create a list of options based on the column's values this.internalOptions = dataSource?.items ?.reduce((acc, item) => { - let value = getValueByPath(item, this.path), + let value = getValueByPath(item, this.path!), label = value?.toString() ?? ''; if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) { label = msg('Blank'); - value = ''; + value = '' as Path>; } if (value !== null && !acc.some(option => option.value === value)) { diff --git a/packages/components/grid/src/filter.ts b/packages/components/grid/src/filter.ts index 7a85370792..1005211151 100644 --- a/packages/components/grid/src/filter.ts +++ b/packages/components/grid/src/filter.ts @@ -7,7 +7,7 @@ import { Checkbox, CheckboxGroup } from '@sl-design-system/checkbox'; import { type DataSourceFilterFunction } from '@sl-design-system/data-source'; import { Icon } from '@sl-design-system/icon'; import { Popover } from '@sl-design-system/popover'; -import { type EventEmitter, event, getNameByPath, getValueByPath } from '@sl-design-system/shared'; +import { type EventEmitter, type PathKeys, event, getNameByPath, getValueByPath } from '@sl-design-system/shared'; import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { TextField } from '@sl-design-system/text-field'; import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; @@ -86,7 +86,7 @@ export class GridFilter extends ScopedElementsMixin(LitElement) { @property({ attribute: false }) options?: GridFilterOption[]; /** The path to the field to filter on. */ - @property() path?: string; + @property() path?: PathKeys; set value(value: string | string[] | undefined) { if (this.mode !== 'text') { @@ -109,7 +109,7 @@ export class GridFilter extends ScopedElementsMixin(LitElement) { if (this.mode === 'text' && !this.filter) { this.filter = item => { - const itemValue = getValueByPath(item, this.column.path); + const itemValue = getValueByPath(item, this.column.path!); if (typeof itemValue !== 'string') { return false; diff --git a/packages/components/grid/src/grid.ts b/packages/components/grid/src/grid.ts index aaf584c2bd..4ec3e8e1a7 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -373,7 +373,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { renderHeader(): TemplateResult { const rows = this.view.headerRows, - selectionColumn = rows.at(-1)?.find((col): col is GridSelectionColumn => col instanceof GridSelectionColumn), + selectionColumn = rows.at(-1)?.find((col): col is GridSelectionColumn => col instanceof GridSelectionColumn), showSelectionHeader = selectionColumn && this.selection.size > 0 && diff --git a/packages/components/grid/src/select-column.ts b/packages/components/grid/src/select-column.ts index 18f2c62f25..bd9fa3e69f 100644 --- a/packages/components/grid/src/select-column.ts +++ b/packages/components/grid/src/select-column.ts @@ -1,5 +1,5 @@ import { Select, SelectOption } from '@sl-design-system/select'; -import { getValueByPath, setValueByPath } from '@sl-design-system/shared'; +import { type Path, type PathKeys, getValueByPath, setValueByPath } from '@sl-design-system/shared'; import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; @@ -27,7 +27,7 @@ export class GridSelectColumn extends GridColumn { this.#onChange(event, item)} - .value=${getValueByPath(item, this.path)} + .value=${getValueByPath(item, this.path!)} > ${this.options?.map(option => typeof option === 'string' @@ -40,6 +40,6 @@ export class GridSelectColumn extends GridColumn { } #onChange(event: SlChangeEvent, item: T): void { - setValueByPath(item, this.path, event.detail); + setValueByPath(item, this.path!, event.detail as Path>); } } diff --git a/packages/components/grid/src/stories/pagination.stories.ts b/packages/components/grid/src/stories/pagination.stories.ts new file mode 100644 index 0000000000..9cdac7797a --- /dev/null +++ b/packages/components/grid/src/stories/pagination.stories.ts @@ -0,0 +1,179 @@ +import '@sl-design-system/button/register.js'; +import '@sl-design-system/button-bar/register.js'; +import { ArrayDataSource } from '@sl-design-system/data-source'; +import { type Person, getPeople } from '@sl-design-system/example-data'; +import { Paginator, PaginatorSize, PaginatorStatus } from '@sl-design-system/paginator'; +import '@sl-design-system/paginator/register.js'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import '@sl-design-system/text-field/register.js'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; +import '../../register.js'; +import { Grid } from '../grid.js'; + +type Story = StoryObj; + +export default { + title: 'Grid/Pagination', + tags: ['draft'], + loaders: [async () => ({ people: (await getPeople()).people as unknown[] })], + parameters: { + // Disables Chromatic's snapshotting on a story level + chromatic: { disableSnapshot: true } + } +} satisfies Meta; + +export const Basic: Story = { + render: (_, { loaded: { people } }) => { + const people2 = people as unknown[]; + const pageSizes = [5, 10, 15]; + let page = 1, + pageSize = pageSizes[1], + startIndex = (page - 1) * pageSize, + endIndex = startIndex + pageSize; + + setTimeout(() => { + const paginator = document.querySelector('sl-paginator') as Paginator, + paginatorSize = document.querySelector('sl-paginator-size') as PaginatorSize, + visibleItems = document.querySelector('sl-paginator-status') as PaginatorStatus, + grid = document.querySelector('sl-grid') as Grid; + + paginator?.addEventListener('sl-page-change', (event: SlChangeEvent) => { + const detail = event.detail as number; + visibleItems.page = detail; + page = detail; + startIndex = (detail - 1) * pageSize; + endIndex = startIndex + pageSize; + grid.items = people2.slice(startIndex, endIndex); + }); + + paginatorSize?.addEventListener('sl-page-size-change', (event: SlChangeEvent) => { + const detail = event.detail as number; + paginator.pageSize = detail; + visibleItems.pageSize = detail; + pageSize = detail; + }); + }); + + return html` + + + + + + + + + + `; + } +}; + +export const PaginatedDataSourceWithFilter: Story = { + render: (_, { loaded: { people } }) => { + const pageSizes = [5, 10, 15, 20], + dataSource = new ArrayDataSource(people as Person[]); + + const total = dataSource.items.length; + dataSource.paginate(2, 15, total); + dataSource.update(); + + return html` + + + + + + + + + + `; + } +}; + +export const PaginatedDataSourceWithSorter: Story = { + render: (_, { loaded: { people } }) => { + const sorter = (a: Person, b: Person): number => { + const lastNameCmp = a.lastName.localeCompare(b.lastName); + + if (lastNameCmp === 0) { + return a.firstName.localeCompare(b.firstName); + } else { + return lastNameCmp; + } + }; + + const dataSource = new ArrayDataSource(people as Person[]); + dataSource.setSort('custom', sorter, 'asc'); + + const pageSizes = [10, 15, 20]; + const total = dataSource.items.length; + dataSource.paginate(3, 10, total); + dataSource.update(); + + return html` + +

This grid sorts people by last name, then first name, via a custom sorter on the data directly.

+ + + + + + + `; + } +}; diff --git a/packages/components/grid/src/text-field-column.ts b/packages/components/grid/src/text-field-column.ts index 86730d0eec..f74fb89554 100644 --- a/packages/components/grid/src/text-field-column.ts +++ b/packages/components/grid/src/text-field-column.ts @@ -1,4 +1,5 @@ -import { getValueByPath, setValueByPath } from '@sl-design-system/shared'; +import { type Path, type PathKeys, getValueByPath, setValueByPath } from '@sl-design-system/shared'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { TextField } from '@sl-design-system/text-field'; import { type TemplateResult, html } from 'lit'; import { GridColumn } from './column.js'; @@ -21,14 +22,14 @@ export class GridTextFieldColumn extends GridColumn { return html` ) => this.#onChange(event, item)} - .value=${getValueByPath(item, this.path)} + @sl-change=${(event: SlChangeEvent) => this.#onChange(event, item)} + .value=${getValueByPath(item, this.path!)} > `; } - #onChange(event: CustomEvent, item: T): void { - setValueByPath(item, this.path, event.detail); + #onChange(event: SlChangeEvent, item: T): void { + setValueByPath(item, this.path!, event.detail as Path>); } } diff --git a/packages/components/grid/src/view-model.ts b/packages/components/grid/src/view-model.ts index c1037f1ef0..e71623e773 100644 --- a/packages/components/grid/src/view-model.ts +++ b/packages/components/grid/src/view-model.ts @@ -137,7 +137,7 @@ export class GridViewModel { return 'none'; } else { const groupByPath = this.#dataSource?.groupBy?.path, - items = this.#dataSource?.items.filter(item => getValueByPath(item, groupByPath) === value); + items = this.#dataSource?.items.filter(item => getValueByPath(item, groupByPath!) === value); const some = items?.some(item => this.#grid.selection.isSelected(item)), all = items?.every(item => this.#grid.selection.isSelected(item)); @@ -153,7 +153,7 @@ export class GridViewModel { return 'none'; } else { const groupByPath = this.#dataSource?.groupBy?.path, - items = this.#dataSource?.items.filter(item => getValueByPath(item, groupByPath) === value); + items = this.#dataSource?.items.filter(item => getValueByPath(item, groupByPath!) === value); const some = items?.some(item => this.#grid.selection.isSelected(item)), all = items?.every(item => this.#grid.selection.isSelected(item)); diff --git a/packages/components/paginator/index.ts b/packages/components/paginator/index.ts new file mode 100644 index 0000000000..1e7f6af9e4 --- /dev/null +++ b/packages/components/paginator/index.ts @@ -0,0 +1,3 @@ +export * from './src/paginator.js'; +export * from './src/paginator-size.js'; +export * from './src/paginator-status.js'; diff --git a/packages/components/paginator/package.json b/packages/components/paginator/package.json new file mode 100644 index 0000000000..e3a9dadc5c --- /dev/null +++ b/packages/components/paginator/package.json @@ -0,0 +1,55 @@ +{ + "name": "@sl-design-system/paginator", + "version": "0.0.0", + "description": "Paginator component 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/paginator" + }, + "homepage": "https://sanomalearning.design/components/paginator", + "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.0.3", + "@sl-design-system/icon": "^1.0.2", + "@sl-design-system/menu": "^0.1.3", + "@sl-design-system/select": "^1.1.2", + "@sl-design-system/shared": "^0.4.0" + }, + "devDependencies": { + "@lit/localize": "^0.12.1", + "@open-wc/scoped-elements": "^3.0.5" + }, + "peerDependencies": { + "@lit/localize": "^0.12.1", + "@open-wc/scoped-elements": "^3.0.5" + } +} diff --git a/packages/components/paginator/register.ts b/packages/components/paginator/register.ts new file mode 100644 index 0000000000..b2a99af670 --- /dev/null +++ b/packages/components/paginator/register.ts @@ -0,0 +1,7 @@ +import { PaginatorSize } from './src/paginator-size'; +import { PaginatorStatus } from './src/paginator-status'; +import { Paginator } from './src/paginator.js'; + +customElements.define('sl-paginator', Paginator); +customElements.define('sl-paginator-size', PaginatorSize); +customElements.define('sl-paginator-status', PaginatorStatus); diff --git a/packages/components/paginator/src/paginator-page.scss b/packages/components/paginator/src/paginator-page.scss new file mode 100644 index 0000000000..7c6768f2d6 --- /dev/null +++ b/packages/components/paginator/src/paginator-page.scss @@ -0,0 +1,77 @@ +:host { + --_background: var(--sl-color-button-default-ghost-idle-background); + --_border-color: var(--sl-color-button-default-ghost-idle-border); + --_border-width: var(--sl-border-width-button-ghost); + --_color: var(--sl-color-button-default-ghost-idle-foreground); + --_focus-outline-offset: var(--sl-border-width-focusring-offset); + --_focus-outline: var(--sl-color-focusring-default) solid var(--sl-border-width-focusring-default); + --_font: var(--sl-text-button-text-lg); + --_min-width: var(--sl-size-paginator-page-button-min-width); + --_padding-block: var(--sl-space-button-solid-block-lg); + --_transition-duration: var(--sl-animation-button-duration); + --_transition-easing: var(--sl-animation-button-easing); +} + +:host(:hover) { + --_background: var(--sl-color-button-default-ghost-hover-background); + --_border-color: var(--sl-color-button-default-ghost-hover-border); + --_color: var(--sl-color-button-default-ghost-hover-foreground); +} + +:host(:active) { + --_background: var(--sl-color-button-default-ghost-active-background); + --_border-color: var(--sl-color-button-default-ghost-active-border); + --_color: var(--sl-color-button-default-ghost-active-foreground); +} + +:host([active]) { + --_background: var(--sl-color-button-default-ghost-selected-idle-background); + --_border-color: var(--sl-color-button-default-ghost-selected-idle-border); + --_color: var(--sl-color-button-default-ghost-selected-idle-foreground); +} + +:host([active]:hover) { + --_background: var(--sl-color-button-default-ghost-selected-hover-background); + --_border-color: var(--sl-color-button-default-ghost-selected-hover-border); + --_color: var(--sl-color-button-default-ghost-hover-foreground); +} + +:host([active]:active) { + --_background: var(--sl-color-button-default-ghost-selected-active-background); + --_border-color: var(--sl-color-button-default-ghost-selected-active-border); + --_color: var(--sl-color-button-default-ghost-active-foreground); +} + +:host([disabled]) { + --_background: var(--sl-color-button-default-ghost-disabled-background); + --_color: var(--sl-color-button-default-ghost-disabled-foreground); + + cursor: default; + pointer-events: none; +} + +button { + background: var(--_background); + border: var(--_border-width) solid var(--_border-color); + border-radius: var(--sl-border-radius-button-lg); + cursor: pointer; + display: inline-flex; + font: var(--_font); + justify-content: center; + min-inline-size: var(--_min-width); + padding: var(--_padding-block) 0; + position: relative; + transition: var(--_transition-duration) var(--_transition-easing); +} + +button:focus-visible { + outline: var(--_focus-outline); + outline-offset: var(--_focus-outline-offset); + z-index: 1; +} + +button:where(:active, :hover) { + transition-duration: var(--_transition-duration); + transition-property: background, border-color, color, outline-color; + transition-timing-function: var(--_transition-easing); +} diff --git a/packages/components/paginator/src/paginator-page.spec.ts b/packages/components/paginator/src/paginator-page.spec.ts new file mode 100644 index 0000000000..9e7fd1b3a4 --- /dev/null +++ b/packages/components/paginator/src/paginator-page.spec.ts @@ -0,0 +1,32 @@ +import { expect, fixture } from '@open-wc/testing'; +import '@sl-design-system/button/register.js'; +import '@sl-design-system/select/register.js'; +import { html } from 'lit'; +import '../register.js'; +import { PaginatorPage } from './paginator-page.js'; + +describe('sl-paginator-page', () => { + let el: PaginatorPage; + + describe('defaults', () => { + beforeEach(async () => { + try { + customElements.define('sl-paginator-page', PaginatorPage); + } catch { + // empty + } + + el = await fixture(html` 1 `); + }); + + it('should have a button', () => { + const button = el.renderRoot.querySelector('button'); + + expect(button).to.exist; + }); + + it('should have a proper aria-label', () => { + expect(el.renderRoot.querySelector('button')).to.have.attribute('aria-label', '1, page'); + }); + }); +}); diff --git a/packages/components/paginator/src/paginator-page.ts b/packages/components/paginator/src/paginator-page.ts new file mode 100644 index 0000000000..f502ebe59d --- /dev/null +++ b/packages/components/paginator/src/paginator-page.ts @@ -0,0 +1,36 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import styles from './paginator-page.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-paginator-page': PaginatorPage; + } +} + +/** + * A page internal component, that is used as part of the paginator, representing pages in the paginator. + */ +export class PaginatorPage extends LitElement { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal Page number used for the aria-label. */ + @state() page?: string; + + override render(): TemplateResult { + return html` + + `; + } + + #onSlotChange(event: Event & { target: HTMLSlotElement }): void { + this.page = event.target + .assignedNodes({ flatten: true }) + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent?.trim()) + .join(''); + } +} diff --git a/packages/components/paginator/src/paginator-size.scss b/packages/components/paginator/src/paginator-size.scss new file mode 100644 index 0000000000..3206f19967 --- /dev/null +++ b/packages/components/paginator/src/paginator-size.scss @@ -0,0 +1,26 @@ +:host { + align-items: center; + color: var(--sl-color-text-default); + display: flex; + flex-wrap: wrap; + font: var(--sl-text-new-body-md); + font-weight: var(--sl-text-typeset-font-weight-icon-regular); + gap: var(--sl-space-paginator-gap); + padding: 0; + text-wrap: nowrap; +} + +sl-select { + min-inline-size: var(--sl-size-paginator-select-min-width); +} + +sl-label label { + font: inherit; +} + +/** Hiding the aria-live element for the UI, but not for the screen readers. */ +#live { + block-size: 0; + inline-size: 0; + overflow: hidden; +} diff --git a/packages/components/paginator/src/paginator-size.spec.ts b/packages/components/paginator/src/paginator-size.spec.ts new file mode 100644 index 0000000000..50667cfc01 --- /dev/null +++ b/packages/components/paginator/src/paginator-size.spec.ts @@ -0,0 +1,107 @@ +import { expect, fixture } from '@open-wc/testing'; +import '@sl-design-system/button/register.js'; +import '@sl-design-system/select/register.js'; +import { html } from 'lit'; +import { spy } from 'sinon'; +import '../register.js'; +import { PaginatorSize } from './paginator-size'; + +describe('sl-paginator-size', () => { + let el: PaginatorSize; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` `); + }); + + it('should have items per page with value of 10 by default', () => { + expect(el.pageSize).to.equal(10); + }); + + it('should have aria-live by default', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Amount of items per page: 10'); + }); + + it('should not have a select inside when pageSizes is not set', () => { + const slSelect = el.renderRoot.querySelector('sl-select'); + + expect(slSelect).not.to.exist; + }); + }); + + describe('page sizes', () => { + beforeEach(async () => { + el = await fixture(html` `); + }); + + it('should have a select inside when pageSizes is set', async () => { + el.pageSizes = [5, 10, 20]; + await el.updateComplete; + + const slSelect = el.renderRoot.querySelector('sl-select'); + + expect(slSelect).to.exist; + }); + + it('should have proper options with possible page sizes', async () => { + el.pageSizes = [5, 10, 20]; + await el.updateComplete; + + const options = el.renderRoot.querySelectorAll('sl-select-option'); + + expect(options).to.exist; + + const pageSizes = Array.from(options)?.map(option => option.value); + + expect(pageSizes).to.deep.equal([5, 10, 20]); + }); + }); + + describe('items per page', () => { + beforeEach(async () => { + el = await fixture(html` `); + }); + + it('should set first value of page sizes when there is no pageSize value', () => { + const slSelect = el.renderRoot.querySelector('sl-select'); + + expect(slSelect).to.exist; + expect(slSelect?.value).to.equal(5); + }); + + it('should set a proper items per page amount when the value has changed', async () => { + const slSelect = el.renderRoot.querySelector('sl-select'); + + expect(slSelect).to.exist; + + const options = el.renderRoot.querySelectorAll('sl-select-option'); + + expect(options).to.exist; + + options[1].click(); + await el.updateComplete; + + expect(slSelect?.value).to.equal(10); + }); + + it('should emit an sl-paginator-size-change event when the value of the items per page has changed', async () => { + const onPageSizeChange = spy(); + el.addEventListener('sl-page-size-change', onPageSizeChange); + const slSelect = el.renderRoot.querySelector('sl-select'); + + expect(slSelect).to.exist; + + const options = el.renderRoot.querySelectorAll('sl-select-option'); + + expect(options).to.exist; + + options[1].click(); + await el.updateComplete; + + expect(onPageSizeChange).to.have.been.called; + }); + }); +}); diff --git a/packages/components/paginator/src/paginator-size.stories.ts b/packages/components/paginator/src/paginator-size.stories.ts new file mode 100644 index 0000000000..1369129f1b --- /dev/null +++ b/packages/components/paginator/src/paginator-size.stories.ts @@ -0,0 +1,94 @@ +import '@sl-design-system/button/register.js'; +import '@sl-design-system/card/register.js'; +import { ArrayDataSource } from '@sl-design-system/data-source'; +import '@sl-design-system/icon/register.js'; +import '@sl-design-system/menu/register.js'; +import '@sl-design-system/paginator/register.js'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { type TemplateResult, html } from 'lit'; +import '../register.js'; +import { type PaginatorSize } from './paginator-size'; +import { type Paginator } from './paginator.js'; + +type Props = Pick & { + totalItems: number; + actions?(): string | TemplateResult; + content?(): string | TemplateResult; +}; +type Story = StoryObj; + +export default { + title: 'Navigation/Paginator/Paginator size', + tags: ['draft'], + parameters: { + viewport: { + defaultViewport: 'reset' + } + }, + args: { + pageSize: 10, + pageSizes: [5, 10, 15] + }, + render: ({ pageSize, pageSizes }) => { + return html` `; + } +} satisfies Meta; + +export const Basic: Story = {}; + +export const WithDataSource: Story = { + render: () => { + const items = Array.from({ length: 80 }, (_, index) => ({ + nr: index + 1 + })); + + const pageSizes = [5, 10, 15, 20, 25, 30]; + + const dataSource = new ArrayDataSource(items); + + requestAnimationFrame(() => { + const totalItems = dataSource.items.length; + dataSource.paginate(2, 5, totalItems); + dataSource.update(); + }); + + return html` `; + } +}; + +export const WithPaginator: Story = { + args: { + totalItems: 120, + pageSize: 30, + pageSizes: [5, 15, 30, 45] + }, + render: ({ pageSize, pageSizes, totalItems }) => { + setTimeout(() => { + const paginator = document.querySelector('sl-paginator') as Paginator, + pageSize = document.querySelector('sl-paginator-size') as PaginatorSize; + + pageSize?.addEventListener('sl-page-size-change', (event: SlChangeEvent) => { + paginator.pageSize = event.detail as number; + }); + }); + return html` + + + `; + } +}; diff --git a/packages/components/paginator/src/paginator-size.ts b/packages/components/paginator/src/paginator-size.ts new file mode 100644 index 0000000000..d1c77b613e --- /dev/null +++ b/packages/components/paginator/src/paginator-size.ts @@ -0,0 +1,119 @@ +import { localized, msg, str } from '@lit/localize'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { type DataSource } from '@sl-design-system/data-source'; +import { Label } from '@sl-design-system/form'; +import { Select, SelectOption } from '@sl-design-system/select'; +import { type EventEmitter, event } from '@sl-design-system/shared'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './paginator-size.scss.js'; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-page-size-change': SlChangeEvent; + } + + interface HTMLElementTagNameMap { + 'sl-paginator-size': PaginatorSize; + } +} + +/** + * A component that can be used with the paginator. + * The component adds a possibility to select/change the amount of items that would be visible per page. + */ +@localized() +export class PaginatorSize extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-label': Label, + 'sl-select': Select, + 'sl-select-option': SelectOption + }; + } + + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** Provided data source. */ + @property({ attribute: false }) dataSource?: DataSource; + + /** Items per page. Default to the first item of pageSizes, if pageSizes is not set - default to 10. */ + @property({ type: Number, attribute: 'page-size' }) pageSize?: number; + + /** @internal Emits when the page size has been selected/changed. */ + @event({ name: 'sl-page-size-change' }) pageSizeChangeEvent!: EventEmitter>; + + /** Page sizes - array of possible page sizes e.g. [5, 10, 15]. */ + @property({ type: Number, attribute: 'page-sizes' }) pageSizes?: number[]; + + override disconnectedCallback(): void { + this.dataSource?.removeEventListener('sl-update', this.#onUpdate); + + super.disconnectedCallback(); + } + + override willUpdate(changes: PropertyValues): void { + super.willUpdate(changes); + if (changes.has('pageSize')) { + if (!this.pageSize) { + this.pageSize = this.pageSizes ? this.pageSizes[0] : 10; + } + } + + if (changes.has('dataSource')) { + this.dataSource?.addEventListener('sl-update', this.#onUpdate); + } + } + + override firstUpdated(changes: PropertyValues): void { + super.firstUpdated(changes); + + if (!this.pageSize) { + this.pageSize = this.pageSizes ? this.pageSizes[0] : 10; + } + } + + override render(): TemplateResult { + return html` + ${msg('Items per page')}: + ${this.pageSizes + ? html` + + ${this.pageSizes.map( + size => html` + + ${size} + + ` + )} + + ` + : nothing} + +
${msg(str`Amount of items per page: ${this.pageSize}`)}
+ `; + } + + #onChange(event: Event): void { + const newValue = Number((event.target as SelectOption).value); + if (this.pageSize !== newValue) { + this.pageSize = newValue; + + /** Emits amount of selected items per page */ + this.pageSizeChangeEvent.emit(newValue); + + this.dataSource?.setPageSize(newValue); + this.dataSource?.update(); + } + } + + #onUpdate = () => { + const newPageSize = this.dataSource?.page?.pageSize; + if (this.pageSize !== newPageSize) { + this.pageSize = newPageSize; + } + }; +} diff --git a/packages/components/paginator/src/paginator-status.scss b/packages/components/paginator/src/paginator-status.scss new file mode 100644 index 0000000000..f7f8e629e6 --- /dev/null +++ b/packages/components/paginator/src/paginator-status.scss @@ -0,0 +1,15 @@ +:host { + color: var(--sl-color-text-default); + display: block; + font: var(--sl-text-new-body-md); + font-weight: var(--sl-text-typeset-font-weight-icon-regular); + padding: 0; + text-wrap: nowrap; +} + +/** Hiding the aria-live element for the UI, but not for the screen readers. */ +#live { + block-size: 0; + inline-size: 0; + overflow: hidden; +} diff --git a/packages/components/paginator/src/paginator-status.spec.ts b/packages/components/paginator/src/paginator-status.spec.ts new file mode 100644 index 0000000000..f570bc8a58 --- /dev/null +++ b/packages/components/paginator/src/paginator-status.spec.ts @@ -0,0 +1,205 @@ +import { expect, fixture } from '@open-wc/testing'; +import '@sl-design-system/button/register.js'; +import '@sl-design-system/select/register.js'; +import { html } from 'lit'; +import '../register.js'; +import { PaginatorStatus } from './paginator-status.js'; + +describe('sl-paginator-status', () => { + let el: PaginatorStatus; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have a rendered text with information about items', () => { + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('1 - 1 of 1 items')).to.be.true; + }); + + it('should have aria-live by default', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 1 to 1 of 1 items'); + }); + }); + + describe('first page', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have a rendered proper text with information about visible items on the first page', () => { + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('1 - 15 of 100 items')).to.be.true; + }); + + it('should have a proper aria-live', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 1 to 15 of 100 items'); + }); + }); + + describe('last page', () => { + beforeEach(async () => { + el = await fixture( + html`` + ); + }); + + it('should have a rendered proper text with information about visible items on the first page', () => { + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('196 - 209 of 209 items')).to.be.true; + }); + + it('should have a proper aria-live', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 196 to 209 of 209 items'); + }); + }); + + describe('invalid page', () => { + beforeEach(async () => { + el = await fixture( + html`` + ); + }); + + it('should have a rendered proper text with information about visible items on the first page', () => { + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('1 - 15 of 209 items')).to.be.true; + }); + + it('should have a proper aria-live', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 1 to 15 of 209 items'); + }); + }); + + describe('page change', () => { + beforeEach(async () => { + el = await fixture( + html`` + ); + }); + + it('should have a rendered proper text with information about visible items on the page', async () => { + el.page = 10; + await el.updateComplete; + + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('136 - 150 of 209 items')).to.be.true; + + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 136 to 150 of 209 items'); + }); + + it('should have a proper page when set smaller than 1', async () => { + el.page = -1; + await new Promise(resolve => setTimeout(resolve, 100)); + + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(el.page).to.equal(1); + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('1 - 15 of 209 items')).to.be.true; + + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 1 to 15 of 209 items'); + }); + + it('should have set page to the last one when the number set is bigger than the total number of pages', async () => { + el.page = 100; + await new Promise(resolve => setTimeout(resolve, 100)); + + const itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(el.page).to.equal(14); + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('196 - 209 of 209 items')).to.be.true; + + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 196 to 209 of 209 items'); + }); + }); + + describe('items per page change', () => { + beforeEach(async () => { + el = await fixture( + html`` + ); + }); + + it('should have a rendered proper text with information about visible items', async () => { + let itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('196 - 209 of 209 items')).to.be.true; + + el.pageSize = 5; + await new Promise(resolve => setTimeout(resolve, 100)); + + itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('66 - 70 of 209 items')).to.be.true; + + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 66 to 70 of 209 items'); + }); + }); + + describe('total amount of items change', () => { + beforeEach(async () => { + el = await fixture( + html`` + ); + }); + + it('should have a rendered proper text with information about visible items', async () => { + let itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('196 - 209 of 209 items')).to.be.true; + + el.totalItems = 508; + await new Promise(resolve => setTimeout(resolve, 100)); + + itemsCounterLabel = el.renderRoot.textContent?.trim(); + + expect(itemsCounterLabel).to.exist; + expect(itemsCounterLabel!.includes('196 - 210 of 508 items')).to.be.true; + + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Currently showing 196 to 210 of 508 items'); + }); + }); +}); diff --git a/packages/components/paginator/src/paginator-status.stories.ts b/packages/components/paginator/src/paginator-status.stories.ts new file mode 100644 index 0000000000..d1c48abf9f --- /dev/null +++ b/packages/components/paginator/src/paginator-status.stories.ts @@ -0,0 +1,102 @@ +import '@sl-design-system/button/register.js'; +import '@sl-design-system/card/register.js'; +import { ArrayDataSource } from '@sl-design-system/data-source'; +import '@sl-design-system/icon/register.js'; +import '@sl-design-system/menu/register.js'; +import '@sl-design-system/paginator/register.js'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { type TemplateResult, html } from 'lit'; +import '../register.js'; +import { PaginatorStatus } from './paginator-status'; +import { type Paginator } from './paginator.js'; + +type Props = Pick & { + actions?(): string | TemplateResult; + content?(): string | TemplateResult; +}; +type Story = StoryObj; + +export default { + title: 'Navigation/Paginator/Paginator status', + tags: ['draft'], + parameters: { + viewport: { + defaultViewport: 'reset' + } + }, + args: { + totalItems: 100, + pageSize: 10, + page: 5 + }, + render: ({ pageSize, page, totalItems }) => { + return html` + + `; + } +} satisfies Meta; + +export const Basic: Story = {}; + +export const WithDataSource: Story = { + render: () => { + const items = [ + { nr: 1, title: 'test 1' }, + { nr: 2, title: 'test 2' }, + { nr: 3, title: 'test 3' }, + { nr: 4, title: 'test 4' }, + { nr: 5, title: 'test 5' }, + { nr: 6, title: 'test 6' }, + { nr: 7, title: 'test 7' }, + { nr: 8, title: 'test 8' }, + { nr: 9, title: 'test 9' }, + { nr: 10, title: 'test 10' } + ]; + + const dataSource = new ArrayDataSource(items); + + requestAnimationFrame(() => { + const totalItems = dataSource.items.length; + dataSource.paginate(2, 5, totalItems); + dataSource.update(); + }); + + return html` `; + } +}; + +export const WithPaginator: Story = { + args: { + page: 3, + totalItems: 200 + }, + render: ({ page, pageSize, totalItems }) => { + setTimeout(() => { + const paginator = document.querySelector('sl-paginator') as Paginator, + visibleItems = document.querySelector('sl-paginator-status') as PaginatorStatus; + + paginator?.addEventListener('sl-page-change', (event: SlChangeEvent) => { + visibleItems.page = event.detail as number; + }); + }); + return html` + + + `; + } +}; diff --git a/packages/components/paginator/src/paginator-status.ts b/packages/components/paginator/src/paginator-status.ts new file mode 100644 index 0000000000..e1ebb0f773 --- /dev/null +++ b/packages/components/paginator/src/paginator-status.ts @@ -0,0 +1,113 @@ +import { localized, msg, str } from '@lit/localize'; +import { type DataSource } from '@sl-design-system/data-source'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import styles from './paginator-status.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-paginator-status': PaginatorStatus; + } +} + +/** + * A component that can be used with the paginator component. + * Contains information about currently visible items on the page and total amount of items. + */ +@localized() +export class PaginatorStatus extends LitElement { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal Pages amount. */ + #pages = 1; + + /** Currently active page, if not set - default to 1. */ + @property({ type: Number }) page = 1; + + /** @internal Currently visible items on the current page. */ + @state() currentlyVisibleItems = 1; + + /** Provided data source. */ + @property({ attribute: false }) dataSource?: DataSource; + + /** Items per page, if not set - default to 10. */ + @property({ type: Number, attribute: 'page-size' }) pageSize = 10; + + /** Total amount of items, if not set - default to 1. */ + @property({ type: Number, attribute: 'total-items' }) totalItems = 1; + + override disconnectedCallback(): void { + this.dataSource?.removeEventListener('sl-update', this.#onUpdate); + + super.disconnectedCallback(); + } + + override firstUpdated(changes: PropertyValues): void { + super.firstUpdated(changes); + + this.#pages = Math.ceil(this.totalItems / this.pageSize); + + this.#setCurrentlyVisibleItems(); + } + + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('dataSource')) { + this.dataSource?.addEventListener('sl-update', this.#onUpdate); + } + + if (changes.has('pageSize') || changes.has('totalItems')) { + this.#pages = Math.ceil(this.totalItems / this.pageSize); + this.#setCurrentlyVisibleItems(); + } + + if (changes.has('page')) { + if (this.page < 1) { + this.page = 1; + } else if (this.page > this.#pages) { + this.page = this.#pages; + } + + this.#pages = Math.ceil(this.totalItems / this.pageSize); + this.#setCurrentlyVisibleItems(); + } + } + + override render(): TemplateResult { + const start = this.page === 1 ? 1 : (this.page - 1) * this.pageSize + 1; + const end = this.page === this.#pages ? this.totalItems : this.page * this.currentlyVisibleItems; + + return html` + ${msg(str`${start} - ${end} of ${this.totalItems} items`)} + +
+ ${msg(str`Currently showing ${start} to ${end} of ${this.totalItems} items`)} +
+ `; + } + + #setCurrentlyVisibleItems(): void { + if (!this.pageSize || !this.#pages) { + return; + } + + if (this.page === this.#pages) { + const itemsOnLastPage = this.totalItems % this.pageSize; + this.currentlyVisibleItems = itemsOnLastPage === 0 ? this.pageSize : itemsOnLastPage; + } else { + this.currentlyVisibleItems = this.pageSize!; + } + } + + #onUpdate = () => { + if (!this.dataSource || !this.dataSource.page) { + return; + } + + this.pageSize = this.dataSource.page.pageSize; + this.page = this.dataSource.page.page; + this.totalItems = this.dataSource.page.totalItems; + }; +} diff --git a/packages/components/paginator/src/paginator.scss b/packages/components/paginator/src/paginator.scss new file mode 100644 index 0000000000..898dd2d197 --- /dev/null +++ b/packages/components/paginator/src/paginator.scss @@ -0,0 +1,76 @@ +:host { + --_focus-space: calc(var(--sl-border-width-focusring-default) + var(--sl-border-width-focusring-offset)); + + display: flex; + justify-content: center; + min-inline-size: 0; +} + +:host([mobile]) { + sl-button.prev, + sl-button.next { + display: none; + } + + .container { + // to align with changes made for focus ring visibility with overflow hidden: + margin: var(--_focus-space); + } + + .pages-wrapper { + display: none; + } + + .select-wrapper { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--sl-space-paginator-gap); + justify-content: center; + + sl-select { + min-inline-size: var(--sl-size-paginator-select-min-width); + } + } +} + +.pages-wrapper { + align-items: center; + display: flex; + gap: var(--sl-space-paginator-gap); + margin: 0; + overflow-x: hidden; + + // to make focus-ring visible with overflow hidden: + padding: var(--_focus-space); +} + +.select-wrapper { + display: none; +} + +.container { + align-items: center; + display: flex; + gap: var(--sl-space-paginator-gap); + + // to make focus-ring visible with overflow hidden: + margin: calc(var(--_focus-space) * -1); + min-inline-size: 0; + padding: 0; +} + +li { + align-items: start; + box-sizing: border-box; + display: flex; + flex-direction: row; + list-style: none; +} + +/** Hiding the aria-live element for the UI, but not for the screen readers. */ +#live { + block-size: 0; + inline-size: 0; + overflow: hidden; +} diff --git a/packages/components/paginator/src/paginator.spec.ts b/packages/components/paginator/src/paginator.spec.ts new file mode 100644 index 0000000000..9e6e7870be --- /dev/null +++ b/packages/components/paginator/src/paginator.spec.ts @@ -0,0 +1,622 @@ +import { expect, fixture } from '@open-wc/testing'; +import { Button } from '@sl-design-system/button'; +import '@sl-design-system/button/register.js'; +import { ArrayDataSource } from '@sl-design-system/data-source'; +import { Select } from '@sl-design-system/select'; +import '@sl-design-system/select/register.js'; +import { html } from 'lit'; +import { spy } from 'sinon'; +import '../register.js'; +import { PaginatorPage } from './paginator-page.js'; +import { Paginator } from './paginator.js'; + +describe('sl-paginator', () => { + let el: Paginator; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` `); + + // Give the resize observer time to do its thing + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + it('should have active page with value of 1 when it is not explicitly set', () => { + expect(el.page).to.equal(1); + }); + + it('should have a proper value of items per page when it is not explicitly set', () => { + expect(el.pageSize).to.equal(20); + }); + + it('should have no mobile attribute', () => { + expect(el).not.to.have.attribute('mobile'); + }); + + it('should have previous and next buttons', () => { + const prev = el.renderRoot.querySelector('sl-button.prev'), + next = el.renderRoot.querySelector('sl-button.next'); + + expect(prev).to.exist; + expect(next).to.exist; + }); + + it('should have proper pages', () => { + const pages = el.renderRoot.querySelectorAll('sl-paginator-page'); + + expect(pages).to.exist; + expect(pages.length).to.equal(10); + + const pagesLabels = Array.from(pages).map(page => page.textContent?.trim()); + + expect(pagesLabels).to.deep.equal(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']); + }); + + it('should have not displayed compact variant', () => { + const selectWrapper = el.renderRoot.querySelector('div.select-wrapper') as HTMLDivElement; + + expect(selectWrapper).to.exist; + expect(getComputedStyle(selectWrapper).display).to.equal('none'); + }); + + it('should have aria-live by default', () => { + const ariaLive = el.renderRoot.querySelector('#live') as HTMLElement; + + expect(ariaLive).to.have.attribute('aria-live', 'polite'); + expect(ariaLive).to.have.rendered.text('Page 1 of 10'); + }); + }); + + describe('page', () => { + beforeEach(async () => { + el = await fixture(html` + + `); + + // Give the resize observer time to do its thing + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + it('should go to page 1 when set page is smaller than 1', async () => { + el.page = -1; + await el.updateComplete; + + expect(el.page).to.equal(1); + }); + + it('should have set page to the last one when the number set is bigger than the total number of pages', async () => { + el.page = 100; + await el.updateComplete; + + expect(el.page).to.equal(10); + }); + + it('should set the right page on page click', async () => { + const pages = el.renderRoot.querySelectorAll('sl-paginator-page'); + + pages[3].click(); + await el.updateComplete; + + expect(el.page).to.equal(4); + }); + + it('should set the next page on next button click', async () => { + const next = el.renderRoot.querySelector