canny-ui 1.0.0 → 2.0.0

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.
@@ -0,0 +1,511 @@
1
+ @use '../tokens/primitives' as *;
2
+
3
+ // DatePicker — enterprise-compact unified picker.
4
+ // Modes: date · month · year · time · datetime.
5
+ // Panel is portaled to document.body so it never gets clipped by overflow:hidden ancestors.
6
+
7
+ @keyframes cny-dp-pop {
8
+ from { opacity: 0; transform: translateY(-4px) scale(0.985); }
9
+ to { opacity: 1; transform: translateY(0) scale(1); }
10
+ }
11
+
12
+ // ─────────────────────────────────────────────────────────────────
13
+ // Trigger — stays in its original DOM position
14
+ // ─────────────────────────────────────────────────────────────────
15
+
16
+ .cny-datepicker {
17
+ position: relative;
18
+ display: inline-block;
19
+ width: 100%;
20
+
21
+ &.is-disabled { pointer-events: none; }
22
+
23
+ &-trigger {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: $space-2;
27
+ width: 100%;
28
+ text-align: left;
29
+ cursor: pointer;
30
+ padding: 0 $space-3;
31
+
32
+ &:focus,
33
+ &.is-open { border-color: var(--border-focus); }
34
+ &[disabled] { cursor: not-allowed; }
35
+ }
36
+
37
+ &-icon {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ color: var(--text-muted);
42
+ flex: none;
43
+ transition: color 140ms ease;
44
+ }
45
+ &-trigger:hover &-icon,
46
+ &-trigger.is-open &-icon { color: var(--border-focus); }
47
+ &-trigger.danger &-icon { color: var(--color-danger); }
48
+
49
+ &-value {
50
+ flex: 1 1 auto;
51
+ min-width: 0;
52
+ color: var(--text-primary);
53
+ font-size: inherit;
54
+ line-height: 1.4;
55
+ white-space: nowrap;
56
+ overflow: hidden;
57
+ text-overflow: ellipsis;
58
+ font-variant-numeric: tabular-nums;
59
+
60
+ &.is-placeholder { color: var(--text-muted); }
61
+ }
62
+
63
+ &-clear {
64
+ flex: none;
65
+ display: inline-flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ width: 18px;
69
+ height: 18px;
70
+ border-radius: var(--radius-xs);
71
+ color: var(--text-muted);
72
+ cursor: pointer;
73
+ transition: color 120ms ease, background-color 120ms ease;
74
+
75
+ &:hover { color: var(--text-primary); background: var(--bg-surface-low); }
76
+ }
77
+
78
+ &-error {
79
+ display: block;
80
+ margin-top: 6px;
81
+ font-size: 0.75rem;
82
+ color: var(--color-danger);
83
+ line-height: 1.4;
84
+ }
85
+ }
86
+
87
+ // ─────────────────────────────────────────────────────────────────
88
+ // Portaled panel — rendered into document.body
89
+ // Escapes overflow:hidden / transform / contain ancestors.
90
+ // ─────────────────────────────────────────────────────────────────
91
+
92
+ .cny-datepicker-panel {
93
+ position: fixed;
94
+ z-index: 1000;
95
+ padding: 10px;
96
+ background: var(--bg-surface-lowest);
97
+ border: 1px solid var(--border-color);
98
+ border-radius: var(--radius-sm);
99
+ box-shadow:
100
+ 0 1px 0 rgba(15, 23, 42, 0.02),
101
+ 0 6px 18px -6px rgba(15, 23, 42, 0.14),
102
+ 0 14px 32px -16px rgba(15, 23, 42, 0.16);
103
+ font-family: var(--font-body);
104
+ color: var(--text-primary);
105
+ animation: cny-dp-pop 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
106
+
107
+ &.is-date,
108
+ &.is-month,
109
+ &.is-year { width: 264px; }
110
+ &.is-time { width: 208px; }
111
+ &.is-datetime { width: 264px; }
112
+
113
+ max-width: calc(100vw - 16px);
114
+
115
+ @media (prefers-reduced-motion: reduce) { animation: none; }
116
+ }
117
+
118
+ // Panel-internal elements (all portaled together)
119
+
120
+ .cny-datepicker-body {
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 8px;
124
+ }
125
+
126
+ .cny-datepicker-header {
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ gap: $space-1;
131
+ margin-bottom: 2px;
132
+ }
133
+
134
+ .cny-datepicker-title {
135
+ font-weight: 600;
136
+ font-size: 0.8125rem;
137
+ letter-spacing: -0.003em;
138
+ color: var(--text-primary);
139
+ flex: 1 1 auto;
140
+ text-align: center;
141
+ font-variant-numeric: tabular-nums;
142
+
143
+ // Click-to-drill-up button variant
144
+ &.is-clickable {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ gap: 4px;
149
+ height: 24px;
150
+ padding: 0 8px;
151
+ background: transparent;
152
+ border: 0;
153
+ border-radius: var(--radius-xs);
154
+ color: inherit;
155
+ font: inherit;
156
+ letter-spacing: inherit;
157
+ cursor: pointer;
158
+ transition: background-color 120ms ease, color 120ms ease;
159
+
160
+ &::after {
161
+ content: '';
162
+ width: 5px;
163
+ height: 5px;
164
+ border-right: 1.5px solid currentColor;
165
+ border-bottom: 1.5px solid currentColor;
166
+ transform: rotate(45deg) translateY(-1px);
167
+ opacity: 0.5;
168
+ transition: opacity 120ms ease, transform 120ms ease;
169
+ }
170
+
171
+ &:hover {
172
+ background: var(--bg-surface-low);
173
+ color: var(--color-info);
174
+
175
+ &::after { opacity: 1; transform: rotate(45deg) translateY(0); }
176
+ }
177
+
178
+ &:focus-visible {
179
+ outline: 2px solid var(--border-focus);
180
+ outline-offset: 1px;
181
+ }
182
+ }
183
+ }
184
+
185
+ .cny-datepicker-nav {
186
+ flex: none;
187
+ display: inline-flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ width: 24px;
191
+ height: 24px;
192
+ border: 0;
193
+ background: transparent;
194
+ color: var(--text-secondary);
195
+ border-radius: var(--radius-xs);
196
+ cursor: pointer;
197
+ transition: background-color 120ms ease, color 120ms ease;
198
+
199
+ &:hover:not(:disabled) { background: var(--bg-surface-low); color: var(--text-primary); }
200
+ &:active:not(:disabled) { transform: scale(0.92); }
201
+ &:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 1px; }
202
+ &:disabled { opacity: 0.35; cursor: not-allowed; }
203
+ }
204
+
205
+ // ── Calendar grid ────────────────────────────────────────────────
206
+ .cny-datepicker-grid { display: grid; gap: 1px; }
207
+
208
+ .cny-datepicker-weekdays,
209
+ .cny-datepicker-row {
210
+ display: grid;
211
+ grid-template-columns: repeat(7, 1fr);
212
+ gap: 1px;
213
+ }
214
+
215
+ .cny-datepicker-weekday {
216
+ text-align: center;
217
+ font-size: 0.625rem;
218
+ font-weight: 600;
219
+ letter-spacing: 0.04em;
220
+ text-transform: uppercase;
221
+ color: var(--text-muted);
222
+ padding: 4px 0 6px;
223
+ line-height: 1;
224
+ }
225
+
226
+ .cny-datepicker-cell {
227
+ position: relative;
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ height: 30px;
232
+ min-width: 0;
233
+ width: 100%;
234
+ border: 0;
235
+ background: transparent;
236
+ color: var(--text-primary);
237
+ font-size: 0.8125rem;
238
+ font-variant-numeric: tabular-nums;
239
+ border-radius: var(--radius-xs);
240
+ cursor: pointer;
241
+ transition: background-color 100ms ease, color 100ms ease;
242
+
243
+ &:hover:not(:disabled):not(.is-selected) { background: var(--bg-surface-low); }
244
+ &:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -1px; z-index: 1; }
245
+
246
+ &.is-outside { color: var(--text-muted); opacity: 0.5; }
247
+
248
+ &.is-today:not(.is-selected) {
249
+ color: var(--color-info);
250
+ font-weight: 600;
251
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-info) 35%, transparent);
252
+
253
+ &::after {
254
+ content: '';
255
+ position: absolute;
256
+ bottom: 3px;
257
+ left: 50%;
258
+ width: 3px;
259
+ height: 3px;
260
+ border-radius: 50%;
261
+ background: currentColor;
262
+ transform: translateX(-50%);
263
+ }
264
+ }
265
+
266
+ &.is-selected {
267
+ background: var(--color-info);
268
+ color: var(--color-info-on);
269
+ font-weight: 600;
270
+ }
271
+
272
+ &:disabled { color: var(--text-disabled); cursor: not-allowed; opacity: 0.4; }
273
+ }
274
+
275
+ // ── Month / year chip grids ─────────────────────────────────────
276
+ .cny-datepicker-chip-grid {
277
+ display: grid;
278
+ grid-template-columns: repeat(3, 1fr);
279
+ gap: 4px;
280
+ padding: 2px 0;
281
+ }
282
+
283
+ .cny-datepicker-chip {
284
+ display: inline-flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ height: 34px;
288
+ padding: 0 $space-2;
289
+ border: 1px solid transparent;
290
+ background: transparent;
291
+ color: var(--text-primary);
292
+ font-size: 0.8125rem;
293
+ font-variant-numeric: tabular-nums;
294
+ border-radius: var(--radius-xs);
295
+ cursor: pointer;
296
+ transition: background-color 120ms ease, color 120ms ease;
297
+
298
+ &:hover:not(:disabled):not(.is-selected) { background: var(--bg-surface-low); }
299
+ &:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -1px; }
300
+
301
+ &.is-outside { color: var(--text-muted); opacity: 0.55; }
302
+
303
+ // Marks the currently-viewed month/year when drilling up for navigation
304
+ &.is-current {
305
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-info) 40%, transparent);
306
+ color: var(--color-info);
307
+ font-weight: 600;
308
+ }
309
+
310
+ &.is-selected {
311
+ background: var(--color-info);
312
+ color: var(--color-info-on);
313
+ font-weight: 600;
314
+ box-shadow: none;
315
+ }
316
+
317
+ &:disabled { color: var(--text-disabled); cursor: not-allowed; opacity: 0.4; }
318
+ }
319
+
320
+ // ─────────────────────────────────────────────────────────────────
321
+ // Compact time picker — stepper digits + segmented AM/PM
322
+ // ─────────────────────────────────────────────────────────────────
323
+
324
+ .cny-dp-time {
325
+ display: flex;
326
+ flex-direction: column;
327
+ align-items: center;
328
+ gap: 6px;
329
+ padding: 6px 0 2px;
330
+ }
331
+
332
+ .cny-datepicker-panel.is-datetime .cny-dp-time {
333
+ padding-top: 8px;
334
+ border-top: 1px solid var(--hairline-color, var(--border-color));
335
+ }
336
+
337
+ .cny-dp-time-row {
338
+ display: inline-flex;
339
+ align-items: center;
340
+ gap: 4px;
341
+ }
342
+
343
+ .cny-dp-time-sep {
344
+ font-size: 1rem;
345
+ font-weight: 600;
346
+ color: var(--text-muted);
347
+ font-variant-numeric: tabular-nums;
348
+ padding: 0 1px;
349
+ user-select: none;
350
+ line-height: 1;
351
+ transform: translateY(-1px);
352
+ }
353
+
354
+ // Editable time field — input on the left, stacked ▲/▼ on the right.
355
+ .cny-dp-timefield {
356
+ display: inline-flex;
357
+ align-items: stretch;
358
+ height: 30px;
359
+ background: var(--bg-surface-lowest);
360
+ border: 1px solid var(--border-color);
361
+ border-radius: var(--radius-xs);
362
+ overflow: hidden;
363
+ transition: border-color 120ms ease, box-shadow 120ms ease;
364
+
365
+ &:hover { border-color: var(--border-color-strong, var(--border-color)); }
366
+
367
+ &:focus-within {
368
+ border-color: var(--border-focus);
369
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-focus) 18%, transparent);
370
+ }
371
+
372
+ &-input {
373
+ width: 32px;
374
+ height: 100%;
375
+ padding: 0 2px;
376
+ text-align: center;
377
+ font-family: var(--font-body);
378
+ font-size: 0.875rem;
379
+ font-weight: 600;
380
+ font-variant-numeric: tabular-nums;
381
+ color: var(--text-primary);
382
+ background: transparent;
383
+ border: 0;
384
+ outline: none;
385
+ line-height: 1;
386
+
387
+ // Hide spinners if a browser renders type=text as numeric
388
+ -moz-appearance: textfield;
389
+ &::-webkit-outer-spin-button,
390
+ &::-webkit-inner-spin-button {
391
+ -webkit-appearance: none;
392
+ margin: 0;
393
+ }
394
+
395
+ &::selection { background: color-mix(in srgb, var(--color-info) 30%, transparent); }
396
+ }
397
+
398
+ &-controls {
399
+ display: flex;
400
+ flex-direction: column;
401
+ border-left: 1px solid var(--hairline-color, var(--border-color));
402
+ }
403
+
404
+ &-btn {
405
+ flex: 1 1 0;
406
+ display: inline-flex;
407
+ align-items: center;
408
+ justify-content: center;
409
+ width: 18px;
410
+ padding: 0;
411
+ border: 0;
412
+ background: transparent;
413
+ color: var(--text-muted);
414
+ cursor: pointer;
415
+ transition: background-color 100ms ease, color 100ms ease;
416
+
417
+ &:first-child { border-bottom: 1px solid var(--hairline-color, var(--border-color)); }
418
+
419
+ &:hover {
420
+ background: var(--bg-surface-low);
421
+ color: var(--text-primary);
422
+ }
423
+ &:active { background: var(--bg-surface-edge, var(--bg-surface-low)); }
424
+ }
425
+ }
426
+
427
+ .cny-dp-ampm {
428
+ display: inline-flex;
429
+ align-items: center;
430
+ margin-left: 6px;
431
+ padding: 2px;
432
+ background: var(--bg-surface-low);
433
+ border: 1px solid var(--hairline-color, var(--border-color));
434
+ border-radius: var(--radius-xs);
435
+
436
+ &-btn {
437
+ display: inline-flex;
438
+ align-items: center;
439
+ justify-content: center;
440
+ height: 20px;
441
+ min-width: 26px;
442
+ padding: 0 6px;
443
+ font-family: var(--font-body);
444
+ font-size: 0.6875rem;
445
+ font-weight: 600;
446
+ letter-spacing: 0.04em;
447
+ color: var(--text-muted);
448
+ background: transparent;
449
+ border: 0;
450
+ border-radius: 2px;
451
+ cursor: pointer;
452
+ transition: background-color 120ms ease, color 120ms ease;
453
+
454
+ &:hover:not(.is-active) { color: var(--text-primary); }
455
+
456
+ &.is-active {
457
+ background: var(--bg-surface-lowest);
458
+ color: var(--color-info);
459
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08), inset 0 0 0 1px var(--hairline-color, var(--border-color));
460
+ }
461
+
462
+ &:focus-visible {
463
+ outline: 2px solid var(--border-focus);
464
+ outline-offset: 1px;
465
+ }
466
+ }
467
+ }
468
+
469
+ // ─────────────────────────────────────────────────────────────────
470
+ // Footer
471
+ // ─────────────────────────────────────────────────────────────────
472
+
473
+ .cny-datepicker-footer {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 4px;
477
+ margin-top: 8px;
478
+ padding-top: 6px;
479
+ border-top: 1px solid var(--hairline-color, var(--border-color));
480
+ }
481
+
482
+ .cny-datepicker-footer-spacer { flex: 1 1 auto; }
483
+
484
+ .cny-datepicker-action {
485
+ border: 0;
486
+ background: transparent;
487
+ color: var(--color-info);
488
+ font-family: var(--font-body);
489
+ font-size: 0.75rem;
490
+ font-weight: 500;
491
+ padding: 4px 8px;
492
+ border-radius: var(--radius-xs);
493
+ cursor: pointer;
494
+ transition: background-color 120ms ease;
495
+
496
+ &:hover:not(:disabled) { background: color-mix(in srgb, var(--color-info) 10%, transparent); }
497
+ &:disabled { color: var(--text-disabled); cursor: not-allowed; }
498
+ &:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 1px; }
499
+
500
+ &.is-primary {
501
+ background: var(--color-info);
502
+ color: var(--color-info-on);
503
+ padding: 4px 10px;
504
+ font-weight: 600;
505
+
506
+ &:hover:not(:disabled) {
507
+ background: var(--color-info-hover, var(--color-info));
508
+ filter: brightness(1.05);
509
+ }
510
+ }
511
+ }
@@ -4,11 +4,19 @@
4
4
  // All use the native element with class-based styling (no JS required).
5
5
 
6
6
  // ── Checkbox & Radio ─────────────────────────────────────────────────────
7
+ // Two markup patterns supported per element:
8
+ // 1. Legacy native input — <input type="checkbox|radio" class="checkbox|radio">
9
+ // uses :checked / :indeterminate.
10
+ // 2. Radix React component — <button class="checkbox|radio"
11
+ // data-state="checked|unchecked|indeterminate"> with a Radix Indicator child.
7
12
  .checkbox,
8
13
  .radio {
9
14
  appearance: none;
10
15
  -webkit-appearance: none;
11
- display: inline-block;
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ flex: none;
12
20
  width: 16px;
13
21
  height: 16px;
14
22
  margin: 0;
@@ -22,42 +30,77 @@
22
30
  border-color var(--transition-fast),
23
31
  box-shadow var(--transition-fast);
24
32
 
25
- &:hover:not(:disabled) { border-color: var(--color-primary); }
33
+ &:hover:not(:disabled):not([data-disabled]) { border-color: var(--color-primary); }
26
34
  &:focus-visible {
27
35
  outline: none;
28
36
  box-shadow: 0 0 0 var(--ring-width) var(--ring-color);
29
37
  }
30
- &:disabled { opacity: 0.5; cursor: not-allowed; }
38
+ &:disabled,
39
+ &[data-disabled] { opacity: 0.5; cursor: not-allowed; }
31
40
  }
32
41
 
33
42
  .checkbox {
34
43
  border-radius: var(--radius-xs);
35
44
 
36
- &:checked {
37
- background-color: var(--color-primary);
38
- border-color: var(--color-primary);
39
- background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'><path d='M3.5 8.5L6.5 11.5L12.5 5' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>");
40
- background-repeat: no-repeat;
41
- background-position: center;
45
+ // Native input pattern — no child possible, so the check is painted via
46
+ // background-image on a colored fill.
47
+ &:not([data-state]) {
48
+ &:checked {
49
+ background-color: var(--color-primary);
50
+ border-color: var(--color-primary);
51
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'><path d='M3.5 8.5L6.5 11.5L12.5 5' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>");
52
+ background-repeat: no-repeat;
53
+ background-position: center;
54
+ }
55
+ &:indeterminate {
56
+ background-color: var(--color-primary);
57
+ border-color: var(--color-primary);
58
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='3' y='7' width='10' height='2' fill='white' rx='1'/></svg>");
59
+ background-repeat: no-repeat;
60
+ background-position: center;
61
+ }
42
62
  }
43
63
 
44
- &:indeterminate {
64
+ // Radix pattern — Indicator child carries the Lucide check/minus icon.
65
+ &[data-state='checked'],
66
+ &[data-state='indeterminate'] {
45
67
  background-color: var(--color-primary);
46
68
  border-color: var(--color-primary);
47
- background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='3' y='7' width='10' height='2' fill='white' rx='1'/></svg>");
48
- background-repeat: no-repeat;
49
- background-position: center;
69
+ }
70
+
71
+ // Indicator wrapper (Radix renders this only when checked/indeterminate).
72
+ > * {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ color: #fff;
77
+ line-height: 0;
50
78
  }
51
79
  }
52
80
 
53
81
  .radio {
54
82
  border-radius: 50%;
83
+ position: relative;
55
84
 
56
- &:checked {
85
+ &:checked,
86
+ &[data-state='checked'] {
57
87
  border-color: var(--color-primary);
58
88
  background-color: var(--bg-surface-lowest);
89
+ }
90
+
91
+ // Native input uses a radial-gradient dot (no child element available).
92
+ &:not([data-state]):checked {
59
93
  background-image: radial-gradient(circle, var(--color-primary) 0 4px, transparent 5px);
60
94
  }
95
+
96
+ // Radix Indicator paints the dot — 8 px circle inside the 16 px ring.
97
+ > * {
98
+ display: block;
99
+ width: 8px;
100
+ height: 8px;
101
+ border-radius: 50%;
102
+ background: var(--color-primary);
103
+ }
61
104
  }
62
105
 
63
106
  // Label wrapper: <label class="control"><input class="checkbox"/> Label</label>
@@ -75,36 +118,55 @@
75
118
  }
76
119
 
77
120
  // ── Switch ──────────────────────────────────────────────────────────────
121
+ // Two markup patterns supported:
122
+ // 1. Legacy native input — <input type="checkbox" class="switch"> uses :checked
123
+ // and a ::before pseudo for the thumb.
124
+ // 2. Radix React component — <button class="switch" data-state="checked|unchecked">
125
+ // with a child <span class="switch-thumb">. Borders and animation are driven
126
+ // by [data-state] and the real .switch-thumb element.
78
127
  .switch {
128
+ // Track sizing / colors shared by both patterns.
129
+ --switch-w: 36px;
130
+ --switch-h: 20px;
131
+ --switch-pad: 2px;
132
+ --switch-thumb: calc(var(--switch-h) - var(--switch-pad) * 2);
133
+ --switch-travel: calc(var(--switch-w) - var(--switch-thumb) - var(--switch-pad) * 2);
134
+
79
135
  appearance: none;
80
136
  -webkit-appearance: none;
81
137
  position: relative;
82
- width: 32px;
83
- height: 18px;
138
+ display: inline-block;
139
+ flex: none;
140
+ width: var(--switch-w);
141
+ height: var(--switch-h);
84
142
  margin: 0;
85
143
  padding: 0;
86
144
  background: var(--border-color-strong);
145
+ border: 0;
87
146
  border-radius: var(--radius-full);
88
147
  cursor: pointer;
89
148
  vertical-align: middle;
90
149
  transition: background-color var(--transition-fast);
91
150
 
151
+ // Legacy input pattern — pseudo-thumb.
92
152
  &::before {
93
153
  content: '';
94
154
  position: absolute;
95
- top: 2px;
96
- left: 2px;
97
- width: 14px;
98
- height: 14px;
155
+ top: var(--switch-pad);
156
+ left: var(--switch-pad);
157
+ width: var(--switch-thumb);
158
+ height: var(--switch-thumb);
99
159
  background: #fff;
100
160
  border-radius: 50%;
101
161
  box-shadow: var(--shadow-xs);
102
162
  transition: transform var(--transition-fast);
103
163
  }
164
+ // Hide the pseudo-thumb when a real .switch-thumb child exists (Radix).
165
+ &:has(.switch-thumb)::before { content: none; }
104
166
 
105
167
  &:checked {
106
168
  background: var(--color-primary);
107
- &::before { transform: translateX(14px); }
169
+ &::before { transform: translateX(var(--switch-travel)); }
108
170
  }
109
171
 
110
172
  &:focus-visible {
@@ -113,6 +175,30 @@
113
175
  }
114
176
 
115
177
  &:disabled { opacity: 0.5; cursor: not-allowed; }
178
+
179
+ // Radix pattern — drive track color from [data-state]; thumb slides via .switch-thumb.
180
+ &[data-state='checked'] { background: var(--color-primary); }
181
+ &[data-disabled] { opacity: 0.5; cursor: not-allowed; }
182
+ }
183
+
184
+ .switch-thumb {
185
+ display: block;
186
+ width: var(--switch-thumb, 16px);
187
+ height: var(--switch-thumb, 16px);
188
+ margin: var(--switch-pad, 2px);
189
+ background: #fff;
190
+ border-radius: 50%;
191
+ box-shadow: var(--shadow-xs);
192
+ transform: translateX(0);
193
+ transition: transform var(--transition-fast);
194
+ will-change: transform;
195
+ pointer-events: none;
196
+
197
+ &[data-state='checked'] { transform: translateX(var(--switch-travel, 14px)); }
198
+ }
199
+
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .switch, .switch::before, .switch-thumb { transition: none; }
116
202
  }
117
203
 
118
204
  // ── Select ──────────────────────────────────────────────────────────────