@explorer02/cfm-survey-sdk 0.3.1 → 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 (31) 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 +18 -24
  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 +43 -33
  10. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  11. package/templates/docs/templates/Question.tsx +109 -44
  12. package/templates/docs/templates/RankOrderScale.tsx +22 -5
  13. package/templates/docs/templates/RatingScale.tsx +5 -3
  14. package/templates/docs/templates/SliderMatrixScale.tsx +44 -16
  15. package/templates/docs/templates/selectionStyles.ts +88 -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 +7 -0
  22. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +59 -30
  23. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +36 -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-DbbLpHdF.js} +1 -1
  27. package/templates/wizard-dist/assets/TypePanel-DQbV2iCf.js +1 -0
  28. package/templates/wizard-dist/assets/index-CY7WMJ93.js +34 -0
  29. package/templates/wizard-dist/index.html +1 -1
  30. package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
  31. package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -12,6 +12,8 @@ import {
12
12
  emojiSizeStyle,
13
13
  cellSelectedVar,
14
14
  focusRingVar,
15
+ mcqSelectedAccentVar,
16
+ mcqSelectedBgVar,
15
17
  } from '@/lib/surveyUi/selectionStyles';
16
18
  import { csatColumnLabelStyle } from '@/lib/surveyUi/labelStyles';
17
19
 
@@ -106,10 +108,10 @@ function CsatMatrixVerticalList({
106
108
  onClick={() => onCellSelect(row.id, submitValue)}
107
109
  style={{
108
110
  width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
109
- borderRadius: '8px', border: `1px solid ${isSelected ? '#e20074' : '#d1d5db'}`,
111
+ borderRadius: '8px', border: `1px solid ${isSelected ? mcqSelectedAccentVar : '#d1d5db'}`,
110
112
  padding: '14px 18px', cursor: 'pointer', transition: 'all 0.15s',
111
- backgroundColor: isSelected ? '#fdf2f8' : '#fff',
112
- color: isSelected ? '#e20074' : '#374151',
113
+ backgroundColor: isSelected ? mcqSelectedBgVar : '#fff',
114
+ color: isSelected ? mcqSelectedAccentVar : '#374151',
113
115
  fontWeight: isSelected ? 600 : 400, fontSize: '15px', textAlign: 'left',
114
116
  }}
115
117
  >
@@ -128,9 +130,9 @@ function CsatMatrixVerticalList({
128
130
  key={col.id}
129
131
  style={{
130
132
  display: 'flex', alignItems: 'center', gap: '16px',
131
- borderRadius: '8px', border: `1px solid ${isSelected ? '#e20074' : '#e5e5e5'}`,
133
+ borderRadius: '8px', border: `1px solid ${isSelected ? mcqSelectedAccentVar : '#e5e5e5'}`,
132
134
  padding: '16px 20px', cursor: 'pointer', transition: 'all 0.15s',
133
- backgroundColor: isSelected ? '#fdf2f8' : '#fff',
135
+ backgroundColor: isSelected ? mcqSelectedBgVar : '#fff',
134
136
  }}
135
137
  >
136
138
  <input type="radio" name={`${question.id}-${row.id}`}
@@ -138,13 +140,8 @@ function CsatMatrixVerticalList({
138
140
  onChange={() => onCellSelect(row.id, submitValue)}
139
141
  style={{ position: 'absolute', width: 0, height: 0, opacity: 0 }}
140
142
  />
141
- <div style={{
142
- display: 'flex', width: '20px', height: '20px', flexShrink: 0,
143
- alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
144
- border: isSelected ? '2px solid #e20074' : '1.5px solid #9ca3af',
145
- backgroundColor: '#fff',
146
- }}>
147
- {isSelected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
143
+ <div style={matrixRadioRingStyle(isSelected, '20px')}>
144
+ <div style={matrixRadioDotStyle(isSelected, '10px')} />
148
145
  </div>
149
146
  <span style={{ fontSize: '15px', fontWeight: 500, color: '#111827' }}
150
147
  dangerouslySetInnerHTML={{ __html: col.label }} />
@@ -158,7 +155,7 @@ function CsatMatrixVerticalList({
158
155
  <label style={{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer', userSelect: 'none' }}>
159
156
  <input type="checkbox" checked={selectedValue[row.id] === null}
160
157
  onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
161
- style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
158
+ style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
162
159
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>Not Applicable</span>
163
160
  </label>
164
161
  )}
@@ -387,7 +384,7 @@ function CsatMatrixGrid({
387
384
  <label style={{ cursor: 'pointer' }}>
388
385
  <input type="checkbox" checked={selectedValue[row.id] === null}
389
386
  onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
390
- style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
387
+ style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
391
388
  </label>
392
389
  </div>
393
390
  )}
@@ -490,13 +487,8 @@ function CsatMatrixCarousel({
490
487
  const EmojiNode = isEmoji ? getEmojiForIndex(displayIdx, scaleColumns.length) : null;
491
488
  const StarNode = isStar ? (isSelected ? (CsatStarIcons.filled as any) : (CsatStarIcons.empty as any)) : null;
492
489
  const RadioNode = (
493
- <div style={{
494
- display: 'flex', width: '18px', height: '18px',
495
- alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
496
- margin: '0 auto', border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
497
- backgroundColor: '#fff',
498
- }}>
499
- {isSelected && <div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
490
+ <div style={matrixRadioRingStyle(isSelected, '18px')}>
491
+ <div style={matrixRadioDotStyle(isSelected, '8px')} />
500
492
  </div>
501
493
  );
502
494
 
@@ -518,7 +510,9 @@ function CsatMatrixCarousel({
518
510
  padding: '4px', transform: isHovered ? 'scale(1.1)' : 'scale(1)', opacity: isSelected ? 1 : isHovered ? 1 : 0.5,
519
511
  } : isNumbered ? {
520
512
  width: '40px', height: '40px', borderRadius: '8px', fontSize: '14px', fontWeight: 500,
521
- border: `1px solid ${isSelected ? '#e20074' : '#d1d5db'}`, backgroundColor: isSelected ? '#e20074' : '#fff', color: isSelected ? '#fff' : '#111827',
513
+ ...(isSelected
514
+ ? scaleCellSelectedStyle(true)
515
+ : { border: '1px solid #d1d5db', backgroundColor: '#fff', color: '#111827' }),
522
516
  } : { padding: '8px' }),
523
517
  }}
524
518
  >
@@ -537,7 +531,7 @@ function CsatMatrixCarousel({
537
531
  <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
538
532
  <input type="checkbox" checked={selectedValue[row.id] === null}
539
533
  onChange={() => onCellSelect(row.id, selectedValue[row.id] === null ? undefined : null)}
540
- style={{ width: '16px', height: '16px', accentColor: '#e20074', cursor: 'pointer' }} />
534
+ style={{ width: '16px', height: '16px', accentColor: focusRingVar, cursor: 'pointer' }} />
541
535
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#4b5563' }}>N/A</span>
542
536
  </label>
543
537
  </div>
@@ -559,7 +553,7 @@ function CsatMatrixCarousel({
559
553
  <button key={i} type="button" onClick={() => setActiveCarouselIndex(i)}
560
554
  style={{
561
555
  width: i === activeCarouselIndex ? '20px' : '8px', height: '8px', borderRadius: '4px',
562
- backgroundColor: i === activeCarouselIndex ? '#e20074' : '#d1d5db', border: 'none', padding: 0,
556
+ backgroundColor: i === activeCarouselIndex ? mcqSelectedAccentVar : '#d1d5db', border: 'none', padding: 0,
563
557
  cursor: 'pointer', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
564
558
  }} />
565
559
  ))}
@@ -50,7 +50,9 @@ export function CustomSliderTrack({
50
50
  ? tooltipLabel.replace(/<[^>]*>/g, '')
51
51
  : String(Math.round(value));
52
52
 
53
- const themeColor = disabled ? '#9ca3af' : 'var(--cfm-input-focus-ring, var(--cfm-primary))';
53
+ const themeColor = disabled
54
+ ? '#9ca3af'
55
+ : 'var(--cfm-slider-thumb, var(--cfm-input-focus-ring, var(--cfm-primary)))';
54
56
 
55
57
  return (
56
58
  <div
@@ -127,7 +129,7 @@ export function CustomSliderTrack({
127
129
  {(isHovered || isDragging) && !disabled && (
128
130
  <div style={{
129
131
  position: 'absolute', bottom: '100%', left: `${percentage}%`, transform: 'translate(-50%, -10px)',
130
- backgroundColor: '#1f2937', color: '#fff', fontSize: '13px', fontWeight: 600,
132
+ backgroundColor: 'var(--cfm-tooltip-bg, #1f2937)', color: '#fff', fontSize: '13px', fontWeight: 600,
131
133
  padding: '4px 10px', borderRadius: '6px', pointerEvents: 'none',
132
134
  boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', zIndex: 20,
133
135
  }}>
@@ -135,7 +137,7 @@ export function CustomSliderTrack({
135
137
  <div style={{
136
138
  position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
137
139
  borderWidth: '5px', borderStyle: 'solid',
138
- borderColor: '#1f2937 transparent transparent transparent',
140
+ borderColor: 'var(--cfm-tooltip-bg, #1f2937) transparent transparent transparent',
139
141
  }} />
140
142
  </div>
141
143
  )}
@@ -8,6 +8,7 @@
8
8
 
9
9
  import React, { useState, useRef } from 'react';
10
10
  import type { FileUploadQuestion } from '@explorer02/cfm-survey-sdk';
11
+ import { focusRingVar, mcqSelectedBgVar } from '@/lib/surveyUi/selectionStyles';
11
12
 
12
13
  type UploadedFileAnswer = {
13
14
  id: string;
@@ -86,8 +87,20 @@ function FileUploadDropzone({
86
87
  onDrag: (e: React.DragEvent) => void;
87
88
  onDrop: (e: React.DragEvent) => void;
88
89
  }) {
90
+ const baseBorder = 'var(--cfm-upload-border-color, #d1d5db)';
91
+ const activeBorder = focusRingVar;
92
+ const baseBg = 'var(--cfm-upload-fill, #f9fafb)';
93
+ const activeBg = mcqSelectedBgVar;
94
+ const borderStyle =
95
+ typeof document !== 'undefined'
96
+ ? getComputedStyle(document.documentElement).getPropertyValue('--cfm-upload-border-style').trim() ||
97
+ 'dashed'
98
+ : 'dashed';
99
+
89
100
  return (
90
101
  <div
102
+ data-cfm-dropzone
103
+ className="cfm-dropzone"
91
104
  onDragEnter={onDrag}
92
105
  onDragOver={onDrag}
93
106
  onDragLeave={onDrag}
@@ -98,17 +111,45 @@ function FileUploadDropzone({
98
111
  flexDirection: 'column',
99
112
  alignItems: 'center',
100
113
  justifyContent: 'center',
101
- border: `2px ${isDragActive ? 'solid' : 'var(--cfm-upload-border-style, dashed)'} var(--cfm-upload-border-color, #d1d5db)`,
114
+ border: `2px ${borderStyle} ${isDragActive ? activeBorder : baseBorder}`,
102
115
  borderRadius: 'var(--cfm-border-radius, 12px)',
103
- backgroundColor: isDragActive ? 'var(--cfm-mcq-selected-bg, #fdf2f8)' : 'var(--cfm-upload-fill, #f9fafb)',
116
+ backgroundColor: isDragActive ? activeBg : baseBg,
104
117
  padding: 'var(--cfm-upload-padding, 40px 24px)',
105
118
  cursor: disabled ? 'not-allowed' : 'pointer',
106
119
  opacity: disabled ? 0.6 : 1,
107
120
  textAlign: 'center',
108
121
  boxSizing: 'border-box',
122
+ transition: 'all 0.2s ease-in-out',
123
+ }}
124
+ onMouseEnter={e => {
125
+ if (disabled) return;
126
+ e.currentTarget.style.borderColor = activeBorder;
127
+ e.currentTarget.style.backgroundColor = activeBg;
128
+ }}
129
+ onMouseLeave={e => {
130
+ if (disabled || isDragActive) return;
131
+ e.currentTarget.style.borderColor = baseBorder;
132
+ e.currentTarget.style.backgroundColor = baseBg;
109
133
  }}
110
134
  >
111
- <div style={{ fontSize: '16px', fontWeight: 600, color: '#374151', marginBottom: '8px' }}
135
+ <div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
136
+ <svg
137
+ width="40"
138
+ height="40"
139
+ viewBox="0 0 24 24"
140
+ fill="none"
141
+ stroke="var(--cfm-upload-icon-color, #9ca3af)"
142
+ strokeWidth="2"
143
+ strokeLinecap="round"
144
+ strokeLinejoin="round"
145
+ >
146
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
147
+ <polyline points="17 8 12 3 7 8" />
148
+ <line x1="12" y1="3" x2="12" y2="15" />
149
+ </svg>
150
+ </div>
151
+ <div
152
+ style={{ fontSize: '16px', fontWeight: 600, color: '#374151', marginBottom: '8px' }}
112
153
  dangerouslySetInnerHTML={{ __html: uploadMessage }}
113
154
  />
114
155
  <div style={{ fontSize: '13px', color: '#6b7280', lineHeight: 1.5 }}>
@@ -120,6 +161,104 @@ function FileUploadDropzone({
120
161
  );
121
162
  }
122
163
 
164
+ function formatFileSize(bytes: number): string {
165
+ if (bytes === 0) return '0 Bytes';
166
+ const k = 1024;
167
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
168
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
169
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
170
+ }
171
+
172
+ function UploadedFileList({
173
+ files,
174
+ onRemoveFile,
175
+ }: {
176
+ files: UploadedFileAnswer[];
177
+ onRemoveFile: (index: number) => void;
178
+ }) {
179
+ return (
180
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
181
+ {files.map((file, idx) => (
182
+ <div
183
+ key={file.id}
184
+ style={{
185
+ display: 'flex',
186
+ alignItems: 'center',
187
+ justifyContent: 'space-between',
188
+ padding: '12px 16px',
189
+ backgroundColor: 'var(--cfm-card-bg, #fff)',
190
+ border: '1px solid var(--cfm-card-border-color, #e5e7eb)',
191
+ borderRadius: 'var(--cfm-border-radius, 8px)',
192
+ boxSizing: 'border-box',
193
+ }}
194
+ >
195
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', overflow: 'hidden' }}>
196
+ <svg
197
+ width="20"
198
+ height="20"
199
+ viewBox="0 0 24 24"
200
+ fill="none"
201
+ stroke="#6b7280"
202
+ strokeWidth="2"
203
+ strokeLinecap="round"
204
+ strokeLinejoin="round"
205
+ >
206
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
207
+ <polyline points="14 2 14 8 20 8" />
208
+ </svg>
209
+ <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
210
+ <span
211
+ style={{
212
+ fontSize: '14px',
213
+ fontWeight: 500,
214
+ color: '#374151',
215
+ textOverflow: 'ellipsis',
216
+ overflow: 'hidden',
217
+ whiteSpace: 'nowrap',
218
+ }}
219
+ >
220
+ {file.name ?? file.mediaUrl}
221
+ </span>
222
+ {file.size != null && (
223
+ <span style={{ fontSize: '12px', color: '#9ca3af' }}>{formatFileSize(file.size)}</span>
224
+ )}
225
+ </div>
226
+ </div>
227
+ <button
228
+ type="button"
229
+ onClick={() => onRemoveFile(idx)}
230
+ style={{
231
+ display: 'flex',
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ width: '24px',
235
+ height: '24px',
236
+ borderRadius: '50%',
237
+ backgroundColor: '#f3f4f6',
238
+ border: 'none',
239
+ color: '#6b7280',
240
+ cursor: 'pointer',
241
+ fontSize: '14px',
242
+ fontWeight: 'bold',
243
+ transition: 'background-color 0.2s',
244
+ }}
245
+ onMouseEnter={e => {
246
+ e.currentTarget.style.backgroundColor = '#fee2e2';
247
+ e.currentTarget.style.color = '#ef4444';
248
+ }}
249
+ onMouseLeave={e => {
250
+ e.currentTarget.style.backgroundColor = '#f3f4f6';
251
+ e.currentTarget.style.color = '#6b7280';
252
+ }}
253
+ >
254
+ &times;
255
+ </button>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ );
260
+ }
261
+
123
262
  export function FileUploadScale({ question, selectedValue, onSelect }: FileUploadScaleProps) {
124
263
  const {
125
264
  uploadMessage = 'Drag attachments here or browse',
@@ -241,21 +380,7 @@ export function FileUploadScale({ question, selectedValue, onSelect }: FileUploa
241
380
  )}
242
381
 
243
382
  {currentFiles.length > 0 && (
244
- <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
245
- {currentFiles.map((file, idx) => (
246
- <div key={file.id} style={{
247
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
248
- padding: '12px 16px', backgroundColor: '#fff', border: '1px solid #e5e7eb', borderRadius: '8px',
249
- }}>
250
- <span style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>{file.name ?? file.mediaUrl}</span>
251
- <button type="button" onClick={() => removeFile(idx)} style={{
252
- border: 'none', background: '#f3f4f6', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer',
253
- }}>
254
- ×
255
- </button>
256
- </div>
257
- ))}
258
- </div>
383
+ <UploadedFileList files={currentFiles} onRemoveFile={removeFile} />
259
384
  )}
260
385
  </div>
261
386
  );
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Portable HeatmapScale — copy to src/components/HeatmapScale.tsx
5
- * Also copy templates/heatmapCoords.ts to src/lib/heatmapCoords.ts
5
+ * Also copy templates/heatmapCoords.ts to src/lib/surveyUi/heatmapCoords.ts
6
6
  */
7
7
 
8
8
  import React, { useState } from 'react';
@@ -11,7 +11,7 @@ import {
11
11
  createNewSpot,
12
12
  findSpotNearClick,
13
13
  getNormalizedClickFromImage,
14
- } from './heatmapCoords';
14
+ } from '@/lib/surveyUi/heatmapCoords';
15
15
 
16
16
  type HeatmapScaleProps = {
17
17
  question: HeatmapQuestion;
@@ -91,6 +91,15 @@ export function HeatmapScale({ question, selectedValue = [], onSelect }: Heatmap
91
91
  className="block max-w-full h-auto select-none rounded-md"
92
92
  style={{ cursor: isImageReady ? 'crosshair' : 'default' }}
93
93
  />
94
+ {isImageReady && (
95
+ <div
96
+ className="pointer-events-none absolute inset-0 rounded-md"
97
+ style={{
98
+ backgroundColor: '#000000',
99
+ opacity: 'var(--cfm-heatmap-overlay-opacity, 0)',
100
+ }}
101
+ />
102
+ )}
94
103
  {isImageReady && (
95
104
  <div className="pointer-events-none absolute inset-0">
96
105
  {spots.map(spot => (
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type { SurveyLanguage } from '@explorer02/cfm-survey-sdk';
4
+ import { focusRingVar } from '@/lib/surveyUi/selectionStyles';
4
5
 
5
6
  type LanguageSelectorProps = {
6
7
  languages: SurveyLanguage[];
@@ -14,27 +15,49 @@ export default function LanguageSelector({
14
15
  selectedLanguage,
15
16
  onChange,
16
17
  }: LanguageSelectorProps) {
17
- if (languages.length <= 1) return null;
18
+ if (!languages || languages.length <= 1) return null;
18
19
 
19
20
  return (
20
- <div data-cfm-language className="mb-6 flex justify-end">
21
- <select
22
- value={selectedLanguage}
23
- onChange={(e) => onChange(e.target.value)}
24
- className="rounded border px-3 py-1.5 text-sm outline-none"
25
- style={{
26
- background: 'var(--cfm-language-bg, #fff)',
27
- color: 'var(--cfm-language-text, #2563eb)',
28
- borderColor: 'var(--cfm-language-border, #2563eb)',
29
- borderRadius: 'var(--cfm-border-radius, 8px)',
30
- }}
31
- >
32
- {languages.map((lang) => (
33
- <option key={lang.code} value={lang.code}>
34
- {lang.label}
35
- </option>
36
- ))}
37
- </select>
21
+ <div data-cfm-language className="mb-6 mt-4 flex justify-end">
22
+ <div className="relative">
23
+ <select
24
+ value={selectedLanguage}
25
+ onChange={(e) => onChange(e.target.value)}
26
+ className="cursor-pointer appearance-none py-2 pl-4 pr-10 text-sm font-semibold outline-none focus:ring-2"
27
+ style={{
28
+ background: 'var(--cfm-language-bg, #fff)',
29
+ color: 'var(--cfm-language-text, #2563eb)',
30
+ borderColor: 'var(--cfm-language-border, #2563eb)',
31
+ borderWidth: 1,
32
+ borderStyle: 'solid',
33
+ borderRadius: 'var(--cfm-border-radius, 8px)',
34
+ boxShadow: `0 0 0 0px ${focusRingVar}`,
35
+ }}
36
+ onFocus={e => {
37
+ e.currentTarget.style.boxShadow = `0 0 0 2px ${focusRingVar}`;
38
+ }}
39
+ onBlur={e => {
40
+ e.currentTarget.style.boxShadow = 'none';
41
+ }}
42
+ >
43
+ {languages.map((lang) => (
44
+ <option key={lang.code} value={lang.code} className="text-gray-900">
45
+ {lang.name}
46
+ </option>
47
+ ))}
48
+ </select>
49
+ <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
50
+ <svg
51
+ className="h-4 w-4"
52
+ style={{ color: 'var(--cfm-language-text, #2563eb)' }}
53
+ fill="none"
54
+ stroke="currentColor"
55
+ viewBox="0 0 24 24"
56
+ >
57
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
58
+ </svg>
59
+ </div>
60
+ </div>
38
61
  </div>
39
62
  );
40
63
  }
@@ -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
  );