@kanzo-tech/ai 0.11.0 → 0.13.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/complete.d.ts +24 -3
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +153 -134
- package/dist/complete.js.map +1 -1
- package/dist/conversation.d.ts +0 -6
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +51 -68
- package/dist/conversation.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -55
- package/dist/suggest.d.ts +8 -1
- package/dist/suggest.d.ts.map +1 -1
- package/dist/suggest.js +32 -32
- package/dist/suggest.js.map +1 -1
- package/package.json +4 -4
package/dist/complete.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface CompleteRootProps {
|
|
|
7
7
|
onValueChange: (value: string) => void;
|
|
8
8
|
debounceMs?: number;
|
|
9
9
|
minLength?: number;
|
|
10
|
+
/**
|
|
11
|
+
* What a screen reader hears once, when a suggestion lands.
|
|
12
|
+
*
|
|
13
|
+
* @default "Suggestion ready. Press Tab to accept, Escape to dismiss."
|
|
14
|
+
*/
|
|
15
|
+
announcement?: string;
|
|
10
16
|
className?: string;
|
|
11
17
|
children: React.ReactNode;
|
|
12
18
|
}
|
|
@@ -22,7 +28,14 @@ export declare function CompleteTextarea({ children }: {
|
|
|
22
28
|
* two readings on purpose: it is the only way to accept without a keyboard, and the only way back
|
|
23
29
|
* after Escape, which otherwise ends the field's assistance until the next keystroke.
|
|
24
30
|
*/
|
|
25
|
-
export declare function CompleteMark(props: Omit<AiMarkProps, "busy" | "offering">
|
|
31
|
+
export declare function CompleteMark(props: Omit<AiMarkProps, "busy" | "offering"> & {
|
|
32
|
+
/**
|
|
33
|
+
* The accessible name while a suggestion is on offer, when `label` names the mark at rest.
|
|
34
|
+
*
|
|
35
|
+
* @default "Accept suggestion"
|
|
36
|
+
*/
|
|
37
|
+
offeringLabel?: string;
|
|
38
|
+
}): React.JSX.Element;
|
|
26
39
|
/**
|
|
27
40
|
* The continuation drawn where it will land, over the field itself. It wraps and scrolls with the
|
|
28
41
|
* field rather than measuring whether a line of it fits.
|
|
@@ -44,12 +57,20 @@ export declare function CompleteGhost({ className }: {
|
|
|
44
57
|
* toolbar under the pointer, which is a gesture a keyboard cannot make. Naming the key next to the
|
|
45
58
|
* offer is the version that works for both.
|
|
46
59
|
*/
|
|
47
|
-
export declare function CompleteKeys({ className }: {
|
|
60
|
+
export declare function CompleteKeys({ acceptLabel, className, dismissLabel, }: {
|
|
61
|
+
/** The word after the `Tab` key. */
|
|
62
|
+
acceptLabel?: string;
|
|
48
63
|
className?: string;
|
|
64
|
+
/** The word after the `Esc` key. */
|
|
65
|
+
dismissLabel?: string;
|
|
49
66
|
}): React.JSX.Element | null;
|
|
50
67
|
/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */
|
|
51
|
-
export declare function CompleteHint({ className }: {
|
|
68
|
+
export declare function CompleteHint({ acceptLabel, className, dismissLabel, }: {
|
|
69
|
+
/** The word after the `Tab` key, as on `CompleteKeys`. */
|
|
70
|
+
acceptLabel?: string;
|
|
52
71
|
className?: string;
|
|
72
|
+
/** The word after the `Esc` key, as on `CompleteKeys`. */
|
|
73
|
+
dismissLabel?: string;
|
|
53
74
|
}): React.JSX.Element | null;
|
|
54
75
|
/**
|
|
55
76
|
* What went wrong, in place. Renders nothing when nothing did.
|
package/dist/complete.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"complete.d.ts","sourceRoot":"","sources":["../src/complete.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAEL,KAAK,uBAAuB,EAG7B,MAAM,aAAa,CAAC;AA+FrB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,aAAa,CAAC,MAAM,CAAC,CAAC;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"complete.d.ts","sourceRoot":"","sources":["../src/complete.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAEL,KAAK,uBAAuB,EAG7B,MAAM,aAAa,CAAC;AA+FrB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,aAAa,CAAC,MAAM,CAAC,CAAC;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,qBAyLpD;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAA;CAAE,qBAkB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG;IAC9C;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,qBAmBF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA8FlE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,EAC3B,WAAsB,EACtB,SAAS,EACT,YAAwB,GACzB,EAAE;IACD,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,4BAgBA;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,EAC3B,WAAW,EACX,SAAS,EACT,YAAY,GACb,EAAE;IACD,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,4BAYA;AAGD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,4BAYlE"}
|
package/dist/complete.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as
|
|
3
|
-
import { ark as
|
|
4
|
-
import * as
|
|
5
|
-
import { cn as y, Kbd as
|
|
6
|
-
import { AiMark as
|
|
7
|
-
import { useInlineCompletion as
|
|
8
|
-
const
|
|
9
|
-
const e =
|
|
10
|
-
if (!e) throw new Error(`${
|
|
2
|
+
import { jsx as m, jsxs as v } from "react/jsx-runtime";
|
|
3
|
+
import { ark as f } from "@ark-ui/react/factory";
|
|
4
|
+
import * as c from "react";
|
|
5
|
+
import { cn as y, Kbd as H } from "@kanzo-tech/ui";
|
|
6
|
+
import { AiMark as _ } from "./ai-mark.js";
|
|
7
|
+
import { useInlineCompletion as G, MIN_COMPLETE_LENGTH as O } from "./use-ai.js";
|
|
8
|
+
const M = c.createContext(null), x = (r) => {
|
|
9
|
+
const e = c.useContext(M);
|
|
10
|
+
if (!e) throw new Error(`${r} must render inside <CompleteRoot>`);
|
|
11
11
|
return e;
|
|
12
|
-
},
|
|
12
|
+
}, z = [
|
|
13
13
|
"fontFamily",
|
|
14
14
|
"fontSize",
|
|
15
15
|
"fontWeight",
|
|
@@ -25,107 +25,116 @@ const H = o.createContext(null), v = (s) => {
|
|
|
25
25
|
"borderRightWidth",
|
|
26
26
|
"borderBottomWidth",
|
|
27
27
|
"borderLeftWidth"
|
|
28
|
-
], S = (
|
|
29
|
-
const
|
|
28
|
+
], S = (r) => Number.parseFloat(r) || 0, V = (r, e) => {
|
|
29
|
+
const i = getComputedStyle(r), s = i.direction === "rtl", l = r.getBoundingClientRect(), a = e.getBoundingClientRect(), b = r.offsetWidth - r.clientWidth - S(i.borderLeftWidth) - S(i.borderRightWidth), u = {
|
|
30
30
|
borderStyle: "solid",
|
|
31
31
|
borderColor: "transparent",
|
|
32
|
-
insetBlockStart:
|
|
33
|
-
insetBlockEnd:
|
|
34
|
-
insetInlineStart:
|
|
35
|
-
insetInlineEnd: (
|
|
32
|
+
insetBlockStart: l.top - a.top,
|
|
33
|
+
insetBlockEnd: a.bottom - l.bottom,
|
|
34
|
+
insetInlineStart: s ? a.right - l.right : l.left - a.left,
|
|
35
|
+
insetInlineEnd: (s ? l.left - a.left : a.right - l.right) + (b > 0 ? b : 0)
|
|
36
36
|
};
|
|
37
|
-
for (const
|
|
38
|
-
return { metrics:
|
|
39
|
-
},
|
|
37
|
+
for (const d of z) u[d] = i[d];
|
|
38
|
+
return { metrics: u, rtl: s };
|
|
39
|
+
}, j = { metrics: {}, rtl: !1 }, q = "[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]", $ = (r) => {
|
|
40
40
|
var e;
|
|
41
|
-
return ((e = /^\s*\S+/.exec(
|
|
41
|
+
return ((e = /^\s*\S+/.exec(r)) == null ? void 0 : e[0]) ?? "";
|
|
42
42
|
};
|
|
43
|
-
function
|
|
44
|
-
const {
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
function ee(r) {
|
|
44
|
+
const {
|
|
45
|
+
announcement: e = "Suggestion ready. Press Tab to accept, Escape to dismiss.",
|
|
46
|
+
complete: i,
|
|
47
|
+
value: s,
|
|
48
|
+
onValueChange: l,
|
|
49
|
+
debounceMs: a,
|
|
50
|
+
minLength: b,
|
|
51
|
+
className: u,
|
|
52
|
+
children: d
|
|
53
|
+
} = r, o = G({ complete: i, debounceMs: a, minLength: b }), p = c.useRef(null), W = c.useRef(null), [h, R] = c.useState(s.length), [k, A] = c.useState(j), g = o.ghost, E = c.useRef(null);
|
|
54
|
+
c.useLayoutEffect(() => {
|
|
55
|
+
const t = p.current, n = E.current;
|
|
47
56
|
!t || n === null || (E.current = null, t.setSelectionRange(n, n), R(n));
|
|
48
|
-
}, [
|
|
49
|
-
const t =
|
|
57
|
+
}, [s]), c.useLayoutEffect(() => {
|
|
58
|
+
const t = p.current, n = W.current;
|
|
50
59
|
if (!t || !n) return;
|
|
51
|
-
const C = () =>
|
|
60
|
+
const C = () => A(V(t, n));
|
|
52
61
|
if (C(), typeof ResizeObserver > "u") return;
|
|
53
62
|
const N = new ResizeObserver(C);
|
|
54
63
|
return N.observe(t), N.observe(n), () => N.disconnect();
|
|
55
64
|
}, []);
|
|
56
|
-
const
|
|
57
|
-
const t =
|
|
65
|
+
const K = o.dismiss, L = c.useCallback(() => {
|
|
66
|
+
const t = p.current;
|
|
58
67
|
if (!t || E.current !== null) return;
|
|
59
68
|
const n = t.selectionStart ?? t.value.length;
|
|
60
69
|
if ((t.selectionEnd ?? n) !== n) {
|
|
61
|
-
|
|
70
|
+
K();
|
|
62
71
|
return;
|
|
63
72
|
}
|
|
64
73
|
R(n);
|
|
65
|
-
}, [
|
|
66
|
-
|
|
67
|
-
const t =
|
|
74
|
+
}, [K]);
|
|
75
|
+
c.useEffect(() => {
|
|
76
|
+
const t = p.current;
|
|
68
77
|
if (!t) return;
|
|
69
78
|
const n = () => {
|
|
70
79
|
document.activeElement === t && L();
|
|
71
80
|
};
|
|
72
81
|
return document.addEventListener("selectionchange", n), () => document.removeEventListener("selectionchange", n);
|
|
73
82
|
}, [L]);
|
|
74
|
-
const
|
|
83
|
+
const D = c.useCallback(
|
|
75
84
|
(t) => {
|
|
76
85
|
const n = t.target, C = n.selectionStart ?? n.value.length;
|
|
77
|
-
R(C), l(n.value),
|
|
86
|
+
R(C), l(n.value), o.setValue(n.value, C);
|
|
78
87
|
},
|
|
79
|
-
[
|
|
80
|
-
), w =
|
|
88
|
+
[o, l]
|
|
89
|
+
), w = c.useCallback(
|
|
81
90
|
(t) => {
|
|
82
|
-
const n =
|
|
83
|
-
return E.current =
|
|
91
|
+
const n = s.slice(0, h) + t + s.slice(h);
|
|
92
|
+
return E.current = h + t.length, l(n), { next: n, at: h + t.length };
|
|
84
93
|
},
|
|
85
|
-
[
|
|
86
|
-
), T =
|
|
94
|
+
[h, l, s]
|
|
95
|
+
), T = c.useCallback(() => {
|
|
87
96
|
var t;
|
|
88
|
-
|
|
89
|
-
}, [
|
|
90
|
-
const t =
|
|
97
|
+
g && (w(g), o.dismiss(), (t = p.current) == null || t.focus());
|
|
98
|
+
}, [o, g, w]), B = c.useCallback(() => {
|
|
99
|
+
const t = $(g);
|
|
91
100
|
if (!t) return;
|
|
92
101
|
const { next: n, at: C } = w(t);
|
|
93
|
-
|
|
94
|
-
}, [
|
|
95
|
-
const t =
|
|
96
|
-
R(n),
|
|
97
|
-
}, [
|
|
102
|
+
o.setValue(n, C);
|
|
103
|
+
}, [o, g, w]), I = c.useCallback(() => {
|
|
104
|
+
const t = p.current, n = t && t === document.activeElement ? t.selectionStart ?? h : h;
|
|
105
|
+
R(n), o.ask(s, n), t == null || t.focus(), t == null || t.setSelectionRange(n, n);
|
|
106
|
+
}, [h, o, s]), P = c.useCallback(
|
|
98
107
|
(t) => {
|
|
99
|
-
if (t.defaultPrevented || !
|
|
108
|
+
if (t.defaultPrevented || !g) return;
|
|
100
109
|
const n = k.rtl ? "ArrowLeft" : "ArrowRight";
|
|
101
|
-
t.key === "Tab" ? (t.preventDefault(), T()) : t.key === n && (t.ctrlKey || t.metaKey) ? (t.preventDefault(),
|
|
110
|
+
t.key === "Tab" ? (t.preventDefault(), T()) : t.key === n && (t.ctrlKey || t.metaKey) ? (t.preventDefault(), B()) : t.key === "Escape" && o.dismiss();
|
|
102
111
|
},
|
|
103
|
-
[T,
|
|
104
|
-
),
|
|
105
|
-
fieldRef:
|
|
106
|
-
value:
|
|
107
|
-
ghost:
|
|
108
|
-
caret:
|
|
112
|
+
[T, B, o, k.rtl, g]
|
|
113
|
+
), F = {
|
|
114
|
+
fieldRef: p,
|
|
115
|
+
value: s,
|
|
116
|
+
ghost: g,
|
|
117
|
+
caret: h,
|
|
109
118
|
rtl: k.rtl,
|
|
110
|
-
status:
|
|
111
|
-
error:
|
|
112
|
-
askable:
|
|
119
|
+
status: o.status,
|
|
120
|
+
error: o.error,
|
|
121
|
+
askable: s.trim().length >= (b ?? O),
|
|
113
122
|
metrics: k.metrics,
|
|
114
|
-
onChange:
|
|
115
|
-
onKeyDown:
|
|
123
|
+
onChange: D,
|
|
124
|
+
onKeyDown: P,
|
|
116
125
|
syncCaret: L,
|
|
117
126
|
accept: T,
|
|
118
|
-
request:
|
|
127
|
+
request: I
|
|
119
128
|
};
|
|
120
|
-
return /* @__PURE__ */
|
|
121
|
-
|
|
122
|
-
/* @__PURE__ */ f
|
|
129
|
+
return /* @__PURE__ */ m(M.Provider, { value: F, children: /* @__PURE__ */ v(f.div, { className: y("relative w-full min-w-0", u), "data-slot": "complete", ref: W, children: [
|
|
130
|
+
d,
|
|
131
|
+
/* @__PURE__ */ m(f.span, { "aria-live": "polite", className: "sr-only", "data-slot": "complete-status", children: g ? e : "" })
|
|
123
132
|
] }) });
|
|
124
133
|
}
|
|
125
|
-
function
|
|
126
|
-
const e =
|
|
127
|
-
return /* @__PURE__ */
|
|
128
|
-
|
|
134
|
+
function te({ children: r }) {
|
|
135
|
+
const e = x("CompleteTextarea");
|
|
136
|
+
return /* @__PURE__ */ m(
|
|
137
|
+
f.textarea,
|
|
129
138
|
{
|
|
130
139
|
"aria-keyshortcuts": e.ghost ? "Tab Escape" : void 0,
|
|
131
140
|
asChild: !0,
|
|
@@ -137,46 +146,46 @@ function ee({ children: s }) {
|
|
|
137
146
|
onSelect: e.syncCaret,
|
|
138
147
|
ref: e.fieldRef,
|
|
139
148
|
value: e.value,
|
|
140
|
-
children:
|
|
149
|
+
children: r
|
|
141
150
|
}
|
|
142
151
|
);
|
|
143
152
|
}
|
|
144
|
-
function
|
|
145
|
-
const { label: e = "AI assist", ...
|
|
146
|
-
return /* @__PURE__ */
|
|
147
|
-
|
|
153
|
+
function ne(r) {
|
|
154
|
+
const { label: e = "AI assist", offeringLabel: i = "Accept suggestion", ...s } = r, l = x("CompleteMark"), a = l.ghost.length > 0;
|
|
155
|
+
return /* @__PURE__ */ m(
|
|
156
|
+
_,
|
|
148
157
|
{
|
|
149
|
-
busy: l.status === "loading" && !
|
|
150
|
-
disabled: !
|
|
151
|
-
label:
|
|
152
|
-
offering:
|
|
153
|
-
onClick:
|
|
154
|
-
...
|
|
158
|
+
busy: l.status === "loading" && !a,
|
|
159
|
+
disabled: !a && !l.askable,
|
|
160
|
+
label: a ? i : e,
|
|
161
|
+
offering: a,
|
|
162
|
+
onClick: a ? l.accept : l.request,
|
|
163
|
+
...s
|
|
155
164
|
}
|
|
156
165
|
);
|
|
157
166
|
}
|
|
158
|
-
function
|
|
159
|
-
const e =
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
if (!
|
|
167
|
+
function re({ className: r }) {
|
|
168
|
+
const e = x("CompleteGhost"), i = c.useRef(null), [s, l] = c.useState(!1);
|
|
169
|
+
c.useLayoutEffect(() => {
|
|
170
|
+
const u = e.fieldRef.current, d = i.current;
|
|
171
|
+
if (!u || !d) return;
|
|
163
172
|
if (!e.ghost) {
|
|
164
|
-
|
|
173
|
+
u.style.removeProperty("min-height");
|
|
165
174
|
return;
|
|
166
175
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
}, [e.fieldRef, e.ghost, e.value]),
|
|
170
|
-
const
|
|
171
|
-
if (!
|
|
172
|
-
const
|
|
173
|
-
|
|
176
|
+
const o = getComputedStyle(u), p = d.scrollHeight + S(o.borderTopWidth) + S(o.borderBottomWidth);
|
|
177
|
+
p > u.getBoundingClientRect().height + 1 && u.style.setProperty("min-height", `${p}px`);
|
|
178
|
+
}, [e.fieldRef, e.ghost, e.value]), c.useLayoutEffect(() => {
|
|
179
|
+
const u = e.fieldRef.current, d = i.current;
|
|
180
|
+
if (!u || !d) return;
|
|
181
|
+
const o = () => {
|
|
182
|
+
d.scrollLeft = u.scrollLeft, d.scrollTop = u.scrollTop;
|
|
174
183
|
};
|
|
175
|
-
return
|
|
184
|
+
return o(), l(d.scrollHeight > d.clientHeight), u.addEventListener("scroll", o), () => u.removeEventListener("scroll", o);
|
|
176
185
|
}, [e.caret, e.fieldRef, e.value, e.ghost]);
|
|
177
|
-
const
|
|
178
|
-
return /* @__PURE__ */
|
|
179
|
-
|
|
186
|
+
const a = e.ghost.length > 0, b = Math.min(e.caret, e.value.length);
|
|
187
|
+
return /* @__PURE__ */ v(
|
|
188
|
+
f.div,
|
|
180
189
|
{
|
|
181
190
|
"aria-hidden": !0,
|
|
182
191
|
className: y(
|
|
@@ -186,61 +195,71 @@ function ne({ className: s }) {
|
|
|
186
195
|
// `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter
|
|
187
196
|
// against the ✨. Applied only when the measurement above says it is genuinely cut, which
|
|
188
197
|
// is now only when a caller has pinned the field's height.
|
|
189
|
-
|
|
190
|
-
!
|
|
191
|
-
|
|
198
|
+
s && q,
|
|
199
|
+
!a && "invisible",
|
|
200
|
+
r
|
|
192
201
|
),
|
|
193
202
|
"data-slot": "complete-ghost",
|
|
194
|
-
ref:
|
|
203
|
+
ref: i,
|
|
195
204
|
style: e.metrics,
|
|
196
205
|
children: [
|
|
197
|
-
/* @__PURE__ */ f
|
|
198
|
-
/* @__PURE__ */ f
|
|
199
|
-
/* @__PURE__ */ f
|
|
206
|
+
/* @__PURE__ */ m(f.span, { className: "invisible", children: e.value.slice(0, b) }),
|
|
207
|
+
/* @__PURE__ */ m(f.span, { className: "text-faint", children: e.ghost }),
|
|
208
|
+
/* @__PURE__ */ m(f.span, { className: "invisible", children: e.value.slice(b) })
|
|
200
209
|
]
|
|
201
210
|
}
|
|
202
211
|
);
|
|
203
212
|
}
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
function U({
|
|
214
|
+
acceptLabel: r = "accept",
|
|
215
|
+
className: e,
|
|
216
|
+
dismissLabel: i = "dismiss"
|
|
217
|
+
}) {
|
|
218
|
+
return x("CompleteKeys").ghost ? /* @__PURE__ */ v(
|
|
219
|
+
f.span,
|
|
207
220
|
{
|
|
208
|
-
className: y("inline-flex items-center gap-2 text-muted-foreground text-xs",
|
|
221
|
+
className: y("inline-flex items-center gap-2 text-muted-foreground text-xs", e),
|
|
209
222
|
"data-slot": "complete-keys",
|
|
210
223
|
children: [
|
|
211
|
-
/* @__PURE__ */
|
|
212
|
-
/* @__PURE__ */
|
|
213
|
-
"
|
|
224
|
+
/* @__PURE__ */ v(f.span, { className: "inline-flex items-center gap-1 whitespace-nowrap", children: [
|
|
225
|
+
/* @__PURE__ */ m(H, { children: "Tab" }),
|
|
226
|
+
" ",
|
|
227
|
+
r
|
|
214
228
|
] }),
|
|
215
|
-
/* @__PURE__ */
|
|
216
|
-
/* @__PURE__ */
|
|
217
|
-
"
|
|
229
|
+
/* @__PURE__ */ v(f.span, { className: "inline-flex items-center gap-1 whitespace-nowrap", children: [
|
|
230
|
+
/* @__PURE__ */ m(H, { children: "Esc" }),
|
|
231
|
+
" ",
|
|
232
|
+
i
|
|
218
233
|
] })
|
|
219
234
|
]
|
|
220
235
|
}
|
|
221
236
|
) : null;
|
|
222
237
|
}
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
function se({
|
|
239
|
+
acceptLabel: r,
|
|
240
|
+
className: e,
|
|
241
|
+
dismissLabel: i
|
|
242
|
+
}) {
|
|
243
|
+
const s = x("CompleteHint");
|
|
244
|
+
return s.ghost ? /* @__PURE__ */ v(
|
|
245
|
+
f.p,
|
|
227
246
|
{
|
|
228
|
-
className: y("mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm",
|
|
247
|
+
className: y("mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm", e),
|
|
229
248
|
"data-slot": "complete-hint",
|
|
230
249
|
children: [
|
|
231
|
-
/* @__PURE__ */ f
|
|
250
|
+
/* @__PURE__ */ m(f.span, { className: "text-faint", children: s.ghost.replace(/^\s+/, "") }),
|
|
232
251
|
" ",
|
|
233
|
-
/* @__PURE__ */
|
|
252
|
+
/* @__PURE__ */ m(U, { acceptLabel: r, dismissLabel: i })
|
|
234
253
|
]
|
|
235
254
|
}
|
|
236
255
|
) : null;
|
|
237
256
|
}
|
|
238
|
-
function
|
|
239
|
-
const e =
|
|
240
|
-
return e.error === null ? null : /* @__PURE__ */
|
|
241
|
-
|
|
257
|
+
function oe({ className: r }) {
|
|
258
|
+
const e = x("CompleteError");
|
|
259
|
+
return e.error === null ? null : /* @__PURE__ */ m(
|
|
260
|
+
f.p,
|
|
242
261
|
{
|
|
243
|
-
className: y("mt-1.5 text-destructive-foreground text-sm",
|
|
262
|
+
className: y("mt-1.5 text-destructive-foreground text-sm", r),
|
|
244
263
|
"data-slot": "complete-error",
|
|
245
264
|
role: "alert",
|
|
246
265
|
children: e.error
|
|
@@ -248,12 +267,12 @@ function se({ className: s }) {
|
|
|
248
267
|
);
|
|
249
268
|
}
|
|
250
269
|
export {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
270
|
+
oe as CompleteError,
|
|
271
|
+
re as CompleteGhost,
|
|
272
|
+
se as CompleteHint,
|
|
273
|
+
U as CompleteKeys,
|
|
274
|
+
ne as CompleteMark,
|
|
275
|
+
ee as CompleteRoot,
|
|
276
|
+
te as CompleteTextarea
|
|
258
277
|
};
|
|
259
278
|
//# sourceMappingURL=complete.js.map
|
package/dist/complete.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"complete.js","sources":["../src/complete.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport * as React from \"react\";\nimport { cn, Kbd } from \"@kanzo-tech/ui\";\nimport { AiMark, type AiMarkProps } from \"./ai-mark.js\";\nimport {\n type AiStatus,\n type InlineCompletionRequest,\n MIN_COMPLETE_LENGTH,\n useInlineCompletion,\n} from \"./use-ai.js\";\n\n/**\n * A `<textarea>`, and there is no `<input>` branch any more.\n *\n * **A one-line field takes candidates, not a continuation.** A continuation drawn over an\n * `<input>` can only ever show what fits in the remaining width — the field's own `scrollLeft`\n * cannot reveal text that is not in its value — so a long offer was unreadable by any gesture and\n * accepting it meant accepting blind. No reference does it either: Gmail continues a body, Copilot\n * an editor, and every single-line field in the wild offers a *list*. That is `Suggest`, and it is\n * already here.\n */\ntype FieldEl = HTMLTextAreaElement;\n\ninterface CompleteCtx {\n fieldRef: React.RefObject<FieldEl | null>;\n value: string;\n /** What is on offer at the caret. Empty when there is nothing. */\n ghost: string;\n /** Where the offer goes. The ghost is drawn here, not at the end of the value. */\n caret: number;\n rtl: boolean;\n status: AiStatus;\n error: string | null;\n /** The value is long enough for `complete` to be asked at all. */\n askable: boolean;\n metrics: React.CSSProperties;\n onChange: (e: React.ChangeEvent<FieldEl>) => void;\n onKeyDown: (e: React.KeyboardEvent<FieldEl>) => void;\n syncCaret: () => void;\n accept: () => void;\n request: () => void;\n}\n\nconst Ctx = React.createContext<CompleteCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <CompleteRoot>`);\n return c;\n};\n\nconst METRICS = [\n \"fontFamily\", \"fontSize\", \"fontWeight\", \"fontStyle\", \"letterSpacing\", \"lineHeight\",\n \"textTransform\", \"paddingTop\", \"paddingRight\", \"paddingBottom\", \"paddingLeft\",\n \"borderTopWidth\", \"borderRightWidth\", \"borderBottomWidth\", \"borderLeftWidth\",\n] as const;\n\ninterface FieldShape {\n metrics: React.CSSProperties;\n rtl: boolean;\n}\n\n// The overlay is positioned against the FIELD, not against the root, because the two are not the\n// same box once the ✨ lives inside an `InputGroup`: the group carries the border the input no\n// longer has, and a mirror pinned to the root paints a character-width off.\nconst px = (v: string) => Number.parseFloat(v) || 0;\n\nconst readField = (el: FieldEl, root: HTMLElement): FieldShape => {\n const cs = getComputedStyle(el);\n const rtl = cs.direction === \"rtl\";\n const box = el.getBoundingClientRect();\n const outer = root.getBoundingClientRect();\n // A scrolling textarea takes its scrollbar out of the text's width, and it takes it from the\n // inline-END side in both directions. Without this the mirror wraps a word earlier or later than\n // the field does wherever scrollbars are not overlays — which is not macOS, so it is exactly the\n // kind of divergence nobody sees here.\n const gutter = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth);\n const metrics: Record<string, string | number> = {\n borderStyle: \"solid\",\n borderColor: \"transparent\",\n insetBlockStart: box.top - outer.top,\n insetBlockEnd: outer.bottom - box.bottom,\n insetInlineStart: rtl ? outer.right - box.right : box.left - outer.left,\n insetInlineEnd:\n (rtl ? box.left - outer.left : outer.right - box.right) + (gutter > 0 ? gutter : 0),\n };\n for (const k of METRICS) metrics[k] = cs[k as keyof CSSStyleDeclaration] as string;\n return { metrics: metrics as React.CSSProperties, rtl };\n};\n\nconst REST: FieldShape = { metrics: {}, rtl: false };\n\n/**\n * A safety net rather than the answer. The field grows to fit its offer, so it runs out of height\n * only where a caller has pinned a `max-height` — and there a fade beats a slice.\n *\n * There were three of these. The two horizontal ones masked a continuation that overran a\n * single-line field, which is the case that no longer exists.\n */\nconst GHOST_FADE =\n \"[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]\";\n\n/** Leading space plus the next word — what one press of the accept-a-word key takes. */\nconst nextWord = (ghost: string) => /^\\s*\\S+/.exec(ghost)?.[0] ?? \"\";\n\nexport interface CompleteRootProps {\n complete: (request: InlineCompletionRequest) => AsyncIterable<string>;\n value: string;\n onValueChange: (value: string) => void;\n debounceMs?: number;\n minLength?: number;\n className?: string;\n children: React.ReactNode;\n}\n\nexport function CompleteRoot(props: CompleteRootProps) {\n const { complete, value, onValueChange, debounceMs, minLength, className, children } = props;\n const completion = useInlineCompletion({ complete, debounceMs, minLength });\n const fieldRef = React.useRef<FieldEl | null>(null);\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [caret, setCaret] = React.useState(value.length);\n const [field, setField] = React.useState<FieldShape>(REST);\n\n const ghost = completion.ghost;\n\n // Where the caret must land after WE changed the value. The browser puts it wherever it likes\n // when React writes a new `value`, and taking a word out of the middle of a sentence is the case\n // that makes it obvious.\n const landing = React.useRef<number | null>(null);\n\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const at = landing.current;\n if (!el || at === null) return;\n landing.current = null;\n el.setSelectionRange(at, at);\n setCaret(at);\n }, [value]);\n\n // The ghost mirror wears the field's real metrics, so it aligns whatever the size/className.\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const root = rootRef.current;\n if (!el || !root) return;\n const sync = () => setField(readField(el, root));\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(el);\n ro.observe(root);\n return () => ro.disconnect();\n }, []);\n\n const dismiss = completion.dismiss;\n\n /**\n * **A range is not an insertion point, so an offer cannot survive one.**\n *\n * `selectionStart` is the HEAD of a selection, not a caret. Selecting a sentence — or pressing\n * ⌘A — moved our caret to the start of it while the offer stayed live, and the continuation was\n * then drawn from there: on top of the words it was made to follow. Taking it with Tab spliced it\n * in at the same wrong place, because `insert` splits the value at this number too.\n *\n * The fix is not to draw it somewhere else. `InlineCompletionRequest` is explicitly the\n * degenerate range — an insertion point — so while text is selected there is no place the offer\n * *could* go, and the honest answer is to let it go. Collapsing the selection asks again, which\n * costs a debounce and no more.\n */\n const syncCaret = React.useCallback(() => {\n const el = fieldRef.current;\n if (!el || landing.current !== null) return;\n const start = el.selectionStart ?? el.value.length;\n if ((el.selectionEnd ?? start) !== start) {\n dismiss();\n return;\n }\n setCaret(start);\n }, [dismiss]);\n\n /**\n * The caret is where the ghost is **drawn**, so ours drifting from the field's is not a missed\n * update — it paints a continuation on top of the value. Seen live: a ghost starting at character\n * zero, over the sentence it was continuing.\n *\n * `selectionchange` is the only event that reports every way a caret moves — arrows, clicks,\n * drags, Home/End, and a script calling `setSelectionRange`. `click` and `keyup` catch most of\n * them and are kept because they fire first; this catches the rest.\n */\n React.useEffect(() => {\n const el = fieldRef.current;\n if (!el) return;\n const onSelectionChange = () => {\n if (document.activeElement === el) syncCaret();\n };\n document.addEventListener(\"selectionchange\", onSelectionChange);\n return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n }, [syncCaret]);\n\n const onChange = React.useCallback(\n (e: React.ChangeEvent<FieldEl>) => {\n const el = e.target;\n const at = el.selectionStart ?? el.value.length;\n setCaret(at);\n onValueChange(el.value);\n completion.setValue(el.value, at);\n },\n [completion, onValueChange],\n );\n\n /** Put `text` in at the caret and leave the caret after it. */\n const insert = React.useCallback(\n (text: string) => {\n const next = value.slice(0, caret) + text + value.slice(caret);\n landing.current = caret + text.length;\n onValueChange(next);\n return { next, at: caret + text.length };\n },\n [caret, onValueChange, value],\n );\n\n const accept = React.useCallback(() => {\n if (!ghost) return;\n insert(ghost);\n completion.dismiss();\n fieldRef.current?.focus();\n }, [completion, ghost, insert]);\n\n // Taking one word is not a new request and not a second machine: the word goes in, the caret\n // moves past it, and the offer survives by the same rule that keeps it alive when the reader\n // types that word themselves. There used to be a `taken` offset here doing it by hand.\n const acceptWord = React.useCallback(() => {\n const word = nextWord(ghost);\n if (!word) return;\n const { next, at } = insert(word);\n completion.setValue(next, at);\n }, [completion, ghost, insert]);\n\n const request = React.useCallback(() => {\n const el = fieldRef.current;\n // Where the offer goes, and the two have to agree. Focusing a field nobody has focused yet puts\n // the DOM caret at 0 while our own is still at the end, and the ghost would then be drawn in one\n // place and inserted in another.\n const at = el && el === document.activeElement ? (el.selectionStart ?? caret) : caret;\n setCaret(at);\n completion.ask(value, at);\n el?.focus();\n el?.setSelectionRange(at, at);\n }, [caret, completion, value]);\n\n const onKeyDown = React.useCallback(\n (e: React.KeyboardEvent<FieldEl>) => {\n if (e.defaultPrevented || !ghost) return;\n // Forward, not right: the key that means \"next word\" is mirrored under RTL, and this is the\n // one place in the compound where a key stands for a direction.\n const forward = field.rtl ? \"ArrowLeft\" : \"ArrowRight\";\n if (e.key === \"Tab\") {\n e.preventDefault();\n accept();\n } else if (e.key === forward && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n acceptWord();\n } else if (e.key === \"Escape\") {\n completion.dismiss();\n }\n },\n [accept, acceptWord, completion, field.rtl, ghost],\n );\n\n const ctx: CompleteCtx = {\n fieldRef, value, ghost, caret,\n rtl: field.rtl,\n status: completion.status,\n error: completion.error,\n askable: value.trim().length >= (minLength ?? MIN_COMPLETE_LENGTH),\n metrics: field.metrics,\n onChange, onKeyDown, syncCaret, accept, request,\n };\n\n return (\n <Ctx.Provider value={ctx}>\n <ark.div className={cn(\"relative w-full min-w-0\", className)} data-slot=\"complete\" ref={rootRef}>\n {children}\n {/* The ghost itself is `aria-hidden` and streams a chunk a frame, so announcing IT would\n read the same sentence twenty times. This sentence changes once, when there is\n something to take. */}\n <ark.span aria-live=\"polite\" className=\"sr-only\" data-slot=\"complete-status\">\n {ghost ? \"Suggestion ready. Press Tab to accept, Escape to dismiss.\" : \"\"}\n </ark.span>\n </ark.div>\n </Ctx.Provider>\n );\n}\n\nexport function CompleteTextarea({ children }: { children: React.ReactElement }) {\n const ctx = useCtx(\"CompleteTextarea\");\n return (\n <ark.textarea\n aria-keyshortcuts={ctx.ghost ? \"Tab Escape\" : undefined}\n asChild\n data-slot=\"complete-textarea\"\n onChange={ctx.onChange}\n onClick={ctx.syncCaret}\n onKeyDown={ctx.onKeyDown}\n onKeyUp={ctx.syncCaret}\n onSelect={ctx.syncCaret}\n ref={ctx.fieldRef as React.Ref<HTMLTextAreaElement>}\n value={ctx.value}\n >\n {children}\n </ark.textarea>\n );\n}\n\n/**\n * The ✨, bound to the stream — the same mark `SuggestTrigger` wears, so an assisted field looks\n * assisted whichever of the two it has.\n *\n * Pressing it **takes what is on offer and asks when there is nothing**. That is one gesture with\n * two readings on purpose: it is the only way to accept without a keyboard, and the only way back\n * after Escape, which otherwise ends the field's assistance until the next keystroke.\n */\nexport function CompleteMark(props: Omit<AiMarkProps, \"busy\" | \"offering\">) {\n const { label = \"AI assist\", ...rest } = props;\n const ctx = useCtx(\"CompleteMark\");\n const offering = ctx.ghost.length > 0;\n\n return (\n <AiMark\n busy={ctx.status === \"loading\" && !offering}\n // The one binding that legitimately differs from `SuggestMark`, and it is domain and not\n // taste: there is nothing to continue below `minLength`, while a candidate source can answer\n // from an empty field. Everything else about the two marks is bound identically.\n disabled={!offering && !ctx.askable}\n label={offering ? \"Accept suggestion\" : label}\n offering={offering}\n onClick={offering ? ctx.accept : ctx.request}\n {...rest}\n />\n );\n}\n\n/**\n * The continuation drawn where it will land, over the field itself. It wraps and scrolls with the\n * field rather than measuring whether a line of it fits.\n */\nexport function CompleteGhost({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteGhost\");\n const boxRef = React.useRef<HTMLDivElement>(null);\n const [clipped, setClipped] = React.useState(false);\n\n /**\n * **A continuation that does not fit makes the field taller. It is not cut.**\n *\n * The fade below was the old answer and it was the wrong one: a wrapping field runs out of\n * *height*, and a mask on the last line still reads as a sentence sliced through the middle of a\n * line of type — with, on a `block-end` addon, the ✨ and the keys painted over the slice. Seen on\n * `docs/ai/fields`. An editor gets away with clipping because it can push the following lines\n * down; a `<textarea>` cannot, because the ghost is not in its value. So the field takes the\n * height the offer needs while the offer is on the table, and gives it back when it is not — which\n * is what a textarea does anyway when you type into it. `rows` becomes a floor rather than a fixed\n * size, which is what `rows` already means.\n *\n * **It grows and never shrinks while one offer is live.** Taking a word makes the ghost shorter,\n * and re-measuring down on every word would make the box jitter under the reader mid-sentence for\n * no gain — somebody taking a word is about to take another. It resets the moment there is no\n * ghost, which is every accept, every dismiss and every keystroke that kills the offer.\n *\n */\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n if (!ctx.ghost) {\n el.style.removeProperty(\"min-height\");\n return;\n }\n // `minHeight` and not `height`, measured: a `block-end` addon turns the group into a column\n // flex container and the field is `flex: 1 1 0%` in it, so the flex algorithm computes the used\n // height and an explicit `height` is ignored outright — the inline `104px` was on the element\n // and the box stayed 64. Raising the floor is what the flex algorithm does honour, and it is\n // also what this means: `rows` is a floor, and the offer asks for a taller one.\n const cs = getComputedStyle(el);\n const needed = box.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);\n if (needed > el.getBoundingClientRect().height + 1)\n el.style.setProperty(\"min-height\", `${needed}px`);\n }, [ctx.fieldRef, ctx.ghost, ctx.value]);\n\n // An `overflow: hidden` box is still scrollable from script, so the mirror can be parked exactly\n // where the field parked its own text — which is what lets a ghost longer than the remaining\n // line be clipped at the edge instead of hidden entirely.\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n const sync = () => {\n box.scrollLeft = el.scrollLeft;\n box.scrollTop = el.scrollTop;\n };\n sync();\n // Does the continuation fit in the space the field actually has? Measured rather than\n // assumed, because the answer changes with the field's size, with the value, and with every\n // chunk that arrives — and after the effect above it should be `false` unless a caller pinned\n // the height.\n setClipped(box.scrollHeight > box.clientHeight);\n el.addEventListener(\"scroll\", sync);\n return () => el.removeEventListener(\"scroll\", sync);\n }, [ctx.caret, ctx.fieldRef, ctx.value, ctx.ghost]);\n\n\n const show = ctx.ghost.length > 0;\n // A caret past the end of the value would split it in the wrong place; the value is the authority.\n const at = Math.min(ctx.caret, ctx.value.length);\n\n return (\n <ark.div\n aria-hidden\n className={cn(\n \"pointer-events-none absolute inset-0 overflow-hidden text-transparent\",\n \"whitespace-pre-wrap break-words\",\n // **A continuation that does not fit fades; it is not guillotined.** The box is\n // `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter\n // against the ✨. Applied only when the measurement above says it is genuinely cut, which\n // is now only when a caller has pinned the field's height.\n clipped && GHOST_FADE,\n !show && \"invisible\",\n className,\n )}\n data-slot=\"complete-ghost\"\n ref={boxRef}\n style={ctx.metrics}\n >\n {/* The value holds its own space so the ghost starts exactly at the caret — `hidden` would\n collapse it. Split in two rather than one leading run: an offer is made *at* the caret,\n and what comes after it has to keep its place or the tail of the sentence shifts. */}\n <ark.span className=\"invisible\">{ctx.value.slice(0, at)}</ark.span>\n <ark.span className=\"text-faint\">{ctx.ghost}</ark.span>\n <ark.span className=\"invisible\">{ctx.value.slice(at)}</ark.span>\n </ark.div>\n );\n}\n\n/**\n * The keys, while there is something to take.\n *\n * The compound already told one class of reader: the `sr-only` region announces *Press Tab to\n * accept, Escape to dismiss* the moment an offer lands. A sighted keyboard user got nothing —\n * `CompleteHint` carries the keys too, so a composition that uses the ghost taught the gesture\n * nowhere. This is the visible twin of that sentence, and it is a part rather than a fixture\n * because only the caller knows where it fits: beside the ✨ in a `block-end` addon, under the\n * field, nowhere at all on a form of eleven where one line of instruction beats eleven.\n *\n * There is no reference to copy. Gmail taught Tab with a popup shown once ever; Copilot puts a\n * toolbar under the pointer, which is a gesture a keyboard cannot make. Naming the key next to the\n * offer is the version that works for both.\n */\nexport function CompleteKeys({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteKeys\");\n if (!ctx.ghost) return null;\n return (\n <ark.span\n className={cn(\"inline-flex items-center gap-2 text-muted-foreground text-xs\", className)}\n data-slot=\"complete-keys\"\n >\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Tab</Kbd> accept\n </ark.span>\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> dismiss\n </ark.span>\n </ark.span>\n );\n}\n\n/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */\nexport function CompleteHint({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteHint\");\n if (!ctx.ghost) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm\", className)}\n data-slot=\"complete-hint\"\n >\n <ark.span className=\"text-faint\">{ctx.ghost.replace(/^\\s+/, \"\")}</ark.span>{\" \"}\n <CompleteKeys />\n </ark.p>\n );\n}\n\n\n/**\n * What went wrong, in place. Renders nothing when nothing did.\n *\n * `Suggest` has always shown its failures — the strip prints them where the candidates would be —\n * and this side showed none at all: the ✨ stopped spinning and the field said nothing, because\n * `error` was on the hook and no part read it. It is a separate part rather than a line inside\n * `CompleteHint` because the ghost surface is usually `CompleteGhost`, an overlay with no room\n * under it, and a failure has to be readable in both.\n */\nexport function CompleteError({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteError\");\n if (ctx.error === null) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 text-destructive-foreground text-sm\", className)}\n data-slot=\"complete-error\"\n role=\"alert\"\n >\n {ctx.error}\n </ark.p>\n );\n}\n"],"names":[],"mappings":";;;;;;;AA6CA;AAEE;AACA;AACA;AACF;AAEgB;AACd;AAAc;AAAY;AAAc;AAAa;AAAiB;AACtE;AAAiB;AAAc;AAAgB;AAAiB;AAChE;AAAkB;AAAoB;AACxC;AAaE;AASiD;AAClC;AACA;AACoB;AACC;AACiC;AAEgB;AAErF;AACA;AACF;;AAeoC;AAA8B;AAY3D;AACL;AAcA;AACE;AAEA;AAGW;AAKX;AAEA;AACA;AAEA;AACA;AACA;AAEgB;AAGlB;AAgBE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AAAc;AAYhB;AACE;AACA;AACA;AACE;AAAmC;AAErC;AAC8E;AAGhF;AAAuB;AAEnB;AAEA;AAEgC;AAClC;AAC0B;AAIP;AAEjB;AACA;AAEgC;AAClC;AAC4B;;AAI5B;AAGkB;AAOlB;AACA;AACA;AACA;AAA4B;AAI5B;AAKA;AAG0B;AAGJ;AAEpB;AAGA;AACA;AAOa;AAEf;AACiD;AAG1B;AACvB;AAAU;AAAO;AAAO;AACb;AACQ;AACD;AAC4B;AAC/B;AACf;AAAU;AAAW;AAAW;AAAQ;AAG1C;AAGO;AAAA;AAMD;AAIR;AAEO;AACL;AACA;AACE;AAAK;AAAJ;AAC+C;AACvC;AACG;AACI;AACD;AACE;AACF;AACC;AACL;AACE;AAEV;AAAA;AAGP;AAUO;AACL;AAIA;AACE;AAAC;AAAA;AACoC;AAIP;AACY;AACxC;AACqC;AACjC;AAAA;AAGV;AAMO;AACL;AAsBA;AACE;AAEA;AACA;AACE;AACA;AAAA;AAOF;AAEA;AACkD;AAOlD;AAEA;AACA;AACE;AACmB;AAErB;AAOkD;AAIpD;AAIA;AACE;AAAK;AAAJ;AACY;AACA;AACT;AACA;AAAA;AAAA;AAAA;AAAA;AAKW;AACF;AACT;AAAA;AAEQ;AACL;AACM;AAKX;AAAwD;AACZ;AACS;AAAA;AAAA;AAG3D;AAgBO;AAEL;AAEE;AAAK;AAAJ;AACwF;AAC7E;AAEV;AACE;AAAQ;AAAM;AAChB;AAEE;AAAQ;AAAM;AAChB;AAAA;AAAA;AAGN;AAGO;AACL;AACA;AAEE;AAAK;AAAJ;AACgG;AACrF;AAEV;AAAgE;AAAY;AAC9D;AAAA;AAAA;AAGpB;AAYO;AACL;AACA;AAEE;AAAK;AAAJ;AACsE;AAC3D;AACL;AAEA;AAAA;AAGX;;;;;;;;;;"}
|
|
1
|
+
{"version":3,"file":"complete.js","sources":["../src/complete.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport * as React from \"react\";\nimport { cn, Kbd } from \"@kanzo-tech/ui\";\nimport { AiMark, type AiMarkProps } from \"./ai-mark.js\";\nimport {\n type AiStatus,\n type InlineCompletionRequest,\n MIN_COMPLETE_LENGTH,\n useInlineCompletion,\n} from \"./use-ai.js\";\n\n/**\n * A `<textarea>`, and there is no `<input>` branch any more.\n *\n * **A one-line field takes candidates, not a continuation.** A continuation drawn over an\n * `<input>` can only ever show what fits in the remaining width — the field's own `scrollLeft`\n * cannot reveal text that is not in its value — so a long offer was unreadable by any gesture and\n * accepting it meant accepting blind. No reference does it either: Gmail continues a body, Copilot\n * an editor, and every single-line field in the wild offers a *list*. That is `Suggest`, and it is\n * already here.\n */\ntype FieldEl = HTMLTextAreaElement;\n\ninterface CompleteCtx {\n fieldRef: React.RefObject<FieldEl | null>;\n value: string;\n /** What is on offer at the caret. Empty when there is nothing. */\n ghost: string;\n /** Where the offer goes. The ghost is drawn here, not at the end of the value. */\n caret: number;\n rtl: boolean;\n status: AiStatus;\n error: string | null;\n /** The value is long enough for `complete` to be asked at all. */\n askable: boolean;\n metrics: React.CSSProperties;\n onChange: (e: React.ChangeEvent<FieldEl>) => void;\n onKeyDown: (e: React.KeyboardEvent<FieldEl>) => void;\n syncCaret: () => void;\n accept: () => void;\n request: () => void;\n}\n\nconst Ctx = React.createContext<CompleteCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <CompleteRoot>`);\n return c;\n};\n\nconst METRICS = [\n \"fontFamily\", \"fontSize\", \"fontWeight\", \"fontStyle\", \"letterSpacing\", \"lineHeight\",\n \"textTransform\", \"paddingTop\", \"paddingRight\", \"paddingBottom\", \"paddingLeft\",\n \"borderTopWidth\", \"borderRightWidth\", \"borderBottomWidth\", \"borderLeftWidth\",\n] as const;\n\ninterface FieldShape {\n metrics: React.CSSProperties;\n rtl: boolean;\n}\n\n// The overlay is positioned against the FIELD, not against the root, because the two are not the\n// same box once the ✨ lives inside an `InputGroup`: the group carries the border the input no\n// longer has, and a mirror pinned to the root paints a character-width off.\nconst px = (v: string) => Number.parseFloat(v) || 0;\n\nconst readField = (el: FieldEl, root: HTMLElement): FieldShape => {\n const cs = getComputedStyle(el);\n const rtl = cs.direction === \"rtl\";\n const box = el.getBoundingClientRect();\n const outer = root.getBoundingClientRect();\n // A scrolling textarea takes its scrollbar out of the text's width, and it takes it from the\n // inline-END side in both directions. Without this the mirror wraps a word earlier or later than\n // the field does wherever scrollbars are not overlays — which is not macOS, so it is exactly the\n // kind of divergence nobody sees here.\n const gutter = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth);\n const metrics: Record<string, string | number> = {\n borderStyle: \"solid\",\n borderColor: \"transparent\",\n insetBlockStart: box.top - outer.top,\n insetBlockEnd: outer.bottom - box.bottom,\n insetInlineStart: rtl ? outer.right - box.right : box.left - outer.left,\n insetInlineEnd:\n (rtl ? box.left - outer.left : outer.right - box.right) + (gutter > 0 ? gutter : 0),\n };\n for (const k of METRICS) metrics[k] = cs[k as keyof CSSStyleDeclaration] as string;\n return { metrics: metrics as React.CSSProperties, rtl };\n};\n\nconst REST: FieldShape = { metrics: {}, rtl: false };\n\n/**\n * A safety net rather than the answer. The field grows to fit its offer, so it runs out of height\n * only where a caller has pinned a `max-height` — and there a fade beats a slice.\n *\n * There were three of these. The two horizontal ones masked a continuation that overran a\n * single-line field, which is the case that no longer exists.\n */\nconst GHOST_FADE =\n \"[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]\";\n\n/** Leading space plus the next word — what one press of the accept-a-word key takes. */\nconst nextWord = (ghost: string) => /^\\s*\\S+/.exec(ghost)?.[0] ?? \"\";\n\nexport interface CompleteRootProps {\n complete: (request: InlineCompletionRequest) => AsyncIterable<string>;\n value: string;\n onValueChange: (value: string) => void;\n debounceMs?: number;\n minLength?: number;\n /**\n * What a screen reader hears once, when a suggestion lands.\n *\n * @default \"Suggestion ready. Press Tab to accept, Escape to dismiss.\"\n */\n announcement?: string;\n className?: string;\n children: React.ReactNode;\n}\n\nexport function CompleteRoot(props: CompleteRootProps) {\n const {\n announcement = \"Suggestion ready. Press Tab to accept, Escape to dismiss.\",\n complete,\n value,\n onValueChange,\n debounceMs,\n minLength,\n className,\n children,\n } = props;\n const completion = useInlineCompletion({ complete, debounceMs, minLength });\n const fieldRef = React.useRef<FieldEl | null>(null);\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [caret, setCaret] = React.useState(value.length);\n const [field, setField] = React.useState<FieldShape>(REST);\n\n const ghost = completion.ghost;\n\n // Where the caret must land after WE changed the value. The browser puts it wherever it likes\n // when React writes a new `value`, and taking a word out of the middle of a sentence is the case\n // that makes it obvious.\n const landing = React.useRef<number | null>(null);\n\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const at = landing.current;\n if (!el || at === null) return;\n landing.current = null;\n el.setSelectionRange(at, at);\n setCaret(at);\n }, [value]);\n\n // The ghost mirror wears the field's real metrics, so it aligns whatever the size/className.\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const root = rootRef.current;\n if (!el || !root) return;\n const sync = () => setField(readField(el, root));\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(el);\n ro.observe(root);\n return () => ro.disconnect();\n }, []);\n\n const dismiss = completion.dismiss;\n\n /**\n * **A range is not an insertion point, so an offer cannot survive one.**\n *\n * `selectionStart` is the HEAD of a selection, not a caret. Selecting a sentence — or pressing\n * ⌘A — moved our caret to the start of it while the offer stayed live, and the continuation was\n * then drawn from there: on top of the words it was made to follow. Taking it with Tab spliced it\n * in at the same wrong place, because `insert` splits the value at this number too.\n *\n * The fix is not to draw it somewhere else. `InlineCompletionRequest` is explicitly the\n * degenerate range — an insertion point — so while text is selected there is no place the offer\n * *could* go, and the honest answer is to let it go. Collapsing the selection asks again, which\n * costs a debounce and no more.\n */\n const syncCaret = React.useCallback(() => {\n const el = fieldRef.current;\n if (!el || landing.current !== null) return;\n const start = el.selectionStart ?? el.value.length;\n if ((el.selectionEnd ?? start) !== start) {\n dismiss();\n return;\n }\n setCaret(start);\n }, [dismiss]);\n\n /**\n * The caret is where the ghost is **drawn**, so ours drifting from the field's is not a missed\n * update — it paints a continuation on top of the value. Seen live: a ghost starting at character\n * zero, over the sentence it was continuing.\n *\n * `selectionchange` is the only event that reports every way a caret moves — arrows, clicks,\n * drags, Home/End, and a script calling `setSelectionRange`. `click` and `keyup` catch most of\n * them and are kept because they fire first; this catches the rest.\n */\n React.useEffect(() => {\n const el = fieldRef.current;\n if (!el) return;\n const onSelectionChange = () => {\n if (document.activeElement === el) syncCaret();\n };\n document.addEventListener(\"selectionchange\", onSelectionChange);\n return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n }, [syncCaret]);\n\n const onChange = React.useCallback(\n (e: React.ChangeEvent<FieldEl>) => {\n const el = e.target;\n const at = el.selectionStart ?? el.value.length;\n setCaret(at);\n onValueChange(el.value);\n completion.setValue(el.value, at);\n },\n [completion, onValueChange],\n );\n\n /** Put `text` in at the caret and leave the caret after it. */\n const insert = React.useCallback(\n (text: string) => {\n const next = value.slice(0, caret) + text + value.slice(caret);\n landing.current = caret + text.length;\n onValueChange(next);\n return { next, at: caret + text.length };\n },\n [caret, onValueChange, value],\n );\n\n const accept = React.useCallback(() => {\n if (!ghost) return;\n insert(ghost);\n completion.dismiss();\n fieldRef.current?.focus();\n }, [completion, ghost, insert]);\n\n // Taking one word is not a new request and not a second machine: the word goes in, the caret\n // moves past it, and the offer survives by the same rule that keeps it alive when the reader\n // types that word themselves. There used to be a `taken` offset here doing it by hand.\n const acceptWord = React.useCallback(() => {\n const word = nextWord(ghost);\n if (!word) return;\n const { next, at } = insert(word);\n completion.setValue(next, at);\n }, [completion, ghost, insert]);\n\n const request = React.useCallback(() => {\n const el = fieldRef.current;\n // Where the offer goes, and the two have to agree. Focusing a field nobody has focused yet puts\n // the DOM caret at 0 while our own is still at the end, and the ghost would then be drawn in one\n // place and inserted in another.\n const at = el && el === document.activeElement ? (el.selectionStart ?? caret) : caret;\n setCaret(at);\n completion.ask(value, at);\n el?.focus();\n el?.setSelectionRange(at, at);\n }, [caret, completion, value]);\n\n const onKeyDown = React.useCallback(\n (e: React.KeyboardEvent<FieldEl>) => {\n if (e.defaultPrevented || !ghost) return;\n // Forward, not right: the key that means \"next word\" is mirrored under RTL, and this is the\n // one place in the compound where a key stands for a direction.\n const forward = field.rtl ? \"ArrowLeft\" : \"ArrowRight\";\n if (e.key === \"Tab\") {\n e.preventDefault();\n accept();\n } else if (e.key === forward && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n acceptWord();\n } else if (e.key === \"Escape\") {\n completion.dismiss();\n }\n },\n [accept, acceptWord, completion, field.rtl, ghost],\n );\n\n const ctx: CompleteCtx = {\n fieldRef, value, ghost, caret,\n rtl: field.rtl,\n status: completion.status,\n error: completion.error,\n askable: value.trim().length >= (minLength ?? MIN_COMPLETE_LENGTH),\n metrics: field.metrics,\n onChange, onKeyDown, syncCaret, accept, request,\n };\n\n return (\n <Ctx.Provider value={ctx}>\n <ark.div className={cn(\"relative w-full min-w-0\", className)} data-slot=\"complete\" ref={rootRef}>\n {children}\n {/* The ghost itself is `aria-hidden` and streams a chunk a frame, so announcing IT would\n read the same sentence twenty times. This sentence changes once, when there is\n something to take. */}\n <ark.span aria-live=\"polite\" className=\"sr-only\" data-slot=\"complete-status\">\n {ghost ? announcement : \"\"}\n </ark.span>\n </ark.div>\n </Ctx.Provider>\n );\n}\n\nexport function CompleteTextarea({ children }: { children: React.ReactElement }) {\n const ctx = useCtx(\"CompleteTextarea\");\n return (\n <ark.textarea\n aria-keyshortcuts={ctx.ghost ? \"Tab Escape\" : undefined}\n asChild\n data-slot=\"complete-textarea\"\n onChange={ctx.onChange}\n onClick={ctx.syncCaret}\n onKeyDown={ctx.onKeyDown}\n onKeyUp={ctx.syncCaret}\n onSelect={ctx.syncCaret}\n ref={ctx.fieldRef as React.Ref<HTMLTextAreaElement>}\n value={ctx.value}\n >\n {children}\n </ark.textarea>\n );\n}\n\n/**\n * The ✨, bound to the stream — the same mark `SuggestTrigger` wears, so an assisted field looks\n * assisted whichever of the two it has.\n *\n * Pressing it **takes what is on offer and asks when there is nothing**. That is one gesture with\n * two readings on purpose: it is the only way to accept without a keyboard, and the only way back\n * after Escape, which otherwise ends the field's assistance until the next keystroke.\n */\nexport function CompleteMark(\n props: Omit<AiMarkProps, \"busy\" | \"offering\"> & {\n /**\n * The accessible name while a suggestion is on offer, when `label` names the mark at rest.\n *\n * @default \"Accept suggestion\"\n */\n offeringLabel?: string;\n }\n) {\n const { label = \"AI assist\", offeringLabel = \"Accept suggestion\", ...rest } = props;\n const ctx = useCtx(\"CompleteMark\");\n const offering = ctx.ghost.length > 0;\n\n return (\n <AiMark\n busy={ctx.status === \"loading\" && !offering}\n // The one binding that legitimately differs from `SuggestMark`, and it is domain and not\n // taste: there is nothing to continue below `minLength`, while a candidate source can answer\n // from an empty field. Everything else about the two marks is bound identically.\n disabled={!offering && !ctx.askable}\n label={offering ? offeringLabel : label}\n offering={offering}\n onClick={offering ? ctx.accept : ctx.request}\n {...rest}\n />\n );\n}\n\n/**\n * The continuation drawn where it will land, over the field itself. It wraps and scrolls with the\n * field rather than measuring whether a line of it fits.\n */\nexport function CompleteGhost({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteGhost\");\n const boxRef = React.useRef<HTMLDivElement>(null);\n const [clipped, setClipped] = React.useState(false);\n\n /**\n * **A continuation that does not fit makes the field taller. It is not cut.**\n *\n * The fade below was the old answer and it was the wrong one: a wrapping field runs out of\n * *height*, and a mask on the last line still reads as a sentence sliced through the middle of a\n * line of type — with, on a `block-end` addon, the ✨ and the keys painted over the slice. Seen on\n * `docs/ai/fields`. An editor gets away with clipping because it can push the following lines\n * down; a `<textarea>` cannot, because the ghost is not in its value. So the field takes the\n * height the offer needs while the offer is on the table, and gives it back when it is not — which\n * is what a textarea does anyway when you type into it. `rows` becomes a floor rather than a fixed\n * size, which is what `rows` already means.\n *\n * **It grows and never shrinks while one offer is live.** Taking a word makes the ghost shorter,\n * and re-measuring down on every word would make the box jitter under the reader mid-sentence for\n * no gain — somebody taking a word is about to take another. It resets the moment there is no\n * ghost, which is every accept, every dismiss and every keystroke that kills the offer.\n *\n */\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n if (!ctx.ghost) {\n el.style.removeProperty(\"min-height\");\n return;\n }\n // `minHeight` and not `height`, measured: a `block-end` addon turns the group into a column\n // flex container and the field is `flex: 1 1 0%` in it, so the flex algorithm computes the used\n // height and an explicit `height` is ignored outright — the inline `104px` was on the element\n // and the box stayed 64. Raising the floor is what the flex algorithm does honour, and it is\n // also what this means: `rows` is a floor, and the offer asks for a taller one.\n const cs = getComputedStyle(el);\n const needed = box.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);\n if (needed > el.getBoundingClientRect().height + 1)\n el.style.setProperty(\"min-height\", `${needed}px`);\n }, [ctx.fieldRef, ctx.ghost, ctx.value]);\n\n // An `overflow: hidden` box is still scrollable from script, so the mirror can be parked exactly\n // where the field parked its own text — which is what lets a ghost longer than the remaining\n // line be clipped at the edge instead of hidden entirely.\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n const sync = () => {\n box.scrollLeft = el.scrollLeft;\n box.scrollTop = el.scrollTop;\n };\n sync();\n // Does the continuation fit in the space the field actually has? Measured rather than\n // assumed, because the answer changes with the field's size, with the value, and with every\n // chunk that arrives — and after the effect above it should be `false` unless a caller pinned\n // the height.\n setClipped(box.scrollHeight > box.clientHeight);\n el.addEventListener(\"scroll\", sync);\n return () => el.removeEventListener(\"scroll\", sync);\n }, [ctx.caret, ctx.fieldRef, ctx.value, ctx.ghost]);\n\n\n const show = ctx.ghost.length > 0;\n // A caret past the end of the value would split it in the wrong place; the value is the authority.\n const at = Math.min(ctx.caret, ctx.value.length);\n\n return (\n <ark.div\n aria-hidden\n className={cn(\n \"pointer-events-none absolute inset-0 overflow-hidden text-transparent\",\n \"whitespace-pre-wrap break-words\",\n // **A continuation that does not fit fades; it is not guillotined.** The box is\n // `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter\n // against the ✨. Applied only when the measurement above says it is genuinely cut, which\n // is now only when a caller has pinned the field's height.\n clipped && GHOST_FADE,\n !show && \"invisible\",\n className,\n )}\n data-slot=\"complete-ghost\"\n ref={boxRef}\n style={ctx.metrics}\n >\n {/* The value holds its own space so the ghost starts exactly at the caret — `hidden` would\n collapse it. Split in two rather than one leading run: an offer is made *at* the caret,\n and what comes after it has to keep its place or the tail of the sentence shifts. */}\n <ark.span className=\"invisible\">{ctx.value.slice(0, at)}</ark.span>\n <ark.span className=\"text-faint\">{ctx.ghost}</ark.span>\n <ark.span className=\"invisible\">{ctx.value.slice(at)}</ark.span>\n </ark.div>\n );\n}\n\n/**\n * The keys, while there is something to take.\n *\n * The compound already told one class of reader: the `sr-only` region announces *Press Tab to\n * accept, Escape to dismiss* the moment an offer lands. A sighted keyboard user got nothing —\n * `CompleteHint` carries the keys too, so a composition that uses the ghost taught the gesture\n * nowhere. This is the visible twin of that sentence, and it is a part rather than a fixture\n * because only the caller knows where it fits: beside the ✨ in a `block-end` addon, under the\n * field, nowhere at all on a form of eleven where one line of instruction beats eleven.\n *\n * There is no reference to copy. Gmail taught Tab with a popup shown once ever; Copilot puts a\n * toolbar under the pointer, which is a gesture a keyboard cannot make. Naming the key next to the\n * offer is the version that works for both.\n */\nexport function CompleteKeys({\n acceptLabel = \"accept\",\n className,\n dismissLabel = \"dismiss\",\n}: {\n /** The word after the `Tab` key. */\n acceptLabel?: string;\n className?: string;\n /** The word after the `Esc` key. */\n dismissLabel?: string;\n}) {\n const ctx = useCtx(\"CompleteKeys\");\n if (!ctx.ghost) return null;\n return (\n <ark.span\n className={cn(\"inline-flex items-center gap-2 text-muted-foreground text-xs\", className)}\n data-slot=\"complete-keys\"\n >\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Tab</Kbd> {acceptLabel}\n </ark.span>\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> {dismissLabel}\n </ark.span>\n </ark.span>\n );\n}\n\n/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */\nexport function CompleteHint({\n acceptLabel,\n className,\n dismissLabel,\n}: {\n /** The word after the `Tab` key, as on `CompleteKeys`. */\n acceptLabel?: string;\n className?: string;\n /** The word after the `Esc` key, as on `CompleteKeys`. */\n dismissLabel?: string;\n}) {\n const ctx = useCtx(\"CompleteHint\");\n if (!ctx.ghost) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm\", className)}\n data-slot=\"complete-hint\"\n >\n <ark.span className=\"text-faint\">{ctx.ghost.replace(/^\\s+/, \"\")}</ark.span>{\" \"}\n <CompleteKeys acceptLabel={acceptLabel} dismissLabel={dismissLabel} />\n </ark.p>\n );\n}\n\n\n/**\n * What went wrong, in place. Renders nothing when nothing did.\n *\n * `Suggest` has always shown its failures — the strip prints them where the candidates would be —\n * and this side showed none at all: the ✨ stopped spinning and the field said nothing, because\n * `error` was on the hook and no part read it. It is a separate part rather than a line inside\n * `CompleteHint` because the ghost surface is usually `CompleteGhost`, an overlay with no room\n * under it, and a failure has to be readable in both.\n */\nexport function CompleteError({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteError\");\n if (ctx.error === null) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 text-destructive-foreground text-sm\", className)}\n data-slot=\"complete-error\"\n role=\"alert\"\n >\n {ctx.error}\n </ark.p>\n );\n}\n"],"names":[],"mappings":";;;;;;;AA6CA;AAEE;AACA;AACA;AACF;AAEgB;AACd;AAAc;AAAY;AAAc;AAAa;AAAiB;AACtE;AAAiB;AAAc;AAAgB;AAAiB;AAChE;AAAkB;AAAoB;AACxC;AAaE;AASiD;AAClC;AACA;AACoB;AACC;AACiC;AAEgB;AAErF;AACA;AACF;;AAeoC;AAA8B;AAkB3D;AACL;AAAM;AACW;AACf;AACA;AACA;AACA;AACA;AACA;AACA;AAeF;AACE;AAEA;AAGW;AAKX;AAEA;AACA;AAEA;AACA;AACA;AAEgB;AAGlB;AAgBE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AAAc;AAYhB;AACE;AACA;AACA;AACE;AAAmC;AAErC;AAC8E;AAGhF;AAAuB;AAEnB;AAEA;AAEgC;AAClC;AAC0B;AAIP;AAEjB;AACA;AAEgC;AAClC;AAC4B;;AAI5B;AAGkB;AAOlB;AACA;AACA;AACA;AAA4B;AAI5B;AAKA;AAG0B;AAGJ;AAEpB;AAGA;AACA;AAOa;AAEf;AACiD;AAG1B;AACvB;AAAU;AAAO;AAAO;AACb;AACQ;AACD;AAC4B;AAC/B;AACf;AAAU;AAAW;AAAW;AAAQ;AAG1C;AAGO;AAAA;AAMD;AAIR;AAEO;AACL;AACA;AACE;AAAK;AAAJ;AAC+C;AACvC;AACG;AACI;AACD;AACE;AACF;AACC;AACL;AACE;AAEV;AAAA;AAGP;AAUO;AAUL;AAIA;AACE;AAAC;AAAA;AACoC;AAIP;AACM;AAClC;AACqC;AACjC;AAAA;AAGV;AAMO;AACL;AAsBA;AACE;AAEA;AACA;AACE;AACA;AAAA;AAOF;AAEA;AACkD;AAOlD;AAEA;AACA;AACE;AACmB;AAErB;AAOkD;AAIpD;AAIA;AACE;AAAK;AAAJ;AACY;AACA;AACT;AACA;AAAA;AAAA;AAAA;AAAA;AAKW;AACF;AACT;AAAA;AAEQ;AACL;AACM;AAKX;AAAwD;AACZ;AACS;AAAA;AAAA;AAG3D;AAgBO;AAAsB;AACb;AACd;AAEF;AAQE;AAEE;AAAK;AAAJ;AACwF;AAC7E;AAEV;AACE;AAAQ;AAAM;AAAE;AAClB;AAEE;AAAQ;AAAM;AAAE;AAClB;AAAA;AAAA;AAGN;AAGO;AAAsB;AAC3B;AACA;AAEF;AAOE;AACA;AAEE;AAAK;AAAJ;AACgG;AACrF;AAEV;AAAgE;AAAY;AACR;AAAA;AAAA;AAG1E;AAYO;AACL;AACA;AAEE;AAAK;AAAJ;AACsE;AAC3D;AACL;AAEA;AAAA;AAGX;;;;;;;;;;"}
|
package/dist/conversation.d.ts
CHANGED
|
@@ -20,12 +20,6 @@ export declare const Conversation: (props: React.ComponentProps<typeof ark.div>)
|
|
|
20
20
|
* declares a role — a message is content, not a widget — so there is no keyboard contract to owe.
|
|
21
21
|
*/
|
|
22
22
|
export declare const ConversationContent: (props: React.ComponentProps<typeof ark.div>) => React.JSX.Element;
|
|
23
|
-
/**
|
|
24
|
-
* The stage for the zero state, not the zero state itself: a centred column with a reading measure,
|
|
25
|
-
* which the caller's markup is composed into. There is deliberately no `title` / `description` /
|
|
26
|
-
* `icon` prop — `EmptyState` was exactly that and it is a tombstone in `@kanzo-tech/ui`.
|
|
27
|
-
*/
|
|
28
|
-
export declare const ConversationEmpty: (props: React.ComponentProps<typeof ark.div>) => React.JSX.Element;
|
|
29
23
|
/**
|
|
30
24
|
* Present only while the pin is released, and pressing it takes the pin back.
|
|
31
25
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../src/conversation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAE5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAM,MAAM,gBAAgB,CAAC;AAmB5C;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBAsDvE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBA0B9E,CAAC;AAEF
|
|
1
|
+
{"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../src/conversation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAE5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAM,MAAM,gBAAgB,CAAC;AAmB5C;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBAsDvE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBA0B9E,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,6BAmClF,CAAC"}
|
package/dist/conversation.js
CHANGED
|
@@ -1,118 +1,101 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as
|
|
3
|
-
import { ark as
|
|
2
|
+
import { jsx as i } from "react/jsx-runtime";
|
|
3
|
+
import { ark as m } from "@ark-ui/react/factory";
|
|
4
4
|
import { ArrowDownIcon as E } from "lucide-react";
|
|
5
|
-
import * as
|
|
6
|
-
import { cn as
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
if (!
|
|
10
|
-
return
|
|
11
|
-
},
|
|
12
|
-
const { className:
|
|
13
|
-
t && (
|
|
5
|
+
import * as r from "react";
|
|
6
|
+
import { cn as v, Button as N } from "@kanzo-tech/ui";
|
|
7
|
+
const h = r.createContext(null), w = (s) => {
|
|
8
|
+
const o = r.useContext(h);
|
|
9
|
+
if (!o) throw new Error(`${s} must render inside <Conversation>`);
|
|
10
|
+
return o;
|
|
11
|
+
}, S = 24, k = (s) => {
|
|
12
|
+
const { className: o, children: n, slot: l, ...a } = s, [t, u] = r.useState(null), [c, C] = r.useState(null), [g, x] = r.useState(!0), f = r.useRef(!0), R = r.useCallback(() => {
|
|
13
|
+
t && (f.current = !0, x(!0), t.scrollTop = t.scrollHeight);
|
|
14
14
|
}, [t]);
|
|
15
|
-
return
|
|
15
|
+
return r.useEffect(() => {
|
|
16
16
|
if (!t) return;
|
|
17
|
-
const
|
|
18
|
-
const p = t.scrollHeight - t.scrollTop - t.clientHeight <=
|
|
19
|
-
|
|
17
|
+
const d = () => {
|
|
18
|
+
const p = t.scrollHeight - t.scrollTop - t.clientHeight <= S;
|
|
19
|
+
f.current = p, x(p);
|
|
20
20
|
};
|
|
21
|
-
|
|
21
|
+
d(), t.addEventListener("scroll", d, { passive: !0 });
|
|
22
22
|
const b = () => {
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
return
|
|
26
|
-
t.removeEventListener("scroll",
|
|
23
|
+
f.current && (t.scrollTop = t.scrollHeight);
|
|
24
|
+
}, e = typeof ResizeObserver > "u" ? null : new ResizeObserver(b);
|
|
25
|
+
return e == null || e.observe(t), c && (e == null || e.observe(c)), () => {
|
|
26
|
+
t.removeEventListener("scroll", d), e == null || e.disconnect();
|
|
27
27
|
};
|
|
28
|
-
}, [t,
|
|
29
|
-
|
|
28
|
+
}, [t, c]), /* @__PURE__ */ i(
|
|
29
|
+
h.Provider,
|
|
30
30
|
{
|
|
31
|
-
value: { viewportRef: u, contentRef: C, pinned: g, scrollToEnd:
|
|
32
|
-
children: /* @__PURE__ */
|
|
33
|
-
|
|
31
|
+
value: { viewportRef: u, contentRef: C, pinned: g, scrollToEnd: R },
|
|
32
|
+
children: /* @__PURE__ */ i(
|
|
33
|
+
m.div,
|
|
34
34
|
{
|
|
35
|
-
className:
|
|
35
|
+
className: v("relative flex min-h-0 flex-1 flex-col", o),
|
|
36
36
|
...a,
|
|
37
|
-
"data-slot":
|
|
38
|
-
children:
|
|
37
|
+
"data-slot": l ?? "conversation",
|
|
38
|
+
children: n
|
|
39
39
|
}
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
42
|
);
|
|
43
|
-
}, B = (
|
|
44
|
-
const { className:
|
|
45
|
-
return /* @__PURE__ */
|
|
46
|
-
|
|
43
|
+
}, B = (s) => {
|
|
44
|
+
const { className: o, children: n, slot: l, ...a } = s, t = w("ConversationContent");
|
|
45
|
+
return /* @__PURE__ */ i(
|
|
46
|
+
m.div,
|
|
47
47
|
{
|
|
48
|
-
className:
|
|
48
|
+
className: v(
|
|
49
49
|
"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain",
|
|
50
50
|
"px-4 py-6",
|
|
51
|
-
|
|
51
|
+
o
|
|
52
52
|
),
|
|
53
53
|
ref: t.viewportRef,
|
|
54
54
|
role: "log",
|
|
55
55
|
...a,
|
|
56
|
-
"data-slot":
|
|
57
|
-
children: /* @__PURE__ */
|
|
58
|
-
|
|
56
|
+
"data-slot": l ?? "conversation-content",
|
|
57
|
+
children: /* @__PURE__ */ i(
|
|
58
|
+
m.div,
|
|
59
59
|
{
|
|
60
60
|
className: "mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6",
|
|
61
61
|
ref: t.contentRef,
|
|
62
|
-
children:
|
|
62
|
+
children: n
|
|
63
63
|
}
|
|
64
64
|
)
|
|
65
65
|
}
|
|
66
66
|
);
|
|
67
|
-
}, P = (
|
|
68
|
-
const { className:
|
|
69
|
-
return /* @__PURE__ */
|
|
70
|
-
|
|
67
|
+
}, P = (s) => {
|
|
68
|
+
const { className: o, children: n, onClick: l, slot: a, ...t } = s, u = w("ConversationScrollButton");
|
|
69
|
+
return u.pinned ? null : /* @__PURE__ */ i(
|
|
70
|
+
N,
|
|
71
71
|
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"mx-auto max-w-md px-4 py-12",
|
|
75
|
-
"text-balance text-center text-muted-foreground text-sm",
|
|
76
|
-
"[&_svg:not([class*='size-'])]:size-6",
|
|
77
|
-
e
|
|
78
|
-
),
|
|
79
|
-
...s,
|
|
80
|
-
"data-slot": o ?? "conversation-empty"
|
|
81
|
-
}
|
|
82
|
-
);
|
|
83
|
-
}, j = (n) => {
|
|
84
|
-
const { className: e, children: o, onClick: s, slot: a, ...t } = n, u = h("ConversationScrollButton");
|
|
85
|
-
return u.pinned ? null : /* @__PURE__ */ c(
|
|
86
|
-
R,
|
|
87
|
-
{
|
|
88
|
-
"aria-label": o === void 0 ? "Scroll to the latest message" : void 0,
|
|
89
|
-
className: m(
|
|
72
|
+
"aria-label": n === void 0 ? "Scroll to the latest message" : void 0,
|
|
73
|
+
className: v(
|
|
90
74
|
"absolute inset-x-0 bottom-4 z-10 mx-auto",
|
|
91
75
|
// `inset-x-0` is what centres it, and it also stretches it, so the width has to be stated.
|
|
92
76
|
// The icon size states its own — `w-(--size)`, square — and a `w-fit` here would win the
|
|
93
77
|
// merge and hand it the icon's width against the control's height.
|
|
94
|
-
|
|
78
|
+
n === void 0 ? void 0 : "w-fit",
|
|
95
79
|
"[--btn-bg:var(--background)] shadow-lg/5",
|
|
96
80
|
"fade-in-0 zoom-in-95 slide-in-from-bottom-2 animate-in",
|
|
97
81
|
"motion-reduce:animate-none!",
|
|
98
|
-
|
|
82
|
+
o
|
|
99
83
|
),
|
|
100
|
-
onClick: (
|
|
101
|
-
|
|
84
|
+
onClick: (c) => {
|
|
85
|
+
l == null || l(c), c.defaultPrevented || u.scrollToEnd();
|
|
102
86
|
},
|
|
103
87
|
pill: !0,
|
|
104
|
-
size:
|
|
88
|
+
size: n === void 0 ? "icon-sm" : "sm",
|
|
105
89
|
variant: "outline",
|
|
106
90
|
...t,
|
|
107
91
|
slot: a ?? "conversation-scroll-button",
|
|
108
|
-
children:
|
|
92
|
+
children: n ?? /* @__PURE__ */ i(E, {})
|
|
109
93
|
}
|
|
110
94
|
);
|
|
111
95
|
};
|
|
112
96
|
export {
|
|
113
97
|
k as Conversation,
|
|
114
98
|
B as ConversationContent,
|
|
115
|
-
P as
|
|
116
|
-
j as ConversationScrollButton
|
|
99
|
+
P as ConversationScrollButton
|
|
117
100
|
};
|
|
118
101
|
//# sourceMappingURL=conversation.js.map
|
package/dist/conversation.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation.js","sources":["../src/conversation.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button, cn } from \"@kanzo-tech/ui\";\n\ninterface ConversationCtx {\n viewportRef: (el: HTMLDivElement | null) => void;\n contentRef: (el: HTMLDivElement | null) => void;\n pinned: boolean;\n scrollToEnd: () => void;\n}\n\nconst Ctx = React.createContext<ConversationCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <Conversation>`);\n return c;\n};\n\n/** A scroll position is fractional and rarely lands on the end exactly; this is the allowance. */\nconst AT_END = 24;\n\n/**\n * The transcript's scroll container, and the pin is the whole component.\n *\n * **The naive version, which both real consumers wrote:** an effect on the message array calling\n * `scrollTo({ top: scrollHeight, behavior: \"smooth\" })`. It follows the tail unconditionally, so a\n * reader who scrolled up to re-read something is dragged back down by the next token, every token,\n * for as long as the answer is streaming. Following is only correct while the reader is *already*\n * at the tail: scrolling up releases the pin, and `ConversationScrollButton` is how they take it\n * back. Roughly forty lines of listener and observer, and no dependency.\n */\nexport const Conversation = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);\n const [content, setContent] = React.useState<HTMLDivElement | null>(null);\n const [pinned, setPinned] = React.useState(true);\n const pinnedRef = React.useRef(true);\n\n const scrollToEnd = React.useCallback(() => {\n if (!viewport) return;\n pinnedRef.current = true;\n setPinned(true);\n viewport.scrollTop = viewport.scrollHeight;\n }, [viewport]);\n\n React.useEffect(() => {\n if (!viewport) return;\n\n const sync = () => {\n const atEnd = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= AT_END;\n pinnedRef.current = atEnd;\n setPinned(atEnd);\n };\n sync();\n viewport.addEventListener(\"scroll\", sync, { passive: true });\n\n // A stream produces *growth*, not scrolling: the content gets taller under a stationary\n // `scrollTop`, and no scroll event is fired for that. Observing the viewport alone never sees\n // it either — its own box did not change — which is why the inner element is observed too.\n const follow = () => {\n if (pinnedRef.current) viewport.scrollTop = viewport.scrollHeight;\n };\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(follow);\n observer?.observe(viewport);\n if (content) observer?.observe(content);\n\n return () => {\n viewport.removeEventListener(\"scroll\", sync);\n observer?.disconnect();\n };\n }, [viewport, content]);\n\n return (\n <Ctx.Provider\n value={{ viewportRef: setViewport, contentRef: setContent, pinned, scrollToEnd }}\n >\n <ark.div\n className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n {...rest}\n data-slot={slot ?? \"conversation\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n};\n\n/**\n * The scrolling region.\n *\n * ARIA: `role=\"log\"` carries an implicit `aria-live=\"polite\"`, so a message appended while the\n * reader is somewhere else is announced after what they are on rather than over it. Nothing inside\n * declares a role — a message is content, not a widget — so there is no keyboard contract to owe.\n */\nexport const ConversationContent = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationContent\");\n\n return (\n <ark.div\n className={cn(\n \"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain\",\n \"px-4 py-6\",\n className\n )}\n ref={ctx.viewportRef}\n role=\"log\"\n {...rest}\n data-slot={slot ?? \"conversation-content\"}\n >\n {/* `flex-1` gives the inner element the viewport's height when the transcript is short, which\n is what lets `
|
|
1
|
+
{"version":3,"file":"conversation.js","sources":["../src/conversation.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button, cn } from \"@kanzo-tech/ui\";\n\ninterface ConversationCtx {\n viewportRef: (el: HTMLDivElement | null) => void;\n contentRef: (el: HTMLDivElement | null) => void;\n pinned: boolean;\n scrollToEnd: () => void;\n}\n\nconst Ctx = React.createContext<ConversationCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <Conversation>`);\n return c;\n};\n\n/** A scroll position is fractional and rarely lands on the end exactly; this is the allowance. */\nconst AT_END = 24;\n\n/**\n * The transcript's scroll container, and the pin is the whole component.\n *\n * **The naive version, which both real consumers wrote:** an effect on the message array calling\n * `scrollTo({ top: scrollHeight, behavior: \"smooth\" })`. It follows the tail unconditionally, so a\n * reader who scrolled up to re-read something is dragged back down by the next token, every token,\n * for as long as the answer is streaming. Following is only correct while the reader is *already*\n * at the tail: scrolling up releases the pin, and `ConversationScrollButton` is how they take it\n * back. Roughly forty lines of listener and observer, and no dependency.\n */\nexport const Conversation = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);\n const [content, setContent] = React.useState<HTMLDivElement | null>(null);\n const [pinned, setPinned] = React.useState(true);\n const pinnedRef = React.useRef(true);\n\n const scrollToEnd = React.useCallback(() => {\n if (!viewport) return;\n pinnedRef.current = true;\n setPinned(true);\n viewport.scrollTop = viewport.scrollHeight;\n }, [viewport]);\n\n React.useEffect(() => {\n if (!viewport) return;\n\n const sync = () => {\n const atEnd = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= AT_END;\n pinnedRef.current = atEnd;\n setPinned(atEnd);\n };\n sync();\n viewport.addEventListener(\"scroll\", sync, { passive: true });\n\n // A stream produces *growth*, not scrolling: the content gets taller under a stationary\n // `scrollTop`, and no scroll event is fired for that. Observing the viewport alone never sees\n // it either — its own box did not change — which is why the inner element is observed too.\n const follow = () => {\n if (pinnedRef.current) viewport.scrollTop = viewport.scrollHeight;\n };\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(follow);\n observer?.observe(viewport);\n if (content) observer?.observe(content);\n\n return () => {\n viewport.removeEventListener(\"scroll\", sync);\n observer?.disconnect();\n };\n }, [viewport, content]);\n\n return (\n <Ctx.Provider\n value={{ viewportRef: setViewport, contentRef: setContent, pinned, scrollToEnd }}\n >\n <ark.div\n className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n {...rest}\n data-slot={slot ?? \"conversation\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n};\n\n/**\n * The scrolling region.\n *\n * ARIA: `role=\"log\"` carries an implicit `aria-live=\"polite\"`, so a message appended while the\n * reader is somewhere else is announced after what they are on rather than over it. Nothing inside\n * declares a role — a message is content, not a widget — so there is no keyboard contract to owe.\n */\nexport const ConversationContent = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationContent\");\n\n return (\n <ark.div\n className={cn(\n \"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain\",\n \"px-4 py-6\",\n className\n )}\n ref={ctx.viewportRef}\n role=\"log\"\n {...rest}\n data-slot={slot ?? \"conversation-content\"}\n >\n {/* `flex-1` gives the inner element the viewport's height when the transcript is short, which\n is what lets an `EmptyRoot` centre the zero state in the panel rather than sit at the top. */}\n <ark.div\n className=\"mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6\"\n ref={ctx.contentRef}\n >\n {children}\n </ark.div>\n </ark.div>\n );\n};\n\n/**\n * Present only while the pin is released, and pressing it takes the pin back.\n *\n * It floats *over* a transcript that is still growing underneath it, which is why it enters rather\n * than appears and why it paints its own ground: `variant=\"outline\"` sets `--btn-bg: transparent`,\n * so without a ground of its own the message it covers reads straight through it.\n *\n * It asks for that ground by **moving the variable**, not by writing `bg-background` on top. The\n * two look identical at rest and diverge the moment you hover: the wash is\n * `color-mix(…, var(--btn-bg), …)`, so a `bg-background` that only overrode the resting fill left\n * the hover mixing from `transparent` and the transcript showed through the button exactly when the\n * pointer was on it. `--btn-bg` is the seam the recipe documents; this is what it is for.\n */\nexport const ConversationScrollButton = (props: React.ComponentProps<typeof Button>) => {\n const { className, children, onClick, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationScrollButton\");\n\n if (ctx.pinned) return null;\n\n return (\n <Button\n aria-label={children === undefined ? \"Scroll to the latest message\" : undefined}\n className={cn(\n \"absolute inset-x-0 bottom-4 z-10 mx-auto\",\n // `inset-x-0` is what centres it, and it also stretches it, so the width has to be stated.\n // The icon size states its own — `w-(--size)`, square — and a `w-fit` here would win the\n // merge and hand it the icon's width against the control's height.\n children === undefined ? undefined : \"w-fit\",\n \"[--btn-bg:var(--background)] shadow-lg/5\",\n \"fade-in-0 zoom-in-95 slide-in-from-bottom-2 animate-in\",\n \"motion-reduce:animate-none!\",\n className\n )}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) ctx.scrollToEnd();\n }}\n pill\n // Icon-only by default; give it words and it becomes a pill wide enough to hold them, the way\n // `PromptInputSubmit` does.\n size={children === undefined ? \"icon-sm\" : \"sm\"}\n variant=\"outline\"\n {...rest}\n slot={slot ?? \"conversation-scroll-button\"}\n >\n {children ?? <ArrowDownIcon />}\n </Button>\n );\n};\n"],"names":[],"mappings":";;;;;;AAcA;AAEE;AACA;AACA;AACF;AAgBE;AAOE;AAG8B;AAGhC;AACE;AAEA;AACE;AACA;AACe;AAEjB;AAMA;AACE;AAAqD;AAGvD;AAIE;AACU;AACZ;AAIA;AAAK;AAAJ;AACoE;AAEnE;AAAK;AAAJ;AACiE;AAC5D;AACe;AAElB;AAAA;AAAA;AACH;AAGN;AAUE;AAGA;AACE;AAAK;AAAJ;AACY;AACT;AACA;AACA;AAAA;AAEO;AACJ;AACD;AACe;AAInB;AAAK;AAAJ;AACW;AACD;AAER;AAAA;AAAA;AACH;AAGN;AAgBE;AAGA;AAGE;AAAC;AAAA;AACuE;AAC3D;AACT;AAAA;AAAA;AAAA;AAIqC;AACrC;AACA;AACA;AACA;AAAA;AAGA;AACiC;AACnC;AACI;AAGuC;AACnC;AACJ;AACU;AAEc;AAAA;AAGlC;;;;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Conversation, ConversationContent,
|
|
1
|
+
export { Conversation, ConversationContent, ConversationScrollButton, } from './conversation.js';
|
|
2
2
|
export { Message, MessageActions, MessageAvatar, MessageContent, MessageList, } from './message.js';
|
|
3
3
|
export { MessageText } from './message-text.js';
|
|
4
4
|
export type { MessageProps, MessageAvatarProps, MessageRole } from './message.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,OAAO,EACP,cAAc,EACd,aAAa,EACb,cAAc,EACd,WAAW,GACZ,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAClF,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAG1D,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAGhE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACjF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrD,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,GACb,MAAM,eAAe,CAAC;AAIvB,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AACrE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvF,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,0BAA0B,EAC1B,qBAAqB,GACtB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI5C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACxF,YAAY,EACV,SAAS,EACT,aAAa,EACb,eAAe,EACf,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,GACZ,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,60 +1,59 @@
|
|
|
1
|
-
import { Conversation as
|
|
2
|
-
import { Message as
|
|
3
|
-
import { MessageText as
|
|
4
|
-
import { PromptInput as
|
|
5
|
-
import { Reasoning as
|
|
6
|
-
import { Tool as
|
|
7
|
-
import { Task as
|
|
8
|
-
import { CompleteError as
|
|
9
|
-
import { AiMark as
|
|
10
|
-
import { SuggestList as
|
|
11
|
-
import { cleanGhost as
|
|
12
|
-
import { isReasoningPart as
|
|
1
|
+
import { Conversation as t, ConversationContent as r, ConversationScrollButton as s } from "./conversation.js";
|
|
2
|
+
import { Message as n, MessageActions as p, MessageAvatar as m, MessageContent as i, MessageList as g } from "./message.js";
|
|
3
|
+
import { MessageText as T } from "./message-text.js";
|
|
4
|
+
import { PromptInput as x, PromptInputSubmit as C, PromptInputTextarea as f, PromptInputToolbar as M } from "./prompt-input.js";
|
|
5
|
+
import { Reasoning as P, ReasoningContent as S, ReasoningTrigger as I } from "./reasoning.js";
|
|
6
|
+
import { Tool as v, ToolContent as A, ToolHeader as c, ToolInput as L, ToolOutput as b } from "./tool.js";
|
|
7
|
+
import { Task as G, TaskList as H, TaskStatus as d, TaskTitle as y } from "./task.js";
|
|
8
|
+
import { CompleteError as E, CompleteGhost as K, CompleteHint as O, CompleteKeys as j, CompleteMark as q, CompleteRoot as w, CompleteTextarea as z } from "./complete.js";
|
|
9
|
+
import { AiMark as F } from "./ai-mark.js";
|
|
10
|
+
import { SuggestList as N, SuggestMark as Q, SuggestRoot as U } from "./suggest.js";
|
|
11
|
+
import { cleanGhost as W, useAiStream as X, useInlineCompletion as Y, useSuggestions as Z } from "./use-ai.js";
|
|
12
|
+
import { isReasoningPart as $, isTaskPart as oo, isTextPart as eo, isToolPart as to } from "./message-part.js";
|
|
13
13
|
export {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
F as AiMark,
|
|
15
|
+
E as CompleteError,
|
|
16
|
+
K as CompleteGhost,
|
|
17
|
+
O as CompleteHint,
|
|
18
|
+
j as CompleteKeys,
|
|
19
|
+
q as CompleteMark,
|
|
20
|
+
w as CompleteRoot,
|
|
21
|
+
z as CompleteTextarea,
|
|
22
|
+
t as Conversation,
|
|
23
23
|
r as ConversationContent,
|
|
24
|
-
s as
|
|
25
|
-
|
|
26
|
-
p as
|
|
27
|
-
m as
|
|
28
|
-
i as
|
|
29
|
-
g as
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
C as
|
|
33
|
-
f as
|
|
34
|
-
M as
|
|
35
|
-
|
|
36
|
-
S as
|
|
37
|
-
I as
|
|
38
|
-
|
|
39
|
-
Q as
|
|
40
|
-
U as
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
A as
|
|
47
|
-
c as
|
|
48
|
-
L as
|
|
49
|
-
b as
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
oo as
|
|
53
|
-
to as isTaskPart,
|
|
24
|
+
s as ConversationScrollButton,
|
|
25
|
+
n as Message,
|
|
26
|
+
p as MessageActions,
|
|
27
|
+
m as MessageAvatar,
|
|
28
|
+
i as MessageContent,
|
|
29
|
+
g as MessageList,
|
|
30
|
+
T as MessageText,
|
|
31
|
+
x as PromptInput,
|
|
32
|
+
C as PromptInputSubmit,
|
|
33
|
+
f as PromptInputTextarea,
|
|
34
|
+
M as PromptInputToolbar,
|
|
35
|
+
P as Reasoning,
|
|
36
|
+
S as ReasoningContent,
|
|
37
|
+
I as ReasoningTrigger,
|
|
38
|
+
N as SuggestList,
|
|
39
|
+
Q as SuggestMark,
|
|
40
|
+
U as SuggestRoot,
|
|
41
|
+
G as Task,
|
|
42
|
+
H as TaskList,
|
|
43
|
+
d as TaskStatus,
|
|
44
|
+
y as TaskTitle,
|
|
45
|
+
v as Tool,
|
|
46
|
+
A as ToolContent,
|
|
47
|
+
c as ToolHeader,
|
|
48
|
+
L as ToolInput,
|
|
49
|
+
b as ToolOutput,
|
|
50
|
+
W as cleanGhost,
|
|
51
|
+
$ as isReasoningPart,
|
|
52
|
+
oo as isTaskPart,
|
|
54
53
|
eo as isTextPart,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
to as isToolPart,
|
|
55
|
+
X as useAiStream,
|
|
56
|
+
Y as useInlineCompletion,
|
|
57
|
+
Z as useSuggestions
|
|
59
58
|
};
|
|
60
59
|
//# sourceMappingURL=index.js.map
|
package/dist/suggest.d.ts
CHANGED
|
@@ -53,7 +53,14 @@ export declare function SuggestRoot(props: SuggestRootProps): React.JSX.Element;
|
|
|
53
53
|
* Pressing it asks. It no longer opens or closes a layer, so there is no `data-state` to light it
|
|
54
54
|
* from and nothing to trap focus in.
|
|
55
55
|
*/
|
|
56
|
-
export declare function SuggestMark(props: Omit<AiMarkProps, "busy" | "offering">
|
|
56
|
+
export declare function SuggestMark(props: Omit<AiMarkProps, "busy" | "offering"> & {
|
|
57
|
+
/**
|
|
58
|
+
* The accessible name while candidates are on offer, when `label` names the mark at rest.
|
|
59
|
+
*
|
|
60
|
+
* @default "Suggest different values"
|
|
61
|
+
*/
|
|
62
|
+
offeringLabel?: string;
|
|
63
|
+
}): React.JSX.Element;
|
|
57
64
|
export interface SuggestListProps extends React.ComponentProps<typeof Suggestions> {
|
|
58
65
|
/** Shown while the source is answering and has produced nothing yet. */
|
|
59
66
|
pending?: React.ReactNode;
|
package/dist/suggest.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"suggest.d.ts","sourceRoot":"","sources":["../src/suggest.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAC5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAgD,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAIxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAqB5C,gCAAgC;AAChC,MAAM,MAAM,cAAc;AACxB,4EAA4E;AAC1E,OAAO;AACT,yFAAyF;GACvF,OAAO,CAAC;AAEZ,MAAM,WAAW,gBACf,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC;IAC9D,+DAA+D;IAC/D,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,aAAa,CAAC,SAAS,CAAC,CAAC;IAC5D,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,iCAAiC;IACjC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;OAEG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,qBA2ElD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,
|
|
1
|
+
{"version":3,"file":"suggest.d.ts","sourceRoot":"","sources":["../src/suggest.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAC5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAgD,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAIxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAqB5C,gCAAgC;AAChC,MAAM,MAAM,cAAc;AACxB,4EAA4E;AAC1E,OAAO;AACT,yFAAyF;GACvF,OAAO,CAAC;AAEZ,MAAM,WAAW,gBACf,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC;IAC9D,+DAA+D;IAC/D,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,aAAa,CAAC,SAAS,CAAC,CAAC;IAC5D,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,iCAAiC;IACjC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;OAEG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,qBA2ElD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG;IAC9C;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,qBAoBF;AAED,MAAM,WAAW,gBAAiB,SAAQ,KAAK,CAAC,cAAc,CAAC,OAAO,WAAW,CAAC;IAChF,wEAAwE;IACxE,OAAO,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,4DAA4D;IAC5D,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,4BAiElD"}
|
package/dist/suggest.js
CHANGED
|
@@ -3,9 +3,9 @@ import { jsx as r, jsxs as x } from "react/jsx-runtime";
|
|
|
3
3
|
import { XIcon as w } from "lucide-react";
|
|
4
4
|
import { ark as d } from "@ark-ui/react/factory";
|
|
5
5
|
import * as f from "react";
|
|
6
|
-
import { ButtonGroup as
|
|
7
|
-
import { AiMark as
|
|
8
|
-
import { useSuggestions as
|
|
6
|
+
import { ButtonGroup as L, Suggestion as M, Button as P, Spinner as R, Suggestions as T, cn as h } from "@kanzo-tech/ui";
|
|
7
|
+
import { AiMark as j } from "./ai-mark.js";
|
|
8
|
+
import { useSuggestions as A } from "./use-ai.js";
|
|
9
9
|
const S = f.createContext(null), k = (o) => {
|
|
10
10
|
const n = f.useContext(S);
|
|
11
11
|
if (!n) throw new Error(`${o} must render inside <SuggestRoot>`);
|
|
@@ -14,18 +14,18 @@ const S = f.createContext(null), k = (o) => {
|
|
|
14
14
|
function I(o) {
|
|
15
15
|
const {
|
|
16
16
|
suggest: n,
|
|
17
|
-
existing:
|
|
18
|
-
onPick:
|
|
19
|
-
limit:
|
|
20
|
-
trigger:
|
|
17
|
+
existing: c,
|
|
18
|
+
onPick: l,
|
|
19
|
+
limit: m,
|
|
20
|
+
trigger: a = "press",
|
|
21
21
|
children: t,
|
|
22
|
-
className:
|
|
22
|
+
className: u,
|
|
23
23
|
onBlur: e,
|
|
24
24
|
onFocus: g,
|
|
25
25
|
slot: v,
|
|
26
|
-
...
|
|
27
|
-
} = o, s =
|
|
28
|
-
|
|
26
|
+
...b
|
|
27
|
+
} = o, s = A({ suggest: n, existing: c, limit: m }), [N, p] = f.useState(!1), y = (i) => {
|
|
28
|
+
l(i), s.dismiss(i);
|
|
29
29
|
}, C = () => {
|
|
30
30
|
s.status !== "loading" && (s.status === "error" || s.items.length > 0 ? s.refresh() : s.ask());
|
|
31
31
|
};
|
|
@@ -36,7 +36,7 @@ function I(o) {
|
|
|
36
36
|
candidates: s.items,
|
|
37
37
|
status: s.status,
|
|
38
38
|
error: s.error,
|
|
39
|
-
active:
|
|
39
|
+
active: N,
|
|
40
40
|
ask: C,
|
|
41
41
|
pick: y,
|
|
42
42
|
dismiss: s.dismiss
|
|
@@ -44,14 +44,14 @@ function I(o) {
|
|
|
44
44
|
children: /* @__PURE__ */ r(
|
|
45
45
|
d.div,
|
|
46
46
|
{
|
|
47
|
-
className: h("flex w-full min-w-0 flex-col gap-2",
|
|
47
|
+
className: h("flex w-full min-w-0 flex-col gap-2", u),
|
|
48
48
|
onBlur: (i) => {
|
|
49
49
|
e == null || e(i), i.currentTarget.contains(i.relatedTarget) || p(!1);
|
|
50
50
|
},
|
|
51
51
|
onFocus: (i) => {
|
|
52
|
-
g == null || g(i), p(!0),
|
|
52
|
+
g == null || g(i), p(!0), a === "focus" && s.ask();
|
|
53
53
|
},
|
|
54
|
-
...
|
|
54
|
+
...b,
|
|
55
55
|
"data-slot": v ?? "suggest",
|
|
56
56
|
children: t
|
|
57
57
|
}
|
|
@@ -60,24 +60,24 @@ function I(o) {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
function X(o) {
|
|
63
|
-
const { label: n = "Suggest", onClick: l, ...
|
|
63
|
+
const { label: n = "Suggest", offeringLabel: c = "Suggest different values", onClick: l, ...m } = o, a = k("SuggestMark"), t = a.candidates.length > 0;
|
|
64
64
|
return /* @__PURE__ */ r(
|
|
65
|
-
|
|
65
|
+
j,
|
|
66
66
|
{
|
|
67
|
-
busy: a.status === "loading" && !
|
|
68
|
-
label:
|
|
69
|
-
offering:
|
|
70
|
-
onClick: (
|
|
71
|
-
l == null || l(
|
|
67
|
+
busy: a.status === "loading" && !t,
|
|
68
|
+
label: t ? c : n,
|
|
69
|
+
offering: t,
|
|
70
|
+
onClick: (u) => {
|
|
71
|
+
l == null || l(u), u.defaultPrevented || a.ask();
|
|
72
72
|
},
|
|
73
|
-
...
|
|
73
|
+
...m
|
|
74
74
|
}
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
function q(o) {
|
|
78
|
-
const { className: n, empty:
|
|
78
|
+
const { className: n, empty: c, pending: l, slot: m, ...a } = o, t = k("SuggestList");
|
|
79
79
|
if (!t.active) return null;
|
|
80
|
-
const
|
|
80
|
+
const u = t.error !== null ? /* @__PURE__ */ r(d.p, { className: "text-destructive-foreground text-sm", "data-slot": "suggest-error", children: t.error }) : t.candidates.length > 0 ? t.candidates.map((e) => {
|
|
81
81
|
const g = e.label ?? e.value;
|
|
82
82
|
return (
|
|
83
83
|
// **Two buttons, so a group — not a ✕ inside the pill.** `Suggestion` *is* a
|
|
@@ -85,10 +85,10 @@ function q(o) {
|
|
|
85
85
|
// to be a sibling. `ButtonGroup` is the house cluster: it collapses the inner radii and
|
|
86
86
|
// overlaps the shared border, so the pair reads as one pill with a divided end, and it
|
|
87
87
|
// requires the accessible name that a `role="group"` owes.
|
|
88
|
-
/* @__PURE__ */ x(
|
|
89
|
-
/* @__PURE__ */ r(
|
|
88
|
+
/* @__PURE__ */ x(L, { "aria-label": g, slot: "suggest-item", children: [
|
|
89
|
+
/* @__PURE__ */ r(M, { onSelect: t.pick, title: e.rationale, value: e.value, children: g }),
|
|
90
90
|
/* @__PURE__ */ r(
|
|
91
|
-
|
|
91
|
+
P,
|
|
92
92
|
{
|
|
93
93
|
"aria-label": `Dismiss ${g}`,
|
|
94
94
|
className: "h-auto min-h-[24px] self-stretch px-2 py-1",
|
|
@@ -102,18 +102,18 @@ function q(o) {
|
|
|
102
102
|
)
|
|
103
103
|
] }, e.value)
|
|
104
104
|
);
|
|
105
|
-
}) : t.status === "loading" ?
|
|
105
|
+
}) : t.status === "loading" ? l ?? /* @__PURE__ */ x(
|
|
106
106
|
d.span,
|
|
107
107
|
{
|
|
108
108
|
className: "inline-flex items-center gap-2 text-muted-foreground text-sm",
|
|
109
109
|
"data-slot": "suggest-pending",
|
|
110
110
|
children: [
|
|
111
|
-
/* @__PURE__ */ r(
|
|
111
|
+
/* @__PURE__ */ r(R, { "aria-hidden": !0 }),
|
|
112
112
|
"Thinking…"
|
|
113
113
|
]
|
|
114
114
|
}
|
|
115
|
-
) : t.status === "ready" ?
|
|
116
|
-
return
|
|
115
|
+
) : t.status === "ready" ? c ?? /* @__PURE__ */ r(d.span, { className: "text-muted-foreground text-sm", "data-slot": "suggest-empty", children: "Nothing to suggest." }) : null;
|
|
116
|
+
return u === null ? null : /* @__PURE__ */ r(T, { className: h("min-h-8", n), ...a, slot: m ?? "suggest-list", children: u });
|
|
117
117
|
}
|
|
118
118
|
export {
|
|
119
119
|
q as SuggestList,
|
package/dist/suggest.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"suggest.js","sources":["../src/suggest.tsx"],"sourcesContent":["\"use client\";\n\nimport { XIcon } from \"lucide-react\";\nimport { ark } from \"@ark-ui/react/factory\";\nimport * as React from \"react\";\nimport { Button, ButtonGroup, cn, Spinner, Suggestion, Suggestions } from \"@kanzo-tech/ui\";\nimport { AiMark, type AiMarkProps } from \"./ai-mark.js\";\n// No alias any more, and that is the whole of this rename: the type and `@kanzo-tech/ui`'s\n// `Suggestion` component were both in scope in this file, both on public barrels, and one of them\n// had to be renamed at the import to be usable at all.\nimport type { Candidate } from \"./types.js\";\nimport { type AiStatus, useSuggestions } from \"./use-ai.js\";\n\ninterface SuggestCtx {\n candidates: Candidate[];\n status: AiStatus;\n error: string | null;\n /** The field, or something inside it, holds focus. */\n active: boolean;\n ask: () => void;\n pick: (value: string) => void;\n dismiss: (value: string) => void;\n}\n\nconst Ctx = React.createContext<SuggestCtx | null>(null);\nconst useCtx = (part: string): SuggestCtx => {\n const context = React.useContext(Ctx);\n if (!context) throw new Error(`${part} must render inside <SuggestRoot>`);\n return context;\n};\n\n/** When the source is asked. */\nexport type SuggestTrigger =\n /** On a press of the ✨. Nothing is requested until somebody asks for it. */\n | \"press\"\n /** As soon as the field takes focus. Prefetches, and bills a model for a tab-through. */\n | \"focus\";\n\nexport interface SuggestRootProps\n extends Omit<React.ComponentProps<typeof ark.div>, \"onSelect\"> {\n /** The source. Yields candidates; honour the `AbortSignal`. */\n suggest: (signal?: AbortSignal) => AsyncIterable<Candidate>;\n /** What the field already holds — these are never offered back. */\n existing?: string[];\n /** Where a chosen value goes. */\n onPick: (value: string) => void;\n /**\n * How many to take from the stream.\n *\n * @default 6\n */\n limit?: number;\n /**\n * @default \"press\"\n */\n trigger?: SuggestTrigger;\n}\n\n/**\n * Candidate values for one field, offered **in the flow underneath it**.\n *\n * This replaced a popover, and the popover is the whole story. Hiding the candidates behind a door\n * bought nothing and cost six workarounds, every one of them downstream of the door and not of the\n * candidates: a non-modal popover, because a modal one put `pointer-events: none` on the very field\n * the values were for; a `data-autofocus`, because a popover has to hand focus somewhere; a\n * `Delete`/`Backspace` handler, because a button inside `role=\"option\"` is unreachable by Tab; a\n * shared label id, because a popover names itself from a title and a listbox from a label; a ✕ that\n * had to be quieted because an offer looked identical to a committed tag; and a three-row window\n * with a refill loop in the hook to keep it full. None of them exist here.\n *\n * **Focus decides visibility, a press decides billing**, and they are deliberately not the same\n * gesture. The strip appears while the field has focus and there is something to show — which is\n * what keeps a form of eleven fields from becoming a wall, since only one field is focused at a\n * time. Asking is `trigger`, and it defaults to a press: a source that fires on focus bills a model\n * for a tab-through, and that is a decision a caller should make on purpose.\n */\nexport function SuggestRoot(props: SuggestRootProps) {\n const {\n suggest,\n existing,\n onPick,\n limit,\n trigger = \"press\",\n children,\n className,\n onBlur,\n onFocus,\n slot,\n ...rest\n } = props;\n\n const controller = useSuggestions({ suggest, existing, limit });\n const [active, setActive] = React.useState(false);\n\n const pick = (value: string) => {\n onPick(value);\n // A taken candidate is no longer a candidate. Nothing refills — the strip simply gets shorter,\n // which is the honest picture of a source that has already answered.\n controller.dismiss(value);\n };\n\n /**\n * **One meaning for a press, in both compounds: give me the assistance.**\n *\n * `Complete` reads that as *take the continuation* when there is one, because a ghost is a single\n * offer that needs a gesture to accept. A strip is N offers that already carry their own buttons,\n * so here the next useful thing is a different set — and after a failure it is a retry. The hook's\n * `ask` stays strict (idle only, so a press cannot re-bill a source that already answered) and\n * this is where the policy lives, which is why it is in the compound and not in `AiMark`.\n *\n * Loading is the one state where nothing happens: the mark is a spinner then, and a spinner you\n * can press is a lie.\n */\n const press = () => {\n if (controller.status === \"loading\") return;\n if (controller.status === \"error\" || controller.items.length > 0) controller.refresh();\n else controller.ask();\n };\n\n return (\n <Ctx.Provider\n value={{\n candidates: controller.items,\n status: controller.status,\n error: controller.error,\n active,\n ask: press,\n pick,\n dismiss: controller.dismiss,\n }}\n >\n <ark.div\n className={cn(\"flex w-full min-w-0 flex-col gap-2\", className)}\n onBlur={(event) => {\n onBlur?.(event);\n // `relatedTarget` is where focus is going. Inside our own subtree it is a pill being\n // clicked or the ✨ being tabbed to, and the strip must survive both.\n if (!event.currentTarget.contains(event.relatedTarget)) setActive(false);\n }}\n onFocus={(event) => {\n onFocus?.(event);\n setActive(true);\n if (trigger === \"focus\") controller.ask();\n }}\n {...rest}\n data-slot={slot ?? \"suggest\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n}\n\n/**\n * The ✨ — **a mark, not a door.**\n *\n * At rest it says the field is model-assisted, which a reader can see without touching anything.\n * Pressing it asks. It no longer opens or closes a layer, so there is no `data-state` to light it\n * from and nothing to trap focus in.\n */\nexport function SuggestMark(props: Omit<AiMarkProps, \"busy\" | \"offering\">) {\n const { label = \"Suggest\", onClick, ...rest } = props;\n const ctx = useCtx(\"SuggestMark\");\n // Bound exactly as `CompleteMark` binds it. A mark that paints one way over a ghost and another\n // over a strip reads as two products; `ai-mark.test.tsx` is the guard on that.\n const offering = ctx.candidates.length > 0;\n\n return (\n <AiMark\n busy={ctx.status === \"loading\" && !offering}\n label={offering ? \"Suggest different values\" : label}\n offering={offering}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) ctx.ask();\n }}\n {...rest}\n />\n );\n}\n\nexport interface SuggestListProps extends React.ComponentProps<typeof Suggestions> {\n /** Shown while the source is answering and has produced nothing yet. */\n pending?: React.ReactNode;\n /** Shown when the source finished with nothing to offer. */\n empty?: React.ReactNode;\n}\n\n/**\n * The strip, and the only part that decides whether anything is on screen.\n *\n * It renders `Suggestions` from `@kanzo-tech/ui` — a row of buttons that knows nothing about a\n * model — so everything model-shaped stays on this side of the boundary: the ✨, the engine, and\n * the rationale that comes back with a candidate.\n *\n * The rationale is the highlighted pill's `title` rather than a line under the strip. The line was\n * tried and it moved the layout on every hover; a `title` is the browser's own tooltip, reaches the\n * keyboard through focus, and costs no box.\n */\nexport function SuggestList(props: SuggestListProps) {\n const { className, empty, pending, slot, ...rest } = props;\n const ctx = useCtx(\"SuggestList\");\n\n if (!ctx.active) return null;\n\n const body =\n ctx.error !== null ? (\n <ark.p className=\"text-destructive-foreground text-sm\" data-slot=\"suggest-error\">\n {ctx.error}\n </ark.p>\n ) : ctx.candidates.length > 0 ? (\n ctx.candidates.map((candidate) => {\n const label = candidate.label ?? candidate.value;\n return (\n // **Two buttons, so a group — not a ✕ inside the pill.** `Suggestion` *is* a\n // `<button>`, and a button inside a button is not a thing the DOM has; the dismiss has\n // to be a sibling. `ButtonGroup` is the house cluster: it collapses the inner radii and\n // overlaps the shared border, so the pair reads as one pill with a divided end, and it\n // requires the accessible name that a `role=\"group\"` owes.\n <ButtonGroup aria-label={label} key={candidate.value} slot=\"suggest-item\">\n <Suggestion onSelect={ctx.pick} title={candidate.rationale} value={candidate.value}>\n {label}\n </Suggestion>\n {/* Always drawn, never on hover: a hover-only control is unreachable by a keyboard and\n absent on a touch pointer, and this strip is explicitly reachable by Tab. */}\n <Button\n aria-label={`Dismiss ${label}`}\n className=\"h-auto min-h-[24px] self-stretch px-2 py-1\"\n onClick={() => ctx.dismiss(candidate.value)}\n pill\n size=\"sm\"\n slot=\"suggest-dismiss\"\n variant=\"outline\"\n >\n <XIcon />\n </Button>\n </ButtonGroup>\n );\n })\n ) : ctx.status === \"loading\" ? (\n (pending ?? (\n <ark.span\n className=\"inline-flex items-center gap-2 text-muted-foreground text-sm\"\n data-slot=\"suggest-pending\"\n >\n <Spinner aria-hidden />\n Thinking…\n </ark.span>\n ))\n ) : ctx.status === \"ready\" ? (\n (empty ?? (\n <ark.span className=\"text-muted-foreground text-sm\" data-slot=\"suggest-empty\">\n Nothing to suggest.\n </ark.span>\n ))\n ) : null;\n\n if (body === null) return null;\n\n return (\n <Suggestions className={cn(\"min-h-8\", className)} {...rest} slot={slot ?? \"suggest-list\"}>\n {body}\n </Suggestions>\n );\n}\n"],"names":[],"mappings":";;;;;;;;AAwBA;AAEE;AACA;AACA;AACF;AA+CO;AACL;AAAM;AACJ;AACA;AACA;AACA;AACU;AACV;AACA;AACA;AACA;AACA;AACG;AAOH;AAGwB;AAgBxB;AAEgB;AAGlB;AACE;AAAK;AAAJ;AACQ;AACkB;AACJ;AACD;AAClB;AACK;AACL;AACoB;AAAA;AAGtB;AAAK;AAAJ;AAC8D;AAE3D;AAGuE;AACzE;AAEE;AAEoC;AACtC;AACI;AACe;AAElB;AAAA;AAAA;AACH;AAGN;AASO;AACL;AAMA;AACE;AAAC;AAAA;AACoC;AACY;AAC/C;AAEE;AACiC;AACnC;AACI;AAAA;AAGV;AAoBO;AACL;AAGA;AAEA;AAOM;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOI;AAEA;AAGA;AAAC;AAAA;AAC6B;AAClB;AACgC;AACtC;AACC;AACA;AACG;AAED;AAAA;AAAA;AAEX;AAAA;AAKF;AAAK;AAAJ;AACW;AACA;AAEV;AAAqB;AAAE;AAAA;AAAA;AAY/B;AAOF;;;;;;"}
|
|
1
|
+
{"version":3,"file":"suggest.js","sources":["../src/suggest.tsx"],"sourcesContent":["\"use client\";\n\nimport { XIcon } from \"lucide-react\";\nimport { ark } from \"@ark-ui/react/factory\";\nimport * as React from \"react\";\nimport { Button, ButtonGroup, cn, Spinner, Suggestion, Suggestions } from \"@kanzo-tech/ui\";\nimport { AiMark, type AiMarkProps } from \"./ai-mark.js\";\n// No alias any more, and that is the whole of this rename: the type and `@kanzo-tech/ui`'s\n// `Suggestion` component were both in scope in this file, both on public barrels, and one of them\n// had to be renamed at the import to be usable at all.\nimport type { Candidate } from \"./types.js\";\nimport { type AiStatus, useSuggestions } from \"./use-ai.js\";\n\ninterface SuggestCtx {\n candidates: Candidate[];\n status: AiStatus;\n error: string | null;\n /** The field, or something inside it, holds focus. */\n active: boolean;\n ask: () => void;\n pick: (value: string) => void;\n dismiss: (value: string) => void;\n}\n\nconst Ctx = React.createContext<SuggestCtx | null>(null);\nconst useCtx = (part: string): SuggestCtx => {\n const context = React.useContext(Ctx);\n if (!context) throw new Error(`${part} must render inside <SuggestRoot>`);\n return context;\n};\n\n/** When the source is asked. */\nexport type SuggestTrigger =\n /** On a press of the ✨. Nothing is requested until somebody asks for it. */\n | \"press\"\n /** As soon as the field takes focus. Prefetches, and bills a model for a tab-through. */\n | \"focus\";\n\nexport interface SuggestRootProps\n extends Omit<React.ComponentProps<typeof ark.div>, \"onSelect\"> {\n /** The source. Yields candidates; honour the `AbortSignal`. */\n suggest: (signal?: AbortSignal) => AsyncIterable<Candidate>;\n /** What the field already holds — these are never offered back. */\n existing?: string[];\n /** Where a chosen value goes. */\n onPick: (value: string) => void;\n /**\n * How many to take from the stream.\n *\n * @default 6\n */\n limit?: number;\n /**\n * @default \"press\"\n */\n trigger?: SuggestTrigger;\n}\n\n/**\n * Candidate values for one field, offered **in the flow underneath it**.\n *\n * This replaced a popover, and the popover is the whole story. Hiding the candidates behind a door\n * bought nothing and cost six workarounds, every one of them downstream of the door and not of the\n * candidates: a non-modal popover, because a modal one put `pointer-events: none` on the very field\n * the values were for; a `data-autofocus`, because a popover has to hand focus somewhere; a\n * `Delete`/`Backspace` handler, because a button inside `role=\"option\"` is unreachable by Tab; a\n * shared label id, because a popover names itself from a title and a listbox from a label; a ✕ that\n * had to be quieted because an offer looked identical to a committed tag; and a three-row window\n * with a refill loop in the hook to keep it full. None of them exist here.\n *\n * **Focus decides visibility, a press decides billing**, and they are deliberately not the same\n * gesture. The strip appears while the field has focus and there is something to show — which is\n * what keeps a form of eleven fields from becoming a wall, since only one field is focused at a\n * time. Asking is `trigger`, and it defaults to a press: a source that fires on focus bills a model\n * for a tab-through, and that is a decision a caller should make on purpose.\n */\nexport function SuggestRoot(props: SuggestRootProps) {\n const {\n suggest,\n existing,\n onPick,\n limit,\n trigger = \"press\",\n children,\n className,\n onBlur,\n onFocus,\n slot,\n ...rest\n } = props;\n\n const controller = useSuggestions({ suggest, existing, limit });\n const [active, setActive] = React.useState(false);\n\n const pick = (value: string) => {\n onPick(value);\n // A taken candidate is no longer a candidate. Nothing refills — the strip simply gets shorter,\n // which is the honest picture of a source that has already answered.\n controller.dismiss(value);\n };\n\n /**\n * **One meaning for a press, in both compounds: give me the assistance.**\n *\n * `Complete` reads that as *take the continuation* when there is one, because a ghost is a single\n * offer that needs a gesture to accept. A strip is N offers that already carry their own buttons,\n * so here the next useful thing is a different set — and after a failure it is a retry. The hook's\n * `ask` stays strict (idle only, so a press cannot re-bill a source that already answered) and\n * this is where the policy lives, which is why it is in the compound and not in `AiMark`.\n *\n * Loading is the one state where nothing happens: the mark is a spinner then, and a spinner you\n * can press is a lie.\n */\n const press = () => {\n if (controller.status === \"loading\") return;\n if (controller.status === \"error\" || controller.items.length > 0) controller.refresh();\n else controller.ask();\n };\n\n return (\n <Ctx.Provider\n value={{\n candidates: controller.items,\n status: controller.status,\n error: controller.error,\n active,\n ask: press,\n pick,\n dismiss: controller.dismiss,\n }}\n >\n <ark.div\n className={cn(\"flex w-full min-w-0 flex-col gap-2\", className)}\n onBlur={(event) => {\n onBlur?.(event);\n // `relatedTarget` is where focus is going. Inside our own subtree it is a pill being\n // clicked or the ✨ being tabbed to, and the strip must survive both.\n if (!event.currentTarget.contains(event.relatedTarget)) setActive(false);\n }}\n onFocus={(event) => {\n onFocus?.(event);\n setActive(true);\n if (trigger === \"focus\") controller.ask();\n }}\n {...rest}\n data-slot={slot ?? \"suggest\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n}\n\n/**\n * The ✨ — **a mark, not a door.**\n *\n * At rest it says the field is model-assisted, which a reader can see without touching anything.\n * Pressing it asks. It no longer opens or closes a layer, so there is no `data-state` to light it\n * from and nothing to trap focus in.\n */\nexport function SuggestMark(\n props: Omit<AiMarkProps, \"busy\" | \"offering\"> & {\n /**\n * The accessible name while candidates are on offer, when `label` names the mark at rest.\n *\n * @default \"Suggest different values\"\n */\n offeringLabel?: string;\n }\n) {\n const { label = \"Suggest\", offeringLabel = \"Suggest different values\", onClick, ...rest } = props;\n const ctx = useCtx(\"SuggestMark\");\n // Bound exactly as `CompleteMark` binds it. A mark that paints one way over a ghost and another\n // over a strip reads as two products; `ai-mark.test.tsx` is the guard on that.\n const offering = ctx.candidates.length > 0;\n\n return (\n <AiMark\n busy={ctx.status === \"loading\" && !offering}\n label={offering ? offeringLabel : label}\n offering={offering}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) ctx.ask();\n }}\n {...rest}\n />\n );\n}\n\nexport interface SuggestListProps extends React.ComponentProps<typeof Suggestions> {\n /** Shown while the source is answering and has produced nothing yet. */\n pending?: React.ReactNode;\n /** Shown when the source finished with nothing to offer. */\n empty?: React.ReactNode;\n}\n\n/**\n * The strip, and the only part that decides whether anything is on screen.\n *\n * It renders `Suggestions` from `@kanzo-tech/ui` — a row of buttons that knows nothing about a\n * model — so everything model-shaped stays on this side of the boundary: the ✨, the engine, and\n * the rationale that comes back with a candidate.\n *\n * The rationale is the highlighted pill's `title` rather than a line under the strip. The line was\n * tried and it moved the layout on every hover; a `title` is the browser's own tooltip, reaches the\n * keyboard through focus, and costs no box.\n */\nexport function SuggestList(props: SuggestListProps) {\n const { className, empty, pending, slot, ...rest } = props;\n const ctx = useCtx(\"SuggestList\");\n\n if (!ctx.active) return null;\n\n const body =\n ctx.error !== null ? (\n <ark.p className=\"text-destructive-foreground text-sm\" data-slot=\"suggest-error\">\n {ctx.error}\n </ark.p>\n ) : ctx.candidates.length > 0 ? (\n ctx.candidates.map((candidate) => {\n const label = candidate.label ?? candidate.value;\n return (\n // **Two buttons, so a group — not a ✕ inside the pill.** `Suggestion` *is* a\n // `<button>`, and a button inside a button is not a thing the DOM has; the dismiss has\n // to be a sibling. `ButtonGroup` is the house cluster: it collapses the inner radii and\n // overlaps the shared border, so the pair reads as one pill with a divided end, and it\n // requires the accessible name that a `role=\"group\"` owes.\n <ButtonGroup aria-label={label} key={candidate.value} slot=\"suggest-item\">\n <Suggestion onSelect={ctx.pick} title={candidate.rationale} value={candidate.value}>\n {label}\n </Suggestion>\n {/* Always drawn, never on hover: a hover-only control is unreachable by a keyboard and\n absent on a touch pointer, and this strip is explicitly reachable by Tab. */}\n <Button\n aria-label={`Dismiss ${label}`}\n className=\"h-auto min-h-[24px] self-stretch px-2 py-1\"\n onClick={() => ctx.dismiss(candidate.value)}\n pill\n size=\"sm\"\n slot=\"suggest-dismiss\"\n variant=\"outline\"\n >\n <XIcon />\n </Button>\n </ButtonGroup>\n );\n })\n ) : ctx.status === \"loading\" ? (\n (pending ?? (\n <ark.span\n className=\"inline-flex items-center gap-2 text-muted-foreground text-sm\"\n data-slot=\"suggest-pending\"\n >\n <Spinner aria-hidden />\n Thinking…\n </ark.span>\n ))\n ) : ctx.status === \"ready\" ? (\n (empty ?? (\n <ark.span className=\"text-muted-foreground text-sm\" data-slot=\"suggest-empty\">\n Nothing to suggest.\n </ark.span>\n ))\n ) : null;\n\n if (body === null) return null;\n\n return (\n <Suggestions className={cn(\"min-h-8\", className)} {...rest} slot={slot ?? \"suggest-list\"}>\n {body}\n </Suggestions>\n );\n}\n"],"names":[],"mappings":";;;;;;;;AAwBA;AAEE;AACA;AACA;AACF;AA+CO;AACL;AAAM;AACJ;AACA;AACA;AACA;AACU;AACV;AACA;AACA;AACA;AACA;AACG;AAOH;AAGwB;AAgBxB;AAEgB;AAGlB;AACE;AAAK;AAAJ;AACQ;AACkB;AACJ;AACD;AAClB;AACK;AACL;AACoB;AAAA;AAGtB;AAAK;AAAJ;AAC8D;AAE3D;AAGuE;AACzE;AAEE;AAEoC;AACtC;AACI;AACe;AAElB;AAAA;AAAA;AACH;AAGN;AASO;AAUL;AAMA;AACE;AAAC;AAAA;AACoC;AACD;AAClC;AAEE;AACiC;AACnC;AACI;AAAA;AAGV;AAoBO;AACL;AAGA;AAEA;AAOM;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOI;AAEA;AAGA;AAAC;AAAA;AAC6B;AAClB;AACgC;AACtC;AACC;AACA;AACG;AAED;AAAA;AAAA;AAEX;AAAA;AAKF;AAAK;AAAJ;AACW;AACA;AAEV;AAAqB;AAAE;AAAA;AAAA;AAY/B;AAOF;;;;;;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Kanzo AI surfaces — the transcript, the composer, a model's reasoning and the calls it makes, plus the field affordances that stream into a value. Sibling of @kanzo-tech/ui, not part of it: a Message has a role and one of the roles is `assistant`, which is domain knowledge the generic vocabulary does not carry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"react": ">=19",
|
|
34
34
|
"react-dom": ">=19",
|
|
35
35
|
"streamdown": ">=2",
|
|
36
|
-
"@kanzo-tech/ui": "0.
|
|
36
|
+
"@kanzo-tech/ui": "0.13.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"tailwind-variants": "^3.2.2"
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"react-dom": "^19.0.0",
|
|
52
52
|
"rollup-plugin-preserve-directives": "^0.4.0",
|
|
53
53
|
"streamdown": "^2.5.0",
|
|
54
|
-
"@kanzo-tech/
|
|
55
|
-
"@kanzo-tech/
|
|
54
|
+
"@kanzo-tech/theme": "0.13.0",
|
|
55
|
+
"@kanzo-tech/ui": "0.13.0"
|
|
56
56
|
},
|
|
57
57
|
"//size-limit": "First measurement taken at the split, so this budget is a change detector from day one rather than a number inherited from somewhere. The four AI-assist modules that moved here cost @kanzo-tech/ui's root barrel about 4 kB when they arrived (38 -> 42 kB, recorded in that package's own note); the barrel is rebaselined down by the same move.",
|
|
58
58
|
"size-limit": [
|