tekivex-ui 2.0.2 → 2.2.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 (102) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +7 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2839 -2538
  5. package/dist/src/components/TkxAppBar.d.ts +12 -0
  6. package/dist/src/components/TkxAppBar.d.ts.map +1 -0
  7. package/dist/src/components/TkxAutocomplete.d.ts +25 -0
  8. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
  9. package/dist/src/components/TkxBottomNav.d.ts +15 -0
  10. package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
  11. package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
  12. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
  13. package/dist/src/components/TkxCard.d.ts.map +1 -1
  14. package/dist/src/components/TkxConfigProvider.d.ts +100 -0
  15. package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
  16. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  17. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  18. package/dist/src/components/TkxEmpty.d.ts +870 -0
  19. package/dist/src/components/TkxEmpty.d.ts.map +1 -0
  20. package/dist/src/components/TkxForm.d.ts +54 -0
  21. package/dist/src/components/TkxForm.d.ts.map +1 -0
  22. package/dist/src/components/TkxLayout.d.ts +87 -0
  23. package/dist/src/components/TkxLayout.d.ts.map +1 -0
  24. package/dist/src/components/TkxMasonry.d.ts +12 -0
  25. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  26. package/dist/src/components/TkxModal.d.ts.map +1 -1
  27. package/dist/src/components/TkxPopover.d.ts +15 -0
  28. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  29. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  30. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  31. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  32. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  33. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  34. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  35. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  36. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  37. package/dist/src/components/TkxSpin.d.ts +15 -0
  38. package/dist/src/components/TkxSpin.d.ts.map +1 -0
  39. package/dist/src/components/TkxStatistic.d.ts +1746 -0
  40. package/dist/src/components/TkxStatistic.d.ts.map +1 -0
  41. package/dist/src/components/TkxToolbar.d.ts +22 -0
  42. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  43. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  44. package/dist/src/components/TkxTransferList.d.ts +20 -0
  45. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  46. package/dist/src/components/TkxTreeView.d.ts +23 -0
  47. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  48. package/dist/src/components/TkxTypography.d.ts +2614 -0
  49. package/dist/src/components/TkxTypography.d.ts.map +1 -0
  50. package/dist/src/components/index.d.ts +20 -0
  51. package/dist/src/components/index.d.ts.map +1 -1
  52. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  53. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  54. package/dist/src/i18n/index.d.ts +50 -0
  55. package/dist/src/i18n/index.d.ts.map +1 -0
  56. package/dist/src/themes/index.d.ts +108 -0
  57. package/dist/src/themes/index.d.ts.map +1 -1
  58. package/package.json +2 -2
  59. package/src/components/TkxAppBar.tsx +260 -0
  60. package/src/components/TkxAutocomplete.tsx +453 -0
  61. package/src/components/TkxBottomNav.tsx +239 -0
  62. package/src/components/TkxBreadcrumb.tsx +322 -0
  63. package/src/components/TkxCard.tsx +3 -1
  64. package/src/components/TkxConfigProvider.tsx +458 -0
  65. package/src/components/TkxDataGrid.tsx +540 -0
  66. package/src/components/TkxEmpty.tsx +219 -0
  67. package/src/components/TkxForm.tsx +607 -0
  68. package/src/components/TkxLayout.tsx +647 -0
  69. package/src/components/TkxMasonry.tsx +176 -0
  70. package/src/components/TkxModal.tsx +4 -3
  71. package/src/components/TkxPopover.tsx +295 -0
  72. package/src/components/TkxRichTextDisplay.tsx +394 -0
  73. package/src/components/TkxSelect.tsx +2 -0
  74. package/src/components/TkxSlider.tsx +1 -0
  75. package/src/components/TkxSnackbar.tsx +314 -0
  76. package/src/components/TkxSpeedDial.tsx +395 -0
  77. package/src/components/TkxSpin.tsx +261 -0
  78. package/src/components/TkxStatistic.tsx +261 -0
  79. package/src/components/TkxToolbar.tsx +260 -0
  80. package/src/components/TkxTooltip.tsx +3 -2
  81. package/src/components/TkxTransferList.tsx +487 -0
  82. package/src/components/TkxTreeView.tsx +421 -0
  83. package/src/components/TkxTypography.tsx +263 -0
  84. package/src/components/index.ts +20 -0
  85. package/src/hooks/index.ts +1 -1
  86. package/src/i18n/I18nProvider.tsx +36 -0
  87. package/src/i18n/index.ts +237 -0
  88. package/src/themes/index.ts +149 -0
  89. package/dist/tests/TkxButton.test.d.ts +0 -2
  90. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  91. package/dist/tests/a11y/components.test.d.ts +0 -2
  92. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  93. package/dist/tests/quantum.test.d.ts +0 -2
  94. package/dist/tests/quantum.test.d.ts.map +0 -1
  95. package/dist/tests/security.test.d.ts +0 -2
  96. package/dist/tests/security.test.d.ts.map +0 -1
  97. package/dist/tests/setup.d.ts +0 -2
  98. package/dist/tests/setup.d.ts.map +0 -1
  99. package/dist/tests/tkx.test.d.ts +0 -2
  100. package/dist/tests/tkx.test.d.ts.map +0 -1
  101. package/dist/tests/wcag.test.d.ts +0 -2
  102. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -0,0 +1,607 @@
1
+ // ══════════════════════════════════════════════════════════════════════════════
2
+ // TKX FORM — Enterprise-grade form controller
3
+ // Context-based form state management with field-level validation,
4
+ // error propagation, layout control, and programmatic access.
5
+ // ══════════════════════════════════════════════════════════════════════════════
6
+
7
+ import {
8
+ createContext,
9
+ useContext,
10
+ useCallback,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ cloneElement,
15
+ type ReactNode,
16
+ type ReactElement,
17
+ type CSSProperties,
18
+ type FormEvent,
19
+ } from 'react';
20
+ import { useTheme } from '../themes';
21
+ import { sanitizeString } from '../engine/security';
22
+ import { tkx, cx } from '../engine/tkx';
23
+
24
+ // ── Validation Rule ─────────────────────────────────────────────────────────
25
+
26
+ export interface ValidationRule {
27
+ required?: boolean;
28
+ min?: number;
29
+ max?: number;
30
+ pattern?: RegExp;
31
+ validator?: (value: any) => string | null | Promise<string | null>;
32
+ message?: string;
33
+ }
34
+
35
+ // ── Form Props ──────────────────────────────────────────────────────────────
36
+
37
+ export interface TkxFormProps {
38
+ onSubmit?: (values: Record<string, any>) => void | Promise<void>;
39
+ onValuesChange?: (changed: Record<string, any>, all: Record<string, any>) => void;
40
+ initialValues?: Record<string, any>;
41
+ layout?: 'vertical' | 'horizontal' | 'inline';
42
+ disabled?: boolean;
43
+ children: ReactNode;
44
+ className?: string;
45
+ style?: CSSProperties;
46
+ }
47
+
48
+ // ── Form Field Props ────────────────────────────────────────────────────────
49
+
50
+ export interface TkxFormFieldProps {
51
+ name: string;
52
+ label?: string;
53
+ rules?: ValidationRule[];
54
+ help?: string;
55
+ required?: boolean;
56
+ children: ReactElement;
57
+ className?: string;
58
+ style?: CSSProperties;
59
+ }
60
+
61
+ // ── Form Instance (programmatic API) ────────────────────────────────────────
62
+
63
+ export interface FormInstance {
64
+ getFieldValue: (name: string) => any;
65
+ setFieldValue: (name: string, value: any) => void;
66
+ getFieldsValue: () => Record<string, any>;
67
+ setFieldsValue: (values: Record<string, any>) => void;
68
+ validateFields: () => Promise<Record<string, any>>;
69
+ validateField: (name: string) => Promise<boolean>;
70
+ resetFields: () => void;
71
+ getFieldError: (name: string) => string | null;
72
+ isFieldTouched: (name: string) => boolean;
73
+ }
74
+
75
+ // ── Internal State ──────────────────────────────────────────────────────────
76
+
77
+ interface FieldMeta {
78
+ rules: ValidationRule[];
79
+ }
80
+
81
+ interface FormState {
82
+ values: Record<string, any>;
83
+ errors: Record<string, string | null>;
84
+ touched: Record<string, boolean>;
85
+ }
86
+
87
+ interface FormContextValue {
88
+ state: FormState;
89
+ initialValues: Record<string, any>;
90
+ layout: 'vertical' | 'horizontal' | 'inline';
91
+ disabled: boolean;
92
+ fieldMeta: React.MutableRefObject<Record<string, FieldMeta>>;
93
+ setFieldValue: (name: string, value: any) => void;
94
+ setFieldError: (name: string, error: string | null) => void;
95
+ setFieldTouched: (name: string) => void;
96
+ registerField: (name: string, meta: FieldMeta) => void;
97
+ unregisterField: (name: string) => void;
98
+ validateField: (name: string) => Promise<boolean>;
99
+ instance: FormInstance;
100
+ }
101
+
102
+ // ── Context ─────────────────────────────────────────────────────────────────
103
+
104
+ const FormContext = createContext<FormContextValue | null>(null);
105
+
106
+ function useFormContext(): FormContextValue {
107
+ const ctx = useContext(FormContext);
108
+ if (!ctx) {
109
+ throw new Error('TkxFormField must be used inside a <TkxForm>. Wrap your fields in a TkxForm component.');
110
+ }
111
+ return ctx;
112
+ }
113
+
114
+ // ── Validation Engine ───────────────────────────────────────────────────────
115
+
116
+ async function runValidation(value: any, rules: ValidationRule[]): Promise<string | null> {
117
+ for (const rule of rules) {
118
+ // Required check
119
+ if (rule.required) {
120
+ const empty = value === undefined || value === null || value === '' ||
121
+ (Array.isArray(value) && value.length === 0);
122
+ if (empty) {
123
+ return rule.message ?? 'This field is required';
124
+ }
125
+ }
126
+
127
+ // Skip further checks if value is empty and not required
128
+ if (value === undefined || value === null || value === '') {
129
+ continue;
130
+ }
131
+
132
+ // Min length / min value
133
+ if (rule.min !== undefined) {
134
+ if (typeof value === 'string' && value.length < rule.min) {
135
+ return rule.message ?? `Must be at least ${rule.min} characters`;
136
+ }
137
+ if (typeof value === 'number' && value < rule.min) {
138
+ return rule.message ?? `Must be at least ${rule.min}`;
139
+ }
140
+ }
141
+
142
+ // Max length / max value
143
+ if (rule.max !== undefined) {
144
+ if (typeof value === 'string' && value.length > rule.max) {
145
+ return rule.message ?? `Must be no more than ${rule.max} characters`;
146
+ }
147
+ if (typeof value === 'number' && value > rule.max) {
148
+ return rule.message ?? `Must be no more than ${rule.max}`;
149
+ }
150
+ }
151
+
152
+ // Pattern check
153
+ if (rule.pattern) {
154
+ if (typeof value === 'string' && !rule.pattern.test(value)) {
155
+ return rule.message ?? 'Invalid format';
156
+ }
157
+ }
158
+
159
+ // Custom async validator
160
+ if (rule.validator) {
161
+ const result = await rule.validator(value);
162
+ if (result) {
163
+ return result;
164
+ }
165
+ }
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /** Merge the `required` shortcut prop into the rules array. */
172
+ function mergeRequiredRule(rules: ValidationRule[] | undefined, required?: boolean): ValidationRule[] {
173
+ const base = rules ? [...rules] : [];
174
+ if (required && !base.some(r => r.required)) {
175
+ base.unshift({ required: true, message: 'This field is required' });
176
+ }
177
+ return base;
178
+ }
179
+
180
+ /** Check whether any rule in a set has `required: true`. */
181
+ function hasRequiredRule(rules: ValidationRule[]): boolean {
182
+ return rules.some(r => r.required);
183
+ }
184
+
185
+ // ── useTkxForm Hook ─────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Create a FormInstance for programmatic access to form state.
189
+ * Can be used standalone or connected to a <TkxForm> via context.
190
+ */
191
+ export function useTkxForm(): FormInstance {
192
+ const ctx = useContext(FormContext);
193
+
194
+ // If used inside a TkxForm, return its instance directly
195
+ if (ctx) {
196
+ return ctx.instance;
197
+ }
198
+
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
+ );
206
+ }
207
+
208
+ // ── TkxForm Component ───────────────────────────────────────────────────────
209
+
210
+ export function TkxForm({
211
+ onSubmit,
212
+ onValuesChange,
213
+ initialValues = {},
214
+ layout = 'vertical',
215
+ disabled = false,
216
+ children,
217
+ className,
218
+ style,
219
+ }: TkxFormProps) {
220
+ const theme = useTheme();
221
+
222
+ // ─── State ──────────────────────────────────────────────────────────────
223
+ const [state, setState] = useState<FormState>({
224
+ values: { ...initialValues },
225
+ errors: {},
226
+ touched: {},
227
+ });
228
+
229
+ const initialValuesRef = useRef(initialValues);
230
+ const fieldMetaRef = useRef<Record<string, FieldMeta>>({});
231
+ const stateRef = useRef(state);
232
+ stateRef.current = state;
233
+
234
+ // ─── Field Registration ─────────────────────────────────────────────────
235
+ const registerField = useCallback((name: string, meta: FieldMeta) => {
236
+ fieldMetaRef.current[name] = meta;
237
+ }, []);
238
+
239
+ const unregisterField = useCallback((name: string) => {
240
+ delete fieldMetaRef.current[name];
241
+ }, []);
242
+
243
+ // ─── Value Management ───────────────────────────────────────────────────
244
+ const setFieldValue = useCallback((name: string, value: any) => {
245
+ setState(prev => {
246
+ const next = {
247
+ ...prev,
248
+ values: { ...prev.values, [name]: value },
249
+ };
250
+ onValuesChange?.({ [name]: value }, next.values);
251
+ return next;
252
+ });
253
+ }, [onValuesChange]);
254
+
255
+ const setFieldError = useCallback((name: string, error: string | null) => {
256
+ setState(prev => ({
257
+ ...prev,
258
+ errors: { ...prev.errors, [name]: error },
259
+ }));
260
+ }, []);
261
+
262
+ const setFieldTouched = useCallback((name: string) => {
263
+ setState(prev => ({
264
+ ...prev,
265
+ touched: { ...prev.touched, [name]: true },
266
+ }));
267
+ }, []);
268
+
269
+ // ─── Validation ─────────────────────────────────────────────────────────
270
+ const validateField = useCallback(async (name: string): Promise<boolean> => {
271
+ const meta = fieldMetaRef.current[name];
272
+ if (!meta) return true;
273
+
274
+ const value = stateRef.current.values[name];
275
+ const error = await runValidation(value, meta.rules);
276
+ setState(prev => ({
277
+ ...prev,
278
+ errors: { ...prev.errors, [name]: error },
279
+ touched: { ...prev.touched, [name]: true },
280
+ }));
281
+ return error === null;
282
+ }, []);
283
+
284
+ const validateFields = useCallback(async (): Promise<Record<string, any>> => {
285
+ const fieldNames = Object.keys(fieldMetaRef.current);
286
+ const results = await Promise.all(
287
+ fieldNames.map(async (name) => {
288
+ const meta = fieldMetaRef.current[name];
289
+ const value = stateRef.current.values[name];
290
+ const error = await runValidation(value, meta.rules);
291
+ return { name, error };
292
+ }),
293
+ );
294
+
295
+ const newErrors: Record<string, string | null> = {};
296
+ const newTouched: Record<string, boolean> = {};
297
+ let hasErrors = false;
298
+
299
+ for (const { name, error } of results) {
300
+ newErrors[name] = error;
301
+ newTouched[name] = true;
302
+ if (error) hasErrors = true;
303
+ }
304
+
305
+ setState(prev => ({
306
+ ...prev,
307
+ errors: { ...prev.errors, ...newErrors },
308
+ touched: { ...prev.touched, ...newTouched },
309
+ }));
310
+
311
+ if (hasErrors) {
312
+ const errorMap = Object.fromEntries(
313
+ results.filter(r => r.error).map(r => [r.name, r.error]),
314
+ );
315
+ return Promise.reject(errorMap);
316
+ }
317
+
318
+ return { ...stateRef.current.values };
319
+ }, []);
320
+
321
+ const resetFields = useCallback(() => {
322
+ setState({
323
+ values: { ...initialValuesRef.current },
324
+ errors: {},
325
+ touched: {},
326
+ });
327
+ }, []);
328
+
329
+ // ─── Form Instance ─────────────────────────────────────────────────────
330
+ const instance = useMemo<FormInstance>(() => ({
331
+ getFieldValue: (name: string) => stateRef.current.values[name],
332
+ setFieldValue,
333
+ getFieldsValue: () => ({ ...stateRef.current.values }),
334
+ setFieldsValue: (values: Record<string, any>) => {
335
+ setState(prev => {
336
+ const merged = { ...prev.values, ...values };
337
+ onValuesChange?.(values, merged);
338
+ return { ...prev, values: merged };
339
+ });
340
+ },
341
+ validateFields,
342
+ validateField,
343
+ resetFields,
344
+ getFieldError: (name: string) => stateRef.current.errors[name] ?? null,
345
+ isFieldTouched: (name: string) => !!stateRef.current.touched[name],
346
+ }), [setFieldValue, validateFields, validateField, resetFields, onValuesChange]);
347
+
348
+ // ─── Submit Handler ─────────────────────────────────────────────────────
349
+ const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
350
+ e.preventDefault();
351
+ try {
352
+ const values = await validateFields();
353
+ await onSubmit?.(values);
354
+ } catch {
355
+ // Validation failed — errors are already in state
356
+ }
357
+ }, [validateFields, onSubmit]);
358
+
359
+ // ─── Layout Styles ─────────────────────────────────────────────────────
360
+ const layoutClass = layout === 'inline'
361
+ ? tkx('flex flex-row flex-wrap items-end gap-4')
362
+ : tkx('flex flex-col gap-5');
363
+
364
+ // ─── Context Value ─────────────────────────────────────────────────────
365
+ const contextValue = useMemo<FormContextValue>(() => ({
366
+ state,
367
+ initialValues: initialValuesRef.current,
368
+ layout,
369
+ disabled,
370
+ fieldMeta: fieldMetaRef,
371
+ setFieldValue,
372
+ setFieldError,
373
+ setFieldTouched,
374
+ registerField,
375
+ unregisterField,
376
+ validateField,
377
+ instance,
378
+ }), [state, layout, disabled, setFieldValue, setFieldError, setFieldTouched, registerField, unregisterField, validateField, instance]);
379
+
380
+ return (
381
+ <FormContext.Provider value={contextValue}>
382
+ <form
383
+ noValidate
384
+ onSubmit={handleSubmit}
385
+ className={cx(layoutClass, className)}
386
+ style={{
387
+ color: theme.text,
388
+ ...style,
389
+ }}
390
+ >
391
+ {children}
392
+ </form>
393
+ </FormContext.Provider>
394
+ );
395
+ }
396
+
397
+ TkxForm.displayName = 'TkxForm';
398
+
399
+ // ── TkxFormField Component ──────────────────────────────────────────────────
400
+
401
+ export function TkxFormField({
402
+ name,
403
+ label,
404
+ rules: rulesProp,
405
+ help,
406
+ required,
407
+ children,
408
+ className,
409
+ style,
410
+ }: TkxFormFieldProps) {
411
+ const theme = useTheme();
412
+ const ctx = useFormContext();
413
+ const { state, layout, disabled } = ctx;
414
+
415
+ // Merge required shortcut into rules
416
+ const rules = useMemo(() => mergeRequiredRule(rulesProp, required), [rulesProp, required]);
417
+ const isRequired = hasRequiredRule(rules);
418
+
419
+ // Register field metadata with form
420
+ const metaRef = useRef<FieldMeta>({ rules });
421
+ metaRef.current.rules = rules;
422
+
423
+ // Register / unregister on mount / unmount
424
+ const registeredRef = useRef(false);
425
+ if (!registeredRef.current) {
426
+ ctx.registerField(name, metaRef.current);
427
+ registeredRef.current = true;
428
+ }
429
+
430
+ // Keep meta in sync
431
+ useMemo(() => {
432
+ ctx.registerField(name, metaRef.current);
433
+ }, [rules, name, ctx]);
434
+
435
+ // Cleanup on unmount — using a ref-based pattern since we cannot use useEffect
436
+ // with a stable callback for cleanup in strict mode without extra complexity.
437
+ // The form handles missing fields gracefully.
438
+
439
+ const value = state.values[name];
440
+ const error = state.touched[name] ? (state.errors[name] ?? null) : null;
441
+ const safeError = error ? sanitizeString(error) : null;
442
+ const safeLabel = label ? sanitizeString(label) : undefined;
443
+ const safeHelp = help ? sanitizeString(help) : undefined;
444
+
445
+ // ─── onChange handler: supports both event objects and direct values ───
446
+ const handleChange = useCallback(
447
+ (eventOrValue: any) => {
448
+ let nextValue: any;
449
+ if (
450
+ eventOrValue !== null &&
451
+ typeof eventOrValue === 'object' &&
452
+ 'target' in eventOrValue
453
+ ) {
454
+ const target = eventOrValue.target;
455
+ nextValue = target.type === 'checkbox' ? target.checked : target.value;
456
+ } else {
457
+ nextValue = eventOrValue;
458
+ }
459
+ ctx.setFieldValue(name, nextValue);
460
+ },
461
+ [ctx, name],
462
+ );
463
+
464
+ // ─── onBlur: trigger validation on blur ────────────────────────────────
465
+ const handleBlur = useCallback(() => {
466
+ ctx.setFieldTouched(name);
467
+ ctx.validateField(name);
468
+ }, [ctx, name]);
469
+
470
+ // ─── Clone child input with injected props ────────────────────────────
471
+ const childElement = cloneElement(children, {
472
+ value: value ?? '',
473
+ onChange: handleChange,
474
+ onBlur: handleBlur,
475
+ error: safeError ?? undefined,
476
+ isInvalid: !!safeError,
477
+ isRequired,
478
+ disabled: disabled || children.props.disabled,
479
+ name,
480
+ });
481
+
482
+ // ─── Layout-dependent rendering ───────────────────────────────────────
483
+ const isHorizontal = layout === 'horizontal';
484
+ const isInline = layout === 'inline';
485
+
486
+ // Error icon SVG (matches TkxInput pattern)
487
+ const errorIcon = (
488
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
489
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
490
+ </svg>
491
+ );
492
+
493
+ // Build the label element
494
+ const labelElement = safeLabel ? (
495
+ <label
496
+ className={tkx(
497
+ 'text-sm font-medium font-sans',
498
+ isHorizontal ? 'min-w-[140px] pt-2.5' : '',
499
+ )}
500
+ style={{ color: theme.text }}
501
+ >
502
+ {safeLabel}
503
+ {isRequired && (
504
+ <span aria-hidden="true" className={tkx('ml-1')} style={{ color: theme.danger }}>
505
+ *
506
+ </span>
507
+ )}
508
+ </label>
509
+ ) : null;
510
+
511
+ // Build the help / error messaging
512
+ const messageElement = (
513
+ <>
514
+ {safeHelp && !safeError && (
515
+ <span className={tkx('text-xs mt-0.5')} style={{ color: theme.textMuted }}>
516
+ {safeHelp}
517
+ </span>
518
+ )}
519
+ {safeError && (
520
+ <span
521
+ role="alert"
522
+ className={tkx('text-xs flex items-center gap-1 mt-0.5')}
523
+ style={{
524
+ color: theme.danger,
525
+ animation: 'tkxFormErrorReveal 200ms ease-out',
526
+ }}
527
+ >
528
+ {errorIcon}
529
+ {safeError}
530
+ </span>
531
+ )}
532
+ </>
533
+ );
534
+
535
+ // Inline layout: label above within a compact wrapper
536
+ if (isInline) {
537
+ return (
538
+ <div
539
+ className={cx(tkx('flex flex-col gap-1'), className)}
540
+ style={style}
541
+ >
542
+ {labelElement}
543
+ {childElement}
544
+ {messageElement}
545
+ </div>
546
+ );
547
+ }
548
+
549
+ // Horizontal layout: label left, input right
550
+ if (isHorizontal) {
551
+ return (
552
+ <div
553
+ className={cx(tkx('flex flex-row gap-4 items-start'), className)}
554
+ style={style}
555
+ >
556
+ {labelElement}
557
+ <div className={tkx('flex flex-col gap-1 flex-1 min-w-0')}>
558
+ {childElement}
559
+ {messageElement}
560
+ </div>
561
+ </div>
562
+ );
563
+ }
564
+
565
+ // Vertical layout (default): label above input
566
+ return (
567
+ <div
568
+ className={cx(tkx('flex flex-col gap-1'), className)}
569
+ style={style}
570
+ >
571
+ {labelElement}
572
+ {childElement}
573
+ {messageElement}
574
+ </div>
575
+ );
576
+ }
577
+
578
+ TkxFormField.displayName = 'TkxFormField';
579
+
580
+ // ── Error Reveal Keyframe Injection ─────────────────────────────────────────
581
+
582
+ let stylesInjected = false;
583
+
584
+ function injectFormStyles(): void {
585
+ if (stylesInjected) return;
586
+ if (typeof document === 'undefined') return;
587
+ stylesInjected = true;
588
+
589
+ const style = document.createElement('style');
590
+ style.setAttribute('data-tkx-form', '');
591
+ style.textContent = `
592
+ @keyframes tkxFormErrorReveal {
593
+ from {
594
+ opacity: 0;
595
+ transform: translateY(-4px);
596
+ }
597
+ to {
598
+ opacity: 1;
599
+ transform: translateY(0);
600
+ }
601
+ }
602
+ `;
603
+ document.head.appendChild(style);
604
+ }
605
+
606
+ // Auto-inject styles on module load (client only)
607
+ injectFormStyles();