@meetelise/chat 1.59.0 → 1.59.2
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/src/WebComponent/Scheduler/tour-scheduler.d.ts +4 -0
- package/dist/src/WebComponent/actions/call-us-window.d.ts +5 -0
- package/dist/src/WebComponent/actions/email-us-window.d.ts +5 -0
- package/dist/src/WebComponent/me-chat.d.ts +15 -9
- package/dist/src/WebComponent/pubnub-chat.d.ts +14 -0
- package/package.json +1 -1
- package/public/dist/index.js +5594 -5573
- package/src/WebComponent/Scheduler/tour-scheduler.ts +27 -0
- package/src/WebComponent/actions/call-us-window.ts +22 -0
- package/src/WebComponent/actions/email-us-window.ts +22 -0
- package/src/WebComponent/me-chat.ts +89 -136
- package/src/WebComponent/me-select.ts +1 -1
- package/src/WebComponent/pubnub-chat-styles.ts +12 -0
- package/src/WebComponent/pubnub-chat.ts +186 -7
- package/src/chatKeyboard.test.ts +430 -0
- package/src/disclaimers.ts +36 -3
- package/src/meChatAutoOpen.test.ts +143 -0
- package/src/pubnub-chat.test.ts +184 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { html, LitElement, TemplateResult } from "lit";
|
|
2
|
+
import { ifDefined } from "lit/directives/if-defined.js";
|
|
2
3
|
import { customElement, property, query, state } from "lit/decorators.js";
|
|
3
4
|
import { classMap } from "lit/directives/class-map.js";
|
|
4
5
|
import { styleMap } from "lit/directives/style-map.js";
|
|
5
6
|
import MyPubnub, {
|
|
6
7
|
SimpleMessageTypes,
|
|
7
8
|
SimpleChatMessage,
|
|
9
|
+
SimpleTextChatMessage,
|
|
8
10
|
isSimpleMediaChatMessage,
|
|
9
11
|
isSimpleNavigationChatMessage,
|
|
10
12
|
isSimpleTextChatMessage,
|
|
@@ -127,6 +129,13 @@ export class PubnubChat extends LitElement {
|
|
|
127
129
|
@state()
|
|
128
130
|
hasReceivedConsent = false;
|
|
129
131
|
|
|
132
|
+
@state()
|
|
133
|
+
private _replyAnnouncement = "";
|
|
134
|
+
|
|
135
|
+
private announcedReplyKeys = new Set<string>();
|
|
136
|
+
|
|
137
|
+
private hasReplySnapshot = false;
|
|
138
|
+
|
|
130
139
|
@state()
|
|
131
140
|
privacyPolicyUrl = "http://bit.ly/me_privacy_policy";
|
|
132
141
|
|
|
@@ -227,6 +236,50 @@ export class PubnubChat extends LitElement {
|
|
|
227
236
|
distanceFromBottom <= PubnubChat.AUTO_SCROLL_THRESHOLD_PX;
|
|
228
237
|
};
|
|
229
238
|
|
|
239
|
+
protected willUpdate(): void {
|
|
240
|
+
this.syncReplyAnnouncement();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private syncReplyAnnouncement(): void {
|
|
244
|
+
const assistantName = this.building?.userFirstName || "Elise";
|
|
245
|
+
const ready = this.messages.flatMap((message) => {
|
|
246
|
+
const announcement = finishedAssistantAnnouncement(
|
|
247
|
+
message,
|
|
248
|
+
assistantName
|
|
249
|
+
);
|
|
250
|
+
return announcement ? [{ key: replyKey(message), announcement }] : [];
|
|
251
|
+
});
|
|
252
|
+
if (!this.hasReplySnapshot) {
|
|
253
|
+
if (this.messages.length === 0) return;
|
|
254
|
+
ready.forEach(({ key }) => this.announcedReplyKeys.add(key));
|
|
255
|
+
this.hasReplySnapshot = true;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const fresh = ready.filter(({ key }) => !this.announcedReplyKeys.has(key));
|
|
259
|
+
if (fresh.length === 0) return;
|
|
260
|
+
fresh.forEach(({ key }) => this.announcedReplyKeys.add(key));
|
|
261
|
+
const text = fresh.map(({ announcement }) => announcement).join(" ");
|
|
262
|
+
// The same string does not update a live region. A repeat toggles a
|
|
263
|
+
// zero-width space so the second reply is spoken. Readers skip that space.
|
|
264
|
+
const previous = this._replyAnnouncement.replace(/\u200b/g, "");
|
|
265
|
+
this._replyAnnouncement =
|
|
266
|
+
text === previous && !this._replyAnnouncement.endsWith("\u200b")
|
|
267
|
+
? `${text}\u200b`
|
|
268
|
+
: text;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private renderReplyAnnouncer(): TemplateResult {
|
|
272
|
+
return html`<div
|
|
273
|
+
id="reply-announcer"
|
|
274
|
+
class="sr-only"
|
|
275
|
+
role="status"
|
|
276
|
+
aria-live="polite"
|
|
277
|
+
aria-atomic="true"
|
|
278
|
+
>
|
|
279
|
+
${this._replyAnnouncement}
|
|
280
|
+
</div>`;
|
|
281
|
+
}
|
|
282
|
+
|
|
230
283
|
async updated(): Promise<void> {
|
|
231
284
|
this.scrollToChatBottom();
|
|
232
285
|
try {
|
|
@@ -248,6 +301,25 @@ export class PubnubChat extends LitElement {
|
|
|
248
301
|
return this.requiresConsent && !this.hasReceivedConsent;
|
|
249
302
|
};
|
|
250
303
|
|
|
304
|
+
/** Caret in the message field when the composer is on screen. */
|
|
305
|
+
focusMessageField(): boolean {
|
|
306
|
+
const root = this.shadowRoot;
|
|
307
|
+
const input = root?.getElementById("message-input");
|
|
308
|
+
if (!(input instanceof HTMLTextAreaElement) || !root) return false;
|
|
309
|
+
// Consent hides the footer with display:none. Focusing it would no-op.
|
|
310
|
+
if (input.getClientRects().length === 0) return false;
|
|
311
|
+
if (root.activeElement !== input) input.focus();
|
|
312
|
+
return root.activeElement === input;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Tab cycles inside the fullscreen mobile dialog. Desktop stays non-modal. */
|
|
316
|
+
private trapTabInMobileDialog(event: KeyboardEvent): void {
|
|
317
|
+
if (!this._isMobile) return;
|
|
318
|
+
const container = this.shadowRoot?.getElementById("pubnub-chat-container");
|
|
319
|
+
if (!container) return;
|
|
320
|
+
trapTabInside(event, container);
|
|
321
|
+
}
|
|
322
|
+
|
|
251
323
|
render(): TemplateResult {
|
|
252
324
|
if (!this.buildingSlug && !this.centralConvo) {
|
|
253
325
|
return html``; // error should be here
|
|
@@ -344,8 +416,6 @@ export class PubnubChat extends LitElement {
|
|
|
344
416
|
>
|
|
345
417
|
<ul
|
|
346
418
|
id="message-thread-list"
|
|
347
|
-
role="log"
|
|
348
|
-
aria-live="polite"
|
|
349
419
|
class=${classMap({
|
|
350
420
|
["hidden"]: this.needsConsent(),
|
|
351
421
|
})}
|
|
@@ -425,6 +495,7 @@ export class PubnubChat extends LitElement {
|
|
|
425
495
|
`
|
|
426
496
|
: ""}
|
|
427
497
|
</ul>
|
|
498
|
+
${this.renderReplyAnnouncer()}
|
|
428
499
|
</div>
|
|
429
500
|
|
|
430
501
|
<div
|
|
@@ -521,8 +592,6 @@ export class PubnubChat extends LitElement {
|
|
|
521
592
|
>
|
|
522
593
|
<ul
|
|
523
594
|
id="message-thread-list"
|
|
524
|
-
role="log"
|
|
525
|
-
aria-live="polite"
|
|
526
595
|
class=${classMap({
|
|
527
596
|
["hidden"]: this.needsConsent(),
|
|
528
597
|
})}
|
|
@@ -603,6 +672,7 @@ export class PubnubChat extends LitElement {
|
|
|
603
672
|
`
|
|
604
673
|
: ""}
|
|
605
674
|
</ul>
|
|
675
|
+
${this.renderReplyAnnouncer()}
|
|
606
676
|
</div>
|
|
607
677
|
|
|
608
678
|
<div
|
|
@@ -655,12 +725,25 @@ export class PubnubChat extends LitElement {
|
|
|
655
725
|
return html`
|
|
656
726
|
<div
|
|
657
727
|
id="pubnub-chat-container"
|
|
728
|
+
role="dialog"
|
|
729
|
+
aria-label=${this.building.name
|
|
730
|
+
? `Chat with ${this.building.name}`
|
|
731
|
+
: "Chat"}
|
|
732
|
+
aria-modal=${ifDefined(this._isMobile ? "true" : undefined)}
|
|
733
|
+
tabindex="-1"
|
|
658
734
|
class=${classMap({
|
|
659
735
|
["pubnub-container__mobile"]: this._isMobile,
|
|
660
736
|
["pubnub-container__desktop"]: !this._isMobile,
|
|
661
737
|
})}
|
|
662
738
|
style=${styleMap(styles)}
|
|
663
|
-
@keydown=${(e: KeyboardEvent) =>
|
|
739
|
+
@keydown=${(e: KeyboardEvent) => {
|
|
740
|
+
e.stopPropagation();
|
|
741
|
+
// Escape cancels an in-progress IME conversion. Don't also close the chat.
|
|
742
|
+
if (e.key === "Escape" && !e.isComposing) {
|
|
743
|
+
this.onClickExit();
|
|
744
|
+
}
|
|
745
|
+
this.trapTabInMobileDialog(e);
|
|
746
|
+
}}
|
|
664
747
|
@keyup=${(e: KeyboardEvent) => e.stopPropagation()}
|
|
665
748
|
>
|
|
666
749
|
<div
|
|
@@ -713,8 +796,6 @@ export class PubnubChat extends LitElement {
|
|
|
713
796
|
>
|
|
714
797
|
<ul
|
|
715
798
|
id="message-thread-list"
|
|
716
|
-
role="log"
|
|
717
|
-
aria-live="polite"
|
|
718
799
|
class=${classMap({
|
|
719
800
|
["hidden"]: this.needsConsent(),
|
|
720
801
|
})}
|
|
@@ -802,6 +883,7 @@ export class PubnubChat extends LitElement {
|
|
|
802
883
|
`
|
|
803
884
|
: ""}
|
|
804
885
|
</ul>
|
|
886
|
+
${this.renderReplyAnnouncer()}
|
|
805
887
|
</div>
|
|
806
888
|
<div
|
|
807
889
|
id="footer"
|
|
@@ -868,6 +950,82 @@ export class PubnubChat extends LitElement {
|
|
|
868
950
|
}
|
|
869
951
|
}
|
|
870
952
|
|
|
953
|
+
/** Move focus to the first control in an overlay. Returns false if there is none yet. */
|
|
954
|
+
export function focusFirstTabStop(container: HTMLElement): boolean {
|
|
955
|
+
const first = tabStopsIn(container)[0];
|
|
956
|
+
if (!first) return false;
|
|
957
|
+
first.focus();
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Wrap Tab inside container when focus is already in it. No effect otherwise. */
|
|
962
|
+
export function trapTabInside(
|
|
963
|
+
event: KeyboardEvent,
|
|
964
|
+
container: HTMLElement
|
|
965
|
+
): void {
|
|
966
|
+
if (event.key !== "Tab") return;
|
|
967
|
+
const active = activeElementInside(container);
|
|
968
|
+
if (!active) return;
|
|
969
|
+
const stops = tabStopsIn(container);
|
|
970
|
+
// No controls yet (e.g. the tour's loading paint): hold focus on the container.
|
|
971
|
+
if (stops.length === 0) {
|
|
972
|
+
event.preventDefault();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const index = stops.indexOf(active);
|
|
976
|
+
const onFirst = index === 0;
|
|
977
|
+
const onLast = index === stops.length - 1;
|
|
978
|
+
if (event.shiftKey && (active === container || onFirst)) {
|
|
979
|
+
event.preventDefault();
|
|
980
|
+
stops[stops.length - 1].focus();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (!event.shiftKey && (active === container || onLast)) {
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
stops[0].focus();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function activeElementInside(container: HTMLElement): HTMLElement | null {
|
|
990
|
+
let active: Element | null = document.activeElement;
|
|
991
|
+
while (active?.shadowRoot?.activeElement) {
|
|
992
|
+
active = active.shadowRoot.activeElement;
|
|
993
|
+
}
|
|
994
|
+
if (!(active instanceof HTMLElement)) return null;
|
|
995
|
+
let node: Node | null = active;
|
|
996
|
+
while (node) {
|
|
997
|
+
if (node === container) return active;
|
|
998
|
+
node = node instanceof ShadowRoot ? node.host : node.parentNode;
|
|
999
|
+
}
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function tabStopsIn(root: ParentNode): HTMLElement[] {
|
|
1004
|
+
const stops: HTMLElement[] = [];
|
|
1005
|
+
const visit = (node: ParentNode): void => {
|
|
1006
|
+
if (node instanceof Element && node.shadowRoot) visit(node.shadowRoot);
|
|
1007
|
+
node.querySelectorAll<HTMLElement>("*").forEach((el) => {
|
|
1008
|
+
if (isTabStop(el)) stops.push(el);
|
|
1009
|
+
if (el.shadowRoot) visit(el.shadowRoot);
|
|
1010
|
+
});
|
|
1011
|
+
};
|
|
1012
|
+
visit(root);
|
|
1013
|
+
return stops;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function isTabStop(el: HTMLElement): boolean {
|
|
1017
|
+
if (el.getAttribute("tabindex") === "-1") return false;
|
|
1018
|
+
if ("disabled" in el && (el as HTMLButtonElement).disabled) return false;
|
|
1019
|
+
const focusable =
|
|
1020
|
+
el.tagName === "BUTTON" ||
|
|
1021
|
+
el.tagName === "INPUT" ||
|
|
1022
|
+
el.tagName === "TEXTAREA" ||
|
|
1023
|
+
el.tagName === "SELECT" ||
|
|
1024
|
+
(el.tagName === "A" && el.hasAttribute("href")) ||
|
|
1025
|
+
el.hasAttribute("tabindex");
|
|
1026
|
+
return focusable && el.getClientRects().length > 0;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
871
1029
|
const messageAriaLabel = (message: SimpleChatMessage): string => {
|
|
872
1030
|
if (isSimpleTextChatMessage(message)) return message.message;
|
|
873
1031
|
if (isSimpleNavigationChatMessage(message)) {
|
|
@@ -875,3 +1033,24 @@ const messageAriaLabel = (message: SimpleChatMessage): string => {
|
|
|
875
1033
|
}
|
|
876
1034
|
return "Media message";
|
|
877
1035
|
};
|
|
1036
|
+
|
|
1037
|
+
const replyKey = (message: SimpleChatMessage): string =>
|
|
1038
|
+
`${message.type}:${message.timestamp}`;
|
|
1039
|
+
|
|
1040
|
+
// Same check as pubnub-message: done chunk present, and the list is past its order.
|
|
1041
|
+
const isTextStillStreaming = (message: SimpleTextChatMessage): boolean => {
|
|
1042
|
+
const doneChunk = message.chunks.find((chunk) => chunk.isDone);
|
|
1043
|
+
if (!doneChunk) return true;
|
|
1044
|
+
return message.chunks.length === doneChunk.order;
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
const finishedAssistantAnnouncement = (
|
|
1048
|
+
message: SimpleChatMessage,
|
|
1049
|
+
assistantName: string
|
|
1050
|
+
): string | null => {
|
|
1051
|
+
if (message.isLeadMessage) return null;
|
|
1052
|
+
if (isSimpleTextChatMessage(message) && isTextStillStreaming(message)) {
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
return `${assistantName}: ${messageAriaLabel(message)}`;
|
|
1056
|
+
};
|