@tangle-network/ui 11.15.1 → 11.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/files/rich-file-tree.js +29 -6
- package/dist/primitives/toast.js +14 -1
- package/package.json +1 -1
- package/src/files/rich-file-tree.test.tsx +50 -2
- package/src/files/rich-file-tree.tsx +25 -12
- package/src/primitives/toast.test.tsx +130 -0
- package/src/primitives/toast.tsx +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.15.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2ecce2e: Cancel pending toast auto-dismiss timers when a toast is dismissed or its provider unmounts.
|
|
8
|
+
|
|
9
|
+
## 11.15.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 11d515d: Keep file-tree selection callbacks current after rerenders and synchronize controlled selection through the native item API.
|
|
14
|
+
Closing a file clears its selection so the same file can be opened again.
|
|
15
|
+
|
|
3
16
|
## 11.15.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import { FileTree, useFileTree } from "@pierre/trees/react";
|
|
4
4
|
//#region src/files/rich-file-tree.tsx
|
|
@@ -66,13 +66,24 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
|
|
|
66
66
|
if (root) return flattenFileNode(root);
|
|
67
67
|
return [];
|
|
68
68
|
}, [paths, root]);
|
|
69
|
+
const selectionRef = useRef({
|
|
70
|
+
selectedPath,
|
|
71
|
+
onSelect
|
|
72
|
+
});
|
|
73
|
+
selectionRef.current = {
|
|
74
|
+
selectedPath,
|
|
75
|
+
onSelect
|
|
76
|
+
};
|
|
77
|
+
const syncingSelectionRef = useRef(false);
|
|
78
|
+
const previousSelectedPathRef = useRef(selectedPath);
|
|
69
79
|
const { model } = useFileTree({
|
|
70
80
|
paths: flatPaths,
|
|
71
81
|
search,
|
|
72
82
|
initialExpansion,
|
|
73
83
|
onSelectionChange: (selected) => {
|
|
74
84
|
const next = selected[0];
|
|
75
|
-
|
|
85
|
+
const current = selectionRef.current;
|
|
86
|
+
if (!syncingSelectionRef.current && next && next !== current.selectedPath) current.onSelect?.(next);
|
|
76
87
|
}
|
|
77
88
|
});
|
|
78
89
|
useEffect(() => {
|
|
@@ -83,11 +94,23 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
|
|
|
83
94
|
model.resetPaths(flatPaths);
|
|
84
95
|
}, [model, flatPaths]);
|
|
85
96
|
useEffect(() => {
|
|
86
|
-
|
|
97
|
+
const previous = previousSelectedPathRef.current;
|
|
98
|
+
previousSelectedPathRef.current = selectedPath;
|
|
99
|
+
if (selectedPath === void 0 && previous === void 0) return;
|
|
87
100
|
const current = model.getSelectedPaths();
|
|
88
|
-
if (current.length === 1 && current[0] === selectedPath) return;
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
|
|
102
|
+
syncingSelectionRef.current = true;
|
|
103
|
+
try {
|
|
104
|
+
for (const path of [...current]) model.getItem(path)?.deselect();
|
|
105
|
+
if (selectedPath) model.getItem(selectedPath)?.select();
|
|
106
|
+
} finally {
|
|
107
|
+
syncingSelectionRef.current = false;
|
|
108
|
+
}
|
|
109
|
+
}, [
|
|
110
|
+
model,
|
|
111
|
+
selectedPath,
|
|
112
|
+
flatPaths
|
|
113
|
+
]);
|
|
91
114
|
const theme = {
|
|
92
115
|
...DEFAULT_THEME,
|
|
93
116
|
...themeOverrides
|
package/dist/primitives/toast.js
CHANGED
|
@@ -67,7 +67,20 @@ function ToastContainer({ toasts, onDismiss }) {
|
|
|
67
67
|
const ToastContext = React.createContext(null);
|
|
68
68
|
function ToastProvider({ children }) {
|
|
69
69
|
const [toasts, setToasts] = React.useState([]);
|
|
70
|
+
const timers = React.useRef(/* @__PURE__ */ new Map());
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
const pendingTimers = timers.current;
|
|
73
|
+
return () => {
|
|
74
|
+
for (const timer of pendingTimers.values()) clearTimeout(timer);
|
|
75
|
+
pendingTimers.clear();
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
70
78
|
const dismiss = React.useCallback((id) => {
|
|
79
|
+
const timer = timers.current.get(id);
|
|
80
|
+
if (timer !== void 0) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
timers.current.delete(id);
|
|
83
|
+
}
|
|
71
84
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
72
85
|
}, []);
|
|
73
86
|
const toast = React.useCallback((input) => {
|
|
@@ -78,7 +91,7 @@ function ToastProvider({ children }) {
|
|
|
78
91
|
};
|
|
79
92
|
setToasts((prev) => [...prev, newToast]);
|
|
80
93
|
const duration = input.duration ?? 5e3;
|
|
81
|
-
if (duration > 0) setTimeout(() => dismiss(id), duration);
|
|
94
|
+
if (duration > 0) timers.current.set(id, setTimeout(() => dismiss(id), duration));
|
|
82
95
|
}, [dismiss]);
|
|
83
96
|
const success = React.useCallback((title, description) => {
|
|
84
97
|
toast({
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { render } from "@testing-library/react"
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react"
|
|
3
3
|
import { RichFileTree } from "./rich-file-tree"
|
|
4
4
|
|
|
5
|
+
function treeRow(container: HTMLElement, path: string): HTMLElement {
|
|
6
|
+
const host = container.querySelector("file-tree-container");
|
|
7
|
+
const row = Array.from(host?.shadowRoot?.querySelectorAll("[data-item-path]") ?? [])
|
|
8
|
+
.find((element) => element.getAttribute("data-item-path") === path);
|
|
9
|
+
if (!(row instanceof HTMLElement)) throw new Error(`Missing tree row: ${path}`);
|
|
10
|
+
return row;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
describe("RichFileTree", () => {
|
|
6
14
|
it("renders without crashing given a flat path list", () => {
|
|
7
15
|
const { container } = render(
|
|
@@ -34,6 +42,46 @@ describe("RichFileTree", () => {
|
|
|
34
42
|
expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
|
|
35
43
|
})
|
|
36
44
|
|
|
45
|
+
it("calls the current selection callback after its owner rerenders", async () => {
|
|
46
|
+
const original = vi.fn();
|
|
47
|
+
const current = vi.fn();
|
|
48
|
+
const paths = ["brief.md", "outreach.md"];
|
|
49
|
+
const { container, rerender } = render(<RichFileTree paths={paths} onSelect={original} />);
|
|
50
|
+
rerender(<RichFileTree paths={paths} onSelect={current} />);
|
|
51
|
+
fireEvent.click(treeRow(container, "outreach.md"));
|
|
52
|
+
await waitFor(() => expect(current).toHaveBeenCalledWith("outreach.md"));
|
|
53
|
+
expect(original).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("clears controlled selection so a closed file can be selected again", async () => {
|
|
57
|
+
const onSelect = vi.fn();
|
|
58
|
+
const paths = ["brief.md", "outreach.md"];
|
|
59
|
+
const { container, rerender } = render(
|
|
60
|
+
<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />,
|
|
61
|
+
);
|
|
62
|
+
rerender(<RichFileTree paths={paths} selectedPath={undefined} onSelect={onSelect} />);
|
|
63
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "false"));
|
|
64
|
+
fireEvent.click(treeRow(container, "brief.md"));
|
|
65
|
+
await waitFor(() => expect(onSelect).toHaveBeenCalledExactlyOnceWith("brief.md"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("preserves uncontrolled selection when the file listing refreshes", async () => {
|
|
69
|
+
const { container, rerender } = render(<RichFileTree paths={["brief.md"]} />);
|
|
70
|
+
fireEvent.click(treeRow(container, "brief.md"));
|
|
71
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
72
|
+
rerender(<RichFileTree paths={["brief.md", "outreach.md"]} />);
|
|
73
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not notify the owner when a controlled selection is synchronized", async () => {
|
|
77
|
+
const onSelect = vi.fn();
|
|
78
|
+
const paths = ["brief.md", "outreach.md"];
|
|
79
|
+
const { container, rerender } = render(<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />);
|
|
80
|
+
rerender(<RichFileTree paths={paths} selectedPath="outreach.md" onSelect={onSelect} />);
|
|
81
|
+
await waitFor(() => expect(treeRow(container, "outreach.md")).toHaveAttribute("aria-selected", "true"));
|
|
82
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
37
85
|
it("throws when both root and paths are passed", () => {
|
|
38
86
|
expect(() =>
|
|
39
87
|
render(<RichFileTree root={{ name: "x", path: "x", type: "file" }} paths={["y"]} />),
|
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
GitStatus,
|
|
28
28
|
GitStatusEntry,
|
|
29
29
|
} from "@pierre/trees";
|
|
30
|
-
import { useEffect, useMemo, type CSSProperties, type ReactNode } from "react";
|
|
30
|
+
import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
|
|
31
31
|
import type { FileNode } from "./file-tree";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -169,13 +169,22 @@ export function RichFileTree({
|
|
|
169
169
|
return [];
|
|
170
170
|
}, [paths, root]);
|
|
171
171
|
|
|
172
|
+
// Pierre creates its model once; event handlers must read the current owner.
|
|
173
|
+
const selectionRef = useRef({ selectedPath, onSelect });
|
|
174
|
+
selectionRef.current = { selectedPath, onSelect };
|
|
175
|
+
const syncingSelectionRef = useRef(false);
|
|
176
|
+
const previousSelectedPathRef = useRef(selectedPath);
|
|
177
|
+
|
|
172
178
|
const { model } = usePierreFileTree({
|
|
173
179
|
paths: flatPaths,
|
|
174
180
|
search,
|
|
175
181
|
initialExpansion,
|
|
176
182
|
onSelectionChange: (selected) => {
|
|
177
|
-
const next = selected[0]
|
|
178
|
-
|
|
183
|
+
const next = selected[0];
|
|
184
|
+
const current = selectionRef.current;
|
|
185
|
+
if (!syncingSelectionRef.current && next && next !== current.selectedPath) {
|
|
186
|
+
current.onSelect?.(next);
|
|
187
|
+
}
|
|
179
188
|
},
|
|
180
189
|
});
|
|
181
190
|
|
|
@@ -192,17 +201,21 @@ export function RichFileTree({
|
|
|
192
201
|
model.resetPaths(flatPaths);
|
|
193
202
|
}, [model, flatPaths]);
|
|
194
203
|
|
|
195
|
-
// Drive selection
|
|
196
|
-
// to avoid an emit loop with onSelectionChange.
|
|
204
|
+
// Drive controlled selection with Pierre's public item API, including clearing it.
|
|
197
205
|
useEffect(() => {
|
|
198
|
-
|
|
206
|
+
const previous = previousSelectedPathRef.current;
|
|
207
|
+
previousSelectedPathRef.current = selectedPath;
|
|
208
|
+
if (selectedPath === undefined && previous === undefined) return;
|
|
199
209
|
const current = model.getSelectedPaths();
|
|
200
|
-
if (current.length === 1 && current[0] === selectedPath) return;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
|
|
211
|
+
syncingSelectionRef.current = true;
|
|
212
|
+
try {
|
|
213
|
+
for (const path of [...current]) model.getItem(path)?.deselect();
|
|
214
|
+
if (selectedPath) model.getItem(selectedPath)?.select();
|
|
215
|
+
} finally {
|
|
216
|
+
syncingSelectionRef.current = false;
|
|
217
|
+
}
|
|
218
|
+
}, [model, selectedPath, flatPaths]);
|
|
206
219
|
|
|
207
220
|
const theme = { ...DEFAULT_THEME, ...themeOverrides };
|
|
208
221
|
const themeStyle = useMemo<CSSProperties>(
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ToastProvider, useToast } from "./toast";
|
|
4
|
+
|
|
5
|
+
function ToastTrigger({ title, duration }: { title: string; duration?: number }) {
|
|
6
|
+
const { toast } = useToast();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<button type="button" onClick={() => toast({ title, duration })}>
|
|
10
|
+
Show {title}
|
|
11
|
+
</button>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup();
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("ToastProvider auto-dismiss", () => {
|
|
26
|
+
it("cancels every pending dismissal on unmount without updating after teardown", () => {
|
|
27
|
+
const view = render(
|
|
28
|
+
<ToastProvider>
|
|
29
|
+
<ToastTrigger title="First" duration={1000} />
|
|
30
|
+
<ToastTrigger title="Second" duration={2000} />
|
|
31
|
+
</ToastProvider>,
|
|
32
|
+
);
|
|
33
|
+
const scheduleTimeout = globalThis.setTimeout;
|
|
34
|
+
const timeoutFired = vi.fn();
|
|
35
|
+
// Observe the real dismissal callbacks without mocking the provider or React state.
|
|
36
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation((callback, delay, ...args) =>
|
|
37
|
+
scheduleTimeout(() => {
|
|
38
|
+
timeoutFired();
|
|
39
|
+
callback(...args);
|
|
40
|
+
}, delay),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
fireEvent.click(screen.getByRole("button", { name: "Show First" }));
|
|
44
|
+
fireEvent.click(screen.getByRole("button", { name: "Show Second" }));
|
|
45
|
+
expect(screen.getAllByRole("alert")).toHaveLength(2);
|
|
46
|
+
expect(vi.getTimerCount()).toBe(2);
|
|
47
|
+
|
|
48
|
+
view.unmount();
|
|
49
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
vi.advanceTimersByTime(5000);
|
|
53
|
+
});
|
|
54
|
+
// A cancelled callback cannot reach dismiss() or its state update after unmount.
|
|
55
|
+
expect(timeoutFired).not.toHaveBeenCalled();
|
|
56
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a toast until its duration elapses, then removes it", () => {
|
|
60
|
+
render(
|
|
61
|
+
<ToastProvider>
|
|
62
|
+
<ToastTrigger title="Saved" duration={1000} />
|
|
63
|
+
</ToastProvider>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
fireEvent.click(screen.getByRole("button", { name: "Show Saved" }));
|
|
67
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
vi.advanceTimersByTime(999);
|
|
71
|
+
});
|
|
72
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Saved");
|
|
73
|
+
|
|
74
|
+
act(() => {
|
|
75
|
+
vi.advanceTimersByTime(1);
|
|
76
|
+
});
|
|
77
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
78
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("clears only the manually dismissed toast's timer", () => {
|
|
82
|
+
render(
|
|
83
|
+
<ToastProvider>
|
|
84
|
+
<ToastTrigger title="First" duration={1000} />
|
|
85
|
+
<ToastTrigger title="Second" duration={2000} />
|
|
86
|
+
</ToastProvider>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getByRole("button", { name: "Show First" }));
|
|
90
|
+
fireEvent.click(screen.getByRole("button", { name: "Show Second" }));
|
|
91
|
+
expect(vi.getTimerCount()).toBe(2);
|
|
92
|
+
|
|
93
|
+
fireEvent.click(within(screen.getAllByRole("alert")[0]!).getByRole("button", {
|
|
94
|
+
name: "Dismiss notification",
|
|
95
|
+
}));
|
|
96
|
+
expect(screen.queryByText("First")).not.toBeInTheDocument();
|
|
97
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Second");
|
|
98
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
vi.advanceTimersByTime(1000);
|
|
102
|
+
});
|
|
103
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Second");
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
vi.advanceTimersByTime(1000);
|
|
107
|
+
});
|
|
108
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
109
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("schedules no timer when duration is zero", () => {
|
|
113
|
+
render(
|
|
114
|
+
<ToastProvider>
|
|
115
|
+
<ToastTrigger title="Persistent" duration={0} />
|
|
116
|
+
</ToastProvider>,
|
|
117
|
+
);
|
|
118
|
+
const scheduleTimeout = vi.spyOn(globalThis, "setTimeout");
|
|
119
|
+
|
|
120
|
+
fireEvent.click(screen.getByRole("button", { name: "Show Persistent" }));
|
|
121
|
+
expect(scheduleTimeout).not.toHaveBeenCalled();
|
|
122
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
vi.advanceTimersByTime(10000);
|
|
126
|
+
});
|
|
127
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Persistent");
|
|
128
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/primitives/toast.tsx
CHANGED
|
@@ -129,8 +129,24 @@ const ToastContext = React.createContext<ToastContextValue | null>(null);
|
|
|
129
129
|
|
|
130
130
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
131
131
|
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
|
132
|
+
const timers = React.useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
|
133
|
+
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
const pendingTimers = timers.current;
|
|
136
|
+
return () => {
|
|
137
|
+
for (const timer of pendingTimers.values()) {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
pendingTimers.clear();
|
|
141
|
+
};
|
|
142
|
+
}, []);
|
|
132
143
|
|
|
133
144
|
const dismiss = React.useCallback((id: string) => {
|
|
145
|
+
const timer = timers.current.get(id);
|
|
146
|
+
if (timer !== undefined) {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
timers.current.delete(id);
|
|
149
|
+
}
|
|
134
150
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
135
151
|
}, []);
|
|
136
152
|
|
|
@@ -143,7 +159,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
|
143
159
|
// Auto-dismiss after duration
|
|
144
160
|
const duration = input.duration ?? 5000;
|
|
145
161
|
if (duration > 0) {
|
|
146
|
-
setTimeout(() => dismiss(id), duration);
|
|
162
|
+
timers.current.set(id, setTimeout(() => dismiss(id), duration));
|
|
147
163
|
}
|
|
148
164
|
},
|
|
149
165
|
[dismiss],
|