From a1dda6b2f98e6afde5cff97fa191ce378c3e90b0 Mon Sep 17 00:00:00 2001 From: Marko Oleksiyenko Date: Wed, 25 Sep 2024 14:06:10 +0200 Subject: [PATCH] feat: Create tree component --- .../bootstrap/src/agnos-ui-angular.module.ts | 6 + .../bootstrap/src/components/tree/index.ts | 2 + .../src/components/tree/tree.component.ts | 222 ++++++++++++++++++ angular/bootstrap/src/index.ts | 4 + .../src/app/samples/tree/basic.route.ts | 35 +++ core-bootstrap/src/components/tree/index.ts | 1 + core-bootstrap/src/components/tree/tree.ts | 60 +++++ core-bootstrap/src/config.ts | 5 + core-bootstrap/src/index.ts | 1 + core-bootstrap/src/scss/_variables.scss | 13 + core-bootstrap/src/scss/agnosui.scss | 1 + core-bootstrap/src/scss/tree.scss | 68 ++++++ core/src/components/tree/index.ts | 1 + core/src/components/tree/tree.spec.ts | 83 +++++++ core/src/components/tree/tree.ts | 184 +++++++++++++++ core/src/config.ts | 5 + core/src/index.ts | 1 + demo/src/lib/components-metadata.ts | 6 + .../components/tree/+layout.server.ts | 3 + .../components/tree/examples/+page.svelte | 16 ++ .../tree/playground/TODO+page.svelte | 0 e2e/demo-po/tree.po.ts | 7 + .../bootstrap-tree-basic.html | 59 +++++ e2e/tree/tree.e2e-spec.ts | 69 ++++++ page-objects/lib/index.ts | 1 + page-objects/lib/tree.po.ts | 46 ++++ react/bootstrap/src/components/tree/index.ts | 2 + react/bootstrap/src/components/tree/tree.tsx | 67 ++++++ react/bootstrap/src/index.ts | 1 + .../bootstrap/samples/tree/Basic.route.tsx | 36 +++ react/headless/src/components/tree/index.ts | 1 + react/headless/src/components/tree/tree.ts | 1 + .../bootstrap/src/components/tree/Tree.svelte | 35 +++ .../components/tree/TreeDefaultItem.svelte | 11 + .../components/tree/TreeDefaultRoot.svelte | 17 ++ .../tree/TreeDefaultStructure.svelte | 13 + .../components/tree/TreeDefaultToggle.svelte | 16 ++ svelte/bootstrap/src/components/tree/index.ts | 4 + svelte/bootstrap/src/index.ts | 1 + .../bootstrap/samples/tree/Basic.route.svelte | 31 +++ 40 files changed, 1135 insertions(+) create mode 100644 angular/bootstrap/src/components/tree/index.ts create mode 100644 angular/bootstrap/src/components/tree/tree.component.ts create mode 100644 angular/demo/bootstrap/src/app/samples/tree/basic.route.ts create mode 100644 core-bootstrap/src/components/tree/index.ts create mode 100644 core-bootstrap/src/components/tree/tree.ts create mode 100644 core-bootstrap/src/scss/tree.scss create mode 100644 core/src/components/tree/index.ts create mode 100644 core/src/components/tree/tree.spec.ts create mode 100644 core/src/components/tree/tree.ts create mode 100644 demo/src/routes/docs/[framework]/components/tree/+layout.server.ts create mode 100644 demo/src/routes/docs/[framework]/components/tree/examples/+page.svelte create mode 100644 demo/src/routes/docs/[framework]/components/tree/playground/TODO+page.svelte create mode 100644 e2e/demo-po/tree.po.ts create mode 100644 e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-tree-basic.html create mode 100644 e2e/tree/tree.e2e-spec.ts create mode 100644 page-objects/lib/tree.po.ts create mode 100644 react/bootstrap/src/components/tree/index.ts create mode 100644 react/bootstrap/src/components/tree/tree.tsx create mode 100644 react/demo/src/bootstrap/samples/tree/Basic.route.tsx create mode 100644 react/headless/src/components/tree/index.ts create mode 100644 react/headless/src/components/tree/tree.ts create mode 100644 svelte/bootstrap/src/components/tree/Tree.svelte create mode 100644 svelte/bootstrap/src/components/tree/TreeDefaultItem.svelte create mode 100644 svelte/bootstrap/src/components/tree/TreeDefaultRoot.svelte create mode 100644 svelte/bootstrap/src/components/tree/TreeDefaultStructure.svelte create mode 100644 svelte/bootstrap/src/components/tree/TreeDefaultToggle.svelte create mode 100644 svelte/bootstrap/src/components/tree/index.ts create mode 100644 svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte diff --git a/angular/bootstrap/src/agnos-ui-angular.module.ts b/angular/bootstrap/src/agnos-ui-angular.module.ts index f5f8db7825..a0c4f6cc1f 100644 --- a/angular/bootstrap/src/agnos-ui-angular.module.ts +++ b/angular/bootstrap/src/agnos-ui-angular.module.ts @@ -33,6 +33,7 @@ import {SliderComponent, SliderHandleDirective, SliderLabelDirective, SliderStru import {ProgressbarComponent, ProgressbarBodyDirective, ProgressbarStructureDirective} from './components/progressbar/progressbar.component'; import {ToastBodyDirective, ToastComponent, ToastHeaderDirective, ToastStructureDirective} from './components/toast/toast.component'; import {CollapseDirective} from './components/collapse'; +import {TreeComponent, TreeItemDirective, TreeRootDirective, TreeStructureDirective, TreeToggleDirective} from './components/tree/tree.component'; /* istanbul ignore next */ const components = [ SlotDirective, @@ -78,6 +79,11 @@ const components = [ ToastBodyDirective, ToastHeaderDirective, CollapseDirective, + TreeComponent, + TreeStructureDirective, + TreeToggleDirective, + TreeItemDirective, + TreeRootDirective, ]; @NgModule({ diff --git a/angular/bootstrap/src/components/tree/index.ts b/angular/bootstrap/src/components/tree/index.ts new file mode 100644 index 0000000000..665ca4a5e3 --- /dev/null +++ b/angular/bootstrap/src/components/tree/index.ts @@ -0,0 +1,2 @@ +export * from './tree.component'; +export * from './tree.gen'; diff --git a/angular/bootstrap/src/components/tree/tree.component.ts b/angular/bootstrap/src/components/tree/tree.component.ts new file mode 100644 index 0000000000..e099572249 --- /dev/null +++ b/angular/bootstrap/src/components/tree/tree.component.ts @@ -0,0 +1,222 @@ +import type {SlotContent} from '@agnos-ui/angular-headless'; +import {BaseWidgetDirective, callWidgetFactory, ComponentTemplate, SlotDirective, UseDirective} from '@agnos-ui/angular-headless'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + inject, + Input, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import type {TreeContext, TreeItem, TreeSlotItemContext, TreeWidget} from './tree.gen'; +import {createTree} from './tree.gen'; + +@Directive({selector: 'ng-template[auTreeStructure]', standalone: true}) +export class TreeStructureDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeStructureDirective, context: unknown): context is TreeContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, TreeStructureDirective, SlotDirective], + template: ` + +
    + @for (node of state.normalizedNodes(); track node) { + + } +
+
+ `, +}) +export class TreeDefaultStructureSlotComponent { + @ViewChild('structure', {static: true}) readonly structure!: TemplateRef; +} + +export const treeDefaultSlotStructure = new ComponentTemplate(TreeDefaultStructureSlotComponent, 'structure'); + +@Directive({selector: 'ng-template[auTreeToggle]', standalone: true}) +export class TreeToggleDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeToggleDirective, context: unknown): context is TreeSlotItemContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, TreeToggleDirective], + template: ` + + @if (item.children!.length > 0) { + + } @else { + + } + + `, +}) +export class TreeDefaultToggleSlotComponent { + @ViewChild('toggle', {static: true}) readonly toggle!: TemplateRef; +} + +export const treeDefaultSlotToggle = new ComponentTemplate(TreeDefaultToggleSlotComponent, 'toggle'); + +@Directive({selector: 'ng-template[auTreeItem]', standalone: true}) +export class TreeItemDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeItemDirective, context: unknown): context is TreeSlotItemContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, SlotDirective, TreeItemDirective], + template: ` + + + + {{ item.label }} + + + `, +}) +export class TreeDefaultItemSlotComponent { + @ViewChild('treeItem', {static: true}) readonly treeItem!: TemplateRef; +} + +export const treeDefaultSlotItem = new ComponentTemplate(TreeDefaultItemSlotComponent, 'treeItem'); + +@Directive({selector: 'ng-template[auTreeRoot]', standalone: true}) +export class TreeRootDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeRootDirective, context: unknown): context is TreeSlotItemContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, SlotDirective, TreeRootDirective], + template: ` + +
  • + + @if (state.expandedMap().get(item)) { +
      + @for (child of item.children; track child) { + + } +
    + } +
  • +
    + `, +}) +export class TreeDefaultRootSlotComponent { + @ViewChild('treeRoot', {static: true}) readonly treeRoot!: TemplateRef; +} + +export const treeDefaultSlotRoot = new ComponentTemplate(TreeDefaultRootSlotComponent, 'treeRoot'); + +@Component({ + selector: '[auTree]', + standalone: true, + imports: [UseDirective, SlotDirective], + template: ` `, +}) +export class TreeComponent extends BaseWidgetDirective { + constructor() { + super( + callWidgetFactory({ + factory: createTree, + widgetName: 'tree', + defaultConfig: { + structure: treeDefaultSlotStructure, + root: treeDefaultSlotRoot, + item: treeDefaultSlotItem, + toggle: treeDefaultSlotToggle, + }, + events: { + onExpandToggle: (item: TreeItem) => this.expandToggle.emit(item), + }, + slotTemplates: () => ({ + structure: this.slotStructureFromContent?.templateRef, + root: this.slotRootFromContent?.templateRef, + item: this.slotItemFromContent?.templateRef, + toggle: this.slotToggleFromContent?.templateRef, + }), + }), + ); + } + /** + * Optional accessiblity label for the tree if there is no explicit label + * + * @defaultValue `''` + */ + @Input('auAriaLabel') ariaLabel: string | undefined; + /** + * Array of the tree nodes to display + * + * @defaultValue `[]` + */ + @Input('auNodes') nodes: TreeItem[] | undefined; + /** + * CSS classes to be applied on the widget main container + * + * @defaultValue `''` + */ + @Input('auClassName') className: string | undefined; + + /** + * An event emitted when the user toggles the expand of the TreeItem. + * + * Event payload is equal to the TreeItem clicked. + * + * @defaultValue + * ```ts + * () => {} + * ``` + */ + @Output('auExpandToggle') expandToggle = new EventEmitter(); + + /** + * Slot to change the default tree item + */ + @Input('auItem') item: SlotContent; + @ContentChild(TreeItemDirective, {static: false}) slotItemFromContent: TreeItemDirective | undefined; + + /** + * Slot to change the default display of the tree + */ + @Input('auStructure') structure: SlotContent; + @ContentChild(TreeStructureDirective, {static: false}) slotStructureFromContent: TreeStructureDirective | undefined; + + /** + * Slot to change the default tree item toggle + */ + @Input('auToggle') toggle: SlotContent; + @ContentChild(TreeToggleDirective, {static: false}) slotToggleFromContent: TreeToggleDirective | undefined; + + /** + * Slot to change the default tree root + */ + @Input('auRoot') root: SlotContent; + @ContentChild(TreeRootDirective, {static: false}) slotRootFromContent: TreeRootDirective | undefined; +} diff --git a/angular/bootstrap/src/index.ts b/angular/bootstrap/src/index.ts index b8bb8f4da7..bd7afec1f9 100644 --- a/angular/bootstrap/src/index.ts +++ b/angular/bootstrap/src/index.ts @@ -84,6 +84,10 @@ export type {ToastContext, ToastProps, ToastState, ToastWidget, ToastApi, ToastD export {createToast, getToastDefaultConfig} from './components/toast'; export * from './components/toast'; +export type {TreeProps, TreeState, TreeWidget, TreeApi, TreeDirectives, TreeItem} from './components/tree'; +export {createTree, getTreeDefaultConfig} from './components/tree'; +export * from './components/tree'; + export * from '@agnos-ui/core-bootstrap/services/transitions'; export * from '@agnos-ui/core-bootstrap/types'; diff --git a/angular/demo/bootstrap/src/app/samples/tree/basic.route.ts b/angular/demo/bootstrap/src/app/samples/tree/basic.route.ts new file mode 100644 index 0000000000..f816396ec9 --- /dev/null +++ b/angular/demo/bootstrap/src/app/samples/tree/basic.route.ts @@ -0,0 +1,35 @@ +import type {TreeItem} from '@agnos-ui/angular-bootstrap'; +import {AgnosUIAngularModule} from '@agnos-ui/angular-bootstrap'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + template: ` `, + imports: [AgnosUIAngularModule], +}) +export default class BasicTreeComponent { + nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + ariaLabel: 'Node 1', + children: [ + { + label: 'Node 1.1', + isExpanded: false, + ariaLabel: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + ariaLabel: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + ariaLabel: 'Node 1.2', + }, + ], + }, + ]; +} diff --git a/core-bootstrap/src/components/tree/index.ts b/core-bootstrap/src/components/tree/index.ts new file mode 100644 index 0000000000..50842b59a3 --- /dev/null +++ b/core-bootstrap/src/components/tree/index.ts @@ -0,0 +1 @@ +export * from './tree'; diff --git a/core-bootstrap/src/components/tree/tree.ts b/core-bootstrap/src/components/tree/tree.ts new file mode 100644 index 0000000000..14440f6b41 --- /dev/null +++ b/core-bootstrap/src/components/tree/tree.ts @@ -0,0 +1,60 @@ +import type {TreeProps as CoreProps, TreeState as CoreState, TreeApi, TreeDirectives, TreeItem} from '@agnos-ui/core/components/tree'; +import {createTree as createCoreTree, getTreeDefaultConfig as getCoreDefaultConfig} from '@agnos-ui/core/components/tree'; +import {extendWidgetProps} from '@agnos-ui/core/services/extendWidget'; +import type {SlotContent, Widget, WidgetFactory, WidgetSlotContext} from '@agnos-ui/core/types'; + +export * from '@agnos-ui/core/components/tree'; + +export type TreeContext = WidgetSlotContext; +export type TreeSlotItemContext = TreeContext & {item: TreeItem}; + +interface TreeExtraProps { + /** + * Slot to change the default display of the tree + */ + structure: SlotContent; + /** + * Slot to change the default tree root + */ + root: SlotContent; + /** + * Slot to change the default tree item + */ + item: SlotContent; + /** + * Slot to change the default tree item toggle + */ + toggle: SlotContent; +} + +export interface TreeState extends CoreState, TreeExtraProps {} +export interface TreeProps extends CoreProps, TreeExtraProps {} + +export type TreeWidget = Widget; + +const defaultConfigExtraProps: TreeExtraProps = { + structure: undefined, + root: undefined, + item: undefined, + toggle: undefined, +}; + +/** + * Retrieve a shallow copy of the default Tree config + * @returns the default Tree config + */ +export function getTreeDefaultConfig(): TreeProps { + return {...getCoreDefaultConfig(), ...defaultConfigExtraProps}; +} + +/** + * Create a Tree with given config props + * @param config - an optional tree config + * @returns a TreeWidget + */ +export const createTree: WidgetFactory = extendWidgetProps(createCoreTree, defaultConfigExtraProps, { + structure: undefined, + root: undefined, + item: undefined, + toggle: undefined, +}); diff --git a/core-bootstrap/src/config.ts b/core-bootstrap/src/config.ts index 7a67365516..a2219fe6ca 100644 --- a/core-bootstrap/src/config.ts +++ b/core-bootstrap/src/config.ts @@ -8,6 +8,7 @@ import type {RatingProps} from './components/rating'; import type {SelectProps} from './components/select'; import type {SliderProps} from './components/slider'; import type {ToastProps} from './components/toast'; +import type {TreeProps} from './components/tree'; /** * Configuration interface for various Bootstrap widgets. @@ -53,4 +54,8 @@ export interface BootstrapWidgetsConfig { * collapse widget config */ collapse: CollapseProps; + /** + * tree widget config + */ + tree: TreeProps; } diff --git a/core-bootstrap/src/index.ts b/core-bootstrap/src/index.ts index 47a6f9fae5..b61c60badb 100644 --- a/core-bootstrap/src/index.ts +++ b/core-bootstrap/src/index.ts @@ -8,6 +8,7 @@ export * from './components/select'; export * from './components/slider'; export * from './components/toast'; export * from './components/collapse'; +export * from './components/tree'; export * from './services/transitions'; export * from './config'; diff --git a/core-bootstrap/src/scss/_variables.scss b/core-bootstrap/src/scss/_variables.scss index 5479e93d46..ef14921d74 100644 --- a/core-bootstrap/src/scss/_variables.scss +++ b/core-bootstrap/src/scss/_variables.scss @@ -67,3 +67,16 @@ $au-slider-handle-size-lg: 1.5rem !default; $au-slider-font-size-lg: 1.125rem !default; $au-slider-offset-lg: 0rem !default; // scss-docs-end slider-vars + +//tree variables +// scss-docs-start tree-vars +$au-tree-item-padding-start: 2.25rem !default; +$au-tree-expand-button-margin-inline-end: 0.5rem !default; +$au-tree-expand-button-border-radius: 0.375rem !default; +$au-tree-expand-button-background-color: transparent !default; +$au-tree-expand-button-background-color-hover: #c5d5f9 !default; +$au-tree-expand-icon-color-default: blue !default; // TODO change to a proper color +$au-tree-expand-icon-color-hover: darkblue !default; // TODO change to a proper color +$au-tree-expand-icon-default: url('data:image/svg+xml;utf8,') !default; +$au-tree-expand-icon-hover: url('data:image/svg+xml;utf8,') !default; +// scss-docs-end slider-vars diff --git a/core-bootstrap/src/scss/agnosui.scss b/core-bootstrap/src/scss/agnosui.scss index ba07f479db..861687fcc7 100644 --- a/core-bootstrap/src/scss/agnosui.scss +++ b/core-bootstrap/src/scss/agnosui.scss @@ -1,4 +1,5 @@ //components +@use 'tree'; @import 'select'; @import 'slider'; @import 'rating'; diff --git a/core-bootstrap/src/scss/tree.scss b/core-bootstrap/src/scss/tree.scss new file mode 100644 index 0000000000..3971dc9267 --- /dev/null +++ b/core-bootstrap/src/scss/tree.scss @@ -0,0 +1,68 @@ +@use 'variables'; + +ul[class*='au-tree'] { + // scss-docs-start tree-css-vars + --#{variables.$prefix}tree-expand-button: #{variables.$au-tree-expand-icon-default}; + --#{variables.$prefix}tree-expand-button-background-color: #{variables.$au-tree-expand-button-background-color}; + // scss-docs-end tree-css-vars + + list-style: none; + padding: 0; + margin: 0; + + ul { + display: flex; + flex-direction: column; + list-style: none; + padding-inline-start: #{variables.$au-tree-item-padding-start}; + margin: 0; + } + + li { + list-style: none; + padding: 0; + margin: 0; + } + + .au-tree-item { + position: relative; + display: flex; + align-items: center; + } + + .au-tree-expand-button-placeholder { + display: flex; + width: 2.75rem; + } + + .au-tree-expand-button { + position: relative; + width: 2.25rem; + height: 2.25rem; + border-radius: #{variables.$au-tree-expand-button-border-radius}; + display: inline-flex; + border: 0; + padding-inline: 0; + margin-inline-end: #{variables.$au-tree-expand-button-margin-inline-end}; + background-color: var(--#{variables.$prefix}tree-expand-button-background-color); + + &:hover { + --#{variables.$prefix}tree-expand-button: #{variables.$au-tree-expand-icon-hover}; + --#{variables.$prefix}tree-expand-button-background-color: #{variables.$au-tree-expand-button-background-color-hover}; + } + } + + .au-tree-expand-button::after { + position: absolute; + content: var(--#{variables.$prefix}tree-expand-button); + transition: transform 0.3s; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + /* Expanded state */ + .au-tree-expand-button-expanded::after { + transform: translate(-55%, -50%) rotate(90deg); + } +} diff --git a/core/src/components/tree/index.ts b/core/src/components/tree/index.ts new file mode 100644 index 0000000000..50842b59a3 --- /dev/null +++ b/core/src/components/tree/index.ts @@ -0,0 +1 @@ +export * from './tree'; diff --git a/core/src/components/tree/tree.spec.ts b/core/src/components/tree/tree.spec.ts new file mode 100644 index 0000000000..d125b2593b --- /dev/null +++ b/core/src/components/tree/tree.spec.ts @@ -0,0 +1,83 @@ +import {computed, writable} from '@amadeus-it-group/tansu'; +import type {UnsubscribeFunction, WritableSignal} from '@amadeus-it-group/tansu'; +import {createTree} from './tree'; +import type {TreeItem, TreeProps, TreeState, TreeWidget} from './tree'; +import type {WidgetState} from '../../types'; +import {test, expect, describe, beforeEach, afterEach} from 'vitest'; +import {assign} from '../../../../common/utils'; +import {attachDirectiveAndSendEvent} from '../components.spec-utils'; + +const defaultState: () => TreeState = () => ({ + className: '', + normalizedNodes: [], + expandedMap: new WeakMap(), +}); + +describe(`Tree`, () => { + let tree: TreeWidget; + let defaultConfig: WritableSignal>; + let state: WidgetState; + let unsubscribe: UnsubscribeFunction; + + const itemExpands: TreeItem[] = []; + + const toggleNode = (node: TreeItem) => { + attachDirectiveAndSendEvent(tree.directives.itemToggleDirective, {item: node}, (node) => node.dispatchEvent(new MouseEvent('click'))); + }; + + const callbacks = { + onExpandToggle: (node: TreeItem) => { + itemExpands.push(node); + }, + }; + + beforeEach(() => { + defaultConfig = writable({}); + tree = createTree({config: computed(() => ({...callbacks, ...defaultConfig()}))}); + + unsubscribe = tree.state$.subscribe((newState) => { + state = newState; + }); + }); + + afterEach(() => { + unsubscribe(); + }); + + test(`should create the default configuration for the model`, () => { + expect(state).toStrictEqual(defaultState()); + }); + + test(`should update state according to the input`, () => { + expect(state).toStrictEqual(defaultState()); + tree.patch({nodes: [{label: 'root', ariaLabel: 'root', children: [{label: 'child', ariaLabel: 'child'}]}]}); + + const expectedState = defaultState(); + + expect(state).toStrictEqual( + assign(expectedState, { + normalizedNodes: [ + { + label: 'root', + ariaLabel: 'root', + level: 0, + children: [ + { + label: 'child', + ariaLabel: 'child', + level: 1, + children: [], + }, + ], + }, + ], + }), + ); + }); + + test(`should register the callback for the onExpandToggle event`, () => { + expect(itemExpands).toStrictEqual([]); + toggleNode({label: 'root', ariaLabel: 'root', children: []}); + expect(itemExpands).toStrictEqual([{label: 'root', ariaLabel: 'root', children: []}]); + }); +}); diff --git a/core/src/components/tree/tree.ts b/core/src/components/tree/tree.ts new file mode 100644 index 0000000000..26f8db55ac --- /dev/null +++ b/core/src/components/tree/tree.ts @@ -0,0 +1,184 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {computed, writable} from '@amadeus-it-group/tansu'; +import type {Directive} from '../../types'; +import {type ConfigValidator, type PropsConfig, type Widget} from '../../types'; +import {createAttributesDirective} from '../../utils/directive'; +import {stateStores, writablesForProps} from '../../utils/stores'; +import {typeArray, typeFunction, typeString} from '../../utils/writables'; +import type {WidgetsCommonPropsAndState} from '../commonProps'; +import {noop} from '../../utils/internal/func'; + +export interface TreeItem { + /** + * Accessiblity label for the node + */ + ariaLabel: string; + /** + * Optional array of children nodes + */ + children?: TreeItem[]; + /** + * If `true` the node is expanded + */ + isExpanded?: boolean; + /** + * String title of the node + */ + label: string; + /** + * level in the hierarchy, starts with 0 for a root node + */ + level?: number; +} + +interface TreeCommonPropsAndState extends WidgetsCommonPropsAndState { + /** + * Optional accessiblity label for the tree if there is no explicit label + * + * @defaultValue `''` + */ + ariaLabel?: string; +} + +export interface TreeProps extends TreeCommonPropsAndState { + /** + * Array of the tree nodes to display + * + * @defaultValue `[]` + */ + nodes: TreeItem[]; + /** + * An event emitted when the user toggles the expand of the TreeItem. + * + * Event payload is equal to the TreeItem clicked. + * + * @defaultValue + * ```ts + * () => {} + * ``` + */ + onExpandToggle: (node: TreeItem) => void; +} + +export interface TreeState extends TreeCommonPropsAndState { + /** + * Array of normalized tree nodes + */ + normalizedNodes: TreeItem[]; + /** + * Map of expanded state for each tree node + */ + expandedMap: WeakMap; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TreeApi {} + +export interface TreeDirectives { + /** + * Directive to handle toggle for the tree item + */ + itemToggleDirective: Directive<{item: TreeItem}>; + /** + * Directive to handle attributes for the tree item + */ + itemAttributesDirective: Directive<{item: TreeItem}>; +} + +export type TreeWidget = Widget; + +/** + * Retrieve a shallow copy of the default Tree config + * @returns the default Tree config + */ +export function getTreeDefaultConfig(): TreeProps { + return { + ...defaultTreeConfig, + }; +} + +const defaultTreeConfig: TreeProps = { + className: '', + nodes: [], + onExpandToggle: noop, +}; + +const configValidator: ConfigValidator = { + className: typeString, + nodes: typeArray, + onExpandToggle: typeFunction, +}; + +/** + * Create a tree widget with given config props + * @param config - an optional tree config + * @returns a TreeWidget + */ +export function createTree(config?: PropsConfig): TreeWidget { + const [{nodes$, onExpandToggle$, ...stateProps}, patch] = writablesForProps(defaultTreeConfig, config, configValidator); + + const _expandedMap$ = writable(new WeakMap()); + const _stateChange$ = writable({}); + + const expandedMap$ = computed(() => { + _stateChange$(); + return _expandedMap$(); + }); + + const traverseTree = (node: TreeItem, level: number) => { + const copyNode = {...node, level, children: node.children ?? []}; + _expandedMap$().set(copyNode, copyNode.isExpanded ?? (copyNode.children.length > 0 ? false : undefined)); + if (node.children) { + copyNode.children = node.children.map((child) => traverseTree(child, level + 1)); + } + + return copyNode; + }; + + // normalize the tree nodes + const normalizedNodes$ = computed(() => nodes$().map((node) => traverseTree(node, 0))); + + /** + * toggle the expanded state of a node + * @param node - TreeItem to be toggled + */ + const toggleExpanded = (node: TreeItem) => { + _expandedMap$().set(node, !_expandedMap$().get(node)); + _stateChange$.set({}); + onExpandToggle$()(node); + }; + + const widget: TreeWidget = { + ...stateStores({normalizedNodes$, expandedMap$, ...stateProps}), + patch, + api: {}, + directives: { + itemToggleDirective: createAttributesDirective((treeItemContext$: ReadableSignal<{item: TreeItem}>) => ({ + events: { + click: () => { + const {item} = treeItemContext$(); + toggleExpanded(item); + }, + }, + attributes: { + 'aria-label': computed(() => { + const {item} = treeItemContext$(); + return `Toggle ${item.label}`; + }), + }, + })), + itemAttributesDirective: createAttributesDirective((treeItemContext$: ReadableSignal<{item: TreeItem}>) => ({ + attributes: { + role: 'treeitem', + 'aria-selected': 'false', // TODO: adapt aria-selected to the actual selected state + 'aria-expanded': computed(() => { + const {item} = treeItemContext$(); + _stateChange$(); + return _expandedMap$().get(item)?.toString(); + }), + }, + })), + }, + }; + return widget; +} diff --git a/core/src/config.ts b/core/src/config.ts index 78204c081a..f1885f7656 100644 --- a/core/src/config.ts +++ b/core/src/config.ts @@ -10,6 +10,7 @@ import type {ProgressbarProps} from './components/progressbar/progressbar'; import {identity} from './utils/internal/func'; import type {SliderProps} from './components/slider/slider'; import type {ToastProps} from './components/toast/toast'; +import type {TreeProps} from './components/tree/tree'; /** * A utility type that makes all properties of an object type `T` optional, @@ -137,4 +138,8 @@ export type WidgetsConfig = { * toast widget config */ toast: ToastProps; + /** + * tree widget config + */ + tree: TreeProps; }; diff --git a/core/src/index.ts b/core/src/index.ts index 7b9581806d..47c73538bf 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -11,6 +11,7 @@ export * from './components/rating'; export * from './components/select'; export * from './components/slider'; export * from './components/toast'; +export * from './components/tree'; // config export * from './config'; diff --git a/demo/src/lib/components-metadata.ts b/demo/src/lib/components-metadata.ts index d95c261d10..7afdba5dc0 100644 --- a/demo/src/lib/components-metadata.ts +++ b/demo/src/lib/components-metadata.ts @@ -102,6 +102,12 @@ export const componentsMetadata: Metadata = { className: 'text-bg-primary', }, }, + Tree: { + title: 'Tree', + status: 'inprogress', + since: 'v0.6.0', + type: 'bootstrap', + }, }; /** diff --git a/demo/src/routes/docs/[framework]/components/tree/+layout.server.ts b/demo/src/routes/docs/[framework]/components/tree/+layout.server.ts new file mode 100644 index 0000000000..889b65fe45 --- /dev/null +++ b/demo/src/routes/docs/[framework]/components/tree/+layout.server.ts @@ -0,0 +1,3 @@ +import {getMenu} from '../getMenu'; + +export const load = () => getMenu('tree'); diff --git a/demo/src/routes/docs/[framework]/components/tree/examples/+page.svelte b/demo/src/routes/docs/[framework]/components/tree/examples/+page.svelte new file mode 100644 index 0000000000..7673a45155 --- /dev/null +++ b/demo/src/routes/docs/[framework]/components/tree/examples/+page.svelte @@ -0,0 +1,16 @@ + + +
    + +
    +
    +

    + The tree component implements the ARIA tree role. +

    +
    diff --git a/demo/src/routes/docs/[framework]/components/tree/playground/TODO+page.svelte b/demo/src/routes/docs/[framework]/components/tree/playground/TODO+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/e2e/demo-po/tree.po.ts b/e2e/demo-po/tree.po.ts new file mode 100644 index 0000000000..dd809f5854 --- /dev/null +++ b/e2e/demo-po/tree.po.ts @@ -0,0 +1,7 @@ +import {BasePO} from '@agnos-ui/base-po'; + +export class TreeDemoPO extends BasePO { + override getComponentSelector(): string { + return '.container'; + } +} diff --git a/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-tree-basic.html b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-tree-basic.html new file mode 100644 index 0000000000..c47d5907d8 --- /dev/null +++ b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-tree-basic.html @@ -0,0 +1,59 @@ + +
    +
      +
    • + +
    • +
    • + + + "Node 1.2" + +
    • +
    + + +
    + \ No newline at end of file diff --git a/e2e/tree/tree.e2e-spec.ts b/e2e/tree/tree.e2e-spec.ts new file mode 100644 index 0000000000..e74c507ff9 --- /dev/null +++ b/e2e/tree/tree.e2e-spec.ts @@ -0,0 +1,69 @@ +import {TreePO} from '@agnos-ui/page-objects'; +import {assign} from '../../common/utils'; +import {expect, test} from '../fixture'; + +const defaultExpectedItemState: {[key: string]: string | null}[] = [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + { + ariaSelected: 'false', + ariaExpanded: null, + }, +]; + +const defaultExpectedToggleState: {[key: string]: string | null}[] = [ + { + ariaLabel: 'Toggle Node 1', + }, + { + ariaLabel: 'Toggle Node 1.1', + }, +]; + +test.describe(`Tree tests`, () => { + test.describe(`Basic tree`, () => { + test(`should properly assign aria properties for treeItems`, async ({page}) => { + const treePO = new TreePO(page, 0); + + await page.goto('#/tree/basic'); + await treePO.locatorRoot.waitFor(); + + const expectedItemState = [...defaultExpectedItemState]; + const expectedToggleState = [...defaultExpectedToggleState]; + + await expect.poll(async () => await treePO.itemToggleState()).toEqual(expectedToggleState); + await expect.poll(async () => await treePO.itemContainerState()).toEqual(expectedItemState); + + await (await treePO.locatorItemToggle.all()).at(1)?.click(); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign(expectedItemState, [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', + ariaExpanded: 'true', // changed + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: null, // new node in dom + }, + { + ariaSelected: 'false', + ariaExpanded: null, + }, + ]), + ); + }); + }); +}); diff --git a/page-objects/lib/index.ts b/page-objects/lib/index.ts index 478423ce91..6110b85e38 100644 --- a/page-objects/lib/index.ts +++ b/page-objects/lib/index.ts @@ -7,3 +7,4 @@ export * from './accordion.po'; export * from './progressbar.po'; export * from './slider.po'; export * from './toast.po'; +export * from './tree.po'; diff --git a/page-objects/lib/tree.po.ts b/page-objects/lib/tree.po.ts new file mode 100644 index 0000000000..003627efee --- /dev/null +++ b/page-objects/lib/tree.po.ts @@ -0,0 +1,46 @@ +import {BasePO} from '@agnos-ui/base-po'; +import type {Locator} from '@playwright/test'; + +export const treeSelectors = { + rootComponent: '[role="tree"]', + itemContainer: '[role="treeitem"]', + itemToggle: '.au-tree-expand-button', + itemContents: '.au-tree-item', +}; + +export class TreePO extends BasePO { + selectors = structuredClone(treeSelectors); + + override getComponentSelector(): string { + return this.selectors.rootComponent; + } + + get locatorItemToggle(): Locator { + return this.locatorRoot.locator(this.selectors.itemToggle); + } + + get locatorItemContainer(): Locator { + return this.locatorRoot.locator(this.selectors.itemContents); + } + + async itemContainerState() { + return this.locatorRoot.locator(this.selectors.itemContainer).evaluateAll((rootNode: HTMLElement[]) => { + return rootNode.map((rn) => { + return { + ariaSelected: rn.getAttribute('aria-selected'), + ariaExpanded: rn.getAttribute('aria-expanded'), + }; + }); + }); + } + + async itemToggleState() { + return this.locatorRoot.locator(this.selectors.itemToggle).evaluateAll((rootNode: HTMLElement[]) => { + return rootNode.map((rn) => { + return { + ariaLabel: rn.getAttribute('aria-label'), + }; + }); + }); + } +} diff --git a/react/bootstrap/src/components/tree/index.ts b/react/bootstrap/src/components/tree/index.ts new file mode 100644 index 0000000000..c5a00d32df --- /dev/null +++ b/react/bootstrap/src/components/tree/index.ts @@ -0,0 +1,2 @@ +export * from './tree'; +export * from './tree.gen'; diff --git a/react/bootstrap/src/components/tree/tree.tsx b/react/bootstrap/src/components/tree/tree.tsx new file mode 100644 index 0000000000..5c712d1e05 --- /dev/null +++ b/react/bootstrap/src/components/tree/tree.tsx @@ -0,0 +1,67 @@ +import {Slot} from '@agnos-ui/react-headless/slot'; +import {useDirective} from '@agnos-ui/react-headless/utils/directive'; +import {useWidgetWithConfig} from '../../config'; +import type {TreeContext, TreeProps, TreeSlotItemContext} from './tree.gen'; +import {createTree} from './tree.gen'; +import classNames from 'classnames'; + +export const DefaultTreeSlotToggle = (slotContext: TreeSlotItemContext) => { + const {state, directives, item} = slotContext; + return item.children!.length > 0 ? ( + + ) : ( + + ); +}; + +export const DefaultTreeSlotItem = (slotContext: TreeSlotItemContext) => { + const {state, item} = slotContext; + return ( + + + {item.label} + + ); +}; + +export const DefaultTreeSlotRoot = (slotContext: TreeSlotItemContext) => { + const {state, directives, item} = slotContext; + return ( +
  • + + {state.expandedMap.get(item) && ( +
      + {item.children!.map((child, index) => ( + + ))} +
    + )} +
  • + ); +}; + +export const DefaultTreeSlotStructure = (slotContext: TreeContext) => { + const {state} = slotContext; + return ( +
      + {state.normalizedNodes.map((node, index) => ( + + ))} +
    + ); +}; + +const defaultConfig: Partial = { + structure: DefaultTreeSlotStructure, + root: DefaultTreeSlotRoot, + item: DefaultTreeSlotItem, + toggle: DefaultTreeSlotToggle, +}; + +export function Tree(props: Partial) { + const widgetContext = useWidgetWithConfig(createTree, props, 'tree', {...defaultConfig}); + return ; +} diff --git a/react/bootstrap/src/index.ts b/react/bootstrap/src/index.ts index a40a147f18..55b549b915 100644 --- a/react/bootstrap/src/index.ts +++ b/react/bootstrap/src/index.ts @@ -8,5 +8,6 @@ export * from './components/rating'; export * from './components/select'; export * from './components/slider'; export * from './components/toast'; +export * from './components/tree'; export * from './generated'; diff --git a/react/demo/src/bootstrap/samples/tree/Basic.route.tsx b/react/demo/src/bootstrap/samples/tree/Basic.route.tsx new file mode 100644 index 0000000000..cd8b113c3d --- /dev/null +++ b/react/demo/src/bootstrap/samples/tree/Basic.route.tsx @@ -0,0 +1,36 @@ +import type {TreeItem} from '@agnos-ui/react-bootstrap/components/tree/tree.gen'; +import {Tree} from '@agnos-ui/react-bootstrap/components/tree'; + +const BasicDemo = () => { + const nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + ariaLabel: 'Node 1', + children: [ + { + label: 'Node 1.1', + isExpanded: false, + ariaLabel: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + ariaLabel: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + ariaLabel: 'Node 1.2', + }, + ], + }, + ]; + return ( + <> + + + ); +}; + +export default BasicDemo; diff --git a/react/headless/src/components/tree/index.ts b/react/headless/src/components/tree/index.ts new file mode 100644 index 0000000000..50842b59a3 --- /dev/null +++ b/react/headless/src/components/tree/index.ts @@ -0,0 +1 @@ +export * from './tree'; diff --git a/react/headless/src/components/tree/tree.ts b/react/headless/src/components/tree/tree.ts new file mode 100644 index 0000000000..f86d6a2bd8 --- /dev/null +++ b/react/headless/src/components/tree/tree.ts @@ -0,0 +1 @@ +export * from '@agnos-ui/core/components/tree'; diff --git a/svelte/bootstrap/src/components/tree/Tree.svelte b/svelte/bootstrap/src/components/tree/Tree.svelte new file mode 100644 index 0000000000..7ec384f078 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/Tree.svelte @@ -0,0 +1,35 @@ + + +{#snippet structure(props: TreeContext)} + +{/snippet} +{#snippet root(props: TreeSlotItemContext)} + +{/snippet} +{#snippet item(props: TreeSlotItemContext)} + +{/snippet} +{#snippet toggle(props: TreeSlotItemContext)} + +{/snippet} + + diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultItem.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultItem.svelte new file mode 100644 index 0000000000..431ef9a312 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultItem.svelte @@ -0,0 +1,11 @@ + + + + + {item.label} + diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultRoot.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultRoot.svelte new file mode 100644 index 0000000000..e33248e895 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultRoot.svelte @@ -0,0 +1,17 @@ + + +
  • + + {#if state.expandedMap.get(item)} +
      + {#each item.children! as child} + + {/each} +
    + {/if} +
  • diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultStructure.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultStructure.svelte new file mode 100644 index 0000000000..844d893768 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultStructure.svelte @@ -0,0 +1,13 @@ + + +
      + {#each state.normalizedNodes as item} + + {/each} +
    diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultToggle.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultToggle.svelte new file mode 100644 index 0000000000..6671b07213 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultToggle.svelte @@ -0,0 +1,16 @@ + + +{#if item.children!.length > 0} + + +{:else} + +{/if} diff --git a/svelte/bootstrap/src/components/tree/index.ts b/svelte/bootstrap/src/components/tree/index.ts new file mode 100644 index 0000000000..98181fe52a --- /dev/null +++ b/svelte/bootstrap/src/components/tree/index.ts @@ -0,0 +1,4 @@ +import Tree from './Tree.svelte'; + +export * from './tree.gen'; +export {Tree}; diff --git a/svelte/bootstrap/src/index.ts b/svelte/bootstrap/src/index.ts index a40a147f18..55b549b915 100644 --- a/svelte/bootstrap/src/index.ts +++ b/svelte/bootstrap/src/index.ts @@ -8,5 +8,6 @@ export * from './components/rating'; export * from './components/select'; export * from './components/slider'; export * from './components/toast'; +export * from './components/tree'; export * from './generated'; diff --git a/svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte b/svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte new file mode 100644 index 0000000000..065127452e --- /dev/null +++ b/svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte @@ -0,0 +1,31 @@ + + +