@exegia/corpora-ui 0.19.0 → 0.21.0
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/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +191 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +27 -31
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +6 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
- package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
- package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
- package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
- package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -0
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +35 -0
- package/dist-lib/components/composed/tree/index.d.ts +6 -0
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +12 -0
- package/dist-lib/components/composed/tree/tree.d.ts +19 -0
- package/dist-lib/components/composed/tree/type.d.ts +290 -0
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -0
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +24 -0
- package/dist-lib/components/composed/tree/utils.d.ts +28 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
- package/dist-lib/components/user-avatar/component.d.ts +3 -0
- package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
- package/dist-lib/components/user-avatar/index.d.ts +18 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +71 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
- package/dist-lib/components/user-avatar/utils.d.ts +2 -0
- package/dist-lib/index.d.ts +6 -2
- package/dist-lib/index.js +3576 -1366
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +16 -12
- package/src/components/beste/piece/browser-frame.tsx +9 -6
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +112 -76
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +228 -29
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +455 -225
- package/src/components/blocks/nav/sidebar/utils.ts +29 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
- package/src/components/blocks/scaffold/constants.ts +7 -0
- package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
- package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
- package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
- package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
- package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
- package/src/components/blocks/scaffold/type.ts +28 -0
- package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
- package/src/components/blocks/scaffold/utils.ts +19 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -0
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +525 -0
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +333 -0
- package/src/components/composed/tree/constants.ts +60 -0
- package/src/components/composed/tree/index.ts +51 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-context.ts +13 -0
- package/src/components/composed/tree/tree-node.tsx +490 -0
- package/src/components/composed/tree/tree.tsx +286 -0
- package/src/components/composed/tree/type.ts +322 -0
- package/src/components/composed/tree/use-tree-dnd.ts +179 -0
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +307 -0
- package/src/components/composed/tree/utils.ts +172 -0
- package/src/components/composed/user-avatar.tsx +11 -96
- package/src/components/docs/component-preview.tsx +11 -11
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
- package/src/components/user-avatar/audio-wave.tsx +22 -0
- package/src/components/user-avatar/component.tsx +152 -0
- package/src/components/user-avatar/fallback.tsx +26 -0
- package/src/components/user-avatar/index.ts +34 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +85 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +144 -0
- package/src/components/user-avatar/user-avatar-atom.ts +198 -0
- package/src/components/user-avatar/utils.ts +8 -0
- package/src/index.css +211 -153
- package/src/index.ts +35 -2
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
|
@@ -12,7 +12,7 @@ export function canContain(item: SidebarResource) {
|
|
|
12
12
|
|
|
13
13
|
export function flattenResources(
|
|
14
14
|
items: SidebarResource[],
|
|
15
|
-
expanded:
|
|
15
|
+
expanded: ReadonlySet<string>,
|
|
16
16
|
depth = 0,
|
|
17
17
|
parentId: string | null = null
|
|
18
18
|
): FlatResource[] {
|
|
@@ -38,6 +38,34 @@ export function findResource(
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
/** Ids of every ancestor of `id` (nearest last); empty when absent. */
|
|
42
|
+
export function ancestorIdsOf(
|
|
43
|
+
items: SidebarResource[],
|
|
44
|
+
id: string
|
|
45
|
+
): string[] {
|
|
46
|
+
const walk = (nodes: SidebarResource[], trail: string[]): string[] | null => {
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
if (node.id === id) return trail
|
|
49
|
+
if (node.children) {
|
|
50
|
+
const found = walk(node.children, [...trail, node.id])
|
|
51
|
+
if (found) return found
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
return walk(items, []) ?? []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Ids of every row that can hold children and has some — the set
|
|
60
|
+
* `expandAll` opens. */
|
|
61
|
+
export function expandableIdsOf(items: SidebarResource[]): string[] {
|
|
62
|
+
return items.flatMap((item) =>
|
|
63
|
+
item.children?.length && canContain(item)
|
|
64
|
+
? [item.id, ...expandableIdsOf(item.children)]
|
|
65
|
+
: []
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
export function containsResource(item: SidebarResource, id: string): boolean {
|
|
42
70
|
return (
|
|
43
71
|
item.id === id ||
|
|
@@ -2,7 +2,12 @@ import { describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
|
|
5
|
+
import { act } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
import { AnimatedSidebarPanelContext } from "@/components/blocks/shell/utils";
|
|
5
8
|
import { ProfileCardBlock } from "../profile-card-block";
|
|
9
|
+
import { useProfileCardActions, useProfileCardState } from "../use-profile-card-state";
|
|
10
|
+
import type { ProfileCardActions, ProfileCardState } from "../type";
|
|
6
11
|
|
|
7
12
|
const USER = { name: "Jenny Hamilton", username: "@jennycodes" };
|
|
8
13
|
|
|
@@ -131,4 +136,193 @@ describe("ProfileCardBlock", () => {
|
|
|
131
136
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
132
137
|
expect(onError).toHaveBeenCalled();
|
|
133
138
|
});
|
|
139
|
+
test("variant=collapsed folds to the avatar but keeps the accessible name", () => {
|
|
140
|
+
const { rerender } = render(<ProfileCardBlock user={USER} variant="collapsed" />);
|
|
141
|
+
|
|
142
|
+
const trigger = screen.getByRole("button", {
|
|
143
|
+
name: "Jenny Hamilton @jennycodes, account menu",
|
|
144
|
+
});
|
|
145
|
+
expect(trigger.hasAttribute("data-collapsed")).toBe(true);
|
|
146
|
+
// Hover names the tile the way the rail's menu buttons do.
|
|
147
|
+
expect(trigger.getAttribute("title")).toBe("Jenny Hamilton @jennycodes");
|
|
148
|
+
// The avatar stays; the identity lines fold (hidden, not removed — that
|
|
149
|
+
// is what lets the fold animate back open).
|
|
150
|
+
expect(screen.getByText("JH")).toBeDefined();
|
|
151
|
+
const identity = trigger.querySelector('[data-slot="profile-card-identity"]');
|
|
152
|
+
expect(identity?.getAttribute("aria-hidden")).toBe("true");
|
|
153
|
+
expect(identity?.textContent).toContain("Jenny Hamilton");
|
|
154
|
+
// 40px tile, like the tree rail: no inset, avatar centred.
|
|
155
|
+
expect(trigger.className).toContain("justify-center");
|
|
156
|
+
expect(trigger.className).toContain("size-10");
|
|
157
|
+
expect(trigger.className).toContain("p-0");
|
|
158
|
+
|
|
159
|
+
rerender(<ProfileCardBlock user={USER} variant="expanded" />);
|
|
160
|
+
expect(trigger.hasAttribute("data-collapsed")).toBe(false);
|
|
161
|
+
expect(trigger.getAttribute("title")).toBeNull();
|
|
162
|
+
expect(identity?.getAttribute("aria-hidden")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("variant=collapsed still opens the menu", async () => {
|
|
166
|
+
const user = userEvent.setup();
|
|
167
|
+
render(<ProfileCardBlock user={USER} variant="collapsed" />);
|
|
168
|
+
await user.click(screen.getByRole("button"));
|
|
169
|
+
expect(await screen.findByRole("menuitem", { name: "Log out" })).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("follows the enclosing AnimatedPanel unless variant is set", () => {
|
|
173
|
+
const panel = { collapsed: true, collapsible: "icon", side: "left" } as const;
|
|
174
|
+
const { rerender } = render(
|
|
175
|
+
<AnimatedSidebarPanelContext.Provider value={panel}>
|
|
176
|
+
<ProfileCardBlock user={USER} />
|
|
177
|
+
</AnimatedSidebarPanelContext.Provider>,
|
|
178
|
+
);
|
|
179
|
+
const trigger = screen.getByRole("button");
|
|
180
|
+
expect(trigger.hasAttribute("data-collapsed")).toBe(true);
|
|
181
|
+
|
|
182
|
+
// An explicit prop wins over the panel.
|
|
183
|
+
rerender(
|
|
184
|
+
<AnimatedSidebarPanelContext.Provider value={panel}>
|
|
185
|
+
<ProfileCardBlock user={USER} variant="expanded" />
|
|
186
|
+
</AnimatedSidebarPanelContext.Provider>,
|
|
187
|
+
);
|
|
188
|
+
expect(trigger.hasAttribute("data-collapsed")).toBe(false);
|
|
189
|
+
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("outside any panel an unnamed card starts expanded", () => {
|
|
193
|
+
render(<ProfileCardBlock user={USER} />);
|
|
194
|
+
expect(screen.getByRole("button").hasAttribute("data-collapsed")).toBe(false);
|
|
195
|
+
expect(screen.getByRole("button").getAttribute("data-variant")).toBe("expanded");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("defaultVariant seeds an uncontrolled card", () => {
|
|
199
|
+
render(<ProfileCardBlock defaultVariant="collapsed" user={USER} />);
|
|
200
|
+
expect(screen.getByRole("button").getAttribute("data-variant")).toBe("collapsed");
|
|
201
|
+
});
|
|
202
|
+
test("data-variant mirrors the fold and onVariantChange reports store-driven changes", () => {
|
|
203
|
+
const onVariantChange = mock(() => {});
|
|
204
|
+
let actions!: ProfileCardActions;
|
|
205
|
+
let state!: ProfileCardState;
|
|
206
|
+
function Probe() {
|
|
207
|
+
actions = useProfileCardActions("account");
|
|
208
|
+
state = useProfileCardState("account");
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
render(
|
|
212
|
+
<>
|
|
213
|
+
<ProfileCardBlock
|
|
214
|
+
onVariantChange={onVariantChange}
|
|
215
|
+
profileCardId="account"
|
|
216
|
+
user={USER}
|
|
217
|
+
/>
|
|
218
|
+
<Probe />
|
|
219
|
+
</>,
|
|
220
|
+
);
|
|
221
|
+
const trigger = screen.getByRole("button");
|
|
222
|
+
expect(trigger.getAttribute("data-variant")).toBe("expanded");
|
|
223
|
+
expect(state.variant).toBe("expanded");
|
|
224
|
+
|
|
225
|
+
// Driven from outside by id — no controller, no ref.
|
|
226
|
+
act(() => actions.collapse());
|
|
227
|
+
expect(trigger.getAttribute("data-variant")).toBe("collapsed");
|
|
228
|
+
expect(state.variant).toBe("collapsed");
|
|
229
|
+
expect(onVariantChange).toHaveBeenCalledWith("collapsed");
|
|
230
|
+
|
|
231
|
+
act(() => actions.toggleVariant());
|
|
232
|
+
expect(trigger.getAttribute("data-variant")).toBe("expanded");
|
|
233
|
+
expect(onVariantChange).toHaveBeenLastCalledWith("expanded");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a controlled variant is never overwritten by the store", () => {
|
|
237
|
+
let actions!: ProfileCardActions;
|
|
238
|
+
function Probe() {
|
|
239
|
+
actions = useProfileCardActions("locked");
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const onVariantChange = mock(() => {});
|
|
243
|
+
render(
|
|
244
|
+
<>
|
|
245
|
+
<ProfileCardBlock
|
|
246
|
+
onVariantChange={onVariantChange}
|
|
247
|
+
profileCardId="locked"
|
|
248
|
+
user={USER}
|
|
249
|
+
variant="expanded"
|
|
250
|
+
/>
|
|
251
|
+
<Probe />
|
|
252
|
+
</>,
|
|
253
|
+
);
|
|
254
|
+
act(() => actions.collapse());
|
|
255
|
+
// Reported, not applied — the prop is truth.
|
|
256
|
+
expect(onVariantChange).toHaveBeenCalledWith("collapsed");
|
|
257
|
+
expect(screen.getByRole("button").getAttribute("data-variant")).toBe("expanded");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("openMenu / closeMenu from outside drive the menu through the store", async () => {
|
|
261
|
+
let actions!: ProfileCardActions;
|
|
262
|
+
function Probe() {
|
|
263
|
+
actions = useProfileCardActions("menu");
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
render(
|
|
267
|
+
<>
|
|
268
|
+
<ProfileCardBlock profileCardId="menu" user={USER} />
|
|
269
|
+
<Probe />
|
|
270
|
+
</>,
|
|
271
|
+
);
|
|
272
|
+
act(() => actions.openMenu());
|
|
273
|
+
expect(await screen.findByRole("menuitem", { name: "Profile" })).toBeDefined();
|
|
274
|
+
act(() => actions.closeMenu());
|
|
275
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
276
|
+
expect(screen.queryByRole("menuitem", { name: "Profile" })).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("busy tracks a pending action in the store", async () => {
|
|
280
|
+
const user = userEvent.setup();
|
|
281
|
+
let resolve!: () => void;
|
|
282
|
+
let state!: ProfileCardState;
|
|
283
|
+
function Probe() {
|
|
284
|
+
state = useProfileCardState("busy");
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
render(
|
|
288
|
+
<>
|
|
289
|
+
<ProfileCardBlock
|
|
290
|
+
items={[
|
|
291
|
+
{
|
|
292
|
+
id: "slow",
|
|
293
|
+
label: "Slow",
|
|
294
|
+
onSelect: () => new Promise<void>((r) => (resolve = r)),
|
|
295
|
+
},
|
|
296
|
+
]}
|
|
297
|
+
profileCardId="busy"
|
|
298
|
+
user={USER}
|
|
299
|
+
/>
|
|
300
|
+
<Probe />
|
|
301
|
+
</>,
|
|
302
|
+
);
|
|
303
|
+
await user.click(screen.getByRole("button"));
|
|
304
|
+
await user.click(await screen.findByRole("menuitem", { name: "Slow" }));
|
|
305
|
+
expect(state.busy).toBe(true);
|
|
306
|
+
expect(state.pendingActionId).toBe("slow");
|
|
307
|
+
await act(async () => {
|
|
308
|
+
resolve();
|
|
309
|
+
await Promise.resolve();
|
|
310
|
+
});
|
|
311
|
+
expect(state.busy).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("user.presence and the presence prop reach the avatar badge", () => {
|
|
315
|
+
const { rerender } = render(
|
|
316
|
+
<ProfileCardBlock user={{ ...USER, presence: "online" }} />,
|
|
317
|
+
);
|
|
318
|
+
const badge = () =>
|
|
319
|
+
document.querySelector('[data-slot="user-avatar-presence"]');
|
|
320
|
+
expect(badge()?.getAttribute("data-presence")).toBe("online");
|
|
321
|
+
rerender(
|
|
322
|
+
<ProfileCardBlock presence="offline" user={{ ...USER, presence: "online" }} />,
|
|
323
|
+
);
|
|
324
|
+
expect(badge()?.getAttribute("data-presence")).toBe("offline");
|
|
325
|
+
rerender(<ProfileCardBlock user={USER} />);
|
|
326
|
+
expect(badge()).toBeNull();
|
|
327
|
+
});
|
|
134
328
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ProfileCardBlock,
|
|
3
|
+
defaultProfileCardItems,
|
|
4
|
+
} from "./profile-card-block"
|
|
5
|
+
export type { ProfileCardBlockProps } from "./profile-card-block"
|
|
6
|
+
export { useProfileCard } from "./use-profile-card"
|
|
7
|
+
export type { ProfileCardBinding, UseProfileCardOptions } from "./use-profile-card"
|
|
8
|
+
export {
|
|
9
|
+
useProfileCardActions,
|
|
10
|
+
useProfileCardState,
|
|
11
|
+
} from "./use-profile-card-state"
|
|
12
|
+
// The public atom surface. `@internal` atoms (config, handlers, projections,
|
|
13
|
+
// the mount seed) stay unexported on purpose.
|
|
14
|
+
export {
|
|
15
|
+
profileCardBusyAtom,
|
|
16
|
+
profileCardCollapsedAtom,
|
|
17
|
+
profileCardMenuOpenAtom,
|
|
18
|
+
profileCardPendingActionIdAtom,
|
|
19
|
+
profileCardStateAtom,
|
|
20
|
+
profileCardVariantAtom,
|
|
21
|
+
removeProfileCardInstance,
|
|
22
|
+
resetProfileCardAtom,
|
|
23
|
+
selectProfileCardActionAtom,
|
|
24
|
+
setProfileCardMenuOpenAtom,
|
|
25
|
+
setProfileCardVariantAtom,
|
|
26
|
+
toggleProfileCardVariantAtom,
|
|
27
|
+
} from "./profile-card-atom"
|
|
28
|
+
export type * from "./type"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile card state — Jotai atom families keyed by a card id.
|
|
3
|
+
*
|
|
4
|
+
* Same layout as the tree (`components/composed/tree/tree-atom.ts`) and the
|
|
5
|
+
* avatar (`components/user-avatar/user-avatar-atom.ts`): in-house `keyed()`
|
|
6
|
+
* families per field, write-only action atoms, a composed read-only state
|
|
7
|
+
* atom, and `removeProfileCardInstance` to drop an id from every family.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { atom } from "jotai"
|
|
11
|
+
import type { Getter, Setter } from "jotai"
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ProfileCardAction,
|
|
15
|
+
ProfileCardConfig,
|
|
16
|
+
ProfileCardHandlers,
|
|
17
|
+
ProfileCardInstanceId,
|
|
18
|
+
ProfileCardState,
|
|
19
|
+
ProfileCardVariant,
|
|
20
|
+
} from "./type"
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONFIG: ProfileCardConfig = {
|
|
23
|
+
controlsVariant: false,
|
|
24
|
+
controlsMenuOpen: false,
|
|
25
|
+
}
|
|
26
|
+
const NO_HANDLERS: ProfileCardHandlers = {}
|
|
27
|
+
|
|
28
|
+
// ── families ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
type Family<AtomType> = ((id: ProfileCardInstanceId) => AtomType) & {
|
|
31
|
+
remove: (id: ProfileCardInstanceId) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const families: { remove: (id: ProfileCardInstanceId) => void }[] = []
|
|
35
|
+
|
|
36
|
+
function keyed<AtomType>(create: (id: ProfileCardInstanceId) => AtomType) {
|
|
37
|
+
const cache = new Map<ProfileCardInstanceId, AtomType>()
|
|
38
|
+
const family = ((id: ProfileCardInstanceId) => {
|
|
39
|
+
let instance = cache.get(id)
|
|
40
|
+
if (instance === undefined) {
|
|
41
|
+
instance = create(id)
|
|
42
|
+
cache.set(id, instance)
|
|
43
|
+
}
|
|
44
|
+
return instance
|
|
45
|
+
}) as Family<AtomType>
|
|
46
|
+
family.remove = (id: ProfileCardInstanceId) => {
|
|
47
|
+
cache.delete(id)
|
|
48
|
+
}
|
|
49
|
+
families.push(family)
|
|
50
|
+
return family
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stateFamily<Value>(name: string, initialValue: Value) {
|
|
54
|
+
return keyed((id) => {
|
|
55
|
+
const instance = atom(initialValue)
|
|
56
|
+
instance.debugLabel = `profileCard/${id}/${name}`
|
|
57
|
+
return instance
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readFamily<Value>(
|
|
62
|
+
name: string,
|
|
63
|
+
read: (get: Getter, id: ProfileCardInstanceId) => Value
|
|
64
|
+
) {
|
|
65
|
+
return keyed((id) => {
|
|
66
|
+
const instance = atom((get) => read(get, id))
|
|
67
|
+
instance.debugLabel = `profileCard/${id}/${name}`
|
|
68
|
+
return instance
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function actionFamily<Args extends unknown[]>(
|
|
73
|
+
name: string,
|
|
74
|
+
write: (
|
|
75
|
+
get: Getter,
|
|
76
|
+
set: Setter,
|
|
77
|
+
id: ProfileCardInstanceId,
|
|
78
|
+
...args: Args
|
|
79
|
+
) => void
|
|
80
|
+
) {
|
|
81
|
+
return keyed((id) => {
|
|
82
|
+
const instance = atom(null, (get, set, ...args: Args) =>
|
|
83
|
+
write(get, set, id, ...args)
|
|
84
|
+
)
|
|
85
|
+
instance.debugLabel = `profileCard/${id}/${name}`
|
|
86
|
+
return instance
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── primitives ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export const profileCardVariantAtom = stateFamily<ProfileCardVariant>(
|
|
93
|
+
"variant",
|
|
94
|
+
"expanded"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
export const profileCardMenuOpenAtom = stateFamily<boolean>("menuOpen", false)
|
|
98
|
+
|
|
99
|
+
export const profileCardPendingActionIdAtom = stateFamily<string | null>(
|
|
100
|
+
"pendingActionId",
|
|
101
|
+
null
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** @internal */
|
|
105
|
+
export const profileCardConfigAtom = stateFamily<ProfileCardConfig>(
|
|
106
|
+
"config",
|
|
107
|
+
DEFAULT_CONFIG
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/** @internal */
|
|
111
|
+
export const profileCardHandlersAtom = stateFamily<ProfileCardHandlers>(
|
|
112
|
+
"handlers",
|
|
113
|
+
NO_HANDLERS
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// ── derived ────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export const profileCardCollapsedAtom = readFamily(
|
|
119
|
+
"collapsed",
|
|
120
|
+
(get, id) => get(profileCardVariantAtom(id)) === "collapsed"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
export const profileCardBusyAtom = readFamily(
|
|
124
|
+
"busy",
|
|
125
|
+
(get, id) => get(profileCardPendingActionIdAtom(id)) !== null
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/** Whole-state read for callers that want the object; the field atoms are
|
|
129
|
+
* cheaper for components that watch one thing. */
|
|
130
|
+
export const profileCardStateAtom = readFamily<ProfileCardState>(
|
|
131
|
+
"state",
|
|
132
|
+
(get, id) => ({
|
|
133
|
+
variant: get(profileCardVariantAtom(id)),
|
|
134
|
+
menuOpen: get(profileCardMenuOpenAtom(id)),
|
|
135
|
+
pendingActionId: get(profileCardPendingActionIdAtom(id)),
|
|
136
|
+
busy: get(profileCardBusyAtom(id)),
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// ── actions: variant ───────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** Reports through `onVariantChange` always; writes the store only when no
|
|
143
|
+
* `variant` prop controls the card — the prop is truth then. */
|
|
144
|
+
export const setProfileCardVariantAtom = actionFamily<
|
|
145
|
+
[variant: ProfileCardVariant]
|
|
146
|
+
>("setVariant", (get, set, id, variant) => {
|
|
147
|
+
if (get(profileCardVariantAtom(id)) === variant) return
|
|
148
|
+
if (!get(profileCardConfigAtom(id)).controlsVariant)
|
|
149
|
+
set(profileCardVariantAtom(id), variant)
|
|
150
|
+
get(profileCardHandlersAtom(id)).onVariantChange?.(variant)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
export const toggleProfileCardVariantAtom = actionFamily<[]>(
|
|
154
|
+
"toggleVariant",
|
|
155
|
+
(get, set, id) => {
|
|
156
|
+
set(
|
|
157
|
+
setProfileCardVariantAtom(id),
|
|
158
|
+
get(profileCardVariantAtom(id)) === "collapsed" ? "expanded" : "collapsed"
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// ── actions: menu ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Inert while an `open` prop controls the menu. */
|
|
166
|
+
export const setProfileCardMenuOpenAtom = actionFamily<[open: boolean]>(
|
|
167
|
+
"setMenuOpen",
|
|
168
|
+
(get, set, id, open) => {
|
|
169
|
+
if (get(profileCardConfigAtom(id)).controlsMenuOpen) return
|
|
170
|
+
set(profileCardMenuOpenAtom(id), open)
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// ── actions: selection ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run an action's `onSelect`. A returned promise marks the card busy until it
|
|
178
|
+
* settles; a rejection goes to `onError` and the card leaves its busy state
|
|
179
|
+
* either way. Because the pending id is a store value, it survives the card
|
|
180
|
+
* unmounting mid-flight (sign-out typically unmounts it) — the settle just
|
|
181
|
+
* writes to an instance nobody renders any more.
|
|
182
|
+
*/
|
|
183
|
+
export const selectProfileCardActionAtom = actionFamily<
|
|
184
|
+
[action: ProfileCardAction]
|
|
185
|
+
>("selectAction", (get, set, id, action) => {
|
|
186
|
+
const result = action.onSelect?.()
|
|
187
|
+
if (!(result instanceof Promise)) return
|
|
188
|
+
set(profileCardPendingActionIdAtom(id), action.id)
|
|
189
|
+
result
|
|
190
|
+
.catch((cause: unknown) => get(profileCardHandlersAtom(id)).onError?.(cause))
|
|
191
|
+
.finally(() => {
|
|
192
|
+
// Only clear our own flight — a later action may have taken over.
|
|
193
|
+
if (get(profileCardPendingActionIdAtom(id)) === action.id)
|
|
194
|
+
set(profileCardPendingActionIdAtom(id), null)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── internal projections ───────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/** @internal Projection of the mounted component's controlled props. */
|
|
201
|
+
export const projectProfileCardPropsAtom = actionFamily<
|
|
202
|
+
[
|
|
203
|
+
config: ProfileCardConfig,
|
|
204
|
+
variant: ProfileCardVariant | undefined,
|
|
205
|
+
menuOpen: boolean | undefined,
|
|
206
|
+
]
|
|
207
|
+
>("projectProps", (_get, set, id, config, variant, menuOpen) => {
|
|
208
|
+
set(profileCardConfigAtom(id), config)
|
|
209
|
+
if (config.controlsVariant && variant !== undefined)
|
|
210
|
+
set(profileCardVariantAtom(id), variant)
|
|
211
|
+
if (config.controlsMenuOpen && menuOpen !== undefined)
|
|
212
|
+
set(profileCardMenuOpenAtom(id), menuOpen)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
/** @internal */
|
|
216
|
+
export const setProfileCardHandlersAtom = actionFamily<
|
|
217
|
+
[handlers: ProfileCardHandlers]
|
|
218
|
+
>("setHandlers", (_get, set, id, handlers) => {
|
|
219
|
+
set(profileCardHandlersAtom(id), handlers)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
/** @internal Seed the uncontrolled variant on mount without firing
|
|
223
|
+
* `onVariantChange`. */
|
|
224
|
+
export const seedProfileCardVariantAtom = actionFamily<
|
|
225
|
+
[variant: ProfileCardVariant]
|
|
226
|
+
>("seedVariant", (_get, set, id, variant) => {
|
|
227
|
+
set(profileCardVariantAtom(id), variant)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// ── reset ──────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export const resetProfileCardAtom = actionFamily<[]>(
|
|
233
|
+
"reset",
|
|
234
|
+
(_get, set, id) => {
|
|
235
|
+
set(profileCardVariantAtom(id), "expanded")
|
|
236
|
+
set(profileCardMenuOpenAtom(id), false)
|
|
237
|
+
set(profileCardPendingActionIdAtom(id), null)
|
|
238
|
+
set(profileCardConfigAtom(id), DEFAULT_CONFIG)
|
|
239
|
+
set(profileCardHandlersAtom(id), NO_HANDLERS)
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
/** Forget a card entirely — every family drops the key so the store can
|
|
244
|
+
* release its state. Call on teardown of a named instance. */
|
|
245
|
+
export function removeProfileCardInstance(id: ProfileCardInstanceId): void {
|
|
246
|
+
for (const family of families) family.remove(id)
|
|
247
|
+
}
|