tekivex-ui 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.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. package/src/themes/index.ts +111 -0
@@ -0,0 +1,114 @@
1
+ import { forwardRef, type HTMLAttributes } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { useReducedMotion } from '../hooks';
4
+ import { validateProps } from '../engine/security';
5
+ import { tkx, cx } from '../engine/tkx';
6
+
7
+ export type ProgressVariant = 'linear' | 'circular';
8
+ export type ProgressSize = 'sm' | 'md' | 'lg';
9
+
10
+ export interface TkxProgressProps extends HTMLAttributes<HTMLDivElement> {
11
+ value?: number;
12
+ variant?: ProgressVariant;
13
+ size?: ProgressSize;
14
+ label?: string;
15
+ showValue?: boolean;
16
+ color?: string;
17
+ }
18
+
19
+ const LINEAR_H: Record<ProgressSize, string> = { sm: '4px', md: '8px', lg: '12px' };
20
+ const CIRCLE_PX: Record<ProgressSize, number> = { sm: 32, md: 48, lg: 64 };
21
+
22
+ export const TkxProgress = forwardRef<HTMLDivElement, TkxProgressProps>(
23
+ ({ value, variant = 'linear', size = 'md', label, showValue, color, className, style, ...rest }, ref) => {
24
+ const theme = useTheme();
25
+ const reducedMotion = useReducedMotion();
26
+ const fillColor = color ?? theme.primary;
27
+ const isIndeterminate = value === undefined;
28
+ const clamped = value !== undefined ? Math.min(100, Math.max(0, value)) : 0;
29
+
30
+ if (value !== undefined) {
31
+ const { errors } = validateProps({ value }, { value: { type: 'number', min: 0, max: 100 } });
32
+ if (errors.length > 0) {
33
+ // eslint-disable-next-line no-console
34
+ console.warn('[TkxProgress]', errors.join(', '));
35
+ }
36
+ }
37
+
38
+ if (variant === 'circular') {
39
+ const px = CIRCLE_PX[size];
40
+ const strokeW = size === 'sm' ? 3 : size === 'md' ? 4 : 5;
41
+ const r = (px - strokeW * 2) / 2;
42
+ const circ = 2 * Math.PI * r;
43
+ const offset = isIndeterminate ? 0 : circ - (clamped / 100) * circ;
44
+
45
+ return (
46
+ <div
47
+ ref={ref}
48
+ role="progressbar"
49
+ aria-valuemin={0}
50
+ aria-valuemax={100}
51
+ aria-valuenow={isIndeterminate ? undefined : clamped}
52
+ aria-label={label ?? (isIndeterminate ? 'Loading' : `${clamped}%`)}
53
+ className={cx(tkx('inline-flex flex-col items-center gap-1'), className)}
54
+ style={style}
55
+ {...rest}
56
+ >
57
+ <svg width={px} height={px} viewBox={`0 0 ${px} ${px}`} aria-hidden="true">
58
+ <circle cx={px / 2} cy={px / 2} r={r} fill="none" stroke={theme.border} strokeWidth={strokeW} />
59
+ <circle
60
+ cx={px / 2} cy={px / 2} r={r} fill="none"
61
+ stroke={fillColor} strokeWidth={strokeW} strokeLinecap="round"
62
+ strokeDasharray={circ} strokeDashoffset={offset}
63
+ transform={`rotate(-90 ${px / 2} ${px / 2})`}
64
+ style={{
65
+ transition: reducedMotion ? 'none' : 'stroke-dashoffset 300ms ease',
66
+ animation: isIndeterminate && !reducedMotion ? 'tkx-spin 1s linear infinite' : undefined,
67
+ }}
68
+ />
69
+ </svg>
70
+ {showValue && !isIndeterminate && (
71
+ <span className={tkx('text-xs')} style={{ color: theme.textMuted }}>{clamped}%</span>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ const h = LINEAR_H[size];
78
+ return (
79
+ <div
80
+ ref={ref}
81
+ role="progressbar"
82
+ aria-valuemin={0}
83
+ aria-valuemax={100}
84
+ aria-valuenow={isIndeterminate ? undefined : clamped}
85
+ aria-label={label ?? (isIndeterminate ? 'Loading' : `${clamped}%`)}
86
+ className={cx(tkx('flex flex-col gap-1 w-full'), className)}
87
+ style={style}
88
+ {...rest}
89
+ >
90
+ {label && (
91
+ <div className={tkx('flex justify-between text-sm')} style={{ color: theme.text }}>
92
+ <span>{label}</span>
93
+ {showValue && !isIndeterminate && <span>{clamped}%</span>}
94
+ </div>
95
+ )}
96
+ <div
97
+ className={tkx('w-full overflow-hidden')}
98
+ style={{ height: h, borderRadius: h, backgroundColor: theme.border }}
99
+ >
100
+ <div
101
+ style={{
102
+ height: '100%', borderRadius: h, backgroundColor: fillColor,
103
+ width: isIndeterminate ? '40%' : `${clamped}%`,
104
+ transition: reducedMotion ? 'none' : 'width 300ms ease',
105
+ animation: isIndeterminate && !reducedMotion ? 'tkx-shimmer 1.5s ease-in-out infinite' : undefined,
106
+ }}
107
+ />
108
+ </div>
109
+ </div>
110
+ );
111
+ },
112
+ );
113
+
114
+ TkxProgress.displayName = 'TkxProgress';
@@ -0,0 +1,284 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useId,
5
+ useState,
6
+ useRef,
7
+ forwardRef,
8
+ type InputHTMLAttributes,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { useTheme } from '../themes';
12
+ import { sanitizeString } from '../engine/security';
13
+ import { getAccessibleForeground } from '../engine/wcag';
14
+ import { useReducedMotion } from '../hooks';
15
+ import { tkx, cx } from '../engine/tkx';
16
+
17
+ // ── Radio Group Context ───────────────────────────────────────────────────────
18
+
19
+ interface RadioGroupContextValue {
20
+ name: string;
21
+ value: string;
22
+ onChange: (value: string) => void;
23
+ isDisabled: boolean;
24
+ isInvalid: boolean;
25
+ }
26
+
27
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
28
+
29
+ function useRadioGroup() {
30
+ return useContext(RadioGroupContext);
31
+ }
32
+
33
+ // ── Size map ──────────────────────────────────────────────────────────────────
34
+
35
+ const SIZE_MAP = {
36
+ sm: { outer: 16, inner: 7, fontSize: '13px' },
37
+ md: { outer: 20, inner: 9, fontSize: '14px' },
38
+ lg: { outer: 24, inner: 11, fontSize: '15px' },
39
+ } as const;
40
+
41
+ // ── TkxRadio ─────────────────────────────────────────────────────────────────
42
+
43
+ export interface TkxRadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
44
+ label?: ReactNode;
45
+ hint?: string;
46
+ size?: 'sm' | 'md' | 'lg';
47
+ colorScheme?: 'primary' | 'success' | 'danger';
48
+ }
49
+
50
+ export const TkxRadio = forwardRef<HTMLInputElement, TkxRadioProps>(
51
+ (
52
+ {
53
+ label,
54
+ hint,
55
+ size = 'md',
56
+ colorScheme = 'primary',
57
+ value,
58
+ checked: checkedProp,
59
+ onChange: onChangeProp,
60
+ disabled: disabledProp,
61
+ name: nameProp,
62
+ className,
63
+ style,
64
+ id: idProp,
65
+ ...rest
66
+ },
67
+ ref,
68
+ ) => {
69
+ const theme = useTheme();
70
+ const reducedMotion = useReducedMotion();
71
+ const autoId = useId();
72
+ const id = idProp ?? autoId;
73
+
74
+ const group = useRadioGroup();
75
+
76
+ const isChecked = group
77
+ ? group.value === String(value ?? '')
78
+ : (checkedProp ?? false);
79
+
80
+ const isDisabled = disabledProp ?? group?.isDisabled ?? false;
81
+ const radioName = nameProp ?? group?.name ?? '';
82
+
83
+ const handleChange = () => {
84
+ if (isDisabled) return;
85
+ if (group) {
86
+ group.onChange(String(value ?? ''));
87
+ } else {
88
+ onChangeProp?.({ target: { value: String(value ?? '') } } as React.ChangeEvent<HTMLInputElement>);
89
+ }
90
+ };
91
+
92
+ const sz = SIZE_MAP[size];
93
+
94
+ const schemeColor: Record<string, string> = {
95
+ primary: theme.primary,
96
+ success: theme.success,
97
+ danger: theme.danger,
98
+ };
99
+ const accentColor = schemeColor[colorScheme] ?? theme.primary;
100
+ const dotColor = getAccessibleForeground(accentColor);
101
+
102
+ const outerBorder = isChecked ? accentColor : theme.border;
103
+ const outerBg = theme.surface;
104
+
105
+ const dotAnimStyle: React.CSSProperties =
106
+ !reducedMotion && isChecked
107
+ ? { animation: 'tkx-radio-dot-in 130ms ease forwards' }
108
+ : {};
109
+
110
+ return (
111
+ <div
112
+ className={cx(tkx('flex flex-col gap-0.5'), className)}
113
+ style={{ opacity: isDisabled ? 0.5 : 1, ...style }}
114
+ >
115
+ <style>{`
116
+ @keyframes tkx-radio-dot-in {
117
+ from { transform: scale(0) translate(-50%, -50%); opacity: 0; }
118
+ to { transform: scale(1) translate(-50%, -50%); opacity: 1; }
119
+ }
120
+ `}</style>
121
+
122
+ <label
123
+ htmlFor={id}
124
+ className={tkx('flex items-center gap-2 select-none')}
125
+ style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', fontFamily: 'inherit' }}
126
+ >
127
+ {/* Hidden native input */}
128
+ <input
129
+ ref={ref}
130
+ id={id}
131
+ type="radio"
132
+ name={radioName}
133
+ value={value}
134
+ checked={isChecked}
135
+ onChange={handleChange}
136
+ disabled={isDisabled}
137
+ aria-invalid={group?.isInvalid}
138
+ className={tkx('sr-only')}
139
+ {...rest}
140
+ />
141
+
142
+ {/* Custom styled circle */}
143
+ <span
144
+ aria-hidden="true"
145
+ style={{
146
+ position: 'relative',
147
+ width: sz.outer,
148
+ height: sz.outer,
149
+ minWidth: sz.outer,
150
+ borderRadius: '50%',
151
+ border: `2px solid ${outerBorder}`,
152
+ backgroundColor: outerBg,
153
+ display: 'inline-block',
154
+ boxSizing: 'border-box',
155
+ transition: reducedMotion ? 'none' : 'border-color 120ms',
156
+ }}
157
+ >
158
+ {isChecked && (
159
+ <span
160
+ aria-hidden="true"
161
+ style={{
162
+ position: 'absolute',
163
+ top: '50%',
164
+ left: '50%',
165
+ width: sz.inner,
166
+ height: sz.inner,
167
+ borderRadius: '50%',
168
+ backgroundColor: accentColor,
169
+ transform: 'translate(-50%, -50%)',
170
+ transformOrigin: 'center',
171
+ ...(reducedMotion ? {} : { animation: 'tkx-radio-dot-in 130ms ease forwards' }),
172
+ }}
173
+ />
174
+ )}
175
+ </span>
176
+
177
+ {label && (
178
+ <span style={{ fontSize: sz.fontSize, color: theme.text, lineHeight: 1.4 }}>
179
+ {typeof label === 'string' ? sanitizeString(label) : label}
180
+ </span>
181
+ )}
182
+ </label>
183
+
184
+ {hint && (
185
+ <span
186
+ className={tkx('text-xs')}
187
+ style={{ color: theme.textMuted, marginLeft: sz.outer + 8 }}
188
+ >
189
+ {sanitizeString(hint)}
190
+ </span>
191
+ )}
192
+ </div>
193
+ );
194
+ },
195
+ );
196
+
197
+ TkxRadio.displayName = 'TkxRadio';
198
+
199
+ // ── TkxRadioGroup ─────────────────────────────────────────────────────────────
200
+
201
+ export interface TkxRadioGroupProps {
202
+ name: string;
203
+ value?: string;
204
+ defaultValue?: string;
205
+ onChange?: (value: string) => void;
206
+ label?: string;
207
+ orientation?: 'horizontal' | 'vertical';
208
+ isDisabled?: boolean;
209
+ isInvalid?: boolean;
210
+ errorMessage?: string;
211
+ children: ReactNode;
212
+ }
213
+
214
+ export function TkxRadioGroup({
215
+ name,
216
+ value: valueProp,
217
+ defaultValue = '',
218
+ onChange,
219
+ label,
220
+ orientation = 'vertical',
221
+ isDisabled = false,
222
+ isInvalid = false,
223
+ errorMessage,
224
+ children,
225
+ }: TkxRadioGroupProps) {
226
+ const theme = useTheme();
227
+ const groupId = useId();
228
+ const errorId = `${groupId}-error`;
229
+
230
+ const [internalValue, setInternalValue] = useState(defaultValue);
231
+ const isControlled = valueProp !== undefined;
232
+ const currentValue = isControlled ? valueProp : internalValue;
233
+
234
+ const handleChange = (val: string) => {
235
+ if (!isControlled) setInternalValue(val);
236
+ onChange?.(val);
237
+ };
238
+
239
+ const hasError = isInvalid || !!errorMessage;
240
+
241
+ return (
242
+ <RadioGroupContext.Provider
243
+ value={{ name, value: currentValue, onChange: handleChange, isDisabled, isInvalid }}
244
+ >
245
+ <div
246
+ role="radiogroup"
247
+ aria-label={label ? sanitizeString(label) : undefined}
248
+ aria-describedby={hasError ? errorId : undefined}
249
+ aria-invalid={hasError}
250
+ >
251
+ {label && (
252
+ <div
253
+ className={tkx('text-sm font-medium font-sans mb-2')}
254
+ style={{ color: theme.text }}
255
+ >
256
+ {sanitizeString(label)}
257
+ </div>
258
+ )}
259
+
260
+ <div
261
+ className={tkx(
262
+ orientation === 'horizontal' ? 'flex flex-row flex-wrap gap-4' : 'flex flex-col gap-2',
263
+ )}
264
+ >
265
+ {children}
266
+ </div>
267
+
268
+ {hasError && errorMessage && (
269
+ <span
270
+ id={errorId}
271
+ role="alert"
272
+ className={tkx('text-xs flex items-center gap-1 mt-1')}
273
+ style={{ color: theme.danger }}
274
+ >
275
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
276
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
277
+ </svg>
278
+ {sanitizeString(errorMessage)}
279
+ </span>
280
+ )}
281
+ </div>
282
+ </RadioGroupContext.Provider>
283
+ );
284
+ }
@@ -0,0 +1,286 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useCallback,
5
+ useId,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ } from 'react';
9
+ import { useTheme } from '../themes';
10
+ import { sanitizeString } from '../engine/security';
11
+ import { useReducedMotion } from '../hooks';
12
+ import { tkx, cx } from '../engine/tkx';
13
+
14
+ export type RatingSize = 'sm' | 'md' | 'lg' | 'xl';
15
+
16
+ export interface TkxRatingProps {
17
+ value?: number;
18
+ defaultValue?: number;
19
+ onChange?: (value: number) => void;
20
+ max?: number;
21
+ precision?: 0.5 | 1;
22
+ size?: RatingSize;
23
+ isReadOnly?: boolean;
24
+ isDisabled?: boolean;
25
+ label?: string;
26
+ showValue?: boolean;
27
+ colorScheme?: 'warning' | 'primary' | 'danger';
28
+ emptyIcon?: 'star' | 'heart' | 'circle';
29
+ filledIcon?: 'star' | 'heart' | 'circle';
30
+ }
31
+
32
+ const SIZE_MAP: Record<RatingSize, number> = { sm: 16, md: 24, lg: 32, xl: 40 };
33
+
34
+ // ── Icon SVGs ─────────────────────────────────────────────────────────────────
35
+
36
+ function StarIcon({ size, filled, color, halfFilled, id }: { size: number; filled: boolean; color: string; halfFilled?: boolean; id?: string }) {
37
+ const gradId = id ? `hg-${id}` : undefined;
38
+ return (
39
+ <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" style={{ display: 'block' }}>
40
+ {halfFilled && gradId && (
41
+ <defs>
42
+ <linearGradient id={gradId} x1="0" x2="1" y1="0" y2="0">
43
+ <stop offset="50%" stopColor={color} />
44
+ <stop offset="50%" stopColor="transparent" />
45
+ </linearGradient>
46
+ </defs>
47
+ )}
48
+ <polygon
49
+ points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
50
+ fill={halfFilled && gradId ? `url(#${gradId})` : filled ? color : 'none'}
51
+ stroke={color}
52
+ strokeWidth={1.5}
53
+ strokeLinejoin="round"
54
+ />
55
+ </svg>
56
+ );
57
+ }
58
+
59
+ function HeartIcon({ size, filled, color, halfFilled, id }: { size: number; filled: boolean; color: string; halfFilled?: boolean; id?: string }) {
60
+ const gradId = id ? `hg-${id}` : undefined;
61
+ return (
62
+ <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" style={{ display: 'block' }}>
63
+ {halfFilled && gradId && (
64
+ <defs>
65
+ <linearGradient id={gradId} x1="0" x2="1" y1="0" y2="0">
66
+ <stop offset="50%" stopColor={color} />
67
+ <stop offset="50%" stopColor="transparent" />
68
+ </linearGradient>
69
+ </defs>
70
+ )}
71
+ <path
72
+ d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
73
+ fill={halfFilled && gradId ? `url(#${gradId})` : filled ? color : 'none'}
74
+ stroke={color}
75
+ strokeWidth={1.5}
76
+ strokeLinejoin="round"
77
+ />
78
+ </svg>
79
+ );
80
+ }
81
+
82
+ function CircleIcon({ size, filled, color, halfFilled, id }: { size: number; filled: boolean; color: string; halfFilled?: boolean; id?: string }) {
83
+ const gradId = id ? `hg-${id}` : undefined;
84
+ return (
85
+ <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" style={{ display: 'block' }}>
86
+ {halfFilled && gradId && (
87
+ <defs>
88
+ <linearGradient id={gradId} x1="0" x2="1" y1="0" y2="0">
89
+ <stop offset="50%" stopColor={color} />
90
+ <stop offset="50%" stopColor="transparent" />
91
+ </linearGradient>
92
+ </defs>
93
+ )}
94
+ <circle
95
+ cx="12" cy="12" r="9"
96
+ fill={halfFilled && gradId ? `url(#${gradId})` : filled ? color : 'none'}
97
+ stroke={color}
98
+ strokeWidth={1.5}
99
+ />
100
+ </svg>
101
+ );
102
+ }
103
+
104
+ type IconType = 'star' | 'heart' | 'circle';
105
+
106
+ function RatingIcon({ type, size, filled, color, halfFilled, uid }: {
107
+ type: IconType; size: number; filled: boolean; color: string; halfFilled?: boolean; uid?: string;
108
+ }) {
109
+ const props = { size, filled, color, halfFilled, id: uid };
110
+ if (type === 'heart') return <HeartIcon {...props} />;
111
+ if (type === 'circle') return <CircleIcon {...props} />;
112
+ return <StarIcon {...props} />;
113
+ }
114
+
115
+ // ── Helpers ───────────────────────────────────────────────────────────────────
116
+
117
+ function clamp(value: number, min: number, max: number): number {
118
+ return Math.min(max, Math.max(min, value));
119
+ }
120
+
121
+ function snapToPrecision(raw: number, precision: 0.5 | 1): number {
122
+ if (precision === 1) return Math.round(raw);
123
+ return Math.round(raw * 2) / 2;
124
+ }
125
+
126
+ function getHoverValue(e: MouseEvent<HTMLSpanElement>, index: number, precision: 0.5 | 1): number {
127
+ if (precision === 1) return index + 1;
128
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
129
+ const midpoint = rect.left + rect.width / 2;
130
+ return e.clientX < midpoint ? index + 0.5 : index + 1;
131
+ }
132
+
133
+ // ── Component ─────────────────────────────────────────────────────────────────
134
+
135
+ export function TkxRating({
136
+ value: controlledValue,
137
+ defaultValue = 0,
138
+ onChange,
139
+ max = 5,
140
+ precision = 1,
141
+ size = 'md',
142
+ isReadOnly = false,
143
+ isDisabled = false,
144
+ label,
145
+ showValue = false,
146
+ colorScheme = 'warning',
147
+ emptyIcon = 'star',
148
+ filledIcon = 'star',
149
+ }: TkxRatingProps) {
150
+ const theme = useTheme();
151
+ const reducedMotion = useReducedMotion();
152
+ const groupId = useId();
153
+ const isControlled = controlledValue !== undefined;
154
+ const [internalValue, setInternalValue] = useState(defaultValue);
155
+ const [hoverValue, setHoverValue] = useState<number | null>(null);
156
+ const rootRef = useRef<HTMLDivElement>(null);
157
+
158
+ const activeValue = isControlled ? (controlledValue ?? 0) : internalValue;
159
+ const displayValue = hoverValue ?? activeValue;
160
+
161
+ const colorMap: Record<string, string> = {
162
+ warning: theme.warning,
163
+ primary: theme.primary,
164
+ danger: theme.danger,
165
+ };
166
+ const iconColor = colorMap[colorScheme] ?? theme.warning;
167
+ const px = SIZE_MAP[size];
168
+ const isInteractive = !isReadOnly && !isDisabled;
169
+
170
+ const commit = useCallback((val: number) => {
171
+ if (!isControlled) setInternalValue(val);
172
+ onChange?.(val);
173
+ }, [isControlled, onChange]);
174
+
175
+ const handleMouseMove = useCallback((e: MouseEvent<HTMLSpanElement>, index: number) => {
176
+ if (!isInteractive) return;
177
+ setHoverValue(getHoverValue(e, index, precision));
178
+ }, [isInteractive, precision]);
179
+
180
+ const handleMouseLeave = useCallback(() => {
181
+ if (!isInteractive) return;
182
+ setHoverValue(null);
183
+ }, [isInteractive]);
184
+
185
+ const handleClick = useCallback((e: MouseEvent<HTMLSpanElement>, index: number) => {
186
+ if (!isInteractive) return;
187
+ const val = getHoverValue(e, index, precision);
188
+ // Toggle off if clicking the same value
189
+ const next = val === activeValue ? 0 : val;
190
+ commit(next);
191
+ }, [isInteractive, precision, activeValue, commit]);
192
+
193
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
194
+ if (!isInteractive) return;
195
+ const step = precision;
196
+ let next = activeValue;
197
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); next = clamp(activeValue + step, 0, max); }
198
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); next = clamp(activeValue - step, 0, max); }
199
+ else if (e.key === 'Home') { e.preventDefault(); next = 0; }
200
+ else if (e.key === 'End') { e.preventDefault(); next = max; }
201
+ else return;
202
+ next = snapToPrecision(next, precision);
203
+ commit(next);
204
+ }, [isInteractive, activeValue, max, precision, commit]);
205
+
206
+ const safeLabel = label ? sanitizeString(label) : 'Rating';
207
+
208
+ return (
209
+ <div
210
+ className={cx(
211
+ tkx('inline-flex items-center gap-2 font-sans'),
212
+ isDisabled ? tkx('opacity-50') : '',
213
+ )}
214
+ >
215
+ <div
216
+ ref={rootRef}
217
+ role="radiogroup"
218
+ aria-label={safeLabel}
219
+ aria-disabled={isDisabled}
220
+ aria-readonly={isReadOnly}
221
+ tabIndex={isInteractive ? 0 : -1}
222
+ onKeyDown={handleKeyDown}
223
+ onMouseLeave={handleMouseLeave}
224
+ className={tkx('flex items-center gap-0.5 outline-none')}
225
+ style={{ cursor: isInteractive ? 'pointer' : 'default' }}
226
+ >
227
+ {Array.from({ length: max }, (_, i) => {
228
+ const starValue = i + 1;
229
+ const halfValue = i + 0.5;
230
+ const isFilled = displayValue >= starValue;
231
+ const isHalf = !isFilled && precision === 0.5 && displayValue >= halfValue;
232
+ const uid = `${groupId}-${i}`;
233
+
234
+ return (
235
+ <span
236
+ key={i}
237
+ role="radio"
238
+ aria-checked={
239
+ precision === 0.5
240
+ ? activeValue === halfValue || activeValue === starValue
241
+ : activeValue === starValue
242
+ }
243
+ aria-label={
244
+ precision === 0.5
245
+ ? `${halfValue} star${halfValue !== 1 ? 's' : ''} / ${starValue} star${starValue !== 1 ? 's' : ''}`
246
+ : `${starValue} star${starValue !== 1 ? 's' : ''}`
247
+ }
248
+ tabIndex={-1}
249
+ onMouseMove={(e) => handleMouseMove(e, i)}
250
+ onClick={(e) => handleClick(e, i)}
251
+ style={{
252
+ display: 'inline-flex',
253
+ alignItems: 'center',
254
+ lineHeight: 0,
255
+ transition: reducedMotion ? 'none' : 'transform 120ms ease',
256
+ transform: hoverValue !== null && displayValue >= halfValue && isInteractive
257
+ ? 'scale(1.15)'
258
+ : 'scale(1)',
259
+ }}
260
+ >
261
+ <RatingIcon
262
+ type={isFilled || isHalf ? filledIcon : emptyIcon}
263
+ size={px}
264
+ filled={isFilled}
265
+ halfFilled={isHalf}
266
+ color={isFilled || isHalf ? iconColor : theme.border}
267
+ uid={isHalf ? uid : undefined}
268
+ />
269
+ </span>
270
+ );
271
+ })}
272
+ </div>
273
+
274
+ {showValue && (
275
+ <span
276
+ className={tkx('text-sm tabular-nums')}
277
+ style={{ color: theme.textMuted }}
278
+ aria-live="polite"
279
+ aria-atomic="true"
280
+ >
281
+ {activeValue % 1 === 0 ? activeValue.toFixed(0) : activeValue.toFixed(1)} / {max}
282
+ </span>
283
+ )}
284
+ </div>
285
+ );
286
+ }