@runtypelabs/persona 4.7.0 → 4.9.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/README.md +1 -0
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.js +14 -14
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +99 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1507 -182
- package/dist/index.d.ts +1507 -182
- package/dist/index.global.js +88 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +99 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +18 -17
- package/dist/smart-dom-reader.d.cts +1260 -16
- package/dist/smart-dom-reader.d.ts +1260 -16
- package/dist/smart-dom-reader.js +18 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +260 -58
- package/dist/theme-editor-preview.d.cts +1234 -15
- package/dist/theme-editor-preview.d.ts +1234 -15
- package/dist/theme-editor-preview.js +87 -58
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +1226 -15
- package/dist/theme-editor.d.ts +1226 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +76 -0
- package/dist/theme-reference.d.ts +76 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +16 -5
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +255 -1
- package/src/client.ts +157 -60
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +46 -1
- package/src/index-global.ts +51 -0
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +83 -27
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +197 -27
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +1002 -152
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +13 -5
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +79 -0
- package/src/types.ts +1041 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +998 -159
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +76 -4
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +118 -11
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseMentionTrigger,
|
|
4
|
+
parseAnyTrigger,
|
|
5
|
+
isMenuOpeningInput,
|
|
6
|
+
stripMentionQuery,
|
|
7
|
+
MENTION_PLACEHOLDER,
|
|
8
|
+
} from "./mention-trigger";
|
|
9
|
+
|
|
10
|
+
describe("parseMentionTrigger", () => {
|
|
11
|
+
it("activates on @ at the start of input", () => {
|
|
12
|
+
const m = parseMentionTrigger("@App", 4);
|
|
13
|
+
expect(m).toEqual({ triggerIndex: 0, query: "App" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("activates on @ after whitespace mid-sentence", () => {
|
|
17
|
+
const m = parseMentionTrigger("check @fo", 9);
|
|
18
|
+
expect(m).toEqual({ triggerIndex: 6, query: "fo" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns an empty query right after a bare @", () => {
|
|
22
|
+
const m = parseMentionTrigger("hi @", 4);
|
|
23
|
+
expect(m).toEqual({ triggerIndex: 3, query: "" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does NOT activate for an email (@ glued to a word char)", () => {
|
|
27
|
+
expect(parseMentionTrigger("user@example.com", 16)).toBeNull();
|
|
28
|
+
expect(parseMentionTrigger("user@", 5)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("closes once a space follows the trigger", () => {
|
|
32
|
+
// caret after "fo bar" — whitespace between query and caret ends the mention
|
|
33
|
+
expect(parseMentionTrigger("@fo bar", 7)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("tracks the nearest active trigger when caret is mid-query", () => {
|
|
37
|
+
const m = parseMentionTrigger("a @one @two", 11);
|
|
38
|
+
expect(m).toEqual({ triggerIndex: 7, query: "two" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null at caret 0 or out of range", () => {
|
|
42
|
+
expect(parseMentionTrigger("@x", 0)).toBeNull();
|
|
43
|
+
expect(parseMentionTrigger("@x", 99)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("honors a custom trigger character", () => {
|
|
47
|
+
expect(parseMentionTrigger("see #iss", 8, "#")).toEqual({
|
|
48
|
+
triggerIndex: 4,
|
|
49
|
+
query: "iss",
|
|
50
|
+
});
|
|
51
|
+
expect(parseMentionTrigger("see #iss", 8, "@")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles multi-byte characters in the query", () => {
|
|
55
|
+
const m = parseMentionTrigger("@café", 5);
|
|
56
|
+
expect(m).toEqual({ triggerIndex: 0, query: "café" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("exports MENTION_PLACEHOLDER as the U+FFFC object-replacement char", () => {
|
|
60
|
+
expect(MENTION_PLACEHOLDER).toBe("");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("treats U+FFFC (inline mention token placeholder) as a query terminator", () => {
|
|
64
|
+
// In inline-mode logical text an existing token is a single `` char. A new
|
|
65
|
+
// `@query` must not scan back THROUGH that token: "@ab" with caret after "b"
|
|
66
|
+
// has no active trigger (the `` ends the backscan like whitespace/newline).
|
|
67
|
+
expect(parseMentionTrigger(`@a${MENTION_PLACEHOLDER}b`, 4)).toBeNull();
|
|
68
|
+
// A fresh `@` typed AFTER a token still activates (scan stops at ``, not the @).
|
|
69
|
+
expect(parseMentionTrigger(" @fo", 5)).toEqual({
|
|
70
|
+
triggerIndex: 2,
|
|
71
|
+
query: "fo",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("position gating (for / slash-commands)", () => {
|
|
76
|
+
it("line-start: activates at input start and after a newline", () => {
|
|
77
|
+
expect(parseMentionTrigger("/dep", 4, "/", "line-start")).toEqual({
|
|
78
|
+
triggerIndex: 0,
|
|
79
|
+
query: "dep",
|
|
80
|
+
});
|
|
81
|
+
expect(parseMentionTrigger("hi\n/dep", 7, "/", "line-start")).toEqual({
|
|
82
|
+
triggerIndex: 3,
|
|
83
|
+
query: "dep",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("line-start: does NOT activate mid-line (after a space)", () => {
|
|
88
|
+
expect(parseMentionTrigger("hi /dep", 7, "/", "line-start")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("input-start: only at index 0", () => {
|
|
92
|
+
expect(parseMentionTrigger("/x", 2, "/", "input-start")).toEqual({
|
|
93
|
+
triggerIndex: 0,
|
|
94
|
+
query: "x",
|
|
95
|
+
});
|
|
96
|
+
expect(parseMentionTrigger("hi\n/x", 5, "/", "input-start")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("parseAnyTrigger", () => {
|
|
102
|
+
const channels = [
|
|
103
|
+
{ trigger: "@", position: "anywhere" as const },
|
|
104
|
+
{ trigger: "/", position: "line-start" as const },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
it("picks the @ channel for a mid-sentence mention", () => {
|
|
108
|
+
const hit = parseAnyTrigger("hey @fo", 7, channels);
|
|
109
|
+
expect(hit?.channel.trigger).toBe("@");
|
|
110
|
+
expect(hit?.match).toEqual({ triggerIndex: 4, query: "fo" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("picks the / channel only at line-start", () => {
|
|
114
|
+
const hit = parseAnyTrigger("/dep", 4, channels);
|
|
115
|
+
expect(hit?.channel.trigger).toBe("/");
|
|
116
|
+
expect(hit?.match).toEqual({ triggerIndex: 0, query: "dep" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not match / mid-line even though @ is anywhere", () => {
|
|
120
|
+
expect(parseAnyTrigger("hi /dep", 7, channels)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns null when no channel is active", () => {
|
|
124
|
+
expect(parseAnyTrigger("plain text", 10, channels)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("isMenuOpeningInput", () => {
|
|
129
|
+
it("opens on typed text and unknown input types", () => {
|
|
130
|
+
expect(isMenuOpeningInput("insertText")).toBe(true);
|
|
131
|
+
expect(isMenuOpeningInput(undefined)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not open on paste or drop", () => {
|
|
135
|
+
expect(isMenuOpeningInput("insertFromPaste")).toBe(false);
|
|
136
|
+
expect(isMenuOpeningInput("insertFromDrop")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("stripMentionQuery", () => {
|
|
141
|
+
it("removes the @query span and moves the caret to the trigger", () => {
|
|
142
|
+
const value = "check @App for bugs";
|
|
143
|
+
const match = parseMentionTrigger("check @App", 10)!;
|
|
144
|
+
// caret sits right after "@App" (index 10)
|
|
145
|
+
const out = stripMentionQuery(value, match, 10);
|
|
146
|
+
expect(out.value).toBe("check for bugs");
|
|
147
|
+
expect(out.caret).toBe(6);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("strips a bare trigger with empty query", () => {
|
|
151
|
+
const out = stripMentionQuery("hi @", { triggerIndex: 3, query: "" }, 4);
|
|
152
|
+
expect(out.value).toBe("hi ");
|
|
153
|
+
expect(out.caret).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure `@`-trigger detection for the composer mention menu.
|
|
3
|
+
*
|
|
4
|
+
* No DOM, no contenteditable — a small function over `(value, caret)` mirroring
|
|
5
|
+
* the Slack/Discord approach. Shared by the core orchestrator's lightweight
|
|
6
|
+
* "should I open?" pre-check and the lazy-loaded controller's full parse, so the
|
|
7
|
+
* trigger rule lives in exactly one place.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type MentionTriggerMatch = {
|
|
11
|
+
/** Index of the trigger character in `value`. */
|
|
12
|
+
triggerIndex: number;
|
|
13
|
+
/** The text typed after the trigger, up to the caret (no leading trigger). */
|
|
14
|
+
query: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a trigger may open. `"anywhere"` is the `@` rule (after any whitespace/
|
|
19
|
+
* start); `"line-start"` and `"input-start"` are the natural rules for `/`
|
|
20
|
+
* slash-commands. Kept as a local string union so the parser stays free of the
|
|
21
|
+
* public config types.
|
|
22
|
+
*/
|
|
23
|
+
export type MentionTriggerPosition = "anywhere" | "line-start" | "input-start";
|
|
24
|
+
|
|
25
|
+
/** One trigger channel for `parseAnyTrigger` — a char plus its position rule. */
|
|
26
|
+
export type MentionTriggerSpec = {
|
|
27
|
+
trigger: string;
|
|
28
|
+
position?: MentionTriggerPosition;
|
|
29
|
+
/**
|
|
30
|
+
* Allow the query to span spaces/tabs (a newline still ends it). Needed for
|
|
31
|
+
* slash-command ARGS (`/deploy staging` → query `"deploy staging"`); leave
|
|
32
|
+
* false for single-token `@` mentions (a space ends the mention).
|
|
33
|
+
*/
|
|
34
|
+
allowSpaces?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const WHITESPACE = /\s/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* OBJECT REPLACEMENT CHAR (U+FFFC) — one inline mention token in the composer's
|
|
41
|
+
* *logical* text (see `toLogicalText` in composer-document.ts). Defined HERE, in
|
|
42
|
+
* the dependency-free trigger module, and re-exported from composer-document.ts so
|
|
43
|
+
* the constant has a single source without dragging the document-model runtime
|
|
44
|
+
* into the chip chunk (which imports this module but not composer-document).
|
|
45
|
+
*/
|
|
46
|
+
export const MENTION_PLACEHOLDER = "";
|
|
47
|
+
|
|
48
|
+
/** Does `triggerIndex` satisfy the channel's position rule? */
|
|
49
|
+
function positionAllowed(
|
|
50
|
+
value: string,
|
|
51
|
+
triggerIndex: number,
|
|
52
|
+
position: MentionTriggerPosition
|
|
53
|
+
): boolean {
|
|
54
|
+
if (position === "input-start") return triggerIndex === 0;
|
|
55
|
+
const before = triggerIndex > 0 ? value[triggerIndex - 1] : "";
|
|
56
|
+
if (position === "line-start") return before === "" || before === "\n";
|
|
57
|
+
// "anywhere": preceded by whitespace or start (also excludes `user@example.com`).
|
|
58
|
+
return before === "" || WHITESPACE.test(before);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect an active mention trigger ending at `caret`.
|
|
63
|
+
*
|
|
64
|
+
* Active when, scanning back from the caret, a trigger char is reached with no
|
|
65
|
+
* intervening whitespace AND the trigger satisfies `position`. Returns `null`
|
|
66
|
+
* otherwise — notably for `user@example.com` (trigger glued to a word char) and
|
|
67
|
+
* once a space follows the trigger.
|
|
68
|
+
*
|
|
69
|
+
* @param value Full textarea value.
|
|
70
|
+
* @param caret Caret offset (selectionStart). Out-of-range → `null`.
|
|
71
|
+
* @param trigger Single trigger character. @default "@"
|
|
72
|
+
* @param position Where the trigger may open. @default "anywhere"
|
|
73
|
+
* @param allowSpaces Let the query span spaces/tabs (for command args). @default false
|
|
74
|
+
*/
|
|
75
|
+
export function parseMentionTrigger(
|
|
76
|
+
value: string,
|
|
77
|
+
caret: number,
|
|
78
|
+
trigger = "@",
|
|
79
|
+
position: MentionTriggerPosition = "anywhere",
|
|
80
|
+
allowSpaces = false
|
|
81
|
+
): MentionTriggerMatch | null {
|
|
82
|
+
if (!trigger) return null;
|
|
83
|
+
if (caret <= 0 || caret > value.length) return null;
|
|
84
|
+
|
|
85
|
+
let i = caret - 1;
|
|
86
|
+
while (i >= 0) {
|
|
87
|
+
const ch = value[i];
|
|
88
|
+
if (ch === trigger) {
|
|
89
|
+
if (positionAllowed(value, i, position)) {
|
|
90
|
+
return { triggerIndex: i, query: value.slice(i + 1, caret) };
|
|
91
|
+
}
|
|
92
|
+
// Trigger present but disallowed here (glued word char / not line-start).
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// A newline always ends the query. Spaces/tabs end it too, unless the
|
|
96
|
+
// channel allows multi-word queries (slash-command args).
|
|
97
|
+
if (ch === "\n") return null;
|
|
98
|
+
// The mention-token placeholder always terminates the backscan so a query
|
|
99
|
+
// never spans an existing token (`@a<token>b` must not report a query through
|
|
100
|
+
// the token). Never appears in a plain textarea value, so chip mode is
|
|
101
|
+
// unaffected.
|
|
102
|
+
if (ch === MENTION_PLACEHOLDER) return null;
|
|
103
|
+
if (!allowSpaces && WHITESPACE.test(ch)) return null;
|
|
104
|
+
i--;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve which of several trigger channels is active at `caret`. Channels are
|
|
111
|
+
* tested in order; the FIRST with an active match wins (put `@` first, `/`
|
|
112
|
+
* next). At any caret at most one channel can match — the scan stops at the
|
|
113
|
+
* nearest trigger/whitespace — so ordering only matters if two channels share a
|
|
114
|
+
* trigger char (don't do that). Returns the winning channel + its match.
|
|
115
|
+
*/
|
|
116
|
+
export function parseAnyTrigger<T extends MentionTriggerSpec>(
|
|
117
|
+
value: string,
|
|
118
|
+
caret: number,
|
|
119
|
+
channels: readonly T[]
|
|
120
|
+
): { channel: T; match: MentionTriggerMatch } | null {
|
|
121
|
+
for (const channel of channels) {
|
|
122
|
+
const match = parseMentionTrigger(
|
|
123
|
+
value,
|
|
124
|
+
caret,
|
|
125
|
+
channel.trigger,
|
|
126
|
+
channel.position ?? "anywhere",
|
|
127
|
+
channel.allowSpaces ?? false
|
|
128
|
+
);
|
|
129
|
+
if (match) return { channel, match };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether a given `inputType` (from an `InputEvent`) is one that may OPEN the
|
|
136
|
+
* menu. Paste never opens it (`insertFromPaste`); only real typing does. The
|
|
137
|
+
* caller separately guards `event.isComposing` for IME safety.
|
|
138
|
+
*/
|
|
139
|
+
export function isMenuOpeningInput(inputType: string | undefined): boolean {
|
|
140
|
+
if (!inputType) return true; // older browsers omit inputType; treat as typing
|
|
141
|
+
return inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove the `@query` span (trigger char through the caret) from `value`,
|
|
146
|
+
* returning the new value + the caret position after removal. Used on select to
|
|
147
|
+
* strip the typed query while leaving the rest of the prose intact.
|
|
148
|
+
*/
|
|
149
|
+
export function stripMentionQuery(
|
|
150
|
+
value: string,
|
|
151
|
+
match: MentionTriggerMatch,
|
|
152
|
+
caret: number
|
|
153
|
+
): { value: string; caret: number } {
|
|
154
|
+
const next = value.slice(0, match.triggerIndex) + value.slice(caret);
|
|
155
|
+
return { value: next, caret: match.triggerIndex };
|
|
156
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createRovingTablist } from "./roving-tablist";
|
|
6
|
+
|
|
7
|
+
const makeTabs = (container: HTMLElement, count: number): HTMLElement[] => {
|
|
8
|
+
const tabs: HTMLElement[] = [];
|
|
9
|
+
for (let i = 0; i < count; i += 1) {
|
|
10
|
+
const b = document.createElement("button");
|
|
11
|
+
b.type = "button";
|
|
12
|
+
b.textContent = `tab-${i}`;
|
|
13
|
+
container.appendChild(b);
|
|
14
|
+
tabs.push(b);
|
|
15
|
+
}
|
|
16
|
+
return tabs;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("createRovingTablist", () => {
|
|
24
|
+
it("applies role=tablist/tab, aria-selected, and a single roving tabindex", () => {
|
|
25
|
+
const container = document.createElement("div");
|
|
26
|
+
const tabs = makeTabs(container, 3);
|
|
27
|
+
const controller = createRovingTablist(container, { onSelect: () => {} });
|
|
28
|
+
controller.render(tabs, 1);
|
|
29
|
+
|
|
30
|
+
expect(container.getAttribute("role")).toBe("tablist");
|
|
31
|
+
expect(tabs.every((t) => t.getAttribute("role") === "tab")).toBe(true);
|
|
32
|
+
expect(tabs.map((t) => t.getAttribute("aria-selected"))).toEqual([
|
|
33
|
+
"false",
|
|
34
|
+
"true",
|
|
35
|
+
"false",
|
|
36
|
+
]);
|
|
37
|
+
// Only the selected tab is the tab stop.
|
|
38
|
+
expect(tabs.filter((t) => t.tabIndex === 0).length).toBe(1);
|
|
39
|
+
expect(tabs[1].tabIndex).toBe(0);
|
|
40
|
+
expect(tabs[0].tabIndex).toBe(-1);
|
|
41
|
+
expect(tabs[2].tabIndex).toBe(-1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("makes the first tab the stop when nothing is selected", () => {
|
|
45
|
+
const container = document.createElement("div");
|
|
46
|
+
const tabs = makeTabs(container, 3);
|
|
47
|
+
const controller = createRovingTablist(container, { onSelect: () => {} });
|
|
48
|
+
controller.render(tabs, -1);
|
|
49
|
+
expect(tabs[0].tabIndex).toBe(0);
|
|
50
|
+
expect(tabs[1].tabIndex).toBe(-1);
|
|
51
|
+
expect(tabs[2].tabIndex).toBe(-1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("moves selection with Arrow/Home/End (clamped) via onSelect", () => {
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
const tabs = makeTabs(container, 3);
|
|
58
|
+
const picks: number[] = [];
|
|
59
|
+
const controller = createRovingTablist(container, {
|
|
60
|
+
onSelect: (i) => picks.push(i),
|
|
61
|
+
});
|
|
62
|
+
controller.render(tabs, 0);
|
|
63
|
+
|
|
64
|
+
tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
65
|
+
expect(picks[picks.length - 1]).toBe(1);
|
|
66
|
+
|
|
67
|
+
// Clamp at the start: ArrowLeft from index 0 stays put (no onSelect).
|
|
68
|
+
const before = picks.length;
|
|
69
|
+
tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
|
|
70
|
+
expect(picks.length).toBe(before);
|
|
71
|
+
|
|
72
|
+
tabs[2].dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
|
73
|
+
expect(picks[picks.length - 1]).toBe(0);
|
|
74
|
+
|
|
75
|
+
tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
|
76
|
+
expect(picks[picks.length - 1]).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses Up/Down for vertical orientation", () => {
|
|
80
|
+
const container = document.createElement("div");
|
|
81
|
+
document.body.appendChild(container);
|
|
82
|
+
const tabs = makeTabs(container, 3);
|
|
83
|
+
const picks: number[] = [];
|
|
84
|
+
const controller = createRovingTablist(container, {
|
|
85
|
+
onSelect: (i) => picks.push(i),
|
|
86
|
+
orientation: "vertical",
|
|
87
|
+
});
|
|
88
|
+
controller.render(tabs, 1);
|
|
89
|
+
|
|
90
|
+
tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
|
91
|
+
expect(picks[picks.length - 1]).toBe(2);
|
|
92
|
+
tabs[2].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
|
93
|
+
expect(picks[picks.length - 1]).toBe(1);
|
|
94
|
+
// Horizontal keys are ignored in vertical mode.
|
|
95
|
+
const before = picks.length;
|
|
96
|
+
tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
97
|
+
expect(picks.length).toBe(before);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("restores focus to the selected tab across a rebuild", () => {
|
|
101
|
+
const container = document.createElement("div");
|
|
102
|
+
document.body.appendChild(container);
|
|
103
|
+
const controller = createRovingTablist(container, { onSelect: () => {} });
|
|
104
|
+
|
|
105
|
+
const first = makeTabs(container, 3);
|
|
106
|
+
controller.render(first, 0);
|
|
107
|
+
first[0].focus();
|
|
108
|
+
expect(document.activeElement).toBe(first[0]);
|
|
109
|
+
|
|
110
|
+
// Rebuild: capture focus, swap the tab DOM, re-render selecting index 1.
|
|
111
|
+
controller.beforeRender();
|
|
112
|
+
container.replaceChildren();
|
|
113
|
+
const next = makeTabs(container, 3);
|
|
114
|
+
controller.render(next, 1);
|
|
115
|
+
|
|
116
|
+
expect(document.activeElement).toBe(next[1]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not restore focus when beforeRender saw no focus inside the strip", () => {
|
|
120
|
+
const container = document.createElement("div");
|
|
121
|
+
document.body.appendChild(container);
|
|
122
|
+
const controller = createRovingTablist(container, { onSelect: () => {} });
|
|
123
|
+
|
|
124
|
+
const first = makeTabs(container, 2);
|
|
125
|
+
controller.render(first, 0);
|
|
126
|
+
// Focus lives outside the strip.
|
|
127
|
+
const outside = document.createElement("button");
|
|
128
|
+
document.body.appendChild(outside);
|
|
129
|
+
outside.focus();
|
|
130
|
+
|
|
131
|
+
controller.beforeRender();
|
|
132
|
+
container.replaceChildren();
|
|
133
|
+
const next = makeTabs(container, 2);
|
|
134
|
+
controller.render(next, 1);
|
|
135
|
+
|
|
136
|
+
expect(document.activeElement).toBe(outside);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("stops routing keys after destroy", () => {
|
|
140
|
+
const container = document.createElement("div");
|
|
141
|
+
document.body.appendChild(container);
|
|
142
|
+
const tabs = makeTabs(container, 2);
|
|
143
|
+
const picks: number[] = [];
|
|
144
|
+
const controller = createRovingTablist(container, {
|
|
145
|
+
onSelect: (i) => picks.push(i),
|
|
146
|
+
});
|
|
147
|
+
controller.render(tabs, 0);
|
|
148
|
+
controller.destroy();
|
|
149
|
+
tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
150
|
+
expect(picks.length).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roving-tabindex tablist controller (WAI-ARIA APG tabs pattern). Owns the tab
|
|
3
|
+
* roles, aria-selected, roving tabindex, Arrow/Home/End keyboard nav routed
|
|
4
|
+
* through an onSelect callback, focus reveal via scrollIntoView, and focus
|
|
5
|
+
* survival across a tab-DOM rebuild. Extracted so custom tab bars stay
|
|
6
|
+
* accessible without re-implementing the pattern.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RovingTablistOptions {
|
|
10
|
+
onSelect: (index: number) => void;
|
|
11
|
+
/** Arrow-key axis. @default "horizontal" */
|
|
12
|
+
orientation?: "horizontal" | "vertical";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RovingTablistController {
|
|
16
|
+
/** Snapshot whether focus is inside the strip. Call before replacing tab DOM. */
|
|
17
|
+
beforeRender(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Apply role=tablist/tab, aria-selected, roving tabindex, and keyboard nav to
|
|
20
|
+
* the given tab elements (in order). Restores focus to the selected tab when
|
|
21
|
+
* the last beforeRender() saw focus inside the strip. Call after (re)building
|
|
22
|
+
* tabs.
|
|
23
|
+
*/
|
|
24
|
+
render(tabs: HTMLElement[], selectedIndex: number): void;
|
|
25
|
+
destroy(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRovingTablist(
|
|
29
|
+
container: HTMLElement,
|
|
30
|
+
options: RovingTablistOptions
|
|
31
|
+
): RovingTablistController {
|
|
32
|
+
const orientation = options.orientation ?? "horizontal";
|
|
33
|
+
const prevKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
|
|
34
|
+
const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
|
|
35
|
+
container.setAttribute("role", "tablist");
|
|
36
|
+
|
|
37
|
+
let tabs: HTMLElement[] = [];
|
|
38
|
+
// Whether the last beforeRender() saw focus inside the strip; consumed by the
|
|
39
|
+
// next render() to restore the roving stop after a rebuild.
|
|
40
|
+
let restoreFocus = false;
|
|
41
|
+
|
|
42
|
+
const revealTab = (tab: HTMLElement) => {
|
|
43
|
+
if (typeof tab.scrollIntoView === "function") {
|
|
44
|
+
tab.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const indexOfTarget = (target: EventTarget | null): number => {
|
|
49
|
+
const node = target as HTMLElement | null;
|
|
50
|
+
return tabs.findIndex((t) => t === node || t.contains(node));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// One delegated keydown listener so it survives child replacement. Arrow moves
|
|
54
|
+
// selection one step (clamped); Home/End jump to the edges; selection routes
|
|
55
|
+
// through onSelect so the host owns state.
|
|
56
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
57
|
+
const index = indexOfTarget(e.target);
|
|
58
|
+
if (index < 0) return;
|
|
59
|
+
let next = index;
|
|
60
|
+
if (e.key === nextKey) next = Math.min(index + 1, tabs.length - 1);
|
|
61
|
+
else if (e.key === prevKey) next = Math.max(index - 1, 0);
|
|
62
|
+
else if (e.key === "Home") next = 0;
|
|
63
|
+
else if (e.key === "End") next = tabs.length - 1;
|
|
64
|
+
else return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (next === index) return;
|
|
67
|
+
options.onSelect(next);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Delegated focus reveal: bring a focused edge tab out from under the fade.
|
|
71
|
+
const onFocusin = (e: FocusEvent) => {
|
|
72
|
+
const index = indexOfTarget(e.target);
|
|
73
|
+
if (index >= 0) revealTab(tabs[index]);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
container.addEventListener("keydown", onKeydown);
|
|
77
|
+
container.addEventListener("focusin", onFocusin);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
beforeRender() {
|
|
81
|
+
restoreFocus =
|
|
82
|
+
typeof document !== "undefined" &&
|
|
83
|
+
container.contains(document.activeElement);
|
|
84
|
+
},
|
|
85
|
+
render(nextTabs: HTMLElement[], selectedIndex: number) {
|
|
86
|
+
tabs = nextTabs;
|
|
87
|
+
nextTabs.forEach((tab, i) => {
|
|
88
|
+
tab.setAttribute("role", "tab");
|
|
89
|
+
const selected = i === selectedIndex;
|
|
90
|
+
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
91
|
+
// Roving tabindex: the selected tab is the only stop, or the first tab
|
|
92
|
+
// when nothing is selected.
|
|
93
|
+
tab.tabIndex = selected || (selectedIndex < 0 && i === 0) ? 0 : -1;
|
|
94
|
+
});
|
|
95
|
+
// Restore focus to the roving stop after the rebuild so arrow nav keeps
|
|
96
|
+
// working across the selection re-render.
|
|
97
|
+
if (restoreFocus) {
|
|
98
|
+
const stop =
|
|
99
|
+
(selectedIndex >= 0 ? nextTabs[selectedIndex] : undefined) ?? nextTabs[0];
|
|
100
|
+
if (stop && typeof stop.focus === "function") {
|
|
101
|
+
revealTab(stop);
|
|
102
|
+
stop.focus();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
destroy() {
|
|
107
|
+
container.removeEventListener("keydown", onKeydown);
|
|
108
|
+
container.removeEventListener("focusin", onFocusin);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cx } from "./dom";
|
|
2
|
+
|
|
3
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reusable icon spinner: a small SVG ring with a faint full track plus a
|
|
7
|
+
* rotating arc.
|
|
8
|
+
*
|
|
9
|
+
* Icon-first loading is the norm for preview surfaces (Sandpack, YouTube/Figma/
|
|
10
|
+
* CodePen embeds, ChatGPT/Claude/v0) and every major design system (Apple HIG,
|
|
11
|
+
* Material, Carbon, Polaris, Geist); text-only "Loading…" is used by none, and
|
|
12
|
+
* HIG explicitly warns against the vague word "loading". So the default preview
|
|
13
|
+
* indicator is this spinner, with any work-naming label added only later as an
|
|
14
|
+
* escalation. The arc spins via a GPU-friendly `transform: rotate` keyframe
|
|
15
|
+
* (see `.persona-spinner` in widget.css); under `prefers-reduced-motion` the
|
|
16
|
+
* rotation stops and the static arc still reads as a progress ring.
|
|
17
|
+
*
|
|
18
|
+
* Structure and animation live in CSS (class `persona-spinner`); geometry here
|
|
19
|
+
* is viewBox-relative so the rendered size follows `--persona-artifact-spinner-size`.
|
|
20
|
+
*/
|
|
21
|
+
export function createSpinner(className?: string): SVGSVGElement {
|
|
22
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
23
|
+
svg.setAttribute("class", cx("persona-spinner", className));
|
|
24
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
25
|
+
// Decorative: the loading state is announced by surrounding copy/labels, so
|
|
26
|
+
// the spinner itself must not add redundant noise to the accessibility tree.
|
|
27
|
+
svg.setAttribute("aria-hidden", "true");
|
|
28
|
+
svg.setAttribute("focusable", "false");
|
|
29
|
+
|
|
30
|
+
const track = document.createElementNS(SVG_NS, "circle");
|
|
31
|
+
track.setAttribute("class", "persona-spinner-track");
|
|
32
|
+
track.setAttribute("cx", "12");
|
|
33
|
+
track.setAttribute("cy", "12");
|
|
34
|
+
track.setAttribute("r", "9");
|
|
35
|
+
|
|
36
|
+
const arc = document.createElementNS(SVG_NS, "circle");
|
|
37
|
+
arc.setAttribute("class", "persona-spinner-arc");
|
|
38
|
+
arc.setAttribute("cx", "12");
|
|
39
|
+
arc.setAttribute("cy", "12");
|
|
40
|
+
arc.setAttribute("r", "9");
|
|
41
|
+
|
|
42
|
+
svg.appendChild(track);
|
|
43
|
+
svg.appendChild(arc);
|
|
44
|
+
return svg;
|
|
45
|
+
}
|