@explorer02/cfm-survey-sdk 0.3.1 → 0.3.3

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 (33) hide show
  1. package/dist/cli/index.js +94 -83
  2. package/dist/cli/index.mjs +94 -83
  3. package/package.json +1 -1
  4. package/templates/docs/templates/CsatMatrixScale.tsx +22 -27
  5. package/templates/docs/templates/CustomSliderTrack.tsx +5 -3
  6. package/templates/docs/templates/FileUploadScale.tsx +143 -18
  7. package/templates/docs/templates/HeatmapScale.tsx +11 -2
  8. package/templates/docs/templates/LanguageSelector.tsx +42 -19
  9. package/templates/docs/templates/LikertMatrixScale.tsx +45 -34
  10. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  11. package/templates/docs/templates/Question.tsx +116 -45
  12. package/templates/docs/templates/RankOrderScale.tsx +29 -5
  13. package/templates/docs/templates/RatingScale.tsx +7 -4
  14. package/templates/docs/templates/SliderMatrixScale.tsx +49 -17
  15. package/templates/docs/templates/selectionStyles.ts +103 -1
  16. package/templates/docs/templates/surveyUiIcons.tsx +2 -2
  17. package/templates/docs/templates/surveyUiScaleUtils.ts +38 -5
  18. package/templates/docs/templates/uiConfig.ts +56 -0
  19. package/templates/preview-harness/previewPages.js +4 -7
  20. package/templates/preview-harness/previewPages.ts +7 -34
  21. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +48 -1
  22. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +61 -31
  23. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +37 -8
  24. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +182 -82
  25. package/templates/survey-theme.css +13 -4
  26. package/templates/wizard-dist/assets/{PreviewMock-Bax7oRAL.js → PreviewMock-_9SsNLwC.js} +1 -1
  27. package/templates/wizard-dist/assets/TypePanel-DyEcZlWP.js +1 -0
  28. package/templates/wizard-dist/assets/index-CVBd54V0.css +1 -0
  29. package/templates/wizard-dist/assets/index-pKQOivEa.js +34 -0
  30. package/templates/wizard-dist/index.html +2 -2
  31. package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
  32. package/templates/wizard-dist/assets/index-BhWM50Yu.css +0 -1
  33. package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
@@ -9,6 +9,7 @@
9
9
  import React, { useState, useRef, useEffect } from 'react';
10
10
  import type { ScaleColumn } from '@explorer02/cfm-survey-sdk';
11
11
  import { matrixColumnStoredValue } from '@explorer02/cfm-survey-sdk';
12
+ import { focusRingVar, mcqSelectedAccentVar, mcqSelectedBgVar } from '@/lib/surveyUi/selectionStyles';
12
13
 
13
14
  type MatrixDropdownProps = {
14
15
  options: ScaleColumn[];
@@ -72,9 +73,9 @@ export default function MatrixDropdown({
72
73
  style={{
73
74
  display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px',
74
75
  width: '100%', padding: '10px 14px', backgroundColor: '#fff',
75
- border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
76
+ border: `1px solid ${isOpen ? focusRingVar : '#d1d5db'}`, borderRadius: '8px',
76
77
  cursor: 'pointer', transition: 'all 0.2s', minHeight: '44px',
77
- boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
78
+ boxShadow: isOpen ? '0 0 0 3px var(--cfm-input-focus-glow, rgba(226, 0, 116, 0.1))' : '0 1px 2px rgba(0,0,0,0.05)',
78
79
  }}
79
80
  >
80
81
  {multiValues.length === 0 ? (
@@ -124,9 +125,9 @@ export default function MatrixDropdown({
124
125
  style={{
125
126
  display: 'flex', alignItems: 'center', justifyContent: 'space-between',
126
127
  width: '100%', padding: '14px 16px', backgroundColor: '#fff',
127
- border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
128
+ border: `1px solid ${isOpen ? focusRingVar : '#d1d5db'}`, borderRadius: '8px',
128
129
  cursor: 'pointer', transition: 'all 0.2s',
129
- boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
130
+ boxShadow: isOpen ? '0 0 0 3px var(--cfm-input-focus-glow, rgba(226, 0, 116, 0.1))' : '0 1px 2px rgba(0,0,0,0.05)',
130
131
  }}
131
132
  >
132
133
  <span
@@ -165,12 +166,12 @@ export default function MatrixDropdown({
165
166
  key={col.id}
166
167
  style={{
167
168
  padding: '12px 16px', cursor: isDisabled ? 'not-allowed' : 'pointer', transition: 'background-color 0.15s',
168
- backgroundColor: isSelected ? '#fdf2f8' : '#fff',
169
+ backgroundColor: isSelected ? mcqSelectedBgVar : '#fff',
169
170
  opacity: isDisabled ? 0.5 : 1,
170
171
  display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
171
172
  }}
172
- onMouseEnter={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = '#fdf2f8'; }}
173
- onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? '#fdf2f8' : '#fff'; }}
173
+ onMouseEnter={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = mcqSelectedBgVar; }}
174
+ onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? mcqSelectedBgVar : '#fff'; }}
174
175
  onClick={e => {
175
176
  e.preventDefault();
176
177
  if (isDisabled) return;
@@ -187,8 +188,8 @@ export default function MatrixDropdown({
187
188
  <div style={{
188
189
  display: 'flex', alignItems: 'center', justifyContent: 'center',
189
190
  width: '18px', height: '18px', borderRadius: '4px',
190
- border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
191
- backgroundColor: isSelected ? '#e20074' : '#fff',
191
+ border: isSelected ? `2px solid ${mcqSelectedAccentVar}` : '2px solid #d1d5db',
192
+ backgroundColor: isSelected ? mcqSelectedAccentVar : '#fff',
192
193
  flexShrink: 0, transition: 'all 0.15s',
193
194
  }}>
194
195
  {isSelected && (
@@ -198,11 +199,11 @@ export default function MatrixDropdown({
198
199
  )}
199
200
  </div>
200
201
  )}
201
- <span style={{ fontSize: '15px', fontWeight: isSelected ? 600 : 400, color: isSelected ? '#e20074' : '#374151', userSelect: 'none' }}
202
+ <span style={{ fontSize: '15px', fontWeight: isSelected ? 600 : 400, color: isSelected ? mcqSelectedAccentVar : '#374151', userSelect: 'none' }}
202
203
  dangerouslySetInnerHTML={{ __html: col.label }} />
203
204
  </div>
204
205
  {!multiple && isSelected && (
205
- <svg style={{ width: '18px', height: '18px', color: '#e20074', flexShrink: 0 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
206
+ <svg style={{ width: '18px', height: '18px', color: mcqSelectedAccentVar, flexShrink: 0 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
206
207
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
207
208
  </svg>
208
209
  )}
@@ -11,7 +11,7 @@
11
11
 
12
12
  import React, { useMemo } from 'react';
13
13
  import type { SurveyQuestion, AnswerValue, SurveyAnswers, CustomFieldValueMap, HeatmapAnswer, HeatmapQuestion } from '@explorer02/cfm-survey-sdk';
14
- import { QUESTION_TYPE, resolveSurveyQuestionPlaceholders, getVisibleMatrixItemsForAnswers, getVisibleMcqOptionsForAnswers, getVisibleRankOrderOptionsForAnswers } from '@explorer02/cfm-survey-sdk';
14
+ import { QUESTION_TYPE, resolveSurveyQuestionPlaceholders, getVisibleMatrixItemsForAnswers, getVisibleMcqOptionsForAnswers, getVisibleRankOrderOptionsForAnswers, normalizeRankOrderAnswers } from '@explorer02/cfm-survey-sdk';
15
15
  import { CsatMatrixScale } from './CsatMatrixScale';
16
16
  import { SliderMatrixScale } from './SliderMatrixScale';
17
17
  import { LikertMatrixScale } from './LikertMatrixScale';
@@ -20,6 +20,13 @@ import { HeatmapScale } from './HeatmapScale';
20
20
  import { RankOrderScale } from './RankOrderScale';
21
21
  import RatingScale from './RatingScale';
22
22
  import { hintLabelStyle } from '@/lib/surveyUi/labelStyles';
23
+ import {
24
+ mcqCheckboxControlStyle,
25
+ mcqOptionCardStyle,
26
+ mcqRadioInnerStyle,
27
+ mcqRadioOuterStyle,
28
+ focusRingVar,
29
+ } from '@/lib/surveyUi/selectionStyles';
23
30
 
24
31
  type QuestionProps = {
25
32
  question: SurveyQuestion;
@@ -114,6 +121,7 @@ export default function Question({
114
121
  boxShadow: 'var(--cfm-card-shadow, 0 1px 3px rgba(0,0,0,0.1))',
115
122
  borderRadius: 'var(--cfm-border-radius, 12px)',
116
123
  fontFamily: 'var(--cfm-font-family)',
124
+ marginBottom: 'var(--cfm-card-gap, 32px)',
117
125
  }}
118
126
  >
119
127
  <h2
@@ -142,15 +150,19 @@ export default function Question({
142
150
  {question.type === QUESTION_TYPE.NPS_SCALE && (
143
151
  <div className="space-y-3">
144
152
  {(question.minLabel || question.midLabel || question.maxLabel) && (
145
- <div className="relative w-full h-6 text-xs font-semibold" style={hintLabelStyle()}>
153
+ <div className="relative w-full min-h-[24px] text-xs font-semibold">
146
154
  <span
147
155
  className="absolute left-0 top-0 max-w-[30%] text-left leading-tight"
156
+ style={{ ...hintLabelStyle(), padding: '2px 6px', borderRadius: '4px' }}
148
157
  dangerouslySetInnerHTML={{ __html: question.minLabel ?? '' }}
149
158
  />
150
159
  {question.midLabel && (
151
160
  <span
152
161
  className="absolute top-0 -translate-x-1/2 text-center max-w-[40%] leading-tight"
153
162
  style={{
163
+ ...hintLabelStyle(),
164
+ padding: '2px 6px',
165
+ borderRadius: '4px',
154
166
  left: `${question.midLabelIndex !== undefined && question.options.length > 1
155
167
  ? (question.midLabelIndex / (question.options.length - 1)) * 100
156
168
  : 50}%`,
@@ -160,6 +172,7 @@ export default function Question({
160
172
  )}
161
173
  <span
162
174
  className="absolute right-0 top-0 max-w-[30%] text-right leading-tight"
175
+ style={{ ...hintLabelStyle(), padding: '2px 6px', borderRadius: '4px' }}
163
176
  dangerouslySetInnerHTML={{ __html: question.maxLabel ?? '' }}
164
177
  />
165
178
  </div>
@@ -205,14 +218,8 @@ export default function Question({
205
218
  return (
206
219
  <label
207
220
  key={option.id}
208
- className="flex cursor-pointer items-center gap-4 transition-all"
209
- style={{
210
- borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
211
- border: `2px solid ${isSelected ? 'var(--cfm-mcq-selected-accent, var(--cfm-primary))' : 'var(--cfm-card-border-color, #e5e7eb)'}`,
212
- background: isSelected ? 'var(--cfm-mcq-selected-bg, var(--cfm-secondary))' : 'var(--cfm-card-bg, #fff)',
213
- padding: 'var(--cfm-mcq-card-padding, 12px)',
214
- marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
215
- }}
221
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
222
+ style={mcqOptionCardStyle(isSelected)}
216
223
  >
217
224
  <input
218
225
  type="checkbox"
@@ -221,6 +228,13 @@ export default function Question({
221
228
  onChange={() => handleToggle(option.id)}
222
229
  className="sr-only"
223
230
  />
231
+ <div style={mcqCheckboxControlStyle(isSelected)}>
232
+ {isSelected && (
233
+ <svg className="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none" aria-hidden="true">
234
+ <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
235
+ </svg>
236
+ )}
237
+ </div>
224
238
  <span
225
239
  className="text-[15px] font-medium text-gray-900 leading-tight"
226
240
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -241,14 +255,8 @@ export default function Question({
241
255
  return (
242
256
  <label
243
257
  key={option.id}
244
- className="flex cursor-pointer items-center gap-4 transition-all"
245
- style={{
246
- borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
247
- border: `2px solid ${isSelected ? 'var(--cfm-mcq-selected-accent, var(--cfm-primary))' : 'var(--cfm-card-border-color, #e5e7eb)'}`,
248
- background: isSelected ? 'var(--cfm-mcq-selected-bg, var(--cfm-secondary))' : 'var(--cfm-card-bg, #fff)',
249
- padding: 'var(--cfm-mcq-card-padding, 12px)',
250
- marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
251
- }}
258
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
259
+ style={mcqOptionCardStyle(isSelected)}
252
260
  >
253
261
  <input
254
262
  type="radio"
@@ -257,6 +265,9 @@ export default function Question({
257
265
  onChange={() => onSelect(option.id)}
258
266
  className="sr-only"
259
267
  />
268
+ <div style={mcqRadioOuterStyle(isSelected)}>
269
+ {isSelected && <div style={mcqRadioInnerStyle()} />}
270
+ </div>
260
271
  <span
261
272
  className="text-[15px] font-medium text-gray-900 leading-tight"
262
273
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -271,31 +282,48 @@ export default function Question({
271
282
  {question.type === QUESTION_TYPE.RANK_ORDER && rankOrderQuestion && (
272
283
  <RankOrderScale
273
284
  question={rankOrderQuestion}
274
- selectedValue={
275
- typeof selectedValue === 'object' && selectedValue !== null && !Array.isArray(selectedValue)
276
- ? selectedValue
277
- : undefined
278
- }
285
+ selectedValue={normalizeRankOrderAnswers(selectedValue)}
279
286
  onSelect={onSelect}
280
287
  />
281
288
  )}
282
289
 
283
290
  {question.type === QUESTION_TYPE.TEXTFIELD && (
284
- <textarea
285
- data-cfm-textfield
286
- rows={question.inputVariant === 'long' ? 4 : 1}
287
- value={(selectedValue as string) ?? ''}
288
- onChange={e => onSelect(e.target.value)}
289
- placeholder={question.placeholder || 'Type your response here...'}
290
- className="w-full outline-none"
291
- style={{
292
- height: question.inputVariant === 'long' ? undefined : 'var(--cfm-input-height, 44px)',
293
- borderRadius: 'var(--cfm-input-radius, 8px)',
294
- border: '1px solid var(--cfm-input-border, #d1d5db)',
295
- padding: 'var(--cfm-input-padding, 12px)',
296
- color: 'var(--cfm-text)',
297
- }}
298
- />
291
+ <div className="space-y-1.5">
292
+ <textarea
293
+ data-cfm-textfield
294
+ rows={question.inputVariant === 'long' ? 4 : 1}
295
+ value={(selectedValue as string) ?? ''}
296
+ onChange={e => onSelect(e.target.value)}
297
+ placeholder={question.placeholder || 'Type your response here...'}
298
+ className="w-full text-sm outline-none transition-colors"
299
+ style={{
300
+ height: question.inputVariant === 'long' ? undefined : 'var(--cfm-input-height, 44px)',
301
+ borderRadius: 'var(--cfm-input-radius, 4px)',
302
+ border: '1px solid var(--cfm-input-border, #d1d5db)',
303
+ padding: 'var(--cfm-input-padding, 16px)',
304
+ color: 'var(--cfm-text)',
305
+ }}
306
+ onFocus={e => {
307
+ e.currentTarget.style.borderColor = focusRingVar;
308
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
309
+ }}
310
+ onBlur={e => {
311
+ e.currentTarget.style.borderColor = 'var(--cfm-input-border, #d1d5db)';
312
+ e.currentTarget.style.boxShadow = 'none';
313
+ }}
314
+ />
315
+ <div
316
+ className="text-right text-xs pr-1"
317
+ style={{ color: 'var(--cfm-input-placeholder, #6b7280)' }}
318
+ >
319
+ {Math.max(
320
+ 0,
321
+ (question.maxCharacterCount ?? 1000) -
322
+ (typeof selectedValue === 'string' ? selectedValue.length : 0)
323
+ )}{' '}
324
+ characters remaining
325
+ </div>
326
+ </div>
299
327
  )}
300
328
 
301
329
  {question.type === QUESTION_TYPE.CFM_MATRIX && (
@@ -336,21 +364,64 @@ export default function Question({
336
364
  )}
337
365
 
338
366
  {question.type === QUESTION_TYPE.TEXT_AND_MEDIA && question.media?.url && (
339
- <div className={`mt-4 flex w-full ${question.media.alignment?.includes('CENTER') ? 'justify-center' : question.media.alignment?.includes('RIGHT') ? 'justify-end' : 'justify-start'}`}>
340
- <div style={{ width: question.media.size ? `${question.media.size}%` : '100%' }}>
367
+ <div
368
+ className={`mt-4 flex w-full ${
369
+ question.media.alignment === 'TOP_CENTER' || question.media.alignment === 'BOTTOM_CENTER'
370
+ ? 'justify-center'
371
+ : question.media.alignment === 'TOP_RIGHT' || question.media.alignment === 'BOTTOM_RIGHT'
372
+ ? 'justify-end'
373
+ : 'justify-start'
374
+ }`}
375
+ >
376
+ <div
377
+ style={{
378
+ width: question.media.size
379
+ ? `${question.media.size}%`
380
+ : 'var(--cfm-media-max-width, 100%)',
381
+ maxWidth: 'var(--cfm-media-max-width, 100%)',
382
+ }}
383
+ className="flex flex-col gap-2"
384
+ >
341
385
  {question.media.mimeType?.startsWith('video/') || question.media.mimeType === 'VIDEO' ? (
342
- <video controls src={question.media.url} className="max-w-full rounded-md" />
386
+ <video
387
+ controls
388
+ src={question.media.url}
389
+ className="max-w-full shadow-sm"
390
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
391
+ />
343
392
  ) : (
344
- <img src={question.media.url} alt={question.media.title || ''} className="max-w-full rounded-md" />
393
+ <img
394
+ src={question.media.url}
395
+ alt={question.media.title || ''}
396
+ className="max-w-full shadow-sm"
397
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
398
+ />
399
+ )}
400
+ {question.media.title && (
401
+ <p
402
+ className="text-center"
403
+ style={{
404
+ color: 'var(--cfm-media-caption-color, #6b7280)',
405
+ fontSize: 'var(--cfm-media-caption-size, 14px)',
406
+ }}
407
+ >
408
+ {question.media.title}
409
+ </p>
345
410
  )}
346
- {question.media.title && <p className="text-sm text-gray-500 text-center mt-2">{question.media.title}</p>}
347
411
  </div>
348
412
  </div>
349
413
  )}
350
414
 
351
415
  {validationError && (
352
- <div className="mt-4 rounded border border-gray-800 bg-gray-200 py-3 px-4">
353
- <p className="text-sm font-bold text-gray-900">{validationError}</p>
416
+ <div
417
+ className="mt-4 rounded py-3.5 px-4 text-left"
418
+ style={{
419
+ border: '1px solid var(--cfm-validation-border, #333333)',
420
+ backgroundColor: 'var(--cfm-validation-bg, #d9d9d9)',
421
+ borderRadius: 'var(--cfm-input-radius, 4px)',
422
+ }}
423
+ >
424
+ <p className="text-[13px] sm:text-sm font-bold text-gray-900 leading-tight">{validationError}</p>
354
425
  </div>
355
426
  )}
356
427
  </section>
@@ -37,6 +37,7 @@ import {
37
37
  getOrderedOptionIdsFromRanks,
38
38
  normalizeRankOrderAnswers,
39
39
  } from '@explorer02/cfm-survey-sdk';
40
+ import { focusRingVar } from '@/lib/surveyUi/selectionStyles';
40
41
 
41
42
  type RankOrderScaleProps = {
42
43
  question: RankOrderQuestion;
@@ -120,15 +121,29 @@ function RankOrderOptionRow({
120
121
  }: RankOrderOptionRowProps) {
121
122
  return (
122
123
  <div
123
- className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] px-4 py-3 transition-colors"
124
- style={{ backgroundColor: 'var(--cfm-rank-item-bg, #fff)', padding: 'var(--cfm-rank-item-padding, 12px 16px)', marginBottom: 'var(--cfm-rank-item-gap, 12px)' }}
124
+ className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] transition-colors"
125
+ style={{
126
+ backgroundColor: 'var(--cfm-rank-item-bg, #fff)',
127
+ padding: 'var(--cfm-rank-item-padding, 12px 16px)',
128
+ marginBottom: 'var(--cfm-rank-item-gap, 12px)',
129
+ }}
130
+ onMouseEnter={(e) => {
131
+ e.currentTarget.style.backgroundColor =
132
+ 'var(--cfm-rank-dropdown-hover-bg, var(--cfm-rank-item-bg, #f9fafb))';
133
+ }}
134
+ onMouseLeave={(e) => {
135
+ e.currentTarget.style.backgroundColor = 'var(--cfm-rank-item-bg, #fff)';
136
+ }}
125
137
  >
126
138
  {rankSelect}
127
139
  {dragHandle}
128
140
  {rankBadge !== undefined && (
129
141
  <span
130
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
131
- style={{ backgroundColor: 'var(--cfm-rank-badge, var(--cfm-primary))' }}
142
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
143
+ style={{
144
+ backgroundColor: 'var(--cfm-rank-badge-bg, var(--cfm-mcq-selected-bg, #fdf2f8))',
145
+ color: 'var(--cfm-rank-badge-text, var(--cfm-mcq-selected-accent, var(--cfm-primary, #e20074)))',
146
+ }}
132
147
  >
133
148
  {rankBadge}
134
149
  </span>
@@ -169,6 +184,14 @@ function DropdownRankLayout({
169
184
  onChange={event => handleRankChange(option.id, event.target.value)}
170
185
  className="h-10 w-16 shrink-0 rounded border bg-white px-2 text-sm outline-none"
171
186
  style={{ borderColor: 'var(--cfm-rank-select-border, #d1d5db)' }}
187
+ onFocus={e => {
188
+ e.currentTarget.style.borderColor = focusRingVar;
189
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
190
+ }}
191
+ onBlur={e => {
192
+ e.currentTarget.style.borderColor = 'var(--cfm-rank-select-border, #d1d5db)';
193
+ e.currentTarget.style.boxShadow = 'none';
194
+ }}
172
195
  >
173
196
  <option value="">-</option>
174
197
  {rankChoices.map(rank => (
@@ -225,7 +248,8 @@ function SortableRankItem({
225
248
  <button
226
249
  type="button"
227
250
  aria-label={`Drag to reorder ${option.optionLabel || option.id}`}
228
- className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded text-gray-500 hover:bg-gray-100 active:cursor-grabbing"
251
+ className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded active:cursor-grabbing"
252
+ style={{ color: 'var(--cfm-rank-handle, #9ca3af)' }}
229
253
  {...attributes}
230
254
  {...listeners}
231
255
  >
@@ -20,7 +20,7 @@ type RatingScaleProps = {
20
20
  selectedValue?: AnswerValue;
21
21
  onSelect: (value: AnswerValue) => void;
22
22
  variant?: 'nps' | 'star';
23
- buttonStyle?: 'standard' | 'numbered' | 'emoji';
23
+ buttonStyle?: 'standard' | 'numbered' | 'emoji' | 'pill';
24
24
  };
25
25
 
26
26
  function resolveBadgeColor(index: number, value: number): string {
@@ -68,6 +68,7 @@ export default function RatingScale({
68
68
  gap: 'var(--cfm-nps-cell-gap, 2px)',
69
69
  };
70
70
  const showNumberBadges = buttonStyle !== 'standard';
71
+ const usePillCells = buttonStyle === 'pill';
71
72
 
72
73
  if (buttonStyle === 'emoji') {
73
74
  return (
@@ -92,7 +93,7 @@ export default function RatingScale({
92
93
  style={{
93
94
  ...scaleRadioRingStyle(isSelected, {
94
95
  pill: false,
95
- size: 'var(--cfm-csat-emoji-size, 48px)',
96
+ size: 'var(--cfm-csat-emoji-size, 40px)',
96
97
  }),
97
98
  fontSize: 'var(--cfm-csat-emoji-size, 24px)',
98
99
  }}
@@ -111,7 +112,7 @@ export default function RatingScale({
111
112
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
112
113
  {showNumberBadges && (
113
114
  <div className="mb-3 min-w-0" style={gridStyle}>
114
- {resolvedOptions.map((option) => {
115
+ {resolvedOptions.map((option, index) => {
115
116
  const isSelected = isScaleAnswerSelected(selectedValue, option.value);
116
117
  return (
117
118
  <div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
@@ -124,6 +125,7 @@ export default function RatingScale({
124
125
  ? scaleCellSelectedStyle(true)
125
126
  : { backgroundColor: option.color }),
126
127
  }}
128
+ data-cfm-number-badge={index}
127
129
  title={option.label}
128
130
  >
129
131
  {option.label}
@@ -158,9 +160,10 @@ export default function RatingScale({
158
160
  className="flex items-center justify-center rounded-full border-2 transition-all"
159
161
  style={scaleRadioRingStyle(isSelected, {
160
162
  size: 'var(--cfm-nps-cell-size, 40px)',
163
+ pill: usePillCells,
161
164
  })}
162
165
  >
163
- <div style={scaleRadioDotStyle(isSelected)} />
166
+ <div style={scaleRadioDotStyle(isSelected, { pill: usePillCells })} />
164
167
  </div>
165
168
  </label>
166
169
  );
@@ -9,6 +9,10 @@ import React from 'react';
9
9
  import type { SliderMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
10
10
  import { tickColorFromIndex } from './surveyUiScaleUtils';
11
11
  import { CustomSliderTrack } from './CustomSliderTrack';
12
+ import { hintLabelStyle, sliderTickLabelStyle } from '@/lib/surveyUi/labelStyles';
13
+
14
+ const MATRIX_ROW_LABEL_WIDTH = 'var(--cfm-matrix-row-width, 180px)';
15
+ const SLIDER_ROW_BAND = 'var(--cfm-slider-row-band, var(--cfm-row-band, #f3f4f6))';
12
16
 
13
17
  type SliderMatrixScaleProps = {
14
18
  question: SliderMatrixQuestion;
@@ -67,26 +71,44 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
67
71
  display: 'flex', width: '100%', marginBottom: '4px', alignItems: 'flex-end',
68
72
  padding: '0 16px', boxSizing: 'border-box',
69
73
  }}>
70
- <div style={{ flex: '0 0 25%', paddingRight: '16px' }} />
74
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px' }} />
71
75
 
72
76
  <div style={{ flex: '1', position: 'relative' }}>
73
77
  {labels && labels.length > 0 && (
74
- <div style={{
75
- position: 'relative', display: 'flex', justifyContent: 'space-between',
76
- margin: '0 16px', marginBottom: '16px', color: '#111827', fontSize: '14px', fontWeight: 500,
77
- }}>
78
+ <div
79
+ style={{
80
+ position: 'relative',
81
+ display: 'flex',
82
+ justifyContent: 'space-between',
83
+ margin: '0 16px',
84
+ marginBottom: '16px',
85
+ fontSize: '14px',
86
+ fontWeight: 500,
87
+ }}
88
+ >
78
89
  {labels.map((lbl, idx) => (
79
90
  <div key={idx} style={{ width: 0, display: 'flex', justifyContent: 'center' }}>
80
- <span style={{ whiteSpace: 'nowrap' }}>{lbl}</span>
91
+ <span
92
+ style={{ ...hintLabelStyle(), padding: '2px 8px', borderRadius: '4px', whiteSpace: 'nowrap' }}
93
+ >
94
+ {lbl}
95
+ </span>
81
96
  </div>
82
97
  ))}
83
98
  </div>
84
99
  )}
85
100
 
86
- <div style={{
87
- position: 'relative', margin: '0 16px', marginBottom: '8px',
88
- color: '#4b5563', fontSize: '14px', fontWeight: 400, height: '28px',
89
- }}>
101
+ <div
102
+ style={{
103
+ position: 'relative',
104
+ margin: '0 16px',
105
+ marginBottom: '8px',
106
+ fontSize: '14px',
107
+ fontWeight: 400,
108
+ height: '28px',
109
+ ...sliderTickLabelStyle(),
110
+ }}
111
+ >
90
112
  {tickValues.length > 0 ? (
91
113
  tickValues.map((tv, idx) => {
92
114
  const percentage = max !== min ? ((tv.value - min) / (max - min)) * 100 : 0;
@@ -101,11 +123,20 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
101
123
  {tv.label}
102
124
  </div>
103
125
  ) : (
104
- <div style={{
105
- backgroundColor: tv.color, color: 'white', padding: '4px 8px', borderRadius: '4px',
106
- fontWeight: 600, wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2',
107
- boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
108
- }}>
126
+ <div
127
+ style={{
128
+ backgroundColor: tv.color,
129
+ color: 'white',
130
+ padding: '4px 8px',
131
+ borderRadius: '4px',
132
+ fontWeight: 600,
133
+ wordBreak: 'break-word',
134
+ textAlign: 'center',
135
+ lineHeight: '1.2',
136
+ boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
137
+ minWidth: 'var(--cfm-slider-tick-size, 28px)',
138
+ }}
139
+ >
109
140
  {tv.label}
110
141
  </div>
111
142
  )}
@@ -151,9 +182,10 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
151
182
  return (
152
183
  <div key={row.id} style={{
153
184
  display: 'flex', width: '100%', alignItems: 'center',
154
- backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
185
+ backgroundColor: SLIDER_ROW_BAND, borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
186
+ borderBottom: '1px solid var(--cfm-card-border-color, #e5e7eb)',
155
187
  }}>
156
- <div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
188
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
157
189
  {row.statementText !== 'Question' && row.statementText !== '' ? (
158
190
  <span dangerouslySetInnerHTML={{ __html: row.statementText }} />
159
191
  ) : null}