@djangocfg/widget-assistant 0.2.4 → 0.2.6
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/package.json +6 -6
- package/src/shell/AssistantFrame.tsx +56 -6
- package/src/styles/layout.css +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-assistant",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "A site assistant on top of @djangocfg/widget-chat: the SSE wire and session layer, a persistent host, docked column, floating button and sheet, dictation, Help and 17 built-in locales",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chat",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"zustand": "^5.0.16"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@djangocfg/i18n": "^2.1.
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
51
|
+
"@djangocfg/i18n": "^2.1.616",
|
|
52
|
+
"@djangocfg/ui-core": "^2.1.616",
|
|
53
53
|
"lucide-react": "^1.68.0",
|
|
54
54
|
"react": "^19.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@djangocfg/i18n": "^2.1.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
57
|
+
"@djangocfg/i18n": "^2.1.616",
|
|
58
|
+
"@djangocfg/typescript-config": "^2.1.616",
|
|
59
|
+
"@djangocfg/ui-core": "^2.1.616",
|
|
60
60
|
"@storybook/react-vite": "^10.6.5",
|
|
61
61
|
"@types/node": "^25.9.5",
|
|
62
62
|
"@types/react": "19.3.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { X } from 'lucide-react';
|
|
6
6
|
|
|
@@ -34,21 +34,68 @@ interface Props {
|
|
|
34
34
|
children: ReactNode;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** How long the column takes to open or close; the same number is in `layout.css`. */
|
|
38
|
+
const MOTION_MS = 380;
|
|
39
|
+
/** A column restored at page load is already there: only a change after this long is animated. */
|
|
40
|
+
const RESTORE_GRACE_MS = 800;
|
|
41
|
+
|
|
37
42
|
/**
|
|
38
43
|
* The page with the assistant column beside it. While `docked`, the page narrows and the column stands at the right edge, full height. Its width is published as the CSS
|
|
39
44
|
* variable `--assistant-w` (`0px` when closed) so fixed elements of the host keep clear of it. Page scroll stays the window's: the column is `sticky`, not a scroll container.
|
|
45
|
+
*
|
|
46
|
+
* Opening and closing move that variable, not the column: the page eases to its new width and the column is revealed from the right edge at its full width (nothing inside
|
|
47
|
+
* reflows), with a short fade. `--assistant-w` is a registered length (`layout.css`), so fixed host elements follow the same curve. The column stays mounted until the
|
|
48
|
+
* close finishes. Resizing by hand and the first paint after a page load are not animated.
|
|
40
49
|
*/
|
|
41
50
|
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, children }: Props) {
|
|
42
|
-
const
|
|
51
|
+
const born = useRef(0);
|
|
52
|
+
const [mounted, setMounted] = useState(false);
|
|
53
|
+
const [shown, setShown] = useState(false);
|
|
54
|
+
const [moving, setMoving] = useState(false);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!born.current) born.current = performance.now();
|
|
58
|
+
const timers: number[] = [];
|
|
59
|
+
let frame = 0;
|
|
60
|
+
if (docked) {
|
|
61
|
+
setMounted(true);
|
|
62
|
+
if (performance.now() - born.current < RESTORE_GRACE_MS) {
|
|
63
|
+
setMoving(false);
|
|
64
|
+
setShown(true);
|
|
65
|
+
} else {
|
|
66
|
+
// Mount at zero width, then (two frames on, so the browser has laid out the start) move to the real one.
|
|
67
|
+
setMoving(true);
|
|
68
|
+
frame = requestAnimationFrame(() => {
|
|
69
|
+
frame = requestAnimationFrame(() => setShown(true));
|
|
70
|
+
});
|
|
71
|
+
timers.push(window.setTimeout(() => setMoving(false), MOTION_MS + 40));
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
setMoving(true);
|
|
75
|
+
setShown(false);
|
|
76
|
+
timers.push(
|
|
77
|
+
window.setTimeout(() => {
|
|
78
|
+
setMounted(false);
|
|
79
|
+
setMoving(false);
|
|
80
|
+
}, MOTION_MS + 40),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return () => {
|
|
84
|
+
cancelAnimationFrame(frame);
|
|
85
|
+
timers.forEach(window.clearTimeout);
|
|
86
|
+
};
|
|
87
|
+
}, [docked]);
|
|
88
|
+
|
|
89
|
+
const style = { '--assistant-w': shown ? `${width}px` : '0px' } as CSSProperties;
|
|
43
90
|
return (
|
|
44
|
-
<div className="flex" style={style}>
|
|
91
|
+
<div className="assistant-frame flex" data-moving={moving ? 'true' : 'false'} style={style}>
|
|
45
92
|
<div className="min-w-0 flex-1">{children}</div>
|
|
46
|
-
{
|
|
93
|
+
{mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }} /> : null}
|
|
47
94
|
</div>
|
|
48
95
|
);
|
|
49
96
|
}
|
|
50
97
|
|
|
51
|
-
function Column({ width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'>) {
|
|
98
|
+
function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
|
|
52
99
|
const copy = useAssistantCopy();
|
|
53
100
|
// The handle sits on the column's left edge: the pointer's distance from the window's right edge is the new width.
|
|
54
101
|
const drag = useCallback(
|
|
@@ -77,7 +124,9 @@ function Column({ width, minWidth, maxWidth, defaultWidth, onWidthChange, onClos
|
|
|
77
124
|
};
|
|
78
125
|
|
|
79
126
|
return (
|
|
80
|
-
|
|
127
|
+
// The clip: as wide as the page has given so far, the column inside keeps its full width and is anchored right, so it slides in instead of squeezing.
|
|
128
|
+
<div className="assistant-clip sticky top-0 flex h-dvh shrink-0 justify-end self-start overflow-hidden" data-shown={shown ? 'true' : 'false'}>
|
|
129
|
+
<aside aria-label={labels.title} style={{ width }} className="assistant-column relative flex h-full shrink-0 flex-col border-l bg-background">
|
|
81
130
|
<div
|
|
82
131
|
role="separator"
|
|
83
132
|
aria-orientation="vertical"
|
|
@@ -99,5 +148,6 @@ function Column({ width, minWidth, maxWidth, defaultWidth, onWidthChange, onClos
|
|
|
99
148
|
</header>
|
|
100
149
|
{content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
|
|
101
150
|
</aside>
|
|
151
|
+
</div>
|
|
102
152
|
);
|
|
103
153
|
}
|
package/src/styles/layout.css
CHANGED
|
@@ -13,3 +13,48 @@
|
|
|
13
13
|
.assistant-chat > .chat-surface {
|
|
14
14
|
flex: 1 1 0;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Opening and closing the docked column (AssistantFrame). `--assistant-w` is a registered length so it can be transitioned: the page, the clip and the fixed elements of the
|
|
19
|
+
* host that read it all follow one curve. The curve is the iOS sheet spring's settle (fast start, long soft landing); keep the duration in step with MOTION_MS in AssistantFrame.
|
|
20
|
+
* Without @property support the width simply jumps, as before.
|
|
21
|
+
*/
|
|
22
|
+
@property --assistant-w {
|
|
23
|
+
syntax: "<length>";
|
|
24
|
+
inherits: true;
|
|
25
|
+
initial-value: 0px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.assistant-frame {
|
|
29
|
+
--assistant-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
|
30
|
+
--assistant-ms: 380ms;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.assistant-frame[data-moving="true"] {
|
|
34
|
+
transition: --assistant-w var(--assistant-ms) var(--assistant-ease);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.assistant-clip {
|
|
38
|
+
width: var(--assistant-w);
|
|
39
|
+
opacity: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.assistant-clip[data-shown="true"] {
|
|
43
|
+
opacity: 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.assistant-frame[data-moving="true"] .assistant-clip {
|
|
47
|
+
transition: opacity calc(var(--assistant-ms) * 0.7) ease-out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Closing: the fade finishes early so the column does not look like it is dragged off. */
|
|
51
|
+
.assistant-frame[data-moving="true"] .assistant-clip[data-shown="false"] {
|
|
52
|
+
transition-duration: calc(var(--assistant-ms) * 0.45);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (prefers-reduced-motion: reduce) {
|
|
56
|
+
.assistant-frame[data-moving="true"],
|
|
57
|
+
.assistant-frame[data-moving="true"] .assistant-clip {
|
|
58
|
+
transition: none;
|
|
59
|
+
}
|
|
60
|
+
}
|