Skip to content

Commit dcbf9bd

Browse files
committed
fix: add empty message, loading, and keyboard nav
1 parent 835299e commit dcbf9bd

14 files changed

Lines changed: 320 additions & 19 deletions

File tree

packages/pluggableWidgets/tree-node-web/src/TreeNode.editorConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function getProperties(
3838
if (values.parentAssociation) {
3939
hidePropertyIn(defaultProperties, values, "hasChildren");
4040
} else {
41+
hidePropertyIn(defaultProperties, values, "noDataMessage");
4142
if (!values.hasChildren) {
4243
hidePropertiesIn(defaultProperties, values, ["startExpanded", "children"]);
4344
}

packages/pluggableWidgets/tree-node-web/src/TreeNode.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@
9090
<description>Animate the icon when the group is collapsing or expanding.</description>
9191
</property>
9292
</propertyGroup>
93+
<propertyGroup caption="Texts">
94+
<property key="noDataMessage" type="textTemplate" required="false">
95+
<caption>No data message</caption>
96+
<description>Message to show when there are no items to display.</description>
97+
<translations>
98+
<translation lang="en_US">No data available</translation>
99+
<translation lang="nl_NL">Geen gegevens beschikbaar</translation>
100+
</translations>
101+
</property>
102+
</propertyGroup>
93103
</propertyGroup>
94104
</properties>
95105
</widget>

packages/pluggableWidgets/tree-node-web/src/components/common/HeaderIcon.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import classNames from "classnames";
22
import { WebIcon } from "mendix";
33
import { ReactNode } from "react";
44

5+
import { ChevronIcon, CustomHeaderIcon } from "./Icons";
6+
import { TreeNodeState } from "./TreeNodeState";
57
import { ShowIconEnum } from "../../../typings/TreeNodeProps";
68
import loadingCircleSvg from "../../assets/loading-circle.svg";
79

8-
import { ChevronIcon, CustomHeaderIcon } from "../v1/Icons";
9-
import { TreeNodeState } from "./TreeNodeState";
10-
1110
export interface IconOptions {
1211
animateIcon: boolean;
1312
collapsedIcon?: WebIcon;

packages/pluggableWidgets/tree-node-web/src/components/v1/Icons.tsx renamed to packages/pluggableWidgets/tree-node-web/src/components/common/Icons.tsx

File renamed without changes.

packages/pluggableWidgets/tree-node-web/src/components/v1/HeaderIcon.tsx

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNode.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@ import classNames from "classnames";
22
import { ObjectItem, WebIcon } from "mendix";
33
import { CSSProperties, ReactElement, ReactNode, useCallback, useContext } from "react";
44

5-
import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../../typings/TreeNodeProps";
6-
import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon";
7-
import { TreeNodeState } from "../common/TreeNodeState";
8-
95
import { useTreeNodeFocusChangeHandler } from "./hooks/TreeNodeAccessibility";
106
import { useTreeNodeRef } from "./hooks/useTreeNodeRef";
117
import { TreeNodeBranch, TreeNodeBranchProps, treeNodeBranchUtils } from "./TreeNodeBranch";
128
import { TreeNodeBranchContext, useInformParentContextOfChildNodes } from "./TreeNodeBranchContext";
13-
14-
export { TreeNodeState };
9+
import { OpenNodeOnEnum, TreeNodeContainerProps } from "../../../typings/TreeNodeProps";
10+
import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon";
1511

1612
export interface TreeNodeItem extends ObjectItem {
1713
headerContent: ReactNode;

packages/pluggableWidgets/tree-node-web/src/components/v1/TreeNodeBranch.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import {
1515

1616
import { OpenNodeOnEnum, ShowIconEnum } from "../../../typings/TreeNodeProps";
1717
import { TreeNodeHeaderIcon } from "../common/HeaderIcon";
18+
import { TreeNodeState } from "../common/TreeNodeState";
1819

1920
import { useTreeNodeLazyLoading } from "./hooks/lazyLoading";
2021
import { TreeNodeFocusChangeHandler, useTreeNodeBranchKeyboardHandler } from "./hooks/TreeNodeAccessibility";
2122
import { useAnimatedTreeNodeContentHeight } from "./hooks/useAnimatedHeight";
23+
import { TreeNodeItem } from "./TreeNode";
2224

23-
import { TreeNodeItem, TreeNodeState } from "./TreeNode";
2425
import { TreeNodeBranchContext, TreeNodeBranchContextProps } from "./TreeNodeBranchContext";
2526

2627
export interface TreeNodeBranchProps {

packages/pluggableWidgets/tree-node-web/src/components/v1/hooks/TreeNodeAccessibility.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventHandler, SyntheticEvent, useCallback, useMemo } from "react";
2-
import { TreeNodeState } from "../TreeNode";
32
import { KeyboardHandlerHook, useKeyboardHandler } from "./useKeyboardHandler";
3+
import { TreeNodeState } from "../../common/TreeNodeState";
44

55
export const enum FocusTargetChange {
66
FIRST = "FIRST",

packages/pluggableWidgets/tree-node-web/src/components/v2/TreeNode.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import classNames from "classnames";
22
import { ValueStatus } from "mendix";
3-
import { ReactElement, useCallback, useMemo, useState } from "react";
3+
import { ReactElement, useCallback, useMemo, useState, KeyboardEvent } from "react";
44
import "./ui/TreeNodeV2.scss";
55
import { renderTreeNodeHeaderIcon, TreeNodeHeaderIcon } from "../common/HeaderIcon";
66
import { TreeNodeState } from "../common/TreeNodeState";
7+
import { onKeyDownHandler } from "./hooks/helpers";
78
import { useIncrementalTreeData, TreeNodeV2DataItem } from "./hooks/useIncrementalTreeData";
89
import { useInfiniteTreeNodes } from "./hooks/useInfiniteTreeNode";
910
import { TreeNodeContainerProps } from "../../../typings/TreeNodeProps";
@@ -23,13 +24,18 @@ function renderRecursiveNode(
2324
const onIconClick = isIconClickable ? () => onNodeClick(node) : undefined;
2425
const onHeaderClick = isHeaderClickable ? () => onNodeClick(node) : undefined;
2526

27+
const onKeyDown = (event: KeyboardEvent<HTMLLIElement>): void => {
28+
onKeyDownHandler<TreeNodeV2DataItem>(event, hasChildren, isExpanded, onNodeClick, node);
29+
};
30+
2631
return (
2732
<li
2833
key={node.id}
2934
className="widget-tree-node-branch"
3035
role="treeitem"
3136
tabIndex={0}
3237
aria-expanded={hasChildren ? isExpanded : undefined}
38+
onKeyDown={onKeyDown}
3339
>
3440
<span
3541
className={classNames("widget-tree-node-branch-header", {
@@ -40,7 +46,7 @@ function renderRecursiveNode(
4046
onClick={onHeaderClick}
4147
>
4248
<span className="widget-tree-node-branch-header-value">{node.title}</span>
43-
{hasChildren && iconPlacement !== "no" ? (
49+
{(hasChildren || node.treeNodeState === TreeNodeState.LOADING) && iconPlacement !== "no" && (
4450
<span
4551
className={classNames("widget-tree-node-branch-header-icon-container", {
4652
"widget-tree-node-branch-header-clickable": hasChildren && isIconClickable
@@ -49,7 +55,7 @@ function renderRecursiveNode(
4955
>
5056
{renderHeaderIcon(node.treeNodeState, iconPlacement)}
5157
</span>
52-
) : null}
58+
)}
5359
</span>
5460
{hasChildren ? (
5561
<div
@@ -128,6 +134,14 @@ export function TreeNodeV2(props: TreeNodeContainerProps): ReactElement {
128134
[appendItems]
129135
);
130136

137+
if (treeData.length === 0) {
138+
return (
139+
<div className={classNames("widget-tree-node", "widget-tree-node-v2", props.class)} style={props.style}>
140+
<div className="widget-tree-node-no-data">{props.noDataMessage?.value ?? "No data available"}</div>
141+
</div>
142+
);
143+
}
144+
131145
return (
132146
<ul
133147
className={classNames("widget-tree-node", "widget-tree-node-v2", props.class)}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import "@testing-library/jest-dom";
3+
import { ListValue, ObjectItem, ListAttributeValue } from "mendix";
4+
import { createElement } from "react";
5+
import { TreeNodeContainerProps } from "../../../../typings/TreeNodeProps";
6+
import { TreeNodeV2 } from "../TreeNode";
7+
8+
describe("TreeNodeV2 - Keyboard Navigation", () => {
9+
const mockItem = (id: string, name: string): ObjectItem => ({
10+
id
11+
});
12+
13+
const createMockListValue = (items: ObjectItem[]): ListValue => ({
14+
status: "available" as const,
15+
items,
16+
limit: 10,
17+
offset: 0,
18+
hasMoreItems: false,
19+
sortOrder: [],
20+
setLimit: jest.fn(),
21+
setOffset: jest.fn(),
22+
requestTotalCount: jest.fn(),
23+
totalCount: undefined
24+
});
25+
26+
const createMockAttribute = (value: string): ListAttributeValue<string> => ({
27+
get: () => ({
28+
status: "available" as const,
29+
value,
30+
displayValue: value,
31+
readOnly: false,
32+
formatter: {
33+
format: (val: string) => val,
34+
withConfig: jest.fn().mockReturnThis()
35+
},
36+
setValue: jest.fn(),
37+
setTextInputValue: jest.fn(),
38+
setValidator: jest.fn()
39+
})
40+
});
41+
42+
const defaultProps: TreeNodeContainerProps = {
43+
name: "treeNode",
44+
class: "",
45+
tabIndex: 0,
46+
headerType: "attribute",
47+
headerCaption: createMockAttribute("Root"),
48+
items: createMockListValue([mockItem("1", "Root")]),
49+
showIcon: "right",
50+
openNodeOn: "headerClick",
51+
animate: false,
52+
animateIcon: false,
53+
startExpanded: false
54+
};
55+
56+
it("expands node when Enter key is pressed", () => {
57+
const items = [mockItem("1", "Parent")];
58+
const props: TreeNodeContainerProps = {
59+
...defaultProps,
60+
items: createMockListValue(items),
61+
headerCaption: createMockAttribute("Parent")
62+
};
63+
64+
render(createElement(TreeNodeV2, props));
65+
const treeItem = screen.getByRole("treeitem");
66+
67+
expect(treeItem).toHaveAttribute("aria-expanded", "false");
68+
69+
fireEvent.keyDown(treeItem, { key: "Enter" });
70+
71+
expect(treeItem).toHaveAttribute("aria-expanded", "true");
72+
});
73+
74+
it("expands node when Space key is pressed", () => {
75+
const items = [mockItem("1", "Parent")];
76+
const props: TreeNodeContainerProps = {
77+
...defaultProps,
78+
items: createMockListValue(items),
79+
headerCaption: createMockAttribute("Parent")
80+
};
81+
82+
render(createElement(TreeNodeV2, props));
83+
const treeItem = screen.getByRole("treeitem");
84+
85+
expect(treeItem).toHaveAttribute("aria-expanded", "false");
86+
87+
fireEvent.keyDown(treeItem, { key: " " });
88+
89+
expect(treeItem).toHaveAttribute("aria-expanded", "true");
90+
});
91+
92+
it("collapses expanded node when Enter key is pressed", () => {
93+
const items = [mockItem("1", "Parent")];
94+
const props: TreeNodeContainerProps = {
95+
...defaultProps,
96+
items: createMockListValue(items),
97+
headerCaption: createMockAttribute("Parent"),
98+
startExpanded: true
99+
};
100+
101+
render(createElement(TreeNodeV2, props));
102+
const treeItem = screen.getByRole("treeitem");
103+
104+
expect(treeItem).toHaveAttribute("aria-expanded", "true");
105+
106+
fireEvent.keyDown(treeItem, { key: "Enter" });
107+
108+
expect(treeItem).toHaveAttribute("aria-expanded", "false");
109+
});
110+
111+
it("expands node when ArrowRight is pressed on collapsed node", () => {
112+
const items = [mockItem("1", "Parent")];
113+
const props: TreeNodeContainerProps = {
114+
...defaultProps,
115+
items: createMockListValue(items),
116+
headerCaption: createMockAttribute("Parent")
117+
};
118+
119+
render(createElement(TreeNodeV2, props));
120+
const treeItem = screen.getByRole("treeitem");
121+
122+
expect(treeItem).toHaveAttribute("aria-expanded", "false");
123+
124+
fireEvent.keyDown(treeItem, { key: "ArrowRight" });
125+
126+
expect(treeItem).toHaveAttribute("aria-expanded", "true");
127+
});
128+
129+
it("collapses node when ArrowLeft is pressed on expanded node", () => {
130+
const items = [mockItem("1", "Parent")];
131+
const props: TreeNodeContainerProps = {
132+
...defaultProps,
133+
items: createMockListValue(items),
134+
headerCaption: createMockAttribute("Parent"),
135+
startExpanded: true
136+
};
137+
138+
render(createElement(TreeNodeV2, props));
139+
const treeItem = screen.getByRole("treeitem");
140+
141+
expect(treeItem).toHaveAttribute("aria-expanded", "true");
142+
143+
fireEvent.keyDown(treeItem, { key: "ArrowLeft" });
144+
145+
expect(treeItem).toHaveAttribute("aria-expanded", "false");
146+
});
147+
148+
it("does not respond to keyboard when node has no children", () => {
149+
const items = [mockItem("1", "Leaf")];
150+
const props: TreeNodeContainerProps = {
151+
...defaultProps,
152+
items: createMockListValue(items),
153+
headerCaption: createMockAttribute("Leaf")
154+
};
155+
156+
render(createElement(TreeNodeV2, props));
157+
const treeItem = screen.getByRole("treeitem");
158+
159+
// Leaf nodes should not have aria-expanded attribute
160+
expect(treeItem).not.toHaveAttribute("aria-expanded");
161+
162+
// Pressing keys should not cause errors
163+
fireEvent.keyDown(treeItem, { key: "Enter" });
164+
fireEvent.keyDown(treeItem, { key: " " });
165+
fireEvent.keyDown(treeItem, { key: "ArrowRight" });
166+
167+
// Should still not have aria-expanded
168+
expect(treeItem).not.toHaveAttribute("aria-expanded");
169+
});
170+
171+
it("prevents default behavior and stops propagation for handled keys", () => {
172+
const items = [mockItem("1", "Parent")];
173+
const props: TreeNodeContainerProps = {
174+
...defaultProps,
175+
items: createMockListValue(items),
176+
headerCaption: createMockAttribute("Parent")
177+
};
178+
179+
render(createElement(TreeNodeV2, props));
180+
const treeItem = screen.getByRole("treeitem");
181+
182+
const event = {
183+
key: "Enter",
184+
currentTarget: treeItem,
185+
target: treeItem,
186+
preventDefault: jest.fn(),
187+
stopPropagation: jest.fn()
188+
};
189+
190+
fireEvent.keyDown(treeItem, event);
191+
192+
expect(event.preventDefault).toHaveBeenCalled();
193+
expect(event.stopPropagation).toHaveBeenCalled();
194+
});
195+
196+
it("ignores keyboard events that bubble from child elements", () => {
197+
const items = [mockItem("1", "Parent")];
198+
const props: TreeNodeContainerProps = {
199+
...defaultProps,
200+
items: createMockListValue(items),
201+
headerCaption: createMockAttribute("Parent"),
202+
startExpanded: true
203+
};
204+
205+
render(createElement(TreeNodeV2, props));
206+
const treeItems = screen.getAllByRole("treeitem");
207+
const parentItem = treeItems[0];
208+
209+
// Simulate a bubbled event from a child element
210+
const childElement = document.createElement("div");
211+
parentItem.appendChild(childElement);
212+
213+
const event = new KeyboardEvent("keydown", {
214+
key: "Enter",
215+
bubbles: true,
216+
cancelable: true
217+
});
218+
219+
Object.defineProperty(event, "currentTarget", {
220+
writable: false,
221+
value: parentItem
222+
});
223+
Object.defineProperty(event, "target", {
224+
writable: false,
225+
value: childElement
226+
});
227+
228+
const initialState = parentItem.getAttribute("aria-expanded");
229+
fireEvent(childElement, event);
230+
231+
// State should not change because event came from child
232+
expect(parentItem.getAttribute("aria-expanded")).toBe(initialState);
233+
});
234+
});

0 commit comments

Comments
 (0)