reflet-sdk 0.6.5 → 0.6.6

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +100 -2
  3. package/dist/{client-DAdWdIFY.js → client-oFFfRxAM.js} +135 -59
  4. package/dist/client-oFFfRxAM.js.map +1 -0
  5. package/dist/client-transport-B7BdVU7J.d.ts +150 -0
  6. package/dist/client-transport-B7BdVU7J.d.ts.map +1 -0
  7. package/dist/{devtools-layer-CEjzYGBB.js → devtools-layer-B91EVgqg.js} +417 -916
  8. package/dist/devtools-layer-B91EVgqg.js.map +1 -0
  9. package/dist/devtools-next.js +1 -1
  10. package/dist/devtools-vite.js +2 -2
  11. package/dist/feedback.d.ts +7 -1
  12. package/dist/feedback.d.ts.map +1 -1
  13. package/dist/feedback.js +105 -57
  14. package/dist/feedback.js.map +1 -1
  15. package/dist/{handler-BSNmdHXv.js → handler-yA_knIf3.js} +4 -4
  16. package/dist/{handler-BSNmdHXv.js.map → handler-yA_knIf3.js.map} +1 -1
  17. package/dist/index-Buum9tIY.d.ts +230 -0
  18. package/dist/index-Buum9tIY.d.ts.map +1 -0
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +1 -1
  21. package/dist/options-CIBazDPp.d.ts.map +1 -1
  22. package/dist/protocol-vJhQHTcB.js +390 -0
  23. package/dist/protocol-vJhQHTcB.js.map +1 -0
  24. package/dist/{react-context-Caq1fodf.js → react-context-Kazo_JCz.js} +3 -14
  25. package/dist/react-context-Kazo_JCz.js.map +1 -0
  26. package/dist/react.d.ts +43 -3
  27. package/dist/react.d.ts.map +1 -1
  28. package/dist/react.js +389 -3
  29. package/dist/react.js.map +1 -1
  30. package/dist/selection-outline-NzAAAZXR.js +51 -0
  31. package/dist/selection-outline-NzAAAZXR.js.map +1 -0
  32. package/dist/server.js +1 -1
  33. package/dist/server.js.map +1 -1
  34. package/dist/sessions-panel-DVyWzZ3C.js +6011 -0
  35. package/dist/sessions-panel-DVyWzZ3C.js.map +1 -0
  36. package/dist/{submit-MvVjNdYc.js → submit-DYGZQVdG.js} +44 -70
  37. package/dist/submit-DYGZQVdG.js.map +1 -0
  38. package/dist/surveys.d.ts +3 -0
  39. package/dist/surveys.js +2 -0
  40. package/dist/team-activation-DCqh71bl.js +24 -0
  41. package/dist/team-activation-DCqh71bl.js.map +1 -0
  42. package/dist/team-layer-F-9BE4hc.js +423 -0
  43. package/dist/team-layer-F-9BE4hc.js.map +1 -0
  44. package/dist/{types-DnaDEN9Y.d.ts → types-B3XiHPqy.d.ts} +4 -9
  45. package/dist/types-B3XiHPqy.d.ts.map +1 -0
  46. package/dist/use-survey-session-LK6sN1Fg.js +1247 -0
  47. package/dist/use-survey-session-LK6sN1Fg.js.map +1 -0
  48. package/dist/z-index-CwokqVq_.js +15 -0
  49. package/dist/z-index-CwokqVq_.js.map +1 -0
  50. package/package.json +6 -2
  51. package/dist/client-DAdWdIFY.js.map +0 -1
  52. package/dist/client-DF1fdsSC.d.ts +0 -131
  53. package/dist/client-DF1fdsSC.d.ts.map +0 -1
  54. package/dist/devtools-layer-CEjzYGBB.js.map +0 -1
  55. package/dist/protocol-ClqTFzwR.js +0 -35
  56. package/dist/protocol-ClqTFzwR.js.map +0 -1
  57. package/dist/react-context-Caq1fodf.js.map +0 -1
  58. package/dist/submit-MvVjNdYc.js.map +0 -1
  59. package/dist/types-DnaDEN9Y.d.ts.map +0 -1
@@ -0,0 +1,1247 @@
1
+ import { t as RefletContext } from "./react-context-Kazo_JCz.js";
2
+ import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ //#region ../survey-core/src/defaults.ts
5
+ const DEFAULT_ENDING = {
6
+ description: "Your answers have been recorded.",
7
+ id: "default",
8
+ title: "Thank you!"
9
+ };
10
+ const DEFAULT_DISPLAY = {
11
+ frequency: "once",
12
+ position: "bottom_right"
13
+ };
14
+ const DEFAULT_TIME_DELAY_MS = 5e3;
15
+ const DEFAULT_RATING_MIN = 1;
16
+ const DEFAULT_RATING_MAX = 5;
17
+ /** Every survey has at least one ending: stored endings, or the default thank-you. */
18
+ const endingsOf = (endings) => endings && endings.length > 0 ? [...endings] : [DEFAULT_ENDING];
19
+ const displayOf = (display) => display ?? DEFAULT_DISPLAY;
20
+ const ratingRange = (config) => ({
21
+ max: config?.maxValue ?? DEFAULT_RATING_MAX,
22
+ min: config?.minValue ?? DEFAULT_RATING_MIN
23
+ });
24
+ const textMaxChars = (config) => config?.maxLength ?? 1e3;
25
+ /** Statements show a message and a button; they never collect an answer. */
26
+ const takesAnswer = (type) => type !== "statement";
27
+ //#endregion
28
+ //#region ../survey-core/src/answers.ts
29
+ const isIntegerBetween = (value, min, max) => typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
30
+ /** A choice outside the list is accepted only as the respondent's "Other" text. */
31
+ const choiceIssue = (choice, config) => {
32
+ if (typeof choice !== "string" || choice.trim() === "") return "Pick one of the options.";
33
+ if (config?.choices?.includes(choice)) return null;
34
+ if (!config?.allowOther) return "Pick one of the options.";
35
+ return choice.length > 200 ? `Keep “Other” under 200 characters.` : null;
36
+ };
37
+ const multipleChoiceIssue = (value, config) => {
38
+ if (!Array.isArray(value)) return "Pick at least one option.";
39
+ if (new Set(value).size !== value.length) return "Each option can only be picked once.";
40
+ const listed = config?.choices ?? [];
41
+ const others = value.filter((choice) => !listed.includes(choice));
42
+ if (others.length > 1) return "Only one “Other” answer is allowed.";
43
+ return others.length === 1 ? choiceIssue(others[0], config) : null;
44
+ };
45
+ /** Why `value` can't be stored for `question`, or null when it's a valid answer. */
46
+ const answerIssue = (question, value) => {
47
+ switch (question.type) {
48
+ case "rating": {
49
+ const { max, min } = ratingRange(question.config);
50
+ return isIntegerBetween(value, min, max) ? null : `Pick a rating from ${min} to ${max}.`;
51
+ }
52
+ case "nps": return isIntegerBetween(value, 0, 10) ? null : `Pick a score from 0 to 10.`;
53
+ case "boolean": return typeof value === "boolean" ? null : "Answer yes or no.";
54
+ case "text": {
55
+ if (typeof value !== "string") return "Type an answer.";
56
+ const max = textMaxChars(question.config);
57
+ return value.length > max ? `Keep your answer under ${max} characters.` : null;
58
+ }
59
+ case "single_choice": return choiceIssue(value, question.config);
60
+ case "multiple_choice": return multipleChoiceIssue(value, question.config);
61
+ case "statement": return "This step doesn’t take an answer.";
62
+ default: return "Unknown question type.";
63
+ }
64
+ };
65
+ //#endregion
66
+ //#region ../survey-core/src/flow.ts
67
+ const isEmptyAnswer = (value) => {
68
+ if (value === void 0) return true;
69
+ if (typeof value === "string") return value.trim() === "";
70
+ if (Array.isArray(value)) return value.length === 0;
71
+ return false;
72
+ };
73
+ const includesValue = (answer, expected) => {
74
+ if (typeof expected !== "string") return false;
75
+ if (Array.isArray(answer)) return answer.includes(expected);
76
+ return typeof answer === "string" && answer.toLowerCase().includes(expected.toLowerCase());
77
+ };
78
+ const compareNumbers = (answer, expected, compare) => typeof answer === "number" && typeof expected === "number" && compare(answer, expected);
79
+ const evaluateRule = (rule, answer) => {
80
+ const empty = isEmptyAnswer(answer);
81
+ if (rule.operator === "answered") return !empty;
82
+ if (rule.operator === "skipped") return empty;
83
+ if (answer === void 0 || empty) return false;
84
+ switch (rule.operator) {
85
+ case "equals": return answer === rule.value;
86
+ case "not_equals": return answer !== rule.value;
87
+ case "greater_than": return compareNumbers(answer, rule.value, (a, b) => a > b);
88
+ case "less_than": return compareNumbers(answer, rule.value, (a, b) => a < b);
89
+ case "includes": return includesValue(answer, rule.value);
90
+ default: return false;
91
+ }
92
+ };
93
+ const sortByOrder = (questions) => [...questions].sort((a, b) => a.order - b.order);
94
+ /** `endingsOf` never returns an empty list, so the first ending always exists. */
95
+ const firstEnding = (endings) => endings[0];
96
+ /**
97
+ * Jumps only go forward in question order, so every flow ends. A target that
98
+ * points backward or at a deleted question is ignored and the default path wins.
99
+ */
100
+ const stepForTarget = (sorted, from, target, endings) => {
101
+ if (target.kind === "ending") return {
102
+ ending: endings.find((candidate) => candidate.id === target.endingId) ?? firstEnding(endings),
103
+ kind: "ending"
104
+ };
105
+ const question = sorted.find((q) => q._id === target.questionId);
106
+ return question && question.order > from.order ? {
107
+ kind: "question",
108
+ question
109
+ } : null;
110
+ };
111
+ const defaultStepAfter = (sorted, from, endings) => {
112
+ const following = sorted.find((q) => q.order > from.order);
113
+ return following ? {
114
+ kind: "question",
115
+ question: following
116
+ } : {
117
+ ending: firstEnding(endings),
118
+ kind: "ending"
119
+ };
120
+ };
121
+ const firstStep = (questions, storedEndings) => {
122
+ const [first] = sortByOrder(questions);
123
+ return first ? {
124
+ kind: "question",
125
+ question: first
126
+ } : {
127
+ ending: firstEnding(endingsOf(storedEndings)),
128
+ kind: "ending"
129
+ };
130
+ };
131
+ const resolveNextStep = (questions, current, answer, storedEndings) => {
132
+ const sorted = sortByOrder(questions);
133
+ const endings = endingsOf(storedEndings);
134
+ const target = (takesAnswer(current.type) ? current.logic?.find((rule) => evaluateRule(rule, answer)) : void 0)?.target ?? current.next;
135
+ return (target ? stepForTarget(sorted, current, target, endings) : null) ?? defaultStepAfter(sorted, current, endings);
136
+ };
137
+ /** Follows the flow from `start` using known answers; unanswered questions take their skipped path. */
138
+ const walkFrom = (questions, start, answers, storedEndings) => {
139
+ const visited = [];
140
+ let step = start;
141
+ while (step.kind === "question" && visited.length <= questions.length) {
142
+ visited.push(step.question);
143
+ step = resolveNextStep(questions, step.question, answers.get(step.question._id), storedEndings);
144
+ }
145
+ return {
146
+ ending: step.kind === "ending" ? step.ending : firstEnding(endingsOf(storedEndings)),
147
+ questions: visited
148
+ };
149
+ };
150
+ //#endregion
151
+ //#region ../survey-core/src/session.ts
152
+ const REQUIRED_ANSWER_MESSAGE = "This question needs an answer.";
153
+ const SAVE_FAILED_MESSAGE = "Couldn’t save your answer. Try again.";
154
+ const START_FAILED_MESSAGE = "Couldn’t load this survey. Try again.";
155
+ const previewTransport = {
156
+ answer: () => Promise.resolve(),
157
+ complete: () => Promise.resolve(),
158
+ dismiss: () => Promise.resolve(),
159
+ start: () => Promise.resolve({ responseId: "preview" })
160
+ };
161
+ var SurveySession = class {
162
+ survey;
163
+ transport;
164
+ callbacks;
165
+ endings;
166
+ listeners = /* @__PURE__ */ new Set();
167
+ submitted = /* @__PURE__ */ new Set();
168
+ history = [];
169
+ answers = /* @__PURE__ */ new Map();
170
+ current = null;
171
+ ending = null;
172
+ error = null;
173
+ isSubmitting = false;
174
+ phase = "starting";
175
+ responseId = null;
176
+ snapshot;
177
+ constructor(options) {
178
+ this.survey = options.survey;
179
+ this.transport = options.transport;
180
+ this.callbacks = options.callbacks ?? {};
181
+ this.endings = endingsOf(options.survey.endings);
182
+ this.snapshot = this.buildSnapshot();
183
+ }
184
+ subscribe = (listener) => {
185
+ this.listeners.add(listener);
186
+ return () => this.listeners.delete(listener);
187
+ };
188
+ getSnapshot = () => this.snapshot;
189
+ /** A method, not a field read, so TS doesn't narrow `phase` across awaits. */
190
+ isDismissed() {
191
+ return this.phase === "dismissed";
192
+ }
193
+ async start() {
194
+ if (this.phase !== "starting" && this.phase !== "start_failed") return;
195
+ this.update({
196
+ error: null,
197
+ phase: "starting"
198
+ });
199
+ try {
200
+ const { responseId } = await this.transport.start({ surveyId: this.survey._id });
201
+ this.responseId = responseId;
202
+ if (this.isDismissed()) {
203
+ this.transport.dismiss({ responseId }).catch(() => void 0);
204
+ return;
205
+ }
206
+ this.callbacks.onStart?.({
207
+ responseId,
208
+ surveyId: this.survey._id
209
+ });
210
+ const step = firstStep(this.survey.questions, this.endings);
211
+ if (step.kind === "question") {
212
+ this.current = step.question;
213
+ this.update({ phase: "question" });
214
+ } else await this.finish(step.ending);
215
+ } catch {
216
+ if (!this.isDismissed()) this.update({
217
+ error: START_FAILED_MESSAGE,
218
+ phase: "start_failed"
219
+ });
220
+ }
221
+ }
222
+ setAnswer(value) {
223
+ if (!this.current || this.phase !== "question") return;
224
+ const answers = new Map(this.answers);
225
+ if (value === void 0) answers.delete(this.current._id);
226
+ else answers.set(this.current._id, value);
227
+ this.answers = answers;
228
+ this.update({ error: null });
229
+ }
230
+ async next() {
231
+ const question = this.current;
232
+ const responseId = this.responseId;
233
+ if (!(question && responseId) || this.isSubmitting) return;
234
+ const value = this.answers.get(question._id);
235
+ const validationError = this.validate(question, value);
236
+ if (validationError) {
237
+ this.update({ error: validationError });
238
+ return;
239
+ }
240
+ this.update({
241
+ error: null,
242
+ isSubmitting: true
243
+ });
244
+ try {
245
+ await this.saveAnswer(question, responseId, value);
246
+ if (this.isDismissed()) return;
247
+ const step = resolveNextStep(this.survey.questions, question, value, this.endings);
248
+ if (step.kind === "question") {
249
+ this.history = [...this.history, question];
250
+ this.current = step.question;
251
+ this.update({ isSubmitting: false });
252
+ } else await this.finish(step.ending);
253
+ } catch {
254
+ if (!this.isDismissed()) this.update({
255
+ error: SAVE_FAILED_MESSAGE,
256
+ isSubmitting: false
257
+ });
258
+ }
259
+ }
260
+ back() {
261
+ const previous = this.history.at(-1);
262
+ if (!previous || this.isSubmitting || this.phase !== "question") return;
263
+ this.history = this.history.slice(0, -1);
264
+ this.current = previous;
265
+ this.update({ error: null });
266
+ }
267
+ dismiss() {
268
+ if (this.phase === "dismissed") return;
269
+ const closesAfterEnding = this.phase === "ending";
270
+ if (this.phase === "question" && this.responseId) this.transport.dismiss({ responseId: this.responseId }).catch(() => void 0);
271
+ if (!closesAfterEnding) this.callbacks.onDismiss?.({
272
+ answeredCount: this.submitted.size,
273
+ responseId: this.responseId,
274
+ surveyId: this.survey._id
275
+ });
276
+ this.update({ phase: "dismissed" });
277
+ }
278
+ validate(question, value) {
279
+ if (!takesAnswer(question.type)) return null;
280
+ if (value === void 0 || isEmptyAnswer(value)) return question.required ? REQUIRED_ANSWER_MESSAGE : null;
281
+ return answerIssue(question, value);
282
+ }
283
+ async saveAnswer(question, responseId, value) {
284
+ if (!takesAnswer(question.type)) return;
285
+ if (value !== void 0 && !isEmptyAnswer(value)) {
286
+ await this.transport.answer({
287
+ questionId: question._id,
288
+ responseId,
289
+ value
290
+ });
291
+ this.submitted.add(question._id);
292
+ this.callbacks.onAnswer?.({
293
+ questionId: question._id,
294
+ surveyId: this.survey._id,
295
+ value
296
+ });
297
+ return;
298
+ }
299
+ if (this.submitted.has(question._id)) {
300
+ await this.transport.answer({
301
+ questionId: question._id,
302
+ responseId,
303
+ value: null
304
+ });
305
+ this.submitted.delete(question._id);
306
+ }
307
+ }
308
+ async finish(ending) {
309
+ const responseId = this.responseId;
310
+ if (!responseId) return;
311
+ await this.transport.complete({ responseId });
312
+ if (this.isDismissed()) return;
313
+ this.ending = ending;
314
+ this.current = null;
315
+ this.update({
316
+ isSubmitting: false,
317
+ phase: "ending"
318
+ });
319
+ this.callbacks.onComplete?.({
320
+ endingId: ending.id,
321
+ responseId,
322
+ surveyId: this.survey._id
323
+ });
324
+ }
325
+ update(patch) {
326
+ if (patch.error !== void 0) this.error = patch.error;
327
+ if (patch.isSubmitting !== void 0) this.isSubmitting = patch.isSubmitting;
328
+ if (patch.phase !== void 0) this.phase = patch.phase;
329
+ this.snapshot = this.buildSnapshot();
330
+ for (const listener of this.listeners) listener();
331
+ }
332
+ buildSnapshot() {
333
+ const question = this.phase === "question" ? this.current : null;
334
+ const answered = this.history.length;
335
+ const remaining = question ? walkFrom(this.survey.questions, {
336
+ kind: "question",
337
+ question
338
+ }, this.answers, this.endings).questions.length : 0;
339
+ const isLastStep = question ? resolveNextStep(this.survey.questions, question, this.answers.get(question._id), this.endings).kind === "ending" : false;
340
+ return {
341
+ answers: this.answers,
342
+ canGoBack: question !== null && answered > 0,
343
+ ending: this.phase === "ending" ? this.ending : null,
344
+ error: this.error,
345
+ isLastStep,
346
+ isSubmitting: this.isSubmitting,
347
+ phase: this.phase,
348
+ progress: this.phase === "ending" ? 1 : answered / (answered + remaining || 1),
349
+ question
350
+ };
351
+ }
352
+ };
353
+ //#endregion
354
+ //#region ../survey-core/src/links.ts
355
+ const RELATIVE_LINK_BASE = "https://relative.invalid";
356
+ /** Only http(s) or relative links reach a button href: survey content is rendered on customer sites. */
357
+ const safeLinkUrl = (url) => {
358
+ if (!url) return null;
359
+ try {
360
+ const { protocol } = new URL(url, RELATIVE_LINK_BASE);
361
+ return protocol === "http:" || protocol === "https:" ? url : null;
362
+ } catch {
363
+ return null;
364
+ }
365
+ };
366
+ //#endregion
367
+ //#region src/surveys/card/card-keyboard.ts
368
+ const DIGIT = /^\d$/;
369
+ const pickByKey = (key, question) => {
370
+ if (question.type === "boolean") {
371
+ const lower = key.toLowerCase();
372
+ if (lower === "y") return {
373
+ kind: "pick",
374
+ value: true
375
+ };
376
+ return lower === "n" ? {
377
+ kind: "pick",
378
+ value: false
379
+ } : null;
380
+ }
381
+ if (!DIGIT.test(key)) return null;
382
+ const digit = Number(key);
383
+ if (question.type === "nps") return {
384
+ kind: "pick",
385
+ value: digit
386
+ };
387
+ if (question.type === "rating") {
388
+ const { max, min } = ratingRange(question.config);
389
+ return digit >= min && digit <= max ? {
390
+ kind: "pick",
391
+ value: digit
392
+ } : null;
393
+ }
394
+ if (question.type === "single_choice") {
395
+ const choice = question.config?.choices?.[digit - 1];
396
+ return choice === void 0 ? null : {
397
+ kind: "pick",
398
+ value: choice
399
+ };
400
+ }
401
+ return null;
402
+ };
403
+ /**
404
+ * Enter moves on, Escape closes, and digits (or Y/N) answer single-click
405
+ * questions — except while typing, where keys belong to the field.
406
+ */
407
+ const cardKeyAction = (event, question) => {
408
+ const { target } = event;
409
+ if (event.key === "Escape") return { kind: "close" };
410
+ const isTextArea = target instanceof HTMLTextAreaElement;
411
+ const isTextInput = target instanceof HTMLInputElement;
412
+ if (event.key === "Enter") {
413
+ if (isTextArea) return event.metaKey || event.ctrlKey ? { kind: "next" } : null;
414
+ return target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || !question ? null : { kind: "next" };
415
+ }
416
+ if (isTextArea || isTextInput || !question) return null;
417
+ return pickByKey(event.key, question);
418
+ };
419
+ //#endregion
420
+ //#region src/surveys/card/card-styles.ts
421
+ const LIGHT_TOKENS = `
422
+ --rfs-bg: #ffffff;
423
+ --rfs-text: #18181b;
424
+ --rfs-muted: #71717a;
425
+ --rfs-border: rgb(24 24 27 / 10%);
426
+ --rfs-subtle: rgb(24 24 27 / 4%);
427
+ --rfs-hover: rgb(24 24 27 / 7%);
428
+ --rfs-danger: #dc2626;
429
+ --rfs-success: #16a34a;
430
+ --rfs-primary: var(--rf-survey-primary, #18181b);
431
+ --rfs-primary-text: var(--rf-survey-primary-text, #ffffff);
432
+ --rfs-shadow: 0 0 0 1px var(--rfs-border), 0 2px 6px -1px rgb(15 15 15 / 8%), 0 18px 44px -14px rgb(15 15 15 / 24%);
433
+ color-scheme: light;
434
+ `;
435
+ const DARK_TOKENS = `
436
+ --rfs-bg: #1c1c1f;
437
+ --rfs-text: #f4f4f5;
438
+ --rfs-muted: #a1a1aa;
439
+ --rfs-border: rgb(255 255 255 / 10%);
440
+ --rfs-subtle: rgb(255 255 255 / 5%);
441
+ --rfs-hover: rgb(255 255 255 / 9%);
442
+ --rfs-danger: #f87171;
443
+ --rfs-success: #4ade80;
444
+ --rfs-primary: var(--rf-survey-primary, #fafafa);
445
+ --rfs-primary-text: var(--rf-survey-primary-text, #18181b);
446
+ --rfs-shadow: 0 0 0 1px var(--rfs-border), 0 2px 6px -1px rgb(0 0 0 / 30%), 0 18px 44px -14px rgb(0 0 0 / 60%);
447
+ color-scheme: dark;
448
+ `;
449
+ /** Every selector hangs off `.rf-survey`, so the card looks the same in a shadow root and in a page. */
450
+ const SURVEY_CARD_STYLES = `
451
+ .rf-survey {
452
+ ${LIGHT_TOKENS}
453
+ --rfs-radius: 14px;
454
+ --rfs-control-radius: 9px;
455
+ --rfs-ease: cubic-bezier(0.16, 1, 0.3, 1);
456
+ position: relative;
457
+ box-sizing: border-box;
458
+ width: 100%;
459
+ padding: 20px;
460
+ border-radius: var(--rfs-radius);
461
+ background: var(--rfs-bg);
462
+ color: var(--rfs-text);
463
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
464
+ font-size: 14px;
465
+ line-height: 1.45;
466
+ text-align: left;
467
+ -webkit-font-smoothing: antialiased;
468
+ overflow: hidden;
469
+ }
470
+ .rf-survey[data-theme="dark"] { ${DARK_TOKENS} }
471
+ @media (prefers-color-scheme: dark) {
472
+ .rf-survey[data-theme="auto"] { ${DARK_TOKENS} }
473
+ }
474
+ .rf-survey *, .rf-survey *::before, .rf-survey *::after { box-sizing: border-box; }
475
+ .rf-survey :where(h2, p) { margin: 0; }
476
+ .rf-survey :where(fieldset) { min-width: 0; margin: 0; padding: 0; border: 0; }
477
+ .rf-survey :where(button, input, textarea, a) {
478
+ margin: 0;
479
+ font: inherit;
480
+ color: inherit;
481
+ letter-spacing: inherit;
482
+ }
483
+ .rf-survey :where(button) { cursor: pointer; background: none; border: 0; padding: 0; }
484
+ .rf-survey :where(button:disabled) { cursor: default; }
485
+ .rf-survey :where(button, a, input, textarea):focus-visible {
486
+ outline: 2px solid var(--rfs-primary);
487
+ outline-offset: 2px;
488
+ }
489
+ .rf-survey[data-variant="floating"] { box-shadow: var(--rfs-shadow); animation: rfs-enter 360ms var(--rfs-ease); }
490
+ .rf-survey[data-variant="inline"] { box-shadow: 0 0 0 1px var(--rfs-border); }
491
+ .rf-survey[data-variant="page"] {
492
+ max-width: 560px;
493
+ margin: 0 auto;
494
+ padding: 32px;
495
+ box-shadow: var(--rfs-shadow);
496
+ font-size: 15px;
497
+ }
498
+ .rfs-progress { position: absolute; inset: 0 0 auto; height: 3px; background: var(--rfs-subtle); }
499
+ .rfs-progress-fill {
500
+ height: 100%;
501
+ background: var(--rfs-primary);
502
+ transform-origin: left;
503
+ transition: transform 400ms var(--rfs-ease);
504
+ }
505
+ .rfs-close {
506
+ position: absolute;
507
+ top: 12px;
508
+ right: 12px;
509
+ display: grid;
510
+ place-items: center;
511
+ width: 28px;
512
+ height: 28px;
513
+ border-radius: 8px;
514
+ color: var(--rfs-muted);
515
+ transition: background-color 150ms ease, color 150ms ease;
516
+ }
517
+ .rfs-close:hover { background: var(--rfs-hover); color: var(--rfs-text); }
518
+ .rfs-step { display: flex; flex-direction: column; gap: 14px; animation: rfs-step-in 280ms var(--rfs-ease); }
519
+ .rfs-heading { display: flex; flex-direction: column; gap: 4px; padding-right: 28px; }
520
+ .rfs-title { font-size: 1.07em; font-weight: 600; line-height: 1.35; text-wrap: balance; outline: none; }
521
+ .rfs-description { color: var(--rfs-muted); text-wrap: pretty; white-space: pre-line; }
522
+ .rfs-scale { display: flex; gap: 6px; }
523
+ .rfs-scale[data-kind="nps"] { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 4px; }
524
+ .rfs-scale-option {
525
+ flex: 1;
526
+ min-width: 0;
527
+ height: 40px;
528
+ border-radius: var(--rfs-control-radius);
529
+ background: var(--rfs-subtle);
530
+ box-shadow: inset 0 0 0 1px var(--rfs-border);
531
+ font-weight: 500;
532
+ font-variant-numeric: tabular-nums;
533
+ transition: background-color 150ms ease, color 150ms ease, transform 150ms var(--rfs-ease);
534
+ }
535
+ .rfs-scale[data-kind="nps"] .rfs-scale-option { height: 36px; font-size: 13px; }
536
+ .rfs-scale-option:hover { background: var(--rfs-hover); }
537
+ .rfs-scale-option:active { transform: scale(0.96); }
538
+ .rfs-scale-option[aria-pressed="true"] { background: var(--rfs-primary); color: var(--rfs-primary-text); box-shadow: none; }
539
+ .rfs-scale[data-style="star"], .rfs-scale[data-style="emoji"] { gap: 2px; justify-content: center; }
540
+ .rfs-scale[data-style="star"] .rfs-scale-option,
541
+ .rfs-scale[data-style="emoji"] .rfs-scale-option {
542
+ flex: 0 1 44px;
543
+ height: 44px;
544
+ background: none;
545
+ box-shadow: none;
546
+ color: var(--rfs-border);
547
+ }
548
+ .rfs-scale[data-style="star"] .rfs-scale-option[data-lit="true"] { color: #f5a524; }
549
+ .rfs-scale[data-style="emoji"] .rfs-scale-option { font-size: 26px; filter: grayscale(1); opacity: 0.55; }
550
+ .rfs-scale[data-style="emoji"] .rfs-scale-option:hover,
551
+ .rfs-scale[data-style="emoji"] .rfs-scale-option[aria-pressed="true"] { filter: none; opacity: 1; transform: scale(1.12); background: none; }
552
+ .rfs-scale-labels { display: flex; justify-content: space-between; gap: 12px; margin-top: -6px; color: var(--rfs-muted); font-size: 12px; }
553
+ .rfs-choices { display: flex; flex-direction: column; gap: 6px; }
554
+ .rfs-choice {
555
+ display: flex;
556
+ align-items: center;
557
+ gap: 10px;
558
+ width: 100%;
559
+ min-height: 40px;
560
+ padding: 8px 12px;
561
+ border-radius: var(--rfs-control-radius);
562
+ background: var(--rfs-subtle);
563
+ box-shadow: inset 0 0 0 1px var(--rfs-border);
564
+ text-align: left;
565
+ transition: background-color 150ms ease, box-shadow 150ms ease;
566
+ }
567
+ .rfs-choice:hover { background: var(--rfs-hover); }
568
+ .rfs-choice[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px var(--rfs-primary); background: var(--rfs-bg); }
569
+ .rfs-indicator {
570
+ flex: none;
571
+ display: grid;
572
+ place-items: center;
573
+ width: 16px;
574
+ height: 16px;
575
+ border-radius: 999px;
576
+ box-shadow: inset 0 0 0 1.5px var(--rfs-muted);
577
+ color: var(--rfs-primary-text);
578
+ transition: background-color 150ms ease, box-shadow 150ms ease;
579
+ }
580
+ .rfs-indicator[data-shape="square"] { border-radius: 5px; }
581
+ .rfs-choice[aria-pressed="true"] .rfs-indicator { background: var(--rfs-primary); box-shadow: none; }
582
+ .rfs-boolean { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
583
+ .rfs-boolean .rfs-choice { justify-content: center; font-weight: 500; }
584
+ .rfs-input {
585
+ display: block;
586
+ width: 100%;
587
+ padding: 10px 12px;
588
+ border: 0;
589
+ border-radius: var(--rfs-control-radius);
590
+ background: var(--rfs-subtle);
591
+ box-shadow: inset 0 0 0 1px var(--rfs-border);
592
+ resize: vertical;
593
+ transition: box-shadow 150ms ease;
594
+ }
595
+ .rfs-input::placeholder { color: var(--rfs-muted); }
596
+ .rfs-input:focus { outline: none; box-shadow: inset 0 0 0 1.5px var(--rfs-primary); }
597
+ .rfs-input-wrap { display: flex; flex-direction: column; gap: 4px; }
598
+ .rfs-counter { align-self: flex-end; color: var(--rfs-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
599
+ .rfs-counter[data-full="true"] { color: var(--rfs-danger); }
600
+ .rfs-error { color: var(--rfs-danger); font-size: 13px; }
601
+ .rfs-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 2px; }
602
+ .rfs-button {
603
+ display: inline-flex;
604
+ align-items: center;
605
+ justify-content: center;
606
+ height: 36px;
607
+ padding: 0 16px;
608
+ border-radius: var(--rfs-control-radius);
609
+ font-weight: 500;
610
+ text-decoration: none;
611
+ transition: background-color 150ms ease, opacity 150ms ease, transform 150ms var(--rfs-ease);
612
+ }
613
+ .rfs-button:active:not(:disabled) { transform: scale(0.97); }
614
+ .rfs-button[data-tone="primary"] { background: var(--rfs-primary); color: var(--rfs-primary-text); }
615
+ .rfs-button[data-tone="primary"]:hover:not(:disabled) { opacity: 0.88; }
616
+ .rfs-button[data-tone="primary"]:disabled { opacity: 0.55; }
617
+ .rfs-button[data-tone="ghost"] { margin-right: auto; color: var(--rfs-muted); }
618
+ .rfs-button[data-tone="ghost"]:hover { background: var(--rfs-hover); color: var(--rfs-text); }
619
+ .rfs-ending { align-items: center; padding: 8px 0 4px; text-align: center; }
620
+ .rfs-ending .rfs-heading { padding-right: 0; align-items: center; }
621
+ .rfs-ending-mark {
622
+ display: grid;
623
+ place-items: center;
624
+ width: 40px;
625
+ height: 40px;
626
+ border-radius: 999px;
627
+ background: var(--rfs-subtle);
628
+ color: var(--rfs-success);
629
+ animation: rfs-pop 420ms var(--rfs-ease);
630
+ }
631
+ .rfs-skeleton { display: flex; flex-direction: column; gap: 10px; }
632
+ .rfs-skeleton-line { height: 12px; border-radius: 6px; background: var(--rfs-subtle); animation: rfs-pulse 1.4s ease-in-out infinite; }
633
+ .rfs-skeleton-line:first-child { width: 70%; height: 16px; }
634
+ @keyframes rfs-enter { from { opacity: 0; transform: translateY(12px) scale(0.98); } }
635
+ @keyframes rfs-step-in { from { opacity: 0; transform: translateX(8px); } }
636
+ @keyframes rfs-pop { from { opacity: 0; transform: scale(0.6); } }
637
+ @keyframes rfs-pulse { 50% { opacity: 0.5; } }
638
+ @media (prefers-reduced-motion: reduce) {
639
+ .rf-survey, .rf-survey * { animation: none !important; transition: none !important; }
640
+ }
641
+ `;
642
+ //#endregion
643
+ //#region src/surveys/card/choice-fields.tsx
644
+ const OTHER_LABEL = "Other";
645
+ function CheckIcon() {
646
+ return /* @__PURE__ */ jsx("svg", {
647
+ "aria-hidden": "true",
648
+ fill: "none",
649
+ height: "10",
650
+ stroke: "currentColor",
651
+ strokeLinecap: "round",
652
+ strokeLinejoin: "round",
653
+ strokeWidth: "3",
654
+ viewBox: "0 0 24 24",
655
+ width: "10",
656
+ children: /* @__PURE__ */ jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
657
+ });
658
+ }
659
+ function ChoiceOption({ checked, label, multiple, onSelect }) {
660
+ return /* @__PURE__ */ jsxs("button", {
661
+ "aria-pressed": checked,
662
+ className: "rfs-choice",
663
+ onClick: onSelect,
664
+ type: "button",
665
+ children: [/* @__PURE__ */ jsx("span", {
666
+ className: "rfs-indicator",
667
+ "data-shape": multiple ? "square" : "round",
668
+ children: checked && /* @__PURE__ */ jsx(CheckIcon, {})
669
+ }), label]
670
+ });
671
+ }
672
+ function OtherInput({ onChange, value }) {
673
+ const inputRef = useRef(null);
674
+ useEffect(() => {
675
+ inputRef.current?.focus({ preventScroll: true });
676
+ }, []);
677
+ return /* @__PURE__ */ jsx("input", {
678
+ "aria-label": "Other answer",
679
+ className: "rfs-input",
680
+ maxLength: 200,
681
+ onChange: (event) => onChange(event.target.value),
682
+ placeholder: "Type your answer…",
683
+ ref: inputRef,
684
+ type: "text",
685
+ value
686
+ });
687
+ }
688
+ function SingleChoiceField({ labelledBy, onChange, onPick, question, value }) {
689
+ const choices = question.config?.choices ?? [];
690
+ const typedOther = typeof value === "string" && !choices.includes(value) ? value : "";
691
+ const [otherOpen, setOtherOpen] = useState(typedOther !== "");
692
+ return /* @__PURE__ */ jsxs("fieldset", {
693
+ "aria-labelledby": labelledBy,
694
+ className: "rfs-choices",
695
+ children: [
696
+ choices.map((choice) => /* @__PURE__ */ jsx(ChoiceOption, {
697
+ checked: !otherOpen && value === choice,
698
+ label: choice,
699
+ multiple: false,
700
+ onSelect: () => {
701
+ setOtherOpen(false);
702
+ onPick(choice);
703
+ }
704
+ }, choice)),
705
+ question.config?.allowOther && /* @__PURE__ */ jsx(ChoiceOption, {
706
+ checked: otherOpen,
707
+ label: OTHER_LABEL,
708
+ multiple: false,
709
+ onSelect: () => {
710
+ setOtherOpen(true);
711
+ onChange(typedOther || void 0);
712
+ }
713
+ }),
714
+ otherOpen && /* @__PURE__ */ jsx(OtherInput, {
715
+ onChange: (text) => onChange(text || void 0),
716
+ value: typedOther
717
+ })
718
+ ]
719
+ });
720
+ }
721
+ function MultipleChoiceField({ labelledBy, onChange, question, value }) {
722
+ const choices = question.config?.choices ?? [];
723
+ const picked = Array.isArray(value) ? value : [];
724
+ const listed = picked.filter((choice) => choices.includes(choice));
725
+ const typedOther = picked.find((choice) => !choices.includes(choice)) ?? "";
726
+ const [otherOpen, setOtherOpen] = useState(typedOther !== "");
727
+ const commit = (nextListed, other) => {
728
+ const next = other ? [...nextListed, other] : nextListed;
729
+ onChange(next.length > 0 ? next : void 0);
730
+ };
731
+ return /* @__PURE__ */ jsxs("fieldset", {
732
+ "aria-labelledby": labelledBy,
733
+ className: "rfs-choices",
734
+ children: [
735
+ choices.map((choice) => {
736
+ const checked = listed.includes(choice);
737
+ return /* @__PURE__ */ jsx(ChoiceOption, {
738
+ checked,
739
+ label: choice,
740
+ multiple: true,
741
+ onSelect: () => commit(checked ? listed.filter((item) => item !== choice) : choices.filter((item) => item === choice || listed.includes(item)), typedOther)
742
+ }, choice);
743
+ }),
744
+ question.config?.allowOther && /* @__PURE__ */ jsx(ChoiceOption, {
745
+ checked: otherOpen,
746
+ label: OTHER_LABEL,
747
+ multiple: true,
748
+ onSelect: () => {
749
+ setOtherOpen(!otherOpen);
750
+ commit(listed, otherOpen ? "" : typedOther);
751
+ }
752
+ }),
753
+ otherOpen && /* @__PURE__ */ jsx(OtherInput, {
754
+ onChange: (text) => commit(listed, text),
755
+ value: typedOther
756
+ })
757
+ ]
758
+ });
759
+ }
760
+ function BooleanField({ labelledBy, onPick, value }) {
761
+ return /* @__PURE__ */ jsx("fieldset", {
762
+ "aria-labelledby": labelledBy,
763
+ className: "rfs-boolean",
764
+ children: [{
765
+ answer: true,
766
+ label: "Yes"
767
+ }, {
768
+ answer: false,
769
+ label: "No"
770
+ }].map(({ answer, label }) => /* @__PURE__ */ jsx("button", {
771
+ "aria-pressed": value === answer,
772
+ className: "rfs-choice",
773
+ onClick: () => onPick(answer),
774
+ type: "button",
775
+ children: label
776
+ }, label))
777
+ });
778
+ }
779
+ //#endregion
780
+ //#region src/surveys/card/scale-fields.tsx
781
+ const EMOJI_FACES = [
782
+ "😞",
783
+ "🙁",
784
+ "😐",
785
+ "🙂",
786
+ "😄"
787
+ ];
788
+ const DEFAULT_NPS_MIN_LABEL = "Not likely";
789
+ const DEFAULT_NPS_MAX_LABEL = "Very likely";
790
+ const valuesBetween = (min, max) => Array.from({ length: max - min + 1 }, (_, index) => min + index);
791
+ function StarIcon() {
792
+ return /* @__PURE__ */ jsx("svg", {
793
+ "aria-hidden": "true",
794
+ fill: "currentColor",
795
+ height: "28",
796
+ viewBox: "0 0 24 24",
797
+ width: "28",
798
+ children: /* @__PURE__ */ jsx("path", { d: "M12 2.8l2.83 5.74 6.33.92-4.58 4.46 1.08 6.3L12 17.25l-5.66 2.97 1.08-6.3L2.84 9.46l6.33-.92z" })
799
+ });
800
+ }
801
+ function ScaleLabels({ max, min }) {
802
+ if (!(min || max)) return null;
803
+ return /* @__PURE__ */ jsxs("div", {
804
+ "aria-hidden": "true",
805
+ className: "rfs-scale-labels",
806
+ children: [/* @__PURE__ */ jsx("span", { children: min }), /* @__PURE__ */ jsx("span", { children: max })]
807
+ });
808
+ }
809
+ function RatingField({ labelledBy, onPick, question, value }) {
810
+ const [hovered, setHovered] = useState(null);
811
+ const { max, min } = ratingRange(question.config);
812
+ const style = question.config?.ratingStyle ?? "number";
813
+ const values = valuesBetween(min, max);
814
+ const lit = hovered ?? value ?? Number.NEGATIVE_INFINITY;
815
+ const faceFor = (option) => {
816
+ const step = values.length > 1 ? (option - min) / (max - min) : 1;
817
+ return EMOJI_FACES[Math.round(step * (EMOJI_FACES.length - 1))];
818
+ };
819
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("fieldset", {
820
+ "aria-labelledby": labelledBy,
821
+ className: "rfs-scale",
822
+ "data-style": style,
823
+ children: values.map((option) => /* @__PURE__ */ jsxs("button", {
824
+ "aria-label": `${option} of ${max}`,
825
+ "aria-pressed": value === option,
826
+ className: "rfs-scale-option",
827
+ "data-lit": option <= lit,
828
+ onClick: () => onPick(option),
829
+ onPointerEnter: () => setHovered(option),
830
+ onPointerLeave: () => setHovered(null),
831
+ type: "button",
832
+ children: [
833
+ style === "number" && option,
834
+ style === "star" && /* @__PURE__ */ jsx(StarIcon, {}),
835
+ style === "emoji" && faceFor(option)
836
+ ]
837
+ }, option))
838
+ }), /* @__PURE__ */ jsx(ScaleLabels, {
839
+ max: question.config?.maxLabel,
840
+ min: question.config?.minLabel
841
+ })] });
842
+ }
843
+ function NpsField({ labelledBy, onPick, question, value }) {
844
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("fieldset", {
845
+ "aria-labelledby": labelledBy,
846
+ className: "rfs-scale",
847
+ "data-kind": "nps",
848
+ children: valuesBetween(0, 10).map((option) => /* @__PURE__ */ jsx("button", {
849
+ "aria-pressed": value === option,
850
+ className: "rfs-scale-option",
851
+ onClick: () => onPick(option),
852
+ type: "button",
853
+ children: option
854
+ }, option))
855
+ }), /* @__PURE__ */ jsx(ScaleLabels, {
856
+ max: question.config?.maxLabel ?? DEFAULT_NPS_MAX_LABEL,
857
+ min: question.config?.minLabel ?? DEFAULT_NPS_MIN_LABEL
858
+ })] });
859
+ }
860
+ //#endregion
861
+ //#region src/surveys/card/question-body.tsx
862
+ function TextField({ labelledBy, onChange, question, value }) {
863
+ const max = textMaxChars(question.config);
864
+ const text = typeof value === "string" ? value : "";
865
+ return /* @__PURE__ */ jsxs("div", {
866
+ className: "rfs-input-wrap",
867
+ children: [/* @__PURE__ */ jsx("textarea", {
868
+ "aria-labelledby": labelledBy,
869
+ className: "rfs-input",
870
+ maxLength: max,
871
+ onChange: (event) => onChange(event.target.value || void 0),
872
+ placeholder: question.config?.placeholder ?? "Type your answer…",
873
+ rows: 3,
874
+ value: text
875
+ }), /* @__PURE__ */ jsxs("span", {
876
+ className: "rfs-counter",
877
+ "data-full": text.length >= max,
878
+ children: [
879
+ text.length,
880
+ "/",
881
+ max
882
+ ]
883
+ })]
884
+ });
885
+ }
886
+ function QuestionBody(props) {
887
+ const { labelledBy, onPick, question, value } = props;
888
+ const numericValue = typeof value === "number" ? value : void 0;
889
+ switch (question.type) {
890
+ case "rating": return /* @__PURE__ */ jsx(RatingField, {
891
+ labelledBy,
892
+ onPick,
893
+ question,
894
+ value: numericValue
895
+ });
896
+ case "nps": return /* @__PURE__ */ jsx(NpsField, {
897
+ labelledBy,
898
+ onPick,
899
+ question,
900
+ value: numericValue
901
+ });
902
+ case "single_choice": return /* @__PURE__ */ jsx(SingleChoiceField, { ...props });
903
+ case "multiple_choice": return /* @__PURE__ */ jsx(MultipleChoiceField, { ...props });
904
+ case "boolean": return /* @__PURE__ */ jsx(BooleanField, { ...props });
905
+ case "text": return /* @__PURE__ */ jsx(TextField, { ...props });
906
+ default: return null;
907
+ }
908
+ }
909
+ function StepHeading({ description, headingId, headingRef, title }) {
910
+ return /* @__PURE__ */ jsxs("div", {
911
+ className: "rfs-heading",
912
+ children: [/* @__PURE__ */ jsx("h2", {
913
+ className: "rfs-title",
914
+ id: headingId,
915
+ ref: headingRef,
916
+ tabIndex: -1,
917
+ children: title
918
+ }), description && /* @__PURE__ */ jsx("p", {
919
+ className: "rfs-description",
920
+ children: description
921
+ })]
922
+ });
923
+ }
924
+ function ActionButton({ children, disabled, onClick, url }) {
925
+ const safeUrl = safeLinkUrl(url);
926
+ if (safeUrl) return /* @__PURE__ */ jsx("a", {
927
+ className: "rfs-button",
928
+ "data-tone": "primary",
929
+ href: safeUrl,
930
+ onClick,
931
+ rel: "noopener noreferrer",
932
+ target: "_blank",
933
+ children
934
+ });
935
+ return /* @__PURE__ */ jsx("button", {
936
+ className: "rfs-button",
937
+ "data-tone": "primary",
938
+ disabled,
939
+ onClick,
940
+ type: "button",
941
+ children
942
+ });
943
+ }
944
+ function SuccessMark() {
945
+ return /* @__PURE__ */ jsx("span", {
946
+ className: "rfs-ending-mark",
947
+ children: /* @__PURE__ */ jsx("svg", {
948
+ "aria-hidden": "true",
949
+ fill: "none",
950
+ height: "20",
951
+ stroke: "currentColor",
952
+ strokeLinecap: "round",
953
+ strokeLinejoin: "round",
954
+ strokeWidth: "2.5",
955
+ viewBox: "0 0 24 24",
956
+ width: "20",
957
+ children: /* @__PURE__ */ jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
958
+ })
959
+ });
960
+ }
961
+ function EndingStep({ ending, headingId, headingRef, onClose }) {
962
+ const hasButton = Boolean(ending.buttonUrl || ending.buttonLabel);
963
+ return /* @__PURE__ */ jsxs("div", {
964
+ className: "rfs-step rfs-ending",
965
+ children: [
966
+ /* @__PURE__ */ jsx(SuccessMark, {}),
967
+ /* @__PURE__ */ jsx(StepHeading, {
968
+ description: ending.description,
969
+ headingId,
970
+ headingRef,
971
+ title: ending.title
972
+ }),
973
+ hasButton && /* @__PURE__ */ jsx(ActionButton, {
974
+ onClick: onClose,
975
+ url: ending.buttonUrl,
976
+ children: ending.buttonLabel ?? "Continue"
977
+ })
978
+ ]
979
+ });
980
+ }
981
+ function LoadingStep() {
982
+ return /* @__PURE__ */ jsxs("div", {
983
+ "aria-busy": "true",
984
+ "aria-label": "Loading survey",
985
+ className: "rfs-skeleton",
986
+ role: "status",
987
+ children: [
988
+ /* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" }),
989
+ /* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" }),
990
+ /* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" })
991
+ ]
992
+ });
993
+ }
994
+ function StartFailedStep({ message, onRetry }) {
995
+ return /* @__PURE__ */ jsxs("div", {
996
+ className: "rfs-step",
997
+ children: [/* @__PURE__ */ jsx("p", {
998
+ className: "rfs-error",
999
+ role: "alert",
1000
+ children: message
1001
+ }), /* @__PURE__ */ jsx("div", {
1002
+ className: "rfs-footer",
1003
+ children: /* @__PURE__ */ jsx(ActionButton, {
1004
+ onClick: onRetry,
1005
+ children: "Try again"
1006
+ })
1007
+ })]
1008
+ });
1009
+ }
1010
+ //#endregion
1011
+ //#region src/surveys/card/question-step.tsx
1012
+ function QuestionStep({ headingId, headingRef, onPick, question, session }) {
1013
+ const { snapshot } = session;
1014
+ const isStatement = question.type === "statement";
1015
+ const nextLabel = snapshot.isLastStep ? "Submit" : "Next";
1016
+ return /* @__PURE__ */ jsxs("div", {
1017
+ className: "rfs-step",
1018
+ children: [
1019
+ /* @__PURE__ */ jsx(StepHeading, {
1020
+ description: question.description,
1021
+ headingId,
1022
+ headingRef,
1023
+ title: question.title
1024
+ }),
1025
+ /* @__PURE__ */ jsx(QuestionBody, {
1026
+ labelledBy: headingId,
1027
+ onChange: session.setAnswer,
1028
+ onPick,
1029
+ question,
1030
+ value: snapshot.answers.get(question._id)
1031
+ }),
1032
+ snapshot.error && /* @__PURE__ */ jsx("p", {
1033
+ className: "rfs-error",
1034
+ role: "alert",
1035
+ children: snapshot.error
1036
+ }),
1037
+ /* @__PURE__ */ jsxs("div", {
1038
+ className: "rfs-footer",
1039
+ children: [snapshot.canGoBack && /* @__PURE__ */ jsx("button", {
1040
+ className: "rfs-button",
1041
+ "data-tone": "ghost",
1042
+ disabled: snapshot.isSubmitting,
1043
+ onClick: session.back,
1044
+ type: "button",
1045
+ children: "Back"
1046
+ }), /* @__PURE__ */ jsx(ActionButton, {
1047
+ disabled: snapshot.isSubmitting,
1048
+ onClick: () => session.next(),
1049
+ url: isStatement ? question.config?.buttonUrl : void 0,
1050
+ children: isStatement ? question.config?.buttonLabel ?? "Continue" : nextLabel
1051
+ })]
1052
+ })
1053
+ ]
1054
+ });
1055
+ }
1056
+ //#endregion
1057
+ //#region src/surveys/card/survey-card.tsx
1058
+ const HEX_COLOR = /^#([\da-f]{3}|[\da-f]{6})$/i;
1059
+ const LIGHT_LUMINANCE_THRESHOLD = .6;
1060
+ const DARK_TEXT = "#18181b";
1061
+ const LIGHT_TEXT = "#ffffff";
1062
+ /** Dark text on light brand colors, white text otherwise. */
1063
+ const readableTextOn = (color) => {
1064
+ const match = HEX_COLOR.exec(color.trim());
1065
+ if (!match?.[1]) return LIGHT_TEXT;
1066
+ const hex = match[1].length === 3 ? [...match[1]].map((digit) => digit + digit).join("") : match[1];
1067
+ const [red = 0, green = 0, blue = 0] = [
1068
+ 0,
1069
+ 2,
1070
+ 4
1071
+ ].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
1072
+ return .2126 * red + .7152 * green + .0722 * blue > LIGHT_LUMINANCE_THRESHOLD ? DARK_TEXT : LIGHT_TEXT;
1073
+ };
1074
+ function CloseIcon() {
1075
+ return /* @__PURE__ */ jsx("svg", {
1076
+ "aria-hidden": "true",
1077
+ fill: "none",
1078
+ height: "16",
1079
+ stroke: "currentColor",
1080
+ strokeLinecap: "round",
1081
+ strokeWidth: "2",
1082
+ viewBox: "0 0 24 24",
1083
+ width: "16",
1084
+ children: /* @__PURE__ */ jsx("path", { d: "M6 6l12 12M18 6L6 18" })
1085
+ });
1086
+ }
1087
+ /**
1088
+ * Renders any survey session: questions, branching, endings and errors.
1089
+ * Ships its own scoped styles, so it works in a shadow root or a page.
1090
+ */
1091
+ function SurveyCard({ className, onClose, primaryColor, session, theme = "auto", variant = "inline" }) {
1092
+ const { snapshot } = session;
1093
+ const { ending, phase, question } = snapshot;
1094
+ const headingId = useId();
1095
+ const headingRef = useRef(null);
1096
+ const hasInteracted = useRef(false);
1097
+ const stepKey = question?._id ?? phase;
1098
+ useEffect(() => {
1099
+ if (stepKey && hasInteracted.current) headingRef.current?.focus({ preventScroll: true });
1100
+ }, [stepKey]);
1101
+ if (phase === "dismissed") return null;
1102
+ const closable = onClose !== void 0 || variant === "floating";
1103
+ const close = () => {
1104
+ session.dismiss();
1105
+ onClose?.();
1106
+ };
1107
+ const pick = (value) => {
1108
+ session.setAnswer(value);
1109
+ session.next();
1110
+ };
1111
+ const onKeyDown = (event) => {
1112
+ hasInteracted.current = true;
1113
+ if (event.nativeEvent.isComposing) return;
1114
+ const action = cardKeyAction({
1115
+ ctrlKey: event.ctrlKey,
1116
+ key: event.key,
1117
+ metaKey: event.metaKey,
1118
+ target: event.target
1119
+ }, question);
1120
+ if (!action || action.kind === "close" && !closable) return;
1121
+ event.preventDefault();
1122
+ if (action.kind === "close") close();
1123
+ else if (action.kind === "next") session.next();
1124
+ else pick(action.value);
1125
+ };
1126
+ const themeStyle = primaryColor ? {
1127
+ "--rf-survey-primary": primaryColor,
1128
+ "--rf-survey-primary-text": readableTextOn(primaryColor)
1129
+ } : {};
1130
+ const showsProgress = phase === "question" || phase === "ending";
1131
+ return /* @__PURE__ */ jsxs("section", {
1132
+ "aria-label": question || ending ? void 0 : session.survey.title,
1133
+ "aria-labelledby": question || ending ? headingId : void 0,
1134
+ className: className ? `rf-survey ${className}` : "rf-survey",
1135
+ "data-theme": theme,
1136
+ "data-variant": variant,
1137
+ onKeyDown,
1138
+ onPointerDown: () => {
1139
+ hasInteracted.current = true;
1140
+ },
1141
+ style: themeStyle,
1142
+ children: [
1143
+ /* @__PURE__ */ jsx("style", { children: SURVEY_CARD_STYLES }),
1144
+ showsProgress && /* @__PURE__ */ jsx("div", {
1145
+ "aria-label": "Survey progress",
1146
+ "aria-valuemax": 100,
1147
+ "aria-valuemin": 0,
1148
+ "aria-valuenow": Math.round(snapshot.progress * 100),
1149
+ className: "rfs-progress",
1150
+ role: "progressbar",
1151
+ children: /* @__PURE__ */ jsx("div", {
1152
+ className: "rfs-progress-fill",
1153
+ style: { transform: `scaleX(${snapshot.progress})` }
1154
+ })
1155
+ }),
1156
+ closable && /* @__PURE__ */ jsx("button", {
1157
+ "aria-label": "Close survey",
1158
+ className: "rfs-close",
1159
+ onClick: close,
1160
+ type: "button",
1161
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1162
+ }),
1163
+ phase === "starting" && /* @__PURE__ */ jsx(LoadingStep, {}),
1164
+ phase === "start_failed" && /* @__PURE__ */ jsx(StartFailedStep, {
1165
+ message: snapshot.error,
1166
+ onRetry: () => session.start()
1167
+ }),
1168
+ ending && /* @__PURE__ */ jsx(EndingStep, {
1169
+ ending,
1170
+ headingId,
1171
+ headingRef,
1172
+ onClose: close
1173
+ }, ending.id),
1174
+ question && /* @__PURE__ */ jsx(QuestionStep, {
1175
+ headingId,
1176
+ headingRef,
1177
+ onPick: pick,
1178
+ question,
1179
+ session
1180
+ }, question._id)
1181
+ ]
1182
+ });
1183
+ }
1184
+ //#endregion
1185
+ //#region src/surveys/client-transport.ts
1186
+ /** Runs a survey session against the Reflet REST API with this client's key and user. */
1187
+ const createRefletSurveyTransport = (client, options = {}) => ({
1188
+ answer: (input) => client.submitSurveyAnswer(input),
1189
+ complete: ({ responseId }) => client.completeSurveyResponse(responseId),
1190
+ dismiss: ({ responseId }) => client.dismissSurveyResponse(responseId),
1191
+ start: ({ surveyId }) => client.startSurveyResponse({
1192
+ pageUrl: options.pageUrl,
1193
+ surveyId
1194
+ })
1195
+ });
1196
+ //#endregion
1197
+ //#region src/surveys/use-survey-session.ts
1198
+ const startedSessions = /* @__PURE__ */ new WeakSet();
1199
+ /** Subscribes a component to an existing session. */
1200
+ function useSessionHandle(session) {
1201
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
1202
+ return useMemo(() => ({
1203
+ back: () => session.back(),
1204
+ dismiss: () => session.dismiss(),
1205
+ next: () => session.next(),
1206
+ setAnswer: (value) => session.setAnswer(value),
1207
+ snapshot,
1208
+ start: () => session.start(),
1209
+ survey: session.survey
1210
+ }), [session, snapshot]);
1211
+ }
1212
+ /**
1213
+ * Headless survey runner for custom UIs. A new session begins whenever
1214
+ * `survey` or `transport` changes identity, so memoize them.
1215
+ */
1216
+ function useSurveySession(survey, options = {}) {
1217
+ const { autoStart = true, callbacks, transport } = options;
1218
+ const client = useContext(RefletContext)?.client;
1219
+ const callbacksRef = useRef(callbacks);
1220
+ useEffect(() => {
1221
+ callbacksRef.current = callbacks;
1222
+ });
1223
+ const resolvedTransport = useMemo(() => {
1224
+ if (transport) return transport;
1225
+ return client ? createRefletSurveyTransport(client) : previewTransport;
1226
+ }, [client, transport]);
1227
+ const session = useMemo(() => new SurveySession({
1228
+ callbacks: {
1229
+ onAnswer: (event) => callbacksRef.current?.onAnswer?.(event),
1230
+ onComplete: (event) => callbacksRef.current?.onComplete?.(event),
1231
+ onDismiss: (event) => callbacksRef.current?.onDismiss?.(event),
1232
+ onStart: (event) => callbacksRef.current?.onStart?.(event)
1233
+ },
1234
+ survey,
1235
+ transport: resolvedTransport
1236
+ }), [survey, resolvedTransport]);
1237
+ useEffect(() => {
1238
+ if (!autoStart || startedSessions.has(session)) return;
1239
+ startedSessions.add(session);
1240
+ session.start();
1241
+ }, [autoStart, session]);
1242
+ return useSessionHandle(session);
1243
+ }
1244
+ //#endregion
1245
+ export { SurveySession as a, displayOf as c, SurveyCard as i, useSurveySession as n, previewTransport as o, createRefletSurveyTransport as r, DEFAULT_TIME_DELAY_MS as s, useSessionHandle as t };
1246
+
1247
+ //# sourceMappingURL=use-survey-session-LK6sN1Fg.js.map