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,124 @@
1
+ import { createContext, useContext, useState, useRef, useId, type ReactNode, type HTMLAttributes } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { handleTabsKeyboard } from '../engine/wcag';
4
+ import { tkx, cx } from '../engine/tkx';
5
+
6
+ interface TabsContextValue {
7
+ activeIndex: number;
8
+ setActiveIndex: (i: number) => void;
9
+ baseId: string;
10
+ tabCount: number;
11
+ }
12
+
13
+ const TabsContext = createContext<TabsContextValue | null>(null);
14
+
15
+ function useTabsContext() {
16
+ const ctx = useContext(TabsContext);
17
+ if (!ctx) throw new Error('TkxTab must be used inside TkxTabs');
18
+ return ctx;
19
+ }
20
+
21
+ export interface TkxTabsProps {
22
+ defaultIndex?: number;
23
+ activeIndex?: number;
24
+ onChange?: (i: number) => void;
25
+ children: ReactNode;
26
+ tabCount?: number;
27
+ style?: React.CSSProperties;
28
+ className?: string;
29
+ }
30
+
31
+ export function TkxTabs({ defaultIndex = 0, activeIndex: controlled, onChange, children, tabCount = 0, style, className }: TkxTabsProps) {
32
+ const [internal, setInternal] = useState(defaultIndex);
33
+ const baseId = useId();
34
+ const active = controlled !== undefined ? controlled : internal;
35
+ const setActive = (i: number) => { if (controlled === undefined) setInternal(i); onChange?.(i); };
36
+
37
+ return (
38
+ <TabsContext.Provider value={{ activeIndex: active, setActiveIndex: setActive, baseId, tabCount }}>
39
+ <div className={className} style={style}>{children}</div>
40
+ </TabsContext.Provider>
41
+ );
42
+ }
43
+
44
+ export interface TkxTabListProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode; }
45
+
46
+ export function TkxTabList({ children, className, style, ...rest }: TkxTabListProps) {
47
+ const theme = useTheme();
48
+ return (
49
+ <div
50
+ role="tablist"
51
+ className={cx(tkx('flex gap-1'), className)}
52
+ style={{ borderBottom: `2px solid ${theme.border}`, ...style }}
53
+ {...rest}
54
+ >
55
+ {children}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ export interface TkxTabProps { index: number; children: ReactNode; disabled?: boolean; className?: string; style?: React.CSSProperties; }
61
+
62
+ export function TkxTab({ index, children, disabled = false, className, style }: TkxTabProps) {
63
+ const theme = useTheme();
64
+ const { activeIndex, setActiveIndex, baseId, tabCount } = useTabsContext();
65
+ const tabRef = useRef<HTMLButtonElement>(null);
66
+ const isActive = activeIndex === index;
67
+
68
+ return (
69
+ <button
70
+ ref={tabRef}
71
+ role="tab"
72
+ id={`${baseId}-tab-${index}`}
73
+ aria-controls={`${baseId}-panel-${index}`}
74
+ aria-selected={isActive}
75
+ tabIndex={isActive ? 0 : -1}
76
+ disabled={disabled}
77
+ className={cx(
78
+ tkx(
79
+ 'border-none bg-transparent font-sans text-sm rounded-t cursor-pointer select-none',
80
+ 'py-2.5 px-4 transition-colors duration-150 focus-visible:focus-ring',
81
+ disabled ? 'opacity-50 cursor-not-allowed' : '',
82
+ ),
83
+ className,
84
+ )}
85
+ style={{
86
+ color: isActive ? theme.primary : theme.textMuted,
87
+ fontWeight: isActive ? 600 : 400,
88
+ borderBottom: isActive ? `2px solid ${theme.primary}` : '2px solid transparent',
89
+ marginBottom: '-2px',
90
+ ...style,
91
+ }}
92
+ onClick={() => !disabled && setActiveIndex(index)}
93
+ onKeyDown={(e) => handleTabsKeyboard(e as unknown as KeyboardEvent, index, tabCount, (ni) => {
94
+ setActiveIndex(ni);
95
+ document.getElementById(`${baseId}-tab-${ni}`)?.focus();
96
+ })}
97
+ >
98
+ {children}
99
+ </button>
100
+ );
101
+ }
102
+
103
+ export function TkxTabPanels({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
104
+ return <div className={className} {...rest}>{children}</div>;
105
+ }
106
+
107
+ export interface TkxTabPanelProps { index: number; children: ReactNode; style?: React.CSSProperties; className?: string; }
108
+
109
+ export function TkxTabPanel({ index, children, style, className }: TkxTabPanelProps) {
110
+ const { activeIndex, baseId } = useTabsContext();
111
+ if (activeIndex !== index) return null;
112
+ return (
113
+ <div
114
+ role="tabpanel"
115
+ id={`${baseId}-panel-${index}`}
116
+ aria-labelledby={`${baseId}-tab-${index}`}
117
+ tabIndex={0}
118
+ className={cx(tkx('pt-4'), className)}
119
+ style={style}
120
+ >
121
+ {children}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,319 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useCallback,
5
+ type HTMLAttributes,
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 TagVariant = 'solid' | 'subtle' | 'outline';
18
+ export type TagSize = 'sm' | 'md' | 'lg';
19
+ export type TagColorScheme = 'default' | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
20
+
21
+ export interface TkxTagProps extends HTMLAttributes<HTMLSpanElement> {
22
+ variant?: TagVariant;
23
+ size?: TagSize;
24
+ colorScheme?: TagColorScheme;
25
+ onRemove?: () => void;
26
+ leftIcon?: ReactNode;
27
+ isDisabled?: boolean;
28
+ clickable?: boolean;
29
+ }
30
+
31
+ export interface TkxTagInputProps {
32
+ value?: string[];
33
+ defaultValue?: string[];
34
+ onChange?: (tags: string[]) => void;
35
+ placeholder?: string;
36
+ maxTags?: number;
37
+ allowDuplicates?: boolean;
38
+ validate?: (tag: string) => boolean | string;
39
+ colorScheme?: TagColorScheme;
40
+ size?: TagSize;
41
+ label?: string;
42
+ hint?: string;
43
+ isDisabled?: boolean;
44
+ }
45
+
46
+ // ── Size maps ─────────────────────────────────────────────────────────────────
47
+
48
+ const SIZE_TEXT: Record<TagSize, string> = {
49
+ sm: 'text-[11px]',
50
+ md: 'text-xs',
51
+ lg: 'text-sm',
52
+ };
53
+ const SIZE_PAD: Record<TagSize, string> = {
54
+ sm: 'px-2 py-0.5',
55
+ md: 'px-2.5 py-1',
56
+ lg: 'px-3 py-1.5',
57
+ };
58
+ const SIZE_GAP: Record<TagSize, string> = {
59
+ sm: 'gap-1',
60
+ md: 'gap-1.5',
61
+ lg: 'gap-2',
62
+ };
63
+ const SIZE_REMOVE: Record<TagSize, number> = { sm: 12, md: 14, lg: 16 };
64
+
65
+ // ── Color resolver ────────────────────────────────────────────────────────────
66
+
67
+ function useTagColors(colorScheme: TagColorScheme, variant: TagVariant) {
68
+ const theme = useTheme();
69
+
70
+ const baseColor: Record<TagColorScheme, string> = {
71
+ default: theme.textMuted,
72
+ primary: theme.primary,
73
+ secondary: theme.secondary,
74
+ success: theme.success,
75
+ danger: theme.danger,
76
+ warning: theme.warning,
77
+ info: theme.info,
78
+ };
79
+
80
+ const accent = baseColor[colorScheme];
81
+
82
+ if (variant === 'solid') {
83
+ return { bg: accent, color: theme.bg, border: 'transparent' };
84
+ }
85
+ if (variant === 'subtle') {
86
+ return { bg: `${accent}22`, color: accent, border: 'transparent' };
87
+ }
88
+ // outline
89
+ return { bg: 'transparent', color: accent, border: accent };
90
+ }
91
+
92
+ // ── TkxTag ────────────────────────────────────────────────────────────────────
93
+
94
+ export function TkxTag({
95
+ variant = 'subtle',
96
+ size = 'md',
97
+ colorScheme = 'default',
98
+ onRemove,
99
+ leftIcon,
100
+ isDisabled = false,
101
+ clickable = false,
102
+ children,
103
+ className,
104
+ style,
105
+ onClick,
106
+ ...rest
107
+ }: TkxTagProps) {
108
+ const theme = useTheme();
109
+ const reducedMotion = useReducedMotion();
110
+ const colors = useTagColors(colorScheme, variant);
111
+ const safeLabel = typeof children === 'string' ? sanitizeString(children) : children;
112
+
113
+ const removeSize = SIZE_REMOVE[size];
114
+
115
+ const base = tkx(
116
+ 'inline-flex items-center font-medium rounded-full select-none',
117
+ SIZE_TEXT[size],
118
+ SIZE_PAD[size],
119
+ SIZE_GAP[size],
120
+ isDisabled ? 'opacity-50 cursor-not-allowed' : clickable ? 'cursor-pointer' : 'cursor-default',
121
+ clickable && !reducedMotion && 'transition-opacity duration-150',
122
+ );
123
+
124
+ return (
125
+ <span
126
+ role={clickable ? 'button' : undefined}
127
+ tabIndex={clickable && !isDisabled ? 0 : undefined}
128
+ aria-disabled={isDisabled || undefined}
129
+ className={cx(base, className)}
130
+ style={{
131
+ backgroundColor: colors.bg,
132
+ color: colors.color,
133
+ border: `1px solid ${colors.border === 'transparent' ? 'transparent' : colors.border}`,
134
+ ...style,
135
+ }}
136
+ onClick={!isDisabled ? onClick : undefined}
137
+ onKeyDown={
138
+ clickable && !isDisabled && onClick
139
+ ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(e as never); } }
140
+ : undefined
141
+ }
142
+ {...rest}
143
+ >
144
+ {leftIcon && <span aria-hidden="true" className={tkx('shrink-0 flex items-center')}>{leftIcon}</span>}
145
+ <span>{safeLabel}</span>
146
+ {onRemove && !isDisabled && (
147
+ <button
148
+ type="button"
149
+ aria-label={`Remove ${typeof children === 'string' ? children : 'tag'}`}
150
+ onClick={(e) => { e.stopPropagation(); onRemove(); }}
151
+ className={tkx('shrink-0 flex items-center justify-center rounded-full cursor-pointer')}
152
+ style={{
153
+ width: removeSize + 4,
154
+ height: removeSize + 4,
155
+ color: colors.color,
156
+ backgroundColor: 'transparent',
157
+ border: 'none',
158
+ padding: 0,
159
+ }}
160
+ tabIndex={0}
161
+ >
162
+ <svg width={removeSize} height={removeSize} viewBox="0 0 12 12" fill="none" aria-hidden="true">
163
+ <path d="M2 2l8 8M10 2L2 10" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
164
+ </svg>
165
+ </button>
166
+ )}
167
+ </span>
168
+ );
169
+ }
170
+
171
+ // ── TkxTagInput ───────────────────────────────────────────────────────────────
172
+
173
+ export function TkxTagInput({
174
+ value,
175
+ defaultValue = [],
176
+ onChange,
177
+ placeholder = 'Add tag…',
178
+ maxTags,
179
+ allowDuplicates = false,
180
+ validate,
181
+ colorScheme = 'primary',
182
+ size = 'md',
183
+ label,
184
+ hint,
185
+ isDisabled = false,
186
+ }: TkxTagInputProps) {
187
+ const theme = useTheme();
188
+ const reducedMotion = useReducedMotion();
189
+ const [internalTags, setInternalTags] = useState<string[]>(defaultValue);
190
+ const [inputVal, setInputVal] = useState('');
191
+ const [error, setError] = useState<string | null>(null);
192
+ const inputRef = useRef<HTMLInputElement>(null);
193
+
194
+ const isControlled = value !== undefined;
195
+ const tags = isControlled ? value : internalTags;
196
+
197
+ const updateTags = useCallback((next: string[]) => {
198
+ if (!isControlled) setInternalTags(next);
199
+ onChange?.(next);
200
+ }, [isControlled, onChange]);
201
+
202
+ const addTag = useCallback((raw: string) => {
203
+ const trimmed = raw.trim();
204
+ if (!trimmed) return;
205
+
206
+ if (maxTags !== undefined && tags.length >= maxTags) {
207
+ setError(`Maximum ${maxTags} tags allowed`);
208
+ return;
209
+ }
210
+
211
+ if (!allowDuplicates && tags.includes(trimmed)) {
212
+ setError('Duplicate tags are not allowed');
213
+ return;
214
+ }
215
+
216
+ if (validate) {
217
+ const result = validate(trimmed);
218
+ if (result === false) { setError('Invalid tag'); return; }
219
+ if (typeof result === 'string') { setError(result); return; }
220
+ }
221
+
222
+ setError(null);
223
+ updateTags([...tags, trimmed]);
224
+ setInputVal('');
225
+ }, [tags, maxTags, allowDuplicates, validate, updateTags]);
226
+
227
+ const removeTag = useCallback((index: number) => {
228
+ const next = tags.filter((_, i) => i !== index);
229
+ updateTags(next);
230
+ setError(null);
231
+ }, [tags, updateTags]);
232
+
233
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
234
+ if (e.key === 'Enter' || e.key === ',') {
235
+ e.preventDefault();
236
+ addTag(inputVal);
237
+ } else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
238
+ removeTag(tags.length - 1);
239
+ }
240
+ }, [inputVal, tags, addTag, removeTag]);
241
+
242
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
243
+ const val = e.target.value;
244
+ if (val.endsWith(',')) {
245
+ addTag(val.slice(0, -1));
246
+ } else {
247
+ setInputVal(val);
248
+ if (error) setError(null);
249
+ }
250
+ }, [addTag, error]);
251
+
252
+ const containerPad = size === 'sm' ? '6px 8px' : size === 'lg' ? '10px 12px' : '8px 10px';
253
+ const inputFontSize = size === 'sm' ? 11 : size === 'lg' ? 14 : 12;
254
+
255
+ return (
256
+ <div className={tkx('flex flex-col gap-1.5')}>
257
+ {label && (
258
+ <label
259
+ className={tkx('text-sm font-medium')}
260
+ style={{ color: theme.text }}
261
+ onClick={() => inputRef.current?.focus()}
262
+ >
263
+ {sanitizeString(label)}
264
+ </label>
265
+ )}
266
+ <div
267
+ className={tkx(
268
+ 'flex flex-wrap items-center gap-1.5 rounded-lg cursor-text',
269
+ !reducedMotion && 'transition-colors duration-150',
270
+ )}
271
+ style={{
272
+ padding: containerPad,
273
+ backgroundColor: theme.surface,
274
+ border: `1.5px solid ${error ? theme.danger : theme.border}`,
275
+ opacity: isDisabled ? 0.55 : 1,
276
+ minHeight: size === 'sm' ? 36 : size === 'lg' ? 48 : 40,
277
+ }}
278
+ onClick={() => !isDisabled && inputRef.current?.focus()}
279
+ >
280
+ {tags.map((tag, i) => (
281
+ <TkxTag
282
+ key={`${tag}-${i}`}
283
+ size={size}
284
+ variant="subtle"
285
+ colorScheme={colorScheme}
286
+ onRemove={isDisabled ? undefined : () => removeTag(i)}
287
+ isDisabled={isDisabled}
288
+ >
289
+ {tag}
290
+ </TkxTag>
291
+ ))}
292
+ {(!maxTags || tags.length < maxTags) && !isDisabled && (
293
+ <input
294
+ ref={inputRef}
295
+ type="text"
296
+ value={inputVal}
297
+ onChange={handleChange}
298
+ onKeyDown={handleKeyDown}
299
+ placeholder={tags.length === 0 ? sanitizeString(placeholder) : ''}
300
+ disabled={isDisabled}
301
+ className={tkx('flex-1 bg-transparent outline-none min-w-[80px]')}
302
+ style={{ fontSize: inputFontSize, color: theme.text, caretColor: theme.primary }}
303
+ aria-label={label ?? 'Tag input'}
304
+ />
305
+ )}
306
+ </div>
307
+ {error && (
308
+ <p className={tkx('text-xs')} style={{ color: theme.danger }} role="alert">
309
+ {error}
310
+ </p>
311
+ )}
312
+ {hint && !error && (
313
+ <p className={tkx('text-xs')} style={{ color: theme.textMuted }}>
314
+ {sanitizeString(hint)}
315
+ </p>
316
+ )}
317
+ </div>
318
+ );
319
+ }