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,588 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useId,
6
+ useCallback,
7
+ type ReactNode,
8
+ type CSSProperties,
9
+ } from 'react';
10
+ import { useTheme } from '../themes';
11
+ import { useReducedMotion } from '../hooks';
12
+ import { tkx } from '../engine/tkx';
13
+
14
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
+
16
+ export type AccordionVariant = 'default' | 'bordered' | 'separated' | 'ghost' | 'flush';
17
+ export type AccordionSize = 'sm' | 'md' | 'lg';
18
+
19
+ export interface AccordionItem {
20
+ id: string;
21
+ title: ReactNode;
22
+ content: ReactNode;
23
+ icon?: ReactNode;
24
+ badge?: ReactNode;
25
+ subtitle?: string;
26
+ disabled?: boolean;
27
+ className?: string;
28
+ }
29
+
30
+ export interface TkxAccordionProps {
31
+ items: AccordionItem[];
32
+ multiple?: boolean;
33
+ defaultOpen?: string | string[];
34
+ value?: string | string[];
35
+ onChange?: (value: string | string[]) => void;
36
+ variant?: AccordionVariant;
37
+ size?: AccordionSize;
38
+ iconPosition?: 'left' | 'right';
39
+ iconStyle?: 'chevron' | 'plus' | 'arrow';
40
+ flush?: boolean;
41
+ className?: string;
42
+ style?: CSSProperties;
43
+ }
44
+
45
+ // ── Size config ───────────────────────────────────────────────────────────────
46
+
47
+ const SIZE_CONFIG: Record<AccordionSize, {
48
+ triggerPadding: string;
49
+ contentPadding: string;
50
+ fontSize: string;
51
+ subtitleSize: string;
52
+ iconSize: number;
53
+ }> = {
54
+ sm: {
55
+ triggerPadding: '10px 12px',
56
+ contentPadding: '0 12px 10px 12px',
57
+ fontSize: '13px',
58
+ subtitleSize: '11px',
59
+ iconSize: 14,
60
+ },
61
+ md: {
62
+ triggerPadding: '14px 16px',
63
+ contentPadding: '0 16px 14px 16px',
64
+ fontSize: '14px',
65
+ subtitleSize: '12px',
66
+ iconSize: 16,
67
+ },
68
+ lg: {
69
+ triggerPadding: '18px 20px',
70
+ contentPadding: '0 20px 18px 20px',
71
+ fontSize: '16px',
72
+ subtitleSize: '13px',
73
+ iconSize: 18,
74
+ },
75
+ };
76
+
77
+ // ── Icon Components ───────────────────────────────────────────────────────────
78
+
79
+ interface AccordionIconProps {
80
+ open: boolean;
81
+ reduced: boolean;
82
+ iconStyle: 'chevron' | 'plus' | 'arrow';
83
+ size: number;
84
+ color: string;
85
+ }
86
+
87
+ function AccordionIcon({ open, reduced, iconStyle, size, color }: AccordionIconProps) {
88
+ const transition = reduced ? 'none' : 'transform 240ms cubic-bezier(0.4, 0, 0.2, 1)';
89
+
90
+ if (iconStyle === 'chevron') {
91
+ return (
92
+ <svg
93
+ width={size}
94
+ height={size}
95
+ viewBox="0 0 24 24"
96
+ fill="none"
97
+ stroke={color}
98
+ strokeWidth={2.5}
99
+ strokeLinecap="round"
100
+ strokeLinejoin="round"
101
+ aria-hidden="true"
102
+ style={{
103
+ transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
104
+ transition,
105
+ flexShrink: 0,
106
+ }}
107
+ >
108
+ <polyline points="6 9 12 15 18 9" />
109
+ </svg>
110
+ );
111
+ }
112
+
113
+ if (iconStyle === 'plus') {
114
+ return (
115
+ <svg
116
+ width={size}
117
+ height={size}
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke={color}
121
+ strokeWidth={2.5}
122
+ strokeLinecap="round"
123
+ strokeLinejoin="round"
124
+ aria-hidden="true"
125
+ style={{
126
+ transform: open ? 'rotate(45deg)' : 'rotate(0deg)',
127
+ transition,
128
+ flexShrink: 0,
129
+ }}
130
+ >
131
+ <line x1="12" y1="5" x2="12" y2="19" />
132
+ <line x1="5" y1="12" x2="19" y2="12" />
133
+ </svg>
134
+ );
135
+ }
136
+
137
+ // arrow style: → rotates to ↓
138
+ return (
139
+ <svg
140
+ width={size}
141
+ height={size}
142
+ viewBox="0 0 24 24"
143
+ fill="none"
144
+ stroke={color}
145
+ strokeWidth={2.5}
146
+ strokeLinecap="round"
147
+ strokeLinejoin="round"
148
+ aria-hidden="true"
149
+ style={{
150
+ transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
151
+ transition,
152
+ flexShrink: 0,
153
+ }}
154
+ >
155
+ <line x1="5" y1="12" x2="19" y2="12" />
156
+ <polyline points="12 5 19 12 12 19" />
157
+ </svg>
158
+ );
159
+ }
160
+
161
+ // ── Animated Panel ────────────────────────────────────────────────────────────
162
+
163
+ interface AnimatedPanelProps {
164
+ isOpen: boolean;
165
+ reduced: boolean;
166
+ children: ReactNode;
167
+ id: string;
168
+ triggerId: string;
169
+ }
170
+
171
+ function AnimatedPanel({ isOpen, reduced, children, id, triggerId }: AnimatedPanelProps) {
172
+ const outerRef = useRef<HTMLDivElement>(null);
173
+ const innerRef = useRef<HTMLDivElement>(null);
174
+ const prevOpenRef = useRef(isOpen);
175
+ const isAnimatingRef = useRef(false);
176
+
177
+ // Initial render — set immediately without animation
178
+ useEffect(() => {
179
+ const el = outerRef.current;
180
+ if (!el) return;
181
+ if (isOpen) {
182
+ el.style.height = 'auto';
183
+ el.style.overflow = 'visible';
184
+ } else {
185
+ el.style.height = '0px';
186
+ el.style.overflow = 'hidden';
187
+ }
188
+ // eslint-disable-next-line react-hooks/exhaustive-deps
189
+ }, []);
190
+
191
+ useEffect(() => {
192
+ if (prevOpenRef.current === isOpen) return;
193
+ prevOpenRef.current = isOpen;
194
+
195
+ const el = outerRef.current;
196
+ const inner = innerRef.current;
197
+ if (!el || !inner) return;
198
+
199
+ if (reduced) {
200
+ el.style.height = isOpen ? 'auto' : '0px';
201
+ el.style.overflow = isOpen ? 'visible' : 'hidden';
202
+ return;
203
+ }
204
+
205
+ if (isAnimatingRef.current) {
206
+ // Cancel mid-animation: snapshot current rendered height
207
+ const currentHeight = el.getBoundingClientRect().height;
208
+ el.style.transition = 'none';
209
+ el.style.height = `${currentHeight}px`;
210
+ // Force reflow
211
+ void el.offsetHeight;
212
+ }
213
+
214
+ isAnimatingRef.current = true;
215
+ el.style.overflow = 'hidden';
216
+
217
+ if (isOpen) {
218
+ // Animate 0 → scrollHeight
219
+ el.style.height = '0px';
220
+ el.style.transition = 'height 280ms cubic-bezier(0.4, 0, 0.2, 1)';
221
+ requestAnimationFrame(() => {
222
+ requestAnimationFrame(() => {
223
+ el.style.height = `${inner.scrollHeight}px`;
224
+ });
225
+ });
226
+ } else {
227
+ // Animate scrollHeight → 0
228
+ el.style.height = `${inner.scrollHeight}px`;
229
+ el.style.transition = 'height 280ms cubic-bezier(0.4, 0, 0.2, 1)';
230
+ requestAnimationFrame(() => {
231
+ requestAnimationFrame(() => {
232
+ el.style.height = '0px';
233
+ });
234
+ });
235
+ }
236
+ }, [isOpen, reduced]);
237
+
238
+ const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
239
+ if (e.propertyName !== 'height') return;
240
+ isAnimatingRef.current = false;
241
+ const el = outerRef.current;
242
+ if (!el) return;
243
+ if (isOpen) {
244
+ el.style.height = 'auto';
245
+ el.style.overflow = 'visible';
246
+ } else {
247
+ el.style.overflow = 'hidden';
248
+ }
249
+ };
250
+
251
+ return (
252
+ <div
253
+ ref={outerRef}
254
+ id={id}
255
+ role="region"
256
+ aria-labelledby={triggerId}
257
+ onTransitionEnd={handleTransitionEnd}
258
+ style={{ willChange: 'height' }}
259
+ >
260
+ <div ref={innerRef}>
261
+ {children}
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ // ── Main Component ────────────────────────────────────────────────────────────
268
+
269
+ export function TkxAccordion({
270
+ items,
271
+ multiple = false,
272
+ defaultOpen,
273
+ value: valueProp,
274
+ onChange,
275
+ variant = 'default',
276
+ size = 'md',
277
+ iconPosition = 'right',
278
+ iconStyle = 'chevron',
279
+ flush = false,
280
+ className,
281
+ style,
282
+ }: TkxAccordionProps) {
283
+ const theme = useTheme();
284
+ const reduced = useReducedMotion();
285
+ const baseId = useId();
286
+
287
+ // Controlled vs uncontrolled
288
+ const isControlled = valueProp !== undefined;
289
+
290
+ const normalizeInitial = (v: string | string[] | undefined): Set<string> => {
291
+ if (!v) return new Set();
292
+ if (Array.isArray(v)) return new Set(v);
293
+ return new Set([v]);
294
+ };
295
+
296
+ const [internalOpen, setInternalOpen] = useState<Set<string>>(() =>
297
+ normalizeInitial(defaultOpen)
298
+ );
299
+
300
+ const openIds: Set<string> = isControlled
301
+ ? normalizeInitial(valueProp)
302
+ : internalOpen;
303
+
304
+ const toggle = useCallback(
305
+ (id: string, disabled?: boolean) => {
306
+ if (disabled) return;
307
+
308
+ let next: Set<string>;
309
+ if (isControlled) {
310
+ const current = normalizeInitial(valueProp);
311
+ next = new Set(current);
312
+ if (next.has(id)) {
313
+ next.delete(id);
314
+ } else {
315
+ if (!multiple) next.clear();
316
+ next.add(id);
317
+ }
318
+ } else {
319
+ setInternalOpen((prev) => {
320
+ next = new Set(prev);
321
+ if (next.has(id)) {
322
+ next.delete(id);
323
+ } else {
324
+ if (!multiple) next.clear();
325
+ next.add(id);
326
+ }
327
+ const arr = [...next];
328
+ onChange?.(multiple ? arr : arr[0] ?? '');
329
+ return next;
330
+ });
331
+ return;
332
+ }
333
+
334
+ const arr = [...next];
335
+ onChange?.(multiple ? arr : arr[0] ?? '');
336
+ },
337
+ // eslint-disable-next-line react-hooks/exhaustive-deps
338
+ [multiple, onChange, isControlled, valueProp]
339
+ );
340
+
341
+ const sz = SIZE_CONFIG[size];
342
+
343
+ // Variant-specific wrapper styles
344
+ const getWrapperStyle = (): CSSProperties => {
345
+ if (flush) return {};
346
+ switch (variant) {
347
+ case 'bordered':
348
+ return {
349
+ border: `1px solid ${theme.border}`,
350
+ borderRadius: '10px',
351
+ overflow: 'hidden',
352
+ };
353
+ case 'separated':
354
+ return {};
355
+ case 'ghost':
356
+ case 'flush':
357
+ return {};
358
+ default: // 'default'
359
+ return {};
360
+ }
361
+ };
362
+
363
+ // Item wrapper styles
364
+ const getItemStyle = (idx: number, isOpen: boolean, isLast: boolean): CSSProperties => {
365
+ if (flush) return {};
366
+ switch (variant) {
367
+ case 'default':
368
+ return {
369
+ borderBottom: isLast ? 'none' : `1px solid ${theme.border}`,
370
+ };
371
+ case 'bordered':
372
+ return {
373
+ borderBottom: isLast ? 'none' : `1px solid ${theme.border}`,
374
+ };
375
+ case 'separated':
376
+ return {
377
+ border: `1px solid ${theme.border}`,
378
+ borderRadius: '10px',
379
+ overflow: 'hidden',
380
+ boxShadow: `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`,
381
+ };
382
+ case 'ghost':
383
+ return {};
384
+ default:
385
+ return {};
386
+ }
387
+ void idx; void isOpen;
388
+ };
389
+
390
+ // Trigger hover style
391
+ const getTriggerHoverBg = (isOpen: boolean): string => {
392
+ if (variant === 'ghost' || variant === 'flush') {
393
+ return isOpen ? `${theme.primary}10` : `${theme.surfaceAlt}80`;
394
+ }
395
+ return isOpen ? `${theme.primary}0d` : `${theme.surfaceAlt}60`;
396
+ };
397
+
398
+ const isSeparatedVariant = variant === 'separated';
399
+
400
+ return (
401
+ <div
402
+ className={tkx(className ?? '')}
403
+ style={{
404
+ ...getWrapperStyle(),
405
+ display: isSeparatedVariant ? 'flex' : undefined,
406
+ flexDirection: isSeparatedVariant ? 'column' : undefined,
407
+ gap: isSeparatedVariant ? '8px' : undefined,
408
+ ...style,
409
+ }}
410
+ >
411
+ {items.map((item, idx) => {
412
+ const isOpen = openIds.has(item.id);
413
+ const isLast = idx === items.length - 1;
414
+ const triggerId = `${baseId}-trigger-${item.id}`;
415
+ const panelId = `${baseId}-panel-${item.id}`;
416
+
417
+ return (
418
+ <div
419
+ key={item.id}
420
+ className={item.className}
421
+ style={getItemStyle(idx, isOpen, isLast)}
422
+ >
423
+ {/* Trigger button */}
424
+ <button
425
+ id={triggerId}
426
+ type="button"
427
+ role="button"
428
+ aria-expanded={isOpen}
429
+ aria-controls={panelId}
430
+ disabled={item.disabled}
431
+ onClick={() => toggle(item.id, item.disabled)}
432
+ style={{
433
+ width: '100%',
434
+ display: 'flex',
435
+ alignItems: item.subtitle ? 'flex-start' : 'center',
436
+ gap: '8px',
437
+ padding: sz.triggerPadding,
438
+ border: 'none',
439
+ background: 'transparent',
440
+ cursor: item.disabled ? 'not-allowed' : 'pointer',
441
+ textAlign: 'left',
442
+ fontFamily: 'inherit',
443
+ fontSize: sz.fontSize,
444
+ fontWeight: 500,
445
+ color: item.disabled ? theme.textMuted : theme.text,
446
+ opacity: item.disabled ? 0.5 : 1,
447
+ transition: reduced ? 'none' : 'background-color 160ms ease',
448
+ outline: 'none',
449
+ }}
450
+ onMouseEnter={(e) => {
451
+ if (!item.disabled) {
452
+ (e.currentTarget as HTMLButtonElement).style.backgroundColor = getTriggerHoverBg(isOpen);
453
+ }
454
+ }}
455
+ onMouseLeave={(e) => {
456
+ (e.currentTarget as HTMLButtonElement).style.backgroundColor = isOpen ? `${theme.primary}0d` : 'transparent';
457
+ }}
458
+ onFocus={(e) => {
459
+ if (!item.disabled) {
460
+ e.currentTarget.style.outline = `2px solid ${theme.primary}`;
461
+ e.currentTarget.style.outlineOffset = '2px';
462
+ }
463
+ }}
464
+ onBlur={(e) => {
465
+ e.currentTarget.style.outline = 'none';
466
+ }}
467
+ >
468
+ {/* Left icon (item icon) */}
469
+ {item.icon && (
470
+ <span
471
+ aria-hidden="true"
472
+ style={{
473
+ display: 'flex',
474
+ alignItems: 'center',
475
+ flexShrink: 0,
476
+ color: theme.primary,
477
+ marginTop: item.subtitle ? '1px' : undefined,
478
+ }}
479
+ >
480
+ {item.icon}
481
+ </span>
482
+ )}
483
+
484
+ {/* Left chevron position */}
485
+ {iconPosition === 'left' && (
486
+ <span
487
+ aria-hidden="true"
488
+ style={{
489
+ display: 'flex',
490
+ alignItems: 'center',
491
+ flexShrink: 0,
492
+ color: isOpen ? theme.primary : theme.textMuted,
493
+ marginTop: item.subtitle ? '1px' : undefined,
494
+ }}
495
+ >
496
+ <AccordionIcon
497
+ open={isOpen}
498
+ reduced={reduced}
499
+ iconStyle={iconStyle}
500
+ size={sz.iconSize}
501
+ color={isOpen ? theme.primary : theme.textMuted}
502
+ />
503
+ </span>
504
+ )}
505
+
506
+ {/* Title + subtitle block */}
507
+ <span style={{ flex: 1, minWidth: 0 }}>
508
+ <span style={{ display: 'block', lineHeight: 1.4 }}>{item.title}</span>
509
+ {item.subtitle && (
510
+ <span
511
+ style={{
512
+ display: 'block',
513
+ fontSize: sz.subtitleSize,
514
+ color: theme.textMuted,
515
+ fontWeight: 400,
516
+ marginTop: '2px',
517
+ lineHeight: 1.3,
518
+ }}
519
+ >
520
+ {item.subtitle}
521
+ </span>
522
+ )}
523
+ </span>
524
+
525
+ {/* Badge */}
526
+ {item.badge && (
527
+ <span
528
+ aria-hidden="true"
529
+ style={{
530
+ display: 'flex',
531
+ alignItems: 'center',
532
+ flexShrink: 0,
533
+ }}
534
+ >
535
+ {item.badge}
536
+ </span>
537
+ )}
538
+
539
+ {/* Right chevron position (default) */}
540
+ {iconPosition === 'right' && (
541
+ <span
542
+ aria-hidden="true"
543
+ style={{
544
+ display: 'flex',
545
+ alignItems: 'center',
546
+ flexShrink: 0,
547
+ color: isOpen ? theme.primary : theme.textMuted,
548
+ marginTop: item.subtitle ? '1px' : undefined,
549
+ }}
550
+ >
551
+ <AccordionIcon
552
+ open={isOpen}
553
+ reduced={reduced}
554
+ iconStyle={iconStyle}
555
+ size={sz.iconSize}
556
+ color={isOpen ? theme.primary : theme.textMuted}
557
+ />
558
+ </span>
559
+ )}
560
+ </button>
561
+
562
+ {/* Animated content panel */}
563
+ <AnimatedPanel
564
+ isOpen={isOpen}
565
+ reduced={reduced}
566
+ id={panelId}
567
+ triggerId={triggerId}
568
+ >
569
+ <div
570
+ style={{
571
+ padding: sz.contentPadding,
572
+ fontSize: sz.fontSize,
573
+ lineHeight: 1.6,
574
+ color: theme.textMuted,
575
+ fontFamily: 'inherit',
576
+ }}
577
+ >
578
+ {item.content}
579
+ </div>
580
+ </AnimatedPanel>
581
+ </div>
582
+ );
583
+ })}
584
+ </div>
585
+ );
586
+ }
587
+
588
+ TkxAccordion.displayName = 'TkxAccordion';
@@ -0,0 +1,91 @@
1
+ import { type ReactNode, useEffect } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useAnnounce } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ export type AlertVariant = 'info' | 'success' | 'warning' | 'danger';
8
+
9
+ export interface TkxAlertProps {
10
+ variant: AlertVariant;
11
+ title?: string;
12
+ children: ReactNode;
13
+ dismissible?: boolean;
14
+ onDismiss?: () => void;
15
+ icon?: ReactNode;
16
+ style?: React.CSSProperties;
17
+ className?: string;
18
+ }
19
+
20
+ const ICONS: Record<AlertVariant, ReactNode> = {
21
+ info: (
22
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
23
+ <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-6h2v6zm0-8h-2V7h2v2z" />
24
+ </svg>
25
+ ),
26
+ success: (
27
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
28
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-2 14.5l-4-4 1.41-1.41L10 13.67l6.59-6.59L18 8.5l-8 8z" />
29
+ </svg>
30
+ ),
31
+ warning: (
32
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
33
+ <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
34
+ </svg>
35
+ ),
36
+ danger: (
37
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
38
+ <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" />
39
+ </svg>
40
+ ),
41
+ };
42
+
43
+ export function TkxAlert({ variant, title, children, dismissible, onDismiss, icon, style, className }: TkxAlertProps) {
44
+ const theme = useTheme();
45
+ const announce = useAnnounce();
46
+ const safeTitle = title ? sanitizeString(title) : undefined;
47
+
48
+ useEffect(() => {
49
+ if (safeTitle) {
50
+ announce(safeTitle, variant === 'danger' || variant === 'warning' ? 'assertive' : 'polite');
51
+ }
52
+ }, [safeTitle, variant, announce]);
53
+
54
+ const colorMap: Record<AlertVariant, string> = {
55
+ info: theme.info, success: theme.success, warning: theme.warning, danger: theme.danger,
56
+ };
57
+ const accentColor = colorMap[variant];
58
+ const role = variant === 'danger' || variant === 'warning' ? 'alert' : 'status';
59
+
60
+ return (
61
+ <div
62
+ role={role}
63
+ className={tkx('flex items-start gap-3 px-4 py-3 rounded-lg border animate-fade-in font-sans', className ?? '')}
64
+ style={{ backgroundColor: `${accentColor}18`, borderColor: accentColor, color: theme.text, ...style }}
65
+ >
66
+ <span className={tkx('shrink-0 mt-[1px]')} style={{ color: accentColor }}>
67
+ {icon ?? ICONS[variant]}
68
+ </span>
69
+
70
+ <div className={tkx('flex-1 min-w-0')}>
71
+ {safeTitle && (
72
+ <p className={tkx('m-0 font-semibold text-sm mb-1')}>{safeTitle}</p>
73
+ )}
74
+ <div className={tkx('text-sm leading-relaxed')}>{children}</div>
75
+ </div>
76
+
77
+ {dismissible && onDismiss && (
78
+ <button
79
+ aria-label="Dismiss alert"
80
+ onClick={onDismiss}
81
+ className={tkx('bg-transparent border-none cursor-pointer rounded p-[2px] shrink-0 flex items-center justify-center focus-visible:focus-ring')}
82
+ style={{ color: theme.textMuted }}
83
+ >
84
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
85
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
86
+ </svg>
87
+ </button>
88
+ )}
89
+ </div>
90
+ );
91
+ }