diff --git a/src/components.d.ts b/src/components.d.ts index 238cb1997..9a8652ef2 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -22,6 +22,8 @@ import { SortingState } from "@tanstack/table-core"; import { ITab } from "./components/modus-wc-tabs/modus-wc-tabs"; import { IThemeConfig } from "./providers/theme/theme.types"; import { ToastPosition } from "./components/modus-wc-toast/modus-wc-toast"; +import { ITreeItemActions } from "./components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions"; +import { ITreeItemActions as ITreeItemActions1 } from "./components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions"; import { TypographyHierarchy, TypographySize, TypographyWeight } from "./components/modus-wc-typography/modus-wc-typography"; export { AutocompleteTypes, DaisySize, Density, IAutocompleteItem, IAutocompleteNoResults, IInputFeedbackProp, ModusSize, Orientation, PopoverPlacement, TextFieldTypes, WeekStartDay } from "./components/types"; export { IBreadcrumb } from "./components/modus-wc-breadcrumbs/modus-wc-breadcrumbs"; @@ -40,6 +42,8 @@ export { SortingState } from "@tanstack/table-core"; export { ITab } from "./components/modus-wc-tabs/modus-wc-tabs"; export { IThemeConfig } from "./providers/theme/theme.types"; export { ToastPosition } from "./components/modus-wc-toast/modus-wc-toast"; +export { ITreeItemActions } from "./components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions"; +export { ITreeItemActions as ITreeItemActions1 } from "./components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions"; export { TypographyHierarchy, TypographySize, TypographyWeight } from "./components/modus-wc-typography/modus-wc-typography"; export namespace Components { /** @@ -515,6 +519,27 @@ export namespace Components { */ "options"?: ICollapseOptions; } + /** + * A customizable content tree component used to display hierarchical data in a tree structure. + */ + interface ModusWcContentTree { + /** + * Custom CSS class to apply to the component. + */ + "customClass"?: string; + /** + * If true, displays the action buttons (expand/collapse all, etc.). + */ + "includeActions"?: boolean; + /** + * If true, displays the search input to filter tree items. + */ + "includeSearch"?: boolean; + /** + * Placeholder text for the search input. + */ + "searchPlaceholder"?: string; + } /** * A customizable date picker component used to create date inputs. * Adheres to WCAG 2.2 standards. @@ -905,6 +930,7 @@ export namespace Components { /** * A component for displaying Trimble product logos with support for both fixed and scalable sizing. * Provides consistent branding across applications with various product logo options. + * Logo colors automatically adapt to the active Modus theme via CSS variables. */ interface ModusWcLogo { /** @@ -1049,8 +1075,6 @@ export namespace Components { } /** * A customizable navbar component used for top level navigation of all Trimble applications. - * ⚠️ **Deprecated**: The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. - * The component requires a profileProps object with user information and optionally accepts menuOne and menuTwo for custom menus. */ interface ModusWcNavbar { /** @@ -1095,7 +1119,6 @@ export namespace Components { "textOverrides"?: INavbarTextOverrides; /** * User information used to render the user card. - * @deprecated The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. */ "userCard": INavbarUserCard; /** @@ -2021,6 +2044,87 @@ export namespace Components { */ "tooltipId"?: string; } + /** + * ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. + * It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + */ + interface ModusWcTreeActions { + /** + * List of actions to display + */ + "actions"?: ITreeItemActions[]; + /** + * The size of the action buttons and icons. + */ + "size": DaisySize; + } + /** + * A tree item component that represents a single node in a hierarchical tree structure. + */ + interface ModusWcTreeItem { + /** + * If true, renders a checkbox at the start of the tree item. + */ + "checkbox"?: boolean; + /** + * The checked state of the tree item when checkbox is enabled. + */ + "checked"?: boolean; + /** + * Public method to collapse the subtree if it's expanded + */ + "collapseSubTree": () => Promise; + /** + * Custom CSS class to apply to the li element. + */ + "customClass"?: string; + /** + * The disabled state of the tree item. + */ + "disabled"?: boolean; + /** + * Public method to expand the subtree if it's collapsed + */ + "expandSubTree": () => Promise; + /** + * Whether this tree item has a collapsible subtree. When true, the item will show a caret and handle toggle behavior. + */ + "hasSubtree"?: boolean; + /** + * The text label displayed for the tree item. + */ + "label": string; + /** + * The selected state of the tree item. + */ + "selected"?: boolean; + /** + * The size of the tree item icons and actions. + */ + "size": DaisySize; + /** + * Actions to display for this tree item. + */ + "treeItemActions"?: ITreeItemActions1[]; + /** + * The unique identifying value of the tree item. + */ + "value": string; + } + /** + * A wrapper component that provides the ul element for tree items. + * This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + */ + interface ModusWcTreeView { + /** + * Custom CSS class to apply to the ul element. + */ + "customClass"?: string; + /** + * Indicates that this list is a nested sublist. + */ + "isSubList"?: boolean; + } /** * A customizable typography component used to render text with different sizes, hierarchy, and weights. * Note: @@ -2191,6 +2295,14 @@ export interface ModusWcTooltipCustomEvent extends CustomEvent { detail: T; target: HTMLModusWcTooltipElement; } +export interface ModusWcTreeActionsCustomEvent extends CustomEvent { + detail: T; + target: HTMLModusWcTreeActionsElement; +} +export interface ModusWcTreeItemCustomEvent extends CustomEvent { + detail: T; + target: HTMLModusWcTreeItemElement; +} export interface ModusWcUtilityPanelCustomEvent extends CustomEvent { detail: T; target: HTMLModusWcUtilityPanelElement; @@ -2432,6 +2544,15 @@ declare global { prototype: HTMLModusWcCollapseElement; new (): HTMLModusWcCollapseElement; }; + /** + * A customizable content tree component used to display hierarchical data in a tree structure. + */ + interface HTMLModusWcContentTreeElement extends Components.ModusWcContentTree, HTMLStencilElement { + } + var HTMLModusWcContentTreeElement: { + prototype: HTMLModusWcContentTreeElement; + new (): HTMLModusWcContentTreeElement; + }; interface HTMLModusWcDateElementEventMap { "inputBlur": FocusEvent; "inputChange": InputEvent; @@ -2559,6 +2680,7 @@ declare global { /** * A component for displaying Trimble product logos with support for both fixed and scalable sizing. * Provides consistent branding across applications with various product logo options. + * Logo colors automatically adapt to the active Modus theme via CSS variables. */ interface HTMLModusWcLogoElement extends Components.ModusWcLogo, HTMLStencilElement { } @@ -2640,8 +2762,6 @@ declare global { } /** * A customizable navbar component used for top level navigation of all Trimble applications. - * ⚠️ **Deprecated**: The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. - * The component requires a profileProps object with user information and optionally accepts menuOne and menuTwo for custom menus. */ interface HTMLModusWcNavbarElement extends Components.ModusWcNavbar, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLModusWcNavbarElement, ev: ModusWcNavbarCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -3081,6 +3201,66 @@ declare global { prototype: HTMLModusWcTooltipElement; new (): HTMLModusWcTooltipElement; }; + interface HTMLModusWcTreeActionsElementEventMap { + "dropdownOpened": HTMLElement; + "treeActionClick": { + actionId: string; + actionName: string; + }; + } + /** + * ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. + * It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + */ + interface HTMLModusWcTreeActionsElement extends Components.ModusWcTreeActions, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLModusWcTreeActionsElement, ev: ModusWcTreeActionsCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLModusWcTreeActionsElement, ev: ModusWcTreeActionsCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLModusWcTreeActionsElement: { + prototype: HTMLModusWcTreeActionsElement; + new (): HTMLModusWcTreeActionsElement; + }; + interface HTMLModusWcTreeItemElementEventMap { + "itemSelect": { + value: string; + }; + "selectionsChange": { + selectedValues: string[]; + }; + } + /** + * A tree item component that represents a single node in a hierarchical tree structure. + */ + interface HTMLModusWcTreeItemElement extends Components.ModusWcTreeItem, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLModusWcTreeItemElement, ev: ModusWcTreeItemCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLModusWcTreeItemElement, ev: ModusWcTreeItemCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLModusWcTreeItemElement: { + prototype: HTMLModusWcTreeItemElement; + new (): HTMLModusWcTreeItemElement; + }; + /** + * A wrapper component that provides the ul element for tree items. + * This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + */ + interface HTMLModusWcTreeViewElement extends Components.ModusWcTreeView, HTMLStencilElement { + } + var HTMLModusWcTreeViewElement: { + prototype: HTMLModusWcTreeViewElement; + new (): HTMLModusWcTreeViewElement; + }; /** * A customizable typography component used to render text with different sizes, hierarchy, and weights. * Note: @@ -3127,6 +3307,7 @@ declare global { "modus-wc-checkbox": HTMLModusWcCheckboxElement; "modus-wc-chip": HTMLModusWcChipElement; "modus-wc-collapse": HTMLModusWcCollapseElement; + "modus-wc-content-tree": HTMLModusWcContentTreeElement; "modus-wc-date": HTMLModusWcDateElement; "modus-wc-divider": HTMLModusWcDividerElement; "modus-wc-dropdown-menu": HTMLModusWcDropdownMenuElement; @@ -3164,6 +3345,9 @@ declare global { "modus-wc-toast": HTMLModusWcToastElement; "modus-wc-toolbar": HTMLModusWcToolbarElement; "modus-wc-tooltip": HTMLModusWcTooltipElement; + "modus-wc-tree-actions": HTMLModusWcTreeActionsElement; + "modus-wc-tree-item": HTMLModusWcTreeItemElement; + "modus-wc-tree-view": HTMLModusWcTreeViewElement; "modus-wc-typography": HTMLModusWcTypographyElement; "modus-wc-utility-panel": HTMLModusWcUtilityPanelElement; } @@ -3702,6 +3886,27 @@ declare namespace LocalJSX { */ "options"?: ICollapseOptions; } + /** + * A customizable content tree component used to display hierarchical data in a tree structure. + */ + interface ModusWcContentTree { + /** + * Custom CSS class to apply to the component. + */ + "customClass"?: string; + /** + * If true, displays the action buttons (expand/collapse all, etc.). + */ + "includeActions"?: boolean; + /** + * If true, displays the search input to filter tree items. + */ + "includeSearch"?: boolean; + /** + * Placeholder text for the search input. + */ + "searchPlaceholder"?: string; + } /** * A customizable date picker component used to create date inputs. * Adheres to WCAG 2.2 standards. @@ -4116,6 +4321,7 @@ declare namespace LocalJSX { /** * A component for displaying Trimble product logos with support for both fixed and scalable sizing. * Provides consistent branding across applications with various product logo options. + * Logo colors automatically adapt to the active Modus theme via CSS variables. */ interface ModusWcLogo { /** @@ -4267,8 +4473,6 @@ declare namespace LocalJSX { } /** * A customizable navbar component used for top level navigation of all Trimble applications. - * ⚠️ **Deprecated**: The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. - * The component requires a profileProps object with user information and optionally accepts menuOne and menuTwo for custom menus. */ interface ModusWcNavbar { /** @@ -4373,7 +4577,6 @@ declare namespace LocalJSX { "textOverrides"?: INavbarTextOverrides; /** * User information used to render the user card. - * @deprecated The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. */ "userCard": INavbarUserCard; /** @@ -5472,6 +5675,102 @@ declare namespace LocalJSX { */ "tooltipId"?: string; } + /** + * ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. + * It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + */ + interface ModusWcTreeActions { + /** + * List of actions to display + */ + "actions"?: ITreeItemActions[]; + /** + * Event emitted when a dropdown is opened + */ + "onDropdownOpened"?: (event: ModusWcTreeActionsCustomEvent) => void; + /** + * Event emitted when an action is clicked + */ + "onTreeActionClick"?: (event: ModusWcTreeActionsCustomEvent<{ + actionId: string; + actionName: string; + }>) => void; + /** + * The size of the action buttons and icons. + */ + "size"?: DaisySize; + } + /** + * A tree item component that represents a single node in a hierarchical tree structure. + */ + interface ModusWcTreeItem { + /** + * If true, renders a checkbox at the start of the tree item. + */ + "checkbox"?: boolean; + /** + * The checked state of the tree item when checkbox is enabled. + */ + "checked"?: boolean; + /** + * Custom CSS class to apply to the li element. + */ + "customClass"?: string; + /** + * The disabled state of the tree item. + */ + "disabled"?: boolean; + /** + * Whether this tree item has a collapsible subtree. When true, the item will show a caret and handle toggle behavior. + */ + "hasSubtree"?: boolean; + /** + * The text label displayed for the tree item. + */ + "label": string; + /** + * Event emitted when a tree item is selected. + */ + "onItemSelect"?: (event: ModusWcTreeItemCustomEvent<{ + value: string; + }>) => void; + /** + * Event emitted when checkbox selection changes in multi-select mode. + */ + "onSelectionsChange"?: (event: ModusWcTreeItemCustomEvent<{ + selectedValues: string[]; + }>) => void; + /** + * The selected state of the tree item. + */ + "selected"?: boolean; + /** + * The size of the tree item icons and actions. + */ + "size"?: DaisySize; + /** + * Actions to display for this tree item. + */ + "treeItemActions"?: ITreeItemActions1[]; + /** + * The unique identifying value of the tree item. + */ + "value"?: string; + } + /** + * A wrapper component that provides the ul element for tree items. + * This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + */ + interface ModusWcTreeView { + /** + * Custom CSS class to apply to the ul element. + */ + "customClass"?: string; + /** + * Indicates that this list is a nested sublist. + */ + "isSubList"?: boolean; + } /** * A customizable typography component used to render text with different sizes, hierarchy, and weights. * Note: @@ -5538,6 +5837,7 @@ declare namespace LocalJSX { "modus-wc-checkbox": ModusWcCheckbox; "modus-wc-chip": ModusWcChip; "modus-wc-collapse": ModusWcCollapse; + "modus-wc-content-tree": ModusWcContentTree; "modus-wc-date": ModusWcDate; "modus-wc-divider": ModusWcDivider; "modus-wc-dropdown-menu": ModusWcDropdownMenu; @@ -5575,6 +5875,9 @@ declare namespace LocalJSX { "modus-wc-toast": ModusWcToast; "modus-wc-toolbar": ModusWcToolbar; "modus-wc-tooltip": ModusWcTooltip; + "modus-wc-tree-actions": ModusWcTreeActions; + "modus-wc-tree-item": ModusWcTreeItem; + "modus-wc-tree-view": ModusWcTreeView; "modus-wc-typography": ModusWcTypography; "modus-wc-utility-panel": ModusWcUtilityPanel; } @@ -5642,6 +5945,10 @@ declare module "@stencil/core" { * The component supports a 'header' and 'content' `` for injecting custom HTML. */ "modus-wc-collapse": LocalJSX.ModusWcCollapse & JSXBase.HTMLAttributes; + /** + * A customizable content tree component used to display hierarchical data in a tree structure. + */ + "modus-wc-content-tree": LocalJSX.ModusWcContentTree & JSXBase.HTMLAttributes; /** * A customizable date picker component used to create date inputs. * Adheres to WCAG 2.2 standards. @@ -5687,6 +5994,7 @@ declare module "@stencil/core" { /** * A component for displaying Trimble product logos with support for both fixed and scalable sizing. * Provides consistent branding across applications with various product logo options. + * Logo colors automatically adapt to the active Modus theme via CSS variables. */ "modus-wc-logo": LocalJSX.ModusWcLogo & JSXBase.HTMLAttributes; /** @@ -5706,8 +6014,6 @@ declare module "@stencil/core" { "modus-wc-modal": LocalJSX.ModusWcModal & JSXBase.HTMLAttributes; /** * A customizable navbar component used for top level navigation of all Trimble applications. - * ⚠️ **Deprecated**: The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. - * The component requires a profileProps object with user information and optionally accepts menuOne and menuTwo for custom menus. */ "modus-wc-navbar": LocalJSX.ModusWcNavbar & JSXBase.HTMLAttributes; /** @@ -5803,6 +6109,20 @@ declare module "@stencil/core" { * When forceOpen is enabled, the tooltip will remain open and can only be closed by setting forceOpen to false. */ "modus-wc-tooltip": LocalJSX.ModusWcTooltip & JSXBase.HTMLAttributes; + /** + * ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. + * It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + */ + "modus-wc-tree-actions": LocalJSX.ModusWcTreeActions & JSXBase.HTMLAttributes; + /** + * A tree item component that represents a single node in a hierarchical tree structure. + */ + "modus-wc-tree-item": LocalJSX.ModusWcTreeItem & JSXBase.HTMLAttributes; + /** + * A wrapper component that provides the ul element for tree items. + * This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + */ + "modus-wc-tree-view": LocalJSX.ModusWcTreeView & JSXBase.HTMLAttributes; /** * A customizable typography component used to render text with different sizes, hierarchy, and weights. * Note: diff --git a/src/components/modus-wc-button/readme.md b/src/components/modus-wc-button/readme.md index 459b27e90..bb3bf0959 100644 --- a/src/components/modus-wc-button/readme.md +++ b/src/components/modus-wc-button/readme.md @@ -37,22 +37,28 @@ The component supports a `` for injecting content within the button, simil - [modus-wc-alert](../modus-wc-alert) - [modus-wc-autocomplete](../modus-wc-autocomplete) + - [modus-wc-content-tree](../modus-wc-content-tree) - [modus-wc-date](../modus-wc-date) - [modus-wc-dropdown-menu](../modus-wc-dropdown-menu) - [modus-wc-handle](../modus-wc-handle) - [modus-wc-modal](../modus-wc-modal) - [modus-wc-navbar](../modus-wc-navbar) + - modus-wc-tree-actions + - [modus-wc-tree-item](../modus-wc-content-tree/modus-wc-tree-item) ### Graph ```mermaid graph TD; modus-wc-alert --> modus-wc-button modus-wc-autocomplete --> modus-wc-button + modus-wc-content-tree --> modus-wc-button modus-wc-date --> modus-wc-button modus-wc-dropdown-menu --> modus-wc-button modus-wc-handle --> modus-wc-button modus-wc-modal --> modus-wc-button modus-wc-navbar --> modus-wc-button + modus-wc-tree-actions --> modus-wc-button + modus-wc-tree-item --> modus-wc-button style modus-wc-button fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/components/modus-wc-checkbox/readme.md b/src/components/modus-wc-checkbox/readme.md index 88c20f323..f7a588f84 100644 --- a/src/components/modus-wc-checkbox/readme.md +++ b/src/components/modus-wc-checkbox/readme.md @@ -40,6 +40,7 @@ A customizable checkbox component - [modus-wc-menu-item](../modus-wc-menu-item) - [modus-wc-table](../modus-wc-table) + - [modus-wc-tree-item](../modus-wc-content-tree/modus-wc-tree-item) ### Depends on @@ -51,6 +52,7 @@ graph TD; modus-wc-checkbox --> modus-wc-input-label modus-wc-menu-item --> modus-wc-checkbox modus-wc-table --> modus-wc-checkbox + modus-wc-tree-item --> modus-wc-checkbox style modus-wc-checkbox fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/components/modus-wc-content-tree/__snapshots__/modus-wc-content-tree.spec.ts.snap b/src/components/modus-wc-content-tree/__snapshots__/modus-wc-content-tree.spec.ts.snap new file mode 100644 index 000000000..aef941614 --- /dev/null +++ b/src/components/modus-wc-content-tree/__snapshots__/modus-wc-content-tree.spec.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modus-wc-content-tree should render with default props 1`] = ` + + +
+
+ +
+
+
+ + +
+
+
+
+`; diff --git a/src/components/modus-wc-content-tree/modus-wc-content-tree.scss b/src/components/modus-wc-content-tree/modus-wc-content-tree.scss new file mode 100644 index 000000000..a392c9d16 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-content-tree.scss @@ -0,0 +1,91 @@ +/** + * This component uses menu items for the tree structure. + * Only add styles here that should not be applied by Tailwind, Daisy, or the theme. + */ + +modus-wc-content-tree { + background-color: var(--modus-wc-color-base-page); + border: 1px solid var(--modus-wc-color-base-100); + display: block; + min-width: 290px; + width: 100%; + + .modus-wc-content-tree-actions { + align-items: center; + display: flex; + gap: var(--modus-wc-spacing-xs); + justify-content: flex-end; + padding: var(--modus-wc-spacing-sm); + padding-top: var(--modus-wc-spacing-xs); + + button.modus-wc-content-tree-action-button { + background-color: transparent; + + &:hover, + &:active, + &[aria-pressed='true'] { + background-color: transparent; + } + } + + .modus-wc-content-tree-action-icon { + color: var(--modus-wc-color-black); + cursor: pointer; + } + } + + .modus-wc-content-tree-content { + min-height: 500px; + } + + li.modus-wc-tree-item-selected:not(.modus-wc-tree-dropdown-show li) { + border-inline-start: 2px solid var(--modus-wc-color-primary); + } + + .modus-wc-content-tree-header { + padding: var(--modus-wc-spacing-sm); + } + + .modus-wc-content-tree-empty { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--modus-wc-spacing-md); + justify-content: center; + min-height: 500px; + padding: 1rem; + + .modus-wc-content-tree-empty-icon { + color: var(--modus-wc-color-gray-6); + } + + .modus-wc-content-tree-empty-text { + color: var(--modus-wc-color-gray-6); + font-size: var(--modus-wc-font-size-md); + text-align: center; + } + } +} + +[data-theme='modus-classic-dark'], +[data-theme='modus-modern-dark'], +[data-theme='connect-dark'] { + modus-wc-content-tree { + modus-wc-button + .modus-wc-btn.modus-wc-btn-borderless.modus-wc-btn-primary.modus-wc-content-tree-action-button { + color: var(--modus-wc-color-gray-light); + + &:hover, + &:active, + &[aria-pressed='true'] { + color: var(--modus-wc-color-gray-light); + } + } + + .modus-wc-content-tree-actions { + .modus-wc-content-tree-action-icon { + color: var(--modus-wc-color-gray-light); + } + } + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-content-tree.spec.ts b/src/components/modus-wc-content-tree/modus-wc-content-tree.spec.ts new file mode 100644 index 000000000..59b5a277e --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-content-tree.spec.ts @@ -0,0 +1,754 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { ModusWcContentTree } from './modus-wc-content-tree'; +import { ITreeItemElement } from './modus-wc-tree-item/modus-wc-tree-item'; + +describe('modus-wc-content-tree', () => { + it('should render with default props', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + expect(page.root).toMatchSnapshot(); + }); + + it('should filter nodes based on search input', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + Item 1 + Item 2 + Item 3 + `, + }); + + const searchInput = page.root!.querySelector('input[type="search"]'); + expect(searchInput as HTMLInputElement).toBeDefined(); + if (searchInput) { + (searchInput as HTMLInputElement).value = 'Item 2'; + searchInput.dispatchEvent(new Event('input')); + await page.waitForChanges(); + const visibleItems = page.root!.querySelectorAll( + 'modus-wc-tree-item:not([hidden])' + ); + expect(visibleItems.length).toBe(1); + expect(visibleItems[0].getAttribute('value')).toBe('item2'); + } + }); + + it('clears filter on Escape key', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + `, + }); + + const input = page.root!.querySelector('modus-wc-text-input')!; + + input.dispatchEvent( + new CustomEvent('inputChange', { + bubbles: true, + composed: true, + }) + ); + + await page.waitForChanges(); + + // Now press Escape + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await page.waitForChanges(); + + // Assert all items visible + const items = page.root!.querySelectorAll('modus-wc-tree-item'); + items.forEach((item) => { + expect((item as HTMLElement).style.display).toBe(''); + }); + }); + + it('expands parent nodes when a child matches search', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + + `, + }); + + const root = page.root!; + const parent = root.querySelectorAll( + 'modus-wc-tree-item' + )[0] as ITreeItemElement; + const child = root.querySelectorAll( + 'modus-wc-tree-item' + )[1] as ITreeItemElement; + + // Mock expandSubTree on parent + const expandSubTreeMock = jest.fn().mockResolvedValue(undefined); + parent.expandSubTree = expandSubTreeMock; + + const tree = page.rootInstance; + + // Trigger filter that matches child + tree.filterNodes('child'); + await page.waitForChanges(); + + // Child should be visible + expect(child.style.display).toBe(''); + + // Parent should be forced visible + expect(parent.style.display).toBe(''); + + // Parent expansion should be triggered + expect(expandSubTreeMock).toHaveBeenCalled(); + }); + + it('updateSlotContent returns early if slotEl is not set', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ``, + }); + + const tree = page.rootInstance; + + tree.slotEl = undefined; + + // Should not throw + tree.updateSlotContent(); + + expect(tree.hasSlotContent).toBe(false); + }); + + it('detects subtree via property instead of attribute', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + const item = page.root!.querySelector( + 'modus-wc-tree-item' + ) as ITreeItemElement; + + item.hasSubtree = true; + const expandMock = jest.fn().mockResolvedValue(undefined); + item.expandSubTree = expandMock; + + await tree.toggleExpandCollapse(); + + expect(expandMock).toHaveBeenCalled(); + }); + + it('skips nodes without subtrees', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + + await expect(tree.toggleExpandCollapse()).resolves.not.toThrow(); + }); + + it('removes event listener on disconnectedCallback', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + const mockRemoveEventListener = jest.fn(); + + // Set up slotEl with mock removeEventListener + tree.slotEl = { + removeEventListener: mockRemoveEventListener, + } as unknown as HTMLSlotElement; + + // Call disconnectedCallback + tree.disconnectedCallback(); + + // Verify removeEventListener was called with correct arguments + expect(mockRemoveEventListener).toHaveBeenCalledWith( + 'slotchange', + tree.updateSlotContent + ); + }); + + it('disconnectedCallback handles missing slotEl gracefully', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + tree.slotEl = undefined; + + // Should not throw when slotEl is undefined + expect(() => tree.disconnectedCallback()).not.toThrow(); + }); + + it('clears debounce timer on disconnectedCallback', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); + + // Set up a mock debounce timer + tree['debounceTimer'] = 123 as unknown as number; + + // Call disconnectedCallback + tree.disconnectedCallback(); + + // Verify clearTimeout was called with the timer + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + + clearTimeoutSpy.mockRestore(); + }); + + it('disconnectedCallback handles missing debounceTimer gracefully', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); + + // Ensure debounceTimer is undefined + tree['debounceTimer'] = undefined; + + // Should not throw and should not call clearTimeout + expect(() => tree.disconnectedCallback()).not.toThrow(); + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + + it('clears both event listener and timer on disconnectedCallback', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + const mockRemoveEventListener = jest.fn(); + const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); + + // Set up both slotEl and debounceTimer + tree.slotEl = { + removeEventListener: mockRemoveEventListener, + } as unknown as HTMLSlotElement; + tree['debounceTimer'] = 456 as unknown as number; + + // Call disconnectedCallback + tree.disconnectedCallback(); + + // Verify both cleanup operations were performed + expect(mockRemoveEventListener).toHaveBeenCalledWith( + 'slotchange', + tree.updateSlotContent + ); + expect(clearTimeoutSpy).toHaveBeenCalledWith(456); + + clearTimeoutSpy.mockRestore(); + }); + + it('renders without search when includeSearch is false', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const searchInput = page.root?.querySelector('modus-wc-text-input'); + expect(searchInput).toBeNull(); + }); + + it('renders without actions when includeActions is false', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const actionsDiv = page.root?.querySelector( + '.modus-wc-content-tree-actions' + ); + expect(actionsDiv).toBeNull(); + }); + + it('applies custom class to wrapper', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const wrapper = page.root?.querySelector('.modus-wc-content-tree-wrapper'); + expect(wrapper?.className).toContain('my-custom-class'); + }); + + it('uses custom search placeholder', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const searchInput = page.root?.querySelector('modus-wc-text-input'); + expect(searchInput?.getAttribute('placeholder')).toBe('Find items...'); + }); + + it('shows empty state when hasSlotContent is false', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + tree.hasSlotContent = false; + await page.waitForChanges(); + + const emptyState = page.root?.querySelector('.modus-wc-content-tree-empty'); + expect(emptyState).toBeDefined(); + + const emptyIcon = emptyState?.querySelector('modus-wc-icon'); + expect(emptyIcon?.getAttribute('name')).toBe('folder_open'); + + const emptyText = emptyState?.querySelector('modus-wc-typography'); + expect(emptyText?.getAttribute('label')).toBe('Empty Content Tree'); + }); + + it('hides empty state when hasSlotContent is true', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + tree.hasSlotContent = true; + await page.waitForChanges(); + + const emptyState = page.root?.querySelector('.modus-wc-content-tree-empty'); + expect(emptyState).toBeNull(); + }); + + it('renders expand/collapse button with "Expand all" aria-label when collapsed', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + tree.areAllExpanded = false; + await page.waitForChanges(); + + const button = page.root?.querySelector( + '.modus-wc-content-tree-actions modus-wc-button' + ); + expect(button?.getAttribute('aria-label')).toBe('Expand all'); + expect(tree.areAllExpanded).toBe(false); + }); + + it('renders expand/collapse button with "Collapse all" aria-label when expanded', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + tree.areAllExpanded = true; + await page.waitForChanges(); + + const button = page.root?.querySelector( + '.modus-wc-content-tree-actions modus-wc-button' + ); + expect(button?.getAttribute('aria-label')).toBe('Collapse all'); + expect(tree.areAllExpanded).toBe(true); + }); + + it('componentWillLoad sets hasSlotContent based on child nodes', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + expect(page.rootInstance.hasSlotContent).toBe(true); + }); + + it('componentWillLoad filters out STYLE nodes', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + expect(page.rootInstance.hasSlotContent).toBe(false); + }); + + it('componentDidLoad handles missing slotEl gracefully', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + tree.slotEl = undefined; + + // Should not throw when slotEl is undefined due to optional chaining + expect(() => tree.componentDidLoad()).not.toThrow(); + }); + + it('componentDidLoad adds addEventListener when slotEl exists', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '
Content
', + }); + + const tree = page.rootInstance; + + // Create a mock slot element with addEventListener spy + const addEventListenerSpy = jest.fn(); + const mockSlot = { + addEventListener: addEventListenerSpy, + assignedNodes: jest + .fn() + .mockReturnValue([{ nodeType: Node.ELEMENT_NODE, tagName: 'DIV' }]), + } as unknown as HTMLSlotElement; + + // Mock querySelector to return our mock slot + jest.spyOn(tree.el, 'querySelector').mockReturnValue(mockSlot); + + // Call componentDidLoad + tree.componentDidLoad(); + + // Verify addEventListener was called + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'slotchange', + tree.updateSlotContent + ); + }); + + it('filterNodes catches and handles expandSubTree errors gracefully', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + + `, + }); + + const tree = page.rootInstance; + const parent = page.root?.querySelector( + 'modus-wc-tree-item' + ) as ITreeItemElement; + + // Mock expandSubTree to reject with error + const expandMock = jest.fn().mockRejectedValue(new Error('Expand failed')); + parent.expandSubTree = expandMock; + + // Should not throw despite the error + await expect(tree.filterNodes('Child')).resolves.not.toThrow(); + + expect(expandMock).toHaveBeenCalled(); + }); + + it('filterNodes continues expanding other items when one fails', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + + + + + `, + }); + + const tree = page.rootInstance; + const parents = page.root?.querySelectorAll( + 'modus-wc-tree-item[has-subtree]' + ) as NodeListOf; + + // First parent fails + const expandMock1 = jest.fn().mockRejectedValue(new Error('Failed')); + parents[0].expandSubTree = expandMock1; + + // Second parent succeeds + const expandMock2 = jest.fn().mockResolvedValue(undefined); + parents[1].expandSubTree = expandMock2; + + await tree.filterNodes('Match'); + + // Both should be called despite first one failing + expect(expandMock1).toHaveBeenCalled(); + expect(expandMock2).toHaveBeenCalled(); + }); + + it('updateSlotContent invalidates cached items', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + tree['cachedItems'] = []; + + const mockSlot = { + assignedNodes: jest.fn().mockReturnValue([]), + } as unknown as HTMLSlotElement; + + tree.slotEl = mockSlot; + tree.updateSlotContent(); + + expect(tree['cachedItems']).toBeUndefined(); + }); + + it('updateSlotContent filters out STYLE nodes from assigned nodes', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const tree = page.rootInstance; + const styleNode = { nodeType: Node.ELEMENT_NODE, tagName: 'STYLE' }; + const elementNode = { nodeType: Node.ELEMENT_NODE, tagName: 'DIV' }; + + const mockSlot = { + assignedNodes: jest.fn().mockReturnValue([styleNode, elementNode]), + } as unknown as HTMLSlotElement; + + tree.slotEl = mockSlot; + tree.updateSlotContent(); + + expect(tree.hasSlotContent).toBe(true); + }); + + it('toggleExpandCollapse collapses when areAllExpanded is true', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + tree.areAllExpanded = true; + + const item = page.root?.querySelector( + 'modus-wc-tree-item' + ) as ITreeItemElement; + const collapseMock = jest.fn().mockResolvedValue(undefined); + item.collapseSubTree = collapseMock; + + await tree.toggleExpandCollapse(); + + expect(collapseMock).toHaveBeenCalled(); + expect(tree.areAllExpanded).toBe(false); + }); + + it('handleToggleClick calls toggleExpandCollapse', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + `, + }); + + const tree = page.rootInstance; + + // Mock the tree item methods + const item = page.root?.querySelector( + 'modus-wc-tree-item' + ) as ITreeItemElement; + item.expandSubTree = jest.fn().mockResolvedValue(undefined); + item.collapseSubTree = jest.fn().mockResolvedValue(undefined); + + const toggleSpy = jest.spyOn(tree, 'toggleExpandCollapse' as never); + + tree['handleToggleClick'](); + + // Wait for the async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(toggleSpy).toHaveBeenCalled(); + }); + + it('filterNodes caches menu items on first call', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + `, + }); + + const tree = page.rootInstance; + expect(tree['cachedItems']).toBeUndefined(); + + await tree.filterNodes('Item'); + + expect(tree['cachedItems']).toBeDefined(); + expect(tree['cachedItems']?.length).toBe(2); + }); + + it('filterNodes hides non-matching items without matching descendants', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + `, + }); + + const tree = page.rootInstance; + await tree.filterNodes('Alpha'); + + const items = page.root?.querySelectorAll('modus-wc-tree-item'); + expect((items?.[0] as HTMLElement).style.display).toBe(''); + expect((items?.[1] as HTMLElement).style.display).toBe('none'); + }); + + it('filterNodes shows items with matching descendants', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + + `, + }); + + const tree = page.rootInstance; + const parent = page.root?.querySelector( + 'modus-wc-tree-item' + ) as ITreeItemElement; + const expandMock = jest.fn().mockResolvedValue(undefined); + parent.expandSubTree = expandMock; + + await tree.filterNodes('Child'); + + expect(parent.style.display).toBe(''); + expect(expandMock).toHaveBeenCalled(); + }); + + it('inherits ARIA attributes', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: '', + }); + + const wrapper = page.root?.querySelector('.modus-wc-content-tree-wrapper'); + expect(wrapper?.getAttribute('aria-label')).toBe('My Tree'); + }); + + it('filterNodes handles items without label attribute', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ` + + + + + `, + }); + + const tree = page.rootInstance; + await tree.filterNodes('search'); + + const items = page.root?.querySelectorAll('modus-wc-tree-item'); + // Item without label should be hidden (empty string doesn't match) + expect((items?.[1] as HTMLElement).style.display).toBe('none'); + }); + + it('should debounce input without timeouts', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ``, + }); + + const instance = page.rootInstance; + + const filterSpy = jest + .spyOn(instance, 'filterNodes') + .mockResolvedValue(undefined); + + instance['handleInputChange']({ + target: { value: 'hello' }, + } as unknown as CustomEvent); + + // Wait slightly longer than debounce (150ms) + await new Promise((r) => setTimeout(r, 180)); + + expect(filterSpy).toHaveBeenCalledWith('hello'); + }); + + it('covers clearTimeout branch', async () => { + const page = await newSpecPage({ + components: [ModusWcContentTree], + html: ``, + }); + + const instance = page.rootInstance; + + jest.spyOn(instance, 'filterNodes').mockResolvedValue(undefined); + + const clearSpy = jest.spyOn(window, 'clearTimeout'); + + // Force branch + instance['debounceTimer'] = 999; + + instance['handleInputChange']({ + target: { value: 'x' }, + } as unknown as CustomEvent); + + expect(clearSpy).toHaveBeenCalledWith(999); + }); +}); diff --git a/src/components/modus-wc-content-tree/modus-wc-content-tree.stories.ts b/src/components/modus-wc-content-tree/modus-wc-content-tree.stories.ts new file mode 100644 index 000000000..29897ff4d --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-content-tree.stories.ts @@ -0,0 +1,879 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; +import { ITreeItemElement } from './modus-wc-tree-item/modus-wc-tree-item'; + +interface ContentTreeArgs { + 'custom-class'?: string; + 'search-placeholder'?: string; + 'include-search'?: boolean; + 'include-actions'?: boolean; +} + +const meta: Meta = { + title: 'Components/Content Tree', + component: 'modus-wc-content-tree', + args: { + 'custom-class': '', + 'search-placeholder': 'Search...', + 'include-search': true, + 'include-actions': true, + }, + argTypes: { + 'search-placeholder': { + control: { type: 'text' }, + table: { category: 'Content Tree' }, + }, + 'include-search': { + control: { type: 'boolean' }, + table: { category: 'Content Tree' }, + }, + 'include-actions': { + control: { type: 'boolean' }, + table: { category: 'Content Tree' }, + }, + }, + decorators: [withActions], + parameters: { + actions: { + handles: [ + 'itemSelect', + 'treeActionClick', + 'selectionsChange', + 'dropdownOpened', + ], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + parameters: { + docs: { + description: { + story: + 'A basic content tree with hierarchical structure. Items can be expanded and collapsed to navigate through the tree.', + }, + source: { + code: ` + + + + + + + + + + + + + + + + + + + + + +`, + }, + }, + }, + render: (args) => { + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + }, +}; + +export const TreeItem: Story = { + name: 'Tree Item', + parameters: { + docs: { + description: { + story: + 'A comprehensive example showing tree item features: checkbox, start icon, and actions.', + }, + source: { + code: ` + + + + + +`, + }, + }, + }, + render: () => { + const actions = [ + { id: 'edit', icon: 'pencil', label: 'Edit' }, + { id: 'delete', icon: 'trash', label: 'Delete' }, + ]; + return html` + + + + + + `; + }, +}; + +export const TreeItemWithStartIcon: Story = { + name: 'Tree Item - With Start Icon', + parameters: { + docs: { + description: { + story: + 'Tree items can display custom icons at the start using the start-icon slot. This is useful for representing file types, folders, or custom item types.', + }, + source: { + code: ` + + + + + + + + + + + + + + + + + + + +`, + }, + }, + }, + render: () => { + return html` + + + + + + + + + + + + + + + + + + + + `; + }, +}; + +export const EmptyState: Story = { + name: 'Empty State', + parameters: { + docs: { + description: { + story: + 'This example shows the content tree when no items are present. An empty state message is displayed.', + }, + source: { + code: ` + + +`, + }, + }, + }, + render: (args) => { + return html` + + + `; + }, +}; + +export const SingleSelection: Story = { + name: 'Single Selection', + parameters: { + docs: { + description: { + story: + 'Content tree with single selection mode. Click on any tree item to select it. Only one item can be selected at a time.', + }, + source: { + code: ` + + + + + + + + + + + + + + + + + + + + + +`, + }, + }, + }, + render: (args) => { + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + }, +}; + +export const CheckboxSelection: Story = { + name: 'Checkbox Selection', + parameters: { + docs: { + description: { + story: + 'This example demonstrates tree items with checkboxes for multi-selection. Selecting a parent item will select all its children, and vice versa.', + }, + source: { + code: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`, + }, + }, + }, + render: (args) => { + return html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + }, +}; + +export const DisabledSelection: Story = { + name: 'Disabled Selection', + parameters: { + docs: { + description: { + story: + 'This example demonstrates tree items with disabled state. Disabled items cannot be selected or interacted with.', + }, + source: { + code: ` + + + + + + + + + + + + +`, + }, + }, + }, + render: (args) => { + return html` + + + + + + + + + + + + + + + + + `; + }, +}; + +export const WithActions: Story = { + name: 'With Actions', + parameters: { + docs: { + description: { + story: + 'This example demonstrates tree items with custom actions. Actions can be used to perform operations like toggling visibility or deleting items.', + }, + source: { + code: ` + + + + + + + + + + + +`, + }, + }, + }, + render: (args) => { + const getTreeItemActions = (isDisabled: boolean) => [ + { + id: 'toggle-visibility', + label: isDisabled ? 'Hidden' : 'Visible', + icon: isDisabled ? 'visibility_off' : 'visibility_on', + ariaLabel: isDisabled ? 'Set item to visible' : 'Set item to hidden', + size: 'sm', + }, + { + id: 'delete', + label: 'Delete', + icon: 'delete', + ariaLabel: 'Delete item', + size: 'sm', + }, + ]; + + const handleTreeActionClick = ( + event: CustomEvent<{ actionId: string }> + ) => { + const actionSource = event.target as HTMLElement; + const treeItem = actionSource.closest( + 'modus-wc-tree-item' + ) as ITreeItemElement; + + if (!treeItem) return; + + if (event.detail.actionId === 'delete') { + treeItem.remove(); + return; + } + + if (event.detail.actionId !== 'toggle-visibility') return; + + treeItem.disabled = !treeItem.disabled; + treeItem.treeItemActions = getTreeItemActions(treeItem.disabled); + }; + + return html` + + + + + + + + + + + `; + }, +}; + +export const ApiReference: Story = { + name: 'API Reference', + parameters: { + docs: { + description: { + story: ` +### Props + +| Name | Type | Default | Description | +|-------------------|------------|--------------|---------------------------------------------------| +| customClass | \`string\` | \`''\` | Additional CSS class to apply to the component | +| searchPlaceholder | \`string\` | \`'Search...'\` | Placeholder text for the search input | +| includeSearch | \`boolean\` | \`true\` | Whether to display the search functionality | +| includeActions | \`boolean\` | \`true\` | Whether to display action buttons for tree items | + +--- + +### Tree View + +#### Props + +| Name | Type | Default | Description | +|-------------|------------|-----------|-------------------------------------------------------| +| customClass | \`string\` | \`''\` | Additional CSS class to apply to the tree view | +| isSubList | \`boolean\` | \`false\` | Whether the tree view is a sublist of another tree item | + +--- + +### Tree Item + +#### Props + +| Name | Type | Default | Description | +|-----------------|----------------------------------------|-----------|--------------------------------------------------------------| +| label | \`string\` | - | The label text for the tree item (required) | +| value | \`string\` | \`''\` | The value associated with the tree item | +| disabled | \`boolean\` | \`false\` | Whether the tree item is disabled | +| checkbox | \`boolean\` | \`false\` | Whether to display a checkbox for the tree item | +| selected | \`boolean\` | - | Whether the tree item is selected (mutable, reflected) | +| checked | \`boolean\` | - | Whether the tree item checkbox is checked (mutable, reflected) | +| hasSubtree | \`boolean\` | \`false\` | Whether the tree item has a subtree | +| treeItemActions | \`ITreeItemActions[]\` | - | Array of actions to display for the tree item | +| size | \`'xs' | 'sm' | 'md' | 'lg'\` | \`'xs'\` | The size of the tree item | +| customClass | \`string\` | \`''\` | Additional CSS class to apply to the tree item | + +#### Events + +| Name | Payload | Description | +|------------------|----------------------------------|-------------------------------------------------| +| itemSelect | \`{ value: string }\` | Emitted when a tree item is selected | +| selectionsChange | \`{ selectedValues: string[] }\` | Emitted when the selection state changes | + +#### Methods + +| Name | Type | Description | +|-----------------|---------------------------|----------------------------| +| collapseSubTree | \`() => Promise\` | Collapses the subtree | +| expandSubTree | \`() => Promise\` | Expands the subtree | + +--- + +### Tree Actions + +#### Props + +| Name | Type | Default | Description | +|---------|-------------------------------------|----------|--------------------------------------| +| actions | \`ITreeItemActions[]\` | - | Array of actions to display | +| size | \`'xs' | 'sm' | 'md' | 'lg'\` | \`'xs'\` | The size of the action buttons | + +#### Events + +| Name | Payload | Description | +|-----------------|-------------------------------------------|--------------------------------------------| +| treeActionClick | \`{ actionId: string; actionName: string }\` | Emitted when an action is clicked | +| dropdownOpened | \`HTMLElement\` | Emitted when the dropdown is opened | + +--- + +### Interfaces + +#### ITreeItemActions + +\`\`\`typescript +interface ITreeItemActions { + id: string; // Unique identifier for the action + icon: string; // Icon name for the action, e.g., 'edit', 'trash' + iconVariant?: 'solid' | 'outlined'; // Optional variant for the icon + label: string; // Text label for the action + ariaLabel?: string; // Optional label for accessibility + disabled?: boolean; // Optional flag to disable the action +} +\`\`\` +`, + }, + }, + controls: { disable: true }, + }, + render: () => { + return html``; + }, +}; diff --git a/src/components/modus-wc-content-tree/modus-wc-content-tree.tsx b/src/components/modus-wc-content-tree/modus-wc-content-tree.tsx new file mode 100644 index 000000000..3cf25e7a5 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-content-tree.tsx @@ -0,0 +1,275 @@ +import { Component, Element, h, Host, Prop, State } from '@stencil/core'; +import { Attributes, inheritAriaAttributes } from '../utils'; +import { ITreeItemElement } from './modus-wc-tree-item/modus-wc-tree-item'; + +/** + * A customizable content tree component used to display hierarchical data in a tree structure. + */ +@Component({ + tag: 'modus-wc-content-tree', + styleUrl: 'modus-wc-content-tree.scss', + shadow: false, +}) +export class ModusWcContentTree { + private inheritedAttributes: Attributes = {}; + private slotEl?: HTMLSlotElement; + private debounceTimer?: number; + private cachedItems?: ITreeItemElement[]; + + /** Reference to the host element */ + @Element() el!: HTMLElement; + + /** Custom CSS class to apply to the component. */ + @Prop() customClass?: string = ''; + + /** Placeholder text for the search input. */ + @Prop() searchPlaceholder?: string = 'Search...'; + + /** If true, displays the search input to filter tree items. */ + @Prop() includeSearch?: boolean = true; + + /** If true, displays the action buttons (expand/collapse all, etc.). */ + @Prop() includeActions?: boolean = true; + + /** Internal state to track if the tree has any content (used for empty state display) */ + @State() private hasSlotContent: boolean = false; + + /** Internal state to track the current search value for filtering tree items */ + @State() private searchValue: string = ''; + + /** Internal state to track if all tree nodes are expanded or collapsed */ + @State() private areAllExpanded: boolean = false; + + componentWillLoad() { + this.inheritedAttributes = inheritAriaAttributes(this.el); + // Check initial slot content + const slotContent = Array.from(this.el.childNodes).filter( + (node) => + node.nodeType === Node.ELEMENT_NODE && + (node as HTMLElement).tagName !== 'STYLE' + ); + this.hasSlotContent = slotContent.length > 0; + } + + componentDidLoad() { + this.slotEl = this.el.querySelector('slot') as HTMLSlotElement; + this.updateSlotContent(); + this.slotEl?.addEventListener('slotchange', this.updateSlotContent); + } + + disconnectedCallback() { + this.slotEl?.removeEventListener('slotchange', this.updateSlotContent); + if (this.debounceTimer) { + window.clearTimeout(this.debounceTimer); + } + } + + private handleInputChange = (event: CustomEvent) => { + const target = event.target as HTMLInputElement; + this.searchValue = target.value; + + // Debounce search to avoid excessive filtering + if (this.debounceTimer) { + window.clearTimeout(this.debounceTimer); + } + + this.debounceTimer = window.setTimeout(() => { + void this.filterNodes(this.searchValue); + }, 150); + }; + + private async filterNodes(searchTerm: string): Promise { + // Cache menu items to avoid repeated queries + if (!this.cachedItems) { + this.cachedItems = Array.from( + this.el.querySelectorAll('modus-wc-tree-item') + ); + } + const menuItems = this.cachedItems; + + const normalizedSearch = searchTerm.toLowerCase().trim(); + + // If search is empty, reset everything in batch + if (!normalizedSearch) { + for (const item of menuItems) { + (item as HTMLElement).style.display = ''; + } + return; + } + + // First pass: identify matches and collect items to show/hide + const matchingItems = new Set(); + const itemsToExpand: ITreeItemElement[] = []; + const processedParents = new Set(); + + // Build match set efficiently + for (const item of menuItems) { + const label = item.getAttribute('label') || ''; + if (label.toLowerCase().includes(normalizedSearch)) { + matchingItems.add(item as HTMLElement); + } + } + + // Second pass: determine visibility and expansion needs + for (const item of menuItems) { + const itemElement = item as HTMLElement; + const isDirectMatch = matchingItems.has(itemElement); + + if (isDirectMatch) { + itemElement.style.display = ''; + + // Process ancestors only once + let parent = item.parentElement; + while (parent && parent !== this.el) { + if ( + parent.tagName === 'MODUS-WC-TREE-ITEM' && + !processedParents.has(parent) + ) { + processedParents.add(parent); + parent.style.display = ''; + itemsToExpand.push(parent as ITreeItemElement); + } + parent = parent.parentElement; + } + } else { + // Check descendants using pre-computed match set + const descendants = itemElement.querySelectorAll('modus-wc-tree-item'); + const hasMatchingChild = Array.from(descendants).some((child) => + matchingItems.has(child as HTMLElement) + ); + + if (hasMatchingChild) { + itemElement.style.display = ''; + itemsToExpand.push(item); + } else { + itemElement.style.display = 'none'; + } + } + } + + // Batch all expand operations + if (itemsToExpand.length > 0) { + await Promise.all( + itemsToExpand.map((item) => item.expandSubTree().catch(() => {})) + ); + } + } + + private handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.searchValue = ''; + void this.filterNodes(''); + } + }; + + private updateSlotContent = () => { + if (!this.slotEl) return; + + const assigned = this.slotEl + .assignedNodes({ flatten: true }) + .filter( + (node) => + node.nodeType === Node.ELEMENT_NODE && + (node as HTMLElement).tagName !== 'STYLE' + ); + + this.hasSlotContent = assigned.length > 0; + // Invalidate cache when content changes + this.cachedItems = undefined; + }; + + private handleToggleClick = (): void => { + void this.toggleExpandCollapse(); + }; + + private async toggleExpandCollapse(): Promise { + const treeItems = this.el.querySelectorAll('modus-wc-tree-item'); + this.areAllExpanded = !this.areAllExpanded; + + const promises = Array.from(treeItems).map((item) => { + const treeItem = item as ITreeItemElement; + const hasSubtree = + item.hasAttribute('has-subtree') || treeItem.hasSubtree === true; + if (hasSubtree) { + if (this.areAllExpanded) { + return treeItem.expandSubTree(); + } else { + return treeItem.collapseSubTree(); + } + } + return Promise.resolve(); + }); + + await Promise.all(promises); + } + + render() { + return ( + +
+
+ {this.includeSearch && ( + + )} + + {this.includeActions && this.hasSlotContent && ( +
+ + + +
+ )} +
+
+ + {!this.hasSlotContent && ( +
+ + +
+ )} +
+
+
+ ); + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-actions/__snapshots__/modus-wc-tree-actions.spec.ts.snap b/src/components/modus-wc-content-tree/modus-wc-tree-actions/__snapshots__/modus-wc-tree-actions.spec.ts.snap new file mode 100644 index 000000000..160617783 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-actions/__snapshots__/modus-wc-tree-actions.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modus-wc-tree-actions renders correctly 1`] = ` + +
+
+`; diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.scss b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.scss new file mode 100644 index 000000000..2b4ab1e4a --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.scss @@ -0,0 +1,120 @@ +/** +* Tree actions component styles +*/ + +modus-wc-tree-actions { + .modus-wc-tree-actions-container { + align-items: center; + display: flex; + + button.modus-wc-tree-action-button { + background: transparent; + border: none; + color: var(--modus-wc-color-base-content); + cursor: pointer; + visibility: hidden; + + &:hover { + background: transparent; + color: var(--modus-wc-color-base-content); + } + + &:active, + &[aria-pressed='true'] { + background: transparent; + } + } + } + + .modus-wc-tree-more-actions-dropdown { + background: var(--modus-wc-color-base-page); + border: 1px solid var(--modus-wc-color-base-100); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + display: none; + min-width: 150px; + padding: 4px 0; + position: absolute; + z-index: 1000; + + &.show { + display: block; + } + } + + .modus-wc-tree-dropdown-action { + align-items: center; + background: transparent; + border: none; + color: var(--modus-wc-color-base-content-high-contrast); + cursor: pointer; + display: flex; + gap: var(--modus-wc-spacing-sm); + padding: var(--modus-wc-spacing-sm) var(--modus-wc-spacing-md); + text-align: start; + width: 100%; + + &:hover:not(.disabled) { + background-color: var(--modus-wc-color-gray-light); + } + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + } + + span { + font-size: var(--modus-wc-font-size-sm); + } + } +} + +[data-theme='modus-classic-dark'], +[data-theme='modus-modern-dark'], +[data-theme='connect-dark'] { + .modus-wc-tree-actions-container { + .modus-wc-tree-action-button { + color: var(--modus-wc-color-base-content); + + &:hover, + &:active, + &[aria-pressed='true'] { + color: var(--modus-wc-color-base-content); + } + } + } + + .modus-wc-tree-more-actions-dropdown { + background: var(--modus-wc-color-trimble-gray); + border-color: var(--modus-wc-color-gray-6); + } + + .modus-wc-tree-dropdown-action { + &:hover:not(.disabled) { + background-color: var(--modus-wc-color-base-100); + } + } +} + +[data-theme='modus-classic-dark'] { + .modus-wc-tree-actions-container { + .modus-wc-tree-action-icon { + color: var(--modus-wc-color-base-content); + } + } +} + +[data-theme='modus-classic-light'], +[data-theme='connect-light'] { + .modus-wc-tree-actions-container { + .modus-wc-tree-action-icon { + color: var(--modus-wc-color-base-content); + } + + &:hover { + background: transparent; + color: var(--modus-wc-color-base-content); + } + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.spec.ts b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.spec.ts new file mode 100644 index 000000000..b2867851b --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.spec.ts @@ -0,0 +1,627 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { ModusWcTreeActions } from './modus-wc-tree-actions'; + +describe('modus-wc-tree-actions', () => { + it('renders correctly', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + expect(page.root).toMatchSnapshot(); + }); + + it('renders with single action', async () => { + const actions = [{ id: '1', icon: 'edit', label: 'Edit' }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const buttons = page.root?.querySelectorAll('modus-wc-button'); + expect(buttons?.length).toBe(1); + }); + + it('emits treeActionClick when action is clicked', async () => { + const actions = [{ id: 'edit-1', icon: 'edit', label: 'Edit' }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const eventSpy = jest.fn(); + page.root?.addEventListener('treeActionClick', eventSpy); + + const button = page.root?.querySelector('modus-wc-button'); + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(eventSpy).toHaveBeenCalled(); + expect(eventSpy.mock.calls[0][0].detail.actionId).toBe('edit-1'); + expect(eventSpy.mock.calls[0][0].detail.actionName).toBe('Edit'); + }); + + it('does not emit event when disabled action is clicked', async () => { + const actions = [{ id: '1', icon: 'edit', label: 'Edit', disabled: true }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const eventSpy = jest.fn(); + page.root?.addEventListener('treeActionClick', eventSpy); + + const treeActions = page.rootInstance; + treeActions['handleActionClick'](actions[0], new MouseEvent('click')); + + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('toggles dropdown on more actions button click', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + expect(treeActions.isDropdownOpen).toBe(false); + + treeActions['handleMoreActionsClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(treeActions.isDropdownOpen).toBe(true); + + treeActions['handleMoreActionsClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(treeActions.isDropdownOpen).toBe(false); + }); + + it('emits dropdownOpened when dropdown is opened', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const eventSpy = jest.fn(); + page.root?.addEventListener('dropdownOpened', eventSpy); + + const treeActions = page.rootInstance; + treeActions['handleMoreActionsClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(eventSpy).toHaveBeenCalled(); + }); + + it('calls popperInstance.update when dropdown is opened and popper exists', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'copy', label: 'Copy' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + const updateSpy = jest.fn(); + treeActions.popperInstance = { + update: updateSpy, + destroy: jest.fn(), + } as unknown as ReturnType; + + treeActions['handleMoreActionsClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(updateSpy).toHaveBeenCalled(); + expect(treeActions.isDropdownOpen).toBe(true); + }); + + it('does not call popperInstance.update when popper is null', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'copy', label: 'Copy' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.popperInstance = null; + + // Should not throw error + expect(() => { + treeActions['handleMoreActionsClick'](new MouseEvent('click')); + }).not.toThrow(); + + expect(treeActions.isDropdownOpen).toBe(true); + }); + + it('closes dropdown when clicking outside', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + + const outsideElement = document.createElement('div'); + const clickEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(clickEvent, 'target', { + value: outsideElement, + enumerable: true, + }); + + treeActions['handleClickOutside'](clickEvent); + await page.waitForChanges(); + + expect(treeActions.isDropdownOpen).toBe(false); + }); + + it('closes dropdown when moreActionsButton ref is not available', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + treeActions.moreActionsButton = null; + + const outsideElement = document.createElement('div'); + const clickEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(clickEvent, 'target', { + value: outsideElement, + enumerable: true, + }); + + treeActions['handleClickOutside'](clickEvent); + + expect(treeActions.isDropdownOpen).toBe(false); + }); + + it('does not process click outside when dropdown is closed', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = false; + + const outsideElement = document.createElement('div'); + const clickEvent = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(clickEvent, 'target', { + value: outsideElement, + enumerable: true, + }); + + const initialState = treeActions.isDropdownOpen; + treeActions['handleClickOutside'](clickEvent); + + expect(treeActions.isDropdownOpen).toBe(initialState); + }); + + it('closes dropdown when another dropdown is opened', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + + const otherElement = document.createElement('div'); + const event = new CustomEvent('dropdownOpened', { detail: otherElement }); + + treeActions.handleOtherDropdownOpened(event); + await page.waitForChanges(); + + expect(treeActions.isDropdownOpen).toBe(false); + }); + + it('does not close dropdown when same dropdown is opened', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + + const event = new CustomEvent('dropdownOpened', { detail: page.root }); + + treeActions.handleOtherDropdownOpened(event); + + expect(treeActions.isDropdownOpen).toBe(true); + }); + + it('initializes popper when more than 2 actions on componentDidUpdate', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + const initializerSpy = jest.spyOn(treeActions, 'initializePopper' as never); + + treeActions.actions = actions; + await page.waitForChanges(); + + expect(initializerSpy).toHaveBeenCalled(); + }); + + it('destroys popper when actions are 2 or less on componentDidUpdate', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + const destroySpy = jest.fn(); + treeActions.popperInstance = { + destroy: destroySpy, + } as unknown as ReturnType; + + treeActions.actions = [{ id: '1', icon: 'edit', label: 'Edit' }]; + + expect(destroySpy).toHaveBeenCalled(); + expect(treeActions.popperInstance).toBeNull(); + }); + + it('renders with custom size', async () => { + const actions = [{ id: '1', icon: 'edit', label: 'Edit' }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + expect(page.rootInstance.size).toBe('lg'); + }); + + it('renders disabled action', async () => { + const actions = [{ id: '1', icon: 'edit', label: 'Edit', disabled: true }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const button = page.root?.querySelector('modus-wc-button'); + expect(button?.hasAttribute('disabled')).toBe(true); + }); + + it('renders action with aria-label', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit', ariaLabel: 'Edit item' }, + { id: '2', icon: 'delete', label: 'Delete', ariaLabel: 'Delete item' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const dropdownAction = page.root?.querySelector( + '.modus-wc-tree-dropdown-action' + ); + expect(dropdownAction?.getAttribute('aria-label')).toBe('Delete item'); + }); + + it('uses label as aria-label when ariaLabel is not provided', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const dropdownAction = page.root?.querySelector( + '.modus-wc-tree-dropdown-action' + ); + expect(dropdownAction?.getAttribute('aria-label')).toBe('Delete'); + }); + + it('applies disabled class to dropdown action when disabled', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete', disabled: true }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const dropdownActions = page.root?.querySelectorAll( + '.modus-wc-tree-dropdown-action' + ); + const deleteAction = dropdownActions?.[0] as HTMLButtonElement; + + expect(deleteAction?.className).toContain('disabled'); + expect(deleteAction?.hasAttribute('disabled')).toBe(true); + }); + + it('does not apply disabled class to dropdown action when not disabled', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const dropdownActions = page.root?.querySelectorAll( + '.modus-wc-tree-dropdown-action' + ); + const deleteAction = dropdownActions?.[0] as HTMLButtonElement; + + expect(deleteAction?.className).not.toContain('disabled'); + expect(deleteAction?.hasAttribute('disabled')).toBe(false); + }); + + it('closes dropdown when action is clicked', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + + treeActions['handleActionClick'](actions[1], new MouseEvent('click')); + await page.waitForChanges(); + + expect(treeActions.isDropdownOpen).toBe(false); + }); + + it('initializePopper returns early when buttons are not available', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + treeActions.moreActionsButton = null; + treeActions.moreActionsDropdown = null; + + treeActions['initializePopper'](); + + expect(treeActions.popperInstance).toBeNull(); + }); + + it('destroys existing popper instance before creating new one', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + treeActions.actions = actions; + await page.waitForChanges(); + + const mockPopper = { + destroy: jest.fn(), + }; + treeActions.popperInstance = mockPopper as unknown as ReturnType< + typeof import('@popperjs/core').createPopper + >; + + treeActions.moreActionsButton = page.root?.querySelector( + '.modus-wc-tree-more-actions-wrapper modus-wc-button' + ) as HTMLElement; + treeActions.moreActionsDropdown = page.root?.querySelector( + '.modus-wc-tree-more-actions-dropdown' + ) as HTMLElement; + + treeActions['initializePopper'](); + + expect(mockPopper.destroy).toHaveBeenCalled(); + }); + + it('destroys popper on disconnectedCallback when popper exists', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + + const destroySpy = jest.fn(); + treeActions.popperInstance = { + destroy: destroySpy, + } as unknown as ReturnType; + + treeActions.disconnectedCallback(); + + expect(destroySpy).toHaveBeenCalled(); + expect(treeActions.popperInstance).toBeNull(); + }); + + it('disconnectedCallback sets popper to null when popper is null', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + const treeActions = page.rootInstance; + + treeActions.popperInstance = null; + + treeActions.disconnectedCallback(); + + expect(treeActions.popperInstance).toBeNull(); + }); + + it('calls handleActionClick when primary action button is clicked', async () => { + const actions = [{ id: '1', icon: 'edit', label: 'Edit' }]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + const handleActionClickSpy = jest.spyOn( + treeActions, + 'handleActionClick' as never + ); + + const button = page.root?.querySelector('modus-wc-button'); + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(handleActionClickSpy).toHaveBeenCalled(); + expect(handleActionClickSpy).toHaveBeenCalledWith( + actions[0], + expect.any(MouseEvent) + ); + }); + + it('calls handleActionClick when dropdown action is clicked', async () => { + const actions = [ + { id: '1', icon: 'edit', label: 'Edit' }, + { id: '2', icon: 'delete', label: 'Delete' }, + { id: '3', icon: 'share', label: 'Share' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeActions], + html: ``, + }); + + page.rootInstance.actions = actions; + await page.waitForChanges(); + + const treeActions = page.rootInstance; + treeActions.isDropdownOpen = true; + await page.waitForChanges(); + + const handleActionClickSpy = jest.spyOn(treeActions, 'handleActionClick'); + + const dropdownAction = page.root?.querySelector( + '.modus-wc-tree-dropdown-action' + ) as HTMLButtonElement; + dropdownAction?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(handleActionClickSpy).toHaveBeenCalled(); + expect(handleActionClickSpy).toHaveBeenCalledWith( + actions[1], + expect.any(MouseEvent) + ); + }); +}); diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.tsx b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.tsx new file mode 100644 index 000000000..9ee642243 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.tsx @@ -0,0 +1,259 @@ +import { createPopper, Instance as PopperInstance } from '@popperjs/core'; +import { + Component, + Element, + EventEmitter, + h, + Host, + Listen, + Prop, + State, + Event as StencilEvent, + Watch, +} from '@stencil/core'; +import { DaisySize } from '../../types'; + +export interface ITreeItemActions { + id: string; // Unique identifier for the action + icon: string; // Icon name for the action, e.g., 'edit', 'trash' + iconVariant?: 'solid' | 'outlined'; // Optional variant for the icon + label: string; // Text label for the action, used for accessibility and tooltips + ariaLabel?: string; // Optional label for accessibility + disabled?: boolean; // Optional flag to disable the action +} + +/** + * ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. + * It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + * @internal + */ +@Component({ + tag: 'modus-wc-tree-actions', + styleUrl: 'modus-wc-tree-actions.scss', + shadow: false, +}) +export class ModusWcTreeActions { + private moreActionsButton!: HTMLElement; + private moreActionsDropdown!: HTMLElement; + private popperInstance: PopperInstance | null = null; + + /** Reference to the host element */ + @Element() el!: HTMLElement; + + /** List of actions to display */ + @Prop() actions?: ITreeItemActions[]; + + /** The size of the action buttons and icons. */ + @Prop() size: DaisySize = 'xs'; + + /** Internal state for dropdown visibility */ + @State() isDropdownOpen: boolean = false; + + /** Event emitted when a dropdown is opened */ + @StencilEvent() dropdownOpened!: EventEmitter; + + /** Event emitted when an action is clicked */ + @StencilEvent() treeActionClick!: EventEmitter<{ + actionId: string; + actionName: string; + }>; + + componentDidLoad() { + this.updatePopperInstance(); + } + + @Watch('actions') + onActionsChange() { + this.updatePopperInstance(); + } + + disconnectedCallback() { + if (this.popperInstance) { + this.popperInstance.destroy(); + this.popperInstance = null; + } + } + + @Listen('click', { target: 'document' }) + handleClickOutside(event: MouseEvent) { + if (!this.isDropdownOpen) return; + + const target = event.target as HTMLElement; + const clickedButton = this.moreActionsButton?.contains(target); + if (!clickedButton) { + this.isDropdownOpen = false; + } + } + + @Listen('dropdownOpened', { target: 'document' }) + handleOtherDropdownOpened(event: CustomEvent) { + // Close this dropdown if another one was opened + if (event.detail !== this.el && this.isDropdownOpen) { + this.isDropdownOpen = false; + } + } + + private handleActionClick = (action: ITreeItemActions, event: MouseEvent) => { + event.stopPropagation(); + if (action.disabled) return; + + this.treeActionClick.emit({ + actionId: action.id, + actionName: action.label, + }); + + this.isDropdownOpen = false; + }; + + private handleMoreActionsClick = (event: MouseEvent) => { + event.stopPropagation(); + this.isDropdownOpen = !this.isDropdownOpen; + + if (this.isDropdownOpen) { + // Emit event to close other dropdowns + this.dropdownOpened.emit(this.el); + + if (this.popperInstance) { + void this.popperInstance.update(); + } + } + }; + + private updatePopperInstance = () => { + if (this.actions && this.actions.length > 2) { + this.initializePopper(); + } else if (this.popperInstance) { + this.popperInstance.destroy(); + this.popperInstance = null; + } + }; + + private initializePopper = () => { + if (this.popperInstance) { + this.popperInstance.destroy(); + this.popperInstance = null; + } + + if (!this.moreActionsButton || !this.moreActionsDropdown) { + return; + } + + this.popperInstance = createPopper( + this.moreActionsButton, + this.moreActionsDropdown, + { + placement: 'bottom', + strategy: 'absolute', + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 8], + }, + }, + { + name: 'preventOverflow', + options: { + padding: 8, + boundary: 'viewport', + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: ['top-start', 'bottom-end', 'top-end'], + padding: 8, + boundary: 'viewport', + }, + }, + { + name: 'computeStyles', + options: { + adaptive: true, + gpuAcceleration: true, + }, + }, + { + name: 'eventListeners', + options: { + scroll: true, + resize: true, + }, + }, + ], + } + ); + }; + + render() { + const remainingActions = this.actions?.slice(1) || []; + + return ( + +
+ {this.actions?.slice(0, 1).map((action) => ( + this.handleActionClick(action, e)} + > + + + ))} + {remainingActions.length > 0 && ( +
+ (this.moreActionsButton = el as HTMLElement)} + onClick={this.handleMoreActionsClick} + aria-expanded={this.isDropdownOpen ? 'true' : 'false'} + aria-haspopup="true" + aria-label="More actions" + > + + +
(this.moreActionsDropdown = el as HTMLElement)} + role="menu" + > + {remainingActions.map((action) => ( + + ))} +
+
+ )} +
+
+ ); + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-actions/readme.md b/src/components/modus-wc-content-tree/modus-wc-tree-actions/readme.md new file mode 100644 index 000000000..a13848e92 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-actions/readme.md @@ -0,0 +1,51 @@ +# modus-wc-tree-actions + + + + + + +## Overview + +ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree. +It supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions. + +## Properties + +| Property | Attribute | Description | Type | Default | +| --------- | --------- | ----------------------------------------- | --------------------------------- | ----------- | +| `actions` | `actions` | List of actions to display | `ITreeItemActions[] \| undefined` | `undefined` | +| `size` | `size` | The size of the action buttons and icons. | `"lg" \| "md" \| "sm" \| "xs"` | `'xs'` | + + +## Events + +| Event | Description | Type | +| ----------------- | --------------------------------------- | -------------------------------------------------------- | +| `dropdownOpened` | Event emitted when a dropdown is opened | `CustomEvent` | +| `treeActionClick` | Event emitted when an action is clicked | `CustomEvent<{ actionId: string; actionName: string; }>` | + + +## Dependencies + +### Used by + + - [modus-wc-tree-item](../modus-wc-tree-item) + +### Depends on + +- [modus-wc-button](../../modus-wc-button) +- [modus-wc-icon](../../modus-wc-icon) + +### Graph +```mermaid +graph TD; + modus-wc-tree-actions --> modus-wc-button + modus-wc-tree-actions --> modus-wc-icon + modus-wc-tree-item --> modus-wc-tree-actions + style modus-wc-tree-actions fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/__snapshots__/modus-wc-tree-item.spec.ts.snap b/src/components/modus-wc-content-tree/modus-wc-tree-item/__snapshots__/modus-wc-tree-item.spec.ts.snap new file mode 100644 index 000000000..2b4f70464 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/__snapshots__/modus-wc-tree-item.spec.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modus-wc-tree-item renders with checkbox 1`] = ` + + +
  • +
    + + + + +
    +
    + Test Item +
    +
    +
    + +
    +
    +
  • +
    +`; + +exports[`modus-wc-tree-item renders with default props 1`] = ` + + +
  • +
    + + + +
    +
    + Test Item +
    +
    +
    + +
    +
    +
  • +
    +`; diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.scss b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.scss new file mode 100644 index 000000000..287e3ef98 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.scss @@ -0,0 +1,177 @@ +/** +* This component uses modus-wc-menu-item structure and DaisyUI menu classes. +* Only add styles here that should not be applied by Tailwind, Daisy, or the theme. +*/ + +modus-wc-tree-item { + .content-tree-item { + list-style: none; + } + + .modus-wc-tree-item-selected > .modus-wc-tree-content { + background-color: var(--modus-wc-color-blue-pale); + border-radius: unset; + color: var(--modus-wc-color-trimble-blue); + + modus-wc-tree-actions + .modus-wc-tree-actions-container + .modus-wc-tree-action-button { + visibility: visible; + } + } + + .modus-wc-tree-item:focus-visible { + outline: none; + } + + .modus-wc-tree-item:focus-visible > .modus-wc-tree-content { + border-radius: 0; + outline: 2px solid #0063a3; + outline-offset: 0; + } + + .modus-wc-tree-content { + align-items: center; + color: var(--modus-wc-color-base-content); + display: flex; + gap: 0; + padding: var(--modus-wc-spacing-xs) var(--modus-wc-font-size-xs); + + .modus-wc-tree-item-actions { + margin-inline-start: 4px; + } + + .modus-wc-tree-drag-handle { + left: 0; + position: absolute; + } + + .modus-wc-tree-toggle-btn { + background-color: transparent; + + &:hover, + &:active, + &[aria-pressed='true'] { + background-color: transparent; + } + } + + [slot='start-icon'] { + padding-inline: 10px; + } + + modus-wc-checkbox { + padding-inline: 10px; + } + + .modus-wc-tree-toggle-icon { + color: var(--modus-wc-color-base-content); + } + + .modus-wc-tree-toggle-button-hidden { + visibility: hidden; + } + + .modus-wc-tree-toggle-button { + visibility: visible; + } + + &:hover { + background-color: var(--modus-wc-color-gray-0); + border-radius: unset; + + modus-wc-tree-actions + .modus-wc-tree-actions-container + .modus-wc-tree-action-button { + visibility: visible; + } + } + } + + .modus-wc-tree-item-group { + border-inline-start: 2px solid var(--modus-wc-color-primary); + } + + .modus-wc-tree-item-labels { + flex: 1; + margin-inline-start: 4px; + + .modus-wc-tree-item-label { + display: block; + } + } + + .modus-wc-tree-item-actions { + align-items: center; + display: flex; + gap: var(--modus-wc-spacing-xs); + } + + .modus-wc-tree-toggle-spacer { + display: inline-block; + flex-shrink: 0; + height: 1.5rem; + width: 1.5rem; + } + + .modus-wc-tree-item-sm .modus-wc-tree-toggle-spacer { + height: 1.75rem; + width: 1.75rem; + } + + .modus-wc-tree-item-md .modus-wc-tree-toggle-spacer { + height: 2rem; + width: 2rem; + } + + .modus-wc-tree-item-lg .modus-wc-tree-toggle-spacer { + height: 2.25rem; + width: 2.25rem; + } + + .modus-wc-tree-dropdown { + display: none; + list-style: none; + + &.modus-wc-tree-dropdown-show { + display: block; + margin-inline-start: 1.3rem; + } + } + + .modus-wc-tree-item-disabled { + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + + .modus-wc-tree-content { + cursor: not-allowed; + } + + .modus-wc-tree-item-actions, + .modus-wc-tree-item-actions * { + pointer-events: auto; + } + } +} + +[data-theme='modus-classic-dark'], +[data-theme='modus-modern-dark'], +[data-theme='connect-dark'] { + modus-wc-tree-item { + .modus-wc-tree-content { + &:hover { + background-color: var(--modus-wc-color-gray-9); + color: var(--modus-wc-color-gray-light); + } + } + + .modus-wc-tree-item-selected > .modus-wc-tree-content { + background-color: color-mix( + in sRGB, + var(--modus-wc-color-primary) 30%, + transparent + ); + } + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.spec.ts b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.spec.ts new file mode 100644 index 000000000..0a315e1d0 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.spec.ts @@ -0,0 +1,1009 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { ITreeItemElement, ModusWcTreeItem } from './modus-wc-tree-item'; + +describe('modus-wc-tree-item', () => { + it('renders with default props', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + expect(page.root).toMatchSnapshot(); + }); + + it('renders with checkbox', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + expect(page.root).toMatchSnapshot(); + }); + + it('renders with disabled state', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.tabIndex).toBe(-1); + }); + + it('renders with selected state', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.getAttribute('aria-selected')).toBe('true'); + }); + + it('renders with custom class', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.classList.contains('custom-item')).toBe(true); + }); + + it('renders with subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('emits itemSelect event on click when no checkbox', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const itemSelectSpy = jest.fn(); + page.root?.addEventListener('itemSelect', itemSelectSpy); + + const li = page.root?.querySelector('li'); + li?.click(); + + await page.waitForChanges(); + + expect(itemSelectSpy).toHaveBeenCalled(); + expect(itemSelectSpy.mock.calls[0][0].detail.value).toBe('test-value'); + }); + + it('handles Enter key to emit itemSelect', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const itemSelectSpy = jest.fn(); + page.root?.addEventListener('itemSelect', itemSelectSpy); + + const li = page.root?.querySelector('li'); + li?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + + await page.waitForChanges(); + + expect(itemSelectSpy).toHaveBeenCalled(); + }); + + it('handles Space key to emit itemSelect', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const itemSelectSpy = jest.fn(); + page.root?.addEventListener('itemSelect', itemSelectSpy); + + const li = page.root?.querySelector('li'); + li?.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + + await page.waitForChanges(); + + expect(itemSelectSpy).toHaveBeenCalled(); + }); + + it('toggles subtree on toggle icon click', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const treeItem = page.rootInstance; + const submenu = page.root?.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + + // Manually call the toggle method + const event = new MouseEvent('click', { bubbles: true }); + treeItem['handleToggleClick'](event); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + true + ); + + treeItem['handleToggleClick'](event); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + }); + + it('toggle button click expands/collapses subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const submenu = page.root?.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + const button = page.root?.querySelector('modus-wc-button'); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + + // Simulate button click by emitting buttonClick event + const mouseEvent = new MouseEvent('click', { bubbles: true }); + const customEvent = new CustomEvent('buttonClick', { + detail: mouseEvent, + bubbles: true, + }); + button?.dispatchEvent(customEvent); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + true + ); + + // Click again to collapse + button?.dispatchEvent(customEvent); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + }); + + it('expandSubTree method expands the subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const treeItem = page.rootInstance; + const submenu = page.root?.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + + await treeItem.expandSubTree(); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + true + ); + }); + + it('collapseSubTree method collapses the subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const treeItem = page.rootInstance; + treeItem.isExpanded = true; + const submenu = page.root?.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + true + ); + + await treeItem.collapseSubTree(); + await page.waitForChanges(); + + expect(submenu.classList.contains('modus-wc-tree-dropdown-show')).toBe( + false + ); + }); + + it('expandSubTree does nothing when already expanded', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const treeItem = page.rootInstance; + treeItem.isExpanded = true; + + const result = await treeItem.expandSubTree(); + expect(result).toBeUndefined(); + }); + + it('collapseSubTree does nothing when already collapsed', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    Child content
    +
    + `, + }); + + const treeItem = page.rootInstance; + const result = await treeItem.collapseSubTree(); + expect(result).toBeUndefined(); + }); + + it('expandSubTree does nothing when no subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const result = await treeItem.expandSubTree(); + expect(result).toBeUndefined(); + }); + + it('collapseSubTree does nothing when no subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const result = await treeItem.collapseSubTree(); + expect(result).toBeUndefined(); + }); + + it('checkbox click toggles selection', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + expect(treeItem.checked).toBeFalsy(); + + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + }); + + it('checkbox handles Enter key', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + + checkbox?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + }); + + it('checkbox handles Space key', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + + checkbox?.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + }); + + it('checkbox uses correct size - xs converts to sm', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + expect(checkbox?.getAttribute('size')).toBe('sm'); + }); + + it('checkbox uses correct size - other sizes pass through', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + expect(checkbox?.getAttribute('size')).toBe('md'); + }); + + it('updates children selection when parent checkbox is clicked', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    + + +
    +
    + `, + }); + + const parent = page.rootInstance; + const children = page.root?.querySelectorAll( + '.modus-wc-tree-dropdown modus-wc-tree-item' + ) as NodeListOf; + + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(parent.checked).toBe(true); + expect(children[0].checked).toBe(true); + expect(children[1].checked).toBe(true); + }); + + it('sets indeterminate state when some children are selected', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    + + +
    +
    + `, + }); + + const parent = page.rootInstance; + + // Manually trigger the update + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: page.root?.querySelector('modus-wc-tree-item[value="child1"]'), + enumerable: true, + }); + + parent['updateIndeterminateState'](event); + await page.waitForChanges(); + + expect(parent.isIndeterminate).toBe(true); + expect(parent.checked).toBe(false); + }); + + it('sets selected state when all children are selected', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
    + + +
    +
    + `, + }); + + const parent = page.rootInstance; + + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: page.root?.querySelector('modus-wc-tree-item[value="child1"]'), + enumerable: true, + }); + + parent['updateIndeterminateState'](event); + await page.waitForChanges(); + + expect(parent.isIndeterminate).toBe(false); + expect(parent.checked).toBe(true); + }); + + it('updateIndeterminateState returns early when event target is self', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const parent = page.rootInstance; + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: page.root, + enumerable: true, + }); + + parent['updateIndeterminateState'](event); + + expect(parent.isIndeterminate).toBe(false); + }); + + it('updateIndeterminateState returns early when no subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const item = page.rootInstance; + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: null, + enumerable: true, + }); + + item['updateIndeterminateState'](event); + + expect(item.isIndeterminate).toBe(false); + }); + + it('updateIndeterminateState returns early when no checkbox', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const parent = page.rootInstance; + const event = new CustomEvent('selectionsChange', { bubbles: true }); + + parent['updateIndeterminateState'](event); + + expect(parent.isIndeterminate).toBe(false); + }); + + it('updateIndeterminateState returns early when submenu is not found', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const parent = page.rootInstance; + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: null, + enumerable: true, + }); + + parent['updateIndeterminateState'](event); + + expect(parent.isIndeterminate).toBe(false); + }); + + it('updateIndeterminateState returns early when no checkbox items in children', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + +
      + +
    +
    + `, + }); + + const parent = page.rootInstance; + const event = new CustomEvent('selectionsChange', { bubbles: true }); + Object.defineProperty(event, 'target', { + value: page.root?.querySelector('modus-wc-tree-item[value="child"]'), + enumerable: true, + }); + + parent['updateIndeterminateState'](event); + + expect(parent.isIndeterminate).toBe(false); + // expect(parent.selected).toBe(false); + }); + + it('updateChildrenSelection returns early when no subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const item = page.rootInstance; + item['updateChildrenSelection'](true); + + // Should return early without error + expect(item.hasSubtree).toBeFalsy(); + }); + + it('updateChildrenSelection returns early when submenu is not found', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const parent = page.rootInstance; + parent['updateChildrenSelection'](true); + + // Should return early without error + expect(parent.hasSubtree).toBe(true); + }); + + it('updateChildrenSelection skips items without checkbox', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + + + + + + + `, + }); + + const parent = page.rootInstance; + parent['updateChildrenSelection'](true); + await page.waitForChanges(); + + const submenu = page.root?.querySelector('.modus-wc-tree-dropdown'); + const children = Array.from( + submenu?.querySelectorAll('modus-wc-tree-item') || [] + ) as ITreeItemElement[]; + + // First child with checkbox should be checked + expect(children[0]?.checked).toBe(true); + // Second child without checkbox should remain unchanged (not checked) + expect(children[1]?.checked).toBeFalsy(); + }); + + it('adds event listener on componentDidLoad when has subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const addEventListenerSpy = jest.spyOn( + page.root as HTMLElement, + 'addEventListener' + ); + + page.rootInstance.componentDidLoad(); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'selectionsChange', + expect.any(Function) + ); + }); + + it('removes event listener on disconnectedCallback', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const removeEventListenerSpy = jest.spyOn( + page.root as HTMLElement, + 'removeEventListener' + ); + + page.rootInstance.disconnectedCallback(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'selectionsChange', + expect.any(Function) + ); + }); + + it('renders tree item actions when provided', async () => { + const actions = [ + { icon: 'edit', label: 'Edit' }, + { icon: 'delete', label: 'Delete' }, + ]; + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + page.rootInstance.treeItemActions = actions; + await page.waitForChanges(); + + const actionsElement = page.root?.querySelector('modus-wc-tree-actions'); + expect(actionsElement).toBeDefined(); + }); + + it('renders with different sizes', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + expect(page.rootInstance.size).toBe('md'); + }); + + it('renders with start icon slot', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ` + + + + `, + }); + + const slot = page.root?.querySelector('slot[name="start-icon"]'); + expect(slot).toBeDefined(); + }); + + it('inherits ARIA attributes', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.getAttribute('aria-label')).toBe('Custom Label'); + }); + + it('getClasses includes disabled class when disabled', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.className).toContain('modus-wc-tree-item'); + }); + + it('getClasses includes selected class when selected', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const li = page.root?.querySelector('li'); + expect(li?.className).toContain('modus-wc-tree-item'); + }); + + it('toggle icon shows chevron_right when collapsed', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + expect(treeItem.isExpanded).toBe(false); + + // Check the icon in the rendered output + const icon = page.root?.querySelector('modus-wc-icon'); + expect(icon).toBeDefined(); + }); + + it('toggle icon shows expand_more when expanded', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.isExpanded = true; + await page.waitForChanges(); + + expect(treeItem.isExpanded).toBe(true); + + // Check the icon in the rendered output + const icon = page.root?.querySelector('modus-wc-icon'); + expect(icon).toBeDefined(); + }); + + it('handleToggleClick does nothing when no subtree', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const initialExpanded = treeItem.isExpanded; + + const event = new MouseEvent('click', { bubbles: true }); + treeItem['handleToggleClick'](event); + + expect(treeItem.isExpanded).toBe(initialExpanded); + }); + + it('checkbox from indeterminate to selected on click', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.isIndeterminate = true; + + const checkbox = page.root?.querySelector('modus-wc-checkbox'); + checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('emits selectionsChange with selected values when checkbox is clicked in tree', async () => { + const contentTree = document.createElement('modus-wc-content-tree'); + const treeView = document.createElement('modus-wc-tree-view'); + contentTree.appendChild(treeView); + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + treeView.appendChild(page.root as HTMLElement); + document.body.appendChild(contentTree); + + const eventSpy = jest.fn(); + page.root?.addEventListener('selectionsChange', eventSpy); + + const treeItem = page.rootInstance; + treeItem['handleCheckboxClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(eventSpy).toHaveBeenCalled(); + expect(eventSpy.mock.calls[0][0].detail.selectedValues).toEqual(['item1']); + + document.body.removeChild(contentTree); + }); + + it('emits selectionsChange with selected values inside standalone tree view', async () => { + const treeView = document.createElement('modus-wc-tree-view'); + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + treeView.appendChild(page.root as HTMLElement); + document.body.appendChild(treeView); + + const eventSpy = jest.fn(); + page.root?.addEventListener('selectionsChange', eventSpy); + + const treeItem = page.rootInstance; + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + expect(eventSpy).toHaveBeenCalled(); + expect(eventSpy.mock.calls[0][0].detail.selectedValues).toEqual(['item1']); + + document.body.removeChild(treeView); + }); + + it('does not emit selectionsChange when rootTreeView is not found', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const eventSpy = jest.fn(); + page.root?.addEventListener('selectionsChange', eventSpy); + + const treeItem = page.rootInstance; + treeItem['handleCheckboxClick'](new MouseEvent('click')); + await page.waitForChanges(); + + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('handleCheckboxClick sets newValue to true when checked is false', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.checked = false; + treeItem.isIndeterminate = false; + + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('handleCheckboxClick sets newValue to false when checked is true and not indeterminate', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.isIndeterminate = false; + + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(false); + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('handleCheckboxClick sets newValue to true when isIndeterminate is true', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.checked = false; + treeItem.isIndeterminate = true; + + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + expect(treeItem.checked).toBe(true); + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('handleCheckboxClick sets newValue to true when checked is true but isIndeterminate is also true', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.isIndeterminate = true; + + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + // When indeterminate is true, newValue should be true (OR condition) + expect(treeItem.checked).toBe(true); + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('handleCheckboxClick resets isIndeterminate to false after click', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + treeItem.checked = false; + treeItem.isIndeterminate = true; + + expect(treeItem.isIndeterminate).toBe(true); + + treeItem['handleCheckboxClick'](); + await page.waitForChanges(); + + expect(treeItem.isIndeterminate).toBe(false); + }); + + it('getRootTreeView returns the closest tree-view when no parent tree-views exist', async () => { + const treeView = document.createElement('modus-wc-tree-view'); + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + treeView.appendChild(page.root as HTMLElement); + document.body.appendChild(treeView); + + const treeItem = page.rootInstance; + const result = treeItem['getRootTreeView'](); + + expect(result).toBe(treeView); + + document.body.removeChild(treeView); + }); + + it('getRootTreeView traverses up to find root tree-view in nested structure', async () => { + const rootTreeView = document.createElement('modus-wc-tree-view'); + const parentItem = document.createElement('modus-wc-tree-item'); + const nestedTreeView = document.createElement('modus-wc-tree-view'); + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + // Structure: rootTreeView > parentItem > nestedTreeView > childItem + nestedTreeView.appendChild(page.root as HTMLElement); + parentItem.appendChild(nestedTreeView); + rootTreeView.appendChild(parentItem); + document.body.appendChild(rootTreeView); + + const treeItem = page.rootInstance; + const result = treeItem['getRootTreeView'](); + + expect(result).toBe(rootTreeView); + + document.body.removeChild(rootTreeView); + }); + + it('getRootTreeView traverses multiple levels to find root tree-view', async () => { + const rootTreeView = document.createElement('modus-wc-tree-view'); + const level1Item = document.createElement('modus-wc-tree-item'); + const level2TreeView = document.createElement('modus-wc-tree-view'); + const level2Item = document.createElement('modus-wc-tree-item'); + const level3TreeView = document.createElement('modus-wc-tree-view'); + + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + // Structure: rootTreeView > level1Item > level2TreeView > level2Item > level3TreeView > deepChild + level3TreeView.appendChild(page.root as HTMLElement); + level2Item.appendChild(level3TreeView); + level2TreeView.appendChild(level2Item); + level1Item.appendChild(level2TreeView); + rootTreeView.appendChild(level1Item); + document.body.appendChild(rootTreeView); + + const treeItem = page.rootInstance; + const result = treeItem['getRootTreeView'](); + + expect(result).toBe(rootTreeView); + + document.body.removeChild(rootTreeView); + }); + + it('getRootTreeView returns null when not inside any tree-view', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeItem], + html: ``, + }); + + const treeItem = page.rootInstance; + const result = treeItem['getRootTreeView'](); + + expect(result).toBeNull(); + }); +}); diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tailwind.ts b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tailwind.ts new file mode 100644 index 000000000..66ae18a32 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tailwind.ts @@ -0,0 +1,27 @@ +import { DaisySize } from '../../types'; + +export const convertPropsToClasses = ({ + disabled, + selected, + size, +}: { + disabled?: boolean; + selected?: boolean; + size?: DaisySize; +}): string => { + let classes = ''; + + if (disabled) { + classes = `${classes} modus-wc-tree-item-disabled`; + } + + if (selected) { + classes = `${classes} modus-wc-tree-item-selected`; + } + + if (size) { + classes = `${classes} modus-wc-tree-item-${size}`; + } + + return classes.trim(); +}; diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx new file mode 100644 index 000000000..7b6bd4955 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx @@ -0,0 +1,358 @@ +import { + Component, + Element, + EventEmitter, + h, + Host, + Method, + Prop, + State, + Event as StencilEvent, +} from '@stencil/core'; +import { convertPropsToClasses } from './modus-wc-tree-item.tailwind'; +import { DaisySize } from '../../types'; +import { Attributes, inheritAriaAttributes } from '../../utils'; +import { ITreeItemActions } from '../modus-wc-tree-actions/modus-wc-tree-actions'; + +export interface ITreeItemElement extends HTMLElement { + value: string; + selected?: boolean; + checked?: boolean; + checkbox?: boolean; + hasSubtree?: boolean; + isIndeterminate?: boolean; + disabled?: boolean; + label: string; + customClass?: string; + treeItemActions?: ITreeItemActions[]; + size?: DaisySize; + collapseSubTree(): Promise; + expandSubTree(): Promise; +} + +/** + * A tree item component that represents a single node in a hierarchical tree structure. + */ +@Component({ + tag: 'modus-wc-tree-item', + styleUrl: 'modus-wc-tree-item.scss', + shadow: false, +}) +export class ModusWcTreeItem { + private inheritedAttributes: Attributes = {}; + + /** Reference to the host element */ + @Element() el!: HTMLElement; + + /** The disabled state of the tree item. */ + @Prop() disabled?: boolean; + + /** If true, renders a checkbox at the start of the tree item. */ + @Prop() checkbox?: boolean = false; + + /** The text label displayed for the tree item. */ + @Prop({ reflect: true }) label!: string; + + /** Custom CSS class to apply to the li element. */ + @Prop() customClass?: string = ''; + + /** The selected state of the tree item. */ + @Prop({ mutable: true, reflect: true }) selected?: boolean; + + /** The checked state of the tree item when checkbox is enabled. */ + @Prop({ mutable: true, reflect: true }) checked?: boolean; + + /** The unique identifying value of the tree item. */ + @Prop() value: string = ''; + + /** Whether this tree item has a collapsible subtree. When true, the item will show a caret and handle toggle behavior. */ + @Prop() hasSubtree?: boolean; + + /** Actions to display for this tree item. */ + @Prop() treeItemActions?: ITreeItemActions[]; + + /** The size of the tree item icons and actions. */ + @Prop() size: DaisySize = 'xs'; + + /** Internal state to track if subtree is expanded */ + @State() isExpanded: boolean = false; + + /** Internal state to track if checkbox is in indeterminate state */ + @State() isIndeterminate: boolean = false; + + /** Event emitted when a tree item is selected. */ + @StencilEvent({ bubbles: true, composed: true }) itemSelect!: EventEmitter<{ + value: string; + }>; + + /** Event emitted when checkbox selection changes in multi-select mode. */ + @StencilEvent({ bubbles: true, composed: true }) + selectionsChange!: EventEmitter<{ + selectedValues: string[]; + }>; + + componentWillLoad() { + this.inheritedAttributes = inheritAriaAttributes(this.el); + } + + componentDidLoad() { + if (this.hasSubtree) { + this.el.addEventListener( + 'selectionsChange', + this.updateIndeterminateState + ); + } + } + + disconnectedCallback() { + this.el.removeEventListener( + 'selectionsChange', + this.updateIndeterminateState + ); + } + + /** + * Public method to collapse the subtree if it's expanded + */ + @Method() + collapseSubTree(): Promise { + if (this.hasSubtree && this.isExpanded) { + const submenu = this.el.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + if (submenu) { + submenu.classList.remove('modus-wc-tree-dropdown-show'); + this.isExpanded = false; + } + } + return Promise.resolve(); + } + + /** + * Public method to expand the subtree if it's collapsed + */ + @Method() + expandSubTree(): Promise { + if (this.hasSubtree && !this.isExpanded) { + const submenu = this.el.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + if (submenu) { + submenu.classList.add('modus-wc-tree-dropdown-show'); + this.isExpanded = true; + } + } + return Promise.resolve(); + } + + private getClasses(): string { + const classList: string[] = ['modus-wc-tree-item']; + + const propClasses = convertPropsToClasses({ + disabled: this.disabled, + selected: this.selected, + size: this.size, + }); + + if (propClasses) classList.push(propClasses); + if (this.customClass) classList.push(this.customClass); + + return classList.join(' '); + } + + private handleToggleClick = (event: MouseEvent | KeyboardEvent) => { + event.stopPropagation(); + if (!this.hasSubtree) return; + + this.isExpanded = !this.isExpanded; + + const submenu = this.el.querySelector( + '.modus-wc-tree-dropdown' + ) as HTMLElement; + + if (submenu) { + submenu.classList.toggle('modus-wc-tree-dropdown-show'); + } + }; + + private handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.handleEmittedSelect(); + } + }; + + private handleItemSelect = (event: MouseEvent) => { + event.stopPropagation(); + this.handleEmittedSelect(); + }; + + private handleCheckboxKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.handleCheckboxClick(); + } + }; + + private updateIndeterminateState = (e: Event) => { + if (e.target === this.el) return; + + if (!this.hasSubtree || !this.checkbox) return; + + const submenu = this.el.querySelector('.modus-wc-tree-dropdown'); + if (!submenu) return; + + const descendants = Array.from( + submenu.querySelectorAll('modus-wc-tree-item') + ) as ITreeItemElement[]; + const checkboxItems = descendants.filter((item) => item.checkbox); + + if (!checkboxItems.length) return; + const checkedCount = checkboxItems.filter((item) => item.checked).length; + + const someChecked = checkedCount > 0; + const allChecked = checkedCount === checkboxItems.length; + + this.checked = allChecked; + this.isIndeterminate = someChecked && !allChecked; + }; + + private updateChildrenSelection = (selected: boolean) => { + if (!this.hasSubtree) return; + + const submenu = this.el.querySelector('.modus-wc-tree-dropdown'); + if (!submenu) return; + + const descendants = Array.from( + submenu.querySelectorAll('modus-wc-tree-item') + ) as ITreeItemElement[]; + + descendants.forEach((item) => { + if (!item.checkbox) return; + + item.checked = selected; + item.isIndeterminate = false; + + const checkbox = item.querySelector('modus-wc-checkbox'); + if (checkbox) { + checkbox.setAttribute('value', selected.toString()); + checkbox.removeAttribute('indeterminate'); + } + }); + }; + + private handleEmittedSelect = () => { + this.itemSelect.emit({ value: this.value }); + }; + + private getRootTreeView(): HTMLElement | null { + let current: HTMLElement | null = this.el.closest('modus-wc-tree-view'); + + while (current?.parentElement?.closest('modus-wc-tree-view')) { + current = current.parentElement.closest('modus-wc-tree-view'); + } + + return current; + } + + private handleCheckboxClick = () => { + const newValue = !this.checked || this.isIndeterminate; + + this.checked = newValue; + this.isIndeterminate = false; + this.updateChildrenSelection(newValue); + + // Emit selectionChange event with all selected values for multi-select mode + const rootTreeView = this.getRootTreeView(); + if (rootTreeView) { + const allTreeItems = Array.from( + rootTreeView.querySelectorAll('modus-wc-tree-item') + ) as ITreeItemElement[]; + const selectedValues = allTreeItems + .filter((item) => item.checkbox && item.checked) + .map((item) => item.value); + + this.selectionsChange.emit({ selectedValues }); + } + }; + + render() { + return ( + +
  • +
    + { + this.handleToggleClick(e.detail); + }} + > + + + {this.checkbox && ( + { + e.stopPropagation(); + this.handleCheckboxClick(); + }} + onKeyDown={(e) => { + this.handleCheckboxKeyDown(e); + }} + /> + )} + +
    +
    {this.label}
    +
    +
    + +
    +
    + +
  • +
    + ); + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-item/readme.md b/src/components/modus-wc-content-tree/modus-wc-tree-item/readme.md new file mode 100644 index 000000000..869f632e3 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-item/readme.md @@ -0,0 +1,83 @@ +# modus-wc-content-tree-item + + + + + + +## Overview + +A tree item component that represents a single node in a hierarchical tree structure. + +## Properties + +| Property | Attribute | Description | Type | Default | +| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ----------- | +| `checkbox` | `checkbox` | If true, renders a checkbox at the start of the tree item. | `boolean \| undefined` | `false` | +| `checked` | `checked` | The checked state of the tree item when checkbox is enabled. | `boolean \| undefined` | `undefined` | +| `customClass` | `custom-class` | Custom CSS class to apply to the li element. | `string \| undefined` | `''` | +| `disabled` | `disabled` | The disabled state of the tree item. | `boolean \| undefined` | `undefined` | +| `hasSubtree` | `has-subtree` | Whether this tree item has a collapsible subtree. When true, the item will show a caret and handle toggle behavior. | `boolean \| undefined` | `undefined` | +| `label` _(required)_ | `label` | The text label displayed for the tree item. | `string` | `undefined` | +| `selected` | `selected` | The selected state of the tree item. | `boolean \| undefined` | `undefined` | +| `size` | `size` | The size of the tree item icons and actions. | `"lg" \| "md" \| "sm" \| "xs"` | `'xs'` | +| `treeItemActions` | `tree-item-actions` | Actions to display for this tree item. | `ITreeItemActions[] \| undefined` | `undefined` | +| `value` | `value` | The unique identifying value of the tree item. | `string` | `''` | + + +## Events + +| Event | Description | Type | +| ------------------ | ------------------------------------------------------------------- | -------------------------------------------- | +| `itemSelect` | Event emitted when a tree item is selected. | `CustomEvent<{ value: string; }>` | +| `selectionsChange` | Event emitted when checkbox selection changes in multi-select mode. | `CustomEvent<{ selectedValues: string[]; }>` | + + +## Methods + +### `collapseSubTree() => Promise` + +Public method to collapse the subtree if it's expanded + +#### Returns + +Type: `Promise` + + + +### `expandSubTree() => Promise` + +Public method to expand the subtree if it's collapsed + +#### Returns + +Type: `Promise` + + + + +## Dependencies + +### Depends on + +- [modus-wc-button](../../modus-wc-button) +- [modus-wc-icon](../../modus-wc-icon) +- [modus-wc-checkbox](../../modus-wc-checkbox) +- modus-wc-tree-actions + +### Graph +```mermaid +graph TD; + modus-wc-tree-item --> modus-wc-button + modus-wc-tree-item --> modus-wc-icon + modus-wc-tree-item --> modus-wc-checkbox + modus-wc-tree-item --> modus-wc-tree-actions + modus-wc-checkbox --> modus-wc-input-label + modus-wc-tree-actions --> modus-wc-button + modus-wc-tree-actions --> modus-wc-icon + style modus-wc-tree-item fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-view/__snapshots__/modus-wc-tree-view.spec.ts.snap b/src/components/modus-wc-content-tree/modus-wc-tree-view/__snapshots__/modus-wc-tree-view.spec.ts.snap new file mode 100644 index 000000000..6498f57b7 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-view/__snapshots__/modus-wc-tree-view.spec.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modus-wc-tree-view renders as a sublist when isSubList is true 1`] = ` + + +
      +
      +`; + +exports[`modus-wc-tree-view renders with custom props 1`] = ` + + +
        +
        +`; + +exports[`modus-wc-tree-view renders with default props 1`] = ` + + +
          +
          +`; diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.scss b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.scss new file mode 100644 index 000000000..dbd0141e8 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.scss @@ -0,0 +1,17 @@ +/** + * Styles for the tree view wrapper component. + * Uses modus-wc-menu structure with consistent class naming. + * Only add styles here that should not be applied by Tailwind, Daisy, or the theme. + */ + +modus-wc-tree-view.modus-wc-tree-submenu { + display: contents; +} + +modus-wc-tree-view { + .modus-wc-menu { + list-style: none; + margin: 0; + padding: 0; + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.spec.ts b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.spec.ts new file mode 100644 index 000000000..aadcedf1a --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.spec.ts @@ -0,0 +1,304 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { ModusWcTreeView } from './modus-wc-tree-view'; +import { ITreeItemElement } from '../modus-wc-tree-item/modus-wc-tree-item'; + +describe('modus-wc-tree-view', () => { + it('renders with default props', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + expect(page.root).toMatchSnapshot(); + }); + + it('renders with custom props', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + expect(page.root).toMatchSnapshot(); + }); + + it('renders as a sublist when isSubList is true', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + expect(page.root).toMatchSnapshot(); + + const host = page.root as HTMLElement; + expect(host.classList.contains('modus-wc-tree-submenu')).toBe(true); + + const ul = host.querySelector('ul'); + expect(ul?.classList.contains('modus-wc-tree-dropdown')).toBe(true); + expect(ul?.getAttribute('role')).toBe('group'); + }); + + it('renders as a tree when isSubList is false', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const ul = page.root?.querySelector('ul'); + expect(ul?.classList.contains('modus-wc-menu')).toBe(true); + expect(ul?.classList.contains('modus-wc-tree-view')).toBe(true); + expect(ul?.getAttribute('role')).toBe('tree'); + }); + + it('applies custom class to sublist', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const ul = page.root?.querySelector('ul'); + expect(ul?.classList.contains('modus-wc-tree-dropdown')).toBe(true); + expect(ul?.classList.contains('custom-sublist')).toBe(true); + }); + + it('applies custom class to main tree view', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const ul = page.root?.querySelector('ul'); + expect(ul?.classList.contains('modus-wc-menu')).toBe(true); + expect(ul?.classList.contains('modus-wc-tree-view')).toBe(true); + expect(ul?.classList.contains('custom-tree')).toBe(true); + }); + + it('handles itemSelect event when target is missing', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const treeView = page.rootInstance; + + // Simulate event with no target + const event = new CustomEvent('itemSelect', { + detail: { value: 'item1' }, + bubbles: true, + }); + Object.defineProperty(event, 'target', { + value: null, + enumerable: true, + }); + + // Should not throw + expect(() => treeView.handleItemSelect(event)).not.toThrow(); + }); + + it('handles itemSelect event when target has no content element', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ` + +
          No content here
          +
          + `, + }); + + const treeView = page.rootInstance; + const item = page.root?.querySelector('.modus-wc-tree-item') as HTMLElement; + + const event = new CustomEvent('itemSelect', { + detail: { value: 'item1' }, + bubbles: true, + }); + Object.defineProperty(event, 'target', { + value: item, + enumerable: true, + }); + + // Should not throw even when content element is missing + expect(() => treeView.handleItemSelect(event)).not.toThrow(); + }); + + it('does not handle itemSelect when isSubList is true', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ` + + +
        • +
          Item 1
          +
        • +
          +
          + `, + }); + + const treeView = page.rootInstance; + const treeItem = page.root?.querySelector( + 'modus-wc-tree-item' + ) as HTMLElement; + + const event = new CustomEvent('itemSelect', { + detail: { value: 'item1' }, + bubbles: true, + }); + Object.defineProperty(event, 'target', { + value: treeItem, + enumerable: true, + }); + + treeView.handleItemSelect(event); + await page.waitForChanges(); + + // When isSubList is true, the event should be ignored and item should not be selected + const treeItemElement = treeItem as ITreeItemElement; + expect(treeItemElement.selected).toBeFalsy(); + }); + + it('inherits ARIA attributes', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const ul = page.root?.querySelector('ul'); + expect(ul?.getAttribute('aria-label')).toBe('Test Tree'); + expect(ul?.getAttribute('aria-expanded')).toBe('true'); + }); + + it('getClasses returns correct classes for main tree view', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const treeView = page.rootInstance; + const classes = treeView['getClasses'](); + + expect(classes).toContain('modus-wc-menu'); + expect(classes).toContain('modus-wc-tree-view'); + }); + + it('getClasses returns correct classes for sublist', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const treeView = page.rootInstance; + const classes = treeView['getClasses'](); + + expect(classes).toContain('modus-wc-tree-dropdown'); + expect(classes).not.toContain('modus-wc-menu'); + }); + + it('getClasses includes custom class for main tree view', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const treeView = page.rootInstance; + const classes = treeView['getClasses'](); + + expect(classes).toContain('modus-wc-menu'); + expect(classes).toContain('modus-wc-tree-view'); + expect(classes).toContain('my-custom-class'); + }); + + it('getClasses includes custom class for sublist', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ``, + }); + + const treeView = page.rootInstance; + const classes = treeView['getClasses'](); + + expect(classes).toContain('modus-wc-tree-dropdown'); + expect(classes).toContain('my-custom-sublist'); + }); + + it('handles itemSelect event and removes class from previously selected li', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ` + + +
        • +
          Item 1
          +
        • +
          + +
        • +
          Item 2
          +
        • +
          +
          + `, + }); + + const treeView = page.rootInstance; + const treeItems = page.root?.querySelectorAll('modus-wc-tree-item'); + const firstTreeItem = treeItems?.[0] as HTMLElement; + const secondTreeItem = treeItems?.[1] as HTMLElement; + const firstLi = firstTreeItem.querySelector('li'); + + // Select first item + const event1 = new CustomEvent('itemSelect', { + detail: { value: 'item1' }, + bubbles: true, + }); + Object.defineProperty(event1, 'target', { + value: firstTreeItem, + enumerable: true, + }); + + treeView.handleItemSelect(event1); + await page.waitForChanges(); + + // Select second item + const event2 = new CustomEvent('itemSelect', { + detail: { value: 'item2' }, + bubbles: true, + }); + Object.defineProperty(event2, 'target', { + value: secondTreeItem, + enumerable: true, + }); + + treeView.handleItemSelect(event2); + await page.waitForChanges(); + + expect(firstLi?.classList.contains('modus-wc-tree-item-li-active')).toBe( + false + ); + }); + + it('handles itemSelect event when content element has no parent li', async () => { + const page = await newSpecPage({ + components: [ModusWcTreeView], + html: ` + +
          +
          Item without li
          +
          +
          + `, + }); + + const treeView = page.rootInstance; + const item = page.root?.querySelector('.modus-wc-tree-item') as HTMLElement; + + const event = new CustomEvent('itemSelect', { + detail: { value: 'item1' }, + bubbles: true, + }); + Object.defineProperty(event, 'target', { + value: item, + enumerable: true, + }); + + // Should not throw when parent is not an li element + expect(() => treeView.handleItemSelect(event)).not.toThrow(); + }); +}); diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx new file mode 100644 index 000000000..ebf857e16 --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx @@ -0,0 +1,73 @@ +import { Component, Element, h, Host, Listen, Prop } from '@stencil/core'; +import { Attributes, inheritAriaAttributes } from '../../utils'; +import { ITreeItemElement } from '../modus-wc-tree-item/modus-wc-tree-item'; + +/** + * A wrapper component that provides the ul element for tree items. + * This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + */ +@Component({ + tag: 'modus-wc-tree-view', + styleUrl: 'modus-wc-tree-view.scss', + shadow: false, +}) +export class ModusWcTreeView { + private inheritedAttributes: Attributes = {}; + + /** Reference to the host element */ + @Element() el!: HTMLElement; + + /** Custom CSS class to apply to the ul element. */ + @Prop() customClass?: string = ''; + + /** Indicates that this list is a nested sublist. */ + @Prop() isSubList?: boolean = false; + + componentWillLoad() { + this.inheritedAttributes = inheritAriaAttributes(this.el); + } + + @Listen('itemSelect') + handleItemSelect(event: CustomEvent<{ value: string }>) { + if (this.isSubList) return; + + const target = event.target as HTMLElement; + if (!target) return; + + const allTreeItems = this.el.querySelectorAll('modus-wc-tree-item'); + allTreeItems.forEach((item: ITreeItemElement) => { + item.selected = false; + }); + + const targetTreeItem = target.closest('modus-wc-tree-item'); + if (targetTreeItem) { + (targetTreeItem as ITreeItemElement).selected = true; + } + } + + private getClasses(): string { + if (this.isSubList) { + const classList: string[] = ['modus-wc-tree-dropdown']; + if (this.customClass) classList.push(this.customClass); + return classList.join(' '); + } + + const classList: string[] = ['modus-wc-menu', 'modus-wc-tree-view']; + if (this.customClass) classList.push(this.customClass); + return classList.join(' '); + } + + render() { + return ( + +
            + +
          +
          + ); + } +} diff --git a/src/components/modus-wc-content-tree/modus-wc-tree-view/readme.md b/src/components/modus-wc-content-tree/modus-wc-tree-view/readme.md new file mode 100644 index 000000000..62620137d --- /dev/null +++ b/src/components/modus-wc-content-tree/modus-wc-tree-view/readme.md @@ -0,0 +1,23 @@ +# modus-wc-content-tree-list + + + + + + +## Overview + +A wrapper component that provides the ul element for tree items. +This component uses the modus-wc-menu structure to wrap tree items in a proper list structure. + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------- | -------------- | --------------------------------------------- | ---------------------- | ------- | +| `customClass` | `custom-class` | Custom CSS class to apply to the ul element. | `string \| undefined` | `''` | +| `isSubList` | `is-sub-list` | Indicates that this list is a nested sublist. | `boolean \| undefined` | `false` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/src/components/modus-wc-content-tree/readme.md b/src/components/modus-wc-content-tree/readme.md new file mode 100644 index 000000000..9340e61db --- /dev/null +++ b/src/components/modus-wc-content-tree/readme.md @@ -0,0 +1,46 @@ +# modus-wc-content-tree + + + + + + +## Overview + +A customizable content tree component used to display hierarchical data in a tree structure. + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------- | -------------------- | ----------------------------------------------------------------- | ---------------------- | ------------- | +| `customClass` | `custom-class` | Custom CSS class to apply to the component. | `string \| undefined` | `''` | +| `includeActions` | `include-actions` | If true, displays the action buttons (expand/collapse all, etc.). | `boolean \| undefined` | `true` | +| `includeSearch` | `include-search` | If true, displays the search input to filter tree items. | `boolean \| undefined` | `true` | +| `searchPlaceholder` | `search-placeholder` | Placeholder text for the search input. | `string \| undefined` | `'Search...'` | + + +## Dependencies + +### Depends on + +- [modus-wc-text-input](../modus-wc-text-input) +- [modus-wc-button](../modus-wc-button) +- [modus-wc-icon](../modus-wc-icon) +- [modus-wc-typography](../modus-wc-typography) + +### Graph +```mermaid +graph TD; + modus-wc-content-tree --> modus-wc-text-input + modus-wc-content-tree --> modus-wc-button + modus-wc-content-tree --> modus-wc-icon + modus-wc-content-tree --> modus-wc-typography + modus-wc-text-input --> modus-wc-input-label + modus-wc-text-input --> modus-wc-input-feedback + modus-wc-input-feedback --> modus-wc-icon + style modus-wc-content-tree fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/src/components/modus-wc-icon/readme.md b/src/components/modus-wc-icon/readme.md index 20889260c..d82e9420d 100644 --- a/src/components/modus-wc-icon/readme.md +++ b/src/components/modus-wc-icon/readme.md @@ -30,6 +30,7 @@ A customizable icon component used to render Modus icons. - [modus-wc-autocomplete](../modus-wc-autocomplete) - [modus-wc-avatar](../modus-wc-avatar) - [modus-wc-collapse](../modus-wc-collapse) + - [modus-wc-content-tree](../modus-wc-content-tree) - [modus-wc-date](../modus-wc-date) - [modus-wc-file-dropzone](../modus-wc-file-dropzone) - [modus-wc-handle](../modus-wc-handle) @@ -37,6 +38,8 @@ A customizable icon component used to render Modus icons. - [modus-wc-profile-menu](../modus-wc-profile-menu) - [modus-wc-table](../modus-wc-table) - [modus-wc-tabs](../modus-wc-tabs) + - modus-wc-tree-actions + - [modus-wc-tree-item](../modus-wc-content-tree/modus-wc-tree-item) ### Graph ```mermaid @@ -45,6 +48,7 @@ graph TD; modus-wc-autocomplete --> modus-wc-icon modus-wc-avatar --> modus-wc-icon modus-wc-collapse --> modus-wc-icon + modus-wc-content-tree --> modus-wc-icon modus-wc-date --> modus-wc-icon modus-wc-file-dropzone --> modus-wc-icon modus-wc-handle --> modus-wc-icon @@ -52,6 +56,8 @@ graph TD; modus-wc-profile-menu --> modus-wc-icon modus-wc-table --> modus-wc-icon modus-wc-tabs --> modus-wc-icon + modus-wc-tree-actions --> modus-wc-icon + modus-wc-tree-item --> modus-wc-icon style modus-wc-icon fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/components/modus-wc-logo/README.md b/src/components/modus-wc-logo/README.md index 43b907a01..153b7046f 100644 --- a/src/components/modus-wc-logo/README.md +++ b/src/components/modus-wc-logo/README.md @@ -9,6 +9,7 @@ A component for displaying Trimble product logos with support for both fixed and scalable sizing. Provides consistent branding across applications with various product logo options. +Logo colors automatically adapt to the active Modus theme via CSS variables. ## Properties diff --git a/src/components/modus-wc-navbar/readme.md b/src/components/modus-wc-navbar/readme.md index eb27eec98..aa4ae9cc0 100644 --- a/src/components/modus-wc-navbar/readme.md +++ b/src/components/modus-wc-navbar/readme.md @@ -9,26 +9,23 @@ A customizable navbar component used for top level navigation of all Trimble applications. -⚠️ **Deprecated**: The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release. -The component requires a profileProps object with user information and optionally accepts menuOne and menuTwo for custom menus. - ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `appsMenuOpen` | `apps-menu-open` | The open state of the apps menu. | `boolean \| undefined` | `false` | -| `condensed` | `condensed` | Applies condensed layout and styling. | `boolean \| undefined` | `false` | -| `condensedMenuOpen` | `condensed-menu-open` | The open state of the condensed menu. | `boolean \| undefined` | `false` | -| `customClass` | `custom-class` | Custom CSS class to apply to the host element. | `string \| undefined` | `''` | -| `logoName` | `logo-name` | The name of the logo to display. Supports any valid 'logo-name' from the 'modus-wc-logo' component. Defaults to 'trimble'. | `LogoName \| undefined` | `'trimble'` | -| `mainMenuOpen` | `main-menu-open` | The open state of the main menu. | `boolean \| undefined` | `false` | -| `notificationsMenuOpen` | `notifications-menu-open` | The open state of the notifications menu. | `boolean \| undefined` | `false` | -| `searchDebounceMs` | `search-debounce-ms` | Debounce time in milliseconds for search input changes. Default is 300ms. | `number \| undefined` | `300` | -| `searchInputOpen` | `search-input-open` | The open state of the search input. | `boolean \| undefined` | `false` | -| `textOverrides` | `text-overrides` | Text replacements for the navbar. | `INavbarTextOverrides \| undefined` | `undefined` | -| `userCard` _(required)_ | `user-card` | **[DEPRECATED]** The `user-card` prop will be replaced by `profile-props` prop of the `modus-wc-profile-menu` component in an upcoming release.

          User information used to render the user card. | `INavbarUserCard` | `undefined` | -| `userMenuOpen` | `user-menu-open` | The open state of the user menu. | `boolean \| undefined` | `false` | -| `visibility` | `visibility` | The visibility of individual navbar buttons. Default is user profile visible, others hidden. | `INavbarVisibility \| undefined` | `{ ai: false, apps: false, help: false, mainMenu: false, notifications: false, search: false, searchInput: false, user: true, }` | +| Property | Attribute | Description | Type | Default | +| ----------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appsMenuOpen` | `apps-menu-open` | The open state of the apps menu. | `boolean \| undefined` | `false` | +| `condensed` | `condensed` | Applies condensed layout and styling. | `boolean \| undefined` | `false` | +| `condensedMenuOpen` | `condensed-menu-open` | The open state of the condensed menu. | `boolean \| undefined` | `false` | +| `customClass` | `custom-class` | Custom CSS class to apply to the host element. | `string \| undefined` | `''` | +| `logoName` | `logo-name` | The name of the logo to display. Supports any valid 'logo-name' from the 'modus-wc-logo' component. Defaults to 'trimble'. | `LogoName \| undefined` | `'trimble'` | +| `mainMenuOpen` | `main-menu-open` | The open state of the main menu. | `boolean \| undefined` | `false` | +| `notificationsMenuOpen` | `notifications-menu-open` | The open state of the notifications menu. | `boolean \| undefined` | `false` | +| `searchDebounceMs` | `search-debounce-ms` | Debounce time in milliseconds for search input changes. Default is 300ms. | `number \| undefined` | `300` | +| `searchInputOpen` | `search-input-open` | The open state of the search input. | `boolean \| undefined` | `false` | +| `textOverrides` | `text-overrides` | Text replacements for the navbar. | `INavbarTextOverrides \| undefined` | `undefined` | +| `userCard` _(required)_ | `user-card` | User information used to render the user card. | `INavbarUserCard` | `undefined` | +| `userMenuOpen` | `user-menu-open` | The open state of the user menu. | `boolean \| undefined` | `false` | +| `visibility` | `visibility` | The visibility of individual navbar buttons. Default is user profile visible, others hidden. | `INavbarVisibility \| undefined` | `{ ai: false, apps: false, help: false, mainMenu: false, notifications: false, search: false, searchInput: false, user: true, }` | ## Events diff --git a/src/components/modus-wc-text-input/readme.md b/src/components/modus-wc-text-input/readme.md index 6bccf1670..8b900540a 100644 --- a/src/components/modus-wc-text-input/readme.md +++ b/src/components/modus-wc-text-input/readme.md @@ -56,6 +56,7 @@ The component supports a `` for injecting additional custom content inside ### Used by - [modus-wc-autocomplete](../modus-wc-autocomplete) + - [modus-wc-content-tree](../modus-wc-content-tree) - [modus-wc-navbar](../modus-wc-navbar) ### Depends on @@ -70,6 +71,7 @@ graph TD; modus-wc-text-input --> modus-wc-input-feedback modus-wc-input-feedback --> modus-wc-icon modus-wc-autocomplete --> modus-wc-text-input + modus-wc-content-tree --> modus-wc-text-input modus-wc-navbar --> modus-wc-text-input style modus-wc-text-input fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/components/modus-wc-typography/readme.md b/src/components/modus-wc-typography/readme.md index abd11f4eb..4c1b62625 100644 --- a/src/components/modus-wc-typography/readme.md +++ b/src/components/modus-wc-typography/readme.md @@ -33,11 +33,13 @@ providing your own custom values for the size or weight properties from the avai ### Used by + - [modus-wc-content-tree](../modus-wc-content-tree) - [modus-wc-profile-menu](../modus-wc-profile-menu) ### Graph ```mermaid graph TD; + modus-wc-content-tree --> modus-wc-typography modus-wc-profile-menu --> modus-wc-typography style modus-wc-typography fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/src/custom-elements.json b/src/custom-elements.json index 76be6519f..5ff4c2003 100644 --- a/src/custom-elements.json +++ b/src/custom-elements.json @@ -2287,6 +2287,466 @@ } ] }, + { + "kind": "javascript-module", + "path": "src/components/modus-wc-content-tree/modus-wc-content-tree.tsx", + "declarations": [ + { + "kind": "class", + "description": "A customizable content tree component used to display hierarchical data in a tree structure.", + "name": "ModusWcContentTree", + "members": [ + { + "kind": "field", + "name": "el", + "type": { + "text": "HTMLElement" + }, + "description": "Reference to the host element" + }, + { + "kind": "method", + "name": "render" + } + ], + "attributes": [ + { + "name": "custom-class", + "fieldName": "customClass", + "default": "''", + "description": "Custom CSS class to apply to the component.", + "type": { + "text": "string" + } + }, + { + "name": "include-actions", + "fieldName": "includeActions", + "default": "true", + "description": "If true, displays the action buttons (expand/collapse all, etc.).", + "type": { + "text": "boolean" + } + }, + { + "name": "include-search", + "fieldName": "includeSearch", + "default": "true", + "description": "If true, displays the search input to filter tree items.", + "type": { + "text": "boolean" + } + }, + { + "name": "search-placeholder", + "fieldName": "searchPlaceholder", + "default": "'Search...'", + "description": "Placeholder text for the search input.", + "type": { + "text": "string" + } + } + ], + "tagName": "modus-wc-content-tree", + "events": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ModusWcContentTree", + "declaration": { + "name": "ModusWcContentTree", + "module": "src/components/modus-wc-content-tree/modus-wc-content-tree.tsx" + } + }, + { + "kind": "custom-element-definition", + "name": "modus-wc-content-tree", + "declaration": { + "name": "ModusWcContentTree", + "module": "src/components/modus-wc-content-tree/modus-wc-content-tree.tsx" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.tsx", + "declarations": [ + { + "kind": "class", + "description": "ModusWcTreeActions is a component that renders action buttons for tree items in the Modus content tree.\r\nIt supports displaying a primary action and grouping additional actions in a dropdown menu if there are more than two actions.", + "name": "ModusWcTreeActions", + "members": [ + { + "kind": "field", + "name": "el", + "type": { + "text": "HTMLElement" + }, + "description": "Reference to the host element" + }, + { + "kind": "method", + "name": "handleClickOutside", + "parameters": [ + { + "name": "event", + "type": { + "text": "MouseEvent" + } + } + ] + }, + { + "kind": "method", + "name": "handleOtherDropdownOpened", + "parameters": [ + { + "name": "event", + "type": { + "text": "CustomEvent" + } + } + ] + }, + { + "kind": "field", + "name": "isDropdownOpen", + "type": { + "text": "boolean" + }, + "default": "false", + "description": "Internal state for dropdown visibility" + }, + { + "kind": "method", + "name": "onActionsChange" + }, + { + "kind": "method", + "name": "render" + } + ], + "attributes": [ + { + "name": "actions", + "fieldName": "actions", + "description": "List of actions to display", + "type": { + "text": "ITreeItemActions[]" + } + }, + { + "name": "size", + "fieldName": "size", + "default": "'xs'", + "description": "The size of the action buttons and icons.", + "type": { + "text": "DaisySize" + } + } + ], + "tagName": "modus-wc-tree-actions", + "events": [ + { + "kind": "field", + "name": "dropdownOpened", + "type": { + "text": "EventEmitter" + }, + "description": "Event emitted when a dropdown is opened" + }, + { + "kind": "field", + "name": "treeActionClick", + "type": { + "text": "EventEmitter<{\r\n actionId: string;\r\n actionName: string;\r\n }>" + }, + "description": "Event emitted when an action is clicked" + } + ], + "customElement": true + } + ], + "exports": [ + { + "kind": "custom-element-definition", + "name": "modus-wc-tree-actions", + "declaration": { + "name": "ModusWcTreeActions", + "module": "src/components/modus-wc-content-tree/modus-wc-tree-actions/modus-wc-tree-actions.tsx" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx", + "declarations": [ + { + "kind": "class", + "description": "A tree item component that represents a single node in a hierarchical tree structure.", + "name": "ModusWcTreeItem", + "members": [ + { + "kind": "method", + "name": "collapseSubTree", + "return": { + "type": { + "text": "Promise" + } + }, + "description": "Public method to collapse the subtree if it's expanded" + }, + { + "kind": "field", + "name": "el", + "type": { + "text": "HTMLElement" + }, + "description": "Reference to the host element" + }, + { + "kind": "method", + "name": "expandSubTree", + "return": { + "type": { + "text": "Promise" + } + }, + "description": "Public method to expand the subtree if it's collapsed" + }, + { + "kind": "field", + "name": "isExpanded", + "type": { + "text": "boolean" + }, + "default": "false", + "description": "Internal state to track if subtree is expanded" + }, + { + "kind": "field", + "name": "isIndeterminate", + "type": { + "text": "boolean" + }, + "default": "false", + "description": "Internal state to track if checkbox is in indeterminate state" + }, + { + "kind": "method", + "name": "render" + } + ], + "attributes": [ + { + "name": "checkbox", + "fieldName": "checkbox", + "default": "false", + "description": "If true, renders a checkbox at the start of the tree item.", + "type": { + "text": "boolean" + } + }, + { + "name": "checked", + "fieldName": "checked", + "description": "The checked state of the tree item when checkbox is enabled.", + "type": { + "text": "boolean" + } + }, + { + "name": "custom-class", + "fieldName": "customClass", + "default": "''", + "description": "Custom CSS class to apply to the li element.", + "type": { + "text": "string" + } + }, + { + "name": "disabled", + "fieldName": "disabled", + "description": "The disabled state of the tree item.", + "type": { + "text": "boolean" + } + }, + { + "name": "has-subtree", + "fieldName": "hasSubtree", + "description": "Whether this tree item has a collapsible subtree. When true, the item will show a caret and handle toggle behavior.", + "type": { + "text": "boolean" + } + }, + { + "name": "label", + "fieldName": "label", + "description": "The text label displayed for the tree item.", + "type": { + "text": "string" + } + }, + { + "name": "selected", + "fieldName": "selected", + "description": "The selected state of the tree item.", + "type": { + "text": "boolean" + } + }, + { + "name": "size", + "fieldName": "size", + "default": "'xs'", + "description": "The size of the tree item icons and actions.", + "type": { + "text": "DaisySize" + } + }, + { + "name": "tree-item-actions", + "fieldName": "treeItemActions", + "description": "Actions to display for this tree item.", + "type": { + "text": "ITreeItemActions[]" + } + }, + { + "name": "value", + "fieldName": "value", + "default": "''", + "description": "The unique identifying value of the tree item.", + "type": { + "text": "string" + } + } + ], + "tagName": "modus-wc-tree-item", + "events": [ + { + "kind": "field", + "name": "itemSelect", + "type": { + "text": "EventEmitter<{\r\n value: string;\r\n }>" + }, + "description": "Event emitted when a tree item is selected." + }, + { + "kind": "field", + "name": "selectionsChange", + "type": { + "text": "EventEmitter<{\r\n selectedValues: string[];\r\n }>" + }, + "description": "Event emitted when checkbox selection changes in multi-select mode." + } + ], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ModusWcTreeItem", + "declaration": { + "name": "ModusWcTreeItem", + "module": "src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx" + } + }, + { + "kind": "custom-element-definition", + "name": "modus-wc-tree-item", + "declaration": { + "name": "ModusWcTreeItem", + "module": "src/components/modus-wc-content-tree/modus-wc-tree-item/modus-wc-tree-item.tsx" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx", + "declarations": [ + { + "kind": "class", + "description": "A wrapper component that provides the ul element for tree items.\r\nThis component uses the modus-wc-menu structure to wrap tree items in a proper list structure.", + "name": "ModusWcTreeView", + "members": [ + { + "kind": "field", + "name": "el", + "type": { + "text": "HTMLElement" + }, + "description": "Reference to the host element" + }, + { + "kind": "method", + "name": "handleItemSelect", + "parameters": [ + { + "name": "event", + "type": { + "text": "CustomEvent<{ value: string }>" + } + } + ] + }, + { + "kind": "method", + "name": "render" + } + ], + "attributes": [ + { + "name": "custom-class", + "fieldName": "customClass", + "default": "''", + "description": "Custom CSS class to apply to the ul element.", + "type": { + "text": "string" + } + }, + { + "name": "is-sub-list", + "fieldName": "isSubList", + "default": "false", + "description": "Indicates that this list is a nested sublist.", + "type": { + "text": "boolean" + } + } + ], + "tagName": "modus-wc-tree-view", + "events": [], + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ModusWcTreeView", + "declaration": { + "name": "ModusWcTreeView", + "module": "src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx" + } + }, + { + "kind": "custom-element-definition", + "name": "modus-wc-tree-view", + "declaration": { + "name": "ModusWcTreeView", + "module": "src/components/modus-wc-content-tree/modus-wc-tree-view/modus-wc-tree-view.tsx" + } + } + ] + }, { "kind": "javascript-module", "path": "src/components/modus-wc-date/modus-wc-date.tsx",