@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { TreeNode } from "./type"
|
|
2
|
+
|
|
3
|
+
/** Depth-0 nodes read as sections only when the data actually nests three
|
|
4
|
+
* levels — a two-level tree keeps its top level as plain link rows. */
|
|
5
|
+
export function hasThreeLevels(items: readonly TreeNode[]): boolean {
|
|
6
|
+
return items.some((node) =>
|
|
7
|
+
node.children?.some((child) => (child.children?.length ?? 0) > 0)
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Ids of every ancestor of `id` (nearest last); empty when absent. */
|
|
12
|
+
export function ancestorIdsOf(
|
|
13
|
+
items: readonly TreeNode[],
|
|
14
|
+
id: string
|
|
15
|
+
): string[] {
|
|
16
|
+
const walk = (nodes: readonly TreeNode[], trail: string[]): string[] | null => {
|
|
17
|
+
for (const node of nodes) {
|
|
18
|
+
if (node.id === id) return trail
|
|
19
|
+
if (node.children) {
|
|
20
|
+
const found = walk(node.children, [...trail, node.id])
|
|
21
|
+
if (found) return found
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
return walk(items, []) ?? []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ids that start expanded: `defaultOpen` nodes plus every ancestor of the
|
|
30
|
+
* active entry, so the current location is never folded away. */
|
|
31
|
+
export function initialExpandedIds(
|
|
32
|
+
items: readonly TreeNode[],
|
|
33
|
+
activeId?: string
|
|
34
|
+
): Set<string> {
|
|
35
|
+
const expanded = new Set<string>()
|
|
36
|
+
const walk = (nodes: readonly TreeNode[]) => {
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
if (node.defaultOpen) expanded.add(node.id)
|
|
39
|
+
if (node.children) walk(node.children)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(items)
|
|
43
|
+
if (activeId !== undefined)
|
|
44
|
+
for (const id of ancestorIdsOf(items, activeId)) expanded.add(id)
|
|
45
|
+
return expanded
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ids of every node that can hold children — the set `expandAll` opens.
|
|
49
|
+
* In `files` an empty `children: []` still counts (it is a folder). */
|
|
50
|
+
export function expandableIdsOf(
|
|
51
|
+
items: readonly TreeNode[],
|
|
52
|
+
filesVariant = false
|
|
53
|
+
): string[] {
|
|
54
|
+
const ids: string[] = []
|
|
55
|
+
const walk = (nodes: readonly TreeNode[]) => {
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
const branch = filesVariant
|
|
58
|
+
? node.children !== undefined
|
|
59
|
+
: (node.children?.length ?? 0) > 0
|
|
60
|
+
if (branch) ids.push(node.id)
|
|
61
|
+
if (node.children) walk(node.children)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
walk(items)
|
|
65
|
+
return ids
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Immutably relabel `id`. Returns the same array reference when absent. */
|
|
69
|
+
export function renameNode(
|
|
70
|
+
items: readonly TreeNode[],
|
|
71
|
+
id: string,
|
|
72
|
+
label: string
|
|
73
|
+
): TreeNode[] {
|
|
74
|
+
return items.map((node) => {
|
|
75
|
+
if (node.id === id) return { ...node, label }
|
|
76
|
+
return node.children
|
|
77
|
+
? { ...node, children: renameNode(node.children, id, label) }
|
|
78
|
+
: node
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether `candidateId` is `id` itself or nests anywhere under it — the
|
|
83
|
+
* guard that keeps a folder from being dropped into its own subtree. */
|
|
84
|
+
export function containsNode(
|
|
85
|
+
items: readonly TreeNode[],
|
|
86
|
+
id: string,
|
|
87
|
+
candidateId: string
|
|
88
|
+
): boolean {
|
|
89
|
+
if (id === candidateId) return true
|
|
90
|
+
const root = findNode(items, id)
|
|
91
|
+
if (!root?.children) return false
|
|
92
|
+
return findNode(root.children, candidateId) !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function findNode(
|
|
96
|
+
items: readonly TreeNode[],
|
|
97
|
+
id: string
|
|
98
|
+
): TreeNode | null {
|
|
99
|
+
for (const node of items) {
|
|
100
|
+
if (node.id === id) return node
|
|
101
|
+
if (node.children) {
|
|
102
|
+
const found = findNode(node.children, id)
|
|
103
|
+
if (found) return found
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parent id (`null` at root) and index of `id` among its siblings. */
|
|
110
|
+
export function locateNode(
|
|
111
|
+
items: readonly TreeNode[],
|
|
112
|
+
id: string
|
|
113
|
+
): { parentId: string | null; index: number } | null {
|
|
114
|
+
const walk = (
|
|
115
|
+
nodes: readonly TreeNode[],
|
|
116
|
+
parentId: string | null
|
|
117
|
+
): { parentId: string | null; index: number } | null => {
|
|
118
|
+
for (const [index, node] of nodes.entries()) {
|
|
119
|
+
if (node.id === id) return { parentId, index }
|
|
120
|
+
if (node.children) {
|
|
121
|
+
const found = walk(node.children, node.id)
|
|
122
|
+
if (found) return found
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return walk(items, null)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Immutably lift `id` out of the tree and reinsert it under `parentId`
|
|
131
|
+
* (`null` = root) at `index` — the standard `onMove` reducer, exported so
|
|
132
|
+
* consumers don't each rebuild it. `index` addresses the sibling list
|
|
133
|
+
* *after* the node has been lifted out. */
|
|
134
|
+
export function moveNode(
|
|
135
|
+
items: readonly TreeNode[],
|
|
136
|
+
id: string,
|
|
137
|
+
parentId: string | null,
|
|
138
|
+
index: number
|
|
139
|
+
): TreeNode[] {
|
|
140
|
+
// Dropping into the node's own subtree would orphan it — refuse.
|
|
141
|
+
if (parentId !== null && containsNode(items, id, parentId)) return [...items]
|
|
142
|
+
let moved: TreeNode | null = null
|
|
143
|
+
const strip = (nodes: readonly TreeNode[]): TreeNode[] =>
|
|
144
|
+
nodes.flatMap((node) => {
|
|
145
|
+
if (node.id === id) {
|
|
146
|
+
moved = node
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
return node.children
|
|
150
|
+
? [{ ...node, children: strip(node.children) }]
|
|
151
|
+
: [node]
|
|
152
|
+
})
|
|
153
|
+
const stripped = strip(items)
|
|
154
|
+
if (!moved) return [...items]
|
|
155
|
+
const captured = moved as TreeNode
|
|
156
|
+
|
|
157
|
+
if (parentId === null) {
|
|
158
|
+
const next = [...stripped]
|
|
159
|
+
next.splice(Math.min(index, next.length), 0, captured)
|
|
160
|
+
return next
|
|
161
|
+
}
|
|
162
|
+
const insert = (nodes: readonly TreeNode[]): TreeNode[] =>
|
|
163
|
+
nodes.map((node) => {
|
|
164
|
+
if (node.id === parentId) {
|
|
165
|
+
const children = [...(node.children ?? [])]
|
|
166
|
+
children.splice(Math.min(index, children.length), 0, captured)
|
|
167
|
+
return { ...node, children }
|
|
168
|
+
}
|
|
169
|
+
return node.children ? { ...node, children: insert(node.children) } : node
|
|
170
|
+
})
|
|
171
|
+
return insert(stripped)
|
|
172
|
+
}
|
|
@@ -1,98 +1,13 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
Avatar,
|
|
7
|
-
AvatarFallback,
|
|
8
|
-
AvatarImage,
|
|
9
|
-
type AvatarPrimitive,
|
|
10
|
-
} from "@/components/ui/avatar"
|
|
11
|
-
import { Skeleton } from "@/components/ui/skeleton"
|
|
12
|
-
import { cn } from "@/lib/utils"
|
|
13
|
-
|
|
14
|
-
type ImageStatus = Parameters<
|
|
15
|
-
NonNullable<AvatarPrimitive.Image.Props["onLoadingStatusChange"]>
|
|
16
|
-
>[0]
|
|
17
|
-
|
|
18
|
-
export interface UserAvatarProps extends Omit<
|
|
19
|
-
AvatarPrimitive.Root.Props,
|
|
20
|
-
"children"
|
|
21
|
-
> {
|
|
22
|
-
/** Image URL. Without one the initials show immediately — no skeleton. */
|
|
23
|
-
src?: string
|
|
24
|
-
/** Drives the initials, and the alt text unless `alt` overrides it. */
|
|
25
|
-
name?: string
|
|
26
|
-
/** Overrides the initials derived from `name`. */
|
|
27
|
-
initials?: string
|
|
28
|
-
/**
|
|
29
|
-
* Alt text for the image. Pass "" when adjacent text already names the
|
|
30
|
-
* person — the image is then decorative.
|
|
31
|
-
*/
|
|
32
|
-
alt?: string
|
|
33
|
-
/**
|
|
34
|
-
* Force the skeleton, for when the identity itself is still being fetched.
|
|
35
|
-
* Omitted, it follows the image: a passed `src` skeletons until it resolves.
|
|
36
|
-
*/
|
|
37
|
-
loading?: boolean
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** "Jenny Hamilton" → "JH", "luna" → "L". */
|
|
41
|
-
export function initialsFrom(name: string): string {
|
|
42
|
-
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
43
|
-
if (parts.length === 0) return ""
|
|
44
|
-
const first = parts[0]?.[0] ?? ""
|
|
45
|
-
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : ""
|
|
46
|
-
return (first + last).toUpperCase()
|
|
47
|
-
}
|
|
48
|
-
|
|
49
1
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* the image resolves rather than flashing initials that are about to be
|
|
54
|
-
* replaced. A failed load settles on the initials.
|
|
2
|
+
* Back-compat path. The avatar moved to `components/user-avatar/` (split
|
|
3
|
+
* into component / atoms / hooks); this module re-exports it so existing
|
|
4
|
+
* imports keep resolving to the one implementation. Prefer the new path.
|
|
55
5
|
*/
|
|
56
|
-
export
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}: UserAvatarProps): React.ReactElement {
|
|
65
|
-
const [status, setStatus] = React.useState<ImageStatus>("idle")
|
|
66
|
-
const loading =
|
|
67
|
-
loadingProp ??
|
|
68
|
-
(src !== undefined && (status === "idle" || status === "loading"))
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<Avatar
|
|
72
|
-
className={cn("size-8", className)}
|
|
73
|
-
data-loading={loading || undefined}
|
|
74
|
-
data-slot="user-avatar"
|
|
75
|
-
{...props}
|
|
76
|
-
>
|
|
77
|
-
{/* Held back while forced-loading: a loaded image would hide the
|
|
78
|
-
fallback the skeleton lives in. */}
|
|
79
|
-
{src === undefined || loadingProp === true ? null : (
|
|
80
|
-
<AvatarImage
|
|
81
|
-
alt={alt ?? name}
|
|
82
|
-
onLoadingStatusChange={setStatus}
|
|
83
|
-
src={src}
|
|
84
|
-
/>
|
|
85
|
-
)}
|
|
86
|
-
<AvatarFallback>
|
|
87
|
-
{loading ? (
|
|
88
|
-
<Skeleton
|
|
89
|
-
className="size-full rounded-full"
|
|
90
|
-
data-slot="avatar-skeleton"
|
|
91
|
-
/>
|
|
92
|
-
) : (
|
|
93
|
-
(initials ?? initialsFrom(name))
|
|
94
|
-
)}
|
|
95
|
-
</AvatarFallback>
|
|
96
|
-
</Avatar>
|
|
97
|
-
)
|
|
98
|
-
}
|
|
6
|
+
export {
|
|
7
|
+
UserAvatar,
|
|
8
|
+
initialsFrom,
|
|
9
|
+
useUserAvatar,
|
|
10
|
+
useUserAvatarActions,
|
|
11
|
+
useUserAvatarState,
|
|
12
|
+
} from "@/components/user-avatar"
|
|
13
|
+
export type { UserAvatarProps, UserPresence } from "@/components/user-avatar"
|
|
@@ -12,18 +12,18 @@ export function ComponentPreview({ entry }: { entry: RegistryEntry }) {
|
|
|
12
12
|
const Demo = entry.preview
|
|
13
13
|
|
|
14
14
|
return (
|
|
15
|
-
<BrowserFrame title={entry.name} titleStyle="hidden">
|
|
15
|
+
<BrowserFrame title={entry.name} titleStyle="hidden" className="min-h-60">
|
|
16
16
|
{Demo ? (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
<React.Suspense
|
|
18
|
+
fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
|
|
19
|
+
>
|
|
20
|
+
<Demo />
|
|
21
|
+
</React.Suspense>
|
|
22
|
+
) : (
|
|
23
|
+
<p className="text-sm text-muted-foreground">
|
|
24
|
+
Preview coming soon ({entry.status}).
|
|
25
|
+
</p>
|
|
26
|
+
)}
|
|
27
27
|
</BrowserFrame>
|
|
28
28
|
)
|
|
29
29
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { act, fireEvent, render, screen } from "@testing-library/react"
|
|
3
|
+
import { createStore, Provider } from "jotai"
|
|
4
|
+
|
|
5
|
+
import { UserAvatar } from "../component"
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_BEZEL_ANGLE,
|
|
8
|
+
removeUserAvatarInstance,
|
|
9
|
+
resetUserAvatarAtom,
|
|
10
|
+
setUserAvatarBezelAngleAtom,
|
|
11
|
+
setUserAvatarPresenceAtom,
|
|
12
|
+
toggleUserAvatarPresenceAtom,
|
|
13
|
+
userAvatarBezelAngleAtom,
|
|
14
|
+
userAvatarIsOnlineAtom,
|
|
15
|
+
userAvatarPresenceAtom,
|
|
16
|
+
userAvatarStateAtom,
|
|
17
|
+
} from "../user-avatar-atom"
|
|
18
|
+
import { useUserAvatarActions, useUserAvatarState } from "../use-user-avatar-state"
|
|
19
|
+
import type { UserAvatarActions, UserAvatarState } from "../type"
|
|
20
|
+
|
|
21
|
+
const badge = () => document.querySelector('[data-slot="user-avatar-presence"]')
|
|
22
|
+
const bezel = () => document.querySelector('[data-slot="user-avatar-bezel"]')
|
|
23
|
+
const frame = () =>
|
|
24
|
+
document.querySelector<HTMLElement>('[data-slot="user-avatar-frame"]')
|
|
25
|
+
/** The rim's rotation, parsed. The component damps the stored bearing
|
|
26
|
+
* (`bezelAngle * 0.8`) so the highlight trails the pointer slightly. */
|
|
27
|
+
const BEZEL_DAMPING = 0.8
|
|
28
|
+
const rimRotation = () => {
|
|
29
|
+
const match = /rotate\((-?[\d.]+)deg\)/.exec(bezel()?.getAttribute("style") ?? "")
|
|
30
|
+
return match ? Number(match[1]) : Number.NaN
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("UserAvatar · presence badge", () => {
|
|
34
|
+
test("no presence, no badge", () => {
|
|
35
|
+
render(<UserAvatar name="Jenny Hamilton" />)
|
|
36
|
+
expect(badge()).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("online is a filled dot named for AT; offline a hollow ring", () => {
|
|
40
|
+
const { rerender } = render(
|
|
41
|
+
<UserAvatar name="Jenny Hamilton" presence="online" />
|
|
42
|
+
)
|
|
43
|
+
expect(badge()?.getAttribute("data-presence")).toBe("online")
|
|
44
|
+
expect(screen.getByRole("img", { name: "Online" })).toBeDefined()
|
|
45
|
+
expect(badge()?.className).toContain("bg-emerald-500")
|
|
46
|
+
|
|
47
|
+
rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
|
|
48
|
+
expect(screen.getByRole("img", { name: "Offline" })).toBeDefined()
|
|
49
|
+
expect(badge()?.className).toContain("border-2")
|
|
50
|
+
|
|
51
|
+
// Releasing the prop clears the badge — a real transition for a user.
|
|
52
|
+
rerender(<UserAvatar name="Jenny Hamilton" />)
|
|
53
|
+
expect(badge()).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("online reflects the badge colour onto the disc; offline does not", async () => {
|
|
57
|
+
const reflection = () =>
|
|
58
|
+
document.querySelector('[data-slot="user-avatar-reflection"]')
|
|
59
|
+
const { rerender } = render(
|
|
60
|
+
<UserAvatar name="Jenny Hamilton" presence="online" />
|
|
61
|
+
)
|
|
62
|
+
expect(reflection()).not.toBeNull()
|
|
63
|
+
// Static — no transform, unlike the bezel rim.
|
|
64
|
+
expect(reflection()?.getAttribute("style")).toBeNull()
|
|
65
|
+
expect(reflection()?.className).toContain("radial-gradient")
|
|
66
|
+
// Initials disc: the smaller, fainter pool.
|
|
67
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(false)
|
|
68
|
+
expect(reflection()?.className).toContain("[--reflect-peak:16%]")
|
|
69
|
+
// A requested-but-not-loaded src still shows initials — still the small
|
|
70
|
+
// pool; only a loaded photo gets the larger one.
|
|
71
|
+
// happy-dom's Image reports naturalWidth 0 (Base UI reads that as a
|
|
72
|
+
// failed preload) — a broken src keeps the initials and the small pool.
|
|
73
|
+
rerender(
|
|
74
|
+
<UserAvatar name="Jenny Hamilton" presence="online" src="/broken.jpg" />
|
|
75
|
+
)
|
|
76
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(false)
|
|
77
|
+
|
|
78
|
+
// Stand in a preload that succeeds: now a photo is on screen.
|
|
79
|
+
const RealImage = window.Image
|
|
80
|
+
window.Image = class {
|
|
81
|
+
complete = true
|
|
82
|
+
naturalWidth = 72
|
|
83
|
+
onload: (() => void) | null = null
|
|
84
|
+
onerror: (() => void) | null = null
|
|
85
|
+
src = ""
|
|
86
|
+
} as unknown as typeof window.Image
|
|
87
|
+
try {
|
|
88
|
+
rerender(
|
|
89
|
+
<UserAvatar name="Jenny Hamilton" presence="online" src="/ok.jpg" />
|
|
90
|
+
)
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
93
|
+
})
|
|
94
|
+
expect(reflection()?.hasAttribute("data-image")).toBe(true)
|
|
95
|
+
expect(reflection()?.className).toContain("[--reflect-peak:30%]")
|
|
96
|
+
} finally {
|
|
97
|
+
window.Image = RealImage
|
|
98
|
+
}
|
|
99
|
+
rerender(<UserAvatar name="Jenny Hamilton" presence="offline" />)
|
|
100
|
+
expect(reflection()).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("the badge carries a sheen lit from the bezel angle; none without a bezel", async () => {
|
|
104
|
+
const sheen = () =>
|
|
105
|
+
document.querySelector('[data-slot="user-avatar-presence-sheen"]')
|
|
106
|
+
const { rerender } = render(
|
|
107
|
+
<UserAvatar avatarId="sheen" name="Jenny Hamilton" presence="online" />
|
|
108
|
+
)
|
|
109
|
+
expect(sheen()).not.toBeNull()
|
|
110
|
+
const rot = () =>
|
|
111
|
+
Number(/rotate\((-?[\d.]+)deg\)/.exec(sheen()?.getAttribute("style") ?? "")?.[1])
|
|
112
|
+
expect(rot()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
|
|
113
|
+
|
|
114
|
+
// Same light as the rim: a pointer move rotates both together.
|
|
115
|
+
const host = frame() as HTMLElement
|
|
116
|
+
host.getBoundingClientRect = () =>
|
|
117
|
+
({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
|
|
118
|
+
await act(async () => {
|
|
119
|
+
fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
|
|
120
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
121
|
+
})
|
|
122
|
+
expect(rot()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
|
|
123
|
+
expect(rot()).toBeCloseTo(rimRotation(), 5)
|
|
124
|
+
|
|
125
|
+
rerender(<UserAvatar bezel={false} name="Jenny Hamilton" presence="online" />)
|
|
126
|
+
expect(sheen()).toBeNull()
|
|
127
|
+
removeUserAvatarInstance("sheen")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("the root carries data-presence for styling hooks", () => {
|
|
131
|
+
render(<UserAvatar name="Jenny Hamilton" presence="online" />)
|
|
132
|
+
expect(
|
|
133
|
+
document
|
|
134
|
+
.querySelector('[data-slot="user-avatar"]')
|
|
135
|
+
?.getAttribute("data-presence")
|
|
136
|
+
).toBe("online")
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("UserAvatar · bezel", () => {
|
|
141
|
+
test("on by default: an emboss shadow on the root and a rotated rim", () => {
|
|
142
|
+
render(<UserAvatar name="Jenny Hamilton" />)
|
|
143
|
+
const root = document.querySelector('[data-slot="user-avatar"]')
|
|
144
|
+
expect(root?.hasAttribute("data-bezel")).toBe(true)
|
|
145
|
+
expect(root?.className).toContain("shadow-[inset")
|
|
146
|
+
// Rests at the classic top-left light until a pointer moves.
|
|
147
|
+
expect(rimRotation()).toBeCloseTo(DEFAULT_BEZEL_ANGLE * BEZEL_DAMPING, 5)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("bezel={false} renders a flat disc", () => {
|
|
151
|
+
render(<UserAvatar bezel={false} name="Jenny Hamilton" />)
|
|
152
|
+
expect(bezel()).toBeNull()
|
|
153
|
+
expect(
|
|
154
|
+
document.querySelector('[data-slot="user-avatar"]')?.hasAttribute("data-bezel")
|
|
155
|
+
).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("the rim follows the pointer's bearing from the avatar's centre", async () => {
|
|
159
|
+
render(<UserAvatar avatarId="lit" name="Jenny Hamilton" />)
|
|
160
|
+
const host = frame() as HTMLElement
|
|
161
|
+
// happy-dom rects are 0×0; give the frame a box the hook can measure.
|
|
162
|
+
host.getBoundingClientRect = () =>
|
|
163
|
+
({ left: 100, top: 100, width: 40, height: 40, right: 140, bottom: 140 }) as DOMRect
|
|
164
|
+
|
|
165
|
+
// Pointer straight to the right of centre (120,120) → 3 o'clock → 90°.
|
|
166
|
+
await act(async () => {
|
|
167
|
+
fireEvent.pointerMove(window, { clientX: 200, clientY: 120 })
|
|
168
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
169
|
+
})
|
|
170
|
+
expect(rimRotation()).toBeCloseTo(90 * BEZEL_DAMPING, 5)
|
|
171
|
+
|
|
172
|
+
// Straight below → 6 o'clock → 180°.
|
|
173
|
+
await act(async () => {
|
|
174
|
+
fireEvent.pointerMove(window, { clientX: 120, clientY: 300 })
|
|
175
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
176
|
+
})
|
|
177
|
+
expect(rimRotation()).toBeCloseTo(180 * BEZEL_DAMPING, 5)
|
|
178
|
+
removeUserAvatarInstance("lit")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe("UserAvatar · store", () => {
|
|
183
|
+
test("presence, toggle, isOnline and reset on an isolated store", () => {
|
|
184
|
+
const store = createStore()
|
|
185
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBeNull()
|
|
186
|
+
store.set(setUserAvatarPresenceAtom("a"), "online")
|
|
187
|
+
expect(store.get(userAvatarIsOnlineAtom("a"))).toBe(true)
|
|
188
|
+
store.set(toggleUserAvatarPresenceAtom("a"))
|
|
189
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBe("offline")
|
|
190
|
+
// A badge-less avatar comes online on toggle.
|
|
191
|
+
store.set(setUserAvatarPresenceAtom("a"), null)
|
|
192
|
+
store.set(toggleUserAvatarPresenceAtom("a"))
|
|
193
|
+
expect(store.get(userAvatarPresenceAtom("a"))).toBe("online")
|
|
194
|
+
|
|
195
|
+
store.set(setUserAvatarBezelAngleAtom("a"), 400)
|
|
196
|
+
// Continuous — not wrapped — so a CSS rotation takes the short way.
|
|
197
|
+
expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
|
|
198
|
+
store.set(setUserAvatarBezelAngleAtom("a"), Number.NaN)
|
|
199
|
+
expect(store.get(userAvatarBezelAngleAtom("a"))).toBe(400)
|
|
200
|
+
|
|
201
|
+
store.set(resetUserAvatarAtom("a"))
|
|
202
|
+
expect(store.get(userAvatarStateAtom("a"))).toEqual({
|
|
203
|
+
presence: null,
|
|
204
|
+
bezelAngle: DEFAULT_BEZEL_ANGLE,
|
|
205
|
+
imageStatus: "idle",
|
|
206
|
+
})
|
|
207
|
+
removeUserAvatarInstance("a")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("a controlled presence prop wins over store writes; hooks by id read and drive", () => {
|
|
211
|
+
let actions!: UserAvatarActions
|
|
212
|
+
let state!: UserAvatarState
|
|
213
|
+
function Probe() {
|
|
214
|
+
actions = useUserAvatarActions("me")
|
|
215
|
+
state = useUserAvatarState("me")
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
const store = createStore()
|
|
219
|
+
const { rerender } = render(
|
|
220
|
+
<Provider store={store}>
|
|
221
|
+
<UserAvatar avatarId="me" name="Jenny Hamilton" presence="offline" />
|
|
222
|
+
<Probe />
|
|
223
|
+
</Provider>
|
|
224
|
+
)
|
|
225
|
+
expect(state.presence).toBe("offline")
|
|
226
|
+
act(() => actions.setPresence("online"))
|
|
227
|
+
// Controlled: the store write is inert, the badge stays offline.
|
|
228
|
+
expect(badge()?.getAttribute("data-presence")).toBe("offline")
|
|
229
|
+
|
|
230
|
+
// Uncontrolled: the store drives the badge from anywhere.
|
|
231
|
+
rerender(
|
|
232
|
+
<Provider store={store}>
|
|
233
|
+
<UserAvatar avatarId="me" name="Jenny Hamilton" />
|
|
234
|
+
<Probe />
|
|
235
|
+
</Provider>
|
|
236
|
+
)
|
|
237
|
+
expect(badge()).toBeNull()
|
|
238
|
+
act(() => actions.setPresence("online"))
|
|
239
|
+
expect(badge()?.getAttribute("data-presence")).toBe("online")
|
|
240
|
+
expect(state.presence).toBe("online")
|
|
241
|
+
act(() => actions.togglePresence())
|
|
242
|
+
expect(badge()?.getAttribute("data-presence")).toBe("offline")
|
|
243
|
+
removeUserAvatarInstance("me")
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils"
|
|
2
|
+
import type { IAudioWaveProps } from "./type"
|
|
3
|
+
|
|
4
|
+
// `volume` is accepted but not yet wired to the ring animation.
|
|
5
|
+
export function AudioWave({ className, volume: _volume, children }: IAudioWaveProps) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
className={cn(
|
|
9
|
+
"group/avatar relative flex items-center justify-center",
|
|
10
|
+
className
|
|
11
|
+
)}
|
|
12
|
+
>
|
|
13
|
+
{/* Animated Story Ring */}
|
|
14
|
+
<div className="absolute -inset-1 animate-[spin_3s_linear_infinite] rounded-full bg-linear-to-tr from-yellow-400 via-fuchsia-500 to-violet-600 opacity-75 blur-xs transition-all duration-500 group-hover/avatar:opacity-100 group-hover/avatar:blur-sm" />
|
|
15
|
+
|
|
16
|
+
{/* Main Avatar */}
|
|
17
|
+
<div className="size-10 ring-2 ring-background transition-transform duration-500 group-hover/avatar:scale-95">
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|