@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.
@@ -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) => e.stopPropagation()}
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
+ };