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,354 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ type ReactNode,
7
+ type KeyboardEvent,
8
+ type ChangeEvent,
9
+ } from 'react';
10
+ import { useTheme } from '../themes';
11
+ import { tkx, cx } from '../engine/tkx';
12
+ import { sanitizeString } from '../engine/security';
13
+ import { useReducedMotion } from '../hooks';
14
+
15
+ // ── Types ─────────────────────────────────────────────────────────────────────
16
+
17
+ export type MessageRole = 'user' | 'assistant' | 'system';
18
+
19
+ export interface ChatMessage {
20
+ id: string;
21
+ role: MessageRole;
22
+ content: string;
23
+ timestamp?: Date;
24
+ isStreaming?: boolean;
25
+ error?: boolean;
26
+ }
27
+
28
+ export interface TkxChatProps {
29
+ messages: ChatMessage[];
30
+ onSend?: (message: string) => void;
31
+ isLoading?: boolean;
32
+ placeholder?: string;
33
+ maxLength?: number;
34
+ showTimestamps?: boolean;
35
+ avatarUser?: ReactNode;
36
+ avatarAssistant?: ReactNode;
37
+ height?: string | number;
38
+ inputPosition?: 'bottom' | 'floating';
39
+ }
40
+
41
+ export interface TkxChatBubbleProps {
42
+ message: ChatMessage;
43
+ showTimestamp?: boolean;
44
+ avatarUser?: ReactNode;
45
+ avatarAssistant?: ReactNode;
46
+ }
47
+
48
+ // ── Animations (injected once) ────────────────────────────────────────────────
49
+
50
+ let _styleInjected = false;
51
+ function injectChatStyles() {
52
+ if (_styleInjected || typeof document === 'undefined') return;
53
+ _styleInjected = true;
54
+ const style = document.createElement('style');
55
+ style.textContent = `
56
+ @keyframes tkx-blink { 0%,100% { opacity:1 } 50% { opacity:0 } }
57
+ @keyframes tkx-bounce { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-5px) } }
58
+ .tkx-cursor::after { content:'▋'; display:inline-block; animation:tkx-blink 1s step-start infinite; margin-left:1px; }
59
+ .tkx-dot1 { animation:tkx-bounce 1s ease-in-out infinite; }
60
+ .tkx-dot2 { animation:tkx-bounce 1s ease-in-out 0.2s infinite; }
61
+ .tkx-dot3 { animation:tkx-bounce 1s ease-in-out 0.4s infinite; }
62
+ `;
63
+ document.head.appendChild(style);
64
+ }
65
+
66
+ // ── Default Avatars ───────────────────────────────────────────────────────────
67
+
68
+ function DefaultUserAvatar() {
69
+ return (
70
+ <svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
71
+ <circle cx="14" cy="14" r="14" fill="currentColor" fillOpacity="0.15" />
72
+ <circle cx="14" cy="11" r="4" fill="currentColor" />
73
+ <path d="M6 24c0-4.418 3.582-8 8-8s8 3.582 8 8" fill="currentColor" />
74
+ </svg>
75
+ );
76
+ }
77
+
78
+ function DefaultAssistantAvatar() {
79
+ return (
80
+ <svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
81
+ <circle cx="14" cy="14" r="14" fill="currentColor" fillOpacity="0.15" />
82
+ <path d="M14 5l2.2 6.5H23l-5.6 4 2.2 6.5L14 18l-5.6 4 2.2-6.5L5 11.5h6.8z" fill="currentColor" />
83
+ </svg>
84
+ );
85
+ }
86
+
87
+ // ── TkxThinkingIndicator ──────────────────────────────────────────────────────
88
+
89
+ export function TkxThinkingIndicator() {
90
+ const theme = useTheme();
91
+ return (
92
+ <div
93
+ className={tkx('flex items-center gap-1.5 px-4 py-3 rounded-2xl w-fit')}
94
+ style={{ backgroundColor: theme.surface, border: `1px solid ${theme.border}` }}
95
+ aria-label="Assistant is thinking"
96
+ >
97
+ <span
98
+ className="tkx-dot1 inline-block w-2 h-2 rounded-full"
99
+ style={{ backgroundColor: theme.textMuted }}
100
+ />
101
+ <span
102
+ className="tkx-dot2 inline-block w-2 h-2 rounded-full"
103
+ style={{ backgroundColor: theme.textMuted }}
104
+ />
105
+ <span
106
+ className="tkx-dot3 inline-block w-2 h-2 rounded-full"
107
+ style={{ backgroundColor: theme.textMuted }}
108
+ />
109
+ </div>
110
+ );
111
+ }
112
+
113
+ // ── TkxChatBubble ─────────────────────────────────────────────────────────────
114
+
115
+ export function TkxChatBubble({ message, showTimestamp, avatarUser, avatarAssistant }: TkxChatBubbleProps) {
116
+ const theme = useTheme();
117
+ const reducedMotion = useReducedMotion();
118
+ const { role, content, timestamp, isStreaming, error } = message;
119
+
120
+ const safeContent = sanitizeString(content);
121
+
122
+ const formattedTime = timestamp
123
+ ? new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).format(timestamp)
124
+ : null;
125
+
126
+ if (role === 'system') {
127
+ return (
128
+ <div role="listitem" className={tkx('flex justify-center my-2')}>
129
+ <p
130
+ className={tkx('text-xs italic px-3 py-1 rounded-full')}
131
+ style={{ color: theme.textMuted, backgroundColor: theme.surfaceAlt }}
132
+ >
133
+ {safeContent}
134
+ </p>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ const isUser = role === 'user';
140
+ const avatar = isUser ? (avatarUser ?? <DefaultUserAvatar />) : (avatarAssistant ?? <DefaultAssistantAvatar />);
141
+
142
+ const bubbleStyle: React.CSSProperties = isUser
143
+ ? { backgroundColor: theme.primary, color: theme.bg }
144
+ : {
145
+ backgroundColor: theme.surface,
146
+ color: theme.text,
147
+ border: `1px solid ${error ? theme.danger : theme.border}`,
148
+ };
149
+
150
+ if (error) {
151
+ bubbleStyle.borderColor = theme.danger;
152
+ bubbleStyle.borderWidth = '2px';
153
+ }
154
+
155
+ return (
156
+ <div
157
+ role="listitem"
158
+ className={tkx('flex gap-2 mb-4', isUser ? 'flex-row-reverse' : 'flex-row')}
159
+ >
160
+ <div
161
+ className={tkx('shrink-0 w-8 h-8 rounded-full flex items-center justify-center overflow-hidden')}
162
+ style={{ color: isUser ? theme.primary : theme.secondary }}
163
+ >
164
+ {avatar}
165
+ </div>
166
+ <div className={tkx('flex flex-col gap-1 max-w-[75%]', isUser ? 'items-end' : 'items-start')}>
167
+ <div
168
+ className={cx(
169
+ tkx('px-4 py-2.5 rounded-2xl text-sm leading-relaxed break-words whitespace-pre-wrap'),
170
+ isStreaming && !reducedMotion ? 'tkx-cursor' : '',
171
+ )}
172
+ style={bubbleStyle}
173
+ >
174
+ {safeContent}
175
+ {error && (
176
+ <span aria-label="Error" style={{ marginLeft: 6, color: theme.danger }}>⚠</span>
177
+ )}
178
+ </div>
179
+ {showTimestamp && formattedTime && (
180
+ <span className={tkx('text-[10px]')} style={{ color: theme.textMuted }}>
181
+ {formattedTime}
182
+ </span>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // ── TkxChat ───────────────────────────────────────────────────────────────────
190
+
191
+ export function TkxChat({
192
+ messages,
193
+ onSend,
194
+ isLoading = false,
195
+ placeholder = 'Type a message…',
196
+ maxLength,
197
+ showTimestamps = false,
198
+ avatarUser,
199
+ avatarAssistant,
200
+ height = 480,
201
+ inputPosition = 'bottom',
202
+ }: TkxChatProps) {
203
+ const theme = useTheme();
204
+ const reducedMotion = useReducedMotion();
205
+ const [draft, setDraft] = useState('');
206
+ const listRef = useRef<HTMLDivElement>(null);
207
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
208
+
209
+ useEffect(() => { injectChatStyles(); }, []);
210
+
211
+ useEffect(() => {
212
+ const el = listRef.current;
213
+ if (!el) return;
214
+ el.scrollTo({ top: el.scrollHeight, behavior: reducedMotion ? 'auto' : 'smooth' });
215
+ }, [messages, isLoading, reducedMotion]);
216
+
217
+ const handleSend = useCallback(() => {
218
+ const trimmed = draft.trim();
219
+ if (!trimmed || !onSend) return;
220
+ onSend(trimmed);
221
+ setDraft('');
222
+ if (textareaRef.current) {
223
+ textareaRef.current.style.height = 'auto';
224
+ }
225
+ }, [draft, onSend]);
226
+
227
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
228
+ if (e.key === 'Enter' && !e.shiftKey) {
229
+ e.preventDefault();
230
+ handleSend();
231
+ }
232
+ }, [handleSend]);
233
+
234
+ const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
235
+ const val = maxLength ? e.target.value.slice(0, maxLength) : e.target.value;
236
+ setDraft(val);
237
+ const ta = textareaRef.current;
238
+ if (ta) {
239
+ ta.style.height = 'auto';
240
+ const lineH = 24;
241
+ const maxH = lineH * 4 + 24;
242
+ ta.style.height = `${Math.min(ta.scrollHeight, maxH)}px`;
243
+ }
244
+ }, [maxLength]);
245
+
246
+ const canSend = draft.trim().length > 0 && !isLoading;
247
+ const heightVal = typeof height === 'number' ? `${height}px` : height;
248
+
249
+ const isFloating = inputPosition === 'floating';
250
+
251
+ const inputArea = (
252
+ <div
253
+ className={tkx('flex flex-col gap-1 p-3')}
254
+ style={
255
+ isFloating
256
+ ? {
257
+ position: 'absolute',
258
+ bottom: 12,
259
+ left: 12,
260
+ right: 12,
261
+ borderRadius: 16,
262
+ backgroundColor: theme.surface,
263
+ border: `1px solid ${theme.border}`,
264
+ boxShadow: '0 4px 24px rgba(0,0,0,0.18)',
265
+ }
266
+ : {
267
+ borderTop: `1px solid ${theme.border}`,
268
+ backgroundColor: theme.surface,
269
+ }
270
+ }
271
+ >
272
+ <div className={tkx('flex items-end gap-2')}>
273
+ <textarea
274
+ ref={textareaRef}
275
+ rows={1}
276
+ value={draft}
277
+ onChange={handleChange}
278
+ onKeyDown={handleKeyDown}
279
+ placeholder={sanitizeString(placeholder)}
280
+ aria-label="Message input"
281
+ aria-multiline="true"
282
+ disabled={isLoading}
283
+ className={tkx('flex-1 resize-none text-sm leading-6 bg-transparent outline-none py-1')}
284
+ style={{
285
+ color: theme.text,
286
+ caretColor: theme.primary,
287
+ minHeight: 32,
288
+ maxHeight: 120,
289
+ }}
290
+ />
291
+ <button
292
+ onClick={handleSend}
293
+ disabled={!canSend}
294
+ aria-label="Send message"
295
+ className={tkx(
296
+ 'shrink-0 w-9 h-9 rounded-xl flex items-center justify-center',
297
+ !reducedMotion && 'transition-opacity duration-150',
298
+ !canSend ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer opacity-100',
299
+ )}
300
+ style={{ backgroundColor: theme.primary, color: theme.bg }}
301
+ >
302
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
303
+ <path d="M2 14L14 8 2 2v4.5l8 1.5-8 1.5V14z" fill="currentColor" />
304
+ </svg>
305
+ </button>
306
+ </div>
307
+ {maxLength && (
308
+ <div className={tkx('text-right text-[10px]')} style={{ color: theme.textMuted }}>
309
+ {draft.length}/{maxLength}
310
+ </div>
311
+ )}
312
+ </div>
313
+ );
314
+
315
+ return (
316
+ <div
317
+ className={tkx('flex flex-col overflow-hidden rounded-2xl')}
318
+ style={{
319
+ height: heightVal,
320
+ backgroundColor: theme.bg,
321
+ border: `1px solid ${theme.border}`,
322
+ position: 'relative',
323
+ }}
324
+ >
325
+ <div
326
+ ref={listRef}
327
+ role="log"
328
+ aria-live="polite"
329
+ aria-label="Chat messages"
330
+ className={tkx('flex-1 overflow-y-auto p-4')}
331
+ style={{ paddingBottom: isFloating ? 80 : undefined }}
332
+ >
333
+ <div role="list">
334
+ {messages.map((msg) => (
335
+ <TkxChatBubble
336
+ key={msg.id}
337
+ message={msg}
338
+ showTimestamp={showTimestamps}
339
+ avatarUser={avatarUser}
340
+ avatarAssistant={avatarAssistant}
341
+ />
342
+ ))}
343
+ {isLoading && (
344
+ <div role="listitem" className={tkx('flex gap-2 mb-4')}>
345
+ <div className={tkx('w-8 h-8 shrink-0')} />
346
+ <TkxThinkingIndicator />
347
+ </div>
348
+ )}
349
+ </div>
350
+ </div>
351
+ {inputArea}
352
+ </div>
353
+ );
354
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ forwardRef,
3
+ useId,
4
+ useRef,
5
+ useEffect,
6
+ useState,
7
+ type InputHTMLAttributes,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { useTheme } from '../themes';
11
+ import { sanitizeString } from '../engine/security';
12
+ import { getAccessibleForeground } from '../engine/wcag';
13
+ import { useReducedMotion } from '../hooks';
14
+ import { tkx, cx } from '../engine/tkx';
15
+
16
+ export type CheckboxSize = 'sm' | 'md' | 'lg';
17
+
18
+ export interface TkxCheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
19
+ label?: ReactNode;
20
+ hint?: string;
21
+ isInvalid?: boolean;
22
+ errorMessage?: string;
23
+ isIndeterminate?: boolean;
24
+ size?: CheckboxSize;
25
+ colorScheme?: 'primary' | 'success' | 'danger' | 'warning';
26
+ }
27
+
28
+ const SIZE_MAP: Record<CheckboxSize, { box: number; fontSize: string; iconStroke: number }> = {
29
+ sm: { box: 16, fontSize: '13px', iconStroke: 2.5 },
30
+ md: { box: 20, fontSize: '14px', iconStroke: 2.5 },
31
+ lg: { box: 24, fontSize: '15px', iconStroke: 2 },
32
+ };
33
+
34
+ export const TkxCheckbox = forwardRef<HTMLInputElement, TkxCheckboxProps>(
35
+ (
36
+ {
37
+ label,
38
+ hint,
39
+ isInvalid,
40
+ errorMessage,
41
+ isIndeterminate = false,
42
+ size = 'md',
43
+ colorScheme = 'primary',
44
+ checked,
45
+ defaultChecked,
46
+ onChange,
47
+ disabled,
48
+ className,
49
+ style,
50
+ id: idProp,
51
+ ...rest
52
+ },
53
+ ref,
54
+ ) => {
55
+ const theme = useTheme();
56
+ const reducedMotion = useReducedMotion();
57
+ const autoId = useId();
58
+ const id = idProp ?? autoId;
59
+ const hintId = `${id}-hint`;
60
+ const errorId = `${id}-error`;
61
+
62
+ const inputRef = useRef<HTMLInputElement>(null);
63
+ const [isFocusVisible, setIsFocusVisible] = useState(false);
64
+
65
+ const sz = SIZE_MAP[size];
66
+ const hasError = isInvalid || !!errorMessage;
67
+
68
+ const schemeColor: Record<string, string> = {
69
+ primary: theme.primary,
70
+ success: theme.success,
71
+ danger: theme.danger,
72
+ warning: theme.warning,
73
+ };
74
+ const accentColor = schemeColor[colorScheme] ?? theme.primary;
75
+
76
+ const isChecked = checked !== undefined ? checked : undefined;
77
+
78
+ // Set indeterminate imperatively — only supported via JS ref
79
+ useEffect(() => {
80
+ const el = (ref as React.RefObject<HTMLInputElement>)?.current ?? inputRef.current;
81
+ if (el) el.indeterminate = isIndeterminate;
82
+ }, [isIndeterminate, ref]);
83
+
84
+ const isVisuallyChecked = isIndeterminate || (isChecked ?? false);
85
+ const boxBg = isVisuallyChecked ? accentColor : theme.surface;
86
+ const boxBorder = hasError ? theme.danger : isVisuallyChecked ? accentColor : theme.border;
87
+ const checkColor = getAccessibleForeground(accentColor);
88
+
89
+ const focusRingStyle: React.CSSProperties = isFocusVisible
90
+ ? { outline: `2px solid ${accentColor}`, outlineOffset: '2px' }
91
+ : {};
92
+
93
+ const describedBy = [hint && hintId, hasError && errorId].filter(Boolean).join(' ') || undefined;
94
+
95
+ const animStyle: React.CSSProperties =
96
+ !reducedMotion && isVisuallyChecked
97
+ ? { animation: 'tkx-check-scale-in 120ms ease forwards' }
98
+ : {};
99
+
100
+ return (
101
+ <div
102
+ className={cx(tkx('flex flex-col gap-1'), className)}
103
+ style={{ opacity: disabled ? 0.5 : 1, ...style }}
104
+ >
105
+ <style>{`
106
+ @keyframes tkx-check-scale-in {
107
+ from { transform: scale(0.4); opacity: 0; }
108
+ to { transform: scale(1); opacity: 1; }
109
+ }
110
+ `}</style>
111
+
112
+ <label
113
+ htmlFor={id}
114
+ className={tkx('flex items-center gap-2 select-none')}
115
+ style={{ cursor: disabled ? 'not-allowed' : 'pointer', fontFamily: 'inherit' }}
116
+ >
117
+ {/* Hidden native input */}
118
+ <input
119
+ ref={(el) => {
120
+ (inputRef as React.MutableRefObject<HTMLInputElement | null>).current = el;
121
+ if (typeof ref === 'function') ref(el);
122
+ else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
123
+ }}
124
+ id={id}
125
+ type="checkbox"
126
+ checked={checked}
127
+ defaultChecked={defaultChecked}
128
+ onChange={onChange}
129
+ disabled={disabled}
130
+ aria-invalid={hasError}
131
+ aria-describedby={describedBy}
132
+ onFocus={(e) => {
133
+ if (e.target.matches(':focus-visible')) setIsFocusVisible(true);
134
+ }}
135
+ onBlur={() => setIsFocusVisible(false)}
136
+ className={tkx('sr-only')}
137
+ {...rest}
138
+ />
139
+
140
+ {/* Custom styled box */}
141
+ <span
142
+ aria-hidden="true"
143
+ style={{
144
+ width: sz.box,
145
+ height: sz.box,
146
+ minWidth: sz.box,
147
+ borderRadius: '4px',
148
+ border: `2px solid ${boxBorder}`,
149
+ backgroundColor: boxBg,
150
+ display: 'flex',
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ transition: reducedMotion ? 'none' : 'background-color 120ms, border-color 120ms',
154
+ boxSizing: 'border-box',
155
+ ...focusRingStyle,
156
+ }}
157
+ >
158
+ {isIndeterminate ? (
159
+ <span
160
+ aria-hidden="true"
161
+ style={{
162
+ display: 'block',
163
+ width: sz.box * 0.5,
164
+ height: 2,
165
+ backgroundColor: checkColor,
166
+ borderRadius: '1px',
167
+ ...animStyle,
168
+ }}
169
+ />
170
+ ) : isVisuallyChecked ? (
171
+ <svg
172
+ width={sz.box * 0.6}
173
+ height={sz.box * 0.6}
174
+ viewBox="0 0 24 24"
175
+ fill="none"
176
+ stroke={checkColor}
177
+ strokeWidth={sz.iconStroke}
178
+ strokeLinecap="round"
179
+ strokeLinejoin="round"
180
+ aria-hidden="true"
181
+ style={animStyle}
182
+ >
183
+ <path d="M20 6L9 17l-5-5" />
184
+ </svg>
185
+ ) : null}
186
+ </span>
187
+
188
+ {label && (
189
+ <span style={{ fontSize: sz.fontSize, color: theme.text, lineHeight: 1.4 }}>
190
+ {typeof label === 'string' ? sanitizeString(label) : label}
191
+ </span>
192
+ )}
193
+ </label>
194
+
195
+ {hint && !hasError && (
196
+ <span id={hintId} className={tkx('text-xs ml-7')} style={{ color: theme.textMuted, marginLeft: sz.box + 8 }}>
197
+ {sanitizeString(hint)}
198
+ </span>
199
+ )}
200
+ {hasError && errorMessage && (
201
+ <span
202
+ id={errorId}
203
+ role="alert"
204
+ className={tkx('text-xs flex items-center gap-1')}
205
+ style={{ color: theme.danger, marginLeft: sz.box + 8 }}
206
+ >
207
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
208
+ <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" />
209
+ </svg>
210
+ {sanitizeString(errorMessage)}
211
+ </span>
212
+ )}
213
+ </div>
214
+ );
215
+ },
216
+ );
217
+
218
+ TkxCheckbox.displayName = 'TkxCheckbox';