pushfeedback 0.1.84 → 0.1.85

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,8 +1,27 @@
1
1
  import { h } from '@stencil/core';
2
+ import { sanitizeHtml } from '../../utils/sanitize-html';
2
3
  export class FeedbackModal {
3
4
  constructor() {
4
5
  // Track the history entry pushed while the modal is open
5
6
  this.historyEntryPushed = false;
7
+ // Element that had focus before the modal opened, so it can be restored on close
8
+ this.previouslyFocused = null;
9
+ this.handleKeyDown = (event) => {
10
+ // The canvas editor renders its own overlay and handles its own keys
11
+ if (!this.showModal || this.embedded || this.showCanvasEditor) {
12
+ return;
13
+ }
14
+ if (event.key === 'Escape' && this.escapeClose) {
15
+ event.preventDefault();
16
+ // Escape is an accidental-dismissal path like clicking outside, so keep
17
+ // whatever the user already typed
18
+ this.doClose(!this.formSuccess);
19
+ return;
20
+ }
21
+ if (event.key === 'Tab') {
22
+ this.trapFocus(event);
23
+ }
24
+ };
6
25
  this.handlePopState = () => {
7
26
  // Back button pressed while the modal is open: close it and consume our entry
8
27
  if (this.historyEntryPushed) {
@@ -46,12 +65,16 @@ export class FeedbackModal {
46
65
  }
47
66
  }
48
67
  const body = Object.assign({ url: window.location.href, message: this.formMessage, email: this.formEmail, project: this.project, screenshot: this.encodedScreenshot, rating: this.selectedRating, ratingMode: this.ratingMode, metadata: this.metadata, verification: this.formVerification, session: localStorage.getItem('pushfeedback_sessionid') || '' }, (recaptchaToken && { recaptchaToken }));
49
- const res = await fetch('https://app.pushfeedback.com/api/feedback/', {
68
+ const headers = {
69
+ 'Content-Type': 'application/json',
70
+ };
71
+ if (this.apiKey) {
72
+ headers['Authorization'] = `Api-Key ${this.apiKey}`;
73
+ }
74
+ const res = await fetch('https://app.pushfeedback.com/api/v1/feedback/', {
50
75
  method: 'POST',
51
76
  body: JSON.stringify(body),
52
- headers: {
53
- 'Content-Type': 'application/json',
54
- },
77
+ headers,
55
78
  });
56
79
  if (res.status === 201) {
57
80
  const feedback_with_id = Object.assign(Object.assign({}, body), { id: await res.json() });
@@ -124,8 +147,15 @@ export class FeedbackModal {
124
147
  this.encodedScreenshot = null;
125
148
  this.formMessage = '';
126
149
  this.formEmail = '';
150
+ // The rating and the privacy checkbox are part of the same in-progress
151
+ // input. Leaving the rating set meant reopening the modal showed the
152
+ // previous session's choice still highlighted, and submitting then sent
153
+ // that stale rating instead of one chosen for this submission.
154
+ this.selectedRating = this.initialRating();
155
+ this.isPrivacyChecked = false;
127
156
  }
128
157
  this.resetOverflow();
158
+ this.restoreFocus();
129
159
  }, 200);
130
160
  };
131
161
  // Handle screenshot events from canvas editor
@@ -135,12 +165,16 @@ export class FeedbackModal {
135
165
  this.takingScreenshot = false;
136
166
  this.showCanvasEditor = false;
137
167
  this.autoStartCapture = false;
168
+ this.focusModal();
138
169
  };
139
170
  this.handleScreenshotCancelled = () => {
140
171
  this.showModal = true;
141
172
  this.takingScreenshot = false;
142
173
  this.showCanvasEditor = false;
143
174
  this.autoStartCapture = false;
175
+ // Focus was inside the editor that just went away, so bring it back to the
176
+ // form rather than letting it fall to the top of the page
177
+ this.focusModal();
144
178
  };
145
179
  this.handleScreenshotError = (event) => {
146
180
  console.error('Screenshot error:', event.detail.error);
@@ -152,6 +186,7 @@ export class FeedbackModal {
152
186
  this.takingScreenshot = false;
153
187
  this.showCanvasEditor = false;
154
188
  this.autoStartCapture = false;
189
+ this.focusModal();
155
190
  // Auto-hide error after 8 seconds
156
191
  setTimeout(() => {
157
192
  this.showScreenshotError = false;
@@ -193,6 +228,7 @@ export class FeedbackModal {
193
228
  this.screenshotError = '';
194
229
  this.showCanvasEditor = false;
195
230
  this.autoStartCapture = false;
231
+ this.apiKey = '';
196
232
  this.customFont = false;
197
233
  this.emailAddress = '';
198
234
  this.hideEmail = false;
@@ -212,8 +248,10 @@ export class FeedbackModal {
212
248
  this.embedded = false;
213
249
  this.clickOutsideClose = true;
214
250
  this.historyClose = true;
251
+ this.escapeClose = true;
215
252
  this.emailPlaceholder = 'Email address (optional)';
216
253
  this.errorMessage = 'Please try again later.';
254
+ this.errorMessage401 = 'This project requires a valid API key to submit feedback. Check the api-key attribute.';
217
255
  this.errorMessage403 = 'The request URL does not match the one defined in PushFeedback for this project.';
218
256
  this.errorMessage404 = 'We could not find the provided project ID in PushFeedback.';
219
257
  this.messagePlaceholder = 'Share your thoughts...';
@@ -226,6 +264,10 @@ export class FeedbackModal {
226
264
  this.ratingPlaceholder = 'Was this page helpful?';
227
265
  this.ratingStarsPlaceholder = 'How would you rate this page?';
228
266
  this.sendButtonText = 'Send';
267
+ this.closeButtonLabel = 'Close feedback form';
268
+ this.ratingPositiveLabel = 'Yes';
269
+ this.ratingNegativeLabel = 'No';
270
+ this.ratingStarLabel = 'Rate {rating} out of 5';
229
271
  this.successMessage = '';
230
272
  this.recaptchaText = 'This form is protected by reCAPTCHA and the Google <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a> and <a href="https://policies.google.com/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a> apply.';
231
273
  this.screenshotAttachedText = 'Screenshot attached';
@@ -248,20 +290,96 @@ export class FeedbackModal {
248
290
  }
249
291
  connectedCallback() {
250
292
  window.addEventListener('popstate', this.handlePopState);
293
+ document.addEventListener('keydown', this.handleKeyDown);
251
294
  }
252
295
  disconnectedCallback() {
253
296
  window.removeEventListener('popstate', this.handlePopState);
297
+ document.removeEventListener('keydown', this.handleKeyDown);
298
+ }
299
+ // Keeps Tab cycling inside the dialog while it is open
300
+ trapFocus(event) {
301
+ var _a, _b;
302
+ const focusable = this.getFocusableElements();
303
+ if (focusable.length === 0) {
304
+ return;
305
+ }
306
+ const first = focusable[0];
307
+ const last = focusable[focusable.length - 1];
308
+ const active = (_a = this.modalContent) === null || _a === void 0 ? void 0 : _a.getRootNode();
309
+ const current = active === null || active === void 0 ? void 0 : active.activeElement;
310
+ if (current === this.modalContent) {
311
+ // Focus is on the dialog container itself (where it lands on open).
312
+ // Tab falls through to the first control natively; Shift+Tab would
313
+ // escape the dialog, so wrap it to the last one.
314
+ if (event.shiftKey) {
315
+ event.preventDefault();
316
+ last.focus();
317
+ }
318
+ }
319
+ else if (event.shiftKey && current === first) {
320
+ event.preventDefault();
321
+ last.focus();
322
+ }
323
+ else if (!event.shiftKey && current === last) {
324
+ event.preventDefault();
325
+ first.focus();
326
+ }
327
+ else if (!current || !((_b = this.modalContent) === null || _b === void 0 ? void 0 : _b.contains(current))) {
328
+ // Focus escaped the dialog (or never entered it) - pull it back in
329
+ event.preventDefault();
330
+ first.focus();
331
+ }
332
+ }
333
+ // offsetParent is not usable here: it is null for `position: fixed` elements
334
+ // even when they are perfectly visible, and it is non-null for
335
+ // `visibility: hidden` ones, which cannot be focused. getClientRects covers
336
+ // display:none and detached nodes, so visibility is the only extra check.
337
+ isFocusable(el) {
338
+ if (el.hidden || el.getClientRects().length === 0) {
339
+ return false;
340
+ }
341
+ return getComputedStyle(el).visibility !== 'hidden';
342
+ }
343
+ getFocusableElements() {
344
+ if (!this.modalContent) {
345
+ return [];
346
+ }
347
+ const selector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
348
+ return Array.from(this.modalContent.querySelectorAll(selector)).filter((el) => this.isFocusable(el));
349
+ }
350
+ // Moves focus to the dialog container once it has rendered, so screen readers
351
+ // announce the dialog title rather than jumping straight to the first control
352
+ focusModal() {
353
+ requestAnimationFrame(() => {
354
+ if (this.modalContent) {
355
+ this.modalContent.focus();
356
+ }
357
+ });
358
+ }
359
+ restoreFocus() {
360
+ const target = this.previouslyFocused;
361
+ this.previouslyFocused = null;
362
+ if (target && typeof target.focus === 'function' && target.isConnected) {
363
+ target.focus();
364
+ }
365
+ }
366
+ // The rating the widget starts from. Closing restores this rather than
367
+ // blanking to neutral, so a consumer's configured `rating` default survives
368
+ // the modal being closed and reopened.
369
+ initialRating() {
370
+ if (this.ratingMode == 'thumbs' && this.rating == 0) {
371
+ return 5;
372
+ }
373
+ if (this.rating) {
374
+ return this.rating;
375
+ }
376
+ return -1;
254
377
  }
255
378
  componentWillLoad() {
256
379
  if (this.fetchData)
257
380
  this.fetchProjectData();
258
381
  this.formEmail = this.emailAddress;
259
- if (this.rating) {
260
- this.selectedRating = this.rating;
261
- }
262
- if (this.ratingMode == 'thumbs' && this.rating == 0) {
263
- this.selectedRating = 5;
264
- }
382
+ this.selectedRating = this.initialRating();
265
383
  // Automatically show modal when embedded
266
384
  if (this.embedded) {
267
385
  this.showModal = true;
@@ -269,7 +387,9 @@ export class FeedbackModal {
269
387
  }
270
388
  async fetchProjectData() {
271
389
  try {
272
- const response = await fetch('https://app.pushfeedback.com/api/projects/' + this.project + '/');
390
+ // Public config endpoint: never send the API key here, it is only
391
+ // needed (and only allowed to travel) on the feedback POST
392
+ const response = await fetch('https://app.pushfeedback.com/api/v1/projects/' + this.project + '/');
273
393
  const data = await response.json();
274
394
  this.whitelabel = data.whitelabel;
275
395
  this.recaptchaEnabled = data.recaptcha_enabled || false;
@@ -358,28 +478,28 @@ export class FeedbackModal {
358
478
  this.selectedRating = newRating;
359
479
  }
360
480
  render() {
361
- return (h("div", { class: `feedback-modal-wrapper ${this.customFont ? 'feedback-modal-wrapper--custom-font' : ''} ${this.embedded ? 'feedback-modal-wrapper--embedded' : ''}`, part: "wrapper" }, this.showCanvasEditor && (h("canvas-editor", { ref: (el) => (this.canvasEditorRef = el), exportparts: "overlay: canvas-editor-overlay, modal: canvas-editor-modal, header: canvas-editor-header, title: canvas-editor-title, toolbar: canvas-editor-toolbar, tool-button: canvas-editor-tool-button, cancel-button: canvas-editor-cancel-button, save-button: canvas-editor-save-button, content: canvas-editor-content, canvas: canvas-editor-canvas", "canvas-editor-title": this.screenshotEditorTitle, "canvas-editor-cancel-text": this.screenshotEditorCancelText, "canvas-editor-save-text": this.screenshotEditorSaveText, "screenshot-taking-text": this.screenshotTakingText, "screenshot-attached-text": this.screenshotAttachedText, "screenshot-button-text": this.screenshotButtonText, "auto-start-screenshot": this.autoStartCapture, "existing-screenshot": this.encodedScreenshot || '', "edit-text-button-text": this.screenshotEditTextButtonText, "size-label-text": this.screenshotSizeLabelText, "border-label-text": this.screenshotBorderLabelText, "edit-text-prompt-text": this.screenshotEditTextPromptText, "screenshot-error-general": this.screenshotErrorGeneral, "screenshot-error-permission": this.screenshotErrorPermission, "screenshot-error-not-supported": this.screenshotErrorNotSupported, "screenshot-error-not-found": this.screenshotErrorNotFound, "screenshot-error-cancelled": this.screenshotErrorCancelled, "screenshot-error-browser-not-supported": this.screenshotErrorBrowserNotSupported, "screenshot-error-unexpected": this.screenshotErrorUnexpected, onScreenshotReady: this.handleScreenshotReady, onScreenshotCancelled: this.handleScreenshotCancelled, onScreenshotFailed: this.handleScreenshotError })), this.showScreenshotError && (h("div", { class: "screenshot-error-notification" }, h("div", { class: "screenshot-error-content" }, h("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("circle", { cx: "12", cy: "12", r: "10" }), h("line", { x1: "15", y1: "9", x2: "9", y2: "15" }), h("line", { x1: "9", y1: "9", x2: "15", y2: "15" })), h("span", null, this.screenshotError), h("button", { class: "error-close-btn", onClick: () => (this.showScreenshotError = false), title: "Close" }, h("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" })))))), this.showModal && !this.embedded && (h("div", { class: `feedback-overlay ${this.isAnimating ? 'feedback-overlay--visible' : ''}`, part: "overlay", onClick: this.clickOutsideClose ? this.handleOverlayClick : undefined })), this.showModal && (h("div", { class: `feedback-modal-content feedback-modal-content--${this.modalPosition} ${this.isAnimating ? 'feedback-modal-content--open' : ''} ${this.embedded ? 'feedback-modal-content--embedded' : ''}`, part: "modal", ref: (el) => (this.modalContent = el) }, h("div", { class: `feedback-modal-header ${(this.formSuccess && !this.successMessage) || (this.formError && !this.errorMessage)
481
+ return (h("div", { class: `feedback-modal-wrapper ${this.customFont ? 'feedback-modal-wrapper--custom-font' : ''} ${this.embedded ? 'feedback-modal-wrapper--embedded' : ''}`, part: "wrapper" }, this.showCanvasEditor && (h("canvas-editor", { ref: (el) => (this.canvasEditorRef = el), exportparts: "overlay: canvas-editor-overlay, modal: canvas-editor-modal, header: canvas-editor-header, title: canvas-editor-title, toolbar: canvas-editor-toolbar, tool-button: canvas-editor-tool-button, cancel-button: canvas-editor-cancel-button, save-button: canvas-editor-save-button, content: canvas-editor-content, canvas: canvas-editor-canvas", "canvas-editor-title": this.screenshotEditorTitle, "canvas-editor-cancel-text": this.screenshotEditorCancelText, "canvas-editor-save-text": this.screenshotEditorSaveText, "screenshot-taking-text": this.screenshotTakingText, "screenshot-attached-text": this.screenshotAttachedText, "screenshot-button-text": this.screenshotButtonText, "escape-close": this.escapeClose ? 'true' : 'false', "click-outside-close": this.clickOutsideClose ? 'true' : 'false', "auto-start-screenshot": this.autoStartCapture, "existing-screenshot": this.encodedScreenshot || '', "edit-text-button-text": this.screenshotEditTextButtonText, "size-label-text": this.screenshotSizeLabelText, "border-label-text": this.screenshotBorderLabelText, "edit-text-prompt-text": this.screenshotEditTextPromptText, "screenshot-error-general": this.screenshotErrorGeneral, "screenshot-error-permission": this.screenshotErrorPermission, "screenshot-error-not-supported": this.screenshotErrorNotSupported, "screenshot-error-not-found": this.screenshotErrorNotFound, "screenshot-error-cancelled": this.screenshotErrorCancelled, "screenshot-error-browser-not-supported": this.screenshotErrorBrowserNotSupported, "screenshot-error-unexpected": this.screenshotErrorUnexpected, onScreenshotReady: this.handleScreenshotReady, onScreenshotCancelled: this.handleScreenshotCancelled, onScreenshotFailed: this.handleScreenshotError })), this.showScreenshotError && (h("div", { class: "screenshot-error-notification" }, h("div", { class: "screenshot-error-content" }, h("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("circle", { cx: "12", cy: "12", r: "10" }), h("line", { x1: "15", y1: "9", x2: "9", y2: "15" }), h("line", { x1: "9", y1: "9", x2: "15", y2: "15" })), h("span", null, this.screenshotError), h("button", { class: "error-close-btn", onClick: () => (this.showScreenshotError = false), title: "Close" }, h("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" })))))), this.showModal && !this.embedded && (h("div", { class: `feedback-overlay ${this.isAnimating ? 'feedback-overlay--visible' : ''}`, part: "overlay", "aria-hidden": "true", onClick: this.clickOutsideClose ? this.handleOverlayClick : undefined })), this.showModal && (h("div", { class: `feedback-modal-content feedback-modal-content--${this.modalPosition} ${this.isAnimating ? 'feedback-modal-content--open' : ''} ${this.embedded ? 'feedback-modal-content--embedded' : ''}`, part: "modal", role: this.embedded ? 'region' : 'dialog', "aria-modal": this.embedded ? undefined : 'true', "aria-labelledby": "feedback-modal-title", tabindex: "-1", ref: (el) => (this.modalContent = el) }, h("div", { class: `feedback-modal-header ${(this.formSuccess && !this.successMessage) || (this.formError && !this.errorMessage)
362
482
  ? 'feedback-modal-header--no-content'
363
- : ''}`, part: "header" }, h("div", { class: "feedback-modal-header-content" }, !this.formSuccess && !this.formError ? (h("div", { class: "feedback-modal-header-text" }, h("span", { class: "feedback-modal-title", part: "title" }, this.modalTitle), !this.whitelabel && (h("span", { class: "feedback-modal-powered-by", part: "powered-by" }, "Powered by", ' ', h("a", { target: "_blank", href: "https://pushfeedback.com" }, "PushFeedback"))))) : this.formSuccess ? (h("span", { class: "feedback-modal-title", part: "title" }, this.modalTitleSuccess)) : (h("span", { class: "feedback-modal-title", part: "title" }, this.modalTitleError))), !this.embedded && (h("button", { class: "feedback-modal-close", part: "close-button", onClick: this.close }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#191919", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", class: "feather feather-x" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" }))))), h("div", { class: "feedback-modal-body", part: "body" }, !this.formSuccess && !this.formError ? (h("form", { part: "form", onSubmit: this.handleSubmit }, !this.hideRating && (h("div", { class: "feedback-modal-rating", part: "rating" }, this.ratingMode === 'thumbs' ? (h("div", { class: "feedback-modal-rating-content" }, h("span", { class: "feedback-modal-input-heading", part: "rating-title" }, this.ratingPlaceholder), h("div", { class: "feedback-modal-rating-buttons feedback-modal-rating-buttons--thumbs", part: "rating-buttons" }, h("button", { title: "Yes", class: `feedback-modal-rating-button feedback-modal-rating-button--positive ${this.selectedRating === 1
483
+ : ''}`, part: "header" }, h("div", { class: "feedback-modal-header-content" }, !this.formSuccess && !this.formError ? (h("div", { class: "feedback-modal-header-text" }, h("span", { id: "feedback-modal-title", class: "feedback-modal-title", part: "title" }, this.modalTitle), !this.whitelabel && (h("span", { class: "feedback-modal-powered-by", part: "powered-by" }, "Powered by", ' ', h("a", { target: "_blank", href: "https://pushfeedback.com" }, "PushFeedback"))))) : this.formSuccess ? (h("span", { id: "feedback-modal-title", class: "feedback-modal-title", part: "title" }, this.modalTitleSuccess)) : (h("span", { id: "feedback-modal-title", class: "feedback-modal-title", part: "title" }, this.modalTitleError))), !this.embedded && (h("button", { class: "feedback-modal-close", part: "close-button", type: "button", "aria-label": this.closeButtonLabel, onClick: this.close }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#191919", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", class: "feather feather-x" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" }))))), h("div", { class: "feedback-modal-body", part: "body" }, !this.formSuccess && !this.formError ? (h("form", { part: "form", onSubmit: this.handleSubmit }, !this.hideRating && (h("div", { class: "feedback-modal-rating", part: "rating" }, this.ratingMode === 'thumbs' ? (h("div", { class: "feedback-modal-rating-content" }, h("span", { class: "feedback-modal-input-heading", part: "rating-title" }, this.ratingPlaceholder), h("div", { class: "feedback-modal-rating-buttons feedback-modal-rating-buttons--thumbs", part: "rating-buttons" }, h("button", { type: "button", title: this.ratingPositiveLabel, "aria-label": this.ratingPositiveLabel, "aria-pressed": this.selectedRating === 1 ? 'true' : 'false', class: `feedback-modal-rating-button feedback-modal-rating-button--positive ${this.selectedRating === 1
364
484
  ? 'feedback-modal-rating-button--selected'
365
485
  : ''}`, part: "rating-button rating-button-positive", onClick: (event) => {
366
486
  event.preventDefault();
367
487
  this.handleRatingChange(1);
368
- } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("path", { d: "M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" }))), h("button", { title: "No", class: `feedback-modal-rating-button feedback-modal-rating-button--negative ${this.selectedRating === 5
488
+ } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("path", { d: "M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" }))), h("button", { type: "button", title: this.ratingNegativeLabel, "aria-label": this.ratingNegativeLabel, "aria-pressed": this.selectedRating === 5 ? 'true' : 'false', class: `feedback-modal-rating-button feedback-modal-rating-button--negative ${this.selectedRating === 5
369
489
  ? 'feedback-modal-rating-button--selected'
370
490
  : ''}`, part: "rating-button rating-button-negative", onClick: (event) => {
371
491
  event.preventDefault();
372
492
  this.handleRatingChange(5);
373
- } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("path", { d: "M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" })))))) : (h("div", { class: "feedback-modal-rating-content" }, h("span", { class: "feedback-modal-input-heading", part: "rating-title" }, this.ratingStarsPlaceholder), h("div", { class: "feedback-modal-rating-buttons feedback-modal-rating-buttons--stars", part: "rating-buttons" }, [1, 2, 3, 4, 5].map((rating) => (h("button", { key: rating, class: `feedback-modal-rating-button ${this.selectedRating >= rating
493
+ } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("path", { d: "M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" })))))) : (h("div", { class: "feedback-modal-rating-content" }, h("span", { class: "feedback-modal-input-heading", part: "rating-title" }, this.ratingStarsPlaceholder), h("div", { class: "feedback-modal-rating-buttons feedback-modal-rating-buttons--stars", part: "rating-buttons" }, [1, 2, 3, 4, 5].map((rating) => (h("button", { key: rating, type: "button", "aria-label": this.ratingStarLabel.replace('{rating}', String(rating)), "aria-pressed": this.selectedRating === rating ? 'true' : 'false', class: `feedback-modal-rating-button ${this.selectedRating >= rating
374
494
  ? 'feedback-modal-rating-button--selected'
375
495
  : ''}`, part: "rating-button", onClick: (event) => {
376
496
  event.preventDefault();
377
497
  this.handleRatingChange(rating);
378
- } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "28", height: "28", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("polygon", { points: "12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" })))))))))), h("div", { class: "feedback-modal-text" }, h("textarea", { part: "message-input", placeholder: this.messagePlaceholder, value: this.formMessage, onInput: (event) => this.handleMessageInput(event) })), !this.hideEmail && (h("div", { class: "feedback-modal-email" }, h("input", { part: "email-input", placeholder: this.emailPlaceholder, type: "email", onInput: (event) => this.handleEmailInput(event), value: this.formEmail, required: this.isEmailRequired }))), h("div", { class: "feedback-verification" }, h("input", { type: "text", name: "verification", style: { display: 'none' }, onInput: (event) => this.handleVerification(event), value: this.formVerification })), !this.hidePrivacyPolicy && (h("div", { class: "feedback-modal-privacy", part: "privacy" }, h("input", { type: "checkbox", id: "privacyPolicy", onChange: (ev) => this.handleCheckboxChange(ev), required: true }), h("span", { innerHTML: this.privacyPolicyText }))), h("div", { class: `feedback-modal-buttons ${this.hideScreenshotButton ? 'single' : ''}`, part: "buttons" }, !this.hideScreenshotButton && (h("button", { type: "button", class: `feedback-modal-button feedback-modal-button--screenshot ${this.encodedScreenshot ? 'feedback-modal-button--active' : ''}`, part: "screenshot-button", onClick: this.openScreenShot, disabled: this.sending || this.takingScreenshot }, this.encodedScreenshot && (h("div", { class: "screenshot-preview", onClick: this.openCanvasEditor }, h("img", { src: this.encodedScreenshot, alt: "Screenshot Preview" }))), !this.encodedScreenshot && !this.takingScreenshot && (h("svg", { xmlns: "http://www.w3.org/2000/svg", height: "24", viewBox: "0 -960 960 960", width: "24" }, h("path", { d: "M680-80v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM200-200v-200h80v120h120v80H200Zm0-360v-200h200v80H280v120h-80Zm480 0v-120H560v-80h200v200h-80Z" }))), this.takingScreenshot && (h("div", { class: "screenshot-loading" }, h("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "#666", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", class: "feather-loader" }, h("line", { x1: "12", y1: "2", x2: "12", y2: "6" }), h("line", { x1: "12", y1: "18", x2: "12", y2: "22" }), h("line", { x1: "4.93", y1: "4.93", x2: "7.76", y2: "7.76" }), h("line", { x1: "16.24", y1: "16.24", x2: "19.07", y2: "19.07" }), h("line", { x1: "2", y1: "12", x2: "6", y2: "12" }), h("line", { x1: "18", y1: "12", x2: "22", y2: "12" }), h("line", { x1: "4.93", y1: "19.07", x2: "7.76", y2: "16.24" }), h("line", { x1: "16.24", y1: "7.76", x2: "19.07", y2: "4.93" })))), this.takingScreenshot
498
+ } }, h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "28", height: "28", viewBox: "0 0 24 24", fill: "none", stroke: "#5F6368", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("polygon", { points: "12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" })))))))))), h("div", { class: "feedback-modal-text" }, h("textarea", { part: "message-input", placeholder: this.messagePlaceholder, value: this.formMessage, onInput: (event) => this.handleMessageInput(event) })), !this.hideEmail && (h("div", { class: "feedback-modal-email" }, h("input", { part: "email-input", placeholder: this.emailPlaceholder, type: "email", onInput: (event) => this.handleEmailInput(event), value: this.formEmail, required: this.isEmailRequired }))), h("div", { class: "feedback-verification" }, h("input", { type: "text", name: "verification", style: { display: 'none' }, onInput: (event) => this.handleVerification(event), value: this.formVerification })), !this.hidePrivacyPolicy && (h("div", { class: "feedback-modal-privacy", part: "privacy" }, h("input", { type: "checkbox", id: "privacyPolicy", onChange: (ev) => this.handleCheckboxChange(ev), required: true }), h("span", { innerHTML: sanitizeHtml(this.privacyPolicyText) }))), h("div", { class: `feedback-modal-buttons ${this.hideScreenshotButton ? 'single' : ''}`, part: "buttons" }, !this.hideScreenshotButton && (h("button", { type: "button", class: `feedback-modal-button feedback-modal-button--screenshot ${this.encodedScreenshot ? 'feedback-modal-button--active' : ''}`, part: "screenshot-button", onClick: this.openScreenShot, disabled: this.sending || this.takingScreenshot }, this.encodedScreenshot && (h("div", { class: "screenshot-preview", onClick: this.openCanvasEditor }, h("img", { src: this.encodedScreenshot, alt: "Screenshot Preview" }))), !this.encodedScreenshot && !this.takingScreenshot && (h("svg", { xmlns: "http://www.w3.org/2000/svg", height: "24", viewBox: "0 -960 960 960", width: "24" }, h("path", { d: "M680-80v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM200-200v-200h80v120h120v80H200Zm0-360v-200h200v80H280v120h-80Zm480 0v-120H560v-80h200v200h-80Z" }))), this.takingScreenshot && (h("div", { class: "screenshot-loading" }, h("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "#666", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", class: "feather-loader" }, h("line", { x1: "12", y1: "2", x2: "12", y2: "6" }), h("line", { x1: "12", y1: "18", x2: "12", y2: "22" }), h("line", { x1: "4.93", y1: "4.93", x2: "7.76", y2: "7.76" }), h("line", { x1: "16.24", y1: "16.24", x2: "19.07", y2: "19.07" }), h("line", { x1: "2", y1: "12", x2: "6", y2: "12" }), h("line", { x1: "18", y1: "12", x2: "22", y2: "12" }), h("line", { x1: "4.93", y1: "19.07", x2: "7.76", y2: "16.24" }), h("line", { x1: "16.24", y1: "7.76", x2: "19.07", y2: "4.93" })))), this.takingScreenshot
379
499
  ? this.screenshotTakingText
380
500
  : this.encodedScreenshot
381
501
  ? this.screenshotAttachedText
382
- : this.screenshotButtonText)), h("button", { class: "feedback-modal-button feedback-modal-button--submit", part: "submit-button", type: "submit", disabled: this.sending }, this.sendButtonText)))) : this.formSuccess && !this.formError ? (h("div", { class: "feedback-modal-success" }, h("p", { class: "feedback-modal-message", part: "success-message" }, this.successMessage))) : this.formError && this.formErrorStatus == 404 ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage404)) : this.formError && this.formErrorStatus == 403 ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage403)) : this.formError ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage)) : (h("span", null))), !this.formSuccess && !this.formError && (this.recaptchaEnabled || this.footerText) && (h("div", { class: "feedback-modal-footer", part: "footer" }, h("div", { class: "feedback-footer-combined" }, this.recaptchaEnabled && h("span", { innerHTML: this.recaptchaText }), this.recaptchaEnabled && this.footerText && ' ', this.footerText && h("span", { innerHTML: this.footerText }))))))));
502
+ : this.screenshotButtonText)), h("button", { class: "feedback-modal-button feedback-modal-button--submit", part: "submit-button", type: "submit", disabled: this.sending }, this.sendButtonText)))) : this.formSuccess && !this.formError ? (h("div", { class: "feedback-modal-success" }, h("p", { class: "feedback-modal-message", part: "success-message" }, this.successMessage))) : this.formError && this.formErrorStatus == 404 ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage404)) : this.formError && this.formErrorStatus == 401 ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage401)) : this.formError && this.formErrorStatus == 403 ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage403)) : this.formError ? (h("p", { class: "feedback-modal-message", part: "error-message" }, this.errorMessage)) : (h("span", null))), !this.formSuccess && !this.formError && (this.recaptchaEnabled || this.footerText) && (h("div", { class: "feedback-modal-footer", part: "footer" }, h("div", { class: "feedback-footer-combined" }, this.recaptchaEnabled && (h("span", { innerHTML: sanitizeHtml(this.recaptchaText) })), this.recaptchaEnabled && this.footerText && ' ', this.footerText && h("span", { innerHTML: sanitizeHtml(this.footerText) }))))))));
383
503
  }
384
504
  componentDidRender() {
385
505
  if (this.showModal) {
@@ -389,7 +509,13 @@ export class FeedbackModal {
389
509
  }
390
510
  }
391
511
  async openModal() {
512
+ // Remember the trigger so focus can return there when the modal closes
513
+ if (!this.embedded) {
514
+ const active = document.activeElement;
515
+ this.previouslyFocused = active && active !== document.body ? active : null;
516
+ }
392
517
  this.showModal = true;
518
+ this.focusModal();
393
519
  if (this.historyClose && !this.embedded && !this.historyEntryPushed) {
394
520
  history.pushState({ feedbackModal: true }, '');
395
521
  this.historyEntryPushed = true;
@@ -414,6 +540,24 @@ export class FeedbackModal {
414
540
  }
415
541
  static get properties() {
416
542
  return {
543
+ "apiKey": {
544
+ "type": "string",
545
+ "mutable": false,
546
+ "complexType": {
547
+ "original": "string",
548
+ "resolved": "string",
549
+ "references": {}
550
+ },
551
+ "required": false,
552
+ "optional": false,
553
+ "docs": {
554
+ "tags": [],
555
+ "text": "Optional API key sent with feedback submissions, for projects that\nrequire one. Sent as `Authorization: Api-Key <key>` on the feedback POST\nrequest only, never on the public config request. Only use keys scoped to\n`feedback:submit`: like any credential used by an embedded widget, the\nvalue is part of the page and readable by any script running on it, so it\nprovides server-side scoping and traceability rather than client-side\nsecrecy. Intended for internal or access-controlled pages."
556
+ },
557
+ "attribute": "api-key",
558
+ "reflect": false,
559
+ "defaultValue": "''"
560
+ },
417
561
  "customFont": {
418
562
  "type": "boolean",
419
563
  "mutable": false,
@@ -754,6 +898,24 @@ export class FeedbackModal {
754
898
  "reflect": false,
755
899
  "defaultValue": "true"
756
900
  },
901
+ "escapeClose": {
902
+ "type": "boolean",
903
+ "mutable": false,
904
+ "complexType": {
905
+ "original": "boolean",
906
+ "resolved": "boolean",
907
+ "references": {}
908
+ },
909
+ "required": false,
910
+ "optional": false,
911
+ "docs": {
912
+ "tags": [],
913
+ "text": ""
914
+ },
915
+ "attribute": "escape-close",
916
+ "reflect": false,
917
+ "defaultValue": "true"
918
+ },
757
919
  "emailPlaceholder": {
758
920
  "type": "string",
759
921
  "mutable": false,
@@ -790,6 +952,24 @@ export class FeedbackModal {
790
952
  "reflect": false,
791
953
  "defaultValue": "'Please try again later.'"
792
954
  },
955
+ "errorMessage401": {
956
+ "type": "string",
957
+ "mutable": false,
958
+ "complexType": {
959
+ "original": "string",
960
+ "resolved": "string",
961
+ "references": {}
962
+ },
963
+ "required": false,
964
+ "optional": false,
965
+ "docs": {
966
+ "tags": [],
967
+ "text": ""
968
+ },
969
+ "attribute": "error-message-4-0-1",
970
+ "reflect": false,
971
+ "defaultValue": "'This project requires a valid API key to submit feedback. Check the api-key attribute.'"
972
+ },
793
973
  "errorMessage403": {
794
974
  "type": "string",
795
975
  "mutable": false,
@@ -1006,6 +1186,78 @@ export class FeedbackModal {
1006
1186
  "reflect": false,
1007
1187
  "defaultValue": "'Send'"
1008
1188
  },
1189
+ "closeButtonLabel": {
1190
+ "type": "string",
1191
+ "mutable": false,
1192
+ "complexType": {
1193
+ "original": "string",
1194
+ "resolved": "string",
1195
+ "references": {}
1196
+ },
1197
+ "required": false,
1198
+ "optional": false,
1199
+ "docs": {
1200
+ "tags": [],
1201
+ "text": ""
1202
+ },
1203
+ "attribute": "close-button-label",
1204
+ "reflect": false,
1205
+ "defaultValue": "'Close feedback form'"
1206
+ },
1207
+ "ratingPositiveLabel": {
1208
+ "type": "string",
1209
+ "mutable": false,
1210
+ "complexType": {
1211
+ "original": "string",
1212
+ "resolved": "string",
1213
+ "references": {}
1214
+ },
1215
+ "required": false,
1216
+ "optional": false,
1217
+ "docs": {
1218
+ "tags": [],
1219
+ "text": ""
1220
+ },
1221
+ "attribute": "rating-positive-label",
1222
+ "reflect": false,
1223
+ "defaultValue": "'Yes'"
1224
+ },
1225
+ "ratingNegativeLabel": {
1226
+ "type": "string",
1227
+ "mutable": false,
1228
+ "complexType": {
1229
+ "original": "string",
1230
+ "resolved": "string",
1231
+ "references": {}
1232
+ },
1233
+ "required": false,
1234
+ "optional": false,
1235
+ "docs": {
1236
+ "tags": [],
1237
+ "text": ""
1238
+ },
1239
+ "attribute": "rating-negative-label",
1240
+ "reflect": false,
1241
+ "defaultValue": "'No'"
1242
+ },
1243
+ "ratingStarLabel": {
1244
+ "type": "string",
1245
+ "mutable": false,
1246
+ "complexType": {
1247
+ "original": "string",
1248
+ "resolved": "string",
1249
+ "references": {}
1250
+ },
1251
+ "required": false,
1252
+ "optional": false,
1253
+ "docs": {
1254
+ "tags": [],
1255
+ "text": ""
1256
+ },
1257
+ "attribute": "rating-star-label",
1258
+ "reflect": false,
1259
+ "defaultValue": "'Rate {rating} out of 5'"
1260
+ },
1009
1261
  "successMessage": {
1010
1262
  "type": "string",
1011
1263
  "mutable": false,
@@ -1416,6 +1668,9 @@ export class FeedbackModal {
1416
1668
  "references": {
1417
1669
  "Promise": {
1418
1670
  "location": "global"
1671
+ },
1672
+ "HTMLElement": {
1673
+ "location": "global"
1419
1674
  }
1420
1675
  },
1421
1676
  "return": "Promise<void>"
@@ -0,0 +1,90 @@
1
+ // Tags allowed in the rich-text props (privacy policy, reCAPTCHA notice,
2
+ // footer). These only ever need links and light inline emphasis.
3
+ const ALLOWED_TAGS = new Set([
4
+ 'A',
5
+ 'B',
6
+ 'BR',
7
+ 'CODE',
8
+ 'EM',
9
+ 'I',
10
+ 'P',
11
+ 'SMALL',
12
+ 'SPAN',
13
+ 'STRONG',
14
+ 'U',
15
+ ]);
16
+ // No `class`: an injected class name cannot reach the host page's CSS (this
17
+ // renders inside a shadow root), but it could match the widget's own internal
18
+ // styles. The default prop values do not use classes, so dropping it is free.
19
+ const ALLOWED_ATTRS = new Set(['href', 'target', 'rel', 'title']);
20
+ // Schemes permitted in href. Anything else, notably javascript:, is dropped.
21
+ const SAFE_URL = /^(https?:|mailto:|tel:|#|\/|\.\/|\.\.\/)/i;
22
+ /**
23
+ * Removes anything that can execute from a fragment: script and style
24
+ * elements, event-handler attributes, javascript: URLs, and any tag not on the
25
+ * allowlist. Text inside a disallowed tag is kept, so stripping a wrapper does
26
+ * not silently delete the words inside it.
27
+ */
28
+ function scrub(root) {
29
+ const elements = Array.from(root.querySelectorAll('*'));
30
+ for (const el of elements) {
31
+ // The node may already have been removed along with an ancestor
32
+ if (!root.contains(el)) {
33
+ continue;
34
+ }
35
+ const tag = el.tagName.toUpperCase();
36
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'IFRAME' || tag === 'OBJECT') {
37
+ el.remove();
38
+ continue;
39
+ }
40
+ if (!ALLOWED_TAGS.has(tag)) {
41
+ // Unwrap: keep the text, drop the element
42
+ el.replaceWith(...Array.from(el.childNodes));
43
+ continue;
44
+ }
45
+ for (const attr of Array.from(el.attributes)) {
46
+ const name = attr.name.toLowerCase();
47
+ if (name.startsWith('on') || !ALLOWED_ATTRS.has(name)) {
48
+ el.removeAttribute(attr.name);
49
+ continue;
50
+ }
51
+ if (name === 'href' && !SAFE_URL.test(attr.value.trim())) {
52
+ el.removeAttribute(attr.name);
53
+ }
54
+ }
55
+ // Links opening a new tab must not hand the opener over
56
+ if (tag === 'A' && el.getAttribute('target') === '_blank') {
57
+ el.setAttribute('rel', 'noopener noreferrer');
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Sanitizes a fragment of HTML for use with innerHTML.
63
+ *
64
+ * Prefers the browser's native Element.setHTML() where available, which parses
65
+ * without executing. Otherwise parses in an inert document (no scripts run, no
66
+ * images or other subresources are fetched) and scrubs the result.
67
+ */
68
+ export function sanitizeHtml(html) {
69
+ if (!html) {
70
+ return '';
71
+ }
72
+ const template = document.createElement('template');
73
+ // Native sanitizer, when the browser has it
74
+ const withSetHtml = template;
75
+ if (typeof withSetHtml.setHTML === 'function') {
76
+ try {
77
+ withSetHtml.setHTML(html);
78
+ scrub(template.content);
79
+ return template.innerHTML;
80
+ }
81
+ catch (_a) {
82
+ // Fall through to the manual path
83
+ }
84
+ }
85
+ // Inert parsing: content inside <template> is not rendered, so scripts do not
86
+ // run and no subresource requests are made while we clean it up.
87
+ template.innerHTML = html;
88
+ scrub(template.content);
89
+ return template.innerHTML;
90
+ }