diff --git a/angular/bootstrap/src/agnos-ui-angular.module.ts b/angular/bootstrap/src/agnos-ui-angular.module.ts index f5f8db7825..841f47b2fd 100644 --- a/angular/bootstrap/src/agnos-ui-angular.module.ts +++ b/angular/bootstrap/src/agnos-ui-angular.module.ts @@ -33,6 +33,13 @@ 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, + TreeItemContentDirective, + TreeItemDirective, + TreeStructureDirective, + TreeItemToggleDirective, +} from './components/tree/tree.component'; /* istanbul ignore next */ const components = [ SlotDirective, @@ -78,6 +85,11 @@ const components = [ ToastBodyDirective, ToastHeaderDirective, CollapseDirective, + TreeComponent, + TreeStructureDirective, + TreeItemToggleDirective, + TreeItemContentDirective, + TreeItemDirective, ]; @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..5c64488a8f --- /dev/null +++ b/angular/bootstrap/src/components/tree/tree.component.ts @@ -0,0 +1,287 @@ +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, NormalizedTreeItem, TreeSlotItemContext, TreeWidget} from './tree.gen'; +import {createTree} from './tree.gen'; + +/** + * Directive to provide a template reference for tree structure. + * + * This directive uses a template reference to render the {@link TreeContext}. + */ +@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: ` + + + + `, +}) +class TreeDefaultStructureSlotComponent { + @ViewChild('structure', {static: true}) readonly structure!: TemplateRef; + + trackNode(index: number, node: NormalizedTreeItem): string { + return node.label + node.level + index; + } +} + +/** + * A constant representing the default slot for tree structure. + */ +export const treeDefaultSlotStructure: SlotContent = new ComponentTemplate(TreeDefaultStructureSlotComponent, 'structure'); + +/** + * Directive to provide a template reference for tree item toggle. + * + * This directive uses a template reference to render the {@link TreeSlotItemContext}. + */ +@Directive({selector: 'ng-template[auTreeItemToggle]', standalone: true}) +export class TreeItemToggleDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeItemToggleDirective, context: unknown): context is TreeSlotItemContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, TreeItemToggleDirective], + template: ` + + @if (item.children!.length > 0) { + + } @else { + + } + + `, +}) +class TreeDefaultItemToggleSlotComponent { + @ViewChild('toggle', {static: true}) readonly toggle!: TemplateRef; +} + +/** + * A constant representing the default slot for tree item toggle. + */ +export const treeDefaultItemToggle: SlotContent = new ComponentTemplate(TreeDefaultItemToggleSlotComponent, 'toggle'); + +/** + * Directive to provide a template reference for tree item content. + * + * This directive uses a template reference to render the {@link TreeSlotItemContext}. + */ +@Directive({selector: 'ng-template[auTreeItemContent]', standalone: true}) +export class TreeItemContentDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: TreeItemContentDirective, context: unknown): context is TreeSlotItemContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, SlotDirective, TreeItemContentDirective], + template: ` + + + + {{ item.label }} + + + `, +}) +class TreeDefaultItemContentSlotComponent { + @ViewChild('treeItemContent', {static: true}) readonly treeItemContent!: TemplateRef; +} + +/** + * A constant representing the default slot for tree item. + */ +export const treeDefaultSlotItemContent: SlotContent = new ComponentTemplate( + TreeDefaultItemContentSlotComponent, + 'treeItemContent', +); + +/** + * Directive to provide a template reference for tree item. + * + * This directive uses a template reference to render the {@link TreeSlotItemContext}. + */ +@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: ` + +
  • + + @if (state.expandedMap().get(item)) { +
      + @for (child of item.children; track trackNode($index, child)) { + + } +
    + } +
  • +
    + `, +}) +class TreeDefaultItemSlotComponent { + @ViewChild('treeItem', {static: true}) readonly treeItem!: TemplateRef; + + trackNode(index: number, node: NormalizedTreeItem) { + return node.label + node.level + index; + } +} + +/** + * A constant representing the default slot for tree item. + */ +export const treeDefaultSlotItem: SlotContent = new ComponentTemplate(TreeDefaultItemSlotComponent, 'treeItem'); + +/** + * TreeComponent is an Angular component that extends the BaseWidgetDirective + * to provide a customizable tree widget. This component allows for various + * configurations and customizations through its inputs and outputs. + */ +@Component({ + selector: '[auTree]', + standalone: true, + imports: [SlotDirective], + template: ` `, +}) +export class TreeComponent extends BaseWidgetDirective { + constructor() { + super( + callWidgetFactory({ + factory: createTree, + widgetName: 'tree', + defaultConfig: { + structure: treeDefaultSlotStructure, + item: treeDefaultSlotItem, + itemContent: treeDefaultSlotItemContent, + itemToggle: treeDefaultItemToggle, + }, + events: { + onExpandToggle: (item: TreeItem) => this.expandToggle.emit(item), + }, + slotTemplates: () => ({ + structure: this.slotStructureFromContent?.templateRef, + item: this.slotItemFromContent?.templateRef, + itemContent: this.slotItemContentFromContent?.templateRef, + itemToggle: this.slotItemToggleFromContent?.templateRef, + }), + }), + ); + } + /** + * Optional accessibility 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; + /** + * Retrieves expand items of the TreeItem + * + * @param node - HTML element that is representing the expand item + * + * @defaultValue + * ```ts + * (node: HTMLElement) => node.querySelectorAll('button') + * ``` + */ + @Input('auNavSelector') navSelector: ((node: HTMLElement) => NodeListOf) | undefined; + /** + * Return the value for the 'aria-label' attribute of the toggle + * @param label - tree item label + * + * @defaultValue + * ```ts + * (label: string) => `Toggle ${label}` + * ``` + */ + @Input('auAriaLabelToggleFn') ariaLabelToggleFn: ((label: string) => 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 content + */ + @Input('auItemContent') item: SlotContent; + @ContentChild(TreeItemContentDirective, {static: false}) slotItemContentFromContent: TreeItemContentDirective | 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(TreeItemToggleDirective, {static: false}) slotItemToggleFromContent: TreeItemToggleDirective | undefined; + + /** + * Slot to change the default tree item + */ + @Input('auItem') root: SlotContent; + @ContentChild(TreeItemDirective, {static: false}) slotItemFromContent: TreeItemDirective | undefined; +} diff --git a/angular/bootstrap/src/index.ts b/angular/bootstrap/src/index.ts index b8bb8f4da7..e751d93ea6 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, NormalizedTreeItem} 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..9cc7e66c5c --- /dev/null +++ b/angular/demo/bootstrap/src/app/samples/tree/basic.route.ts @@ -0,0 +1,34 @@ +import {TreeComponent, type TreeItem} from '@agnos-ui/angular-bootstrap'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + template: ` `, + imports: [TreeComponent], +}) +export default class BasicTreeComponent { + readonly nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + children: [ + { + label: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + children: [ + { + label: 'Node 1.2.1', + }, + ], + }, + ], + }, + ]; +} diff --git a/angular/ssr-app/src/app/app.component.html b/angular/ssr-app/src/app/app.component.html index c532e7c1fc..8e6828c17f 100644 --- a/angular/ssr-app/src/app/app.component.html +++ b/angular/ssr-app/src/app/app.component.html @@ -40,4 +40,8 @@

    Toast

    This is a toast!
    +

    Tree

    +
    + +
    diff --git a/angular/ssr-app/src/app/app.component.ts b/angular/ssr-app/src/app/app.component.ts index a7196de11a..1c43b45eec 100644 --- a/angular/ssr-app/src/app/app.component.ts +++ b/angular/ssr-app/src/app/app.component.ts @@ -1,5 +1,5 @@ import {Component} from '@angular/core'; -import {AgnosUIAngularModule} from '@agnos-ui/angular-bootstrap'; +import {AgnosUIAngularModule, type TreeItem} from '@agnos-ui/angular-bootstrap'; @Component({ selector: 'app-root', @@ -7,4 +7,29 @@ import {AgnosUIAngularModule} from '@agnos-ui/angular-bootstrap'; imports: [AgnosUIAngularModule], templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + readonly nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + children: [ + { + label: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + children: [ + { + label: 'Node 1.2.1', + }, + ], + }, + ], + }, + ]; +} 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..0ae630ce88 --- /dev/null +++ b/core-bootstrap/src/components/tree/tree.ts @@ -0,0 +1,76 @@ +import type {TreeProps as CoreProps, TreeState as CoreState, TreeApi, TreeDirectives, NormalizedTreeItem} 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'; + +/** + * Represents the context for a Tree widget. + * This interface is an alias for `WidgetSlotContext`. + */ +export type TreeContext = WidgetSlotContext; +/** + * Represents the context for a tree item, extending the base `TreeContext` + * with an additional `item` property. + */ +export type TreeSlotItemContext = TreeContext & {item: NormalizedTreeItem}; + +interface TreeExtraProps { + /** + * Slot to change the default display of the tree + */ + structure: SlotContent; + /** + * Slot to change the default tree item + */ + item: SlotContent; + /** + * Slot to change the default tree item content + */ + itemContent: SlotContent; + /** + * Slot to change the default tree item toggle + */ + itemToggle: SlotContent; +} + +/** + * Represents the state of a Tree component. + */ +export interface TreeState extends CoreState, TreeExtraProps {} +/** + * Represents the properties for the Tree component. + */ +export interface TreeProps extends CoreProps, TreeExtraProps {} +/** + * Represents a Tree widget component. + */ +export type TreeWidget = Widget; + +const defaultConfigExtraProps: TreeExtraProps = { + structure: undefined, + item: undefined, + itemContent: undefined, + itemToggle: 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, + item: undefined, + itemContent: undefined, + itemToggle: 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/_functions.scss b/core-bootstrap/src/scss/_functions.scss new file mode 100644 index 0000000000..e09cbd9f71 --- /dev/null +++ b/core-bootstrap/src/scss/_functions.scss @@ -0,0 +1,34 @@ +/** +* Utility function to replace a substring as Sass doesn't provide the built-in method to do it +*/ +@function str-replace($string, $search, $replace: '') { + $index: str-index($string, $search); + + @if $index { + @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace); + } + + @return $string; +} + +// Characters which are escaped by the escape-svg function +$escaped-characters: (('<', '%3c'), ('>', '%3e'), ('#', '%23'), ('(', '%28'), (')', '%29')) !default; + +/** +* Method implementation taken from Bootstrap +* ref: https://github.com/twbs/bootstrap/blob/cacbdc680ecdfee5f0c7fbb876ad15188eaf697d/scss/_functions.scss#L131 +*/ +@function escape-svg($string) { + @if str-index($string, 'data:image/svg+xml') { + @each $char, $encoded in $escaped-characters { + // Do not escape the url brackets + @if str-index($string, 'url(') == 1 { + $string: url('#{str-replace(str-slice($string, 6, -3), $char, $encoded)}'); + } @else { + $string: str-replace($string, $char, $encoded); + } + } + } + + @return $string; +} diff --git a/core-bootstrap/src/scss/_variables.scss b/core-bootstrap/src/scss/_variables.scss index 5479e93d46..4c5f5b10b0 100644 --- a/core-bootstrap/src/scss/_variables.scss +++ b/core-bootstrap/src/scss/_variables.scss @@ -67,3 +67,18 @@ $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-icon-margin-inline-end: 0.5rem !default; +$au-tree-expand-icon-border-radius: 0.375rem !default; +$au-tree-expand-icon-background-color: transparent !default; +$au-tree-expand-icon-background-color-hover: var(--#{$prefix}blue-100, #cfe2ff) !default; +$au-tree-expand-icon-width: 2.25rem; +$au-tree-expand-icon-height: 2.25rem; +$au-tree-expand-icon-color-default: #0d6efd !default; +$au-tree-expand-icon-color-hover: #052c65 !default; +$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 tree-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..008da9f8c5 --- /dev/null +++ b/core-bootstrap/src/scss/tree.scss @@ -0,0 +1,76 @@ +@use 'variables'; +@import '_functions'; + +.au-tree { + // scss-docs-start tree-css-vars + --#{variables.$prefix}tree-item-padding-start: #{variables.$au-tree-item-padding-start}; + --#{variables.$prefix}tree-expand-icon-margin-inline-end: #{variables.$au-tree-expand-icon-margin-inline-end}; + --#{variables.$prefix}tree-expand-icon-border-radius: #{variables.$au-tree-expand-icon-border-radius}; + --#{variables.$prefix}tree-expand-icon-background-color: #{variables.$au-tree-expand-icon-background-color}; + --#{variables.$prefix}tree-expand-icon-background-color-hover: #{variables.$au-tree-expand-icon-background-color-hover}; + --#{variables.$prefix}tree-expand-icon-width: #{variables.$au-tree-expand-icon-width}; + --#{variables.$prefix}tree-expand-icon-height: #{variables.$au-tree-expand-icon-height}; + --#{variables.$prefix}tree-expand-icon-default: #{escape-svg(variables.$au-tree-expand-icon-default)}; + --#{variables.$prefix}tree-expand-icon-hover: #{escape-svg(variables.$au-tree-expand-icon-hover)}; + // 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: var(--#{variables.$prefix}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-icon-placeholder { + display: flex; + width: calc(var(--#{variables.$prefix}tree-expand-icon-width) + var(--#{variables.$prefix}tree-expand-icon-margin-inline-end)); + } + + .au-tree-expand-icon { + position: relative; + width: var(--#{variables.$prefix}tree-expand-icon-width); + height: var(--#{variables.$prefix}tree-expand-icon-height); + border-radius: var(--#{variables.$prefix}tree-expand-icon-border-radius); + display: inline-flex; + border: 0; + padding-inline: 0; + margin-inline-end: var(--#{variables.$prefix}tree-expand-icon-margin-inline-end); + background-color: var(--#{variables.$prefix}tree-expand-icon-background-color); + + &:hover { + --#{variables.$prefix}tree-expand-icon-default: var(--#{variables.$prefix}tree-expand-icon-hover); + --#{variables.$prefix}tree-expand-icon-background-color: var(--#{variables.$prefix}tree-expand-icon-background-color-hover); + } + } + + .au-tree-expand-icon::after { + position: absolute; + content: var(--#{variables.$prefix}tree-expand-icon-default); + transition: transform 0.3s; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + /* Expanded state */ + .au-tree-expand-icon-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..68b2a88b0c --- /dev/null +++ b/core/src/components/tree/tree.spec.ts @@ -0,0 +1,90 @@ +import type {UnsubscribeFunction, WritableSignal} from '@amadeus-it-group/tansu'; +import {computed, writable} from '@amadeus-it-group/tansu'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {assign} from '../../../../common/utils'; +import {attachDirectiveAndSendEvent} from '../components.spec-utils'; +import {createTree, type NormalizedTreeItem, type TreeItem, type TreeProps, type TreeState, type TreeWidget} from './tree'; + +type TestingTreeState = Omit; + +const defaultState: () => TestingTreeState = () => ({ + className: '', + normalizedNodes: [], +}); + +describe(`Tree`, () => { + let tree: TreeWidget; + let defaultConfig: WritableSignal>; + let state: TestingTreeState; + let unsubscribe: UnsubscribeFunction; + + const itemExpands: TreeItem[] = []; + + const toggleNode = (node: NormalizedTreeItem) => { + 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) => { + const {expandedMap, ...updatedState} = newState; + state = updatedState; + }); + }); + + 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, + isExpanded: false, + children: [ + { + label: 'child', + ariaLabel: 'child', + level: 1, + isExpanded: undefined, + children: [], + }, + ], + }, + ], + }), + ); + }); + + test(`should register the callback for the onExpandToggle event and update the normalizedNodes`, () => { + tree.patch({nodes: [{label: 'root', ariaLabel: 'root', children: [{label: 'child', ariaLabel: 'child', children: []}]}]}); + expect(state.normalizedNodes[0].isExpanded).toBe(false); + expect(itemExpands.length).toEqual(0); + + toggleNode(state.normalizedNodes[0]); + + expect(state.normalizedNodes[0].isExpanded).toBe(true); + expect(itemExpands.length).toEqual(1); + }); +}); diff --git a/core/src/components/tree/tree.ts b/core/src/components/tree/tree.ts new file mode 100644 index 0000000000..f9a10d5971 --- /dev/null +++ b/core/src/components/tree/tree.ts @@ -0,0 +1,395 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {computed, writable} from '@amadeus-it-group/tansu'; +import {createNavManager, type NavManagerItemConfig} from '../../services/navManager'; +import type {Directive} from '../../types'; +import {type ConfigValidator, type PropsConfig, type Widget} from '../../types'; +import {bindDirective, browserDirective, createAttributesDirective, mergeDirectives} from '../../utils/directive'; +import {noop} from '../../utils/internal/func'; +import {stateStores, writablesForProps} from '../../utils/stores'; +import {typeArray, typeFunction, typeString} from '../../utils/writables'; +import type {WidgetsCommonPropsAndState} from '../commonProps'; + +/** + * Represents a tree item component. + */ +export interface TreeItem { + /** + * Optional accessibility 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; +} + +/** + * Normalized TreeItem object + */ +export interface NormalizedTreeItem extends TreeItem { + /** + * Accessibility label for the node + */ + ariaLabel: string; + + /** + * Level in the hierarchy, starts with 0 for a root node + */ + level: number; + + /** + * An array of children nodes + */ + children: NormalizedTreeItem[]; +} + +/** + * Represents an internal tree item object necessary for the proper displayand behavior + */ +interface TreeItemInfo { + parent: NormalizedTreeItem | undefined; + htmlElement?: HTMLElement; +} + +interface TreeCommonPropsAndState extends WidgetsCommonPropsAndState { + /** + * Optional accessibility label for the tree if there is no explicit label + * + * @defaultValue `''` + */ + ariaLabel?: string; +} +/** + * Interface representing the properties for the Tree component. + */ +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: NormalizedTreeItem) => void; + /** + * Retrieves expand items of the TreeItem + * + * @param node - HTML element that is representing the expand item + * + * @defaultValue + * ```ts + * (node: HTMLElement) => node.querySelectorAll('button') + * ``` + */ + navSelector(node: HTMLElement): NodeListOf; + /** + * Return the value for the 'aria-label' attribute of the toggle + * @param label - tree item label + * + * @defaultValue + * ```ts + * (label: string) => `Toggle ${label}` + * ``` + */ + ariaLabelToggleFn: (label: string) => string; +} +/** + * Represents the state of a Tree component. + */ +export interface TreeState extends TreeCommonPropsAndState { + /** + * Array of normalized tree nodes + */ + normalizedNodes: NormalizedTreeItem[]; + /** + * Getter of expanded state for each tree node + */ + expandedMap: {get(item: NormalizedTreeItem): boolean | undefined}; +} + +/** + * Interface representing the API for a Tree component. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TreeApi {} + +/** + * Interface representing various directives used in the Tree component. + */ +export interface TreeDirectives { + /** + * Directive to attach navManager for the tree + */ + navigationDirective: Directive; + /** + * Directive to handle toggle for the tree item + */ + itemToggleDirective: Directive<{item: NormalizedTreeItem}>; + /** + * Directive to handle attributes for the tree item + */ + itemAttributesDirective: Directive<{item: NormalizedTreeItem}>; +} +/** + * Represents a Tree widget component. + */ +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, + navSelector: (node: HTMLElement) => node.querySelectorAll('button'), + ariaLabelToggleFn: (label: string) => `Toggle ${label}`, +}; + +const configValidator: ConfigValidator = { + className: typeString, + nodes: typeArray, + onExpandToggle: typeFunction, + navSelector: typeFunction, + ariaLabelToggleFn: 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$, navSelector$, ariaLabelToggleFn$, ...stateProps}, patch] = writablesForProps( + defaultTreeConfig, + config, + configValidator, + ); + const treeMap = new Map(); + const _expandedMap = { + get(item: NormalizedTreeItem): boolean | undefined { + return item.isExpanded; + }, + }; + + const _toggleChange$ = writable({}); + + const expandedMap$ = computed(() => { + normalizedNodes$(); + _toggleChange$(); + return _expandedMap; + }); + + const { + elementsInDomOrder$, + directive: navDirective, + refreshElements, + focusIndex, + focusPrevious, + focusNext, + focusFirst, + focusLast, + } = createNavManager(); + const navManagerConfig$ = computed(() => ({ + keys: { + ArrowUp: focusPrevious, + ArrowDown: focusNext, + Home: focusFirst, + End: focusLast, + }, + selector: navSelector$(), + })); + + const traverseTree = (node: TreeItem, level: number, parent: NormalizedTreeItem | undefined) => { + const copyNode: NormalizedTreeItem = { + ...node, + ariaLabel: node.ariaLabel ?? node.label, + level, + children: [], + isExpanded: node.children?.length ? (node.isExpanded ?? false) : undefined, + }; + treeMap.set(copyNode, { + parent, + }); + + if (node.children) { + copyNode.children = node.children.map((child) => traverseTree(child, level + 1, copyNode)); + } + return copyNode; + }; + + // normalize the tree nodes + const normalizedNodes$ = computed(() => { + treeMap.clear(); + return nodes$().map((node) => traverseTree(node, 0, undefined)); + }); + const _lastFocusedTreeItem$ = writable(normalizedNodes$()[0]); + + const getTreeItemInfo = (item: NormalizedTreeItem) => { + const treeItem = treeMap.get(item); + if (!treeItem) { + console.error(`Node ${item.label} doesn't exist in the map`); + } + return treeItem; + }; + + // custom directive to retrieve the HTMLElement of each tree toggle + const treeItemElementDirective: Directive<{item: NormalizedTreeItem}> = browserDirective( + (toggleItem: HTMLElement, args: {item: NormalizedTreeItem}) => { + let treeItemInfo: TreeItemInfo | undefined; + + const destroy = () => { + if (treeItemInfo && treeItemInfo.htmlElement === toggleItem) { + treeItemInfo.htmlElement = undefined; + } + treeItemInfo = undefined; + }; + + const update = (args: {item: NormalizedTreeItem}) => { + destroy(); + treeItemInfo = getTreeItemInfo(args.item); + if (treeItemInfo) { + if (treeItemInfo.htmlElement) { + console.warn(`The tree item directive should be used once per element`); + } + treeItemInfo.htmlElement = toggleItem; + } + }; + + update(args); + + return { + update, + destroy, + }; + }, + ); + + const focusElementIfExists = (itemToFocus: NormalizedTreeItem | undefined) => { + if (itemToFocus) { + const mapItemHtml = getTreeItemInfo(itemToFocus)?.htmlElement; + if (mapItemHtml) { + const index = elementsInDomOrder$().indexOf(mapItemHtml); + focusIndex(index, 0); + } + } + }; + + const itemToggleAttributesDirective = createAttributesDirective((treeItemContext$: ReadableSignal<{item: NormalizedTreeItem}>) => ({ + events: { + focus: () => { + const {item} = treeItemContext$(); + _lastFocusedTreeItem$.set(item); + }, + click: () => { + const {item} = treeItemContext$(); + toggleExpanded(item); + }, + keydown: (event: KeyboardEvent) => { + const {key} = event; + const {item} = treeItemContext$(); + const isExpanded = item.isExpanded; + refreshElements(); // collapsed items were added to the dom + switch (key) { + case 'ArrowLeft': + if (isExpanded) { + toggleExpanded(item); + } else { + focusElementIfExists(getTreeItemInfo(item)?.parent); + } + break; + case 'ArrowRight': + if (!isExpanded) { + toggleExpanded(item); + } else { + focusElementIfExists(item.children?.[0]); + } + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + }, + }, + attributes: { + 'aria-label': computed(() => { + const {item} = treeItemContext$(); + return ariaLabelToggleFn$()(item.ariaLabel); + }), + tabindex: computed(() => { + const {item} = treeItemContext$(); + return item === _lastFocusedTreeItem$() ? '0' : '-1'; + }), + type: 'button', + }, + classNames: { + 'au-tree-expand-icon': true, + 'au-tree-expand-icon-expanded': computed(() => { + _toggleChange$(); + const {item} = treeItemContext$(); + return item.isExpanded ?? false; + }), + }, + })); + + /** + * toggle the expanded state of a node + * @param node - TreeItem to be toggled + */ + const toggleExpanded = (node: NormalizedTreeItem) => { + const treeItemInfo = getTreeItemInfo(node); + if (treeItemInfo === undefined || node.isExpanded === undefined) { + return; + } + node.isExpanded = !node.isExpanded; + _toggleChange$.set({}); + onExpandToggle$()(node); + }; + + const widget: TreeWidget = { + ...stateStores({normalizedNodes$, expandedMap$, ...stateProps}), + patch, + api: {}, + directives: { + navigationDirective: bindDirective(navDirective, navManagerConfig$), + itemToggleDirective: mergeDirectives(treeItemElementDirective, itemToggleAttributesDirective), + itemAttributesDirective: createAttributesDirective((treeItemContext$: ReadableSignal<{item: NormalizedTreeItem}>) => ({ + attributes: { + role: 'treeitem', + 'aria-selected': 'false', // TODO: adapt aria-selected to the actual selected state + 'aria-expanded': computed(() => { + const {item} = treeItemContext$(); + _toggleChange$(); + return item.isExpanded?.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..f134e2b02a 100644 --- a/demo/src/lib/components-metadata.ts +++ b/demo/src/lib/components-metadata.ts @@ -102,6 +102,13 @@ export const componentsMetadata: Metadata = { className: 'text-bg-primary', }, }, + Tree: { + title: 'Tree', + status: 'beta', + since: 'v0.6.0', + type: 'standalone', + includeStyles: true, + }, }; /** diff --git a/demo/src/routes/docs/[framework]/components/getMenu.ts b/demo/src/routes/docs/[framework]/components/getMenu.ts index 01661bce70..cc91c35d8e 100644 --- a/demo/src/routes/docs/[framework]/components/getMenu.ts +++ b/demo/src/routes/docs/[framework]/components/getMenu.ts @@ -18,7 +18,7 @@ export function getMenu(component: string) { tabs: [ {title: 'Examples', key: 'examples', path: `/components/${component}/examples`}, {title: 'Api', key: 'api', path: `/components/${component}/api`}, - ...(componentMetadata.includeStyles ? [{title: 'Styling', key: 'style', path: '/components/slider/style'}] : []), + ...(componentMetadata.includeStyles ? [{title: 'Styling', key: 'style', path: `/components/${component}/style`}] : []), /** TODO show Playground tab again when we have finished building it { title: 'Playground', 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..9ec00724bc --- /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/demo/src/routes/docs/[framework]/components/tree/style/+page.svelte b/demo/src/routes/docs/[framework]/components/tree/style/+page.svelte new file mode 100644 index 0000000000..0fee972d01 --- /dev/null +++ b/demo/src/routes/docs/[framework]/components/tree/style/+page.svelte @@ -0,0 +1,22 @@ + + +

    + You will need to import the styles defined in @agnos-ui/core-bootstrap/css/tree.css or + @agnos-ui/core-bootstrap/scss/tree.scss +
    + These styles are also exported in the global export @agnos-ui/core-bootstrap/css/agnosui.css or + @agnos-ui/core-bootstrap/scss/agnosui.scss +

    + +
    + +
    + +
    + +
    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..73ec86e514 --- /dev/null +++ b/e2e/samplesMarkup.singlebrowser-e2e-spec.ts-snapshots/bootstrap-tree-basic.html @@ -0,0 +1,67 @@ + +
    +
      +
    • + +
    • + +
    + + +
    + \ No newline at end of file diff --git a/e2e/ssr.ssr-e2e-spec.ts-snapshots/ssr.html b/e2e/ssr.ssr-e2e-spec.ts-snapshots/ssr.html index 2b770ab833..37019acf6b 100644 --- a/e2e/ssr.ssr-e2e-spec.ts-snapshots/ssr.html +++ b/e2e/ssr.ssr-e2e-spec.ts-snapshots/ssr.html @@ -486,5 +486,72 @@

    /> +

    + "Tree" +

    +
    +
      +
    • + +
    • + +
    + + +
    \ 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..6a6576f4b4 --- /dev/null +++ b/e2e/tree/tree.e2e-spec.ts @@ -0,0 +1,367 @@ +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: 'false', + }, +]; + +const defaultExpectedToggleState: {[key: string]: string | null}[] = [ + { + ariaLabel: 'Toggle Node 1', + }, + { + ariaLabel: 'Toggle Node 1.1', + }, + { + ariaLabel: 'Toggle Node 1.2', + }, +]; + +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: 'false', + }, + ]), + ); + }); + + test(`Keyboard navigation`, async ({page}) => { + const treePO = new TreePO(page, 0); + + await page.goto('#/tree/basic'); + await treePO.locatorRoot.waitFor(); + await test.step(`should navigate to the end with End key`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: null, // new node in dom + }, + ], + ), + ); + }); + + await test.step(`should handle the left key stroke`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowLeft'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + { + ariaSelected: 'false', + ariaExpanded: 'false', // changed + }, + ], + ), + ); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + ], + ), + ); + + await expect + .poll(async () => await treePO.itemToggleState()) + .toEqual( + assign( + [{}], + [ + { + ariaLabel: 'Toggle Node 1', + }, + ], + ), + ); + }); + + await test.step(`should handle the right key stroke`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowRight'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: 'false', // new node in dom + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: 'false', // new node in dom + }, + ], + ), + ); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: null, // new node in dom + }, + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + ], + ), + ); + + await expect + .poll(async () => await treePO.itemToggleState()) + .toEqual( + assign( + [{}], + [ + { + ariaLabel: 'Toggle Node 1', + }, + { + ariaLabel: 'Toggle Node 1.1', + }, + { + ariaLabel: 'Toggle Node 1.2', + }, + ], + ), + ); + }); + + await test.step(`should handle the up key stroke`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + ], + ), + ); + + await expect + .poll(async () => await treePO.itemToggleState()) + .toEqual( + assign( + [{}], + [ + { + ariaLabel: 'Toggle Node 1', + }, + ], + ), + ); + }); + + await test.step(`should handle the down key stroke`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'true', + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: 'false', // new node in dom + }, + { + ariaSelected: 'false', // new node in dom + ariaExpanded: 'false', // new node in dom + }, + ], + ), + ); + + await expect + .poll(async () => await treePO.itemToggleState()) + .toEqual( + assign( + [{}], + [ + { + ariaLabel: 'Toggle Node 1', + }, + { + ariaLabel: 'Toggle Node 1.1', // new node in dom + }, + { + ariaLabel: 'Toggle Node 1.2', // new node in dom + }, + ], + ), + ); + }); + + await test.step(`should handle the Home key stroke`, async () => { + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Home'); + await page.keyboard.press('Enter'); + + await expect + .poll(async () => await treePO.itemContainerState()) + .toEqual( + assign( + [{}], + [ + { + ariaSelected: 'false', + ariaExpanded: 'false', + }, + ], + ), + ); + + await expect + .poll(async () => await treePO.itemToggleState()) + .toEqual( + assign( + [{}], + [ + { + ariaLabel: 'Toggle Node 1', + }, + ], + ), + ); + }); + }); + }); +}); 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..d21ad7c12b --- /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-icon', + 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..d054f24d5d --- /dev/null +++ b/react/bootstrap/src/components/tree/tree.tsx @@ -0,0 +1,103 @@ +import {Slot} from '@agnos-ui/react-headless/slot'; +import {useDirective} from '@agnos-ui/react-headless/utils/directive'; +import {useWidgetWithConfig} from '../../config'; +import type {TreeContext, TreeDirectives, NormalizedTreeItem, TreeProps, TreeSlotItemContext} from './tree.gen'; +import {createTree} from './tree.gen'; +import classNames from 'classnames'; + +const ToggleButtonDisplay = ({directive, item}: {directive: TreeDirectives['itemToggleDirective']; item: NormalizedTreeItem}) => { + return ; +}; + +/** + * A functional component that renders a toggle element with a directive applied to it. + * The directive is provided through the `slotContext` parameter. + * + * @param slotContext - The context object containing the directives and item the toggle. + * @returns A toggle element with the applied directive. + */ +export const DefaultTreeSlotItemToggle = (slotContext: TreeSlotItemContext) => { + const {directives, item} = slotContext; + return item.children.length > 0 ? ( + + ) : ( + + ); +}; + +/** + * A functional component that renders a tree item content element. + * + * @param slotContext - The context object containing the item content for display. + * @returns A tree item element. + */ +export const DefaultTreeSlotItemContent = (slotContext: TreeSlotItemContext) => { + const {state, item} = slotContext; + return ( + + + {item.label} + + ); +}; + +/** + * A functional component that renders a tree item element with a directive applied to it. + * The directive is provided through the `slotContext` parameter. + * + * @param slotContext - The context object containing the directives and item for the tree item element. + * @returns A tree root element with the applied directive. + */ +export const DefaultTreeSlotItem = (slotContext: TreeSlotItemContext) => { + const {state, directives, item} = slotContext; + return ( +
  • + + {state.expandedMap.get(item) && ( +
      + {item.children.map((child, index) => ( + + ))} +
    + )} +
  • + ); +}; +/** + * A functional component that renders a tree structure with a directive applied to it. + * The directive is provided through the `slotContext` parameter. + * + * @param slotContext - The context object containing the directives and items for the tree display. + * @returns A tree structure with the applied directive. + */ +export const DefaultTreeSlotStructure = (slotContext: TreeContext) => { + const {state} = slotContext; + return ( +
      + {state.normalizedNodes.map((node, index) => ( + + ))} +
    + ); +}; + +const defaultConfig: Partial = { + structure: DefaultTreeSlotStructure, + item: DefaultTreeSlotItem, + itemContent: DefaultTreeSlotItemContent, + itemToggle: DefaultTreeSlotItemToggle, +}; + +/** + * Tree component that integrates with a widget context and renders a slot structure. + * + * @param props - The properties for the Tree component. + * @returns The rendered Tree component. + * + * The Tree component uses the {@link useWidgetWithConfig} hook to create a widget context with the provided + * configuration. It renders the slot content using the `Slot` component. + */ +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..7bc0d9d4cc --- /dev/null +++ b/react/demo/src/bootstrap/samples/tree/Basic.route.tsx @@ -0,0 +1,31 @@ +import {Tree, type TreeItem} from '@agnos-ui/react-bootstrap/components/tree'; + +const BasicDemo = () => { + const nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + children: [ + { + label: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + children: [ + { + label: 'Node 1.2.1', + }, + ], + }, + ], + }, + ]; + 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/react/ssr-app/src/App.tsx b/react/ssr-app/src/App.tsx index 97948d31b7..97da960268 100644 --- a/react/ssr-app/src/App.tsx +++ b/react/ssr-app/src/App.tsx @@ -7,6 +7,7 @@ import {Rating} from '@agnos-ui/react-bootstrap/components/rating'; import {Select} from '@agnos-ui/react-bootstrap/components/select'; import {Slider} from '@agnos-ui/react-bootstrap/components/slider'; import {Toast} from '@agnos-ui/react-bootstrap/components/toast'; +import {Tree, type TreeItem} from '@agnos-ui/react-bootstrap/components/tree'; export const App = () => (
    @@ -55,5 +56,34 @@ export const App = () => (
    This is a toast!
    +

    Tree

    +
    + +
    ); + +const nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + children: [ + { + label: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + children: [ + { + label: 'Node 1.2.1', + }, + ], + }, + ], + }, +]; diff --git a/svelte/bootstrap/src/components/tree/Tree.svelte b/svelte/bootstrap/src/components/tree/Tree.svelte new file mode 100644 index 0000000000..53418f27d0 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/Tree.svelte @@ -0,0 +1,36 @@ + + +{#snippet structure(props: TreeContext)} + +{/snippet} +{#snippet item(props: TreeSlotItemContext)} + +{/snippet} +{#snippet itemContent(props: TreeSlotItemContext)} + +{/snippet} +{#snippet itemToggle(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..777a0aea17 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultItem.svelte @@ -0,0 +1,17 @@ + + +
  • + + {#if state.expandedMap.get(item)} +
      + {#each item.children as child, index (child.label + child.level + index)} + + {/each} +
    + {/if} +
  • diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultItemContent.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultItemContent.svelte new file mode 100644 index 0000000000..e63a47e718 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultItemContent.svelte @@ -0,0 +1,11 @@ + + + + + {item.label} + diff --git a/svelte/bootstrap/src/components/tree/TreeDefaultItemToggle.svelte b/svelte/bootstrap/src/components/tree/TreeDefaultItemToggle.svelte new file mode 100644 index 0000000000..0d6e6ad0c2 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultItemToggle.svelte @@ -0,0 +1,12 @@ + + +{#if item.children.length > 0} + + +{:else} + +{/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..2eb27852a8 --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultStructure.svelte @@ -0,0 +1,13 @@ + + +
      + {#each state.normalizedNodes as item, index (item.label + item.level + index)} + + {/each} +
    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..f9b9b65b75 --- /dev/null +++ b/svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte @@ -0,0 +1,30 @@ + + + diff --git a/svelte/ssr-app/src/routes/+page.svelte b/svelte/ssr-app/src/routes/+page.svelte index 247b48628d..6658f32e03 100644 --- a/svelte/ssr-app/src/routes/+page.svelte +++ b/svelte/ssr-app/src/routes/+page.svelte @@ -8,11 +8,37 @@ import {Select} from '@agnos-ui/svelte-bootstrap/components/select'; import {Slider} from '@agnos-ui/svelte-bootstrap/components/slider'; import {Toast} from '@agnos-ui/svelte-bootstrap/components/toast'; + import {Tree, type TreeItem} from '@agnos-ui/svelte-bootstrap/components/tree'; import {onMount} from 'svelte'; onMount(() => { console.log('AGNOSUI-SSR-HYDRATION-COMPLETE'); }); + + const nodes: TreeItem[] = [ + { + label: 'Node 1', + isExpanded: true, + children: [ + { + label: 'Node 1.1', + children: [ + { + label: 'Node 1.1.1', + }, + ], + }, + { + label: 'Node 1.2', + children: [ + { + label: 'Node 1.2.1', + }, + ], + }, + ], + }, + ];
    @@ -57,4 +83,8 @@
    This is a toast!
    +

    Tree

    +
    + +