tekivex-ui 2.0.1 → 2.1.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 (79) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +12 -12
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2207 -2018
  6. package/dist/src/components/TkxAppBar.d.ts +12 -0
  7. package/dist/src/components/TkxAppBar.d.ts.map +1 -0
  8. package/dist/src/components/TkxAutocomplete.d.ts +25 -0
  9. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
  10. package/dist/src/components/TkxBottomNav.d.ts +15 -0
  11. package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
  12. package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
  13. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
  14. package/dist/src/components/TkxCard.d.ts.map +1 -1
  15. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  16. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  17. package/dist/src/components/TkxMasonry.d.ts +12 -0
  18. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  19. package/dist/src/components/TkxModal.d.ts.map +1 -1
  20. package/dist/src/components/TkxPopover.d.ts +15 -0
  21. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  22. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  23. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  24. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  25. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  26. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  27. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  28. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  29. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  30. package/dist/src/components/TkxToolbar.d.ts +22 -0
  31. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  32. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  33. package/dist/src/components/TkxTransferList.d.ts +20 -0
  34. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  35. package/dist/src/components/TkxTreeView.d.ts +23 -0
  36. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  37. package/dist/src/components/index.d.ts +13 -0
  38. package/dist/src/components/index.d.ts.map +1 -1
  39. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  40. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  41. package/dist/src/i18n/index.d.ts +50 -0
  42. package/dist/src/i18n/index.d.ts.map +1 -0
  43. package/package.json +2 -2
  44. package/src/components/TkxAppBar.tsx +260 -0
  45. package/src/components/TkxAutocomplete.tsx +453 -0
  46. package/src/components/TkxBottomNav.tsx +239 -0
  47. package/src/components/TkxBreadcrumb.tsx +322 -0
  48. package/src/components/TkxCard.tsx +3 -1
  49. package/src/components/TkxDataGrid.tsx +540 -0
  50. package/src/components/TkxMasonry.tsx +176 -0
  51. package/src/components/TkxModal.tsx +4 -3
  52. package/src/components/TkxPopover.tsx +295 -0
  53. package/src/components/TkxRichTextDisplay.tsx +394 -0
  54. package/src/components/TkxSelect.tsx +2 -0
  55. package/src/components/TkxSlider.tsx +1 -0
  56. package/src/components/TkxSnackbar.tsx +314 -0
  57. package/src/components/TkxSpeedDial.tsx +395 -0
  58. package/src/components/TkxToolbar.tsx +260 -0
  59. package/src/components/TkxTooltip.tsx +3 -2
  60. package/src/components/TkxTransferList.tsx +487 -0
  61. package/src/components/TkxTreeView.tsx +421 -0
  62. package/src/components/index.ts +13 -0
  63. package/src/hooks/index.ts +1 -1
  64. package/src/i18n/I18nProvider.tsx +36 -0
  65. package/src/i18n/index.ts +237 -0
  66. package/dist/tests/TkxButton.test.d.ts +0 -2
  67. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  68. package/dist/tests/a11y/components.test.d.ts +0 -2
  69. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  70. package/dist/tests/quantum.test.d.ts +0 -2
  71. package/dist/tests/quantum.test.d.ts.map +0 -1
  72. package/dist/tests/security.test.d.ts +0 -2
  73. package/dist/tests/security.test.d.ts.map +0 -1
  74. package/dist/tests/setup.d.ts +0 -2
  75. package/dist/tests/setup.d.ts.map +0 -1
  76. package/dist/tests/tkx.test.d.ts +0 -2
  77. package/dist/tests/tkx.test.d.ts.map +0 -1
  78. package/dist/tests/wcag.test.d.ts +0 -2
  79. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -0,0 +1,239 @@
1
+ import {
2
+ useCallback,
3
+ useRef,
4
+ useEffect,
5
+ type ReactNode,
6
+ type KeyboardEvent,
7
+ } from 'react';
8
+ import { useTheme } from '../themes';
9
+ import { sanitizeString } from '../engine/security';
10
+ import { useReducedMotion } from '../hooks';
11
+ import { tkx } from '../engine/tkx';
12
+
13
+ // ── Types ─────────────────────────────────────────────────────────────────────
14
+
15
+ export interface BottomNavItem {
16
+ id: string;
17
+ label: string;
18
+ icon: ReactNode;
19
+ badge?: number;
20
+ }
21
+
22
+ export interface TkxBottomNavProps {
23
+ items: BottomNavItem[];
24
+ activeId?: string;
25
+ onChange?: (id: string) => void;
26
+ showLabels?: boolean;
27
+ }
28
+
29
+ // ── Constants ─────────────────────────────────────────────────────────────────
30
+
31
+ const MAX_ITEMS = 5;
32
+ const NAV_HEIGHT = 56;
33
+
34
+ // ── Keyframe injection ──────────────────────────────────────────────────────
35
+
36
+ let injected = false;
37
+ function injectKeyframes() {
38
+ if (injected || typeof document === 'undefined') return;
39
+ injected = true;
40
+ const style = document.createElement('style');
41
+ style.id = 'tkx-bottom-nav-kf';
42
+ style.textContent = `
43
+ @keyframes tkx-bottom-nav-pulse {
44
+ 0% { transform: scale(1); }
45
+ 50% { transform: scale(1.15); }
46
+ 100% { transform: scale(1); }
47
+ }
48
+ `;
49
+ document.head.appendChild(style);
50
+ }
51
+
52
+ // ── Component ─────────────────────────────────────────────────────────────────
53
+
54
+ export function TkxBottomNav({
55
+ items,
56
+ activeId,
57
+ onChange,
58
+ showLabels = true,
59
+ }: TkxBottomNavProps) {
60
+ const theme = useTheme();
61
+ const reduced = useReducedMotion();
62
+ const navRef = useRef<HTMLElement>(null);
63
+
64
+ // Inject keyframes once
65
+ useEffect(() => {
66
+ injectKeyframes();
67
+ }, []);
68
+
69
+ // Clamp to max 5 items
70
+ const visibleItems = items.slice(0, MAX_ITEMS);
71
+
72
+ const handleSelect = useCallback(
73
+ (id: string) => {
74
+ onChange?.(id);
75
+ },
76
+ [onChange],
77
+ );
78
+
79
+ // ── Keyboard navigation (left/right arrows) ────────────────────────────
80
+
81
+ const handleKeyDown = useCallback(
82
+ (e: KeyboardEvent<HTMLElement>) => {
83
+ const nav = navRef.current;
84
+ if (!nav) return;
85
+
86
+ const buttons = Array.from(
87
+ nav.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
88
+ );
89
+ const currentIndex = buttons.findIndex(
90
+ (btn) => btn === document.activeElement,
91
+ );
92
+ if (currentIndex === -1) return;
93
+
94
+ let nextIndex = currentIndex;
95
+
96
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
97
+ e.preventDefault();
98
+ nextIndex = (currentIndex + 1) % buttons.length;
99
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
100
+ e.preventDefault();
101
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
102
+ } else if (e.key === 'Home') {
103
+ e.preventDefault();
104
+ nextIndex = 0;
105
+ } else if (e.key === 'End') {
106
+ e.preventDefault();
107
+ nextIndex = buttons.length - 1;
108
+ }
109
+
110
+ if (nextIndex !== currentIndex) {
111
+ buttons[nextIndex].focus();
112
+ handleSelect(visibleItems[nextIndex].id);
113
+ }
114
+ },
115
+ [handleSelect, visibleItems],
116
+ );
117
+
118
+ // ── Transition ──────────────────────────────────────────────────────────
119
+
120
+ const transition = reduced ? 'none' : 'color 200ms ease, transform 150ms ease';
121
+
122
+ return (
123
+ <nav
124
+ ref={navRef}
125
+ role="navigation"
126
+ aria-label="Bottom navigation"
127
+ className={tkx('fixed bottom-0 left-0 right-0 z-[1000] font-sans')}
128
+ style={{
129
+ height: NAV_HEIGHT,
130
+ backgroundColor: theme.surface,
131
+ borderTop: `1px solid ${theme.border}`,
132
+ boxShadow: `0 -2px 12px ${theme.bg}44`,
133
+ }}
134
+ >
135
+ <div
136
+ role="tablist"
137
+ aria-label="Navigation tabs"
138
+ className={tkx('flex items-center justify-around h-full')}
139
+ onKeyDown={handleKeyDown}
140
+ >
141
+ {visibleItems.map((item) => {
142
+ const isActive = item.id === activeId;
143
+ const safeLabel = sanitizeString(item.label);
144
+ const color = isActive ? theme.primary : theme.textMuted;
145
+
146
+ return (
147
+ <button
148
+ key={item.id}
149
+ role="tab"
150
+ aria-selected={isActive}
151
+ aria-label={safeLabel}
152
+ tabIndex={isActive ? 0 : -1}
153
+ onClick={() => handleSelect(item.id)}
154
+ className={tkx(
155
+ 'flex flex-col items-center justify-center gap-0.5',
156
+ 'bg-transparent border-none cursor-pointer p-1',
157
+ 'flex-1 h-full relative',
158
+ 'focus-visible:focus-ring',
159
+ )}
160
+ style={{
161
+ color,
162
+ transition,
163
+ outline: 'none',
164
+ }}
165
+ >
166
+ {/* Active indicator bar */}
167
+ {isActive && (
168
+ <div
169
+ aria-hidden="true"
170
+ style={{
171
+ position: 'absolute',
172
+ top: 0,
173
+ left: '25%',
174
+ right: '25%',
175
+ height: 3,
176
+ borderRadius: '0 0 3px 3px',
177
+ backgroundColor: theme.primary,
178
+ transition: reduced ? 'none' : 'width 200ms ease',
179
+ }}
180
+ />
181
+ )}
182
+
183
+ {/* Icon wrapper with badge */}
184
+ <div
185
+ className={tkx('relative flex items-center justify-center')}
186
+ style={{
187
+ animation:
188
+ isActive && !reduced
189
+ ? 'tkx-bottom-nav-pulse 300ms ease-out'
190
+ : 'none',
191
+ }}
192
+ >
193
+ {item.icon}
194
+
195
+ {/* Badge */}
196
+ {item.badge !== undefined && item.badge > 0 && (
197
+ <span
198
+ aria-label={`${item.badge} notifications`}
199
+ className={tkx(
200
+ 'absolute flex items-center justify-center',
201
+ 'text-[10px] font-bold leading-none rounded-full',
202
+ )}
203
+ style={{
204
+ top: -4,
205
+ right: -8,
206
+ minWidth: 16,
207
+ height: 16,
208
+ padding: '0 4px',
209
+ backgroundColor: theme.danger,
210
+ color: theme.bg,
211
+ }}
212
+ >
213
+ {item.badge > 99 ? '99+' : item.badge}
214
+ </span>
215
+ )}
216
+ </div>
217
+
218
+ {/* Label */}
219
+ {showLabels && (
220
+ <span
221
+ className={tkx('text-[10px] leading-tight mt-0.5')}
222
+ style={{
223
+ fontWeight: isActive ? 600 : 400,
224
+ maxWidth: '100%',
225
+ overflow: 'hidden',
226
+ textOverflow: 'ellipsis',
227
+ whiteSpace: 'nowrap',
228
+ }}
229
+ >
230
+ {safeLabel}
231
+ </span>
232
+ )}
233
+ </button>
234
+ );
235
+ })}
236
+ </div>
237
+ </nav>
238
+ );
239
+ }
@@ -0,0 +1,322 @@
1
+ import {
2
+ useCallback,
3
+ type ReactNode,
4
+ type CSSProperties,
5
+ type KeyboardEvent,
6
+ } from 'react';
7
+ import { useTheme } from '../themes';
8
+ import { sanitizeString } from '../engine/security';
9
+ import { useReducedMotion } from '../hooks';
10
+ import { tkx } from '../engine/tkx';
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────────────
13
+
14
+ export interface BreadcrumbItem {
15
+ label: string;
16
+ href?: string;
17
+ icon?: ReactNode;
18
+ }
19
+
20
+ export interface TkxBreadcrumbProps {
21
+ items: BreadcrumbItem[];
22
+ separator?: ReactNode;
23
+ maxItems?: number;
24
+ onNavigate?: (item: BreadcrumbItem, index: number) => void;
25
+ className?: string;
26
+ style?: CSSProperties;
27
+ }
28
+
29
+ // ── Chevron separator icon ──────────────────────────────────────────────────
30
+
31
+ function DefaultSeparator({ color }: { color: string }) {
32
+ return (
33
+ <svg
34
+ width="16"
35
+ height="16"
36
+ viewBox="0 0 16 16"
37
+ fill="none"
38
+ aria-hidden="true"
39
+ style={{ flexShrink: 0 }}
40
+ >
41
+ <path
42
+ d="M6 3l5 5-5 5"
43
+ stroke={color}
44
+ strokeWidth="1.5"
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ />
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ // ── Ellipsis button ─────────────────────────────────────────────────────────
53
+
54
+ function EllipsisButton({
55
+ onClick,
56
+ bgColor,
57
+ hoverColor,
58
+ textColor,
59
+ }: {
60
+ onClick: () => void;
61
+ bgColor: string;
62
+ hoverColor: string;
63
+ textColor: string;
64
+ }) {
65
+ const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
66
+ if (e.key === 'Enter' || e.key === ' ') {
67
+ e.preventDefault();
68
+ onClick();
69
+ }
70
+ };
71
+
72
+ return (
73
+ <button
74
+ type="button"
75
+ onClick={onClick}
76
+ onKeyDown={handleKeyDown}
77
+ aria-label="Show hidden breadcrumb items"
78
+ className={tkx(
79
+ 'inline-flex items-center justify-center rounded px-1.5 py-0.5',
80
+ 'text-sm font-medium cursor-pointer border-none outline-none',
81
+ 'focus-visible:ring-2 focus-visible:ring-offset-1',
82
+ )}
83
+ style={{
84
+ backgroundColor: bgColor,
85
+ color: textColor,
86
+ transition: 'background-color 150ms ease',
87
+ }}
88
+ onMouseEnter={(e) => {
89
+ (e.currentTarget as HTMLElement).style.backgroundColor = hoverColor;
90
+ }}
91
+ onMouseLeave={(e) => {
92
+ (e.currentTarget as HTMLElement).style.backgroundColor = bgColor;
93
+ }}
94
+ >
95
+ &hellip;
96
+ </button>
97
+ );
98
+ }
99
+
100
+ // ── Component ───────────────────────────────────────────────────────────────
101
+
102
+ export function TkxBreadcrumb({
103
+ items,
104
+ separator,
105
+ maxItems,
106
+ onNavigate,
107
+ className,
108
+ style,
109
+ }: TkxBreadcrumbProps) {
110
+ const theme = useTheme();
111
+ const reducedMotion = useReducedMotion();
112
+
113
+ // Collapse logic: show first, last, and ellipsis when items exceed maxItems
114
+ const shouldCollapse = maxItems !== undefined && maxItems > 1 && items.length > maxItems;
115
+
116
+ const getVisibleItems = useCallback((): { item: BreadcrumbItem; originalIndex: number }[] => {
117
+ if (!shouldCollapse || !maxItems) {
118
+ return items.map((item, i) => ({ item, originalIndex: i }));
119
+ }
120
+
121
+ const headCount = Math.ceil((maxItems - 1) / 2);
122
+ const tailCount = maxItems - 1 - headCount;
123
+
124
+ const head = items.slice(0, headCount).map((item, i) => ({
125
+ item,
126
+ originalIndex: i,
127
+ }));
128
+
129
+ const tail = items.slice(items.length - tailCount).map((item, i) => ({
130
+ item,
131
+ originalIndex: items.length - tailCount + i,
132
+ }));
133
+
134
+ return head.concat(tail);
135
+ }, [items, maxItems, shouldCollapse]);
136
+
137
+ const visibleItems = getVisibleItems();
138
+
139
+ // Where to insert the ellipsis
140
+ const headCount = shouldCollapse && maxItems ? Math.ceil((maxItems - 1) / 2) : -1;
141
+
142
+ const handleClick = (item: BreadcrumbItem, index: number) => {
143
+ onNavigate?.(item, index);
144
+ };
145
+
146
+ const handleKeyDown = (e: KeyboardEvent<HTMLElement>, item: BreadcrumbItem, index: number) => {
147
+ if (e.key === 'Enter' || e.key === ' ') {
148
+ e.preventDefault();
149
+ handleClick(item, index);
150
+ }
151
+ };
152
+
153
+ const transitionStyle: CSSProperties = reducedMotion
154
+ ? {}
155
+ : { transition: 'color 150ms ease, background-color 150ms ease' };
156
+
157
+ const renderSeparator = (key: string) => (
158
+ <li
159
+ key={key}
160
+ role="presentation"
161
+ aria-hidden="true"
162
+ className={tkx('flex items-center mx-1.5')}
163
+ >
164
+ {separator ?? <DefaultSeparator color={theme.textMuted} />}
165
+ </li>
166
+ );
167
+
168
+ const renderItem = (
169
+ item: BreadcrumbItem,
170
+ originalIndex: number,
171
+ isLast: boolean,
172
+ ) => {
173
+ const safeLabel = sanitizeString(item.label);
174
+
175
+ if (isLast) {
176
+ return (
177
+ <li key={`item-${originalIndex}`}>
178
+ <span
179
+ aria-current="page"
180
+ className={tkx('inline-flex items-center gap-1.5 text-sm font-medium')}
181
+ style={{ color: theme.text }}
182
+ >
183
+ {item.icon && (
184
+ <span aria-hidden="true" className={tkx('flex-shrink-0')}>
185
+ {item.icon}
186
+ </span>
187
+ )}
188
+ {safeLabel}
189
+ </span>
190
+ </li>
191
+ );
192
+ }
193
+
194
+ const isLink = !!item.href;
195
+
196
+ const commonStyle: CSSProperties = {
197
+ color: theme.textMuted,
198
+ textDecoration: 'none',
199
+ ...transitionStyle,
200
+ };
201
+
202
+ const hoverHandlers = {
203
+ onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
204
+ (e.currentTarget as HTMLElement).style.color = theme.primary;
205
+ },
206
+ onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
207
+ (e.currentTarget as HTMLElement).style.color = theme.textMuted;
208
+ },
209
+ };
210
+
211
+ const content = (
212
+ <>
213
+ {item.icon && (
214
+ <span aria-hidden="true" className={tkx('flex-shrink-0')}>
215
+ {item.icon}
216
+ </span>
217
+ )}
218
+ {safeLabel}
219
+ </>
220
+ );
221
+
222
+ if (isLink) {
223
+ return (
224
+ <li key={`item-${originalIndex}`}>
225
+ <a
226
+ href={item.href}
227
+ onClick={(e) => {
228
+ if (onNavigate) {
229
+ e.preventDefault();
230
+ handleClick(item, originalIndex);
231
+ }
232
+ }}
233
+ className={tkx(
234
+ 'inline-flex items-center gap-1.5 text-sm rounded',
235
+ 'outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
236
+ )}
237
+ style={{
238
+ ...commonStyle,
239
+ focusRingColor: theme.primary,
240
+ } as CSSProperties}
241
+ {...hoverHandlers}
242
+ >
243
+ {content}
244
+ </a>
245
+ </li>
246
+ );
247
+ }
248
+
249
+ return (
250
+ <li key={`item-${originalIndex}`}>
251
+ <button
252
+ type="button"
253
+ onClick={() => handleClick(item, originalIndex)}
254
+ onKeyDown={(e) => handleKeyDown(e, item, originalIndex)}
255
+ className={tkx(
256
+ 'inline-flex items-center gap-1.5 text-sm rounded',
257
+ 'border-none bg-transparent cursor-pointer p-0',
258
+ 'outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
259
+ )}
260
+ style={commonStyle}
261
+ {...hoverHandlers}
262
+ >
263
+ {content}
264
+ </button>
265
+ </li>
266
+ );
267
+ };
268
+
269
+ // Build the rendered list
270
+ const rendered: ReactNode[] = [];
271
+ let ellipsisInserted = false;
272
+
273
+ for (let vi = 0; vi < visibleItems.length; vi++) {
274
+ const { item, originalIndex } = visibleItems[vi];
275
+ const isLast = originalIndex === items.length - 1;
276
+
277
+ // Insert ellipsis after headCount items
278
+ if (shouldCollapse && !ellipsisInserted && vi === headCount) {
279
+ if (rendered.length > 0) {
280
+ rendered.push(renderSeparator(`sep-ellipsis-before`));
281
+ }
282
+ rendered.push(
283
+ <li key="ellipsis" role="presentation">
284
+ <EllipsisButton
285
+ onClick={() => {
286
+ // Re-render without collapse -- parent can control maxItems
287
+ }}
288
+ bgColor={theme.surfaceAlt}
289
+ hoverColor={theme.surface}
290
+ textColor={theme.textMuted}
291
+ />
292
+ </li>,
293
+ );
294
+ ellipsisInserted = true;
295
+ }
296
+
297
+ if (rendered.length > 0 && rendered[rendered.length - 1] !== null) {
298
+ rendered.push(renderSeparator(`sep-${originalIndex}`));
299
+ }
300
+
301
+ rendered.push(renderItem(item, originalIndex, isLast));
302
+ }
303
+
304
+ return (
305
+ <nav
306
+ aria-label="Breadcrumb"
307
+ className={tkx('font-sans', className ?? '')}
308
+ style={{
309
+ ...style,
310
+ }}
311
+ >
312
+ <ol
313
+ role="list"
314
+ className={tkx('flex items-center flex-wrap list-none m-0 p-0')}
315
+ >
316
+ {rendered}
317
+ </ol>
318
+ </nav>
319
+ );
320
+ }
321
+
322
+ export default TkxBreadcrumb;
@@ -1,5 +1,6 @@
1
1
  import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
2
2
  import { useTheme } from '../themes';
3
+ import { useReducedMotion } from '../hooks';
3
4
  import { tkx, cx } from '../engine/tkx';
4
5
 
5
6
  export type CardVariant = 'default' | 'glass' | 'quantum' | 'elevated' | 'outlined';
@@ -20,6 +21,7 @@ const PADDING_CLASS: Record<CardPadding, string> = {
20
21
  export const TkxCard = forwardRef<HTMLElement, TkxCardProps>(
21
22
  ({ variant = 'default', isHoverable, isClickable, padding = 'md', as, children, className, style, ...rest }, ref) => {
22
23
  const theme = useTheme();
24
+ const reducedMotion = useReducedMotion();
23
25
  const Tag = (as ?? (isClickable ? 'button' : 'div')) as 'div';
24
26
 
25
27
  const variantStyle: React.CSSProperties = {
@@ -33,7 +35,7 @@ export const TkxCard = forwardRef<HTMLElement, TkxCardProps>(
33
35
  const base = tkx(
34
36
  'rounded-xl w-full text-left block font-sans focus-visible:focus-ring',
35
37
  PADDING_CLASS[padding],
36
- (isHoverable || isClickable) && 'transition-transform duration-150 hover:scale-[1.01]',
38
+ (isHoverable || isClickable) && !reducedMotion && 'transition-transform duration-150 hover:scale-[1.01]',
37
39
  isClickable ? 'cursor-pointer' : 'cursor-default',
38
40
  );
39
41