@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.
@@ -75,11 +75,13 @@ import { readGoogleReserveReferral } from "../../googleReserve";
75
75
  import fetchBuildingUnitsSummary, {
76
76
  UnitSummary,
77
77
  } from "../../fetchBuildingUnitsSummary";
78
+ import { focusFirstTabStop, trapTabInside } from "../pubnub-chat";
78
79
 
79
80
  @customElement("tour-scheduler")
80
81
  export class TourScheduler extends LitElement {
81
82
  connectedCallback(): void {
82
83
  super.connectedCallback();
84
+ this.addEventListener("keydown", this.trapOverlayTab);
83
85
  if (!this.hasAttribute("role")) {
84
86
  this.setAttribute("role", "dialog");
85
87
  }
@@ -149,6 +151,31 @@ export class TourScheduler extends LitElement {
149
151
  @property({ attribute: true })
150
152
  buildingName = "";
151
153
 
154
+ disconnectedCallback(): void {
155
+ this.removeEventListener("keydown", this.trapOverlayTab);
156
+ super.disconnectedCallback();
157
+ }
158
+
159
+ private trapOverlayTab = (event: KeyboardEvent): void => {
160
+ trapTabInside(event, this);
161
+ };
162
+
163
+ private movedFocusInside = false;
164
+
165
+ updated(): void {
166
+ if (this.movedFocusInside) return;
167
+ if (focusFirstTabStop(this)) {
168
+ this.movedFocusInside = true;
169
+ return;
170
+ }
171
+ // The first paint is the loading state and has no controls. A custom
172
+ // element's tabIndex reads as -1 until the attribute is set, and focus()
173
+ // is ignored until then. Hold focus on the dialog so it does not fall
174
+ // through to the page.
175
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
176
+ if (!this.matches(":focus")) this.focus({ preventScroll: true });
177
+ }
178
+
152
179
  onCloseClicked?: (e: MouseEvent) => void;
153
180
 
154
181
  @state()
@@ -17,6 +17,7 @@ import {
17
17
  } from "../LeadSourceMultitouchClient";
18
18
 
19
19
  import { getOfficeHourText } from "../OfficeHours";
20
+ import { focusFirstTabStop, trapTabInside } from "../pubnub-chat";
20
21
  import {
21
22
  formatToUsPhoneInput,
22
23
  isModifierKey,
@@ -32,6 +33,27 @@ import {
32
33
 
33
34
  @customElement("call-us-window")
34
35
  export class CallUsWindow extends LitElement {
36
+ connectedCallback(): void {
37
+ super.connectedCallback();
38
+ this.addEventListener("keydown", this.trapOverlayTab);
39
+ }
40
+
41
+ disconnectedCallback(): void {
42
+ this.removeEventListener("keydown", this.trapOverlayTab);
43
+ super.disconnectedCallback();
44
+ }
45
+
46
+ private trapOverlayTab = (event: KeyboardEvent): void => {
47
+ trapTabInside(event, this);
48
+ };
49
+
50
+ private movedFocusInside = false;
51
+
52
+ updated(): void {
53
+ if (this.movedFocusInside) return;
54
+ if (focusFirstTabStop(this)) this.movedFocusInside = true;
55
+ }
56
+
35
57
  static styles = [
36
58
  InputStyles,
37
59
  css`
@@ -14,6 +14,7 @@ import axios from "axios";
14
14
  import { FeatureFlagsShowDropdown } from "../../fetchFeatureFlag";
15
15
  import { pushGtmEvent } from "../../gtm";
16
16
  import formDisclaimer from "../../disclaimers";
17
+ import { focusFirstTabStop, trapTabInside } from "../pubnub-chat";
17
18
  import { isMobile, isValidEmail, snakify } from "../../utils";
18
19
  import LeadSourceClient, {
19
20
  getDefaultLeadSourceAttribution,
@@ -32,6 +33,27 @@ import {
32
33
 
33
34
  @customElement("email-us-window")
34
35
  export class EmailUsWindow extends LitElement {
36
+ connectedCallback(): void {
37
+ super.connectedCallback();
38
+ this.addEventListener("keydown", this.trapOverlayTab);
39
+ }
40
+
41
+ disconnectedCallback(): void {
42
+ this.removeEventListener("keydown", this.trapOverlayTab);
43
+ super.disconnectedCallback();
44
+ }
45
+
46
+ private trapOverlayTab = (event: KeyboardEvent): void => {
47
+ trapTabInside(event, this);
48
+ };
49
+
50
+ private movedFocusInside = false;
51
+
52
+ updated(): void {
53
+ if (this.movedFocusInside) return;
54
+ if (focusFirstTabStop(this)) this.movedFocusInside = true;
55
+ }
56
+
35
57
  static styles = [
36
58
  css`
37
59
  .email-us__name-input,
@@ -10,7 +10,7 @@ import fetchBuildingWebchatView, {
10
10
  DesignConcepts,
11
11
  fetchOrgCentralWebchatView,
12
12
  } from "../fetchBuildingWebchatView";
13
- import Analytics, { logSignal, LogType, sendLoggingEvent } from "../analytics";
13
+ import Analytics, { logSignal } from "../analytics";
14
14
  import {
15
15
  FeatureFlagsShowDropdown,
16
16
  fetchFeatureFlagUseApplicationsLinkReplacement,
@@ -26,15 +26,11 @@ import { defaultPrimaryColor, defaultBackgroundColor } from "../themes";
26
26
  import getShouldShowWebchat from "../getShouldShowWebchat";
27
27
  import { isMobile, onMobileChange } from "../utils";
28
28
  import { installLauncher } from "./launcher/Launcher";
29
- import parseISO from "date-fns/parseISO";
30
- import isPast from "date-fns/isPast";
31
29
  import { getWidgetOverrides } from "../utils/getWidgetOverrides";
32
30
 
33
31
  import "./MEChat.css";
34
32
  import { getRawAvailabilities } from "../getAvailabilities";
35
33
  import { StyleInfo } from "lit/directives/style-map";
36
- import addMinutes from "date-fns/addMinutes";
37
- import formatISO from "date-fns/formatISO";
38
34
  import fetchLeadSources from "../fetchLeadSources";
39
35
  import fetchBuildingAip from "../fetchBuildingAip";
40
36
  import { formatPhoneNumber } from "./actions/formatPhoneNumber";
@@ -50,6 +46,7 @@ import {
50
46
  import "./actions/minimize-expand-button";
51
47
  import "./launcher/mobile-launcher";
52
48
  import "./pubnub-chat";
49
+ import type { PubnubChat } from "./pubnub-chat";
53
50
  import { getBuildingPhoneNumber } from "../getBuildingPhoneNumber";
54
51
  import {
55
52
  clearChatStorageKey,
@@ -220,6 +217,11 @@ export class MEChat extends LitElement {
220
217
  @state()
221
218
  private displayPubnubChat = false;
222
219
 
220
+ private launcherButton: HTMLElement | null = null;
221
+
222
+ /** Set only for a user-initiated open, so auto-open does not steal focus. */
223
+ private focusChatOnMount = false;
224
+
223
225
  @state()
224
226
  private myPubnub: MyPubnub | null = null;
225
227
 
@@ -375,7 +377,7 @@ export class MEChat extends LitElement {
375
377
  this.displayPubnubChat = true;
376
378
  this.hideLauncher = true;
377
379
  this.hasMounted = true;
378
- this.updateAlreadyAutoOpenedTimestamp();
380
+ this.rememberChatAutoOpenDismissed();
379
381
  } else if (localStorage.getItem("isChatOpen") === "true") {
380
382
  this.hideLauncher = true;
381
383
  this.displayPubnubChat = true;
@@ -850,17 +852,31 @@ export class MEChat extends LitElement {
850
852
  }
851
853
  };
852
854
 
855
+ private chatAutoOpenDismissedStorageKey = (): string | null => {
856
+ const { orgSlug, buildingSlug } = this;
857
+ if (orgSlug && buildingSlug) {
858
+ return `com.elise.webchat.autoOpenDismissed=${orgSlug}=${buildingSlug}`;
859
+ }
860
+ if (buildingSlug) {
861
+ return `com.elise.webchat.autoOpenDismissed=${buildingSlug}`;
862
+ }
863
+ if (orgSlug) {
864
+ return `com.elise.webchat.autoOpenDismissed=${orgSlug}`;
865
+ }
866
+ return null;
867
+ };
868
+
853
869
  private shouldAutoOpenChat = (buildingHasAutoOpen: boolean): boolean => {
854
- const alreadyAutoOpenedTimestamp = sessionStorage.getItem(
855
- "alreadyAutoOpenedTimestamp"
856
- ); // we dont want to autoopen on EVERY single page load, so we'll only do it once every 15 minutes max
857
- const shouldAutoOpen =
858
- !alreadyAutoOpenedTimestamp ||
859
- !(
860
- alreadyAutoOpenedTimestamp &&
861
- !isPast(parseISO(alreadyAutoOpenedTimestamp))
862
- );
863
- return !!buildingHasAutoOpen && !!shouldAutoOpen && !isMobile();
870
+ if (!buildingHasAutoOpen || isMobile()) return false;
871
+ const key = this.chatAutoOpenDismissedStorageKey();
872
+ if (!key) return true;
873
+ return localStorage.getItem(key) !== "true";
874
+ };
875
+
876
+ private rememberChatAutoOpenDismissed = (): void => {
877
+ const key = this.chatAutoOpenDismissedStorageKey();
878
+ if (!key) return;
879
+ localStorage.setItem(key, "true");
864
880
  };
865
881
 
866
882
  private async handleChatInitializeAnalytics(): Promise<void> {
@@ -888,13 +904,6 @@ export class MEChat extends LitElement {
888
904
  this.hideLauncher = false;
889
905
  }
890
906
 
891
- private updateAlreadyAutoOpenedTimestamp = (): void => {
892
- sessionStorage.setItem(
893
- "alreadyAutoOpenedTimestamp",
894
- formatISO(addMinutes(new Date(), 15))
895
- );
896
- };
897
-
898
907
  /** Show the chat button on the screen if it was previously hidden. */
899
908
  show(): void {
900
909
  if (!this.launcher) {
@@ -963,116 +972,11 @@ export class MEChat extends LitElement {
963
972
  connectedCallback(): void {
964
973
  super.connectedCallback();
965
974
  window.addEventListener("resize", this.adjustPositionChatAdditionalActions);
966
- window.addEventListener("keydown", this.handleKeydownTab);
967
975
  this._cleanupMobileListener = onMobileChange((m) => {
968
976
  this._isMobile = m;
969
977
  });
970
978
  }
971
979
 
972
- private static readonly FOCUSABLE_SELECTOR =
973
- "a[href]:not([tabindex='-1']), input:not([disabled]), button:not([disabled]), textarea, [tabindex]:not([tabindex='-1'])";
974
-
975
- /**
976
- * Recursively collects focusable elements, piercing shadow DOM boundaries.
977
- */
978
- private static isFocusable(el: HTMLElement): boolean {
979
- if (
980
- "disabled" in el &&
981
- (el as HTMLButtonElement | HTMLInputElement).disabled
982
- ) {
983
- return false;
984
- }
985
- return true;
986
- }
987
-
988
- private collectFocusableElements(root: Element | ShadowRoot): HTMLElement[] {
989
- const results: HTMLElement[] = [];
990
- // If root is an Element with its own shadow root, recurse into it
991
- if (root instanceof Element && root.shadowRoot) {
992
- results.push(...this.collectFocusableElements(root.shadowRoot));
993
- }
994
- const elements = root.querySelectorAll("*");
995
- for (const el of Array.from(elements)) {
996
- if (
997
- el.matches(MEChat.FOCUSABLE_SELECTOR) &&
998
- MEChat.isFocusable(el as HTMLElement)
999
- ) {
1000
- results.push(el as HTMLElement);
1001
- }
1002
- if (el.shadowRoot) {
1003
- results.push(...this.collectFocusableElements(el.shadowRoot));
1004
- }
1005
- }
1006
- return results;
1007
- }
1008
-
1009
- handleKeydownTab = (e: KeyboardEvent): void => {
1010
- if (e.key !== "Tab") return;
1011
-
1012
- const launcherShadow =
1013
- this.shadowRoot?.getElementById("meetelise-launcher")?.shadowRoot;
1014
-
1015
- let inputsArray: HTMLElement[] = [];
1016
- if (this.displayPubnubChat) {
1017
- const chatContainer = this.shadowRoot
1018
- ?.getElementById("pubnub-chat")
1019
- ?.shadowRoot?.getElementById("pubnub-chat-container");
1020
- const additionalActions = this.shadowRoot?.getElementById(
1021
- "chatAdditionalActionsPubnub"
1022
- );
1023
- if (chatContainer) {
1024
- inputsArray.push(...this.collectFocusableElements(chatContainer));
1025
- }
1026
- if (additionalActions) {
1027
- inputsArray.push(...this.collectFocusableElements(additionalActions));
1028
- }
1029
- } else {
1030
- const containerElement =
1031
- launcherShadow?.getElementById("email-us-window") ?? // email us form
1032
- launcherShadow?.getElementById("tour-scheduler-window") ?? // tour booking form
1033
- launcherShadow?.getElementById("call-us-window"); // text/call us form
1034
- if (containerElement) {
1035
- inputsArray = this.collectFocusableElements(containerElement);
1036
- }
1037
- }
1038
-
1039
- if (inputsArray.length === 0) return;
1040
- e.preventDefault();
1041
-
1042
- const deepActive = this.getDeepActiveElement();
1043
- const currentFocusedIndex = inputsArray.findIndex(
1044
- (el) => el === deepActive
1045
- );
1046
-
1047
- const nextIndex = e.shiftKey
1048
- ? (currentFocusedIndex - 1 + inputsArray.length) % inputsArray.length
1049
- : (currentFocusedIndex + 1) % inputsArray.length;
1050
- inputsArray[nextIndex].focus();
1051
- };
1052
-
1053
- getDeepActiveElement(): Element | null {
1054
- let activeElement = document.activeElement;
1055
- let count = 0;
1056
- while (
1057
- activeElement?.shadowRoot &&
1058
- activeElement?.shadowRoot.activeElement
1059
- ) {
1060
- count += 1;
1061
- activeElement = activeElement?.shadowRoot.activeElement;
1062
- if (count > 100) {
1063
- // just incase we get stuck in an infinite loop
1064
- sendLoggingEvent({
1065
- logType: LogType.error,
1066
- buildingSlug: this.buildingSlug,
1067
- logTitle: "[ERROR_GETTING_DEEP_ELEMENT]",
1068
- logData: { message: "error getting deep active element" },
1069
- });
1070
- break;
1071
- }
1072
- }
1073
- return activeElement;
1074
- }
1075
-
1076
980
  updated(): void {
1077
981
  this.adjustPositionChatAdditionalActions();
1078
982
  }
@@ -1083,7 +987,6 @@ export class MEChat extends LitElement {
1083
987
  "resize",
1084
988
  this.adjustPositionChatAdditionalActions
1085
989
  );
1086
- window.removeEventListener("keydown", this.handleKeydownTab);
1087
990
  this._cleanupMobileListener?.();
1088
991
 
1089
992
  super.disconnectedCallback();
@@ -1103,6 +1006,7 @@ export class MEChat extends LitElement {
1103
1006
  }
1104
1007
 
1105
1008
  launcher.onChatTapped = async () => {
1009
+ this.focusChatOnMount = true;
1106
1010
  this.displayPubnubChat = true;
1107
1011
  this.hideLauncher = true;
1108
1012
  this.hasMounted = true;
@@ -1116,8 +1020,58 @@ export class MEChat extends LitElement {
1116
1020
  this.hideLauncher = false;
1117
1021
  this.showTourNextToChat = false;
1118
1022
  localStorage.setItem("isChatOpen", "false");
1023
+ this.rememberChatAutoOpenDismissed();
1119
1024
  };
1120
1025
 
1026
+ private focusLauncherButton = (): void => {
1027
+ const stored = this.launcherButton?.isConnected
1028
+ ? this.launcherButton
1029
+ : null;
1030
+ (stored ?? this.currentLauncherButton())?.focus();
1031
+ };
1032
+
1033
+ /**
1034
+ * User-initiated open. The caret belongs in the message field.
1035
+ * While consent hides the composer, keep focus on the dialog so the agree
1036
+ * control is reachable. Stored consent is applied after onMount, so try
1037
+ * again once that paint has committed.
1038
+ */
1039
+ private focusMessageFieldOnOpen = (): void => {
1040
+ const chat = this.shadowRoot?.getElementById(
1041
+ "pubnub-chat"
1042
+ ) as PubnubChat | null;
1043
+ const root = chat?.shadowRoot;
1044
+ if (!chat || !root) return;
1045
+ if (!chat.focusMessageField()) {
1046
+ root.getElementById("pubnub-chat-container")?.focus();
1047
+ }
1048
+ setTimeout(() => {
1049
+ chat.focusMessageField();
1050
+ }, 0);
1051
+ };
1052
+
1053
+ private closeChatAndRestoreFocus = (): void => {
1054
+ this.onExitChat();
1055
+ void this.updateComplete.then(() => {
1056
+ this.focusLauncherButton();
1057
+ requestAnimationFrame(() => this.focusLauncherButton());
1058
+ });
1059
+ };
1060
+
1061
+ private currentLauncherButton(): HTMLElement | null {
1062
+ const launcherShadow =
1063
+ this.shadowRoot?.getElementById("meetelise-launcher")?.shadowRoot;
1064
+ const directButton = launcherShadow?.querySelector(
1065
+ "#type-hey__ask-prompt, button[aria-label='Chat with us']"
1066
+ );
1067
+ const mobileButton = launcherShadow
1068
+ ?.querySelector("mobile-launcher")
1069
+ ?.shadowRoot?.querySelector("button[aria-label='Chat with us']");
1070
+ if (directButton instanceof HTMLElement) return directButton;
1071
+ if (mobileButton instanceof HTMLElement) return mobileButton;
1072
+ return null;
1073
+ }
1074
+
1121
1075
  render(): TemplateResult {
1122
1076
  installLauncher();
1123
1077
  const showChatAdditionalActions =
@@ -1139,9 +1093,13 @@ export class MEChat extends LitElement {
1139
1093
  .building=${this.buildingWebchatView}
1140
1094
  .primaryColor=${this.primaryColor}
1141
1095
  .backgroundColor=${this.backgroundColor}
1142
- .onClickExit=${this.onExitChat}
1096
+ .onClickExit=${this.closeChatAndRestoreFocus}
1143
1097
  .onMount=${() => {
1144
1098
  this.adjustPositionChatAdditionalActions();
1099
+ this.launcherButton = this.currentLauncherButton();
1100
+ if (!this.focusChatOnMount) return;
1101
+ this.focusChatOnMount = false;
1102
+ this.focusMessageFieldOnOpen();
1145
1103
  }}
1146
1104
  .requiresConsent=${this.requiresConsent}
1147
1105
  .hideMobileFeatures=${this.hideMobileFeatures}
@@ -1167,12 +1125,7 @@ export class MEChat extends LitElement {
1167
1125
  .onClickEmailOption=${this.handleContactClicked}
1168
1126
  .onClickPhoneOption=${this.handleContactTabClicked}
1169
1127
  .onClickSSTOption=${this.handleTourClicked}
1170
- .onClose=${() => {
1171
- this.displayPubnubChat = false;
1172
- this.showTourNextToChat = false;
1173
- this.hideLauncher = false;
1174
- localStorage.setItem("isChatOpen", "false");
1175
- }}
1128
+ .onClose=${this.closeChatAndRestoreFocus}
1176
1129
  ></chat-additional-actions>
1177
1130
  `
1178
1131
  : ""
@@ -357,7 +357,7 @@ export class MESelect extends LitElement {
357
357
  </svg>
358
358
  </div>
359
359
  ${this.isOpen
360
- ? html`<ul id=${this.listboxId} role="listbox">
360
+ ? html`<ul id=${this.listboxId} role="listbox" tabindex="-1">
361
361
  ${visibleOptions.map(
362
362
  (option, index) =>
363
363
  html`<li
@@ -201,4 +201,16 @@ export const pubnubChatStyles = css`
201
201
  .checkbox:hover {
202
202
  opacity: 0.5;
203
203
  }
204
+
205
+ .sr-only {
206
+ position: absolute;
207
+ width: 1px;
208
+ height: 1px;
209
+ padding: 0;
210
+ margin: -1px;
211
+ overflow: hidden;
212
+ clip: rect(0, 0, 0, 0);
213
+ white-space: nowrap;
214
+ border: 0;
215
+ }
204
216
  `;