tekivex-ui 2.2.0 → 2.5.1

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 (129) hide show
  1. package/dist/TkxForm-BWK4LqY3.js +1461 -0
  2. package/dist/TkxForm-ljQjX7KD.cjs +15 -0
  3. package/dist/charts.cjs +1 -0
  4. package/dist/charts.js +480 -0
  5. package/dist/headless.cjs +1 -0
  6. package/dist/headless.js +258 -0
  7. package/dist/index-BINBzXuY.cjs +2 -0
  8. package/dist/index-eT_U4qB2.js +512 -0
  9. package/dist/index.cjs +63 -14
  10. package/dist/index.d.ts +33 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +11396 -6336
  13. package/dist/src/charts/TkxAreaChart.d.ts +29 -0
  14. package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
  15. package/dist/src/charts/TkxBarChart.d.ts +28 -0
  16. package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
  17. package/dist/src/charts/TkxDonutChart.d.ts +23 -0
  18. package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
  19. package/dist/src/charts/TkxLineChart.d.ts +35 -0
  20. package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
  21. package/dist/src/charts/TkxPieChart.d.ts +19 -0
  22. package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
  23. package/dist/src/charts/TkxRadarChart.d.ts +20 -0
  24. package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
  25. package/dist/src/charts/TkxScatterChart.d.ts +28 -0
  26. package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
  27. package/dist/src/charts/index.d.ts +15 -0
  28. package/dist/src/charts/index.d.ts.map +1 -0
  29. package/dist/src/charts/shared.d.ts +33 -0
  30. package/dist/src/charts/shared.d.ts.map +1 -0
  31. package/dist/src/components/TkxAffix.d.ts +10 -0
  32. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  33. package/dist/src/components/TkxAnchor.d.ts +14 -0
  34. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  35. package/dist/src/components/TkxCascader.d.ts +16 -0
  36. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  37. package/dist/src/components/TkxDataGrid.d.ts +19 -1
  38. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  39. package/dist/src/components/TkxForm.d.ts +33 -3
  40. package/dist/src/components/TkxForm.d.ts.map +1 -1
  41. package/dist/src/components/TkxImage.d.ts.map +1 -1
  42. package/dist/src/components/TkxList.d.ts +24 -0
  43. package/dist/src/components/TkxList.d.ts.map +1 -0
  44. package/dist/src/components/TkxMentions.d.ts +15 -0
  45. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  46. package/dist/src/components/TkxQRCode.d.ts +11 -0
  47. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  48. package/dist/src/components/TkxResult.d.ts +11 -0
  49. package/dist/src/components/TkxResult.d.ts.map +1 -0
  50. package/dist/src/components/TkxSegmented.d.ts +16 -0
  51. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  52. package/dist/src/components/TkxSelect.d.ts +5 -1
  53. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  54. package/dist/src/components/TkxTour.d.ts +15 -0
  55. package/dist/src/components/TkxTour.d.ts.map +1 -0
  56. package/dist/src/components/TkxTypography.d.ts +1 -1
  57. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  58. package/dist/src/components/TkxWatermark.d.ts +12 -0
  59. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  60. package/dist/src/components/index.d.ts +10 -0
  61. package/dist/src/components/index.d.ts.map +1 -1
  62. package/dist/src/engine/tkx.d.ts +38 -0
  63. package/dist/src/engine/tkx.d.ts.map +1 -1
  64. package/dist/src/headless/index.d.ts +18 -0
  65. package/dist/src/headless/index.d.ts.map +1 -0
  66. package/dist/src/headless/useControllable.d.ts +20 -0
  67. package/dist/src/headless/useControllable.d.ts.map +1 -0
  68. package/dist/src/headless/useDebounce.d.ts +14 -0
  69. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  70. package/dist/src/headless/useDisclosure.d.ts +20 -0
  71. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  72. package/dist/src/headless/useFormState.d.ts +44 -0
  73. package/dist/src/headless/useFormState.d.ts.map +1 -0
  74. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  75. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  76. package/dist/src/headless/useListSelection.d.ts +29 -0
  77. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  78. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  79. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  80. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  81. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  82. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  83. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  84. package/dist/src/headless/useThrottle.d.ts +9 -0
  85. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  86. package/dist/src/i18n/index.d.ts +47 -2
  87. package/dist/src/i18n/index.d.ts.map +1 -1
  88. package/dist/src/themes/index.d.ts.map +1 -1
  89. package/package.json +61 -7
  90. package/src/charts/TkxAreaChart.tsx +119 -0
  91. package/src/charts/TkxBarChart.tsx +115 -0
  92. package/src/charts/TkxDonutChart.tsx +145 -0
  93. package/src/charts/TkxLineChart.tsx +135 -0
  94. package/src/charts/TkxPieChart.tsx +82 -0
  95. package/src/charts/TkxRadarChart.tsx +90 -0
  96. package/src/charts/TkxScatterChart.tsx +102 -0
  97. package/src/charts/index.ts +20 -0
  98. package/src/charts/shared.ts +45 -0
  99. package/src/components/TkxAffix.tsx +138 -0
  100. package/src/components/TkxAnchor.tsx +193 -0
  101. package/src/components/TkxCascader.tsx +276 -0
  102. package/src/components/TkxDataGrid.tsx +557 -230
  103. package/src/components/TkxForm.tsx +67 -15
  104. package/src/components/TkxImage.tsx +1 -1
  105. package/src/components/TkxList.tsx +242 -0
  106. package/src/components/TkxMentions.tsx +210 -0
  107. package/src/components/TkxQRCode.tsx +174 -0
  108. package/src/components/TkxResult.tsx +128 -0
  109. package/src/components/TkxSegmented.tsx +169 -0
  110. package/src/components/TkxSelect.tsx +209 -6
  111. package/src/components/TkxTour.tsx +239 -0
  112. package/src/components/TkxTypography.tsx +4 -2
  113. package/src/components/TkxWatermark.tsx +143 -0
  114. package/src/components/index.ts +10 -0
  115. package/src/engine/tkx.ts +122 -3
  116. package/src/headless/index.ts +44 -0
  117. package/src/headless/useControllable.ts +40 -0
  118. package/src/headless/useDebounce.ts +35 -0
  119. package/src/headless/useDisclosure.ts +28 -0
  120. package/src/headless/useFormState.ts +111 -0
  121. package/src/headless/useIntersectionObserver.ts +51 -0
  122. package/src/headless/useListSelection.ts +101 -0
  123. package/src/headless/useLocalStorage.ts +46 -0
  124. package/src/headless/useMediaQuery.ts +37 -0
  125. package/src/headless/useRovingTabIndex.ts +84 -0
  126. package/src/headless/useThrottle.ts +46 -0
  127. package/src/i18n/I18nProvider.tsx +1 -1
  128. package/src/i18n/index.ts +678 -38
  129. package/src/themes/index.ts +31 -4
@@ -43,6 +43,12 @@ export interface TkxFormProps {
43
43
  children: ReactNode;
44
44
  className?: string;
45
45
  style?: CSSProperties;
46
+ /**
47
+ * An external FormInstance created via useTkxForm().
48
+ * When provided, the form exposes its internal instance through this reference,
49
+ * enabling programmatic control from outside the component tree.
50
+ */
51
+ form?: FormInstance;
46
52
  }
47
53
 
48
54
  // ── Form Field Props ────────────────────────────────────────────────────────
@@ -185,24 +191,63 @@ function hasRequiredRule(rules: ValidationRule[]): boolean {
185
191
  // ── useTkxForm Hook ─────────────────────────────────────────────────────────
186
192
 
187
193
  /**
188
- * Create a FormInstance for programmatic access to form state.
189
- * Can be used standalone or connected to a <TkxForm> via context.
194
+ * Returns a FormInstance for programmatic access to form state.
195
+ *
196
+ * - Inside a <TkxForm>: returns the form's live instance (reads/writes real field state).
197
+ * - Outside a <TkxForm>: returns a standalone instance backed by a ref store.
198
+ * Pass it to <TkxForm form={instance}> to connect it to a form.
199
+ *
200
+ * @example — programmatic access inside a form
201
+ * ```tsx
202
+ * function MyInnerButtons() {
203
+ * const form = useTkxForm(); // connected to parent TkxForm
204
+ * return <button onClick={() => form.resetFields()}>Reset</button>;
205
+ * }
206
+ * ```
207
+ *
208
+ * @example — external instance (Ant Design pattern)
209
+ * ```tsx
210
+ * function Parent() {
211
+ * const form = useTkxForm();
212
+ * return (
213
+ * <TkxForm form={form}>
214
+ * <TkxFormField name="email" label="Email"><TkxInput label="Email" /></TkxFormField>
215
+ * <button onClick={() => console.log(form.getFieldsValue())}>Log</button>
216
+ * </TkxForm>
217
+ * );
218
+ * }
219
+ * ```
190
220
  */
191
221
  export function useTkxForm(): FormInstance {
192
222
  const ctx = useContext(FormContext);
193
223
 
194
- // If used inside a TkxForm, return its instance directly
195
- if (ctx) {
196
- return ctx.instance;
197
- }
224
+ // Always call hooks unconditionally (Rules of Hooks).
225
+ // These refs back the standalone instance when used outside a TkxForm.
226
+ const valuesRef = useRef<Record<string, any>>({});
227
+ const errorsRef = useRef<Record<string, string | null>>({});
228
+ const touchedRef = useRef<Record<string, boolean>>({});
198
229
 
199
- // Standalone usage maintain internal ref so the same object is returned
200
- // across renders. Note: without a TkxForm provider, the standalone instance
201
- // is inert (no UI binding), but callers can still use it as a value store.
202
- throw new Error(
203
- 'useTkxForm() must be called inside a <TkxForm> provider. ' +
204
- 'Wrap your component tree with <TkxForm> first.',
205
- );
230
+ const standaloneInstance = useMemo<FormInstance>(() => ({
231
+ getFieldValue: (name: string) => valuesRef.current[name],
232
+ setFieldValue: (name: string, value: any) => { valuesRef.current[name] = value; },
233
+ getFieldsValue: () => ({ ...valuesRef.current }),
234
+ setFieldsValue: (values: Record<string, any>) => {
235
+ Object.assign(valuesRef.current, values);
236
+ },
237
+ validateFields: () => Promise.resolve({ ...valuesRef.current }),
238
+ validateField: (_name: string) => Promise.resolve(true),
239
+ resetFields: () => {
240
+ valuesRef.current = {};
241
+ errorsRef.current = {};
242
+ touchedRef.current = {};
243
+ },
244
+ getFieldError: (name: string) => errorsRef.current[name] ?? null,
245
+ isFieldTouched: (name: string) => touchedRef.current[name] ?? false,
246
+ }), []);
247
+
248
+ // If inside a TkxForm context, return the live connected instance.
249
+ // Otherwise return the standalone ref-backed instance.
250
+ return ctx ? ctx.instance : standaloneInstance;
206
251
  }
207
252
 
208
253
  // ── TkxForm Component ───────────────────────────────────────────────────────
@@ -216,6 +261,7 @@ export function TkxForm({
216
261
  children,
217
262
  className,
218
263
  style,
264
+ form: externalInstance,
219
265
  }: TkxFormProps) {
220
266
  const theme = useTheme();
221
267
 
@@ -362,6 +408,10 @@ export function TkxForm({
362
408
  : tkx('flex flex-col gap-5');
363
409
 
364
410
  // ─── Context Value ─────────────────────────────────────────────────────
411
+ // When an external form instance is provided via the `form` prop, expose it
412
+ // through the context so that useTkxForm() inside children returns it.
413
+ const activeInstance = externalInstance ?? instance;
414
+
365
415
  const contextValue = useMemo<FormContextValue>(() => ({
366
416
  state,
367
417
  initialValues: initialValuesRef.current,
@@ -374,13 +424,15 @@ export function TkxForm({
374
424
  registerField,
375
425
  unregisterField,
376
426
  validateField,
377
- instance,
378
- }), [state, layout, disabled, setFieldValue, setFieldError, setFieldTouched, registerField, unregisterField, validateField, instance]);
427
+ instance: activeInstance,
428
+ }), [state, layout, disabled, setFieldValue, setFieldError, setFieldTouched, registerField, unregisterField, validateField, activeInstance]);
379
429
 
380
430
  return (
381
431
  <FormContext.Provider value={contextValue}>
382
432
  <form
383
433
  noValidate
434
+ role="form"
435
+ aria-label="Form"
384
436
  onSubmit={handleSubmit}
385
437
  className={cx(layoutClass, className)}
386
438
  style={{
@@ -70,7 +70,7 @@ export function TkxImage({
70
70
  ratio = 'auto',
71
71
  radius = 'none',
72
72
  caption,
73
- lazy = false,
73
+ lazy = true,
74
74
  preview = false,
75
75
  className,
76
76
  style,
@@ -0,0 +1,242 @@
1
+ import { type ReactNode } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface ListItem {
10
+ key: string;
11
+ title: string;
12
+ description?: string;
13
+ avatar?: ReactNode;
14
+ actions?: ReactNode;
15
+ extra?: ReactNode;
16
+ }
17
+
18
+ export interface TkxListProps {
19
+ items: ListItem[];
20
+ header?: ReactNode;
21
+ footer?: ReactNode;
22
+ bordered?: boolean;
23
+ size?: 'sm' | 'md' | 'lg';
24
+ loading?: boolean;
25
+ emptyText?: string;
26
+ grid?: { column?: number; gutter?: number };
27
+ }
28
+
29
+ // ── Size Tokens ──────────────────────────────────────────────────────────────
30
+
31
+ const SIZE_PADDING = {
32
+ sm: '8px 12px',
33
+ md: '12px 16px',
34
+ lg: '16px 20px',
35
+ } as const;
36
+
37
+ // ── Loading Skeleton ─────────────────────────────────────────────────────────
38
+
39
+ function Skeleton({ theme, reducedMotion }: { theme: ReturnType<typeof useTheme>; reducedMotion: boolean }) {
40
+ return (
41
+ <div className={tkx('flex items-center gap-3 px-4 py-3')} aria-hidden="true">
42
+ <div
43
+ style={{
44
+ width: 40,
45
+ height: 40,
46
+ borderRadius: '50%',
47
+ backgroundColor: theme.surfaceAlt,
48
+ animation: reducedMotion ? 'none' : 'tkxPulse 1.5s ease infinite',
49
+ }}
50
+ />
51
+ <div className={tkx('flex-1')}>
52
+ <div
53
+ style={{
54
+ width: '60%',
55
+ height: 14,
56
+ borderRadius: 4,
57
+ backgroundColor: theme.surfaceAlt,
58
+ marginBottom: 8,
59
+ animation: reducedMotion ? 'none' : 'tkxPulse 1.5s ease infinite',
60
+ }}
61
+ />
62
+ <div
63
+ style={{
64
+ width: '40%',
65
+ height: 12,
66
+ borderRadius: 4,
67
+ backgroundColor: theme.surfaceAlt,
68
+ animation: reducedMotion ? 'none' : 'tkxPulse 1.5s ease infinite',
69
+ }}
70
+ />
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ // ── Component ────────────────────────────────────────────────────────────────
77
+
78
+ export function TkxList({
79
+ items,
80
+ header,
81
+ footer,
82
+ bordered = true,
83
+ size = 'md',
84
+ loading = false,
85
+ emptyText = 'No data',
86
+ grid,
87
+ }: TkxListProps) {
88
+ const theme = useTheme();
89
+ const reducedMotion = useReducedMotion();
90
+ const safeEmptyText = sanitizeString(emptyText);
91
+ const padding = SIZE_PADDING[size];
92
+
93
+ const isGrid = !!grid;
94
+ const columns = grid?.column ?? 1;
95
+ const gutter = grid?.gutter ?? 16;
96
+
97
+ return (
98
+ <div
99
+ role="list"
100
+ aria-label="List"
101
+ aria-busy={loading}
102
+ className={tkx('font-sans rounded-lg overflow-hidden')}
103
+ style={{
104
+ border: bordered ? `1px solid ${theme.border}` : 'none',
105
+ backgroundColor: theme.surface,
106
+ }}
107
+ >
108
+ {/* Header */}
109
+ {header && (
110
+ <div
111
+ className={tkx('font-semibold text-sm')}
112
+ style={{
113
+ padding,
114
+ color: theme.text,
115
+ borderBottom: `1px solid ${theme.border}`,
116
+ backgroundColor: theme.surfaceAlt,
117
+ }}
118
+ >
119
+ {header}
120
+ </div>
121
+ )}
122
+
123
+ {/* Loading */}
124
+ {loading && (
125
+ <div>
126
+ {Array.from({ length: 3 }).map((_, i) => (
127
+ <Skeleton key={i} theme={theme} reducedMotion={reducedMotion} />
128
+ ))}
129
+ </div>
130
+ )}
131
+
132
+ {/* Empty State */}
133
+ {!loading && items.length === 0 && (
134
+ <div
135
+ role="status"
136
+ className={tkx('text-center py-12 text-sm')}
137
+ style={{ color: theme.textMuted }}
138
+ >
139
+ {safeEmptyText}
140
+ </div>
141
+ )}
142
+
143
+ {/* Items */}
144
+ {!loading && items.length > 0 && (
145
+ <div
146
+ style={
147
+ isGrid
148
+ ? {
149
+ display: 'grid',
150
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
151
+ gap: gutter,
152
+ padding: gutter,
153
+ }
154
+ : undefined
155
+ }
156
+ >
157
+ {items.map((item, idx) => {
158
+ const safeTitle = sanitizeString(item.title);
159
+ const safeDesc = item.description ? sanitizeString(item.description) : undefined;
160
+
161
+ return (
162
+ <div
163
+ key={item.key}
164
+ role="listitem"
165
+ className={tkx('flex items-start gap-3')}
166
+ style={{
167
+ padding: isGrid ? padding : padding,
168
+ borderBottom:
169
+ !isGrid && idx < items.length - 1
170
+ ? `1px solid ${theme.border}`
171
+ : 'none',
172
+ ...(isGrid
173
+ ? {
174
+ border: `1px solid ${theme.border}`,
175
+ borderRadius: 8,
176
+ backgroundColor: theme.surface,
177
+ }
178
+ : {}),
179
+ animation: reducedMotion ? 'none' : `tkxFadeIn 0.2s ease ${idx * 0.03}s both`,
180
+ }}
181
+ >
182
+ {/* Avatar */}
183
+ {item.avatar && (
184
+ <div aria-hidden="true" className={tkx('shrink-0')}>
185
+ {item.avatar}
186
+ </div>
187
+ )}
188
+
189
+ {/* Content */}
190
+ <div className={tkx('flex-1 min-w-0')}>
191
+ <div
192
+ className={tkx('text-sm font-medium')}
193
+ style={{ color: theme.text }}
194
+ >
195
+ {safeTitle}
196
+ </div>
197
+ {safeDesc && (
198
+ <div
199
+ className={tkx('text-xs mt-1 leading-relaxed')}
200
+ style={{ color: theme.textMuted }}
201
+ >
202
+ {safeDesc}
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ {/* Extra */}
208
+ {item.extra && (
209
+ <div className={tkx('shrink-0 ml-auto')} style={{ color: theme.textMuted }}>
210
+ {item.extra}
211
+ </div>
212
+ )}
213
+
214
+ {/* Actions */}
215
+ {item.actions && (
216
+ <div className={tkx('shrink-0 flex items-center gap-2')}>
217
+ {item.actions}
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ })}
223
+ </div>
224
+ )}
225
+
226
+ {/* Footer */}
227
+ {footer && (
228
+ <div
229
+ className={tkx('text-sm')}
230
+ style={{
231
+ padding,
232
+ color: theme.textMuted,
233
+ borderTop: `1px solid ${theme.border}`,
234
+ backgroundColor: theme.surfaceAlt,
235
+ }}
236
+ >
237
+ {footer}
238
+ </div>
239
+ )}
240
+ </div>
241
+ );
242
+ }
@@ -0,0 +1,210 @@
1
+ import { type ReactNode, useState, useRef, useCallback, useEffect } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface MentionOption {
10
+ value: string;
11
+ label: string;
12
+ avatar?: string;
13
+ }
14
+
15
+ export interface TkxMentionsProps {
16
+ options: MentionOption[];
17
+ value?: string;
18
+ onChange?: (value: string) => void;
19
+ trigger?: string;
20
+ placeholder?: string;
21
+ label?: string;
22
+ }
23
+
24
+ // ── Component ────────────────────────────────────────────────────────────────
25
+
26
+ export function TkxMentions({
27
+ options,
28
+ value = '',
29
+ onChange,
30
+ trigger = '@',
31
+ placeholder,
32
+ label,
33
+ }: TkxMentionsProps) {
34
+ const theme = useTheme();
35
+ const reducedMotion = useReducedMotion();
36
+ const inputRef = useRef<HTMLTextAreaElement>(null);
37
+ const dropdownRef = useRef<HTMLUListElement>(null);
38
+ const [text, setText] = useState(value);
39
+ const [showDropdown, setShowDropdown] = useState(false);
40
+ const [query, setQuery] = useState('');
41
+ const [activeIdx, setActiveIdx] = useState(0);
42
+ const [triggerPos, setTriggerPos] = useState(-1);
43
+
44
+ useEffect(() => {
45
+ if (value !== undefined) setText(value);
46
+ }, [value]);
47
+
48
+ const filtered = options.filter((o) =>
49
+ o.label.toLowerCase().includes(query.toLowerCase()),
50
+ );
51
+
52
+ const handleInput = useCallback(
53
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
54
+ const val = e.target.value;
55
+ setText(val);
56
+ onChange?.(val);
57
+
58
+ const cursorPos = e.target.selectionStart ?? val.length;
59
+ const beforeCursor = val.slice(0, cursorPos);
60
+ const lastTrigger = beforeCursor.lastIndexOf(trigger);
61
+
62
+ if (lastTrigger >= 0) {
63
+ const afterTrigger = beforeCursor.slice(lastTrigger + trigger.length);
64
+ if (!/\s/.test(afterTrigger)) {
65
+ setQuery(afterTrigger);
66
+ setTriggerPos(lastTrigger);
67
+ setShowDropdown(true);
68
+ setActiveIdx(0);
69
+ return;
70
+ }
71
+ }
72
+ setShowDropdown(false);
73
+ },
74
+ [trigger, onChange],
75
+ );
76
+
77
+ const insertMention = useCallback(
78
+ (opt: MentionOption) => {
79
+ const before = text.slice(0, triggerPos);
80
+ const after = text.slice(
81
+ triggerPos + trigger.length + query.length,
82
+ );
83
+ const newText = `${before}${trigger}${opt.value} ${after}`;
84
+ setText(newText);
85
+ onChange?.(newText);
86
+ setShowDropdown(false);
87
+ inputRef.current?.focus();
88
+ },
89
+ [text, triggerPos, trigger, query, onChange],
90
+ );
91
+
92
+ const handleKeyDown = useCallback(
93
+ (e: React.KeyboardEvent) => {
94
+ if (!showDropdown) return;
95
+ if (e.key === 'ArrowDown') {
96
+ e.preventDefault();
97
+ setActiveIdx((i) => (i + 1) % filtered.length);
98
+ } else if (e.key === 'ArrowUp') {
99
+ e.preventDefault();
100
+ setActiveIdx((i) => (i - 1 + filtered.length) % filtered.length);
101
+ } else if (e.key === 'Enter' && filtered[activeIdx]) {
102
+ e.preventDefault();
103
+ insertMention(filtered[activeIdx]);
104
+ } else if (e.key === 'Escape') {
105
+ setShowDropdown(false);
106
+ }
107
+ },
108
+ [showDropdown, filtered, activeIdx, insertMention],
109
+ );
110
+
111
+ // Scroll active option into view
112
+ useEffect(() => {
113
+ if (!showDropdown || !dropdownRef.current) return;
114
+ const active = dropdownRef.current.children[activeIdx] as HTMLElement;
115
+ active?.scrollIntoView({ block: 'nearest' });
116
+ }, [activeIdx, showDropdown]);
117
+
118
+ const safeLabel = label ? sanitizeString(label) : undefined;
119
+ const safePlaceholder = placeholder ? sanitizeString(placeholder) : undefined;
120
+
121
+ return (
122
+ <div className={tkx('relative font-sans')} style={{ width: '100%' }}>
123
+ {safeLabel && (
124
+ <label
125
+ className={tkx('block text-sm font-medium mb-1')}
126
+ style={{ color: theme.text }}
127
+ >
128
+ {safeLabel}
129
+ </label>
130
+ )}
131
+
132
+ <textarea
133
+ ref={inputRef}
134
+ role="combobox"
135
+ aria-expanded={showDropdown}
136
+ aria-haspopup="listbox"
137
+ aria-autocomplete="list"
138
+ aria-label={safeLabel ?? 'Mentions input'}
139
+ value={text}
140
+ placeholder={safePlaceholder}
141
+ onChange={handleInput}
142
+ onKeyDown={handleKeyDown}
143
+ onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
144
+ className={tkx('w-full rounded-lg border px-3 py-2 text-sm resize-y')}
145
+ style={{
146
+ backgroundColor: theme.surface,
147
+ borderColor: theme.border,
148
+ color: theme.text,
149
+ minHeight: 80,
150
+ outline: 'none',
151
+ }}
152
+ rows={3}
153
+ />
154
+
155
+ {showDropdown && filtered.length > 0 && (
156
+ <ul
157
+ ref={dropdownRef}
158
+ role="listbox"
159
+ aria-label="Mention suggestions"
160
+ className={tkx('absolute left-0 right-0 z-50 rounded-lg border overflow-auto')}
161
+ style={{
162
+ backgroundColor: theme.surface,
163
+ borderColor: theme.border,
164
+ maxHeight: 200,
165
+ top: '100%',
166
+ marginTop: 4,
167
+ listStyle: 'none',
168
+ padding: 0,
169
+ boxShadow: `0 4px 12px ${theme.bg}80`,
170
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.15s ease',
171
+ }}
172
+ >
173
+ {filtered.map((opt, idx) => (
174
+ <li
175
+ key={opt.value}
176
+ role="option"
177
+ aria-selected={idx === activeIdx}
178
+ className={tkx('flex items-center gap-3 px-3 py-2 cursor-pointer text-sm')}
179
+ style={{
180
+ backgroundColor: idx === activeIdx ? theme.surfaceAlt : 'transparent',
181
+ color: theme.text,
182
+ }}
183
+ onMouseDown={(e) => {
184
+ e.preventDefault();
185
+ insertMention(opt);
186
+ }}
187
+ onMouseEnter={() => setActiveIdx(idx)}
188
+ >
189
+ {opt.avatar && (
190
+ <img
191
+ src={sanitizeString(opt.avatar)}
192
+ alt=""
193
+ aria-hidden="true"
194
+ width={24}
195
+ height={24}
196
+ className={tkx('rounded-full')}
197
+ style={{ objectFit: 'cover' }}
198
+ />
199
+ )}
200
+ <span>{sanitizeString(opt.label)}</span>
201
+ <span style={{ color: theme.textMuted, marginLeft: 'auto' }}>
202
+ {trigger}{sanitizeString(opt.value)}
203
+ </span>
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ )}
208
+ </div>
209
+ );
210
+ }