@explorer02/cfm-survey-sdk 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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/00-integration/client-lib-folder.md +1 -0
  5. package/templates/docs/00-integration/wizard-chrome-contract.md +2 -1
  6. package/templates/docs/00-integration/wizard-preview-build-guide.md +17 -0
  7. package/templates/docs/01-components/03-rating-scale.md +8 -3
  8. package/templates/docs/02-reference/value-derivation.md +2 -0
  9. package/templates/docs/03-ui-specs/01-rating.md +15 -1
  10. package/templates/docs/templates/CsatMatrixScale.tsx +18 -24
  11. package/templates/docs/templates/CustomSliderTrack.tsx +5 -3
  12. package/templates/docs/templates/FileUploadScale.tsx +143 -18
  13. package/templates/docs/templates/Header.tsx +1 -0
  14. package/templates/docs/templates/HeatmapScale.tsx +11 -2
  15. package/templates/docs/templates/LanguageSelector.tsx +42 -19
  16. package/templates/docs/templates/LikertMatrixScale.tsx +43 -33
  17. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  18. package/templates/docs/templates/Question.tsx +109 -44
  19. package/templates/docs/templates/RankOrderScale.tsx +22 -5
  20. package/templates/docs/templates/RatingScale.tsx +77 -39
  21. package/templates/docs/templates/SliderMatrixScale.tsx +44 -16
  22. package/templates/docs/templates/selectionStyles.ts +90 -2
  23. package/templates/docs/templates/surveyUiIcons.tsx +2 -2
  24. package/templates/docs/templates/surveyUiScaleUtils.ts +52 -5
  25. package/templates/docs/templates/uiConfig.ts +56 -0
  26. package/templates/preview-harness/preview-bridge.inline.js +44 -0
  27. package/templates/preview-harness/previewPages.js +76 -51
  28. package/templates/preview-harness/previewPages.ts +7 -34
  29. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +7 -0
  30. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +59 -30
  31. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +36 -8
  32. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +182 -82
  33. package/templates/previewBridge.ts +33 -0
  34. package/templates/survey-theme.css +13 -4
  35. package/templates/wizard-dist/assets/{PreviewMock-Bax7oRAL.js → PreviewMock-DbbLpHdF.js} +1 -1
  36. package/templates/wizard-dist/assets/TypePanel-DQbV2iCf.js +1 -0
  37. package/templates/wizard-dist/assets/index-CY7WMJ93.js +34 -0
  38. package/templates/wizard-dist/index.html +1 -1
  39. package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
  40. package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
@@ -3,8 +3,10 @@ import type { CfmMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey
3
3
  import { columnSubmitValue } from './surveyUiScaleUtils';
4
4
  import MatrixDropdown from './MatrixDropdown';
5
5
  import { matrixColumnLabelStyle } from '@/lib/surveyUi/labelStyles';
6
+ import { focusRingVar, matrixCarouselDotVar, matrixRadioDotStyle, matrixRadioRingStyle } from '@/lib/surveyUi/selectionStyles';
6
7
 
7
8
  const MATRIX_ROW_LABEL_WIDTH = 'var(--cfm-matrix-row-width, 180px)';
9
+ const BIPOLAR_COL_WIDTH = 'var(--cfm-matrix-bipolar-width, 25%)';
8
10
 
9
11
  type LikertMatrixScaleProps = {
10
12
  question: CfmMatrixQuestion;
@@ -114,8 +116,8 @@ function LikertMatrixCarouselLayout({
114
116
  <div style={{
115
117
  display: 'flex', alignItems: 'center', justifyContent: 'center',
116
118
  width: '18px', height: '18px', borderRadius: '4px',
117
- border: selected ? '2px solid #e20074' : '2px solid #d1d5db',
118
- backgroundColor: selected ? '#e20074' : '#fff',
119
+ border: selected ? `2px solid ${focusRingVar}` : '2px solid #d1d5db',
120
+ backgroundColor: selected ? focusRingVar : '#fff',
119
121
  flexShrink: 0, transition: 'all 0.15s'
120
122
  }}>
121
123
  {selected && (
@@ -125,13 +127,8 @@ function LikertMatrixCarouselLayout({
125
127
  )}
126
128
  </div>
127
129
  ) : (
128
- <div style={{
129
- display: 'flex', width: '18px', height: '18px', flexShrink: 0,
130
- alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
131
- border: selected ? '2px solid #e20074' : '1.5px solid #9ca3af',
132
- backgroundColor: '#fff',
133
- }}>
134
- {selected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
130
+ <div style={matrixRadioRingStyle(selected, '18px')}>
131
+ <div style={matrixRadioDotStyle(selected, '8px')} />
135
132
  </div>
136
133
  )}
137
134
  <span style={{ fontSize: '15px', fontWeight: 400, color: '#374151', userSelect: 'none' }} dangerouslySetInnerHTML={{ __html: col.label }} />
@@ -154,7 +151,7 @@ function LikertMatrixCarouselLayout({
154
151
  {statementRows.map((_, i) => (
155
152
  <div key={i} style={{
156
153
  width: '10px', height: '10px', borderRadius: '50%',
157
- backgroundColor: i === activeCarouselIndex ? '#2563eb' : '#e5e7eb',
154
+ backgroundColor: i === activeCarouselIndex ? matrixCarouselDotVar : '#e5e7eb',
158
155
  cursor: 'pointer', transition: 'background-color 0.2s'
159
156
  }} onClick={() => setActiveCarouselIndex(i)} />
160
157
  ))}
@@ -209,7 +206,7 @@ function LikertMatrixGridLayout({
209
206
  <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '16px' }}>
210
207
  {hasLabels && (
211
208
  <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px', marginBottom: '8px' }}>
212
- <div style={{ flex: '0 0 25%', paddingRight: '16px', boxSizing: 'border-box' }} />
209
+ <div style={{ flex: `0 0 ${BIPOLAR_COL_WIDTH}`, paddingRight: '16px', boxSizing: 'border-box' }} />
213
210
  <div style={{ flex: 1, position: 'relative', height: '20px' }}>
214
211
  {labels.map((lbl, idx) => {
215
212
  let leftPos = '50%';
@@ -227,12 +224,12 @@ function LikertMatrixGridLayout({
227
224
  );
228
225
  })}
229
226
  </div>
230
- <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />
227
+ <div style={{ flex: `0 0 ${BIPOLAR_COL_WIDTH}`, paddingLeft: '16px', boxSizing: 'border-box' }} />
231
228
  </div>
232
229
  )}
233
230
  {showColumnHeaders && (
234
231
  <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px' }}>
235
- <div style={{ flex: isBipolar ? '0 0 25%' : `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px', boxSizing: 'border-box' }} />
232
+ <div style={{ flex: isBipolar ? `0 0 ${BIPOLAR_COL_WIDTH}` : `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px', boxSizing: 'border-box' }} />
236
233
  <div style={{ flex: 1, display: 'flex' }}>
237
234
  <div style={{ flex: 1, display: 'grid', gridTemplateColumns: `repeat(${gridCols.length}, 1fr)` }}>
238
235
  {gridCols.map((col) => (
@@ -243,7 +240,7 @@ function LikertMatrixGridLayout({
243
240
  ))}
244
241
  </div>
245
242
  </div>
246
- {isBipolar && <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />}
243
+ {isBipolar && <div style={{ flex: `0 0 ${BIPOLAR_COL_WIDTH}`, paddingLeft: '16px', boxSizing: 'border-box' }} />}
247
244
  </div>
248
245
  )}
249
246
  </div>
@@ -261,10 +258,10 @@ function LikertMatrixGridLayout({
261
258
  {repeatColumnHeaders && renderHeader()}
262
259
  <div style={{
263
260
  display: 'flex', alignItems: 'center', borderRadius: '8px',
264
- padding: '12px 0', backgroundColor: !repeatColumnHeaders && rowIdx % 2 === 0 ? 'rgba(249, 250, 251, 0.8)' : '#fff',
261
+ padding: 'var(--cfm-matrix-cell-padding, 12px) 0', backgroundColor: !repeatColumnHeaders && rowIdx % 2 === 0 ? 'var(--cfm-zebra-row, rgba(249, 250, 251, 0.8))' : '#fff',
265
262
  boxSizing: 'border-box'
266
263
  }}>
267
- <div style={{ flex: isBipolar ? '0 0 25%' : `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, padding: '0 16px', boxSizing: 'border-box' }}>
264
+ <div style={{ flex: isBipolar ? `0 0 ${BIPOLAR_COL_WIDTH}` : `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, padding: '0 16px', boxSizing: 'border-box' }}>
268
265
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
269
266
  dangerouslySetInnerHTML={{ __html: transposeTable ? ('label' in rowItem ? rowItem.label : '') : ('statementText' in rowItem ? rowItem.statementText : '') }} />
270
267
  </div>
@@ -282,31 +279,44 @@ function LikertMatrixGridLayout({
282
279
 
283
280
  return (
284
281
  <div key={colItem.id} style={{ display: 'flex', justifyContent: 'center' }}>
285
- <label style={{
286
- cursor: isDisabled ? 'not-allowed' : 'pointer',
282
+ <label style={{
283
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
287
284
  opacity: isDisabled ? 0.4 : 1,
288
- padding: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center'
285
+ padding: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center',
289
286
  }}>
290
- <input
291
- type={isMultiple ? "checkbox" : "radio"}
287
+ <input
288
+ type="checkbox"
289
+ className="sr-only"
292
290
  checked={selected}
293
291
  disabled={isDisabled}
294
- onClick={(e) => {
295
- if (!isMultiple && selected) {
296
- onCellSelect(actualRowId, undefined);
297
- }
298
- }}
299
- onChange={(e) => {
300
- if (isMultiple || !selected) {
292
+ onChange={() => {
293
+ if (isDisabled) return;
294
+ if (isMultiple) {
295
+ onCellSelect(actualRowId, selected ? undefined : actualColValue);
296
+ } else if (!selected) {
301
297
  onCellSelect(actualRowId, actualColValue);
298
+ } else {
299
+ onCellSelect(actualRowId, undefined);
302
300
  }
303
301
  }}
304
- style={{
305
- width: '18px', height: '18px', accentColor: 'var(--cfm-input-focus-ring)',
306
- cursor: isDisabled ? 'not-allowed' : 'pointer',
307
- ...(isMultiple ? { borderRadius: '4px' } : {})
308
- }}
309
302
  />
303
+ {isMultiple ? (
304
+ <div style={{
305
+ display: 'flex', width: '18px', height: '18px', alignItems: 'center', justifyContent: 'center',
306
+ borderRadius: '4px', border: selected ? `2px solid ${focusRingVar}` : '2px solid #d1d5db',
307
+ backgroundColor: selected ? focusRingVar : '#fff',
308
+ }}>
309
+ {selected && (
310
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3">
311
+ <polyline points="20 6 9 17 4 12" />
312
+ </svg>
313
+ )}
314
+ </div>
315
+ ) : (
316
+ <div style={matrixRadioRingStyle(selected, '18px')}>
317
+ <div style={matrixRadioDotStyle(selected, '8px')} />
318
+ </div>
319
+ )}
310
320
  </label>
311
321
  </div>
312
322
  );
@@ -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;
@@ -205,14 +212,8 @@ export default function Question({
205
212
  return (
206
213
  <label
207
214
  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
- }}
215
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
216
+ style={mcqOptionCardStyle(isSelected)}
216
217
  >
217
218
  <input
218
219
  type="checkbox"
@@ -221,6 +222,13 @@ export default function Question({
221
222
  onChange={() => handleToggle(option.id)}
222
223
  className="sr-only"
223
224
  />
225
+ <div style={mcqCheckboxControlStyle(isSelected)}>
226
+ {isSelected && (
227
+ <svg className="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none" aria-hidden="true">
228
+ <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
229
+ </svg>
230
+ )}
231
+ </div>
224
232
  <span
225
233
  className="text-[15px] font-medium text-gray-900 leading-tight"
226
234
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -241,14 +249,8 @@ export default function Question({
241
249
  return (
242
250
  <label
243
251
  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
- }}
252
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
253
+ style={mcqOptionCardStyle(isSelected)}
252
254
  >
253
255
  <input
254
256
  type="radio"
@@ -257,6 +259,9 @@ export default function Question({
257
259
  onChange={() => onSelect(option.id)}
258
260
  className="sr-only"
259
261
  />
262
+ <div style={mcqRadioOuterStyle(isSelected)}>
263
+ {isSelected && <div style={mcqRadioInnerStyle()} />}
264
+ </div>
260
265
  <span
261
266
  className="text-[15px] font-medium text-gray-900 leading-tight"
262
267
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -271,31 +276,48 @@ export default function Question({
271
276
  {question.type === QUESTION_TYPE.RANK_ORDER && rankOrderQuestion && (
272
277
  <RankOrderScale
273
278
  question={rankOrderQuestion}
274
- selectedValue={
275
- typeof selectedValue === 'object' && selectedValue !== null && !Array.isArray(selectedValue)
276
- ? selectedValue
277
- : undefined
278
- }
279
+ selectedValue={normalizeRankOrderAnswers(selectedValue)}
279
280
  onSelect={onSelect}
280
281
  />
281
282
  )}
282
283
 
283
284
  {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
- />
285
+ <div className="space-y-1.5">
286
+ <textarea
287
+ data-cfm-textfield
288
+ rows={question.inputVariant === 'long' ? 4 : 1}
289
+ value={(selectedValue as string) ?? ''}
290
+ onChange={e => onSelect(e.target.value)}
291
+ placeholder={question.placeholder || 'Type your response here...'}
292
+ className="w-full text-sm outline-none transition-colors"
293
+ style={{
294
+ height: question.inputVariant === 'long' ? undefined : 'var(--cfm-input-height, 44px)',
295
+ borderRadius: 'var(--cfm-input-radius, 4px)',
296
+ border: '1px solid var(--cfm-input-border, #d1d5db)',
297
+ padding: 'var(--cfm-input-padding, 16px)',
298
+ color: 'var(--cfm-text)',
299
+ }}
300
+ onFocus={e => {
301
+ e.currentTarget.style.borderColor = focusRingVar;
302
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
303
+ }}
304
+ onBlur={e => {
305
+ e.currentTarget.style.borderColor = 'var(--cfm-input-border, #d1d5db)';
306
+ e.currentTarget.style.boxShadow = 'none';
307
+ }}
308
+ />
309
+ <div
310
+ className="text-right text-xs pr-1"
311
+ style={{ color: 'var(--cfm-input-placeholder, #6b7280)' }}
312
+ >
313
+ {Math.max(
314
+ 0,
315
+ (question.maxCharacterCount ?? 1000) -
316
+ (typeof selectedValue === 'string' ? selectedValue.length : 0)
317
+ )}{' '}
318
+ characters remaining
319
+ </div>
320
+ </div>
299
321
  )}
300
322
 
301
323
  {question.type === QUESTION_TYPE.CFM_MATRIX && (
@@ -336,21 +358,64 @@ export default function Question({
336
358
  )}
337
359
 
338
360
  {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%' }}>
361
+ <div
362
+ className={`mt-4 flex w-full ${
363
+ question.media.alignment === 'TOP_CENTER' || question.media.alignment === 'BOTTOM_CENTER'
364
+ ? 'justify-center'
365
+ : question.media.alignment === 'TOP_RIGHT' || question.media.alignment === 'BOTTOM_RIGHT'
366
+ ? 'justify-end'
367
+ : 'justify-start'
368
+ }`}
369
+ >
370
+ <div
371
+ style={{
372
+ width: question.media.size
373
+ ? `${question.media.size}%`
374
+ : 'var(--cfm-media-max-width, 100%)',
375
+ maxWidth: 'var(--cfm-media-max-width, 100%)',
376
+ }}
377
+ className="flex flex-col gap-2"
378
+ >
341
379
  {question.media.mimeType?.startsWith('video/') || question.media.mimeType === 'VIDEO' ? (
342
- <video controls src={question.media.url} className="max-w-full rounded-md" />
380
+ <video
381
+ controls
382
+ src={question.media.url}
383
+ className="max-w-full shadow-sm"
384
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
385
+ />
343
386
  ) : (
344
- <img src={question.media.url} alt={question.media.title || ''} className="max-w-full rounded-md" />
387
+ <img
388
+ src={question.media.url}
389
+ alt={question.media.title || ''}
390
+ className="max-w-full shadow-sm"
391
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
392
+ />
393
+ )}
394
+ {question.media.title && (
395
+ <p
396
+ className="text-center"
397
+ style={{
398
+ color: 'var(--cfm-media-caption-color, #6b7280)',
399
+ fontSize: 'var(--cfm-media-caption-size, 14px)',
400
+ }}
401
+ >
402
+ {question.media.title}
403
+ </p>
345
404
  )}
346
- {question.media.title && <p className="text-sm text-gray-500 text-center mt-2">{question.media.title}</p>}
347
405
  </div>
348
406
  </div>
349
407
  )}
350
408
 
351
409
  {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>
410
+ <div
411
+ className="mt-4 rounded py-3.5 px-4 text-left"
412
+ style={{
413
+ border: '1px solid var(--cfm-validation-border, #333333)',
414
+ backgroundColor: 'var(--cfm-validation-bg, #d9d9d9)',
415
+ borderRadius: 'var(--cfm-input-radius, 4px)',
416
+ }}
417
+ >
418
+ <p className="text-[13px] sm:text-sm font-bold text-gray-900 leading-tight">{validationError}</p>
354
419
  </div>
355
420
  )}
356
421
  </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,22 @@ 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 hover:bg-gray-50/50"
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
+ }}
125
130
  >
126
131
  {rankSelect}
127
132
  {dragHandle}
128
133
  {rankBadge !== undefined && (
129
134
  <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))' }}
135
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
136
+ style={{
137
+ backgroundColor: 'var(--cfm-rank-badge-bg, var(--cfm-mcq-selected-bg, #fdf2f8))',
138
+ color: 'var(--cfm-rank-badge-text, var(--cfm-mcq-selected-accent, var(--cfm-primary, #e20074)))',
139
+ }}
132
140
  >
133
141
  {rankBadge}
134
142
  </span>
@@ -169,6 +177,14 @@ function DropdownRankLayout({
169
177
  onChange={event => handleRankChange(option.id, event.target.value)}
170
178
  className="h-10 w-16 shrink-0 rounded border bg-white px-2 text-sm outline-none"
171
179
  style={{ borderColor: 'var(--cfm-rank-select-border, #d1d5db)' }}
180
+ onFocus={e => {
181
+ e.currentTarget.style.borderColor = focusRingVar;
182
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
183
+ }}
184
+ onBlur={e => {
185
+ e.currentTarget.style.borderColor = 'var(--cfm-rank-select-border, #d1d5db)';
186
+ e.currentTarget.style.boxShadow = 'none';
187
+ }}
172
188
  >
173
189
  <option value="">-</option>
174
190
  {rankChoices.map(rank => (
@@ -225,7 +241,8 @@ function SortableRankItem({
225
241
  <button
226
242
  type="button"
227
243
  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"
244
+ className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded active:cursor-grabbing"
245
+ style={{ color: 'var(--cfm-rank-handle, #9ca3af)' }}
229
246
  {...attributes}
230
247
  {...listeners}
231
248
  >