diff --git a/.changeset/curvy-jeans-travel.md b/.changeset/curvy-jeans-travel.md new file mode 100644 index 0000000000..1b4f208bc4 --- /dev/null +++ b/.changeset/curvy-jeans-travel.md @@ -0,0 +1,7 @@ +--- +'@sl-design-system/checkbox': patch +--- + +Various fixes: +- Fix bug where clicking a checkbox in a tree-node will not check it +- Fix `sl-change` event firing multiple times for a single click diff --git a/.changeset/mean-kids-poke.md b/.changeset/mean-kids-poke.md new file mode 100644 index 0000000000..2eab3d6916 --- /dev/null +++ b/.changeset/mean-kids-poke.md @@ -0,0 +1,8 @@ +--- +'@sl-design-system/eslint-config': patch +--- + +Bump typescript-eslint version to fix false positive + +Updating to the latest version fixes a false positive error that +was being thrown related to the `RovingTabindexController` having an `any` type. \ No newline at end of file diff --git a/.changeset/real-bugs-camp.md b/.changeset/real-bugs-camp.md new file mode 100644 index 0000000000..2e878a841b --- /dev/null +++ b/.changeset/real-bugs-camp.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/grid': patch +--- + +Fix incorrect type import using absolute path diff --git a/.changeset/shy-wombats-run.md b/.changeset/shy-wombats-run.md new file mode 100644 index 0000000000..c7ae55412f --- /dev/null +++ b/.changeset/shy-wombats-run.md @@ -0,0 +1,9 @@ +--- +'@sl-design-system/shared': minor +--- + +Add new `focusToElement` method to `FocusGroupController` + +This allows you to focus on a specific element within a focus group. This is +useful when you want to focus on a specific element within a focus group, but still +maintain the roving tabindex behavior. diff --git a/.changeset/tender-ways-reply.md b/.changeset/tender-ways-reply.md new file mode 100644 index 0000000000..d6d5665e1d --- /dev/null +++ b/.changeset/tender-ways-reply.md @@ -0,0 +1,12 @@ +--- +'@sl-design-system/data-source': patch +'@sl-design-system/grid': patch +'@sl-design-system/paginator': patch +--- + +Refactor existing data sources into list specific datasources, clearing +the way to add `TreeDataSource` in the `@sl-design-system/tree` package. + +- The base `DataSource` class has support for sorting and filtering +- Grouping and pagination has been moved to the `ListDataSource` class +- `ArrayDataSource` and `FetchDataSource` have been renamed to `ArrayListDataSource` and `FetchListDataSource` respectively diff --git a/.changeset/yellow-islands-attack.md b/.changeset/yellow-islands-attack.md new file mode 100644 index 0000000000..baf7fd346f --- /dev/null +++ b/.changeset/yellow-islands-attack.md @@ -0,0 +1,7 @@ +--- +'@sl-design-system/tree': patch +--- + +New tree component: +- Added a new `` component +- Added `abstract` `TreeModel`, `FlatTreeModel` and `NestedTreeModel` classes diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts index 8f65827ab8..a438a8eda8 100644 --- a/packages/components/checkbox/src/checkbox.ts +++ b/packages/components/checkbox/src/checkbox.ts @@ -192,8 +192,15 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMix return; } - if (event.target instanceof HTMLLabelElement) { + const label = event.composedPath().find((el): el is HTMLLabelElement => el instanceof HTMLLabelElement); + if (label?.parentElement === this) { this.input.click(); + + event.preventDefault(); + event.stopPropagation(); + + // Return early to prevent the checkbox from being toggled twice + return; } event.stopPropagation(); diff --git a/packages/components/data-source/index.ts b/packages/components/data-source/index.ts index beadb7e46d..93303716f1 100644 --- a/packages/components/data-source/index.ts +++ b/packages/components/data-source/index.ts @@ -1,3 +1,4 @@ -export * from './src/array-data-source.js'; +export * from './src/array-list-data-source.js'; export * from './src/data-source.js'; -export * from './src/fetch-data-source.js'; +export * from './src/fetch-list-data-source.js'; +export * from './src/list-data-source.js'; diff --git a/packages/components/data-source/src/array-data-source.spec.ts b/packages/components/data-source/src/array-list-data-source.spec.ts similarity index 93% rename from packages/components/data-source/src/array-data-source.spec.ts rename to packages/components/data-source/src/array-list-data-source.spec.ts index b9b3bbc603..169213c65f 100644 --- a/packages/components/data-source/src/array-data-source.spec.ts +++ b/packages/components/data-source/src/array-list-data-source.spec.ts @@ -1,14 +1,14 @@ import { expect } from '@open-wc/testing'; import { spy } from 'sinon'; -import { ArrayDataSource } from './array-data-source.js'; +import { ArrayListDataSource } from './array-list-data-source.js'; import { type Person, people } from './data-source.spec.js'; -describe('ArrayDataSource', () => { - let ds: ArrayDataSource; +describe('ArrayListDataSource', () => { + let ds: ArrayListDataSource; describe('basics', () => { beforeEach(() => { - ds = new ArrayDataSource(people); + ds = new ArrayListDataSource(people); }); it('should have items', () => { @@ -47,7 +47,7 @@ describe('ArrayDataSource', () => { describe('filtering', () => { beforeEach(() => { - ds = new ArrayDataSource(people); + ds = new ArrayListDataSource(people); }); it('should filter by path', () => { @@ -121,7 +121,7 @@ describe('ArrayDataSource', () => { describe('sorting', () => { beforeEach(() => { - ds = new ArrayDataSource(people); + ds = new ArrayListDataSource(people); }); it('should sort by path', () => { @@ -172,7 +172,7 @@ describe('ArrayDataSource', () => { describe('pagination', () => { beforeEach(() => { - ds = new ArrayDataSource(people, { pagination: true }); + ds = new ArrayListDataSource(people, { pagination: true }); ds.setPage(1); ds.setPageSize(3); ds.update(); diff --git a/packages/components/data-source/src/array-data-source.ts b/packages/components/data-source/src/array-list-data-source.ts similarity index 93% rename from packages/components/data-source/src/array-data-source.ts rename to packages/components/data-source/src/array-list-data-source.ts index 9417e228f4..03dde6b1d2 100644 --- a/packages/components/data-source/src/array-data-source.ts +++ b/packages/components/data-source/src/array-list-data-source.ts @@ -1,13 +1,10 @@ import { type PathKeys, getStringByPath, getValueByPath } from '@sl-design-system/shared'; import { - DataSource, type DataSourceFilterByFunction, type DataSourceFilterByPath, - type DataSourceOptions, type DataSourceSortFunction } from './data-source.js'; - -export type ArrayDataSourceOptions = DataSourceOptions; +import { ListDataSource, type ListDataSourceOptions } from './list-data-source.js'; /** * A data source that can be used to filter, group by, sort, @@ -16,7 +13,7 @@ export type ArrayDataSourceOptions = DataSourceOptions; * to load any additional data. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class ArrayDataSource extends DataSource { +export class ArrayListDataSource extends ListDataSource { /** * The array of items after filtering, sorting, grouping and * pagination has been applied. @@ -30,16 +27,11 @@ export class ArrayDataSource extends DataSource { return this.#filteredItems; } - set items(items: T[]) { - this.#items = items; - this.update(); - } - get size(): number { return this.#items.length; } - constructor(items: T[], options: ArrayDataSourceOptions = {}) { + constructor(items: T[], options: ListDataSourceOptions = {}) { super(options); this.#filteredItems = [...items]; this.#items = [...items]; diff --git a/packages/components/data-source/src/data-source.spec.ts b/packages/components/data-source/src/data-source.spec.ts index 6ba7e0bd1a..ae0bd43642 100644 --- a/packages/components/data-source/src/data-source.spec.ts +++ b/packages/components/data-source/src/data-source.spec.ts @@ -1,5 +1,4 @@ import { expect } from '@open-wc/testing'; -import { spy } from 'sinon'; import { DataSource } from './data-source.js'; // eslint-disable-next-line mocha/no-exports @@ -116,23 +115,6 @@ describe('DataSource', () => { expect(ds.filters).to.be.empty; }); - it('should not group by by default', () => { - expect(ds.groupBy).to.be.undefined; - }); - - it('should group by after setting one', () => { - ds.setGroupBy('profession'); - - expect(ds.groupBy).to.deep.equal({ path: 'profession', sorter: undefined, direction: undefined }); - }); - - it('should not group by after removing it', () => { - ds.setGroupBy('profession'); - ds.removeGroupBy(); - - expect(ds.groupBy).to.be.undefined; - }); - it('should not sort by default', () => { expect(ds.sort).to.be.undefined; }); @@ -149,15 +131,4 @@ describe('DataSource', () => { expect(ds.sort).to.be.undefined; }); - - it('should reorder items', () => { - spy(ds, 'update'); - - expect(ds.items.map(({ id }) => id)).to.deep.equal([1, 2, 3, 4, 5]); - - ds.reorder(people[0], people[4], 'before'); - - expect(ds.items.map(({ id }) => id)).to.deep.equal([2, 3, 4, 1, 5]); - expect(ds.update).to.have.been.calledOnce; - }); }); diff --git a/packages/components/data-source/src/data-source.ts b/packages/components/data-source/src/data-source.ts index 38f89dd12e..40d8d887c9 100644 --- a/packages/components/data-source/src/data-source.ts +++ b/packages/components/data-source/src/data-source.ts @@ -6,116 +6,73 @@ declare global { } } -export type DataSourceFilterFunction = (item: T, index: number, array: T[]) => boolean; +export type DataSourceFilterFunction = (item: Model, index: number, array: Model[]) => boolean; -export type DataSourceFilterByFunction = { - filter: DataSourceFilterFunction; +export type DataSourceFilterByFunction = { + filter: DataSourceFilterFunction; value?: string | string[]; }; -export type DataSourceFilterByPath = { path: PathKeys; value: string | string[] }; +export type DataSourceFilterByPath = { path: PathKeys; value: string | string[] }; -export type DataSourceFilter = DataSourceFilterByFunction | DataSourceFilterByPath; - -export type DataSourceGroupBy = { - path: PathKeys; - sorter?: DataSourceSortFunction; - direction?: DataSourceSortDirection; -}; +export type DataSourceFilter = DataSourceFilterByFunction | DataSourceFilterByPath; export type DataSourceSortDirection = 'asc' | 'desc'; -export type DataSourceSortFunction = (a: T, b: T) => number; +export type DataSourceSortFunction = (a: Model, b: Model) => number; -export type DataSourceSortByPath = { id?: string; path: PathKeys; direction: DataSourceSortDirection }; +export type DataSourceSortByPath = { id?: string; path: PathKeys; direction: DataSourceSortDirection }; -export type DataSourceSortByFunction = { +export type DataSourceSortByFunction = { id?: string; - sorter: DataSourceSortFunction; + sorter: DataSourceSortFunction; direction: DataSourceSortDirection; }; -export type DataSourceSort = DataSourceSortByFunction | DataSourceSortByPath; +export type DataSourceSort = DataSourceSortByFunction | DataSourceSortByPath; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DataSourceUpdateEvent = CustomEvent<{ dataSource: DataSource }>; - -/** The default page size, if not explicitly set. */ -export const DATA_SOURCE_DEFAULT_PAGE_SIZE = 10; - -export type DataSourceOptions = { - pagination?: boolean; -}; +export type DataSourceUpdateEvent = CustomEvent<{ dataSource: DataSource }>; +/** + * Base class for all data sources. Data sources are used to filter and sort. Data sources + * can be used for components such as combobox, grid, listbox, paginator, tree etc. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export abstract class DataSource extends EventTarget { +export abstract class DataSource extends EventTarget { /** Map of all active filters. */ - #filters: Map> = new Map(); - - /** Order the items by grouping them on the given attributes. */ - #groupBy?: DataSourceGroupBy; - - /** The index of the page. */ - #page = 0; - - /** The number of items on a single page. */ - #pageSize = DATA_SOURCE_DEFAULT_PAGE_SIZE; - - /** Whether this data source uses pagination. */ - #pagination: boolean; + #filters: Map> = new Map(); /** * The value and path/function to use for sorting. When setting this property, * it will cause the data to be automatically sorted. */ - #sort?: DataSourceSort; + #sort?: DataSourceSort; - get filters(): Map> { + get filters(): Map> { return this.#filters; } - get groupBy(): DataSourceGroupBy | undefined { - return this.#groupBy; - } - - get page(): number { - return this.#page; - } - - get pageSize(): number { - return this.#pageSize; - } - - get pagination(): boolean { - return this.#pagination; - } - - get sort(): DataSourceSort | undefined { + get sort(): DataSourceSort | undefined { return this.#sort; } - /** The filtered & sorted array of items. */ - abstract items: T[]; + /** The filtered & sorted array of view models. */ + abstract readonly items: ViewModel[]; /** Total number of items in this data source. */ abstract readonly size: number; - /** Updates the list of items using filter, sorting, grouping and pagination if available. */ + /** Updates items using filter and sorting if available. */ abstract update(): void; - constructor(options: DataSourceOptions = {}) { - super(); - - this.#pagination = options.pagination ?? false; - } - - addFilter | DataSourceFilterFunction>( + addFilter | DataSourceFilterFunction>( id: string, - pathOrFilter: U, + pathOrFilter: T, value?: string | string[] ): void { if (typeof pathOrFilter === 'string') { - this.#filters.set(id, { path: pathOrFilter as PathKeys, value: value ?? '' }); + this.#filters.set(id, { path: pathOrFilter as PathKeys, value: value ?? '' }); } else { this.#filters.set(id, { filter: pathOrFilter, value }); } @@ -125,78 +82,19 @@ export abstract class DataSource extends EventTarget { this.#filters.delete(id); } - /** - * Group the items by the given path. Optionally, you can provide a sorter and direction. - * - * This is part of the DataSource interface, because it changes how the data is sorted. You - * may want to pass the groupBy attribute to the server, so it can sort the data for you. - * - * @param path Path to group by attribute. - * @param sorter Optional sorter function. - * @param direction Optional sort direction. - */ - setGroupBy(path: PathKeys, sorter?: DataSourceSortFunction, direction?: DataSourceSortDirection): void { - this.#groupBy = { path, sorter, direction }; - } - - /** - * Remove the groupBy attribute. This will cause the data to be sorted as if it was not grouped. - */ - removeGroupBy(): void { - this.#groupBy = undefined; - } - - setPage(page: number): void { - this.#page = page; - } - - setPageSize(pageSize: number): void { - this.#pageSize = pageSize; - } - - setSort | DataSourceSortFunction>( + setSort | DataSourceSortFunction>( id: string, - pathOrSorter: U, + pathOrSorter: T, direction: DataSourceSortDirection ): void { if (typeof pathOrSorter === 'string') { - this.#sort = { id, path: pathOrSorter as PathKeys, 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); - } - } - - /** - * Reorder the item in the data source. - * @param item The item to reorder. - * @param relativeItem The item to reorder relative to. - * @param position The position relative to the relativeItem. - * @returns True if the items were reordered, false if not. - */ - reorder(item: T, relativeItem: T, position: 'before' | 'after'): void { - const items = this.items, - from = items.indexOf(item), - to = items.indexOf(relativeItem) + (position === 'before' ? 0 : 1); - - if (from === -1 || to === -1 || from === to) { - return; - } - - items.splice(from, 1); - items.splice(to + (from < to ? -1 : 0), 0, item); - - this.update(); } } diff --git a/packages/components/data-source/src/fetch-data-source.spec.ts b/packages/components/data-source/src/fetch-list-data-source.spec.ts similarity index 93% rename from packages/components/data-source/src/fetch-data-source.spec.ts rename to packages/components/data-source/src/fetch-list-data-source.spec.ts index 5da424378b..81caa063a7 100644 --- a/packages/components/data-source/src/fetch-data-source.spec.ts +++ b/packages/components/data-source/src/fetch-list-data-source.spec.ts @@ -2,17 +2,17 @@ import { expect } from '@open-wc/testing'; import { spy } from 'sinon'; import { type Person, people } from './data-source.spec.js'; import { - FetchDataSource, - type FetchDataSourceCallbackOptions, - FetchDataSourcePlaceholder -} from './fetch-data-source.js'; + FetchListDataSource, + type FetchListDataSourceCallbackOptions, + FetchListDataSourcePlaceholder +} from './fetch-list-data-source.js'; -describe('FetchDataSource', () => { - let ds: FetchDataSource; +describe('FetchListDataSource', () => { + let ds: FetchListDataSource; describe('defaults', () => { beforeEach(() => { - ds = new FetchDataSource({ + ds = new FetchListDataSource({ fetchPage: ({ page, pageSize }) => { const start = page * pageSize, end = Math.min(start + pageSize, people.length); @@ -24,7 +24,7 @@ describe('FetchDataSource', () => { }); it('should have a size', () => { - expect(ds.size).to.equal(FetchDataSource.defaultSize); + expect(ds.size).to.equal(FetchListDataSource.defaultSize); }); it('should update the size after fetching for the first time', async () => { @@ -68,7 +68,7 @@ describe('FetchDataSource', () => { it('should return a placeholder item when the item is not yet available', async () => { ds.update(); - expect(ds.items[0]).to.equal(FetchDataSourcePlaceholder); + expect(ds.items[0]).to.equal(FetchListDataSourcePlaceholder); await new Promise(resolve => setTimeout(resolve)); @@ -134,7 +134,7 @@ describe('FetchDataSource', () => { }); it('should provide filter options when fetching a page', () => { - let options: FetchDataSourceCallbackOptions | undefined; + let options: FetchListDataSourceCallbackOptions | undefined; ds.fetchPage = _options => { options = _options; @@ -182,7 +182,7 @@ describe('FetchDataSource', () => { describe('pagination', () => { beforeEach(() => { - ds = new FetchDataSource({ + ds = new FetchListDataSource({ fetchPage: ({ page, pageSize }) => { const start = page * pageSize, end = Math.min(start + pageSize, people.length); diff --git a/packages/components/data-source/src/fetch-data-source.ts b/packages/components/data-source/src/fetch-list-data-source.ts similarity index 77% rename from packages/components/data-source/src/fetch-data-source.ts rename to packages/components/data-source/src/fetch-list-data-source.ts index dfc05f79cb..9a70fc4d1d 100644 --- a/packages/components/data-source/src/fetch-data-source.ts +++ b/packages/components/data-source/src/fetch-list-data-source.ts @@ -1,34 +1,35 @@ -import { DataSource, type DataSourceOptions, type DataSourceSort } from './data-source.js'; +import { type DataSourceSort } from './data-source.js'; +import { ListDataSource, type ListDataSourceOptions } from './list-data-source.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface FetchDataSourceCallbackOptions { +export interface FetchListDataSourceCallbackOptions { page: number; pageSize: number; sort?: DataSourceSort; [key: string]: unknown; } -export interface FetchDataSourceCallbackResult { +export interface FetchListDataSourceCallbackResult { items: T[]; totalItems?: number; } -export type FetchDataSourceCallback = ( - options: FetchDataSourceCallbackOptions -) => Promise>; +export type FetchListDataSourceCallback = ( + options: FetchListDataSourceCallbackOptions +) => Promise>; -export type FetchDataSourcePlaceholder = (n: number) => T; +export type FetchListDataSourcePlaceholder = (n: number) => T; -export type FetchDataSourceOptions = DataSourceOptions & { - fetchPage: FetchDataSourceCallback; - pageSize?: number; - placeholder?: FetchDataSourcePlaceholder; +export interface FetchListDataSourceOptions extends ListDataSourceOptions { + fetchPage: FetchListDataSourceCallback; + pageSize: number; + placeholder?: FetchListDataSourcePlaceholder; size?: number; -}; +} -export type FetchDataSourceEvent = CustomEvent; +export type FetchListDataSourceEvent = CustomEvent; -export const FetchDataSourceError = class extends Error { +export const FetchListDataSourceError = class extends Error { constructor( message: string, public response: Response @@ -38,10 +39,10 @@ export const FetchDataSourceError = class extends Error { }; /** Symbol used as a placeholder for items that are being loaded. */ -export const FetchDataSourcePlaceholder = Symbol('FetchDataSourcePlaceholder'); +export const FetchListDataSourcePlaceholder = Symbol('FetchListDataSourcePlaceholder'); // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class FetchDataSource extends DataSource { +export class FetchListDataSource extends ListDataSource { /** The default size of the item collection if not explicitly set. */ static defaultSize = 10; @@ -58,10 +59,10 @@ export class FetchDataSource extends DataSource { #size: number; /** The callback for retrieving data. */ - fetchPage: FetchDataSourceCallback; + fetchPage: FetchListDataSourceCallback; /** Returns placeholder data for items not yet loaded. */ - placeholder: FetchDataSourcePlaceholder = () => FetchDataSourcePlaceholder as T; + placeholder: FetchListDataSourcePlaceholder = () => FetchListDataSourcePlaceholder as T; get items(): T[] { return this.#proxy; @@ -71,10 +72,10 @@ export class FetchDataSource extends DataSource { return this.#size; } - constructor(options: FetchDataSourceOptions) { + constructor(options: FetchListDataSourceOptions) { super(options); - this.#size = options.size ?? FetchDataSource.defaultSize; + this.#size = options.size ?? FetchListDataSource.defaultSize; this.fetchPage = options.fetchPage; if (typeof options.pageSize === 'number') { @@ -111,7 +112,7 @@ export class FetchDataSource extends DataSource { * Override this function if you are extending the `FetchDataSource` class to * provide any additional options you may need when `fetchPage` is called. */ - getFetchOptions(page: number, pageSize: number): FetchDataSourceCallbackOptions { + getFetchOptions(page: number, pageSize: number): FetchListDataSourceCallbackOptions { return { filters: Array.from(this.filters.values()), page, pageSize, sort: this.sort }; } diff --git a/packages/components/data-source/src/list-data-source.spec.ts b/packages/components/data-source/src/list-data-source.spec.ts new file mode 100644 index 0000000000..51c1cd82d7 --- /dev/null +++ b/packages/components/data-source/src/list-data-source.spec.ts @@ -0,0 +1,56 @@ +import { expect } from '@open-wc/testing'; +import { spy } from 'sinon'; +import { type Person, people } from './data-source.spec.js'; +import { ListDataSource } from './list-data-source.js'; + +class TestListDataSource extends ListDataSource { + override items: Person[]; + override size: number; + + constructor() { + super(); + + this.items = [...people]; + this.size = people.length; + } + + override update(): void { + // empty + } +} + +describe('ListDataSource', () => { + let ds: TestListDataSource; + + beforeEach(() => { + ds = new TestListDataSource(); + }); + + it('should not group by by default', () => { + expect(ds.groupBy).to.be.undefined; + }); + + it('should group by after setting one', () => { + ds.setGroupBy('profession'); + + expect(ds.groupBy).to.deep.equal({ path: 'profession', sorter: undefined, direction: undefined }); + }); + + it('should not group by after removing it', () => { + ds.setGroupBy('profession'); + ds.removeGroupBy(); + + expect(ds.groupBy).to.be.undefined; + }); + + it('should reorder items', () => { + spy(ds, 'update'); + + expect(ds.items.map(({ id }) => id)).to.deep.equal([1, 2, 3, 4, 5]); + + ds.reorder(people[0], people[4], 'before'); + + expect(ds.items.map(({ id }) => id)).to.deep.equal([2, 3, 4, 1, 5]); + expect(ds.update).to.have.been.calledOnce; + }); +}); diff --git a/packages/components/data-source/src/list-data-source.ts b/packages/components/data-source/src/list-data-source.ts new file mode 100644 index 0000000000..e0ef8e0459 --- /dev/null +++ b/packages/components/data-source/src/list-data-source.ts @@ -0,0 +1,123 @@ +import { type PathKeys } from '@sl-design-system/shared'; +import { DataSource, type DataSourceSortDirection, type DataSourceSortFunction } from './data-source.js'; + +export type ListDataSourceGroupBy = { + path: PathKeys; + sorter?: DataSourceSortFunction; + direction?: DataSourceSortDirection; +}; + +export type ListDataSourceOptions = { + pagination?: boolean; +}; + +/** The default page size, if not explicitly set. */ +export const DATA_SOURCE_DEFAULT_PAGE_SIZE = 10; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class ListDataSource extends DataSource { + /** Order the items by grouping them on the given attributes. */ + #groupBy?: ListDataSourceGroupBy; + + /** The index of the page. */ + #page = 0; + + /** The number of items on a single page. */ + #pageSize = DATA_SOURCE_DEFAULT_PAGE_SIZE; + + /** Whether this data source uses pagination. */ + #pagination: boolean; + + get groupBy(): ListDataSourceGroupBy | undefined { + return this.#groupBy; + } + + get page(): number { + return this.#page; + } + + get pageSize(): number { + return this.#pageSize; + } + + get pagination(): boolean { + return this.#pagination; + } + + constructor(options: ListDataSourceOptions = {}) { + super(); + + this.#pagination = options.pagination ?? false; + } + + /** + * Group the items by the given path. Optionally, you can provide a sorter and direction. + * + * This is part of the DataSource interface, because it changes how the data is sorted. You + * may want to pass the groupBy attribute to the server, so it can sort the data for you. + * + * @param path Path to group by attribute. + * @param sorter Optional sorter function. + * @param direction Optional sort direction. + */ + setGroupBy(path: PathKeys, sorter?: DataSourceSortFunction, direction?: DataSourceSortDirection): void { + this.#groupBy = { path, sorter, direction }; + } + + /** + * Remove the groupBy attribute. This will cause the data to be sorted as if it was not grouped. + */ + removeGroupBy(): void { + this.#groupBy = undefined; + } + + setPage(page: number): void { + this.#page = page; + } + + setPageSize(pageSize: number): void { + this.#pageSize = pageSize; + } + + override setSort | DataSourceSortFunction>( + id: string, + pathOrSorter: U, + direction: DataSourceSortDirection + ): void { + super.setSort(id, pathOrSorter, direction); + + if (this.#page) { + this.setPage(0); + } + } + + override removeSort(): void { + super.removeSort(); + + if (this.#page) { + this.setPage(0); + } + } + + /** + * Reorder the item in the data source. + * @param item The item to reorder. + * @param relativeItem The item to reorder relative to. + * @param position The position relative to the relativeItem. + * @returns True if the items were reordered, false if not. + */ + reorder(item: U, relativeItem: U, position: 'before' | 'after'): void { + const items = this.items, + from = items.indexOf(item), + to = items.indexOf(relativeItem) + (position === 'before' ? 0 : 1); + + if (from === -1 || to === -1 || from === to) { + return; + } + + items.splice(from, 1); + items.splice(to + (from < to ? -1 : 0), 0, item); + + this.update(); + } +} diff --git a/packages/components/grid/src/column.spec.ts b/packages/components/grid/src/column.spec.ts index 322c7c4957..2dd6468b14 100644 --- a/packages/components/grid/src/column.spec.ts +++ b/packages/components/grid/src/column.spec.ts @@ -2,7 +2,7 @@ import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer import { expect, fixture } from '@open-wc/testing'; import { Avatar } from '@sl-design-system/avatar'; import '@sl-design-system/avatar/register.js'; -import { FetchDataSourcePlaceholder } from '@sl-design-system/data-source'; +import { FetchListDataSourcePlaceholder } from '@sl-design-system/data-source'; import { html } from 'lit'; import { Person } from 'tools/example-data/index.js'; import '../register.js'; @@ -138,7 +138,7 @@ describe('sl-column', () => { `); - el.items = [FetchDataSourcePlaceholder]; + el.items = [FetchListDataSourcePlaceholder]; await el.updateComplete; // Give grid time to render the table structure diff --git a/packages/components/grid/src/column.ts b/packages/components/grid/src/column.ts index 49eff037f6..5c0569f00d 100644 --- a/packages/components/grid/src/column.ts +++ b/packages/components/grid/src/column.ts @@ -1,4 +1,4 @@ -import { FetchDataSourcePlaceholder } from '@sl-design-system/data-source'; +import { FetchListDataSourcePlaceholder } from '@sl-design-system/data-source'; import { type EventEmitter, type PathKeys, @@ -174,7 +174,7 @@ export class GridColumn extends LitElement { let data: unknown; if (this.renderer) { data = this.renderer(item); - } else if (item === FetchDataSourcePlaceholder) { + } else if (item === FetchListDataSourcePlaceholder) { data = html``; } else if (this.path) { data = getValueByPath(item, this.path); @@ -212,7 +212,7 @@ export class GridColumn extends LitElement { parts = this.parts(item)?.split(' ') ?? []; } - if (item === FetchDataSourcePlaceholder) { + if (item === FetchListDataSourcePlaceholder) { parts.push('placeholder'); } diff --git a/packages/components/grid/src/filter-column.ts b/packages/components/grid/src/filter-column.ts index bf3440a618..a9c461df1f 100644 --- a/packages/components/grid/src/filter-column.ts +++ b/packages/components/grid/src/filter-column.ts @@ -1,5 +1,5 @@ import { localized, msg } from '@lit/localize'; -import { type DataSource, type DataSourceFilterFunction } from '@sl-design-system/data-source'; +import { type DataSourceFilterFunction } from '@sl-design-system/data-source'; 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'; @@ -60,7 +60,7 @@ export class GridFilterColumn extends GridColumn { super.itemsChanged(); if (this.mode !== 'text' && typeof this.options === 'undefined') { - const dataSource = this.grid?.dataSource as DataSource | undefined; + const dataSource = this.grid?.dataSource; // No options were provided, so we'll create a list of options based on the column's values this.internalOptions = dataSource?.items diff --git a/packages/components/grid/src/grid.ts b/packages/components/grid/src/grid.ts index f7ef1cf1ca..8042c45f16 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -2,11 +2,12 @@ import { localized, msg } from '@lit/localize'; import { type VirtualizerHostElement, virtualize, virtualizerRef } from '@lit-labs/virtualizer/virtualize.js'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { ArrayDataSource, type DataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource, ListDataSource } from '@sl-design-system/data-source'; import { EllipsizeText } from '@sl-design-system/ellipsize-text'; import { Scrollbar } from '@sl-design-system/scrollbar'; import { type EventEmitter, + type PathKeys, SelectionController, event, getValueByPath, @@ -20,7 +21,6 @@ import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { type Virtualizer } from 'node_modules/@lit-labs/virtualizer/Virtualizer.js'; import { GridColumnGroup } from './column-group.js'; import { GridColumn } from './column.js'; import { GridFilterColumn } from './filter-column.js'; @@ -171,7 +171,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { #sorters: Array> = []; /** The virtualizer instance for the grid. */ - #virtualizer?: Virtualizer; + #virtualizer?: VirtualizerHostElement[typeof virtualizerRef]; /** Selection manager. */ readonly selection = new SelectionController(this); @@ -183,7 +183,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { @event({ name: 'sl-active-item-change' }) activeItemChangeEvent!: EventEmitter>; /** Provide your own implementation for getting the data. */ - @property({ attribute: false }) dataSource?: DataSource; + @property({ attribute: false }) dataSource?: ListDataSource; /** * Whether you can drag rows in the grid. If you use the drag-handle column, @@ -285,7 +285,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { await new Promise(resolve => requestAnimationFrame(resolve)); const host = this.tbody as VirtualizerHostElement; - this.#virtualizer = host[virtualizerRef] as Virtualizer; + this.#virtualizer = host[virtualizerRef]; this.#virtualizer?.disconnected(); this.#virtualizer?.connected(); } @@ -296,11 +296,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { } if (changes.has('items')) { - if (this.dataSource) { - this.dataSource.items = this.items ?? []; - } else { - this.dataSource = this.items ? new ArrayDataSource(this.items) : undefined; - } + this.dataSource = this.items ? new ArrayListDataSource(this.items) : undefined; this.#updateDataSource(this.dataSource); } @@ -743,12 +739,12 @@ export class Grid extends ScopedElementsMixin(LitElement) { #onGroupSelect(event: SlSelectEvent, group: GridViewModelGroup): void { const items = this.dataSource?.items ?? [], - groupItems = items.filter(item => getValueByPath(item, group.path) === group.value); + groupItems = items.filter(item => getValueByPath(item, group.path as PathKeys) === group.value); if (event.detail) { - groupItems.forEach(item => this.selection.select(item as T)); + groupItems.forEach(item => this.selection.select(item)); } else { - groupItems.forEach(item => this.selection.deselect(item as T)); + groupItems.forEach(item => this.selection.deselect(item)); } } @@ -907,7 +903,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { } } - #updateDataSource(dataSource?: DataSource): void { + #updateDataSource(dataSource?: ListDataSource): void { this.view.dataSource = dataSource; this.selection.size = dataSource?.size ?? 0; diff --git a/packages/components/grid/src/sorter.spec.ts b/packages/components/grid/src/sorter.spec.ts index a21d1f7604..36178a005a 100644 --- a/packages/components/grid/src/sorter.spec.ts +++ b/packages/components/grid/src/sorter.spec.ts @@ -1,6 +1,6 @@ import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; import { expect, fixture } from '@open-wc/testing'; -import { ArrayDataSource, DataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource, DataSource } from '@sl-design-system/data-source'; import { Icon } from '@sl-design-system/icon'; import { html } from 'lit'; import '../register.js'; @@ -15,7 +15,7 @@ describe('sl-grid-sorter', () => { const items = [{ name: 'John' }, { name: 'Jane' }, { name: 'Jimmy' }, { name: 'Jane' }]; const column = new GridSortColumn(); - const dataSource = new ArrayDataSource(items) as DataSource; + const dataSource = new ArrayListDataSource(items) as DataSource; dataSource.setSort('', 'name', 'asc'); describe('defaults', () => { diff --git a/packages/components/grid/src/stories/basics.stories.ts b/packages/components/grid/src/stories/basics.stories.ts index 21a96df418..a6edde4c23 100644 --- a/packages/components/grid/src/stories/basics.stories.ts +++ b/packages/components/grid/src/stories/basics.stories.ts @@ -1,5 +1,9 @@ import { Avatar } from '@sl-design-system/avatar'; -import { FetchDataSource, FetchDataSourceError, FetchDataSourcePlaceholder } from '@sl-design-system/data-source'; +import { + FetchListDataSource, + FetchListDataSourceError, + FetchListDataSourcePlaceholder +} from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import { Icon } from '@sl-design-system/icon'; import { MenuButton, MenuItem } from '@sl-design-system/menu'; @@ -257,7 +261,7 @@ export const LazyLoad: Story = { limit: number; } - const dataSource = new FetchDataSource({ + const dataSource = new FetchListDataSource({ pageSize: 30, fetchPage: async ({ page, pageSize }) => { const response = await fetch(`https://dummyjson.com/quotes?skip=${page * pageSize}&limit=${pageSize}`); @@ -267,7 +271,7 @@ export const LazyLoad: Story = { return { items: quotes, totalItems: total }; } else { - throw new FetchDataSourceError('Failed to fetch data', response); + throw new FetchListDataSourceError('Failed to fetch data', response); } } }); @@ -284,7 +288,7 @@ export const LazyLoad: Story = { export const Skeleton: Story = { render: () => { - const dataSource = new FetchDataSource({ + const dataSource = new FetchListDataSource({ pageSize: 30, fetchPage: async ({ page, pageSize }) => { const { people, total } = await getPeople({ count: pageSize, startIndex: (page - 1) * pageSize }); @@ -310,7 +314,7 @@ export const Skeleton: Story = { export const CustomSkeleton: Story = { render: () => { const avatarRenderer: GridColumnDataRenderer = item => { - if (typeof item === 'symbol' && item === FetchDataSourcePlaceholder) { + if (typeof item === 'symbol' && item === FetchListDataSourcePlaceholder) { return html`
({ + const dataSource = new FetchListDataSource({ pageSize: 30, fetchPage: async ({ page, pageSize }) => { const { people, total } = await getPeople({ count: pageSize, startIndex: (page - 1) * pageSize }); diff --git a/packages/components/grid/src/stories/drag-and-drop.stories.ts b/packages/components/grid/src/stories/drag-and-drop.stories.ts index a6a4c5bd55..61fbe15ac8 100644 --- a/packages/components/grid/src/stories/drag-and-drop.stories.ts +++ b/packages/components/grid/src/stories/drag-and-drop.stories.ts @@ -1,4 +1,4 @@ -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import { type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; @@ -77,7 +77,7 @@ export const Fixed: Story = { export const Grouping: Story = { loaders: [async () => ({ people: (await getPeople({ count: 10 })).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); return html` diff --git a/packages/components/grid/src/stories/filtering.stories.ts b/packages/components/grid/src/stories/filtering.stories.ts index c95831770f..75d689e55d 100644 --- a/packages/components/grid/src/stories/filtering.stories.ts +++ b/packages/components/grid/src/stories/filtering.stories.ts @@ -1,6 +1,6 @@ import '@sl-design-system/button/register.js'; import '@sl-design-system/button-bar/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import { type TextField } from '@sl-design-system/text-field'; import '@sl-design-system/text-field/register.js'; @@ -48,7 +48,7 @@ export const Filtered: Story = { export const FilteredDataSource: Story = { render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.addFilter('filter-profession', 'profession', 'Endo'); dataSource.addFilter('filter-status', 'status', 'Available'); dataSource.addFilter('filter-membership', 'membership', ['Regular', 'Premium']); @@ -82,7 +82,7 @@ export const Custom: Story = { return person.profession === 'Gastroenterologist'; }; - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.addFilter('custom', filter); return html` @@ -120,7 +120,7 @@ export const EmptyValues: Story = { export const Grouped: Story = { render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); return html` @@ -137,7 +137,7 @@ export const Grouped: Story = { export const OutsideGrid: Story = { render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); const onInput = ({ target }: Event & { target: TextField }): void => { const value = target.value?.toString().trim() ?? ''; diff --git a/packages/components/grid/src/stories/grouping.stories.ts b/packages/components/grid/src/stories/grouping.stories.ts index a2ac50b886..ab7f0f2c6c 100644 --- a/packages/components/grid/src/stories/grouping.stories.ts +++ b/packages/components/grid/src/stories/grouping.stories.ts @@ -1,4 +1,4 @@ -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import { Icon } from '@sl-design-system/icon'; import { MenuButton, MenuItem } from '@sl-design-system/menu'; @@ -22,7 +22,7 @@ export default { export const Basic: Story = { loaders: [async () => ({ people: (await getPeople()).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); return html` @@ -41,7 +41,7 @@ export const Basic: Story = { export const Collapsed: Story = { loaders: [async () => ({ people: (await getPeople()).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); setTimeout(() => { @@ -63,7 +63,7 @@ export const Collapsed: Story = { export const CustomHeader: Story = { loaders: [async () => ({ people: (await getPeople()).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); const groupHeaderRenderer: GridGroupHeaderRenderer = (group: GridViewModelGroup) => { diff --git a/packages/components/grid/src/stories/pagination.stories.ts b/packages/components/grid/src/stories/pagination.stories.ts index 71282885b4..b48ed1bb7d 100644 --- a/packages/components/grid/src/stories/pagination.stories.ts +++ b/packages/components/grid/src/stories/pagination.stories.ts @@ -1,4 +1,4 @@ -import { ArrayDataSource, FetchDataSource, FetchDataSourceError } from '@sl-design-system/data-source'; +import { ArrayListDataSource, FetchListDataSource, FetchListDataSourceError } from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import '@sl-design-system/paginator/register.js'; import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; @@ -83,7 +83,7 @@ export const Basic: Story = { export const DataSource: Story = { name: 'Array Data Source', render: (_, { loaded: { people } }) => { - const ds = new ArrayDataSource(people as Person[], { pagination: true }); + const ds = new ArrayListDataSource(people as Person[], { pagination: true }); ds.setPage(2); ds.setPageSize(10); ds.update(); @@ -135,7 +135,7 @@ export const DataSource2: Story = { limit: number; } - const ds = new FetchDataSource({ + const ds = new FetchListDataSource({ pageSize: 10, pagination: true, fetchPage: async ({ page, pageSize }) => { @@ -146,7 +146,7 @@ export const DataSource2: Story = { return { items: quotes, totalItems: total }; } else { - throw new FetchDataSourceError('Failed to fetch data', response); + throw new FetchListDataSourceError('Failed to fetch data', response); } } }); diff --git a/packages/components/grid/src/stories/selection.stories.ts b/packages/components/grid/src/stories/selection.stories.ts index e23c558819..ca37a6248a 100644 --- a/packages/components/grid/src/stories/selection.stories.ts +++ b/packages/components/grid/src/stories/selection.stories.ts @@ -1,6 +1,6 @@ import '@sl-design-system/button/register.js'; import '@sl-design-system/button-bar/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Person, getPeople } from '@sl-design-system/example-data'; import { type SelectionController } from '@sl-design-system/shared'; import { type StoryObj } from '@storybook/web-components'; @@ -116,7 +116,7 @@ export const SelectionColumnWithCustomHeader: Story = { export const Grouped: Story = { loaders: [async () => ({ people: (await getPeople()).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); const onActiveItemChange = ({ detail: { item } }: SlActiveItemChangeEvent): void => { diff --git a/packages/components/grid/src/stories/sorting.stories.ts b/packages/components/grid/src/stories/sorting.stories.ts index 340a347b73..583e1189fa 100644 --- a/packages/components/grid/src/stories/sorting.stories.ts +++ b/packages/components/grid/src/stories/sorting.stories.ts @@ -1,5 +1,5 @@ import { Button } from '@sl-design-system/button'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } 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 { type TemplateResult, html } from 'lit'; @@ -105,7 +105,7 @@ export const CustomDataSourceSorter: Story = { } }; - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setSort('custom', sorter, 'asc'); return html` @@ -122,7 +122,7 @@ export const CustomDataSourceSorter: Story = { export const Grouped: Story = { loaders: [async () => ({ people: (await getPeople()).people })], render: (_, { loaded: { people } }) => { - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setGroupBy('membership'); return html` diff --git a/packages/components/grid/src/view-model.ts b/packages/components/grid/src/view-model.ts index 2e610a514c..272a0c0438 100644 --- a/packages/components/grid/src/view-model.ts +++ b/packages/components/grid/src/view-model.ts @@ -1,4 +1,4 @@ -import { type DataSource } from '@sl-design-system/data-source'; +import { type ListDataSource } from '@sl-design-system/data-source'; import { getStringByPath, getValueByPath } from '@sl-design-system/shared'; import { GridColumnGroup } from './column-group.js'; import { GridColumn } from './column.js'; @@ -16,7 +16,7 @@ export class GridViewModelGroup { export class GridViewModel { #columnDefinitions: Array> = []; #columns: Array> = []; - #dataSource?: DataSource; + #dataSource?: ListDataSource; #grid: Grid; #groups = new Map(); #headerRows: Array>> = [[]]; @@ -38,11 +38,11 @@ export class GridViewModel { return this.#columns; } - get dataSource(): DataSource | undefined { + get dataSource(): ListDataSource | undefined { return this.#dataSource; } - set dataSource(dataSource: DataSource | undefined) { + set dataSource(dataSource: ListDataSource | undefined) { if (this.#dataSource) { this.#dataSource.removeEventListener('sl-update', this.update); } diff --git a/packages/components/paginator/src/examples.stories.ts b/packages/components/paginator/src/examples.stories.ts index 0f73ece346..38446bda62 100644 --- a/packages/components/paginator/src/examples.stories.ts +++ b/packages/components/paginator/src/examples.stories.ts @@ -1,5 +1,5 @@ import '@sl-design-system/card/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { LitElement, type TemplateResult, css, html } from 'lit'; @@ -78,7 +78,7 @@ export const DataSource: Story = { } `; - dataSource = new ArrayDataSource( + dataSource = new ArrayListDataSource( Array.from({ length: 80 }, (_, index) => ({ nr: index + 1, title: `Title of card number ${index + 1}` diff --git a/packages/components/paginator/src/page-size.stories.ts b/packages/components/paginator/src/page-size.stories.ts index bf19d29959..7af14de767 100644 --- a/packages/components/paginator/src/page-size.stories.ts +++ b/packages/components/paginator/src/page-size.stories.ts @@ -1,4 +1,4 @@ -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import '../register.js'; @@ -26,7 +26,7 @@ export const DataSource: Story = { const items = Array.from({ length: 80 }, (_, index) => ({ nr: index + 1 })), pageSizes = [5, 10, 15, 20, 25, 30]; - const dataSource = new ArrayDataSource(items); + const dataSource = new ArrayListDataSource(items, { pagination: true }); dataSource.setPage(2); dataSource.setPageSize(5); dataSource.update(); diff --git a/packages/components/paginator/src/page-size.ts b/packages/components/paginator/src/page-size.ts index f08609bf8c..1e35e40f6a 100644 --- a/packages/components/paginator/src/page-size.ts +++ b/packages/components/paginator/src/page-size.ts @@ -1,6 +1,6 @@ import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type DataSource } from '@sl-design-system/data-source'; +import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type ListDataSource } 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'; @@ -40,9 +40,9 @@ export class PaginatorPageSize extends ScopedElementsMixin(LitElement) static override styles: CSSResultGroup = styles; /** The data source that the paginator controls. */ - #dataSource?: DataSource; + #dataSource?: ListDataSource; - get dataSource(): DataSource | undefined { + get dataSource(): ListDataSource | undefined { return this.#dataSource; } @@ -53,7 +53,7 @@ export class PaginatorPageSize extends ScopedElementsMixin(LitElement) * component, such as ``. */ @property({ attribute: false }) - set dataSource(dataSource: DataSource | undefined) { + set dataSource(dataSource: ListDataSource | undefined) { if (this.#dataSource) { this.#dataSource.removeEventListener('sl-update', this.#onUpdate); } diff --git a/packages/components/paginator/src/paginator.spec.ts b/packages/components/paginator/src/paginator.spec.ts index c4dfea162a..1a337b4449 100644 --- a/packages/components/paginator/src/paginator.spec.ts +++ b/packages/components/paginator/src/paginator.spec.ts @@ -1,7 +1,9 @@ import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; import { expect, fixture } from '@open-wc/testing'; import { Button } from '@sl-design-system/button'; -import { ArrayDataSource, type DataSource } from '@sl-design-system/data-source'; +import '@sl-design-system/button/register.js'; +import { ArrayListDataSource, type ListDataSource } from '@sl-design-system/data-source'; +import '@sl-design-system/select/register.js'; import { html } from 'lit'; import { spy, stub } from 'sinon'; import '../register.js'; @@ -237,10 +239,10 @@ describe('sl-paginator', () => { }); describe('dataSource', () => { - let ds: DataSource; + let ds: ListDataSource; beforeEach(async () => { - ds = new ArrayDataSource(Array.from({ length: 80 }, (_, index) => ({ nr: index + 1 }))); + ds = new ArrayListDataSource(Array.from({ length: 80 }, (_, index) => ({ nr: index + 1 }))); el = await fixture(html``); }); diff --git a/packages/components/paginator/src/paginator.ts b/packages/components/paginator/src/paginator.ts index 527de0aa65..5b07e9724e 100644 --- a/packages/components/paginator/src/paginator.ts +++ b/packages/components/paginator/src/paginator.ts @@ -2,7 +2,7 @@ import { localized, msg, str } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { announce } from '@sl-design-system/announcer'; import { Button } from '@sl-design-system/button'; -import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type DataSource } from '@sl-design-system/data-source'; +import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type ListDataSource } from '@sl-design-system/data-source'; import { Icon } from '@sl-design-system/icon'; import { Menu, MenuButton, MenuItem } from '@sl-design-system/menu'; import { Select, SelectOption } from '@sl-design-system/select'; @@ -58,12 +58,18 @@ export class Paginator extends ScopedElementsMixin(LitElement) { static override styles: CSSResultGroup = styles; /** The data source that the paginator controls. */ - #dataSource?: DataSource; + #dataSource?: ListDataSource; /** Observe changes in size of the container. */ #observer = new ResizeObserver(entries => this.#onResize(entries[0])); - get dataSource(): DataSource | undefined { + /** The original size, before any resize observer logic. */ + #originalSize?: PaginatorSize; + + /** The current size. */ + #size?: PaginatorSize; + + get dataSource(): ListDataSource | undefined { return this.#dataSource; } @@ -74,7 +80,7 @@ export class Paginator extends ScopedElementsMixin(LitElement) { * component, such as ``. */ @property({ attribute: false }) - set dataSource(dataSource: DataSource | undefined) { + set dataSource(dataSource: ListDataSource | undefined) { if (this.#dataSource) { this.#dataSource.removeEventListener('sl-update', this.#onUpdate); } @@ -85,12 +91,6 @@ export class Paginator extends ScopedElementsMixin(LitElement) { void this.#onUpdate(); } - /** The original size, before any resize observer logic. */ - #originalSize?: PaginatorSize; - - /** The current size. */ - #size?: PaginatorSize; - /** * Current page. * @default 0 diff --git a/packages/components/paginator/src/status.stories.ts b/packages/components/paginator/src/status.stories.ts index 382b153471..5a6dfc7f7c 100644 --- a/packages/components/paginator/src/status.stories.ts +++ b/packages/components/paginator/src/status.stories.ts @@ -1,4 +1,4 @@ -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import '../register.js'; @@ -26,18 +26,21 @@ export const Basic: Story = {}; export const DataSource: Story = { render: () => { - const dataSource = new ArrayDataSource([ - { 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 ArrayListDataSource( + [ + { 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' } + ], + { pagination: true } + ); dataSource.setPage(1); dataSource.setPageSize(5); diff --git a/packages/components/paginator/src/status.ts b/packages/components/paginator/src/status.ts index b8f2a02140..0cd50ec512 100644 --- a/packages/components/paginator/src/status.ts +++ b/packages/components/paginator/src/status.ts @@ -1,6 +1,6 @@ import { localized, msg, str } from '@lit/localize'; import { announce } from '@sl-design-system/announcer'; -import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type DataSource } from '@sl-design-system/data-source'; +import { DATA_SOURCE_DEFAULT_PAGE_SIZE, type ListDataSource } 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 './status.scss.js'; @@ -22,12 +22,12 @@ export class PaginatorStatus extends LitElement { static override styles: CSSResultGroup = styles; /** The data source that the paginator controls. */ - #dataSource?: DataSource; + #dataSource?: ListDataSource; /** Timeout id, to be used with `clearTimeout`. */ #timeoutId?: ReturnType; - get dataSource(): DataSource | undefined { + get dataSource(): ListDataSource | undefined { return this.#dataSource; } @@ -36,7 +36,7 @@ export class PaginatorStatus extends LitElement { * and control the data source when the user selects a new page size in the component. */ @property({ attribute: false }) - set dataSource(dataSource: DataSource | undefined) { + set dataSource(dataSource: ListDataSource | undefined) { if (this.#dataSource) { this.#dataSource.removeEventListener('sl-update', this.#onUpdate); } diff --git a/packages/components/shared/src/controllers/focus-group.ts b/packages/components/shared/src/controllers/focus-group.ts index 3f314e217b..657f29dd68 100644 --- a/packages/components/shared/src/controllers/focus-group.ts +++ b/packages/components/shared/src/controllers/focus-group.ts @@ -169,8 +169,11 @@ export class FocusGroupController implements ReactiveCont } } - focusToElement(elementIndex: number): void { - this.currentIndex = elementIndex; + focusToElement(element: T): void; + focusToElement(elementIndex: number): void; + + focusToElement(elementOrIndex: T | number): void { + this.currentIndex = typeof elementOrIndex === 'number' ? elementOrIndex : this.elements.indexOf(elementOrIndex); this.elementEnterAction(this.elements[this.currentIndex]); this.focus({ preventScroll: false }); } diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts new file mode 100644 index 0000000000..e8194fb7fa --- /dev/null +++ b/packages/components/tree/index.ts @@ -0,0 +1,4 @@ +export * from './src/flat-tree-data-source.js'; +export * from './src/nested-tree-data-source.js'; +export * from './src/tree-data-source.js'; +export * from './src/tree.js'; diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json new file mode 100644 index 0000000000..73ff8444ff --- /dev/null +++ b/packages/components/tree/package.json @@ -0,0 +1,57 @@ +{ + "name": "@sl-design-system/tree", + "version": "0.0.0", + "description": "Tree 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/tree" + }, + "homepage": "https://sanomalearning.design/components/tree", + "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-bar": "^1.1.0", + "@sl-design-system/checkbox": "^2.0.1", + "@sl-design-system/data-source": "^0.0.1", + "@sl-design-system/icon": "^1.0.2", + "@sl-design-system/shared": "^0.4.0", + "@sl-design-system/skeleton": "^1.0.0", + "@sl-design-system/spinner": "^1.0.1" + }, + "devDependencies": { + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.2.1" + }, + "peerDependencies": { + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.1.4" + } +} diff --git a/packages/components/tree/register.ts b/packages/components/tree/register.ts new file mode 100644 index 0000000000..91b03b0b73 --- /dev/null +++ b/packages/components/tree/register.ts @@ -0,0 +1,3 @@ +import { Tree } from './src/tree.js'; + +customElements.define('sl-tree', Tree); diff --git a/packages/components/tree/src/flat-tree-data-source.spec.ts b/packages/components/tree/src/flat-tree-data-source.spec.ts new file mode 100644 index 0000000000..21221783b5 --- /dev/null +++ b/packages/components/tree/src/flat-tree-data-source.spec.ts @@ -0,0 +1,34 @@ +import { expect } from '@open-wc/testing'; +import { FlatTreeDataSource } from './flat-tree-data-source.js'; + +describe('FlatTreeDataSource', () => { + let ds: FlatTreeDataSource; + + describe('defaults', () => { + beforeEach(() => { + ds = new FlatTreeDataSource( + [ + { id: 1, name: '1', level: 0, expandable: true }, + { id: 2, name: '2', level: 1, expandable: true }, + { id: 3, name: '3', level: 2, expandable: false }, + { id: 4, name: '4', level: 1, expandable: false }, + { id: 5, name: '5', level: 0, expandable: false } + ], + { + getId: ({ id }) => id, + getLabel: ({ name }) => name, + getLevel: ({ level }) => level, + isExpandable: ({ expandable }) => expandable + } + ); + }); + + it('should not be selectable', () => { + expect(ds.selects).to.be.undefined; + }); + + it('should have the correct size', () => { + expect(ds.size).to.equal(2); + }); + }); +}); diff --git a/packages/components/tree/src/flat-tree-data-source.ts b/packages/components/tree/src/flat-tree-data-source.ts new file mode 100644 index 0000000000..e0493affee --- /dev/null +++ b/packages/components/tree/src/flat-tree-data-source.ts @@ -0,0 +1,156 @@ +import { + TreeDataSource, + type TreeDataSourceMapping, + TreeDataSourceNode, + type TreeDataSourceOptions +} from './tree-data-source.js'; + +export interface FlatTreeDataSourceMapping extends TreeDataSourceMapping { + getLevel(item: T): number; +} + +export interface FlatTreeDataSourceOptions extends FlatTreeDataSourceMapping { + loadChildren?(node: T): Promise; + selects?: 'single' | 'multiple'; +} + +/** + * A tree model that represents a flat list of nodes. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class FlatTreeDataSource extends TreeDataSource { + /** The mapping from the source model to the tree model. */ + #mapping: FlatTreeDataSourceMapping; + + /** Array of tree nodes that were mapped from the source model. */ + #nodes: Array> = []; + + /** Array of view nodes that represent the current state of the tree. */ + #viewNodes: Array> = []; + + get items(): Array> { + return this.#viewNodes; + } + + get nodes(): Array> { + return this.#nodes; + } + + get size() { + return this.#nodes.length; + } + + constructor(items: T[], options: FlatTreeDataSourceOptions) { + let loadChildren: TreeDataSourceOptions['loadChildren'] | undefined = undefined; + if (options.loadChildren) { + loadChildren = async (node: TreeDataSourceNode) => { + const children = await options.loadChildren!(node.dataNode); + + return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); + }; + } + + super({ ...options, loadChildren }); + + this.#mapping = { + getChildrenCount: options.getChildrenCount, + getIcon: options.getIcon, + getId: options.getId ?? (item => item), + getLabel: options.getLabel ?? (() => ''), + getLevel: options.getLevel ?? (() => 0), + isExpandable: options.isExpandable ?? (() => false), + isExpanded: options.isExpanded, + isSelected: options.isSelected + }; + + this.#nodes = this.#mapToTreeNodes(items); + + if (this.selects === 'multiple') { + Array.from(this.selection) + .filter(node => node.parent) + .forEach(node => { + this.#updateSelected(node.parent!); + }); + } + } + + override update(): void { + this.#viewNodes = this.toViewArray(); + + this.dispatchEvent(new CustomEvent('sl-update')); + } + + #mapToTreeNodes(items: T[]): Array> { + const levelMap: Map>> = new Map(), + rootNodes: Array> = []; + + items.forEach((item, index) => { + const nextLevel = index < items.length - 1 ? this.#mapping.getLevel(items[index + 1]) : 0, + level = this.#mapping.getLevel(item); + + const treeNode = this.#mapToTreeNode(item, undefined, level > nextLevel); + + if (treeNode.selected) { + this.selection.add(treeNode); + } + + if (level === 0) { + rootNodes.push(treeNode); + } else { + const parentLevel = level - 1, + parentNodes = levelMap.get(parentLevel); + + if (parentNodes) { + const parentNode = parentNodes[parentNodes.length - 1]; + parentNode.children ||= []; + parentNode.children.push(treeNode); + treeNode.parent = parentNode; + } + } + + if (!levelMap.has(level)) { + levelMap.set(level, []); + } + + levelMap.get(level)!.push(treeNode); + }); + + return rootNodes; + } + + #mapToTreeNode(item: T, parent?: TreeDataSourceNode, lastNodeInLevel?: boolean): TreeDataSourceNode { + const { getChildrenCount, getIcon, getId, getLabel, getLevel, isExpandable, isExpanded, isSelected } = + this.#mapping; + + const treeNode: TreeDataSourceNode = { + id: getId(item), + childrenCount: getChildrenCount?.(item), + dataNode: item, + expandable: isExpandable(item), + expanded: isExpanded?.(item) ?? false, + expandedIcon: getIcon?.(item, true), + icon: getIcon?.(item, false), + label: getLabel(item), + lastNodeInLevel, + level: getLevel(item), + parent, + selected: isSelected?.(item), + type: 'node' + }; + + return treeNode; + } + + /** Traverse up the tree and update the selected/indeterminate state. */ + #updateSelected(node: TreeDataSourceNode): void { + this.selection.add(node); + + node.selected = node.children?.every(child => child.selected) ?? false; + node.indeterminate = + (!node.selected && node.children?.some(child => child.indeterminate || child.selected)) ?? false; + + if (node.parent) { + this.#updateSelected(node.parent); + } + } +} diff --git a/packages/components/tree/src/indent-guides.scss b/packages/components/tree/src/indent-guides.scss new file mode 100644 index 0000000000..0f877f65b9 --- /dev/null +++ b/packages/components/tree/src/indent-guides.scss @@ -0,0 +1,33 @@ +:host { + align-items: stretch; + color: var(--sl-color-border-plain); + display: flex; + padding-inline-end: calc(var(--sl-space-200) + var(--sl-space-025)); +} + +:host([expandable]) { + padding-inline-end: 0; +} + +:host([last-node-in-level]) [part='guide']:last-child { + align-self: start; + block-size: 25%; + position: relative; + + &::before { + block-size: 100%; + border-block-end: var(--sl-size-borderWidth-subtle) solid currentcolor; + border-end-start-radius: var(--sl-size-050); + border-inline-start: var(--sl-size-borderWidth-subtle) solid currentcolor; + content: ''; + inline-size: var(--sl-space-050); + inset: 100% auto auto 0; + position: absolute; + } +} + +[part='guide'] { + background: currentcolor; + inline-size: var(--sl-size-borderWidth-subtle); + margin-inline-start: var(--sl-space-150); +} diff --git a/packages/components/tree/src/indent-guides.ts b/packages/components/tree/src/indent-guides.ts new file mode 100644 index 0000000000..46250d6292 --- /dev/null +++ b/packages/components/tree/src/indent-guides.ts @@ -0,0 +1,37 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './indent-guides.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-indent-guides': IndentGuides; + } +} + +/** + * A component that renders indentation guides for tree nodes. This component + * is not public API and is used internally by ``. + */ +export class IndentGuides extends LitElement { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** Wether the parent tree node is expandable. */ + @property({ type: Boolean, reflect: true }) expandable?: boolean; + + /** Whether this node is the last one on this level; used for styling. */ + @property({ type: Boolean, attribute: 'last-node-in-level', reflect: true }) lastNodeInLevel?: boolean; + + /** Level of indentation. */ + @property({ type: Number }) level = 0; + + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('aria-hidden', 'true'); + } + + override render(): TemplateResult[] { + return Array.from({ length: this.level }).map(() => html`
`); + } +} diff --git a/packages/components/tree/src/nested-tree-data-source.ts b/packages/components/tree/src/nested-tree-data-source.ts new file mode 100644 index 0000000000..29d1de4635 --- /dev/null +++ b/packages/components/tree/src/nested-tree-data-source.ts @@ -0,0 +1,139 @@ +import { + TreeDataSource, + type TreeDataSourceMapping, + type TreeDataSourceNode, + type TreeDataSourceOptions +} from './tree-data-source.js'; + +export interface NestedTreeDataSourceMapping extends TreeDataSourceMapping { + getChildren(item: T): T[] | Promise | undefined; +} + +export interface NestedTreeDataSourceOptions extends NestedTreeDataSourceMapping { + loadChildren?(node: T): Promise; + selects?: 'single' | 'multiple'; +} + +/** + * A tree model that represents a nested list of nodes. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class NestedTreeDataSource extends TreeDataSource { + #mapping: NestedTreeDataSourceMapping; + #nodes: Array> = []; + #viewNodes: Array> = []; + + get items(): Array> { + return this.#viewNodes; + } + + get nodes(): Array> { + return this.#nodes; + } + + get size() { + return this.#nodes.length; + } + + constructor(items: T[], options: NestedTreeDataSourceOptions) { + let loadChildren: TreeDataSourceOptions['loadChildren'] | undefined = undefined; + if (options.loadChildren) { + loadChildren = async (node: TreeDataSourceNode) => { + const children = await options.loadChildren!(node.dataNode); + + return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); + }; + } + + super({ ...options, loadChildren }); + + this.#mapping = { + getChildren: options.getChildren, + getChildrenCount: options.getChildrenCount, + getIcon: options.getIcon, + getId: options.getId ?? (item => item), + getLabel: options.getLabel ?? (() => ''), + isExpandable: options.isExpandable ?? (() => false), + isExpanded: options.isExpanded, + isSelected: options.isSelected + }; + + this.#nodes = items.map(item => this.#mapToTreeNode(item)); + + if (this.selects === 'multiple') { + Array.from(this.selection) + .filter(node => node.parent) + .forEach(node => { + this.#updateSelected(node.parent!); + }); + } + } + + override update(): void { + this.#viewNodes = this.toViewArray(); + + this.dispatchEvent(new CustomEvent('sl-update')); + } + + #mapToTreeNode(item: T, parent?: TreeDataSourceNode, lastNodeInLevel?: boolean): TreeDataSourceNode { + const { getChildren, getChildrenCount, getIcon, getId, getLabel, isExpandable, isExpanded, isSelected } = + this.#mapping; + + const treeNode: TreeDataSourceNode = { + id: getId(item), + childrenCount: getChildrenCount?.(item), + dataNode: item, + expandable: isExpandable(item), + expanded: isExpanded?.(item) ?? false, + expandedIcon: getIcon?.(item, true), + icon: getIcon?.(item, false), + label: getLabel(item), + lastNodeInLevel, + level: parent ? parent.level + 1 : 0, + parent, + selected: isSelected?.(item), + type: 'node' + }; + + if (treeNode.selected) { + this.selection.add(treeNode); + } + + if (treeNode.expandable) { + const children = getChildren(item); + + if (Array.isArray(children)) { + treeNode.children = children.map((child, index) => + this.#mapToTreeNode(child, treeNode, index === children.length - 1) + ); + } else if (children instanceof Promise) { + treeNode.childrenLoading = new Promise(resolve => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + children.then(loadedChildren => { + treeNode.children = loadedChildren.map((child, index) => + this.#mapToTreeNode(child, treeNode, index === loadedChildren.length - 1) + ); + treeNode.childrenLoading = undefined; + + resolve(); + }); + }); + } + } + + return treeNode; + } + + /** Traverse up the tree and update the selected/indeterminate state. */ + #updateSelected(node: TreeDataSourceNode): void { + this.selection.add(node); + + node.selected = node.children?.every(child => child.selected) ?? false; + node.indeterminate = + (!node.selected && node.children?.some(child => child.indeterminate || child.selected)) ?? false; + + if (node.parent) { + this.#updateSelected(node.parent); + } + } +} diff --git a/packages/components/tree/src/tree-data-source.ts b/packages/components/tree/src/tree-data-source.ts new file mode 100644 index 0000000000..ec44840994 --- /dev/null +++ b/packages/components/tree/src/tree-data-source.ts @@ -0,0 +1,385 @@ +import { DataSource } from '@sl-design-system/data-source'; +import { type TreeNodeType } from './tree-node.js'; + +export interface TreeDataSourceNode { + id: unknown; + children?: Array>; + childrenCount?: number; + childrenLoading?: Promise; + dataNode: T; + expandable: boolean; + expanded: boolean; + expandedIcon?: string; + icon?: string; + indeterminate?: boolean; + label: string; + lastNodeInLevel?: boolean; + level: number; + parent?: TreeDataSourceNode; + selected?: boolean; + type: TreeNodeType; +} + +export interface TreeDataSourceMapping { + /** + * Returns the number of children. This can be used in combination with + * lazy loading children. This way, the tree component can show skeletons + * for the children while they are being loaded. + */ + getChildrenCount?(item: T): number | undefined; + + /** Optional method for returning a custom icon for a tree node. */ + getIcon?(item: T, expanded: boolean): string; + + /** Used to identify a tree node. */ + getId(item: T): unknown; + + /** + * Returns a string that is used as the label for the tree node. + * If you want to customize how the tree node is rendered, you can + * provide your own `TreeItemRenderer` function to the tree component. + */ + getLabel(item: T): string; + + /** Returns whether the given node is expandable. */ + isExpandable(item: T): boolean; + + /** + * Returns whether the given node is expanded. This is only used for the initial + * expanded state of the node. If you want to expand/collapse a node programmatically, + * use the `expand` and `collapse` methods on the tree model. + */ + isExpanded?(item: T): boolean; + + /** + * Returns whether the given node is selected. This is only used for the initial + * selected state of the node. If you want to select/deselect a node programmatically, + * use the `select` and `deselect` methods on the tree model. + */ + isSelected?(item: T): boolean; +} + +export interface TreeDataSourceOptions { + /** Provide this method to lazy load child nodes when a parent node is expanded. */ + loadChildren?(node: TreeDataSourceNode): Promise>>; + + /** Enables single or multiple selection of tree nodes. */ + selects?: 'single' | 'multiple'; +} + +/** + * Abstract class used to provide a common interface for tree data. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class TreeDataSource extends DataSource> { + /** An optional callback for loading additional tree nodes. */ + #loadChildren?: TreeDataSourceOptions['loadChildren']; + /** A set containing the selected node(s) in the tree. */ + #selection: Set> = new Set(); + + /** The selection type for the tree model. */ + #selects?: 'single' | 'multiple'; + + /** A hierarchical representation of the items in the tree. */ + abstract readonly nodes: Array>; + + /** The current selection of tree node(s). */ + get selection() { + return this.#selection; + } + + /** Indicates whether the tree model allows single or multiple selection. */ + get selects() { + return this.#selects; + } + + constructor(options: TreeDataSourceOptions = {}) { + super(); + + this.#loadChildren = options.loadChildren; + this.#selects = options.selects; + } + + /** + * Toggles the expansion state of a tree node. You can optionally force the + * state to a specific value using the `force` parameter. The `emitEvent` + * parameter determines whether the model should emit an `sl-update` event + * after changing the state. + */ + toggle(node: TreeDataSourceNode, force?: boolean, emitEvent?: boolean): void { + if ((typeof force === 'boolean' && !force) || node.expanded) { + this.collapse(node, emitEvent); + } else { + this.expand(node, emitEvent); + } + } + + /** Expands a tree node. */ + expand(node: TreeDataSourceNode, emitEvent = true): void { + if (!node.expandable) { + return; + } + + node.expanded = true; + + if (!node.children) { + node.childrenLoading = this.#loadChildren?.(node).then(children => { + node.children = children; + node.childrenLoading = undefined; + + this.update(); + }); + } + + if (emitEvent) { + this.update(); + } + } + + /** Collapses a tree node. */ + collapse(node: TreeDataSourceNode, emitEvent = true): void { + if (!node.expandable) { + return; + } + + node.expanded = false; + + if (emitEvent) { + this.update(); + } + } + + /** Toggles the expansion state of all descendants of a given tree node. */ + toggleDescendants(node: TreeDataSourceNode, force?: boolean): void { + const traverse = (node: TreeDataSourceNode): void => { + if (node.expandable) { + if ((typeof force === 'boolean' && !force) || node.expanded) { + this.collapse(node, false); + } else { + this.expand(node, false); + } + + (node.children || []).forEach(traverse); + } + }; + + traverse(node); + + this.update(); + } + + /** Expands all descendants of a given tree node. */ + expandDescendants(node: TreeDataSourceNode): void { + this.toggleDescendants(node, true); + } + + /** Collapses all descendants of a given tree node. */ + collapseDescendants(node: TreeDataSourceNode): void { + this.toggleDescendants(node, false); + } + + /** Expands all expandable tree nodes. */ + async expandAll(): Promise { + const traverse = async (node: TreeDataSourceNode): Promise => { + if (node.expandable) { + this.expand(node, false); + + if (node.childrenLoading) { + await node.childrenLoading; + } + + for (const child of node.children || []) { + await traverse(child); + } + } + }; + + for (const node of this.nodes) { + await traverse(node); + } + + this.update(); + } + + /** Collapses all expandable tree nodes. */ + collapseAll(): void { + const traverse = (node: TreeDataSourceNode): void => { + if (node.expandable) { + this.collapse(node, false); + + (node.children || []).forEach(traverse); + } + }; + + this.nodes.forEach(traverse); + + this.update(); + } + + /** Selects the given node and any children. */ + select(node: TreeDataSourceNode, emitEvent = true): void { + if (this.selects === 'single') { + this.deselectAll(); + } + + node.indeterminate = false; + node.selected = true; + this.#selection.add(node); + + if (this.selects === 'multiple') { + // Select all children + if (node.expandable) { + const traverse = (node: TreeDataSourceNode): void => { + node.indeterminate = false; + node.selected = true; + this.#selection.add(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + node.children?.forEach(traverse); + } + + // Update parent nodes + let parent = node.parent; + while (parent) { + parent.selected = parent.children!.every(child => child.selected); + parent.indeterminate = + !parent.selected && parent.children!.some(child => child.indeterminate || child.selected); + parent = parent.parent; + } + } + + if (emitEvent) { + this.update(); + } + } + + /** Deselects the given node and any children. */ + deselect(node: TreeDataSourceNode, emitEvent = true): void { + node.indeterminate = node.selected = false; + this.#selection.delete(node); + + if (this.selects === 'multiple') { + // Deselect all children + if (node.expandable) { + const traverse = (node: TreeDataSourceNode): void => { + node.indeterminate = node.selected = false; + this.#selection.delete(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + node.children?.forEach(traverse); + } + + // Update parent nodes + let parent = node.parent; + while (parent) { + parent.selected = parent.children!.every(child => child.selected); + parent.indeterminate = + !parent.selected && parent.children!.some(child => child.indeterminate || child.selected); + parent = parent.parent; + } + } + + if (emitEvent) { + this.update(); + } + } + + /** Selects all nodes in the tree. */ + selectAll(): void { + const traverse = (node: TreeDataSourceNode): void => { + node.indeterminate = false; + node.selected = true; + this.#selection.add(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + this.nodes.forEach(traverse); + + this.update(); + } + + /** Deselects all nodes in the tree. */ + deselectAll(): void { + const traverse = (node: TreeDataSourceNode): void => { + node.indeterminate = node.selected = false; + this.#selection.delete(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + this.nodes.forEach(traverse); + + this.update(); + } + + /** Flattens the tree nodes to an array based on the expansion state. */ + toViewArray(): Array> { + const traverse = (treeNode: TreeDataSourceNode): Array> => { + if (treeNode.expandable && treeNode.expanded) { + if (Array.isArray(treeNode.children)) { + const array = treeNode.children.map(childNode => { + if (childNode instanceof Promise) { + return this.#createPlaceholderTreeNode(treeNode); + } else { + return traverse(childNode); + } + }); + + return [treeNode, ...array.flat()]; + } else if (treeNode.childrenLoading instanceof Promise) { + if (typeof treeNode.childrenCount === 'number') { + return [ + treeNode, + ...Array.from({ length: treeNode.childrenCount }).map(() => this.#createSkeletonTreeNode(treeNode)) + ]; + } else { + return [treeNode, this.#createPlaceholderTreeNode(treeNode)]; + } + } + } + + return [treeNode]; + }; + + return this.nodes.flatMap(treeNode => traverse(treeNode)); + } + + #createPlaceholderTreeNode(parent: TreeDataSourceNode): TreeDataSourceNode { + return { + dataNode: null as unknown as T, + expandable: false, + expanded: false, + id: 'placeholder', + label: '', + level: parent.level + 1, + parent, + type: 'placeholder' + }; + } + + #createSkeletonTreeNode(parent: TreeDataSourceNode): TreeDataSourceNode { + return { + dataNode: null as unknown as T, + expandable: false, + expanded: false, + id: 'skeleton', + label: '', + level: parent.level + 1, + parent, + type: 'skeleton' + }; + } +} diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss new file mode 100644 index 0000000000..4282f14e53 --- /dev/null +++ b/packages/components/tree/src/tree-node.scss @@ -0,0 +1,115 @@ +:host { + align-items: center; + background: var(--sl-elevation-surface-raised-default-idle); + border-radius: var(--sl-size-borderRadius-default); + color: var(--sl-color-text-plain); + cursor: pointer; + display: flex; + gap: var(--sl-size-075); + inline-size: 100%; + scroll-margin-block: var(--sl-space-100); + transition: background 0.2s ease-in-out; +} + +:host([aria-expanded='true']) sl-icon { + rotate: 90deg; +} + +:host([hide-guides]) sl-indent-guides { + color: transparent; +} + +:host([selected]) { + background: var(--sl-color-background-selected-subtle-idle); + color: var(--sl-color-text-selected); +} + +:host(:focus-visible) { + outline: var(--sl-size-borderWidth-bold) solid var(--sl-color-border-focused); + outline-offset: calc(var(--_focus-outline-offset) * -1); + z-index: 1; + + sl-button-bar { + display: flex; + } +} + +:host(:hover) { + background: var(--sl-elevation-surface-raised-default-hover); + + @media (hover: hover) { + sl-button-bar { + display: flex; + } + } +} + +:host(:active) { + background: var(--sl-elevation-surface-raised-default-active); +} + +:host([disabled]) { + cursor: default; + pointer-events: none; +} + +sl-indent-guides { + align-self: stretch; +} + +.expander { + align-items: center; + align-self: stretch; + display: inline-flex; +} + +[part='wrapper'] { + align-items: center; + display: flex; + flex: 1; + gap: var(--sl-size-075); +} + +[part='content'] { + align-items: center; + display: flex; + gap: var(--sl-size-075); + padding-block: var(--sl-size-075); + padding-inline: 0 var(--sl-size-100); +} + +::slotted(*) { + flex: 1; +} + +::slotted(sl-icon) { + flex: 0 1; + vertical-align: bottom; +} + +sl-icon { + rotate: 0deg; + transition: rotate 100ms ease-in-out; +} + +sl-checkbox { + align-items: center; + font: inherit; // FIXME: Remove when checkbox styling has been refactored + line-height: inherit; // FIXME: Same as above + + &::part(label) { + align-items: center; + display: inline-flex; + gap: var(--sl-size-075); + margin: 0; + } +} + +sl-button-bar { + display: none; + margin-inline-start: auto; +} + +sl-skeleton { + block-size: 1lh; +} diff --git a/packages/components/tree/src/tree-node.spec.ts b/packages/components/tree/src/tree-node.spec.ts new file mode 100644 index 0000000000..01885e4b6d --- /dev/null +++ b/packages/components/tree/src/tree-node.spec.ts @@ -0,0 +1,304 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { sendKeys } from '@web/test-runner-commands'; +import { spy } from 'sinon'; +import { TreeNode } from './tree-node.js'; + +// We need to define sl-tree-node ourselves, since it's not +// part of the public API of the tree. +customElements.define('sl-tree-node', TreeNode); + +describe('sl-tree-node', () => { + let el: TreeNode; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html` + + Lorem + + `); + }); + + it('should have a treeitem role', () => { + expect(el).to.have.attribute('role', 'treeitem'); + }); + + it('should not be checked', () => { + expect(el).not.to.have.attribute('aria-checked'); + expect(el.checked).to.not.be.true; + }); + + it('should not be disabled', () => { + expect(el).not.to.have.attribute('disabled'); + expect(el.disabled).to.not.be.true; + }); + + it('should be disabled when set', async () => { + el.disabled = true; + await el.updateComplete; + + expect(el).to.have.attribute('disabled'); + }); + + it('should not be expandable', () => { + expect(el.expandable).to.not.be.true; + }); + + it('should not be expanded', () => { + expect(el).not.to.have.attribute('aria-expanded'); + expect(el.expanded).to.not.be.true; + }); + + it('should not hide the indentation guides', () => { + expect(el).not.to.have.attribute('hide-guides'); + expect(el.hideGuides).to.not.be.true; + }); + + it('should hide the indentation guides when set', async () => { + el.hideGuides = true; + await el.updateComplete; + + expect(el).to.have.attribute('hide-guides'); + }); + + it('should not be indeterminate', () => { + expect(el.indeterminate).to.not.be.true; + }); + + it('should not be the last node in the level', () => { + expect(el.lastNodeInLevel).to.not.be.true; + }); + + it('should be at the root level', () => { + expect(el.level).to.equal(0); + }); + + it('should not be selected', () => { + expect(el).not.to.have.attribute('aria-selected'); + expect(el.selected).to.not.be.true; + }); + + it('should not support selection', () => { + expect(el.selects).to.be.undefined; + }); + + it('should have a tabindex of 0', () => { + expect(el.tabIndex).to.equal(0); + }); + + it('should not have a type', () => { + expect(el.type).to.be.undefined; + }); + + it('should render a spinner when type "placeholder"', async () => { + el.type = 'placeholder'; + await el.updateComplete; + + expect(el.renderRoot.querySelector('sl-spinner')).to.exist; + }); + + it('should render a skeleton when type "skeleton"', async () => { + el.type = 'skeleton'; + await el.updateComplete; + + expect(el.renderRoot.querySelector('sl-skeleton')).to.exist; + }); + }); + + describe('expandable', () => { + beforeEach(async () => { + el = await fixture(html` + + Lorem + + `); + }); + + it('should be expandable', () => { + expect(el.expandable).to.be.true; + }); + + it('should not be expanded', () => { + expect(el).to.have.attribute('aria-expanded', 'false'); + expect(el.expanded).not.to.be.true; + }); + + it('should render an expander', () => { + const expander = el.renderRoot.querySelector('.expander'); + + expect(expander).to.exist; + expect(expander).to.contain('sl-icon[name="chevron-right"]'); + }); + + it('should toggle the expanded state when clicking the element', async () => { + el.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'true'); + + el.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'false'); + }); + + it('should not toggle the expanded state when clicking the text', async () => { + el.querySelector('span')?.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'false'); + }); + + it('should toggle the expanded state when using the keyboard', async () => { + el.focus(); + + await sendKeys({ press: 'ArrowRight' }); + expect(el).to.have.attribute('aria-expanded', 'true'); + + await sendKeys({ press: 'ArrowLeft' }); + expect(el).to.have.attribute('aria-expanded', 'false'); + }); + + it('should toggle the expanded state by using the toggle() method', async () => { + el.toggle(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'true'); + + el.toggle(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'false'); + }); + + it('should force toggle the expanded state by using the toggle(true) method', async () => { + el.toggle(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'true'); + + el.toggle(true); + await el.updateComplete; + + expect(el).to.have.attribute('aria-expanded', 'true'); + }); + + it('should emit a toggle event when the expanded state changes', () => { + const onToggle = spy(); + + el.addEventListener('sl-toggle', (event: CustomEvent) => { + onToggle(event.detail); + }); + + el.click(); + + expect(onToggle).to.have.been.calledOnce; + expect(onToggle.lastCall.firstArg).to.be.true; + + el.toggle(); + + expect(onToggle).to.have.been.calledTwice; + expect(onToggle.lastCall.firstArg).to.be.false; + }); + }); + + describe('single select', () => { + beforeEach(async () => { + el = await fixture(html` + + Lorem + + `); + }); + + it('should have an aria-selected attribute', () => { + expect(el).to.have.attribute('aria-selected', 'false'); + }); + + it('should not render a checkbox', () => { + const checkbox = el.renderRoot.querySelector('sl-checkbox'); + + expect(checkbox).to.not.exist; + }); + + it('should set the selected state when clicking the text', async () => { + el.querySelector('span')?.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-selected', 'true'); + }); + + it('should set the selected state by using the keyboard', async () => { + el.focus(); + + await sendKeys({ press: 'Enter' }); + + expect(el).to.have.attribute('aria-selected', 'true'); + }); + + it('should emit a select event when the text is clicked', () => { + const onSelect = spy(); + + el.addEventListener('sl-select', (event: CustomEvent) => { + onSelect(event.detail); + }); + el.querySelector('span')?.click(); + + expect(onSelect).to.have.been.calledOnce; + expect(onSelect.lastCall.firstArg).to.deep.equal({ hello: true }); + }); + }); + + describe('multiple select', () => { + beforeEach(async () => { + el = await fixture(html` + + Lorem + + `); + }); + + it('should have an aria-checked attribute', () => { + expect(el).to.have.attribute('aria-checked', 'false'); + }); + + it('should render a checkbox', () => { + const checkbox = el.renderRoot.querySelector('sl-checkbox'); + + expect(checkbox).to.exist; + }); + + it('should toggle the checkbox when clicking the text', async () => { + el.querySelector('span')?.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-checked', 'true'); + expect(el.renderRoot.querySelector('sl-checkbox')).to.have.property('checked', true); + + el.querySelector('span')?.click(); + await el.updateComplete; + + expect(el).to.have.attribute('aria-checked', 'false'); + expect(el.renderRoot.querySelector('sl-checkbox')).to.have.property('checked', false); + }); + + it('should emit a change event when the checkbox is toggled', () => { + const onChange = spy(); + + el.addEventListener('sl-change', (event: SlChangeEvent) => { + onChange(event.detail); + }); + + el.querySelector('span')?.click(); + + expect(onChange).to.have.been.calledOnce; + expect(onChange.lastCall.firstArg).to.be.true; + + el.querySelector('span')?.click(); + + expect(onChange).to.have.been.calledTwice; + expect(onChange.lastCall.firstArg).to.not.be.true; + }); + }); +}); diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts new file mode 100644 index 0000000000..e1543f044d --- /dev/null +++ b/packages/components/tree/src/tree-node.ts @@ -0,0 +1,262 @@ +import { localized, msg } from '@lit/localize'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { ButtonBar } from '@sl-design-system/button-bar'; +import { Checkbox } from '@sl-design-system/checkbox'; +import { Icon } from '@sl-design-system/icon'; +import { type Menu } from '@sl-design-system/menu'; +import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; +import { type SlChangeEvent, type SlSelectEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; +import { Skeleton } from '@sl-design-system/skeleton'; +import { Spinner } from '@sl-design-system/spinner'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { IndentGuides } from './indent-guides.js'; +import { type TreeDataSourceNode } from './tree-data-source.js'; +import styles from './tree-node.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree-node': TreeNode; + } +} + +export type TreeNodeContextMenu = (node: TreeDataSourceNode) => Menu | undefined; + +export type TreeNodeType = 'node' | 'placeholder' | 'skeleton'; + +/** + * A tree node component. Used to represent a node in a tree. This component + * is not public API and is used internally by ``. + */ +@localized() +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class TreeNode extends ScopedElementsMixin(LitElement) { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-button-bar': ButtonBar, + 'sl-checkbox': Checkbox, + 'sl-icon': Icon, + 'sl-indent-guides': IndentGuides, + 'sl-spinner': Spinner, + 'sl-skeleton': Skeleton + }; + } + + // eslint-disable-next-line no-unused-private-class-members + #events = new EventsController(this, { + click: this.#onClick, + keydown: this.#onKeydown + }); + + /** @internal Emits when the checked state of the checkbox changes. */ + @event({ name: 'sl-change' }) changeEvent!: EventEmitter>; + + /** Determines whether the checkbox is checked or not. */ + @property({ type: Boolean }) checked?: boolean; + + /** Whether the node is disabled. */ + @property({ type: Boolean, reflect: true }) disabled?: boolean; + + /** If true, will render an indicator whether the node is expanded or collapsed. */ + @property({ type: Boolean }) expandable?: boolean; + + /** Indicates whether the node is expanded or collapsed. */ + @property({ type: Boolean }) expanded?: boolean; + + /** Hides the indentation guides when set. */ + @property({ type: Boolean, attribute: 'hide-guides', reflect: true }) hideGuides?: boolean; + + /** Indeterminate state of the checkbox. Used when not all children are checked. */ + @property({ type: Boolean }) indeterminate?: boolean; + + /** Whether this node is the last one on this level; used for styling. */ + @property({ type: Boolean, attribute: 'last-node-in-level' }) lastNodeInLevel?: boolean; + + /** The depth level of this node, 0 being the root of the tree. */ + @property({ type: Number }) level = 0; + + /** The tree model node. */ + @property({ attribute: false }) node?: TreeDataSourceNode; + + /** @internal Emits when the user clicks a the wrapper part of the tree node. */ + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; + + /** Whether the node is currently selected. */ + @property({ type: Boolean }) selected?: boolean; + + /** If you are able to select one or more tree nodes (at the same time). */ + @property() selects?: 'single' | 'multiple'; + + /** @internal Emits when the expanded state changes. */ + @event({ name: 'sl-toggle' }) toggleEvent!: EventEmitter>; + + /** + * The type of tree node: + * - 'node': A regular tree node. + * - 'placeholder': A placeholder node used for loading children. + * - 'skeleton': A skeleton node used for loading individual nodes. + * + * @default node + */ + @property() type?: TreeNodeType; + + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('role', 'treeitem'); + this.tabIndex = 0; + } + + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('checked') || changes.has('indeterminate') || changes.has('selected') || changes.has('selects')) { + if (this.selects === 'multiple') { + this.setAttribute('aria-checked', this.checked ? 'true' : this.indeterminate ? 'mixed' : 'false'); + } else { + this.removeAttribute('aria-checked'); + } + + if (this.selects === 'single') { + this.setAttribute('aria-selected', Boolean(this.selected).toString()); + } else { + this.removeAttribute('aria-selected'); + } + } + + if (changes.has('expandable') || changes.has('expanded')) { + if (this.expandable) { + this.setAttribute('aria-expanded', Boolean(this.expanded).toString()); + } else { + this.removeAttribute('aria-expanded'); + } + } + } + + override render(): TemplateResult { + return html` + + ${this.expandable + ? html` +
+ +
+ ` + : nothing} +
+ ${choose( + this.type, + [ + ['placeholder', () => html`${msg('Loading')}`], + [ + 'skeleton', + () => html`` + ] + ], + () => + this.selects === 'multiple' + ? html` + + + + + ` + : html` +
+ +
+ + + + + + ` + )} +
+ `; + } + + toggle(expanded = !this.expanded): void { + this.expanded = expanded; + this.toggleEvent.emit(this.expanded); + } + + #onChange(event: SlChangeEvent): void { + event.preventDefault(); + event.stopPropagation(); + + this.checked = event.detail; + this.indeterminate = false; + this.changeEvent.emit(this.checked); + } + + /** + * If the user clicked on the wrapper part of the tree node, + * emit the select event. Otherwise, if the node is expandable, + * toggle the expanded state. + */ + #onClick(event: Event): void { + const wrapper = this.renderRoot.querySelector('[part="wrapper"]'); + + const insideWrapper = !!event + .composedPath() + .filter((el): el is HTMLElement => el instanceof HTMLElement) + .find(el => el === wrapper); + + if (insideWrapper) { + event.preventDefault(); + + this.selected = this.selects === 'single' ? true : this.selected; + this.selectEvent.emit(this.node!); + } else if (this.expandable) { + this.toggle(); + } + } + + /** See https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction */ + #onKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + + if (this.selects === 'multiple') { + this.checked = !this.checked; + this.indeterminate = false; + this.changeEvent.emit(this.checked); + } else { + this.selected = this.selects === 'single' ? true : this.selected; + this.selectEvent.emit(this.node!); + } + } else if (event.key === 'ArrowLeft') { + if (this.expanded) { + event.preventDefault(); + event.stopPropagation(); + + this.toggle(); + } else if (this.level === 0) { + event.preventDefault(); + } + } else if (event.key === 'ArrowRight') { + if (this.expandable && !this.expanded) { + event.preventDefault(); + + this.toggle(); + } else if (!this.expandable) { + event.preventDefault(); + } + } + } +} diff --git a/packages/components/tree/src/tree.scss b/packages/components/tree/src/tree.scss new file mode 100644 index 0000000000..b7c6d78cf5 --- /dev/null +++ b/packages/components/tree/src/tree.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + scroll-padding-block: var(--sl-space-100); +} diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts new file mode 100644 index 0000000000..300873e1d4 --- /dev/null +++ b/packages/components/tree/src/tree.spec.ts @@ -0,0 +1,306 @@ +import { setupIgnoreWindowResizeObserverLoopErrors } from '@lit-labs/virtualizer/support/resize-observer-errors.js'; +import { expect, fixture } from '@open-wc/testing'; +import { type Icon } from '@sl-design-system/icon'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit'; +import '../register.js'; +import { FlatTreeDataSource } from './flat-tree-data-source.js'; +import { NestedTreeDataSource } from './nested-tree-data-source.js'; +import { type Tree } from './tree.js'; + +interface FlatDataNode { + id: number; + expandable: boolean; + level: number; + name: string; +} + +interface NestedDataNode { + id: number; + name: string; + children?: NestedDataNode[]; +} + +setupIgnoreWindowResizeObserverLoopErrors(beforeEach, afterEach); + +const flatData: FlatDataNode[] = [ + { + id: 0, + expandable: true, + level: 0, + name: 'Actions' + }, + { + id: 1, + expandable: false, + level: 1, + name: 'Button' + }, + { + id: 2, + expandable: true, + level: 0, + name: 'Navigation' + }, + { + id: 3, + expandable: true, + level: 1, + name: 'Tree' + }, + { + id: 4, + expandable: false, + level: 2, + name: 'Flat Data Source' + }, + { + id: 5, + expandable: false, + level: 2, + name: 'Nested Data Source' + } +]; + +const nestedData: NestedDataNode[] = [ + { + id: 0, + name: 'Actions', + children: [{ id: 1, name: 'Button' }] + }, + { + id: 2, + name: 'Navigation', + children: [ + { + id: 3, + name: 'Tree', + children: [ + { id: 4, name: 'Flat Data Source' }, + { id: 5, name: 'Nested Data Source' } + ] + } + ] + } +]; + +describe('sl-tree', () => { + let el: Tree; + + describe('defaults', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should have a tree role', () => { + expect(el).to.have.attribute('role', 'tree'); + }); + + it('should not hide the indentation guides', () => { + expect(el).to.not.have.attribute('hide-guides'); + expect(el.hideGuides).not.to.be.true; + }); + + it('should not have a data source', () => { + expect(el.dataSource).to.be.undefined; + }); + + it('should not have any tree nodes', () => { + const nodes = el.renderRoot.querySelectorAll('sl-tree-node'); + + expect(nodes).to.have.lengthOf(0); + }); + }); + + describe('keyboard navigation', () => { + let ds: FlatTreeDataSource; + + beforeEach(async () => { + ds = new FlatTreeDataSource(flatData, { + getIcon: ({ expandable }, expanded) => (!expandable ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + getLevel: ({ level }) => level, + isExpandable: ({ expandable }) => expandable, + isExpanded: ({ name }) => ['Navigation', 'Tree'].includes(name) + }); + + el = await fixture(html``); + await el.layoutComplete; + }); + + it('should focus the first tree node when focusing the tree', () => { + el.focus(); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(1)'); + }); + + it('should focus the next/previous tree nodes when pressing the up/down arrow keys', async () => { + el.focus(); + + await sendKeys({ press: 'ArrowDown' }); + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(2)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Navigation'); + + await sendKeys({ press: 'ArrowDown' }); + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(3)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Tree'); + + await sendKeys({ press: 'ArrowUp' }); + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(2)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Navigation'); + }); + + it('should expand and collapse tree nodes when pressing the right/left arrow keys', async () => { + el.focus(); + + // Expand first node + await sendKeys({ press: 'ArrowRight' }); + + // Wait for the tree nodes to update + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(1)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Actions'); + expect(el.shadowRoot?.activeElement).to.have.property('expanded', true); + + // Move focus to the next node + await sendKeys({ press: 'ArrowRight' }); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(2)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Button'); + + // Move focus to the previous (first) node + await sendKeys({ press: 'ArrowLeft' }); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(1)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Actions'); + + // Collapse first node + await sendKeys({ press: 'ArrowLeft' }); + + // Wait for the tree nodes to update + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(1)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Actions'); + expect(el.shadowRoot?.activeElement).to.have.property('expanded', false); + }); + + it('should focus the parent node when pressing the left arrow on a leaf node', async () => { + el.renderRoot.querySelector('sl-tree-node:nth-of-type(5)')?.focus(); + + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Nested Data Source'); + + await sendKeys({ press: 'ArrowLeft' }); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(3)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Tree'); + }); + + it('should do nothing when pressing the right arrow on a leaf node', async () => { + el.renderRoot.querySelector('sl-tree-node:nth-of-type(4)')?.focus(); + + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Flat Data Source'); + + await sendKeys({ press: 'ArrowRight' }); + + expect(el.shadowRoot?.activeElement).to.match('sl-tree-node:nth-of-type(4)'); + expect(el.shadowRoot?.activeElement).to.have.trimmed.text('Flat Data Source'); + }); + + it('should expand all siblings that are at the same level when pressing *', async () => { + el.renderRoot.querySelector('sl-tree-node:nth-of-type(2)')?.focus(); + + await sendKeys({ press: '*' }); + + // Wait for the tree nodes to update + await new Promise(resolve => setTimeout(resolve, 50)); + + const nodes = Array.from(el.renderRoot.querySelectorAll('sl-tree-node')); + + expect(nodes).to.have.lengthOf(6); + expect(nodes.every(n => !n.expandable || n.expanded)).to.be.true; + }); + }); + + describe('using flat data', () => { + let ds: FlatTreeDataSource; + + beforeEach(async () => { + ds = new FlatTreeDataSource(flatData, { + getIcon: ({ expandable }, expanded) => (!expandable ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + getLevel: ({ level }) => level, + isExpandable: ({ expandable }) => expandable, + isExpanded: ({ name }) => ['Navigation', 'Tree'].includes(name) + }); + + el = await fixture(html``); + await el.layoutComplete; + }); + + it('should render the visible tree nodes', () => { + const nodes = Array.from(el.renderRoot.querySelectorAll('sl-tree-node')), + names = nodes.map(node => node.textContent?.trim()); + + expect(nodes).to.have.lengthOf(5); + expect(names).to.deep.equal(['Actions', 'Navigation', 'Tree', 'Flat Data Source', 'Nested Data Source']); + }); + + it('should render the tree nodes with the correct icons', () => { + const icons = Array.from(el.renderRoot.querySelectorAll('sl-tree-node sl-icon')).map(icon => icon.name); + + expect(icons).to.deep.equal(['far-folder', 'far-folder-open', 'far-folder-open', 'far-file', 'far-file']); + }); + + it('should render the tree nodes with indentation guides', () => { + const nodes = Array.from(el.renderRoot.querySelectorAll('sl-tree-node')), + guides = nodes.map(node => node.renderRoot.querySelector('sl-indent-guides')!); + + expect(guides).to.have.lengthOf(5); + expect(guides.map(g => g.level)).to.deep.equal([0, 0, 1, 2, 2]); + }); + }); + + describe('using nested data', () => { + let ds: NestedTreeDataSource; + + beforeEach(async () => { + ds = new NestedTreeDataSource(nestedData, { + getChildren: ({ children }) => children, + getIcon: ({ children }, expanded) => (!children ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + isExpandable: ({ children }) => !!children, + isExpanded: ({ name }) => ['Navigation', 'Tree'].includes(name) + }); + + el = await fixture(html``); + await el.layoutComplete; + }); + + it('should render the visible tree nodes', () => { + const nodes = Array.from(el.renderRoot.querySelectorAll('sl-tree-node')), + names = nodes.map(node => node.textContent?.trim()); + + expect(nodes).to.have.lengthOf(5); + expect(names).to.deep.equal(['Actions', 'Navigation', 'Tree', 'Flat Data Source', 'Nested Data Source']); + }); + + it('should render the tree nodes with the correct icons', () => { + const icons = Array.from(el.renderRoot.querySelectorAll('sl-tree-node sl-icon')).map(icon => icon.name); + + expect(icons).to.deep.equal(['far-folder', 'far-folder-open', 'far-folder-open', 'far-file', 'far-file']); + }); + + it('should render the tree nodes with indentation guides', () => { + const nodes = Array.from(el.renderRoot.querySelectorAll('sl-tree-node')), + guides = nodes.map(node => node.renderRoot.querySelector('sl-indent-guides')!); + + expect(guides).to.have.lengthOf(5); + expect(guides.map(g => g.level)).to.deep.equal([0, 0, 1, 2, 2]); + }); + }); +}); diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts new file mode 100644 index 0000000000..ba37d88fa2 --- /dev/null +++ b/packages/components/tree/src/tree.stories.ts @@ -0,0 +1,412 @@ +import { faFile, faFolder, faFolderOpen, faPen, faTrash } from '@fortawesome/pro-regular-svg-icons'; +import { Button } from '@sl-design-system/button'; +import '@sl-design-system/button/register.js'; +import { ButtonBar } from '@sl-design-system/button-bar'; +import '@sl-design-system/button-bar/register.js'; +import { Icon } from '@sl-design-system/icon'; +import '@sl-design-system/menu/register.js'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { html, nothing } from 'lit'; +import '../register.js'; +import { FlatTreeDataSource } from './flat-tree-data-source.js'; +import { NestedTreeDataSource } from './nested-tree-data-source.js'; +import { type Tree } from './tree.js'; + +type Props = Pick & { + styles?: string; +}; +type Story = StoryObj; + +export interface FlatDataNode { + id: number; + expandable: boolean; + level: number; + name: string; +} + +export interface NestedDataNode { + id: number; + name: string; + children?: NestedDataNode[]; +} + +export interface LazyNestedDataNode { + id: string; + expandable?: boolean; + children?: LazyNestedDataNode[] | Promise | Array>; +} + +Icon.register(faFile, faFolder, faFolderOpen, faPen, faTrash); + +export const flatData: FlatDataNode[] = [ + { + id: 0, + expandable: true, + level: 0, + name: 'textarea' + }, + { + id: 1, + expandable: false, + level: 1, + name: 'package.json' + }, + { + id: 2, + expandable: true, + level: 0, + name: 'tooltip' + }, + { + id: 3, + expandable: false, + level: 1, + name: 'package.json' + }, + { + id: 4, + expandable: true, + level: 0, + name: 'tree' + }, + { + id: 5, + expandable: true, + level: 1, + name: 'src' + }, + { + id: 6, + expandable: false, + level: 2, + name: 'flat-tree-model.ts' + }, + { + id: 7, + expandable: false, + level: 2, + name: 'nested-tree-model.ts' + }, + { + id: 8, + expandable: false, + level: 2, + name: 'tree-model.ts' + }, + { + id: 9, + expandable: false, + level: 2, + name: 'tree-node.scss' + }, + { + id: 10, + expandable: false, + level: 2, + name: 'tree-node.ts' + }, + { + id: 11, + expandable: false, + level: 2, + name: 'tree.ts' + }, + { + id: 12, + expandable: false, + level: 2, + name: 'utils.ts' + }, + { + id: 13, + expandable: false, + level: 1, + name: 'index.ts' + }, + { + id: 14, + expandable: false, + level: 1, + name: 'package.json' + }, + { + id: 15, + expandable: false, + level: 1, + name: 'register.ts' + }, + { + id: 16, + expandable: false, + level: 0, + name: 'eslint.config.mjs' + }, + { + id: 17, + expandable: false, + level: 0, + name: 'stylelint.config.mjs' + } +]; + +export const nestedData: NestedDataNode[] = [ + { + id: 0, + name: 'textarea', + children: [{ id: 1, name: 'package.json' }] + }, + { + id: 2, + name: 'tooltip', + children: [{ id: 3, name: 'package.json' }] + }, + { + id: 4, + name: 'tree', + children: [ + { + id: 5, + name: 'src', + children: [ + { id: 6, name: 'flat-tree-model.ts' }, + { id: 7, name: 'nested-tree-model.ts' }, + { id: 8, name: 'tree-model.ts' }, + { id: 9, name: 'tree-node.scss' }, + { id: 10, name: 'tree-node.ts' }, + { id: 11, name: 'tree.ts' }, + { id: 12, name: 'utils.ts' } + ] + }, + { id: 13, name: 'index.ts' }, + { id: 14, name: 'package.json' }, + { id: 15, name: 'register.ts' } + ] + }, + { id: 16, name: 'eslint.config.mjs' }, + { id: 17, name: 'stylelint.config.mjs' } +]; + +export default { + title: 'Navigation/Tree', + tags: ['draft'], + excludeStories: ['flatData', 'nestedData'], + args: { + hideGuides: false, + dataSource: undefined + }, + argTypes: { + dataSource: { + table: { disable: true } + }, + renderer: { + table: { disable: true } + }, + styles: { + table: { disable: true } + } + }, + render: ({ dataSource, hideGuides, renderer, scopedElements, styles }) => { + const onToggle = () => dataSource?.selection.forEach(node => dataSource?.toggle(node)), + onToggleDescendants = () => dataSource?.selection.forEach(node => dataSource?.toggleDescendants(node)), + onExpandAll = () => dataSource?.expandAll(), + onCollapseAll = () => dataSource?.collapseAll(); + + return html` + ${styles + ? html` + + ` + : nothing} + + ${dataSource?.selects + ? html` + Toggle selected + Toggle descendants + ` + : nothing} + Expand all + Collapse all + + + `; + } +} satisfies Meta; + +export const FlatDataSource: Story = { + args: { + dataSource: new FlatTreeDataSource(flatData, { + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + getLevel: ({ level }) => level, + isExpandable: ({ expandable }) => expandable, + isExpanded: ({ name }) => ['tree', 'src'].includes(name) + }) + } +}; + +export const NestedDataSource: Story = { + args: { + dataSource: new NestedTreeDataSource(nestedData, { + getChildren: ({ children }) => children, + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + isExpandable: ({ children }) => !!children, + isExpanded: ({ name }) => ['tree', 'src'].includes(name) + }) + } +}; + +export const SingleSelect: Story = { + args: { + dataSource: new FlatTreeDataSource(flatData, { + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + getLevel: ({ level }) => level, + isExpandable: ({ expandable }) => expandable, + isExpanded: ({ name }) => ['tree', 'src'].includes(name), + isSelected: ({ name }) => name === 'tree-node.ts', + selects: 'single' + }) + } +}; + +export const MultiSelect: Story = { + args: { + dataSource: new NestedTreeDataSource(nestedData, { + getChildren: ({ children }) => children, + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + getId: item => item.id, + getLabel: ({ name }) => name, + isExpanded: ({ name }) => ['tree', 'src'].includes(name), + isExpandable: ({ children }) => !!children, + isSelected: ({ name }) => ['tree-node.scss', 'tree-node.ts'].includes(name), + selects: 'multiple' + }) + } +}; + +export const LazyLoad: Story = { + args: { + dataSource: new NestedTreeDataSource( + [ + { id: '0-0', expandable: true }, + { id: '0-1', expandable: true }, + { id: '0-2', expandable: true }, + { id: '0-3' }, + { id: '0-4' } + ] as LazyNestedDataNode[], + { + loadChildren: node => { + return new Promise(resolve => { + setTimeout(() => { + const children = Array.from({ length: 10 }).map((_, i) => ({ + id: `${node.id}-${i}`, + expandable: true + })); + + resolve(children); + }, 1000); + }); + }, + getChildren: () => undefined, + getId: ({ id }) => id, + getLabel: ({ id }) => id, + isExpandable: ({ expandable }) => !!expandable + } + ) + } +}; + +export const Skeleton: Story = { + args: { + dataSource: new NestedTreeDataSource( + [ + { id: '0-0', expandable: true }, + { id: '0-1', expandable: true }, + { id: '0-2', expandable: true }, + { id: '0-3' }, + { id: '0-4' } + ] as LazyNestedDataNode[], + { + loadChildren: node => { + return new Promise(resolve => { + setTimeout(() => { + const children = Array.from({ length: 10 }).map((_, i) => ({ + id: `${node.id}-${i}`, + expandable: true + })); + + resolve(children); + }, 1000); + }); + }, + getChildren: () => undefined, + getChildrenCount: ({ expandable }) => (expandable ? 10 : undefined), + getId: ({ id }) => id, + getLabel: ({ id }) => id, + isExpandable: ({ expandable }) => !!expandable + } + ) + } +}; + +export const Scrolling: Story = { + parameters: { + // The size of the snapshot exceeds the maximum + chromatic: { disableSnapshot: true } + }, + args: { + dataSource: new NestedTreeDataSource( + [1, 2, 3].map(id => ({ + id, + name: `Root ${id}`, + children: Array.from({ length: 1000 }).map((_, i) => ({ id: 1000 * id + i, name: `Child ${i}` })) + })), + { + getChildren: ({ children }) => children, + getId: ({ id }) => id, + getLabel: ({ name }) => name, + isExpandable: ({ children }) => !!children, + isExpanded: () => true, + isSelected: ({ id }) => id === 2010, + selects: 'single' + } + ) + } +}; + +export const CustomRenderer: Story = { + args: { + ...FlatDataSource.args, + renderer: node => { + const icon = node.label.includes('.') ? 'far-file' : `far-folder${node.expanded ? '-open' : ''}`; + + return html` + ${icon ? html`` : nothing} + ${node.label} + + + + + + + + `; + }, + scopedElements: { + 'sl-button': Button, + 'sl-button-bar': ButtonBar, + 'sl-icon': Icon + } + } +}; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts new file mode 100644 index 0000000000..97feec16a9 --- /dev/null +++ b/packages/components/tree/src/tree.ts @@ -0,0 +1,265 @@ +import { type RangeChangedEvent } from '@lit-labs/virtualizer'; +import { type VirtualizerHostElement, virtualize, virtualizerRef } from '@lit-labs/virtualizer/virtualize.js'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; +import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; +import { Skeleton } from '@sl-design-system/skeleton'; +import { Spinner } from '@sl-design-system/spinner'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { TreeDataSource, type TreeDataSourceNode } from './tree-data-source.js'; +import { TreeNode } from './tree-node.js'; +import styles from './tree.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree': Tree; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TreeItemRenderer = (item: TreeDataSourceNode) => TemplateResult; + +/** + * A tree component. Use this if you have hierarchical data that you want + * to visualize. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class Tree extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-icon': Icon, + 'sl-skeleton': Skeleton, + 'sl-spinner': Spinner, + 'sl-tree-node': TreeNode + }; + } + + /** @internal */ + static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** The data model for the tree. */ + #dataSource?: TreeDataSource; + + /** Manage keyboard navigation between tabs. */ + #rovingTabindexController = new RovingTabindexController>(this, { + focusInIndex: (elements: Array>) => elements.findIndex(el => !el.disabled), + elements: () => Array.from(this.shadowRoot?.querySelectorAll('sl-tree-node') ?? []), + isFocusableElement: (el: TreeNode) => !el.disabled + }); + + /** The virtualizer instance. */ + #virtualizer?: VirtualizerHostElement[typeof virtualizerRef]; + + get dataSource() { + return this.#dataSource; + } + + /** The model for the tree. */ + @property({ attribute: false }) + set dataSource(dataSource: TreeDataSource | undefined) { + if (this.#dataSource) { + this.#dataSource.removeEventListener('sl-update', this.#onUpdate); + } + + this.#dataSource = dataSource; + this.#dataSource?.addEventListener('sl-update', this.#onUpdate); + this.#dataSource?.update(); + } + + /** Hides the indentation guides when set. */ + @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; + + /** + * Use this if you want to wait until lit-virtualizer has finished the rendering + * the tree nodes. This can be useful in unit tests for example. + */ + get layoutComplete() { + return this.#virtualizer?.layoutComplete ?? Promise.resolve(); + } + + /** Custom renderer function for tree items. */ + @property({ attribute: false }) renderer?: TreeItemRenderer; + + /** + * The custom elements used for rendering this tree. If you are using a custom renderer + * to render the tree nodes, any custom elements you use in the renderer need to be specified + * via this property. Otherwise those custom elements will not initialize, since the tree + * uses a Scoped Custom Element Registry. + */ + @property({ attribute: false }) scopedElements?: Record; + + /** @internal Emits when the user selects a tree node. */ + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; + + override connectedCallback(): void { + super.connectedCallback(); + + this.role = 'tree'; + } + + override async firstUpdated(changes: PropertyValues): Promise { + super.firstUpdated(changes); + + const wrapper = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; + this.#virtualizer = wrapper[virtualizerRef]; + + await this.layoutComplete; + + if (this.dataSource?.selection.size) { + const node = this.dataSource.selection.keys().next().value as TreeDataSourceNode; + + this.scrollToNode(node, { block: 'center' }); + } + } + + override willUpdate(changes: PropertyValues): void { + super.willUpdate(changes); + + if (changes.has('dataSource')) { + if (this.dataSource?.selects === 'multiple') { + this.setAttribute('aria-multiselectable', 'true'); + } else if (this.dataSource?.selects === 'single') { + this.setAttribute('aria-multiselectable', 'false'); + } else { + this.removeAttribute('aria-multiselectable'); + } + } + + if (changes.has('scopedElements') && this.scopedElements) { + for (const [tagName, klass] of Object.entries(this.scopedElements)) { + if (!this.registry?.get(tagName)) { + this.registry?.define(tagName, klass); + } + } + } + } + + override render(): TemplateResult { + return html` +
+ ${virtualize({ + items: this.dataSource?.items, + keyFunction: (item: TreeDataSourceNode) => item.id, + renderItem: (item: TreeDataSourceNode) => this.renderItem(item) + })} +
+ `; + } + + renderItem(item: TreeDataSourceNode): TemplateResult { + const icon = item.expanded ? item.expandedIcon : item.icon; + + return html` + ) => this.#onChange(event, item)} + @sl-toggle=${() => this.#onToggle(item)} + ?checked=${this.dataSource?.selects === 'multiple' && item.selected} + ?expandable=${item.expandable} + ?expanded=${item.expanded} + ?hide-guides=${this.hideGuides} + ?indeterminate=${item.indeterminate} + ?last-node-in-level=${item.lastNodeInLevel} + ?selected=${this.dataSource?.selects === 'single' && item.selected} + .level=${item.level} + .node=${item} + .selects=${this.dataSource?.selects} + .type=${item.type} + aria-level=${item.level} + > + ${this.renderer?.(item) ?? + html` + ${icon ? html`` : nothing} + ${item.label} + `} + + `; + } + + scrollToNode(node: TreeDataSourceNode, options?: ScrollIntoViewOptions): void { + const index = this.dataSource?.items.indexOf(node) ?? -1; + if (index !== -1) { + this.#virtualizer?.element(index)?.scrollIntoView(options); + } + } + + #onChange(event: SlChangeEvent, node: TreeDataSourceNode): void { + if (event.detail) { + this.dataSource?.select(node); + } else { + this.dataSource?.deselect(node); + } + + this.selectEvent.emit(node); + } + + #onKeydown(event: KeyboardEvent): void { + if (!(event.target instanceof TreeNode)) { + return; + } + + // Expands all siblings that are at the same level as the current node. + // See https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction + if (event.key === '*') { + event.preventDefault(); + + const treeNode = event.target.node as TreeDataSourceNode, + siblings = treeNode.parent?.children ?? this.dataSource?.items; + + if (Array.isArray(siblings)) { + siblings + .filter(sibling => sibling !== treeNode && sibling.expandable) + .forEach(sibling => this.dataSource?.expand(sibling, false)); + } + + this.dataSource?.update(); + } else if (event.key === 'ArrowLeft' && !event.target.expanded) { + event.preventDefault(); + + let parent = event.target.previousElementSibling as TreeNode | null; + while (parent && parent.level === event.target.level) { + parent = parent.previousElementSibling as TreeNode | null; + } + + if (parent) { + this.#rovingTabindexController.focusToElement(parent); + } + } + } + + #onRangeChanged(event: RangeChangedEvent): void { + // Give lit-virtualizer time to finish rendering the tree nodes + requestAnimationFrame(() => { + this.#rovingTabindexController.updateWithVirtualizer( + { elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) }, + event + ); + }); + } + + #onSelect(event: SlSelectEvent>): void { + event.preventDefault(); + event.stopPropagation(); + + this.dataSource?.select(event.detail); + this.selectEvent.emit(event.detail); + } + + #onToggle(node: TreeDataSourceNode): void { + this.dataSource?.toggle(node); + } + + #onUpdate = (): void => { + this.requestUpdate('dataSource'); + }; +} diff --git a/packages/components/tree/tsconfig.json b/packages/components/tree/tsconfig.json new file mode 100644 index 0000000000..386c2cfcb7 --- /dev/null +++ b/packages/components/tree/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./" + }, + "include": ["index.ts", "register.ts", "src/**/*.ts"] +} \ No newline at end of file diff --git a/packages/locales/src/nl.ts b/packages/locales/src/nl.ts index 834f15f804..f5e172bd3e 100644 --- a/packages/locales/src/nl.ts +++ b/packages/locales/src/nl.ts @@ -43,6 +43,7 @@ export const templates = { sa7c7ba461453bfef: str`${0} - ${1} van ${2} items`, sa996297f6a208e98: 'Kruimelpad', sb2c57b2d347203dd: 'Meer tonen', + sb59d68ed12d46377: 'Laden', sb85774dc5d18ff0f: 'Bevestig', sb881081d4f28c851: 'Kies een optie uit de lijst.', sbf1de7bf2881bae1: 'Vaak gebruikt', diff --git a/packages/locales/src/nl.xlf b/packages/locales/src/nl.xlf index 9ae2315b19..e4a20d8652 100644 --- a/packages/locales/src/nl.xlf +++ b/packages/locales/src/nl.xlf @@ -226,6 +226,10 @@ Show more Meer tonen + + Loading + Laden + diff --git a/tools/eslint-config/package.json b/tools/eslint-config/package.json index fff7e35c8d..a6842fef00 100644 --- a/tools/eslint-config/package.json +++ b/tools/eslint-config/package.json @@ -36,7 +36,7 @@ "eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-wc": "^2.2.0", "prettier": "^3.4.2", - "typescript-eslint": "^8.18.1" + "typescript-eslint": "^8.20.0" }, "peerDependencies": { "eslint": "^9.17.0", diff --git a/tsconfig.all.json b/tsconfig.all.json index 70ff95c2d6..cb20182f46 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -61,6 +61,7 @@ { "path": "./packages/components/toggle-group" }, { "path": "./packages/components/tool-bar" }, { "path": "./packages/components/tooltip" }, + { "path": "./packages/components/tree" }, { "path": "./packages/locales" }, { "path": "./packages/themes/bingel-dc" }, { "path": "./packages/themes/bingel-int" }, diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index c49675fa7d..55f9803e94 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -18,7 +18,7 @@ const config = { browsers: [ playwrightLauncher({ - product: 'chromium' , + product: 'chromium', createBrowserContext({ browser }) { return browser.newContext({ locale: 'en' }); } diff --git a/yarn.lock b/yarn.lock index 7a4c046b7f..03a0993dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -723,7 +723,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-define-polyfill-provider@npm:^0.6.2, @babel/helper-define-polyfill-provider@npm:^0.6.3": +"@babel/helper-define-polyfill-provider@npm:^0.6.2": version: 0.6.3 resolution: "@babel/helper-define-polyfill-provider@npm:0.6.3" dependencies: @@ -4990,7 +4990,7 @@ __metadata: eslint-plugin-wc: "npm:^2.2.0" prettier: "npm:^3.4.2" typescript: "npm:^5.4.5" - typescript-eslint: "npm:^8.18.1" + typescript-eslint: "npm:^8.20.0" peerDependencies: eslint: ^9.17.0 typescript: ^5.4.5 @@ -5413,7 +5413,7 @@ __metadata: languageName: unknown linkType: soft -"@sl-design-system/spinner@workspace:packages/components/spinner": +"@sl-design-system/spinner@npm:^1.0.1, @sl-design-system/spinner@workspace:packages/components/spinner": version: 0.0.0-use.local resolution: "@sl-design-system/spinner@workspace:packages/components/spinner" dependencies: @@ -5578,6 +5578,25 @@ __metadata: languageName: unknown linkType: soft +"@sl-design-system/tree@workspace:packages/components/tree": + version: 0.0.0-use.local + resolution: "@sl-design-system/tree@workspace:packages/components/tree" + dependencies: + "@open-wc/scoped-elements": "npm:^3.0.5" + "@sl-design-system/button-bar": "npm:^1.1.0" + "@sl-design-system/checkbox": "npm:^2.0.1" + "@sl-design-system/data-source": "npm:^0.0.1" + "@sl-design-system/icon": "npm:^1.0.2" + "@sl-design-system/shared": "npm:^0.4.0" + "@sl-design-system/skeleton": "npm:^1.0.0" + "@sl-design-system/spinner": "npm:^1.0.1" + lit: "npm:^3.2.1" + peerDependencies: + "@open-wc/scoped-elements": ^3.0.5 + lit: ^3.1.4 + languageName: unknown + linkType: soft + "@sl-design-system/website@workspace:website": version: 0.0.0-use.local resolution: "@sl-design-system/website@workspace:website" @@ -6909,115 +6928,115 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.18.1" +"@typescript-eslint/eslint-plugin@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.20.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.18.1" - "@typescript-eslint/type-utils": "npm:8.18.1" - "@typescript-eslint/utils": "npm:8.18.1" - "@typescript-eslint/visitor-keys": "npm:8.18.1" + "@typescript-eslint/scope-manager": "npm:8.20.0" + "@typescript-eslint/type-utils": "npm:8.20.0" + "@typescript-eslint/utils": "npm:8.20.0" + "@typescript-eslint/visitor-keys": "npm:8.20.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^1.3.0" + ts-api-utils: "npm:^2.0.0" peerDependencies: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/7994d323228f3fc3ec124291cd02761251bcd9a5a6356001d2cb8f68abdb400c3cfbeb343d6941d8e6b6c8d2d616a278bbb3b6d9ed839ba5148a05f60a1f67b4 + checksum: 10c0/c68d0dc5419db93c38eea8adecac19e27f8b023d015a944ffded112d584e87fa7fe512070a6a1085899cab2e12e1c8db276e10412b74bf639ca6b04052bbfedc languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/parser@npm:8.18.1" +"@typescript-eslint/parser@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/parser@npm:8.20.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.18.1" - "@typescript-eslint/types": "npm:8.18.1" - "@typescript-eslint/typescript-estree": "npm:8.18.1" - "@typescript-eslint/visitor-keys": "npm:8.18.1" + "@typescript-eslint/scope-manager": "npm:8.20.0" + "@typescript-eslint/types": "npm:8.20.0" + "@typescript-eslint/typescript-estree": "npm:8.20.0" + "@typescript-eslint/visitor-keys": "npm:8.20.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/23ab30b3f00b86108137e7df03710a088046ead3582595b0f8e17d5062770365e24e0a1ae3398bb3a1c29aa0f05a0de30887e2e0f6fb86163e878dd0eed1b25c + checksum: 10c0/fff4a86be27f603ad8d6f7dd9758c46b04a254828f0c6d8a34869c1cf30b5828b60a1dc088f72680a7b65cc5fc696848df4605de19e59a18467306d7ca56c11d languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/scope-manager@npm:8.18.1" +"@typescript-eslint/scope-manager@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/scope-manager@npm:8.20.0" dependencies: - "@typescript-eslint/types": "npm:8.18.1" - "@typescript-eslint/visitor-keys": "npm:8.18.1" - checksum: 10c0/97c503b2ece79b6c99ca8e6a5f1f40855cf72f17fbf05e42e62d19c2666e7e6f5df9bf71f13dbc4720c5ee0397670ba8052482a90441fbffa901da5f2e739565 + "@typescript-eslint/types": "npm:8.20.0" + "@typescript-eslint/visitor-keys": "npm:8.20.0" + checksum: 10c0/a8074768d06c863169294116624a45c19377ff0b8635ad5fa4ae673b43cf704d1b9b79384ceef0ff0abb78b107d345cd90fe5572354daf6ad773fe462ee71e6a languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/type-utils@npm:8.18.1" +"@typescript-eslint/type-utils@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/type-utils@npm:8.20.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.18.1" - "@typescript-eslint/utils": "npm:8.18.1" + "@typescript-eslint/typescript-estree": "npm:8.20.0" + "@typescript-eslint/utils": "npm:8.20.0" debug: "npm:^4.3.4" - ts-api-utils: "npm:^1.3.0" + ts-api-utils: "npm:^2.0.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/cfe5362a22fa5e18a2662928904da024e42c84cb58a46238b9b61edafcd046f53c9505637176c8cd1c386165c6a6ed15a2b51700495cad6c20e0e33499d483a1 + checksum: 10c0/7d46143f26ec606b71d20f0f5535b16abba2ba7a5a2daecd2584ddb61d1284dd8404f34265cc1fdfd541068b24b0211f7ad94801c94e4c60869d9f26bf3c0b9b languageName: node linkType: hard -"@typescript-eslint/types@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/types@npm:8.18.1" - checksum: 10c0/0a2ca5f7cdebcc844b6bc1e5afc5d83b563f55917d20e3fea3a17ed39c54b003178e26b5ec535113f45c93c569b46628d9a67defa70c01cbdfa801573fed69a2 +"@typescript-eslint/types@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/types@npm:8.20.0" + checksum: 10c0/21292d4ca089897015d2bf5ab99909a7b362902f63f4ba10696676823b50d00c7b4cd093b4b43fba01d12bc3feca3852d2c28528c06d8e45446b7477887dbee7 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.18.1" +"@typescript-eslint/typescript-estree@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.20.0" dependencies: - "@typescript-eslint/types": "npm:8.18.1" - "@typescript-eslint/visitor-keys": "npm:8.18.1" + "@typescript-eslint/types": "npm:8.20.0" + "@typescript-eslint/visitor-keys": "npm:8.20.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^1.3.0" + ts-api-utils: "npm:^2.0.0" peerDependencies: typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/7ecb061dc63c729b23f4f15db5736ca93b1ae633108400e6c31cf8af782494912f25c3683f9f952dbfd10cb96031caba247a1ad406abf5d163639a00ac3ce5a3 + checksum: 10c0/54a2c1da7d1c5f7e865b941e8a3c98eb4b5f56ed8741664a84065173bde9602cdb8866b0984b26816d6af885c1528311c11e7286e869ed424483b74366514cbd languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.18.1, @typescript-eslint/utils@npm:^8.13.0, @typescript-eslint/utils@npm:^8.8.1": - version: 8.18.1 - resolution: "@typescript-eslint/utils@npm:8.18.1" +"@typescript-eslint/utils@npm:8.20.0, @typescript-eslint/utils@npm:^8.13.0, @typescript-eslint/utils@npm:^8.8.1": + version: 8.20.0 + resolution: "@typescript-eslint/utils@npm:8.20.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.18.1" - "@typescript-eslint/types": "npm:8.18.1" - "@typescript-eslint/typescript-estree": "npm:8.18.1" + "@typescript-eslint/scope-manager": "npm:8.20.0" + "@typescript-eslint/types": "npm:8.20.0" + "@typescript-eslint/typescript-estree": "npm:8.20.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/1e29408bd8fbda9f3386dabdb2b7471dacff28342d5bd6521ca3b7932df0cae100030d2eac75d946a82cbefa33f78000eed4ce789128fdea069ffeabd4429d80 + checksum: 10c0/dd36c3b22a2adde1e1462aed0c8b4720f61859b4ebb0c3ef935a786a6b1cb0ec21eb0689f5a8debe8db26d97ebb979bab68d6f8fe7b0098e6200a485cfe2991b languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.18.1": - version: 8.18.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.18.1" +"@typescript-eslint/visitor-keys@npm:8.20.0": + version: 8.20.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.20.0" dependencies: - "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/types": "npm:8.20.0" eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/68651ae1825dbd660ea39b4e1d1618f6ad0026fa3a04aecec296750977cab316564e3e2ace8edbebf1ae86bd17d86acc98cac7b6e9aad4e1c666bd26f18706ad + checksum: 10c0/e95d8b2685e8beb6637bf2e9d06e4177a400d3a2b142ba749944690f969ee3186b750082fd9bf34ada82acf1c5dd5970201dfd97619029c8ecca85fb4b50dbd8 languageName: node linkType: hard @@ -8310,15 +8329,15 @@ __metadata: linkType: hard "babel-plugin-polyfill-corejs2@npm:^0.4.10": - version: 0.4.12 - resolution: "babel-plugin-polyfill-corejs2@npm:0.4.12" + version: 0.4.11 + resolution: "babel-plugin-polyfill-corejs2@npm:0.4.11" dependencies: "@babel/compat-data": "npm:^7.22.6" - "@babel/helper-define-polyfill-provider": "npm:^0.6.3" + "@babel/helper-define-polyfill-provider": "npm:^0.6.2" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/49150c310de2d472ecb95bd892bca1aa833cf5e84bbb76e3e95cf9ff2c6c8c3b3783dd19d70ba50ff6235eb8ce1fa1c0affe491273c95a1ef6a2923f4d5a3819 + checksum: 10c0/b2217bc8d5976cf8142453ed44daabf0b2e0e75518f24eac83b54a8892e87a88f1bd9089daa92fd25df979ecd0acfd29b6bc28c4182c1c46344cee15ef9bce84 languageName: node linkType: hard @@ -8335,13 +8354,13 @@ __metadata: linkType: hard "babel-plugin-polyfill-regenerator@npm:^0.6.1": - version: 0.6.3 - resolution: "babel-plugin-polyfill-regenerator@npm:0.6.3" + version: 0.6.2 + resolution: "babel-plugin-polyfill-regenerator@npm:0.6.2" dependencies: - "@babel/helper-define-polyfill-provider": "npm:^0.6.3" + "@babel/helper-define-polyfill-provider": "npm:^0.6.2" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/40164432e058e4b5c6d56feecacdad22692ae0534bd80c92d5399ed9e1a6a2b6797c8fda837995daddd4ca391f9aa2d58c74ad465164922e0f73631eaf9c4f76 + checksum: 10c0/bc541037cf7620bc84ddb75a1c0ce3288f90e7d2799c070a53f8a495c8c8ae0316447becb06f958dd25dcce2a2fce855d318ecfa48036a1ddb218d55aa38a744 languageName: node linkType: hard @@ -13495,9 +13514,9 @@ __metadata: linkType: hard "ignore@npm:^5.1.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.1": - version: 5.3.2 - resolution: "ignore@npm:5.3.2" - checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + version: 5.3.1 + resolution: "ignore@npm:5.3.1" + checksum: 10c0/703f7f45ffb2a27fb2c5a8db0c32e7dee66b33a225d28e8db4e1be6474795f606686a6e3bcc50e1aa12f2042db4c9d4a7d60af3250511de74620fbed052ea4cd languageName: node linkType: hard @@ -21826,12 +21845,12 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^1.3.0": - version: 1.3.0 - resolution: "ts-api-utils@npm:1.3.0" +"ts-api-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "ts-api-utils@npm:2.0.0" peerDependencies: - typescript: ">=4.2.0" - checksum: 10c0/f54a0ba9ed56ce66baea90a3fa087a484002e807f28a8ccb2d070c75e76bde64bd0f6dce98b3802834156306050871b67eec325cb4e918015a360a3f0868c77c + typescript: ">=4.8.4" + checksum: 10c0/6165e29a5b75bd0218e3cb0f9ee31aa893dbd819c2e46dbb086c841121eb0436ed47c2c18a20cb3463d74fd1fb5af62e2604ba5971cc48e5b38ebbdc56746dfc languageName: node linkType: hard @@ -22037,17 +22056,17 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.18.1": - version: 8.18.1 - resolution: "typescript-eslint@npm:8.18.1" +"typescript-eslint@npm:^8.20.0": + version: 8.20.0 + resolution: "typescript-eslint@npm:8.20.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.18.1" - "@typescript-eslint/parser": "npm:8.18.1" - "@typescript-eslint/utils": "npm:8.18.1" + "@typescript-eslint/eslint-plugin": "npm:8.20.0" + "@typescript-eslint/parser": "npm:8.20.0" + "@typescript-eslint/utils": "npm:8.20.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/cb75af9b7381051cf80a18d4d96782a23196f7500766fa52926c1515fd7eaa42cb01ed37582d1bf519860075bea3f5375e6fcbbaf7fed3e3ab1b0f6da95805ce + checksum: 10c0/049e0fa000657232c0fe26a062ef6a9cd16c5a58c814a74ac45971554c8b6bc67355821a66229f9537e819939a2ab065e7fcba9a70cd95c8283630dc58ac0144 languageName: node linkType: hard