@runtypelabs/persona 4.15.0 → 4.17.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/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +283 -9
- package/dist/context-mentions.d.ts +283 -9
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +299 -10
- package/dist/index.d.ts +299 -10
- package/dist/index.global.js +57 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -69
- 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 +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +36 -36
- package/dist/smart-dom-reader.d.cts +283 -9
- package/dist/smart-dom-reader.d.ts +283 -9
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +64 -64
- package/dist/theme-editor-preview.d.cts +283 -9
- package/dist/theme-editor-preview.d.ts +283 -9
- package/dist/theme-editor-preview.js +56 -56
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +283 -10
- package/dist/theme-editor.d.ts +283 -10
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +66 -3
- package/dist/theme-reference.d.ts +66 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +2 -2
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/panel.ts +69 -74
- package/src/components/suggestions.test.ts +40 -0
- package/src/components/suggestions.ts +213 -153
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +14 -7
- package/src/defaults.ts +2 -2
- package/src/index-core.ts +12 -1
- package/src/install.test.ts +35 -1
- package/src/install.ts +47 -11
- package/src/launcher-global.ts +10 -7
- package/src/plugins/types.ts +66 -1
- package/src/styles/widget-styles.test.ts +220 -0
- package/src/styles/widget.css +353 -9
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/sections.ts +30 -1
- package/src/theme-reference.ts +26 -4
- package/src/types/theme.ts +40 -3
- package/src/types.ts +164 -6
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +46 -0
- package/src/ui.ts +747 -136
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +137 -0
- package/src/utils/tokens.ts +49 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
|
@@ -13,6 +13,28 @@ export const HEADER_THEME_CSS = {
|
|
|
13
13
|
"var(--persona-header-action-icon-fg, var(--persona-muted, #9ca3af))",
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Inline typography for the header title/subtitle, from
|
|
18
|
+
* `theme.components.header.title` / `.subtitle`. Fallbacks reproduce the
|
|
19
|
+
* `persona-text-base persona-font-semibold` / `persona-text-xs` utilities.
|
|
20
|
+
*/
|
|
21
|
+
export const HEADER_TITLE_TYPOGRAPHY = {
|
|
22
|
+
fontFamily: "var(--persona-components-header-title-fontFamily, inherit)",
|
|
23
|
+
fontSize: "var(--persona-components-header-title-fontSize, 1rem)",
|
|
24
|
+
fontWeight: "var(--persona-components-header-title-fontWeight, 600)",
|
|
25
|
+
lineHeight: "var(--persona-components-header-title-lineHeight, 1.5rem)",
|
|
26
|
+
letterSpacing: "var(--persona-components-header-title-letterSpacing, normal)",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const HEADER_SUBTITLE_TYPOGRAPHY = {
|
|
30
|
+
fontFamily: "var(--persona-components-header-subtitle-fontFamily, inherit)",
|
|
31
|
+
fontSize: "var(--persona-components-header-subtitle-fontSize, 0.75rem)",
|
|
32
|
+
fontWeight: "var(--persona-components-header-subtitle-fontWeight, inherit)",
|
|
33
|
+
lineHeight: "var(--persona-components-header-subtitle-lineHeight, 1rem)",
|
|
34
|
+
letterSpacing:
|
|
35
|
+
"var(--persona-components-header-subtitle-letterSpacing, normal)",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
16
38
|
export interface HeaderElements {
|
|
17
39
|
header: HTMLElement;
|
|
18
40
|
iconHolder: HTMLElement;
|
|
@@ -99,12 +121,15 @@ export const buildHeader = (context: HeaderBuildContext): HeaderElements => {
|
|
|
99
121
|
const title = createNode("span", {
|
|
100
122
|
className: "persona-text-base persona-font-semibold",
|
|
101
123
|
text: config?.launcher?.title ?? "Chat Assistant",
|
|
102
|
-
style: { color: HEADER_THEME_CSS.titleColor },
|
|
124
|
+
style: { color: HEADER_THEME_CSS.titleColor, ...HEADER_TITLE_TYPOGRAPHY },
|
|
103
125
|
});
|
|
104
126
|
const subtitle = createNode("span", {
|
|
105
127
|
className: "persona-text-xs",
|
|
106
128
|
text: config?.launcher?.subtitle ?? "Here to help you get answers fast",
|
|
107
|
-
style: {
|
|
129
|
+
style: {
|
|
130
|
+
color: HEADER_THEME_CSS.subtitleColor,
|
|
131
|
+
...HEADER_SUBTITLE_TYPOGRAPHY,
|
|
132
|
+
},
|
|
108
133
|
});
|
|
109
134
|
|
|
110
135
|
headerCopy.append(title, subtitle);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { buildMinimalHeader } from "./header-layouts";
|
|
6
|
+
import { HEADER_THEME_CSS } from "./header-builder";
|
|
7
|
+
|
|
8
|
+
const build = (layoutHeaderConfig: Parameters<typeof buildMinimalHeader>[0]["layoutHeaderConfig"]) =>
|
|
9
|
+
buildMinimalHeader({
|
|
10
|
+
config: { apiUrl: "https://example.com/api" },
|
|
11
|
+
showClose: true,
|
|
12
|
+
onClose: () => {},
|
|
13
|
+
layoutHeaderConfig,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("minimal header trailing actions", () => {
|
|
17
|
+
it("clusters actions at the trailing edge with the close button's chrome", () => {
|
|
18
|
+
const onAction = vi.fn();
|
|
19
|
+
const elements = build({
|
|
20
|
+
layout: "minimal",
|
|
21
|
+
trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
|
|
22
|
+
onAction,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const button = elements.header.querySelector<HTMLButtonElement>(
|
|
26
|
+
'button[aria-label="Back to home"]'
|
|
27
|
+
);
|
|
28
|
+
expect(button).not.toBeNull();
|
|
29
|
+
|
|
30
|
+
// Trailing cluster, not the title row: the button shares a parent with
|
|
31
|
+
// the close button wrapper and does not precede the title.
|
|
32
|
+
expect(button!.parentElement).toBe(elements.closeButtonWrapper.parentElement);
|
|
33
|
+
expect(elements.headerTitle.parentElement!.contains(button!)).toBe(false);
|
|
34
|
+
|
|
35
|
+
// Close-button chrome: 32px round hit area, header action-icon color.
|
|
36
|
+
expect(button!.style.height).toBe("32px");
|
|
37
|
+
expect(button!.style.width).toBe("32px");
|
|
38
|
+
expect(button!.style.color).toBe(HEADER_THEME_CSS.actionIconColor);
|
|
39
|
+
expect(button!.className).toContain("persona-rounded-full");
|
|
40
|
+
|
|
41
|
+
button!.click();
|
|
42
|
+
expect(onAction).toHaveBeenCalledWith("home");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("still ignores trailingActions when titleMenu is configured", () => {
|
|
46
|
+
const elements = build({
|
|
47
|
+
layout: "minimal",
|
|
48
|
+
titleMenu: { menuItems: [{ id: "a", label: "A" }], onSelect: () => {} },
|
|
49
|
+
trailingActions: [{ id: "home", icon: "house", ariaLabel: "Back to home" }],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
elements.header.querySelector('button[aria-label="Back to home"]')
|
|
54
|
+
).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
buildHeader,
|
|
12
12
|
HEADER_THEME_CSS,
|
|
13
|
+
HEADER_TITLE_TYPOGRAPHY,
|
|
13
14
|
HeaderElements,
|
|
14
15
|
attachHeaderToContainer as _attachHeaderToContainer,
|
|
15
16
|
} from "./header-builder";
|
|
@@ -70,14 +71,20 @@ function appendTrailingHeaderActions(
|
|
|
70
71
|
): void {
|
|
71
72
|
if (!actions?.length) return;
|
|
72
73
|
for (const a of actions) {
|
|
74
|
+
// Same chrome as the close button beside them: 32px round hit area,
|
|
75
|
+
// hover fill, and the header zone's action-icon color (the muted body
|
|
76
|
+
// token disappears on themed headers).
|
|
73
77
|
const btn = createElement(
|
|
74
78
|
"button",
|
|
75
|
-
"persona-inline-flex persona-items-center persona-justify-center persona-rounded-
|
|
79
|
+
"persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-0"
|
|
76
80
|
) as HTMLButtonElement;
|
|
77
81
|
btn.type = "button";
|
|
82
|
+
btn.style.height = "32px";
|
|
83
|
+
btn.style.width = "32px";
|
|
84
|
+
btn.style.color = HEADER_THEME_CSS.actionIconColor;
|
|
78
85
|
btn.setAttribute("aria-label", a.ariaLabel ?? a.label ?? a.id);
|
|
79
86
|
if (a.icon) {
|
|
80
|
-
const ic = renderLucideIcon(a.icon,
|
|
87
|
+
const ic = renderLucideIcon(a.icon, 16, "currentColor", 2);
|
|
81
88
|
if (ic) btn.appendChild(ic);
|
|
82
89
|
} else if (a.label) {
|
|
83
90
|
btn.textContent = a.label;
|
|
@@ -91,7 +98,9 @@ function appendTrailingHeaderActions(
|
|
|
91
98
|
items: a.menuItems,
|
|
92
99
|
onSelect: (itemId) => onAction?.(itemId),
|
|
93
100
|
anchor: wrapper,
|
|
94
|
-
|
|
101
|
+
// The cluster sits at the trailing edge; a left-aligned menu would
|
|
102
|
+
// extend past it.
|
|
103
|
+
position: 'bottom-right',
|
|
95
104
|
});
|
|
96
105
|
wrapper.appendChild(dropdown.element);
|
|
97
106
|
btn.addEventListener("click", (e) => {
|
|
@@ -148,14 +157,10 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
|
|
|
148
157
|
// Title only (no icon, no subtitle)
|
|
149
158
|
headerTitle = createElement("span", "persona-text-base persona-font-semibold persona-truncate");
|
|
150
159
|
headerTitle.style.color = HEADER_THEME_CSS.titleColor;
|
|
160
|
+
Object.assign(headerTitle.style, HEADER_TITLE_TYPOGRAPHY);
|
|
151
161
|
headerTitle.textContent = launcher.title ?? "Chat Assistant";
|
|
152
162
|
|
|
153
163
|
titleRow.appendChild(headerTitle);
|
|
154
|
-
appendTrailingHeaderActions(
|
|
155
|
-
titleRow,
|
|
156
|
-
layoutHeaderConfig?.trailingActions,
|
|
157
|
-
layoutHeaderConfig?.onAction ?? onHeaderAction
|
|
158
|
-
);
|
|
159
164
|
|
|
160
165
|
// Make title row clickable when onTitleClick is provided
|
|
161
166
|
if (layoutHeaderConfig?.onTitleClick) {
|
|
@@ -229,7 +234,22 @@ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
|
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
closeButtonWrapper.appendChild(closeButton);
|
|
232
|
-
|
|
237
|
+
|
|
238
|
+
// Trailing edge: action buttons cluster with the close button, matching
|
|
239
|
+
// its chrome. `titleMenu` still ignores `trailingActions` (documented).
|
|
240
|
+
const trailingCluster = createElement(
|
|
241
|
+
"div",
|
|
242
|
+
"persona-flex persona-items-center persona-gap-1"
|
|
243
|
+
);
|
|
244
|
+
if (!titleMenuConfig) {
|
|
245
|
+
appendTrailingHeaderActions(
|
|
246
|
+
trailingCluster,
|
|
247
|
+
layoutHeaderConfig?.trailingActions,
|
|
248
|
+
layoutHeaderConfig?.onAction ?? onHeaderAction
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
trailingCluster.appendChild(closeButtonWrapper);
|
|
252
|
+
header.appendChild(trailingCluster);
|
|
233
253
|
|
|
234
254
|
// title was moved into titleRow; keep headerTitle ref pointing at title for updateController
|
|
235
255
|
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createLauncherSurface } from "./launcher";
|
|
5
|
+
import type {
|
|
6
|
+
AgentWidgetConfig,
|
|
7
|
+
AgentWidgetLauncherTeaserConfig,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every case uses its own `keyPrefix`: the consumed-teaser set is module-level
|
|
12
|
+
* (it is the page-load memory), so a shared key would leak between tests.
|
|
13
|
+
*/
|
|
14
|
+
let keyCounter = 0;
|
|
15
|
+
const nextPrefix = () => `teaser-test-${++keyCounter}-`;
|
|
16
|
+
|
|
17
|
+
const configWith = (
|
|
18
|
+
prefix: string,
|
|
19
|
+
teaser: AgentWidgetLauncherTeaserConfig
|
|
20
|
+
): AgentWidgetConfig => ({
|
|
21
|
+
persistState: { keyPrefix: prefix },
|
|
22
|
+
launcher: { teaser },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("createLauncherSurface", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
window.localStorage.clear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
document.body.replaceChildren();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stays a display:contents passthrough when no teaser is configured", () => {
|
|
38
|
+
const surface = createLauncherSurface({ launcher: {} }, () => {});
|
|
39
|
+
document.body.appendChild(surface.element);
|
|
40
|
+
|
|
41
|
+
expect(surface.teaser).toBeNull();
|
|
42
|
+
expect(surface.element.getAttribute("data-teaser")).toBeNull();
|
|
43
|
+
expect(surface.element.className).toBe("persona-launcher-surface");
|
|
44
|
+
expect(surface.element.firstElementChild).toBe(surface.launcher.element);
|
|
45
|
+
expect(surface.launcher.element.style.position).toBe("");
|
|
46
|
+
surface.destroy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders the teaser as a sibling of the launcher with its own dismiss button", () => {
|
|
50
|
+
const prefix = nextPrefix();
|
|
51
|
+
const surface = createLauncherSurface(
|
|
52
|
+
configWith(prefix, { text: "Need a hand?" }),
|
|
53
|
+
() => {}
|
|
54
|
+
);
|
|
55
|
+
document.body.appendChild(surface.element);
|
|
56
|
+
vi.advanceTimersByTime(0);
|
|
57
|
+
|
|
58
|
+
const teaser = surface.teaser;
|
|
59
|
+
expect(teaser).not.toBeNull();
|
|
60
|
+
expect(teaser?.element.parentElement).toBe(surface.element);
|
|
61
|
+
expect(teaser?.element.getAttribute("aria-live")).toBe("polite");
|
|
62
|
+
expect(teaser?.element.hidden).toBe(false);
|
|
63
|
+
|
|
64
|
+
const buttons = teaser?.element.querySelectorAll("button") ?? [];
|
|
65
|
+
expect(buttons.length).toBe(2);
|
|
66
|
+
// Nested interactive elements are invalid HTML: both controls are siblings.
|
|
67
|
+
expect(buttons[1].closest("button")).toBe(buttons[1]);
|
|
68
|
+
expect(buttons[0].textContent).toBe("Need a hand?");
|
|
69
|
+
|
|
70
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("bottom-right");
|
|
71
|
+
expect(surface.launcher.element.style.position).toBe("static");
|
|
72
|
+
surface.destroy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("honors delayMs before showing", () => {
|
|
76
|
+
const prefix = nextPrefix();
|
|
77
|
+
const surface = createLauncherSurface(
|
|
78
|
+
configWith(prefix, { text: "Hi there", delayMs: 3000 }),
|
|
79
|
+
() => {}
|
|
80
|
+
);
|
|
81
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
82
|
+
vi.advanceTimersByTime(2999);
|
|
83
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
84
|
+
vi.advanceTimersByTime(1);
|
|
85
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
86
|
+
surface.destroy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("hides the dismiss affordance when dismissible is false", () => {
|
|
90
|
+
const prefix = nextPrefix();
|
|
91
|
+
const surface = createLauncherSurface(
|
|
92
|
+
configWith(prefix, { text: "Hi there", dismissible: false }),
|
|
93
|
+
() => {}
|
|
94
|
+
);
|
|
95
|
+
const dismiss = surface.teaser?.element.querySelectorAll("button")[1];
|
|
96
|
+
expect(dismiss?.hidden).toBe(true);
|
|
97
|
+
surface.destroy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("consumption rules", () => {
|
|
101
|
+
it("click-through opens the panel and persists under the default frequency", () => {
|
|
102
|
+
const prefix = nextPrefix();
|
|
103
|
+
const onToggle = vi.fn();
|
|
104
|
+
const surface = createLauncherSurface(
|
|
105
|
+
configWith(prefix, { text: "Hi there" }),
|
|
106
|
+
onToggle
|
|
107
|
+
);
|
|
108
|
+
vi.advanceTimersByTime(0);
|
|
109
|
+
|
|
110
|
+
surface.teaser?.element.querySelector("button")?.click();
|
|
111
|
+
|
|
112
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
114
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
115
|
+
surface.destroy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("dismissal persists without opening the panel", () => {
|
|
119
|
+
const prefix = nextPrefix();
|
|
120
|
+
const onToggle = vi.fn();
|
|
121
|
+
const surface = createLauncherSurface(
|
|
122
|
+
configWith(prefix, { text: "Hi there" }),
|
|
123
|
+
onToggle
|
|
124
|
+
);
|
|
125
|
+
vi.advanceTimersByTime(0);
|
|
126
|
+
|
|
127
|
+
const buttons = surface.teaser?.element.querySelectorAll("button");
|
|
128
|
+
buttons?.[1].click();
|
|
129
|
+
|
|
130
|
+
expect(onToggle).not.toHaveBeenCalled();
|
|
131
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
132
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
133
|
+
surface.destroy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a launcher click consumes the visible teaser too", () => {
|
|
137
|
+
const prefix = nextPrefix();
|
|
138
|
+
const onToggle = vi.fn();
|
|
139
|
+
const surface = createLauncherSurface(
|
|
140
|
+
configWith(prefix, { text: "Hi there" }),
|
|
141
|
+
onToggle
|
|
142
|
+
);
|
|
143
|
+
vi.advanceTimersByTime(0);
|
|
144
|
+
|
|
145
|
+
surface.launcher.element.click();
|
|
146
|
+
|
|
147
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
149
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBe("1");
|
|
150
|
+
surface.destroy();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not persist a teaser the user never saw", () => {
|
|
154
|
+
const prefix = nextPrefix();
|
|
155
|
+
const surface = createLauncherSurface(
|
|
156
|
+
configWith(prefix, { text: "Hi there", delayMs: 5000 }),
|
|
157
|
+
() => {}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
surface.launcher.element.click();
|
|
161
|
+
vi.advanceTimersByTime(10000);
|
|
162
|
+
|
|
163
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
164
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
165
|
+
surface.destroy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("suppresses the teaser while the panel is open and never resurrects it", () => {
|
|
169
|
+
const prefix = nextPrefix();
|
|
170
|
+
const surface = createLauncherSurface(
|
|
171
|
+
configWith(prefix, { text: "Hi there", delayMs: 100 }),
|
|
172
|
+
() => {}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
surface.setPanelOpen(true);
|
|
176
|
+
vi.advanceTimersByTime(1000);
|
|
177
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
178
|
+
|
|
179
|
+
surface.setPanelOpen(false);
|
|
180
|
+
surface.update(configWith(prefix, { text: "Hi there", delayMs: 100 }));
|
|
181
|
+
vi.advanceTimersByTime(1000);
|
|
182
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
183
|
+
surface.destroy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not persist the flag when an automatic open consumes the teaser", () => {
|
|
187
|
+
const prefix = nextPrefix();
|
|
188
|
+
const surface = createLauncherSurface(
|
|
189
|
+
configWith(prefix, { text: "Hi there" }),
|
|
190
|
+
() => {}
|
|
191
|
+
);
|
|
192
|
+
vi.advanceTimersByTime(0);
|
|
193
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
194
|
+
|
|
195
|
+
surface.setPanelOpen(true, "auto");
|
|
196
|
+
|
|
197
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
198
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
199
|
+
surface.destroy();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("appears at most once per page load under frequency always", () => {
|
|
203
|
+
const prefix = nextPrefix();
|
|
204
|
+
const config = configWith(prefix, { text: "Hi there", frequency: "always" });
|
|
205
|
+
|
|
206
|
+
const first = createLauncherSurface(config, () => {});
|
|
207
|
+
vi.advanceTimersByTime(0);
|
|
208
|
+
first.teaser?.dismiss(false);
|
|
209
|
+
expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
|
|
210
|
+
first.destroy();
|
|
211
|
+
|
|
212
|
+
// Same page load, same key: the in-memory consumption still applies.
|
|
213
|
+
const second = createLauncherSurface(config, () => {});
|
|
214
|
+
vi.advanceTimersByTime(0);
|
|
215
|
+
expect(second.teaser?.element.hidden).toBe(true);
|
|
216
|
+
second.destroy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("skips the teaser when the persisted flag is already set", () => {
|
|
220
|
+
const prefix = nextPrefix();
|
|
221
|
+
window.localStorage.setItem(`${prefix}teaser-dismissed`, "1");
|
|
222
|
+
|
|
223
|
+
const surface = createLauncherSurface(
|
|
224
|
+
configWith(prefix, { text: "Hi there" }),
|
|
225
|
+
() => {}
|
|
226
|
+
);
|
|
227
|
+
vi.advanceTimersByTime(0);
|
|
228
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
229
|
+
surface.destroy();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("ignores a stale persisted flag under frequency always", () => {
|
|
233
|
+
const prefix = nextPrefix();
|
|
234
|
+
window.localStorage.setItem(`${prefix}teaser-dismissed`, "1");
|
|
235
|
+
|
|
236
|
+
const surface = createLauncherSurface(
|
|
237
|
+
configWith(prefix, { text: "Hi there", frequency: "always" }),
|
|
238
|
+
() => {}
|
|
239
|
+
);
|
|
240
|
+
vi.advanceTimersByTime(0);
|
|
241
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
242
|
+
surface.destroy();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps dismissal in memory when persistState is false", () => {
|
|
246
|
+
const surface = createLauncherSurface(
|
|
247
|
+
{ persistState: false, launcher: { teaser: { text: "Hi there" } } },
|
|
248
|
+
() => {}
|
|
249
|
+
);
|
|
250
|
+
vi.advanceTimersByTime(0);
|
|
251
|
+
surface.teaser?.dismiss(true);
|
|
252
|
+
|
|
253
|
+
expect(window.localStorage.getItem("persona-teaser-dismissed")).toBeNull();
|
|
254
|
+
expect(surface.teaser?.element.hidden).toBe(true);
|
|
255
|
+
surface.destroy();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("falls back to memory when localStorage throws", () => {
|
|
260
|
+
const prefix = nextPrefix();
|
|
261
|
+
const getItem = vi
|
|
262
|
+
.spyOn(Storage.prototype, "getItem")
|
|
263
|
+
.mockImplementation(() => {
|
|
264
|
+
throw new Error("blocked");
|
|
265
|
+
});
|
|
266
|
+
const setItem = vi
|
|
267
|
+
.spyOn(Storage.prototype, "setItem")
|
|
268
|
+
.mockImplementation(() => {
|
|
269
|
+
throw new Error("blocked");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const config = configWith(prefix, { text: "Hi there" });
|
|
273
|
+
const first = createLauncherSurface(config, () => {});
|
|
274
|
+
vi.advanceTimersByTime(0);
|
|
275
|
+
expect(first.teaser?.element.hidden).toBe(false);
|
|
276
|
+
|
|
277
|
+
first.teaser?.dismiss(true);
|
|
278
|
+
expect(first.teaser?.element.hidden).toBe(true);
|
|
279
|
+
first.destroy();
|
|
280
|
+
|
|
281
|
+
const second = createLauncherSurface(config, () => {});
|
|
282
|
+
vi.advanceTimersByTime(0);
|
|
283
|
+
expect(second.teaser?.element.hidden).toBe(true);
|
|
284
|
+
second.destroy();
|
|
285
|
+
|
|
286
|
+
expect(getItem).toHaveBeenCalled();
|
|
287
|
+
expect(setItem).toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("live updates", () => {
|
|
291
|
+
it("adds a teaser configured by a later update()", () => {
|
|
292
|
+
const prefix = nextPrefix();
|
|
293
|
+
const surface = createLauncherSurface({ launcher: {} }, () => {});
|
|
294
|
+
expect(surface.teaser).toBeNull();
|
|
295
|
+
|
|
296
|
+
surface.update(configWith(prefix, { text: "Added later" }));
|
|
297
|
+
vi.advanceTimersByTime(0);
|
|
298
|
+
|
|
299
|
+
expect(surface.teaser?.element.hidden).toBe(false);
|
|
300
|
+
expect(surface.teaser?.element.textContent).toContain("Added later");
|
|
301
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("bottom-right");
|
|
302
|
+
surface.destroy();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("swaps teaser text in place", () => {
|
|
306
|
+
const prefix = nextPrefix();
|
|
307
|
+
const surface = createLauncherSurface(
|
|
308
|
+
configWith(prefix, { text: "First" }),
|
|
309
|
+
() => {}
|
|
310
|
+
);
|
|
311
|
+
vi.advanceTimersByTime(0);
|
|
312
|
+
const element = surface.teaser?.element;
|
|
313
|
+
|
|
314
|
+
surface.update(configWith(prefix, { text: "Second" }));
|
|
315
|
+
|
|
316
|
+
expect(surface.teaser?.element).toBe(element);
|
|
317
|
+
expect(element?.querySelector("button")?.textContent).toBe("Second");
|
|
318
|
+
surface.destroy();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("toggles the dismiss affordance", () => {
|
|
322
|
+
const prefix = nextPrefix();
|
|
323
|
+
const surface = createLauncherSurface(
|
|
324
|
+
configWith(prefix, { text: "Hi there" }),
|
|
325
|
+
() => {}
|
|
326
|
+
);
|
|
327
|
+
const dismiss = () =>
|
|
328
|
+
surface.teaser?.element.querySelectorAll("button")[1];
|
|
329
|
+
expect(dismiss()?.hidden).toBe(false);
|
|
330
|
+
|
|
331
|
+
surface.update(
|
|
332
|
+
configWith(prefix, { text: "Hi there", dismissible: false })
|
|
333
|
+
);
|
|
334
|
+
expect(dismiss()?.hidden).toBe(true);
|
|
335
|
+
|
|
336
|
+
surface.update(configWith(prefix, { text: "Hi there", dismissible: true }));
|
|
337
|
+
expect(dismiss()?.hidden).toBe(false);
|
|
338
|
+
surface.destroy();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("removes the teaser and restores passthrough placement", () => {
|
|
342
|
+
const prefix = nextPrefix();
|
|
343
|
+
const surface = createLauncherSurface(
|
|
344
|
+
configWith(prefix, { text: "Hi there" }),
|
|
345
|
+
() => {}
|
|
346
|
+
);
|
|
347
|
+
vi.advanceTimersByTime(0);
|
|
348
|
+
|
|
349
|
+
surface.update({ persistState: { keyPrefix: prefix }, launcher: {} });
|
|
350
|
+
|
|
351
|
+
expect(surface.teaser).toBeNull();
|
|
352
|
+
expect(surface.element.getAttribute("data-teaser")).toBeNull();
|
|
353
|
+
expect(surface.launcher.element.style.position).toBe("");
|
|
354
|
+
surface.destroy();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("follows launcher.position onto the wrapper", () => {
|
|
358
|
+
const prefix = nextPrefix();
|
|
359
|
+
const surface = createLauncherSurface(
|
|
360
|
+
{
|
|
361
|
+
persistState: { keyPrefix: prefix },
|
|
362
|
+
launcher: { teaser: { text: "Hi there" }, position: "top-left" },
|
|
363
|
+
},
|
|
364
|
+
() => {}
|
|
365
|
+
);
|
|
366
|
+
expect(surface.element.getAttribute("data-teaser")).toBe("top-left");
|
|
367
|
+
expect(surface.element.className).toContain("persona-top-6");
|
|
368
|
+
expect(surface.element.className).toContain("persona-left-6");
|
|
369
|
+
surface.destroy();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("drops the teaser in docked mount mode", () => {
|
|
373
|
+
const prefix = nextPrefix();
|
|
374
|
+
const surface = createLauncherSurface(
|
|
375
|
+
{
|
|
376
|
+
persistState: { keyPrefix: prefix },
|
|
377
|
+
launcher: { teaser: { text: "Hi there" }, mountMode: "docked" },
|
|
378
|
+
},
|
|
379
|
+
() => {}
|
|
380
|
+
);
|
|
381
|
+
expect(surface.teaser).toBeNull();
|
|
382
|
+
surface.destroy();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("clears the pending timer and removes the wrapper on destroy", () => {
|
|
387
|
+
const prefix = nextPrefix();
|
|
388
|
+
const surface = createLauncherSurface(
|
|
389
|
+
configWith(prefix, { text: "Hi there", delayMs: 5000 }),
|
|
390
|
+
() => {}
|
|
391
|
+
);
|
|
392
|
+
document.body.appendChild(surface.element);
|
|
393
|
+
const teaserElement = surface.teaser?.element;
|
|
394
|
+
|
|
395
|
+
surface.destroy();
|
|
396
|
+
vi.advanceTimersByTime(10000);
|
|
397
|
+
|
|
398
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
399
|
+
expect(teaserElement?.hidden).toBe(true);
|
|
400
|
+
expect(surface.element.parentElement).toBeNull();
|
|
401
|
+
expect(document.body.children.length).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
});
|