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..49a3dc47fb --- /dev/null +++ b/angular/bootstrap/src/components/tree/tree.component.ts @@ -0,0 +1,239 @@ +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: ` + + + + `, +}) +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 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 + */ + @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..fdde61b631 --- /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, + 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..cbd2c08114 --- /dev/null +++ b/core-bootstrap/src/components/tree/tree.ts @@ -0,0 +1,76 @@ +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'; + +/** + * 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: 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; +} + +/** + * 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, + 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/_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..6a2f4a04bf 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 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..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..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..ec6de7f415 --- /dev/null +++ b/core/src/components/tree/tree.ts @@ -0,0 +1,331 @@ +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 { + /** + * 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; + /** + * level in the hierarchy, starts with 0 for a root node + */ + level?: number; +} + +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: TreeItem) => 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: TreeItem[]; + /** + * Map of expanded state for each tree node + */ + expandedMap: WeakMap; +} + +/** + * 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: TreeItem}>; + /** + * Directive to handle attributes for the tree item + */ + itemAttributesDirective: Directive<{item: TreeItem}>; +} +/** + * 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 _expandedMap$ = writable(new WeakMap()); + const _parentMap$ = writable(new WeakMap()); + const _htmlElementMap$ = writable(new WeakMap()); + + const _stateChange$ = writable({}); + + const expandedMap$ = computed(() => { + _stateChange$(); + 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: TreeItem | undefined) => { + const copyNode = {...node, level, children: node.children ?? []}; + _expandedMap$().set(copyNode, copyNode.isExpanded ?? (copyNode.children.length > 0 ? false : undefined)); + _parentMap$().set(copyNode, parent); + + if (copyNode.children) { + copyNode.children = copyNode.children.map((child) => traverseTree(child, level + 1, copyNode)); + } + + return copyNode; + }; + + // normalize the tree nodes + const normalizedNodes$ = computed(() => nodes$().map((node) => traverseTree(node, 0, undefined))); + const _lastFocusedTreeItem$ = writable(normalizedNodes$()[0]); + + // custom directive to retrieve the HTMLElement of each tree toggle + const treeItemElementDirective: Directive<{item: TreeItem}> = browserDirective((toggleItem: HTMLElement, args: {item: TreeItem}) => { + const update = () => { + _htmlElementMap$().set(args.item, toggleItem); + }; + + update(); + + return { + update, + destroy: noop, + }; + }); + + const focusElementIfExists = (itemToFocus: TreeItem | undefined) => { + if (itemToFocus) { + const mapItemHtml = _htmlElementMap$().get(itemToFocus); + if (mapItemHtml) { + const index = elementsInDomOrder$().indexOf(mapItemHtml); + focusIndex(index, 0); + } + } + }; + + const itemToggleAttributesDirective = createAttributesDirective((treeItemContext$: ReadableSignal<{item: TreeItem}>) => ({ + events: { + focus: () => { + const {item} = treeItemContext$(); + _lastFocusedTreeItem$.set(item); + }, + click: () => { + const {item} = treeItemContext$(); + toggleExpanded(item); + }, + keydown: (event: KeyboardEvent) => { + const {key} = event; + const {item} = treeItemContext$(); + refreshElements(); // collapsed items were added to the dom + switch (key) { + case 'ArrowLeft': + if (_expandedMap$().get(item)) { + toggleExpanded(item); + } else { + focusElementIfExists(_parentMap$().get(item)); + } + break; + case 'ArrowRight': + if (!_expandedMap$().get(item)) { + 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 ?? item.label); + }), + tabindex: computed(() => { + const {item} = treeItemContext$(); + return item === _lastFocusedTreeItem$() ? '0' : '-1'; + }), + type: 'button', + }, + classNames: { + 'au-tree-expand-icon': true, + 'au-tree-expand-icon-expanded': computed(() => { + _stateChange$(); + const {item} = treeItemContext$(); + return _expandedMap$().get(item) ?? false; + }), + }, + })); + + /** + * 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: { + navigationDirective: bindDirective(navDirective, navManagerConfig$), + itemToggleDirective: mergeDirectives(treeItemElementDirective, itemToggleAttributesDirective), + 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..70576802df 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: 'beta', + since: 'v0.6.0', + type: 'standalone', + }, }; /** 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/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/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..213f3975a5 --- /dev/null +++ b/react/bootstrap/src/components/tree/tree.tsx @@ -0,0 +1,99 @@ +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'; + +/** + * 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 DefaultTreeSlotToggle = (slotContext: TreeSlotItemContext) => { + const {directives, item} = slotContext; + return item.children!.length > 0 ? ( + + ) : ( + + ); +}; + +/** + * A functional component that renders a tree item element. + * + * @param slotContext - The context object containing the item for display. + * @returns A tree item element. + */ +export const DefaultTreeSlotItem = (slotContext: TreeSlotItemContext) => { + const {state, item} = slotContext; + return ( + + + {item.label} + + ); +}; + +/** + * A functional component that renders a root tree 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 root element. + * @returns A tree root element with the applied directive. + */ +export const DefaultTreeSlotRoot = (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, + root: DefaultTreeSlotRoot, + item: DefaultTreeSlotItem, + toggle: DefaultTreeSlotToggle, +}; + +/** + * 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..cdc07db603 --- /dev/null +++ b/react/demo/src/bootstrap/samples/tree/Basic.route.tsx @@ -0,0 +1,35 @@ +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/svelte/bootstrap/src/components/tree/Tree.svelte b/svelte/bootstrap/src/components/tree/Tree.svelte new file mode 100644 index 0000000000..7aa9a5a46f --- /dev/null +++ b/svelte/bootstrap/src/components/tree/Tree.svelte @@ -0,0 +1,37 @@ + + +{#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..851ff0c2fe --- /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..89e25cf80e --- /dev/null +++ b/svelte/bootstrap/src/components/tree/TreeDefaultToggle.svelte @@ -0,0 +1,12 @@ + + +{#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..243c9cd5d6 --- /dev/null +++ b/svelte/demo/src/bootstrap/samples/tree/Basic.route.svelte @@ -0,0 +1,31 @@ + + +