From d8bba4ffbaa9fceaaca5bd8bf06f40dfe217b0bc Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 5 Apr 2024 12:58:23 +0200 Subject: [PATCH 01/88] =?UTF-8?q?=F0=9F=96=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/index.ts | 1 + packages/components/tree/package.json | 50 ++++++++++++++++++++ packages/components/tree/register.ts | 3 ++ packages/components/tree/src/tree.scss | 3 ++ packages/components/tree/src/tree.stories.ts | 25 ++++++++++ packages/components/tree/src/tree.ts | 21 ++++++++ packages/components/tree/tsconfig.json | 8 ++++ tsconfig.all.json | 1 + yarn.lock | 12 +++++ 9 files changed, 124 insertions(+) create mode 100644 packages/components/tree/index.ts create mode 100644 packages/components/tree/package.json create mode 100644 packages/components/tree/register.ts create mode 100644 packages/components/tree/src/tree.scss create mode 100644 packages/components/tree/src/tree.stories.ts create mode 100644 packages/components/tree/src/tree.ts create mode 100644 packages/components/tree/tsconfig.json diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts new file mode 100644 index 0000000000..738aff0d36 --- /dev/null +++ b/packages/components/tree/index.ts @@ -0,0 +1 @@ +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..6f3a48b2c6 --- /dev/null +++ b/packages/components/tree/package.json @@ -0,0 +1,50 @@ +{ + "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/icon": "0.0.9", + "@sl-design-system/shared": "0.2.9" + }, + "devDependencies": { + "@open-wc/scoped-elements": "^3.0.5" + }, + "peerDependencies": { + "@open-wc/scoped-elements": "^3.0.5" + } +} 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/tree.scss b/packages/components/tree/src/tree.scss new file mode 100644 index 0000000000..79ff2d5269 --- /dev/null +++ b/packages/components/tree/src/tree.scss @@ -0,0 +1,3 @@ +:host { + display: flex; +} diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts new file mode 100644 index 0000000000..4eea801e14 --- /dev/null +++ b/packages/components/tree/src/tree.stories.ts @@ -0,0 +1,25 @@ +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { type TemplateResult, html } from 'lit'; +import '../register.js'; +import { type Tree } from './tree.js'; + +type Props = Pick & { items?(): TemplateResult }; +type Story = StoryObj; + +export default { + title: 'In progress/Tree', + argTypes: { + items: { + table: { disable: true } + }, + selects: { + control: { + type: 'inline-radio', + options: ['single', 'multiple'] + } + } + }, + render: ({ items, selects }) => html`${items?.()}` +} satisfies Meta; + +export const Basic: Story = {}; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts new file mode 100644 index 0000000000..d76152cc89 --- /dev/null +++ b/packages/components/tree/src/tree.ts @@ -0,0 +1,21 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './tree.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree': Tree; + } +} + +export class Tree extends LitElement { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** If you are able to select one or more tree items (at the same time). */ + @property() selects?: 'single' | 'multiple'; + + override render(): TemplateResult { + return html`HOHOHO`; + } +} 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/tsconfig.all.json b/tsconfig.all.json index bffe5d97ee..90713605fd 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -36,6 +36,7 @@ { "path": "./packages/components/text-field"}, { "path": "./packages/components/textarea" }, { "path": "./packages/components/tooltip" }, + { "path": "./packages/components/tree" }, { "path": "./packages/locales" }, { "path": "./packages/themes/bingel" }, { "path": "./packages/themes/bingel-dc" }, diff --git a/yarn.lock b/yarn.lock index 1e553b1186..975f247d07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4877,6 +4877,18 @@ __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/icon": "npm:0.0.9" + "@sl-design-system/shared": "npm:0.2.9" + peerDependencies: + "@open-wc/scoped-elements": ^3.0.5 + languageName: unknown + linkType: soft + "@sl-design-system/website@workspace:website": version: 0.0.0-use.local resolution: "@sl-design-system/website@workspace:website" From 4cfe7bca00cae0801d5fe935640c35d224f711dd Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 8 Apr 2024 09:08:46 +0200 Subject: [PATCH 02/88] =?UTF-8?q?=F0=9F=8F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/index.ts | 1 + packages/components/tree/register.ts | 2 + .../components/tree/src/flat-tree-control.ts | 11 +++++ .../tree/src/nested-tree-control.ts | 13 ++++++ packages/components/tree/src/tree-control.ts | 27 ++++++++++++ packages/components/tree/src/tree-item.scss | 3 ++ packages/components/tree/src/tree-item.ts | 17 +++++++ packages/components/tree/src/tree.stories.ts | 15 ++++--- packages/components/tree/src/tree.ts | 44 ++++++++++++++++++- 9 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 packages/components/tree/src/flat-tree-control.ts create mode 100644 packages/components/tree/src/nested-tree-control.ts create mode 100644 packages/components/tree/src/tree-control.ts create mode 100644 packages/components/tree/src/tree-item.scss create mode 100644 packages/components/tree/src/tree-item.ts diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts index 738aff0d36..ba4fc6f364 100644 --- a/packages/components/tree/index.ts +++ b/packages/components/tree/index.ts @@ -1 +1,2 @@ export * from './src/tree.js'; +export * from './src/tree-item.js'; diff --git a/packages/components/tree/register.ts b/packages/components/tree/register.ts index 91b03b0b73..224e0e43bc 100644 --- a/packages/components/tree/register.ts +++ b/packages/components/tree/register.ts @@ -1,3 +1,5 @@ +import { TreeItem } from './src/tree-item.js'; import { Tree } from './src/tree.js'; customElements.define('sl-tree', Tree); +customElements.define('sl-tree-item', TreeItem); diff --git a/packages/components/tree/src/flat-tree-control.ts b/packages/components/tree/src/flat-tree-control.ts new file mode 100644 index 0000000000..9758cc81b5 --- /dev/null +++ b/packages/components/tree/src/flat-tree-control.ts @@ -0,0 +1,11 @@ +import { TreeControl } from './tree-control.js'; + +export class FlatTreeControl extends TreeControl { + constructor(public override readonly dataNodes: T[]) { + super(); + } + + getDescendants(_dataNode: T): T[] { + return []; + } +} diff --git a/packages/components/tree/src/nested-tree-control.ts b/packages/components/tree/src/nested-tree-control.ts new file mode 100644 index 0000000000..6528af5ab6 --- /dev/null +++ b/packages/components/tree/src/nested-tree-control.ts @@ -0,0 +1,13 @@ +// Copyright 2024 jzwartepoorte +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. diff --git a/packages/components/tree/src/tree-control.ts b/packages/components/tree/src/tree-control.ts new file mode 100644 index 0000000000..b2a25b6562 --- /dev/null +++ b/packages/components/tree/src/tree-control.ts @@ -0,0 +1,27 @@ +export abstract class TreeControl { + dataNodes?: T[]; + + isExpanded(_dataNode: T): boolean { + return false; + } + + isExpandable(_dataNode: T): boolean { + return false; + } + + abstract getDescendants(_dataNode: T): T[]; + getLevel(_dataNode: T): number { + return 0; + } + + toggle(_dataNode: T): void {} + expand(_dataNode: T): void {} + collapse(_dataNode: T): void {} + + expandAll(): void {} + collapseAll(): void {} + + toggleDescendants(_dataNode: T): void {} + expandDescendants(_dataNode: T): void {} + collapseDescendants(_dataNode: T): void {} +} diff --git a/packages/components/tree/src/tree-item.scss b/packages/components/tree/src/tree-item.scss new file mode 100644 index 0000000000..79ff2d5269 --- /dev/null +++ b/packages/components/tree/src/tree-item.scss @@ -0,0 +1,3 @@ +:host { + display: flex; +} diff --git a/packages/components/tree/src/tree-item.ts b/packages/components/tree/src/tree-item.ts new file mode 100644 index 0000000000..06702c535f --- /dev/null +++ b/packages/components/tree/src/tree-item.ts @@ -0,0 +1,17 @@ +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; +import styles from './tree-item.scss.js'; + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree-item': TreeItem; + } +} + +export class TreeItem extends LitElement { + /** @internal */ + static override styles: CSSResultGroup = styles; + + override render(): TemplateResult { + return html``; + } +} diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 4eea801e14..cf5a49d75b 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -1,15 +1,16 @@ +import { ArrayDataSource } from '@sl-design-system/shared'; import { type Meta, type StoryObj } from '@storybook/web-components'; -import { type TemplateResult, html } from 'lit'; +import { html } from 'lit'; import '../register.js'; import { type Tree } from './tree.js'; -type Props = Pick & { items?(): TemplateResult }; +type Props = Pick; type Story = StoryObj; export default { title: 'In progress/Tree', argTypes: { - items: { + dataSource: { table: { disable: true } }, selects: { @@ -19,7 +20,11 @@ export default { } } }, - render: ({ items, selects }) => html`${items?.()}` + render: ({ dataSource, selects }) => html`` } satisfies Meta; -export const Basic: Story = {}; +export const Basic: Story = { + args: { + dataSource: new ArrayDataSource(['Item 1', 'Item 2', 'Item 3']) + } +}; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index d76152cc89..b96a3185a1 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,5 +1,11 @@ +import { LitVirtualizer } from '@lit-labs/virtualizer'; +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Icon } from '@sl-design-system/icon'; +import { type DataSource } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; +import { type TreeControl } from './tree-control.js'; +import { TreeItem } from './tree-item.js'; import styles from './tree.scss.js'; declare global { @@ -8,14 +14,48 @@ declare global { } } -export class Tree extends LitElement { +export interface TreeItemRendererOptions { + level: number; + expanded: boolean; + expandable: boolean; + selected: boolean; +} + +export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class Tree extends ScopedElementsMixin(LitElement) { + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'lit-virtualizer': LitVirtualizer, + 'sl-icon': Icon, + 'sl-tree-item': TreeItem + }; + } + /** @internal */ static override styles: CSSResultGroup = styles; + /** The source for all the tree items. */ + @property({ attribute: false }) dataSource?: DataSource; + + /** Custom renderer function for tree items. */ + @property({ attribute: false }) renderer?: TreeItemRenderer; + /** If you are able to select one or more tree items (at the same time). */ @property() selects?: 'single' | 'multiple'; + /** Control for managing the tree. */ + @property({ attribute: false }) treeControl?: TreeControl; + + override connectedCallback(): void { + super.connectedCallback(); + + this.role = 'tree'; + } + override render(): TemplateResult { - return html`HOHOHO`; + return html``; } } From adb09c965ff8d45c5a98d43183df95377166f36a Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sun, 21 Apr 2024 11:59:06 +0200 Subject: [PATCH 03/88] =?UTF-8?q?=F0=9F=8C=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/package.json | 4 ++-- yarn.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json index 6f3a48b2c6..198148a387 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -38,8 +38,8 @@ "test": "echo \"Error: run tests from monorepo root.\" && exit 1" }, "dependencies": { - "@sl-design-system/icon": "0.0.9", - "@sl-design-system/shared": "0.2.9" + "@sl-design-system/icon": "0.0.10", + "@sl-design-system/shared": "0.2.10" }, "devDependencies": { "@open-wc/scoped-elements": "^3.0.5" diff --git a/yarn.lock b/yarn.lock index 0e0ad222be..fae45a9599 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4891,8 +4891,8 @@ __metadata: resolution: "@sl-design-system/tree@workspace:packages/components/tree" dependencies: "@open-wc/scoped-elements": "npm:^3.0.5" - "@sl-design-system/icon": "npm:0.0.9" - "@sl-design-system/shared": "npm:0.2.9" + "@sl-design-system/icon": "npm:0.0.10" + "@sl-design-system/shared": "npm:0.2.10" peerDependencies: "@open-wc/scoped-elements": ^3.0.5 languageName: unknown From 03ebf8d38d49ae12efbf998ba2385728534a3d52 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sun, 21 Apr 2024 19:20:50 +0200 Subject: [PATCH 04/88] =?UTF-8?q?=F0=9F=8E=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/index.ts | 5 +- packages/components/tree/register.ts | 2 - .../components/tree/src/flat-tree-control.ts | 11 -- .../components/tree/src/flat-tree-model.ts | 40 ++++++ .../tree/src/nested-tree-control.ts | 13 -- .../components/tree/src/nested-tree-model.ts | 24 ++++ packages/components/tree/src/tree-item.scss | 3 - packages/components/tree/src/tree-item.ts | 17 --- .../src/{tree-control.ts => tree-model.ts} | 10 +- packages/components/tree/src/tree-node.scss | 22 +++ packages/components/tree/src/tree-node.ts | 89 ++++++++++++ packages/components/tree/src/tree.stories.ts | 130 ++++++++++++++++-- packages/components/tree/src/tree.ts | 66 ++++++--- packages/components/tree/src/utils.ts | 5 + 14 files changed, 363 insertions(+), 74 deletions(-) delete mode 100644 packages/components/tree/src/flat-tree-control.ts create mode 100644 packages/components/tree/src/flat-tree-model.ts delete mode 100644 packages/components/tree/src/nested-tree-control.ts create mode 100644 packages/components/tree/src/nested-tree-model.ts delete mode 100644 packages/components/tree/src/tree-item.scss delete mode 100644 packages/components/tree/src/tree-item.ts rename packages/components/tree/src/{tree-control.ts => tree-model.ts} (61%) create mode 100644 packages/components/tree/src/tree-node.scss create mode 100644 packages/components/tree/src/tree-node.ts create mode 100644 packages/components/tree/src/utils.ts diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts index ba4fc6f364..938fd9569e 100644 --- a/packages/components/tree/index.ts +++ b/packages/components/tree/index.ts @@ -1,2 +1,5 @@ +export * from './src/flat-tree-model.js'; +export * from './src/nested-tree-model.js'; +export * from './src/tree-model.js'; +export * from './src/tree-node.js'; export * from './src/tree.js'; -export * from './src/tree-item.js'; diff --git a/packages/components/tree/register.ts b/packages/components/tree/register.ts index 224e0e43bc..91b03b0b73 100644 --- a/packages/components/tree/register.ts +++ b/packages/components/tree/register.ts @@ -1,5 +1,3 @@ -import { TreeItem } from './src/tree-item.js'; import { Tree } from './src/tree.js'; customElements.define('sl-tree', Tree); -customElements.define('sl-tree-item', TreeItem); diff --git a/packages/components/tree/src/flat-tree-control.ts b/packages/components/tree/src/flat-tree-control.ts deleted file mode 100644 index 9758cc81b5..0000000000 --- a/packages/components/tree/src/flat-tree-control.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TreeControl } from './tree-control.js'; - -export class FlatTreeControl extends TreeControl { - constructor(public override readonly dataNodes: T[]) { - super(); - } - - getDescendants(_dataNode: T): T[] { - return []; - } -} diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts new file mode 100644 index 0000000000..c3494ea20e --- /dev/null +++ b/packages/components/tree/src/flat-tree-model.ts @@ -0,0 +1,40 @@ +import { TreeModel } from './tree-model.js'; + +export class FlatTreeModel extends TreeModel { + constructor( + public override dataNodes: T[], + public levelKey: keyof T, + public labelKey: keyof T, + public iconKey?: keyof T + ) { + super(); + } + + getDescendants(_dataNode: T): T[] { + return []; + } + + override getIcon(dataNode: T): T[keyof T] | undefined { + return this.iconKey ? dataNode[this.iconKey] : undefined; + } + + override getLabel(dataNode: T): T[keyof T] { + return dataNode[this.labelKey]; + } + + override getLevel(dataNode: T): number { + return dataNode[this.levelKey] as number; + } + + override isExpandable(dataNode: T): boolean { + const index = this.dataNodes.indexOf(dataNode); + + if (index === this.dataNodes.length - 1) { + return false; + } else { + const nextNode = this.dataNodes[index + 1]; + + return this.getLevel(nextNode) > this.getLevel(dataNode); + } + } +} diff --git a/packages/components/tree/src/nested-tree-control.ts b/packages/components/tree/src/nested-tree-control.ts deleted file mode 100644 index 6528af5ab6..0000000000 --- a/packages/components/tree/src/nested-tree-control.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2024 jzwartepoorte -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts new file mode 100644 index 0000000000..364ec4129c --- /dev/null +++ b/packages/components/tree/src/nested-tree-model.ts @@ -0,0 +1,24 @@ +import { TreeModel } from './tree-model.js'; + +export class NestedTreeModel extends TreeModel { + constructor( + public override dataNodes: T[], + public childrenKey: keyof T, + public labelKey: keyof T, + public iconKey?: keyof T + ) { + super(); + } + + override getDescendants(_dataNode: T): T[] { + return _dataNode[this.childrenKey] as T[]; + } + + override getIcon(_dataNode: T): T[keyof T] | undefined { + return this.iconKey ? _dataNode[this.iconKey] : undefined; + } + + override getLabel(_dataNode: T): T[keyof T] { + return _dataNode[this.labelKey]; + } +} diff --git a/packages/components/tree/src/tree-item.scss b/packages/components/tree/src/tree-item.scss deleted file mode 100644 index 79ff2d5269..0000000000 --- a/packages/components/tree/src/tree-item.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: flex; -} diff --git a/packages/components/tree/src/tree-item.ts b/packages/components/tree/src/tree-item.ts deleted file mode 100644 index 06702c535f..0000000000 --- a/packages/components/tree/src/tree-item.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; -import styles from './tree-item.scss.js'; - -declare global { - interface HTMLElementTagNameMap { - 'sl-tree-item': TreeItem; - } -} - -export class TreeItem extends LitElement { - /** @internal */ - static override styles: CSSResultGroup = styles; - - override render(): TemplateResult { - return html``; - } -} diff --git a/packages/components/tree/src/tree-control.ts b/packages/components/tree/src/tree-model.ts similarity index 61% rename from packages/components/tree/src/tree-control.ts rename to packages/components/tree/src/tree-model.ts index b2a25b6562..15876b29a9 100644 --- a/packages/components/tree/src/tree-control.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,15 +1,23 @@ -export abstract class TreeControl { +/** + * Abstract class used to provide a common interface for tree data. + */ +export abstract class TreeModel { dataNodes?: T[]; + /** Returns whether the given node is expanded. */ isExpanded(_dataNode: T): boolean { return false; } + /** Returns whether the given node is expandable. */ isExpandable(_dataNode: T): boolean { return false; } abstract getDescendants(_dataNode: T): T[]; + abstract getIcon(_dataNode: T): T[keyof T] | undefined; + abstract getLabel(_dataNode: T): T[keyof T]; + getLevel(_dataNode: T): number { return 0; } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss new file mode 100644 index 0000000000..2ff7b0e6e4 --- /dev/null +++ b/packages/components/tree/src/tree-node.scss @@ -0,0 +1,22 @@ +:host { + --_expander-indent: calc(var(--_expander-size) * clamp(0, calc(var(--_level)), 1)); + --_expander-size: 1.5rem; + --_gap: 0.25rem; + --_level-indent: 0.25rem; + --_padding-block: 0.25rem; + --_padding-inline: 0.5rem; + + align-items: center; + display: flex; + gap: var(--_gap); + inline-size: 100%; + padding-block: var(--_padding-block); + padding-inline: calc(var(--_expander-indent) + var(--_padding-inline) + var(--_level-indent) * var(--_level, 0)); +} + +[part='wrapper'] { + align-items: center; + display: flex; + flex: 1; + gap: var(--_gap); +} diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts new file mode 100644 index 0000000000..5a12d9e95b --- /dev/null +++ b/packages/components/tree/src/tree-node.ts @@ -0,0 +1,89 @@ +import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { Button } from '@sl-design-system/button'; +import { Checkbox } from '@sl-design-system/checkbox'; +import { Icon } from '@sl-design-system/icon'; +import { type EventEmitter, event } from '@sl-design-system/shared'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import styles from './tree-node.scss.js'; + +declare global { + interface GlobalEventHandlersEventMap { + 'sl-expand': SlExpandEvent; + } + + interface HTMLElementTagNameMap { + 'sl-tree-item': TreeNode; + } +} + +export type SlExpandEvent = CustomEvent; + +export class TreeNode extends ScopedElementsMixin(LitElement) { + /** @internal */ + static override styles: CSSResultGroup = styles; + + /** @internal */ + static get scopedElements(): ScopedElementsMap { + return { + 'sl-button': Button, + 'sl-checkbox': Checkbox, + 'sl-icon': Icon + }; + } + + /** Indicates whether the node is expanded or collapsed. */ + @property({ type: Boolean, reflect: true }) expanded?: boolean; + + /** @internal Emits when the expanded state changes. */ + @event({ name: 'sl-expand' }) expandEvent!: EventEmitter; + + /** If true, will render an indicator whether the node is expanded or collapsed. */ + @property({ type: Boolean }) expandable?: boolean; + + /** The depth level of this node, 0 being the root of the tree. */ + @property({ type: Number }) level = 0; + + /** Will render a checkbox if true. */ + @property({ type: Boolean }) selectable?: boolean; + + /** Determines whether the checkbox is checked or not. */ + @property({ type: Boolean }) checked?: boolean; + + /** Indeterminate state of the checkbox. Used when not all children are checked. */ + @property({ type: Boolean }) indeterminate?: boolean; + + override render(): TemplateResult { + return html` + ${this.expandable + ? html` + + + + ` + : nothing} +
+ ${this.selectable + ? html` + + + + ` + : html``} +
+ `; + } + + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('level')) { + this.style.setProperty('--_level', this.level?.toString()); + } + } + + #onToggle(): void { + this.expanded = !this.expanded; + this.expandEvent.emit(this.expanded); + } +} diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index cf5a49d75b..36be052c4c 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -1,30 +1,140 @@ -import { ArrayDataSource } from '@sl-design-system/shared'; +import { faFile, faFolder } from '@fortawesome/pro-regular-svg-icons'; +import { Icon } from '@sl-design-system/icon'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import '../register.js'; +import { FlatTreeModel } from './flat-tree-model.js'; +import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick; +type Props = Pick; type Story = StoryObj; +Icon.register(faFile, faFolder); + +const flatData = [ + { + level: 0, + name: 'tree', + icon: 'far-folder' + }, + { + level: 1, + name: 'src', + icon: 'far-folder' + }, + { + level: 2, + name: 'flat-tree-model.ts', + icon: 'far-file' + }, + { + level: 2, + name: 'nested-tree-model.ts', + icon: 'far-file' + }, + { + level: 2, + name: 'tree-model.ts', + icon: 'far-file' + }, + { + level: 2, + name: 'tree-node.scss', + icon: 'far-file' + }, + { + level: 2, + name: 'tree-node.ts', + icon: 'far-file' + }, + { + level: 2, + name: 'tree.ts', + icon: 'far-file' + }, + { + level: 2, + name: 'utils.ts', + icon: 'far-file' + }, + { + level: 1, + name: 'index.ts', + icon: 'far-file' + }, + { + level: 1, + name: 'package.json', + icon: 'far-file' + }, + { + level: 1, + name: 'register.ts', + icon: 'far-file' + } +]; + +const nestedData = [ + { + name: 'tree', + children: [ + { + name: 'src', + children: [ + { name: 'flat-tree-model.ts' }, + { name: 'nested-tree-model.ts' }, + { name: 'tree-model.ts' }, + { name: 'tree-node.scss' }, + { name: 'tree-node.ts' }, + { name: 'tree.ts' }, + { name: 'utils.ts' } + ] + }, + { name: 'index.ts' }, + { name: 'package.json' }, + { name: 'register.ts' } + ] + } +]; + export default { title: 'In progress/Tree', + args: {}, argTypes: { - dataSource: { + model: { table: { disable: true } }, selects: { - control: { - type: 'inline-radio', - options: ['single', 'multiple'] - } + control: 'inline-radio', + options: ['single', 'multiple'] } }, - render: ({ dataSource, selects }) => html`` + render: ({ model, selects }) => html`` } satisfies Meta; -export const Basic: Story = { +export const Flat: Story = { + args: { + model: new FlatTreeModel(flatData, 'level', 'name', 'icon') + } +}; + +export const Nested: Story = { + args: { + model: new NestedTreeModel(nestedData, 'children', 'name') + } +}; + +export const SingleSelect: Story = { + args: { + ...Flat.args, + selects: 'single' + } +}; + +export const MultiSelect: Story = { args: { - dataSource: new ArrayDataSource(['Item 1', 'Item 2', 'Item 3']) + ...Flat.args, + selects: 'multiple' } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index b96a3185a1..7267aa5599 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,12 +1,12 @@ -import { LitVirtualizer } from '@lit-labs/virtualizer'; +import { virtualize } 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 DataSource } from '@sl-design-system/shared'; -import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { type TreeControl } from './tree-control.js'; -import { TreeItem } from './tree-item.js'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { TreeModel } from './tree-model.js'; +import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; +import { modelToList } from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -18,27 +18,30 @@ export interface TreeItemRendererOptions { level: number; expanded: boolean; expandable: boolean; - selected: boolean; + selected?: boolean; } -export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static get scopedElements(): ScopedElementsMap { return { - 'lit-virtualizer': LitVirtualizer, 'sl-icon': Icon, - 'sl-tree-item': TreeItem + 'sl-tree-node': TreeNode }; } /** @internal */ static override styles: CSSResultGroup = styles; - /** The source for all the tree items. */ - @property({ attribute: false }) dataSource?: DataSource; + /** The tree model flattened to an array. */ + @state() nodes?: T[]; + + /** The model for the tree. */ + @property({ attribute: false }) model?: TreeModel; /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; @@ -46,16 +49,47 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** If you are able to select one or more tree items (at the same time). */ @property() selects?: 'single' | 'multiple'; - /** Control for managing the tree. */ - @property({ attribute: false }) treeControl?: TreeControl; - override connectedCallback(): void { super.connectedCallback(); this.role = 'tree'; } + override willUpdate(changes: PropertyValues): void { + super.willUpdate(changes); + + if (changes.has('model')) { + this.nodes = modelToList(this.model); + } + } + override render(): TemplateResult { - return html``; + return html` +
+ ${virtualize({ + items: this.nodes, + renderItem: (item: T) => this.renderItem(item) + })} +
+ `; + } + + renderItem(item: T): TemplateResult { + const model = this.model!, + expandable = model.isExpandable(item), + expanded = expandable && model.isExpanded(item), + icon = model.getIcon(item), + level = model.getLevel(item); + + return html` + + ${this.renderer + ? this.renderer(item, { level, expanded, expandable }) + : html` + ${icon ? html`` : nothing} + ${model.getLabel(item)} + `} + + `; } } diff --git a/packages/components/tree/src/utils.ts b/packages/components/tree/src/utils.ts new file mode 100644 index 0000000000..9fe47d6e59 --- /dev/null +++ b/packages/components/tree/src/utils.ts @@ -0,0 +1,5 @@ +import { type TreeModel } from './tree-model.js'; + +export function modelToList(model?: TreeModel): T[] { + return model?.dataNodes || []; +} From 326a6e48ad3b9ea0baa7bda8599c2fc3303eeb7f Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sun, 21 Apr 2024 19:26:34 +0200 Subject: [PATCH 05/88] =?UTF-8?q?=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 5a12d9e95b..8e191d34b0 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -53,6 +53,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Indeterminate state of the checkbox. Used when not all children are checked. */ @property({ type: Boolean }) indeterminate?: boolean; + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('role', 'treeitem'); + } + override render(): TemplateResult { return html` ${this.expandable From 9981a70a651b4212ffa68b848e4cda6983b3657d Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 22 Apr 2024 09:42:51 +0200 Subject: [PATCH 06/88] =?UTF-8?q?=F0=9F=91=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 7267aa5599..60e82e8974 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,6 +1,7 @@ import { virtualize } 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 { SelectionController } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { TreeModel } from './tree-model.js'; @@ -46,6 +47,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; + /** Selection manager. */ + readonly selection = new SelectionController(this); + /** If you are able to select one or more tree items (at the same time). */ @property() selects?: 'single' | 'multiple'; From 7e54c2aeaa3c9e83e5b0e0f79ff693f3ec45372f Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 25 Apr 2024 08:05:06 +0200 Subject: [PATCH 07/88] =?UTF-8?q?=F0=9F=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/package.json | 2 ++ .../components/tree/src/flat-tree-model.ts | 23 ++++++++++++++ .../components/tree/src/nested-tree-model.ts | 5 +++ packages/components/tree/src/tree-model.ts | 12 +++---- packages/components/tree/src/tree-node.scss | 9 ++++++ packages/components/tree/src/tree-node.ts | 15 ++++----- packages/components/tree/src/tree.ts | 31 +++++++++++++------ packages/components/tree/src/utils.ts | 5 --- yarn.lock | 2 ++ 9 files changed, 74 insertions(+), 30 deletions(-) delete mode 100644 packages/components/tree/src/utils.ts diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json index 198148a387..229ead23ae 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -38,6 +38,8 @@ "test": "echo \"Error: run tests from monorepo root.\" && exit 1" }, "dependencies": { + "@sl-design-system/button": "0.0.27", + "@sl-design-system/checkbox": "0.0.28", "@sl-design-system/icon": "0.0.10", "@sl-design-system/shared": "0.2.10" }, diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index c3494ea20e..1ec62304f9 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,3 +1,4 @@ +import { SelectionController } from '@sl-design-system/shared'; import { TreeModel } from './tree-model.js'; export class FlatTreeModel extends TreeModel { @@ -37,4 +38,26 @@ export class FlatTreeModel extends TreeModel { return this.getLevel(nextNode) > this.getLevel(dataNode); } } + + override toArray(expansion: SelectionController): T[] { + let currentLevel = 0; + + return this.dataNodes.reduce((nodes: T[], node) => { + const level = this.getLevel(node); + + if (level === currentLevel) { + if (expansion.isSelected(node)) { + currentLevel++; + } + + return [...nodes, node]; + } else { + if (level < currentLevel) { + currentLevel = level; + } + + return nodes; + } + }, []); + } } diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 364ec4129c..0dcf0f2fec 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,3 +1,4 @@ +import { type SelectionController } from '@sl-design-system/shared'; import { TreeModel } from './tree-model.js'; export class NestedTreeModel extends TreeModel { @@ -21,4 +22,8 @@ export class NestedTreeModel extends TreeModel { override getLabel(_dataNode: T): T[keyof T] { return _dataNode[this.labelKey]; } + + override toArray(_expansion: SelectionController): T[] { + return this.dataNodes; + } } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 15876b29a9..61ba20fb30 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,13 +1,10 @@ +import { type SelectionController } from '@sl-design-system/shared'; + /** * Abstract class used to provide a common interface for tree data. */ export abstract class TreeModel { - dataNodes?: T[]; - - /** Returns whether the given node is expanded. */ - isExpanded(_dataNode: T): boolean { - return false; - } + dataNodes: T[] = []; /** Returns whether the given node is expandable. */ isExpandable(_dataNode: T): boolean { @@ -32,4 +29,7 @@ export abstract class TreeModel { toggleDescendants(_dataNode: T): void {} expandDescendants(_dataNode: T): void {} collapseDescendants(_dataNode: T): void {} + + /** Flattens the tree to an array based on the expansion state. */ + abstract toArray(expansion: SelectionController): T[]; } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 2ff7b0e6e4..4617e26918 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -14,9 +14,18 @@ padding-inline: calc(var(--_expander-indent) + var(--_padding-inline) + var(--_level-indent) * var(--_level, 0)); } +:host([expanded]) .toggle sl-icon { + rotate: 90deg; +} + [part='wrapper'] { align-items: center; display: flex; flex: 1; gap: var(--_gap); } + +.toggle sl-icon { + rotate: 0deg; + transition: rotate 100ms ease-in-out; +} diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 8e191d34b0..5475e5613b 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -3,15 +3,12 @@ import { Button } from '@sl-design-system/button'; import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, event } from '@sl-design-system/shared'; +import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import styles from './tree-node.scss.js'; declare global { - interface GlobalEventHandlersEventMap { - 'sl-expand': SlExpandEvent; - } - interface HTMLElementTagNameMap { 'sl-tree-item': TreeNode; } @@ -35,9 +32,6 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Indicates whether the node is expanded or collapsed. */ @property({ type: Boolean, reflect: true }) expanded?: boolean; - /** @internal Emits when the expanded state changes. */ - @event({ name: 'sl-expand' }) expandEvent!: EventEmitter; - /** If true, will render an indicator whether the node is expanded or collapsed. */ @property({ type: Boolean }) expandable?: boolean; @@ -53,6 +47,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Indeterminate state of the checkbox. Used when not all children are checked. */ @property({ type: Boolean }) indeterminate?: boolean; + /** @internal Emits when the expanded state changes. */ + @event({ name: 'sl-toggle' }) toggleEvent!: EventEmitter>; + override connectedCallback(): void { super.connectedCallback(); @@ -63,7 +60,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { return html` ${this.expandable ? html` - + ` @@ -90,6 +87,6 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { #onToggle(): void { this.expanded = !this.expanded; - this.expandEvent.emit(this.expanded); + this.toggleEvent.emit(this.expanded); } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 60e82e8974..275233d538 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -3,11 +3,10 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele import { Icon } from '@sl-design-system/icon'; import { SelectionController } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { TreeModel } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; -import { modelToList } from './utils.js'; declare global { interface HTMLElementTagNameMap { @@ -38,8 +37,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; - /** The tree model flattened to an array. */ - @state() nodes?: T[]; + /** Contains the expanded state for the tree. */ + readonly expansion = new SelectionController(this, { multiple: true }); /** The model for the tree. */ @property({ attribute: false }) model?: TreeModel; @@ -47,7 +46,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; - /** Selection manager. */ + /** Contains the selection state for the tree when `selects` is defined. */ readonly selection = new SelectionController(this); /** If you are able to select one or more tree items (at the same time). */ @@ -62,16 +61,18 @@ export class Tree extends ScopedElementsMixin(LitElement) { override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); - if (changes.has('model')) { - this.nodes = modelToList(this.model); + if (changes.has('selects')) { + this.selection.multiple = this.selects === 'multiple'; } } override render(): TemplateResult { + const items = this.model?.toArray(this.expansion) ?? []; + return html`
${virtualize({ - items: this.nodes, + items, renderItem: (item: T) => this.renderItem(item) })}
@@ -81,12 +82,18 @@ export class Tree extends ScopedElementsMixin(LitElement) { renderItem(item: T): TemplateResult { const model = this.model!, expandable = model.isExpandable(item), - expanded = expandable && model.isExpanded(item), + expanded = expandable && this.expansion.isSelected(item), icon = model.getIcon(item), level = model.getLevel(item); return html` - + this.#onToggle(item)} + ?expanded=${expanded} + ?expandable=${expandable} + ?selectable=${!!this.selects} + .level=${level} + > ${this.renderer ? this.renderer(item, { level, expanded, expandable }) : html` @@ -96,4 +103,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { `; } + + #onToggle(item: T): void { + this.expansion.toggle(item); + } } diff --git a/packages/components/tree/src/utils.ts b/packages/components/tree/src/utils.ts deleted file mode 100644 index 9fe47d6e59..0000000000 --- a/packages/components/tree/src/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type TreeModel } from './tree-model.js'; - -export function modelToList(model?: TreeModel): T[] { - return model?.dataNodes || []; -} diff --git a/yarn.lock b/yarn.lock index fae45a9599..537b9bd5d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4891,6 +4891,8 @@ __metadata: resolution: "@sl-design-system/tree@workspace:packages/components/tree" dependencies: "@open-wc/scoped-elements": "npm:^3.0.5" + "@sl-design-system/button": "npm:0.0.27" + "@sl-design-system/checkbox": "npm:0.0.28" "@sl-design-system/icon": "npm:0.0.10" "@sl-design-system/shared": "npm:0.2.10" peerDependencies: From 961cb167e0731246bb0fb358e2d4907ddb1bb3e1 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 25 Apr 2024 13:52:40 +0200 Subject: [PATCH 08/88] =?UTF-8?q?=F0=9F=8E=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 22 +++------ packages/components/tree/src/tree-model.ts | 6 +-- packages/components/tree/src/tree-node.scss | 4 +- packages/components/tree/src/tree-node.ts | 2 +- packages/components/tree/src/tree.stories.ts | 47 ++++++++----------- packages/components/tree/src/tree.ts | 2 +- 6 files changed, 34 insertions(+), 49 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 1ec62304f9..c099660e12 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -4,29 +4,21 @@ import { TreeModel } from './tree-model.js'; export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public levelKey: keyof T, - public labelKey: keyof T, - public iconKey?: keyof T + public override getLevel: TreeModel['getLevel'], + public override getLabel: TreeModel['getLabel'], + getIcon?: TreeModel['getIcon'] ) { super(); + + if (getIcon) { + this.getIcon = getIcon; + } } getDescendants(_dataNode: T): T[] { return []; } - override getIcon(dataNode: T): T[keyof T] | undefined { - return this.iconKey ? dataNode[this.iconKey] : undefined; - } - - override getLabel(dataNode: T): T[keyof T] { - return dataNode[this.labelKey]; - } - - override getLevel(dataNode: T): number { - return dataNode[this.levelKey] as number; - } - override isExpandable(dataNode: T): boolean { const index = this.dataNodes.indexOf(dataNode); diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 61ba20fb30..3d9c0df919 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -12,11 +12,11 @@ export abstract class TreeModel { } abstract getDescendants(_dataNode: T): T[]; - abstract getIcon(_dataNode: T): T[keyof T] | undefined; abstract getLabel(_dataNode: T): T[keyof T]; + abstract getLevel(_dataNode: T): number; - getLevel(_dataNode: T): number { - return 0; + getIcon(_dataNode: T, _expanded?: boolean): T[keyof T] | undefined { + return undefined; } toggle(_dataNode: T): void {} diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 4617e26918..22cdc7c83c 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -14,7 +14,7 @@ padding-inline: calc(var(--_expander-indent) + var(--_padding-inline) + var(--_level-indent) * var(--_level, 0)); } -:host([expanded]) .toggle sl-icon { +:host([expanded]) sl-icon { rotate: 90deg; } @@ -25,7 +25,7 @@ gap: var(--_gap); } -.toggle sl-icon { +sl-icon { rotate: 0deg; transition: rotate 100ms ease-in-out; } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 5475e5613b..f0b4ce6521 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -60,7 +60,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { return html` ${this.expandable ? html` - + ` diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 36be052c4c..acc64d4980 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -1,4 +1,4 @@ -import { faFile, faFolder } from '@fortawesome/pro-regular-svg-icons'; +import { faFile, faFolder, faFolderOpen } from '@fortawesome/pro-regular-svg-icons'; import { Icon } from '@sl-design-system/icon'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; @@ -10,68 +10,56 @@ import { type Tree } from './tree.js'; type Props = Pick; type Story = StoryObj; -Icon.register(faFile, faFolder); +Icon.register(faFile, faFolder, faFolderOpen); const flatData = [ { level: 0, - name: 'tree', - icon: 'far-folder' + name: 'tree' }, { level: 1, - name: 'src', - icon: 'far-folder' + name: 'src' }, { level: 2, - name: 'flat-tree-model.ts', - icon: 'far-file' + name: 'flat-tree-model.ts' }, { level: 2, - name: 'nested-tree-model.ts', - icon: 'far-file' + name: 'nested-tree-model.ts' }, { level: 2, - name: 'tree-model.ts', - icon: 'far-file' + name: 'tree-model.ts' }, { level: 2, - name: 'tree-node.scss', - icon: 'far-file' + name: 'tree-node.scss' }, { level: 2, - name: 'tree-node.ts', - icon: 'far-file' + name: 'tree-node.ts' }, { level: 2, - name: 'tree.ts', - icon: 'far-file' + name: 'tree.ts' }, { level: 2, - name: 'utils.ts', - icon: 'far-file' + name: 'utils.ts' }, { level: 1, - name: 'index.ts', - icon: 'far-file' + name: 'index.ts' }, { level: 1, - name: 'package.json', - icon: 'far-file' + name: 'package.json' }, { level: 1, - name: 'register.ts', - icon: 'far-file' + name: 'register.ts' } ]; @@ -115,7 +103,12 @@ export default { export const Flat: Story = { args: { - model: new FlatTreeModel(flatData, 'level', 'name', 'icon') + model: new FlatTreeModel( + flatData, + ({ level }) => level, + ({ name }) => name, + ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`) + ) } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 275233d538..0d22284155 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -83,7 +83,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { const model = this.model!, expandable = model.isExpandable(item), expanded = expandable && this.expansion.isSelected(item), - icon = model.getIcon(item), + icon = model.getIcon(item, expanded), level = model.getLevel(item); return html` From 3bf10affe7b90bfde18a6ee503ff59e93758df97 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 29 Apr 2024 08:49:25 +0200 Subject: [PATCH 09/88] =?UTF-8?q?=F0=9F=8C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 6 +----- .../components/tree/src/nested-tree-model.ts | 20 ++++++------------- packages/components/tree/src/tree-model.ts | 2 -- packages/components/tree/src/tree.stories.ts | 13 ++++++++++-- packages/components/tree/src/tree.ts | 7 ++----- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index c099660e12..b9f3bfe08c 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -4,7 +4,7 @@ import { TreeModel } from './tree-model.js'; export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public override getLevel: TreeModel['getLevel'], + public getLevel: (dataNode: T) => number, public override getLabel: TreeModel['getLabel'], getIcon?: TreeModel['getIcon'] ) { @@ -15,10 +15,6 @@ export class FlatTreeModel extends TreeModel { } } - getDescendants(_dataNode: T): T[] { - return []; - } - override isExpandable(dataNode: T): boolean { const index = this.dataNodes.indexOf(dataNode); diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 0dcf0f2fec..6d92f0f151 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -4,23 +4,15 @@ import { TreeModel } from './tree-model.js'; export class NestedTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public childrenKey: keyof T, - public labelKey: keyof T, - public iconKey?: keyof T + public getChildren: (dataNode: T) => T[] | undefined, + public override getLabel: TreeModel['getLabel'], + getIcon?: TreeModel['getIcon'] ) { super(); - } - - override getDescendants(_dataNode: T): T[] { - return _dataNode[this.childrenKey] as T[]; - } - - override getIcon(_dataNode: T): T[keyof T] | undefined { - return this.iconKey ? _dataNode[this.iconKey] : undefined; - } - override getLabel(_dataNode: T): T[keyof T] { - return _dataNode[this.labelKey]; + if (getIcon) { + this.getIcon = getIcon; + } } override toArray(_expansion: SelectionController): T[] { diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 3d9c0df919..9f4997e796 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -11,9 +11,7 @@ export abstract class TreeModel { return false; } - abstract getDescendants(_dataNode: T): T[]; abstract getLabel(_dataNode: T): T[keyof T]; - abstract getLevel(_dataNode: T): number; getIcon(_dataNode: T, _expanded?: boolean): T[keyof T] | undefined { return undefined; diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index acc64d4980..d269c20457 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -10,6 +10,11 @@ import { type Tree } from './tree.js'; type Props = Pick; type Story = StoryObj; +interface NestedDataNode { + name: string; + children?: NestedDataNode[]; +} + Icon.register(faFile, faFolder, faFolderOpen); const flatData = [ @@ -63,7 +68,7 @@ const flatData = [ } ]; -const nestedData = [ +const nestedData: NestedDataNode[] = [ { name: 'tree', children: [ @@ -114,7 +119,11 @@ export const Flat: Story = { export const Nested: Story = { args: { - model: new NestedTreeModel(nestedData, 'children', 'name') + model: new NestedTreeModel( + nestedData, + dataNode => dataNode.children, + dataNode => dataNode.name + ) } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 0d22284155..a2ea0e582b 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -15,7 +15,6 @@ declare global { } export interface TreeItemRendererOptions { - level: number; expanded: boolean; expandable: boolean; selected?: boolean; @@ -83,8 +82,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { const model = this.model!, expandable = model.isExpandable(item), expanded = expandable && this.expansion.isSelected(item), - icon = model.getIcon(item, expanded), - level = model.getLevel(item); + icon = model.getIcon(item, expanded); return html` extends ScopedElementsMixin(LitElement) { ?expanded=${expanded} ?expandable=${expandable} ?selectable=${!!this.selects} - .level=${level} > ${this.renderer - ? this.renderer(item, { level, expanded, expandable }) + ? this.renderer(item, { expanded, expandable }) : html` ${icon ? html`` : nothing} ${model.getLabel(item)} From ef2801fd019acb86a4cd9dfd5b5265e20f932731 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 30 Apr 2024 15:08:41 +0200 Subject: [PATCH 10/88] =?UTF-8?q?=F0=9F=8E=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 39 +++++---------- .../components/tree/src/nested-tree-model.ts | 47 +++++++++++++++---- packages/components/tree/src/tree-model.ts | 36 +++++++++++--- packages/components/tree/src/tree.stories.ts | 22 +++++++-- packages/components/tree/src/tree.ts | 19 ++++---- 5 files changed, 109 insertions(+), 54 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index b9f3bfe08c..60b5ca57eb 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,50 +1,37 @@ import { SelectionController } from '@sl-design-system/shared'; -import { TreeModel } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], + public getLabel: TreeModel['getLabel'], public getLevel: (dataNode: T) => number, - public override getLabel: TreeModel['getLabel'], - getIcon?: TreeModel['getIcon'] + public isExpandable: TreeModel['isExpandable'], + options: Partial> = {} ) { - super(); - - if (getIcon) { - this.getIcon = getIcon; - } - } - - override isExpandable(dataNode: T): boolean { - const index = this.dataNodes.indexOf(dataNode); - - if (index === this.dataNodes.length - 1) { - return false; - } else { - const nextNode = this.dataNodes[index + 1]; - - return this.getLevel(nextNode) > this.getLevel(dataNode); - } + super(options); } - override toArray(expansion: SelectionController): T[] { + override toArray(expansion: SelectionController): Array> { let currentLevel = 0; - return this.dataNodes.reduce((nodes: T[], node) => { - const level = this.getLevel(node); + return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { + const expanded = expansion.isSelected(dataNode), + expandable = this.isExpandable(dataNode), + level = this.getLevel(dataNode); if (level === currentLevel) { - if (expansion.isSelected(node)) { + if (expanded) { currentLevel++; } - return [...nodes, node]; + return [...dataNodes, { dataNode, expandable, expanded, level }]; } else { if (level < currentLevel) { currentLevel = level; } - return nodes; + return dataNodes; } }, []); } diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 6d92f0f151..c27cb14508 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,21 +1,50 @@ import { type SelectionController } from '@sl-design-system/shared'; -import { TreeModel } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; export class NestedTreeModel extends TreeModel { constructor( public override dataNodes: T[], public getChildren: (dataNode: T) => T[] | undefined, - public override getLabel: TreeModel['getLabel'], - getIcon?: TreeModel['getIcon'] + public getLabel: TreeModel['getLabel'], + public isExpandable: TreeModel['isExpandable'], + options: Partial> = {} ) { - super(); + super(options); + } - if (getIcon) { - this.getIcon = getIcon; - } + override toArray(expansion: SelectionController): Array> { + return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { + const expandable = this.isExpandable(dataNode), + expanded = expansion.isSelected(dataNode); + + dataNodes.push({ dataNode, expandable, expanded, level: 0 }); + + if (expandable && expanded) { + dataNodes.push(...this.nestedToArray(expansion, dataNode, 1)); + } + + return dataNodes; + }, []); } - override toArray(_expansion: SelectionController): T[] { - return this.dataNodes; + nestedToArray(expansion: SelectionController, dataNode: T, level: number): Array> { + const children = this.getChildren(dataNode); + + if (!Array.isArray(children)) { + return []; + } + + return children.reduce((dataNodes: Array>, childNode) => { + const expanded = expansion.isSelected(dataNode), + expandable = this.isExpandable(dataNode); + + dataNodes.push({ dataNode: childNode, expandable, expanded, level }); + + if (expandable && expanded) { + dataNodes.push(...this.nestedToArray(expansion, childNode, level + 1)); + } + + return dataNodes; + }, []); } } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 9f4997e796..2cf1e77ef3 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,19 +1,43 @@ import { type SelectionController } from '@sl-design-system/shared'; +export interface TreeModelArrayItem { + dataNode: T; + expanded: boolean; + expandable: boolean; + level: number; +} + +export interface TreeModelOptions { + getIcon: TreeModel['getIcon']; + trackBy: string; +} + /** * Abstract class used to provide a common interface for tree data. */ export abstract class TreeModel { + /** The nodes of the tree. */ dataNodes: T[] = []; - /** Returns whether the given node is expandable. */ - isExpandable(_dataNode: T): boolean { - return false; + /** Used during rendering to determine if a tree node needs to be rerendered. */ + trackBy?: string; + + constructor(options: Partial> = {}) { + if (options.getIcon) { + this.getIcon = options.getIcon; + } + + this.trackBy = options.trackBy; } - abstract getLabel(_dataNode: T): T[keyof T]; + /** Returns whether the given node is expandable. */ + abstract isExpandable(dataNode: T): boolean; + + /** Returns a string that is used as the label for the treenode. */ + abstract getLabel(dataNode: T): string; - getIcon(_dataNode: T, _expanded?: boolean): T[keyof T] | undefined { + /** Optional method for returning a custom icon for a treenode. */ + getIcon(_dataNode: T, _expanded?: boolean): string | undefined { return undefined; } @@ -29,5 +53,5 @@ export abstract class TreeModel { collapseDescendants(_dataNode: T): void {} /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(expansion: SelectionController): T[]; + abstract toArray(expansion: SelectionController): Array>; } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index d269c20457..c49f423716 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -19,50 +19,62 @@ Icon.register(faFile, faFolder, faFolderOpen); const flatData = [ { + expandable: true, level: 0, name: 'tree' }, { + expandable: true, level: 1, name: 'src' }, { + expandable: false, level: 2, name: 'flat-tree-model.ts' }, { + expandable: false, level: 2, name: 'nested-tree-model.ts' }, { + expandable: false, level: 2, name: 'tree-model.ts' }, { + expandable: false, level: 2, name: 'tree-node.scss' }, { + expandable: false, level: 2, name: 'tree-node.ts' }, { + expandable: false, level: 2, name: 'tree.ts' }, { + expandable: false, level: 2, name: 'utils.ts' }, { + expandable: false, level: 1, name: 'index.ts' }, { + expandable: false, level: 1, name: 'package.json' }, { + expandable: false, level: 1, name: 'register.ts' } @@ -110,9 +122,12 @@ export const Flat: Story = { args: { model: new FlatTreeModel( flatData, - ({ level }) => level, ({ name }) => name, - ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`) + ({ level }) => level, + ({ expandable }) => expandable, + { + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`) + } ) } }; @@ -122,7 +137,8 @@ export const Nested: Story = { model: new NestedTreeModel( nestedData, dataNode => dataNode.children, - dataNode => dataNode.name + dataNode => dataNode.name, + dataNode => !!dataNode.children ) } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index a2ea0e582b..f486709c02 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -4,7 +4,7 @@ import { Icon } from '@sl-design-system/icon'; import { SelectionController } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; -import { TreeModel } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -72,30 +72,29 @@ export class Tree extends ScopedElementsMixin(LitElement) {
${virtualize({ items, - renderItem: (item: T) => this.renderItem(item) + renderItem: (item: TreeModelArrayItem) => this.renderItem(item) })}
`; } - renderItem(item: T): TemplateResult { - const model = this.model!, - expandable = model.isExpandable(item), - expanded = expandable && this.expansion.isSelected(item), - icon = model.getIcon(item, expanded); + renderItem(item: TreeModelArrayItem): TemplateResult { + const { dataNode, expandable, expanded, level } = item, + icon = this.model!.getIcon(dataNode, expanded); return html` this.#onToggle(item)} + @sl-toggle=${() => this.#onToggle(dataNode)} ?expanded=${expanded} ?expandable=${expandable} ?selectable=${!!this.selects} + .level=${level} > ${this.renderer - ? this.renderer(item, { expanded, expandable }) + ? this.renderer(dataNode, { expanded, expandable }) : html` ${icon ? html`` : nothing} - ${model.getLabel(item)} + ${this.model!.getLabel(dataNode)} `} `; From cc070d5ad56d18eb94d36c92fe2f53f87ec365e0 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 May 2024 09:01:31 +0200 Subject: [PATCH 11/88] =?UTF-8?q?=F0=9F=90=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index f486709c02..9df4a32314 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -85,8 +85,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { return html` this.#onToggle(dataNode)} - ?expanded=${expanded} ?expandable=${expandable} + ?expanded=${expanded} ?selectable=${!!this.selects} .level=${level} > From 4864770d1f59cd6febed135690f2608e790de812 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 May 2024 19:31:02 +0200 Subject: [PATCH 12/88] =?UTF-8?q?=F0=9F=90=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-model.ts | 4 +- packages/components/tree/src/tree-node.scss | 42 ++++++++++++++++++-- packages/components/tree/src/tree-node.ts | 24 +++++++++-- packages/components/tree/src/tree.stories.ts | 39 +++++++++++++++++- packages/components/tree/src/tree.ts | 12 +++++- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 2cf1e77ef3..d141ec7e84 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -9,7 +9,7 @@ export interface TreeModelArrayItem { export interface TreeModelOptions { getIcon: TreeModel['getIcon']; - trackBy: string; + trackBy(dataNode: T, index: number): unknown; } /** @@ -20,7 +20,7 @@ export abstract class TreeModel { dataNodes: T[] = []; /** Used during rendering to determine if a tree node needs to be rerendered. */ - trackBy?: string; + trackBy?(dataNode: T, index: number): unknown; constructor(options: Partial> = {}) { if (options.getIcon) { diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 22cdc7c83c..2bde7e3e4a 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -1,23 +1,51 @@ :host { - --_expander-indent: calc(var(--_expander-size) * clamp(0, calc(var(--_level)), 1)); - --_expander-size: 1.5rem; + --_background: #fff; + --_background-hover: #f7f7f7; + --_expander-indent: 0px; + --_expander-size: 1.75rem; + --_focus-outline: var(--sl-color-focusring-default) solid var(--sl-border-width-focusring-default); + --_focus-outline-offset: var(--sl-border-width-focusring-offset); + --_focus-radius: var(--sl-border-radius-focusring-default); --_gap: 0.25rem; --_level-indent: 0.25rem; --_padding-block: 0.25rem; --_padding-inline: 0.5rem; align-items: center; + background: var(--_background); + cursor: pointer; display: flex; gap: var(--_gap); inline-size: 100%; padding-block: var(--_padding-block); - padding-inline: calc(var(--_expander-indent) + var(--_padding-inline) + var(--_level-indent) * var(--_level, 0)); + padding-inline: calc(var(--_padding-inline) + var(--_expander-indent) + var(--_level-indent) * var(--_level, 0)) + var(--_padding-inline); +} + +:host(:not([expandable])) { + --_expander-indent: var(--_expander-size); } :host([expanded]) sl-icon { rotate: 90deg; } +:host(:focus-visible) { + border-radius: var(--_focus-radius); + outline: var(--_focus-outline); + outline-offset: calc(var(--_focus-outline-offset) * -1); + z-index: 1; +} + +:host(:hover) { + background: var(--_background-hover); +} + +:host([disabled]) { + cursor: default; + pointer-events: none; +} + [part='wrapper'] { align-items: center; display: flex; @@ -25,6 +53,14 @@ gap: var(--_gap); } +::slotted(*) { + flex: 1; +} + +::slotted(sl-icon) { + flex: 0 1; +} + sl-icon { rotate: 0deg; transition: rotate 100ms ease-in-out; diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index f0b4ce6521..ee18410c51 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -2,7 +2,7 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele import { Button } from '@sl-design-system/button'; import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; -import { type EventEmitter, event } from '@sl-design-system/shared'; +import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; @@ -29,6 +29,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { }; } + /** Event controller. */ + #events = new EventsController(this, { keydown: this.#onKeydown }); + + /** Whether the node is disabled. */ + @property({ type: Boolean, reflect: true }) disabled?: boolean; + /** Indicates whether the node is expanded or collapsed. */ @property({ type: Boolean, reflect: true }) expanded?: boolean; @@ -54,13 +60,14 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { super.connectedCallback(); this.setAttribute('role', 'treeitem'); + this.tabIndex = 0; } override render(): TemplateResult { return html` ${this.expandable ? html` - + this.toggle()} fill="ghost" size="sm" tabindex="-1"> ` @@ -81,12 +88,21 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { super.updated(changes); if (changes.has('level')) { + this.toggleAttribute('root', this.level === 0); this.style.setProperty('--_level', this.level?.toString()); } } - #onToggle(): void { - this.expanded = !this.expanded; + toggle(expanded = !this.expanded): void { + this.expanded = expanded; this.toggleEvent.emit(this.expanded); } + + #onKeydown(event: KeyboardEvent): void { + if (!this.expandable) { + return; + } else if ((event.key === 'ArrowRight' && !this.expanded) || (event.key === 'ArrowLeft' && this.expanded)) { + this.toggle(); + } + } } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index c49f423716..e95e89f48c 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -19,64 +19,100 @@ Icon.register(faFile, faFolder, faFolderOpen); const flatData = [ { + id: 0, + expandable: true, + level: 0, + name: 'textarea' + }, + { + id: 1, + expandable: true, + level: 0, + name: 'tooltip' + }, + { + id: 2, expandable: true, level: 0, name: 'tree' }, { + id: 3, expandable: true, level: 1, name: 'src' }, { + id: 4, expandable: false, level: 2, name: 'flat-tree-model.ts' }, { + id: 5, expandable: false, level: 2, name: 'nested-tree-model.ts' }, { + id: 6, expandable: false, level: 2, name: 'tree-model.ts' }, { + id: 7, expandable: false, level: 2, name: 'tree-node.scss' }, { + id: 8, expandable: false, level: 2, name: 'tree-node.ts' }, { + id: 9, expandable: false, level: 2, name: 'tree.ts' }, { + id: 10, expandable: false, level: 2, name: 'utils.ts' }, { + id: 11, expandable: false, level: 1, name: 'index.ts' }, { + id: 12, expandable: false, level: 1, name: 'package.json' }, { + id: 13, expandable: false, level: 1, name: 'register.ts' + }, + { + id: 14, + expandable: false, + level: 0, + name: 'eslint.config.mjs' + }, + { + id: 15, + expandable: false, + level: 0, + name: 'stylelint.config.mjs' } ]; @@ -126,7 +162,8 @@ export const Flat: Story = { ({ level }) => level, ({ expandable }) => expandable, { - getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`) + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + trackBy: item => item.id } ) } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 9df4a32314..1743c7fc1f 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,7 +1,7 @@ import { virtualize } 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 { SelectionController } from '@sl-design-system/shared'; +import { RovingTabindexController, SelectionController } from '@sl-design-system/shared'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { TreeModel, type TreeModelArrayItem } from './tree-model.js'; @@ -36,6 +36,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; + /** Manage keyboard navigation between tabs. */ + #rovingTabindexController = new RovingTabindexController(this, { + focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), + elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) || [], + isFocusableElement: (el: TreeNode) => !el.disabled + }); + /** Contains the expanded state for the tree. */ readonly expansion = new SelectionController(this, { multiple: true }); @@ -72,6 +79,9 @@ export class Tree extends ScopedElementsMixin(LitElement) {
${virtualize({ items, + keyFunction: this.model?.trackBy + ? (item: TreeModelArrayItem, index: number) => this.model!.trackBy!(item.dataNode, index) + : undefined, renderItem: (item: TreeModelArrayItem) => this.renderItem(item) })}
From dc834d21ea351d6235c27aaf101dbdf88ddbbdde Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 May 2024 20:05:16 +0200 Subject: [PATCH 13/88] =?UTF-8?q?=F0=9F=95=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/package.json | 1 - packages/components/tree/src/tree-node.scss | 10 ++++++++-- packages/components/tree/src/tree-node.ts | 19 +++++++++++++------ packages/components/tree/src/tree.ts | 3 +++ yarn.lock | 1 - 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json index 229ead23ae..5b25539f43 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -38,7 +38,6 @@ "test": "echo \"Error: run tests from monorepo root.\" && exit 1" }, "dependencies": { - "@sl-design-system/button": "0.0.27", "@sl-design-system/checkbox": "0.0.28", "@sl-design-system/icon": "0.0.10", "@sl-design-system/shared": "0.2.10" diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 2bde7e3e4a..5a657b48a9 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -2,11 +2,11 @@ --_background: #fff; --_background-hover: #f7f7f7; --_expander-indent: 0px; - --_expander-size: 1.75rem; + --_expander-size: 1.125rem; --_focus-outline: var(--sl-color-focusring-default) solid var(--sl-border-width-focusring-default); --_focus-outline-offset: var(--sl-border-width-focusring-offset); --_focus-radius: var(--sl-border-radius-focusring-default); - --_gap: 0.25rem; + --_gap: 0.375rem; --_level-indent: 0.25rem; --_padding-block: 0.25rem; --_padding-inline: 0.5rem; @@ -46,6 +46,12 @@ pointer-events: none; } +.expander { + align-items: center; + align-self: stretch; + display: inline-flex; +} + [part='wrapper'] { align-items: center; display: flex; diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index ee18410c51..92363abfc8 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -1,5 +1,4 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; -import { Button } from '@sl-design-system/button'; import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; @@ -23,14 +22,16 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** @internal */ static get scopedElements(): ScopedElementsMap { return { - 'sl-button': Button, 'sl-checkbox': Checkbox, 'sl-icon': Icon }; } /** Event controller. */ - #events = new EventsController(this, { keydown: this.#onKeydown }); + #events = new EventsController(this, { + click: this.#onClick, + keydown: this.#onKeydown + }); /** Whether the node is disabled. */ @property({ type: Boolean, reflect: true }) disabled?: boolean; @@ -67,9 +68,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { return html` ${this.expandable ? html` - this.toggle()} fill="ghost" size="sm" tabindex="-1"> - - +
+ +
` : nothing}
@@ -98,6 +99,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.toggleEvent.emit(this.expanded); } + #onClick(): void { + if (this.expandable) { + this.toggle(); + } + } + #onKeydown(event: KeyboardEvent): void { if (!this.expandable) { return; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 1743c7fc1f..8fa3055428 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -39,6 +39,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), + direction: 'vertical', elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) || [], isFocusableElement: (el: TreeNode) => !el.disabled }); @@ -75,6 +76,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { override render(): TemplateResult { const items = this.model?.toArray(this.expansion) ?? []; + setTimeout(() => this.#rovingTabindexController.clearElementCache(), 100); + return html`
${virtualize({ diff --git a/yarn.lock b/yarn.lock index a09cd1f445..055c5e0704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4891,7 +4891,6 @@ __metadata: resolution: "@sl-design-system/tree@workspace:packages/components/tree" dependencies: "@open-wc/scoped-elements": "npm:^3.0.5" - "@sl-design-system/button": "npm:0.0.27" "@sl-design-system/checkbox": "npm:0.0.28" "@sl-design-system/icon": "npm:0.0.10" "@sl-design-system/shared": "npm:0.2.10" From 83150c606f5ed519343ec205c9b38648ce5c3efb Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 4 May 2024 12:40:26 +0200 Subject: [PATCH 14/88] =?UTF-8?q?=F0=9F=8D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 7 ++-- .../components/tree/src/indent-guides.scss | 37 +++++++++++++++++++ packages/components/tree/src/indent-guides.ts | 35 ++++++++++++++++++ .../components/tree/src/nested-tree-model.ts | 7 ++-- packages/components/tree/src/tree-model.ts | 1 + packages/components/tree/src/tree-node.scss | 12 +++--- packages/components/tree/src/tree-node.ts | 32 +++++++++------- packages/components/tree/src/tree.ts | 6 ++- 8 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 packages/components/tree/src/indent-guides.scss create mode 100644 packages/components/tree/src/indent-guides.ts diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 60b5ca57eb..5487738481 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -15,17 +15,18 @@ export class FlatTreeModel extends TreeModel { override toArray(expansion: SelectionController): Array> { let currentLevel = 0; - return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { + return this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { const expanded = expansion.isSelected(dataNode), expandable = this.isExpandable(dataNode), - level = this.getLevel(dataNode); + level = this.getLevel(dataNode), + nextLevel = index < array.length - 1 ? this.getLevel(array[index + 1]) : level; if (level === currentLevel) { if (expanded) { currentLevel++; } - return [...dataNodes, { dataNode, expandable, expanded, level }]; + return [...dataNodes, { dataNode, expandable, expanded, lastNodeInLevel: level > nextLevel, level }]; } else { if (level < currentLevel) { currentLevel = level; diff --git a/packages/components/tree/src/indent-guides.scss b/packages/components/tree/src/indent-guides.scss new file mode 100644 index 0000000000..3551955e6a --- /dev/null +++ b/packages/components/tree/src/indent-guides.scss @@ -0,0 +1,37 @@ +:host { + --_expander-indent: 1.125rem; + --_guide-color: var(--sl-color-palette-neutral-100); + --_guide-size: 1px; + --_level-indent: 0.75rem; + + align-items: stretch; + display: flex; + padding-inline-end: var(--_expander-indent); +} + +:host([expandable]) { + --_expander-indent: 0px; +} + +: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(--_guide-size) solid var(--_guide-color); + border-end-start-radius: 4px; + border-inline-start: var(--_guide-size) solid var(--_guide-color); + content: ''; + inline-size: 0.25rem; + inset: 100% auto auto 0; + position: absolute; + } +} + +[part='guide'] { + background: var(--_guide-color); + inline-size: var(--_guide-size); + margin-inline-start: var(--_level-indent); +} diff --git a/packages/components/tree/src/indent-guides.ts b/packages/components/tree/src/indent-guides.ts new file mode 100644 index 0000000000..395d1ccda5 --- /dev/null +++ b/packages/components/tree/src/indent-guides.ts @@ -0,0 +1,35 @@ +import { type CSSResultGroup, LitElement, type PropertyValues, 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; + } +} + +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, reflect: true }) level = 0; + + override updated(changes: PropertyValues): void { + if (changes.has('level')) { + this.style.setProperty('--guide-level', this.level.toString()); + } + } + + override render(): TemplateResult[] { + return Array(this.level) + .fill(0) + .map(() => html`
`); + } +} diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index c27cb14508..1977fcffda 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -34,11 +34,12 @@ export class NestedTreeModel extends TreeModel { return []; } - return children.reduce((dataNodes: Array>, childNode) => { + return children.reduce((dataNodes: Array>, childNode, index, array) => { const expanded = expansion.isSelected(dataNode), - expandable = this.isExpandable(dataNode); + expandable = this.isExpandable(dataNode), + lastNodeInLevel = index === array.length - 1; - dataNodes.push({ dataNode: childNode, expandable, expanded, level }); + dataNodes.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); if (expandable && expanded) { dataNodes.push(...this.nestedToArray(expansion, childNode, level + 1)); diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index d141ec7e84..d02c7ecf5e 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -4,6 +4,7 @@ export interface TreeModelArrayItem { dataNode: T; expanded: boolean; expandable: boolean; + lastNodeInLevel?: boolean; level: number; } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 5a657b48a9..67e265b321 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -1,13 +1,10 @@ :host { --_background: #fff; --_background-hover: #f7f7f7; - --_expander-indent: 0px; - --_expander-size: 1.125rem; --_focus-outline: var(--sl-color-focusring-default) solid var(--sl-border-width-focusring-default); --_focus-outline-offset: var(--sl-border-width-focusring-offset); --_focus-radius: var(--sl-border-radius-focusring-default); --_gap: 0.375rem; - --_level-indent: 0.25rem; --_padding-block: 0.25rem; --_padding-inline: 0.5rem; @@ -17,9 +14,6 @@ display: flex; gap: var(--_gap); inline-size: 100%; - padding-block: var(--_padding-block); - padding-inline: calc(var(--_padding-inline) + var(--_expander-indent) + var(--_level-indent) * var(--_level, 0)) - var(--_padding-inline); } :host(:not([expandable])) { @@ -46,6 +40,10 @@ pointer-events: none; } +sl-indent-guides { + align-self: stretch; +} + .expander { align-items: center; align-self: stretch; @@ -57,6 +55,8 @@ display: flex; flex: 1; gap: var(--_gap); + padding-block: var(--_padding-block); + padding-inline: 0 var(--_padding-inline); } ::slotted(*) { diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 92363abfc8..3779aa0ed8 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -3,8 +3,9 @@ import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; -import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; +import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; +import { IndentGuides } from './indent-guides.js'; import styles from './tree-node.scss.js'; declare global { @@ -23,7 +24,8 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { static get scopedElements(): ScopedElementsMap { return { 'sl-checkbox': Checkbox, - 'sl-icon': Icon + 'sl-icon': Icon, + 'sl-indent-guides': IndentGuides }; } @@ -43,10 +45,10 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { @property({ type: Boolean }) expandable?: boolean; /** The depth level of this node, 0 being the root of the tree. */ - @property({ type: Number }) level = 0; + @property({ type: Number, reflect: true }) level = 0; /** Will render a checkbox if true. */ - @property({ type: Boolean }) selectable?: boolean; + @property({ type: Boolean }) checkable?: boolean; /** Determines whether the checkbox is checked or not. */ @property({ type: Boolean }) checked?: boolean; @@ -54,6 +56,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** 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; + + /** Whether the node is currently selected. */ + @property({ type: Boolean }) selected?: boolean; + /** @internal Emits when the expanded state changes. */ @event({ name: 'sl-toggle' }) toggleEvent!: EventEmitter>; @@ -66,6 +74,11 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { override render(): TemplateResult { return html` + ${this.expandable ? html`
@@ -74,7 +87,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ` : nothing}
- ${this.selectable + ${this.checkable ? html` @@ -85,15 +98,6 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { `; } - override updated(changes: PropertyValues): void { - super.updated(changes); - - if (changes.has('level')) { - this.toggleAttribute('root', this.level === 0); - this.style.setProperty('--_level', this.level?.toString()); - } - } - toggle(expanded = !this.expanded): void { this.expanded = expanded; this.toggleEvent.emit(this.expanded); diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 8fa3055428..04e46cfb01 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -92,15 +92,17 @@ export class Tree extends ScopedElementsMixin(LitElement) { } renderItem(item: TreeModelArrayItem): TemplateResult { - const { dataNode, expandable, expanded, level } = item, + const { dataNode, expandable, expanded, lastNodeInLevel, level } = item, icon = this.model!.getIcon(dataNode, expanded); return html` this.#onToggle(dataNode)} + ?checkable=${this.selects === 'multiple'} ?expandable=${expandable} ?expanded=${expanded} - ?selectable=${!!this.selects} + ?selected=${this.selection.isSelected(dataNode)} + .lastNodeInLevel=${lastNodeInLevel} .level=${level} > ${this.renderer From 47929bd6ccefa1404e104315c23856f6a4d5b2ec Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 4 May 2024 13:47:20 +0200 Subject: [PATCH 15/88] =?UTF-8?q?=F0=9F=8D=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.ts | 48 +++++++++++++++++------ packages/components/tree/src/tree.ts | 2 +- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 3779aa0ed8..03e2178b84 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -3,7 +3,7 @@ import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; -import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit'; +import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { IndentGuides } from './indent-guides.js'; import styles from './tree-node.scss.js'; @@ -35,23 +35,17 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { keydown: this.#onKeydown }); + /** 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; - /** Indicates whether the node is expanded or collapsed. */ - @property({ type: Boolean, reflect: true }) expanded?: boolean; - /** If true, will render an indicator whether the node is expanded or collapsed. */ @property({ type: Boolean }) expandable?: boolean; - /** The depth level of this node, 0 being the root of the tree. */ - @property({ type: Number, reflect: true }) level = 0; - - /** Will render a checkbox if true. */ - @property({ type: Boolean }) checkable?: boolean; - - /** Determines whether the checkbox is checked or not. */ - @property({ type: Boolean }) checked?: boolean; + /** Indicates whether the node is expanded or collapsed. */ + @property({ type: Boolean, reflect: true }) expanded?: boolean; /** Indeterminate state of the checkbox. Used when not all children are checked. */ @property({ type: Boolean }) indeterminate?: boolean; @@ -59,9 +53,15 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** 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, reflect: true }) level = 0; + /** 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>; @@ -72,6 +72,28 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.tabIndex = 0; } + override updated(changes: PropertyValues): void { + super.updated(changes); + + if (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', this.selected ? 'true' : 'false'); + } else { + this.removeAttribute('aria-selected'); + } + } + + if (changes.has('expanded')) { + this.toggleAttribute('aria-expanded', this.expanded); + } + } + override render(): TemplateResult { return html` - ${this.checkable + ${this.selects === 'multiple' ? html` diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 04e46cfb01..6d36694735 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -98,12 +98,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { return html` this.#onToggle(dataNode)} - ?checkable=${this.selects === 'multiple'} ?expandable=${expandable} ?expanded=${expanded} ?selected=${this.selection.isSelected(dataNode)} .lastNodeInLevel=${lastNodeInLevel} .level=${level} + .selects=${this.selects} > ${this.renderer ? this.renderer(dataNode, { expanded, expandable }) From 68ee0ed59d7b50f72af79c6d776c18701ee74564 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 4 May 2024 14:08:20 +0200 Subject: [PATCH 16/88] =?UTF-8?q?=F0=9F=9B=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/checkbox/src/checkbox.scss | 2 +- packages/components/checkbox/src/checkbox.ts | 4 +++- packages/components/tree/src/tree-node.scss | 6 ++++++ packages/components/tree/src/tree-node.ts | 16 ++++++++++++---- packages/components/tree/src/tree.ts | 6 ++++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/components/checkbox/src/checkbox.scss b/packages/components/checkbox/src/checkbox.scss index 94109bec2e..caf1393ffd 100644 --- a/packages/components/checkbox/src/checkbox.scss +++ b/packages/components/checkbox/src/checkbox.scss @@ -135,7 +135,7 @@ $variants: default, valid, invalid; } } -.label { +[part='label'] { line-height: var(--_line-height); margin-block-start: calc((var(--_size) + var(--_padding-block) * 2 - var(--_line-height)) / 2); } diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts index 738daa7f0c..fb21d1b735 100644 --- a/packages/components/checkbox/src/checkbox.ts +++ b/packages/components/checkbox/src/checkbox.ts @@ -18,6 +18,8 @@ export type CheckboxSize = 'sm' | 'md' | 'lg'; /** * A checkbox with 3 states; unchecked, checked and intermediate. * + * @csspart label - The label of the checkbox. + * * @slot default - Text label of the checkbox. Technically there are no limits what can be put here; text, images, icons etc. */ @localized() @@ -141,7 +143,7 @@ export class Checkbox extends FormControlMixin(LitElement) {
- + this.#updateNoLabel()}> `; diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 67e265b321..b3d7e2b149 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -71,3 +71,9 @@ sl-icon { rotate: 0deg; transition: rotate 100ms ease-in-out; } + +sl-checkbox::part(label) { + align-items: center; + display: inline-flex; + gap: var(--_gap); +} diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 03e2178b84..8c04c3e691 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -2,7 +2,7 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; -import { type SlToggleEvent } from '@sl-design-system/shared/events.js'; +import { type SlChangeEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { IndentGuides } from './indent-guides.js'; @@ -14,8 +14,6 @@ declare global { } } -export type SlExpandEvent = CustomEvent; - export class TreeNode extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; @@ -111,7 +109,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) {
${this.selects === 'multiple' ? html` - + ` @@ -125,6 +128,11 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.toggleEvent.emit(this.expanded); } + #onChange(event: SlChangeEvent): void { + this.checked = event.detail; + this.indeterminate = false; + } + #onClick(): void { if (this.expandable) { this.toggle(); diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 6d36694735..59ed61898b 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -2,6 +2,7 @@ import { virtualize } 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 { RovingTabindexController, SelectionController } from '@sl-design-system/shared'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { TreeModel, type TreeModelArrayItem } from './tree-model.js'; @@ -97,6 +98,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { return html` ) => this.#onChange(event, dataNode)} @sl-toggle=${() => this.#onToggle(dataNode)} ?expandable=${expandable} ?expanded=${expanded} @@ -115,6 +117,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { `; } + #onChange(event: SlChangeEvent, item: T): void { + console.log('event', event, event.detail, item); + } + #onToggle(item: T): void { this.expansion.toggle(item); } From 9cb9d52810a59b63fbc5f2957916ae203a4aec10 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 4 May 2024 14:10:28 +0200 Subject: [PATCH 17/88] =?UTF-8?q?=E2=9A=BD=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 59ed61898b..1961bdcf5d 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -94,7 +94,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { renderItem(item: TreeModelArrayItem): TemplateResult { const { dataNode, expandable, expanded, lastNodeInLevel, level } = item, - icon = this.model!.getIcon(dataNode, expanded); + icon = this.model!.getIcon(dataNode, expanded), + selected = this.selection.isSelected(dataNode); return html` extends ScopedElementsMixin(LitElement) { @sl-toggle=${() => this.#onToggle(dataNode)} ?expandable=${expandable} ?expanded=${expanded} - ?selected=${this.selection.isSelected(dataNode)} + ?checked=${selected && this.selects === 'multiple'} + ?selected=${selected && this.selects === 'single'} .lastNodeInLevel=${lastNodeInLevel} .level=${level} .selects=${this.selects} From ef52472e2fb9a6f9452afae8100f93b76664f3b0 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 4 May 2024 20:29:27 +0200 Subject: [PATCH 18/88] =?UTF-8?q?=F0=9F=90=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/main.ts | 5 +- .storybook/preview.ts | 3 +- .storybook/stories/grades.stories.ts | 201 +++++++++++++++++++++ packages/components/switch/src/switch.scss | 8 +- 4 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 .storybook/stories/grades.stories.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 351725f89a..66f3bbc95f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -4,7 +4,10 @@ import { argv } from 'node:process'; const devMode = !argv.includes('build'); const config: StorybookConfig = { - stories: ['../packages/{checklist,components}/**/*.stories.ts'], + stories: [ + './stories/**/*.stories.ts', + '../packages/{checklist,components}/**/*.stories.ts' + ], addons: [ '@storybook/addon-a11y', '@storybook/addon-actions', diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 4a8cc2b639..3efb8b648e 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -66,7 +66,8 @@ const preview: Preview = { parameters: { options: { storySort: { - method: 'alphabetical' + method: 'alphabetical', + order: ['', 'Components', 'Form', 'In progress', 'Experiments'] } }, viewport: { diff --git a/.storybook/stories/grades.stories.ts b/.storybook/stories/grades.stories.ts new file mode 100644 index 0000000000..1d9ff068dd --- /dev/null +++ b/.storybook/stories/grades.stories.ts @@ -0,0 +1,201 @@ +import { faPlus } from '@fortawesome/pro-regular-svg-icons'; +import '@sl-design-system/breadcrumbs/register.js'; +import '@sl-design-system/button/register.js'; +import { Icon } from '@sl-design-system/icon'; +import { type Person, getPeople } from '@sl-design-system/example-data'; +import '@sl-design-system/grid/register.js'; +import '@sl-design-system/icon/register.js'; +import '@sl-design-system/select/register.js'; +import '@sl-design-system/switch/register.js'; +import '@sl-design-system/text-field/register.js'; +import '@sl-design-system/tree/register.js'; +import { FlatTreeModel } from '@sl-design-system/tree'; +import { type StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +type Story = StoryObj; + +Icon.register(faPlus); + +export default { + title: 'Experiments/Grades', + parameters: { + layout: 'fullscreen' + } +} + +export const Default: Story = { + loaders: [async () => ({ people: (await getPeople()).people })], + render: (_, { loaded: { people } }) => { + const courses = [ + { id: 1, name: 'Group 1', level: 0 }, + { id: 3, name: 'Mathematics', level: 1 }, + { id: 4, name: 'Science', level: 1 }, + { id: 5, name: 'History', level: 1 }, + { id: 2, name: 'Group 2', level: 0 }, + { id: 6, name: 'Algebra', level: 1 }, + { id: 7, name: 'Geometry', level: 1 }, + { id: 8, name: 'Physics', level: 1 }, + { id: 9, name: 'Chemistry', level: 1 }, + { id: 10, name: 'World History', level: 1 }, + { id: 11, name: 'Group 3A', level: 0 }, + { id: 12, name: 'Group 3B', level: 0 }, + { id: 13, name: 'Group 3C', level: 0 }, + { id: 14, name: 'Group 4A', level: 0 }, + { id: 15, name: 'Group 4B', level: 0 }, + { id: 16, name: 'Group 4C', level: 0 }, + { id: 17, name: 'Group 5A', level: 0 }, + { id: 18, name: 'Group 5B', level: 0 }, + { id: 19, name: 'Group 5C', level: 0 }, + { id: 20, name: 'Group 6', level: 0 }, + { id: 21, name: 'Group 7', level: 0 }, + { id: 22, name: 'Group 8', level: 0 }, + { id: 23, name: 'Group 9', level: 0 }, + { id: 24, name: 'Group 10', level: 0 }, + { id: 25, name: 'Group 11', level: 0 }, + { id: 26, name: 'Group 12', level: 0 }, + { id: 27, name: 'Group 13', level: 0 }, + { id: 28, name: 'Group 14', level: 0 }, + { id: 29, name: 'Group 15', level: 0 }, + { id: 30, name: 'Group 16', level: 0 }, + { id: 31, name: 'Group 17', level: 0 }, + { id: 32, name: 'Group 18', level: 0 }, + ]; + + const model = new FlatTreeModel(courses, ({ name }) => name, ({ level }) => level, ({ level }) => level === 0, { trackBy: course => course.id }); + + return html` + + + Grades + Administration + +

Grade columns

+ +
+
+

Studies

+

9 studies for all locations

+ + +
+ +
+
+
+

Dutch

+

HAVO 3

+
+ Show only PTA exams + + + New grade column + +
+ + + + + + + +
+
+ `; + } +}; diff --git a/packages/components/switch/src/switch.scss b/packages/components/switch/src/switch.scss index 6781bd4e13..38136bd6a0 100644 --- a/packages/components/switch/src/switch.scss +++ b/packages/components/switch/src/switch.scss @@ -76,12 +76,8 @@ $sizes: sm, md, lg; --_main-background-color: var(--sl-color-input-switch-default-checked-disabled-background); } -:host([reverse]) { - align-self: start; - - .toggle { - order: -1; - } +:host([reverse]) .toggle { + order: -1; } :host(:focus-within) .track:focus-visible { From df1ccc321c878434058dec4499ea8e13627ecfdc Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 11:38:59 +0100 Subject: [PATCH 19/88] =?UTF-8?q?=F0=9F=8C=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 14 ++---- packages/components/tree/src/tree-model.ts | 4 +- packages/components/tree/src/tree-node.ts | 4 +- packages/components/tree/src/tree.stories.ts | 43 ++++++++++++------- packages/components/tree/src/tree.ts | 4 +- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 5487738481..46cd5bd673 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -21,18 +21,12 @@ export class FlatTreeModel extends TreeModel { level = this.getLevel(dataNode), nextLevel = index < array.length - 1 ? this.getLevel(array[index + 1]) : level; - if (level === currentLevel) { - if (expanded) { - currentLevel++; - } - - return [...dataNodes, { dataNode, expandable, expanded, lastNodeInLevel: level > nextLevel, level }]; + if (level > currentLevel) { + return dataNodes; } else { - if (level < currentLevel) { - currentLevel = level; - } + currentLevel = expanded ? level + 1 : level; - return dataNodes; + return [...dataNodes, { dataNode, expandable, expanded, lastNodeInLevel: level > nextLevel, level }]; } }, []); } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index d02c7ecf5e..a446ba0f84 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -34,10 +34,10 @@ export abstract class TreeModel { /** Returns whether the given node is expandable. */ abstract isExpandable(dataNode: T): boolean; - /** Returns a string that is used as the label for the treenode. */ + /** Returns a string that is used as the label for the tree node. */ abstract getLabel(dataNode: T): string; - /** Optional method for returning a custom icon for a treenode. */ + /** Optional method for returning a custom icon for a tree node. */ getIcon(_dataNode: T, _expanded?: boolean): string | undefined { return undefined; } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 8c04c3e691..785d064008 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -27,7 +27,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { }; } - /** Event controller. */ + // eslint-disable-next-line no-unused-private-class-members #events = new EventsController(this, { click: this.#onClick, keydown: this.#onKeydown @@ -96,7 +96,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { return html` ${this.expandable diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index e95e89f48c..1ce3fd9ba3 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -26,90 +26,102 @@ const flatData = [ }, { id: 1, + expandable: false, + level: 1, + name: 'package.json' + }, + { + id: 2, expandable: true, level: 0, name: 'tooltip' }, { - id: 2, + id: 3, + expandable: false, + level: 1, + name: 'package.json' + }, + { + id: 4, expandable: true, level: 0, name: 'tree' }, { - id: 3, + id: 5, expandable: true, level: 1, name: 'src' }, { - id: 4, + id: 6, expandable: false, level: 2, name: 'flat-tree-model.ts' }, { - id: 5, + id: 7, expandable: false, level: 2, name: 'nested-tree-model.ts' }, { - id: 6, + id: 8, expandable: false, level: 2, name: 'tree-model.ts' }, { - id: 7, + id: 9, expandable: false, level: 2, name: 'tree-node.scss' }, { - id: 8, + id: 10, expandable: false, level: 2, name: 'tree-node.ts' }, { - id: 9, + id: 11, expandable: false, level: 2, name: 'tree.ts' }, { - id: 10, + id: 12, expandable: false, level: 2, name: 'utils.ts' }, { - id: 11, + id: 13, expandable: false, level: 1, name: 'index.ts' }, { - id: 12, + id: 14, expandable: false, level: 1, name: 'package.json' }, { - id: 13, + id: 15, expandable: false, level: 1, name: 'register.ts' }, { - id: 14, + id: 16, expandable: false, level: 0, name: 'eslint.config.mjs' }, { - id: 15, + id: 17, expandable: false, level: 0, name: 'stylelint.config.mjs' @@ -140,7 +152,8 @@ const nestedData: NestedDataNode[] = [ ]; export default { - title: 'In progress/Tree', + title: 'Navigation/Tree', + tags: ['draft'], args: {}, argTypes: { model: { diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 1961bdcf5d..fdeaeb907c 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -101,11 +101,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { ) => this.#onChange(event, dataNode)} @sl-toggle=${() => this.#onToggle(dataNode)} + ?checked=${selected && this.selects === 'multiple'} ?expandable=${expandable} ?expanded=${expanded} - ?checked=${selected && this.selects === 'multiple'} + ?last-node-in-level=${lastNodeInLevel} ?selected=${selected && this.selects === 'single'} - .lastNodeInLevel=${lastNodeInLevel} .level=${level} .selects=${this.selects} > From 2205a9c5f1f4216ed7b8ceda0989587510faee6a Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 12:03:13 +0100 Subject: [PATCH 20/88] =?UTF-8?q?=E2=9B=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 3 ++ .../components/tree/src/nested-tree-model.ts | 7 ++- packages/components/tree/src/tree.stories.ts | 47 +++++++++++++------ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 46cd5bd673..46953edd62 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,6 +1,9 @@ import { SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +/** + * A tree model that represents a flat list of nodes. + */ export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 1977fcffda..892558af7d 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,6 +1,9 @@ import { type SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +/** + * A tree model that represents a nested list of nodes. + */ export class NestedTreeModel extends TreeModel { constructor( public override dataNodes: T[], @@ -35,8 +38,8 @@ export class NestedTreeModel extends TreeModel { } return children.reduce((dataNodes: Array>, childNode, index, array) => { - const expanded = expansion.isSelected(dataNode), - expandable = this.isExpandable(dataNode), + const expanded = expansion.isSelected(childNode), + expandable = this.isExpandable(childNode), lastNodeInLevel = index === array.length - 1; dataNodes.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 1ce3fd9ba3..6770dab722 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -11,6 +11,7 @@ type Props = Pick; type Story = StoryObj; interface NestedDataNode { + id: number; name: string; children?: NestedDataNode[]; } @@ -130,25 +131,39 @@ const flatData = [ 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: [ - { name: 'flat-tree-model.ts' }, - { name: 'nested-tree-model.ts' }, - { name: 'tree-model.ts' }, - { name: 'tree-node.scss' }, - { name: 'tree-node.ts' }, - { name: 'tree.ts' }, - { name: 'utils.ts' } + { 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' } ] }, - { name: 'index.ts' }, - { name: 'package.json' }, - { name: 'register.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 { @@ -186,9 +201,13 @@ export const Nested: Story = { args: { model: new NestedTreeModel( nestedData, - dataNode => dataNode.children, - dataNode => dataNode.name, - dataNode => !!dataNode.children + ({ children }) => children, + ({ name }) => name, + ({ children }) => !!children, + { + getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), + trackBy: item => item.id + } ) } }; From 132d4a55641e5a6deebefbcfcac6de0ce9f7c6c5 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 13:52:02 +0100 Subject: [PATCH 21/88] =?UTF-8?q?=F0=9F=92=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 8 ++-- .../components/tree/src/nested-tree-model.ts | 8 ++-- packages/components/tree/src/tree-model.ts | 12 ++--- packages/components/tree/src/tree-node.scss | 48 +++++++++++-------- packages/components/tree/src/tree.stories.ts | 14 +++--- packages/components/tree/src/tree.ts | 23 +++++++-- 6 files changed, 68 insertions(+), 45 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 46953edd62..3098a3a74d 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -4,13 +4,13 @@ import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tre /** * A tree model that represents a flat list of nodes. */ -export class FlatTreeModel extends TreeModel { +export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public getLabel: TreeModel['getLabel'], + public getLabel: TreeModel['getLabel'], public getLevel: (dataNode: T) => number, - public isExpandable: TreeModel['isExpandable'], - options: Partial> = {} + public isExpandable: TreeModel['isExpandable'], + options: Partial> = {} ) { super(options); } diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 892558af7d..d365948f9a 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -4,13 +4,13 @@ import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tre /** * A tree model that represents a nested list of nodes. */ -export class NestedTreeModel extends TreeModel { +export class NestedTreeModel extends TreeModel { constructor( public override dataNodes: T[], public getChildren: (dataNode: T) => T[] | undefined, - public getLabel: TreeModel['getLabel'], - public isExpandable: TreeModel['isExpandable'], - options: Partial> = {} + public getLabel: TreeModel['getLabel'], + public isExpandable: TreeModel['isExpandable'], + options: Partial> = {} ) { super(options); } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index a446ba0f84..c05e821ea7 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -8,22 +8,22 @@ export interface TreeModelArrayItem { level: number; } -export interface TreeModelOptions { - getIcon: TreeModel['getIcon']; - trackBy(dataNode: T, index: number): unknown; +export interface TreeModelOptions { + getIcon: TreeModel['getIcon']; + trackBy(dataNode: T): T[U]; } /** * Abstract class used to provide a common interface for tree data. */ -export abstract class TreeModel { +export abstract class TreeModel { /** The nodes of the tree. */ dataNodes: T[] = []; /** Used during rendering to determine if a tree node needs to be rerendered. */ - trackBy?(dataNode: T, index: number): unknown; + trackBy?(dataNode: T): T[U]; - constructor(options: Partial> = {}) { + constructor(options: Partial> = {}) { if (options.getIcon) { this.getIcon = options.getIcon; } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index b3d7e2b149..3682157f97 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -1,19 +1,12 @@ :host { - --_background: #fff; - --_background-hover: #f7f7f7; - --_focus-outline: var(--sl-color-focusring-default) solid var(--sl-border-width-focusring-default); - --_focus-outline-offset: var(--sl-border-width-focusring-offset); - --_focus-radius: var(--sl-border-radius-focusring-default); - --_gap: 0.375rem; - --_padding-block: 0.25rem; - --_padding-inline: 0.5rem; - align-items: center; - background: var(--_background); + background: var(--sl-elevation-surface-raised-default-idle); + border-radius: var(--sl-size-borderRadius-default); cursor: pointer; display: flex; - gap: var(--_gap); + gap: var(--sl-size-075); inline-size: 100%; + transition: background 0.2s ease-in-out; } :host(:not([expandable])) { @@ -24,15 +17,22 @@ rotate: 90deg; } +:host([aria-selected='true']) { + background: var(--sl-color-background-selected-subtle-idle); +} + :host(:focus-visible) { - border-radius: var(--_focus-radius); - outline: var(--_focus-outline); + outline: var(--sl-size-borderWidth-bold) solid var(--sl-color-border-focused); outline-offset: calc(var(--_focus-outline-offset) * -1); z-index: 1; } :host(:hover) { - background: var(--_background-hover); + background: var(--sl-elevation-surface-raised-default-hover); +} + +:host(:active) { + background: var(--sl-elevation-surface-raised-default-active); } :host([disabled]) { @@ -54,9 +54,9 @@ sl-indent-guides { align-items: center; display: flex; flex: 1; - gap: var(--_gap); - padding-block: var(--_padding-block); - padding-inline: 0 var(--_padding-inline); + gap: var(--sl-size-075); + padding-block: var(--sl-size-050); + padding-inline: 0 var(--sl-size-100); } ::slotted(*) { @@ -65,6 +65,7 @@ sl-indent-guides { ::slotted(sl-icon) { flex: 0 1; + vertical-align: bottom; } sl-icon { @@ -72,8 +73,15 @@ sl-icon { transition: rotate 100ms ease-in-out; } -sl-checkbox::part(label) { +sl-checkbox { align-items: center; - display: inline-flex; - gap: var(--_gap); + font: inherit; + line-height: inherit; + + &::part(label) { + align-items: center; + display: inline-flex; + gap: var(--sl-size-075); + margin: 0; + } } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 6770dab722..8b68c3ca8b 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -7,7 +7,7 @@ import { FlatTreeModel } from './flat-tree-model.js'; import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick; +type Props = Pick; type Story = StoryObj; interface NestedDataNode { @@ -179,10 +179,11 @@ export default { options: ['single', 'multiple'] } }, - render: ({ model, selects }) => html`` + render: ({ model, selected, selects }) => + html`` } satisfies Meta; -export const Flat: Story = { +export const FlatModel: Story = { args: { model: new FlatTreeModel( flatData, @@ -197,7 +198,7 @@ export const Flat: Story = { } }; -export const Nested: Story = { +export const NestedModel: Story = { args: { model: new NestedTreeModel( nestedData, @@ -214,14 +215,15 @@ export const Nested: Story = { export const SingleSelect: Story = { args: { - ...Flat.args, + ...FlatModel.args, + selected: 16, selects: 'single' } }; export const MultiSelect: Story = { args: { - ...Flat.args, + ...FlatModel.args, selects: 'multiple' } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index fdeaeb907c..390d235be5 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -25,7 +25,7 @@ export interface TreeItemRendererOptions { export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class Tree extends ScopedElementsMixin(LitElement) { +export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static get scopedElements(): ScopedElementsMap { return { @@ -49,13 +49,16 @@ export class Tree extends ScopedElementsMixin(LitElement) { readonly expansion = new SelectionController(this, { multiple: true }); /** The model for the tree. */ - @property({ attribute: false }) model?: TreeModel; + @property({ attribute: false }) model?: TreeModel; /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; /** Contains the selection state for the tree when `selects` is defined. */ - readonly selection = new SelectionController(this); + readonly selection = new SelectionController(this); + + /** The selected tree node(s). */ + @property() selected?: T[U] | Array; /** If you are able to select one or more tree items (at the same time). */ @property() selects?: 'single' | 'multiple'; @@ -72,6 +75,16 @@ export class Tree extends ScopedElementsMixin(LitElement) { if (changes.has('selects')) { this.selection.multiple = this.selects === 'multiple'; } + + if (changes.has('selected')) { + this.selection.deselectAll(); + + if (this.selects === 'single' && this.selected && !Array.isArray(this.selected)) { + this.selection.select(this.selected); + } else if (this.selects === 'multiple' && Array.isArray(this.selected)) { + this.selected.forEach(item => this.selection.select(item)); + } + } } override render(): TemplateResult { @@ -84,7 +97,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { ${virtualize({ items, keyFunction: this.model?.trackBy - ? (item: TreeModelArrayItem, index: number) => this.model!.trackBy!(item.dataNode, index) + ? (item: TreeModelArrayItem) => this.model!.trackBy!(item.dataNode) : undefined, renderItem: (item: TreeModelArrayItem) => this.renderItem(item) })} @@ -95,7 +108,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { renderItem(item: TreeModelArrayItem): TemplateResult { const { dataNode, expandable, expanded, lastNodeInLevel, level } = item, icon = this.model!.getIcon(dataNode, expanded), - selected = this.selection.isSelected(dataNode); + selected = this.selection.isSelected(this.model!.trackBy?.(dataNode) || (dataNode as T[U])); return html` Date: Mon, 30 Dec 2024 14:27:15 +0100 Subject: [PATCH 22/88] =?UTF-8?q?=F0=9F=8C=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 21 +++++--- .../components/tree/src/nested-tree-model.ts | 25 ++++++---- packages/components/tree/src/tree-model.ts | 39 ++++++++++----- packages/components/tree/src/tree.stories.ts | 48 ++++++++++--------- packages/components/tree/src/tree.ts | 33 ++++++++----- 5 files changed, 103 insertions(+), 63 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 3098a3a74d..92f1c2676a 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,25 +1,32 @@ import { SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +export interface FlatTreeModelOptions extends TreeModelOptions { + getLevel(dataNode: T): number; +} + /** * A tree model that represents a flat list of nodes. */ -export class FlatTreeModel extends TreeModel { +export class FlatTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public getLabel: TreeModel['getLabel'], - public getLevel: (dataNode: T) => number, - public isExpandable: TreeModel['isExpandable'], - options: Partial> = {} + options: FlatTreeModelOptions ) { super(options); + + this.getLevel = options.getLevel; + } + + getLevel(_dataNode: T): number { + return 0; } - override toArray(expansion: SelectionController): Array> { + override toArray(expansion: SelectionController): Array> { let currentLevel = 0; return this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { - const expanded = expansion.isSelected(dataNode), + const expanded = expansion.isSelected(this.getId(dataNode)), expandable = this.isExpandable(dataNode), level = this.getLevel(dataNode), nextLevel = index < array.length - 1 ? this.getLevel(array[index + 1]) : level; diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index d365948f9a..0e715cfa24 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,24 +1,31 @@ import { type SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +export interface NestedTreeModelOptions extends TreeModelOptions { + getChildren(dataNode: T): T[] | undefined; +} + /** * A tree model that represents a nested list of nodes. */ -export class NestedTreeModel extends TreeModel { +export class NestedTreeModel extends TreeModel { constructor( public override dataNodes: T[], - public getChildren: (dataNode: T) => T[] | undefined, - public getLabel: TreeModel['getLabel'], - public isExpandable: TreeModel['isExpandable'], - options: Partial> = {} + options: NestedTreeModelOptions ) { super(options); + + this.getChildren = options.getChildren; + } + + getChildren(_dataNode: T): T[] | undefined { + return undefined; } - override toArray(expansion: SelectionController): Array> { + override toArray(expansion: SelectionController): Array> { return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { const expandable = this.isExpandable(dataNode), - expanded = expansion.isSelected(dataNode); + expanded = expansion.isSelected(this.getId(dataNode)); dataNodes.push({ dataNode, expandable, expanded, level: 0 }); @@ -30,7 +37,7 @@ export class NestedTreeModel extends TreeModel { }, []); } - nestedToArray(expansion: SelectionController, dataNode: T, level: number): Array> { + nestedToArray(expansion: SelectionController, dataNode: T, level: number): Array> { const children = this.getChildren(dataNode); if (!Array.isArray(children)) { @@ -38,7 +45,7 @@ export class NestedTreeModel extends TreeModel { } return children.reduce((dataNodes: Array>, childNode, index, array) => { - const expanded = expansion.isSelected(childNode), + const expanded = expansion.isSelected(this.getId(childNode)), expandable = this.isExpandable(childNode), lastNodeInLevel = index === array.length - 1; diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index c05e821ea7..5d80a740f0 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -8,34 +8,49 @@ export interface TreeModelArrayItem { level: number; } -export interface TreeModelOptions { - getIcon: TreeModel['getIcon']; - trackBy(dataNode: T): T[U]; +export interface TreeModelOptions { + getIcon?: TreeModel['getIcon']; + getId?: TreeModel['getId']; + getLabel: TreeModel['getLabel']; + isExpandable: TreeModel['isExpandable']; } +export type TreeModelId = ReturnType['getId']>; + /** * Abstract class used to provide a common interface for tree data. */ -export abstract class TreeModel { +export abstract class TreeModel { /** The nodes of the tree. */ dataNodes: T[] = []; - /** Used during rendering to determine if a tree node needs to be rerendered. */ - trackBy?(dataNode: T): T[U]; - - constructor(options: Partial> = {}) { + constructor(options: TreeModelOptions) { if (options.getIcon) { this.getIcon = options.getIcon; } - this.trackBy = options.trackBy; + if (options.getId) { + this.getId = options.getId; + } + + this.getLabel = options.getLabel; + this.isExpandable = options.isExpandable; } /** Returns whether the given node is expandable. */ - abstract isExpandable(dataNode: T): boolean; + isExpandable(_dataNode: T): boolean { + return false; + } /** Returns a string that is used as the label for the tree node. */ - abstract getLabel(dataNode: T): string; + getLabel(_dataNode: T): string { + return ''; + } + + /** Used to identify a tree node. */ + getId(dataNode: T): unknown { + return dataNode; + } /** Optional method for returning a custom icon for a tree node. */ getIcon(_dataNode: T, _expanded?: boolean): string | undefined { @@ -54,5 +69,5 @@ export abstract class TreeModel { collapseDescendants(_dataNode: T): void {} /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(expansion: SelectionController): Array>; + abstract toArray(expansion: SelectionController): Array>; } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 8b68c3ca8b..8edc8da846 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -7,7 +7,7 @@ import { FlatTreeModel } from './flat-tree-model.js'; import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick; +type Props = Pick; type Story = StoryObj; interface NestedDataNode { @@ -179,37 +179,38 @@ export default { options: ['single', 'multiple'] } }, - render: ({ model, selected, selects }) => - html`` + render: ({ expanded, model, selected, selects }) => + html`` } satisfies Meta; export const FlatModel: Story = { args: { - model: new FlatTreeModel( - flatData, - ({ name }) => name, - ({ level }) => level, - ({ expandable }) => expandable, - { - getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), - trackBy: item => item.id - } - ) + model: new FlatTreeModel(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 + }) } }; export const NestedModel: Story = { args: { - model: new NestedTreeModel( - nestedData, - ({ children }) => children, - ({ name }) => name, - ({ children }) => !!children, - { - getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), - trackBy: item => item.id - } - ) + model: new NestedTreeModel(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 + }) + } +}; + +export const Expanded: Story = { + args: { + ...FlatModel.args, + expanded: [4, 5] } }; @@ -224,6 +225,7 @@ export const SingleSelect: Story = { export const MultiSelect: Story = { args: { ...FlatModel.args, + selected: [16, 17], selects: 'multiple' } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 390d235be5..263a51fb69 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -5,7 +5,7 @@ import { RovingTabindexController, SelectionController } from '@sl-design-system import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; -import { TreeModel, type TreeModelArrayItem } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem, type TreeModelId } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -25,7 +25,7 @@ export interface TreeItemRendererOptions { export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class Tree extends ScopedElementsMixin(LitElement) { +export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static get scopedElements(): ScopedElementsMap { return { @@ -45,20 +45,23 @@ export class Tree extends ScopedElementsMi isFocusableElement: (el: TreeNode) => !el.disabled }); + /** The initial expanded tree nodes. */ + @property({ type: Array }) expanded?: Array>; + /** Contains the expanded state for the tree. */ - readonly expansion = new SelectionController(this, { multiple: true }); + readonly expansion = new SelectionController(this, { multiple: true }); /** The model for the tree. */ - @property({ attribute: false }) model?: TreeModel; + @property({ attribute: false }) model?: TreeModel; /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; /** Contains the selection state for the tree when `selects` is defined. */ - readonly selection = new SelectionController(this); + readonly selection = new SelectionController(this); - /** The selected tree node(s). */ - @property() selected?: T[U] | Array; + /** The initial selected tree node(s). */ + @property() selected?: unknown; /** If you are able to select one or more tree items (at the same time). */ @property() selects?: 'single' | 'multiple'; @@ -72,6 +75,14 @@ export class Tree extends ScopedElementsMi override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); + if (changes.has('expanded')) { + this.expansion.deselectAll(); + + if (this.expanded) { + this.expanded.forEach(item => this.expansion.select(item)); + } + } + if (changes.has('selects')) { this.selection.multiple = this.selects === 'multiple'; } @@ -96,9 +107,7 @@ export class Tree extends ScopedElementsMi
${virtualize({ items, - keyFunction: this.model?.trackBy - ? (item: TreeModelArrayItem) => this.model!.trackBy!(item.dataNode) - : undefined, + keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), renderItem: (item: TreeModelArrayItem) => this.renderItem(item) })}
@@ -108,7 +117,7 @@ export class Tree extends ScopedElementsMi renderItem(item: TreeModelArrayItem): TemplateResult { const { dataNode, expandable, expanded, lastNodeInLevel, level } = item, icon = this.model!.getIcon(dataNode, expanded), - selected = this.selection.isSelected(this.model!.trackBy?.(dataNode) || (dataNode as T[U])); + selected = this.selection.isSelected(this.model!.getId(dataNode)); return html` extends ScopedElementsMi } #onToggle(item: T): void { - this.expansion.toggle(item); + this.expansion.toggle(this.model?.getId(item)); } } From 946bae54ca5fd40e7e7fc7dc1d79e133ba16329c Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 15:52:56 +0100 Subject: [PATCH 23/88] =?UTF-8?q?=F0=9F=92=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 12 ++-- .../components/tree/src/nested-tree-model.ts | 20 +++--- packages/components/tree/src/tree-model.ts | 68 +++++++++++++++---- packages/components/tree/src/tree.stories.ts | 35 ++++++---- packages/components/tree/src/tree.ts | 30 ++++++-- 5 files changed, 112 insertions(+), 53 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 92f1c2676a..0ce289e194 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,4 +1,3 @@ -import { SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; export interface FlatTreeModelOptions extends TreeModelOptions { @@ -9,11 +8,8 @@ export interface FlatTreeModelOptions extends TreeModelOptions { * A tree model that represents a flat list of nodes. */ export class FlatTreeModel extends TreeModel { - constructor( - public override dataNodes: T[], - options: FlatTreeModelOptions - ) { - super(options); + constructor(dataNodes: T[], options: FlatTreeModelOptions) { + super(dataNodes, options); this.getLevel = options.getLevel; } @@ -22,11 +18,11 @@ export class FlatTreeModel extends TreeModel { return 0; } - override toArray(expansion: SelectionController): Array> { + override toArray(): Array> { let currentLevel = 0; return this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { - const expanded = expansion.isSelected(this.getId(dataNode)), + const expanded = this.isExpanded(this.getId(dataNode)), expandable = this.isExpandable(dataNode), level = this.getLevel(dataNode), nextLevel = index < array.length - 1 ? this.getLevel(array[index + 1]) : level; diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 0e715cfa24..66d024f1fd 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,4 +1,3 @@ -import { type SelectionController } from '@sl-design-system/shared'; import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; export interface NestedTreeModelOptions extends TreeModelOptions { @@ -9,11 +8,8 @@ export interface NestedTreeModelOptions extends TreeModelOptions { * A tree model that represents a nested list of nodes. */ export class NestedTreeModel extends TreeModel { - constructor( - public override dataNodes: T[], - options: NestedTreeModelOptions - ) { - super(options); + constructor(dataNodes: T[], options: NestedTreeModelOptions) { + super(dataNodes, options); this.getChildren = options.getChildren; } @@ -22,22 +18,22 @@ export class NestedTreeModel extends TreeModel { return undefined; } - override toArray(expansion: SelectionController): Array> { + override toArray(): Array> { return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { const expandable = this.isExpandable(dataNode), - expanded = expansion.isSelected(this.getId(dataNode)); + expanded = this.isExpanded(this.getId(dataNode)); dataNodes.push({ dataNode, expandable, expanded, level: 0 }); if (expandable && expanded) { - dataNodes.push(...this.nestedToArray(expansion, dataNode, 1)); + dataNodes.push(...this.nestedToArray(dataNode, 1)); } return dataNodes; }, []); } - nestedToArray(expansion: SelectionController, dataNode: T, level: number): Array> { + nestedToArray(dataNode: T, level: number): Array> { const children = this.getChildren(dataNode); if (!Array.isArray(children)) { @@ -45,14 +41,14 @@ export class NestedTreeModel extends TreeModel { } return children.reduce((dataNodes: Array>, childNode, index, array) => { - const expanded = expansion.isSelected(this.getId(childNode)), + const expanded = this.isExpanded(this.getId(childNode)), expandable = this.isExpandable(childNode), lastNodeInLevel = index === array.length - 1; dataNodes.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); if (expandable && expanded) { - dataNodes.push(...this.nestedToArray(expansion, childNode, level + 1)); + dataNodes.push(...this.nestedToArray(childNode, level + 1)); } return dataNodes; diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 5d80a740f0..458cac3773 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,5 +1,3 @@ -import { type SelectionController } from '@sl-design-system/shared'; - export interface TreeModelArrayItem { dataNode: T; expanded: boolean; @@ -20,11 +18,18 @@ export type TreeModelId = ReturnType['getId']>; /** * Abstract class used to provide a common interface for tree data. */ -export abstract class TreeModel { +export abstract class TreeModel extends EventTarget { + /** The expansion state of the tree. */ + #expansion = new Set>(); + /** The nodes of the tree. */ - dataNodes: T[] = []; + readonly dataNodes: T[] = []; + + constructor(dataNodes: T[], options: TreeModelOptions) { + super(); + + this.dataNodes = dataNodes; - constructor(options: TreeModelOptions) { if (options.getIcon) { this.getIcon = options.getIcon; } @@ -57,17 +62,52 @@ export abstract class TreeModel { return undefined; } - toggle(_dataNode: T): void {} - expand(_dataNode: T): void {} - collapse(_dataNode: T): void {} + toggle(id: TreeModelId): void { + if (this.isExpanded(id)) { + this.collapse(id); + } else { + this.expand(id); + } + } + + expand(id: TreeModelId): void { + this.#expansion.add(id); + this.#update(); + } + + collapse(id: TreeModelId): void { + this.#expansion.delete(id); + this.#update(); + } + + isExpanded(id: TreeModelId): boolean { + return this.#expansion.has(id); + } - expandAll(): void {} - collapseAll(): void {} + expandAll(): void { + this.#expansion = new Set(this.dataNodes.map(n => this.getId(n))); + this.#update(); + } + + collapseAll(): void { + this.#expansion.clear(); + this.#update(); + } - toggleDescendants(_dataNode: T): void {} - expandDescendants(_dataNode: T): void {} - collapseDescendants(_dataNode: T): void {} + toggleDescendants(id: TreeModelId): void { + console.log('toggleDescendants', id); + } + expandDescendants(id: TreeModelId): void { + console.log('expandDescendants', id); + } + collapseDescendants(id: TreeModelId): void { + console.log('collapseDescendants', id); + } /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(expansion: SelectionController): Array>; + abstract toArray(): Array>; + + #update(): void { + this.dispatchEvent(new Event('sl-update')); + } } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 8edc8da846..266476f8ac 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -1,4 +1,6 @@ import { faFile, faFolder, faFolderOpen } from '@fortawesome/pro-regular-svg-icons'; +import '@sl-design-system/button/register.js'; +import '@sl-design-system/button-bar/register.js'; import { Icon } from '@sl-design-system/icon'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html } from 'lit'; @@ -179,8 +181,22 @@ export default { options: ['single', 'multiple'] } }, - render: ({ expanded, model, selected, selects }) => - html`` + render: ({ expanded, model, selected, selects }) => { + const onToggleTree = () => model?.toggle(4), + onToggleTreeDescendants = () => model?.toggleDescendants(4), + onExpandAll = () => model?.expandAll(), + onCollapseAll = () => model?.collapseAll(); + + return html` + + Toggle "tree" + Toggle all below "tree" + Expand all + Collapse all + + + `; + } } satisfies Meta; export const FlatModel: Story = { @@ -191,7 +207,8 @@ export const FlatModel: Story = { getLabel: ({ name }) => name, getLevel: ({ level }) => level, isExpandable: ({ expandable }) => expandable - }) + }), + expanded: [4, 5] } }; @@ -203,13 +220,7 @@ export const NestedModel: Story = { getId: item => item.id, getLabel: ({ name }) => name, isExpandable: ({ children }) => !!children - }) - } -}; - -export const Expanded: Story = { - args: { - ...FlatModel.args, + }), expanded: [4, 5] } }; @@ -217,7 +228,7 @@ export const Expanded: Story = { export const SingleSelect: Story = { args: { ...FlatModel.args, - selected: 16, + selected: 10, selects: 'single' } }; @@ -225,7 +236,7 @@ export const SingleSelect: Story = { export const MultiSelect: Story = { args: { ...FlatModel.args, - selected: [16, 17], + selected: [9, 10], selects: 'multiple' } }; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 263a51fb69..fe37e4bfda 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -37,6 +37,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; + /** The data model for the tree. */ + #model?: TreeModel; + /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), @@ -48,11 +51,20 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The initial expanded tree nodes. */ @property({ type: Array }) expanded?: Array>; - /** Contains the expanded state for the tree. */ - readonly expansion = new SelectionController(this, { multiple: true }); + get model() { + return this.#model; + } /** The model for the tree. */ - @property({ attribute: false }) model?: TreeModel; + @property({ attribute: false }) + set model(model: TreeModel | undefined) { + if (this.#model) { + this.#model.removeEventListener('sl-update', this.#onUpdate); + } + + this.#model = model; + this.#model?.addEventListener('sl-update', this.#onUpdate); + } /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; @@ -76,10 +88,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { super.willUpdate(changes); if (changes.has('expanded')) { - this.expansion.deselectAll(); + this.model?.collapseAll(); if (this.expanded) { - this.expanded.forEach(item => this.expansion.select(item)); + this.expanded.forEach(item => this.model?.expand(item)); } } @@ -99,7 +111,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { } override render(): TemplateResult { - const items = this.model?.toArray(this.expansion) ?? []; + const items = this.model?.toArray() ?? []; setTimeout(() => this.#rovingTabindexController.clearElementCache(), 100); @@ -146,6 +158,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #onToggle(item: T): void { - this.expansion.toggle(this.model?.getId(item)); + this.model?.toggle(this.model?.getId(item)); } + + #onUpdate = (): void => { + this.requestUpdate('model'); + }; } From d55d2687c944ee6d9a277636fb232c55bbf46d00 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 16:16:07 +0100 Subject: [PATCH 24/88] =?UTF-8?q?=F0=9F=96=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 24 +++++++++- .../components/tree/src/nested-tree-model.ts | 44 ++++++++++++++++++- packages/components/tree/src/tree-model.ts | 40 +++++++++-------- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 0ce289e194..3045587232 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,4 +1,4 @@ -import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem, type TreeModelId, type TreeModelOptions } from './tree-model.js'; export interface FlatTreeModelOptions extends TreeModelOptions { getLevel(dataNode: T): number; @@ -18,6 +18,28 @@ export class FlatTreeModel extends TreeModel { return 0; } + override toggleDescendants(id: TreeModelId, force?: boolean): void { + const node = this.dataNodes.find(n => this.getId(n) === id); + if (!node) { + return; + } + + const index = this.dataNodes.indexOf(node), + level = this.getLevel(node); + + for (let i = index + 1; i < this.dataNodes.length; i++) { + const nextNode = this.dataNodes[i]; + + if (this.getLevel(nextNode) <= level) { + break; + } + + this.toggle(this.getId(nextNode), force, false); + } + + this.dispatchEvent(new Event('sl-update')); + } + override toArray(): Array> { let currentLevel = 0; diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 66d024f1fd..ec61a15f04 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,4 +1,4 @@ -import { TreeModel, type TreeModelArrayItem, type TreeModelOptions } from './tree-model.js'; +import { TreeModel, type TreeModelArrayItem, type TreeModelId, type TreeModelOptions } from './tree-model.js'; export interface NestedTreeModelOptions extends TreeModelOptions { getChildren(dataNode: T): T[] | undefined; @@ -18,6 +18,17 @@ export class NestedTreeModel extends TreeModel { return undefined; } + override toggleDescendants(id: TreeModelId, force?: boolean): void { + const node = this.#findById(id, this.dataNodes); + if (!node) { + return; + } + + this.#toggleChildren(node, force); + + this.dispatchEvent(new Event('sl-update')); + } + override toArray(): Array> { return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { const expandable = this.isExpandable(dataNode), @@ -54,4 +65,35 @@ export class NestedTreeModel extends TreeModel { return dataNodes; }, []); } + + #findById(id: TreeModelId, dataNodes: T[]): T | undefined { + for (const dataNode of dataNodes) { + if (this.getId(dataNode) === id) { + return dataNode; + } + + const children = this.getChildren(dataNode); + if (Array.isArray(children)) { + const found = this.#findById(id, children); + + if (found) { + return found; + } + } + } + + return undefined; + } + + #toggleChildren(dataNode: T, force?: boolean): void { + const children = this.getChildren(dataNode); + if (!Array.isArray(children)) { + return; + } + + for (const child of children) { + this.toggle(this.getId(child), force, false); + this.#toggleChildren(child, force); + } + } } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 458cac3773..afadbf3772 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -62,52 +62,54 @@ export abstract class TreeModel extends EventTarget { return undefined; } - toggle(id: TreeModelId): void { - if (this.isExpanded(id)) { - this.collapse(id); + toggle(id: TreeModelId, force?: boolean, emitEvent?: boolean): void { + if ((typeof force === 'boolean' && !force) || this.isExpanded(id)) { + this.collapse(id, emitEvent); } else { - this.expand(id); + this.expand(id, emitEvent); } } - expand(id: TreeModelId): void { + expand(id: TreeModelId, emitEvent = true): void { this.#expansion.add(id); - this.#update(); + this.#update(emitEvent); } - collapse(id: TreeModelId): void { + collapse(id: TreeModelId, emitEvent = true): void { this.#expansion.delete(id); - this.#update(); + this.#update(emitEvent); } isExpanded(id: TreeModelId): boolean { return this.#expansion.has(id); } - expandAll(): void { + expandAll(emitEvent = true): void { this.#expansion = new Set(this.dataNodes.map(n => this.getId(n))); - this.#update(); + this.#update(emitEvent); } - collapseAll(): void { + collapseAll(emitEvent = true): void { this.#expansion.clear(); - this.#update(); + this.#update(emitEvent); } - toggleDescendants(id: TreeModelId): void { - console.log('toggleDescendants', id); - } + abstract toggleDescendants(id: TreeModelId, force?: boolean): void; + expandDescendants(id: TreeModelId): void { - console.log('expandDescendants', id); + this.toggleDescendants(id, true); } + collapseDescendants(id: TreeModelId): void { - console.log('collapseDescendants', id); + this.toggleDescendants(id, false); } /** Flattens the tree to an array based on the expansion state. */ abstract toArray(): Array>; - #update(): void { - this.dispatchEvent(new Event('sl-update')); + #update(emitEvent: boolean): void { + if (emitEvent) { + this.dispatchEvent(new Event('sl-update')); + } } } From a3bedb4aae75bc3bcbba645a1ab7d8d3d8004747 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 16:20:26 +0100 Subject: [PATCH 25/88] =?UTF-8?q?=F0=9F=91=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-model.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index afadbf3772..414847df27 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -47,7 +47,11 @@ export abstract class TreeModel extends EventTarget { return false; } - /** Returns a string that is used as the label for the tree node. */ + /** + * 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(_dataNode: T): string { return ''; } @@ -62,6 +66,12 @@ export abstract class TreeModel extends EventTarget { return undefined; } + /** + * 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(id: TreeModelId, force?: boolean, emitEvent?: boolean): void { if ((typeof force === 'boolean' && !force) || this.isExpanded(id)) { this.collapse(id, emitEvent); @@ -70,36 +80,44 @@ export abstract class TreeModel extends EventTarget { } } + /** Expands a tree node. */ expand(id: TreeModelId, emitEvent = true): void { this.#expansion.add(id); this.#update(emitEvent); } + /** Collapses a tree node. */ collapse(id: TreeModelId, emitEvent = true): void { this.#expansion.delete(id); this.#update(emitEvent); } + /** Returns whether a tree node is expanded. */ isExpanded(id: TreeModelId): boolean { return this.#expansion.has(id); } + /** Expands all expandable tree nodes. */ expandAll(emitEvent = true): void { this.#expansion = new Set(this.dataNodes.map(n => this.getId(n))); this.#update(emitEvent); } + /** Collapses all expandable tree nodes. */ collapseAll(emitEvent = true): void { this.#expansion.clear(); this.#update(emitEvent); } + /** Toggles the expansion state of all descendants of a given tree node. */ abstract toggleDescendants(id: TreeModelId, force?: boolean): void; + /** Expands all descendants of a given tree node. */ expandDescendants(id: TreeModelId): void { this.toggleDescendants(id, true); } + /** Collapses all descendants of a given tree node. */ collapseDescendants(id: TreeModelId): void { this.toggleDescendants(id, false); } From b38638898a6a725bd99879d1b3e4bc830801f7a2 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 18:39:25 +0100 Subject: [PATCH 26/88] =?UTF-8?q?=F0=9F=9A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/flat-tree-model.ts | 6 ++++++ packages/components/tree/src/nested-tree-model.ts | 9 +++++++++ packages/components/tree/src/tree-model.ts | 9 +++------ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 3045587232..caee5151b3 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -18,6 +18,12 @@ export class FlatTreeModel extends TreeModel { return 0; } + override expandAll(): void { + this.dataNodes.filter(n => this.isExpandable(n)).forEach(dataNode => this.expand(this.getId(dataNode), false)); + + this.dispatchEvent(new Event('sl-update')); + } + override toggleDescendants(id: TreeModelId, force?: boolean): void { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index ec61a15f04..79f6c3f15d 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -18,6 +18,15 @@ export class NestedTreeModel extends TreeModel { return undefined; } + override expandAll(): void { + this.dataNodes.forEach(dataNode => { + this.expand(this.getId(dataNode), false); + this.#toggleChildren(dataNode, true); + }); + + this.dispatchEvent(new Event('sl-update')); + } + override toggleDescendants(id: TreeModelId, force?: boolean): void { const node = this.#findById(id, this.dataNodes); if (!node) { diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 414847df27..4912d25789 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -98,15 +98,12 @@ export abstract class TreeModel extends EventTarget { } /** Expands all expandable tree nodes. */ - expandAll(emitEvent = true): void { - this.#expansion = new Set(this.dataNodes.map(n => this.getId(n))); - this.#update(emitEvent); - } + abstract expandAll(): void; /** Collapses all expandable tree nodes. */ - collapseAll(emitEvent = true): void { + collapseAll(): void { this.#expansion.clear(); - this.#update(emitEvent); + this.#update(true); } /** Toggles the expansion state of all descendants of a given tree node. */ From c7381c7428219e9a8c8c48ebcad5e96e28c6b20e Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 18:52:43 +0100 Subject: [PATCH 27/88] =?UTF-8?q?=E2=9B=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/indent-guides.ts | 6 ++++++ packages/components/tree/src/tree-node.scss | 4 ++++ packages/components/tree/src/tree-node.ts | 3 +++ packages/components/tree/src/tree.stories.ts | 16 ++++++++++++---- packages/components/tree/src/tree.ts | 4 ++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/components/tree/src/indent-guides.ts b/packages/components/tree/src/indent-guides.ts index 395d1ccda5..f66a589d0d 100644 --- a/packages/components/tree/src/indent-guides.ts +++ b/packages/components/tree/src/indent-guides.ts @@ -21,6 +21,12 @@ export class IndentGuides extends LitElement { /** Level of indentation. */ @property({ type: Number, reflect: true }) level = 0; + override connectedCallback(): void { + super.connectedCallback(); + + this.setAttribute('aria-hidden', 'true'); + } + override updated(changes: PropertyValues): void { if (changes.has('level')) { this.style.setProperty('--guide-level', this.level.toString()); diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 3682157f97..50e4244e81 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -17,6 +17,10 @@ rotate: 90deg; } +:host([hide-guides]) sl-indent-guides { + --_guide-color: transparent; +} + :host([aria-selected='true']) { background: var(--sl-color-background-selected-subtle-idle); } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 785d064008..eb060b46ab 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -45,6 +45,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Indicates whether the node is expanded or collapsed. */ @property({ type: Boolean, reflect: true }) 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; diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 266476f8ac..f936e48cc4 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -9,7 +9,7 @@ import { FlatTreeModel } from './flat-tree-model.js'; import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick; +type Props = Pick; type Story = StoryObj; interface NestedDataNode { @@ -171,7 +171,9 @@ const nestedData: NestedDataNode[] = [ export default { title: 'Navigation/Tree', tags: ['draft'], - args: {}, + args: { + hideGuides: false + }, argTypes: { model: { table: { disable: true } @@ -181,7 +183,7 @@ export default { options: ['single', 'multiple'] } }, - render: ({ expanded, model, selected, selects }) => { + render: ({ expanded, hideGuides, model, selected, selects }) => { const onToggleTree = () => model?.toggle(4), onToggleTreeDescendants = () => model?.toggleDescendants(4), onExpandAll = () => model?.expandAll(), @@ -194,7 +196,13 @@ export default { Expand all Collapse all - + `; } } satisfies Meta; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index fe37e4bfda..85ad9b6517 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -51,6 +51,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The initial expanded tree nodes. */ @property({ type: Array }) expanded?: Array>; + /** Hides the indentation guides when set. */ + @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; + get model() { return this.#model; } @@ -138,6 +141,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { ?checked=${selected && this.selects === 'multiple'} ?expandable=${expandable} ?expanded=${expanded} + ?hide-guides=${this.hideGuides} ?last-node-in-level=${lastNodeInLevel} ?selected=${selected && this.selects === 'single'} .level=${level} From 9e937cd50b9eec5ff23e187105e03079da5558df Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 19:30:20 +0100 Subject: [PATCH 28/88] =?UTF-8?q?=F0=9F=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/indent-guides.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/components/tree/src/indent-guides.ts b/packages/components/tree/src/indent-guides.ts index f66a589d0d..64c0186b28 100644 --- a/packages/components/tree/src/indent-guides.ts +++ b/packages/components/tree/src/indent-guides.ts @@ -34,8 +34,6 @@ export class IndentGuides extends LitElement { } override render(): TemplateResult[] { - return Array(this.level) - .fill(0) - .map(() => html`
`); + return Array.from({ length: this.level }).map(() => html`
`); } } From 22f28808ee9452df49401719efdbaadab35359e2 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 30 Dec 2024 20:16:33 +0100 Subject: [PATCH 29/88] =?UTF-8?q?=F0=9F=8C=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.scss | 4 ++- packages/components/tree/src/tree-node.ts | 28 +++++++++++++++++---- packages/components/tree/src/tree.ts | 21 +++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 50e4244e81..2d05ccfcd9 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -2,6 +2,7 @@ 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); @@ -21,8 +22,9 @@ --_guide-color: transparent; } -:host([aria-selected='true']) { +:host([selected]) { background: var(--sl-color-background-selected-subtle-idle); + color: var(--sl-color-text-selected); } :host(:focus-visible) { diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index eb060b46ab..c6cbf07043 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -2,7 +2,7 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; import { type EventEmitter, EventsController, event } from '@sl-design-system/shared'; -import { type SlChangeEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; +import { type SlChangeEvent, type SlSelectEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { IndentGuides } from './indent-guides.js'; @@ -14,7 +14,8 @@ declare global { } } -export class TreeNode extends ScopedElementsMixin(LitElement) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class TreeNode extends ScopedElementsMixin(LitElement) { /** @internal */ static override styles: CSSResultGroup = styles; @@ -36,6 +37,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Determines whether the checkbox is checked or not. */ @property({ type: Boolean }) checked?: boolean; + /** The node data. */ + @property({ attribute: false }) data?: T; + /** Whether the node is disabled. */ @property({ type: Boolean, reflect: true }) disabled?: boolean; @@ -57,6 +61,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** The depth level of this node, 0 being the root of the tree. */ @property({ type: Number, reflect: true }) level = 0; + /** @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; @@ -76,7 +83,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { override updated(changes: PropertyValues): void { super.updated(changes); - if (changes.has('selects')) { + 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 { @@ -136,8 +143,19 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.indeterminate = false; } - #onClick(): void { - if (this.expandable) { + #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.selectEvent.emit(this.data!); + } else if (this.expandable) { this.toggle(); } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 85ad9b6517..2e2f431a3c 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,8 +1,8 @@ import { virtualize } 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 { RovingTabindexController, SelectionController } from '@sl-design-system/shared'; -import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { type EventEmitter, RovingTabindexController, SelectionController, event } from '@sl-design-system/shared'; +import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { TreeModel, type TreeModelArrayItem, type TreeModelId } from './tree-model.js'; @@ -72,6 +72,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; + /** @internal Emits when the user selects a tree node. */ + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; + /** Contains the selection state for the tree when `selects` is defined. */ readonly selection = new SelectionController(this); @@ -119,7 +122,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { setTimeout(() => this.#rovingTabindexController.clearElementCache(), 100); return html` -
+
${virtualize({ items, keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), @@ -134,6 +137,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { icon = this.model!.getIcon(dataNode, expanded), selected = this.selection.isSelected(this.model!.getId(dataNode)); + console.log('renderItem', dataNode, selected); + return html` ) => this.#onChange(event, dataNode)} @@ -144,6 +149,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { ?hide-guides=${this.hideGuides} ?last-node-in-level=${lastNodeInLevel} ?selected=${selected && this.selects === 'single'} + .data=${dataNode} .level=${level} .selects=${this.selects} > @@ -161,6 +167,15 @@ export class Tree extends ScopedElementsMixin(LitElement) { console.log('event', event, event.detail, item); } + #onSelect(event: SlSelectEvent): void { + event.preventDefault(); + event.stopPropagation(); + + console.log('select', this.model!.getId(event.detail)); + + this.selection.select(this.model!.getId(event.detail)); + } + #onToggle(item: T): void { this.model?.toggle(this.model?.getId(item)); } From eaf8efbfe7d9498212a4e5bf9fecc661dcfdc153 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 31 Dec 2024 14:49:02 +0100 Subject: [PATCH 30/88] =?UTF-8?q?=F0=9F=8C=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.stories.ts | 73 ++++++++++++++++++-- packages/components/tree/src/tree.ts | 18 ++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index f936e48cc4..d1cbe81290 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -1,26 +1,38 @@ -import { faFile, faFolder, faFolderOpen } from '@fortawesome/pro-regular-svg-icons'; +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 { type Meta, type StoryObj } from '@storybook/web-components'; -import { html } from 'lit'; +import { html, nothing } from 'lit'; import '../register.js'; import { FlatTreeModel } from './flat-tree-model.js'; import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick; +type Props = Pick< + Tree, + 'expanded' | 'hideGuides' | 'model' | 'renderer' | 'scopedElements' | 'selected' | 'selects' +> & { styles?: string }; type Story = StoryObj; +interface FlatDataNode { + id: number; + expandable: boolean; + level: number; + name: string; +} + interface NestedDataNode { id: number; name: string; children?: NestedDataNode[]; } -Icon.register(faFile, faFolder, faFolderOpen); +Icon.register(faFile, faFolder, faFolderOpen, faPen, faTrash); -const flatData = [ +const flatData: FlatDataNode[] = [ { id: 0, expandable: true, @@ -178,18 +190,31 @@ export default { model: { table: { disable: true } }, + renderer: { + table: { disable: true } + }, selects: { control: 'inline-radio', options: ['single', 'multiple'] + }, + styles: { + table: { disable: true } } }, - render: ({ expanded, hideGuides, model, selected, selects }) => { + render: ({ expanded, hideGuides, model, renderer, scopedElements, selected, selects, styles }) => { const onToggleTree = () => model?.toggle(4), onToggleTreeDescendants = () => model?.toggleDescendants(4), onExpandAll = () => model?.expandAll(), onCollapseAll = () => model?.collapseAll(); return html` + ${styles + ? html` + + ` + : nothing} Toggle "tree" Toggle all below "tree" @@ -200,6 +225,8 @@ export default { ?hide-guides=${hideGuides} .expanded=${expanded} .model=${model} + .renderer=${renderer} + .scopedElements=${scopedElements} .selected=${selected} .selects=${selects} > @@ -248,3 +275,37 @@ export const MultiSelect: Story = { selects: 'multiple' } }; + +export const CustomRenderer: Story = { + args: { + ...FlatModel.args, + renderer: (node, { expanded }) => { + const { name } = node as FlatDataNode, + icon = name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`; + + return html` + ${icon ? html`` : nothing} + ${name} + + + + + + + + + `; + }, + scopedElements: { + 'sl-button': Button, + 'sl-button-bar': ButtonBar, + 'sl-icon': Icon + }, + styles: ` + sl-tree::part(button-bar) { + flex: inherit; + margin-inline-start: auto; + } + ` + } +}; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 2e2f431a3c..df4f749894 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -72,6 +72,14 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** 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>; @@ -101,6 +109,14 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } + 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); + } + } + } + if (changes.has('selects')) { this.selection.multiple = this.selects === 'multiple'; } @@ -171,8 +187,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { event.preventDefault(); event.stopPropagation(); - console.log('select', this.model!.getId(event.detail)); - this.selection.select(this.model!.getId(event.detail)); } From 348a96a568cc72e4dec4dc4b429affb1f2aaa191 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 31 Dec 2024 15:00:56 +0100 Subject: [PATCH 31/88] =?UTF-8?q?=F0=9F=8D=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index c6cbf07043..2adb5e5199 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -161,9 +161,14 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } #onKeydown(event: KeyboardEvent): void { - if (!this.expandable) { - return; - } else if ((event.key === 'ArrowRight' && !this.expanded) || (event.key === 'ArrowLeft' && this.expanded)) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + + this.selectEvent.emit(this.data!); + } else if ( + this.expandable && + ((event.key === 'ArrowRight' && !this.expanded) || (event.key === 'ArrowLeft' && this.expanded)) + ) { this.toggle(); } } From 9780362485ba686344ac7476060c91e261e6709f Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 31 Dec 2024 15:28:08 +0100 Subject: [PATCH 32/88] =?UTF-8?q?=F0=9F=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 17 +++-- packages/components/tree/src/indent-guides.ts | 4 ++ .../components/tree/src/nested-tree-model.ts | 45 +++++++----- packages/components/tree/src/tree-model.ts | 3 + packages/components/tree/src/tree-node.ts | 6 +- packages/components/tree/src/tree.ts | 70 +++++++++++++++---- 6 files changed, 111 insertions(+), 34 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index caee5151b3..78db5abb29 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -24,14 +24,15 @@ export class FlatTreeModel extends TreeModel { this.dispatchEvent(new Event('sl-update')); } - override toggleDescendants(id: TreeModelId, force?: boolean): void { + override getDescendants(id: TreeModelId): T[] { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { - return; + return []; } const index = this.dataNodes.indexOf(node), - level = this.getLevel(node); + level = this.getLevel(node), + descendants: T[] = []; for (let i = index + 1; i < this.dataNodes.length; i++) { const nextNode = this.dataNodes[i]; @@ -40,9 +41,17 @@ export class FlatTreeModel extends TreeModel { break; } - this.toggle(this.getId(nextNode), force, false); + descendants.push(nextNode); } + return descendants; + } + + override toggleDescendants(id: TreeModelId, force?: boolean): void { + this.getDescendants(id).forEach(nextNode => { + this.toggle(this.getId(nextNode), force, false); + }); + this.dispatchEvent(new Event('sl-update')); } diff --git a/packages/components/tree/src/indent-guides.ts b/packages/components/tree/src/indent-guides.ts index 64c0186b28..5804347b1d 100644 --- a/packages/components/tree/src/indent-guides.ts +++ b/packages/components/tree/src/indent-guides.ts @@ -8,6 +8,10 @@ declare global { } } +/** + * 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; diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 79f6c3f15d..d9c98f129c 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -20,20 +20,43 @@ export class NestedTreeModel extends TreeModel { override expandAll(): void { this.dataNodes.forEach(dataNode => { - this.expand(this.getId(dataNode), false); - this.#toggleChildren(dataNode, true); + const id = this.getId(dataNode); + + this.expand(id, false); + + this.getDescendants(id).forEach(nextNode => { + this.expand(this.getId(nextNode), false); + }); }); this.dispatchEvent(new Event('sl-update')); } - override toggleDescendants(id: TreeModelId, force?: boolean): void { + override getDescendants(id: TreeModelId): T[] { const node = this.#findById(id, this.dataNodes); if (!node) { - return; + return []; } - this.#toggleChildren(node, force); + const descendants: T[] = []; + + const traverse = (dataNode: T) => { + const children = this.getChildren(dataNode); + if (Array.isArray(children)) { + descendants.push(...children); + children.forEach(traverse); + } + }; + + traverse(node); + + return descendants; + } + + override toggleDescendants(id: TreeModelId, force?: boolean): void { + this.getDescendants(id).forEach(nextNode => { + this.toggle(this.getId(nextNode), force, false); + }); this.dispatchEvent(new Event('sl-update')); } @@ -93,16 +116,4 @@ export class NestedTreeModel extends TreeModel { return undefined; } - - #toggleChildren(dataNode: T, force?: boolean): void { - const children = this.getChildren(dataNode); - if (!Array.isArray(children)) { - return; - } - - for (const child of children) { - this.toggle(this.getId(child), force, false); - this.#toggleChildren(child, force); - } - } } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 4912d25789..bda3516525 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -106,6 +106,9 @@ export abstract class TreeModel extends EventTarget { this.#update(true); } + /** Returns an array of all the descendants of a given tree node. */ + abstract getDescendants(id: TreeModelId): T[]; + /** Toggles the expansion state of all descendants of a given tree node. */ abstract toggleDescendants(id: TreeModelId, force?: boolean): void; diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 2adb5e5199..bcaf4814b8 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -10,10 +10,14 @@ import styles from './tree-node.scss.js'; declare global { interface HTMLElementTagNameMap { - 'sl-tree-item': TreeNode; + 'sl-tree-node': TreeNode; } } +/** + * A tree node component. Used to represent a node in a tree. This component + * is not public API and is used internally by ``. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export class TreeNode extends ScopedElementsMixin(LitElement) { /** @internal */ diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index df4f749894..7f6a1406fb 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -24,6 +24,10 @@ export interface TreeItemRendererOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; +/** + * A tree component. Supports both flat and nested data structures. Use this if you + * have hierarchical data that you want to display in a tree-like structure. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Tree extends ScopedElementsMixin(LitElement) { /** @internal */ @@ -149,38 +153,80 @@ export class Tree extends ScopedElementsMixin(LitElement) { } renderItem(item: TreeModelArrayItem): TemplateResult { - const { dataNode, expandable, expanded, lastNodeInLevel, level } = item, + const isSelected = (node: T) => this.selection.isSelected(this.model!.getId(node)), + { dataNode, expandable, expanded, lastNodeInLevel, level } = item, icon = this.model!.getIcon(dataNode, expanded), - selected = this.selection.isSelected(this.model!.getId(dataNode)); + selected = isSelected(dataNode); - console.log('renderItem', dataNode, selected); + let checked = false, + indeterminate = false; + if (this.selects === 'multiple') { + checked = !expandable && selected; + + if (expandable) { + const descendants = this.model!.getDescendants(this.model!.getId(dataNode)).filter( + n => !this.model!.isExpandable(n) + ); + + const someChecked = descendants.some(isSelected); + + if (someChecked) { + const allChecked = descendants.every(isSelected); + + if (allChecked) { + checked = true; + } else if (someChecked) { + indeterminate = true; + } + } + } + } return html` ) => this.#onChange(event, dataNode)} @sl-toggle=${() => this.#onToggle(dataNode)} - ?checked=${selected && this.selects === 'multiple'} + ?checked=${checked} ?expandable=${expandable} ?expanded=${expanded} ?hide-guides=${this.hideGuides} + ?indeterminate=${indeterminate} ?last-node-in-level=${lastNodeInLevel} ?selected=${selected && this.selects === 'single'} .data=${dataNode} .level=${level} .selects=${this.selects} > - ${this.renderer - ? this.renderer(dataNode, { expanded, expandable }) - : html` - ${icon ? html`` : nothing} - ${this.model!.getLabel(dataNode)} - `} + ${this.renderer?.(dataNode, { expanded, expandable }) ?? + html` + ${icon ? html`` : nothing} + ${this.model!.getLabel(dataNode)} + `} `; } - #onChange(event: SlChangeEvent, item: T): void { - console.log('event', event, event.detail, item); + #onChange(event: SlChangeEvent, node: T): void { + const id = this.model!.getId(node), + expandable = this.model!.isExpandable(node); + + if (expandable) { + const descendants = this.model!.getDescendants(id).filter(n => !this.model!.isExpandable(n)); + + descendants.forEach(n => { + if (event.detail) { + this.selection.select(this.model!.getId(n)); + } else { + this.selection.deselect(this.model!.getId(n)); + } + }); + } else { + if (event.detail) { + this.selection.select(id); + } else { + this.selection.deselect(id); + } + } } #onSelect(event: SlSelectEvent): void { From 810f327b5c099cb795bcb13fa751e4a8fc32addb Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 31 Dec 2024 16:41:13 +0100 Subject: [PATCH 33/88] =?UTF-8?q?=F0=9F=9A=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 50 +++++++++++++---- .../components/tree/src/nested-tree-model.ts | 53 ++++++++++++------- packages/components/tree/src/tree-model.ts | 45 +++++++++++----- packages/components/tree/src/tree-node.ts | 19 ++++--- packages/components/tree/src/tree.ts | 8 ++- 5 files changed, 126 insertions(+), 49 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 78db5abb29..52112c8098 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -18,12 +18,6 @@ export class FlatTreeModel extends TreeModel { return 0; } - override expandAll(): void { - this.dataNodes.filter(n => this.isExpandable(n)).forEach(dataNode => this.expand(this.getId(dataNode), false)); - - this.dispatchEvent(new Event('sl-update')); - } - override getDescendants(id: TreeModelId): T[] { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { @@ -47,12 +41,46 @@ export class FlatTreeModel extends TreeModel { return descendants; } - override toggleDescendants(id: TreeModelId, force?: boolean): void { - this.getDescendants(id).forEach(nextNode => { - this.toggle(this.getId(nextNode), force, false); - }); + override getParent(id: TreeModelId): T | undefined { + const node = this.dataNodes.find(n => this.getId(n) === id); + if (!node) { + return undefined; + } + + const level = this.getLevel(node); + + for (let i = this.dataNodes.indexOf(node) - 1; i >= 0; i--) { + const prevNode = this.dataNodes[i]; + + if (this.getLevel(prevNode) < level) { + return prevNode; + } + } + + return undefined; + } + + override getSiblings(id: TreeModelId): T[] { + const node = this.dataNodes.find(n => this.getId(n) === id); + if (!node) { + return []; + } + + const index = this.dataNodes.indexOf(node), + level = this.getLevel(node), + siblings: T[] = []; + + for (let i = index + 1; i < this.dataNodes.length; i++) { + const nextNode = this.dataNodes[i]; + + if (this.getLevel(nextNode) === level) { + break; + } + + siblings.push(nextNode); + } - this.dispatchEvent(new Event('sl-update')); + return siblings; } override toArray(): Array> { diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index d9c98f129c..1878b09f72 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -18,20 +18,6 @@ export class NestedTreeModel extends TreeModel { return undefined; } - override expandAll(): void { - this.dataNodes.forEach(dataNode => { - const id = this.getId(dataNode); - - this.expand(id, false); - - this.getDescendants(id).forEach(nextNode => { - this.expand(this.getId(nextNode), false); - }); - }); - - this.dispatchEvent(new Event('sl-update')); - } - override getDescendants(id: TreeModelId): T[] { const node = this.#findById(id, this.dataNodes); if (!node) { @@ -42,6 +28,7 @@ export class NestedTreeModel extends TreeModel { const traverse = (dataNode: T) => { const children = this.getChildren(dataNode); + if (Array.isArray(children)) { descendants.push(...children); children.forEach(traverse); @@ -53,12 +40,40 @@ export class NestedTreeModel extends TreeModel { return descendants; } - override toggleDescendants(id: TreeModelId, force?: boolean): void { - this.getDescendants(id).forEach(nextNode => { - this.toggle(this.getId(nextNode), force, false); - }); + override getParent(id: TreeModelId): T | undefined { + const traverse = (dataNodes: T[]): T | undefined => { + for (const dataNode of dataNodes) { + const children = this.getChildren(dataNode); + + if (Array.isArray(children)) { + if (children.find(child => this.getId(child) === id)) { + return dataNode; + } else { + const found = traverse(children); + + if (found) { + return found; + } + } + } + } + + return undefined; + }; + + return traverse(this.dataNodes); + } + + override getSiblings(id: TreeModelId): T[] { + for (const dataNode of this.dataNodes) { + const children = this.getChildren(dataNode); + + if (Array.isArray(children) && children.find(child => this.getId(child) === id)) { + return children; + } + } - this.dispatchEvent(new Event('sl-update')); + return []; } override toArray(): Array> { diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index bda3516525..61bfbeaca3 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -42,10 +42,17 @@ export abstract class TreeModel extends EventTarget { this.isExpandable = options.isExpandable; } - /** Returns whether the given node is expandable. */ - isExpandable(_dataNode: T): boolean { - return false; - } + /** Returns an array of all the descendants of a given tree node. */ + abstract getDescendants(id: TreeModelId): T[]; + + /** Returns the parent node or `undefined` if the node is a root node. */ + abstract getParent(id: TreeModelId): T | undefined; + + /** Returns an array of all siblings of a given tree node. */ + abstract getSiblings(id: TreeModelId): T[]; + + /** Flattens the tree to an array based on the expansion state. */ + abstract toArray(): Array>; /** * Returns a string that is used as the label for the tree node. @@ -66,6 +73,11 @@ export abstract class TreeModel extends EventTarget { return undefined; } + /** Returns whether the given node is expandable. */ + isExpandable(_dataNode: T): boolean { + return false; + } + /** * Toggles the expansion state of a tree node. You can optionally force the * state to a specific value using the `force` parameter. The `emitEvent` @@ -98,7 +110,16 @@ export abstract class TreeModel extends EventTarget { } /** Expands all expandable tree nodes. */ - abstract expandAll(): void; + expandAll(): void { + this.dataNodes.forEach(node => { + const id = this.getId(node); + + this.expand(id, false); + this.expandDescendants(id); + }); + + this.dispatchEvent(new Event('sl-update')); + } /** Collapses all expandable tree nodes. */ collapseAll(): void { @@ -106,11 +127,14 @@ export abstract class TreeModel extends EventTarget { this.#update(true); } - /** Returns an array of all the descendants of a given tree node. */ - abstract getDescendants(id: TreeModelId): T[]; - /** Toggles the expansion state of all descendants of a given tree node. */ - abstract toggleDescendants(id: TreeModelId, force?: boolean): void; + toggleDescendants(id: TreeModelId, force?: boolean): void { + this.getDescendants(id).forEach(nextNode => { + this.toggle(this.getId(nextNode), force, false); + }); + + this.dispatchEvent(new Event('sl-update')); + } /** Expands all descendants of a given tree node. */ expandDescendants(id: TreeModelId): void { @@ -122,9 +146,6 @@ export abstract class TreeModel extends EventTarget { this.toggleDescendants(id, false); } - /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(): Array>; - #update(emitEvent: boolean): void { if (emitEvent) { this.dispatchEvent(new Event('sl-update')); diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index bcaf4814b8..194b7a585c 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -165,15 +165,22 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } #onKeydown(event: KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { + if (event.key === 'Enter') { event.preventDefault(); this.selectEvent.emit(this.data!); - } else if ( - this.expandable && - ((event.key === 'ArrowRight' && !this.expanded) || (event.key === 'ArrowLeft' && this.expanded)) - ) { - this.toggle(); + } else if (event.key === 'ArrowLeft') { + if (this.expandable && this.expanded) { + this.toggle(); + } else { + console.log('Focus the parent'); + } + } else if (event.key === 'ArrowRight') { + if (this.expandable && !this.expanded) { + this.toggle(); + } else if (this.expanded) { + console.log('Focus the first child'); + } } } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 7f6a1406fb..d3a4862cae 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -142,7 +142,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { setTimeout(() => this.#rovingTabindexController.clearElementCache(), 100); return html` -
+
${virtualize({ items, keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), @@ -229,6 +229,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } + #onKeydown(event: KeyboardEvent): void { + if (event.key === '*' && event.target instanceof TreeNode) { + console.log('Expand all siblings'); + } + } + #onSelect(event: SlSelectEvent): void { event.preventDefault(); event.stopPropagation(); From af4263f91694288f8915d4180c23fb55f03b540b Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 1 Jan 2025 11:10:31 +0100 Subject: [PATCH 34/88] =?UTF-8?q?=F0=9F=8C=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 22 +++++++++++++++---- packages/components/tree/src/tree-node.ts | 9 ++++---- packages/components/tree/src/tree.ts | 10 ++++++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 52112c8098..b5e6085ddc 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -70,14 +70,28 @@ export class FlatTreeModel extends TreeModel { level = this.getLevel(node), siblings: T[] = []; - for (let i = index + 1; i < this.dataNodes.length; i++) { - const nextNode = this.dataNodes[i]; + // Get siblings before the node + for (let i = index - 1; i >= 0; i--) { + const prevNode = this.dataNodes[i], + prevLevel = this.getLevel(prevNode); - if (this.getLevel(nextNode) === level) { + if (prevLevel < level) { break; + } else if (prevLevel === level) { + siblings.unshift(prevNode); } + } - siblings.push(nextNode); + // Get siblings after the node + for (let i = index + 1; i < this.dataNodes.length; i++) { + const nextNode = this.dataNodes[i], + nextLevel = this.getLevel(nextNode); + + if (nextLevel < level) { + break; + } else if (nextLevel === level) { + siblings.push(nextNode); + } } return siblings; diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 194b7a585c..131efef10e 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -164,6 +164,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } } + /** See https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction */ #onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { event.preventDefault(); @@ -172,14 +173,14 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } else if (event.key === 'ArrowLeft') { if (this.expandable && this.expanded) { this.toggle(); - } else { - console.log('Focus the parent'); + } else if (this.previousElementSibling instanceof TreeNode) { + this.previousElementSibling?.focus(); } } else if (event.key === 'ArrowRight') { if (this.expandable && !this.expanded) { this.toggle(); - } else if (this.expanded) { - console.log('Focus the first child'); + } else if (this.expanded && this.nextElementSibling instanceof TreeNode) { + this.nextElementSibling?.focus(); } } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index d3a4862cae..db96642324 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -230,8 +230,16 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #onKeydown(event: KeyboardEvent): void { + // 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.target instanceof TreeNode) { - console.log('Expand all siblings'); + event.preventDefault(); + + const id = this.model?.getId(event.target.data as T); + + this.model?.getSiblings(id).forEach(sibling => { + this.model?.expand(this.model.getId(sibling)); + }); } } From e74add9d98b58c71071a2dced4144f31d755a0cc Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 1 Jan 2025 14:46:31 +0100 Subject: [PATCH 35/88] =?UTF-8?q?=F0=9F=8D=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.ts | 19 ++++++++++++++----- packages/components/tree/src/tree.ts | 1 - 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 131efef10e..5a6e6bc066 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -147,6 +147,11 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.indeterminate = false; } + /** + * 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"]'); @@ -171,16 +176,20 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.selectEvent.emit(this.data!); } else if (event.key === 'ArrowLeft') { - if (this.expandable && this.expanded) { + if (this.expanded) { + event.preventDefault(); + this.toggle(); - } else if (this.previousElementSibling instanceof TreeNode) { - this.previousElementSibling?.focus(); + } else if (this.level === 0) { + event.preventDefault(); } } else if (event.key === 'ArrowRight') { if (this.expandable && !this.expanded) { + event.preventDefault(); + this.toggle(); - } else if (this.expanded && this.nextElementSibling instanceof TreeNode) { - this.nextElementSibling?.focus(); + } else if (!this.expandable) { + event.preventDefault(); } } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index db96642324..b6def423a7 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -47,7 +47,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), - direction: 'vertical', elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) || [], isFocusableElement: (el: TreeNode) => !el.disabled }); From a49e2d93dbf145e2334d61fc608c3b24ae2ec8ca Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 1 Jan 2025 15:27:27 +0100 Subject: [PATCH 36/88] =?UTF-8?q?=F0=9F=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/indent-guides.scss | 16 +++++++--------- packages/components/tree/src/indent-guides.ts | 10 ++-------- packages/components/tree/src/tree-node.scss | 6 +----- packages/components/tree/src/tree-node.ts | 14 +++++++++----- packages/components/tree/src/tree.ts | 12 ++++++++++-- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/components/tree/src/indent-guides.scss b/packages/components/tree/src/indent-guides.scss index 3551955e6a..63eafb55fe 100644 --- a/packages/components/tree/src/indent-guides.scss +++ b/packages/components/tree/src/indent-guides.scss @@ -1,16 +1,14 @@ :host { - --_expander-indent: 1.125rem; - --_guide-color: var(--sl-color-palette-neutral-100); - --_guide-size: 1px; - --_level-indent: 0.75rem; + --_guide-color: var(--sl-color-border-plain); + --_guide-size: var(--sl-size-borderWidth-subtle); align-items: stretch; display: flex; - padding-inline-end: var(--_expander-indent); + padding-inline-end: calc(var(--sl-space-200) + var(--sl-space-025)); } :host([expandable]) { - --_expander-indent: 0px; + padding-inline-end: 0; } :host([last-node-in-level]) [part='guide']:last-child { @@ -21,10 +19,10 @@ &::before { block-size: 100%; border-block-end: var(--_guide-size) solid var(--_guide-color); - border-end-start-radius: 4px; + border-end-start-radius: var(--sl-size-050); border-inline-start: var(--_guide-size) solid var(--_guide-color); content: ''; - inline-size: 0.25rem; + inline-size: var(--sl-space-050); inset: 100% auto auto 0; position: absolute; } @@ -33,5 +31,5 @@ [part='guide'] { background: var(--_guide-color); inline-size: var(--_guide-size); - margin-inline-start: var(--_level-indent); + 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 index 5804347b1d..46250d6292 100644 --- a/packages/components/tree/src/indent-guides.ts +++ b/packages/components/tree/src/indent-guides.ts @@ -1,4 +1,4 @@ -import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit'; +import { type CSSResultGroup, LitElement, type TemplateResult, html } from 'lit'; import { property } from 'lit/decorators.js'; import styles from './indent-guides.scss.js'; @@ -23,7 +23,7 @@ export class IndentGuides extends LitElement { @property({ type: Boolean, attribute: 'last-node-in-level', reflect: true }) lastNodeInLevel?: boolean; /** Level of indentation. */ - @property({ type: Number, reflect: true }) level = 0; + @property({ type: Number }) level = 0; override connectedCallback(): void { super.connectedCallback(); @@ -31,12 +31,6 @@ export class IndentGuides extends LitElement { this.setAttribute('aria-hidden', 'true'); } - override updated(changes: PropertyValues): void { - if (changes.has('level')) { - this.style.setProperty('--guide-level', this.level.toString()); - } - } - override render(): TemplateResult[] { return Array.from({ length: this.level }).map(() => html`
`); } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 2d05ccfcd9..8cc5f2cfb2 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -10,11 +10,7 @@ transition: background 0.2s ease-in-out; } -:host(:not([expandable])) { - --_expander-indent: var(--_expander-size); -} - -:host([expanded]) sl-icon { +:host([aria-expanded='true']) sl-icon { rotate: 90deg; } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 5a6e6bc066..87d4895224 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -51,7 +51,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { @property({ type: Boolean }) expandable?: boolean; /** Indicates whether the node is expanded or collapsed. */ - @property({ type: Boolean, reflect: true }) expanded?: boolean; + @property({ type: Boolean }) expanded?: boolean; /** Hides the indentation guides when set. */ @property({ type: Boolean, attribute: 'hide-guides', reflect: true }) hideGuides?: boolean; @@ -63,7 +63,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { @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, reflect: true }) level = 0; + @property({ type: Number }) level = 0; /** @internal Emits when the user clicks a the wrapper part of the tree node. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; @@ -95,14 +95,18 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } if (this.selects === 'single') { - this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); + this.setAttribute('aria-selected', Boolean(this.selected).toString()); } else { this.removeAttribute('aria-selected'); } } - if (changes.has('expanded')) { - this.toggleAttribute('aria-expanded', this.expanded); + if (changes.has('expandable') || changes.has('expanded')) { + if (this.expandable) { + this.setAttribute('aria-expanded', Boolean(this.expanded).toString()); + } else { + this.removeAttribute('aria-expanded'); + } } } diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index b6def423a7..90ad7d8f2e 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -122,6 +122,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { if (changes.has('selects')) { this.selection.multiple = this.selects === 'multiple'; + + if (this.selects === 'multiple') { + this.setAttribute('aria-multiselectable', 'true'); + } else { + this.removeAttribute('aria-multiselectable'); + } } if (changes.has('selected')) { @@ -145,13 +151,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { ${virtualize({ items, keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), - renderItem: (item: TreeModelArrayItem) => this.renderItem(item) + renderItem: (item: TreeModelArrayItem) => this.renderItem(item, items) })}
`; } - renderItem(item: TreeModelArrayItem): TemplateResult { + renderItem(item: TreeModelArrayItem, items: Array>): TemplateResult { const isSelected = (node: T) => this.selection.isSelected(this.model!.getId(node)), { dataNode, expandable, expanded, lastNodeInLevel, level } = item, icon = this.model!.getIcon(dataNode, expanded), @@ -195,6 +201,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { .data=${dataNode} .level=${level} .selects=${this.selects} + aria-level=${level} + aria-setsize=${items.length} > ${this.renderer?.(dataNode, { expanded, expandable }) ?? html` From b17f94178999817268d8703214acdfbaf9bd4957 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 1 Jan 2025 15:40:43 +0100 Subject: [PATCH 37/88] =?UTF-8?q?=F0=9F=90=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/indent-guides.scss | 12 +++++------- packages/components/tree/src/tree-node.scss | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/components/tree/src/indent-guides.scss b/packages/components/tree/src/indent-guides.scss index 63eafb55fe..0f877f65b9 100644 --- a/packages/components/tree/src/indent-guides.scss +++ b/packages/components/tree/src/indent-guides.scss @@ -1,8 +1,6 @@ :host { - --_guide-color: var(--sl-color-border-plain); - --_guide-size: var(--sl-size-borderWidth-subtle); - align-items: stretch; + color: var(--sl-color-border-plain); display: flex; padding-inline-end: calc(var(--sl-space-200) + var(--sl-space-025)); } @@ -18,9 +16,9 @@ &::before { block-size: 100%; - border-block-end: var(--_guide-size) solid var(--_guide-color); + border-block-end: var(--sl-size-borderWidth-subtle) solid currentcolor; border-end-start-radius: var(--sl-size-050); - border-inline-start: var(--_guide-size) solid var(--_guide-color); + border-inline-start: var(--sl-size-borderWidth-subtle) solid currentcolor; content: ''; inline-size: var(--sl-space-050); inset: 100% auto auto 0; @@ -29,7 +27,7 @@ } [part='guide'] { - background: var(--_guide-color); - inline-size: var(--_guide-size); + background: currentcolor; + inline-size: var(--sl-size-borderWidth-subtle); margin-inline-start: var(--sl-space-150); } diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 8cc5f2cfb2..d8a37a2b8a 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -15,7 +15,7 @@ } :host([hide-guides]) sl-indent-guides { - --_guide-color: transparent; + color: transparent; } :host([selected]) { From 7ec82231087ec103915057a7c628e21efaf03c89 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 1 Jan 2025 19:18:48 +0100 Subject: [PATCH 38/88] =?UTF-8?q?=F0=9F=8E=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts index 938fd9569e..364d019993 100644 --- a/packages/components/tree/index.ts +++ b/packages/components/tree/index.ts @@ -1,5 +1,4 @@ export * from './src/flat-tree-model.js'; export * from './src/nested-tree-model.js'; export * from './src/tree-model.js'; -export * from './src/tree-node.js'; export * from './src/tree.js'; From c18f68a2b4293017733582b8e785759f59b3546e Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 09:09:25 +0100 Subject: [PATCH 39/88] =?UTF-8?q?=F0=9F=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/checkbox/src/checkbox.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts index 8f65827ab8..8ecb73a179 100644 --- a/packages/components/checkbox/src/checkbox.ts +++ b/packages/components/checkbox/src/checkbox.ts @@ -192,7 +192,8 @@ 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(); } From 0a3a2fde6e961a852d172bc8f3d0f130a14107c7 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 09:10:42 +0100 Subject: [PATCH 40/88] =?UTF-8?q?=F0=9F=8F=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/stories/grades.stories.ts | 201 --------------------------- 1 file changed, 201 deletions(-) delete mode 100644 .storybook/stories/grades.stories.ts diff --git a/.storybook/stories/grades.stories.ts b/.storybook/stories/grades.stories.ts deleted file mode 100644 index 1d9ff068dd..0000000000 --- a/.storybook/stories/grades.stories.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { faPlus } from '@fortawesome/pro-regular-svg-icons'; -import '@sl-design-system/breadcrumbs/register.js'; -import '@sl-design-system/button/register.js'; -import { Icon } from '@sl-design-system/icon'; -import { type Person, getPeople } from '@sl-design-system/example-data'; -import '@sl-design-system/grid/register.js'; -import '@sl-design-system/icon/register.js'; -import '@sl-design-system/select/register.js'; -import '@sl-design-system/switch/register.js'; -import '@sl-design-system/text-field/register.js'; -import '@sl-design-system/tree/register.js'; -import { FlatTreeModel } from '@sl-design-system/tree'; -import { type StoryObj } from '@storybook/web-components'; -import { html } from 'lit'; - -type Story = StoryObj; - -Icon.register(faPlus); - -export default { - title: 'Experiments/Grades', - parameters: { - layout: 'fullscreen' - } -} - -export const Default: Story = { - loaders: [async () => ({ people: (await getPeople()).people })], - render: (_, { loaded: { people } }) => { - const courses = [ - { id: 1, name: 'Group 1', level: 0 }, - { id: 3, name: 'Mathematics', level: 1 }, - { id: 4, name: 'Science', level: 1 }, - { id: 5, name: 'History', level: 1 }, - { id: 2, name: 'Group 2', level: 0 }, - { id: 6, name: 'Algebra', level: 1 }, - { id: 7, name: 'Geometry', level: 1 }, - { id: 8, name: 'Physics', level: 1 }, - { id: 9, name: 'Chemistry', level: 1 }, - { id: 10, name: 'World History', level: 1 }, - { id: 11, name: 'Group 3A', level: 0 }, - { id: 12, name: 'Group 3B', level: 0 }, - { id: 13, name: 'Group 3C', level: 0 }, - { id: 14, name: 'Group 4A', level: 0 }, - { id: 15, name: 'Group 4B', level: 0 }, - { id: 16, name: 'Group 4C', level: 0 }, - { id: 17, name: 'Group 5A', level: 0 }, - { id: 18, name: 'Group 5B', level: 0 }, - { id: 19, name: 'Group 5C', level: 0 }, - { id: 20, name: 'Group 6', level: 0 }, - { id: 21, name: 'Group 7', level: 0 }, - { id: 22, name: 'Group 8', level: 0 }, - { id: 23, name: 'Group 9', level: 0 }, - { id: 24, name: 'Group 10', level: 0 }, - { id: 25, name: 'Group 11', level: 0 }, - { id: 26, name: 'Group 12', level: 0 }, - { id: 27, name: 'Group 13', level: 0 }, - { id: 28, name: 'Group 14', level: 0 }, - { id: 29, name: 'Group 15', level: 0 }, - { id: 30, name: 'Group 16', level: 0 }, - { id: 31, name: 'Group 17', level: 0 }, - { id: 32, name: 'Group 18', level: 0 }, - ]; - - const model = new FlatTreeModel(courses, ({ name }) => name, ({ level }) => level, ({ level }) => level === 0, { trackBy: course => course.id }); - - return html` - - - Grades - Administration - -

Grade columns

- -
-
-

Studies

-

9 studies for all locations

- - -
- -
-
-
-

Dutch

-

HAVO 3

-
- Show only PTA exams - - - New grade column - -
- - - - - - - -
-
- `; - } -}; From fa0019e3d3fa6af0c034f06aa91223f89c3e8cb4 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 09:12:13 +0100 Subject: [PATCH 41/88] =?UTF-8?q?=F0=9F=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/curvy-jeans-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curvy-jeans-travel.md diff --git a/.changeset/curvy-jeans-travel.md b/.changeset/curvy-jeans-travel.md new file mode 100644 index 0000000000..c617607a46 --- /dev/null +++ b/.changeset/curvy-jeans-travel.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/checkbox': patch +--- + +Fix bug where clicking a checkbox in a tree-node will not check it From 8a15b6e3f68ed58f6232db4d7af42ff19ae1fafc Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 09:13:39 +0100 Subject: [PATCH 42/88] =?UTF-8?q?=F0=9F=A4=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/yellow-islands-attack.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/yellow-islands-attack.md 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 From 27c1fb5c1de5e193089de06a6881355b3644b5c0 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 09:45:58 +0100 Subject: [PATCH 43/88] =?UTF-8?q?=F0=9F=9A=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.scss | 4 ++-- packages/components/tree/src/tree-node.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index d8a37a2b8a..54305988c1 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -77,8 +77,8 @@ sl-icon { sl-checkbox { align-items: center; - font: inherit; - line-height: inherit; + font: inherit; // FIXME: Remove when checkbox styling has been refactored + line-height: inherit; // FIXME: Same as above &::part(label) { align-items: center; diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 87d4895224..9ecdb30360 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -38,6 +38,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { 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; @@ -133,6 +136,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ?indeterminate=${this.indeterminate} size="sm" > + ` @@ -147,8 +151,12 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } #onChange(event: SlChangeEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.checked = event.detail; this.indeterminate = false; + this.changeEvent.emit(this.checked); } /** @@ -178,7 +186,13 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { if (event.key === 'Enter') { event.preventDefault(); - this.selectEvent.emit(this.data!); + if (this.selects === 'multiple') { + this.checked = !this.checked; + this.indeterminate = false; + this.changeEvent.emit(this.checked); + } else { + this.selectEvent.emit(this.data!); + } } else if (event.key === 'ArrowLeft') { if (this.expanded) { event.preventDefault(); From 6ea36f13ebadbe7722e81053307a5ffebc57a215 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 13:26:46 +0100 Subject: [PATCH 44/88] =?UTF-8?q?=E2=9A=A1=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/grid/src/grid.ts | 5 +- .../components/tree/src/flat-tree-model.ts | 26 +-- .../components/tree/src/nested-tree-model.ts | 66 +++---- packages/components/tree/src/tree-model.ts | 31 ++-- packages/components/tree/src/tree.stories.ts | 41 +++++ packages/components/tree/src/tree.ts | 161 ++++++++++++------ web-test-runner.config.mjs | 2 +- 7 files changed, 214 insertions(+), 118 deletions(-) diff --git a/packages/components/grid/src/grid.ts b/packages/components/grid/src/grid.ts index 41d20a37e1..e3ab8b7e5b 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -13,7 +13,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'; @@ -164,7 +163,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); @@ -272,7 +271,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(); } diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index b5e6085ddc..9a557cb8db 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -18,10 +18,10 @@ export class FlatTreeModel extends TreeModel { return 0; } - override getDescendants(id: TreeModelId): T[] { + override getDescendants(id: TreeModelId): Promise { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { - return []; + return Promise.resolve([]); } const index = this.dataNodes.indexOf(node), @@ -38,13 +38,13 @@ export class FlatTreeModel extends TreeModel { descendants.push(nextNode); } - return descendants; + return Promise.resolve(descendants); } - override getParent(id: TreeModelId): T | undefined { + override getParent(id: TreeModelId): Promise { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { - return undefined; + return Promise.resolve(undefined); } const level = this.getLevel(node); @@ -53,17 +53,17 @@ export class FlatTreeModel extends TreeModel { const prevNode = this.dataNodes[i]; if (this.getLevel(prevNode) < level) { - return prevNode; + return Promise.resolve(prevNode); } } - return undefined; + return Promise.resolve(undefined); } - override getSiblings(id: TreeModelId): T[] { + override getSiblings(id: TreeModelId): Promise { const node = this.dataNodes.find(n => this.getId(n) === id); if (!node) { - return []; + return Promise.resolve([]); } const index = this.dataNodes.indexOf(node), @@ -94,13 +94,13 @@ export class FlatTreeModel extends TreeModel { } } - return siblings; + return Promise.resolve(siblings); } - override toArray(): Array> { + override toArray(): Promise>> { let currentLevel = 0; - return this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { + const array = this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { const expanded = this.isExpanded(this.getId(dataNode)), expandable = this.isExpandable(dataNode), level = this.getLevel(dataNode), @@ -114,5 +114,7 @@ export class FlatTreeModel extends TreeModel { return [...dataNodes, { dataNode, expandable, expanded, lastNodeInLevel: level > nextLevel, level }]; } }, []); + + return Promise.resolve(array); } } diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index 1878b09f72..e6b38899ca 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,7 +1,7 @@ import { TreeModel, type TreeModelArrayItem, type TreeModelId, type TreeModelOptions } from './tree-model.js'; export interface NestedTreeModelOptions extends TreeModelOptions { - getChildren(dataNode: T): T[] | undefined; + getChildren(dataNode: T): T[] | Promise | undefined; } /** @@ -14,20 +14,20 @@ export class NestedTreeModel extends TreeModel { this.getChildren = options.getChildren; } - getChildren(_dataNode: T): T[] | undefined { + getChildren(_dataNode: T): T[] | Promise | undefined { return undefined; } - override getDescendants(id: TreeModelId): T[] { - const node = this.#findById(id, this.dataNodes); + override async getDescendants(id: TreeModelId): Promise { + const node = await this.#findById(id, this.dataNodes); if (!node) { return []; } const descendants: T[] = []; - const traverse = (dataNode: T) => { - const children = this.getChildren(dataNode); + const traverse = async (dataNode: T) => { + const children = await this.getChildren(dataNode); if (Array.isArray(children)) { descendants.push(...children); @@ -35,21 +35,21 @@ export class NestedTreeModel extends TreeModel { } }; - traverse(node); + await traverse(node); return descendants; } - override getParent(id: TreeModelId): T | undefined { - const traverse = (dataNodes: T[]): T | undefined => { + override async getParent(id: TreeModelId): Promise { + const traverse = async (dataNodes: T[]): Promise => { for (const dataNode of dataNodes) { - const children = this.getChildren(dataNode); + const children = await this.getChildren(dataNode); if (Array.isArray(children)) { if (children.find(child => this.getId(child) === id)) { return dataNode; } else { - const found = traverse(children); + const found = await traverse(children); if (found) { return found; @@ -61,12 +61,12 @@ export class NestedTreeModel extends TreeModel { return undefined; }; - return traverse(this.dataNodes); + return await traverse(this.dataNodes); } - override getSiblings(id: TreeModelId): T[] { + override async getSiblings(id: TreeModelId): Promise { for (const dataNode of this.dataNodes) { - const children = this.getChildren(dataNode); + const children = await this.getChildren(dataNode); if (Array.isArray(children) && children.find(child => this.getId(child) === id)) { return children; @@ -76,52 +76,56 @@ export class NestedTreeModel extends TreeModel { return []; } - override toArray(): Array> { - return this.dataNodes.reduce((dataNodes: Array>, dataNode) => { + override async toArray(): Promise>> { + const array: Array> = []; + + for (const dataNode of this.dataNodes) { const expandable = this.isExpandable(dataNode), expanded = this.isExpanded(this.getId(dataNode)); - dataNodes.push({ dataNode, expandable, expanded, level: 0 }); + array.push({ dataNode, expandable, expanded, level: 0 }); if (expandable && expanded) { - dataNodes.push(...this.nestedToArray(dataNode, 1)); + array.push(...(await this.nestedToArray(dataNode, 1))); } + } - return dataNodes; - }, []); + return array; } - nestedToArray(dataNode: T, level: number): Array> { - const children = this.getChildren(dataNode); + async nestedToArray(dataNode: T, level: number): Promise>> { + const children = await this.getChildren(dataNode); if (!Array.isArray(children)) { return []; } - return children.reduce((dataNodes: Array>, childNode, index, array) => { + const array: Array> = []; + + for (const [index, childNode] of children.entries()) { const expanded = this.isExpanded(this.getId(childNode)), expandable = this.isExpandable(childNode), - lastNodeInLevel = index === array.length - 1; + lastNodeInLevel = index === children.length - 1; - dataNodes.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); + array.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); if (expandable && expanded) { - dataNodes.push(...this.nestedToArray(childNode, level + 1)); + array.push(...(await this.nestedToArray(childNode, level + 1))); } + } - return dataNodes; - }, []); + return array; } - #findById(id: TreeModelId, dataNodes: T[]): T | undefined { + async #findById(id: TreeModelId, dataNodes: T[]): Promise { for (const dataNode of dataNodes) { if (this.getId(dataNode) === id) { return dataNode; } - const children = this.getChildren(dataNode); + const children = await this.getChildren(dataNode); if (Array.isArray(children)) { - const found = this.#findById(id, children); + const found = await this.#findById(id, children); if (found) { return found; diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 61bfbeaca3..fdaee09b4c 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -43,16 +43,16 @@ export abstract class TreeModel extends EventTarget { } /** Returns an array of all the descendants of a given tree node. */ - abstract getDescendants(id: TreeModelId): T[]; + abstract getDescendants(id: TreeModelId): Promise; /** Returns the parent node or `undefined` if the node is a root node. */ - abstract getParent(id: TreeModelId): T | undefined; + abstract getParent(id: TreeModelId): Promise; /** Returns an array of all siblings of a given tree node. */ - abstract getSiblings(id: TreeModelId): T[]; + abstract getSiblings(id: TreeModelId): Promise; /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(): Array>; + abstract toArray(): Promise>>; /** * Returns a string that is used as the label for the tree node. @@ -110,13 +110,13 @@ export abstract class TreeModel extends EventTarget { } /** Expands all expandable tree nodes. */ - expandAll(): void { - this.dataNodes.forEach(node => { + async expandAll(): Promise { + for (const node of this.dataNodes) { const id = this.getId(node); this.expand(id, false); - this.expandDescendants(id); - }); + await this.expandDescendants(id); + } this.dispatchEvent(new Event('sl-update')); } @@ -128,22 +128,21 @@ export abstract class TreeModel extends EventTarget { } /** Toggles the expansion state of all descendants of a given tree node. */ - toggleDescendants(id: TreeModelId, force?: boolean): void { - this.getDescendants(id).forEach(nextNode => { - this.toggle(this.getId(nextNode), force, false); - }); + async toggleDescendants(id: TreeModelId, force?: boolean): Promise { + const descendants = await this.getDescendants(id); + descendants.forEach(n => this.toggle(this.getId(n), force, false)); this.dispatchEvent(new Event('sl-update')); } /** Expands all descendants of a given tree node. */ - expandDescendants(id: TreeModelId): void { - this.toggleDescendants(id, true); + async expandDescendants(id: TreeModelId): Promise { + await this.toggleDescendants(id, true); } /** Collapses all descendants of a given tree node. */ - collapseDescendants(id: TreeModelId): void { - this.toggleDescendants(id, false); + async collapseDescendants(id: TreeModelId): Promise { + await this.toggleDescendants(id, false); } #update(emitEvent: boolean): void { diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index d1cbe81290..78e01c5ccd 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -30,6 +30,12 @@ interface NestedDataNode { children?: NestedDataNode[]; } +interface LazyNestedDataNode { + id: string; + expandable?: boolean; + children?: LazyNestedDataNode[]; +} + Icon.register(faFile, faFolder, faFolderOpen, faPen, faTrash); const flatData: FlatDataNode[] = [ @@ -276,6 +282,41 @@ export const MultiSelect: Story = { } }; +export const LazyLoad: Story = { + args: { + model: new NestedTreeModel( + [ + { id: '0-0', expandable: true }, + { id: '0-1', expandable: true }, + { id: '0-2', expandable: true }, + { id: '0-3' }, + { id: '0-4' } + ] as LazyNestedDataNode[], + { + getChildren: async node => { + if (node.children) { + return node.children; + } + + return await new Promise(resolve => + setTimeout(() => { + node.children = Array.from({ length: 10 }).map((_, i) => ({ + id: `${node.id}-${i}`, + expandable: true + })); + + resolve(node.children); + }, 2000) + ); + }, + getId: ({ id }) => id, + getLabel: ({ id }) => id, + isExpandable: ({ expandable }) => !!expandable + } + ) + } +}; + export const CustomRenderer: Story = { args: { ...FlatModel.args, diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 90ad7d8f2e..d43c3d79cd 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,10 +1,10 @@ -import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; +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, SelectionController, event } from '@sl-design-system/shared'; import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; import { TreeModel, type TreeModelArrayItem, type TreeModelId } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -15,6 +15,14 @@ declare global { } } +/** @internal Item structure used for rendering ``s. */ +interface TreeItem extends TreeModelArrayItem { + checked?: boolean; + icon?: string; + indeterminate?: boolean; + selected?: boolean; +} + export interface TreeItemRendererOptions { expanded: boolean; expandable: boolean; @@ -47,16 +55,22 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), - elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) || [], + elements: () => Array.from(this.shadowRoot?.querySelectorAll('sl-tree-node') ?? []), isFocusableElement: (el: TreeNode) => !el.disabled }); + /** The virtualizer instance. */ + #virtualizer?: VirtualizerHostElement[typeof virtualizerRef]; + /** The initial expanded tree nodes. */ @property({ type: Array }) expanded?: Array>; /** Hides the indentation guides when set. */ @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; + /** @internal The array of items to be rendered. */ + @state() items?: Array>; + get model() { return this.#model; } @@ -70,6 +84,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.#model = model; this.#model?.addEventListener('sl-update', this.#onUpdate); + + // Trigger first update + void this.#onUpdate(); } /** Custom renderer function for tree items. */ @@ -101,6 +118,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.role = 'tree'; } + override firstUpdated(changes: PropertyValues): void { + super.firstUpdated(changes); + + const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; + this.#virtualizer = host[virtualizerRef]; + } + override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); @@ -141,51 +165,31 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } - override render(): TemplateResult { - const items = this.model?.toArray() ?? []; + override updated(changes: PropertyValues): void { + super.updated(changes); - setTimeout(() => this.#rovingTabindexController.clearElementCache(), 100); + if (changes.has('items')) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#virtualizer?.layoutComplete.then(() => { + this.#rovingTabindexController.clearElementCache(); + }); + } + } + override render(): TemplateResult { return html`
${virtualize({ - items, + items: this.items, keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), - renderItem: (item: TreeModelArrayItem) => this.renderItem(item, items) + renderItem: (item: TreeModelArrayItem) => this.renderItem(item) })}
`; } - renderItem(item: TreeModelArrayItem, items: Array>): TemplateResult { - const isSelected = (node: T) => this.selection.isSelected(this.model!.getId(node)), - { dataNode, expandable, expanded, lastNodeInLevel, level } = item, - icon = this.model!.getIcon(dataNode, expanded), - selected = isSelected(dataNode); - - let checked = false, - indeterminate = false; - if (this.selects === 'multiple') { - checked = !expandable && selected; - - if (expandable) { - const descendants = this.model!.getDescendants(this.model!.getId(dataNode)).filter( - n => !this.model!.isExpandable(n) - ); - - const someChecked = descendants.some(isSelected); - - if (someChecked) { - const allChecked = descendants.every(isSelected); - - if (allChecked) { - checked = true; - } else if (someChecked) { - indeterminate = true; - } - } - } - } + renderItem(item: TreeItem): TemplateResult { + const { checked, dataNode, expandable, expanded, icon, indeterminate, lastNodeInLevel, level, selected } = item; return html` extends ScopedElementsMixin(LitElement) { .level=${level} .selects=${this.selects} aria-level=${level} - aria-setsize=${items.length} > ${this.renderer?.(dataNode, { expanded, expandable }) ?? html` @@ -213,20 +216,22 @@ export class Tree extends ScopedElementsMixin(LitElement) { `; } - #onChange(event: SlChangeEvent, node: T): void { + async #onChange(event: SlChangeEvent, node: T): Promise { const id = this.model!.getId(node), expandable = this.model!.isExpandable(node); if (expandable) { - const descendants = this.model!.getDescendants(id).filter(n => !this.model!.isExpandable(n)); - - descendants.forEach(n => { - if (event.detail) { - this.selection.select(this.model!.getId(n)); - } else { - this.selection.deselect(this.model!.getId(n)); - } - }); + const descendants = await this.model!.getDescendants(id); + + descendants + .filter(n => !this.model!.isExpandable(n)) + .forEach(n => { + if (event.detail) { + this.selection.select(this.model!.getId(n)); + } else { + this.selection.deselect(this.model!.getId(n)); + } + }); } else { if (event.detail) { this.selection.select(id); @@ -234,19 +239,21 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.selection.deselect(id); } } + + await this.#onUpdate(); } - #onKeydown(event: KeyboardEvent): void { + async #onKeydown(event: KeyboardEvent): Promise { // 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.target instanceof TreeNode) { event.preventDefault(); - const id = this.model?.getId(event.target.data as T); + const id = this.model?.getId(event.target.data as T), + siblings = await this.model?.getSiblings(id); - this.model?.getSiblings(id).forEach(sibling => { - this.model?.expand(this.model.getId(sibling)); - }); + siblings?.forEach(sibling => this.model?.expand(this.model.getId(sibling), false)); + await this.#onUpdate(); } } @@ -255,13 +262,57 @@ export class Tree extends ScopedElementsMixin(LitElement) { event.stopPropagation(); this.selection.select(this.model!.getId(event.detail)); + void this.#onUpdate(); } #onToggle(item: T): void { this.model?.toggle(this.model?.getId(item)); } - #onUpdate = (): void => { - this.requestUpdate('model'); + #onUpdate = async (): Promise => { + this.items = await this.model?.toArray(); + + const isSelected = (node: T) => this.selection.isSelected(this.model!.getId(node)); + + if (this.selects === 'multiple' && this.items) { + this.items = await Promise.all( + this.items.map(async item => { + const { expanded, expandable, dataNode } = item, + icon = this.model!.getIcon(dataNode, expanded), + selected = isSelected(dataNode); + + let checked = !expandable && selected, + indeterminate = false; + if (expandable) { + const descendants = (await this.model!.getDescendants(this.model!.getId(dataNode))).filter( + n => !this.model!.isExpandable(n) + ); + + const someChecked = descendants.some(isSelected); + + if (someChecked) { + const allChecked = descendants.every(isSelected); + + if (allChecked) { + checked = true; + } else if (someChecked) { + indeterminate = true; + } + } + } + + return { ...item, checked, icon, indeterminate, selected }; + }) + ); + } else { + this.items = this.items?.map(item => { + const icon = this.model!.getIcon(item.dataNode, item.expanded), + selected = isSelected(item.dataNode); + + return { ...item, icon, selected }; + }); + } + + console.log(this.items); }; } diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 02bcb9e719..e0c62d31b8 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -16,7 +16,7 @@ const config = { 'packages/components/**/src/**/*.spec.ts', ], - browsers: [playwrightLauncher({ product: 'chromium' })], + browsers: [playwrightLauncher({ product: 'chromium', createPage: ({ context }) => context.newPage({ locale: 'nl' }) })], plugins: [a11ySnapshotPlugin(), esbuildPlugin({ ts: true, tsconfig: './tsconfig.base.json' })], filterBrowserLogs: ({ type, args }) => { From 259287c1c71856bdfe2c48a0892e4b1575d8eee4 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 13:27:35 +0100 Subject: [PATCH 45/88] =?UTF-8?q?=F0=9F=8F=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-test-runner.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index e0c62d31b8..02bcb9e719 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -16,7 +16,7 @@ const config = { 'packages/components/**/src/**/*.spec.ts', ], - browsers: [playwrightLauncher({ product: 'chromium', createPage: ({ context }) => context.newPage({ locale: 'nl' }) })], + browsers: [playwrightLauncher({ product: 'chromium' })], plugins: [a11ySnapshotPlugin(), esbuildPlugin({ ts: true, tsconfig: './tsconfig.base.json' })], filterBrowserLogs: ({ type, args }) => { From 43ca96679d83015cc796d3dc26f1a6a11b923232 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 2 Jan 2025 13:28:17 +0100 Subject: [PATCH 46/88] =?UTF-8?q?=F0=9F=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/real-bugs-camp.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/real-bugs-camp.md 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 From 8fa64fc87a1a9ce7e1a8f5000ed6d93693360162 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 14:28:58 +0100 Subject: [PATCH 47/88] =?UTF-8?q?=F0=9F=8C=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/flat-tree-model.ts | 182 ++++----- .../components/tree/src/nested-tree-model.ts | 188 +++++----- packages/components/tree/src/tree-model.ts | 348 +++++++++++++----- packages/components/tree/src/tree-node.ts | 13 +- packages/components/tree/src/tree.stories.ts | 127 ++++--- packages/components/tree/src/tree.ts | 215 +++-------- 6 files changed, 586 insertions(+), 487 deletions(-) diff --git a/packages/components/tree/src/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts index 9a557cb8db..c11ed8c37e 100644 --- a/packages/components/tree/src/flat-tree-model.ts +++ b/packages/components/tree/src/flat-tree-model.ts @@ -1,120 +1,126 @@ -import { TreeModel, type TreeModelArrayItem, type TreeModelId, type TreeModelOptions } from './tree-model.js'; +import { TreeModel, TreeModelNode, type TreeModelNodeMapping, type TreeModelOptions } from './tree-model.js'; -export interface FlatTreeModelOptions extends TreeModelOptions { +export interface FlatTreeModelNodeMapping extends TreeModelNodeMapping { getLevel(dataNode: T): number; } +export interface FlatTreeModelOptions extends FlatTreeModelNodeMapping { + loadChildren?(node: T): Promise; + selects?: 'single' | 'multiple'; +} + /** * A tree model that represents a flat list of nodes. */ export class FlatTreeModel extends TreeModel { - constructor(dataNodes: T[], options: FlatTreeModelOptions) { - super(dataNodes, options); + #mapping: FlatTreeModelNodeMapping; - this.getLevel = options.getLevel; - } + override treeNodes: Array>; - getLevel(_dataNode: T): number { - return 0; - } + constructor(dataNodes: T[], options: FlatTreeModelOptions) { + let loadChildren: TreeModelOptions['loadChildren'] | undefined = undefined; + if (options.loadChildren) { + loadChildren = async (node: TreeModelNode) => { + const children = await options.loadChildren!(node.dataNode); - override getDescendants(id: TreeModelId): Promise { - const node = this.dataNodes.find(n => this.getId(n) === id); - if (!node) { - return Promise.resolve([]); + return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); + }; } - const index = this.dataNodes.indexOf(node), - level = this.getLevel(node), - descendants: T[] = []; - - for (let i = index + 1; i < this.dataNodes.length; i++) { - const nextNode = this.dataNodes[i]; - - if (this.getLevel(nextNode) <= level) { - break; - } - - descendants.push(nextNode); + super(dataNodes, { ...options, loadChildren }); + + this.#mapping = { + getChildrenCount: options.getChildrenCount, + getIcon: options.getIcon, + getId: options.getId ?? (dataNode => dataNode), + getLabel: options.getLabel ?? (() => ''), + getLevel: options.getLevel ?? (() => 0), + isExpandable: options.isExpandable ?? (() => false), + isExpanded: options.isExpanded, + isSelected: options.isSelected + }; + + this.treeNodes = this.#mapToTreeNodes(dataNodes); + + if (this.selects === 'multiple') { + Array.from(this.selection) + .filter(node => node.parent) + .forEach(node => { + this.#updateSelected(node.parent!); + }); } - - return Promise.resolve(descendants); } - override getParent(id: TreeModelId): Promise { - const node = this.dataNodes.find(n => this.getId(n) === id); - if (!node) { - return Promise.resolve(undefined); - } + #mapToTreeNodes(dataNodes: T[]): Array> { + const levelMap: Map>> = new Map(), + rootNodes: Array> = []; - const level = this.getLevel(node); + dataNodes.forEach((dataNode, index) => { + const nextLevel = index < dataNodes.length - 1 ? this.#mapping.getLevel(dataNodes[index + 1]) : 0, + level = this.#mapping.getLevel(dataNode); - for (let i = this.dataNodes.indexOf(node) - 1; i >= 0; i--) { - const prevNode = this.dataNodes[i]; + const treeNode = this.#mapToTreeNode(dataNode, undefined, level > nextLevel); - if (this.getLevel(prevNode) < level) { - return Promise.resolve(prevNode); + if (treeNode.selected) { + this.selection.add(treeNode); } - } - - return Promise.resolve(undefined); - } - override getSiblings(id: TreeModelId): Promise { - const node = this.dataNodes.find(n => this.getId(n) === id); - if (!node) { - return Promise.resolve([]); - } - - const index = this.dataNodes.indexOf(node), - level = this.getLevel(node), - siblings: T[] = []; - - // Get siblings before the node - for (let i = index - 1; i >= 0; i--) { - const prevNode = this.dataNodes[i], - prevLevel = this.getLevel(prevNode); - - if (prevLevel < level) { - break; - } else if (prevLevel === level) { - siblings.unshift(prevNode); + 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; + } } - } - - // Get siblings after the node - for (let i = index + 1; i < this.dataNodes.length; i++) { - const nextNode = this.dataNodes[i], - nextLevel = this.getLevel(nextNode); - if (nextLevel < level) { - break; - } else if (nextLevel === level) { - siblings.push(nextNode); + if (!levelMap.has(level)) { + levelMap.set(level, []); } - } - return Promise.resolve(siblings); + levelMap.get(level)!.push(treeNode); + }); + + return rootNodes; } - override toArray(): Promise>> { - let currentLevel = 0; + #mapToTreeNode(dataNode: T, parent?: TreeModelNode, lastNodeInLevel?: boolean): TreeModelNode { + const { getChildrenCount, getIcon, getId, getLabel, getLevel, isExpandable, isExpanded, isSelected } = + this.#mapping; + + const treeNode: TreeModelNode = { + id: getId(dataNode), + childrenCount: getChildrenCount?.(dataNode), + dataNode, + expandable: isExpandable(dataNode), + expanded: isExpanded?.(dataNode) ?? false, + expandedIcon: getIcon?.(dataNode, true), + icon: getIcon?.(dataNode, false), + label: getLabel(dataNode), + lastNodeInLevel, + level: getLevel(dataNode), + parent, + selected: isSelected?.(dataNode) + }; + + return treeNode; + } - const array = this.dataNodes.reduce((dataNodes: Array>, dataNode, index, array) => { - const expanded = this.isExpanded(this.getId(dataNode)), - expandable = this.isExpandable(dataNode), - level = this.getLevel(dataNode), - nextLevel = index < array.length - 1 ? this.getLevel(array[index + 1]) : level; + /** Traverse up the tree and update the selected/indeterminate state. */ + #updateSelected(treeNode: TreeModelNode): void { + this.selection.add(treeNode); - if (level > currentLevel) { - return dataNodes; - } else { - currentLevel = expanded ? level + 1 : level; + treeNode.selected = treeNode.children?.every(child => child.selected) ?? false; + treeNode.indeterminate = + (!treeNode.selected && treeNode.children?.some(child => child.indeterminate || child.selected)) ?? false; - return [...dataNodes, { dataNode, expandable, expanded, lastNodeInLevel: level > nextLevel, level }]; - } - }, []); - - return Promise.resolve(array); + if (treeNode.parent) { + this.#updateSelected(treeNode.parent); + } } } diff --git a/packages/components/tree/src/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts index e6b38899ca..649d012b1f 100644 --- a/packages/components/tree/src/nested-tree-model.ts +++ b/packages/components/tree/src/nested-tree-model.ts @@ -1,138 +1,114 @@ -import { TreeModel, type TreeModelArrayItem, type TreeModelId, type TreeModelOptions } from './tree-model.js'; +import { TreeModel, type TreeModelNode, type TreeModelNodeMapping, type TreeModelOptions } from './tree-model.js'; -export interface NestedTreeModelOptions extends TreeModelOptions { - getChildren(dataNode: T): T[] | Promise | undefined; +export interface NestedTreeModelNodeMapping extends TreeModelNodeMapping { + getChildren(dataNode: T): T[] | Promise | undefined; +} + +export interface NestedTreeModelOptions extends NestedTreeModelNodeMapping { + loadChildren?(node: T): Promise; + selects?: 'single' | 'multiple'; } /** * A tree model that represents a nested list of nodes. */ export class NestedTreeModel extends TreeModel { - constructor(dataNodes: T[], options: NestedTreeModelOptions) { - super(dataNodes, options); + #mapping: NestedTreeModelNodeMapping; - this.getChildren = options.getChildren; - } + override treeNodes: Array>; - getChildren(_dataNode: T): T[] | Promise | undefined { - return undefined; - } + constructor(dataNodes: T[], options: NestedTreeModelOptions) { + let loadChildren: TreeModelOptions['loadChildren'] | undefined = undefined; + if (options.loadChildren) { + loadChildren = async (node: TreeModelNode) => { + const children = await options.loadChildren!(node.dataNode); - override async getDescendants(id: TreeModelId): Promise { - const node = await this.#findById(id, this.dataNodes); - if (!node) { - return []; + return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); + }; } - const descendants: T[] = []; - - const traverse = async (dataNode: T) => { - const children = await this.getChildren(dataNode); - - if (Array.isArray(children)) { - descendants.push(...children); - children.forEach(traverse); - } + super(dataNodes, { ...options, loadChildren }); + + this.#mapping = { + getChildren: options.getChildren, + getChildrenCount: options.getChildrenCount, + getIcon: options.getIcon, + getId: options.getId ?? (dataNode => dataNode), + getLabel: options.getLabel ?? (() => ''), + isExpandable: options.isExpandable ?? (() => false), + isExpanded: options.isExpanded, + isSelected: options.isSelected }; - await traverse(node); - - return descendants; - } - - override async getParent(id: TreeModelId): Promise { - const traverse = async (dataNodes: T[]): Promise => { - for (const dataNode of dataNodes) { - const children = await this.getChildren(dataNode); - - if (Array.isArray(children)) { - if (children.find(child => this.getId(child) === id)) { - return dataNode; - } else { - const found = await traverse(children); - - if (found) { - return found; - } - } - } - } - - return undefined; - }; - - return await traverse(this.dataNodes); - } - - override async getSiblings(id: TreeModelId): Promise { - for (const dataNode of this.dataNodes) { - const children = await this.getChildren(dataNode); + this.treeNodes = dataNodes.map(dataNode => this.#mapToTreeNode(dataNode)); - if (Array.isArray(children) && children.find(child => this.getId(child) === id)) { - return children; - } - } - - return []; - } - - override async toArray(): Promise>> { - const array: Array> = []; - - for (const dataNode of this.dataNodes) { - const expandable = this.isExpandable(dataNode), - expanded = this.isExpanded(this.getId(dataNode)); - - array.push({ dataNode, expandable, expanded, level: 0 }); - - if (expandable && expanded) { - array.push(...(await this.nestedToArray(dataNode, 1))); - } + if (this.selects === 'multiple') { + Array.from(this.selection) + .filter(node => node.parent) + .forEach(node => { + this.#updateSelected(node.parent!); + }); } - - return array; } - async nestedToArray(dataNode: T, level: number): Promise>> { - const children = await this.getChildren(dataNode); + #mapToTreeNode(dataNode: T, parent?: TreeModelNode, lastNodeInLevel?: boolean): TreeModelNode { + const { getChildren, getChildrenCount, getIcon, getId, getLabel, isExpandable, isExpanded, isSelected } = + this.#mapping; + + const treeNode: TreeModelNode = { + id: getId(dataNode), + childrenCount: getChildrenCount?.(dataNode), + dataNode, + expandable: isExpandable(dataNode), + expanded: isExpanded?.(dataNode) ?? false, + expandedIcon: getIcon?.(dataNode, true), + icon: getIcon?.(dataNode, false), + label: getLabel(dataNode), + lastNodeInLevel, + level: parent ? parent.level + 1 : 0, + parent, + selected: isSelected?.(dataNode) + }; - if (!Array.isArray(children)) { - return []; + if (treeNode.selected) { + this.selection.add(treeNode); } - const array: Array> = []; - - for (const [index, childNode] of children.entries()) { - const expanded = this.isExpanded(this.getId(childNode)), - expandable = this.isExpandable(childNode), - lastNodeInLevel = index === children.length - 1; + if (treeNode.expandable && treeNode.expanded) { + const children = getChildren(dataNode); - array.push({ dataNode: childNode, expandable, expanded, lastNodeInLevel, level }); - - if (expandable && expanded) { - array.push(...(await this.nestedToArray(childNode, level + 1))); + 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 array; + return treeNode; } - async #findById(id: TreeModelId, dataNodes: T[]): Promise { - for (const dataNode of dataNodes) { - if (this.getId(dataNode) === id) { - return dataNode; - } + /** Traverse up the tree and update the selected/indeterminate state. */ + #updateSelected(treeNode: TreeModelNode): void { + this.selection.add(treeNode); - const children = await this.getChildren(dataNode); - if (Array.isArray(children)) { - const found = await this.#findById(id, children); + treeNode.selected = treeNode.children?.every(child => child.selected) ?? false; + treeNode.indeterminate = + (!treeNode.selected && treeNode.children?.some(child => child.indeterminate || child.selected)) ?? false; - if (found) { - return found; - } - } + if (treeNode.parent) { + this.#updateSelected(treeNode.parent); } - - return undefined; } } diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index fdaee09b4c..5df8be05e3 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,81 +1,100 @@ -export interface TreeModelArrayItem { +/** Symbol used as a placeholder for tree nodes that are being loaded. */ +export const TreeModelNodePlaceholder = Symbol('TreeModelItemPlaceholder'); + +export interface TreeModelNode { + id: unknown; + children?: Array>; + childrenCount?: number; + childrenLoading?: Promise; dataNode: T; - expanded: boolean; expandable: boolean; + expanded: boolean; + expandedIcon?: string; + icon?: string; + indeterminate?: boolean; + label: string; lastNodeInLevel?: boolean; level: number; + parent?: TreeModelNode; + selected?: boolean; } -export interface TreeModelOptions { - getIcon?: TreeModel['getIcon']; - getId?: TreeModel['getId']; - getLabel: TreeModel['getLabel']; - isExpandable: TreeModel['isExpandable']; -} - -export type TreeModelId = ReturnType['getId']>; - -/** - * Abstract class used to provide a common interface for tree data. - */ -export abstract class TreeModel extends EventTarget { - /** The expansion state of the tree. */ - #expansion = new Set>(); - - /** The nodes of the tree. */ - readonly dataNodes: T[] = []; +export interface TreeModelNodeMapping { + /** + * 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?(dataNode: T): number; - constructor(dataNodes: T[], options: TreeModelOptions) { - super(); + /** Optional method for returning a custom icon for a tree node. */ + getIcon?(dataNode: T, expanded: boolean): string; - this.dataNodes = dataNodes; + /** Used to identify a tree node. */ + getId(dataNode: T): unknown; - if (options.getIcon) { - this.getIcon = options.getIcon; - } + /** + * 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(dataNode: T): string; - if (options.getId) { - this.getId = options.getId; - } + /** Returns whether the given node is expandable. */ + isExpandable(dataNode: T): boolean; - this.getLabel = options.getLabel; - this.isExpandable = options.isExpandable; - } + /** + * 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?(dataNode: T): boolean; - /** Returns an array of all the descendants of a given tree node. */ - abstract getDescendants(id: TreeModelId): Promise; + /** + * 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?(dataNode: T): boolean; +} - /** Returns the parent node or `undefined` if the node is a root node. */ - abstract getParent(id: TreeModelId): Promise; +export interface TreeModelOptions { + /** Provide this method to lazy load child nodes when a parent node is expanded. */ + loadChildren?(node: TreeModelNode): Promise>>; - /** Returns an array of all siblings of a given tree node. */ - abstract getSiblings(id: TreeModelId): Promise; + /** Enables single or multiple selection of tree nodes. */ + selects?: 'single' | 'multiple'; +} - /** Flattens the tree to an array based on the expansion state. */ - abstract toArray(): Promise>>; +/** + * Abstract class used to provide a common interface for tree data. + */ +export abstract class TreeModel extends EventTarget { + #loadChildren?: TreeModelOptions['loadChildren']; + #selection: Set> = new Set(); + #selects?: 'single' | 'multiple'; - /** - * 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(_dataNode: T): string { - return ''; + /** The current selection of tree node(s). */ + get selection() { + return this.#selection; } - /** Used to identify a tree node. */ - getId(dataNode: T): unknown { - return dataNode; + /** Indicates whether the tree model allows single or multiple selection. */ + get selects() { + return this.#selects; } - /** Optional method for returning a custom icon for a tree node. */ - getIcon(_dataNode: T, _expanded?: boolean): string | undefined { - return undefined; - } + /** An optimized representation of the data nodes for rendering in a tree. */ + abstract get treeNodes(): Array>; - /** Returns whether the given node is expandable. */ - isExpandable(_dataNode: T): boolean { - return false; + constructor( + public readonly dataNodes: T[], + options: TreeModelOptions = {} + ) { + super(); + + this.#loadChildren = options.loadChildren; + this.#selects = options.selects; } /** @@ -84,65 +103,224 @@ export abstract class TreeModel extends EventTarget { * parameter determines whether the model should emit an `sl-update` event * after changing the state. */ - toggle(id: TreeModelId, force?: boolean, emitEvent?: boolean): void { - if ((typeof force === 'boolean' && !force) || this.isExpanded(id)) { - this.collapse(id, emitEvent); + toggle(node: TreeModelNode, force?: boolean, emitEvent?: boolean): void { + if ((typeof force === 'boolean' && !force) || node.expanded) { + this.collapse(node, emitEvent); } else { - this.expand(id, emitEvent); + this.expand(node, emitEvent); } } /** Expands a tree node. */ - expand(id: TreeModelId, emitEvent = true): void { - this.#expansion.add(id); + expand(node: TreeModelNode, 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(true); + }); + } + this.#update(emitEvent); } /** Collapses a tree node. */ - collapse(id: TreeModelId, emitEvent = true): void { - this.#expansion.delete(id); + collapse(node: TreeModelNode, emitEvent = true): void { + if (!node.expandable) { + return; + } + + node.expanded = false; + this.#update(emitEvent); } - /** Returns whether a tree node is expanded. */ - isExpanded(id: TreeModelId): boolean { - return this.#expansion.has(id); + /** Toggles the expansion state of all descendants of a given tree node. */ + toggleDescendants(node: TreeModelNode, force?: boolean): void { + const traverse = (node: TreeModelNode): 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.dispatchEvent(new Event('sl-update')); + } + + /** Expands all descendants of a given tree node. */ + expandDescendants(node: TreeModelNode): void { + this.toggleDescendants(node, true); + } + + /** Collapses all descendants of a given tree node. */ + collapseDescendants(node: TreeModelNode): void { + this.toggleDescendants(node, false); } /** Expands all expandable tree nodes. */ async expandAll(): Promise { - for (const node of this.dataNodes) { - const id = this.getId(node); + const traverse = async (node: TreeModelNode): Promise => { + if (node.expandable) { + this.expand(node, false); - this.expand(id, false); - await this.expandDescendants(id); + if (node.childrenLoading) { + await node.childrenLoading; + } + + for (const child of node.children || []) { + await traverse(child); + } + } + }; + + for (const node of this.treeNodes) { + await traverse(node); } - this.dispatchEvent(new Event('sl-update')); + this.#update(true); } /** Collapses all expandable tree nodes. */ collapseAll(): void { - this.#expansion.clear(); + const traverse = (node: TreeModelNode): void => { + if (node.expandable) { + this.collapse(node, false); + + (node.children || []).forEach(traverse); + } + }; + + this.treeNodes.forEach(traverse); + this.#update(true); } - /** Toggles the expansion state of all descendants of a given tree node. */ - async toggleDescendants(id: TreeModelId, force?: boolean): Promise { - const descendants = await this.getDescendants(id); - descendants.forEach(n => this.toggle(this.getId(n), force, false)); + /** Selects the given node and any children. */ + select(node: TreeModelNode, emitEvent = true): void { + if (this.selects === 'single') { + this.deselectAll(); + } - this.dispatchEvent(new Event('sl-update')); + node.selected = true; + this.#selection.add(node); + + if (this.selects === 'multiple') { + // Select all children + if (node.expandable) { + const traverse = (node: TreeModelNode): void => { + 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.selected); + parent = parent.parent; + } + } + + this.#update(emitEvent); } - /** Expands all descendants of a given tree node. */ - async expandDescendants(id: TreeModelId): Promise { - await this.toggleDescendants(id, true); + /** Deselects the given node and any children. */ + deselect(node: TreeModelNode, emitEvent = true): void { + node.selected = false; + this.#selection.delete(node); + + if (node.expandable) { + const traverse = (node: TreeModelNode): void => { + node.selected = false; + this.#selection.delete(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + node.children?.forEach(traverse); + } + + this.#update(emitEvent); } - /** Collapses all descendants of a given tree node. */ - async collapseDescendants(id: TreeModelId): Promise { - await this.toggleDescendants(id, false); + /** Selects all nodes in the tree. */ + selectAll(): void { + const traverse = (node: TreeModelNode): void => { + node.selected = true; + this.#selection.add(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + this.treeNodes.forEach(traverse); + + this.#update(true); + } + + /** Deselects all nodes in the tree. */ + deselectAll(): void { + const traverse = (node: TreeModelNode): void => { + node.selected = false; + this.#selection.delete(node); + + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; + + this.treeNodes.forEach(traverse); + + this.#update(true); + } + + /** Flattens the tree nodes to an array based on the expansion state. */ + toArray(): Array | typeof TreeModelNodePlaceholder> { + const traverse = (treeNode: TreeModelNode): Array | typeof TreeModelNodePlaceholder> => { + if (treeNode.expandable && treeNode.expanded) { + if (Array.isArray(treeNode.children)) { + const array = treeNode.children.map(childNode => { + if (childNode instanceof Promise) { + return TreeModelNodePlaceholder; + } else { + return traverse(childNode); + } + }); + + return [treeNode, ...array.flat()]; + } else if (treeNode.childrenLoading instanceof Promise) { + return [treeNode, TreeModelNodePlaceholder]; + } + } + + return [treeNode]; + }; + + return this.treeNodes.flatMap(treeNode => traverse(treeNode)); } #update(emitEvent: boolean): void { diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 9ecdb30360..ad56a5e950 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -6,6 +6,7 @@ import { type SlChangeEvent, type SlSelectEvent, type SlToggleEvent } from '@sl- import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { IndentGuides } from './indent-guides.js'; +import { type TreeModelNode } from './tree-model.js'; import styles from './tree-node.scss.js'; declare global { @@ -44,9 +45,6 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** Determines whether the checkbox is checked or not. */ @property({ type: Boolean }) checked?: boolean; - /** The node data. */ - @property({ attribute: false }) data?: T; - /** Whether the node is disabled. */ @property({ type: Boolean, reflect: true }) disabled?: boolean; @@ -68,8 +66,11 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** 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?: TreeModelNode; + /** @internal Emits when the user clicks a the wrapper part of the tree node. */ - @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; /** Whether the node is currently selected. */ @property({ type: Boolean }) selected?: boolean; @@ -175,7 +176,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { if (insideWrapper) { event.preventDefault(); - this.selectEvent.emit(this.data!); + this.selectEvent.emit(this.node!); } else if (this.expandable) { this.toggle(); } @@ -191,7 +192,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { this.indeterminate = false; this.changeEvent.emit(this.checked); } else { - this.selectEvent.emit(this.data!); + this.selectEvent.emit(this.node!); } } else if (event.key === 'ArrowLeft') { if (this.expanded) { diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 78e01c5ccd..b6a56f0e3d 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -11,10 +11,9 @@ import { FlatTreeModel } from './flat-tree-model.js'; import { NestedTreeModel } from './nested-tree-model.js'; import { type Tree } from './tree.js'; -type Props = Pick< - Tree, - 'expanded' | 'hideGuides' | 'model' | 'renderer' | 'scopedElements' | 'selected' | 'selects' -> & { styles?: string }; +type Props = Pick & { + styles?: string; +}; type Story = StoryObj; interface FlatDataNode { @@ -33,7 +32,7 @@ interface NestedDataNode { interface LazyNestedDataNode { id: string; expandable?: boolean; - children?: LazyNestedDataNode[]; + children?: LazyNestedDataNode[] | Promise | Array>; } Icon.register(faFile, faFolder, faFolderOpen, faPen, faTrash); @@ -190,7 +189,8 @@ export default { title: 'Navigation/Tree', tags: ['draft'], args: { - hideGuides: false + hideGuides: false, + model: undefined }, argTypes: { model: { @@ -199,17 +199,13 @@ export default { renderer: { table: { disable: true } }, - selects: { - control: 'inline-radio', - options: ['single', 'multiple'] - }, styles: { table: { disable: true } } }, - render: ({ expanded, hideGuides, model, renderer, scopedElements, selected, selects, styles }) => { - const onToggleTree = () => model?.toggle(4), - onToggleTreeDescendants = () => model?.toggleDescendants(4), + render: ({ hideGuides, model, renderer, scopedElements, styles }) => { + const onToggle = () => model?.selection.forEach(node => model?.toggle(node)), + onToggleDescendants = () => model?.selection.forEach(node => model?.toggleDescendants(node)), onExpandAll = () => model?.expandAll(), onCollapseAll = () => model?.collapseAll(); @@ -222,19 +218,20 @@ export default { ` : nothing} - Toggle "tree" - Toggle all below "tree" + ${model?.selects + ? html` + Toggle selected + Toggle descendants + ` + : nothing} Expand all Collapse all `; } @@ -247,9 +244,9 @@ export const FlatModel: Story = { getId: item => item.id, getLabel: ({ name }) => name, getLevel: ({ level }) => level, - isExpandable: ({ expandable }) => expandable - }), - expanded: [4, 5] + isExpandable: ({ expandable }) => expandable, + isExpanded: ({ name }) => ['tree', 'src'].includes(name) + }) } }; @@ -260,25 +257,39 @@ export const NestedModel: Story = { getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), getId: item => item.id, getLabel: ({ name }) => name, - isExpandable: ({ children }) => !!children - }), - expanded: [4, 5] + isExpandable: ({ children }) => !!children, + isExpanded: ({ name }) => ['tree', 'src'].includes(name) + }) } }; export const SingleSelect: Story = { args: { - ...FlatModel.args, - selected: 10, - selects: 'single' + model: new FlatTreeModel(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: { - ...FlatModel.args, - selected: [9, 10], - selects: 'multiple' + model: new NestedTreeModel(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' + }) } }; @@ -293,22 +304,19 @@ export const LazyLoad: Story = { { id: '0-4' } ] as LazyNestedDataNode[], { - getChildren: async node => { - if (node.children) { - return node.children; - } - - return await new Promise(resolve => + loadChildren: node => { + return new Promise(resolve => { setTimeout(() => { - node.children = Array.from({ length: 10 }).map((_, i) => ({ + const children = Array.from({ length: 10 }).map((_, i) => ({ id: `${node.id}-${i}`, expandable: true })); - resolve(node.children); - }, 2000) - ); + resolve(children); + }, 2000); + }); }, + getChildren: () => undefined, getId: ({ id }) => id, getLabel: ({ id }) => id, isExpandable: ({ expandable }) => !!expandable @@ -317,16 +325,47 @@ export const LazyLoad: Story = { } }; +// export const Skeleton: Story = { +// args: { +// model: new NestedTreeModel( +// [ +// { id: '0-0', expandable: true }, +// { id: '0-1', expandable: true }, +// { id: '0-2', expandable: true }, +// { id: '0-3' }, +// { id: '0-4' } +// ] as LazyNestedDataNode[], +// { +// getChildren: node => { +// if (!node.children) { +// node.children = Array.from({ length: 10 }).map((_, i) => { +// return new Promise(resolve => +// setTimeout(() => { +// resolve({ id: `${node.id}-${i}`, expandable: true }); +// }, Math.random() * 4000) +// ); +// }); +// } + +// return node.children; +// }, +// getId: ({ id }) => id, +// getLabel: ({ id }) => id, +// isExpandable: ({ expandable }) => !!expandable +// } +// ) +// } +// }; + export const CustomRenderer: Story = { args: { ...FlatModel.args, - renderer: (node, { expanded }) => { - const { name } = node as FlatDataNode, - icon = name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`; + renderer: node => { + const icon = node.label.includes('.') ? 'far-file' : `far-folder${node.expanded ? '-open' : ''}`; return html` ${icon ? html`` : nothing} - ${name} + ${node.label} diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index d43c3d79cd..6da2f9337a 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,11 +1,11 @@ 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, SelectionController, event } from '@sl-design-system/shared'; +import { type EventEmitter, RovingTabindexController, event } from '@sl-design-system/shared'; import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared/events.js'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; -import { TreeModel, type TreeModelArrayItem, type TreeModelId } from './tree-model.js'; +import { TreeModel, type TreeModelNode, TreeModelNodePlaceholder } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -15,26 +15,12 @@ declare global { } } -/** @internal Item structure used for rendering ``s. */ -interface TreeItem extends TreeModelArrayItem { - checked?: boolean; - icon?: string; - indeterminate?: boolean; - selected?: boolean; -} - -export interface TreeItemRendererOptions { - expanded: boolean; - expandable: boolean; - selected?: boolean; -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type TreeItemRenderer = (item: T, options: TreeItemRendererOptions) => TemplateResult; +export type TreeItemRenderer = (item: TreeModelNode) => TemplateResult; /** - * A tree component. Supports both flat and nested data structures. Use this if you - * have hierarchical data that you want to display in a tree-like structure. + * 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) { @@ -62,14 +48,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The virtualizer instance. */ #virtualizer?: VirtualizerHostElement[typeof virtualizerRef]; - /** The initial expanded tree nodes. */ - @property({ type: Array }) expanded?: Array>; - /** Hides the indentation guides when set. */ @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; /** @internal The array of items to be rendered. */ - @state() items?: Array>; + @state() items?: Array | typeof TreeModelNodePlaceholder>; get model() { return this.#model; @@ -85,8 +68,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.#model = model; this.#model?.addEventListener('sl-update', this.#onUpdate); - // Trigger first update - void this.#onUpdate(); + // Trigger first time render + this.#onUpdate(); } /** Custom renderer function for tree items. */ @@ -101,16 +84,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { @property({ attribute: false }) scopedElements?: Record; /** @internal Emits when the user selects a tree node. */ - @event({ name: 'sl-select' }) selectEvent!: EventEmitter>; - - /** Contains the selection state for the tree when `selects` is defined. */ - readonly selection = new SelectionController(this); - - /** The initial selected tree node(s). */ - @property() selected?: unknown; - - /** If you are able to select one or more tree items (at the same time). */ - @property() selects?: 'single' | 'multiple'; + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; override connectedCallback(): void { super.connectedCallback(); @@ -128,11 +102,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); - if (changes.has('expanded')) { - this.model?.collapseAll(); - - if (this.expanded) { - this.expanded.forEach(item => this.model?.expand(item)); + if (changes.has('model')) { + if (this.model?.selects === 'multiple') { + this.setAttribute('aria-multiselectable', 'true'); + } else { + this.removeAttribute('aria-multiselectable'); } } @@ -143,26 +117,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } } - - if (changes.has('selects')) { - this.selection.multiple = this.selects === 'multiple'; - - if (this.selects === 'multiple') { - this.setAttribute('aria-multiselectable', 'true'); - } else { - this.removeAttribute('aria-multiselectable'); - } - } - - if (changes.has('selected')) { - this.selection.deselectAll(); - - if (this.selects === 'single' && this.selected && !Array.isArray(this.selected)) { - this.selection.select(this.selected); - } else if (this.selects === 'multiple' && Array.isArray(this.selected)) { - this.selected.forEach(item => this.selection.select(item)); - } - } } override updated(changes: PropertyValues): void { @@ -181,138 +135,83 @@ export class Tree extends ScopedElementsMixin(LitElement) {
${virtualize({ items: this.items, - keyFunction: (item: TreeModelArrayItem) => this.model?.getId(item.dataNode), - renderItem: (item: TreeModelArrayItem) => this.renderItem(item) + keyFunction: (item: TreeModelNode | typeof TreeModelNodePlaceholder) => + item === TreeModelNodePlaceholder ? TreeModelNodePlaceholder : item.id, + renderItem: (item: TreeModelNode | typeof TreeModelNodePlaceholder) => this.renderItem(item) })}
`; } - renderItem(item: TreeItem): TemplateResult { - const { checked, dataNode, expandable, expanded, icon, indeterminate, lastNodeInLevel, level, selected } = item; + renderItem(item: TreeModelNode | typeof TreeModelNodePlaceholder): TemplateResult { + if (item === TreeModelNodePlaceholder) { + return html`
`; + } + + const icon = item.expanded ? item.expandedIcon : item.icon; return html` ) => this.#onChange(event, dataNode)} - @sl-toggle=${() => this.#onToggle(dataNode)} - ?checked=${checked} - ?expandable=${expandable} - ?expanded=${expanded} + @sl-change=${(event: SlChangeEvent) => this.#onChange(event, item)} + @sl-toggle=${() => this.#onToggle(item)} + ?checked=${this.model?.selects === 'multiple' && item.selected} + ?expandable=${item.expandable} + ?expanded=${item.expanded} ?hide-guides=${this.hideGuides} - ?indeterminate=${indeterminate} - ?last-node-in-level=${lastNodeInLevel} - ?selected=${selected && this.selects === 'single'} - .data=${dataNode} - .level=${level} - .selects=${this.selects} - aria-level=${level} + ?indeterminate=${item.indeterminate} + ?last-node-in-level=${item.lastNodeInLevel} + ?selected=${this.model?.selects === 'single' && item.selected} + .level=${item.level} + .node=${item} + .selects=${this.model?.selects} + aria-level=${item.level} > - ${this.renderer?.(dataNode, { expanded, expandable }) ?? + ${this.renderer?.(item) ?? html` ${icon ? html`` : nothing} - ${this.model!.getLabel(dataNode)} + ${item.label} `} `; } - async #onChange(event: SlChangeEvent, node: T): Promise { - const id = this.model!.getId(node), - expandable = this.model!.isExpandable(node); - - if (expandable) { - const descendants = await this.model!.getDescendants(id); - - descendants - .filter(n => !this.model!.isExpandable(n)) - .forEach(n => { - if (event.detail) { - this.selection.select(this.model!.getId(n)); - } else { - this.selection.deselect(this.model!.getId(n)); - } - }); - } else { - if (event.detail) { - this.selection.select(id); - } else { - this.selection.deselect(id); - } - } - - await this.#onUpdate(); + #onChange(event: SlChangeEvent, node: TreeModelNode): void { + this.model?.select(node, event.detail); + this.selectEvent.emit(node); } - async #onKeydown(event: KeyboardEvent): Promise { + #onKeydown(event: KeyboardEvent): void { // 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.target instanceof TreeNode) { event.preventDefault(); - const id = this.model?.getId(event.target.data as T), - siblings = await this.model?.getSiblings(id); + const treeNode = event.target.node as TreeModelNode, + siblings = treeNode.parent?.children ?? this.model?.treeNodes; + + if (Array.isArray(siblings)) { + siblings + .filter(sibling => sibling !== treeNode && sibling.expandable) + .forEach(sibling => this.model?.expand(sibling, false)); + } - siblings?.forEach(sibling => this.model?.expand(this.model.getId(sibling), false)); - await this.#onUpdate(); + this.#onUpdate(); } } - #onSelect(event: SlSelectEvent): void { + #onSelect(event: SlSelectEvent>): void { event.preventDefault(); event.stopPropagation(); - this.selection.select(this.model!.getId(event.detail)); - void this.#onUpdate(); + this.model?.select(event.detail); + this.selectEvent.emit(event.detail); } - #onToggle(item: T): void { - this.model?.toggle(this.model?.getId(item)); + #onToggle(node: TreeModelNode): void { + this.model?.toggle(node); } - #onUpdate = async (): Promise => { - this.items = await this.model?.toArray(); - - const isSelected = (node: T) => this.selection.isSelected(this.model!.getId(node)); - - if (this.selects === 'multiple' && this.items) { - this.items = await Promise.all( - this.items.map(async item => { - const { expanded, expandable, dataNode } = item, - icon = this.model!.getIcon(dataNode, expanded), - selected = isSelected(dataNode); - - let checked = !expandable && selected, - indeterminate = false; - if (expandable) { - const descendants = (await this.model!.getDescendants(this.model!.getId(dataNode))).filter( - n => !this.model!.isExpandable(n) - ); - - const someChecked = descendants.some(isSelected); - - if (someChecked) { - const allChecked = descendants.every(isSelected); - - if (allChecked) { - checked = true; - } else if (someChecked) { - indeterminate = true; - } - } - } - - return { ...item, checked, icon, indeterminate, selected }; - }) - ); - } else { - this.items = this.items?.map(item => { - const icon = this.model!.getIcon(item.dataNode, item.expanded), - selected = isSelected(item.dataNode); - - return { ...item, icon, selected }; - }); - } - - console.log(this.items); + #onUpdate = (): void => { + this.items = this.model?.toArray() ?? []; }; } From 0f418e2156179ec9decebf48ec1e99caafffee92 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 14:46:50 +0100 Subject: [PATCH 48/88] =?UTF-8?q?=F0=9F=8D=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/checkbox/src/checkbox.ts | 6 +++ packages/components/tree/src/tree-model.ts | 40 ++++++++++++++------ packages/components/tree/src/tree.ts | 7 +++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/components/checkbox/src/checkbox.ts b/packages/components/checkbox/src/checkbox.ts index 8ecb73a179..a438a8eda8 100644 --- a/packages/components/checkbox/src/checkbox.ts +++ b/packages/components/checkbox/src/checkbox.ts @@ -195,6 +195,12 @@ export class Checkbox extends ObserveAttributesMixin(FormControlMix 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/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 5df8be05e3..6f808333b4 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -215,6 +215,7 @@ export abstract class TreeModel extends EventTarget { this.deselectAll(); } + node.indeterminate = false; node.selected = true; this.#selection.add(node); @@ -222,6 +223,7 @@ export abstract class TreeModel extends EventTarget { // Select all children if (node.expandable) { const traverse = (node: TreeModelNode): void => { + node.indeterminate = false; node.selected = true; this.#selection.add(node); @@ -237,7 +239,8 @@ export abstract class TreeModel extends EventTarget { let parent = node.parent; while (parent) { parent.selected = parent.children!.every(child => child.selected); - parent.indeterminate = !parent.selected && parent.children!.some(child => child.selected); + parent.indeterminate = + !parent.selected && parent.children!.some(child => child.indeterminate || child.selected); parent = parent.parent; } } @@ -247,20 +250,32 @@ export abstract class TreeModel extends EventTarget { /** Deselects the given node and any children. */ deselect(node: TreeModelNode, emitEvent = true): void { - node.selected = false; + node.indeterminate = node.selected = false; this.#selection.delete(node); - if (node.expandable) { - const traverse = (node: TreeModelNode): void => { - node.selected = false; - this.#selection.delete(node); + if (this.selects === 'multiple') { + // Deselect all children + if (node.expandable) { + const traverse = (node: TreeModelNode): void => { + node.indeterminate = node.selected = false; + this.#selection.delete(node); - if (node.expandable) { - (node.children || []).forEach(traverse); - } - }; + if (node.expandable) { + (node.children || []).forEach(traverse); + } + }; - 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; + } } this.#update(emitEvent); @@ -269,6 +284,7 @@ export abstract class TreeModel extends EventTarget { /** Selects all nodes in the tree. */ selectAll(): void { const traverse = (node: TreeModelNode): void => { + node.indeterminate = false; node.selected = true; this.#selection.add(node); @@ -285,7 +301,7 @@ export abstract class TreeModel extends EventTarget { /** Deselects all nodes in the tree. */ deselectAll(): void { const traverse = (node: TreeModelNode): void => { - node.selected = false; + node.indeterminate = node.selected = false; this.#selection.delete(node); if (node.expandable) { diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 6da2f9337a..c418d2be78 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -176,7 +176,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #onChange(event: SlChangeEvent, node: TreeModelNode): void { - this.model?.select(node, event.detail); + if (event.detail) { + this.model?.select(node); + } else { + this.model?.deselect(node); + } + this.selectEvent.emit(node); } From 45005388c0b23e57695496445a6d17991e439e5e Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 14:47:52 +0100 Subject: [PATCH 49/88] =?UTF-8?q?=F0=9F=8C=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/ninety-flowers-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-flowers-fail.md diff --git a/.changeset/ninety-flowers-fail.md b/.changeset/ninety-flowers-fail.md new file mode 100644 index 0000000000..60a26384d4 --- /dev/null +++ b/.changeset/ninety-flowers-fail.md @@ -0,0 +1,5 @@ +--- +'@sl-design-system/checkbox': patch +--- + +Fix `sl-change` event firing multiple times for a single click From e13244d3e559dac3acad777c314d4bec1855b3e0 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 14:55:50 +0100 Subject: [PATCH 50/88] =?UTF-8?q?=F0=9F=90=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/storybook.yml | 2 +- .github/workflows/website.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76a6290f84..4e6d87a971 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' # Set up GitHub Actions caching for Wireit. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e542ece009..466d71557e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' registry-url: 'https://npm.pkg.github.com' scope: '@sl-design-system' diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index e8114d4abc..dbf10aedb3 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -34,7 +34,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' # Set up GitHub Actions caching for Wireit. diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index edbd8571b8..ff08aa8a9e 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' - run: yarn install --immutable - run: NODE_ENV=production yarn website @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' - run: yarn install --immutable - run: NODE_ENV=production yarn website From 4f0f5adcd32b976cd58516076acdf24f43c04fb8 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 15:00:47 +0100 Subject: [PATCH 51/88] =?UTF-8?q?=F0=9F=9A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 178 +++++++-------------------------------------------- 2 files changed, 25 insertions(+), 154 deletions(-) diff --git a/package.json b/package.json index e163134635..6caa09640a 100644 --- a/package.json +++ b/package.json @@ -463,6 +463,7 @@ "husky": "^9.1.7", "lint-staged": "^15.2.11", "lit": "^3.2.1", + "playwright": "^1.49.1", "sinon": "^19.0.2", "storybook": "^8.4.7", "stylelint": "^16.12.0", diff --git a/yarn.lock b/yarn.lock index c2103d07fa..edf71e9cb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -723,21 +723,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-define-polyfill-provider@npm:^0.6.1": - version: 0.6.2 - resolution: "@babel/helper-define-polyfill-provider@npm:0.6.2" - dependencies: - "@babel/helper-compilation-targets": "npm:^7.22.6" - "@babel/helper-plugin-utils": "npm:^7.22.5" - debug: "npm:^4.1.1" - lodash.debounce: "npm:^4.0.8" - resolve: "npm:^1.14.2" - peerDependencies: - "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/f777fe0ee1e467fdaaac059c39ed203bdc94ef2465fb873316e9e1acfc511a276263724b061e3b0af2f6d7ad3ff174f2bb368fde236a860e0f650fda43d7e022 - languageName: node - linkType: hard - "@babel/helper-define-polyfill-provider@npm:^0.6.2": version: 0.6.3 resolution: "@babel/helper-define-polyfill-provider@npm:0.6.3" @@ -1902,7 +1887,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:7.25.0": +"@babel/runtime@npm:7.25.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4": version: 7.25.0 resolution: "@babel/runtime@npm:7.25.0" dependencies: @@ -1911,15 +1896,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4": - version: 7.24.6 - resolution: "@babel/runtime@npm:7.24.6" - dependencies: - regenerator-runtime: "npm:^0.14.0" - checksum: 10c0/224ad205de33ea28979baaec89eea4c4d4e9482000dd87d15b97859365511cdd4d06517712504024f5d33a5fb9412f9b91c96f1d923974adf9359e1575cde049 - languageName: node - linkType: hard - "@babel/template@npm:^7.25.0, @babel/template@npm:^7.25.9": version: 7.25.9 resolution: "@babel/template@npm:7.25.9" @@ -3026,14 +3002,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0": - version: 4.10.0 - resolution: "@eslint-community/regexpp@npm:4.10.0" - checksum: 10c0/c5f60ef1f1ea7649fa7af0e80a5a79f64b55a8a8fa5086de4727eb4c86c652aedee407a9c143b8995d2c0b2d75c1222bec9ba5d73dbfc1f314550554f0979ef4 - languageName: node - linkType: hard - -"@eslint-community/regexpp@npm:^4.12.1": +"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 @@ -3588,13 +3557,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.15": - version: 1.4.15 - resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" - checksum: 10c0/0c6b5ae663087558039052a626d2d7ed5208da36cfd707dcc5cea4a07cfc918248403dcb5989a8f7afaf245ce0573b7cc6fd94c4a30453bd10e44d9363940ba5 - languageName: node - linkType: hard - "@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.20, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" @@ -5243,6 +5205,7 @@ __metadata: husky: "npm:^9.1.7" lint-staged: "npm:^15.2.11" lit: "npm:^3.2.1" + playwright: "npm:^1.49.1" sinon: "npm:^19.0.2" storybook: "npm:^8.4.7" stylelint: "npm:^16.12.0" @@ -6524,7 +6487,7 @@ __metadata: languageName: node linkType: hard -"@types/http-proxy@npm:^1.17.15": +"@types/http-proxy@npm:^1.17.15, @types/http-proxy@npm:^1.17.8": version: 1.17.15 resolution: "@types/http-proxy@npm:1.17.15" dependencies: @@ -6533,15 +6496,6 @@ __metadata: languageName: node linkType: hard -"@types/http-proxy@npm:^1.17.8": - version: 1.17.12 - resolution: "@types/http-proxy@npm:1.17.12" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/06719371ece6bdf9fd28b90b03bd56e48ffca675dfaadca81ae12ca18db6e77e70a509537ebfa3b2c37810d77dc52e5a3190c09bc490668dde7e384c7b579090 - languageName: node - linkType: hard - "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.3": version: 2.0.4 resolution: "@types/istanbul-lib-coverage@npm:2.0.4" @@ -8379,7 +8333,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-corejs3@npm:^0.10.1": +"babel-plugin-polyfill-corejs3@npm:^0.10.1, babel-plugin-polyfill-corejs3@npm:^0.10.4": version: 0.10.6 resolution: "babel-plugin-polyfill-corejs3@npm:0.10.6" dependencies: @@ -8391,18 +8345,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-corejs3@npm:^0.10.4": - version: 0.10.4 - resolution: "babel-plugin-polyfill-corejs3@npm:0.10.4" - dependencies: - "@babel/helper-define-polyfill-provider": "npm:^0.6.1" - core-js-compat: "npm:^3.36.1" - peerDependencies: - "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/31b92cd3dfb5b417da8dfcf0deaa4b8b032b476d7bb31ca51c66127cf25d41e89260e89d17bc004b2520faa38aa9515fafabf81d89f9d4976e9dc1163e4a7c41 - languageName: node - linkType: hard - "babel-plugin-polyfill-regenerator@npm:^0.6.1": version: 0.6.2 resolution: "babel-plugin-polyfill-regenerator@npm:0.6.2" @@ -9778,15 +9720,6 @@ __metadata: languageName: node linkType: hard -"core-js-compat@npm:^3.36.1": - version: 3.37.1 - resolution: "core-js-compat@npm:3.37.1" - dependencies: - browserslist: "npm:^4.23.0" - checksum: 10c0/4e2da9c900f2951a57947af7aeef4d16f2c75d7f7e966c0d0b62953f65225003ade5e84d3ae98847f65b24c109c606821d9dc925db8ca418fb761e7c81963c2a - languageName: node - linkType: hard - "core-js-compat@npm:^3.37.1, core-js-compat@npm:^3.38.0": version: 3.39.0 resolution: "core-js-compat@npm:3.39.0" @@ -10201,15 +10134,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": - version: 4.3.5 - resolution: "debug@npm:4.3.5" +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:~4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" dependencies: - ms: "npm:2.1.2" + ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de languageName: node linkType: hard @@ -10222,18 +10155,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:~4.4.0": - version: 4.4.0 - resolution: "debug@npm:4.4.0" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de - languageName: node - linkType: hard - "debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -10488,14 +10409,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.2": - version: 2.0.2 - resolution: "detect-libc@npm:2.0.2" - checksum: 10c0/a9f4ffcd2701525c589617d98afe5a5d0676c8ea82bcc4ed6f3747241b79f781d36437c59a5e855254c864d36a3e9f8276568b6b531c28d6e53b093a15703f11 - languageName: node - linkType: hard - -"detect-libc@npm:^2.0.1": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.2": version: 2.0.3 resolution: "detect-libc@npm:2.0.3" checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 @@ -15625,14 +15539,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.0.1": - version: 10.0.1 - resolution: "lru-cache@npm:10.0.1" - checksum: 10c0/982dabfb227b9a2daf56d712ae0e72e01115a28c0a2068cd71277bca04568f3417bbf741c6c7941abc5c620fd8059e34f15607f90ebccbfa0a17533322d27a8e - languageName: node - linkType: hard - -"lru-cache@npm:^10.2.0": +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb @@ -15678,7 +15585,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.11": +"magic-string@npm:0.30.11, magic-string@npm:^0.30.0, magic-string@npm:^0.30.5": version: 0.30.11 resolution: "magic-string@npm:0.30.11" dependencies: @@ -15696,15 +15603,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0, magic-string@npm:^0.30.5": - version: 0.30.8 - resolution: "magic-string@npm:0.30.8" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.4.15" - checksum: 10c0/51a1f06f678c082aceddfb5943de9b6bdb88f2ea1385a1c2adf116deb73dfcfa50df6c222901d691b529455222d4d68d0b28be5689ac6f69b3baa3462861f922 - languageName: node - linkType: hard - "make-dir@npm:^2.1.0": version: 2.1.0 resolution: "make-dir@npm:2.1.0" @@ -16889,13 +16787,6 @@ __metadata: languageName: node linkType: hard -"ms@npm:2.1.2": - version: 2.1.2 - resolution: "ms@npm:2.1.2" - checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc - languageName: node - linkType: hard - "ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -18314,20 +18205,13 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 languageName: node linkType: hard -"picocolors@npm:^1.0.1": - version: 1.0.1 - resolution: "picocolors@npm:1.0.1" - checksum: 10c0/c63cdad2bf812ef0d66c8db29583802355d4ca67b9285d846f390cc15c2f6ccb94e8cb7eb6a6e97fc5990a6d3ad4ae42d86c84d3146e667c739a4234ed50d400 - languageName: node - linkType: hard - "picomatch@npm:4.0.2, picomatch@npm:^4.0.2": version: 4.0.2 resolution: "picomatch@npm:4.0.2" @@ -18395,27 +18279,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.39.0": - version: 1.39.0 - resolution: "playwright-core@npm:1.39.0" +"playwright-core@npm:1.49.1": + version: 1.49.1 + resolution: "playwright-core@npm:1.49.1" bin: playwright-core: cli.js - checksum: 10c0/dbd719ae77ae84a43f831beb89514ca5cca62840a2f0cce445645002ac045c256c19b5f4f3cf9a7aa205428a1571e9e8d946ff1937cc316033ea58090c549a76 + checksum: 10c0/990b619c75715cd98b2c10c1180a126e3a454b247063b8352bc67792fe01183ec07f31d30c8714c3768cefed12886d1d64ac06da701f2baafc2cad9b439e3919 languageName: node linkType: hard -"playwright@npm:^1.22.2": - version: 1.39.0 - resolution: "playwright@npm:1.39.0" +"playwright@npm:^1.22.2, playwright@npm:^1.49.1": + version: 1.49.1 + resolution: "playwright@npm:1.49.1" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.39.0" + playwright-core: "npm:1.49.1" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/b55adb3453a9c2a02fe61dbcbd5fcb0cbae1038ea6115158c7933d203ae5b62a13cb294905d6661836751a5825bc2cfdc71b67988082ec47ac6930ca744af724 + checksum: 10c0/2368762c898920d4a0a5788b153dead45f9c36c3f5cf4d2af5228d0b8ea65823e3bbe998877950a2b9bb23a211e4633996f854c6188769dc81a25543ac818ab5 languageName: node linkType: hard @@ -21765,7 +21649,7 @@ __metadata: languageName: node linkType: hard -"terser@npm:5.31.6": +"terser@npm:5.31.6, terser@npm:^5.10.0, terser@npm:^5.26.0": version: 5.31.6 resolution: "terser@npm:5.31.6" dependencies: @@ -21779,20 +21663,6 @@ __metadata: languageName: node linkType: hard -"terser@npm:^5.10.0, terser@npm:^5.26.0": - version: 5.29.2 - resolution: "terser@npm:5.29.2" - dependencies: - "@jridgewell/source-map": "npm:^0.3.3" - acorn: "npm:^8.8.2" - commander: "npm:^2.20.0" - source-map-support: "npm:~0.5.20" - bin: - terser: bin/terser - checksum: 10c0/a6f1e26725e3dc99943d7173a3fca8bee21418a3ff39f37053fecd6a988b5341432d535721642807e9c24604aff64410577e9aed3200d9345c89b176b0ba3d65 - languageName: node - linkType: hard - "thingies@npm:^1.20.0": version: 1.21.0 resolution: "thingies@npm:1.21.0" From c7fe11a35614d5c96268d44283ed29329bed1cac Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 3 Jan 2025 15:27:22 +0100 Subject: [PATCH 52/88] =?UTF-8?q?=F0=9F=90=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-test-runner.config.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 02bcb9e719..c49675fa7d 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -16,7 +16,14 @@ const config = { 'packages/components/**/src/**/*.spec.ts', ], - browsers: [playwrightLauncher({ product: 'chromium' })], + browsers: [ + playwrightLauncher({ + product: 'chromium' , + createBrowserContext({ browser }) { + return browser.newContext({ locale: 'en' }); + } + }) + ], plugins: [a11ySnapshotPlugin(), esbuildPlugin({ ts: true, tsconfig: './tsconfig.base.json' })], filterBrowserLogs: ({ type, args }) => { From 032d8fb1b82372eeb33ee41d629a3a2c3b2d59ca Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 6 Jan 2025 09:00:56 +0100 Subject: [PATCH 53/88] =?UTF-8?q?=E2=99=A8=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/package.json | 4 ++- packages/components/tree/src/tree-model.ts | 25 +++++++++---- packages/components/tree/src/tree-node.ts | 37 ++++++++++++-------- packages/components/tree/src/tree.stories.ts | 2 +- packages/components/tree/src/tree.ts | 22 ++++++------ yarn.lock | 4 ++- 6 files changed, 59 insertions(+), 35 deletions(-) diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json index 8b6eb3b471..32ffc3e3ba 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -40,7 +40,9 @@ "dependencies": { "@sl-design-system/checkbox": "^2.0.1", "@sl-design-system/icon": "^1.0.2", - "@sl-design-system/shared": "^0.4.0" + "@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" diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-model.ts index 6f808333b4..657bb4ae99 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-model.ts @@ -1,6 +1,3 @@ -/** Symbol used as a placeholder for tree nodes that are being loaded. */ -export const TreeModelNodePlaceholder = Symbol('TreeModelItemPlaceholder'); - export interface TreeModelNode { id: unknown; children?: Array>; @@ -16,6 +13,7 @@ export interface TreeModelNode { lastNodeInLevel?: boolean; level: number; parent?: TreeModelNode; + placeholder?: boolean; selected?: boolean; } @@ -315,13 +313,13 @@ export abstract class TreeModel extends EventTarget { } /** Flattens the tree nodes to an array based on the expansion state. */ - toArray(): Array | typeof TreeModelNodePlaceholder> { - const traverse = (treeNode: TreeModelNode): Array | typeof TreeModelNodePlaceholder> => { + toViewArray(): Array> { + const traverse = (treeNode: TreeModelNode): Array> => { if (treeNode.expandable && treeNode.expanded) { if (Array.isArray(treeNode.children)) { const array = treeNode.children.map(childNode => { if (childNode instanceof Promise) { - return TreeModelNodePlaceholder; + return this.#createTreeNodePlaceholder(treeNode); } else { return traverse(childNode); } @@ -329,7 +327,7 @@ export abstract class TreeModel extends EventTarget { return [treeNode, ...array.flat()]; } else if (treeNode.childrenLoading instanceof Promise) { - return [treeNode, TreeModelNodePlaceholder]; + return [treeNode, this.#createTreeNodePlaceholder(treeNode)]; } } @@ -339,6 +337,19 @@ export abstract class TreeModel extends EventTarget { return this.treeNodes.flatMap(treeNode => traverse(treeNode)); } + #createTreeNodePlaceholder(parent: TreeModelNode): TreeModelNode { + return { + dataNode: null as unknown as T, + expandable: false, + expanded: false, + id: 'placeholder', + label: '', + level: parent.level + 1, + parent, + placeholder: true + }; + } + #update(emitEvent: boolean): void { if (emitEvent) { this.dispatchEvent(new Event('sl-update')); diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index ad56a5e950..6fc7711f11 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -1,8 +1,10 @@ +import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; 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 { 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 { IndentGuides } from './indent-guides.js'; @@ -19,6 +21,7 @@ declare global { * 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 */ @@ -29,7 +32,8 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { return { 'sl-checkbox': Checkbox, 'sl-icon': Icon, - 'sl-indent-guides': IndentGuides + 'sl-indent-guides': IndentGuides, + 'sl-spinner': Spinner }; } @@ -69,6 +73,9 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** The tree model node. */ @property({ attribute: false }) node?: TreeModelNode; + /** Acts as a placeholder for loading nodes when set. */ + @property({ type: Boolean }) placeholder?: boolean; + /** @internal Emits when the user clicks a the wrapper part of the tree node. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; @@ -129,19 +136,21 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ` : nothing}
- ${this.selects === 'multiple' - ? html` - - - - - ` - : html``} + ${this.placeholder + ? html`${msg('Loading')}` + : this.selects === 'multiple' + ? html` + + + + + ` + : html``}
`; } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index b6a56f0e3d..c29b504b45 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -313,7 +313,7 @@ export const LazyLoad: Story = { })); resolve(children); - }, 2000); + }, 1000); }); }, getChildren: () => undefined, diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index c418d2be78..beeaade6f5 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -3,9 +3,11 @@ import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-ele 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, state } from 'lit/decorators.js'; -import { TreeModel, type TreeModelNode, TreeModelNodePlaceholder } from './tree-model.js'; +import { TreeModel, type TreeModelNode } from './tree-model.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -28,6 +30,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { static get scopedElements(): ScopedElementsMap { return { 'sl-icon': Icon, + 'sl-skeleton': Skeleton, + 'sl-spinner': Spinner, 'sl-tree-node': TreeNode }; } @@ -52,7 +56,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; /** @internal The array of items to be rendered. */ - @state() items?: Array | typeof TreeModelNodePlaceholder>; + @state() items?: Array>; get model() { return this.#model; @@ -135,19 +139,14 @@ export class Tree extends ScopedElementsMixin(LitElement) {
${virtualize({ items: this.items, - keyFunction: (item: TreeModelNode | typeof TreeModelNodePlaceholder) => - item === TreeModelNodePlaceholder ? TreeModelNodePlaceholder : item.id, - renderItem: (item: TreeModelNode | typeof TreeModelNodePlaceholder) => this.renderItem(item) + keyFunction: (item: TreeModelNode) => item.id, + renderItem: (item: TreeModelNode) => this.renderItem(item) })}
`; } - renderItem(item: TreeModelNode | typeof TreeModelNodePlaceholder): TemplateResult { - if (item === TreeModelNodePlaceholder) { - return html`
`; - } - + renderItem(item: TreeModelNode): TemplateResult { const icon = item.expanded ? item.expandedIcon : item.icon; return html` @@ -160,6 +159,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { ?hide-guides=${this.hideGuides} ?indeterminate=${item.indeterminate} ?last-node-in-level=${item.lastNodeInLevel} + ?placeholder=${item.placeholder} ?selected=${this.model?.selects === 'single' && item.selected} .level=${item.level} .node=${item} @@ -217,6 +217,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #onUpdate = (): void => { - this.items = this.model?.toArray() ?? []; + this.items = this.model?.toViewArray() ?? []; }; } diff --git a/yarn.lock b/yarn.lock index edf71e9cb3..778c869687 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5411,7 +5411,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: @@ -5584,6 +5584,8 @@ __metadata: "@sl-design-system/checkbox": "npm:^2.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" peerDependencies: "@open-wc/scoped-elements": ^3.0.5 languageName: unknown From f572a878cfe0fb30698bc566e3145024671ab2d7 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 6 Jan 2025 13:54:43 +0100 Subject: [PATCH 54/88] =?UTF-8?q?=E2=99=A8=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/data-source/index.ts | 5 +- ...spec.ts => array-list-data-source.spec.ts} | 14 +- ...ta-source.ts => array-list-data-source.ts} | 9 +- .../data-source/src/data-source.spec.ts | 29 ---- .../components/data-source/src/data-source.ts | 152 ++++-------------- ...spec.ts => fetch-list-data-source.spec.ts} | 20 +-- ...ta-source.ts => fetch-list-data-source.ts} | 39 ++--- .../data-source/src/list-data-source.spec.ts | 56 +++++++ .../data-source/src/list-data-source.ts | 113 +++++++++++++ packages/components/grid/src/column.spec.ts | 4 +- packages/components/grid/src/column.ts | 6 +- packages/components/grid/src/filter-column.ts | 4 +- packages/components/grid/src/grid.ts | 23 ++- packages/components/grid/src/sorter.spec.ts | 4 +- .../grid/src/stories/basics.stories.ts | 16 +- .../grid/src/stories/drag-and-drop.stories.ts | 4 +- .../grid/src/stories/filtering.stories.ts | 10 +- .../grid/src/stories/grouping.stories.ts | 8 +- .../grid/src/stories/pagination.stories.ts | 6 +- .../grid/src/stories/selection.stories.ts | 4 +- .../grid/src/stories/sorting.stories.ts | 6 +- packages/components/grid/src/view-model.ts | 8 +- .../paginator/src/paginator-size.stories.ts | 4 +- .../paginator/src/paginator-size.ts | 4 +- .../paginator/src/paginator-status.stories.ts | 4 +- .../paginator/src/paginator-status.ts | 4 +- .../paginator/src/paginator.spec.ts | 4 +- .../paginator/src/paginator.stories.ts | 6 +- .../components/paginator/src/paginator.ts | 4 +- packages/components/tree/index.ts | 6 +- packages/components/tree/package.json | 7 +- .../tree/src/flat-tree-data-source.ts | 150 +++++++++++++++++ .../components/tree/src/flat-tree-model.ts | 126 --------------- .../tree/src/nested-tree-data-source.ts | 138 ++++++++++++++++ .../components/tree/src/nested-tree-model.ts | 114 ------------- .../{tree-model.ts => tree-data-source.ts} | 126 ++++++++------- packages/components/tree/src/tree-node.ts | 6 +- packages/components/tree/src/tree.stories.ts | 40 ++--- packages/components/tree/src/tree.ts | 78 ++++----- packages/locales/src/nl.ts | 3 +- yarn.lock | 3 + 41 files changed, 740 insertions(+), 627 deletions(-) rename packages/components/data-source/src/{array-data-source.spec.ts => array-list-data-source.spec.ts} (94%) rename packages/components/data-source/src/{array-data-source.ts => array-list-data-source.ts} (96%) rename packages/components/data-source/src/{fetch-data-source.spec.ts => fetch-list-data-source.spec.ts} (90%) rename packages/components/data-source/src/{fetch-data-source.ts => fetch-list-data-source.ts} (72%) create mode 100644 packages/components/data-source/src/list-data-source.spec.ts create mode 100644 packages/components/data-source/src/list-data-source.ts create mode 100644 packages/components/tree/src/flat-tree-data-source.ts delete mode 100644 packages/components/tree/src/flat-tree-model.ts create mode 100644 packages/components/tree/src/nested-tree-data-source.ts delete mode 100644 packages/components/tree/src/nested-tree-model.ts rename packages/components/tree/src/{tree-model.ts => tree-data-source.ts} (72%) 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 94% 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 1750b73d0f..e27e1b4dc7 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); + ds = new ArrayListDataSource(people); ds.paginate(2, 3, people.length); 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 96% rename from packages/components/data-source/src/array-data-source.ts rename to packages/components/data-source/src/array-list-data-source.ts index 61bc695c12..94128fa516 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,13 @@ import { type PathKeys, getStringByPath, getValueByPath } from '@sl-design-system/shared'; import { - DataSource, type DataSourceFilterByFunction, type DataSourceFilterByPath, type DataSourceSortFunction } from './data-source.js'; +import { ListDataSource } from './list-data-source.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class ArrayDataSource extends DataSource { +export class ArrayListDataSource extends ListDataSource { #filteredItems: T[] = []; #items: T[]; @@ -15,11 +15,6 @@ export class ArrayDataSource extends DataSource { return this.#filteredItems; } - set items(items: T[]) { - this.#items = items; - this.update(); - } - get size(): number { return this.#items.length; } 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 7b5279766f..40d8d887c9 100644 --- a/packages/components/data-source/src/data-source.ts +++ b/packages/components/data-source/src/data-source.ts @@ -6,91 +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 DataSourcePagination = { page: number; pageSize: number; totalItems: number }; +export type DataSourceSort = DataSourceSortByFunction | DataSourceSortByPath; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DataSourceUpdateEvent = CustomEvent<{ dataSource: DataSource }>; +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; - - /** Parameters for pagination, contains page number, page size and total items amount. */ - #page?: DataSourcePagination; + #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(): DataSourcePagination | undefined { - return this.#page; - } - - 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 and pagination if available. */ + /** Updates items using filter and sorting if available. */ abstract update(): void; - 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 }); } @@ -100,95 +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; - } - - 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(); - } - - setPage(page: number): void { - if (this.#page) { - this.paginate(page, this.#page.pageSize, this.#page.totalItems); - } - } - - setPageSize(pageSize: number): void { - if (this.#page) { - this.paginate(0, pageSize, this.#page.totalItems); - } - } - - setTotalItems(totalItems: number): void { - if (this.#page) { - this.paginate(this.#page.page, this.#page.pageSize, totalItems); - } - } - - /** - * Use to get the paginated data for usage with the sl-paginator component. - * */ - paginate(page: number, pageSize: number, totalItems: number): void { - this.#page = { page: page, pageSize: pageSize, totalItems: totalItems }; } } diff --git a/packages/components/data-source/src/fetch-data-source.spec.ts b/packages/components/data-source/src/fetch-list-data-source.spec.ts similarity index 90% 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 a9a105c416..e64c23d123 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,23 +2,23 @@ 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; beforeEach(() => { - ds = new FetchDataSource({ + ds = new FetchListDataSource({ fetchPage: () => Promise.resolve({ items: [...people], totalItems: people.length }), pageSize: 2 }); }); it('should have a size', () => { - expect(ds.size).to.equal(FetchDataSource.defaultSize); + expect(ds.size).to.equal(FetchListDataSource.defaultSize); }); it('should have a page size', () => { @@ -46,7 +46,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)); @@ -112,7 +112,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; 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 72% rename from packages/components/data-source/src/fetch-data-source.ts rename to packages/components/data-source/src/fetch-list-data-source.ts index 08597766ca..5a4ca5858b 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 DataSourceSort } from './data-source.js'; +import { type DataSourceSort } from './data-source.js'; +import { ListDataSource } 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 interface FetchDataSourceOptions { - fetchPage: FetchDataSourceCallback; +export interface FetchListDataSourceOptions { + fetchPage: FetchListDataSourceCallback; pageSize: number; - placeholder?: FetchDataSourcePlaceholder; + 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,13 +59,13 @@ export class FetchDataSource extends DataSource { #size: number; /** The callback for retrieving data. */ - fetchPage: FetchDataSourceCallback; + fetchPage: FetchListDataSourceCallback; /** The page size when retrieving data. */ pageSize: number; /** Returns placeholder data for items not yet loaded. */ - placeholder: FetchDataSourcePlaceholder = () => FetchDataSourcePlaceholder as T; + placeholder: FetchListDataSourcePlaceholder = () => FetchListDataSourcePlaceholder as T; get items(): T[] { return this.#proxy; @@ -74,9 +75,9 @@ export class FetchDataSource extends DataSource { return this.#size; } - constructor({ fetchPage, pageSize, placeholder, size }: FetchDataSourceOptions) { + constructor({ fetchPage, pageSize, placeholder, size }: FetchListDataSourceOptions) { super(); - this.#size = size ?? FetchDataSource.defaultSize; + this.#size = size ?? FetchListDataSource.defaultSize; this.fetchPage = fetchPage; this.pageSize = pageSize; @@ -96,7 +97,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..166ff36deb --- /dev/null +++ b/packages/components/data-source/src/list-data-source.ts @@ -0,0 +1,113 @@ +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 ListDataSourcePagination = { page: number; pageSize: number; totalItems: number }; + +// 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; + + /** Parameters for pagination, contains page number, page size and total items amount. */ + #page?: ListDataSourcePagination; + + get groupBy(): ListDataSourceGroupBy | undefined { + return this.#groupBy; + } + + get page(): ListDataSourcePagination | undefined { + return this.#page; + } + + /** + * 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 { + if (this.#page) { + this.paginate(page, this.#page.pageSize, this.#page.totalItems); + } + } + + setPageSize(pageSize: number): void { + if (this.#page) { + this.paginate(0, pageSize, this.#page.totalItems); + } + } + + 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); + } + } + + setTotalItems(totalItems: number): void { + if (this.#page) { + this.paginate(this.#page.page, this.#page.pageSize, totalItems); + } + } + + /** Use to get the paginated data for usage with the sl-paginator component. */ + paginate(page: number, pageSize: number, totalItems: number): void { + this.#page = { page: page, pageSize: pageSize, totalItems: totalItems }; + } + + /** + * 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 e3ab8b7e5b..eb79d61a5a 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -2,10 +2,17 @@ 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, SelectionController, event, getValueByPath, isSafari } from '@sl-design-system/shared'; +import { + type EventEmitter, + type PathKeys, + SelectionController, + event, + getValueByPath, + isSafari +} from '@sl-design-system/shared'; import { type SlSelectEvent, type SlToggleEvent } from '@sl-design-system/shared/events.js'; import { Skeleton } from '@sl-design-system/skeleton'; import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html, nothing } from 'lit'; @@ -175,7 +182,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 +292,7 @@ export class Grid extends ScopedElementsMixin(LitElement) { 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); @@ -727,12 +734,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)); } } @@ -884,7 +891,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 bb7a92f7fa..7e7d4677d9 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 - 1) * 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 9cdac7797a..7b96652245 100644 --- a/packages/components/grid/src/stories/pagination.stories.ts +++ b/packages/components/grid/src/stories/pagination.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 { Paginator, PaginatorSize, PaginatorStatus } from '@sl-design-system/paginator'; import '@sl-design-system/paginator/register.js'; @@ -93,7 +93,7 @@ export const Basic: Story = { export const PaginatedDataSourceWithFilter: Story = { render: (_, { loaded: { people } }) => { const pageSizes = [5, 10, 15, 20], - dataSource = new ArrayDataSource(people as Person[]); + dataSource = new ArrayListDataSource(people as Person[]); const total = dataSource.items.length; dataSource.paginate(2, 15, total); @@ -141,7 +141,7 @@ export const PaginatedDataSourceWithSorter: Story = { } }; - const dataSource = new ArrayDataSource(people as Person[]); + const dataSource = new ArrayListDataSource(people as Person[]); dataSource.setSort('custom', sorter, 'asc'); const pageSizes = [10, 15, 20]; 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/paginator-size.stories.ts b/packages/components/paginator/src/paginator-size.stories.ts index 1369129f1b..17cf9dac16 100644 --- a/packages/components/paginator/src/paginator-size.stories.ts +++ b/packages/components/paginator/src/paginator-size.stories.ts @@ -1,6 +1,6 @@ import '@sl-design-system/button/register.js'; import '@sl-design-system/card/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import '@sl-design-system/icon/register.js'; import '@sl-design-system/menu/register.js'; import '@sl-design-system/paginator/register.js'; @@ -45,7 +45,7 @@ export const WithDataSource: Story = { const pageSizes = [5, 10, 15, 20, 25, 30]; - const dataSource = new ArrayDataSource(items); + const dataSource = new ArrayListDataSource(items); requestAnimationFrame(() => { const totalItems = dataSource.items.length; diff --git a/packages/components/paginator/src/paginator-size.ts b/packages/components/paginator/src/paginator-size.ts index 570ac3ba34..7a3a4b2ba9 100644 --- a/packages/components/paginator/src/paginator-size.ts +++ b/packages/components/paginator/src/paginator-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 { type DataSource } from '@sl-design-system/data-source'; +import { 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'; @@ -38,7 +38,7 @@ export class PaginatorSize extends ScopedElementsMixin(LitElement) { static override styles: CSSResultGroup = styles; /** Provided data source. */ - @property({ attribute: false }) dataSource?: DataSource; + @property({ attribute: false }) dataSource?: ListDataSource; /** Items per page. Default to the first item of pageSizes, if pageSizes is not set - default to 10. */ @property({ type: Number, attribute: 'page-size' }) pageSize?: number; diff --git a/packages/components/paginator/src/paginator-status.stories.ts b/packages/components/paginator/src/paginator-status.stories.ts index d1c48abf9f..972a954772 100644 --- a/packages/components/paginator/src/paginator-status.stories.ts +++ b/packages/components/paginator/src/paginator-status.stories.ts @@ -1,6 +1,6 @@ import '@sl-design-system/button/register.js'; import '@sl-design-system/card/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import '@sl-design-system/icon/register.js'; import '@sl-design-system/menu/register.js'; import '@sl-design-system/paginator/register.js'; @@ -54,7 +54,7 @@ export const WithDataSource: Story = { { nr: 10, title: 'test 10' } ]; - const dataSource = new ArrayDataSource(items); + const dataSource = new ArrayListDataSource(items); requestAnimationFrame(() => { const totalItems = dataSource.items.length; diff --git a/packages/components/paginator/src/paginator-status.ts b/packages/components/paginator/src/paginator-status.ts index c229a61077..31a1f13397 100644 --- a/packages/components/paginator/src/paginator-status.ts +++ b/packages/components/paginator/src/paginator-status.ts @@ -1,6 +1,6 @@ import { localized, msg, str } from '@lit/localize'; import { announce } from '@sl-design-system/announcer'; -import { type DataSource } from '@sl-design-system/data-source'; +import { 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 './paginator-status.scss.js'; @@ -30,7 +30,7 @@ export class PaginatorStatus extends LitElement { @state() currentlyVisibleItems = 1; /** Provided data source. */ - @property({ attribute: false }) dataSource?: DataSource; + @property({ attribute: false }) dataSource?: ListDataSource; /** Items per page, if not set - default to 10. */ @property({ type: Number, attribute: 'page-size' }) pageSize = 10; diff --git a/packages/components/paginator/src/paginator.spec.ts b/packages/components/paginator/src/paginator.spec.ts index 9bfbbc2733..acf10c6c69 100644 --- a/packages/components/paginator/src/paginator.spec.ts +++ b/packages/components/paginator/src/paginator.spec.ts @@ -2,7 +2,7 @@ import { expect, fixture } from '@open-wc/testing'; import { SlAnnounceEvent } from '@sl-design-system/announcer'; import { Button } from '@sl-design-system/button'; import '@sl-design-system/button/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import { Select } from '@sl-design-system/select'; import '@sl-design-system/select/register.js'; import { html } from 'lit'; @@ -548,7 +548,7 @@ describe('sl-paginator', () => { nr: index + 1 })); - const dataSource = new ArrayDataSource(items); + const dataSource = new ArrayListDataSource(items); const totalItems = dataSource.items.length; beforeEach(async () => { diff --git a/packages/components/paginator/src/paginator.stories.ts b/packages/components/paginator/src/paginator.stories.ts index 41d76f3788..7f3c76e66c 100644 --- a/packages/components/paginator/src/paginator.stories.ts +++ b/packages/components/paginator/src/paginator.stories.ts @@ -1,6 +1,6 @@ import '@sl-design-system/button/register.js'; import '@sl-design-system/card/register.js'; -import { ArrayDataSource } from '@sl-design-system/data-source'; +import { ArrayListDataSource } from '@sl-design-system/data-source'; import '@sl-design-system/icon/register.js'; import '@sl-design-system/menu/register.js'; import '@sl-design-system/paginator/register.js'; @@ -180,14 +180,14 @@ export const WithDataSource: Story = { pageSizes = [5, 10, 15, 20]; - dataSource = new ArrayDataSource(this.items); + dataSource = new ArrayListDataSource(this.items); totalItems: number = 1; override connectedCallback(): void { super.connectedCallback(); - this.dataSource = new ArrayDataSource(this.items); + this.dataSource = new ArrayListDataSource(this.items); this.totalItems = this.dataSource?.items.length; diff --git a/packages/components/paginator/src/paginator.ts b/packages/components/paginator/src/paginator.ts index 969e048987..cdbdfdadd5 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 { type DataSource } from '@sl-design-system/data-source'; +import { 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'; @@ -82,7 +82,7 @@ export class Paginator extends ScopedElementsMixin(LitElement) { @state() currentlyVisibleItems = 1; /** Provided data source. */ - @property({ attribute: false }) dataSource?: DataSource; + @property({ attribute: false }) dataSource?: ListDataSource; /** @internal Hidden pages on the left, after the first page in the overflow version. */ @state() hiddenPagesLeft: HTMLLIElement[] = []; diff --git a/packages/components/tree/index.ts b/packages/components/tree/index.ts index 364d019993..e8194fb7fa 100644 --- a/packages/components/tree/index.ts +++ b/packages/components/tree/index.ts @@ -1,4 +1,4 @@ -export * from './src/flat-tree-model.js'; -export * from './src/nested-tree-model.js'; -export * from './src/tree-model.js'; +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 index 32ffc3e3ba..2c58d2c330 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -39,15 +39,18 @@ }, "dependencies": { "@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" + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.2.1" }, "peerDependencies": { - "@open-wc/scoped-elements": "^3.0.5" + "@open-wc/scoped-elements": "^3.0.5", + "lit": "^3.1.4" } } 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..961ee62ac1 --- /dev/null +++ b/packages/components/tree/src/flat-tree-data-source.ts @@ -0,0 +1,150 @@ +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 { + #mapping: FlatTreeDataSourceMapping; + #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: 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) + }; + + 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/flat-tree-model.ts b/packages/components/tree/src/flat-tree-model.ts deleted file mode 100644 index c11ed8c37e..0000000000 --- a/packages/components/tree/src/flat-tree-model.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { TreeModel, TreeModelNode, type TreeModelNodeMapping, type TreeModelOptions } from './tree-model.js'; - -export interface FlatTreeModelNodeMapping extends TreeModelNodeMapping { - getLevel(dataNode: T): number; -} - -export interface FlatTreeModelOptions extends FlatTreeModelNodeMapping { - loadChildren?(node: T): Promise; - selects?: 'single' | 'multiple'; -} - -/** - * A tree model that represents a flat list of nodes. - */ -export class FlatTreeModel extends TreeModel { - #mapping: FlatTreeModelNodeMapping; - - override treeNodes: Array>; - - constructor(dataNodes: T[], options: FlatTreeModelOptions) { - let loadChildren: TreeModelOptions['loadChildren'] | undefined = undefined; - if (options.loadChildren) { - loadChildren = async (node: TreeModelNode) => { - const children = await options.loadChildren!(node.dataNode); - - return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); - }; - } - - super(dataNodes, { ...options, loadChildren }); - - this.#mapping = { - getChildrenCount: options.getChildrenCount, - getIcon: options.getIcon, - getId: options.getId ?? (dataNode => dataNode), - getLabel: options.getLabel ?? (() => ''), - getLevel: options.getLevel ?? (() => 0), - isExpandable: options.isExpandable ?? (() => false), - isExpanded: options.isExpanded, - isSelected: options.isSelected - }; - - this.treeNodes = this.#mapToTreeNodes(dataNodes); - - if (this.selects === 'multiple') { - Array.from(this.selection) - .filter(node => node.parent) - .forEach(node => { - this.#updateSelected(node.parent!); - }); - } - } - - #mapToTreeNodes(dataNodes: T[]): Array> { - const levelMap: Map>> = new Map(), - rootNodes: Array> = []; - - dataNodes.forEach((dataNode, index) => { - const nextLevel = index < dataNodes.length - 1 ? this.#mapping.getLevel(dataNodes[index + 1]) : 0, - level = this.#mapping.getLevel(dataNode); - - const treeNode = this.#mapToTreeNode(dataNode, 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(dataNode: T, parent?: TreeModelNode, lastNodeInLevel?: boolean): TreeModelNode { - const { getChildrenCount, getIcon, getId, getLabel, getLevel, isExpandable, isExpanded, isSelected } = - this.#mapping; - - const treeNode: TreeModelNode = { - id: getId(dataNode), - childrenCount: getChildrenCount?.(dataNode), - dataNode, - expandable: isExpandable(dataNode), - expanded: isExpanded?.(dataNode) ?? false, - expandedIcon: getIcon?.(dataNode, true), - icon: getIcon?.(dataNode, false), - label: getLabel(dataNode), - lastNodeInLevel, - level: getLevel(dataNode), - parent, - selected: isSelected?.(dataNode) - }; - - return treeNode; - } - - /** Traverse up the tree and update the selected/indeterminate state. */ - #updateSelected(treeNode: TreeModelNode): void { - this.selection.add(treeNode); - - treeNode.selected = treeNode.children?.every(child => child.selected) ?? false; - treeNode.indeterminate = - (!treeNode.selected && treeNode.children?.some(child => child.indeterminate || child.selected)) ?? false; - - if (treeNode.parent) { - this.#updateSelected(treeNode.parent); - } - } -} 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..4719195f6d --- /dev/null +++ b/packages/components/tree/src/nested-tree-data-source.ts @@ -0,0 +1,138 @@ +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) + }; + + if (treeNode.selected) { + this.selection.add(treeNode); + } + + if (treeNode.expandable && treeNode.expanded) { + 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/nested-tree-model.ts b/packages/components/tree/src/nested-tree-model.ts deleted file mode 100644 index 649d012b1f..0000000000 --- a/packages/components/tree/src/nested-tree-model.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { TreeModel, type TreeModelNode, type TreeModelNodeMapping, type TreeModelOptions } from './tree-model.js'; - -export interface NestedTreeModelNodeMapping extends TreeModelNodeMapping { - getChildren(dataNode: T): T[] | Promise | undefined; -} - -export interface NestedTreeModelOptions extends NestedTreeModelNodeMapping { - loadChildren?(node: T): Promise; - selects?: 'single' | 'multiple'; -} - -/** - * A tree model that represents a nested list of nodes. - */ -export class NestedTreeModel extends TreeModel { - #mapping: NestedTreeModelNodeMapping; - - override treeNodes: Array>; - - constructor(dataNodes: T[], options: NestedTreeModelOptions) { - let loadChildren: TreeModelOptions['loadChildren'] | undefined = undefined; - if (options.loadChildren) { - loadChildren = async (node: TreeModelNode) => { - const children = await options.loadChildren!(node.dataNode); - - return children.map((child, index) => this.#mapToTreeNode(child, node, index === children.length - 1)); - }; - } - - super(dataNodes, { ...options, loadChildren }); - - this.#mapping = { - getChildren: options.getChildren, - getChildrenCount: options.getChildrenCount, - getIcon: options.getIcon, - getId: options.getId ?? (dataNode => dataNode), - getLabel: options.getLabel ?? (() => ''), - isExpandable: options.isExpandable ?? (() => false), - isExpanded: options.isExpanded, - isSelected: options.isSelected - }; - - this.treeNodes = dataNodes.map(dataNode => this.#mapToTreeNode(dataNode)); - - if (this.selects === 'multiple') { - Array.from(this.selection) - .filter(node => node.parent) - .forEach(node => { - this.#updateSelected(node.parent!); - }); - } - } - - #mapToTreeNode(dataNode: T, parent?: TreeModelNode, lastNodeInLevel?: boolean): TreeModelNode { - const { getChildren, getChildrenCount, getIcon, getId, getLabel, isExpandable, isExpanded, isSelected } = - this.#mapping; - - const treeNode: TreeModelNode = { - id: getId(dataNode), - childrenCount: getChildrenCount?.(dataNode), - dataNode, - expandable: isExpandable(dataNode), - expanded: isExpanded?.(dataNode) ?? false, - expandedIcon: getIcon?.(dataNode, true), - icon: getIcon?.(dataNode, false), - label: getLabel(dataNode), - lastNodeInLevel, - level: parent ? parent.level + 1 : 0, - parent, - selected: isSelected?.(dataNode) - }; - - if (treeNode.selected) { - this.selection.add(treeNode); - } - - if (treeNode.expandable && treeNode.expanded) { - const children = getChildren(dataNode); - - 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(treeNode: TreeModelNode): void { - this.selection.add(treeNode); - - treeNode.selected = treeNode.children?.every(child => child.selected) ?? false; - treeNode.indeterminate = - (!treeNode.selected && treeNode.children?.some(child => child.indeterminate || child.selected)) ?? false; - - if (treeNode.parent) { - this.#updateSelected(treeNode.parent); - } - } -} diff --git a/packages/components/tree/src/tree-model.ts b/packages/components/tree/src/tree-data-source.ts similarity index 72% rename from packages/components/tree/src/tree-model.ts rename to packages/components/tree/src/tree-data-source.ts index 657bb4ae99..18e18d6f4c 100644 --- a/packages/components/tree/src/tree-model.ts +++ b/packages/components/tree/src/tree-data-source.ts @@ -1,6 +1,8 @@ -export interface TreeModelNode { +import { DataSource } from '@sl-design-system/data-source'; + +export interface TreeDataSourceNode { id: unknown; - children?: Array>; + children?: Array>; childrenCount?: number; childrenLoading?: Promise; dataNode: T; @@ -12,53 +14,53 @@ export interface TreeModelNode { label: string; lastNodeInLevel?: boolean; level: number; - parent?: TreeModelNode; + parent?: TreeDataSourceNode; placeholder?: boolean; selected?: boolean; } -export interface TreeModelNodeMapping { +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?(dataNode: T): number; + getChildrenCount?(item: T): number; /** Optional method for returning a custom icon for a tree node. */ - getIcon?(dataNode: T, expanded: boolean): string; + getIcon?(item: T, expanded: boolean): string; /** Used to identify a tree node. */ - getId(dataNode: T): unknown; + 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(dataNode: T): string; + getLabel(item: T): string; /** Returns whether the given node is expandable. */ - isExpandable(dataNode: T): boolean; + 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?(dataNode: T): boolean; + 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?(dataNode: T): boolean; + isSelected?(item: T): boolean; } -export interface TreeModelOptions { +export interface TreeDataSourceOptions { /** Provide this method to lazy load child nodes when a parent node is expanded. */ - loadChildren?(node: TreeModelNode): Promise>>; + loadChildren?(node: TreeDataSourceNode): Promise>>; /** Enables single or multiple selection of tree nodes. */ selects?: 'single' | 'multiple'; @@ -67,11 +69,15 @@ export interface TreeModelOptions { /** * Abstract class used to provide a common interface for tree data. */ -export abstract class TreeModel extends EventTarget { - #loadChildren?: TreeModelOptions['loadChildren']; - #selection: Set> = new Set(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class TreeDataSource extends DataSource> { + #loadChildren?: TreeDataSourceOptions['loadChildren']; + #selection: Set> = new Set(); #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; @@ -82,13 +88,7 @@ export abstract class TreeModel extends EventTarget { return this.#selects; } - /** An optimized representation of the data nodes for rendering in a tree. */ - abstract get treeNodes(): Array>; - - constructor( - public readonly dataNodes: T[], - options: TreeModelOptions = {} - ) { + constructor(options: TreeDataSourceOptions = {}) { super(); this.#loadChildren = options.loadChildren; @@ -101,7 +101,7 @@ export abstract class TreeModel extends EventTarget { * parameter determines whether the model should emit an `sl-update` event * after changing the state. */ - toggle(node: TreeModelNode, force?: boolean, emitEvent?: boolean): void { + toggle(node: TreeDataSourceNode, force?: boolean, emitEvent?: boolean): void { if ((typeof force === 'boolean' && !force) || node.expanded) { this.collapse(node, emitEvent); } else { @@ -110,7 +110,7 @@ export abstract class TreeModel extends EventTarget { } /** Expands a tree node. */ - expand(node: TreeModelNode, emitEvent = true): void { + expand(node: TreeDataSourceNode, emitEvent = true): void { if (!node.expandable) { return; } @@ -122,27 +122,31 @@ export abstract class TreeModel extends EventTarget { node.children = children; node.childrenLoading = undefined; - this.#update(true); + this.update(); }); } - this.#update(emitEvent); + if (emitEvent) { + this.update(); + } } /** Collapses a tree node. */ - collapse(node: TreeModelNode, emitEvent = true): void { + collapse(node: TreeDataSourceNode, emitEvent = true): void { if (!node.expandable) { return; } node.expanded = false; - this.#update(emitEvent); + if (emitEvent) { + this.update(); + } } /** Toggles the expansion state of all descendants of a given tree node. */ - toggleDescendants(node: TreeModelNode, force?: boolean): void { - const traverse = (node: TreeModelNode): void => { + 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); @@ -156,22 +160,22 @@ export abstract class TreeModel extends EventTarget { traverse(node); - this.dispatchEvent(new Event('sl-update')); + this.update(); } /** Expands all descendants of a given tree node. */ - expandDescendants(node: TreeModelNode): void { + expandDescendants(node: TreeDataSourceNode): void { this.toggleDescendants(node, true); } /** Collapses all descendants of a given tree node. */ - collapseDescendants(node: TreeModelNode): void { + collapseDescendants(node: TreeDataSourceNode): void { this.toggleDescendants(node, false); } /** Expands all expandable tree nodes. */ async expandAll(): Promise { - const traverse = async (node: TreeModelNode): Promise => { + const traverse = async (node: TreeDataSourceNode): Promise => { if (node.expandable) { this.expand(node, false); @@ -185,16 +189,16 @@ export abstract class TreeModel extends EventTarget { } }; - for (const node of this.treeNodes) { + for (const node of this.nodes) { await traverse(node); } - this.#update(true); + this.update(); } /** Collapses all expandable tree nodes. */ collapseAll(): void { - const traverse = (node: TreeModelNode): void => { + const traverse = (node: TreeDataSourceNode): void => { if (node.expandable) { this.collapse(node, false); @@ -202,13 +206,13 @@ export abstract class TreeModel extends EventTarget { } }; - this.treeNodes.forEach(traverse); + this.nodes.forEach(traverse); - this.#update(true); + this.update(); } /** Selects the given node and any children. */ - select(node: TreeModelNode, emitEvent = true): void { + select(node: TreeDataSourceNode, emitEvent = true): void { if (this.selects === 'single') { this.deselectAll(); } @@ -220,7 +224,7 @@ export abstract class TreeModel extends EventTarget { if (this.selects === 'multiple') { // Select all children if (node.expandable) { - const traverse = (node: TreeModelNode): void => { + const traverse = (node: TreeDataSourceNode): void => { node.indeterminate = false; node.selected = true; this.#selection.add(node); @@ -243,18 +247,20 @@ export abstract class TreeModel extends EventTarget { } } - this.#update(emitEvent); + if (emitEvent) { + this.update(); + } } /** Deselects the given node and any children. */ - deselect(node: TreeModelNode, emitEvent = true): void { + 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: TreeModelNode): void => { + const traverse = (node: TreeDataSourceNode): void => { node.indeterminate = node.selected = false; this.#selection.delete(node); @@ -276,12 +282,14 @@ export abstract class TreeModel extends EventTarget { } } - this.#update(emitEvent); + if (emitEvent) { + this.update(); + } } /** Selects all nodes in the tree. */ selectAll(): void { - const traverse = (node: TreeModelNode): void => { + const traverse = (node: TreeDataSourceNode): void => { node.indeterminate = false; node.selected = true; this.#selection.add(node); @@ -291,14 +299,14 @@ export abstract class TreeModel extends EventTarget { } }; - this.treeNodes.forEach(traverse); + this.nodes.forEach(traverse); - this.#update(true); + this.update(); } /** Deselects all nodes in the tree. */ deselectAll(): void { - const traverse = (node: TreeModelNode): void => { + const traverse = (node: TreeDataSourceNode): void => { node.indeterminate = node.selected = false; this.#selection.delete(node); @@ -307,14 +315,14 @@ export abstract class TreeModel extends EventTarget { } }; - this.treeNodes.forEach(traverse); + this.nodes.forEach(traverse); - this.#update(true); + this.update(); } /** Flattens the tree nodes to an array based on the expansion state. */ - toViewArray(): Array> { - const traverse = (treeNode: TreeModelNode): Array> => { + toViewArray(): Array> { + const traverse = (treeNode: TreeDataSourceNode): Array> => { if (treeNode.expandable && treeNode.expanded) { if (Array.isArray(treeNode.children)) { const array = treeNode.children.map(childNode => { @@ -334,10 +342,10 @@ export abstract class TreeModel extends EventTarget { return [treeNode]; }; - return this.treeNodes.flatMap(treeNode => traverse(treeNode)); + return this.nodes.flatMap(treeNode => traverse(treeNode)); } - #createTreeNodePlaceholder(parent: TreeModelNode): TreeModelNode { + #createTreeNodePlaceholder(parent: TreeDataSourceNode): TreeDataSourceNode { return { dataNode: null as unknown as T, expandable: false, @@ -349,10 +357,4 @@ export abstract class TreeModel extends EventTarget { placeholder: true }; } - - #update(emitEvent: boolean): void { - if (emitEvent) { - this.dispatchEvent(new Event('sl-update')); - } - } } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 6fc7711f11..31a4f45ad6 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -8,7 +8,7 @@ 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 { IndentGuides } from './indent-guides.js'; -import { type TreeModelNode } from './tree-model.js'; +import { type TreeDataSourceNode } from './tree-data-source.js'; import styles from './tree-node.scss.js'; declare global { @@ -71,13 +71,13 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { @property({ type: Number }) level = 0; /** The tree model node. */ - @property({ attribute: false }) node?: TreeModelNode; + @property({ attribute: false }) node?: TreeDataSourceNode; /** Acts as a placeholder for loading nodes when set. */ @property({ type: Boolean }) placeholder?: boolean; /** @internal Emits when the user clicks a the wrapper part of the tree node. */ - @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; /** Whether the node is currently selected. */ @property({ type: Boolean }) selected?: boolean; diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index c29b504b45..1f98573dda 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -7,11 +7,11 @@ import { Icon } from '@sl-design-system/icon'; import { type Meta, type StoryObj } from '@storybook/web-components'; import { html, nothing } from 'lit'; import '../register.js'; -import { FlatTreeModel } from './flat-tree-model.js'; -import { NestedTreeModel } from './nested-tree-model.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 & { +type Props = Pick & { styles?: string; }; type Story = StoryObj; @@ -190,10 +190,10 @@ export default { tags: ['draft'], args: { hideGuides: false, - model: undefined + dataSource: undefined }, argTypes: { - model: { + dataSource: { table: { disable: true } }, renderer: { @@ -203,11 +203,11 @@ export default { table: { disable: true } } }, - render: ({ hideGuides, model, renderer, scopedElements, styles }) => { - const onToggle = () => model?.selection.forEach(node => model?.toggle(node)), - onToggleDescendants = () => model?.selection.forEach(node => model?.toggleDescendants(node)), - onExpandAll = () => model?.expandAll(), - onCollapseAll = () => model?.collapseAll(); + 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 @@ -218,7 +218,7 @@ export default { ` : nothing} - ${model?.selects + ${dataSource?.selects ? html` Toggle selected Toggle descendants @@ -229,7 +229,7 @@ export default { @@ -237,9 +237,9 @@ export default { } } satisfies Meta; -export const FlatModel: Story = { +export const FlatDataSource: Story = { args: { - model: new FlatTreeModel(flatData, { + dataSource: new FlatTreeDataSource(flatData, { getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), getId: item => item.id, getLabel: ({ name }) => name, @@ -250,9 +250,9 @@ export const FlatModel: Story = { } }; -export const NestedModel: Story = { +export const NestedDataSource: Story = { args: { - model: new NestedTreeModel(nestedData, { + dataSource: new NestedTreeDataSource(nestedData, { getChildren: ({ children }) => children, getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), getId: item => item.id, @@ -265,7 +265,7 @@ export const NestedModel: Story = { export const SingleSelect: Story = { args: { - model: new FlatTreeModel(flatData, { + dataSource: new FlatTreeDataSource(flatData, { getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), getId: item => item.id, getLabel: ({ name }) => name, @@ -280,7 +280,7 @@ export const SingleSelect: Story = { export const MultiSelect: Story = { args: { - model: new NestedTreeModel(nestedData, { + dataSource: new NestedTreeDataSource(nestedData, { getChildren: ({ children }) => children, getIcon: ({ name }, expanded) => (name.includes('.') ? 'far-file' : `far-folder${expanded ? '-open' : ''}`), getId: item => item.id, @@ -295,7 +295,7 @@ export const MultiSelect: Story = { export const LazyLoad: Story = { args: { - model: new NestedTreeModel( + dataSource: new NestedTreeDataSource( [ { id: '0-0', expandable: true }, { id: '0-1', expandable: true }, @@ -359,7 +359,7 @@ export const LazyLoad: Story = { export const CustomRenderer: Story = { args: { - ...FlatModel.args, + ...FlatDataSource.args, renderer: node => { const icon = node.label.includes('.') ? 'far-file' : `far-folder${node.expanded ? '-open' : ''}`; diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index beeaade6f5..d6841231ef 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -7,7 +7,7 @@ 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, state } from 'lit/decorators.js'; -import { TreeModel, type TreeModelNode } from './tree-model.js'; +import { TreeDataSource, type TreeDataSourceNode } from './tree-data-source.js'; import { TreeNode } from './tree-node.js'; import styles from './tree.scss.js'; @@ -18,7 +18,7 @@ declare global { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type TreeItemRenderer = (item: TreeModelNode) => TemplateResult; +export type TreeItemRenderer = (item: TreeDataSourceNode) => TemplateResult; /** * A tree component. Use this if you have hierarchical data that you want @@ -40,7 +40,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { static override styles: CSSResultGroup = styles; /** The data model for the tree. */ - #model?: TreeModel; + #dataSource?: TreeDataSource; /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController(this, { @@ -52,30 +52,31 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The virtualizer instance. */ #virtualizer?: VirtualizerHostElement[typeof virtualizerRef]; - /** Hides the indentation guides when set. */ - @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; - - /** @internal The array of items to be rendered. */ - @state() items?: Array>; - - get model() { - return this.#model; + get dataSource() { + return this.#dataSource; } /** The model for the tree. */ @property({ attribute: false }) - set model(model: TreeModel | undefined) { - if (this.#model) { - this.#model.removeEventListener('sl-update', this.#onUpdate); + set dataSource(model: TreeDataSource | undefined) { + if (this.#dataSource) { + this.#dataSource.removeEventListener('sl-update', this.#onUpdate); } - this.#model = model; - this.#model?.addEventListener('sl-update', this.#onUpdate); + this.#dataSource = model; + this.#dataSource?.addEventListener('sl-update', this.#onUpdate); + this.#dataSource?.update(); // Trigger first time render - this.#onUpdate(); + // this.#onUpdate(); } + /** Hides the indentation guides when set. */ + @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; + + /** @internal The array of items to be rendered. */ + @state() items?: Array>; + /** Custom renderer function for tree items. */ @property({ attribute: false }) renderer?: TreeItemRenderer; @@ -88,7 +89,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { @property({ attribute: false }) scopedElements?: Record; /** @internal Emits when the user selects a tree node. */ - @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; + @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; override connectedCallback(): void { super.connectedCallback(); @@ -106,8 +107,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { override willUpdate(changes: PropertyValues): void { super.willUpdate(changes); - if (changes.has('model')) { - if (this.model?.selects === 'multiple') { + if (changes.has('dataSource')) { + if (this.dataSource?.selects === 'multiple') { this.setAttribute('aria-multiselectable', 'true'); } else { this.removeAttribute('aria-multiselectable'); @@ -138,32 +139,32 @@ export class Tree extends ScopedElementsMixin(LitElement) { return html`
${virtualize({ - items: this.items, - keyFunction: (item: TreeModelNode) => item.id, - renderItem: (item: TreeModelNode) => this.renderItem(item) + items: this.dataSource?.items, + keyFunction: (item: TreeDataSourceNode) => item.id, + renderItem: (item: TreeDataSourceNode) => this.renderItem(item) })}
`; } - renderItem(item: TreeModelNode): TemplateResult { + 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.model?.selects === 'multiple' && item.selected} + ?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} ?placeholder=${item.placeholder} - ?selected=${this.model?.selects === 'single' && item.selected} + ?selected=${this.dataSource?.selects === 'single' && item.selected} .level=${item.level} .node=${item} - .selects=${this.model?.selects} + .selects=${this.dataSource?.selects} aria-level=${item.level} > ${this.renderer?.(item) ?? @@ -175,11 +176,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { `; } - #onChange(event: SlChangeEvent, node: TreeModelNode): void { + #onChange(event: SlChangeEvent, node: TreeDataSourceNode): void { if (event.detail) { - this.model?.select(node); + this.dataSource?.select(node); } else { - this.model?.deselect(node); + this.dataSource?.deselect(node); } this.selectEvent.emit(node); @@ -191,32 +192,33 @@ export class Tree extends ScopedElementsMixin(LitElement) { if (event.key === '*' && event.target instanceof TreeNode) { event.preventDefault(); - const treeNode = event.target.node as TreeModelNode, - siblings = treeNode.parent?.children ?? this.model?.treeNodes; + 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.model?.expand(sibling, false)); + .forEach(sibling => this.dataSource?.expand(sibling, false)); } this.#onUpdate(); } } - #onSelect(event: SlSelectEvent>): void { + #onSelect(event: SlSelectEvent>): void { event.preventDefault(); event.stopPropagation(); - this.model?.select(event.detail); + this.dataSource?.select(event.detail); this.selectEvent.emit(event.detail); } - #onToggle(node: TreeModelNode): void { - this.model?.toggle(node); + #onToggle(node: TreeDataSourceNode): void { + this.dataSource?.toggle(node); } #onUpdate = (): void => { - this.items = this.model?.toViewArray() ?? []; + this.requestUpdate('dataSource'); + // this.items = this.dataSource?.toViewArray() ?? []; }; } diff --git a/packages/locales/src/nl.ts b/packages/locales/src/nl.ts index 80365a97fb..d157fa1967 100644 --- a/packages/locales/src/nl.ts +++ b/packages/locales/src/nl.ts @@ -56,5 +56,6 @@ export const templates = { see63aaad45b1b116: 'status', sf1ec4acb8d744ed9: 'Mededeling', sf677da98fa27f9b6: 'Meer links', - sf7290005be5beae6: str`${0}, pagina` + sf7290005be5beae6: str`${0}, pagina`, + sb59d68ed12d46377: 'Loading' }; diff --git a/yarn.lock b/yarn.lock index 778c869687..25072be9a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5582,12 +5582,15 @@ __metadata: dependencies: "@open-wc/scoped-elements": "npm:^3.0.5" "@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 From e0ed777ba92e4c0c042b4c80148860936cf597a9 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 6 Jan 2025 13:56:09 +0100 Subject: [PATCH 55/88] =?UTF-8?q?=F0=9F=90=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/grid/src/grid.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/components/grid/src/grid.ts b/packages/components/grid/src/grid.ts index eb79d61a5a..2859ada2a1 100644 --- a/packages/components/grid/src/grid.ts +++ b/packages/components/grid/src/grid.ts @@ -289,11 +289,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 ArrayListDataSource(this.items) : undefined; - } + this.dataSource = this.items ? new ArrayListDataSource(this.items) : undefined; this.#updateDataSource(this.dataSource); } From 831f3bb50f76cb59d2f83224667aea1d73ac5541 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 7 Jan 2025 09:59:58 +0100 Subject: [PATCH 56/88] =?UTF-8?q?=F0=9F=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/nested-tree-data-source.ts | 2 +- web-test-runner.config.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/tree/src/nested-tree-data-source.ts b/packages/components/tree/src/nested-tree-data-source.ts index 4719195f6d..327bec7181 100644 --- a/packages/components/tree/src/nested-tree-data-source.ts +++ b/packages/components/tree/src/nested-tree-data-source.ts @@ -98,7 +98,7 @@ export class NestedTreeDataSource extends TreeDataSource { this.selection.add(treeNode); } - if (treeNode.expandable && treeNode.expanded) { + if (treeNode.expandable) { const children = getChildren(item); if (Array.isArray(children)) { 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' }); } From 6b7ced5b1512dbb2437c4cadce7c9a94801dd63c Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 8 Jan 2025 10:36:09 +0100 Subject: [PATCH 57/88] =?UTF-8?q?=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tree/src/flat-tree-data-source.ts | 3 +- .../tree/src/nested-tree-data-source.ts | 3 +- .../components/tree/src/tree-data-source.ts | 33 ++++++++-- packages/components/tree/src/tree-node.scss | 4 ++ packages/components/tree/src/tree-node.ts | 56 ++++++++++++----- packages/components/tree/src/tree.stories.ts | 61 ++++++++++--------- packages/components/tree/src/tree.ts | 2 +- 7 files changed, 107 insertions(+), 55 deletions(-) diff --git a/packages/components/tree/src/flat-tree-data-source.ts b/packages/components/tree/src/flat-tree-data-source.ts index 961ee62ac1..7bd9a66d50 100644 --- a/packages/components/tree/src/flat-tree-data-source.ts +++ b/packages/components/tree/src/flat-tree-data-source.ts @@ -129,7 +129,8 @@ export class FlatTreeDataSource extends TreeDataSource { lastNodeInLevel, level: getLevel(item), parent, - selected: isSelected?.(item) + selected: isSelected?.(item), + type: 'node' }; return treeNode; diff --git a/packages/components/tree/src/nested-tree-data-source.ts b/packages/components/tree/src/nested-tree-data-source.ts index 327bec7181..29d1de4635 100644 --- a/packages/components/tree/src/nested-tree-data-source.ts +++ b/packages/components/tree/src/nested-tree-data-source.ts @@ -91,7 +91,8 @@ export class NestedTreeDataSource extends TreeDataSource { lastNodeInLevel, level: parent ? parent.level + 1 : 0, parent, - selected: isSelected?.(item) + selected: isSelected?.(item), + type: 'node' }; if (treeNode.selected) { diff --git a/packages/components/tree/src/tree-data-source.ts b/packages/components/tree/src/tree-data-source.ts index 18e18d6f4c..a207f617ab 100644 --- a/packages/components/tree/src/tree-data-source.ts +++ b/packages/components/tree/src/tree-data-source.ts @@ -1,4 +1,5 @@ import { DataSource } from '@sl-design-system/data-source'; +import { type TreeNodeType } from './tree-node.js'; export interface TreeDataSourceNode { id: unknown; @@ -15,8 +16,8 @@ export interface TreeDataSourceNode { lastNodeInLevel?: boolean; level: number; parent?: TreeDataSourceNode; - placeholder?: boolean; selected?: boolean; + type: TreeNodeType; } export interface TreeDataSourceMapping { @@ -25,7 +26,7 @@ export interface TreeDataSourceMapping { * lazy loading children. This way, the tree component can show skeletons * for the children while they are being loaded. */ - getChildrenCount?(item: T): number; + getChildrenCount?(item: T): number | undefined; /** Optional method for returning a custom icon for a tree node. */ getIcon?(item: T, expanded: boolean): string; @@ -327,7 +328,7 @@ export abstract class TreeDataSource extends DataSource { if (childNode instanceof Promise) { - return this.#createTreeNodePlaceholder(treeNode); + return this.#createPlaceholderTreeNode(treeNode); } else { return traverse(childNode); } @@ -335,7 +336,14 @@ export abstract class TreeDataSource extends DataSource this.#createSkeletonTreeNode(treeNode)) + ]; + } else { + return [treeNode, this.#createPlaceholderTreeNode(treeNode)]; + } } } @@ -345,7 +353,7 @@ export abstract class TreeDataSource extends DataSource traverse(treeNode)); } - #createTreeNodePlaceholder(parent: TreeDataSourceNode): TreeDataSourceNode { + #createPlaceholderTreeNode(parent: TreeDataSourceNode): TreeDataSourceNode { return { dataNode: null as unknown as T, expandable: false, @@ -354,7 +362,20 @@ export abstract class TreeDataSource extends DataSource): 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 index 54305988c1..8b4c589ec8 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -87,3 +87,7 @@ sl-checkbox { margin: 0; } } + +sl-skeleton { + block-size: 1lh; +} diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 31a4f45ad6..221845df9d 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -4,9 +4,11 @@ import { Checkbox } from '@sl-design-system/checkbox'; import { Icon } from '@sl-design-system/icon'; 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'; @@ -17,6 +19,8 @@ declare global { } } +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 ``. @@ -33,7 +37,8 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { 'sl-checkbox': Checkbox, 'sl-icon': Icon, 'sl-indent-guides': IndentGuides, - 'sl-spinner': Spinner + 'sl-spinner': Spinner, + 'sl-skeleton': Skeleton }; } @@ -88,6 +93,16 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** @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(); @@ -136,21 +151,30 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ` : nothing}
- ${this.placeholder - ? html`${msg('Loading')}` - : this.selects === 'multiple' - ? html` - - - - - ` - : html``} + ${choose( + this.type, + [ + ['placeholder', () => html`${msg('Loading')}`], + [ + 'skeleton', + () => html` ` + ] + ], + () => + this.selects === 'multiple' + ? html` + + + + + ` + : html`` + )}
`; } diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 1f98573dda..fbf41b35b4 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -325,37 +325,38 @@ export const LazyLoad: Story = { } }; -// export const Skeleton: Story = { -// args: { -// model: new NestedTreeModel( -// [ -// { id: '0-0', expandable: true }, -// { id: '0-1', expandable: true }, -// { id: '0-2', expandable: true }, -// { id: '0-3' }, -// { id: '0-4' } -// ] as LazyNestedDataNode[], -// { -// getChildren: node => { -// if (!node.children) { -// node.children = Array.from({ length: 10 }).map((_, i) => { -// return new Promise(resolve => -// setTimeout(() => { -// resolve({ id: `${node.id}-${i}`, expandable: true }); -// }, Math.random() * 4000) -// ); -// }); -// } +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 + })); -// return node.children; -// }, -// getId: ({ id }) => id, -// getLabel: ({ id }) => id, -// isExpandable: ({ expandable }) => !!expandable -// } -// ) -// } -// }; + resolve(children); + }, 1000); + }); + }, + getChildren: () => undefined, + getChildrenCount: ({ expandable }) => (expandable ? 10 : undefined), + getId: ({ id }) => id, + getLabel: ({ id }) => id, + isExpandable: ({ expandable }) => !!expandable + } + ) + } +}; export const CustomRenderer: Story = { args: { diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index d6841231ef..d9ee582d4a 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -160,11 +160,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { ?hide-guides=${this.hideGuides} ?indeterminate=${item.indeterminate} ?last-node-in-level=${item.lastNodeInLevel} - ?placeholder=${item.placeholder} ?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) ?? From 63a813cfbce5c3ca7724f0e45487ef1569b53dd1 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 9 Jan 2025 13:34:01 +0100 Subject: [PATCH 58/88] =?UTF-8?q?=F0=9F=8E=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.ts | 3 +++ packages/components/tree/src/tree.stories.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 221845df9d..c3caff3fbe 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -2,6 +2,7 @@ import { localized, msg } from '@lit/localize'; import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; 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'; @@ -19,6 +20,8 @@ declare global { } } +export type TreeNodeContextMenu = (node: TreeDataSourceNode) => Menu | undefined; + export type TreeNodeType = 'node' | 'placeholder' | 'skeleton'; /** diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index fbf41b35b4..cab8c31047 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -4,6 +4,7 @@ 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'; From 3d8a083e48a7ede783bee409fca01f638391890a Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 08:58:31 +0100 Subject: [PATCH 59/88] =?UTF-8?q?=F0=9F=90=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/tree-node.spec.ts | 87 ++++++++ packages/components/tree/src/tree-node.ts | 3 - packages/components/tree/src/tree.spec.ts | 193 ++++++++++++++++++ packages/components/tree/src/tree.stories.ts | 11 +- packages/components/tree/src/tree.ts | 8 + 5 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 packages/components/tree/src/tree-node.spec.ts create mode 100644 packages/components/tree/src/tree.spec.ts 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..ea7a50d913 --- /dev/null +++ b/packages/components/tree/src/tree-node.spec.ts @@ -0,0 +1,87 @@ +import { expect, fixture, html } from '@open-wc/testing'; +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``); + }); + + 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; + }); + }); +}); diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index c3caff3fbe..367b1ca314 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -81,9 +81,6 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** The tree model node. */ @property({ attribute: false }) node?: TreeDataSourceNode; - /** Acts as a placeholder for loading nodes when set. */ - @property({ type: Boolean }) placeholder?: boolean; - /** @internal Emits when the user clicks a the wrapper part of the tree node. */ @event({ name: 'sl-select' }) selectEvent!: EventEmitter>>; diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts new file mode 100644 index 0000000000..f282d5a6ea --- /dev/null +++ b/packages/components/tree/src/tree.spec.ts @@ -0,0 +1,193 @@ +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 { 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('with 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('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 index cab8c31047..1c562c56f6 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -17,20 +17,20 @@ type Props = Pick; -interface FlatDataNode { +export interface FlatDataNode { id: number; expandable: boolean; level: number; name: string; } -interface NestedDataNode { +export interface NestedDataNode { id: number; name: string; children?: NestedDataNode[]; } -interface LazyNestedDataNode { +export interface LazyNestedDataNode { id: string; expandable?: boolean; children?: LazyNestedDataNode[] | Promise | Array>; @@ -38,7 +38,7 @@ interface LazyNestedDataNode { Icon.register(faFile, faFolder, faFolderOpen, faPen, faTrash); -const flatData: FlatDataNode[] = [ +export const flatData: FlatDataNode[] = [ { id: 0, expandable: true, @@ -149,7 +149,7 @@ const flatData: FlatDataNode[] = [ } ]; -const nestedData: NestedDataNode[] = [ +export const nestedData: NestedDataNode[] = [ { id: 0, name: 'textarea', @@ -189,6 +189,7 @@ const nestedData: NestedDataNode[] = [ export default { title: 'Navigation/Tree', tags: ['draft'], + excludeStories: ['flatData', 'nestedData'], args: { hideGuides: false, dataSource: undefined diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index d9ee582d4a..8e0956db25 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -77,6 +77,14 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** @internal The array of items to be rendered. */ @state() items?: Array>; + /** + * 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; From 6e308e7c8d04afb55b339bb71b8ff9ebaf86cc5b Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 14:20:08 +0100 Subject: [PATCH 60/88] =?UTF-8?q?=F0=9F=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/tree-node.spec.ts | 70 +++++++++++++++++++ packages/components/tree/src/tree-node.ts | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree-node.spec.ts b/packages/components/tree/src/tree-node.spec.ts index ea7a50d913..ac65136ad4 100644 --- a/packages/components/tree/src/tree-node.spec.ts +++ b/packages/components/tree/src/tree-node.spec.ts @@ -1,4 +1,6 @@ import { expect, fixture, html } from '@open-wc/testing'; +import { type SlChangeEvent } from '@sl-design-system/shared/events.js'; +import { spy } from 'sinon'; import { TreeNode } from './tree-node.js'; // We need to define sl-tree-node ourselves, since it's not @@ -84,4 +86,72 @@ describe('sl-tree-node', () => { expect(el.type).to.be.undefined; }); }); + + describe('single select', () => { + beforeEach(async () => { + el = await fixture(html``); + }); + + 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; + }); + }); + + 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 index 367b1ca314..8bbbddc85e 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -157,7 +157,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ['placeholder', () => html`${msg('Loading')}`], [ 'skeleton', - () => html` ` + () => html`` ] ], () => From b93fb88e0b27fc2225a6fb9444fdc0c2fcba9081 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 14:36:29 +0100 Subject: [PATCH 61/88] =?UTF-8?q?=F0=9F=8D=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 34 +++++++++++----------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 8e0956db25..82c217825b 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -6,7 +6,7 @@ import { type SlChangeEvent, type SlSelectEvent } from '@sl-design-system/shared 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, state } from 'lit/decorators.js'; +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'; @@ -58,25 +58,19 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The model for the tree. */ @property({ attribute: false }) - set dataSource(model: TreeDataSource | undefined) { + set dataSource(dataSource: TreeDataSource | undefined) { if (this.#dataSource) { this.#dataSource.removeEventListener('sl-update', this.#onUpdate); } - this.#dataSource = model; + this.#dataSource = dataSource; this.#dataSource?.addEventListener('sl-update', this.#onUpdate); this.#dataSource?.update(); - - // Trigger first time render - // this.#onUpdate(); } /** Hides the indentation guides when set. */ @property({ type: Boolean, attribute: 'hide-guides' }) hideGuides?: boolean; - /** @internal The array of items to be rendered. */ - @state() items?: Array>; - /** * 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. @@ -110,6 +104,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; this.#virtualizer = host[virtualizerRef]; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#virtualizer?.layoutComplete.then(() => { + this.#rovingTabindexController.clearElementCache(); + }); } override willUpdate(changes: PropertyValues): void { @@ -132,17 +131,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } - override updated(changes: PropertyValues): void { - super.updated(changes); - - if (changes.has('items')) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#virtualizer?.layoutComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); - }); - } - } - override render(): TemplateResult { return html`
@@ -227,6 +215,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { #onUpdate = (): void => { this.requestUpdate('dataSource'); - // this.items = this.dataSource?.toViewArray() ?? []; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#virtualizer?.layoutComplete.then(() => { + this.#rovingTabindexController.clearElementCache(); + }); }; } From 78ef1c3641b57e3409d9b4af1f9012863a056449 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 14:43:50 +0100 Subject: [PATCH 62/88] =?UTF-8?q?=F0=9F=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 82c217825b..e087664ad1 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -99,16 +99,14 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.role = 'tree'; } - override firstUpdated(changes: PropertyValues): void { + override async firstUpdated(changes: PropertyValues): Promise { super.firstUpdated(changes); const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; this.#virtualizer = host[virtualizerRef]; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#virtualizer?.layoutComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); - }); + await this.layoutComplete; + this.#rovingTabindexController.clearElementCache(); } override willUpdate(changes: PropertyValues): void { @@ -197,7 +195,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { .forEach(sibling => this.dataSource?.expand(sibling, false)); } - this.#onUpdate(); + void this.#onUpdate(); } } @@ -213,12 +211,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.dataSource?.toggle(node); } - #onUpdate = (): void => { + #onUpdate = async (): Promise => { this.requestUpdate('dataSource'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#virtualizer?.layoutComplete.then(() => { - this.#rovingTabindexController.clearElementCache(); - }); + await this.layoutComplete; + this.#rovingTabindexController.clearElementCache(); }; } From f729fbb003c5a2ad2f1eb11d226e38562e5b4f00 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 15:15:43 +0100 Subject: [PATCH 63/88] =?UTF-8?q?=F0=9F=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/tree-node.spec.ts | 34 ++++++++++++++++++- packages/components/tree/src/tree-node.ts | 2 ++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree-node.spec.ts b/packages/components/tree/src/tree-node.spec.ts index ac65136ad4..7c149a965d 100644 --- a/packages/components/tree/src/tree-node.spec.ts +++ b/packages/components/tree/src/tree-node.spec.ts @@ -1,5 +1,6 @@ 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'; @@ -89,7 +90,11 @@ describe('sl-tree-node', () => { describe('single select', () => { beforeEach(async () => { - el = await fixture(html``); + el = await fixture(html` + + Lorem + + `); }); it('should have an aria-selected attribute', () => { @@ -101,6 +106,33 @@ describe('sl-tree-node', () => { 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', () => { diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index 8bbbddc85e..a7eb23c8bb 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -209,6 +209,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { if (insideWrapper) { event.preventDefault(); + this.selected = this.selects === 'single' ? true : this.selected; this.selectEvent.emit(this.node!); } else if (this.expandable) { this.toggle(); @@ -225,6 +226,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { 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') { From 7efcce53e318051abc4278dda3a615fbfcd218cf Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 15:36:07 +0100 Subject: [PATCH 64/88] =?UTF-8?q?=F0=9F=8E=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tree/src/tree-node.spec.ts | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree-node.spec.ts b/packages/components/tree/src/tree-node.spec.ts index 7c149a965d..01885e4b6d 100644 --- a/packages/components/tree/src/tree-node.spec.ts +++ b/packages/components/tree/src/tree-node.spec.ts @@ -13,7 +13,11 @@ describe('sl-tree-node', () => { describe('defaults', () => { beforeEach(async () => { - el = await fixture(html``); + el = await fixture(html` + + Lorem + + `); }); it('should have a treeitem role', () => { @@ -86,6 +90,117 @@ describe('sl-tree-node', () => { 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', () => { From 993fc41e0b843bf2e97855f04ffe737e91bf70b5 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 13 Jan 2025 16:00:41 +0100 Subject: [PATCH 65/88] =?UTF-8?q?=F0=9F=91=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index e087664ad1..18ea5703f1 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -99,14 +99,11 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.role = 'tree'; } - override async firstUpdated(changes: PropertyValues): Promise { + override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; this.#virtualizer = host[virtualizerRef]; - - await this.layoutComplete; - this.#rovingTabindexController.clearElementCache(); } override willUpdate(changes: PropertyValues): void { @@ -130,6 +127,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { } override render(): TemplateResult { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.layoutComplete.then(() => this.#rovingTabindexController.clearElementCache()); + return html`
${virtualize({ @@ -195,7 +195,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { .forEach(sibling => this.dataSource?.expand(sibling, false)); } - void this.#onUpdate(); + this.#onUpdate(); } } @@ -211,10 +211,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.dataSource?.toggle(node); } - #onUpdate = async (): Promise => { + #onUpdate = (): void => { this.requestUpdate('dataSource'); - - await this.layoutComplete; - this.#rovingTabindexController.clearElementCache(); }; } From 4e07c9c3f5e03052b5de7e538fbe2b58d2afccaf Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 14 Jan 2025 10:45:47 +0100 Subject: [PATCH 66/88] =?UTF-8?q?=F0=9F=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 18ea5703f1..f0b93ffa55 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -99,11 +99,18 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.role = 'tree'; } - override firstUpdated(changes: PropertyValues): void { + override async firstUpdated(changes: PropertyValues): Promise { super.firstUpdated(changes); const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; this.#virtualizer = host[virtualizerRef]; + + // Check if there is a data source, otherwise we don't need to do anything. + // Doing this when there is no data source causes errors in unit tests. + if (this.dataSource) { + await this.layoutComplete; + this.#rovingTabindexController.clearElementCache(); + } } override willUpdate(changes: PropertyValues): void { @@ -127,9 +134,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { } override render(): TemplateResult { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.layoutComplete.then(() => this.#rovingTabindexController.clearElementCache()); - return html`
${virtualize({ @@ -195,7 +199,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { .forEach(sibling => this.dataSource?.expand(sibling, false)); } - this.#onUpdate(); + void this.#onUpdate(); } } @@ -211,7 +215,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.dataSource?.toggle(node); } - #onUpdate = (): void => { + #onUpdate = async (): Promise => { this.requestUpdate('dataSource'); + + await this.layoutComplete; + this.#rovingTabindexController.clearElementCache(); }; } From 2eeba0a4ec0c04c27cb82db08d2fc5fae5a21ee1 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 14 Jan 2025 13:54:14 +0100 Subject: [PATCH 67/88] =?UTF-8?q?=F0=9F=98=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.spec.ts | 81 ++++++++++++++++++++++- packages/components/tree/src/tree.ts | 9 ++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts index f282d5a6ea..9d26b33df5 100644 --- a/packages/components/tree/src/tree.spec.ts +++ b/packages/components/tree/src/tree.spec.ts @@ -1,6 +1,7 @@ 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'; @@ -111,7 +112,83 @@ describe('sl-tree', () => { }); }); - describe('with flat data', () => { + 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); + }); + }); + + describe('using flat data', () => { let ds: FlatTreeDataSource; beforeEach(async () => { @@ -151,7 +228,7 @@ describe('sl-tree', () => { }); }); - describe('nested data', () => { + describe('using nested data', () => { let ds: NestedTreeDataSource; beforeEach(async () => { diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index f0b93ffa55..6cc5c26ca4 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -36,6 +36,9 @@ export class Tree extends ScopedElementsMixin(LitElement) { }; } + /** @internal */ + static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + /** @internal */ static override styles: CSSResultGroup = styles; @@ -43,10 +46,10 @@ export class Tree extends ScopedElementsMixin(LitElement) { #dataSource?: TreeDataSource; /** Manage keyboard navigation between tabs. */ - #rovingTabindexController = new RovingTabindexController(this, { - focusInIndex: (elements: TreeNode[]) => elements.findIndex(el => !el.disabled), + #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 + isFocusableElement: (el: TreeNode) => !el.disabled }); /** The virtualizer instance. */ From 6a3d24af340c202ae317819f30fd3fa2bfd15645 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 15 Jan 2025 18:39:51 +0100 Subject: [PATCH 68/88] =?UTF-8?q?=F0=9F=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/src/controllers/focus-group.ts | 7 +++++-- packages/components/tree/src/tree-node.ts | 1 + packages/components/tree/src/tree.ts | 17 ++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) 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/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index a7eb23c8bb..b28e964143 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -232,6 +232,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { } else if (event.key === 'ArrowLeft') { if (this.expanded) { event.preventDefault(); + event.stopPropagation(); this.toggle(); } else if (this.level === 0) { diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 6cc5c26ca4..3ce5ebbf79 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -188,9 +188,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #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.target instanceof TreeNode) { + if (event.key === '*') { event.preventDefault(); const treeNode = event.target.node as TreeDataSourceNode, @@ -203,6 +207,17 @@ export class Tree extends ScopedElementsMixin(LitElement) { } void this.#onUpdate(); + } 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); + } } } From 885edb06f24a2fdf2794071bd195b7dd9aea27fc Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 16 Jan 2025 10:33:52 +0100 Subject: [PATCH 69/88] =?UTF-8?q?=F0=9F=A6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.spec.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts index 9d26b33df5..7e6b94fbfb 100644 --- a/packages/components/tree/src/tree.spec.ts +++ b/packages/components/tree/src/tree.spec.ts @@ -186,6 +186,28 @@ describe('sl-tree', () => { 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'); + }); }); describe('using flat data', () => { From 0b49865ec76c1e9818f601971138984ba3f8ea3b Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 16 Jan 2025 10:50:20 +0100 Subject: [PATCH 70/88] =?UTF-8?q?=F0=9F=8D=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.spec.ts | 14 +++ tools/eslint-config/package.json | 2 +- yarn.lock | 132 +++++++++++----------- 3 files changed, 81 insertions(+), 67 deletions(-) diff --git a/packages/components/tree/src/tree.spec.ts b/packages/components/tree/src/tree.spec.ts index 7e6b94fbfb..300873e1d4 100644 --- a/packages/components/tree/src/tree.spec.ts +++ b/packages/components/tree/src/tree.spec.ts @@ -208,6 +208,20 @@ describe('sl-tree', () => { 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', () => { 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/yarn.lock b/yarn.lock index bc043c5ece..00cec24993 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -6927,115 +6927,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 @@ -21844,12 +21844,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 @@ -22055,17 +22055,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 From 8cab6eee571587db950848797499de871e219999 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 10:55:37 +0100 Subject: [PATCH 71/88] =?UTF-8?q?=F0=9F=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 3ce5ebbf79..7aba029223 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -206,7 +206,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { .forEach(sibling => this.dataSource?.expand(sibling, false)); } - void this.#onUpdate(); + this.dataSource?.update(); } else if (event.key === 'ArrowLeft' && !event.target.expanded) { event.preventDefault(); From 5afc06ae1530e37034e45137adeaa29f4b02a7b6 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 11:07:09 +0100 Subject: [PATCH 72/88] =?UTF-8?q?=F0=9F=90=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/tender-ways-reply.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/tender-ways-reply.md 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 From 29ac666ed3b71eb692beaea8d1e740ecd92bd235 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 11:09:38 +0100 Subject: [PATCH 73/88] =?UTF-8?q?=F0=9F=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shy-wombats-run.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/shy-wombats-run.md 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. From 683215bec8a46a43b460056e4b273cec578d519e Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 11:12:02 +0100 Subject: [PATCH 74/88] =?UTF-8?q?=F0=9F=A6=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/mean-kids-poke.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/mean-kids-poke.md 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 From 7983df2e58cf3ac970b5a5ca7f76822ec971e5f2 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 13:13:09 +0100 Subject: [PATCH 75/88] =?UTF-8?q?=F0=9F=90=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.stories.ts | 21 ++++++++++++++++++++ packages/components/tree/src/tree.ts | 15 ++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 1c562c56f6..e34353a9b9 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -360,6 +360,27 @@ export const Skeleton: Story = { } }; +export const Scrolling: Story = { + 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, diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 7aba029223..40401a7723 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -114,6 +114,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { await this.layoutComplete; this.#rovingTabindexController.clearElementCache(); } + + 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 { @@ -187,6 +193,15 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.selectEvent.emit(node); } + scrollToNode(node: TreeDataSourceNode, options?: ScrollIntoViewOptions): void { + console.log('scrollToNode', node); + + const index = this.dataSource?.items.indexOf(node) ?? -1; + if (index !== -1) { + this.#virtualizer?.element(index)?.scrollIntoView(options); + } + } + #onKeydown(event: KeyboardEvent): void { if (!(event.target instanceof TreeNode)) { return; From 91b3b518a156c552a99c97b42804b7be2c8a954f Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 13:15:24 +0100 Subject: [PATCH 76/88] =?UTF-8?q?=F0=9F=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 40401a7723..3c7e2bbe1d 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -194,8 +194,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { } scrollToNode(node: TreeDataSourceNode, options?: ScrollIntoViewOptions): void { - console.log('scrollToNode', node); - const index = this.dataSource?.items.indexOf(node) ?? -1; if (index !== -1) { this.#virtualizer?.element(index)?.scrollIntoView(options); From e818703ea0bd15f162fd73fa35eb8a8002b1d5c9 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 13:18:32 +0100 Subject: [PATCH 77/88] =?UTF-8?q?=F0=9F=8E=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree-node.scss | 1 + packages/components/tree/src/tree.scss | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index 8b4c589ec8..ba5d74e8ab 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -7,6 +7,7 @@ display: flex; gap: var(--sl-size-075); inline-size: 100%; + scroll-margin-block: var(--sl-space-100); transition: background 0.2s ease-in-out; } diff --git a/packages/components/tree/src/tree.scss b/packages/components/tree/src/tree.scss index 79ff2d5269..b7c6d78cf5 100644 --- a/packages/components/tree/src/tree.scss +++ b/packages/components/tree/src/tree.scss @@ -1,3 +1,4 @@ :host { display: flex; + scroll-padding-block: var(--sl-space-100); } From 15aeb4abadbba97ed7459f253ac89ca603293d4c Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 14:08:26 +0100 Subject: [PATCH 78/88] =?UTF-8?q?=F0=9F=8C=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/package.json | 1 + packages/components/tree/src/tree-node.scss | 23 +++++++++++++++++++- packages/components/tree/src/tree-node.ts | 13 ++++++++++- packages/components/tree/src/tree.stories.ts | 23 +++++++------------- yarn.lock | 1 + 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/components/tree/package.json b/packages/components/tree/package.json index 2c58d2c330..73ff8444ff 100644 --- a/packages/components/tree/package.json +++ b/packages/components/tree/package.json @@ -38,6 +38,7 @@ "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", diff --git a/packages/components/tree/src/tree-node.scss b/packages/components/tree/src/tree-node.scss index ba5d74e8ab..4282f14e53 100644 --- a/packages/components/tree/src/tree-node.scss +++ b/packages/components/tree/src/tree-node.scss @@ -28,10 +28,20 @@ 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) { @@ -58,7 +68,13 @@ sl-indent-guides { display: flex; flex: 1; gap: var(--sl-size-075); - padding-block: var(--sl-size-050); +} + +[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); } @@ -89,6 +105,11 @@ sl-checkbox { } } +sl-button-bar { + display: none; + margin-inline-start: auto; +} + sl-skeleton { block-size: 1lh; } diff --git a/packages/components/tree/src/tree-node.ts b/packages/components/tree/src/tree-node.ts index b28e964143..e1543f044d 100644 --- a/packages/components/tree/src/tree-node.ts +++ b/packages/components/tree/src/tree-node.ts @@ -1,5 +1,6 @@ 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'; @@ -37,6 +38,7 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { /** @internal */ static get scopedElements(): ScopedElementsMap { return { + 'sl-button-bar': ButtonBar, 'sl-checkbox': Checkbox, 'sl-icon': Icon, 'sl-indent-guides': IndentGuides, @@ -173,7 +175,16 @@ export class TreeNode extends ScopedElementsMixin(LitElement) { ` - : html`` + : html` +
+ +
+ + + + + + ` )}
`; diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index e34353a9b9..2308513f35 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -390,26 +390,19 @@ export const CustomRenderer: Story = { return html` ${icon ? html`` : nothing} ${node.label} - - - - - - - - + + + + + + + `; }, scopedElements: { 'sl-button': Button, 'sl-button-bar': ButtonBar, 'sl-icon': Icon - }, - styles: ` - sl-tree::part(button-bar) { - flex: inherit; - margin-inline-start: auto; - } - ` + } } }; diff --git a/yarn.lock b/yarn.lock index 00cec24993..03a0993dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5583,6 +5583,7 @@ __metadata: 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" From 4863ef4468bf276ae4511252362e5c3ba829c0a4 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 17 Jan 2025 16:25:08 +0100 Subject: [PATCH 79/88] =?UTF-8?q?=E2=9B=84=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tree/src/flat-tree-data-source.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/components/tree/src/flat-tree-data-source.spec.ts 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); + }); + }); +}); From 0cac49e34c9a5b8e6a86e3cf5319bf2bdb72a555 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 21 Jan 2025 13:24:23 +0100 Subject: [PATCH 80/88] =?UTF-8?q?=F0=9F=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 36 ++++++++++++++++++---------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 3c7e2bbe1d..332aab806b 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -45,6 +45,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The data model for the tree. */ #dataSource?: TreeDataSource; + /** Observe changes to the rendered tree nodes and clear the element cache. */ + #observer = new MutationObserver(() => { + const offset = (this.#virtualizer as unknown as { _first: number })?._first ?? 0; + + this.#rovingTabindexController.clearElementCache(offset); + }); + /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController>(this, { focusInIndex: (elements: Array>) => elements.findIndex(el => !el.disabled), @@ -100,20 +107,26 @@ export class Tree extends ScopedElementsMixin(LitElement) { super.connectedCallback(); this.role = 'tree'; + + const wrapper = this.renderRoot.querySelector('[part="wrapper"]'); + if (wrapper) { + this.#observer.observe(wrapper, { childList: true }); + } } - override async firstUpdated(changes: PropertyValues): Promise { + override disconnectedCallback(): void { + this.#observer.disconnect(); + + super.disconnectedCallback(); + } + + override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); - const host = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; - this.#virtualizer = host[virtualizerRef]; + const wrapper = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; - // Check if there is a data source, otherwise we don't need to do anything. - // Doing this when there is no data source causes errors in unit tests. - if (this.dataSource) { - await this.layoutComplete; - this.#rovingTabindexController.clearElementCache(); - } + this.#observer.observe(wrapper, { childList: true }); + this.#virtualizer = wrapper[virtualizerRef]; if (this.dataSource?.selection.size) { const node = this.dataSource.selection.keys().next().value as TreeDataSourceNode; @@ -246,10 +259,7 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.dataSource?.toggle(node); } - #onUpdate = async (): Promise => { + #onUpdate = (): void => { this.requestUpdate('dataSource'); - - await this.layoutComplete; - this.#rovingTabindexController.clearElementCache(); }; } From 8919cda11d5ac65f298b9ec56b5007bdafcfc3b9 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 21 Jan 2025 13:43:17 +0100 Subject: [PATCH 81/88] =?UTF-8?q?=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 49 ++++++++++++---------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 332aab806b..e6857729b3 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -1,3 +1,4 @@ +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'; @@ -45,13 +46,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { /** The data model for the tree. */ #dataSource?: TreeDataSource; - /** Observe changes to the rendered tree nodes and clear the element cache. */ - #observer = new MutationObserver(() => { - const offset = (this.#virtualizer as unknown as { _first: number })?._first ?? 0; - - this.#rovingTabindexController.clearElementCache(offset); - }); - /** Manage keyboard navigation between tabs. */ #rovingTabindexController = new RovingTabindexController>(this, { focusInIndex: (elements: Array>) => elements.findIndex(el => !el.disabled), @@ -107,25 +101,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { super.connectedCallback(); this.role = 'tree'; - - const wrapper = this.renderRoot.querySelector('[part="wrapper"]'); - if (wrapper) { - this.#observer.observe(wrapper, { childList: true }); - } - } - - override disconnectedCallback(): void { - this.#observer.disconnect(); - - super.disconnectedCallback(); } override firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); const wrapper = this.renderRoot.querySelector('[part="wrapper"]') as VirtualizerHostElement; - - this.#observer.observe(wrapper, { childList: true }); this.#virtualizer = wrapper[virtualizerRef]; if (this.dataSource?.selection.size) { @@ -157,7 +138,12 @@ export class Tree extends ScopedElementsMixin(LitElement) { override render(): TemplateResult { return html` -
+
${virtualize({ items: this.dataSource?.items, keyFunction: (item: TreeDataSourceNode) => item.id, @@ -196,6 +182,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { `; } + 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); @@ -206,13 +199,6 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.selectEvent.emit(node); } - scrollToNode(node: TreeDataSourceNode, options?: ScrollIntoViewOptions): void { - const index = this.dataSource?.items.indexOf(node) ?? -1; - if (index !== -1) { - this.#virtualizer?.element(index)?.scrollIntoView(options); - } - } - #onKeydown(event: KeyboardEvent): void { if (!(event.target instanceof TreeNode)) { return; @@ -247,6 +233,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { } } + #onRangeChanged(event: RangeChangedEvent): void { + this.#rovingTabindexController.updateWithVirtualizer( + { elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) }, + event + ); + } + #onSelect(event: SlSelectEvent>): void { event.preventDefault(); event.stopPropagation(); From 2ac986f8e996c9f7258ef8c648b583d7566b76af Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 21 Jan 2025 13:47:10 +0100 Subject: [PATCH 82/88] =?UTF-8?q?=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index e6857729b3..4b8bb30a31 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -103,12 +103,14 @@ export class Tree extends ScopedElementsMixin(LitElement) { this.role = 'tree'; } - override firstUpdated(changes: PropertyValues): void { + 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; From 4b065275006b793ae4f496022026a81cf495e505 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 22 Jan 2025 09:41:23 +0100 Subject: [PATCH 83/88] =?UTF-8?q?=F0=9F=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/curvy-jeans-travel.md | 4 +++- .changeset/ninety-flowers-fail.md | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 .changeset/ninety-flowers-fail.md diff --git a/.changeset/curvy-jeans-travel.md b/.changeset/curvy-jeans-travel.md index c617607a46..1b4f208bc4 100644 --- a/.changeset/curvy-jeans-travel.md +++ b/.changeset/curvy-jeans-travel.md @@ -2,4 +2,6 @@ '@sl-design-system/checkbox': patch --- -Fix bug where clicking a checkbox in a tree-node will not check it +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/ninety-flowers-fail.md b/.changeset/ninety-flowers-fail.md deleted file mode 100644 index 60a26384d4..0000000000 --- a/.changeset/ninety-flowers-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@sl-design-system/checkbox': patch ---- - -Fix `sl-change` event firing multiple times for a single click From 7f8bda60bb0f2b729126b4ee6543672403b20a88 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 22 Jan 2025 09:45:01 +0100 Subject: [PATCH 84/88] =?UTF-8?q?=F0=9F=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/locales/src/nl.ts | 4 ++-- packages/locales/src/nl.xlf | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/locales/src/nl.ts b/packages/locales/src/nl.ts index f2af914318..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', @@ -59,6 +60,5 @@ export const templates = { see63aaad45b1b116: 'status', sf1ec4acb8d744ed9: 'Mededeling', sf677da98fa27f9b6: 'Meer links', - sf7290005be5beae6: str`${0}, pagina`, - sb59d68ed12d46377: 'Loading' + sf7290005be5beae6: str`${0}, pagina` }; 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 + From 4bf2810cd90ad9714b45728d1657b2a9286a6cc3 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Wed, 22 Jan 2025 10:29:49 +0100 Subject: [PATCH 85/88] =?UTF-8?q?=F0=9F=9A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 4b8bb30a31..4e73cf709f 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -236,10 +236,13 @@ export class Tree extends ScopedElementsMixin(LitElement) { } #onRangeChanged(event: RangeChangedEvent): void { - this.#rovingTabindexController.updateWithVirtualizer( - { elements: () => Array.from(this.renderRoot.querySelectorAll('sl-tree-node')) }, - event - ); + // 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 { From 0eb27acdda949e9bc558fc7a79866ee425d8fc96 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 30 Jan 2025 11:49:43 +0100 Subject: [PATCH 86/88] =?UTF-8?q?=F0=9F=90=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/flat-tree-data-source.ts | 5 +++++ packages/components/tree/src/tree-data-source.ts | 4 ++++ packages/components/tree/src/tree.ts | 2 ++ 3 files changed, 11 insertions(+) diff --git a/packages/components/tree/src/flat-tree-data-source.ts b/packages/components/tree/src/flat-tree-data-source.ts index 7bd9a66d50..e0493affee 100644 --- a/packages/components/tree/src/flat-tree-data-source.ts +++ b/packages/components/tree/src/flat-tree-data-source.ts @@ -19,8 +19,13 @@ export interface FlatTreeDataSourceOptions extends FlatTreeDataSourceMapping< */ // 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> { diff --git a/packages/components/tree/src/tree-data-source.ts b/packages/components/tree/src/tree-data-source.ts index a207f617ab..ec44840994 100644 --- a/packages/components/tree/src/tree-data-source.ts +++ b/packages/components/tree/src/tree-data-source.ts @@ -72,8 +72,12 @@ export interface TreeDataSourceOptions { */ // 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. */ diff --git a/packages/components/tree/src/tree.ts b/packages/components/tree/src/tree.ts index 4e73cf709f..97feec16a9 100644 --- a/packages/components/tree/src/tree.ts +++ b/packages/components/tree/src/tree.ts @@ -124,6 +124,8 @@ export class Tree extends ScopedElementsMixin(LitElement) { 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'); } From fcfce24bd2a92af0a4b34c49af2167727d365ef3 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 30 Jan 2025 11:59:19 +0100 Subject: [PATCH 87/88] =?UTF-8?q?=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.stories.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index 2308513f35..e81548742f 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -361,6 +361,9 @@ export const Skeleton: Story = { }; export const Scrolling: Story = { + parameters: { + chromatic: { disableSnapshot: true } + }, args: { dataSource: new NestedTreeDataSource( [1, 2, 3].map(id => ({ From e145cf91e5cccd53a67ce558aa559844f9b3a4e9 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Thu, 30 Jan 2025 12:04:35 +0100 Subject: [PATCH 88/88] =?UTF-8?q?=F0=9F=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/tree/src/tree.stories.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/tree/src/tree.stories.ts b/packages/components/tree/src/tree.stories.ts index e81548742f..ba37d88fa2 100644 --- a/packages/components/tree/src/tree.stories.ts +++ b/packages/components/tree/src/tree.stories.ts @@ -362,6 +362,7 @@ export const Skeleton: Story = { export const Scrolling: Story = { parameters: { + // The size of the snapshot exceeds the maximum chromatic: { disableSnapshot: true } }, args: {