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.
- package/dist/TkxForm-BWK4LqY3.js +1461 -0
- package/dist/TkxForm-ljQjX7KD.cjs +15 -0
- package/dist/charts.cjs +1 -0
- package/dist/charts.js +480 -0
- package/dist/headless.cjs +1 -0
- package/dist/headless.js +258 -0
- package/dist/index-BINBzXuY.cjs +2 -0
- package/dist/index-eT_U4qB2.js +512 -0
- package/dist/index.cjs +63 -14
- package/dist/index.d.ts +33 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11396 -6336
- package/dist/src/charts/TkxAreaChart.d.ts +29 -0
- package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
- package/dist/src/charts/TkxBarChart.d.ts +28 -0
- package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxDonutChart.d.ts +23 -0
- package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
- package/dist/src/charts/TkxLineChart.d.ts +35 -0
- package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
- package/dist/src/charts/TkxPieChart.d.ts +19 -0
- package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
- package/dist/src/charts/TkxRadarChart.d.ts +20 -0
- package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxScatterChart.d.ts +28 -0
- package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
- package/dist/src/charts/index.d.ts +15 -0
- package/dist/src/charts/index.d.ts.map +1 -0
- package/dist/src/charts/shared.d.ts +33 -0
- package/dist/src/charts/shared.d.ts.map +1 -0
- package/dist/src/components/TkxAffix.d.ts +10 -0
- package/dist/src/components/TkxAffix.d.ts.map +1 -0
- package/dist/src/components/TkxAnchor.d.ts +14 -0
- package/dist/src/components/TkxAnchor.d.ts.map +1 -0
- package/dist/src/components/TkxCascader.d.ts +16 -0
- package/dist/src/components/TkxCascader.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +19 -1
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxForm.d.ts +33 -3
- package/dist/src/components/TkxForm.d.ts.map +1 -1
- package/dist/src/components/TkxImage.d.ts.map +1 -1
- package/dist/src/components/TkxList.d.ts +24 -0
- package/dist/src/components/TkxList.d.ts.map +1 -0
- package/dist/src/components/TkxMentions.d.ts +15 -0
- package/dist/src/components/TkxMentions.d.ts.map +1 -0
- package/dist/src/components/TkxQRCode.d.ts +11 -0
- package/dist/src/components/TkxQRCode.d.ts.map +1 -0
- package/dist/src/components/TkxResult.d.ts +11 -0
- package/dist/src/components/TkxResult.d.ts.map +1 -0
- package/dist/src/components/TkxSegmented.d.ts +16 -0
- package/dist/src/components/TkxSegmented.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +5 -1
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +1 -1
- package/dist/src/components/TkxTypography.d.ts.map +1 -1
- package/dist/src/components/TkxWatermark.d.ts +12 -0
- package/dist/src/components/TkxWatermark.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +10 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts +38 -0
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/dist/src/headless/index.d.ts +18 -0
- package/dist/src/headless/index.d.ts.map +1 -0
- package/dist/src/headless/useControllable.d.ts +20 -0
- package/dist/src/headless/useControllable.d.ts.map +1 -0
- package/dist/src/headless/useDebounce.d.ts +14 -0
- package/dist/src/headless/useDebounce.d.ts.map +1 -0
- package/dist/src/headless/useDisclosure.d.ts +20 -0
- package/dist/src/headless/useDisclosure.d.ts.map +1 -0
- package/dist/src/headless/useFormState.d.ts +44 -0
- package/dist/src/headless/useFormState.d.ts.map +1 -0
- package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
- package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
- package/dist/src/headless/useListSelection.d.ts +29 -0
- package/dist/src/headless/useListSelection.d.ts.map +1 -0
- package/dist/src/headless/useLocalStorage.d.ts +9 -0
- package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
- package/dist/src/headless/useMediaQuery.d.ts +21 -0
- package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
- package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
- package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
- package/dist/src/headless/useThrottle.d.ts +9 -0
- package/dist/src/headless/useThrottle.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +47 -2
- package/dist/src/i18n/index.d.ts.map +1 -1
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +61 -7
- package/src/charts/TkxAreaChart.tsx +119 -0
- package/src/charts/TkxBarChart.tsx +115 -0
- package/src/charts/TkxDonutChart.tsx +145 -0
- package/src/charts/TkxLineChart.tsx +135 -0
- package/src/charts/TkxPieChart.tsx +82 -0
- package/src/charts/TkxRadarChart.tsx +90 -0
- package/src/charts/TkxScatterChart.tsx +102 -0
- package/src/charts/index.ts +20 -0
- package/src/charts/shared.ts +45 -0
- package/src/components/TkxAffix.tsx +138 -0
- package/src/components/TkxAnchor.tsx +193 -0
- package/src/components/TkxCascader.tsx +276 -0
- package/src/components/TkxDataGrid.tsx +557 -230
- package/src/components/TkxForm.tsx +67 -15
- package/src/components/TkxImage.tsx +1 -1
- package/src/components/TkxList.tsx +242 -0
- package/src/components/TkxMentions.tsx +210 -0
- package/src/components/TkxQRCode.tsx +174 -0
- package/src/components/TkxResult.tsx +128 -0
- package/src/components/TkxSegmented.tsx +169 -0
- package/src/components/TkxSelect.tsx +209 -6
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxTypography.tsx +4 -2
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +10 -0
- package/src/engine/tkx.ts +122 -3
- package/src/headless/index.ts +44 -0
- package/src/headless/useControllable.ts +40 -0
- package/src/headless/useDebounce.ts +35 -0
- package/src/headless/useDisclosure.ts +28 -0
- package/src/headless/useFormState.ts +111 -0
- package/src/headless/useIntersectionObserver.ts +51 -0
- package/src/headless/useListSelection.ts +101 -0
- package/src/headless/useLocalStorage.ts +46 -0
- package/src/headless/useMediaQuery.ts +37 -0
- package/src/headless/useRovingTabIndex.ts +84 -0
- package/src/headless/useThrottle.ts +46 -0
- package/src/i18n/I18nProvider.tsx +1 -1
- package/src/i18n/index.ts +678 -38
- 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
|
-
*
|
|
189
|
-
*
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
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={{
|
|
@@ -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
|
+
}
|