Skip to content

Commit a381855

Browse files
committed
feat(chat): add initial KB nav between messages
1 parent bcb49c3 commit a381855

File tree

8 files changed

+121
-15
lines changed

8 files changed

+121
-15
lines changed

src/components/chat/chat-input.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export default class IgcChatInputComponent extends LitElement {
6161

6262
protected override firstUpdated() {
6363
this.setupDragAndDrop();
64-
this._chatState?.updateAcceptedTypesCache();
64+
if (this._chatState) {
65+
this._chatState.updateAcceptedTypesCache();
66+
this._chatState.textArea = this.textInputElement;
67+
}
6568
}
6669

6770
protected override updated() {

src/components/chat/chat-message-list.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { consume } from '@lit/context';
22
import { html, LitElement, nothing } from 'lit';
3+
import { state } from 'lit/decorators.js';
34
import { repeat } from 'lit/directives/repeat.js';
45
import { chatContext } from '../common/context.js';
56
import { registerComponent } from '../common/definitions/register.js';
@@ -21,6 +22,9 @@ export default class IgcChatMessageListComponent extends LitElement {
2122
@consume({ context: chatContext, subscribe: true })
2223
private _chatState?: ChatState;
2324

25+
@state()
26+
private _activeMessageId = '';
27+
2428
/* blazorSuppress */
2529
public static register() {
2630
registerComponent(IgcChatMessageListComponent, IgcChatMessageComponent);
@@ -74,6 +78,50 @@ export default class IgcChatMessageListComponent extends LitElement {
7478
});
7579
}
7680

81+
private scrollToMessage(messageId: string) {
82+
const messageElement = this.shadowRoot?.querySelector(
83+
`#message-${messageId}`
84+
);
85+
messageElement?.scrollIntoView();
86+
}
87+
88+
private handleFocusIn() {
89+
if (!this._chatState?.messages || this._chatState.messages.length === 0) {
90+
return;
91+
}
92+
const lastMessage = this._chatState.sortedMessagesIds?.pop() ?? '';
93+
this._activeMessageId = lastMessage !== '' ? `message-${lastMessage}` : '';
94+
}
95+
96+
private handleFocusOut() {
97+
this._activeMessageId = '';
98+
}
99+
100+
private handleKeyDown(e: KeyboardEvent) {
101+
if (!this._chatState?.messages || this._chatState.messages.length === 0) {
102+
return;
103+
}
104+
105+
const currentIndex = this._chatState?.sortedMessagesIds.findIndex(
106+
(id) => `message-${id}` === this._activeMessageId
107+
);
108+
109+
if (e.key === 'ArrowUp' && currentIndex > 0) {
110+
const previousMessageId =
111+
this._chatState.sortedMessagesIds[currentIndex - 1];
112+
this._activeMessageId = `message-${previousMessageId}`;
113+
this.scrollToMessage(previousMessageId);
114+
}
115+
if (
116+
e.key === 'ArrowDown' &&
117+
currentIndex < this._chatState?.messages.length - 1
118+
) {
119+
const nextMessageId = this._chatState.sortedMessagesIds[currentIndex + 1];
120+
this._activeMessageId = `message-${nextMessageId}`;
121+
this.scrollToMessage(nextMessageId);
122+
}
123+
}
124+
77125
protected override updated() {
78126
if (!this._chatState?.options?.disableAutoScroll) {
79127
this.scrollToBottom();
@@ -102,7 +150,13 @@ export default class IgcChatMessageListComponent extends LitElement {
102150
);
103151

104152
return html`
105-
<div class='message-container'></div>
153+
<div
154+
class='message-container'
155+
aria-activedescendant=${this._activeMessageId}
156+
tabindex='0'
157+
@focusin=${this.handleFocusIn}
158+
@focusout=${this.handleFocusOut}
159+
@keydown=${this.handleKeyDown}></div>
106160
<div class="message-list">
107161
${repeat(
108162
groupedMessages,
@@ -111,9 +165,19 @@ export default class IgcChatMessageListComponent extends LitElement {
111165
${repeat(
112166
group.messages,
113167
(message) => message.id,
114-
(message) => html`
115-
<igc-chat-message .message=${message}></igc-chat-message>
116-
`
168+
(message) => {
169+
const messageId = `message-${message.id}`;
170+
return html`
171+
<igc-chat-message
172+
id=${messageId}
173+
class=${this._activeMessageId === messageId
174+
? 'active'
175+
: ''}
176+
.message=${message}
177+
>
178+
</igc-chat-message>
179+
`;
180+
}
117181
)}
118182
`
119183
)}

src/components/chat/chat-state.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type IgcTextareaComponent from '../textarea/textarea.js';
12
import type IgcChatComponent from './chat.js';
23
import type { IgcChatComponentEventMap } from './chat.js';
34
import type {
@@ -9,7 +10,9 @@ import type {
910
export class ChatState {
1011
//#region Internal properties and state
1112
private readonly _host: IgcChatComponent;
13+
private _textArea: IgcTextareaComponent | null = null;
1214
private _messages: IgcMessage[] = [];
15+
private _sortedMessages: IgcMessage[] = [];
1316
private _options?: IgcChatOptions;
1417
private _inputAttachments: IgcMessageAttachment[] = [];
1518
private _inputValue = '';
@@ -24,6 +27,10 @@ export class ChatState {
2427

2528
//#region Public properties
2629

30+
public get sortedMessagesIds(): string[] {
31+
return this._sortedMessages.map((m) => m.id);
32+
}
33+
2734
/** Chat message list. */
2835
public get messages(): IgcMessage[] {
2936
return this._messages;
@@ -32,6 +39,9 @@ export class ChatState {
3239
/** Sets the chat message list. */
3340
public set messages(value: IgcMessage[]) {
3441
this._messages = value;
42+
this._sortedMessages = value.slice().sort((a, b) => {
43+
return a.timestamp.getTime() - b.timestamp.getTime();
44+
});
3545
this._host.requestUpdate();
3646
}
3747

@@ -51,6 +61,16 @@ export class ChatState {
5161
return this._options?.currentUserId ?? 'user';
5262
}
5363

64+
/** Gets the textarea component. */
65+
public get textArea(): IgcTextareaComponent | null {
66+
return this._textArea;
67+
}
68+
69+
/** Sets the textarea component. */
70+
public set textArea(value: IgcTextareaComponent) {
71+
this._textArea = value;
72+
}
73+
5474
/** Input attachments. */
5575
public get inputAttachments(): IgcMessageAttachment[] {
5676
return this._inputAttachments;
@@ -159,6 +179,14 @@ export class ChatState {
159179
}
160180
}
161181

182+
/** Send message and focus back the text area when a suggestion is selected */
183+
public handleSuggestionClick(suggestion: string): void {
184+
this.addMessage({ text: suggestion });
185+
if (this.textArea) {
186+
this.textArea.focus();
187+
}
188+
}
189+
162190
/** Updates chat options dynamically. */
163191
public updateOptions(options: Partial<IgcChatOptions>): void {
164192
this.options = { ...this.options, ...options };

src/components/chat/chat.spec.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ describe('Chat', () => {
201201
);
202202

203203
expect(messageList).shadowDom.to.equal(
204-
`<div class="message-container">
204+
`<div aria-activedescendant="" class="message-container" tabindex="0">
205205
</div>
206206
<div class="message-list">
207207
</div>`
@@ -256,13 +256,13 @@ describe('Chat', () => {
256256
expect(chat.messages.length).to.equal(4);
257257
expect(messageContainer).dom.to.equal(
258258
`<div class="message-list">
259-
<igc-chat-message>
259+
<igc-chat-message id="message-1">
260260
</igc-chat-message>
261-
<igc-chat-message>
261+
<igc-chat-message id="message-2">
262262
</igc-chat-message>
263-
<igc-chat-message>
263+
<igc-chat-message id="message-3">
264264
</igc-chat-message>
265-
<igc-chat-message>
265+
<igc-chat-message id="message-4">
266266
</igc-chat-message>
267267
</div>`
268268
);
@@ -633,6 +633,7 @@ describe('Chat', () => {
633633
class="small"
634634
collection="material"
635635
name="preview"
636+
tabindex="-1"
636637
type="button"
637638
variant="flat"
638639
>
@@ -641,6 +642,7 @@ describe('Chat', () => {
641642
class="small"
642643
collection="material"
643644
name="more"
645+
tabindex="-1"
644646
type="button"
645647
variant="flat"
646648
>
@@ -686,6 +688,7 @@ describe('Chat', () => {
686688
class="small"
687689
collection="material"
688690
name="more"
691+
tabindex="-1"
689692
type="button"
690693
variant="flat"
691694
>
@@ -748,7 +751,7 @@ describe('Chat', () => {
748751
expect(chat.messages.length).to.equal(1);
749752
expect(messageContainer).dom.to.equal(
750753
`<div class="message-list">
751-
<igc-chat-message>
754+
<igc-chat-message id="message-1">
752755
</igc-chat-message>
753756
<div class="typing-indicator">
754757
<div class="typing-dot">
@@ -948,7 +951,7 @@ describe('Chat', () => {
948951
expect(chat.messages.length).to.equal(1);
949952
expect(messageContainer).dom.to.equal(
950953
`<div class="message-list">
951-
<igc-chat-message>
954+
<igc-chat-message id="message-1">
952955
</igc-chat-message>
953956
<span>loading...</span>
954957
</div>`

src/components/chat/chat.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ export default class IgcChatComponent extends EventEmitterMixin<
138138
(suggestion) => html`
139139
<slot name="suggestion" part="suggestion">
140140
<igc-chip
141-
@click=${() => this._chatState.addMessage({ text: suggestion })}
141+
@click=${() =>
142+
this._chatState?.handleSuggestionClick(suggestion)}
142143
>
143144
<span>${suggestion}</span>
144145
</igc-chip>

src/components/chat/message-attachments.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement {
121121
collection="material"
122122
variant="flat"
123123
class="small"
124+
tabIndex="-1"
124125
@click=${() => this.openImagePreview(attachment)}
125126
></igc-icon-button>`
126127
: nothing}
@@ -129,6 +130,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement {
129130
collection="material"
130131
variant="flat"
131132
class="small"
133+
tabIndex="-1"
132134
></igc-icon-button>
133135
</slot>
134136
`}
@@ -186,6 +188,7 @@ export default class IgcMessageAttachmentsComponent extends LitElement {
186188
collection="material"
187189
variant="contained"
188190
class="small"
191+
tabIndex="-1"
189192
@click=${this.closeImagePreview}
190193
></igc-icon-button>
191194
</div>

src/components/chat/themes/message-list.base.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
margin: 0 8px;
3232
}
3333

34+
igc-chat-message.active {
35+
border: 2px solid #7f8386;
36+
}
37+
3438
.typing-indicator {
3539
display: flex;
3640
align-items: center;

stories/chat.stories.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const userMessages: any[] = [];
7777

7878
let isResponseSent: boolean;
7979

80-
const messageActionsTemplate = (msg: any) => {
80+
const _messageActionsTemplate = (msg: any) => {
8181
return msg.sender !== 'user' && msg.text.trim()
8282
? isResponseSent !== false
8383
? html`
@@ -135,7 +135,7 @@ const ai_chat_options = {
135135
headerText: 'Chat',
136136
suggestions: ['Hello', 'Hi', 'Generate an image of a pig!'],
137137
templates: {
138-
messageActionsTemplate: messageActionsTemplate,
138+
// messageActionsTemplate: messageActionsTemplate,
139139
//composingIndicatorTemplate: _composingIndicatorTemplate,
140140
// textInputTemplate: _textInputTemplate,
141141
// textAreaActionsTemplate: _textAreaActionsTemplate,

0 commit comments

Comments
 (0)