@aleph-alpha/chat-kit 1.0.0 → 1.2.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.
Files changed (133) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-744ZHsQU.js → CkInput.vue_vue_type_script_setup_true_lang-CRymGkQs.js} +24 -69
  2. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts +3 -4
  3. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts.map +1 -1
  4. package/dist/components/base/CkHistoryItem/types.d.ts +3 -2
  5. package/dist/components/base/CkHistoryItem/types.d.ts.map +1 -1
  6. package/dist/components/base/CkPromptStopButton/CkPromptStopButton.vue.d.ts.map +1 -1
  7. package/dist/components/index.js +1 -1
  8. package/dist/composables/index.d.ts +2 -0
  9. package/dist/composables/index.d.ts.map +1 -1
  10. package/dist/composables/index.js +3 -2
  11. package/dist/composables/useConversationSessions.d.ts +29 -0
  12. package/dist/composables/useConversationSessions.d.ts.map +1 -0
  13. package/dist/handlers/index.js +2 -2
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/purify.es-md74IFlY.js +1081 -0
  18. package/dist/{useChatKitLabels-Dvi2FmjE.js → useChatKitLabels-BmWmMyqX.js} +75 -1152
  19. package/dist/{useConversations-Ca242D2P.js → useConversationSessions-ulFhtpQr.js} +50 -2
  20. package/dist/{useMessageHandler-uLHNac39.js → useMessageHandler-BmBdu2EW.js} +4 -1
  21. package/package.json +5 -5
  22. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +91 -0
  23. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +215 -0
  24. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +25 -0
  25. package/src/components/base/CkAgentMessage/index.ts +2 -0
  26. package/src/components/base/CkAgentMessage/types.ts +11 -0
  27. package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +129 -0
  28. package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +251 -0
  29. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +206 -0
  30. package/src/components/base/CkConversationLayout/index.ts +2 -0
  31. package/src/components/base/CkConversationLayout/types.ts +18 -0
  32. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts +89 -0
  33. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts +70 -0
  34. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue +110 -0
  35. package/src/components/base/CkCopyMessageActionButton/index.ts +2 -0
  36. package/src/components/base/CkCopyMessageActionButton/types.ts +43 -0
  37. package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +42 -0
  38. package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +230 -0
  39. package/src/components/base/CkHistoryItem/CkHistoryItem.vue +285 -0
  40. package/src/components/base/CkHistoryItem/activeIndicator.ts +15 -0
  41. package/src/components/base/CkHistoryItem/index.ts +2 -0
  42. package/src/components/base/CkHistoryItem/types.ts +111 -0
  43. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.spec.ts +26 -0
  44. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +149 -0
  45. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.vue +36 -0
  46. package/src/components/base/CkHistoryWrapper/index.ts +2 -0
  47. package/src/components/base/CkHistoryWrapper/types.ts +16 -0
  48. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +348 -0
  49. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +382 -0
  50. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +89 -0
  51. package/src/components/base/CkMarkdownRenderer/defaultMarkdownClasses.ts +19 -0
  52. package/src/components/base/CkMarkdownRenderer/index.ts +5 -0
  53. package/src/components/base/CkMarkdownRenderer/types.ts +53 -0
  54. package/src/components/base/CkMessageActionButton/CkMessageActionButton.spec.ts +40 -0
  55. package/src/components/base/CkMessageActionButton/CkMessageActionButton.stories.ts +56 -0
  56. package/src/components/base/CkMessageActionButton/CkMessageActionButton.vue +23 -0
  57. package/src/components/base/CkMessageActionButton/index.ts +2 -0
  58. package/src/components/base/CkMessageActionButton/types.ts +21 -0
  59. package/src/components/base/CkPromptActionButton/CkPromptActionButton.spec.ts +52 -0
  60. package/src/components/base/CkPromptActionButton/CkPromptActionButton.stories.ts +126 -0
  61. package/src/components/base/CkPromptActionButton/CkPromptActionButton.vue +25 -0
  62. package/src/components/base/CkPromptActionButton/index.ts +2 -0
  63. package/src/components/base/CkPromptActionButton/types.ts +32 -0
  64. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +181 -0
  65. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +224 -0
  66. package/src/components/base/CkPromptInput/CkPromptInput.vue +104 -0
  67. package/src/components/base/CkPromptInput/index.ts +2 -0
  68. package/src/components/base/CkPromptInput/types.ts +39 -0
  69. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +40 -0
  70. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.stories.ts +103 -0
  71. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +16 -0
  72. package/src/components/base/CkPromptInputDisclaimer/index.ts +2 -0
  73. package/src/components/base/CkPromptInputDisclaimer/types.ts +17 -0
  74. package/src/components/base/CkPromptStopButton/CkPromptStopButton.spec.ts +64 -0
  75. package/src/components/base/CkPromptStopButton/CkPromptStopButton.stories.ts +81 -0
  76. package/src/components/base/CkPromptStopButton/CkPromptStopButton.vue +27 -0
  77. package/src/components/base/CkPromptStopButton/index.ts +2 -0
  78. package/src/components/base/CkPromptStopButton/types.ts +28 -0
  79. package/src/components/base/CkUserMessage/CkUserMessage.spec.ts +20 -0
  80. package/src/components/base/CkUserMessage/CkUserMessage.stories.ts +112 -0
  81. package/src/components/base/CkUserMessage/CkUserMessage.vue +19 -0
  82. package/src/components/base/CkUserMessage/index.ts +2 -0
  83. package/src/components/base/CkUserMessage/types.ts +14 -0
  84. package/src/components/base/icons/CkSendIcon.vue +22 -0
  85. package/src/components/base/icons/CkStopIcon.vue +18 -0
  86. package/src/components/base/icons/index.ts +2 -0
  87. package/src/components/base/index.ts +21 -0
  88. package/src/components/composed/CkConversation/CkConversation.spec.ts +439 -0
  89. package/src/components/composed/CkConversation/CkConversation.stories.ts +642 -0
  90. package/src/components/composed/CkConversation/CkConversation.vue +146 -0
  91. package/src/components/composed/CkConversation/index.ts +2 -0
  92. package/src/components/composed/CkConversation/types.ts +37 -0
  93. package/src/components/composed/CkHistory/CkHistory.spec.ts +327 -0
  94. package/src/components/composed/CkHistory/CkHistory.stories.ts +373 -0
  95. package/src/components/composed/CkHistory/CkHistory.vue +126 -0
  96. package/src/components/composed/CkHistory/index.ts +2 -0
  97. package/src/components/composed/CkHistory/types.ts +72 -0
  98. package/src/components/composed/CkInput/CkInput.spec.ts +191 -0
  99. package/src/components/composed/CkInput/CkInput.stories.ts +438 -0
  100. package/src/components/composed/CkInput/CkInput.vue +104 -0
  101. package/src/components/composed/CkInput/index.ts +2 -0
  102. package/src/components/composed/CkInput/types.ts +60 -0
  103. package/src/components/composed/index.ts +5 -0
  104. package/src/components/index.ts +34 -0
  105. package/src/composables/index.ts +15 -0
  106. package/src/composables/types.ts +36 -0
  107. package/src/composables/useChat.ts +18 -0
  108. package/src/composables/useConversationSessions.spec.ts +86 -0
  109. package/src/composables/useConversationSessions.ts +85 -0
  110. package/src/composables/useConversations.spec.ts +557 -0
  111. package/src/composables/useConversations.ts +321 -0
  112. package/src/handlers/index.ts +9 -0
  113. package/src/handlers/useChatKitLabels.spec.ts +290 -0
  114. package/src/handlers/useChatKitLabels.ts +96 -0
  115. package/src/handlers/useMarkdownParser.spec.ts +112 -0
  116. package/src/handlers/useMarkdownParser.ts +47 -0
  117. package/src/handlers/useMessageHandler.spec.ts +535 -0
  118. package/src/handlers/useMessageHandler.ts +138 -0
  119. package/src/helpers/conversationTree.spec.ts +423 -0
  120. package/src/helpers/conversationTree.ts +117 -0
  121. package/src/helpers/generateId.ts +5 -0
  122. package/src/helpers/index.ts +4 -0
  123. package/src/helpers/secondsToDate.ts +7 -0
  124. package/src/helpers/toError.ts +8 -0
  125. package/src/index.ts +86 -0
  126. package/src/services/ChatService.ts +60 -0
  127. package/src/services/MockChatService.ts +47 -0
  128. package/src/services/index.ts +12 -0
  129. package/src/services/types.ts +92 -0
  130. package/src/test-setup.ts +12 -0
  131. package/src/test-utils/createTextPlugin.ts +9 -0
  132. package/src/types.ts +47 -0
  133. package/dist/generateId-BHf0W4Rq.js +0 -6
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
3
+ import type { BaseMessage } from '../../../types';
4
+ import CkAgentMessage from '../../base/CkAgentMessage/CkAgentMessage.vue';
5
+ import CkConversationLayout from '../../base/CkConversationLayout/CkConversationLayout.vue';
6
+ import CkCopyMessageActionButton from '../../base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue';
7
+ import CkMarkdownRenderer from '../../base/CkMarkdownRenderer/CkMarkdownRenderer.vue';
8
+ import CkUserMessage from '../../base/CkUserMessage/CkUserMessage.vue';
9
+ import type { CkConversationProps } from './types';
10
+ import {
11
+ UiAvatar,
12
+ UiAvatarFallback,
13
+ UiAvatarImage,
14
+ UiSpinner,
15
+ } from '@aleph-alpha/ui-library';
16
+ import { computed } from 'vue';
17
+
18
+ defineOptions({ name: 'CkConversation' });
19
+
20
+ const props = withDefaults(defineProps<CkConversationProps>(), {
21
+ agentAvatarImage: '',
22
+ agentAvatarFallback: '',
23
+ agentLoadingImage: '',
24
+ });
25
+
26
+ const slots = defineSlots<{
27
+ /** Replace the entire user message rendering */
28
+ 'user-message': (props: { message: BaseMessage }) => void;
29
+ /** Replace the user message content inside the bubble */
30
+ 'user-message-content': (props: { message: BaseMessage }) => void;
31
+ /** Replace the entire agent message rendering */
32
+ 'agent-message': (props: { message: BaseMessage }) => void;
33
+ /** Replace the agent avatar icon */
34
+ 'agent-message-icon': (props: { message: BaseMessage }) => void;
35
+ /** Replace the agent loading icon shown while a message is being generated */
36
+ 'agent-message-loading-icon': (props: { message: BaseMessage }) => void;
37
+ /** Replace the agent message header */
38
+ 'agent-message-header': (props: { message: BaseMessage }) => void;
39
+ /** Replace the agent message body */
40
+ 'agent-message-content': (props: { message: BaseMessage }) => void;
41
+ /** Provide footer actions for the agent message */
42
+ 'agent-message-footer': (props: { message: BaseMessage }) => void;
43
+ /** Replace the default code block rendering inside agent markdown */
44
+ 'agent-message-code-block': (props: {
45
+ code: string;
46
+ language: string;
47
+ }) => void;
48
+ }>();
49
+
50
+ const labels = useChatKitLabels();
51
+
52
+ /** Whether the icon area should be rendered at all */
53
+ const hasIcon = computed(
54
+ () =>
55
+ Boolean(props.agentAvatarImage) ||
56
+ Boolean(props.agentAvatarFallback) ||
57
+ Boolean(slots['agent-message-icon']),
58
+ );
59
+
60
+ const isLoading = (message: BaseMessage) =>
61
+ message.status === 'started' || message.status === 'sending';
62
+ </script>
63
+
64
+ <template>
65
+ <CkConversationLayout :messages="messages">
66
+ <template #user-message="{ message }">
67
+ <div :class="{ 'ml-8': hasIcon }">
68
+ <slot name="user-message" :message="message">
69
+ <CkUserMessage :message="message.content">
70
+ <slot name="user-message-content" :message="message">
71
+ {{ message.content }}
72
+ </slot>
73
+ </CkUserMessage>
74
+ </slot>
75
+ </div>
76
+ </template>
77
+
78
+ <template #agent-message="{ message }">
79
+ <slot name="agent-message" :message="message">
80
+ <CkAgentMessage>
81
+ <template v-if="hasIcon" #icon>
82
+ <template v-if="isLoading(message)">
83
+ <slot name="agent-message-loading-icon" :message="message">
84
+ <UiAvatar v-if="agentLoadingImage" class="size-6">
85
+ <UiAvatarImage
86
+ :src="agentLoadingImage"
87
+ :alt="labels.agentLoadingAvatarAlt"
88
+ />
89
+ </UiAvatar>
90
+ <UiSpinner
91
+ v-else
92
+ class="size-6"
93
+ :aria-label="labels.agentLoadingAvatarAlt"
94
+ />
95
+ </slot>
96
+ </template>
97
+
98
+ <template v-else>
99
+ <slot name="agent-message-icon" :message="message">
100
+ <UiAvatar class="size-6">
101
+ <UiAvatarImage
102
+ v-if="agentAvatarImage"
103
+ :src="agentAvatarImage"
104
+ :alt="labels.agentAvatarAlt"
105
+ />
106
+ <UiAvatarFallback v-if="agentAvatarFallback">{{
107
+ agentAvatarFallback
108
+ }}</UiAvatarFallback>
109
+ </UiAvatar>
110
+ </slot>
111
+ </template>
112
+ </template>
113
+
114
+ <template v-if="$slots['agent-message-header']" #header>
115
+ <slot name="agent-message-header" :message="message" />
116
+ </template>
117
+
118
+ <slot name="agent-message-content" :message="message">
119
+ <CkMarkdownRenderer
120
+ :content="message.content"
121
+ :classes="agentMarkdownClasses"
122
+ >
123
+ <template
124
+ v-if="$slots['agent-message-code-block']"
125
+ #code-block="codeBlockProps"
126
+ >
127
+ <slot name="agent-message-code-block" v-bind="codeBlockProps" />
128
+ </template>
129
+ </CkMarkdownRenderer>
130
+ </slot>
131
+
132
+ <template #footer>
133
+ <slot name="agent-message-footer" :message="message">
134
+ <CkCopyMessageActionButton
135
+ v-if="message.content && !isLoading(message)"
136
+ :text="message.content"
137
+ :aria-label-copy="labels.copyMessageAction"
138
+ :aria-label-copied="labels.copyMessageActionCopied"
139
+ />
140
+ </slot>
141
+ </template>
142
+ </CkAgentMessage>
143
+ </slot>
144
+ </template>
145
+ </CkConversationLayout>
146
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as CkConversation } from './CkConversation.vue';
2
+ export type { CkConversationProps } from './types';
@@ -0,0 +1,37 @@
1
+ import type { BaseMessage } from '../../../types';
2
+ import type { CkMarkdownElementClasses } from '../../base/CkMarkdownRenderer';
3
+
4
+ /**
5
+ * High-level chat conversation: renders user and agent messages, Markdown for
6
+ * assistant replies, an avatar, copy-to-clipboard action, streaming viewport,
7
+ * and slots for overriding any part of the rendering. Use as the drop-in
8
+ * conversation surface for most chat-kit integrations.
9
+ *
10
+ * @category Messages
11
+ * @useCases complete chat conversation ui, streaming chat with markdown, assistant chat view, llm conversation component, drop-in chat surface
12
+ * @keywords conversation, chat, messages, assistant, agent, user, markdown, streaming, avatar
13
+ * @related CkConversationLayout, CkAgentMessage, CkUserMessage, CkMarkdownRenderer, CkCopyMessageActionButton, CkInput
14
+ */
15
+ export interface CkConversationProps {
16
+ /** List of messages to display, in chronological order. */
17
+ messages: BaseMessage[];
18
+ /**
19
+ * URL of the agent avatar image, shared across all agent messages.
20
+ * @default ''
21
+ */
22
+ agentAvatarImage?: string;
23
+ /**
24
+ * Fallback text rendered inside the default agent avatar when no image is
25
+ * available.
26
+ * @default ''
27
+ */
28
+ agentAvatarFallback?: string;
29
+ /**
30
+ * URL of the loading avatar image shown while an agent message is being
31
+ * generated. When omitted a spinner is used instead.
32
+ * @default ''
33
+ */
34
+ agentLoadingImage?: string;
35
+ /** Override default CSS classes applied to Markdown elements in agent messages. */
36
+ agentMarkdownClasses?: CkMarkdownElementClasses;
37
+ }
@@ -0,0 +1,327 @@
1
+ import { render, screen } from '@testing-library/vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { h } from 'vue';
5
+ import CkHistory from './CkHistory.vue';
6
+ import type { Conversation } from '../../../types';
7
+
8
+ const NOW = 1_700_000_000;
9
+
10
+ function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
11
+ return {
12
+ id: 'c-1',
13
+ title: 'Project Proposal Overview',
14
+ createdAt: NOW - 60 * 60 * 24,
15
+ updatedAt: NOW,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ const hrefResolver = (id: string) => `/chat/${id}`;
21
+
22
+ describe('CkHistory', () => {
23
+ it('renders one row per conversation with the metadata title', () => {
24
+ const conversations = [
25
+ makeConversation({ id: 'c-1', title: 'Thread A' }),
26
+ makeConversation({ id: 'c-2', title: 'Thread B' }),
27
+ ];
28
+
29
+ render(CkHistory, {
30
+ props: { conversations, hrefResolver },
31
+ });
32
+
33
+ expect(screen.getByText('Thread A')).toBeInTheDocument();
34
+ expect(screen.getByText('Thread B')).toBeInTheDocument();
35
+ });
36
+
37
+ it('falls back to the historyItemUntitled label when title is missing or blank', () => {
38
+ const conversations = [
39
+ makeConversation({ id: 'c-x', title: undefined }),
40
+ makeConversation({ id: 'c-y', title: ' ' }),
41
+ ];
42
+
43
+ render(CkHistory, {
44
+ props: { conversations, hrefResolver },
45
+ });
46
+
47
+ const fallbacks = screen.getAllByText('Untitled conversation');
48
+ expect(fallbacks).toHaveLength(2);
49
+ });
50
+
51
+ it('builds href via the supplied hrefResolver', () => {
52
+ const conversations = [
53
+ makeConversation({ id: 'abc' }),
54
+ makeConversation({ id: 'def' }),
55
+ ];
56
+
57
+ const { container } = render(CkHistory, {
58
+ props: {
59
+ conversations,
60
+ hrefResolver: (id: string) => `/conversations/${id}?source=ui`,
61
+ },
62
+ });
63
+
64
+ const links = container.querySelectorAll('a[href]');
65
+ const hrefs = Array.from(links).map((a) => a.getAttribute('href'));
66
+ expect(hrefs).toContain('/conversations/abc?source=ui');
67
+ expect(hrefs).toContain('/conversations/def?source=ui');
68
+ });
69
+
70
+ it('marks the row matching activeId as active', () => {
71
+ const conversations = [
72
+ makeConversation({ id: 'c-1' }),
73
+ makeConversation({ id: 'c-2' }),
74
+ ];
75
+
76
+ const { container } = render(CkHistory, {
77
+ props: { conversations, hrefResolver, activeId: 'c-2' },
78
+ });
79
+
80
+ const activeRows = container.querySelectorAll(
81
+ '[data-active="true"] > a[href="/chat/c-2"]',
82
+ );
83
+ expect(activeRows.length).toBeGreaterThan(0);
84
+
85
+ const inactiveRows = container.querySelectorAll(
86
+ '[data-active="false"] > a[href="/chat/c-1"]',
87
+ );
88
+ expect(inactiveRows.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it('emits select with conversation + event on plain left-click and prevents default', async () => {
92
+ const conversation = makeConversation({ id: 'c-1' });
93
+ const { container, emitted } = render(CkHistory, {
94
+ props: { conversations: [conversation], hrefResolver },
95
+ });
96
+
97
+ const link = container.querySelector('a[href="/chat/c-1"]') as HTMLElement;
98
+ expect(link).toBeTruthy();
99
+
100
+ const event = new MouseEvent('click', {
101
+ button: 0,
102
+ bubbles: true,
103
+ cancelable: true,
104
+ });
105
+ link.dispatchEvent(event);
106
+
107
+ expect(event.defaultPrevented).toBe(true);
108
+ const events = emitted().select as unknown[][];
109
+ expect(events).toHaveLength(1);
110
+ expect(events[0][0]).toStrictEqual(conversation);
111
+ expect(events[0][1]).toBeInstanceOf(MouseEvent);
112
+ });
113
+
114
+ it('does not emit select for middle-click or cmd-click and lets the browser handle them natively', () => {
115
+ const conversation = makeConversation({ id: 'c-1' });
116
+ const { container, emitted } = render(CkHistory, {
117
+ props: { conversations: [conversation], hrefResolver },
118
+ });
119
+
120
+ const link = container.querySelector('a[href="/chat/c-1"]') as HTMLElement;
121
+
122
+ const middleClick = new MouseEvent('click', {
123
+ button: 1,
124
+ bubbles: true,
125
+ cancelable: true,
126
+ });
127
+ const cmdClick = new MouseEvent('click', {
128
+ button: 0,
129
+ metaKey: true,
130
+ bubbles: true,
131
+ cancelable: true,
132
+ });
133
+
134
+ link.dispatchEvent(middleClick);
135
+ link.dispatchEvent(cmdClick);
136
+
137
+ expect(middleClick.defaultPrevented).toBe(false);
138
+ expect(cmdClick.defaultPrevented).toBe(false);
139
+ expect(emitted().select).toBeUndefined();
140
+ });
141
+
142
+ it('emits edit and delete from the popover menu without firing select', async () => {
143
+ const user = userEvent.setup();
144
+ const conversation = makeConversation({ id: 'c-edit' });
145
+
146
+ const { emitted } = render(CkHistory, {
147
+ props: {
148
+ conversations: [conversation],
149
+ hrefResolver,
150
+ showActionOnHover: false,
151
+ },
152
+ });
153
+
154
+ await user.click(
155
+ screen.getByRole('button', { name: 'Conversation options' }),
156
+ );
157
+
158
+ await user.click(screen.getByRole('button', { name: 'Edit' }));
159
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
160
+
161
+ const editEvents = emitted().edit as unknown[][];
162
+ const deleteEvents = emitted().delete as unknown[][];
163
+
164
+ expect(editEvents).toHaveLength(1);
165
+ expect(editEvents[0][0]).toStrictEqual(conversation);
166
+
167
+ expect(deleteEvents).toHaveLength(1);
168
+ expect(deleteEvents[0][0]).toStrictEqual(conversation);
169
+
170
+ expect(emitted().select).toBeUndefined();
171
+ });
172
+
173
+ describe('#item slot', () => {
174
+ interface ItemSlotProps {
175
+ conversation: Conversation;
176
+ href: string;
177
+ isActive: boolean;
178
+ title: string;
179
+ select: (event: MouseEvent) => void;
180
+ edit: () => void;
181
+ delete: () => void;
182
+ }
183
+
184
+ it('exposes resolved row data (conversation, href, isActive, title) to the slot', () => {
185
+ const conversations = [
186
+ makeConversation({
187
+ id: 'c-1',
188
+ title: 'Thread A',
189
+ }),
190
+ makeConversation({
191
+ id: 'c-2',
192
+ title: undefined,
193
+ }),
194
+ ];
195
+
196
+ const { container } = render(CkHistory, {
197
+ props: {
198
+ conversations,
199
+ hrefResolver: (id: string) => `/c/${id}`,
200
+ activeId: 'c-1',
201
+ },
202
+ slots: {
203
+ item: ({ conversation, href, isActive, title }: ItemSlotProps) =>
204
+ h(
205
+ 'a',
206
+ {
207
+ class: 'custom-row',
208
+ href,
209
+ 'data-id': conversation.id,
210
+ 'data-active': String(isActive),
211
+ },
212
+ [h('span', { class: 'custom-title' }, title)],
213
+ ),
214
+ },
215
+ });
216
+
217
+ const rows = container.querySelectorAll('a.custom-row');
218
+ expect(rows).toHaveLength(2);
219
+
220
+ expect(rows[0].getAttribute('href')).toBe('/c/c-1');
221
+ expect(rows[0].getAttribute('data-id')).toBe('c-1');
222
+ expect(rows[0].getAttribute('data-active')).toBe('true');
223
+ expect(rows[0].querySelector('.custom-title')?.textContent).toBe(
224
+ 'Thread A',
225
+ );
226
+
227
+ expect(rows[1].getAttribute('href')).toBe('/c/c-2');
228
+ expect(rows[1].getAttribute('data-id')).toBe('c-2');
229
+ expect(rows[1].getAttribute('data-active')).toBe('false');
230
+ expect(rows[1].querySelector('.custom-title')?.textContent).toBe(
231
+ 'Untitled conversation',
232
+ );
233
+ });
234
+
235
+ it('slot `select` helper applies the click guard and emits select for plain left-clicks only', () => {
236
+ const conversation = makeConversation({ id: 'c-1' });
237
+
238
+ const { container, emitted } = render(CkHistory, {
239
+ props: { conversations: [conversation], hrefResolver },
240
+ slots: {
241
+ item: ({ href, select }: ItemSlotProps) =>
242
+ h(
243
+ 'a',
244
+ {
245
+ class: 'custom-row',
246
+ href,
247
+ onClick: (event: MouseEvent) => select(event),
248
+ },
249
+ 'row',
250
+ ),
251
+ },
252
+ });
253
+
254
+ const link = container.querySelector('a.custom-row') as HTMLElement;
255
+ expect(link).toBeTruthy();
256
+
257
+ const cmdClick = new MouseEvent('click', {
258
+ button: 0,
259
+ metaKey: true,
260
+ bubbles: true,
261
+ cancelable: true,
262
+ });
263
+ link.dispatchEvent(cmdClick);
264
+ expect(cmdClick.defaultPrevented).toBe(false);
265
+ expect(emitted().select).toBeUndefined();
266
+
267
+ const plainClick = new MouseEvent('click', {
268
+ button: 0,
269
+ bubbles: true,
270
+ cancelable: true,
271
+ });
272
+ link.dispatchEvent(plainClick);
273
+ expect(plainClick.defaultPrevented).toBe(true);
274
+
275
+ const events = emitted().select as unknown[][];
276
+ expect(events).toHaveLength(1);
277
+ expect(events[0][0]).toStrictEqual(conversation);
278
+ expect(events[0][1]).toBe(plainClick);
279
+ });
280
+
281
+ it('slot `edit` and `delete` helpers emit the matching events with the conversation', async () => {
282
+ const user = userEvent.setup();
283
+ const conversation = makeConversation({ id: 'c-1' });
284
+
285
+ const { emitted } = render(CkHistory, {
286
+ props: { conversations: [conversation], hrefResolver },
287
+ slots: {
288
+ item: ({ edit, delete: del }: ItemSlotProps) =>
289
+ h('div', null, [
290
+ h(
291
+ 'button',
292
+ {
293
+ type: 'button',
294
+ 'data-testid': 'custom-edit',
295
+ onClick: () => edit(),
296
+ },
297
+ 'edit',
298
+ ),
299
+ h(
300
+ 'button',
301
+ {
302
+ type: 'button',
303
+ 'data-testid': 'custom-delete',
304
+ onClick: () => del(),
305
+ },
306
+ 'delete',
307
+ ),
308
+ ]),
309
+ },
310
+ });
311
+
312
+ await user.click(screen.getByTestId('custom-edit'));
313
+ await user.click(screen.getByTestId('custom-delete'));
314
+
315
+ const editEvents = emitted().edit as unknown[][];
316
+ const deleteEvents = emitted().delete as unknown[][];
317
+
318
+ expect(editEvents).toHaveLength(1);
319
+ expect(editEvents[0][0]).toStrictEqual(conversation);
320
+
321
+ expect(deleteEvents).toHaveLength(1);
322
+ expect(deleteEvents[0][0]).toStrictEqual(conversation);
323
+
324
+ expect(emitted().select).toBeUndefined();
325
+ });
326
+ });
327
+ });